Files
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(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 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

340 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}