package rules import ( "database/sql" "fmt" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // EventInfo 是展开通道模板所需的最小项目信息。 type EventInfo struct { ID int64 Code string Name string SportCode string EventType domain.EventType ResultType domain.ResultType Scored bool Multiplier float64 AwardMedals bool // Compare 是排序方向。必须读出来:basis=result / basis=aggregate 的通道要先按成绩排名, // compare=none(名次靠人工录入)的小项配不了它们(见 resolveTargets 的守卫)。 Compare domain.CompareMode Channels []Channel } // queryer 是 *sql.DB 与 *sql.Tx 共有的读接口。 // Load 既要在库上读,也要在导入事务**内部**读:ApplyTx 必须看到与自己写入同一个事务的一致视图, // 否则「读到的现状」可能已被别处的改动覆盖,按过期现状算出的目标通道会写错。 type queryer interface { Query(query string, args ...any) (*sql.Rows, error) QueryRow(query string, args ...any) *sql.Row } // Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。 // 差异预览需要项目目录才能把 channelTemplates 展开到具体项目。 type Snapshot struct { Pack *RulePack Events []EventInfo } // EventByCode 按项目编码查找项目。 func (s *Snapshot) EventByCode(code string) (EventInfo, bool) { for _, e := range s.Events { if e.Code == code { return e, true } } return EventInfo{}, false } // Load 从赛事库读取当前规则状态。规则包元数据(meta)库里不存,保持为空。 func Load(db *sql.DB) (*Snapshot, error) { return loadSnapshot(db) } // loadSnapshot 是 Load 的实现体,接受 *sql.DB 或 *sql.Tx。 func loadSnapshot(q queryer) (*Snapshot, error) { pack := &RulePack{ Format: FormatName, FormatVersion: FormatVersion, } profileByID, profiles, err := loadProfiles(q) if err != nil { return nil, err } pack.Profiles = profiles settings, err := loadSettings(q) if err != nil { return nil, err } pack.Standings = standingsFromSettings(settings) pack.RecordBonus = recordBonusFromSettings(settings) pack.ManualAdjustment = manualAdjustmentFromSettings(settings) events, err := loadEvents(q, profileByID) if err != nil { return nil, err } // 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/不结算奖牌/权重非 1」的项目 // 全部写成 eventOverrides,保证「导出 → 导入」能还原当前配置。 for _, e := range events { needOverride := len(e.Channels) > 0 || !e.Scored || !e.AwardMedals || e.Multiplier != 1 if !needOverride { continue } ov := EventOverride{EventCode: e.Code, Name: e.Name} if !e.Scored { ov.Scored = BoolPtr(false) } if e.Multiplier != 1 { ov.Multiplier = Float64Ptr(e.Multiplier) } if !e.AwardMedals { ov.AwardMedals = BoolPtr(false) } if len(e.Channels) > 0 { ov.Channels = ChannelListPtr(append([]Channel(nil), e.Channels...)) } pack.EventOverrides = append(pack.EventOverrides, ov) } canonical := pack.Canonical() return &Snapshot{Pack: &canonical, Events: events}, nil } // Export 从赛事库生成规则包;meta 由调用方提供(库里不存规则包元数据)。 func Export(db *sql.DB, meta Meta) (*RulePack, error) { snap, err := Load(db) if err != nil { return nil, err } pack := *snap.Pack pack.Meta = meta if strings.TrimSpace(pack.Meta.Code) == "" { pack.Meta.Code = "exported-rules" } if strings.TrimSpace(pack.Meta.Name) == "" { if name, err := settingByKey(db, "event.name"); err == nil && strings.TrimSpace(name) != "" { pack.Meta.Name = name } else { pack.Meta.Name = "未命名赛事 · 积分规则" } } canonical := pack.Canonical() return &canonical, nil } func loadProfiles(q queryer) (map[int64]string, []Profile, error) { rows, err := q.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero FROM scoring_profiles ORDER BY code`) if err != nil { return nil, nil, fmt.Errorf("查询积分模板: %w", err) } defer func() { _ = rows.Close() }() byID := map[int64]string{} byCode := map[string]int{} var profiles []Profile for rows.Next() { var ( id, awardRanks int64 code, name string tie, bonus string lastPlaceZeroRaw int ) if err := rows.Scan(&id, &code, &name, &tie, &bonus, &awardRanks, &lastPlaceZeroRaw); err != nil { return nil, nil, fmt.Errorf("读取积分模板: %w", err) } byID[id] = code byCode[code] = len(profiles) profiles = append(profiles, Profile{ Code: code, Name: name, AwardRanks: int(awardRanks), LastPlaceZero: lastPlaceZeroRaw != 0, TieMode: domain.TieMode(tie), RecordBonusMode: domain.RecordBonusMode(bonus), }) } if err := rows.Err(); err != nil { return nil, nil, fmt.Errorf("读取积分模板: %w", err) } ruleRows, err := q.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`) if err != nil { return nil, nil, fmt.Errorf("查询积分模板明细: %w", err) } defer func() { _ = ruleRows.Close() }() for ruleRows.Next() { var ( profileID int64 rank int points float64 ) if err := ruleRows.Scan(&profileID, &rank, &points); err != nil { return nil, nil, fmt.Errorf("读取积分模板明细: %w", err) } code, ok := byID[profileID] if !ok { continue } idx := byCode[code] profiles[idx].Rules = append(profiles[idx].Rules, Rule{Rank: rank, Points: points}) } if err := ruleRows.Err(); err != nil { return nil, nil, fmt.Errorf("读取积分模板明细: %w", err) } return byID, profiles, nil } func loadSettings(q queryer) (map[string]string, error) { rows, err := q.Query(`SELECT key, value FROM settings`) if err != nil { return nil, fmt.Errorf("查询赛事设置: %w", err) } defer func() { _ = rows.Close() }() out := map[string]string{} for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, fmt.Errorf("读取赛事设置: %w", err) } out[k] = v } if err := rows.Err(); err != nil { return nil, fmt.Errorf("读取赛事设置: %w", err) } return out, nil } func settingByKey(db *sql.DB, key string) (string, error) { var value string if err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value); err != nil { return "", err } return value, nil } func loadEvents(q queryer, profileByID map[int64]string) ([]EventInfo, error) { rows, err := q.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.compare, e.scored, e.multiplier, e.award_medals FROM events e JOIN sports s ON s.id = e.sport_id ORDER BY e.sort_order, e.code`) if err != nil { return nil, fmt.Errorf("查询项目: %w", err) } defer func() { _ = rows.Close() }() events := []EventInfo{} index := map[int64]int{} for rows.Next() { var ( id int64 code, name string sportCode string eventType string resultType string compare string scoredRaw int multiplier float64 awardMedalsRaw int ) if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &compare, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil { return nil, fmt.Errorf("读取项目: %w", err) } index[id] = len(events) events = append(events, EventInfo{ ID: id, Code: code, Name: name, SportCode: sportCode, EventType: domain.EventType(eventType), ResultType: domain.ResultType(resultType), Compare: domain.CompareMode(compare), Scored: scoredRaw != 0, Multiplier: multiplier, AwardMedals: awardMedalsRaw != 0, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("读取项目: %w", err) } chRows, err := q.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.aggregate_mode, c.count_to_team, c.count_to_medals, c.count_to_team_medals FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id ORDER BY c.event_id, c.sort_order, c.id`) if err != nil { return nil, fmt.Errorf("查询计分通道: %w", err) } defer func() { _ = chRows.Close() }() for chRows.Next() { var ( eventID int64 scope string basis string code string sortOrd int aggregateMode string countToTeam int countToMedals int countToTeamMedals int ) if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &aggregateMode, &countToTeam, &countToMedals, &countToTeamMedals); err != nil { return nil, fmt.Errorf("读取计分通道: %w", err) } idx, ok := index[eventID] if !ok { continue } ch := Channel{ Scope: domain.Scope(scope), Basis: domain.Basis(basis), Profile: code, } // 只把非缺省的口径写进导出文件:avg 是缺省值,写了会让「导出 → 导入」凭空多出差异。 if aggregateMode == string(domain.AggregateSum) { ch.AggregateMode = string(domain.AggregateSum) } // 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。 if countToTeam == 0 { ch.CountToTeam = BoolPtr(false) } if countToMedals == 0 { ch.CountToMedals = BoolPtr(false) } if countToTeamMedals == 0 { ch.CountToTeamMedals = BoolPtr(false) } events[idx].Channels = append(events[idx].Channels, ch) } if err := chRows.Err(); err != nil { return nil, fmt.Errorf("读取计分通道: %w", err) } return events, nil }