Files
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

335 lines
14 KiB
Go
Raw Permalink 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 (
"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)
}
}