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

+84 -31
View File
@@ -2,6 +2,9 @@ package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
@@ -129,17 +132,18 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
}, nil
}
res, err := rules.Apply(s.ev, cs)
res, rows, err := s.applyRulesAndRecompute(r.Context(), cs, middlewareUserID(r))
if err != nil {
return nil, internal(err)
}
// 分值一变,历史成绩的积分与名次必须全量重算。
rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r))
if err != nil {
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
s.logger.Error("规则导入后重算失败", "err", err)
return nil, internalMsg("规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入", err)
// 配置本身就不成立(compare=none 的小项被配上 basis=result 等):落地前就拒了,
// 一个字节都没写,属于 400 而不是「服务端异常」。
var conflict *rules.CompareConflictError
if errors.As(err, &conflict) {
return nil, badRequest(conflict.Error(), nil)
}
// 其余情况是重算在某个小项上失败,整包(规则 + 积分 + 名次)已跟着回滚,
// 榜单维持导入前的状态。原始错误带着「哪个小项、因为什么」,由 detail 一起回给现场。
s.logger.Error("规则导入失败,已整体回滚", "err", err)
return nil, internalMsg("规则包未导入:积分重算失败,本次规则与积分改动已整体回滚,榜单维持导入前的状态(原因见后)", err)
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return rulesImportResult{
@@ -151,22 +155,80 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
}, nil
}
// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。
// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。
// 返回写入的积分流水条数,供导入结果回报。
// applyRulesAndRecompute 把「规则包落地」与「全量重算」放进**同一个事务**,成功才提交。
//
// 这两步必须同事务:换通道会先删 event_score_channels,而 channel_ranks.channel_id 挂的是
// `ON DELETE CASCADE`,名次会跟着被清空、靠紧接着的重算重建。以前两步各自提交,重算一失败
// (例如某个小项配不出名次)名次表就停在空表状态:榜单「分还在、奖牌全 0」,而且自愈与重试导入
// 都会因同一个小项再次失败,无人可解(审查 04 P0-1,实测 2 行 → 0 行、金牌 1 → 0)。
// 现在要么整体成功,要么整体回滚到导入前。
//
// 返回落地统计与重算写回的流水条数(后者供导入结果回报)。
func (s *server) applyRulesAndRecompute(ctx context.Context, cs *rules.ChangeSet, actorID int64) (*rules.ApplyResult, int, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, 0, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := rules.ApplyTx(tx, cs)
if err != nil {
return nil, 0, err
}
rows, err := s.recomputeAllEventsTx(ctx, tx, actorID)
if err != nil {
return nil, 0, err
}
if err := tx.Commit(); err != nil {
return nil, 0, internal(err)
}
return res, rows, nil
}
// recomputeAllEvents 全量重算所有项目的积分流水与名次(自带事务),用于改积分模板等
// 会改变分值的场景。返回写入的积分流水条数,供调用方回报。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
var ids []int64
defer func() { _ = tx.Rollback() }()
total, err := s.recomputeAllEventsTx(ctx, tx, actorID)
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return total, nil
}
// recomputeAllEventsTx 在调用方的事务里全量重算,自己不提交。
//
// 整个重算共用调用方这一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态;
// 规则包导入正是靠这一点把「换通道」与「重算」绑在一起(见 applyRulesAndRecompute)。
//
// 每个小项的失败都带上小项编码与名称:只回一句「basis=result 的通道要求小项配置 compare=asc/desc」,
// 现场在一场几十上百个小项的赛事里定位不到是哪一个,也就无从修起。
func (s *server) recomputeAllEventsTx(ctx context.Context, tx *sql.Tx, actorID int64) (int, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, code, name FROM events ORDER BY id`)
if err != nil {
return 0, err
}
type target struct {
id int64
code string
name string
}
var events []target
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
var t target
if err := rows.Scan(&t.id, &t.code, &t.name); err != nil {
_ = rows.Close()
return 0, err
}
ids = append(ids, id)
events = append(events, t)
}
if err := rows.Close(); err != nil {
return 0, err
@@ -175,21 +237,15 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er
return 0, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
total := 0
for _, eventID := range ids {
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import")
for _, ev := range events {
score, err := recomputeLedgerTx(ctx, tx, ev.id, 0, actorID, "rules-import")
if err != nil {
return 0, err
return 0, fmt.Errorf("小项 %s(%s)重算失败:%w", ev.code, ev.name, err)
}
// 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return 0, err
return 0, fmt.Errorf("小项 %s(%s)回写名次失败:%w", ev.code, ev.name, err)
}
total += len(score.Ledger)
}
@@ -197,8 +253,5 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return total, nil
}