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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user