## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `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 例。
118 lines
4.8 KiB
Go
118 lines
4.8 KiB
Go
package event
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
|
)
|
|
|
|
// 0021 只给 record_breaks 补一列 note(可空):老库升级后数据一行不丢、老登记取 NULL、
|
|
// 新列可写可清,且迁移只登记一次(ALTER TABLE ADD COLUMN 重放会报 duplicate column)。
|
|
//
|
|
// 这一列存在的意义是「驳回 / 作废这条登记的理由」:老库里没有理由,NULL 才是事实,
|
|
// 所以它不能有 NOT NULL、也不能有默认值(默认值会把历史行写成一条假理由)。
|
|
func TestMigration0021AddsRecordBreakNote(t *testing.T) {
|
|
db := openEventDB(t)
|
|
applyBefore(t, db, "0021")
|
|
|
|
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, record_eligible, status)
|
|
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 'published')`,
|
|
`INSERT INTO event_categories(event_id, name) VALUES (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, status, state)
|
|
VALUES (1, 1, 1, '11.58', 11.58, 'ok', 'published')`,
|
|
`INSERT INTO records(event_id, level, result_value, result_text, holder_name, is_current, source)
|
|
VALUES (1, 'meet', 11.20, '11.20', '李四', 1, 'history')`,
|
|
`INSERT INTO record_breaks(event_id, entry_id, level, result_value, result_text, bonus_points, state)
|
|
VALUES (1, 1, 'meet', 11.58, '11.58', 2, 'approved')`,
|
|
}
|
|
for _, s := range stmts {
|
|
if _, err := db.Exec(s); err != nil {
|
|
t.Fatalf("造数据 %q: %v", s, err)
|
|
}
|
|
}
|
|
|
|
// 升级前不该有 note 列。
|
|
var probe sql.NullString
|
|
if err := db.QueryRow(`SELECT note FROM record_breaks`).Scan(&probe); err == nil {
|
|
t.Fatal("升级前不该有 record_breaks.note 列")
|
|
}
|
|
|
|
// 走真实升级路径(main.go 导入旧赛事包后补跑的就是它);ADD 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{"records": 1, "record_breaks": 1, "results": 1, "entries": 1} {
|
|
if got := countRows(t, db, table); got != want {
|
|
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
|
}
|
|
}
|
|
|
|
// 老登记的状态与加分原样保留,note 为 NULL(没有理由,不是空串理由)。
|
|
var (
|
|
state string
|
|
bonus float64
|
|
note sql.NullString
|
|
text string
|
|
level string
|
|
heldBy string
|
|
)
|
|
if err := db.QueryRow(`SELECT state, bonus_points, note FROM record_breaks WHERE id = 1`).
|
|
Scan(&state, &bonus, ¬e); err != nil {
|
|
t.Fatalf("读破纪录登记: %v", err)
|
|
}
|
|
if state != "approved" || bonus != 2 {
|
|
t.Errorf("老登记被改动: state=%q bonus=%v", state, bonus)
|
|
}
|
|
if note.Valid {
|
|
t.Errorf("老登记的 note 应为 NULL(历史行没有理由),得到 %q", note.String)
|
|
}
|
|
if err := db.QueryRow(`SELECT level, COALESCE(result_text, ''), COALESCE(holder_name, '') FROM records WHERE id = 1`).
|
|
Scan(&level, &text, &heldBy); err != nil {
|
|
t.Fatalf("读纪录库: %v", err)
|
|
}
|
|
if level != "meet" || text != "11.20" || heldBy != "李四" {
|
|
t.Errorf("老纪录被改动: level=%q text=%q holder=%q", level, text, heldBy)
|
|
}
|
|
|
|
// 新列可用:写理由 → 清理由;状态机照旧接受 rejected。
|
|
if _, err := db.Exec(`UPDATE record_breaks SET state = 'rejected', note = '录像复核未破' WHERE id = 1`); err != nil {
|
|
t.Fatalf("驳回老登记: %v", err)
|
|
}
|
|
if err := db.QueryRow(`SELECT note FROM record_breaks WHERE id = 1`).Scan(¬e); err != nil {
|
|
t.Fatalf("读 note: %v", err)
|
|
}
|
|
if !note.Valid || note.String != "录像复核未破" {
|
|
t.Errorf("写入的驳回理由未生效: %+v", note)
|
|
}
|
|
if _, err := db.Exec(`UPDATE record_breaks SET state = 'approved', note = NULL WHERE id = 1`); err != nil {
|
|
t.Fatalf("重新批准并清理由: %v", err)
|
|
}
|
|
// 新登记不写 note 也能插(列可空)。
|
|
if _, err := db.Exec(`INSERT INTO record_breaks(event_id, entry_id, level, state) VALUES (1, 1, 'city', 'pending')`); err != nil {
|
|
t.Fatalf("插入未带 note 的新登记: %v", err)
|
|
}
|
|
// 非法状态仍被 CHECK 挡住(迁移没有动过状态机)。
|
|
if _, err := db.Exec(`UPDATE record_breaks SET state = 'bogus' WHERE id = 1`); err == nil {
|
|
t.Error("state='bogus' 应被 CHECK 拒绝")
|
|
}
|
|
|
|
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
|
|
if err != nil {
|
|
t.Fatalf("外键检查: %v", err)
|
|
}
|
|
defer func() { _ = fkRows.Close() }()
|
|
if fkRows.Next() {
|
|
t.Error("升级后存在外键悬挂")
|
|
}
|
|
}
|