## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
340 lines
11 KiB
Go
340 lines
11 KiB
Go
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
|
||
}
|