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) } }