feat: 小项类型加「个人+团体」+ 报名导入拆团体/个人两部分
小项类型: - 迁移 0016 给 events.event_type 的 CHECK 增加 individual_team。SQLite 改不了 CHECK, 而 events 被多张子表引用(含 ON DELETE CASCADE/SET NULL),事务内 DROP 重建会级联删数据, 故用 writable_schema 就地改写建表语句文本(只追加一个枚举值,'match' 在建表语句里仅出现一次, 无副作用),零数据搬动 - domain 加 EventTypeIndividualTeam;管理端下拉加「个人+团体」并提示会同时走两条通道; guards/rules 的 eventType 白名单同步(否则会被降级成 individual) - 注意:该类型目前只解决「能被选出来」,双计的算分语义(个人分不计入单位、团体按队内平均排名) 尚未实现,等口径确认后再动数据模型 报名导入: - 卡片拆成「个人报名 / 团体报名」两个 tab,小项+组别提到 tab 之上共享 - 团体报名 = 从已配置单位清单里多选(默认全不勾,正好表达「部分单位不参加」), 可逐单位为登记队员(0 人即纯单位报名),一次提交为多个单位各建一条报名 - 已报名单位仍可覆盖名单,但会提示「提交将清空原名单」,且「全选」只勾启用中且未报名的单位 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 117/117;console 16 文件通过。
This commit is contained in:
1 parent
b5f40642ff
commit
592e47aef7
19 files changed
+1318
-117
No files matched your search
@@ -109,6 +109,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
|
||||
|
||||
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
|
||||
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
|
||||
// 「个人+团体」的双计靠两条通道表达,这条退化路径只排个人榜,故不入单位分支。
|
||||
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
|
||||
basis := BasisResult
|
||||
if in.Event.ResultMode == ResultModeManual {
|
||||
|
||||
@@ -379,6 +379,41 @@ func TestRankOnlyRespectsResultMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndividualTeamDualChannel 验证「个人+团体」按配置的两条通道双计:
|
||||
// 同一份成绩同时产出个人通道与团体通道的名次(类型本身不参与计算)。
|
||||
func TestIndividualTeamDualChannel(t *testing.T) {
|
||||
entries, results := fixture(lineupValues(11.5, 11.8, 12.0))
|
||||
ev := individualEvent(1)
|
||||
ev.EventType = EventTypeIndividualTeam
|
||||
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{
|
||||
athleteResultChannel(1),
|
||||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
|
||||
},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
|
||||
byScope := make(map[Scope]map[int64]int, 2)
|
||||
for _, rr := range es.Ranked {
|
||||
if byScope[rr.Scope] == nil {
|
||||
byScope[rr.Scope] = make(map[int64]int, len(es.Ranked))
|
||||
}
|
||||
byScope[rr.Scope][rr.EntryID] = rr.Rank
|
||||
}
|
||||
for _, scope := range []Scope{ScopeAthlete, ScopeTeam} {
|
||||
ranks := byScope[scope]
|
||||
if ranks[1] != 1 || ranks[2] != 2 || ranks[3] != 3 {
|
||||
t.Fatalf("%s 通道名次 = %v,期望 1/2/3", scope, ranks)
|
||||
}
|
||||
}
|
||||
if len(es.Ledger) != 6 {
|
||||
t.Errorf("双通道流水条数 = %d,期望 6(3 条个人 + 3 条团体)", len(es.Ledger))
|
||||
}
|
||||
}
|
||||
|
||||
// TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。
|
||||
func TestScoreEventErrors(t *testing.T) {
|
||||
entries, results := fixture(lineup(1))
|
||||
|
||||
@@ -12,6 +12,9 @@ const (
|
||||
EventTypeIndividual EventType = "individual"
|
||||
EventTypeTeam EventType = "team"
|
||||
EventTypeMatch EventType = "match"
|
||||
// EventTypeIndividualTeam 个人+团体:团体与个人赛事合并的项目,个人榜与团体榜同时记分。
|
||||
// 双计靠两条计分通道(athlete + team)表达,不在这里做特殊计算。
|
||||
EventTypeIndividualTeam EventType = "individual_team"
|
||||
)
|
||||
|
||||
// ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。
|
||||
|
||||
@@ -88,6 +88,47 @@ func TestEventWritesWriteChangeLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 小项类型新增「个人+团体」:REST 写接口要能存下来并原样返回,不被当成未知类型。
|
||||
func TestEventAcceptsIndividualTeamType(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
||||
"sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上",
|
||||
"eventType": "individual_team", "resultType": "count", "compare": "desc", "scored": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("建「个人+团体」项目应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
if created["eventType"] != "individual_team" {
|
||||
t.Fatalf("建回的 eventType = %v,期望 individual_team", created["eventType"])
|
||||
}
|
||||
eventID := int64(created["id"].(float64))
|
||||
|
||||
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读项目应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := decodeData[map[string]any](t, env2); got["eventType"] != "individual_team" {
|
||||
t.Fatalf("读回的 eventType = %v,期望 individual_team", got["eventType"])
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
|
||||
"sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上",
|
||||
"eventType": "team", "resultType": "count", "compare": "desc", "scored": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改回团体应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := decodeData[map[string]any](t, env2); got["eventType"] != "team" {
|
||||
t.Fatalf("改回的 eventType = %v,期望 team", got["eventType"])
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号是人员的唯一标识:可读写、重复 409、空值不互斥(部分唯一索引)。
|
||||
func TestAthleteIDCardUnique(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
@@ -257,3 +257,63 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
|
||||
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
|
||||
const individualTeamPackYAML = `format: scoreboard-rules
|
||||
formatVersion: 1
|
||||
meta: { code: demo, name: 个人+团体模板 }
|
||||
standings:
|
||||
individualToTeamWeight: 1
|
||||
tieBreak: [points, gold]
|
||||
profiles:
|
||||
- code: individual_top6
|
||||
name: 个人项目前六名
|
||||
awardRanks: 1
|
||||
tieMode: skip
|
||||
rules:
|
||||
- { rank: 1, points: 10 }
|
||||
- code: team_top6
|
||||
name: 团体项目前六名
|
||||
awardRanks: 1
|
||||
tieMode: skip
|
||||
rules:
|
||||
- { rank: 1, points: 15 }
|
||||
channelTemplates:
|
||||
- match: { eventType: individual_team }
|
||||
channels:
|
||||
- { scope: athlete, basis: result, profile: individual_top6 }
|
||||
- { scope: team, basis: manual, profile: team_top6 }
|
||||
`
|
||||
|
||||
func TestResolveTargetsIndividualTeamTemplate(t *testing.T) {
|
||||
cur := testSnapshot(t,
|
||||
[]Profile{
|
||||
{Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 10}}},
|
||||
{Code: "team_top6", Name: "团体项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 15}}},
|
||||
},
|
||||
[]EventInfo{
|
||||
{ID: 1, Code: "EVT_BOTH", Name: "团体引体向上", EventType: domain.EventTypeIndividualTeam,
|
||||
ResultType: domain.ResultTypeCount, Scored: true, Multiplier: 1,
|
||||
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}},
|
||||
})
|
||||
|
||||
next, err := Parse([]byte(individualTeamPackYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
targets, _, err := resolveTargets(cur, next)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTargets: %v", err)
|
||||
}
|
||||
got := targets["EVT_BOTH"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("individual_team 应展开成两条通道,实际 %+v", got)
|
||||
}
|
||||
if got[0].Scope != domain.ScopeAthlete || got[0].Basis != domain.BasisResult || got[0].Profile != "individual_top6" {
|
||||
t.Errorf("个人通道 = %+v", got[0])
|
||||
}
|
||||
if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisManual || got[1].Profile != "team_top6" {
|
||||
t.Errorf("团体通道 = %+v", got[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 小项类型新增「个人+团体」(individual_team):团体与个人赛事合并的项目,
|
||||
-- 默认同时走个人通道与团体通道。event_type 的取值由 0004 的 CHECK 限定,
|
||||
-- SQLite 改不了 CHECK,必须换一份表定义。
|
||||
--
|
||||
-- events 被多张子表引用,其中 records / record_breaks / result_revisions 是
|
||||
-- ON DELETE CASCADE,score_ledger / point_adjustments 是 ON DELETE SET NULL,
|
||||
-- 而迁移在事务内执行:PRAGMA foreign_keys 关不掉,直接 DROP 又会触发级联/置空,
|
||||
-- defer_foreign_keys 则会让事务在提交时报未解决的外键。因此这里沿用 SQLite 的
|
||||
-- writable_schema 就地改写 events 的建表语句——只改 schema 文本,不动任何数据。
|
||||
PRAGMA writable_schema = ON;
|
||||
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, '''match''', '''match'', ''individual_team''')
|
||||
WHERE type = 'table' AND name = 'events';
|
||||
|
||||
-- RESET 让本连接立刻按新定义重解析 schema(否则缓存里仍是旧 CHECK),并关闭 writable_schema。
|
||||
PRAGMA writable_schema = RESET;
|
||||
@@ -0,0 +1,239 @@
|
||||
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。events 被多张子表以级联/置空引用,
|
||||
// 迁移用 writable_schema 就地改写建表语句,本文件的用例守住「数据不丢 + 新 CHECK 生效」。
|
||||
|
||||
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 执行 0016 之前的迁移并登记到 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 一样登记,升级时才能只补跑 0016。
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil {
|
||||
t.Fatalf("登记迁移 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 := countRows(t, db, "schema_migrations"); got != 16 {
|
||||
t.Errorf("迁移登记数 = %d,期望 16", got)
|
||||
}
|
||||
|
||||
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("迁移后存在外键悬挂")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user