## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `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 例。
308 lines
14 KiB
Go
308 lines
14 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|