Files
Scoreboard/server/migrations/event/migration_test.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

882 lines
34 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"
"io/fs"
"path/filepath"
"sort"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
// 迁移用例:0016 给 events.event_type 增加 individual_team(writable_schema 就地改写建表语句),
// 0017 给 score_ledger / event_score_channels 加 count_to_team、给 events 加 avg_mode。
// 两个用例都守住同一条底线:老库升级后数据一行不少、新定义与默认值生效。
func openEventDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "event.db")
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
// 与 store.Open 一致:单连接。迁移改的是连接内的 schema 缓存,必须在这个前提下验证。
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
return db
}
// applyBefore 执行给定迁移之前的全部迁移并登记到 schema_migrations,
// 模拟老库在升级前的状态(store.MigrateEvent 只能补跑未登记的迁移)。
func applyBefore(t *testing.T, db *sql.DB, name string) {
t.Helper()
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
t.Fatalf("创建 schema_migrations: %v", err)
}
fsys, err := fs.Sub(migrations.EventFS, "event")
if err != nil {
t.Fatalf("定位迁移目录: %v", err)
}
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
t.Fatalf("列出迁移: %v", err)
}
sort.Strings(names)
for _, n := range names {
if n >= name {
continue
}
body, err := fs.ReadFile(fsys, n)
if err != nil {
t.Fatalf("读取迁移 %s: %v", n, err)
}
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("应用迁移 %s: %v", n, err)
}
// 与 store.migrate 一样登记,升级时才能只补跑后面的迁移。
if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil {
t.Fatalf("登记迁移 %s: %v", n, err)
}
}
}
// migrationCount 是嵌入的赛事库迁移条数:升级后登记数必须与文件数一致,
// 新增迁移时这条断言不用改。
func migrationCount(t *testing.T) int {
t.Helper()
fsys, err := fs.Sub(migrations.EventFS, "event")
if err != nil {
t.Fatalf("定位迁移目录: %v", err)
}
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
t.Fatalf("列出迁移: %v", err)
}
return len(names)
}
func countRows(t *testing.T, db *sql.DB, table string) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
t.Fatalf("统计 %s: %v", table, err)
}
return n
}
// 全量迁移(run 两次验幂等)后,events 应接受 individual_team、拒绝未知取值。
func TestMigrateEventAcceptsIndividualTeam(t *testing.T) {
db := openEventDB(t)
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, err)
}
}
if _, err := db.Exec(`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`); err != nil {
t.Fatalf("插入大项: %v", err)
}
// 原有三种类型与新增类型都要放行。
for _, ok := range []string{"individual", "team", "match", "individual_team"} {
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, ?, '合法', ?)`,
"OK_"+ok, ok); err != nil {
t.Errorf("event_type %q 应被接受: %v", ok, err)
}
}
for _, bad := range []string{"", "bogus", "Individual_Team"} {
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, ?, '非法', ?)`,
"BAD_"+bad, bad); err == nil {
t.Errorf("非法 event_type %q 应被拒绝", bad)
}
}
}
// 老库升级:升级前的数据一行都不能少,外键与索引保持有效,新类型可用。
func TestMigration0016PreservesExistingRows(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0016")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, venue, remark)
VALUES (1, 'E1', '100米跑', 'individual', '田径场', '备注')`,
`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E2', '团体引体向上', 'team')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
`INSERT INTO records(event_id, level, holder_name) VALUES (1, 'meet', '张三')`,
`INSERT INTO record_breaks(event_id, entry_id, level) VALUES (1, 1, 'meet')`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO point_adjustments(team_id, delta, category, reason, event_id)
VALUES (1, 5, 'bonus', '测试', 1)`,
`INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json)
VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 走真实升级路径:store.MigrateEvent 只补跑未登记的 0016(及其后的迁移)。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级迁移: %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{
"events": 2, "event_categories": 1, "event_score_channels": 1, "entries": 1,
"results": 1, "records": 1, "record_breaks": 1, "score_ledger": 1,
"point_adjustments": 1, "result_revisions": 2,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
var (
id int64
code string
et string
venue sql.NullString
remark sql.NullString
)
if err := db.QueryRow(`SELECT id, code, event_type, venue, remark FROM events WHERE id = 1`).
Scan(&id, &code, &et, &venue, &remark); err != nil {
t.Fatalf("读 events: %v", err)
}
if id != 1 || code != "E1" || et != "individual" || !venue.Valid || !remark.Valid {
t.Errorf("events 未原样保留: id=%d code=%q type=%q venue=%v remark=%v", id, code, et, venue, remark)
}
if got := countRows(t, db, "events"); got != 2 {
t.Errorf("events 行数 = %d,期望 2", got)
}
// 被 SET NULL / CASCADE 波及的两列必须回填。
var linked int
if err := db.QueryRow(`SELECT count(*) FROM score_ledger WHERE event_id = 1`).Scan(&linked); err != nil {
t.Fatalf("查 score_ledger: %v", err)
}
if linked != 1 {
t.Errorf("score_ledger.event_id 未回填,命中 %d 行", linked)
}
if err := db.QueryRow(`SELECT count(*) FROM point_adjustments WHERE event_id = 1`).Scan(&linked); err != nil {
t.Fatalf("查 point_adjustments: %v", err)
}
if linked != 1 {
t.Errorf("point_adjustments.event_id 未回填,命中 %d 行", linked)
}
if err := db.QueryRow(`SELECT count(*) FROM result_revisions WHERE parent_id = 1`).Scan(&linked); err != nil {
t.Fatalf("查 result_revisions: %v", err)
}
if linked != 1 {
t.Errorf("result_revisions 父子关系未保留,子行 %d", linked)
}
// 迁移后的连接(单连接池)必须按新定义校验;RESET 没生效时这里会失败。
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type)
VALUES (1, 'E3', '双计项', 'individual_team')`); err != nil {
t.Errorf("迁移后 individual_team 应被接受: %v", err)
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type)
VALUES (1, 'E4', '非法', 'bogus')`); err == nil {
t.Error("迁移后非法 event_type 应被拒绝")
}
// 自增序列延续,避免后续插入撞号。
var newID int64
if err := db.QueryRow(`SELECT id FROM events WHERE code = 'E3'`).Scan(&newID); err != nil {
t.Fatalf("读新项目: %v", err)
}
if newID <= 2 {
t.Errorf("events 自增序列未延续,新 id = %d", newID)
}
// 索引随建表语句一起被保留(writable_schema 不改索引)。
var indexCount int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
('idx_events_sport_id', 'idx_events_status')`).Scan(&indexCount); err != nil {
t.Fatalf("查索引: %v", err)
}
if indexCount != 2 {
t.Errorf("events 索引缺失,命中 %d 个", indexCount)
}
// 子表外键仍然生效。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id) VALUES (999, 1, 1)`); err == nil {
t.Error("entries.event_id 外键应仍然生效")
}
rows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = rows.Close() }()
if rows.Next() {
t.Error("迁移后存在外键悬挂")
}
}
// ---------------------------------------------------------------------------
// 0017 迁移:score_ledger 加 count_to_team(重建表)、event_score_channels 加同名字段、
// events 加 avg_mode。本文件的用例守住「老数据不丢 + 默认值正确 + 新 CHECK 生效」。
// ---------------------------------------------------------------------------
// 全量迁移(run 两次验幂等)后,三张表都应按新定义读写。
func TestMigrate0017AppliesCountToTeamAndAvgMode(t *testing.T) {
db := openEventDB(t)
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, err)
}
}
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E1', '团体引体向上', 'individual_team')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 未指定时取默认值:通道计入单位总分、平均值口径只算有效成绩。
var (
countToTeam int
avgMode string
)
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)
}
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
t.Fatalf("读小项: %v", err)
}
if avgMode != "valid" {
t.Errorf("avg_mode 默认值 = %q,期望 valid", avgMode)
}
// 显式写:个人通道不计入单位总分。
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("改通道开关: %v", err)
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode)
VALUES (1, 'E2', '团体引体向上', 'individual_team', 'all')`); err != nil {
t.Fatalf("写入 avg_mode=all: %v", err)
}
// 非法取值被 CHECK 挡住。
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 2 WHERE event_id = 1`); err == nil {
t.Error("count_to_team=2 应被拒绝")
}
if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil {
t.Error("avg_mode='bogus' 应被拒绝")
}
// 重建 score_ledger 后:默认计入、单条可置 0、CHECK 生效。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
VALUES (1, 1, 'result', 1, 10)`); err != nil {
t.Fatalf("写入流水: %v", err)
}
var ledgerCount int
if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE id = 1`).Scan(&ledgerCount); err != nil {
t.Fatalf("读流水: %v", err)
}
if ledgerCount != 1 {
t.Errorf("流水 count_to_team 默认值 = %d,期望 1", ledgerCount)
}
if _, err := db.Exec(`UPDATE score_ledger SET count_to_team = 2 WHERE id = 1`); err == nil {
t.Error("流水 count_to_team=2 应被拒绝")
}
}
// 老库升级:0016 之前的赛事库补跑 0016 + 0017,行数与索引一个不少,新列取默认值。
func TestMigration0017PreservesExistingRows(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0017")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
`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, void_reason)
VALUES (1, 'adjustment', 1, -3, '人工扣分')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 走真实升级路径:只补跑未登记的 0017 及其后的迁移。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级迁移: %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{
"events": 1, "event_categories": 1, "event_score_channels": 1, "entries": 1,
"results": 1, "score_ledger": 2,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
// 历史流水原样保留(含分值、作废原因),新列一律为「计入单位总分」。
rows, err := db.Query(`SELECT id, points, count_to_team, COALESCE(void_reason, '') FROM score_ledger ORDER BY id`)
if err != nil {
t.Fatalf("读流水: %v", err)
}
defer func() { _ = rows.Close() }()
wantPoints := []float64{10, -3}
i := 0
for rows.Next() {
var (
id int64
points float64
countToTeam int
reason string
)
if err := rows.Scan(&id, &points, &countToTeam, &reason); err != nil {
t.Fatalf("扫描流水: %v", err)
}
if i >= len(wantPoints) || points != wantPoints[i] {
t.Fatalf("第 %d 条流水分值 = %v,期望 %v", i, points, wantPoints[i])
}
if countToTeam != 1 {
t.Errorf("第 %d 条流水 count_to_team = %d,期望 1", i, countToTeam)
}
i++
}
if i != len(wantPoints) {
t.Fatalf("流水条数 = %d,期望 %d", i, len(wantPoints))
}
// 自增序列延续,避免重建后新流水撞号。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (1, 'adjustment', 9, 2)`); err != nil {
t.Fatalf("重建后写入流水: %v", err)
}
var newLedgerID int64
if err := db.QueryRow(`SELECT max(id) FROM score_ledger`).Scan(&newLedgerID); err != nil {
t.Fatalf("读新流水 id: %v", err)
}
if newLedgerID <= int64(len(wantPoints)) {
t.Errorf("score_ledger 自增序列未延续,新 id = %d", newLedgerID)
}
// 通道与旧小项取默认值。
var countToTeam int
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 avgMode string
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
t.Fatalf("读小项: %v", err)
}
if avgMode != "valid" {
t.Errorf("老小项 avg_mode = %q,期望 valid", avgMode)
}
// 重建表后索引一个不少(0012 补的 (entry_id, voided) 也在内)。
var indexCount int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
('idx_score_ledger_team_voided', 'idx_score_ledger_source',
'idx_score_ledger_athlete', 'idx_score_ledger_entry_voided')`).Scan(&indexCount); err != nil {
t.Fatalf("查索引: %v", err)
}
if indexCount != 4 {
t.Errorf("score_ledger 索引缺失,命中 %d 个", indexCount)
}
// 外键仍然生效,且没有悬挂。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (999, 'adjustment', 1, 1)`); err == nil {
t.Error("重建后 score_ledger.team_id 外键应仍然生效")
}
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("迁移后存在外键悬挂")
}
}
// 老库升级:0018 只给 teams 补一列 deleted_at,已有单位与引用它的数据一行不少、默认「未删除」。
func TestMigration0018AddsDeletedAtForExistingTeams(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0018")
stmts := []string{
`INSERT INTO teams(code, name, active) VALUES ('T1', '第一分公司', 0)`,
// 单位被停用(active = 0)是老库里的状态,升级后不能跟「彻底删除」混为一谈。
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
var probe sql.NullString
if err := db.QueryRow(`SELECT deleted_at FROM teams`).Scan(&probe); err == nil {
t.Fatal("升级前不该有 deleted_at 列")
}
// 走真实升级路径;迁移只登记一次,ALTER TABLE ADD COLUMN 不能重放(重放会报 duplicate column)。
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, err)
}
}
if got := countRows(t, db, "teams"); got != 1 {
t.Fatalf("升级后单位行数 = %d,期望 1", got)
}
if got := countRows(t, db, "athletes"); got != 1 {
t.Fatalf("升级后人员行数 = %d,期望 1", got)
}
if got := countRows(t, db, "entries"); got != 1 {
t.Fatalf("升级后报名行数 = %d,期望 1", got)
}
var (
name string
active int
deletedAt sql.NullString
)
if err := db.QueryRow(`SELECT name, active, deleted_at FROM teams WHERE id = 1`).
Scan(&name, &active, &deletedAt); err != nil {
t.Fatalf("读单位: %v", err)
}
if name != "第一分公司" || active != 0 {
t.Errorf("老单位字段被改动: name=%q active=%d", name, active)
}
if deletedAt.Valid {
t.Errorf("老单位的 deleted_at 应为空(未删除),得到 %q", deletedAt.String)
}
// 新列可写可清,列表过滤与导入复活都靠它。
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(`UPDATE teams SET deleted_at = NULL WHERE id = 1`); err != nil {
t.Fatalf("清 deleted_at: %v", err)
}
}
// 老库升级:0019 只给 entries 补一列 heat(可空),已有报名一行不少、批次一律为空(未分批)。
// 批次只是录入 / 展示分组,迁移不能改动任何成绩或积分数据。
func TestMigration0019AddsHeatForExistingEntries(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0019")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
`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)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
var probe sql.NullInt64
if err := db.QueryRow(`SELECT heat FROM entries`).Scan(&probe); err == nil {
t.Fatal("升级前不该有 heat 列")
}
// 走真实升级路径;ALTER TABLE ADD COLUMN 只登记一次,重放会报 duplicate column。
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, 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{
"entries": 1, "results": 1, "score_ledger": 1,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
// 老报名一律「未分批」,成绩与积分原样保留。
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if probe.Valid {
t.Errorf("老报名的 heat 应为空(未分批),得到 %d", probe.Int64)
}
var (
rank int
state string
)
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
t.Fatalf("读成绩: %v", err)
}
if rank != 1 || state != "published" {
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
}
var points float64
if err := db.QueryRow(`SELECT points FROM score_ledger WHERE entry_id = 1`).Scan(&points); err != nil {
t.Fatalf("读流水: %v", err)
}
if points != 10 {
t.Errorf("老积分被改动: %v", points)
}
// 新列可写可清。
if _, err := db.Exec(`UPDATE entries SET heat = 3 WHERE id = 1`); err != nil {
t.Fatalf("写 heat: %v", err)
}
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if !probe.Valid || probe.Int64 != 3 {
t.Errorf("写入的 heat 未生效: %+v", probe)
}
if _, err := db.Exec(`UPDATE entries SET heat = NULL WHERE id = 1`); err != nil {
t.Fatalf("清 heat: %v", err)
}
}
// tableColumns 返回 PRAGMA table_info 的列名集合,用于比对迁移新建表的结构。
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
t.Fatalf("读表结构 %s: %v", table, err)
}
defer func() { _ = rows.Close() }()
cols := map[string]bool{}
for rows.Next() {
var (
cid int
name string
ctype string
notNull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &ctype, &notNull, &dflt, &pk); err != nil {
t.Fatalf("扫描表结构 %s: %v", table, err)
}
cols[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历表结构 %s: %v", table, err)
}
return cols
}
// extraColumns 返回实际列里多出来的部分,仅用于列数不符时的报错信息。
func extraColumns(got map[string]bool, want []string) []string {
known := make(map[string]bool, len(want))
for _, c := range want {
known[c] = true
}
var extra []string
for c := range got {
if !known[c] {
extra = append(extra, c)
}
}
sort.Strings(extra)
return extra
}
// ---------------------------------------------------------------------------
// 0020 迁移:events 加 award_medals,event_score_channels 加 count_to_medals /
// count_to_team_medals(三列默认 1,与升级前行为一致),新建 channel_ranks 与
// channel_manual_ranks。本用例守住「老数据不丢 + 默认值正确 + 新表结构 / 唯一约束 /
// CHECK 生效 + 迁移幂等」这几条底线。
// ---------------------------------------------------------------------------
// 老库升级:applyBefore 构造 0020 之前的老库,塞入老数据后走真实升级路径补跑 0020。
func TestMigration0020ChannelRanksAndMedals(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0020")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 升级前不该有 0020 的列与表。
var probe sql.NullInt64
if err := db.QueryRow(`SELECT award_medals FROM events`).Scan(&probe); err == nil {
t.Fatal("升级前不该有 events.award_medals 列")
}
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)
}
}
// 走真实升级路径;ADD COLUMN 只登记一次,重放会报 duplicate column。
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, 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{"events": 1, "event_score_channels": 1, "results": 1} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
var (
code string
status string
rank int
state string
channel int
)
if err := db.QueryRow(`SELECT code, status FROM events WHERE id = 1`).Scan(&code, &status); err != nil {
t.Fatalf("读 events: %v", err)
}
if code != "E1" || status != "published" {
t.Errorf("老小项被改动: code=%q status=%q", code, status)
}
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
t.Fatalf("读成绩: %v", err)
}
if rank != 1 || state != "published" {
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
}
var scope, basis string
if err := db.QueryRow(`SELECT id, scope, basis FROM event_score_channels WHERE event_id = 1`).
Scan(&channel, &scope, &basis); err != nil {
t.Fatalf("读通道: %v", err)
}
if scope != "athlete" || basis != "result" {
t.Errorf("老通道被改动: scope=%q basis=%q", scope, basis)
}
// 三个新列在老行上取默认值 1(与升级前「算奖牌、计入总分与单位奖牌」一致)。
var awardMedals int
if err := db.QueryRow(`SELECT award_medals FROM events WHERE id = 1`).Scan(&awardMedals); err != nil {
t.Fatalf("读 events.award_medals: %v", err)
}
if awardMedals != 1 {
t.Errorf("老小项 award_medals = %d,期望 1", awardMedals)
}
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("读 event_score_channels 奖牌开关: %v", err)
}
if countToMedals != 1 || countToTeamMedals != 1 {
t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals)
}
// 新列可显式置 0;CHECK 拒绝 0/1 之外的取值。
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
t.Fatalf("写 award_medals=0: %v", err)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0, count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("写通道奖牌开关 0: %v", err)
}
if _, err := db.Exec(`UPDATE events SET award_medals = 2 WHERE id = 1`); err == nil {
t.Error("award_medals=2 应被 CHECK 拒绝")
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, award_medals)
VALUES (1, 'E2', '非法开关', 'individual', 2)`); err == nil {
t.Error("插入 award_medals=2 应被 CHECK 拒绝")
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 2 WHERE event_id = 1`); err == nil {
t.Error("count_to_medals=2 应被 CHECK 拒绝")
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 2 WHERE event_id = 1`); err == nil {
t.Error("count_to_team_medals=2 应被 CHECK 拒绝")
}
// 两张新表存在,列集合与迁移文件一致(少列、多列都会在这里暴露)。
for _, tc := range []struct {
table string
want []string
}{
{"channel_ranks", []string{
"id", "event_id", "category_id", "channel_id", "basis", "scope", "subject_key",
"entry_id", "result_id", "athlete_id", "team_id", "rank", "points",
"result_value", "status", "updated_at",
}},
{"channel_manual_ranks", []string{
"id", "event_id", "category_id", "team_id", "scope", "basis", "rank",
"updated_by", "updated_at",
}},
} {
got := tableColumns(t, db, tc.table)
if len(got) == 0 {
t.Errorf("%s 表不存在", tc.table)
continue
}
for _, col := range tc.want {
if !got[col] {
t.Errorf("%s 缺列 %s", tc.table, col)
}
}
if len(got) != len(tc.want) {
t.Errorf("%s 列数 = %d,期望 %d(多余列: %v)", tc.table, len(got), len(tc.want), extraColumns(got, tc.want))
}
}
// channel_ranks 的唯一索引:同一通道 / 组别 / 主体只能一行。
var indexCount int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_channel_ranks_unique'`).Scan(&indexCount); err != nil {
t.Fatalf("查唯一索引: %v", err)
}
if indexCount != 1 {
t.Errorf("channel_ranks 唯一索引 idx_channel_ranks_unique 缺失")
}
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("写入 channel_ranks: %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, 2, 8)`); err == nil {
t.Error("channel_ranks 重复 (channel_id, category_id, subject_key) 应被唯一索引拒绝")
}
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:2', 1, 1, 2, 8)`); err != nil {
t.Errorf("channel_ranks 不同 subject_key 应可写入: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
VALUES (1, 1, 1, 'result', 'bogus', 'x:1', 1)`); err == nil {
t.Error("channel_ranks.scope='bogus' 应被 CHECK 拒绝")
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
VALUES (1, 1, 1, 'result', 'athlete', 'x:1', 0)`); err == nil {
t.Error("channel_ranks.rank=0 应被 CHECK 拒绝")
}
// channel_manual_ranks 的 UNIQUE(event_id, category_id, team_id, scope, basis)。
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual', 1)`); err != nil {
t.Fatalf("写入 channel_manual_ranks: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual', 2)`); err == nil {
t.Error("channel_manual_ranks 重复 (event_id, category_id, team_id, scope, basis) 应被唯一约束拒绝")
}
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual_alt', 2)`); err != nil {
t.Errorf("channel_manual_ranks 不同 basis 应可写入: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual0', 0)`); err == nil {
t.Error("channel_manual_ranks.rank=0 应被 CHECK 拒绝")
}
// 迁移可重复执行:再跑一次 MigrateEvent 不报错、新表与老表行数不变。
countsBefore := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复迁移: %v", err)
}
countsAfter := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
for i, name := range []string{"channel_ranks", "channel_manual_ranks", "events"} {
if countsBefore[i] != countsAfter[i] {
t.Errorf("重复迁移 %s 行数 %d → %d", name, countsBefore[i], countsAfter[i])
}
}
}