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

+56 -2
View File
@@ -86,7 +86,7 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
`SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
@@ -98,14 +98,19 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
for rows.Next() {
var (
c domain.ScoreChannel
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 汇总口径只认 sum:avg(含缺字段的老库)保持零值,由引擎按缺省 avg 处理。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = domain.AggregateSum
}
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
if countToTeam == 0 {
c.CountToTeam = boolPtr(false)
@@ -545,14 +550,30 @@ func resultStatusPtr(s domain.ResultStatus) *string {
// 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列——
// 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。
//
// 这一列还是 manual 通道的**输入**(个人手工名次走它、团体报名项的团体名次也走它兜底),
// 所以命中「人工名次的录入位」时必须让路,否则同一份数据第二次发布会算出另一个结果:
//
// - 小项配了 [athlete, manual]:每一条成绩的 results.rank 都是操作员填的输入,
// 自动名次覆盖它 → 人工通道下一轮读到的是自动名次(工单 A-2:A 队 18 → 20 分);
// - 小项配了 [team, manual] 且该行是**团体报名项**(AthleteID == 0):它的 results.rank
// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源),
// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。
//
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
inputs, err := manualRankInputEvents(ctx, tx, ranked)
if err != nil {
return err
}
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) {
continue
}
cur, ok := best[rr.ResultID]
if !ok || preferComputedRank(rr, cur) {
best[rr.ResultID] = rr
@@ -566,6 +587,39 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
return nil
}
// manualRankInputs 描述一个小项把 results.rank 当作「人工名次录入位」的两种通道形态。
type manualRankInputs struct {
athleteManual bool // [athlete, manual]:每条成绩的 results.rank 都是人工录入的个人名次
teamManual bool // [team, manual]:团体报名项的 results.rank 是人工录入的团体名次
}
// manualRankInputEvents 查出这些名次所属小项里,哪些把 results.rank 当输入用。
// 只查 ranked 里出现的小项:没有名次可写回时不需要这一趟查询,
// 而「有没有 manual 通道」必须看库(不能只看 ranked——名次还没录时该通道不产出明细)。
func manualRankInputEvents(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) (map[int64]manualRankInputs, error) {
if len(ranked) == 0 {
return nil, nil
}
out := make(map[int64]manualRankInputs)
for _, rr := range ranked {
if rr.EventID == 0 {
continue
}
if _, done := out[rr.EventID]; done {
continue
}
var athlete, team int
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(CASE WHEN scope = 'athlete' AND basis = 'manual' THEN 1 ELSE 0 END), 0),
COALESCE(MAX(CASE WHEN scope = 'team' AND basis = 'manual' THEN 1 ELSE 0 END), 0)
FROM event_score_channels WHERE event_id = ?`, rr.EventID).Scan(&athlete, &team); err != nil {
return nil, internal(err)
}
out[rr.EventID] = manualRankInputs{athleteManual: athlete != 0, teamManual: team != 0}
}
return out, nil
}
// preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。
func preferComputedRank(a, b domain.RankedResult) bool {
if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) {