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:
1 parent
22ab49e73a
commit
9d4ee0946c
22 files changed
+1264
-54
No files matched your search
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user