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 的双通道形态按要求改变。
This commit is contained in:
AskaEth committed 2026-09-13 15:20:18 +08:00
1 parent 78b14efaa9
commit 877ff15823
181 files changed
+8747 -371

No files matched your search

+69 -7
View File
@@ -4,6 +4,9 @@ import (
"errors"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ChangeKind 是差异类型。
@@ -181,14 +184,18 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
}
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
} 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
@@ -201,6 +208,57 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
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 {
@@ -240,7 +298,8 @@ func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
}
// channelsEqual 逐字段比较目标通道与库中通道。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比;
// 汇总口径同理按「缺省即 avg」比,避免规则包显式写 avg 与库里缺省值被当成一次改动。
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
@@ -249,6 +308,9 @@ func channelsEqual(a, b []Channel) bool {
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
}