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:
1 parent
78b14efaa9
commit
877ff15823
181 files changed
+8747
-371
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user