Files
Scoreboard/server/migrations/event/migration_legacy_test.go
T
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00

308 lines
14 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~0021 十条,
// 其中 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~0021 全部十条(含只加一列的 0018/0019/0021)。
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)
}
// 0021 的 note 是老库上不存在的列,老登记(这条是 approved 的破纪录)升级后必须是 NULL:
// 历史行没有驳回理由,NULL 才是事实,写成空串或默认文案都会让审核清单说谎。
var breakNote sql.NullString
if err := db.QueryRow(`SELECT note FROM record_breaks WHERE id = 1`).Scan(&breakNote); err != nil {
t.Fatalf("读破纪录登记的 note: %v", err)
}
if breakNote.Valid {
t.Errorf("老破纪录登记的 note 应为 NULL,得到 %q", breakNote.String)
}
// ④ 新表建出来了,且是空的(升级不该凭空造名次)。
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)
}
// 0021 的 note 也要能写能清:列建了却写不进去,等于没升。
if _, err := db.Exec(`UPDATE record_breaks SET state = 'rejected', note = '录像复核未破' WHERE id = 1`); err != nil {
t.Fatalf("写 0021 的驳回理由: %v", err)
}
if _, err := db.Exec(`UPDATE record_breaks SET state = 'approved', note = NULL WHERE id = 1`); err != nil {
t.Fatalf("清 0021 的驳回理由: %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)
}
}
}