fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)

P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5658 -1820

No files matched your search

+1 -242
View File
@@ -1,89 +1,6 @@
package domain
import (
"errors"
"testing"
)
func TestAppendLedger(t *testing.T) {
t.Run("自动续号", func(t *testing.T) {
out, err := AppendLedger([]LedgerEntry{{ID: 3, Points: 1}}, LedgerEntry{Points: 2}, LedgerEntry{Points: 3})
if err != nil {
t.Fatalf("追加失败: %v", err)
}
if len(out) != 3 || out[1].ID != 4 || out[2].ID != 5 {
t.Fatalf("id 续号错误: %+v", out)
}
})
t.Run("保留已有行", func(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}}
out, _ := AppendLedger(base, LedgerEntry{Points: 2})
if base[0].Points != 5 || len(out) != 2 {
t.Fatalf("原流水被改动: base=%+v out=%+v", base, out)
}
})
t.Run("显式 id 冲突报错", func(t *testing.T) {
if _, err := AppendLedger([]LedgerEntry{{ID: 1}}, LedgerEntry{ID: 1}); !errors.Is(err, ErrDuplicateLedgerID) {
t.Fatalf("错误 = %v,期望 ErrDuplicateLedgerID", err)
}
})
}
func TestVoidLedger(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}, {ID: 2, Points: 3}}
t.Run("作废指定流水", func(t *testing.T) {
out, err := VoidLedger(base, 1, "admin", "录入错误")
if err != nil {
t.Fatalf("作废失败: %v", err)
}
if !out[0].Voided || out[0].VoidedBy != "admin" || out[0].VoidReason != "录入错误" {
t.Fatalf("作废信息不全: %+v", out[0])
}
if out[1].Voided {
t.Fatalf("误作废了其他流水: %+v", out[1])
}
if base[0].Voided {
t.Fatal("作废不应原地修改入参")
}
})
t.Run("重复作废报错", func(t *testing.T) {
out, _ := VoidLedger(base, 1, "admin", "x")
if _, err := VoidLedger(out, 1, "admin", "y"); !errors.Is(err, ErrLedgerAlreadyVoided) {
t.Fatalf("错误 = %v,期望 ErrLedgerAlreadyVoided", err)
}
})
t.Run("流水不存在报错", func(t *testing.T) {
if _, err := VoidLedger(base, 404, "admin", "x"); !errors.Is(err, ErrLedgerNotFound) {
t.Fatalf("错误 = %v,期望 ErrLedgerNotFound", err)
}
})
}
func TestVoidBySource(t *testing.T) {
base := []LedgerEntry{
{ID: 1, SourceType: SourceResult, SourceID: 100, Points: 10},
{ID: 2, SourceType: SourceResult, SourceID: 100, Points: 8},
{ID: 3, SourceType: SourceResult, SourceID: 200, Points: 6},
}
out, count := VoidBySource(base, SourceResult, 100, "admin", "撤销")
if count != 2 {
t.Fatalf("作废条数 = %d,期望 2", count)
}
if !out[0].Voided || !out[1].Voided || out[2].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
t.Run("重复作废是空操作", func(t *testing.T) {
again, count := VoidBySource(out, SourceResult, 100, "admin", "再撤销")
if count != 0 {
t.Fatalf("重复作废条数 = %d,期望 0", count)
}
if len(again) != len(out) {
t.Fatalf("流水条数变了: %d", len(again))
}
})
}
import "testing"
func TestSumByTeam(t *testing.T) {
ledger := []LedgerEntry{
@@ -116,161 +33,3 @@ func TestSumByAthlete(t *testing.T) {
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
}
}
func TestRecomputeEventLedger(t *testing.T) {
old := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 1, Points: 8},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
t.Run("有变化时旧流水作废并追加新流水", func(t *testing.T) {
out, err := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != 3 {
t.Fatalf("流水条数 = %d,期望 3(旧2 + 新1)", len(out))
}
if !out[0].Voided || out[1].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
if !nearlyEqual(SumByTeam(out, 1)[1].Total, 14) {
t.Fatalf("重算后总分 = %v,期望 6 + 8 = 14", SumByTeam(out, 1)[1].Total)
}
})
t.Run("无变化时原样返回", func(t *testing.T) {
same := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
out, err := RecomputeEventLedger(old, 1, same, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(old) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(old))
}
})
t.Run("只影响目标小项", func(t *testing.T) {
out, _ := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if out[1].Voided {
t.Fatalf("其他小项的流水被误作废: %+v", out[1])
}
})
}
func TestApplyLedgerOps(t *testing.T) {
a := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
b := []LedgerEntry{{EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 2, Points: 8}}
t.Run("追加与作废可乱序收敛", func(t *testing.T) {
ops := []LedgerOp{
{Kind: LedgerOpAppend, Entries: b},
{Kind: LedgerOpAppend, Entries: a},
{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: 100, By: "admin", Reason: "撤销"},
}
out, err := ApplyLedgerOps(nil, ops)
if err != nil {
t.Fatalf("应用失败: %v", err)
}
got := SumByTeam(out, 1)
if !nearlyEqual(got[1].Total, 0) || !nearlyEqual(got[2].Total, 8) {
t.Fatalf("最终状态 = %+v,期望 1:0 / 2:8", got)
}
})
t.Run("未知操作报错", func(t *testing.T) {
if _, err := ApplyLedgerOps(nil, []LedgerOp{{Kind: "magic"}}); !errors.Is(err, ErrUnknownLedgerOp) {
t.Fatalf("错误 = %v,期望 ErrUnknownLedgerOp", err)
}
})
}
func TestAdjustmentsToLedger(t *testing.T) {
adj := PointAdjustment{ID: 1, TeamID: 5, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规"}
t.Run("未启用时报错", func(t *testing.T) {
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{}, []PointAdjustment{adj}); !errors.Is(err, ErrManualAdjustmentDisabled) {
t.Fatalf("错误 = %v,期望 ErrManualAdjustmentDisabled", err)
}
})
t.Run("启用后转成流水", func(t *testing.T) {
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{adj})
if err != nil {
t.Fatalf("转换失败: %v", err)
}
if len(out) != 1 || out[0].SourceType != SourceAdjustment || !nearlyEqual(out[0].Points, -3) {
t.Fatalf("流水 = %+v,期望一条 -3 的 adjustment", out)
}
})
t.Run("理由必填", func(t *testing.T) {
bad := adj
bad.Reason = " "
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{bad}); !errors.Is(err, ErrAdjustmentReasonRequired) {
t.Fatalf("错误 = %v,期望 ErrAdjustmentReasonRequired", err)
}
})
t.Run("已作废的调整被跳过", func(t *testing.T) {
voided := adj
voided.Voided = true
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true}, []PointAdjustment{voided})
if err != nil || len(out) != 0 {
t.Fatalf("流水 = %+v err=%v,期望空", out, err)
}
})
}
func TestActiveLedger(t *testing.T) {
ledger := []LedgerEntry{{ID: 1, Points: 1}, {ID: 2, Points: 2, Voided: true}}
got := ActiveLedger(ledger)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("未作废流水 = %+v,期望只有 id=1", got)
}
}
// TestRecomputeEventLedgerKeepsAdjustments 验证重算只作废 result/record_bonus,
// 人工调整原样保留——否则每重算一次就把人工扣/加分抹掉。
func TestRecomputeEventLedgerKeepsAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 1, SourceType: SourceRecordBonus, SourceID: 100, TeamID: 1, Points: 2},
{ID: 3, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "成绩更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
for _, e := range out {
if e.SourceType == SourceAdjustment {
if e.Voided {
t.Fatalf("人工调整被误作废: %+v", e)
}
}
}
if got := SumByTeam(out, 1)[1].Total; !nearlyEqual(got, 3) {
t.Fatalf("重算后总分 = %v,期望 6(新成绩分)- 3(调整保留)= 3", got)
}
}
// TestRecomputeEventLedgerIdempotentWithAdjustments 验证带人工调整时重复重算也幂等。
func TestRecomputeEventLedgerIdempotentWithAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6},
{ID: 2, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(existing) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(existing))
}
for _, e := range out {
if e.Voided {
t.Fatalf("幂等重算不应作废任何流水: %+v", e)
}
}
}