## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
292 lines
13 KiB
Go
292 lines
13 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|