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
@@ -878,6 +878,77 @@ func TestImportResolveKeepNewAgeOnlyOverwritesWhenGiven(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// status 列归一:DNS / 退赛 / 空串 / Registered 都能正常入库,库里存的是四个合法值之一。
|
||||
func TestImportNormalizesStatusColumn(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
cases := []struct{ cell, want string }{
|
||||
{"DNS", "dns"},
|
||||
{"退赛", "scratched"},
|
||||
{"", "registered"},
|
||||
{"Registered", "registered"},
|
||||
}
|
||||
body := importHeader + "\n"
|
||||
for i, c := range cases {
|
||||
body += fmt.Sprintf("M100,男子,A,选手%d,11010119900101%04d,男,,,,%s,\n", i+1, i+1, c.cell)
|
||||
}
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(len(cases)) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("四种写法都应导入成功: %+v", result)
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
idCard := fmt.Sprintf("11010119900101%04d", i+1)
|
||||
var got string
|
||||
if err := env.ev.QueryRow(`SELECT e.status FROM entries e JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE a.id_card = ?`, idCard).Scan(&got); err != nil {
|
||||
t.Fatalf("查询 status(%s): %v", c.cell, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("status=%q 应存成 %q,得到 %q", c.cell, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status 填了认不出的值:该行跳过,错误消息要是中文可读的行级提示,不能是原始 SQLite 报错。
|
||||
func TestImportRejectsUnknownStatus(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,已报到,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("乱填 status 的行应跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("应有 1 条行级错误,得到 %+v", errs)
|
||||
}
|
||||
msg, _ := errs[0].(string)
|
||||
for _, want := range []string{"第 2 行", "已报到", "registered / scratched / dq / dns"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("错误消息应含 %q,得到 %q", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 该行一个字段都不该落库:既不建人员,也不建报名。
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries e JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE a.id_card = ?`, "110101199001011234").Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 0 {
|
||||
t.Fatalf("认不出 status 的行不应落库,得到 %d 条报名", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 图片上传与静态访问 ----------
|
||||
|
||||
// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。
|
||||
|
||||
Reference in new issue
Block a user