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

+43 -3
View File
@@ -10,6 +10,7 @@ import (
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
@@ -1290,6 +1291,29 @@ func validateAwardRanks(awardRanks int) error {
return nil
}
// validateProfileAwardCoverage 拦住「分值表档位超出录取名次」的静默截断。
//
// 与上一条不同,这里不是字段取值范围的问题,而是两个字段**合起来**不成立:
// award_ranks > 0 时算分引擎把超出名次的档位一律当 0 分(domain/scoring.go 的 basePoints),
// 保存侧却从不校验两边的档位数,于是「分值表配到第 7 档、录取名次还留着 6」能安静落库——
// 模板页上第 7 档明明写着 2 分,榜上却是 0 分,界面上看不出任何异常(审查 01 P1)。
// 与其让人配一份算不出分的表,不如在保存时拒绝,并把两种改法直接写进错误里。
//
// list 传「保存后实际生效的档位」:本次带了 rules 就是它,没带就是库里现有的(只改 awardRanks
// 一样能把已有档位截断)。文案与规则包导入侧共用 rules.AwardRanksOverflow,两条路给同一句话。
func validateProfileAwardCoverage(awardRanks int, list []profileRuleDTO) error {
maxRank := 0
for _, r := range list {
if r.Rank > maxRank {
maxRank = r.Rank
}
}
if msg := rules.AwardRanksOverflow(awardRanks, maxRank); msg != "" {
return badRequest(msg, nil)
}
return nil
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
@@ -1306,6 +1330,9 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
if err := validateProfileAwardCoverage(in.AwardRanks, in.Rules); err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -1363,6 +1390,19 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
// 没带 rules 时按库里现有的档位校验:只把 awardRanks 调小,一样会把已有档位截断。
// 这一步必须在开事务之前读(赛事库只开一条连接,事务里再查会自锁)。
effectiveRules := in.Rules
if effectiveRules == nil {
cur, err := s.profileRules(ctx, id)
if err != nil {
return profileDTO{}, err
}
effectiveRules = cur
}
if err := validateProfileAwardCoverage(in.AwardRanks, effectiveRules); err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -1400,15 +1440,15 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
return s.getProfile(ctx, id)
}
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error {
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, list []profileRuleDTO) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
return internal(err)
}
// 名次去重:主键是 (profile_id, rank),模板页把两行填成同一个数字就会撞 UNIQUE,
// 报出来是 500 + 英文约束原文。事务虽然会回滚,但错误无法自解释,现场只能去猜。
// 先自己查一遍,报清是哪一名重复(与上面 rank < 1 的 400 同一层)。
seen := make(map[int]bool, len(rules))
for _, r := range rules {
seen := make(map[int]bool, len(list))
for _, r := range list {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}