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

+22 -6
View File
@@ -67,13 +67,29 @@ func applyBefore(t *testing.T, db *sql.DB, name string) {
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("应用迁移 %s: %v", n, err)
}
// 与 store.migrate 一样登记,升级时才能只补跑 0016。
// 与 store.migrate 一样登记,升级时才能只补跑后面的迁移。
if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil {
t.Fatalf("登记迁移 %s: %v", n, err)
}
}
}
// migrationCount 是嵌入的赛事库迁移条数:升级后登记数必须与文件数一致,
// 新增迁移时这条断言不用改。
func migrationCount(t *testing.T) int {
t.Helper()
fsys, err := fs.Sub(migrations.EventFS, "event")
if err != nil {
t.Fatalf("定位迁移目录: %v", err)
}
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
t.Fatalf("列出迁移: %v", err)
}
return len(names)
}
func countRows(t *testing.T, db *sql.DB, table string) int {
t.Helper()
@@ -146,8 +162,8 @@ func TestMigration0016PreservesExistingRows(t *testing.T) {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级迁移: %v", err)
}
if got := countRows(t, db, "schema_migrations"); got != 17 {
t.Errorf("迁移登记数 = %d,期望 17", got)
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
for table, want := range map[string]int{
@@ -345,12 +361,12 @@ func TestMigration0017PreservesExistingRows(t *testing.T) {
}
}
// 走真实升级路径:只补跑未登记的 0016、0017。
// 走真实升级路径:只补跑未登记的 0017 及其后的迁移。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级迁移: %v", err)
}
if got := countRows(t, db, "schema_migrations"); got != 17 {
t.Errorf("迁移登记数 = %d,期望 17", got)
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
for table, want := range map[string]int{
"events": 1, "event_categories": 1, "event_score_channels": 1, "entries": 1,