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 -1
View File
@@ -40,7 +40,7 @@ func testCategory() EventCategory {
func individualEvent(multiplier float64) Event {
return Event{
ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual,
Compare: CompareAsc, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
Compare: CompareAsc, RecordEligible: true, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
}
}
-228
View File
@@ -1,28 +1,5 @@
package domain
import (
"fmt"
"strings"
)
// LedgerOpKind 增量操作类型。
type LedgerOpKind string
const (
LedgerOpAppend LedgerOpKind = "append" // 追加一批流水
LedgerOpVoidSource LedgerOpKind = "void_source" // 按来源作废
)
// LedgerOp 是一条可乱序应用的积分变更,用于「增量同步」场景。
type LedgerOp struct {
Kind LedgerOpKind
Entries []LedgerEntry
SourceType SourceType
SourceID int64
By string
Reason string
}
// TeamPoints 一个单位的分项汇总;系数只在汇总时折算,历史流水保持原始分。
type TeamPoints struct {
Total float64 // 单位总分
@@ -30,103 +7,6 @@ type TeamPoints struct {
Individual float64 // 记到个人的流水(原始分,未乘系数)
}
// AppendLedger 追加流水。只追加、不修改已有行;id 为 0 时按当前最大 id 续号。
func AppendLedger(existing []LedgerEntry, adds ...LedgerEntry) ([]LedgerEntry, error) {
out := make([]LedgerEntry, len(existing), len(existing)+len(adds))
copy(out, existing)
maxID := int64(0)
used := make(map[int64]bool, len(out)+len(adds))
for _, e := range out {
if e.ID > maxID {
maxID = e.ID
}
used[e.ID] = true
}
for _, a := range adds {
if a.ID == 0 {
continue
}
if used[a.ID] {
return nil, fmt.Errorf("%w: %d", ErrDuplicateLedgerID, a.ID)
}
used[a.ID] = true
if a.ID > maxID {
maxID = a.ID
}
}
for _, a := range adds {
if a.ID == 0 {
maxID++
a.ID = maxID
}
out = append(out, a)
}
return out, nil
}
// VoidLedger 作废一条流水;重复作废视为错误,避免掩盖重复撤销。
func VoidLedger(entries []LedgerEntry, id int64, by, reason string) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), entries...)
for i := range out {
if out[i].ID != id {
continue
}
if out[i].Voided {
return nil, fmt.Errorf("%w: %d", ErrLedgerAlreadyVoided, id)
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
return out, nil
}
return nil, fmt.Errorf("%w: %d", ErrLedgerNotFound, id)
}
// VoidBySource 作废某来源产生的全部未作废流水,返回新的流水集合与作废条数。
// 找不到时是空操作:撤销发布重复执行应当幂等。
func VoidBySource(entries []LedgerEntry, sourceType SourceType, sourceID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].SourceType != sourceType || out[i].SourceID != sourceID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// VoidByEvent 作废某个小项的全部未作废流水(含成绩分、破纪录加分、人工调整)。
func VoidByEvent(entries []LedgerEntry, eventID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].EventID != eventID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// ActiveLedger 返回未作废的流水。
func ActiveLedger(entries []LedgerEntry) []LedgerEntry {
out := make([]LedgerEntry, 0, len(entries))
for _, e := range entries {
if !e.Voided {
out = append(out, e)
}
}
return out
}
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
@@ -161,111 +41,3 @@ func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
}
return out
}
// RecomputeEventLedger 重算某个小项的成绩分与破纪录加分:旧流水整项作废,再追加新流水。
// 人工调整不参与重算,原样保留——否则每重算一次就把人工扣/加分抹掉。
// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。
func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) {
active := make([]LedgerEntry, 0, len(existing))
for _, e := range existing {
if e.EventID == eventID && !e.Voided && recomputableSource(e.SourceType) {
active = append(active, e)
}
}
if sameLedgerSet(active, fresh) {
return append([]LedgerEntry(nil), existing...), nil
}
out := append([]LedgerEntry(nil), existing...)
for i := range out {
if out[i].Voided || out[i].EventID != eventID || !recomputableSource(out[i].SourceType) {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
}
return AppendLedger(out, fresh...)
}
// recomputableSource 判断流水来源是否属于「重算范围」:只有引擎产出的成绩分与破纪录加分,
// 人工调整由人负责,重算不得触碰。
func recomputableSource(t SourceType) bool {
return t == SourceResult || t == SourceRecordBonus
}
// ApplyLedgerOps 按给定顺序应用增量操作。
// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), existing...)
for _, op := range ops {
switch op.Kind {
case LedgerOpAppend:
next, err := AppendLedger(out, op.Entries...)
if err != nil {
return nil, err
}
out = next
case LedgerOpVoidSource:
next, _ := VoidBySource(out, op.SourceType, op.SourceID, op.By, op.Reason)
out = next
default:
return nil, fmt.Errorf("%w: %q", ErrUnknownLedgerOp, op.Kind)
}
}
return out, nil
}
// AdjustmentsToLedger 把人工调整转成积分流水;本期默认关闭,启用后分值可正可负。
func AdjustmentsToLedger(cfg ManualAdjustmentConfig, adjustments []PointAdjustment) ([]LedgerEntry, error) {
if !cfg.Enabled {
return nil, ErrManualAdjustmentDisabled
}
out := make([]LedgerEntry, 0, len(adjustments))
for _, a := range adjustments {
if a.Voided {
continue
}
if cfg.ReasonRequired && strings.TrimSpace(a.Reason) == "" {
return nil, fmt.Errorf("%w: 调整 %d", ErrAdjustmentReasonRequired, a.ID)
}
out = append(out, LedgerEntry{
TeamID: a.TeamID,
EventID: a.EventID,
SourceType: SourceAdjustment,
SourceID: a.ID,
Points: a.Delta,
CreatedBy: a.CreatedBy,
CreatedAt: a.CreatedAt,
})
}
return out, nil
}
// sameLedgerSet 比较两组流水是否等价:只比「来源 + 归属 + 分值」,不比 id 与创建信息。
func sameLedgerSet(a, b []LedgerEntry) bool {
if len(a) != len(b) {
return false
}
am := ledgerSetMap(a)
bm := ledgerSetMap(b)
if len(am) != len(bm) {
return false
}
for k, v := range am {
w, ok := bm[k]
if !ok || !nearlyEqual(v, w) {
return false
}
}
return true
}
func ledgerSetMap(entries []LedgerEntry) map[string]float64 {
out := make(map[string]float64, len(entries))
for _, e := range entries {
key := fmt.Sprintf("%s|%d|%d|%d|%d", e.SourceType, e.SourceID, e.ChannelID, e.TeamID, e.AthleteID)
out[key] += e.Points
}
return out
}
+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)
}
}
}
+4 -148
View File
@@ -131,66 +131,6 @@ func TestMatrix07_TiedFirstMedalCount(t *testing.T) {
}
}
// case 8:发布后撤销 → 流水作废,总分回退。
func TestMatrix08_UnpublishRevertsTotal(t *testing.T) {
entries, results := fixture(lineup(3))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
published := SumByTeam(es.Ledger, 1)
if !nearlyEqual(published[1001].Total, 10) {
t.Fatalf("发布后总分 = %v,期望 10", published[1001].Total)
}
voided, count := VoidByEvent(es.Ledger, 1, "admin", "撤销发布")
if count != len(es.Ledger) {
t.Fatalf("作废条数 = %d,期望 %d", count, len(es.Ledger))
}
after := SumByTeam(voided, 1)
for id, p := range after {
if p.Total != 0 {
t.Errorf("单位 %d 撤销后总分 = %v,期望 0", id, p.Total)
}
}
}
// case 9:修改已发布成绩 → 旧流水作废 + 新流水,审计可查。
func TestMatrix09_EditPublishedResult(t *testing.T) {
entries, results := fixture(lineup(3))
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
// 改判:entry1 变成最后一名,entry2、entry3 各前进一位。
entries[0].ID = 1
results[0].ResultValue = ptr(10)
results[1].ResultValue = ptr(1)
results[2].ResultValue = ptr(2)
fresh := mustScore(t, in)
rebuilt, err := RecomputeEventLedger(first.Ledger, 1, fresh.Ledger, "judge", "成绩更正")
if err != nil {
t.Fatalf("重算流水失败: %v", err)
}
if len(rebuilt) <= len(first.Ledger) {
t.Fatalf("重算后流水条数 = %d,期望大于 %d(旧作废 + 新追加)", len(rebuilt), len(first.Ledger))
}
for _, e := range rebuilt {
if e.Voided && (e.VoidedBy != "judge" || e.VoidReason != "成绩更正") {
t.Errorf("作废流水缺少审计信息: %+v", e)
}
}
assertEntryPoints(t, pointsByEntry(rebuilt), []float64{6, 10, 8})
}
// case 10:弃权者不占名次 → 名次顺延。
func TestMatrix10_WithdrawnDoesNotOccupyRank(t *testing.T) {
ps := lineup(5)
@@ -230,14 +170,10 @@ func TestMatrix12_ManualPenaltyReorders(t *testing.T) {
t.Fatalf("扣分前榜首 = 单位 %d,期望 1(同分时编码小者在前)", before[0].TeamID)
}
adjustments, err := AdjustmentsToLedger(
ManualAdjustmentConfig{Enabled: true, ReasonRequired: true},
[]PointAdjustment{{ID: 1, TeamID: 1, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规扣分"}},
)
if err != nil {
t.Fatalf("人工调整转换失败: %v", err)
}
in.Ledger = append(in.Ledger, adjustments...)
// 人工扣分:单位1 -3 分(本期入口关闭,这里直接构造调整流水验证榜单口径)。
in.Ledger = append(in.Ledger, LedgerEntry{
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3,
})
after := BuildTeamStandings(in)
if after[0].TeamID != 2 {
@@ -375,83 +311,3 @@ func TestMatrix16_UnionEntry(t *testing.T) {
t.Fatalf("整体归属 = %v,期望 1001:10 / 1002:0", byTeam)
}
}
// case 17:重复发布同一组 → 幂等,不产生重复流水。
func TestMatrix17_RepublishIdempotent(t *testing.T) {
entries, results := fixture(lineup(3))
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
second := mustScore(t, in)
rebuilt, err := RecomputeEventLedger(first.Ledger, 1, second.Ledger, "admin", "重复发布")
if err != nil {
t.Fatalf("重算流水失败: %v", err)
}
if len(rebuilt) != len(first.Ledger) {
t.Fatalf("重复发布后流水条数 = %d,期望 %d(幂等)", len(rebuilt), len(first.Ledger))
}
before := SumByTeam(first.Ledger, 1)
after := SumByTeam(rebuilt, 1)
for id, p := range before {
if !nearlyEqual(after[id].Total, p.Total) {
t.Errorf("单位 %d 总分 = %v,期望 %v", id, after[id].Total, p.Total)
}
}
}
// case 18:乱序应用增量 → 最终状态一致。
func TestMatrix18_OutOfOrderIncrements(t *testing.T) {
makeScore := func(eventID int64) []LedgerEntry {
entries, results := fixture([]participant{{entryID: eventID, athleteID: eventID, teamID: eventID, value: 1}})
entries[0].EventID = eventID
results[0].EventID = eventID
return mustScore(t, EventScoreInput{
Event: Event{ID: eventID, Compare: CompareAsc, Scored: true, Multiplier: 1},
Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}).Ledger
}
a := makeScore(11)
b := makeScore(22)
appendA := LedgerOp{Kind: LedgerOpAppend, Entries: a}
appendB := LedgerOp{Kind: LedgerOpAppend, Entries: b}
voidA := LedgerOp{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: a[0].SourceID, By: "admin", Reason: "撤销"}
orders := map[string][]LedgerOp{
"A→B→撤销A": {appendA, appendB, voidA},
"B→A→撤销A": {appendB, appendA, voidA},
"A→撤销A→B": {appendA, voidA, appendB},
}
var baseline map[int64]TeamPoints
for name, ops := range orders {
out, err := ApplyLedgerOps(nil, ops)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
got := SumByTeam(out, 1)
if baseline == nil {
baseline = got
continue
}
if len(got) != len(baseline) {
t.Fatalf("%s: 单位数 %d,期望 %d", name, len(got), len(baseline))
}
for id, p := range baseline {
if !nearlyEqual(got[id].Total, p.Total) {
t.Errorf("%s: 单位 %d 总分 = %v,期望 %v", name, id, got[id].Total, p.Total)
}
}
}
if !nearlyEqual(baseline[11].Total, 0) || !nearlyEqual(baseline[22].Total, 10) {
t.Fatalf("最终状态 = %+v,期望 A 作废 0 分、B 保留 10 分", baseline)
}
}
+53 -2
View File
@@ -16,6 +16,12 @@ func (c RecordBonusConfig) levelPoints(code string) (float64, bool) {
// 同一人同时破多级时,max 取最高档、sum 累加,口径由配置决定。
// 未配置的档位视为无加分,避免脏 level 凭空加分。
func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonus {
return recordBonusPoints(cfg, breaks, nil)
}
// recordBonusPoints 是聚合实现;modeByEntry 给出每个报名项命中通道所属模板的口径,
// 缺失时退回全局 cfg.Mode,保证直接调用 RecordBonusPoints 的旧口径不变。
func recordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak, modeByEntry map[int64]RecordBonusMode) []RecordBonus {
if !cfg.Enabled || len(breaks) == 0 {
return nil
}
@@ -65,7 +71,11 @@ func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonu
TeamID: g.first.TeamID,
Levels: g.levels,
}
if cfg.Mode == RecordBonusSum {
mode := cfg.Mode
if m, ok := modeByEntry[entryID]; ok {
mode = m
}
if mode == RecordBonusSum {
for _, p := range g.points {
bonus.Points += p
}
@@ -89,6 +99,10 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 {
return nil, nil
}
// 只对「可破纪录」的项目加分;登记接口另有一层校验,这里是引擎侧的最后防线。
if !in.Event.RecordEligible {
return nil, nil
}
breaks := make([]RecordBreak, 0, len(in.RecordBreaks))
for _, b := range in.RecordBreaks {
@@ -97,7 +111,16 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
breaks = append(breaks, b)
}
bonuses := RecordBonusPoints(*in.RecordBonus, breaks)
modes := make(map[int64]RecordBonusMode, len(in.Entries))
for _, e := range in.Entries {
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
continue
}
if m, ok := recordBonusModeFor(in, e); ok {
modes[e.ID] = m
}
}
bonuses := recordBonusPoints(*in.RecordBonus, breaks, modes)
if len(bonuses) == 0 {
return nil, nil
}
@@ -138,3 +161,31 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
return out, nil
}
// recordBonusModeFor 取报名项「命中通道」所属模板的破纪录口径。
// 命中通道按 channelBefore 的优先级挑选(athlete → team → 通道 id 升序),
// 与奖牌去重同一口径;没有可命中通道或模板缺失时返回 false,由调用方退回全局口径。
func recordBonusModeFor(in EventScoreInput, e Entry) (RecordBonusMode, bool) {
var (
picked Scope
pickedID int64
profileID int64
found bool
)
for _, ch := range in.Channels {
if ch.Scope == ScopeAthlete && e.AthleteID == 0 {
continue
}
if !found || channelBefore(ch.Scope, ch.ID, picked, pickedID) {
picked, pickedID, profileID, found = ch.Scope, ch.ID, ch.ProfileID, true
}
}
if !found {
return "", false
}
p, ok := in.Profiles[profileID]
if !ok {
return "", false
}
return p.bonusMode(), true
}
+117
View File
@@ -194,6 +194,123 @@ func TestRecordBonusLedger(t *testing.T) {
})
}
// recordBonusPointsOf 统计一次计分产出的破纪录加分总和。
func recordBonusPointsOf(t *testing.T, in EventScoreInput) float64 {
t.Helper()
var got float64
for _, e := range mustScore(t, in).Ledger {
if e.SourceType == SourceRecordBonus {
got += e.Points
}
}
return got
}
// TestRecordBonusRequiresEligibleEvent 验证只有「可破纪录」的项目才产生加分(P1-5)。
func TestRecordBonusRequiresEligibleEvent(t *testing.T) {
entries, results := fixture(lineup(1))
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
}
in.Event.RecordEligible = false
if got := recordBonusPointsOf(t, in); got != 0 {
t.Fatalf("不可破纪录项目的加分 = %v,期望 0", got)
}
in.Event.RecordEligible = true
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
t.Fatalf("可破纪录项目的加分 = %v,期望 2", got)
}
}
// TestRecordBonusUsesHitChannelProfileMode 验证 max/sum 口径取「命中通道」所属模板的配置,
// 而不是全局 cfg.Mode(P1-3)。
func TestRecordBonusUsesHitChannelProfileMode(t *testing.T) {
entries, results := fixture(lineup(1))
athleteProfile := individualTop6()
athleteProfile.RecordBonusMode = RecordBonusSum
teamProfile := teamTop6()
teamProfile.RecordBonusMode = RecordBonusMax
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
// 团体通道排在前面、通道 id 更小,个人通道仍应优先命中。
Channels: []ScoreChannel{
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
},
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
t.Fatalf("加分 = %v,期望 7(个人通道 sum:2+5)", got)
}
}
// TestRecordBonusProfileModeOverridesGlobal 验证模板口径优先于全局口径。
func TestRecordBonusProfileModeOverridesGlobal(t *testing.T) {
entries, results := fixture(lineup(1))
profile := individualTop6()
profile.RecordBonusMode = RecordBonusMax
cfg := bonusConfig(RecordBonusSum)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: profile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 5) {
t.Fatalf("加分 = %v,期望 5(模板 max 优先于全局 sum)", got)
}
}
// TestRecordBonusTeamEntryUsesTeamChannelMode 验证没有运动员的报名项命中团体通道的模板口径。
func TestRecordBonusTeamEntryUsesTeamChannelMode(t *testing.T) {
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1, rank: 1}})
athleteProfile := individualTop6()
athleteProfile.RecordBonusMode = RecordBonusMax
teamProfile := teamTop6()
teamProfile.RecordBonusMode = RecordBonusSum
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
t.Fatalf("加分 = %v,期望 7(团体通道 sum:2+5)", got)
}
}
// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤:
// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。
func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
+68 -5
View File
@@ -204,14 +204,77 @@ func buildTeamRow(t Team, p TeamPoints, m medalTally) StandingsRow {
}
}
// channelBefore 是计分通道的优先级比较(口径固定,必须确定性):
// 个人通道优先于团体通道;同为个人/团体时取通道 id 小者。
// 奖牌去重与破纪录口径共用这套优先级,保证同一份输入每次选出同一个通道。
func channelBefore(scopeA Scope, idA int64, scopeB Scope, idB int64) bool {
if scopeA != scopeB {
return scopeA == ScopeAthlete
}
return idA < idB
}
// medalKey 是奖牌去重键:同一报名项在同一小项、同一组别只记一枚奖牌。
// 聚合通道不对应单条报名项(EntryID=0),改用名次主体(个人/单位)去重。
type medalKey struct {
eventID int64
categoryID int64
entryID int64
scope Scope
subject int64
unique int // 退化数据(EntryID 与主体都为空)没有可识别身份,按输入顺序各占一个键
}
// medalKeyOf 计算一条名次明细的去重键;index 用于给无法识别主体的退化数据兜底。
func medalKeyOf(rr RankedResult, index int) medalKey {
k := medalKey{eventID: rr.EventID, categoryID: rr.CategoryID, entryID: rr.EntryID}
if k.entryID != 0 {
return k
}
k.scope = rr.Scope
if rr.Scope == ScopeAthlete {
k.subject = rr.AthleteID
} else {
k.subject = rr.TeamID
}
if k.subject == 0 {
k.unique = index + 1
}
return k
}
// pickMedalChannels 把按通道展开的名次明细去重成「每个报名项一条」:
// 只保留优先级最高的通道名次,否则双通道项目一条成绩会拿两枚奖牌。
func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
best := make(map[medalKey]RankedResult, len(ranked))
order := make([]medalKey, 0, len(ranked))
for i, rr := range ranked {
if excludeTeam && rr.Scope == ScopeTeam {
continue
}
k := medalKeyOf(rr, i)
cur, ok := best[k]
if !ok {
best[k] = rr
order = append(order, k)
continue
}
if channelBefore(rr.Scope, rr.ChannelID, cur.Scope, cur.ChannelID) {
best[k] = rr
}
}
out := make([]RankedResult, 0, len(order))
for _, k := range order {
out = append(out, best[k])
}
return out
}
// teamMedals 统计团体奖牌榜。联队拆分时两个单位各记一枚:
// 联合组队拿到的名次对每个成员单位都是「本单位取得的名次」。
func teamMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range in.Ranked {
if rr.Scope == ScopeTeam && in.ExcludeTeamEventMedals {
continue
}
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) {
medal := rankMedal(rr.Rank)
if medal == 0 {
continue
@@ -235,7 +298,7 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
// athleteMedals 只统计个人项目(scope=athlete)的奖牌。
func athleteMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range in.Ranked {
for _, rr := range pickMedalChannels(in.Ranked, false) {
if rr.Scope != ScopeAthlete || rr.AthleteID == 0 {
continue
}
+75
View File
@@ -267,6 +267,81 @@ func TestTeamStandingsTieBreakConfig(t *testing.T) {
}
}
// TestMedalDedupAcrossChannels 验证多通道项目一条成绩只记一枚奖牌(P0-2):
// buildRanked 会按通道把一条成绩展开成多条名次明细,去重后只保留优先级最高的通道名次。
func TestMedalDedupAcrossChannels(t *testing.T) {
ranked := func(scope Scope, channelID int64, rank int) RankedResult {
return RankedResult{
EventID: 1, CategoryID: 1, EntryID: 7, ChannelID: channelID, Scope: scope,
TeamID: 1, AthleteID: 101, Rank: rank,
Shares: []TeamShare{{TeamID: 1, Weight: 1}},
}
}
teams := testTeams(1)
athletes := []Athlete{{ID: 101, Name: "甲", TeamID: 1}}
t.Run("双通道一条成绩只记一枚", func(t *testing.T) {
in := StandingsInput{Teams: teams, Athletes: athletes, Ranked: []RankedResult{
ranked(ScopeTeam, 2, 1), ranked(ScopeAthlete, 1, 1),
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 1 || rows[0].Medals != 1 {
t.Fatalf("单位奖牌 = %+v,期望只记 1 金", rows[0])
}
arows := BuildAthleteStandings(in)
if len(arows) != 1 || arows[0].Gold != 1 {
t.Fatalf("个人奖牌 = %+v,期望只记 1 金", arows)
}
})
t.Run("个人通道优先于团体通道", func(t *testing.T) {
in := StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeTeam, 1, 1), // 团体通道名次更靠前,但优先级更低
ranked(ScopeAthlete, 9, 2), // 个人通道优先,取银牌
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 0 || rows[0].Silver != 1 {
t.Fatalf("单位奖牌 = %+v,期望取个人通道的 1 银", rows[0])
}
})
t.Run("同 scope 取通道 id 小者", func(t *testing.T) {
in := StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeAthlete, 5, 3),
ranked(ScopeAthlete, 2, 1),
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 1 || rows[0].Bronze != 0 {
t.Fatalf("单位奖牌 = %+v,期望取通道 id=2 的 1 金", rows[0])
}
})
t.Run("不同报名项各记一枚", func(t *testing.T) {
other := ranked(ScopeAthlete, 1, 1)
other.EntryID = 8
other.AthleteID = 102
rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeAthlete, 1, 1), other,
}})
if rows[0].Gold != 2 {
t.Fatalf("单位金牌 = %d,期望 2(两条成绩各一枚)", rows[0].Gold)
}
})
t.Run("聚合通道按主体去重", func(t *testing.T) {
a := RankedResult{
EventID: 1, CategoryID: 1, ChannelID: 1, Scope: ScopeAthlete, AthleteID: 101,
TeamID: 1, Rank: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}},
}
b := a
b.ChannelID = 2
rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{a, b}})
if rows[0].Gold != 1 {
t.Fatalf("单位金牌 = %d,期望 1(同一主体只记一枚)", rows[0].Gold)
}
})
}
// TestAthleteStandingsTieBreakConfig 验证个人榜同样按配置排序。
func TestAthleteStandingsTieBreakConfig(t *testing.T) {
ledger := []LedgerEntry{ledgerForAthlete(1001, 1, 10), ledgerForAthlete(1002, 2, 10)}