Files
Scoreboard/server/internal/httpapi/athlete_batch_test.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

242 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"net/http"
"testing"
"time"
)
// batchData 解一次批量动作的响应体。
type batchItemView struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Code string `json:"code"`
Message string `json:"message"`
}
type batchResultView struct {
Action string `json:"action"`
DryRun bool `json:"dryRun"`
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Items []batchItemView `json:"items"`
Purge *struct {
DeletedEntries int `json:"deletedEntries"`
DeletedResults int `json:"deletedResults"`
DeletedLedger int `json:"deletedLedger"`
DeletedBreaks int `json:"deletedBreaks"`
RecomputedEvent []int64 `json:"recomputedEvents"`
} `json:"purge"`
}
func doBatch(t *testing.T, env *testEnv, client *http.Client, body map[string]any) batchResultView {
t.Helper()
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量动作应 200,得到 %d %+v", status, env2.Error)
}
return decodeData[batchResultView](t, env2)
}
// auditCount 数某个 action 的审计条数;审计在响应之后落库,给一点重试窗口。
func auditCount(t *testing.T, env *testEnv, action string) int {
t.Helper()
for i := 0; i < 100; i++ {
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action); n > 0 {
return n
}
time.Sleep(10 * time.Millisecond)
}
return 0
}
// 一次请求停用 5 人:库内 5 行 active = 0,响应逐条成功,且只产生一条审计。
func TestBatchDisableAthletesOneRequest(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := []int64{ids.athleteA, ids.athB}
for _, name := range []string{"赵一", "钱二", "孙三"} {
targets = append(targets, env.insert(t,
`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, name, ids.teamA))
}
result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets})
if result.Action != "disable" || result.Requested != 5 || result.Succeeded != 5 || result.Failed != 0 {
t.Fatalf("响应应为 5 成功 0 失败,得到 %+v", result)
}
for _, item := range result.Items {
if !item.OK {
t.Fatalf("每条都该成功,得到 %+v", item)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 5 {
t.Fatalf("应有 5 人处于停用,得到 %d", n)
}
// 停用是软删除:报名与成绩一条不动。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 {
t.Fatalf("停用不该动报名,得到 %d 条", n)
}
if n := auditCount(t, env, "athlete.batch.disable"); n != 1 {
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
}
}
// 混入不存在的 id:逐条回报,整批仍然成功,重复 id 只算一次。
func TestBatchDisableReportsMissingIDs(t *testing.T) {
env, ids, client := recorderWithSeed(t)
result := doBatch(t, env, client, map[string]any{
"action": "disable", "ids": []int64{ids.athleteA, ids.athleteA, 999999},
})
if result.Requested != 2 || result.Succeeded != 1 || result.Failed != 1 {
t.Fatalf("重复 id 应去重、缺失 id 应逐条失败,得到 %+v", result)
}
if len(result.Items) != 2 {
t.Fatalf("应逐条回报 2 条,得到 %+v", result.Items)
}
missing := result.Items[1]
if missing.ID != 999999 || missing.OK || missing.Code != "NOT_FOUND" || missing.Message != "运动员不存在" {
t.Fatalf("缺失 id 应回报 运动员不存在,得到 %+v", missing)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 {
t.Fatalf("存在的那个人应被停用,得到 %d", n)
}
}
// 批量彻底删除:删 2 人、重算一次受影响组别,第三个人升到第一并拿到 10 分;
// 缺失的 id 不影响其余两人被删掉。
func TestBatchPurgeRecomputesAndReportsPerItem(t *testing.T) {
env, ids, client := recorderWithSeed(t)
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athC)
publishResults(t, env, client, ids.event, "batch-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"},
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
)
// 前置:A 第一 10 分,C 第三 6 分。
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
t.Fatalf("前置条件:C 应是 6 分,得到 %v", got)
}
result := doBatch(t, env, client, map[string]any{
"action": "purge", "ids": []int64{ids.athleteA, ids.athB, 999999},
})
if result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("应 2 成功 1 失败,得到 %+v", result)
}
if result.Purge == nil {
t.Fatalf("purge 应带回删除规模,得到 %+v", result)
}
if result.Purge.DeletedEntries != 2 || result.Purge.DeletedResults != 2 || result.Purge.DeletedLedger != 2 {
t.Fatalf("删除规模应为 报名2 / 成绩2 / 流水2,得到 %+v", result.Purge)
}
if len(result.Purge.RecomputedEvent) != 1 || result.Purge.RecomputedEvent[0] != ids.event {
t.Fatalf("应重算 1 个受影响小项,得到 %+v", result.Purge.RecomputedEvent)
}
// 被删的两人痕迹全无,第三人原样保留。
for _, id := range []int64{ids.athleteA, ids.athB} {
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 {
t.Fatalf("运动员 %d 应被删掉,得到 %d", id, n)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, athC); n != 1 {
t.Fatalf("第三个人的报名不该被删,得到 %d", n)
}
// 别人的名次被重算:C 从第三升到第一,得分 6 → 10,单位总分跟着变。
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{10}) {
t.Fatalf("C 应随新名次重算成 10 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("B 单位删空后总分应归零,得到 %v", got)
}
// 榜单也重算了:C 所在的 A 单位仍是 10 分(A 的 10 分换成了 C 的 10 分)。
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
t.Fatalf("A 单位应保持 10 分,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 {
t.Fatalf("重算应把旧流水作废留下来")
}
assertNoForeignKeyViolation(t, env)
if n := auditCount(t, env, "athlete.batch.purge"); n != 1 {
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
}
}
// dryRun 只统计不改库:预览数字与真删同源,但一行都不能动,也不该留审计。
func TestBatchPurgeDryRunChangesNothing(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "batch-dry",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
before := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
result := doBatch(t, env, client, map[string]any{
"action": "purge", "ids": []int64{ids.athleteA}, "dryRun": true,
})
if !result.DryRun || result.Succeeded != 1 {
t.Fatalf("预览应逐条成功并标记 dryRun,得到 %+v", result)
}
if result.Purge == nil || result.Purge.DeletedEntries != 1 || result.Purge.DeletedResults != 1 {
t.Fatalf("预览应报出会删掉什么,得到 %+v", result.Purge)
}
if len(result.Purge.RecomputedEvent) != 1 {
t.Fatalf("预览应报出会重算哪个小项,得到 %+v", result.Purge.RecomputedEvent)
}
after := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
if before != after {
t.Fatalf("预览不该改动任何数据:\n%s\n---\n%s", before, after)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'athlete.batch%'`); n != 0 {
t.Fatalf("预览不该写审计,得到 %d 条", n)
}
}
// 非法入参:action 只认 disable / purge,ids 去重后不能为空。
func TestBatchRejectsBadInput(t *testing.T) {
env, ids, client := recorderWithSeed(t)
for _, tc := range []struct {
name string
body map[string]any
want string
}{
{"action 非法", map[string]any{"action": "delete", "ids": []int64{ids.athleteA}}, "action 只能是 disable / purge"},
{"ids 为空", map[string]any{"action": "disable", "ids": []int64{}}, "ids 不能为空"},
{"ids 全非法", map[string]any{"action": "purge", "ids": []int64{0, -3}}, "ids 不能为空"},
} {
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", tc.body, true)
if status != http.StatusBadRequest {
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
}
if env2.Error == nil || env2.Error.Message != tc.want {
t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error)
}
}
}
// 未登录的写操作一律 401,且不改动任何数据。
func TestBatchRequiresLogin(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client, _ := env.client()
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch",
map[string]any{"action": "disable", "ids": []int64{ids.athleteA}}, false)
if status != http.StatusUnauthorized {
t.Fatalf("未登录应 401,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 0 {
t.Fatalf("被拒后不该改动数据,得到 %d", n)
}
}