Files
Scoreboard/server/migrations/event/migration_legacy_test.go
T
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

292 lines
13 KiB
Go
Raw 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 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)
}
}
}