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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

@@ -1,4 +1,6 @@
-- 成绩版本图谱:parent_id 形成 DAG,撤销后从撤销点分支,仅用于可视化。
-- 成绩版本图谱:parent_id 指向上一条版本,仅用于可视化。
-- ⚠️ 不是 DAG:写入侧(httpapi.recordRevision)恒取「同小项当前 MAX(id)」,所以一个小项一条链、
-- 撤销后再发布也接在链尾,不会从撤销点分出分支;每个小项的第一条版本 parent_id 为 NULL。
CREATE TABLE IF NOT EXISTS result_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES result_revisions(id),
@@ -0,0 +1,291 @@
package event
import (
"database/sql"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// C12:0001–0011 的老库一次性升到最新。
//
// 此前每个迁移用例都是 applyBefore 到「目标那一条的前一条」,覆盖的是相邻一两跳;
// 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0020 九条,
// 其中 0016 会就地改写 events 的建表语句、0017 会重建 score_ledger——这些步骤正是
// 「只在老库上才走得到」的路径。这条用例把这条路整条走一遍:
// 数据一行不少、新列取文档写明的默认值、新表建出来、升级后新能力可用、再跑一次也不报错。
func TestMigration0001ToLatestKeepsData(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0012") // 只应用 0001–0011,回到「很久没升级过」的现场
// 老库数据:只用 0011 之前存在的列(id_card / bib / age / deleted_at / heat / avg_mode 等都还不存在)。
stmts := []string{
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
VALUES ('T1', '第一分公司', '一司', '#FF0000', 3, 0)`,
`INSERT INTO athletes(name, gender, team_id, employee_no, active) VALUES ('张三', '男', 1, 'E001', 1)`,
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 5, 1)`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, sort_order, venue, remark)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'published', 7, '田径场', '备注')`,
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (1, '男子', 3, '男', 18, 35, 9)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) VALUES (1, 'athlete', 'result', 1, 0)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, is_tie, status, state)
VALUES (1, 1, 1, '11.58', 11.58, 0, 'ok', 'published')`,
`INSERT INTO records(event_id, level, result_value, holder_name, is_current, source)
VALUES (1, 'meet', 11.2, '李四', 1, 'history')`,
`INSERT INTO record_breaks(event_id, entry_id, level, result_value, bonus_points, state)
VALUES (1, 1, 'meet', 11.2, 5, 'approved')`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO score_ledger(team_id, source_type, source_id, points, voided, void_reason)
VALUES (1, 'adjustment', 1, -3, 1, '人工扣分')`,
`INSERT INTO point_adjustments(team_id, delta, category, reason, event_id, voided)
VALUES (1, 5, 'bonus', '测试奖励', 1, 0)`,
`INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json)
VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`,
`INSERT INTO change_log(entity_type, entity_id, action, payload_json) VALUES ('team', 1, 'upsert', '{}')`,
`INSERT INTO audit_logs(user_id, action, entity_type, entity_id, ip, request_id)
VALUES (1, 'team.create', 'team', 1, '127.0.0.1', 'req-1')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
// 升级前:新列与新表都不该在。
for _, col := range []string{"id_card", "bib", "age"} {
var probe sql.NullString
if err := db.QueryRow(`SELECT ` + col + ` FROM athletes`).Scan(&probe); err == nil {
t.Fatalf("升级前不该有 athletes.%s 列", col)
}
}
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&n); err != nil {
t.Fatalf("查表 %s: %v", table, err)
}
if n != 0 {
t.Fatalf("升级前不该有 %s 表", table)
}
}
// 走真实升级路径:一次补跑 0012~0020 全部九条。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("从 0001–0011 直接升到最新失败: %v", err)
}
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
// ① 数据一行不少、一行不多(迁移里没有隐式的删改)。
for table, want := range map[string]int{
"teams": 1, "athletes": 1, "sports": 1, "events": 1, "event_categories": 1,
"event_score_channels": 1, "entries": 1, "results": 1, "records": 1, "record_breaks": 1,
"score_ledger": 2, "point_adjustments": 1, "result_revisions": 2, "change_log": 1, "audit_logs": 1,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
// 项目里的积分模板由 0011 种下,升级不能把它冲掉(新库现场靠它算分)。
if got := countRows(t, db, "scoring_profiles"); got != 2 {
t.Errorf("scoring_profiles 行数 = %d,期望 2(0011 种下的两个模板)", got)
}
if got := countRows(t, db, "scoring_profile_rules"); got != 12 {
t.Errorf("scoring_profile_rules 行数 = %d,期望 12", got)
}
// ② 老行上的关键取值原样保留。
var (
teamName, teamColor string
teamActive, teamSortOrder int
)
if err := db.QueryRow(`SELECT name, COALESCE(color, ''), active, sort_order FROM teams WHERE id = 1`).
Scan(&teamName, &teamColor, &teamActive, &teamSortOrder); err != nil {
t.Fatalf("读单位: %v", err)
}
if teamName != "第一分公司" || teamColor != "#FF0000" || teamActive != 0 || teamSortOrder != 3 {
t.Errorf("老单位被改动: name=%q color=%q active=%d sort=%d", teamName, teamColor, teamActive, teamSortOrder)
}
var (
eventName, eventStatus, venue, remark string
eventMultiplier float64
eventPublished string
)
if err := db.QueryRow(`SELECT name, status, COALESCE(venue, ''), COALESCE(remark, ''), multiplier
FROM events WHERE id = 1`).
Scan(&eventName, &eventStatus, &venue, &remark, &eventMultiplier); err != nil {
t.Fatalf("读小项: %v", err)
}
eventPublished = eventStatus
if eventName != "100米跑" || eventPublished != "published" || venue != "田径场" ||
remark != "备注" || eventMultiplier != 2 {
t.Errorf("老小项被改动: name=%q status=%q venue=%q remark=%q multiplier=%v",
eventName, eventPublished, venue, remark, eventMultiplier)
}
var rank, resultState string
var resultValue float64
if err := db.QueryRow(`SELECT rank, state, result_value FROM results WHERE entry_id = 1`).
Scan(&rank, &resultState, &resultValue); err != nil {
t.Fatalf("读成绩: %v", err)
}
if rank != "1" || resultState != "published" || resultValue != 11.58 {
t.Errorf("老成绩被改动: rank=%s state=%q value=%v", rank, resultState, resultValue)
}
var ledgerPoints float64
var ledgerVoided int
if err := db.QueryRow(`SELECT points, voided FROM score_ledger WHERE source_type = 'adjustment'`).
Scan(&ledgerPoints, &ledgerVoided); err != nil {
t.Fatalf("读流水: %v", err)
}
if ledgerPoints != -3 || ledgerVoided != 1 {
t.Errorf("老流水被改动: points=%v voided=%d", ledgerPoints, ledgerVoided)
}
// 重建 score_ledger(0017)之后父子关系与自增序列都要正常。
var childParent sql.NullInt64
if err := db.QueryRow(`SELECT parent_id FROM result_revisions WHERE id = 2`).Scan(&childParent); err != nil {
t.Fatalf("读变更历史: %v", err)
}
if !childParent.Valid || childParent.Int64 != 1 {
t.Errorf("result_revisions 父子关系未保留: %+v", childParent)
}
// ③ 每个新列/新表都按文档取默认值:0013–0019 是可空列(老行必须为空),
// 0017 与 0020 的开关默认 1(与升级前「算分、算奖牌、计入总分」的行为一致)。
var (
idCard, bib sql.NullString
age sql.NullInt64
heat sql.NullInt64
)
if err := db.QueryRow(`SELECT id_card, bib, age FROM athletes WHERE id = 1`).
Scan(&idCard, &bib, &age); err != nil {
t.Fatalf("读人员新列: %v", err)
}
if idCard.Valid || bib.Valid || age.Valid {
t.Errorf("老人员的新列应全为空: idCard=%v bib=%v age=%v", idCard, bib, age)
}
var deletedAt sql.NullString
if err := db.QueryRow(`SELECT deleted_at FROM teams WHERE id = 1`).Scan(&deletedAt); err != nil {
t.Fatalf("读 deleted_at: %v", err)
}
if deletedAt.Valid {
t.Errorf("老单位的 deleted_at 应为空: %q", deletedAt.String)
}
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&heat); err != nil {
t.Fatalf("读 heat: %v", err)
}
if heat.Valid {
t.Errorf("老报名的 heat 应为空(未分批): %d", heat.Int64)
}
var (
avgMode string
eventAwardMedals, scored int
countToTeam int
)
if err := db.QueryRow(`SELECT avg_mode, award_medals, scored FROM events WHERE id = 1`).
Scan(&avgMode, &eventAwardMedals, &scored); err != nil {
t.Fatalf("读小项新列: %v", err)
}
if avgMode != "valid" || eventAwardMedals != 1 || scored != 1 {
t.Errorf("老小项的新开关不对: avg_mode=%q award_medals=%d scored=%d", avgMode, eventAwardMedals, scored)
}
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
t.Fatalf("读通道: %v", err)
}
if countToTeam != 1 {
t.Errorf("老通道 count_to_team = %d,期望 1", countToTeam)
}
var ledgerCountToTeam int
if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE source_type = 'result'`).Scan(&ledgerCountToTeam); err != nil {
t.Fatalf("读老流水: %v", err)
}
if ledgerCountToTeam != 1 {
t.Errorf("重建后的老流水 count_to_team = %d,期望 1", ledgerCountToTeam)
}
var countToMedals, countToTeamMedals int
if err := db.QueryRow(`SELECT count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = 1`).
Scan(&countToMedals, &countToTeamMedals); err != nil {
t.Fatalf("读通道奖牌开关: %v", err)
}
if countToMedals != 1 || countToTeamMedals != 1 {
t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals)
}
// ④ 新表建出来了,且是空的(升级不该凭空造名次)。
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
if got := countRows(t, db, table); got != 0 {
t.Errorf("%s 升级后应为空表,得到 %d 行", table, got)
}
}
// 0012 补的 (entry_id, voided) 索引与 0013–0015 的唯一索引都必须建在。
var indexCount int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
('idx_score_ledger_entry_voided', 'idx_athletes_id_card',
'idx_athletes_bib', 'idx_channel_ranks_unique')`).Scan(&indexCount); err != nil {
t.Fatalf("查索引: %v", err)
}
if indexCount != 4 {
t.Errorf("升级后索引缺失,命中 %d / 4", indexCount)
}
// ⑤ 外键与数据一致性没有悬挂(0016 改的是建表语句、0017 重建过表)。
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("跨九条迁移升级后存在外键悬挂")
}
var fkEnabled int
if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fkEnabled); err != nil {
t.Fatalf("读外键开关: %v", err)
}
if fkEnabled != 1 {
t.Error("升级后外键开关应仍然打开")
}
// ⑥ 升级后的库要能真的用上这批新能力(列建了但写不进去,等于没升)。
if _, err := db.Exec(`UPDATE athletes SET id_card = '110101199001011234', bib = 'A001', age = 30 WHERE id = 1`); err != nil {
t.Fatalf("写人员新列: %v", err)
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode, award_medals)
VALUES (1, 'E2', '双计项', 'individual_team', 'all', 0)`); err != nil {
t.Fatalf("写 0016/0017/0020 的新枚举与新开关: %v", err)
}
if _, err := db.Exec(`UPDATE entries SET heat = 2 WHERE id = 1`); err != nil {
t.Fatalf("写 heat: %v", err)
}
if _, err := db.Exec(`UPDATE teams SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
t.Fatalf("写 deleted_at: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope,
subject_key, entry_id, result_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil {
t.Fatalf("写 0020 新建的名次表: %v", err)
}
// 新 CHECK 也在:合法的拒绝了非法值才算真的生效。
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E3', '非法', 'bogus')`); err == nil {
t.Error("升级后非法 event_type 应被 CHECK 拒绝")
}
if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil {
t.Error("升级后非法 avg_mode 应被 CHECK 拒绝")
}
// ⑦ 再跑一次迁移:不报错,也不再改动任何数据。
countsBefore := map[string]int{}
for _, table := range []string{"teams", "athletes", "events", "score_ledger", "channel_ranks", "schema_migrations"} {
countsBefore[table] = countRows(t, db, table)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复迁移: %v", err)
}
for table, before := range countsBefore {
if got := countRows(t, db, table); got != before {
t.Errorf("重复迁移改动 %s 行数:%d → %d", table, before, got)
}
}
}