Files
Scoreboard/server/internal/httpapi/repo.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

1240 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// ---------- 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
}
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
}
// 证件号与号码布都是唯一标识:先查重给出明确 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 := 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,必须像发布那样把名次写回去。
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
}
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
}
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)
}
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
}
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)
}
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
}