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

329 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"
"strings"
"testing"
)
// resultLedger 统计成绩分流水(未作废)的条数与合计。
func (e *testEnv) resultLedger(t *testing.T) (int, float64) {
t.Helper()
var (
count int
total float64
)
if err := e.ev.QueryRow(`SELECT COUNT(*), COALESCE(SUM(points), 0) FROM score_ledger
WHERE voided = 0 AND source_type = 'result'`).Scan(&count, &total); err != nil {
t.Fatalf("统计成绩流水: %v", err)
}
return count, total
}
// 聚合通道的流水 entry_id 为空,按组别重算若只靠 entry_id 定位就作废不掉旧流水,
// 重算再插一份导致分数翻倍。整项发布后反复按组别发布,分数必须保持不变。
func TestRecomputeAggregateLedgerIsIdempotent(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 换成聚合通道:按单位求和后排名,流水不对应单条报名项。
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
env.exec(t, `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'aggregate', ?, 0)`, ids.event, teamProfile)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
publish := func(name string, categoryID *int64) {
t.Helper()
body := map[string]any{"eventId": ids.event}
if categoryID != nil {
body["categoryId"] = *categoryID
}
status, env2 := env.do(client, http.MethodPost, "/api/results/publish", body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("%s应 200,得到 %d %+v", name, status, env2.Error)
}
// 聚合通道两单位各一条流水:15 + 10。
if count, total := env.resultLedger(t); count != 2 || total != 25 {
t.Fatalf("%s后成绩流水应仍为 2 条 25 分(不得翻倍),得到 %d 条 %v 分", name, count, total)
}
}
catID := ids.category
publish("整项发布", nil)
publish("按组别发布", &catID)
publish("再次按组别发布", &catID)
}
// 整体替换通道会 DELETE + INSERT 生成新的通道 id,旧聚合流水的 source_id 随之失效。
// 若作废范围仍按 source_id IN (本小项通道) 定位,旧流水作废不掉、重算再插一份 → 分数翻倍。
func TestRecomputeAggregateLedgerSurvivesChannelRebuild(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
env.exec(t, `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'aggregate', ?, 0)`, ids.event, teamProfile)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": ids.event}, true); status != http.StatusOK {
t.Fatalf("整项发布应 200,得到 %d %+v", status, env2.Error)
}
// 聚合通道两单位各一条流水:15 + 10。
if count, total := env.resultLedger(t); count != 2 || total != 25 {
t.Fatalf("整项发布后成绩流水应为 2 条 25 分,得到 %d 条 %v 分", count, total)
}
// 走真实接口整体替换通道,模拟运维改配置(通道 id 会被重建)。
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
map[string]any{"channels": []map[string]any{
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 0},
}}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("替换通道应 200,得到 %d %+v", status, env2.Error)
}
catID := ids.category
status, env2 = env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": ids.event, "categoryId": catID}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("按组别重算应 200,得到 %d %+v", status, env2.Error)
}
if count, total := env.resultLedger(t); count != 2 || total != 25 {
t.Fatalf("改通道后按组别重算不得翻倍,应仍为 2 条 25 分,得到 %d 条 %v 分", count, total)
}
}
// 作废一条成绩会改变同组别名次,重算结果必须回写 results.rank,
// 否则奖牌榜(读 rank)与积分榜(读流水)互相矛盾。
func TestVoidResultRewritesRemainingRanks(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
var winnerID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&winnerID); err != nil {
t.Fatalf("读取成绩 id: %v", err)
}
status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/void", winnerID),
map[string]any{"reason": "抢跑"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("作废成绩应 200,得到 %d %+v", status, env2.Error)
}
var rank int
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rank); err != nil {
t.Fatalf("读取名次: %v", err)
}
if rank != 1 {
t.Fatalf("作废冠军后剩余成绩名次应回写为 1,得到 %d", rank)
}
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
standings := decodeData[[]map[string]any](t, env2)
for _, row := range standings {
if row["teamCode"] == "B" {
if row["totalPoints"].(float64) != 10 || row["gold"].(float64) != 1 {
t.Fatalf("作废后 B 应升为第一(10 分 1 金): %+v", row)
}
}
}
}
// eventStatus 读小项进度(events.status)。
func eventStatus(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
var s string
if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, eventID).Scan(&s); err != nil {
t.Fatalf("读取小项状态: %v", err)
}
return s
}
// A07:result_revisions.ledger_ids 没有任何读取侧(变更历史只读 snapshot / action / message),
// 写入侧与它脱钩之后,列被迁移删掉也必须不影响发布记录修订——否则删列一上线就发不出成绩。
func TestPublishRevisionWriteDoesNotNeedLedgerIdsColumn(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.exec(t, `ALTER TABLE result_revisions DROP COLUMN ledger_ids`)
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("删掉 ledger_ids 列后发布仍应 200,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM result_revisions WHERE event_id = ?`, 1, ids.event)
}
// A14:只有成绩文本的草稿按 publishableResultCond 永远发不出去,而接口照回 200。
// 发布必须把「跳过几条」写进本次修订(变更历史直接显示 message),静默漏成绩才算被打破。
func TestPublishReportsTextOnlyDrafts(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第三条报名项连成绩都没录:它是正常的「还没跑」,不能被算进跳过的条数里。
athlete := 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, athlete)
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
// 客户端只送了文本、没算出成绩值(resultValue 缺失)。
{"entryId": ids.entryB, "resultText": "11.92", "unit": "s", "status": "ok"},
{"entryId": entryC, "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
}
// 只有文本的那条留在 draft,有值的那条上屏。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'published'`, 1, ids.entryA)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'draft'`, 1, ids.entryB)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读变更列表应 200,得到 %d %+v", status, env2.Error)
}
items := decodeData[map[string]any](t, env2)["items"].([]any)
if len(items) != 1 {
t.Fatalf("发布后应有 1 条修订,得到 %d", len(items))
}
message, _ := items[0].(map[string]any)["message"].(string)
if !strings.Contains(message, "跳过 1 条") {
t.Fatalf("修订应写明跳过 1 条无值草稿(空行不算),得到 %q", message)
}
}
// A15:录入页永远带 categoryId,按组别发布是常规路径。小项进度(events.status)
// 不能在只发完一组时就写 published,也不能在整项都发完后还停在原状态——
// 后者会让界面一直显示「待录入」、侧栏与仪表盘的已发布计数永远漏掉这一项。
func TestCategoryPublishAdvancesEventStatusWhenDone(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第二个组别 + 两条报名项:整项共有 4 条该出成绩的报名项。
cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
athD := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵敏', '女', ?, 1)`, ids.teamB)
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamA, athC)
entryD := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamB, athD)
save := func(categoryID int64, rows []map[string]any) {
t.Helper()
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": categoryID, "results": rows,
}, true); status != http.StatusOK {
t.Fatalf("组别 %d 存草稿应 200,得到 %d %+v", categoryID, status, e.Error)
}
}
publish := func(categoryID int64) {
t.Helper()
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": categoryID,
}, true); status != http.StatusOK {
t.Fatalf("组别 %d 发布应 200,得到 %d %+v", categoryID, status, e.Error)
}
}
save(ids.category, []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"},
})
save(cat2, []map[string]any{
{"entryId": entryC, "resultText": "12.30", "resultValue": 12.30, "unit": "s", "status": "ok"},
{"entryId": entryD, "resultText": "12.44", "resultValue": 12.44, "unit": "s", "status": "ok"},
})
publish(ids.category)
if got := eventStatus(t, env, ids.event); got != "ongoing" {
t.Fatalf("只发完一个组不得把小项标成已发布,得到 %q", got)
}
publish(cat2)
if got := eventStatus(t, env, ids.event); got != "published" {
t.Fatalf("整项所有组都发完后小项应标成 published,得到 %q", got)
}
if status, e := env.do(client, http.MethodPost, "/api/results/unpublish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK {
t.Fatalf("撤销一个组应 200,得到 %d %+v", status, e.Error)
}
if got := eventStatus(t, env, ids.event); got != "finished" {
t.Fatalf("撤回一个组后小项不该再是已发布,得到 %q", got)
}
}