用户给出的 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。
242 lines
10 KiB
Go
242 lines
10 KiB
Go
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)
|
||
}
|
||
}
|