package rules import ( "database/sql" "fmt" "sort" "strings" ) // ApplyResult 汇总一次导入实际落库的行数。 type ApplyResult struct { ProfilesWritten int `json:"profilesWritten"` ProfilesDeleted int `json:"profilesDeleted"` ChannelsWritten int `json:"channelsWritten"` EventsUpdated int `json:"eventsUpdated"` SettingsWritten int `json:"settingsWritten"` EventsSkipped []string `json:"eventsSkipped,omitempty"` } // Apply 把差异预览落地(自带事务)。 // // 规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,而不是逐条套用 diff 列表—— // 这样重复导入同一份规则包是幂等的。 // 只填了 Next 的变更集(例如确认导入时重新 Parse 上传内容)同样可以调用,等价于强制全量导入。 // // Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。 // 需要「换通道」与「重算」同事务时用 ApplyTx(规则包导入走的是那条路,见其注释)。 func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { tx, err := db.Begin() if err != nil { return nil, fmt.Errorf("开启事务: %w", err) } defer func() { _ = tx.Rollback() }() res, err := ApplyTx(tx, cs) if err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("提交导入事务: %w", err) } return res, nil } // ApplyTx 在调用方给定的**未提交**事务里落地规则包变更,自己不提交。 // // 为什么要有这个入口:换通道会先 `DELETE FROM event_score_channels` 再插新行,而 channel_ranks // 的 channel_id 挂的是 `ON DELETE CASCADE`——通道一删,该通道的名次就跟着没了。名次由紧随其后的 // 全量重算重建,两者分属不同事务时,只要重算失败(例如某个小项配不出名次),名次表就停在 // 「已经被清空、又补不回来」的中间态:榜单分还在、奖牌全 0,而且自愈与重试导入都会因同一个小项 // 再次失败(审查 04 P0-1,实测 2 行 → 0 行、金牌 1 → 0)。 // 共用一个事务后,重算失败即整包回滚,名次表回到导入前的状态。 // // 落地顺序:模板 → 通道 → 删除无主模板 → 项目开关 → 赛事设置。 // 通道先改完再删模板,避免「模板被换掉」时误判引用。 // 现状快照在**本事务内**读取(loadSnapshot(tx)),保证看到的就是即将写入的这份数据。 func ApplyTx(tx *sql.Tx, cs *ChangeSet) (*ApplyResult, error) { if cs == nil || cs.Next == nil { return nil, ErrNilChangeSet } next := cs.Next.Canonical() if err := next.Validate(); err != nil { return nil, err } cur, err := loadSnapshot(tx) if err != nil { return nil, err } targets, unknown, err := resolveTargets(cur, &next) if err != nil { return nil, err } res := &ApplyResult{EventsSkipped: unknown} // 1. 模板全量 upsert,并建立「编码 → id」映射供通道引用。 profileIDs := map[string]int64{} for _, prof := range next.Profiles { id, err := upsertProfile(tx, prof) if err != nil { return nil, err } profileIDs[prof.Code] = id res.ProfilesWritten++ } // 2. 通道:只覆盖规则包明确提到的项目。 eventIDs := map[string]int64{} for _, ev := range cur.Events { eventIDs[ev.Code] = ev.ID } codes := make([]string, 0, len(targets)) for code := range targets { codes = append(codes, code) } sort.Strings(codes) for _, code := range codes { eventID, ok := eventIDs[code] if !ok { continue } if _, err := tx.Exec(`DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil { return nil, fmt.Errorf("清理项目 %s 的计分通道: %w", code, err) } for i, ch := range targets[code] { profileID, ok := profileIDs[ch.Profile] if !ok { return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile) } if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team, count_to_medals, count_to_team_medals) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i, string(ch.AggregatesBy()), boolToInt(ch.CountsToTeam()), boolToInt(ch.CountsToMedals()), boolToInt(ch.CountsToTeamMedals())); err != nil { return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err) } } res.ChannelsWritten++ } // 3. 删除目标规则包里已没有的模板;仍被引用就报错,绝不静默拆掉别人的配置。 for _, prof := range cur.Pack.Profiles { if _, keep := profileIDs[prof.Code]; keep { continue } if err := deleteProfile(tx, prof.Code); err != nil { return nil, err } res.ProfilesDeleted++ } // 4. 项目开关:只改 override 里显式给出的字段。 for _, ov := range next.EventOverrides { eventID, ok := eventIDs[ov.EventCode] if !ok || (ov.Scored == nil && ov.Multiplier == nil && ov.AwardMedals == nil) { continue } scored, err := currentScored(tx, eventID) if err != nil { return nil, err } if ov.Scored != nil { scored = *ov.Scored } multiplier, err := currentMultiplier(tx, eventID) if err != nil { return nil, err } if ov.Multiplier != nil { multiplier = *ov.Multiplier } awardMedals, err := currentAwardMedals(tx, eventID) if err != nil { return nil, err } if ov.AwardMedals != nil { awardMedals = *ov.AwardMedals } if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ?, award_medals = ? WHERE id = ?`, boolToInt(scored), multiplier, boolToInt(awardMedals), eventID); err != nil { return nil, fmt.Errorf("更新项目 %s: %w", ov.EventCode, err) } res.EventsUpdated++ } // 5. 赛事设置。 settings := settingsFromPack(&next) for _, key := range settingsKeys() { if _, err := tx.Exec(`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, key, settings[key]); err != nil { return nil, fmt.Errorf("写入赛事设置 %s: %w", key, err) } res.SettingsWritten++ } return res, nil } func upsertProfile(tx *sql.Tx, prof Profile) (int64, error) { if _, err := tx.Exec(`INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(code) DO UPDATE SET name = excluded.name, tie_mode = excluded.tie_mode, record_bonus_mode = excluded.record_bonus_mode, award_ranks = excluded.award_ranks, last_place_zero = excluded.last_place_zero, updated_at = datetime('now')`, prof.Code, prof.Name, string(prof.TieMode), string(prof.RecordBonusMode), prof.AwardRanks, boolToInt(prof.LastPlaceZero)); err != nil { return 0, fmt.Errorf("写入积分模板 %s: %w", prof.Code, err) } var id int64 if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, prof.Code).Scan(&id); err != nil { return 0, fmt.Errorf("读取积分模板 %s 的 id: %w", prof.Code, err) } if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil { return 0, fmt.Errorf("清理积分模板 %s 的明细: %w", prof.Code, err) } for _, r := range prof.Rules { if _, err := tx.Exec(`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`, id, r.Rank, r.Points); err != nil { return 0, fmt.Errorf("写入积分模板 %s 的名次 %d: %w", prof.Code, r.Rank, err) } } return id, nil } func deleteProfile(tx *sql.Tx, code string) error { var id int64 if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id); err != nil { if err == sql.ErrNoRows { return nil } return fmt.Errorf("读取积分模板 %s: %w", code, err) } refs, err := profileRefs(tx, id) if err != nil { return err } if len(refs) > 0 { return fmt.Errorf("%w: 模板 %s 仍被 %s 引用", ErrProfileInUse, code, strings.Join(refs, "、")) } if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil { return fmt.Errorf("删除积分模板 %s 的明细: %w", code, err) } if _, err := tx.Exec(`DELETE FROM scoring_profiles WHERE id = ?`, id); err != nil { return fmt.Errorf("删除积分模板 %s: %w", code, err) } return nil } func profileRefs(tx *sql.Tx, profileID int64) ([]string, error) { var refs []string var channels int if err := tx.QueryRow(`SELECT COUNT(*) FROM event_score_channels WHERE profile_id = ?`, profileID).Scan(&channels); err != nil { return nil, fmt.Errorf("统计模板引用: %w", err) } if channels > 0 { refs = append(refs, fmt.Sprintf("%d 个计分通道", channels)) } var sports int if err := tx.QueryRow(`SELECT COUNT(*) FROM sports WHERE default_profile_id = ?`, profileID).Scan(&sports); err != nil { return nil, fmt.Errorf("统计模板引用: %w", err) } if sports > 0 { refs = append(refs, fmt.Sprintf("%d 个大项的默认模板", sports)) } return refs, nil } func currentScored(tx *sql.Tx, eventID int64) (bool, error) { var v int if err := tx.QueryRow(`SELECT scored FROM events WHERE id = ?`, eventID).Scan(&v); err != nil { return false, fmt.Errorf("读取项目计分开关: %w", err) } return v != 0, nil } func currentMultiplier(tx *sql.Tx, eventID int64) (float64, error) { var v float64 if err := tx.QueryRow(`SELECT multiplier FROM events WHERE id = ?`, eventID).Scan(&v); err != nil { return 0, fmt.Errorf("读取项目权重: %w", err) } return v, nil } func currentAwardMedals(tx *sql.Tx, eventID int64) (bool, error) { var v int if err := tx.QueryRow(`SELECT award_medals FROM events WHERE id = ?`, eventID).Scan(&v); err != nil { return false, fmt.Errorf("读取项目奖牌结算开关: %w", err) } return v != 0, nil } func boolToInt(v bool) int { if v { return 1 } return 0 }