package httpapi import ( "context" "database/sql" "errors" "fmt" "net/http" "slices" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" ) // 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移, // 仓储查询按实体分文件放在 httpapi 内,避免改动 store 的公开签名。 func strPtr(ns sql.NullString) *string { if !ns.Valid { return nil } v := ns.String return &v } func i64Ptr(ni sql.NullInt64) *int64 { if !ni.Valid { return nil } v := ni.Int64 return &v } func intPtr(ni sql.NullInt64) *int { if !ni.Valid { return nil } v := int(ni.Int64) return &v } func f64Ptr(nf sql.NullFloat64) *float64 { if !nf.Valid { return nil } v := nf.Float64 return &v } func nullStr(p *string) any { if p == nil || *p == "" { return nil } return *p } func nullI64(p *int64) any { if p == nil { return nil } return *p } func boolPtr(v bool) *bool { return &v } // boolToInt 把布尔写给 SQLite 的 0/1 列。 func boolToInt(v bool) int { if v { return 1 } return 0 } func nullInt(p *int) any { if p == nil { return nil } return *p } func nullF64(p *float64) any { if p == nil { return nil } return *p } func lastInsertID(res sql.Result) (int64, error) { id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("获取自增 id: %w", err) } return id, nil } // requireChanges 把「影响行数为 0」翻译成 404。 func requireChanges(res sql.Result, message string) error { n, err := res.RowsAffected() if err != nil { return internal(err) } if n == 0 { return notFound(message) } return nil } // isUniqueViolation 判断错误是否来自 UNIQUE 约束。 // // 必须只认 "unique constraint failed":SQLite 把 CHECK / NOT NULL / FOREIGN KEY 失败 // 也报成 "... constraint failed",早先按 "constraint failed" 宽松匹配会把它们一律 // 当成「已存在」,于是「小项 status 取值非法」这种错被报成「小项编码已存在」, // 现场会照着提示去找一个根本不存在的重名编码。CHECK / NOT NULL 属于另一类错误, // 交给调用方按内部错误处理,别在这里吞掉。 func isUniqueViolation(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") } func isForeignKeyViolation(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "foreign key") } func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) { var one int err := db.QueryRowContext(ctx, query, args...).Scan(&one) switch { case errors.Is(err, sql.ErrNoRows): return false, nil case err != nil: return false, err } return true, nil } // ---------- teams ---------- const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active, deleted_at` func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) { var ( t teamDTO shortName, logoPath, color sql.NullString deletedAt sql.NullString ) err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active, &deletedAt) t.ShortName = strPtr(shortName) t.LogoPath = strPtr(logoPath) t.Color = strPtr(color) t.DeletedAt = strPtr(deletedAt) return t, err } // listTeams 列出参赛单位。默认排除「彻底删除」(deleted_at 非空)的单位, // includeDeleted 只给运维排查用,由 handler 限 admin;停用(active = 0)仍在列表里。 func (s *server) listTeams(ctx context.Context, includeDeleted bool) ([]teamDTO, error) { query := `SELECT ` + teamColumns + ` FROM teams` if !includeDeleted { query += ` WHERE deleted_at IS NULL` } query += ` ORDER BY sort_order, code` rows, err := s.ev.QueryContext(ctx, query) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]teamDTO, 0) for rows.Next() { t, err := scanTeam(rows) if err != nil { return nil, internal(err) } out = append(out, t) } return out, rows.Err() } func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) { return teamMapQ(ctx, s.ev) } // teamMapQ 与 teamMap 同义,但可在事务内使用,保证榜单快照与写操作看到同一状态。 func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) { rows, err := q.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make(map[int64]teamDTO) for rows.Next() { t, err := scanTeam(rows) if err != nil { return nil, internal(err) } out[t.ID] = t } return out, rows.Err() } func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) { return teamByIDQ(ctx, s.ev, id) } // teamByIDQ 在给定连接/事务内读取单位,供写事务内构造 change_log 载荷复用。 func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) { t, err := scanTeam(q.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return teamDTO{}, notFound("单位不存在") } if err != nil { return teamDTO{}, internal(err) } return t, nil } func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) { if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { return teamDTO{}, badRequest("code 与 name 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return teamDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active) VALUES (?, ?, ?, ?, ?, ?, ?)`, in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active) if isUniqueViolation(err) { return teamDTO{}, conflict("单位编码已存在") } if err != nil { return teamDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return teamDTO{}, internal(err) } team, err := teamByIDQ(ctx, tx, id) if err != nil { return teamDTO{}, err } // 单位改名/新建要推给大屏,否则榜单里的单位名不会刷新。 if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil { return teamDTO{}, err } if err := tx.Commit(); err != nil { return teamDTO{}, internal(err) } return team, nil } func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return teamDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ? WHERE id = ?`, in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id) if isUniqueViolation(err) { return teamDTO{}, conflict("单位编码已存在") } if err != nil { return teamDTO{}, internal(err) } if err := requireChanges(res, "单位不存在"); err != nil { return teamDTO{}, err } team, err := teamByIDQ(ctx, tx, id) if err != nil { return teamDTO{}, err } if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil { return teamDTO{}, err } if err := tx.Commit(); err != nil { return teamDTO{}, internal(err) } return team, nil } func (s *server) deleteTeam(ctx context.Context, id int64) error { // 业务数据软删除:置 active=0,保留历史成绩与流水。 tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id) if err != nil { return internal(err) } if err := requireChanges(res, "单位不存在"); err != nil { return err } if err := bumpVersion(ctx, tx, "teams", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil { return err } return tx.Commit() } // hideTeam 彻底删除参赛单位:只打「对界面不可见」的标记,行本身留在库里, // 返回更新后的单位(含 deletedAt)供审计记录。 // // 报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,所以榜单不会变、 // 也不需要任何重算;active 一并置 0,任何漏了 deleted_at 判断的老查询也不会再选中它。 func (s *server) hideTeam(ctx context.Context, id int64) (teamDTO, error) { res, err := s.ev.ExecContext(ctx, `UPDATE teams SET deleted_at = datetime('now'), active = 0 WHERE id = ?`, id) if err != nil { return teamDTO{}, internal(err) } if err := requireChanges(res, "单位不存在"); err != nil { return teamDTO{}, err } return s.getTeam(ctx, id) } // reviveTeam 把被彻底删除的单位放回界面:导入再次命中它的编码时视为同一个单位复用。 // 单位编码有唯一索引,漏掉已隐藏的行只会撞索引或被当成新单位重复建档。 // 停用(active = 0)是另一种状态,这里一并恢复,否则复活后仍显示成「已停用」。 func (s *server) reviveTeam(ctx context.Context, id int64) error { if _, err := s.ev.ExecContext(ctx, `UPDATE teams SET deleted_at = NULL, active = 1 WHERE id = ? AND deleted_at IS NOT NULL`, id); err != nil { return internal(err) } return nil } // teamRestore 是恢复接口的回报。 // // Restored 单独报出来,是因为「本来就没被隐藏」与「这一次真的恢复了」在界面上是两句话, // 审计也只该为后者留痕:重复点恢复不该在审计里堆一串什么都没发生的记录。 type teamRestore struct { // Before 只给审计用(被隐藏时的样子),不进响应体——响应体回答的是「它现在长什么样」。 Before teamDTO `json:"-"` Team teamDTO `json:"team"` Restored bool `json:"restored"` } // restoreTeam 把「彻底删除」(隐藏)的单位放回界面,是操作员显式点的那个恢复入口。 // // 与 reviveTeam(导入命中编码时的顺带复活)的区别只有两点,语义本身完全一致: // - 先确认它真的被隐藏:没被隐藏就按幂等成功返回现状,绝不顺手把「停用」改成启用; // - 编码已被另一个可见单位占用时明确报 409,不让库里出现两个同 code 的可见单位。 // // 隐藏时只动了 deleted_at / active 两列,报名、成绩、流水、人工调整、纪录一行未动, // 所以这里同样不重算、不推榜单:成功后榜单与 change_log 应逐字节不变(有测试钉着)。 func (s *server) restoreTeam(ctx context.Context, id int64) (teamRestore, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return teamRestore{}, internal(err) } defer func() { _ = tx.Rollback() }() before, err := teamByIDQ(ctx, tx, id) if err != nil { return teamRestore{}, err } if before.DeletedAt == nil { return teamRestore{Before: before, Team: before, Restored: false}, nil } // 编码是唯一索引,正常不可能撞上;但有人绕过导入路径手工改库时「已隐藏的 A」与 // 「新建的 A」会同时存在,此时恢复会造出两个同 code 的可见单位,榜单与导出都会指错行。 var dup int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM teams WHERE code = ? AND id <> ? AND deleted_at IS NULL`, before.Code, id).Scan(&dup); err != nil { return teamRestore{}, internal(err) } if dup > 0 { return teamRestore{}, conflict("单位编码 " + before.Code + " 已被另一个可见单位占用,请先处理那个单位再恢复") } if _, err := tx.ExecContext(ctx, `UPDATE teams SET deleted_at = NULL, active = 1 WHERE id = ?`, id); err != nil { return teamRestore{}, internal(err) } after, err := teamByIDQ(ctx, tx, id) if err != nil { return teamRestore{}, err } if err := tx.Commit(); err != nil { return teamRestore{}, internal(err) } return teamRestore{Before: before, Team: after, Restored: true}, nil } // restoreTeamHandler 处理 POST /api/teams/{id}/restore:把「彻底删除」的单位放回界面。 // // 权限与彻底删除同档(recorder)——能隐藏的人就该能恢复,不额外收紧。 func (s *server) restoreTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { return nil, err } result, err := s.restoreTeam(r.Context(), id) if err != nil { return nil, err } // 只在真的改了库时记审计:重复点恢复、或对没隐藏的单位调用,都只是幂等回放,不是事件。 if result.Restored { s.audit(r.Context(), "team.restore", "team", id, result.Before, result.Team) } return result, nil } // ---------- athletes ---------- const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active` func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) { var ( a athleteDTO gender, empNo, idCard, bib sql.NullString teamID, age sql.NullInt64 ) err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age, &a.Active) a.Gender = strPtr(gender) a.EmployeeNo = strPtr(empNo) a.IDCard = strPtr(idCard) a.Bib = strPtr(bib) a.Age = intPtr(age) if teamID.Valid { a.TeamID = teamID.Int64 } return a, err } // isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。 // 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。 func isIDCardConflict(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card") } // isBibConflict 判断错误是否来自 athletes.bib 的唯一索引,文案要与证件号冲突区分开。 func isBibConflict(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "athletes.bib") } func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) { query := `SELECT ` + athleteColumns + ` FROM athletes` args := []any{} if teamID > 0 { query += ` WHERE team_id = ?` args = append(args, teamID) } query += ` ORDER BY name` rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]athleteDTO, 0) for rows.Next() { a, err := scanAthlete(rows) if err != nil { return nil, internal(err) } out = append(out, a) } return out, rows.Err() } func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) { a, err := scanAthlete(s.ev.QueryRowContext(ctx, `SELECT `+athleteColumns+` FROM athletes WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return athleteDTO{}, notFound("运动员不存在") } if err != nil { return athleteDTO{}, internal(err) } return a, nil } // validateAthleteAge 年龄可以留空,填了就必须落在 0–120(JSON 已保证是整数)。 func validateAthleteAge(age *int) error { if age != nil && (*age < 0 || *age > 120) { return badRequest("age 需要在 0-120 之间", nil) } return nil } // validateAthleteGender 性别可以留空(历史档案里就有空的),填了就必须是「男」或「女」。 // // athletes.gender 有 CHECK (gender IN ('男', '女')),不在写入侧拦下来,现场把性别填成 // M / male 就只能看到 500 + 英文约束原文 —— 同一类错在组别就地更新路径上是 400。 // 与组别那条(validateCategoryGender)同口径:不做 TrimSpace,带空格的值按非法拒绝, // 免得「校验通过、落库撞 CHECK」这种更难看的结果。 func validateAthleteGender(gender *string) error { if gender == nil { return nil } if v := *gender; v != "" && v != "男" && v != "女" { return badRequest("gender 只能是 男 / 女", gender) } return nil } func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) { if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 { return athleteDTO{}, badRequest("name 与 teamId 必填", nil) } if err := validateAthleteAge(in.Age); err != nil { return athleteDTO{}, err } if err := validateAthleteGender(in.Gender); err != nil { return athleteDTO{}, err } // 证件号与号码布都是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。 if err := s.checkAthleteIdentity(ctx, in, 0); err != nil { return athleteDTO{}, err } res, err := s.ev.ExecContext(ctx, `INSERT INTO athletes (name, gender, team_id, employee_no, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active) switch { case isIDCardConflict(err): return athleteDTO{}, conflict("证件号已被其它人员使用") case isBibConflict(err): return athleteDTO{}, conflict("号码布已被其它人员使用") case isForeignKeyViolation(err): return athleteDTO{}, badRequest("teamId 不存在", nil) case err != nil: return athleteDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return athleteDTO{}, internal(err) } return s.getAthlete(ctx, id) } // checkAthleteIdentity 校验证件号/号码布是否已被他人占用(id 为 0 表示新建)。 func (s *server) checkAthleteIdentity(ctx context.Context, in athleteDTO, id int64) error { if in.IDCard != nil && *in.IDCard != "" { dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? AND id <> ? LIMIT 1`, *in.IDCard, id) if err != nil { return internal(err) } if dup { return conflict("证件号已被其它人员使用") } } if in.Bib != nil && *in.Bib != "" { dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE bib = ? AND id <> ? LIMIT 1`, *in.Bib, id) if err != nil { return internal(err) } if dup { return conflict("号码布已被其它人员使用") } } return nil } func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) { if err := validateAthleteAge(in.Age); err != nil { return athleteDTO{}, err } if err := validateAthleteGender(in.Gender); err != nil { return athleteDTO{}, err } if err := s.checkAthleteIdentity(ctx, in, id); err != nil { return athleteDTO{}, err } res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, bib = ?, age = ?, active = ? WHERE id = ?`, in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active, id) switch { case isIDCardConflict(err): return athleteDTO{}, conflict("证件号已被其它人员使用") case isBibConflict(err): return athleteDTO{}, conflict("号码布已被其它人员使用") case isForeignKeyViolation(err): return athleteDTO{}, badRequest("teamId 不存在", nil) case err != nil: return athleteDTO{}, internal(err) } if err := requireChanges(res, "运动员不存在"); err != nil { return athleteDTO{}, err } return s.getAthlete(ctx, id) } func (s *server) deleteAthlete(ctx context.Context, id int64) error { res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id = ?`, id) if err != nil { return internal(err) } return requireChanges(res, "运动员不存在") } // athletePurge 是物理删除的规模统计:随响应体返回给前端做提示,同时进审计。 type athletePurge struct { DeletedEntries int `json:"deletedEntries"` DeletedResults int `json:"deletedResults"` DeletedLedger int `json:"deletedLedger"` DeletedBreaks int `json:"deletedBreaks"` RecomputedEvent []int64 `json:"recomputedEvents"` } // purgeAthlete 物理删除人员及其全部痕迹,并在同一事务内重算受影响的积分。 // // 删除顺序按外键方向来(PRAGMA foreign_key_list 实测): // score_ledger 三种引用都先清(athlete_id / entry_id / result_id,后两者本来是 SET NULL, // 但留着会变成指向不了任何东西的空壳流水,voided = 1 的历史流水同样要删); // 再删 record_breaks(对 entries 是 CASCADE,显式删更清楚); // 然后 results(对 entries 无 ON DELETE,必须先于 entries 删)、entries、athletes。 // // 成绩被删会让名次与得分变化,所以删完必须按 (小项, 组别) 重算; // 重算走 recomputeLedgerTx,它只作废引擎产出的流水,人工调整绝不动。 func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return athletePurge{}, internal(err) } defer func() { _ = tx.Rollback() }() // 先收集受影响范围:这些成绩所属的 (小项, 组别) 在删除后要重算。 type scope struct{ eventID, categoryID int64 } scopeRows, err := tx.QueryContext(ctx, `SELECT DISTINCT e.event_id, e.category_id FROM results r JOIN entries e ON e.id = r.entry_id WHERE e.athlete_id = ?`, id) if err != nil { return athletePurge{}, internal(err) } scopes := make([]scope, 0, 4) for scopeRows.Next() { var sc scope if err := scopeRows.Scan(&sc.eventID, &sc.categoryID); err != nil { _ = scopeRows.Close() return athletePurge{}, internal(err) } scopes = append(scopes, sc) } err = scopeRows.Err() _ = scopeRows.Close() if err != nil { return athletePurge{}, internal(err) } var out athletePurge for _, step := range []struct { query string args []any count *int }{ {`DELETE FROM score_ledger WHERE athlete_id = ? OR entry_id IN (SELECT id FROM entries WHERE athlete_id = ?) OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id WHERE e.athlete_id = ?)`, []any{id, id, id}, &out.DeletedLedger}, {`DELETE FROM record_breaks WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id = ?)`, []any{id}, &out.DeletedBreaks}, {`DELETE FROM results WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id = ?)`, []any{id}, &out.DeletedResults}, {`DELETE FROM entries WHERE athlete_id = ?`, []any{id}, &out.DeletedEntries}, } { res, err := tx.ExecContext(ctx, step.query, step.args...) if err != nil { return athletePurge{}, s.purgeBlocked(ctx, tx, id, err) } n, err := res.RowsAffected() if err != nil { return athletePurge{}, internal(err) } *step.count = int(n) } res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id = ?`, id) if err != nil { return athletePurge{}, s.purgeBlocked(ctx, tx, id, err) } // 走到这里说明并发删过了,回滚别把成绩删了却报成功。 if err := requireChanges(res, "运动员不存在"); err != nil { return athletePurge{}, err } // 重算:删完成绩后按组别重建流水与通道名次,别人的名次与得分随新排序更新。 // 只重建流水不够——成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去; // 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。 actorID := middleware.User(ctx).ID eventIDs := make([]int64, 0, len(scopes)) for _, sc := range scopes { score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员删除后重算") if err != nil { return athletePurge{}, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return athletePurge{}, err } if !slices.Contains(eventIDs, sc.eventID) { eventIDs = append(eventIDs, sc.eventID) } } // 推大屏:受影响小项与榜单,否则大屏还挂着旧分数。 for _, eventID := range eventIDs { if err := s.bumpEvent(ctx, tx, eventID); err != nil { return athletePurge{}, err } } if len(eventIDs) > 0 { if err := s.bumpStandings(ctx, tx, actorID); err != nil { return athletePurge{}, err } } out.RecomputedEvent = eventIDs if err := tx.Commit(); err != nil { return athletePurge{}, s.purgeBlocked(ctx, tx, id, err) } return out, nil } // purgeBlocked 兜底:撞上外键约束时回滚并给出可读的 409,不让它冒成 500。 // 正常路径已经按外键方向把引用清干净了,走到这里说明还有没覆盖到的引用,必须留日志证据。 func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause error) error { if !isForeignKeyViolation(cause) { return internal(cause) } s.logger.WarnContext(ctx, "彻底删除人员撞上外键约束", "athleteId", id, "err", cause) // 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。 _ = tx.Rollback() return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。") } // ---------- 运动员批量动作 ---------- const ( athleteBatchDisable = "disable" athleteBatchPurge = "purge" ) // athleteBatchItem 是批量动作里逐条的结果。 // // 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试, // 静默跳过又会让他以为整批都成了,所以一条一条回报。 type athleteBatchItem struct { ID int64 `json:"id"` OK bool `json:"ok"` Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` } // athleteBatchResult 是批量动作的响应体:逐条成败 + 合计。 // Purge 只在 action=purge 时出现,带合计删除规模与受影响小项,供前端提示「榜为什么变了」。 type athleteBatchResult struct { Action string `json:"action"` DryRun bool `json:"dryRun,omitempty"` Requested int `json:"requested"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Items []athleteBatchItem `json:"items"` Purge *athletePurge `json:"purge,omitempty"` } // purgeScope 是一条成绩所属的 (小项, 组别):删除后要按它重算积分与名次。 type purgeScope struct{ eventID, categoryID int64 } func idArgs(ids []int64) []any { args := make([]any, 0, len(ids)) for _, id := range ids { args = append(args, id) } return args } // existingAthleteIDs 挑出这批 id 里真实存在的那些,保持传入顺序。 func existingAthleteIDs(ctx context.Context, q queryer, ids []int64) ([]int64, error) { rows, err := q.QueryContext(ctx, `SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() found := make(map[int64]bool, len(ids)) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, internal(err) } found[id] = true } if err := rows.Err(); err != nil { return nil, internal(err) } out := make([]int64, 0, len(found)) for _, id := range ids { if found[id] { out = append(out, id) } } return out, nil } // newBatchResult 按传入顺序拼逐条结果:不在 existing 里的 id 回报「运动员不存在」。 func newBatchResult(action string, ids, existing []int64, dryRun bool, purge *athletePurge) athleteBatchResult { ok := make(map[int64]bool, len(existing)) for _, id := range existing { ok[id] = true } items := make([]athleteBatchItem, 0, len(ids)) result := athleteBatchResult{Action: action, DryRun: dryRun, Requested: len(ids), Purge: purge} for _, id := range ids { if ok[id] { items = append(items, athleteBatchItem{ID: id, OK: true}) result.Succeeded++ continue } items = append(items, athleteBatchItem{ID: id, OK: false, Code: "NOT_FOUND", Message: "运动员不存在"}) result.Failed++ } result.Items = items return result } // batchAthletes 是 POST /api/athletes/batch 的业务实现:一次请求完成整批停用 / 彻底删除。 // // 逐个调 DELETE /api/athletes/{id} 是「一次操作一条请求」,现场批量清理时会直接撞上写限流; // 收成一次写既少一次配额,也让「部分成功」有个统一的地方逐条说清楚。 // dryRun 只统计不改库,供前端在危险确认框里先列出会删掉什么、重算哪些小项。 func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int64, dryRun bool) (athleteBatchResult, error) { switch action { case athleteBatchDisable, athleteBatchPurge: default: return athleteBatchResult{}, badRequest("action 只能是 disable / purge", action) } ids := dedupeIDs(rawIDs) if len(ids) == 0 { return athleteBatchResult{}, badRequest("ids 不能为空", nil) } if action == athleteBatchPurge { return s.batchPurgeAthletes(ctx, ids, dryRun) } return s.batchDisableAthletes(ctx, ids, dryRun) } // batchDisableAthletes 批量停用:与单条软删除同口径,只置 active = 0, // 报名、成绩、流水一行不动,也不触发任何重算。 func (s *server) batchDisableAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) { existing, err := existingAthleteIDs(ctx, s.ev, ids) if err != nil { return athleteBatchResult{}, err } if !dryRun && len(existing) > 0 { if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id IN (`+placeholders(len(existing))+`)`, idArgs(existing)...); err != nil { return athleteBatchResult{}, internal(err) } } return newBatchResult(athleteBatchDisable, ids, existing, dryRun, nil), nil } // batchPurgeAthletes 批量物理删除:删人员及其报名 / 成绩 / 流水 / 纪录, // 再按受影响的 (小项, 组别) 去重重算一次——重算是批量里最贵的一步,绝不该按人重复做。 // // 整批一个事务:任一 id 撞上外键就整体回滚并给 409,不会留下删了一半的库。 // 「id 不存在 / 已被别人删掉」属于逐条结果,不影响其余人的删除。 func (s *server) batchPurgeAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return athleteBatchResult{}, internal(err) } defer func() { _ = tx.Rollback() }() // 存在性在事务内一次查完:逐条 DELETE 再看 RowsAffected 会白跑半个事务, // 而且「已被别人删掉」和「id 不存在」本来就该一起回报。 existing, err := existingAthleteIDs(ctx, tx, ids) if err != nil { return athleteBatchResult{}, err } // 受影响范围必须在删之前收集:成绩没了就再也问不出它属于哪个组别。 scopes, err := purgeScopesTx(ctx, tx, existing) if err != nil { return athleteBatchResult{}, err } counts, err := deleteAthletesTx(ctx, tx, existing) if err != nil { var api *apiError if errors.As(err, &api) { return athleteBatchResult{}, err } return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err) } purge := &athletePurge{ DeletedEntries: counts.DeletedEntries, DeletedResults: counts.DeletedResults, DeletedLedger: counts.DeletedLedger, DeletedBreaks: counts.DeletedBreaks, } eventIDs := make([]int64, 0, len(scopes)) for _, sc := range scopes { if !slices.Contains(eventIDs, sc.eventID) { eventIDs = append(eventIDs, sc.eventID) } } purge.RecomputedEvent = eventIDs if dryRun { // 预览:删除都还在事务里没提交,defer 会回滚;统计与真删时同源,不会两处算法漂移。 return newBatchResult(athleteBatchPurge, ids, existing, true, purge), nil } actorID := middleware.User(ctx).ID for _, sc := range scopes { score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员批量删除后重算") if err != nil { return athleteBatchResult{}, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return athleteBatchResult{}, err } } for _, eventID := range eventIDs { if err := s.bumpEvent(ctx, tx, eventID); err != nil { return athleteBatchResult{}, err } } if len(eventIDs) > 0 { if err := s.bumpStandings(ctx, tx, actorID); err != nil { return athleteBatchResult{}, err } } if err := tx.Commit(); err != nil { return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err) } return newBatchResult(athleteBatchPurge, ids, existing, false, purge), nil } // purgeScopesTx 收集这批人员名下有成绩的 (小项, 组别)。 func purgeScopesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]purgeScope, error) { if len(ids) == 0 { return nil, nil } rows, err := tx.QueryContext(ctx, `SELECT DISTINCT e.event_id, e.category_id FROM results r JOIN entries e ON e.id = r.entry_id WHERE e.athlete_id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]purgeScope, 0, 4) for rows.Next() { var sc purgeScope if err := rows.Scan(&sc.eventID, &sc.categoryID); err != nil { return nil, internal(err) } out = append(out, sc) } return out, rows.Err() } // deleteAthletesTx 按外键方向清理这批人员的全部痕迹,返回删除规模。 // 顺序与单条 purgeAthlete 一致:流水 → 纪录 → 成绩 → 报名 → 人员。 func deleteAthletesTx(ctx context.Context, tx *sql.Tx, ids []int64) (athletePurge, error) { var out athletePurge if len(ids) == 0 { return out, nil } ph := placeholders(len(ids)) args := idArgs(ids) // score_ledger 的三个引用各要一份占位符参数,拼三份副本(都是只读,不会互相污染)。 ledgerArgs := append(append(append([]any{}, args...), args...), args...) for _, step := range []struct { query string args []any count *int }{ {`DELETE FROM score_ledger WHERE athlete_id IN (` + ph + `) OR entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `)) OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id WHERE e.athlete_id IN (` + ph + `))`, ledgerArgs, &out.DeletedLedger}, {`DELETE FROM record_breaks WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`, args, &out.DeletedBreaks}, {`DELETE FROM results WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`, args, &out.DeletedResults}, {`DELETE FROM entries WHERE athlete_id IN (` + ph + `)`, args, &out.DeletedEntries}, } { res, err := tx.ExecContext(ctx, step.query, step.args...) if err != nil { return out, err } n, err := res.RowsAffected() if err != nil { return out, internal(err) } *step.count = int(n) } res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id IN (`+ph+`)`, args...) if err != nil { return out, err } n, err := res.RowsAffected() if err != nil { return out, internal(err) } // 存在性是同一事务里查过的,行数对不上只可能是并发抢先删了;报 500 会瞒掉真实状态, // 用 409 明说并让整个事务回滚,别把成绩删了却报成功。 if int(n) != len(ids) { return out, conflict("有运动员在本次操作期间已被删除,批量删除已回滚,请刷新名单后重试") } return out, nil } // batchPurgeBlocked 是批量删除的外键兜底:回滚并给出可读的 409,不让它冒成 500。 func (s *server) batchPurgeBlocked(ctx context.Context, tx *sql.Tx, cause error) error { if !isForeignKeyViolation(cause) { return internal(cause) } s.logger.WarnContext(ctx, "批量彻底删除运动员撞上外键约束", "err", cause) _ = tx.Rollback() return conflict("有运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,批量删除已回滚,人员和成绩都没有改动。") } // batchAthletesHandler 是 POST /api/athletes/batch:一次请求完成整批停用 / 彻底删除。 func (s *server) batchAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) { var in athletesBatchPayload if err := decodeJSON(r, &in); err != nil { return nil, err } result, err := s.batchAthletes(r.Context(), in.Action, in.IDs, in.DryRun) if err != nil { return nil, err } // dryRun 只是预览,什么都没改,不该在审计里留下「做过一次批量删除」的假痕迹。 if result.DryRun { return result, nil } // 整批只登记一条审计:审计与放行日志都是一条一次请求,批量才真把写压力降下来。 // 逐条明细与失败原因都进 after,事后能对着名单查。 s.audit(r.Context(), "athlete.batch."+result.Action, "athlete", 0, nil, result) return result, nil } // ---------- sports ---------- const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active` func scanSport(row interface{ Scan(...any) error }) (sportDTO, error) { var ( sp sportDTO shortName sql.NullString defaultProfileID sql.NullInt64 ) err := row.Scan(&sp.ID, &sp.Code, &sp.Name, &shortName, &defaultProfileID, &sp.SortOrder, &sp.Active) sp.ShortName = strPtr(shortName) sp.DefaultProfileID = i64Ptr(defaultProfileID) return sp, err } func (s *server) listSports(ctx context.Context) ([]sportDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT `+sportColumns+` FROM sports ORDER BY sort_order, code`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]sportDTO, 0) for rows.Next() { sp, err := scanSport(rows) if err != nil { return nil, internal(err) } out = append(out, sp) } return out, rows.Err() } func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) { sp, err := scanSport(s.ev.QueryRowContext(ctx, `SELECT `+sportColumns+` FROM sports WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return sportDTO{}, notFound("大项不存在") } if err != nil { return sportDTO{}, internal(err) } return sp, nil } func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) { if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { return sportDTO{}, badRequest("code 与 name 必填", nil) } res, err := s.ev.ExecContext(ctx, `INSERT INTO sports (code, name, short_name, default_profile_id, sort_order, active) VALUES (?, ?, ?, ?, ?, ?)`, in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active) if isUniqueViolation(err) { return sportDTO{}, conflict("大项编码已存在") } if err != nil { return sportDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return sportDTO{}, internal(err) } return s.getSport(ctx, id) } func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) { res, err := s.ev.ExecContext(ctx, `UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ? WHERE id = ?`, in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active, id) if isUniqueViolation(err) { return sportDTO{}, conflict("大项编码已存在") } if err != nil { return sportDTO{}, internal(err) } if err := requireChanges(res, "大项不存在"); err != nil { return sportDTO{}, err } return s.getSport(ctx, id) } func (s *server) deleteSport(ctx context.Context, id int64) error { // 大项有子项目时不允许软删,避免项目挂在不可见的大项下。 has, err := exists(ctx, s.ev, `SELECT 1 FROM events WHERE sport_id = ? LIMIT 1`, id) if err != nil { return internal(err) } if has { return conflict("大项下仍有小项,不能删除") } res, err := s.ev.ExecContext(ctx, `UPDATE sports SET active = 0 WHERE id = ?`, id) if err != nil { return internal(err) } return requireChanges(res, "大项不存在") } // ---------- scoring profiles ---------- func (s *server) listProfiles(ctx context.Context) ([]profileDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero FROM scoring_profiles ORDER BY code`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]profileDTO, 0) for rows.Next() { var p profileDTO if err := rows.Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero); err != nil { return nil, internal(err) } out = append(out, p) } if err := rows.Err(); err != nil { return nil, internal(err) } for i := range out { rules, err := s.profileRules(ctx, out[i].ID) if err != nil { return nil, err } out[i].Rules = rules } return out, nil } func (s *server) profileRules(ctx context.Context, profileID int64) ([]profileRuleDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, profileID) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]profileRuleDTO, 0) for rows.Next() { var r profileRuleDTO if err := rows.Scan(&r.Rank, &r.Points); err != nil { return nil, internal(err) } out = append(out, r) } return out, rows.Err() } func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) { var p profileDTO err := s.ev.QueryRowContext(ctx, `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero FROM scoring_profiles WHERE id = ?`, id). Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero) if errors.Is(err, sql.ErrNoRows) { return profileDTO{}, notFound("积分模板不存在") } if err != nil { return profileDTO{}, internal(err) } rules, err := s.profileRules(ctx, p.ID) if err != nil { return profileDTO{}, err } p.Rules = rules return p, nil } // validateAwardRanks 校验积分模板的「录取名次」(scoring_profiles.award_ranks)。 // // 口径照建表约束来(migrations/event/0003:CHECK (award_ranks >= 0)),不自己发明上限: // 0 表示不按名次封顶——算分引擎只在 award_ranks > 0 时才按名次截断(domain.basePoints), // 规则包侧(rules/validate.go)也只拒负数。不在这里拦,-1 会一路撞 CHECK 报 500 + // 原始约束文本,而同一张表的 tieMode / recordBonusMode 早就是 400。 func validateAwardRanks(awardRanks int) error { if awardRanks < 0 { return badRequest("awardRanks 需要是不小于 0 的整数(0 表示不按名次封顶)", awardRanks) } return nil } func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) { if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { return profileDTO{}, badRequest("code 与 name 必填", nil) } tieMode, err := normalizeTieMode(in.TieMode) if err != nil { return profileDTO{}, err } recordBonusMode, err := normalizeRecordBonusMode(in.RecordBonusMode) if err != nil { return profileDTO{}, err } if err := validateAwardRanks(in.AwardRanks); err != nil { return profileDTO{}, err } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return profileDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero) VALUES (?, ?, ?, ?, ?, ?)`, in.Code, in.Name, tieMode, recordBonusMode, in.AwardRanks, in.LastPlaceZero) if isUniqueViolation(err) { return profileDTO{}, conflict("积分模板编码已存在") } if err != nil { return profileDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return profileDTO{}, internal(err) } if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil { return profileDTO{}, err } if err := tx.Commit(); err != nil { return profileDTO{}, internal(err) } // 新建的模板此刻还没有任何引用(sports.default_profile_id 与 // event_score_channels.profile_id 都是指向 scoring_profiles(id) 的外键, // 模板必须先存在才挂得上),所以这一次重算实际上是空转;留在这里是为了与 // 「改模板」走同一条落地路径,免得以后给新建流程加上「顺手挂到某处」的逻辑时漏掉重算。 // recomputeAllEvents 自带事务与推榜,别再包一层。 if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil { s.logger.Error("积分模板新建后重算失败", "err", err) return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err) } return s.getProfile(ctx, id) } func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) { tieMode, err := normalizeTieMode(in.TieMode) if err != nil { return profileDTO{}, err } recordBonusMode, err := normalizeRecordBonusMode(in.RecordBonusMode) if err != nil { return profileDTO{}, err } if err := validateAwardRanks(in.AwardRanks); err != nil { return profileDTO{}, err } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return profileDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?, award_ranks = ?, last_place_zero = ?, updated_at = datetime('now') WHERE id = ?`, in.Code, in.Name, tieMode, recordBonusMode, in.AwardRanks, in.LastPlaceZero, id) if isUniqueViolation(err) { return profileDTO{}, conflict("积分模板编码已存在") } if err != nil { return profileDTO{}, internal(err) } if err := requireChanges(res, "积分模板不存在"); err != nil { return profileDTO{}, err } if in.Rules != nil { if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil { return profileDTO{}, err } } if err := tx.Commit(); err != nil { return profileDTO{}, internal(err) } // 模板分值是算分公式的输入:改了就必须全量重算已发布成绩的积分与名次,并把新榜单推给大屏。 // recomputeAllEvents 内部自建事务并写 standings 版本,这里不要再套事务(嵌套会死锁在单连接上)。 if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil { s.logger.Error("积分模板更新后重算失败", "err", err) return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err) } return s.getProfile(ctx, id) } func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error { if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil { return internal(err) } for _, r := range rules { if r.Rank < 1 { return badRequest("积分名次必须 >= 1", r) } if _, err := tx.ExecContext(ctx, `INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`, profileID, r.Rank, r.Points); err != nil { return internal(err) } } return nil } func orDefault(v, def string) string { if strings.TrimSpace(v) == "" { return def } return v }