Files
Scoreboard/server/internal/httpapi/athlete_purge_test.go
T
AskaEth 9d4ee0946c 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/ 同步更新。
2026-09-10 22:37:25 +08:00

377 lines
16 KiB
Go

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)
}
}