Files
Scoreboard/server/internal/httpapi/repo_events.go
T
AskaEth 2c983bcfdf fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理
服务端一批「会误导现场」的缺陷,全部补了测试:

- 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、
  scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status)
  过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成
  服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED,
  message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。
- 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的
  原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文,
  日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数,
  拦截点不再退化成 response.go 里的构造函数。
- /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」),
  超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号,
  数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。
- 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential):
  401 的语义是「本请求没有有效会话」,而这里会话是好的。
- 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动,
  否则堆键数就能冲掉别人的计数)。
- createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。
  数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results /
  score_ledger 的级联,风险高不做),这一层先兜住。
- 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、
  5xx 不外泄原文、限流键清理不误删。
2026-09-11 14:28:25 +08:00

701 lines
22 KiB
Go

package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- events ----------
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode`
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
var (
e eventDTO
scheduledAt, venue, note sql.NullString
)
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
&scheduledAt, &venue, &e.SortOrder, &note, &e.AvgMode)
e.ScheduledAt = strPtr(scheduledAt)
e.Venue = strPtr(venue)
e.Remark = strPtr(note)
return e, err
}
func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, error) {
query := `SELECT ` + eventColumns + ` FROM events`
args := []any{}
if sportID > 0 {
query += ` WHERE sport_id = ?`
args = append(args, sportID)
}
query += ` ORDER BY sort_order, code`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]eventDTO, 0)
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
e, err := eventByIDQ(ctx, s.ev, id)
if err != nil {
return eventDTO{}, err
}
cats, err := s.listCategories(ctx, id)
if err != nil {
return eventDTO{}, err
}
chs, err := s.listChannels(ctx, id)
if err != nil {
return eventDTO{}, err
}
e.Categories = cats
e.Channels = chs
return e, nil
}
// eventByIDQ 在给定连接/事务内读取小项(不含组别/通道),供写事务内构造 change_log 载荷复用。
func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) {
e, err := scanEvent(q.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return eventDTO{}, notFound("小项不存在")
}
if err != nil {
return eventDTO{}, internal(err)
}
return e, nil
}
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
}
eventType, err := normalizeEventType(in.EventType)
if err != nil {
return eventDTO{}, err
}
resultMode, err := normalizeResultMode(in.ResultMode)
if err != nil {
return eventDTO{}, err
}
resultType, err := normalizeResultType(in.ResultType)
if err != nil {
return eventDTO{}, err
}
compare, err := normalizeCompare(in.Compare)
if err != nil {
return eventDTO{}, err
}
status, err := normalizeEventStatus(in.Status)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.SportID, in.Code, in.Name,
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return eventDTO{}, internal(err)
}
// 项目增删改要推给大屏,否则底栏/分项页拿不到新项目。
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
}
eventType, err := normalizeEventType(in.EventType)
if err != nil {
return eventDTO{}, err
}
resultMode, err := normalizeResultMode(in.ResultMode)
if err != nil {
return eventDTO{}, err
}
resultType, err := normalizeResultType(in.ResultType)
if err != nil {
return eventDTO{}, err
}
compare, err := normalizeCompare(in.Compare)
if err != nil {
return eventDTO{}, err
}
status, err := normalizeEventStatus(in.Status)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
WHERE id = ?`,
in.SportID, in.Code, in.Name,
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
// bumpEvent 在写事务内推送小项变更;组别/通道的改动同样以本小项为载体。
func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error {
ev, err := eventByIDQ(ctx, tx, eventID)
if err != nil {
return err
}
return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID)
}
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
return internal(err)
}
if has {
return conflict("小项已有报名数据,不能删除")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range []string{
`DELETE FROM event_score_channels WHERE event_id = ?`,
`DELETE FROM event_categories WHERE event_id = ?`,
`DELETE FROM events WHERE id = ?`,
} {
if _, err := tx.ExecContext(ctx, stmt, id); err != nil {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
return nil
}
func multiplierOrOne(v float64) float64 {
if v <= 0 {
return 1
}
return v
}
// normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。
func normalizeAvgMode(v string) (string, error) {
switch v {
case "":
return string(domain.AvgModeValid), nil
case string(domain.AvgModeValid), string(domain.AvgModeAll):
return v, nil
default:
return "", badRequest("avgMode 只能是 valid / all", v)
}
}
// ---------- categories ----------
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]categoryDTO, 0)
for rows.Next() {
var (
c categoryDTO
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil {
return nil, internal(err)
}
c.Gender = strPtr(gender)
c.AgeMin = intPtr(ageMin)
c.AgeMax = intPtr(ageMax)
out = append(out, c)
}
return out, rows.Err()
}
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
// 否则外键会把历史报名项一并带走。
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
has, err := exists(ctx, s.ev,
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
WHERE c.event_id = ? LIMIT 1`, eventID)
if err != nil {
return nil, internal(err)
}
if has {
return nil, conflict("该项目已有报名数据,不能整体替换组别")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if strings.TrimSpace(c.Name) == "" {
return nil, badRequest("组别 name 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Name, multiplierOrOne(c.Multiplier), nullStr(c.Gender), nullInt(c.AgeMin), nullInt(c.AgeMax), c.SortOrder); err != nil {
if isUniqueViolation(err) {
return nil, conflict("组别名称重复:" + c.Name)
}
return nil, internal(err)
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listCategories(ctx, eventID)
}
// ---------- score channels ----------
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]channelDTO, 0)
for rows.Next() {
var (
c channelDTO
countToTeam int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
return nil, internal(err)
}
c.CountToTeam = boolPtr(countToTeam != 0)
out = append(out, c)
}
return out, rows.Err()
}
// channelCountsToTeam 读请求体里的通道开关:缺省(null)按 true 处理,
// 保证不带该字段的旧客户端保存通道时不会把单位总分关掉。
func channelCountsToTeam(c channelDTO) bool {
return c.CountToTeam == nil || *c.CountToTeam
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if c.Scope != "athlete" && c.Scope != "team" {
return nil, badRequest("通道 scope 只能是 athlete/team", i)
}
switch c.Basis {
case "result", "manual", "aggregate":
default:
return nil, badRequest("通道 basis 非法", i)
}
if c.ProfileID == 0 {
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
}
if isForeignKeyViolation(err) {
return nil, badRequest("profileId 不存在", i)
}
return nil, internal(err)
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listChannels(ctx, eventID)
}
// ---------- entries ----------
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
var (
e entryDTO
athleteID sql.NullInt64
memberNames sql.NullString
bib, seed, rem sql.NullString
heat sql.NullInt64
athleteName sql.NullString
teamCode sql.NullString
teamName sql.NullString
shortName sql.NullString
categoryName sql.NullString
)
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
return e, err
}
e.AthleteID = i64Ptr(athleteID)
e.MemberNames = parseNames(memberNames)
e.Bib = strPtr(bib)
e.Seed = strPtr(seed)
e.Heat = i64Ptr(heat)
e.Remark = strPtr(rem)
e.AthleteName = strPtr(athleteName)
e.TeamCode = teamCode.String
e.TeamName = teamName.String
e.ShortName = strPtr(shortName)
e.CategoryName = categoryName.String
return e, nil
}
const entrySelect = `SELECT ` + entryColumns + `
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
JOIN teams t ON t.id = e.team_id
JOIN event_categories c ON c.id = e.category_id`
func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, error) {
rows, err := s.ev.QueryContext(ctx, entrySelect+` WHERE e.event_id = ? ORDER BY c.sort_order, e.id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]entryDTO, 0)
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
// listEntriesWithResults 返回报名项及其成绩(无成绩时 result=null)。
func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]entryWithResultDTO, error) {
entries, err := s.listEntries(ctx, eventID)
if err != nil {
return nil, err
}
results, err := s.resultsByEvent(ctx, eventID)
if err != nil {
return nil, err
}
byEntry := make(map[int64]resultDTO, len(results))
for _, r := range results {
byEntry[r.EntryID] = r
}
out := make([]entryWithResultDTO, 0, len(entries))
for _, e := range entries {
item := entryWithResultDTO{entryDTO: e}
if r, ok := byEntry[e.ID]; ok {
cp := r
item.Result = &cp
}
out = append(out, item)
}
return out, nil
}
func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) {
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return entryDTO{}, notFound("报名项不存在")
}
if err != nil {
return entryDTO{}, internal(err)
}
return e, nil
}
func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) {
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
}
status, err := normalizeEntryStatus(in.Status)
if err != nil {
return entryDTO{}, err
}
if err := s.checkCategoryInEvent(ctx, in.EventID, in.CategoryID); err != nil {
return entryDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark))
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return entryDTO{}, internal(err)
}
return s.getEntry(ctx, id)
}
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
// 先确认报名项存在:小项不可改,categoryId 要拿它的 event_id 来校验。
// 这条查询同时守住了 404 语义,别让后面的校验把它降级成 400。
eventID, err := s.entryEventID(ctx, id)
if err != nil {
return entryDTO{}, err
}
status, err := normalizeEntryStatus(in.Status)
if err != nil {
return entryDTO{}, err
}
if err := s.checkCategoryInEvent(ctx, eventID, in.CategoryID); err != nil {
return entryDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark), id)
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
if err := requireChanges(res, "报名项不存在"); err != nil {
return entryDTO{}, err
}
return s.getEntry(ctx, id)
}
// entryEventID 读报名项所属小项:小项不可改,更新组别时要以它为准做归属校验。
// 找不到时给 404,调用方(更新)原本也靠 requireChanges 给出同样的语义。
func (s *server) entryEventID(ctx context.Context, id int64) (int64, error) {
var eventID int64
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, id).Scan(&eventID)
if errors.Is(err, sql.ErrNoRows) {
return 0, notFound("报名项不存在")
}
if err != nil {
return 0, internal(err)
}
return eventID, nil
}
// checkCategoryInEvent 拦住「A 小项的报名挂在 B 小项的组别上」。
//
// event_categories 只有 (event_id, name) 唯一约束,entries 也没有 (event_id, category_id)
// 复合外键,所以外键只能保证组别存在、保证不了同属一个小项。组别不存在时放行,
// 交给 INSERT/UPDATE 的外键报错,保持原有「...不存在」的 400 文案不变。
func (s *server) checkCategoryInEvent(ctx context.Context, eventID, categoryID int64) error {
var catEvent int64
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM event_categories WHERE id = ?`, categoryID).Scan(&catEvent)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil
case err != nil:
return internal(err)
case catEvent != eventID:
return badRequest("categoryId 不属于该小项", categoryID)
}
return nil
}
func (s *server) deleteEntry(ctx context.Context, id int64) error {
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "报名项不存在")
}
// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义;
// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。
const maxEntryHeat = 999
// setEntriesHeat 给一批报名项统一设置批次(heat 为 nil 表示清除批次 / 未分批)。
//
// 只改 entries.heat,不碰任何成绩与积分:批次不参与算分,排名与积分仍按「小项 + 组别」算。
// 幂等:重复提交同一个批次号结果一致。要求所有报名项属于同一个小项(跨小项批量设置是误操作)。
func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int64) (entryHeatResultDTO, error) {
ids := dedupeIDs(entryIDs)
if len(ids) == 0 {
return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil)
}
if heat != nil && (*heat < 1 || *heat > maxEntryHeat) {
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil)
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return entryHeatResultDTO{}, internal(err)
}
defer func() { _ = rows.Close() }()
found := make(map[int64]bool, len(ids))
events := make(map[int64]bool)
for rows.Next() {
var (
entryID int64
eventID int64
)
if err := rows.Scan(&entryID, &eventID); err != nil {
return entryHeatResultDTO{}, internal(err)
}
found[entryID] = true
events[eventID] = true
}
if err := rows.Err(); err != nil {
return entryHeatResultDTO{}, internal(err)
}
missing := make([]int64, 0)
for _, id := range ids {
if !found[id] {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return entryHeatResultDTO{}, badRequest("部分报名项不存在", missing)
}
// 批次挂在「小项 + 组别」之下,批量设置跨小项一定是选错了行。
if len(events) > 1 {
return entryHeatResultDTO{}, badRequest("不能跨小项批量设置批次,请在同一小项内操作", nil)
}
res, err := s.ev.ExecContext(ctx,
`UPDATE entries SET heat = ? WHERE id IN (`+placeholders(len(ids))+`)`,
append([]any{nullI64(heat)}, args...)...)
if err != nil {
return entryHeatResultDTO{}, internal(err)
}
updated, err := res.RowsAffected()
if err != nil {
return entryHeatResultDTO{}, internal(err)
}
return entryHeatResultDTO{Updated: int(updated), Heat: heat, EntryIDs: ids}, nil
}