Files
AskaEth 877ff15823 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 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

258 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}