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

@@ -0,0 +1,376 @@
package httpapi_test
import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"
)
// waitAudit 取一条审计的 after_json。审计中间件在响应写完之后才落库,
// 所以这里给一小段重试窗口,避免用例跟中间件抢时间。
func waitAudit(t *testing.T, env *testEnv, action string, entityID int64) string {
t.Helper()
for i := 0; i < 100; i++ {
var after sql.NullString
err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id = ?`,
action, entityID).Scan(&after)
switch {
case err == nil:
return after.String
case errors.Is(err, sql.ErrNoRows):
time.Sleep(10 * time.Millisecond)
default:
t.Fatalf("查询审计: %v", err)
}
}
t.Fatalf("等不到 %s 的审计记录", action)
return ""
}
// countRows 统计满足条件的行数。
func countRows(t *testing.T, env *testEnv, query string, args ...any) int {
t.Helper()
var n int
if err := env.ev.QueryRow(query, args...).Scan(&n); err != nil {
t.Fatalf("统计行数: %v\n%s", err, query)
}
return n
}
// activeLedgerPoints 取某个报名名下的有效分值(按流水 id 排序)。
func activeLedgerPoints(t *testing.T, env *testEnv, entryID int64) []float64 {
t.Helper()
rows, err := env.ev.Query(`SELECT points FROM score_ledger WHERE entry_id = ? AND voided = 0 ORDER BY id`, entryID)
if err != nil {
t.Fatalf("读流水: %v", err)
}
defer func() { _ = rows.Close() }()
var out []float64
for rows.Next() {
var p float64
if err := rows.Scan(&p); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out = append(out, p)
}
return out
}
// equalFloats 比较两组分值(都是整数分,直接按元素比)。
func equalFloats(a, b []float64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// publishResults 走真实的草稿 + 发布接口把成绩发布出去,积分榜据此算分。
func publishResults(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string, rows ...map[string]any) {
t.Helper()
if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID, "requestId": tag + "-draft", "results": rows,
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// teamPoints 取积分榜上某个单位的总分。
func teamPoints(t *testing.T, env *testEnv, client *http.Client, teamID int64) float64 {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
}
for _, row := range decodeData[[]map[string]any](t, env2) {
if row["teamId"] == float64(teamID) {
points, _ := row["totalPoints"].(float64)
return points
}
}
t.Fatalf("积分榜上没有单位 %d", teamID)
return 0
}
// 连报名都没有的人:物理删除成功,事后再查就是 404。
func TestPurgeAthleteWithoutAnyData(t *testing.T) {
env, _, client := recorderWithSeed(t)
id := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active)
VALUES ('独行侠', '女', (SELECT id FROM teams WHERE code = 'A'), 1)`)
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", id), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("无数据的人员应能删除,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 {
t.Fatalf("人员应被删掉,得到 %d 条", n)
}
if status, _ := env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", id), nil, false); status != http.StatusNotFound {
t.Fatalf("删除后再查应 404,得到 %d", status)
}
}
// 只有报名、没有成绩的人:报名一并删掉。
func TestPurgeAthleteWithEntriesOnly(t *testing.T) {
env, ids, client := recorderWithSeed(t)
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 {
t.Fatalf("前置条件:athleteA 应有 1 条报名,得到 %d", n)
}
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("只有报名的人应能删除,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 0 {
t.Fatalf("报名应一并删除,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, ids.athleteA); n != 0 {
t.Fatalf("人员应被删掉,得到 %d 条", n)
}
}
// 有已发布成绩的人:人员、报名、成绩、流水(含已作废的历史流水)全删干净,
// 并把受影响的组别重算——同组别的 B 从第二升到第一,得分从 8 变 10,且没有重复计分。
func TestPurgeAthleteRecomputesLedger(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "purge",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
// 前置:A 第一 10 分、B 第二 8 分,单位总分各 10 / 8。
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
}
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
t.Fatalf("前置条件:B 应是 8 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
t.Fatalf("前置条件:A 单位总分应是 10,得到 %v", got)
}
// 破纪录登记与该运动员名下的历史流水(voided = 1)也要跟着消失。
env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state)
VALUES (?, ?, 'meet', 11.58, 5, 'pending')`, ids.event, ids.entryA)
env.exec(t, `INSERT INTO score_ledger (team_id, event_id, entry_id, athlete_id, source_type, source_id, points, voided)
VALUES (?, ?, ?, ?, 'result', ?, 7, 1)`,
ids.teamA, ids.event, ids.entryA, ids.athleteA, ids.entryA)
var logBefore int64
if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&logBefore); err != nil {
t.Fatalf("读变更日志: %v", err)
}
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("删除应 200,得到 %d %+v", status, env2.Error)
}
purged := decodeData[map[string]any](t, env2)
if purged["deletedEntries"] != float64(1) || purged["deletedResults"] != float64(1) ||
purged["deletedLedger"] != float64(2) || purged["deletedBreaks"] != float64(1) {
t.Fatalf("响应应带回删除规模,得到 %+v", purged)
}
// A 的痕迹全没了——包含 voided = 1 的历史流水。
for _, c := range []struct {
name string
query string
}{
{"人员", `SELECT COUNT(*) FROM athletes WHERE id = ?`},
{"报名", `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`},
{"名下的流水", `SELECT COUNT(*) FROM score_ledger WHERE athlete_id = ?`},
{"报名名下的流水", `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`},
} {
arg := ids.athleteA
if strings.Contains(c.name, "报名名下") {
arg = ids.entryA
}
if n := countRows(t, env, c.query, arg); n != 0 {
t.Fatalf("%s 应删干净,得到 %d 条", c.name, n)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != 0 {
t.Fatalf("成绩应删干净,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryA); n != 0 {
t.Fatalf("破纪录登记应删干净,得到 %d 条", n)
}
// 重算发生了:B 升到第一得 10 分,旧流水被作废保留痕迹,没有重复计分。
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{10}) {
t.Fatalf("B 应随新名次重算成 10 分,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 2 {
t.Fatalf("B 应留下「1 条作废 + 1 条有效」,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 {
t.Fatalf("B 的旧流水应被作废,得到 %d 条", n)
}
// B 的成绩跟着新名次重排:名次列写回 1,分数与发布态不变。
var (
rank sql.NullInt64
value sql.NullFloat64
state string
status2 string
)
if err := env.ev.QueryRow(`SELECT rank, result_value, state, status FROM results WHERE entry_id = ?`, ids.entryB).
Scan(&rank, &value, &state, &status2); err != nil {
t.Fatalf("读 B 的成绩: %v", err)
}
if !rank.Valid || rank.Int64 != 1 {
t.Fatalf("B 的名次应重算成 1,得到 %+v", rank)
}
if !value.Valid || value.Float64 != 11.92 || state != "published" || status2 != "ok" {
t.Fatalf("B 的成绩内容不该被改动: value=%+v state=%s status=%s", value, state, status2)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 1 {
t.Fatalf("B 的报名不该被删,得到 %d 条", n)
}
// 榜单跟着变:A 单位归零,B 单位拿到 10 分。
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("删掉 A 后其单位总分应归零,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
t.Fatalf("B 的单位总分应重算成 10,得到 %v", got)
}
// 审计带上删除规模与重算的小项。
after := waitAudit(t, env, "athlete.purge", ids.athleteA)
for _, want := range []string{`"deletedEntries":1`, `"deletedResults":1`, fmt.Sprintf(`"recomputedEvents":[%d]`, ids.event)} {
if !strings.Contains(after, want) {
t.Fatalf("审计应含 %s,得到 %s", want, after)
}
}
// 删干净之后库里不该留下任何悬挂外键。
assertNoForeignKeyViolation(t, env)
// 大屏收到通知:受影响小项与榜单的变更记录都在。
pushed := countRows(t, env,
`SELECT COUNT(*) FROM change_log WHERE id > ? AND entity_type IN ('events', 'standings')`, logBefore)
if pushed < 2 {
t.Fatalf("应把受影响小项与榜单推给大屏,得到 %d 条变更记录", pushed)
}
}
// 重算只碰被删成绩所在的组别:另一个组别的流水连 id 都不该变。
func TestPurgeAthleteLeavesOtherCategoryUntouched(t *testing.T) {
env, ids, client := recorderWithSeed(t)
cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamB, ids.athB)
publishResults(t, env, client, ids.event, "purge-cat",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": entryB2, "resultValue": 12.30, "resultText": "12.30", "unit": "s", "status": "ok"},
)
var beforeID, beforeResultID, beforeEntryID int64
var beforePoints float64
if err := env.ev.QueryRow(`SELECT id, result_id, entry_id, points FROM score_ledger
WHERE entry_id = ? AND voided = 0`, entryB2).
Scan(&beforeID, &beforeResultID, &beforeEntryID, &beforePoints); err != nil {
t.Fatalf("读另一个组别的流水: %v", err)
}
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true)
if status != http.StatusOK {
t.Fatalf("删除应 200,得到 %d %+v", status, env2.Error)
}
purged := decodeData[map[string]any](t, env2)
// 只重算了 A 成绩所在的组别。
if events, _ := purged["recomputedEvents"].([]any); len(events) != 1 || events[0] != float64(ids.event) {
t.Fatalf("只应重算受影响小项,得到 %+v", purged["recomputedEvents"])
}
var afterID, afterResultID, afterEntryID int64
var afterPoints float64
if err := env.ev.QueryRow(`SELECT id, result_id, entry_id, points FROM score_ledger
WHERE entry_id = ? AND voided = 0`, entryB2).
Scan(&afterID, &afterResultID, &afterEntryID, &afterPoints); err != nil {
t.Fatalf("重算后读另一个组别的流水: %v", err)
}
if afterID != beforeID || afterResultID != beforeResultID || afterEntryID != beforeEntryID || afterPoints != beforePoints {
t.Fatalf("另一个组别的流水不该被重算: 之前 id=%d result=%d entry=%d points=%v,之后 id=%d result=%d entry=%d points=%v",
beforeID, beforeResultID, beforeEntryID, beforePoints, afterID, afterResultID, afterEntryID, afterPoints)
}
// 另一个组别的报名与成绩也在。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, entryB2); n != 1 {
t.Fatalf("另一个组别的报名不该被动,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, entryB2); n != 1 {
t.Fatalf("另一个组别的成绩不该被动,得到 %d 条", n)
}
}
// assertNoForeignKeyViolation 断言库里没有悬挂外键——级联删除最容易留下的后患。
func assertNoForeignKeyViolation(t *testing.T, env *testEnv) {
t.Helper()
rows, err := env.ev.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = rows.Close() }()
if rows.Next() {
var table string
if err := rows.Scan(&table, new(any), new(any), new(any)); err != nil {
t.Fatalf("读外键检查结果: %v", err)
}
t.Fatalf("删除后存在外键悬挂,表 %s", table)
}
}
// 人员不存在时(含 purge=1)404 沿用仓库既有语义。
func TestPurgeMissingAthleteReturns404(t *testing.T) {
env, _, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodDelete, "/api/athletes/999999?purge=1", nil, true)
if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "运动员不存在" {
t.Fatalf("不存在的人员应 404 运动员不存在,得到 %d %+v", status, env2.Error)
}
}
// 不带参数的 DELETE 仍是停用:只置 active = 0,报名、成绩、流水全留着。
func TestSoftDeleteAthleteKeepsData(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "soft",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
// purge=0 这类明确否定的写法同样按停用处理。
for _, path := range []string{
fmt.Sprintf("/api/athletes/%d", ids.athleteA),
fmt.Sprintf("/api/athletes/%d?purge=0", ids.athleteA),
} {
if status, env2 := env.do(client, http.MethodDelete, path, nil, true); status != http.StatusOK || !env2.OK {
t.Fatalf("DELETE %s 应 200,得到 %d %+v", path, status, env2.Error)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 {
t.Fatalf("停用应把 active 置 0,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 {
t.Fatalf("停用不该动报名,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != 1 {
t.Fatalf("停用不该动成绩,得到 %d 条", n)
}
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("停用不该动流水,得到 %v", got)
}
}
+32
View File
@@ -0,0 +1,32 @@
package httpapi
import (
"fmt"
"strings"
)
// normalizeImportStatus 把报名表 status 列归一成 entries.status 允许的四个取值。
//
// entries.status 上有 CHECK (status IN ('registered','scratched','dq','dns')),
// 而填表人习惯写大写(DNS、Registered)或中文(退赛、取消资格),原样入库会撞约束,
// 报出来的又是原始 SQLite 错误,现场看不出是 status 这一格填错了。
func normalizeImportStatus(raw string) (string, error) {
// 中文输入法打出的全角空格 TrimSpace 不认,先统一成半角再裁首尾。
v := strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " "))
// 大小写不敏感,所以先转小写再比对;中文别名不受影响。
switch strings.ToLower(v) {
case "":
// 空值即建表默认值:不填就是不填,按正常参赛处理。
return "registered", nil
case "registered", "正常", "正常参赛", "参赛":
return "registered", nil
case "scratched", "退赛", "弃赛", "请假":
return "scratched", nil
case "dq", "取消资格", "犯规":
return "dq", nil
case "dns", "未出场", "未到", "缺席":
return "dns", nil
}
return "", fmt.Errorf("status「%s」无法识别,只能填 registered / scratched / dq / dns(或留空)", v)
}
@@ -0,0 +1,54 @@
package httpapi
import (
"strings"
"testing"
)
// status 归一:大小写不敏感、认中文别名、空值按 registered,认不出的必须报错而不是原样入库。
func TestNormalizeImportStatus(t *testing.T) {
ok := map[string]string{
"": "registered",
" ": "registered",
"\u3000": "registered", // 只有全角空格
"registered": "registered",
"Registered": "registered",
"REGISTERED": "registered",
" \u3000registered\u3000 ": "registered",
"正常": "registered",
"正常参赛": "registered",
"参赛": "registered",
"scratched": "scratched",
"Scratched": "scratched",
"退赛": "scratched",
"弃赛": "scratched",
"请假": "scratched",
"dq": "dq",
"DQ": "dq",
"取消资格": "dq",
"犯规": "dq",
"dns": "dns",
"DNS": "dns",
"未出场": "dns",
"未到": "dns",
"缺席": "dns",
}
for raw, want := range ok {
got, err := normalizeImportStatus(raw)
if err != nil || got != want {
t.Errorf("normalizeImportStatus(%q) = (%q, %v),期望 (%q, nil)", raw, got, err, want)
}
}
for _, raw := range []string{"已报到", "unknown", "男子", "DNF", "registered/scratched"} {
got, err := normalizeImportStatus(raw)
if err == nil {
t.Errorf("normalizeImportStatus(%q) 应报错,得到 %q", raw, got)
continue
}
// 错误消息要能直接拼在「第 N 行:」后面,且带上原始值。
if !strings.Contains(err.Error(), raw) || !strings.Contains(err.Error(), "无法识别") {
t.Errorf("normalizeImportStatus(%q) 的错误应含原值与「无法识别」,得到 %q", raw, err)
}
}
}
+35 -8
View File
@@ -518,20 +518,40 @@ func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (a
return updated, nil
}
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) error {
// deleteAthleteHandler 默认软删除(停用);带 purge=1 时物理删除人员、报名、成绩并重算受影响的积分。
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return err
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return err
return nil, err
}
if err := s.deleteAthlete(r.Context(), id); err != nil {
return err
if !isQueryTrue(r, "purge") {
if err := s.deleteAthlete(r.Context(), id); err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
return nil, nil
}
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
return nil
purged, err := s.purgeAthlete(r.Context(), id)
if err != nil {
return nil, err
}
// 审计带上删除规模与重算过的小项,事后能对上「榜为什么变了」。
s.audit(r.Context(), "athlete.purge", "athlete", id, before, purged)
return purged, nil
}
// isQueryTrue 解析开关型查询参数:只认 1 / true(大小写不敏感),其余值一律当 false。
// 写错的值不会误开开关——purge=0、purge=false 都还是软删除。
func isQueryTrue(r *http.Request, key string) bool {
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) {
case "1", "true":
return true
}
return false
}
// ---------- 报名表导入 ----------
@@ -776,6 +796,13 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
@@ -872,7 +899,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Seed: nilIfEmpty(cell("seed")),
Status: orDefault(cell("status"), "registered"),
Status: status,
})
switch {
case err != nil:
@@ -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 目录的真实路径。
+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`
+1 -1
View File
@@ -209,7 +209,7 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/athletes", s.write(auth.RoleRecorder, s.createAthleteHandler))
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
mux.HandleFunc("DELETE /api/athletes/{id}", s.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler))
mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler))
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))