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

+91
View File
@@ -1,6 +1,8 @@
package rules
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
@@ -258,6 +260,95 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
})
}
// compare=none 的个人小项(名次靠人工录入)不能被配上 basis=result / basis=aggregate 的通道:
// 这两种口径都要先按成绩排名,而 compare=none 没有名次可算,算分时必然返回 ErrCompareRequired。
// 以前这条配置能过,代价是「先换通道」(channel_ranks 被外键级联清空)已经提交、紧接着的重算才失败,
// 名次表停在空表状态且补不回来(审查 04 P0-1)。现在必须在落地之前整包拒收。
func TestResolveTargetsRejectsCompareRequiredChannelOnCompareNoneEvent(t *testing.T) {
cur := currentSnapshot(t)
cur.Events = append(cur.Events, EventInfo{
ID: 9, Code: "EVT_MANUAL", Name: "手榴弹投准",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
Compare: domain.CompareNone, Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}},
})
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
t.Run("模板套出来的 basis=result 被拦下", func(t *testing.T) {
_, _, err := resolveTargets(cur, next)
var conflict *CompareConflictError
if !errors.As(err, &conflict) {
t.Fatalf("compare=none 的小项被配上 basis=result 应报 CompareConflictError,得到 %v", err)
}
if conflict.EventCode != "EVT_MANUAL" || conflict.EventName != "手榴弹投准" {
t.Errorf("错误里必须指名是哪个小项,得到 %+v", conflict)
}
// 人话:说清哪个小项、为什么、怎么改。
msg := conflict.Error()
for _, want := range []string{"EVT_MANUAL", "手榴弹投准", "compare=none", "basis=result", "eventOverrides", "basis=manual"} {
if !strings.Contains(msg, want) {
t.Errorf("错误信息缺少 %q:%s", want, msg)
}
}
// 预览阶段就必须失败:不能预览说「就改这些」,落地时才炸。
if _, err := Diff(cur, next); !errors.As(err, &conflict) {
t.Errorf("Diff 也应报 CompareConflictError,得到 %v", err)
}
})
t.Run("显式 override 写 basis=result 一样被拦下", func(t *testing.T) {
patched := *next
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
EventCode: "EVT_MANUAL",
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
})
var conflict *CompareConflictError
if _, _, err := resolveTargets(cur, &patched); !errors.As(err, &conflict) {
t.Errorf("override 显式写了 basis=result 也应拦下,得到 %v", err)
}
})
t.Run("override 改成 basis=manual 即放行", func(t *testing.T) {
patched := *next
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
EventCode: "EVT_MANUAL",
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}}),
})
targets, _, err := resolveTargets(cur, &patched)
if err != nil {
t.Fatalf("basis=manual 不该被拦:%v", err)
}
if got := targets["EVT_MANUAL"]; len(got) != 1 || got[0].Basis != domain.BasisManual {
t.Errorf("EVT_MANUAL 的目标通道 = %+v,期望一条 basis=manual", got)
}
// 规则包没覆盖到的其它小项不受影响。
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Basis != domain.BasisResult {
t.Errorf("EVT_IND 的目标通道 = %+v,期望模板给的 basis=result", got)
}
})
t.Run("不计分的小项不参与校验", func(t *testing.T) {
unscored := currentSnapshot(t)
unscored.Events = append(unscored.Events, EventInfo{
ID: 10, Code: "EVT_OFF", Name: "不计分手工名次",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
Compare: domain.CompareNone, Scored: false, Multiplier: 1,
})
targets, _, err := resolveTargets(unscored, next)
if err != nil {
t.Fatalf("只排名不计分的小项不该被拦:%v", err)
}
if got, ok := targets["EVT_OFF"]; !ok || len(got) != 0 {
t.Errorf("不计分小项的目标通道 = %+v,期望空", got)
}
})
}
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。