Files
Scoreboard/server/migrations/event/migration_test.go
T
AskaEth 9d4ee0946c feat: 运动员彻底删除(真物理删除 + 重算积分)+ 报名表 status 容错 + 部署包带导入规范
## 运动员「彻底删除」= 真删除
`DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里:
删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 →
对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 →
bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。
响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。
必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id /
result_id 三种引用一把清,漏一种就留悬挂。

管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、
相关小项排名与积分会重算),成功 toast 报出删除规模。

## 报名表 status 列容错
新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认),
认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered,
认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后
抛原始 SQLite 错误,填表人看不懂。

## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」
SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」
报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认
"unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。

## 安装包平铺《报名名单导入规范》
deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增
报名名单导入规范.txt。

测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go);
前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。
另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、
榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。
2026-09-10 22:37:25 +08:00

464 lines
17 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("迁移后存在外键悬挂")
}
}