## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。
## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。
主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试
## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。
## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败
## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
335 lines
14 KiB
Go
335 lines
14 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"fmt"
|
||
"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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// batchLimit 是服务端单次批量动作的人数上限(`maxAthleteBatch`,与 `entries/batch` 的
|
||
// `maxEntryBatch` 刻意同值 500)。用例在 httpapi 包外,拿不到那个常量,只能写字面量:
|
||
// 上限一旦改动,这里与 devdocs/05 会一起变红,正好逼着同步改口径与文档。
|
||
const batchLimit = 500
|
||
|
||
// 超限(上限 + 1):400,响应体与同一个接口上既有的 400 同构,且一个人都不动。
|
||
//
|
||
// 用「上限 + 1」构造,不真发几万条:拦的是条数,与 id 具体是什么无关;
|
||
// 但条数是真做满的(全是库里存在的人),删掉上限检查后这一批会被真的停用,
|
||
// 用例只能靠状态码与文案变红,不会靠「id 不存在」之类的旁证蒙对。
|
||
func TestBatchRejectsOverLimit(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
targets := []int64{ids.athleteA, ids.athB}
|
||
targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit+1-len(targets))...)
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch",
|
||
map[string]any{"action": "disable", "ids": targets}, true)
|
||
if status != http.StatusBadRequest {
|
||
t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if env2.OK || env2.Error == nil {
|
||
t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2)
|
||
}
|
||
want := fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", batchLimit)
|
||
if env2.Error.Message != want {
|
||
t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message)
|
||
}
|
||
|
||
// 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。
|
||
// 对照用「ids 不能为空」(detail 为 nil),与超限同为「只给一句话」的校验。
|
||
refStatus, ref := env.do(client, http.MethodPost, "/api/athletes/batch",
|
||
map[string]any{"action": "disable", "ids": []int64{}}, true)
|
||
if refStatus != http.StatusBadRequest || ref.Error == nil {
|
||
t.Fatalf("对照组(ids 为空)应 400,得到 %d %+v", refStatus, ref)
|
||
}
|
||
if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code ||
|
||
(env2.Error.Detail == nil) != (ref.Error.Detail == nil) {
|
||
t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error)
|
||
}
|
||
|
||
// 先校验、后动作:被拒的请求一个人都不能停用。
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 0 {
|
||
t.Fatalf("超限被拒后不该停用任何人,得到 %d 人", n)
|
||
}
|
||
}
|
||
|
||
// 正好等于上限必须放行:边界是「超过」才拒,500 条一次全部停用。
|
||
func TestBatchAllowsExactlyAtLimit(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
targets := []int64{ids.athleteA, ids.athB}
|
||
targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit-len(targets))...)
|
||
|
||
result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets})
|
||
if result.Requested != batchLimit || result.Succeeded != batchLimit || result.Failed != 0 {
|
||
t.Fatalf("正好 %d 条应全部成功,得到 %+v", batchLimit, result)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != batchLimit {
|
||
t.Fatalf("应停用 %d 人,得到 %d", batchLimit, n)
|
||
}
|
||
}
|
||
|
||
// seedAthleteBatch 建 count 个运动员并返回其 id,供「正好等于上限」这类要凑够条数的用例使用。
|
||
// 整批放在一个事务里:单连接库上逐条提交会把用例拖成几百次提交。
|
||
func seedAthleteBatch(t *testing.T, env *testEnv, teamID int64, count int) []int64 {
|
||
t.Helper()
|
||
tx, err := env.ev.Begin()
|
||
if err != nil {
|
||
t.Fatalf("开事务: %v", err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
out := make([]int64, 0, count)
|
||
for i := 0; i < count; i++ {
|
||
res, err := tx.Exec(`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
|
||
fmt.Sprintf("批量%03d", i), teamID)
|
||
if err != nil {
|
||
t.Fatalf("建运动员: %v", err)
|
||
}
|
||
id, err := res.LastInsertId()
|
||
if err != nil {
|
||
t.Fatalf("取运动员 id: %v", err)
|
||
}
|
||
out = append(out, id)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
t.Fatalf("提交: %v", err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 未登录的写操作一律 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)
|
||
}
|
||
}
|