feat: 运动员彻底删除(真物理删除 + 重算积分)+ 报名表 status 容错 + 部署包带导入规范

## 运动员「彻底删除」= 真删除
`DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里:
删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 →
对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 →
bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。
响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。
必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id /
result_id 三种引用一把清,漏一种就留悬挂。

管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、
相关小项排名与积分会重算),成功 toast 报出删除规模。

## 报名表 status 列容错
新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认),
认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered,
认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后
抛原始 SQLite 错误,填表人看不懂。

## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」
SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」
报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认
"unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。

## 安装包平铺《报名名单导入规范》
deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增
报名名单导入规范.txt。

测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go);
前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。
另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、
榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。
This commit is contained in:
AskaEth committed 2026-09-10 22:37:25 +08:00
1 parent 22ab49e73a
commit 9d4ee0946c
22 files changed
+1264 -54

No files matched your search

+142 -2
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"slices"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
@@ -103,12 +104,18 @@ func requireChanges(res sql.Result, message string) error {
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
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed")
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
}
func isForeignKeyViolation(err error) bool {
@@ -467,6 +474,139 @@ func (s *server) deleteAthlete(ctx context.Context, id int64) error {
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("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
}
// ---------- sports ----------
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`