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

+139
View File
@@ -0,0 +1,139 @@
package rules
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 本文件钉住工单 E:分值表的档位超出「录取档位数(awardRanks)」时必须拒收。
//
// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
// 而本包原先只拒 awardRanks < 0:一份「配到第 7 档、录取名次还留着 6」的规则包能过校验、
// 安静导入——第 7 档在模板页上写着分,榜上恒为 0,界面上看不出任何异常(审查 01 P1)。
// awardRanks: 0 表示不按名次封顶(既有语义),这种包必须继续放行。
// sevenRankPackYAML 是「配到第 7 档」的规则包;录取名次由用例替换成 6 / 0。
const sevenRankPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 第七档 }
profiles:
- code: p7
name: 前七名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 5 }
- { rank: 5, points: 4 }
- { rank: 6, points: 3 }
- { rank: 7, points: 2 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: p7 }
`
// 录取名次 6、分值表到第 7 档:导入必须整体拒收,且说明要说清「哪一档超了」「怎么改」。
func TestParseRejectsAwardRanksOverflow(t *testing.T) {
_, err := Parse([]byte(sevenRankPackYAML))
if !errors.Is(err, ErrValidation) {
t.Fatalf("第 7 档 + awardRanks=6 应报 ErrValidation,实际 %v", err)
}
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("应返回 *ValidationError,实际 %T", err)
}
var found bool
for _, is := range ve.Issues {
if is.Path != "profiles[0].awardRanks" {
continue
}
found = true
// 现场要能照着改:说清超出的档位、当前录取名次,以及「0 表示不封顶」这个出口。
for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} {
if !strings.Contains(is.Msg, want) {
t.Fatalf("说明里缺少 %q:%s", want, is.Msg)
}
}
}
if !found {
t.Fatalf("问题应挂在 profiles[0].awardRanks 上,实际 %+v", ve.Issues)
}
}
// 这条检查只在**写入**侧生效:导出(Marshal)走的是 Validate,必须继续容忍越档模板。
// 库里修好之前存下的历史配置如果让导不出来,GET /api/rules/export 与赛事项目包(备份)会一起 500,
// 而它们正是现场排障与备份的入口——所以导出侧宁可原样导出,由用户在模板页上改。
func TestMarshalStillAllowsLegacyAwardRanksOverflow(t *testing.T) {
pack := &RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
Meta: Meta{Code: "legacy", Name: "历史越档模板"},
Profiles: []Profile{{
Code: "p7", Name: "前七名", AwardRanks: 6, TieMode: domain.TieSkip,
Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}},
}},
}
if err := pack.Validate(); err != nil {
t.Fatalf("导出侧校验不该拦越档模板,实际 %v", err)
}
if _, err := Marshal(pack); err != nil {
t.Fatalf("越档模板必须还能导出,实际 %v", err)
}
// 但同一份配置从导入进来时就要被拒(Parse → ValidateForImport)。
if _, err := Parse([]byte(sevenRankPackYAML)); !errors.Is(err, ErrValidation) {
t.Fatalf("导入侧应拒收越档模板,实际 %v", err)
}
}
// 两种合法形态不得被误杀:正好相等(第 6 档 + awardRanks 6)、不封顶(第 7 档 + awardRanks 0)。
func TestParseAcceptsAwardRanksCoveringRules(t *testing.T) {
cases := map[string]string{
"正好相等": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 7", 1),
"不封顶": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 0", 1),
}
for name, yaml := range cases {
t.Run(name, func(t *testing.T) {
pack, err := Parse([]byte(yaml))
if err != nil {
t.Fatalf("应解析成功,实际 %v", err)
}
if got := len(pack.Profiles[0].Rules); got != 7 {
t.Fatalf("分值表应保留 7 档,实际 %d", got)
}
})
}
}
// AwardRanksOverflow 是保存模板与导入规则包共用的判定,边界直接钉在函数上。
func TestAwardRanksOverflowBoundaries(t *testing.T) {
cases := []struct {
awardRanks int
maxRuleRank int
overflow bool
}{
{0, 10, false}, // 0 = 不封顶
{-1, 10, false}, // 负数由别处拦,这里不重复报
{6, 0, false}, // 没有分值明细
{6, 6, false}, // 正好覆盖
{7, 6, false}, // 录取名次多于档位:合法(后面几档没人拿得到)
{6, 7, true},
{6, 10, true},
}
for _, tc := range cases {
msg := AwardRanksOverflow(tc.awardRanks, tc.maxRuleRank)
if tc.overflow != (msg != "") {
t.Fatalf("awardRanks=%d maxRuleRank=%d 应 overflow=%v,实际 %q",
tc.awardRanks, tc.maxRuleRank, tc.overflow, msg)
}
}
// 超出的档位取的是「最大的那一档」,不是第一条超出的档位。
if msg := AwardRanksOverflow(6, 10); !strings.Contains(msg, "第 10 档") {
t.Fatalf("应点名最大的超档档位,实际 %q", msg)
}
}