## 算分正确性(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 的双通道形态按要求改变。
258 lines
9.3 KiB
Go
258 lines
9.3 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
||
)
|
||
|
||
// rulesRoutes 挂载规则包的导出与导入(导入支持 ?preview=1 只预览差异)。
|
||
func (s *server) rulesRoutes(mux *http.ServeMux) {
|
||
mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules))
|
||
mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules))
|
||
// 录入员要判断破纪录加分/人工调整是否启用,但无权读 /api/settings,故单开只读开关。
|
||
mux.HandleFunc("GET /api/rules/switches", s.read(auth.RoleRecorder, s.ruleSwitches))
|
||
}
|
||
|
||
// ruleSwitches 只返回布尔开关,不下发规则内容、模板明细或文件路径。
|
||
func (s *server) ruleSwitches(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
snap, err := rules.Load(s.ev)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
pack := snap.Pack
|
||
return dto.RuleSwitches{
|
||
RecordBonusEnabled: pack.RecordBonus.Enabled,
|
||
AdjustmentsEnabled: pack.ManualAdjustment.Enabled,
|
||
AdjustmentReasonRequired: pack.ManualAdjustment.ReasonRequired,
|
||
MedalCountsTeamEvents: pack.Standings.MedalCountsTeamEventsEnabled(),
|
||
ShowIndividualBoard: pack.Standings.ShowIndividualBoardEnabled(),
|
||
ShowIndividualMedals: pack.Standings.ShowIndividualMedalsEnabled(),
|
||
}, nil
|
||
}
|
||
|
||
func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
pack, err := rules.Export(s.ev, rules.Meta{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
body, err := rules.Marshal(pack)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="rules.yaml"`)
|
||
if _, err := w.Write(body); err != nil {
|
||
s.logger.Error("规则包写出失败", "err", err)
|
||
}
|
||
return nil, errHandled
|
||
}
|
||
|
||
// rulesPackCompatible 判断规则包格式版本是否与当前程序一致。
|
||
func rulesPackCompatible(pack *rules.RulePack) bool {
|
||
return pack.Format == rules.FormatName && pack.FormatVersion == rules.FormatVersion
|
||
}
|
||
|
||
// rulesImportPreview 是规则预览响应:在 ChangeSet 之上补齐前端契约里的兼容性与影响面。
|
||
type rulesImportPreview struct {
|
||
*rules.ChangeSet
|
||
// Compatible 由 formatVersion 判断;能解析到这里说明版本已兼容。
|
||
Compatible bool `json:"compatible"`
|
||
AffectedEventCount int `json:"affectedEventCount"`
|
||
}
|
||
|
||
// rulesImportResult 是规则落地响应:在 ApplyResult 之上补齐预览字段与重算条数。
|
||
// 同时带上 ChangeSet,前端落地后仍能显示这次导入的逐条差异(否则会被覆盖成「无差异」)。
|
||
type rulesImportResult struct {
|
||
*rules.ApplyResult
|
||
*rules.ChangeSet
|
||
Compatible bool `json:"compatible"`
|
||
AffectedEventCount int `json:"affectedEventCount"`
|
||
RecalculatedLedgerRows int `json:"recalculatedLedgerRows"`
|
||
}
|
||
|
||
// affectedEventCount 统计本次导入影响的小项数:计分通道变化的项目,
|
||
// 加上引用了被改动模板的项目——模板分值一变,这些项目的积分就会跟着变。
|
||
func affectedEventCount(cs *rules.ChangeSet, cur *rules.Snapshot) int {
|
||
affected := make(map[string]bool, len(cs.Channels))
|
||
for _, ch := range cs.Channels {
|
||
affected[ch.EventCode] = true
|
||
}
|
||
if len(cs.Profiles) > 0 {
|
||
changed := make(map[string]bool, len(cs.Profiles))
|
||
for _, pc := range cs.Profiles {
|
||
changed[pc.Code] = true
|
||
}
|
||
for _, ev := range cur.Events {
|
||
for _, ch := range ev.Channels {
|
||
if changed[ch.Profile] {
|
||
affected[ev.Code] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return len(affected)
|
||
}
|
||
|
||
func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||
if err != nil {
|
||
return nil, badRequest("读取请求体失败", nil)
|
||
}
|
||
if len(body) == 0 {
|
||
return nil, badRequest("请求体为空", nil)
|
||
}
|
||
|
||
pack, err := rules.Parse(body)
|
||
if err != nil {
|
||
return nil, badRequest(err.Error(), nil)
|
||
}
|
||
cur, err := rules.Load(s.ev)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
cs, err := rules.Diff(cur, pack)
|
||
if err != nil {
|
||
return nil, badRequest(err.Error(), nil)
|
||
}
|
||
|
||
// 先预览再落地:?preview=1 只返回差异。
|
||
if r.URL.Query().Get("preview") == "1" {
|
||
return rulesImportPreview{
|
||
ChangeSet: cs,
|
||
Compatible: rulesPackCompatible(pack),
|
||
AffectedEventCount: affectedEventCount(cs, cur),
|
||
}, nil
|
||
}
|
||
|
||
res, rows, err := s.applyRulesAndRecompute(r.Context(), cs, middlewareUserID(r))
|
||
if err != nil {
|
||
// 配置本身就不成立(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{
|
||
ApplyResult: res,
|
||
ChangeSet: cs,
|
||
Compatible: rulesPackCompatible(pack),
|
||
AffectedEventCount: affectedEventCount(cs, cur),
|
||
RecalculatedLedgerRows: rows,
|
||
}, nil
|
||
}
|
||
|
||
// 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) {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
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 t target
|
||
if err := rows.Scan(&t.id, &t.code, &t.name); err != nil {
|
||
_ = rows.Close()
|
||
return 0, err
|
||
}
|
||
events = append(events, t)
|
||
}
|
||
if err := rows.Close(); err != nil {
|
||
return 0, err
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
total := 0
|
||
for _, ev := range events {
|
||
score, err := recomputeLedgerTx(ctx, tx, ev.id, 0, actorID, "rules-import")
|
||
if err != nil {
|
||
return 0, fmt.Errorf("小项 %s(%s)重算失败:%w", ev.code, ev.name, err)
|
||
}
|
||
// 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
|
||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||
return 0, fmt.Errorf("小项 %s(%s)回写名次失败:%w", ev.code, ev.name, err)
|
||
}
|
||
total += len(score.Ledger)
|
||
}
|
||
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
|
||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||
return 0, err
|
||
}
|
||
return total, nil
|
||
}
|