package rules import ( "errors" "fmt" "sort" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // ChangeKind 是差异类型。 type ChangeKind string const ( ChangeAdded ChangeKind = "added" ChangeModified ChangeKind = "modified" ChangeRemoved ChangeKind = "removed" ) // ProfileChange 是一个积分模板的增/改/删。 type ProfileChange struct { Code string `json:"code"` Kind ChangeKind `json:"kind"` Before *Profile `json:"before,omitempty"` After *Profile `json:"after,omitempty"` // Fields 列出被修改的字段,便于前端只高亮变化项。 Fields []string `json:"fields,omitempty"` } // ChannelChange 是一个项目计分通道的变化。 type ChannelChange struct { EventCode string `json:"eventCode"` EventName string `json:"eventName,omitempty"` Kind ChangeKind `json:"kind"` Before []Channel `json:"before,omitempty"` After []Channel `json:"after,omitempty"` } // SettingChange 是一条赛事设置的变化。 type SettingChange struct { Key string `json:"key"` Before string `json:"before"` After string `json:"after"` } // ChangeSet 是导入预览的结果,也是 Apply 的输入。 type ChangeSet struct { Profiles []ProfileChange `json:"profiles,omitempty"` Channels []ChannelChange `json:"channels,omitempty"` Settings []SettingChange `json:"settings,omitempty"` // UnknownEvents 是规则包里出现、但赛事库中不存在的项目编码;Apply 会跳过它们。 UnknownEvents []string `json:"unknownEvents,omitempty"` // Next 是目标规则包;Apply 以它为完整目标状态落库。不参与 JSON 序列化。 Next *RulePack `json:"-"` } // Empty 判断这次导入是否真的会改动任何东西。 // 只用于预览展示;Apply 始终以 Next 为目标全量同步,不依赖它做短路。 func (cs *ChangeSet) Empty() bool { return cs == nil || (len(cs.Profiles) == 0 && len(cs.Channels) == 0 && len(cs.Settings) == 0) } // Diff 计算「当前赛事库 → 目标规则包」的差异,只读不落库。 func Diff(cur *Snapshot, next *RulePack) (*ChangeSet, error) { if cur == nil || cur.Pack == nil { return nil, errors.New("rules: 当前快照为空,无法比对差异") } if next == nil { return nil, fmt.Errorf("%w: 目标规则包为空", ErrNotRulePack) } target := next.Canonical() if err := target.Validate(); err != nil { return nil, err } cs := &ChangeSet{Next: &target} cs.Profiles = diffProfiles(cur.Pack, &target) targets, unknown, err := resolveTargets(cur, &target) if err != nil { return nil, err } cs.UnknownEvents = unknown cs.Channels = diffChannels(cur, targets) cs.Settings = diffSettings(cur.Pack, &target) return cs, nil } func diffProfiles(cur, next *RulePack) []ProfileChange { curIndex := cur.ProfileIndex() nextIndex := next.ProfileIndex() codes := make([]string, 0, len(curIndex)+len(nextIndex)) seen := map[string]bool{} for code := range curIndex { if !seen[code] { seen[code] = true codes = append(codes, code) } } for code := range nextIndex { if !seen[code] { seen[code] = true codes = append(codes, code) } } sort.Strings(codes) var out []ProfileChange for _, code := range codes { before, hasBefore := curIndex[code] after, hasAfter := nextIndex[code] switch { case !hasBefore: out = append(out, ProfileChange{Code: code, Kind: ChangeAdded, After: &after}) case !hasAfter: out = append(out, ProfileChange{Code: code, Kind: ChangeRemoved, Before: &before}) default: if fields := profileDiffFields(before, after); len(fields) > 0 { out = append(out, ProfileChange{Code: code, Kind: ChangeModified, Before: &before, After: &after, Fields: fields}) } } } return out } func profileDiffFields(a, b Profile) []string { var fields []string if a.Name != b.Name { fields = append(fields, "name") } if a.AwardRanks != b.AwardRanks { fields = append(fields, "awardRanks") } if a.LastPlaceZero != b.LastPlaceZero { fields = append(fields, "lastPlaceZero") } if a.TieMode != b.TieMode { fields = append(fields, "tieMode") } if a.RecordBonusMode != b.RecordBonusMode { fields = append(fields, "recordBonusMode") } if !rulesEqual(a.Rules, b.Rules) { fields = append(fields, "rules") } return fields } func rulesEqual(a, b []Rule) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // resolveTargets 把规则包展开成「项目编码 → 目标通道」。 // 优先级:eventOverrides 显式给了 channels > 第一条命中的 channelTemplates。 // 「只排名不计分」的项目固定解析成 0 通道;没被规则包提到的计分项目不出现在结果里,表示「保持原样」。 func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []string, error) { overrideByCode := make(map[string]EventOverride, len(next.EventOverrides)) for _, o := range next.EventOverrides { overrideByCode[o.EventCode] = o } targets := make(map[string][]Channel) for _, ev := range cur.Events { // 只排名不计分的项目固定 0 通道(devdocs 02 §2.4):否则会继承模板通道, // 把名次带进奖牌榜。override 显式写了 scored:false 时同样清空。 scored := ev.Scored if ov, ok := overrideByCode[ev.Code]; ok && ov.Scored != nil { scored = *ov.Scored } if !scored { targets[ev.Code] = nil continue } if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil { targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...) } else { for _, t := range next.ChannelTemplates { if matchEvent(t.Match, ev) { targets[ev.Code] = append([]Channel(nil), t.Channels...) break } } } // 目标通道算出来了,落地前先确认它在这个小项上跑得起来(见 checkCompareCompatible)。 if err := checkCompareCompatible(ev, targets[ev.Code]); err != nil { return nil, nil, err } } var unknown []string for _, o := range next.EventOverrides { if _, ok := cur.EventByCode(o.EventCode); !ok { unknown = append(unknown, o.EventCode) } } sort.Strings(unknown) return targets, unknown, nil } // CompareConflictError 是「规则包给 compare=none 的小项配了需要比较方向的通道」这一配置错误。 // // 带小项编码与名称是必须的:导入失败的提示如果只说「basis=result 的通道要求小项配置 // compare=asc/desc」,现场在一场几十上百个小项的赛事里根本找不到是哪一个。 type CompareConflictError struct { EventCode string EventName string // Bases 是冲突的通道口径(result / aggregate),按通道出现顺序、已去重。 Bases []string } func (e *CompareConflictError) Error() string { return fmt.Sprintf("小项 %s(%s)配置的是 compare=none(名次靠人工录入),规则包却给它配了 basis=%s 的通道:"+ "这类通道要先按成绩排出名次,compare=none 时排不出来,导入后这个小项算不了分。"+ "请在规则包里为它写一段 eventOverrides,把 channels 改成 basis=manual"+ "(个人名次用 scope: athlete,团体名次用 scope: team),或调整通道模板的 match 条件别匹配到它。", e.EventCode, e.EventName, strings.Join(e.Bases, "、")) } // checkCompareCompatible 拦下「compare=none 的小项被配上 basis=result / basis=aggregate 通道」。 // // 为什么要在这里拦:这两种口径都要先按成绩排名,而 compare=none(名次靠人工录入)没有名次可算, // domain.ScoreEvent 会直接返回 ErrCompareRequired。以前这条配置能过,代价是导入「先换通道」已经提交 // (换通道时 channel_ranks 被外键级联清空)、紧接着的全量重算才报错,名次表就停在空表状态, // 而且自愈与重试导入都会因同一个小项再次失败(审查 04 P0-1)。 // 现在改成在**落地之前**整包拒收:一个字节都不写,失败提示直接指出哪个小项、为什么。 // // 只校验本次要写入的目标通道(模板套用与显式 override 一视同仁):库里既有的、规则包没覆盖到的 // 通道不动它——那属于「改配置」而不是「导入规则包」的事,越权清理会误伤别人手工配好的设置。 func checkCompareCompatible(ev EventInfo, channels []Channel) error { if len(channels) == 0 || ev.Compare != domain.CompareNone { return nil } var bases []string seen := map[string]bool{} for _, ch := range channels { if ch.Basis != domain.BasisResult && ch.Basis != domain.BasisAggregate { continue } if seen[string(ch.Basis)] { continue } seen[string(ch.Basis)] = true bases = append(bases, string(ch.Basis)) } if len(bases) == 0 { return nil } return &CompareConflictError{EventCode: ev.Code, EventName: ev.Name, Bases: bases} } func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange { curByCode := make(map[string]EventInfo, len(cur.Events)) for _, ev := range cur.Events { curByCode[ev.Code] = ev } codes := make([]string, 0, len(targets)) for code := range targets { codes = append(codes, code) } sort.Strings(codes) var out []ChannelChange for _, code := range codes { ev := curByCode[code] before := ev.Channels after := targets[code] if channelsEqual(before, after) { continue } kind := ChangeModified switch { case len(before) == 0: kind = ChangeAdded case len(after) == 0: kind = ChangeRemoved } out = append(out, ChannelChange{ EventCode: code, EventName: ev.Name, Kind: kind, Before: append([]Channel(nil), before...), After: append([]Channel(nil), after...), }) } return out } // channelsEqual 逐字段比较目标通道与库中通道。 // Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比; // 汇总口径同理按「缺省即 avg」比,避免规则包显式写 avg 与库里缺省值被当成一次改动。 func channelsEqual(a, b []Channel) bool { if len(a) != len(b) { return false } for i := range a { if a[i].Scope != b[i].Scope || a[i].Basis != b[i].Basis || a[i].Profile != b[i].Profile { return false } if a[i].AggregatesBy() != b[i].AggregatesBy() { return false } if a[i].CountsToTeam() != b[i].CountsToTeam() { return false } if a[i].CountsToMedals() != b[i].CountsToMedals() { return false } if a[i].CountsToTeamMedals() != b[i].CountsToTeamMedals() { return false } } return true } func diffSettings(cur, next *RulePack) []SettingChange { curSettings := settingsFromPack(cur) nextSettings := settingsFromPack(next) var out []SettingChange for _, key := range settingsKeys() { before, after := curSettings[key], nextSettings[key] if before != after { out = append(out, SettingChange{Key: key, Before: before, After: after}) } } return out }