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。
This commit is contained in:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
@@ -0,0 +1,241 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user