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