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:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user