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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 目录的真实路径。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user