Files
Scoreboard/server/internal/rules/diff.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

267 lines
7.2 KiB
Go

package rules
import (
"errors"
"fmt"
"sort"
)
// 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)...)
continue
}
for _, t := range next.ChannelTemplates {
if matchEvent(t.Match, ev) {
targets[ev.Code] = append([]Channel(nil), t.Channels...)
break
}
}
}
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
}
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
}
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
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
}