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 的双通道形态按要求改变。
This commit is contained in:
AskaEth committed 2026-09-13 15:20:18 +08:00
1 parent 78b14efaa9
commit 877ff15823
181 files changed
+8747 -371

No files matched your search

@@ -0,0 +1,232 @@
package domain
import (
"reflect"
"testing"
)
// 聚合口径(通道的 aggregate_mode:avg 按成员平均 / sum 按成员总和)的引擎口径。
//
// 现场要在「平均」与「总和」之间临时定口径(团体引体向上这类双计项目领导还没拍板),
// 所以这里钉住三件事:缺省必须是 avg(升级前后榜单一分不差)、人数相同时两种口径排名一致、
// 人数不同时两者可能不同(现场不能默认它们等价)。
// aggScore 用指定汇总口径跑一次团体聚合通道,返回「单位 → 平均/总和值、名次、积分」。
func aggScore(t *testing.T, ev Event, mode AggregateMode, entries []Entry, results []Result) EventScore {
t.Helper()
ch := teamAggregateChannel(2)
ch.AggregateMode = mode
return mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{ch},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
}
// TestAggregateModeEqualRosterSameRanking:各队人数相同时 sum 与 avg 的排名与积分完全一致。
// 这是现场选口径的依据——人数整齐时选哪种都不影响榜单,人数不齐时才会分叉。
func TestAggregateModeEqualRosterSameRanking(t *testing.T) {
// 三队各 2 人,compare=desc(越大越好)。总和 = 平均 × 2,是同一个单调变换。
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 18},
{entryID: 2, athleteID: 2, teamID: 1001, value: 20},
{entryID: 3, athleteID: 3, teamID: 1002, value: 16},
{entryID: 4, athleteID: 4, teamID: 1002, value: 14},
{entryID: 5, athleteID: 5, teamID: 1003, value: 12},
{entryID: 6, athleteID: 6, teamID: 1003, value: 10},
})
ev := teamEvent(1)
ev.Compare = CompareDesc
avg := aggScore(t, ev, AggregateAvg, entries, results)
sum := aggScore(t, ev, AggregateSum, entries, results)
// 名次与积分逐队一致(录取前六名,三队都有分);汇总值本身当然不同(19 平均 / 38 总和)。
avgOutcome := aggregateByTeam(t, avg)
sumOutcome := aggregateByTeam(t, sum)
for _, teamID := range []int64{1001, 1002, 1003} {
if avgOutcome[teamID].rank != sumOutcome[teamID].rank || !nearlyEqual(avgOutcome[teamID].points, sumOutcome[teamID].points) {
t.Fatalf("人数相同时两种口径的名次/积分应一致:单位 %d avg=%+v sum=%+v",
teamID, avgOutcome[teamID], sumOutcome[teamID])
}
}
if !reflect.DeepEqual(pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger)) {
t.Fatalf("人数相同时两种口径的流水应一致:avg=%v sum=%v",
pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger))
}
// 顺带钉住名次本身:1001 平均 19 / 总和 38 → 第一。
got := aggregateByTeam(t, sum)
if got[1001].rank != 1 || got[1002].rank != 2 || got[1003].rank != 3 {
t.Fatalf("sum 口径名次 = %+v,期望 1001/1002/1003 依次第 1/2/3 名", got)
}
}
// TestAggregateModeRosterDiffersFlipsRanking:各队人数不同时两种口径的排名会不同。
// 1001 队 2 人(10 + 1)、1002 队 1 人(9):平均看 1002 强,总和看 1001 强。
func TestAggregateModeRosterDiffersFlipsRanking(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
})
ev := teamEvent(1)
ev.Compare = CompareDesc
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
// 平均:1001 = 5.5 第二、1002 = 9 第一。
if !nearlyEqual(avg[1001].value, 5.5) || avg[1001].rank != 2 || avg[1001].points != 10 {
t.Errorf("avg:1001 应 5.5 第 2 名 10 分,实际 %+v", avg[1001])
}
if !nearlyEqual(avg[1002].value, 9) || avg[1002].rank != 1 || avg[1002].points != 15 {
t.Errorf("avg:1002 应 9 第 1 名 15 分,实际 %+v", avg[1002])
}
// 总和:1001 = 11 第一、1002 = 9 第二。
if !nearlyEqual(sum[1001].value, 11) || sum[1001].rank != 1 || sum[1001].points != 15 {
t.Errorf("sum:1001 应 11 第 1 名 15 分,实际 %+v", sum[1001])
}
if !nearlyEqual(sum[1002].value, 9) || sum[1002].rank != 2 || sum[1002].points != 10 {
t.Errorf("sum:1002 应 9 第 2 名 10 分,实际 %+v", sum[1002])
}
if avg[1001].rank == sum[1001].rank {
t.Fatalf("人数不同时两种口径的名次不应相同:avg=%+v sum=%+v", avg, sum)
}
}
// TestAggregateModeDefaultsToAverage:缺省(不带 aggregateMode / 老库老客户端)就是 avg,
// 结果与显式写 avg 逐字段一致——现有数据与现有用例的行为一字不变。
func TestAggregateModeDefaultsToAverage(t *testing.T) {
if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg {
t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got)
}
// 认不出的取值也退回 avg,不是退回 sum(库里有 CHECK,但内存里的对象仍要兜住)。
for _, bogus := range []AggregateMode{"", "average", "AVG", "SUM", "biggest"} {
if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg {
t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got)
}
}
// 与 TestAggregateAvgMode 同一个现场(compare=asc,越小越好):1001 一人有成绩 10、一人没成绩;
// 1002 两人各 4 / 6。valid 口径下 1002 平均 5 第一(15 分)、1001 平均 10 第二(10 分)
// ——这是升级前的既有结果。
legacy := []struct {
name string
mode AvgMode
build func() ([]Entry, []Result)
want map[int64]aggregateOutcome
}{
{
name: "valid",
mode: AvgModeValid,
build: func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
})
},
want: map[int64]aggregateOutcome{
1002: {value: 5, points: 15, rank: 1},
1001: {value: 10, points: 10, rank: 2},
},
},
{
name: "all",
mode: AvgModeAll,
build: func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
})
},
want: map[int64]aggregateOutcome{
1001: {value: 5, points: 15, rank: 1},
1002: {value: 5, points: 15, rank: 1},
},
},
}
for _, tc := range legacy {
t.Run("既有结果不变/"+tc.name, func(t *testing.T) {
entries, results := tc.build()
ev := teamEvent(1)
ev.AvgMode = tc.mode
// defaultScore:通道不带 aggregateMode(老库数据 / 老客户端保存的通道)。
defaultScore := aggScore(t, ev, "", entries, results)
explicitAvg := aggScore(t, ev, AggregateAvg, entries, results)
// 「缺省」与「显式 avg」必须逐字段一致(连流水顺序都一致)。
if !reflect.DeepEqual(defaultScore.Ledger, explicitAvg.Ledger) {
t.Errorf("缺省口径的流水与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ledger, explicitAvg.Ledger)
}
if !reflect.DeepEqual(defaultScore.Ranked, explicitAvg.Ranked) {
t.Errorf("缺省口径的名次与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ranked, explicitAvg.Ranked)
}
got := aggregateByTeam(t, defaultScore)
for teamID, want := range tc.want {
have := got[teamID]
if !nearlyEqual(have.value, want.value) || have.rank != want.rank || !nearlyEqual(have.points, want.points) {
t.Errorf("单位 %d = %+v,期望 %+v", teamID, have, want)
}
}
if got := pointsByTeam(defaultScore.Ledger); !reflect.DeepEqual(got, pointsByTeam(explicitAvg.Ledger)) {
t.Errorf("缺省口径的单位总分与显式 avg 不一致:%v / %v", got, pointsByTeam(explicitAvg.Ledger))
}
})
}
}
// TestAggregateModeSumWithAvgMode:sum 只累加有效成绩,avg_mode 在求和口径下只剩
// 「一个有效成绩都没有的单位算不算 0 分参与排名」这一条作用(valid 不参与 / all 参与)。
func TestAggregateModeSumWithAvgMode(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
// 1003 全队一条有效成绩都没有:valid 不参与、all 按 0 参与(与平均口径同规则)。
{entryID: 4, athleteID: 4, teamID: 1003, noValue: true},
})
}
t.Run("valid:没成绩的单位不成桶", func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.Compare = CompareDesc
ev.AvgMode = AvgModeValid
got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 {
t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001])
}
if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 {
t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002])
}
if got[1003].rank != 0 || got[1003].points != 0 {
t.Errorf("1003 全队没成绩,不应参与排名,实际 %+v", got[1003])
}
})
t.Run("all:没成绩的单位按 0 参与、排在最后", func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.Compare = CompareDesc
ev.AvgMode = AvgModeAll
got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
// 求和:1001 = 11 第一;1002 = 9 第二;1003 = 0 第三(加 0 不改变其它队的和)。
if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 {
t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001])
}
if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 {
t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002])
}
if !nearlyEqual(got[1003].value, 0) || got[1003].rank != 3 {
t.Errorf("1003 应总和 0 第 3 名,实际 %+v", got[1003])
}
})
}
+106 -1
View File
@@ -44,12 +44,30 @@ func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput {
}
// writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks:
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank。
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank,
// 且要给 manual 通道的「录入位」让路(工单 A-1 / A-2,与 httpapi.scoring.go 同口径):
// 配了 [athlete,manual] 时整项不写回,配了 [team,manual] 时团体报名项(AthleteID=0)不写回。
// 这里少一条判断,模拟出来的「第二次发布」就会跟真实系统不是一回事。
func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
athleteManual, teamManual := false, false
for _, ch := range in.Channels {
if ch.Basis != BasisManual {
continue
}
switch ch.Scope {
case ScopeAthlete:
athleteManual = true
case ScopeTeam:
teamManual = true
}
}
for _, rr := range ranked {
if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
if athleteManual || (teamManual && rr.AthleteID == 0) {
continue
}
for i := range in.Results {
if in.Results[i].ID == rr.ResultID {
in.Results[i].Rank = rr.Rank
@@ -58,6 +76,93 @@ func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
}
}
// TestTeamEntryDualChannelPublishTwiceIsIdempotent 是工单 A-1 的引擎侧回归:
// team 类型小项 + 3 条团体报名项(athlete_id 为空)+ 双通道 [athlete/result, team/manual]。
// 团体报名项的 results.rank 是「单位名次」的录入位,个人通道的自动名次不许写进去,
// 否则第二次发布团体手工通道会把个人名次当成团体名次再发一遍分(10/8/6 → 25/18/14)。
func TestTeamEntryDualChannelPublishTwiceIsIdempotent(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, name: "甲队", value: 90},
{entryID: 2, teamID: 1002, name: "乙队", value: 80},
{entryID: 3, teamID: 1003, name: "丙队", value: 70},
})
ev := teamEvent(1)
ev.Compare = CompareDesc
in := EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
teamManualChannel(2),
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first, second) {
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
}
for _, r := range in.Results {
if r.Rank != 0 {
t.Fatalf("团体报名项的 results.rank 被自动名次写回,得到 %+v", r)
}
}
// 团体手工通道没有录入名次:两次发布都只有个人通道的 10/8/6。
got := pointsByTeam(second.Ledger)
for _, want := range []struct {
teamID int64
points float64
}{{1001, 10}, {1002, 8}, {1003, 6}} {
if !nearlyEqual(got[want.teamID], want.points) {
t.Fatalf("单位 %d = %v,期望 %v(团体名次未录,不该多出团体分):%v",
want.teamID, got[want.teamID], want.points, got)
}
}
if len(second.Ranked) != 3 {
t.Fatalf("名次明细 = %d 条,期望只有个人通道的 3 条:%+v", len(second.Ranked), second.Ranked)
}
}
// TestAthleteManualRankNotOverwritten 是工单 A-2 的引擎侧回归:
// [athlete,result] 与 [athlete,manual] 共存时,results.rank 是人工录入的个人名次,
// 自动名次不许覆盖它;反复「发布 → 写回」必须得到同一份结果。
func TestAthleteManualRankNotOverwritten(t *testing.T) {
// 成绩 30 / 29(compare=asc:数值小的靠前 → 自动名次本该是 乙1 第 1、甲1 第 2);
// 人工名次反着填(甲1 第 1、乙1 第 2)。
entries, results := fixture([]participant{
{entryID: 1, athleteID: 101, teamID: 1001, name: "甲", value: 30, rank: 1},
{entryID: 2, athleteID: 102, teamID: 1002, name: "乙", value: 29, rank: 2},
})
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
athleteResultChannel(1),
{ID: 2, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: 1},
},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first, second) {
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
}
if in.Results[0].Rank != 1 || in.Results[1].Rank != 2 {
t.Fatalf("人工名次被自动名次覆盖:甲=%d、乙=%d,期望 1 / 2", in.Results[0].Rank, in.Results[1].Rank)
}
// 自动通道 8/10 + 人工通道 10/8:两队各 18 分。
got := pointsByTeam(second.Ledger)
if !nearlyEqual(got[1001], 18) || !nearlyEqual(got[1002], 18) {
t.Fatalf("两队应各 18 分(自动 + 人工),得到 %v", got)
}
}
// TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归:
// 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。
func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) {
@@ -0,0 +1,359 @@
package domain
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"testing"
)
// 任务 F:修正「末位归零」的语义。
//
// 修复前的判据是「本组占用的名次区间是否包含末位名次」:
//
// startRank <= lastRank && lastRank <= startRank+size-1
//
// 并列组只要跨过末位就整组 0 分,于是 7 人里三人并列第 5(占 5/6/7)时,
// 第 5 名的 2 分与第 6 名的 1 分被一起抹掉(审查报告 01 的 P1)。
//
// 修复后的语义:**末位那一档(名次 == lastPlace)不得分**,并列组按起始名次取分,
// 所以只有起始名次正好落在末位档的组才归零——那种组必然只有一个人(startRank == lastPlace
// 蕴含 size == 1,因为 lastPlace 是所有组占用名次的最大值)。区间跨过末位的并列组不归零。
//
// 修复前后实测对照(个人成绩通道,模板 10/8/6/3/2/1,lastPlaceZero=true):
//
// ① 7 人三人并列第 5(占 5/6/7):修前 [10 8 6 3 0 0 0] → 修后 [10 8 6 3 2 2 2]
// ② 5 人独占第 5 名: 修前 [10 8 6 3 0] → 修后 [10 8 6 3 0](真末位,继续归零)
// ③ 6 人两人并列第 5(占 5/6): 修前 [10 8 6 3 0 0] → 修后 [10 8 6 3 2 2]
//
// 名次(Rank)不受本修复影响:修复只改「这一组拿多少分」,不改谁排第几。
// scoreValueChannel 用个人成绩通道(basis=result)跑一次计分,返回 entryID 1..len(values)
// 逐条的积分与展示名次。
func scoreValueChannel(t *testing.T, profile ScoringProfile, values ...float64) ([]float64, []int) {
t.Helper()
entries, results := fixture(lineupValues(values...))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9)},
Profiles: map[int64]ScoringProfile{9: profile},
Entries: entries, Results: results,
})
pts := pointsByEntry(es.Ledger)
ranks := rankByEntry(es.Ranked)
out := make([]float64, len(values))
outRanks := make([]int, len(values))
for i := range values {
out[i] = pts[int64(i+1)]
outRanks[i] = ranks[int64(i+1)]
}
return out, outRanks
}
// scoreManualTeamChannel 用团体手工名次通道(basis=manual + scope=team)跑一次计分,
// 返回按录入顺序(单位 1001、1002 …)的积分。
func scoreManualTeamChannel(t *testing.T, profile ScoringProfile, ranks []int) []float64 {
t.Helper()
teamRanks := make([]TeamManualRank, 0, len(ranks))
for i, rk := range ranks {
teamRanks = append(teamRanks, TeamManualRank{TeamID: int64(1001 + i), Rank: rk})
}
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(9)},
Profiles: map[int64]ScoringProfile{9: profile},
TeamRanks: teamRanks,
})
pts := pointsByTeam(es.Ledger)
out := make([]float64, len(ranks))
for i := range ranks {
out[i] = pts[int64(1001+i)]
}
return out
}
// scoreAggregateChannel 用团体聚合通道(basis=aggregate)跑一次计分,每个单位一条报名项,
// 返回按单位 1001、1002 … 顺序的积分。
func scoreAggregateChannel(t *testing.T, profile ScoringProfile, mode AvgMode, values ...float64) []float64 {
t.Helper()
ps := make([]participant, 0, len(values))
for i, v := range values {
ps = append(ps, participant{
entryID: int64(i + 1), athleteID: int64(100 + i + 1), teamID: int64(1001 + i),
name: "选手" + string(rune('A'+i)), value: v,
})
}
entries, results := fixture(ps)
ev := teamEvent(1)
ev.AvgMode = mode
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(9)},
Profiles: map[int64]ScoringProfile{9: profile},
Entries: entries, Results: results,
})
pts := pointsByTeam(es.Ledger)
out := make([]float64, len(values))
for i := range values {
out[i] = pts[int64(1001+i)]
}
return out
}
// TestLastPlaceZeroOnlyZeroesLastPlaceTier 覆盖工单任务 F 要求的三种形态。
func TestLastPlaceZeroOnlyZeroesLastPlaceTier(t *testing.T) {
profile := individualTop6()
profile.LastPlaceZero = true
cases := []struct {
name string
values []float64
wantPoints []float64
wantRanks []int
}{
{
// 修复前这里断言的是 [10 8 6 3 0 0 0]——整组被末位判据误伤,第 5、6 名白丢 2+1 分。
name: "① 7 人三人并列第 5(占 5/6/7):不归零,按第 5 名给分",
values: []float64{1, 2, 3, 4, 5, 5, 5},
wantPoints: []float64{10, 8, 6, 3, 2, 2, 2},
wantRanks: []int{1, 2, 3, 4, 5, 5, 5},
},
{
name: "② 5 人独占第 5 名:这一档就是末位,归零",
values: []float64{1, 2, 3, 4, 5},
wantPoints: []float64{10, 8, 6, 3, 0},
wantRanks: []int{1, 2, 3, 4, 5},
},
{
name: "③ 6 人末位与第 5 名并列(占 5/6):起始名次 5 < 末位 6,不归零",
values: []float64{1, 2, 3, 4, 5, 5},
wantPoints: []float64{10, 8, 6, 3, 2, 2},
wantRanks: []int{1, 2, 3, 4, 5, 5},
},
{
name: "7 人两人并列第 6(占 6/7):不归零,按第 6 名给分",
values: []float64{1, 2, 3, 4, 5, 6, 6},
wantPoints: []float64{10, 8, 6, 3, 2, 1, 1},
wantRanks: []int{1, 2, 3, 4, 5, 6, 6},
},
{
name: "7 人独占第 7 名:真末位,归零",
values: []float64{1, 2, 3, 4, 5, 6, 7},
wantPoints: []float64{10, 8, 6, 3, 2, 1, 0},
wantRanks: []int{1, 2, 3, 4, 5, 6, 7},
},
{
name: "仅 1 人参赛:唯一参赛者就是末位,归零",
values: []float64{1},
wantPoints: []float64{0},
wantRanks: []int{1},
},
{
// 全场并列时起始名次是 1,没人在「末位那一档」上,所以都不归零。
name: "7 人全场并列第 1:起始名次 1 ≠ 末位 7,不归零",
values: []float64{1, 1, 1, 1, 1, 1, 1},
wantPoints: []float64{10, 10, 10, 10, 10, 10, 10},
wantRanks: []int{1, 1, 1, 1, 1, 1, 1},
},
{
name: "2 人并列第 1(占 1/2):不归零,两人同分",
values: []float64{1, 1},
wantPoints: []float64{10, 10},
wantRanks: []int{1, 1},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
points, ranks := scoreValueChannel(t, profile, tc.values...)
assertPointsAndRanks(t, points, ranks, tc.wantPoints, tc.wantRanks)
})
}
}
// TestLastPlaceZeroTeamManualAndAggregateChannels 把同一条语义钉在另外两类名次来源上:
// 团体手工名次(篮球 / 足球等纯团体项目的主路径)与团体聚合通道共用 groupPoints,
// 末位判据必须同样只抹末位那一档。
func TestLastPlaceZeroTeamManualAndAggregateChannels(t *testing.T) {
manual := teamTop6()
manual.LastPlaceZero = true
aggregate := teamTop6()
aggregate.LastPlaceZero = true
t.Run("团体手工名次:7 队独占末位 → 第 7 名归零", func(t *testing.T) {
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 6, 7})
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 1, 0})
})
t.Run("团体手工名次:三人并列第 5(占 5/6/7)→ 不归零", func(t *testing.T) {
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 5, 5})
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 2, 2})
})
t.Run("团体手工名次:录入漏顺延 1,2,2,3,4,5,6 → 只抹被占用的末日档(第 6 名)", func(t *testing.T) {
// lastPlace 取「所有组占用名次的最大值」:录入员按最自然的顺延理解写 1,2,2,3,4,5,6 时,
// 最后一个队展示的是第 6 名,就是这份录入里的末位,仍应归零。
got := scoreManualTeamChannel(t, manual, []int{1, 2, 2, 3, 4, 5, 6})
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 10, 8, 4, 2, 0})
})
t.Run("团体手工名次:5 队独占第 5 名 → 归零", func(t *testing.T) {
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5})
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 0})
})
t.Run("团体聚合:6 队末位与第 5 名并列(占 5/6)→ 不归零", func(t *testing.T) {
got := scoreAggregateChannel(t, aggregate, AvgModeValid, 1, 2, 3, 4, 5, 5)
assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 2, 2})
})
t.Run("团体聚合:5 队独占末位 → 归零", func(t *testing.T) {
got := scoreAggregateChannel(t, aggregate, AvgModeValid, 1, 2, 3, 4, 5)
assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 0})
})
}
// TestDefaultConfigScoringUnchanged 是「默认配置逐字节不变」的回归:
// lastPlaceZero=false(现场多数小项在用)时修复只动了 `if profile.LastPlaceZero` 里面的判据,
// 这个分支根本不进,所以任何形态的结果都必须与修复前一致。这里把值直接写死。
func TestDefaultConfigScoringUnchanged(t *testing.T) {
profile := individualTop6()
cases := []struct {
name string
values []float64
want []float64
}{
{"7 人三人并列第 5", []float64{1, 2, 3, 4, 5, 5, 5}, []float64{10, 8, 6, 3, 2, 2, 2}},
{"6 人两人并列第 5", []float64{1, 2, 3, 4, 5, 5}, []float64{10, 8, 6, 3, 2, 2}},
{"5 人独占第 5 名", []float64{1, 2, 3, 4, 5}, []float64{10, 8, 6, 3, 2}},
{"7 人无并列", []float64{1, 2, 3, 4, 5, 6, 7}, []float64{10, 8, 6, 3, 2, 1, 0}},
{"1 人参赛", []float64{1}, []float64{10}},
{"2 人并列第 1", []float64{1, 1}, []float64{10, 10}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
points, _ := scoreValueChannel(t, profile, tc.values...)
assertFloatSlice(t, "积分", points, tc.want)
})
}
manual := teamTop6()
t.Run("团体手工名次", func(t *testing.T) {
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 6, 7})
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 1, 0})
})
t.Run("团体聚合", func(t *testing.T) {
got := scoreAggregateChannel(t, manual, AvgModeValid, 1, 2, 3, 4, 5, 5)
assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 2, 2})
})
}
// defaultConfigGoldenSHA256 是 defaultConfigScoreDump 的签名,取自**修复前**的代码
// (2026-09-13 任务 F 开工时记录)。覆盖:成绩通道 1..9 人 × 19 种成绩形态 × 三种并列口径、
// 团体手工名次 14 种录入形态、团体聚合两种 avg_mode × 8 种成绩形态。
// 这些形态在默认配置(lastPlaceZero=false)下一分都不该变。
const defaultConfigGoldenSHA256 = "2c7425cb7371d584ca435403cea0ec9c03d069e6f08f6fdc21531845702b11f8"
// TestDefaultConfigScoringDumpUnchanged 是本任务对「lastPlaceZero=false 逐字节一致」的全量兜底:
// 逐个形态的积分拼成一份签名与修复前对比。失败时会把完整签名打出来,方便定位是哪个形态变了。
func TestDefaultConfigScoringDumpUnchanged(t *testing.T) {
dump := defaultConfigScoreDump(t)
sum := sha256.Sum256([]byte(dump))
got := hex.EncodeToString(sum[:])
if got != defaultConfigGoldenSHA256 {
t.Fatalf("lastPlaceZero=false 的计分结果与修复前不一致:\n签名 = %s\n期望 = %s\n\n%s",
got, defaultConfigGoldenSHA256, dump)
}
}
// defaultConfigScoreDump 把默认配置下各形态的逐条积分拼成一份稳定文本。
// 构造只依赖引擎输出,不依赖 map 遍历顺序,所以修复前后跑出来的文本可以直接比对。
func defaultConfigScoreDump(t *testing.T) string {
t.Helper()
var lines []string
add := func(name string, points []float64) {
lines = append(lines, fmt.Sprintf("%s => %s", name, formatPoints(points)))
}
valueShapes := [][]float64{
{1}, {1, 2}, {1, 1}, {1, 2, 3}, {1, 1, 1}, {1, 2, 2},
{1, 2, 3, 4}, {1, 2, 3, 5}, {1, 1, 2, 3}, {1, 1, 1, 1},
{1, 2, 3, 4, 5}, {1, 2, 3, 4, 4}, {1, 2, 3, 4, 5, 5},
{1, 1, 1, 1, 1, 1, 1}, {1, 2, 3, 4, 5, 5, 5}, {1, 2, 3, 4, 5, 6, 6},
{1, 2, 3, 4, 5, 6, 7}, {1, 2, 3, 4, 5, 6, 7, 8}, {1, 2, 3, 4, 5, 6, 7, 8, 9},
}
for _, tie := range []TieMode{TieSkip, TieAverage, TieHigher} {
for i, values := range valueShapes {
points, _ := scoreValueChannel(t, nineProfile(tie), values...)
add(fmt.Sprintf("result/%s/shape%02d/%s", tie, i, formatPoints(values)), points)
}
}
manualShapes := [][]int{
{1}, {1, 2}, {1, 1}, {1, 2, 3}, {1, 1, 1}, {1, 2, 2}, {1, 2, 4},
{1, 2, 3, 4, 5}, {1, 2, 3, 4, 4}, {1, 2, 3, 4, 5, 5},
{1, 2, 3, 4, 5, 6, 7}, {1, 2, 2, 3, 4, 5, 6}, {1, 2, 3, 4, 5, 5, 7},
{1, 1, 1, 1, 1, 1, 1},
}
for i, ranks := range manualShapes {
got := scoreManualTeamChannel(t, teamTop6(), ranks)
add(fmt.Sprintf("manual-team/%02d/%s", i, formatInts(ranks)), got)
}
aggregateShapes := [][]float64{
{1}, {1, 2}, {1, 1}, {1, 2, 3, 4, 5}, {1, 2, 3, 4, 5, 5},
{1, 2, 3, 4, 5, 5, 5}, {1, 2, 3, 4, 5, 6, 7}, {1, 1, 1, 1, 1, 1, 1},
}
for _, mode := range []AvgMode{AvgModeValid, AvgModeAll} {
for i, values := range aggregateShapes {
got := scoreAggregateChannel(t, teamTop6(), mode, values...)
add(fmt.Sprintf("aggregate/%s/%02d/%s", mode, i, formatPoints(values)), got)
}
}
return strings.Join(lines, "\n") + "\n"
}
func assertFloatSlice(t *testing.T, what string, got, want []float64) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s 条数 = %d,期望 %d(%v)", what, len(got), len(want), got)
}
for i := range want {
if !nearlyEqual(got[i], want[i]) {
t.Errorf("%s 第 %d 项 = %v,期望 %v(完整:%v)", what, i+1, got[i], want[i], got)
}
}
}
func assertPointsAndRanks(t *testing.T, points []float64, ranks []int, wantPoints []float64, wantRanks []int) {
t.Helper()
assertFloatSlice(t, "积分", points, wantPoints)
if len(ranks) != len(wantRanks) {
t.Fatalf("名次条数 = %d,期望 %d(%v)", len(ranks), len(wantRanks), ranks)
}
for i := range wantRanks {
if ranks[i] != wantRanks[i] {
t.Errorf("第 %d 条名次 = %d,期望 %d(完整:%v)", i+1, ranks[i], wantRanks[i], ranks)
}
}
}
func formatPoints(fs []float64) string {
parts := make([]string, len(fs))
for i, f := range fs {
parts[i] = fmt.Sprintf("%v", f)
}
return "[" + strings.Join(parts, ", ") + "]"
}
func formatInts(is []int) string {
parts := make([]string, len(is))
for i, v := range is {
parts[i] = fmt.Sprintf("%d", v)
}
return "[" + strings.Join(parts, ", ") + "]"
}
+13
View File
@@ -10,6 +10,18 @@ type TeamPoints struct {
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
// count_to_team = 0 的流水(如「个人+团体」项目的个人通道)只记在个人头上,不计入单位总分。
//
// 刻意不按 EventID 过滤(也不该有人来「补」这条过滤):
// - 「event_id 为空」不等于「孤儿流水」。人工调整(source_type='adjustment')本来就允许不挂小项,
// 有 event_id 为 NULL 的合法行(写入口 repo_misc.go 的 createAdjustment,由迁移测试钉住),
// 一刀切过滤会把合法的调整分一起吞掉;
// - 就算再收窄到 source_type='result',过滤也只是「让分悄悄消失」:没有作废人、没有作废原因、
// 没有审计行。积分变动必须可追溯,所以作废只能发生在写侧(如 deleteEvent 在同一事务里
// 按 void_reason 作废),而不是读取时假装看不见;
// - 同一个「删掉的小项」在别处仍会被读到:单位积分明细(httpapi/repo_misc.go 的 teamBreakdown)
// 是另一条 SQL,过滤这里只会变成「榜上无分、明细有分」的新矛盾。
//
// 因此本函数只认调用方给它的流水:要让它少算分,就在写侧把流水作废掉。
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
if weight <= 0 {
weight = 1
@@ -33,6 +45,7 @@ func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
// SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。
// 不计入单位总分的流水(count_to_team = 0)照样算在个人头上,个人榜不受影响。
// 与 SumByTeam 同口径,同样不按 EventID 过滤(理由见 SumByTeam 的注释)。
func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
out := make(map[int64]float64)
for _, e := range entries {
+42 -21
View File
@@ -365,16 +365,18 @@ func groupByManualRank(cands []candidate) []tieGroup {
// rankGroups 给每组分配积分并产出名次明细。
func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, groups []tieGroup) []RankedResult {
lastRank := 0
// lastPlace 是本组别被占用的最大名次档,也就是「末位」那一档:skip 口径下各组的占位
// 连续铺满 1..N,它正好等于实际可排名人数 N。末位归零只抹这一档,见 groupPoints。
lastPlace := 0
for _, g := range groups {
if r := g.startRank + len(g.members) - 1; r > lastRank {
lastRank = r
if r := g.startRank + len(g.members) - 1; r > lastPlace {
lastPlace = r
}
}
var out []RankedResult
for _, g := range groups {
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category)
pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category)
for _, c := range g.members {
out = append(out, rankedResult(in, ch, c, g.startRank, pts))
}
@@ -383,14 +385,18 @@ func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, gro
}
// groupPoints 计算一组并列者拿到的分。
// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1。
func groupPoints(profile ScoringProfile, startRank, size, lastRank int, ev Event, cat EventCategory) float64 {
// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1;
// lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。
func groupPoints(profile ScoringProfile, startRank, size, lastPlace int, ev Event, cat EventCategory) float64 {
if startRank <= 0 || size <= 0 {
return 0
}
// lastPlaceZero 是对「最后一名」的覆盖;并列在末位的整组都按最后一名处理,
// 否则同组出现 0 与 1 两种分数,违背「并列同分」的语义。
if profile.LastPlaceZero && startRank <= lastRank && lastRank <= startRank+size-1 {
// lastPlaceZero 只把「末位那一档」置零:判据是起始名次**正好落在这档**。
// 这种组必然只有一个人(startRank == lastPlace 意味着 size == 1,因为 lastPlace 是所有组
// 占位名次的最大值)。并列组若从更靠前的名次起步、区间跨过末位,组内多数人本可拿到录取线内的分,
// 整组归零会把他们一起抹掉——7 人里三人并列第 5(占 5/6/7)时,第 5 名的 2 分与第 6 名的 1 分
// 都该照发,而他们并不是「末位那一档」。
if profile.LastPlaceZero && startRank == lastPlace {
return 0
}
@@ -459,13 +465,21 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
return rr
}
// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩取平均,
// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩汇成一个值,
// 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。
// 平均值分母由项目的 avg_mode 决定:
//
// 汇总口径(通道的 aggregate_mode,缺省 avg):
// - avg:团体成绩 = 成员成绩的平均值;
// - sum:团体成绩 = 成员成绩的总和。
//
// avg 的平均值分母由项目的 avg_mode 决定:
// - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分;
// - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。
// 没有报名项的主体不会成桶,自然不参与。
//
// sum 只累加有效成绩(没成绩的人按 0 计入和不计入是一样的和),所以 avg_mode 在这里只剩一个作用:
// 决定「一个有效成绩都没有的单位算不算 0 分参与排名」——valid 不参与、all 参与,与 avg 完全一致。
//
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
if in.Event.Compare == CompareNone {
@@ -510,18 +524,24 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
b.valid++
}
}
sumMode := ch.aggregateMode() == AggregateSum
allMode := in.Event.avgMode() == AvgModeAll
agg := make([]candidate, 0, len(order))
for _, id := range order {
b := buckets[id]
if b.valid == 0 && !allMode {
// 一个人都没有效成绩的单位不参与团体排名,也不得分(两种汇总口径都一样)。
continue
}
var value float64
if in.Event.avgMode() == AvgModeAll {
switch {
case sumMode:
// 求和口径:只累加有效成绩(没成绩按 0 计入与否不改变和)。
value = b.sum
case allMode:
// 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。
value = b.sum / float64(b.roster)
} else {
if b.valid == 0 {
// 一个人都没有效成绩的单位不参与团体排名,也不得分。
continue
}
default:
value = b.sum / float64(b.valid)
}
c := candidate{entry: b.entry, subject: b.subject, value: value}
@@ -531,16 +551,17 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
sortCandidatesByValue(agg, in.Event.Compare)
groups := groupByValue(agg)
lastRank := 0
// 与 rankGroups 同一个口径:lastPlace = 被占用的最大名次档(聚合结果同样是 skip 占位)。
lastPlace := 0
for _, g := range groups {
if r := g.startRank + len(g.members) - 1; r > lastRank {
lastRank = r
if r := g.startRank + len(g.members) - 1; r > lastPlace {
lastPlace = r
}
}
var out []RankedResult
for _, g := range groups {
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category)
pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category)
for _, c := range g.members {
rr := RankedResult{
EventID: in.Event.ID,
+2 -1
View File
@@ -31,7 +31,8 @@ func TestGroupPointsTieModes(t *testing.T) {
{"skip 超出录取名次", nineProfile(TieSkip), 9, 1, 9, 0},
{"空模板口径按 skip", ScoringProfile{AwardRanks: 8, Rules: nineProfile(TieSkip).Rules}, 2, 1, 8, 7},
{"lastPlaceZero 抹掉最后一名", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 1, 1, 1, 0},
{"lastPlaceZero 抹掉并列末位整组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 0},
// 末位档是 3(三人参赛),这组从第 2 名起步、区间跨过末位 → 只吞末位那一档,不整组归零。
{"lastPlaceZero 不牵连跨末位的并列组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 7},
{"权重相乘", nineProfile(TieSkip), 1, 1, 8, 9},
}
for _, tc := range cases {
+61 -12
View File
@@ -19,6 +19,13 @@ var (
defaultAthleteTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakName}
)
// 单位奖牌榜的排序口径(主人 2026-09-13 拍板):金牌 → 银牌 → 铜牌 → 单位编码。
//
// 它**故意不读** StandingsInput.TieBreak:那套键是给总积分榜与个人榜的,
// 规则包里写的 `points,gold,...` 一进来,奖牌榜就又变回"总分优先",
// 会出现"金牌 6 枚的单位排在金牌 2 枚的后面"(06 报告 P1④)。
var defaultMedalTieBreak = []string{TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
// StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。
type StandingsInput struct {
Teams []Team
@@ -27,6 +34,7 @@ type StandingsInput struct {
Ranked []RankedResult
// TieBreak 是排序键顺序(如 points/gold/silver/bronze/teamCode/name);
// 空表示用默认口径。配置里的键优先,其余按默认顺序补齐,保证结果全序。
// **只作用于总积分榜与个人榜**:单位奖牌榜固定按金→银→铜→单位编码排(defaultMedalTieBreak)。
TieBreak []string
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
IndividualToTeamWeight float64
@@ -61,6 +69,19 @@ func medalEligible(rr RankedResult) bool {
// BuildTeamStandings 生成总积分榜。
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。
func BuildTeamStandings(in StandingsInput) []StandingsRow {
rows := buildTeamRows(in)
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) })
for i := range rows {
rows[i].Rank = i + 1
}
return rows
}
// buildTeamRows 汇总出「每个单位一行」的原始数据(含兜底补行),不排序、不填名次。
//
// 单位总分榜与单位奖牌榜的行集合必须完全一致(同一批单位、同样兜底),
// 差别只在排序与名次;两条路都从这里出行,避免哪天只给一个榜补了兜底行。
func buildTeamRows(in StandingsInput) []StandingsRow {
weight := effectiveWeight(in.IndividualToTeamWeight)
sums := SumByTeam(in.Ledger, weight)
medals := teamMedals(in)
@@ -87,22 +108,19 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow {
rows = append(rows, buildTeamRow(Team{ID: id}, sums[id], m))
}
}
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) })
for i := range rows {
rows[i].Rank = i + 1
}
return rows
}
// BuildMedalStandings 生成单位奖牌榜。
// 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。
//
// 行集合与总积分榜相同(同一份输入、同一套兜底),但**名次与顺序自成一套**:
// 金 → 银 → 铜 → 单位编码,与总分无关(主人 2026-09-13 拍板)。
// 因此绝不能复用总积分榜的 Rank——那正是「金牌多的排在金牌少的后面」的来源。
func BuildMedalStandings(in StandingsInput) []MedalRow {
teamRows := BuildTeamStandings(in)
out := make([]MedalRow, len(teamRows))
for i, r := range teamRows {
out[i] = MedalRow{
Rank: r.Rank,
teamRows := buildTeamRows(in)
out := make([]MedalRow, 0, len(teamRows))
for _, r := range teamRows {
out = append(out, MedalRow{
TeamID: r.TeamID,
Code: r.Code,
Name: r.Name,
@@ -112,11 +130,42 @@ func BuildMedalStandings(in StandingsInput) []MedalRow {
Silver: r.Silver,
Bronze: r.Bronze,
Total: r.Gold + r.Silver + r.Bronze,
}
})
}
sort.SliceStable(out, func(i, j int) bool { return lessMedalRow(out[i], out[j]) })
for i := range out {
out[i].Rank = i + 1
}
return out
}
// lessMedalRow 是单位奖牌榜的排序:金 DESC → 银 DESC → 铜 DESC → 单位编码 ASC。
// 只用 defaultMedalTieBreak 这几个键(不掺 StandingsInput.TieBreak),最后用单位 ID 兜底,
// 保证任意两行都有确定先后(结果全序)。
func lessMedalRow(a, b MedalRow) bool {
for _, key := range defaultMedalTieBreak {
switch key {
case TieBreakGold:
if a.Gold != b.Gold {
return a.Gold > b.Gold
}
case TieBreakSilver:
if a.Silver != b.Silver {
return a.Silver > b.Silver
}
case TieBreakBronze:
if a.Bronze != b.Bronze {
return a.Bronze > b.Bronze
}
case TieBreakTeamCode:
if a.Code != b.Code {
return a.Code < b.Code
}
}
}
return a.TeamID < b.TeamID
}
// BuildAthleteStandings 生成个人积分奖牌榜。
// 排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
+88
View File
@@ -147,6 +147,94 @@ func TestMedalStandings(t *testing.T) {
}
}
// medalRanked 造一条能拿奖牌的名次明细:一个小项一条,避免被「同一主体只记一枚」去重掉。
func medalRanked(teamID, eventID int64, rank int) RankedResult {
rr := mkRanked(teamID, ScopeTeam, rank)
rr.EventID = eventID
rr.CategoryID = 1
return rr
}
// medalPodium 给一个单位凑出金/银/铜若干枚(各占一个小项),firstEventID 用来错开小项。
func medalPodium(teamID, firstEventID int64, gold, silver, bronze int) []RankedResult {
out := make([]RankedResult, 0, gold+silver+bronze)
next := firstEventID
for i := 0; i < gold; i++ {
out = append(out, medalRanked(teamID, next, 1))
next++
}
for i := 0; i < silver; i++ {
out = append(out, medalRanked(teamID, next, 2))
next++
}
for i := 0; i < bronze; i++ {
out = append(out, medalRanked(teamID, next, 3))
next++
}
return out
}
// TestMedalStandingsIndependentOfPointsBoard 是 06 报告 P1④ 的回归用例(主人 2026-09-13 拍板):
// 单位1 金牌 6 枚但只有 10 分,单位2 金牌 2 枚却拿了 30 分。
// 修前:单位奖牌榜直接沿用总积分榜的顺序与名次,单位1 排在单位2 后面(观众看到"金牌多的排后面")。
// 修后:奖牌榜按金→银→铜排,单位1 榜首;总积分榜仍按总分排,单位2 榜首——两个榜解耦。
func TestMedalStandingsIndependentOfPointsBoard(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 30)}
ranked := append(medalPodium(1, 1, 6, 0, 0), medalPodium(2, 101, 2, 0, 0)...)
in := StandingsInput{Teams: testTeams(1, 2), Ledger: ledger, Ranked: ranked}
points := BuildTeamStandings(in)
if len(points) != 2 || points[0].TeamID != 2 || points[0].Rank != 1 || points[1].TeamID != 1 || points[1].Rank != 2 {
t.Fatalf("总积分榜必须仍按总分排(单位2 30 分在前,名次 1):%+v", points)
}
medal := BuildMedalStandings(in)
if len(medal) != 2 {
t.Fatalf("奖牌榜行数 = %d,期望 2", len(medal))
}
if medal[0].TeamID != 1 || medal[0].Rank != 1 || medal[0].Gold != 6 {
t.Fatalf("奖牌榜榜首应为金牌 6 枚的单位1 且名次 1:%+v", medal)
}
if medal[1].TeamID != 2 || medal[1].Rank != 2 || medal[1].Gold != 2 {
t.Fatalf("奖牌榜次席应为金牌 2 枚的单位2 且名次 2:%+v", medal)
}
// 名次重排了,但总分列仍照实带出:单位2 在奖牌榜上依然是 30 分。
if !nearlyEqual(medal[1].Points, 30) {
t.Fatalf("奖牌榜的总分列 = %v,期望 30", medal[1].Points)
}
// 规则包的 tie_break 以 points 打头,也不能把奖牌榜拉回总分口径。
configured := in
configured.TieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
if got := BuildMedalStandings(configured); got[0].TeamID != 1 || got[0].Rank != 1 {
t.Fatalf("tie_break=points,… 时奖牌榜榜首 = 单位 %d,期望仍为金牌多的单位1:%+v", got[0].TeamID, got)
}
}
// 奖牌相同时才比下一级:银 → 铜 → 单位编码,名次始终是 1..n 的全序。
func TestMedalStandingsSortChain(t *testing.T) {
cases := []struct {
name string
ranked []RankedResult
want int64
}{
{"金相同比银", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 1, 0)...), 2},
{"金、银相同比铜", append(medalPodium(1, 1, 1, 1, 0), medalPodium(2, 101, 1, 1, 1)...), 2},
{"奖牌全同按单位编码 A < B", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 0, 0)...), 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rows := BuildMedalStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: tc.ranked})
if rows[0].TeamID != tc.want {
t.Fatalf("榜首 = 单位 %d,期望 %d:%+v", rows[0].TeamID, tc.want, rows)
}
if rows[0].Rank != 1 || rows[1].Rank != 2 {
t.Fatalf("名次 = %d/%d,期望 1/2", rows[0].Rank, rows[1].Rank)
}
})
}
}
func TestAthleteStandings(t *testing.T) {
ledger := []LedgerEntry{
ledgerForAthlete(1001, 1, 10),
+25 -1
View File
@@ -35,6 +35,17 @@ const (
AvgModeAll AvgMode = "all"
)
// AggregateMode 聚合通道(basis=aggregate)的汇总口径:把同一主体的成绩汇成一个值的方式。
// 与 AvgMode(平均值内部的分母口径)是两个维度,可以组合,见 scoreByAggregate。
type AggregateMode string
const (
// AggregateAvg 按主体成员成绩的平均值排名(缺省口径,与升级前行为一致)。
AggregateAvg AggregateMode = "avg"
// AggregateSum 按主体成员成绩的总和排名。
AggregateSum AggregateMode = "sum"
)
// ResultType 成绩的物理量类型,决定比较与展示方式。
type ResultType string
@@ -229,7 +240,8 @@ type EventCategory struct {
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由本通道的 aggregate_mode 决定
// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。
type ScoreChannel struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
@@ -237,6 +249,9 @@ type ScoreChannel struct {
Basis Basis `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder,omitempty"`
// AggregateMode 是 basis=aggregate 通道的汇总口径;空值按缺省(avg)处理,
// 只对 aggregate 通道有意义,其它通道即使带着该字段也不参与计算。
AggregateMode AggregateMode `json:"aggregateMode,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。
// 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `json:"countToTeam,omitempty"`
@@ -462,6 +477,15 @@ func (c ScoreChannel) countToTeam() bool {
return c.CountToTeam == nil || *c.CountToTeam
}
// aggregateMode 未配置或配错时按 avg 处理(与库里的默认值一致):
// 缺字段的老数据、老客户端保存的通道都维持「按成员平均」的既有口径。
func (c ScoreChannel) aggregateMode() AggregateMode {
if c.AggregateMode == AggregateSum {
return AggregateSum
}
return AggregateAvg
}
// CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。
func (c ScoreChannel) CountsToMedals() bool {
return c.CountToMedals == nil || *c.CountToMedals
+15 -1
View File
@@ -6,6 +6,9 @@ import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
//
// 字段名与 web/src/shared/types.ts 的 StandingRow 对齐:大屏与管理端共用一份解析逻辑,
// 不能再把 domain.StandingsRow 的 points/code/medals 直接下发。
//
// 两个榜共用这一行:rank 是总积分榜名次(总分优先),medalRank 是单位奖牌榜名次
// (金 → 银 → 铜 → 单位编码,与总分无关)。金牌多但总分低的单位,medalRank 会比 rank 靠前。
type StandingRow struct {
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
@@ -14,6 +17,7 @@ type StandingRow struct {
Color *string `json:"color"`
LogoPath *string `json:"logoPath"`
Rank int `json:"rank"`
MedalRank int `json:"medalRank"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
@@ -23,12 +27,21 @@ type StandingRow struct {
}
// StandingRows 把 domain 榜单转换成线上结构。
//
// rows 是总积分榜(顺序与 Rank 都按总分),medal 是同一份输入算出的单位奖牌榜
// (顺序与名次按金→银→铜)。两个榜的行集合相同,这里用团队 id 把奖牌榜名次并进每一行,
// 于是 REST / WS 快照 / xlsx 导出三处拿到的奖牌榜口径完全一致(同一个 domain.BuildMedalStandings)。
//
// teams 用于补齐颜色/logo 等 domain 榜单没带的展示字段;rankChange 快照没有对比基准,与 REST 一致下发 null。
func StandingRows(rows []domain.StandingsRow, teams []domain.Team) []StandingRow {
func StandingRows(rows []domain.StandingsRow, medal []domain.MedalRow, teams []domain.Team) []StandingRow {
byID := make(map[int64]domain.Team, len(teams))
for _, t := range teams {
byID[t.ID] = t
}
medalRanks := make(map[int64]int, len(medal))
for _, m := range medal {
medalRanks[m.TeamID] = m.Rank
}
out := make([]StandingRow, 0, len(rows))
for _, r := range rows {
@@ -39,6 +52,7 @@ func StandingRows(rows []domain.StandingsRow, teams []domain.Team) []StandingRow
TeamName: r.Name,
ShortName: nilIfEmpty(r.ShortName),
Rank: r.Rank,
MedalRank: medalRanks[r.TeamID],
Gold: r.Gold,
Silver: r.Silver,
Bronze: r.Bronze,
+64 -3
View File
@@ -1,6 +1,7 @@
package export
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -9,6 +10,7 @@ import (
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/revisions"
)
// Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。
@@ -646,7 +648,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
COALESCE(r.unit, ''),
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0),
COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0)
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id
@@ -673,13 +676,15 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
defer func() { _ = rows.Close() }()
var out []TeamDetailRow
// 每条流水背后的成绩身份((小项, 报名项),口径见 internal/revisions),用来补「备注」列的修订痕迹。
keys := make([]revisions.Key, 0, len(out))
for rows.Next() {
var r TeamDetailRow
var source string
var athleteID int64
var athleteID, eventID, entryID int64
var resultText, unit string
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID); err != nil {
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
// 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩
@@ -687,14 +692,70 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
r.ResultText = displayResult(resultText, unit)
r.Source = sourceLabel(source)
r.individual = athleteID != 0
keys = append(keys, revisions.KeyFor(source, eventID, entryID))
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历积分明细: %w", err)
}
attachRevisionRemarks(out, keys, loadRevisionTrails(db, keys))
return out, nil
}
// loadRevisionTrails 取这些成绩的修订痕迹;查不出来只留空,导出照旧出。
//
// 导出件与屏幕上写的必须是同一句话(口径见 internal/revisions):明细在屏幕上挂着
// 「已修订 ×2」,xlsx 里一个字没有,事后拿表格对账的人就不知道该信哪份。
func loadRevisionTrails(db *sql.DB, keys []revisions.Key) map[revisions.Key]revisions.Trail {
trails, err := revisions.Load(context.Background(), db, keys)
if err != nil {
return nil
}
return trails
}
// attachRevisionRemarks 把修订痕迹写进「备注」列:`已修订 ×2;最后一次 10:42 成绩录入笔误`。
//
// 没改过(或追不出来)的行备注留空 —— 这个列本来只写「作废理由」,而作废的流水不会出现在这里,
// 所以正常行本来就是空白,只有真被改过的成绩才会多出一句话。
func attachRevisionRemarks(rows []TeamDetailRow, keys []revisions.Key, trails map[revisions.Key]revisions.Trail) {
for i, key := range keys {
if i >= len(rows) {
return
}
trail, ok := trails[key]
if !ok || trail.Count == 0 {
continue
}
text := fmt.Sprintf("已修订 ×%d", trail.Count)
if clock := revisionClock(trail.At); clock != "" {
text += ";最后一次 " + clock
if trail.Reason != "" {
text += " " + trail.Reason
}
} else if trail.Reason != "" {
text += ";" + trail.Reason
}
if rows[i].Remark == "" {
rows[i].Remark = text
} else {
rows[i].Remark += ";" + text
}
}
}
// revisionClock 把 result_revisions.created_at 转成 `HH:mm`(本地时区)。
//
// 库里存的是 datetime('now') 的 UTC 但不带时区,按 UTC 解析再转本地 —— 与前端
// shared/format.ts 的 toDate 同一口径,否则导出件上的 10:42 会比大屏早 8 小时。
func revisionClock(at string) string {
parsed, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(at), time.UTC)
if err != nil {
return ""
}
return parsed.Local().Format("15:04")
}
func rankLabel(rank int, isTie bool) string {
if rank <= 0 {
return ""
+206
View File
@@ -3,8 +3,15 @@ package export
import (
"context"
"database/sql"
"encoding/json"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
@@ -27,6 +34,10 @@ func TestExportStandingsMatchesSnapshot(t *testing.T) {
if len(wb.Team) != len(snap.Standings) {
t.Fatalf("单位数不一致:导出 %d,快照 %d", len(wb.Team), len(snap.Standings))
}
medalRank := make(map[int64]int, len(wb.Medal))
for _, m := range wb.Medal {
medalRank[m.TeamID] = m.Rank
}
for _, s := range snap.Standings {
idx := -1
for i := range wb.Team {
@@ -43,6 +54,10 @@ func TestExportStandingsMatchesSnapshot(t *testing.T) {
row.Bronze != s.Bronze || row.Medals != s.MedalTotal || row.Rank != s.Rank {
t.Errorf("单位 %d 口径不一致:导出 %+v,快照 %+v", s.TeamID, row, s)
}
// 单位奖牌榜的名次也必须同源:xlsx 的奖牌榜名次 = 快照下发的 medalRank。
if got := medalRank[s.TeamID]; got != s.MedalRank {
t.Errorf("单位 %d 的奖牌榜名次不一致:导出 %d,快照 %d", s.TeamID, got, s.MedalRank)
}
}
}
@@ -138,6 +153,107 @@ func TestLoadStandingsHonoursTieBreak(t *testing.T) {
}
}
// 单位奖牌榜独立于积分榜(主人 2026-09-13 拍板,06 报告 P1④):
// T1 有 1 金但只 5 分、T2 无牌却 10 分——总积分榜 T2 在前,奖牌榜必须 T1 在前。
// 三处同源:xlsx 的「单位奖牌榜」sheet 与 WS 快照下发的 medalRank 都出自 domain.BuildMedalStandings。
func TestLoadStandingsMedalBoardIndependentOfPoints(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 与 TestLoadStandingsHonoursTieBreak 同一份造数:T1 金牌 1 / 5 分,T2 无牌 / 10 分。
for _, s := range []string{
`UPDATE score_ledger SET points = 5 WHERE team_id = 1`,
`UPDATE score_ledger SET points = 10 WHERE team_id = 2`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
if wb.Team[0].Code != "T2" {
t.Fatalf("总积分榜仍按总分排,榜首 = %q,期望 T2", wb.Team[0].Code)
}
if len(wb.Medal) != 2 || wb.Medal[0].Code != "T1" || wb.Medal[0].Rank != 1 || wb.Medal[0].Gold != 1 {
t.Fatalf("奖牌榜应按金牌排,榜首 = %+v,期望 T1(1 金、名次 1)", wb.Medal)
}
if wb.Medal[1].Code != "T2" || wb.Medal[1].Rank != 2 {
t.Fatalf("奖牌榜次席 = %+v,期望 T2(名次 2)", wb.Medal[1])
}
// WS 快照(大屏推送)必须给出同一套奖牌榜名次。
hub := realtime.NewHub(db, testLogger())
defer func() { _ = hub.Close() }()
snap, err := hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("读取 WS 快照: %v", err)
}
rows := make(map[string]dto.StandingRow, len(snap.Standings))
for _, r := range snap.Standings {
rows[r.TeamCode] = r
}
if rows["T1"].MedalRank != 1 || rows["T2"].MedalRank != 2 {
t.Fatalf("快照的奖牌榜名次 = T1:%d T2:%d,期望 1 / 2", rows["T1"].MedalRank, rows["T2"].MedalRank)
}
// 两套名次确实解耦:T1 总分低,总积分榜名次仍是 2。
if rows["T1"].Rank != 2 || rows["T2"].Rank != 1 {
t.Fatalf("快照的总积分榜名次 = T1:%d T2:%d,期望 2 / 1", rows["T1"].Rank, rows["T2"].Rank)
}
// xlsx 的「单位奖牌榜」sheet 必须照同一个顺序落表(表头下一行就是第一名)。
path := filepath.Join(t.TempDir(), "standings.xlsx")
if err := ExportStandingsXLSX(db, path, Options{}); err != nil {
t.Fatalf("导出总表 xlsx: %v", err)
}
f, err := excelize.OpenFile(path)
if err != nil {
t.Fatalf("打开导出件: %v", err)
}
defer func() { _ = f.Close() }()
sheet := sheetNamed(t, f, "单位奖牌榜")
sheetRows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取 %s: %v", sheet, err)
}
// 表头之前还有大标题 / 副标题 / 空行,按表头文字定位,别写死行号。
head := -1
for i, row := range sheetRows {
if len(row) > 0 && row[0] == "名次" {
head = i
break
}
}
if head < 0 {
t.Fatalf("%s 里找不到表头行:%v", sheet, sheetRows)
}
if len(sheetRows) < head+3 {
t.Fatalf("%s 数据行数 = %d,期望表头 + 2 行", sheet, len(sheetRows)-head-1)
}
// 列序:名次 / 单位代码 / 单位名称 / 金牌 / 银牌 / 铜牌 / 奖牌合计 / 总分。
first, second := sheetRows[head+1], sheetRows[head+2]
if first[0] != "1" || first[1] != "T1" || first[3] != "1" {
t.Fatalf("奖牌榜首行 = %v,期望 1 / T1 / 1 金", first)
}
if second[0] != "2" || second[1] != "T2" {
t.Fatalf("奖牌榜次行 = %v,期望 2 / T2", second)
}
}
// sheetNamed 按后缀取工作表名:导出件的工作表名带赛事名前缀(如「测试运动会 · 单位奖牌榜」)。
func sheetNamed(t *testing.T, f *excelize.File, suffix string) string {
t.Helper()
for _, name := range f.GetSheetList() {
if strings.HasSuffix(name, suffix) {
return name
}
}
t.Fatalf("导出件里没有以 %q 结尾的工作表:%v", suffix, f.GetSheetList())
return ""
}
// 停用但仍有积分的单位要显示编码/名称,不能是空行。
func TestLoadStandingsShowsInactiveTeam(t *testing.T) {
db := openEventDB(t)
@@ -605,6 +721,96 @@ func TestEventRowsIncludeDraftsKeepsDraftValues(t *testing.T) {
}
}
// 「备注」列要带上修订痕迹:屏幕上挂着「已修订 ×2」而导出件一个字没有,
// 事后拿表格对账的人就不知道该信哪份。判定口径见 internal/revisions。
func TestTeamDetailRemarkCarriesRevisionTrail(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedDetailRevisions(t, db)
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
if len(rows) < 2 {
t.Fatalf("明细行数 = %d,期望含合计行", len(rows))
}
// 备注是第 10 列(下标 9)。按「来源」找行,不按下标 —— 行序由名次决定,别把断言写死在行号上。
for _, row := range rows {
switch row[7] {
case "成绩":
if got, ok := row[9].(string); !ok ||
!regexp.MustCompile(`^已修订 ×1;最后一次 \d{2}:\d{2} 成绩录入笔误$`).MatchString(got) {
t.Errorf("成绩行备注 = %v,期望 `已修订 ×1;最后一次 HH:MM 成绩录入笔误`", row[9])
}
case "破纪录加分":
// 破纪录加分是站在成绩旁边的另一笔分,不继承成绩的修订痕迹(见 revisions.KeyFor)。
if got, _ := row[9].(string); got != "" {
t.Errorf("破纪录加分行备注 = %q,期望留空(改的是成绩,不是这笔加分)", got)
}
}
}
// 合计行的备注留空,不被带上。
if got, _ := rows[len(rows)-1][9].(string); got != "" {
t.Errorf("合计行备注 = %q,期望留空", got)
}
}
// 没改过的成绩备注必须留空 —— 导出侧同样不许出现「其实没改过却写了已修订」。
func TestTeamDetailRemarkEmptyWhenNeverRevised(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 只发布过一版(有基线、没改动):能追溯,但一个字都不该写。
writeRevision(t, db, "2026-09-13 02:40:00", "", []map[string]any{
{"entryId": 1, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
})
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
if got, _ := rows[0][9].(string); got != "" {
t.Errorf("没改过的成绩备注 = %q,期望留空", got)
}
}
// seedDetailRevisions 造一条「发布 → 改成绩 → 重新发布」的历史:张三那条 11.58 被改成 11.42。
// 两条修订都带上同小项另一条成绩,证明「别人改了」不会把修订痕迹算到自己头上。
func seedDetailRevisions(t *testing.T, db *sql.DB) {
t.Helper()
writeRevision(t, db, "2026-09-13 02:40:00", "", []map[string]any{
{"entryId": 1, "rank": 1, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "state": "published"},
{"entryId": 2, "rank": 2, "resultText": "11.90", "resultValue": 11.90, "unit": "s", "state": "published"},
})
writeRevision(t, db, "2026-09-13 02:58:00", "", []map[string]any{
{"entryId": 1, "rank": 1, "resultText": "11.42", "resultValue": 11.42, "unit": "s", "state": "published",
"note": "成绩录入笔误"},
// 名次 2 → 1 是改张三的成绩后引擎重算的,李四这条自己没动过。
{"entryId": 2, "rank": 1, "resultText": "11.90", "resultValue": 11.90, "unit": "s", "state": "published"},
})
// 同一报名项上再挂一条破纪录加分流水:它不该继承成绩的修订痕迹。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'record_bonus', 1, 3)`); err != nil {
t.Fatalf("写入破纪录加分流水: %v", err)
}
}
// writeRevision 往 result_revisions 里插一版快照(字段名与 httpapi.resultDTO 对齐)。
func writeRevision(t *testing.T, db *sql.DB, createdAt, message string, results []map[string]any) {
t.Helper()
raw, err := json.Marshal(results)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
if _, err := db.Exec(`INSERT INTO result_revisions (event_id, action, snapshot_json, message, created_at)
VALUES (1, 'publish', ?, ?, ?)`, string(raw), message, createdAt); err != nil {
t.Fatalf("插入修订: %v", err)
}
}
// equalCells 逐格比较两行;float64 与 string 都按值比。
func equalCells(got, want []any) bool {
if len(got) != len(want) {
+3 -1
View File
@@ -157,7 +157,9 @@ func medalRow(r MedalDetailRow) []any {
}
// LoadMedalDetailWorkbook 读取全部奖牌来源。
// 单位顺序沿用「单位奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。
// 单位顺序按「单位总分榜」(teamStandingsOrder,得分高的在前),**不跟单位奖牌榜**:
// 奖牌榜已独立按金→银→铜排(domain.BuildMedalStandings),两份顺序本来就不一样。
// 没有奖牌的单位不出分表。
func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, error) {
if db == nil {
return MedalDetailWorkbook{}, ErrNoDatabase
@@ -0,0 +1,233 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// breakdownItems 读某单位的积分明细行。
func breakdownItems(t *testing.T, env *testEnv, client *http.Client, teamID int64) []map[string]any {
t.Helper()
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", teamID), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
rows := decodeData[map[string]any](t, env2)["items"].([]any)
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
t.Fatalf("明细行不是对象: %+v", row)
}
out = append(out, item)
}
return out
}
// publishTrailResults 走真实的草稿 + 发布接口发布一批成绩(每次换一个 requestId,不走幂等缓存)。
func publishTrailResults(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string,
rows ...map[string]any) {
t.Helper()
if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID, "requestId": tag + "-draft", "results": rows,
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// 「发布 → 裁判发现点错、退回草稿改一遍 → 重新发布」之后,明细要能说清这条成绩被改过、
// 最后一次因为什么改、什么时候 —— 现场观众看到的分要是解释得清的。
//
// 同时钉住「重新发布会产生新流水行」这件事:修订痕迹跟着 (小项, 报名项) 这条线走,
// 换了流水 id 照样追得到。
func TestBreakdownCarriesRevisionTrailAfterRepublish(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
firstLedger := items[0]["ledgerId"]
if got := items[0]["revisionCount"]; got != float64(0) {
t.Fatalf("刚发布、没人改过的成绩 revisionCount 应为 0,得到 %v", got)
}
if items[0]["lastRevisionAt"] != nil || items[0]["lastRevisionReason"] != nil {
t.Fatalf("没改过的成绩不该有修订时间 / 原因: %+v", items[0])
}
// 裁判发现成绩录错:退回草稿(写理由)→ 改成正确值 → 重新发布。
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s",
"status": "ok", "note": "成绩录入笔误"})
items = breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
item := items[0]
if item["ledgerId"] == firstLedger {
t.Fatalf("重新发布应作废旧流水、写一条新流水(旧 id %v),得到 %v", firstLedger, item["ledgerId"])
}
if got := item["revisionCount"]; got != float64(1) {
t.Fatalf("改过一次的成绩 revisionCount 应为 1,得到 %v(%+v)", got, item)
}
if got := item["lastRevisionReason"]; got != "成绩录入笔误" {
t.Fatalf("最后一次修订原因 = %v,期望 成绩录入笔误", got)
}
if at, ok := item["lastRevisionAt"].(string); !ok || at == "" {
t.Fatalf("最后一次修订时间应下发,得到 %v", item["lastRevisionAt"])
}
if got := item["lastRevisedBy"]; got != "recorder" {
t.Fatalf("最后一次修订的操作人 = %v,期望 recorder", got)
}
// 未登录访客(大屏读的就是这个接口)拿得到「被改过、什么时候、为什么」,
// 但拿不到「是谁点错的」—— 那是裁判自己看的账,与 /api/revisions 只对 recorder 开放同一口径。
guest, _ := env.client()
items = breakdownItems(t, env, guest, ids.teamA)
if got := items[0]["revisionCount"]; got != float64(1) {
t.Fatalf("访客也该看到修订次数,得到 %v", got)
}
if got := items[0]["lastRevisionReason"]; got != "成绩录入笔误" {
t.Fatalf("访客也该看到修订原因,得到 %v", got)
}
if got := items[0]["lastRevisedBy"]; got != nil {
t.Fatalf("访客不该拿到操作人姓名,得到 %v", got)
}
}
// 一次都没改过的成绩不能标成「已修订」——这是最要防的误报。
//
// 两次发布之间引擎会重算名次、把 state / verified_at 重写一遍,同小项别人的成绩也在变;
// 这些都不该让一条没动过的成绩挂上角标。
func TestBreakdownKeepsUnchangedResultUnrevised(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"})
// A 单位这条成绩一个字没改,只是把别人的成绩改了一次再发布(名次会跟着重算)。
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryB).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "计时有误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryB, "resultValue": 11.60, "resultText": "11.60", "unit": "s", "status": "ok"})
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
for field, want := range map[string]any{
"revisionCount": float64(0),
"lastRevisionAt": nil,
"lastRevisionReason": nil,
"lastRevisedBy": nil,
} {
if got := items[0][field]; got != want {
t.Fatalf("没改过的成绩 %s 应为 %v,得到 %v(%+v)", field, want, got, items[0])
}
}
}
// 破纪录加分这张流水是站在成绩旁边的另一笔分:它不继承成绩的修订痕迹。
// 挂上去等于对外说「那笔加分被改过」,而现场改的是成绩 —— 那是误报。
func TestBreakdownRecordBonusDoesNotInheritTrail(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s", "status": "ok"})
// 同一报名项再挂一条破纪录加分流水(与 writeLedgerTx 产出的形态一致:带 entry_id / result_id)。
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, ?, ?, ?, ?, 'record_bonus', 1, 3, 1)`,
ids.teamA, ids.athleteA, ids.event, ids.entryA, resultID)
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 2 {
t.Fatalf("明细行数 = %d,期望 2(成绩 + 破纪录加分)", len(items))
}
for _, item := range items {
switch item["sourceType"] {
case "result":
if got := item["revisionCount"]; got != float64(1) {
t.Fatalf("被改过的成绩应报 1 次修订,得到 %v(%+v)", got, item)
}
case "record_bonus":
if got := item["revisionCount"]; got != nil {
t.Fatalf("破纪录加分不继承成绩的修订痕迹,得到 %v(%+v)", got, item)
}
default:
t.Fatalf("意外的来源类型: %+v", item)
}
}
}
// 聚合流水(按队内平均)与人工调整没有「一条成绩」可追:整组字段留空,不许猜一个数字出来。
func TestBreakdownAggregateAndAdjustmentHaveNoTrail(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, NULL, NULL, NULL, 'adjustment', 1, 5, 1)`, ids.teamA)
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 2 {
t.Fatalf("明细行数 = %d,期望 2", len(items))
}
for _, item := range items {
if got := item["revisionCount"]; got != nil {
t.Fatalf("追不出来源的流水 revisionCount 应为 null,得到 %v(%+v)", got, item)
}
}
}
@@ -0,0 +1,157 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 聚合口径(通道的 aggregateMode:avg 平均 / sum 总和)的 REST 契约与端到端效果。
//
// 契约:
// - 请求体写 sum 能存下、读回来还是 sum;
// - 不写该字段(旧客户端 / 老配置)落成缺省 avg,响应里不下发——老数据的响应体一个字节都不变;
// - 认不出的取值 400 拦下,而不是静默按 avg 处理(拼错的配置会静默改口径,现场看不出来)。
//
// 效果:现场切换口径后重算,单位总分(此处看流水)必须按新口径走。
func TestChannelAggregateModeSwitch(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
// 团体引体向上:A 队 2 人(10 + 1)、B 队 1 人(9)。
// 总数:A 11 > B 9;平均:A 5.5 < B 9 —— 两种口径的名次正好互换。
ev := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'TEAM_PULLUP', '团体引体向上', 'individual_team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ev)
athA1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('甲', '男', ?, 1)`, ids.teamA)
athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('乙', '男', ?, 1)`, ids.teamA)
entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA1)
entryA2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA2)
entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamB, ids.athB)
chans := fmt.Sprintf("/api/events/%d/channels", ev)
// setMode 通过 REST 写通道配置;mode 为空串表示不带该字段(模拟旧客户端)。
setMode := func(mode string) []map[string]any {
t.Helper()
channel := map[string]any{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1}
if mode != "" {
channel["aggregateMode"] = mode
}
status, resp := env.do(client, http.MethodPut, chans, map[string]any{"channels": []map[string]any{channel}}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("写通道(aggregateMode=%q)应 200,得到 %d %+v", mode, status, resp.Error)
}
return decodeData[[]map[string]any](t, resp)
}
storedMode := func() string {
t.Helper()
var mode string
if err := env.ev.QueryRow(`SELECT aggregate_mode FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, ev).Scan(&mode); err != nil {
t.Fatalf("读取落库的聚合口径: %v", err)
}
return mode
}
listedMode := func() (string, bool) {
t.Helper()
status, resp := env.do(client, http.MethodGet, chans, nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error)
}
got := decodeData[[]map[string]any](t, resp)
raw, ok := got[0]["aggregateMode"]
if !ok {
return "", false
}
return raw.(string), true
}
teamLedger := func() map[int64]float64 {
t.Helper()
rows, err := env.ev.Query(`SELECT team_id, SUM(points) FROM score_ledger
WHERE voided = 0 AND event_id = ? GROUP BY team_id`, ev)
if err != nil {
t.Fatalf("统计单位流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]float64)
for rows.Next() {
var teamID int64
var points float64
if err := rows.Scan(&teamID, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out[teamID] = points
}
return out
}
// 1) 缺省:不写 aggregateMode,落库是 avg,响应里也不下发该字段(老数据响应体不变)。
channels := setMode("")
if _, ok := channels[0]["aggregateMode"]; ok {
t.Errorf("缺省口径(avg)不应出现在响应里,得到 %+v", channels[0])
}
if got := storedMode(); got != "avg" {
t.Fatalf("不带 aggregateMode 落库 = %q,期望 avg", got)
}
// 录成绩并发布:B 队平均 9 第一(15 分),A 队平均 5.5 第二(10 分)。
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ev,
"results": []map[string]any{
{"entryId": entryA1, "resultValue": 10, "unit": "个", "status": "ok"},
{"entryId": entryA2, "resultValue": 1, "unit": "个", "status": "ok"},
{"entryId": entryB1, "resultValue": 9, "unit": "个", "status": "ok"},
},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ev}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
}
if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 {
t.Fatalf("avg 口径下单位流水 = %+v,期望 A=10 B=15", got)
}
// 2) 现场切成总和:改通道配置即触发重算,名次互换(A 11 > B 9)。
channels = setMode("sum")
if channels[0]["aggregateMode"] != "sum" {
t.Errorf("sum 口径应原样读回,得到 %+v", channels[0])
}
if got := storedMode(); got != "sum" {
t.Fatalf("sum 口径落库 = %q,期望 sum", got)
}
if got, ok := listedMode(); !ok || got != "sum" {
t.Fatalf("通道列表应带 aggregateMode=sum,得到 %q(下发=%v)", got, ok)
}
if got := teamLedger(); got[ids.teamA] != 15 || got[ids.teamB] != 10 {
t.Fatalf("sum 口径下单位流水 = %+v,期望 A=15 B=10", got)
}
// 3) 切回缺省:不带该字段保存后,口径与落到 avg 的榜都要回来。
setMode("")
if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 {
t.Fatalf("切回 avg 后单位流水 = %+v,期望 A=10 B=15", got)
}
// 4) 认不出的取值 400,且不能把已配好的口径改坏。
status, resp = env.do(client, http.MethodPut, chans, map[string]any{
"channels": []map[string]any{
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1, "aggregateMode": "average"},
},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || !strings.Contains(resp.Error.Message, "aggregateMode") {
t.Fatalf("非法聚合口径应 400 并点明 aggregateMode,得到 %d %+v", status, resp.Error)
}
if got := storedMode(); got != "avg" {
t.Fatalf("被拒的请求不应改动落库口径,得到 %q", got)
}
}
+14
View File
@@ -56,6 +56,10 @@ type channelDTO struct {
Basis string `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder"`
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均 / sum 按成员总和。
// 请求体里缺省(空串)按 avg 处理,不带该字段的旧客户端保存通道时口径不变。
// 响应里只下发非缺省值(sum);缺省(avg)不下发,老数据的响应体因此一个字节都不变。
AggregateMode string `json:"aggregateMode,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
CountToTeam *bool `json:"countToTeam"`
@@ -188,6 +192,16 @@ type breakdownItemDTO struct {
ResultText *string `json:"resultText"`
Points float64 `json:"points"`
CreatedAt string `json:"createdAt"`
// 修订痕迹(口径见 internal/revisions):这条成绩发布之后被改过几次、最后一次因为什么改、什么时候。
//
// 三个字段一起看:RevisionCount 为 nil 表示**追不出来**(没有版本基线),为 0 表示追出来了、
// 确实没改过;前端只在 > 0 时显示「已修订 ×N」角标。宁可为空也不误报。
// LastRevisionReason / LastRevisedBy 都可能为 nil:现场没写原因、操作人已删号时就是空的。
RevisionCount *int `json:"revisionCount"`
LastRevisionAt *string `json:"lastRevisionAt"`
LastRevisionReason *string `json:"lastRevisionReason"`
LastRevisedBy *string `json:"lastRevisedBy"`
}
type teamBreakdownDTO struct {
@@ -0,0 +1,200 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"testing"
)
// 本文件钉住工单 A-1 / A-2 两条「同一份数据发布两次结果不同」的失败场景。
// 两者的共同根因是 results.rank 一列身兼两职——它既是自动算出的个人名次的回写位,
// 又是人工名次的录入位:
//
// A-1:团体报名项(athlete_id 为空)的 results.rank 被个人通道自动回写,
// 下一次发布时又被团体手工通道的兜底读成「团体名次」,团体分凭空多出一份
// (发布 #1 得 10/8/6,发布 #2 变 25/18/14);
// A-2:[athlete,result] 与 [athlete,manual] 共存时,自动名次覆盖人工名次,
// 人工通道的输入被就地改写 → 第二次发布才算对(发布 #1 得 18 分、#2 得 20 分)。
// seedDualChannelTeamEvent 建工单 A-1 的形态:team 类型小项 + 3 条团体报名项
// (athlete_id 为空,正是《报名名单导入规范》要求的团体项目填法)+ 双通道
// [athlete/result, team/manual](自带规则包曾给 TEAM_PULLUP 配的就是这两条)。
func seedDualChannelTeamEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64, entries []int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'EMIX', '混合配置试验', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "individual_top6"))
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 1)`, eventID, profileIDByCode(t, env, "team_top6"))
teamC := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active)
VALUES ('C', '第三分公司', '三司', 2, 1)`)
for _, teamID := range []int64{ids.teamA, ids.teamB, teamC} {
entries = append(entries, env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, teamID))
}
return eventID, categoryID, entries
}
// resultRank 读一条成绩的手工名次(NULL 与 0 都表示「没填」)。
func resultRank(t *testing.T, env *testEnv, entryID int64) int {
t.Helper()
var rank sql.NullInt64
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
t.Fatalf("读报名项 %d 的名次: %v", entryID, err)
}
if !rank.Valid {
return 0
}
return int(rank.Int64)
}
// resultRanksDump 导出某小项每条成绩的手工名次,用于断言「自动名次有没有被写回 results.rank」。
func resultRanksDump(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(`SELECT entry_id, COALESCE(rank, 0) AS rank, COALESCE(result_value, 0) AS value
FROM results WHERE event_id = %d ORDER BY entry_id`, eventID))
}
// A-1:团体报名项 + 双通道,连发 1~3 次必须逐字节一致。
func TestPublishTwiceWithTeamEntryAndDualChannelIsIdempotent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _, entries := seedDualChannelTeamEvent(t, env, ids)
publishResults(t, env, client, eventID, "a1-p1",
map[string]any{"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"},
)
// 团体报名项的 results.rank 是「单位名次」的录入位(团体手工通道的兜底来源),
// 个人通道算出的自动名次不许写进去——写进去第二次发布就会被当成团体名次再发一遍分。
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID); n != 0 {
t.Fatalf("团体报名项的 results.rank 被自动名次回写,得到 %d 行:\n%s", n, resultRanksDump(t, env, eventID))
}
if total := activeLedgerTotal(t, env); total != 24 {
t.Fatalf("首次发布应只有个人通道的 10+8+6=24 分(团体名次未录),得到 %v", total)
}
ledgerFirst := ledgerActiveDump(t, env)
ranksFirst := channelRanksDump(t, env)
for _, tag := range []string{"a1-p2", "a1-p3"} {
publishEvent(t, env, client, eventID, tag)
if total := activeLedgerTotal(t, env); total != 24 {
t.Fatalf("%s:重复发布后合计应为 24(团体手工通道没有名次可发),得到 %v", tag, total)
}
if got := ledgerActiveDump(t, env); got != ledgerFirst {
t.Fatalf("%s:同一份数据重复发布流水应完全一致:\n第一次 %s\n本次 %s", tag, ledgerFirst, got)
}
if got := channelRanksDump(t, env); got != ranksFirst {
t.Fatalf("%s:同一份数据重复发布名次应完全一致:\n第一次 %s\n本次 %s", tag, ranksFirst, got)
}
}
}
// A-2:同一 result 同时被「自动名次通道」与「人工名次通道」消费时,人工名次不被覆盖。
func TestManualAthleteRankNotOverwrittenByComputedRank(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 在既有的 [athlete,result] 之外再加一条 [athlete,manual](唯一约束按 scope+basis 允许共存)。
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'manual', ?, 1)`, ids.event, profileIDByCode(t, env, "individual_top6"))
// 成绩 30 / 29(compare=asc:数值小的靠前)→ 自动名次本该是 乙1 第 1、甲1 第 2;
// 人工名次反着填(甲1 第 1、乙1 第 2),两次发布都必须按人工名次算。
publishResults(t, env, client, ids.event, "a2-p1",
map[string]any{"entryId": ids.entryA, "resultValue": 30, "resultText": "30", "unit": "次", "status": "ok", "rank": 1},
map[string]any{"entryId": ids.entryB, "resultValue": 29, "resultText": "29", "unit": "次", "status": "ok", "rank": 2},
)
if rankA, rankB := resultRank(t, env, ids.entryA), resultRank(t, env, ids.entryB); rankA != 1 || rankB != 2 {
t.Fatalf("人工名次被自动名次覆盖:甲1=%d、乙1=%d,期望 1 / 2", rankA, rankB)
}
// 自动通道 8/10 + 人工通道 10/8:A 18 分、B 18 分。
if got := teamPoints(t, env, client, ids.teamA); got != 18 {
t.Fatalf("首次发布后 A 应为 18 分(自动 8 + 人工 10),得到 %v", got)
}
ledgerFirst := ledgerActiveDump(t, env)
ranksFirst := channelRanksDump(t, env)
for _, tag := range []string{"a2-p2", "a2-p3"} {
publishEvent(t, env, client, ids.event, tag)
if rankA := resultRank(t, env, ids.entryA); rankA != 1 {
t.Fatalf("%s:人工名次又被改写,甲1=%d,期望 1", tag, rankA)
}
if got := teamPoints(t, env, client, ids.teamA); got != 18 {
t.Fatalf("%s:重复发布后 A 应仍为 18 分,得到 %v", tag, got)
}
if got := ledgerActiveDump(t, env); got != ledgerFirst {
t.Fatalf("%s:同一份数据重复发布流水应完全一致:\n第一次 %s\n本次 %s", tag, ledgerFirst, got)
}
if got := channelRanksDump(t, env); got != ranksFirst {
t.Fatalf("%s:同一份数据重复发布名次应完全一致:\n第一次 %s\n本次 %s", tag, ranksFirst, got)
}
}
}
// 修 A-1 时不能把「team + basis=result」这条路的团体名次一起修断:
// 纯团体项目的团体名次(从 channel_ranks 读)必须照旧产出。
func TestTeamResultChannelStillRanksTeams(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID, teamEntry := seedTeamEvent(t, env, ids)
publishResults(t, env, client, eventID, "team-result",
map[string]any{"entryId": teamEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"},
)
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
WHERE event_id = ? AND category_id = ? AND scope = 'team' AND basis = 'result' AND rank = 1`,
eventID, categoryID); n != 1 {
t.Fatalf("纯团体项目(team + basis=result)应产出团体名次,得到 %d 行:%s", n, channelRanksDump(t, env))
}
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("纯团体项目 A 应得 15 分,得到 %v", got)
}
publishEvent(t, env, client, eventID, "team-result-again")
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("重复发布后 A 应仍为 15 分,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 1 {
t.Fatalf("纯团体项目应只有 1 条流水,得到 %d 条:%s", n, ledgerActiveDump(t, env))
}
}
// 团队手工名次(channel_manual_ranks)与团体报名项兜底两条来源并存时,以录入的团体名次为准;
// 个人通道的自动名次不得插进团体名次的来源。
func TestTeamManualRanksSurviveComputedRankWriteback(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID, entries := seedDualChannelTeamEvent(t, env, ids)
if status, env2 := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
}); status != http.StatusOK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
}
publishResults(t, env, client, eventID, "a1-manual",
map[string]any{"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
map[string]any{"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"},
)
// 个人通道 10/8/6 + 团体手工名次 15/10。
if total := activeLedgerTotal(t, env); total != 24+25 {
t.Fatalf("首次发布合计应为 49(24 个人 + 25 团体),得到 %v", total)
}
ledgerFirst := ledgerActiveDump(t, env)
publishEvent(t, env, client, eventID, "a1-manual-again")
if total := activeLedgerTotal(t, env); total != 49 {
t.Fatalf("重复发布后合计应仍为 49,得到 %v", total)
}
if got := ledgerActiveDump(t, env); got != ledgerFirst {
t.Fatalf("流水应完全一致:\n第一次 %s\n本次 %s", ledgerFirst, got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 10+15 {
t.Fatalf("A 应为个人 10 + 团体 15 = 25 分,得到 %v", got)
}
}
@@ -0,0 +1,142 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 本文件钉住审查 01 的 P0:删除小项必须让它的积分流水一起离榜。
//
// 现场形态是「球类那种」小项——纯团体、名次按单位直接录进 channel_manual_ranks,
// 一条报名项都没有。这种小项正是唯一能被 DELETE /api/events/{id} 删掉、却又在榜上有分的东西:
// 有报名数据的小项会被守卫拦下(见 TestDeleteEventStillBlockedByEntries)。
// seedTeamManualOnlyEvent 建一个「只有团体名次、没有报名数据」的小项:纯团体 + 一个组别 +
// 一条 scope=team/basis=manual 通道,不建任何 entries(有 entries 就删不掉了)。
func seedTeamManualOnlyEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'BASKET', '篮球', 'team', 'manual', 'score', 'desc', 1, 1, 'ongoing')`, ids.sport)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
addTeamManualChannel(t, env, eventID)
return eventID, categoryID
}
// standingsRaw 取积分榜原始响应体(逐字节比较用,比逐字段断言更严格)。
func standingsRaw(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
}
return string(env2.Data)
}
// P0 回归:删掉「只有团体名次、没有报名数据」的小项后,它的流水必须被作废、榜单必须跟着变。
//
// 修之前:DELETE 返回 200,但流水只是被外键置空 event_id(voided 仍是 0),
// 分数永久留在榜上,此后任何按 event_id 的重算都打不到这些行(审查 01 的实测证据)。
func TestDeleteEventVoidsTeamManualLedgerAndRepushesStandings(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 基线:还没有这个小项时的榜单,删除后必须逐字节回到这里。
baseline := standingsRaw(t, env, client)
eventID, categoryID := seedTeamManualOnlyEvent(t, env, ids)
// 名次是现场直接按单位录的,没有报名项也没有成绩行;录入即重算,分当场进榜。
if status, env2 := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
}); status != http.StatusOK || !env2.OK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
}
// 前置条件:删除前这个小项确实在榜上有分,否则这条用例证明不了任何事。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID); n != 2 {
t.Fatalf("前置条件:小项应有 2 条有效流水,得到 %d 条:%s", n, ledgerActiveDump(t, env))
}
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("前置条件:A 总分应为 15,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
t.Fatalf("前置条件:B 总分应为 10,得到 %v", got)
}
if gold := teamGold(t, env, client, ids.teamA); gold != 1 {
t.Fatalf("前置条件:A 应有 1 金,得到 %d", gold)
}
standingsBefore := standingsChangeRows(t, env)
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("删除小项应 200,得到 %d %+v", status, env2.Error)
}
// 1) 流水是作废(保留行 + 作废原因),不是没有痕迹地消失。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 0 {
t.Fatalf("删除后不该还有有效流水(孤儿会永久留在榜上),得到 %d 条:%s", n, ledgerActiveDump(t, env))
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
WHERE voided = 1 AND void_reason = '小项删除,本小项积分流水一并作废'`); n != 2 {
t.Fatalf("应留下 2 条带作废原因的流水作为审计痕迹,得到 %d 条", n)
}
// 作废行此刻的 event_id 已被外键置空:能作废成功本身就说明 UPDATE 排在 DELETE FROM events 之前,
// 顺序反了这里就是 0 条(上面两条断言会先失败)。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id IS NULL AND voided = 1`); n != 2 {
t.Fatalf("作废行应保留下来(event_id 置空),得到 %d 条", n)
}
// 2) 榜单不再受它影响:分数、奖牌、名次全部回到建这个小项之前的形态。
if after := standingsRaw(t, env, client); after != baseline {
t.Fatalf("删除后榜单应逐字节回到该小项存在之前:\n之前 %s\n之后 %s", baseline, after)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("删除后 A 不该再有分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("删除后 B 不该再有分,得到 %v", got)
}
if gold := teamGold(t, env, client, ids.teamA); gold != 0 {
t.Fatalf("删除后 A 不该再有金牌,得到 %d", gold)
}
// 3) 派生数据随之消失(外键级联),下钻不会再列出一堆查不到出处的名次。
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ?`, eventID); n != 0 {
t.Fatalf("删除小项后 channel_ranks 应随外键级联清空,得到 %d 行", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ?`, eventID); n != 0 {
t.Fatalf("删除小项后 channel_manual_ranks 应随外键级联清空,得到 %d 行", n)
}
// 4) 榜单变了就必须推给大屏:只有 events void 一条信号时,大屏会一直停在旧分上。
if after := standingsChangeRows(t, env); after <= standingsBefore {
t.Fatalf("删除小项应推一次 standings:之前 %d 条,之后 %d 条", standingsBefore, after)
}
if n := changeLogCount(t, env, "events", "void"); n != 1 {
t.Fatalf("删除小项应照旧写 1 条 events void 变更,得到 %d 条", n)
}
}
// 守卫没被改动:有报名数据的小项仍然删不掉,且被拦下时一条流水都不许作废。
func TestDeleteEventStillBlockedByEntries(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "guard",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
ledgerBefore := ledgerActiveDump(t, env)
standingsBefore := standingsRaw(t, env, client)
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", ids.event), nil, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("有报名数据的小项应 409 且报错可读,得到 %d %+v", status, env2.Error)
}
if after := ledgerActiveDump(t, env); after != ledgerBefore {
t.Fatalf("被拦下的删除不该动作何流水:\n之前 %s\n之后 %s", ledgerBefore, after)
}
if after := standingsRaw(t, env, client); after != standingsBefore {
t.Fatalf("被拦下的删除不该动榜单:\n之前 %s\n之后 %s", standingsBefore, after)
}
}
@@ -0,0 +1,61 @@
package httpapi_test
import (
"net/http"
"testing"
)
// 末位归零的端到端回归(任务 F):现场模板勾着 last_place_zero=1,7 人里三人并列第 5
// (占 5/6/7)时,第 5 名的 2 分与第 6 名的 1 分不该被一起抹掉——修复前这里是 10/8/6/3/**0/0/0**。
// 走的是真实接口:存草稿 → 发布 → 写 score_ledger → 积分榜,覆盖引擎之外的那一段。
func TestLastPlaceZeroTieCrossingKeepsEarnedPointsE2E(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 现场模板勾着「末位归零」。
env.exec(t, `UPDATE scoring_profiles SET last_place_zero = 1 WHERE code = 'individual_top6'`)
// 补 5 个单位 + 7 条报名项(A、B 用 seed 的)。
entries := []int64{ids.entryA, ids.entryB}
codes := []string{"A", "B", "C", "D", "E", "F", "G"}
for i := 2; i < 7; i++ {
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, ?, 1)`,
codes[i], "单位"+codes[i], codes[i], i)
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, "选手"+codes[i], teamID)
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
entries = append(entries, entryID)
}
// 成绩 1,2,3,4,5,5,5 → 名次 1,2,3,4,5,5,5(三人并列第 5,占 5/6/7)。
values := []float64{1, 2, 3, 4, 5, 5, 5}
results := make([]map[string]any, 0, len(entries))
for i, entryID := range entries {
results = append(results, map[string]any{"entryId": entryID, "resultValue": values[i], "status": "ok"})
}
if status, env2 := env.do(client, http.MethodPut, "/api/results/draft",
map[string]any{"eventId": ids.event, "results": results}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": ids.event}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
points := map[string]float64{}
for _, row := range decodeData[[]map[string]any](t, env2) {
points[row["teamCode"].(string)] = row["totalPoints"].(float64)
}
// 个人前六名表 10/8/6/3/2/1:三个并列第 5 的单位都拿第 5 名的 2 分,末位那一档(第 7 名)
// 本来就没配分,所以这里看不到任何「被归零」的痕迹。
want := map[string]float64{"A": 10, "B": 8, "C": 6, "D": 3, "E": 2, "F": 2, "G": 2}
for code, w := range want {
if points[code] != w {
t.Errorf("单位 %s 总分 = %v,期望 %v(完整:%v)", code, points[code], w, points)
}
}
}
@@ -0,0 +1,250 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住工单 E 的失败场景:「分值表档位」与「录取档位数(awardRanks)」不一致时的静默截断。
//
// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
// 而保存模板(repo.go 的 replaceProfileRules)原先只看名次是否重复、是否 >= 1,
// 从不看两边的档位数是否对得上:配到第 7 档、录取名次还留着 6 时,7 档照样落库——
// 模板页上第 7 档写着 2 分,榜上恒为 0 分,界面上看不出任何异常(审查 01 P1)。
// 现在保存与规则包导入两条路都在写入前拒收,并给出同一句人话说明。
// awardRanks = 0 表示不按名次封顶(既有语义):必须继续放行,且第 7 档真能得分。
// sevenRankRules 是配到第 7 档的分值表(第 7 档 2 分);与前六档分值互不相同,便于按分数定位名次。
func sevenRankRules() []map[string]any {
return []map[string]any{
{"rank": 1, "points": 10}, {"rank": 2, "points": 8}, {"rank": 3, "points": 6},
{"rank": 4, "points": 5}, {"rank": 5, "points": 4}, {"rank": 6, "points": 3},
{"rank": 7, "points": 2},
}
}
// sixRankRules 是种子模板 individual_top6 的现行六档分值表,用于对照「正好覆盖」。
func sixRankRules() []map[string]any {
return sevenRankRules()[:6]
}
// addIndividualEntries 给种子小项补 n 个运动员与报名项,返回新增的报名项 id
// (个人项目一人一条,沿用种子里的单位与组别)。
func addIndividualEntries(t *testing.T, env *testEnv, ids seedIDs, n int) []int64 {
t.Helper()
out := make([]int64, 0, n)
for i := 0; i < n; i++ {
athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
fmt.Sprintf("补录%d", i), ids.teamA)
out = append(out, env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athlete))
}
return out
}
// 甲:分值表配到第 7 档、录取名次只到 6 → 新建与更新都必须拒绝,且库里一个字节都不动。
func TestProfileAwardRanksMustCoverRuleRanks(t *testing.T) {
env, _, client := recorderWithSeed(t)
// 新建:改前是 200 + 7 档全落库(第 7 档永远算 0 分)。
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-TRUNC", "name": "录取六档配到七档", "awardRanks": 6, "rules": sevenRankRules(),
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("awardRanks=6 + 第 7 档应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
// 文案要能让现场自己改:点明超出的档位、当前录取名次、以及「0 表示不封顶」这个出口。
for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} {
if !strings.Contains(resp.Error.Message, want) {
t.Fatalf("400 文案里缺少 %q:%q", want, resp.Error.Message)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-TRUNC'`); n != 0 {
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
}
// 更新:带上同一份七档分值表,同样拒绝,且模板姓名次表都不许改。
before := profileRuleRanks(t, env, client, 1)
status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名", "awardRanks": 6, "rules": sevenRankRules(),
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新成 7 档应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "第 7 档") {
t.Fatalf("更新路径的 400 文案也应点明超出的档位,得到 %q", resp.Error.Message)
}
if after := profileRuleRanks(t, env, client, 1); len(after) != len(before) {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
// 更新但**不带** rules:只把录取名次调小,一样会把库里已有的第 6 档截断,同样要拦。
status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名", "awardRanks": 5,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("把 awardRanks 调小到 5 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "第 6 档") {
t.Fatalf("文案应点明库里已有的超档档位,得到 %q", resp.Error.Message)
}
var stored int
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = 1`).Scan(&stored); err != nil {
t.Fatalf("读录取名次: %v", err)
}
if stored != 6 {
t.Fatalf("被拒的更新不该改录取名次,得到 %d", stored)
}
// 对照:正好覆盖(第 6 档 + awardRanks=6)与不封顶(第 7 档 + awardRanks=0)都必须放行。
if status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名", "awardRanks": 6, "rules": sixRankRules(),
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("正好覆盖应 200,得到 %d %+v", status, resp.Error)
}
if status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-UNCAPPED", "name": "不封顶前七名", "awardRanks": 0, "rules": sevenRankRules(),
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("awardRanks=0 + 第 7 档应 200,得到 %d %+v", status, resp.Error)
}
}
// 乙:awardRanks=0 表示不封顶——保存要成功,而且第 7 档必须**真能得分**。
// 这条走完整链路(保存模板 → 把它挂到小项的计分通道 → 录 7 条成绩 → 发布 → 读流水),
// 只测校验函数的话,说不出「第 7 档到底算不算分」。
func TestProfileAwardRanksZeroScoresSeventhPlace(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-UNCAPPED", "name": "不封顶前七名", "awardRanks": 0, "rules": sevenRankRules(),
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("awardRanks=0 + 第 7 档应保存成功,得到 %d %+v", status, resp.Error)
}
saved := decodeData[map[string]any](t, resp)
profileID := int64(saved["id"].(float64))
if saved["awardRanks"] != float64(0) {
t.Fatalf("awardRanks=0 应原样落库,得到 %v", saved["awardRanks"])
}
if ranks := profileRuleRanks(t, env, client, profileID); len(ranks) != 7 {
t.Fatalf("7 档分值表应全部保留,得到 %v", ranks)
}
env.exec(t, `UPDATE event_score_channels SET profile_id = ? WHERE event_id = ?`, profileID, ids.event)
extra := addIndividualEntries(t, env, ids, 5)
// 成绩按录入顺序递增,7 人正好排出第 1~7 名(第 7 名成绩最差)。
publishResults(t, env, client, ids.event, "award-uncapped",
map[string]any{"entryId": ids.entryA, "resultValue": 10, "resultText": "10", "unit": "秒", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11, "resultText": "11", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[0], "resultValue": 12, "resultText": "12", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[1], "resultValue": 13, "resultText": "13", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[2], "resultValue": 14, "resultText": "14", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[3], "resultValue": 15, "resultText": "15", "unit": "秒", "status": "ok"},
map[string]any{"entryId": extra[4], "resultValue": 16, "resultText": "16", "unit": "秒", "status": "ok"},
)
// 七个名次各拿一档分:第 7 名拿到 2 分就证明第 7 档没被截断。
want := []struct {
entry int64
points []float64
}{
{ids.entryA, []float64{10}},
{ids.entryB, []float64{8}},
{extra[0], []float64{6}},
{extra[1], []float64{5}},
{extra[2], []float64{4}},
{extra[3], []float64{3}},
{extra[4], []float64{2}},
}
for i, tc := range want {
if pts := activeLedgerPoints(t, env, tc.entry); !equalFloats(pts, tc.points) {
t.Fatalf("第 %d 名的报名项 %d 应得 %v 分,得到 %v", i+1, tc.entry, tc.points, pts)
}
}
}
// 规则包导入路径同一口径:YAML 里的 awardRanks 与 profiles[].rules 不一致时,
// 导入必须整体拒收(改前会安静导入,第 7 档在库里永远算 0 分)。
func TestRulesImportRejectsAwardRanksOverflow(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
const pack = `format: scoreboard-rules
formatVersion: 1
meta: { code: award-overflow, name: 第 7 档超出录取名次 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 5 }
- { rank: 5, points: 4 }
- { rank: 6, points: 3 }
- { rank: 7, points: 2 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
`
before := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true)
if status != http.StatusBadRequest {
t.Fatalf("导入第 7 档 + awardRanks=6 的规则包应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "第 7 档") {
t.Fatalf("导入失败的提示应点明超出的档位,得到 %s", raw)
}
after := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`)
if after != before {
t.Fatalf("被拒的导入不该动库里的分值表,得到 %d → %d 档", before, after)
}
// 对照:同一份包把录取名次改成 0(不封顶)就能导入。
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import",
[]byte(strings.Replace(pack, "awardRanks: 6", "awardRanks: 0", 1)), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("awardRanks=0 的同一份规则包应导入成功,得到 %d %s", status, raw)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`); got != 7 {
t.Fatalf("导入成功后应有 7 档,得到 %d", got)
}
}
// 库里**已经存在**的越档模板(修好之前从模板页存下的那种)必须照常导得出来:
// GET /api/rules/export 与赛事项目包是现场备份与排障的入口,把它们一起 500 掉是更大的事故
// (同类伤疤:TestUpdateProfileCodeRequiredKeepsRulesExportUsable)。
// 所以这条检查只在写入侧生效:导出原样给出,再导回来才拒。
func TestLegacyAwardRanksOverflowStillExports(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
// 绕开接口直接写库,模拟「修好之前存下的」第 7 档 + awardRanks 6(现在两条写入路径都已拦下)。
env.exec(t, `INSERT INTO scoring_profile_rules (profile_id, rank, points)
SELECT id, 7, 2 FROM scoring_profiles WHERE code = 'individual_top6'`)
exported := env.exportRulesYAML(t, client)
if !strings.Contains(exported, "rank: 7") {
t.Fatalf("越档模板应原样导出,实际\n%s", exported)
}
// 同一份导出内容导回来:写入侧必须拒,且提示点明超档的那一档。
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(exported), "application/yaml", true)
if status != http.StatusBadRequest {
t.Fatalf("把导出的越档规则包导回来应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "第 7 档") {
t.Fatalf("提示应点明超出的档位,得到 %s", raw)
}
}
+43 -3
View File
@@ -10,6 +10,7 @@ import (
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
@@ -1290,6 +1291,29 @@ func validateAwardRanks(awardRanks int) error {
return nil
}
// validateProfileAwardCoverage 拦住「分值表档位超出录取名次」的静默截断。
//
// 与上一条不同,这里不是字段取值范围的问题,而是两个字段**合起来**不成立:
// award_ranks > 0 时算分引擎把超出名次的档位一律当 0 分(domain/scoring.go 的 basePoints),
// 保存侧却从不校验两边的档位数,于是「分值表配到第 7 档、录取名次还留着 6」能安静落库——
// 模板页上第 7 档明明写着 2 分,榜上却是 0 分,界面上看不出任何异常(审查 01 P1)。
// 与其让人配一份算不出分的表,不如在保存时拒绝,并把两种改法直接写进错误里。
//
// list 传「保存后实际生效的档位」:本次带了 rules 就是它,没带就是库里现有的(只改 awardRanks
// 一样能把已有档位截断)。文案与规则包导入侧共用 rules.AwardRanksOverflow,两条路给同一句话。
func validateProfileAwardCoverage(awardRanks int, list []profileRuleDTO) error {
maxRank := 0
for _, r := range list {
if r.Rank > maxRank {
maxRank = r.Rank
}
}
if msg := rules.AwardRanksOverflow(awardRanks, maxRank); msg != "" {
return badRequest(msg, nil)
}
return nil
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
@@ -1306,6 +1330,9 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
if err := validateProfileAwardCoverage(in.AwardRanks, in.Rules); err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -1363,6 +1390,19 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
// 没带 rules 时按库里现有的档位校验:只把 awardRanks 调小,一样会把已有档位截断。
// 这一步必须在开事务之前读(赛事库只开一条连接,事务里再查会自锁)。
effectiveRules := in.Rules
if effectiveRules == nil {
cur, err := s.profileRules(ctx, id)
if err != nil {
return profileDTO{}, err
}
effectiveRules = cur
}
if err := validateProfileAwardCoverage(in.AwardRanks, effectiveRules); err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -1400,15 +1440,15 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
return s.getProfile(ctx, id)
}
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error {
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, list []profileRuleDTO) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
return internal(err)
}
// 名次去重:主键是 (profile_id, rank),模板页把两行填成同一个数字就会撞 UNIQUE,
// 报出来是 500 + 英文约束原文。事务虽然会回滚,但错误无法自解释,现场只能去猜。
// 先自己查一遍,报清是哪一名重复(与上面 rank < 1 的 400 同一层)。
seen := make(map[int]bool, len(rules))
for _, r := range rules {
seen := make(map[int]bool, len(list))
for _, r := range list {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}
+53 -7
View File
@@ -269,6 +269,18 @@ func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error
return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID)
}
// deleteEvent 删除小项:作废它名下的成绩流水,再删通道 / 组别 / 小项本身。
//
// 守卫只挡「已有报名数据」,所以「只有团体名次、没有报名项」的小项(球类那种形态,
// 名次直接按单位录进 channel_manual_ranks)走得通。这类小项删除时,它产生的流水必须
// 在同一个事务里显式作废:score_ledger.event_id 是 ON DELETE SET NULL,先删小项就只把
// 外键置空(voided 仍是 0),分数留在榜上、而此后任何按 event_id 的重算都打不到这些行,
// 变成「永久残留、只能改库」(审查 01 的 P0)。
//
// 作废而不是删除:保留流水行作为审计痕迹(谁在什么时候因为什么作废)。范围与
// writeLedgerTx 的作废范围同口径——只动引擎产出的成绩分与破纪录加分,人工调整
// (source_type = 'adjustment')不在此列,它由 point_adjustments 管,删小项只把它的
// event_id 置空。
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
@@ -282,6 +294,16 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
actorID := middleware.User(ctx).ID
// 必须排在下面那条 DELETE FROM events 之前:event_id 一被置空,这条件就再也匹配不上。
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE voided = 0 AND event_id = ? AND source_type IN ('result', 'record_bonus')`,
nullID(actorID), "小项删除,本小项积分流水一并作废", id); err != nil {
return internal(err)
}
for _, stmt := range []string{
`DELETE FROM event_score_channels WHERE event_id = ?`,
`DELETE FROM event_categories WHERE event_id = ?`,
@@ -291,7 +313,11 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, actorID); err != nil {
return err
}
// 流水作废会直接改榜单内容:不推 standings,大屏会一直停在旧分上(要等下一次发布才刷新)。
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
@@ -607,7 +633,7 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
return nil, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
`SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
@@ -619,14 +645,19 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
for rows.Next() {
var (
c channelDTO
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 只下发非缺省口径(sum):avg 是缺省值,老数据的响应体因此一个字节都不变。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = aggregateMode
}
c.CountToTeam = boolPtr(countToTeam != 0)
c.CountToMedals = boolPtr(countToMedals != 0)
c.CountToTeamMedals = boolPtr(countToTeamMedals != 0)
@@ -647,6 +678,15 @@ func channelFlag(v *bool) bool {
return v == nil || *v
}
// channelAggregateMode 读请求体里的聚合口径:缺省(空串)落成 avg,
// 保证不带该字段的旧客户端保存通道时口径不变(与库里的默认值一致)。
func channelAggregateMode(c channelDTO) string {
if c.AggregateMode == string(domain.AggregateSum) {
return string(domain.AggregateSum)
}
return string(domain.AggregateAvg)
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
@@ -672,14 +712,20 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
default:
return nil, badRequest("通道 basis 非法", i)
}
switch c.AggregateMode {
case "", string(domain.AggregateAvg), string(domain.AggregateSum):
default:
return nil, badRequest("通道 aggregateMode 只能是 avg/sum", i)
}
if c.ProfileID == 0 {
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c)),
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, aggregate_mode,
count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, channelAggregateMode(c),
boolToInt(channelCountsToTeam(c)),
boolToInt(channelFlag(c.CountToMedals)), boolToInt(channelFlag(c.CountToTeamMedals))); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
+84 -5
View File
@@ -15,6 +15,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/revisions"
)
// ---------- settings ----------
@@ -476,6 +477,7 @@ func splitSettingList(raw string) []string {
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
// 总积分榜与单位奖牌榜都要算:前者的顺序是数组顺序,后者的名次由 medalRank 带走。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
in, err := s.loadStandingsInput(ctx, q)
if err != nil {
@@ -485,7 +487,11 @@ func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.Standi
if err != nil {
return nil, err
}
return dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil
return dto.StandingRows(
domain.BuildTeamStandings(in),
domain.BuildMedalStandings(in),
domainTeamsFromMap(teams),
), nil
}
// domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。
@@ -668,9 +674,12 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
}
// 名次不再只读 results.rank:那是个人名次(见 applyComputedRanks),
// 团体名次与聚合名次只落在 channel_ranks,读旧列会让下钻的名次列整列变空。
// 组别与导出口径一致(export.loadTeamDetailRows 的 COALESCE(c.name, ca.name, '')):
// 「按队内平均」产出的聚合流水 entry_id 为空,取不到报名项的组别,得用 ca 回推的那个。
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, '')
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, COALESCE(cat.name, ca.name),
l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, ''),
l.entry_id
FROM score_ledger l
LEFT JOIN events ev ON ev.id = l.event_id
LEFT JOIN entries e ON e.id = l.entry_id
@@ -694,17 +703,21 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
defer func() { _ = rows.Close() }()
out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}}
// 每条流水背后那条成绩的版本身份;聚合流水 / 人工调整没有报名项,留零值(追不了就不追)。
keys := make([]revisions.Key, 0, 16)
for rows.Next() {
var (
item breakdownItemDTO
eventID, athleteID sql.NullInt64
entryID sql.NullInt64
eventCode, eventName sql.NullString
categoryName, athleteName sql.NullString
rank sql.NullInt64
resultText sql.NullString
)
if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName,
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil {
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt,
&entryID); err != nil {
return teamBreakdownDTO{}, internal(err)
}
item.EventID = i64Ptr(eventID)
@@ -718,10 +731,76 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
if item.AthleteID != nil && *item.AthleteID != 0 {
item.Points *= weight
}
// 只有成绩流水本身带得上修订痕迹(见 revisions.KeyFor):破纪录加分 / 人工调整 / 聚合流水留零值。
key := revisions.Key{}
if item.EventID != nil {
key = revisions.KeyFor(item.SourceType, *item.EventID, entryID.Int64)
}
keys = append(keys, key)
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return teamBreakdownDTO{}, internal(err)
}
s.attachBreakdownRevisions(ctx, out.Items, keys)
return out, nil
}
// attachBreakdownRevisions 给明细行补上修订痕迹(谁在什么时候因为什么改过这条成绩)。
//
// 查不出来只记日志、不报错:修订痕迹是「解释这一分怎么来的」的补充信息,追溯失败不该让
// 整张明细打不开 —— 现场要看的是分,不是角标。此时字段保持 nil,前端显示为空。
//
// 操作人姓名只发给 recorder 及以上:这个接口是公开的(大屏也读它),而「谁点错的」
// 属于裁判自己要看的账,不该跟着 JSON 一起下发给未登录的访客 —— 与 /api/revisions
// 只对 recorder 开放是同一个口径。时间与原因照发:那是给观众的解释,不是人名的账。
func (s *server) attachBreakdownRevisions(ctx context.Context, items []breakdownItemDTO, keys []revisions.Key) {
if len(keys) == 0 {
return
}
trails, err := revisions.Load(ctx, s.ev, keys)
if err != nil {
s.logger.Warn("读取成绩修订痕迹失败,明细按「未修订」展示", "err", err)
return
}
showActor := middleware.User(ctx).Role.Allows(auth.RoleRecorder)
actorIDs := make([]int64, 0, 4)
byActor := make(map[int]int64)
for i, key := range keys {
trail, ok := trails[key]
if !ok {
continue
}
count := trail.Count
items[i].RevisionCount = &count
if count == 0 {
continue
}
at := trail.At
items[i].LastRevisionAt = &at
if trail.Reason != "" {
reason := trail.Reason
items[i].LastRevisionReason = &reason
}
if trail.ActorID != nil && showActor {
actorIDs = append(actorIDs, *trail.ActorID)
byActor[i] = *trail.ActorID
}
}
if len(actorIDs) == 0 || s.sys == nil {
return
}
names, err := s.userNames(ctx, actorIDs)
if err != nil {
s.logger.Warn("读取修订操作人姓名失败,只显示时间与原因", "err", err)
return
}
for i, id := range byActor {
if name := names[id]; name != "" {
items[i].LastRevisedBy = &name
}
}
}
// ---------- records ----------
+109
View File
@@ -162,6 +162,115 @@ func TestTeamBreakdownAppliesIndividualWeight(t *testing.T) {
}
}
// 单位积分明细的「组别」列不能整列变空:聚合通道(按队内平均)产出的流水 entry_id 与 result_id
// 都为空,从 entries 取不到组别,接口要按「单位在本小项只参加了一个组别」回推那一个
// (与 export.loadTeamDetailRows 的组别回推口径一致)。
func TestTeamBreakdownAggregateRowKeepsCategory(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 模拟聚合通道产出的流水:entry_id 与 result_id 都为空(见 isAggregateLedger)。
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
items := decodeData[map[string]any](t, env2)["items"].([]any)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items))
}
item := items[0].(map[string]any)
if got := item["categoryName"]; got != "男子" {
t.Fatalf("聚合流水的组别 = %v,期望 男子(按单位唯一参加的组别回推)", got)
}
// 聚合分不对应单个运动员,运动员列为空是合理的(前端降级成单位名);但组别不能跟着空。
if name, ok := item["athleteName"]; ok && name != nil {
t.Fatalf("聚合流水不该带运动员名,得到 %v", name)
}
}
// 正常发布的个人流水(走报名项)必须带齐「谁 · 哪个项目 · 哪个组别」:
// 大屏明细的「运动员」列就靠这一行数据,任何一项为空都会让现场只看到项目与名次。
func TestTeamBreakdownIndividualRowCarriesWhoAndCategory(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
items := decodeData[map[string]any](t, env2)["items"].([]any)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
item := items[0].(map[string]any)
for field, want := range map[string]any{
"athleteName": "张伟",
"categoryName": "男子",
"eventName": "100米跑",
"resultText": "11.58",
} {
if got := item[field]; got != want {
t.Fatalf("明细行的 %s = %v,期望 %v(大屏明细要能说清是谁在哪个项目拿了多少分)", field, got, want)
}
}
}
// 单位在同一小项参加了多个组别时回推不出组别,明细宁可留空也不猜(与导出一致)。
func TestTeamBreakdownAmbiguousAggregateCategoryStaysEmpty(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
otherAthlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active)
VALUES ('王芳', '女', ?, 1)`, ids.teamA)
env.exec(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, otherCat, ids.teamA, otherAthlete)
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
items := decodeData[map[string]any](t, env2)["items"].([]any)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items))
}
if got := items[0].(map[string]any)["categoryName"]; got != nil {
t.Fatalf("两个组别都参加时组别应留空(不猜),得到 %v", got)
}
}
// 只排名不计分(scored=0)的项目即使配了计分通道,也不得在大屏奖牌榜产出奖牌;
// 导出侧已按 ev.scored = 1 过滤,两边口径必须一致,否则奖牌榜与成绩册对不上。
func TestStandingsExcludesUnscoredEventMedals(t *testing.T) {
+84 -31
View File
@@ -2,6 +2,9 @@ package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
@@ -129,17 +132,18 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
}, nil
}
res, err := rules.Apply(s.ev, cs)
res, rows, err := s.applyRulesAndRecompute(r.Context(), cs, middlewareUserID(r))
if err != nil {
return nil, internal(err)
}
// 分值一变,历史成绩的积分与名次必须全量重算。
rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r))
if err != nil {
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
s.logger.Error("规则导入后重算失败", "err", err)
return nil, internalMsg("规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入", err)
// 配置本身就不成立(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{
@@ -151,22 +155,80 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
}, nil
}
// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。
// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。
// 返回写入的积分流水条数,供导入结果回报。
// 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) {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
var ids []int64
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 id int64
if err := rows.Scan(&id); err != nil {
var t target
if err := rows.Scan(&t.id, &t.code, &t.name); err != nil {
_ = rows.Close()
return 0, err
}
ids = append(ids, id)
events = append(events, t)
}
if err := rows.Close(); err != nil {
return 0, err
@@ -175,21 +237,15 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er
return 0, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
total := 0
for _, eventID := range ids {
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import")
for _, ev := range events {
score, err := recomputeLedgerTx(ctx, tx, ev.id, 0, actorID, "rules-import")
if err != nil {
return 0, err
return 0, fmt.Errorf("小项 %s(%s)重算失败:%w", ev.code, ev.name, err)
}
// 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return 0, err
return 0, fmt.Errorf("小项 %s(%s)回写名次失败:%w", ev.code, ev.name, err)
}
total += len(score.Ledger)
}
@@ -197,8 +253,5 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return total, nil
}
+190 -25
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"io"
"net/http"
"reflect"
"strings"
"testing"
)
@@ -183,8 +184,152 @@ func TestRulesImportReturnsContractFields(t *testing.T) {
}
}
// 重算失败必须显式报错,且整个重算在同一事务里回滚,不能留下「新规则 + 部分新积分」(P1-12)。
func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
// rulesPackTemplateOnly 是一份「只按 eventType 匹配、不看 compare」的规则包:individual 小项
// 一律被配上 basis=result。库里若存在 compare=none(名次靠人工录入)的个人小项,这份模板
// 就会给它配上一条根本跑不起来的通道——这正是审查 04 P0-1 的触发条件。
// 模板分值与本仓库默认模板一致,保证测试里的差异只出现在通道上。
const rulesPackTemplateOnly = `format: scoreboard-rules
formatVersion: 1
meta: { code: test-template-only, name: 只按 eventType 匹配的规则包 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 3 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
- code: team_top6
name: 团体项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
- { rank: 3, points: 8 }
- { rank: 4, points: 4 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
`
// rulesPackFixedBroken 是同一份规则包加上「坏小项改用 basis=manual」的 override:
// 导入失败提示就是这么让现场自己救回来的(compare=none 的小项只能用人工名次通道)。
const rulesPackFixedBroken = rulesPackTemplateOnly + `
eventOverrides:
- eventCode: Z_BROKEN
channels:
- { scope: athlete, basis: manual, profile: individual_top6 }
`
// seedCompareNoneEvent 造一个 compare=none 的个人小项并发布一条人工名次。
// 发布走真实接口,好让它占住一行 channel_ranks——那份名次就是「被级联清空就补不回来」的数据。
func seedCompareNoneEvent(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, code, name string) int64 {
t.Helper()
eventID := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, ?, ?, 'individual', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport, code, name)
categoryID := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, eventID)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'manual', ?, 0)`, eventID, profileID)
entryID := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, categoryID, ids.teamA, ids.athleteA)
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID,
"requestId": "draft-" + code,
"results": []map[string]any{{"entryId": entryID, "rank": 1, "status": "ok"}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("保存手工名次应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": eventID,
"requestId": "publish-" + code,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("发布手工名次应 200,得到 %d %+v", status, resp.Error)
}
return eventID
}
// 审查 04 P0-1 的回归:库里存在 compare=none 的个人小项时导入只按 eventType 匹配的规则包。
//
// 导入前 channel_ranks 有 3 行(M100 两条 + 手工小项一条)、A 队 1 金;旧代码在这里会把通道
// 先换掉(channel_ranks 被外键级联清空)、随后重算在手工小项上报 ErrCompareRequired,
// 结果是 3 行 → 0 行、金牌 1 → 0,而自愈与「重试导入」都以同一个错误失败,无人可解。
// 修完之后:要么整体成功,要么整体回滚到导入前;名次表**不允许**变成空的。
func TestRulesImportWithCompareNoneEventRollsBackOrSucceeds(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
seedCompareNoneEvent(t, env, client, ids, "Z_MANUAL", "手榴弹投准")
beforeRanks := channelRankCount(t, env)
beforeMedals := medalsByTeam(t, env)
beforePoints := env.ledgerPointsByTeam(t)
if beforeRanks != 3 {
t.Fatalf("导入前应有 3 行通道名次(M100 两条 + 手工小项一条),得到 %d", beforeRanks)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackTemplateOnly), "application/yaml", true)
// 两种结局都算对:整体成功,或整体回滚回导入前。名次表变空就是失败。
if status == http.StatusOK {
if got := channelRankCount(t, env); got == 0 {
t.Fatalf("导入返回成功却把通道名次清空了(%d → 0)", beforeRanks)
}
return
}
if status < http.StatusBadRequest {
t.Fatalf("导入既没成功也没失败,status=%d %s", status, raw)
}
// 报错要说人话:哪个小项、因为什么。
for _, want := range []string{"Z_MANUAL", "手榴弹投准", "compare=none"} {
if !strings.Contains(string(raw), want) {
t.Errorf("导入失败提示缺少 %q:%s", want, raw)
}
}
// 名次表必须回到导入前的状态(不是空的)。
if got := channelRankCount(t, env); got != beforeRanks {
t.Fatalf("导入失败应整体回滚,通道名次 %d 行 → %d 行", beforeRanks, got)
}
if got := medalsByTeam(t, env); !reflect.DeepEqual(got, beforeMedals) {
t.Fatalf("导入失败后奖牌榜应不变:before=%+v after=%+v", beforeMedals, got)
}
afterPoints := env.ledgerPointsByTeam(t)
if !reflect.DeepEqual(afterPoints, beforePoints) {
t.Fatalf("导入失败后流水应不变:before=%+v after=%+v", beforePoints, afterPoints)
}
}
// 审查 04 P0-1 的第二个回归:重算失败时,「换通道」必须一起回滚。
//
// 现场形态是旧代码留下的:库里已经有一个跑不起来的小项(compare=none 却配了 basis=result 通道),
// 规则包没有覆盖它(模板只匹配 individual,它是 match 类型),于是全量重算必然在它身上失败。
// 旧代码此时通道替换已经提交,M100 的 channel_ranks 被级联清空 → 榜单「分还在、奖牌全 0」,
// 重试导入以同一个错误失败。修完后:整包回滚(名次表维持原样),
// 并且按提示给坏小项写一条 override 再导入就能修好——恢复得回来。
func TestRulesImportRecomputeFailureRollsBackChannelReplacement(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
@@ -192,17 +337,17 @@ func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
// 坏小项:compare=none(排不出名次)却配了 basis=result 的通道,还有一条已发布成绩。
// 规则包的 individual 模板匹配不到它(event_type=match),所以它的通道不会被导入覆盖。
broken := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'Z_BROKEN', '损坏项目', 'match', 'auto', 'none', 'none', 1, 1, 'ongoing')`, ids.sport)
brokenCat := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
// 第二个项目 compare=none 却配了 basis=result 通道,重算到这里必然报错。
// 项目编码排在 M100 之后,保证替换到的第一个通道属于 M100。
broken := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'Z_BROKEN', '损坏项目', 'individual', 'auto', 'time', 'none', 1, 1, 'ongoing')`, ids.sport)
brokenCat := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, broken, profileID)
brokenEntry := env.insert(t,
@@ -211,33 +356,53 @@ func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
env.insert(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
VALUES (?, ?, 11.0, '11.00', 's', 'ok', 'published', 1)`, broken, brokenEntry)
before := env.ledgerPointsByTeam(t)
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道")
beforeRanks := channelRankCount(t, env)
beforeMedals := medalsByTeam(t, env)
beforePoints := env.ledgerPointsByTeam(t)
if beforeRanks != 2 {
t.Fatalf("导入前应有 2 行通道名次(M100 两条),得到 %d", beforeRanks)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackTemplateOnly), "application/yaml", true)
if status != http.StatusInternalServerError {
t.Fatalf("重算失败应 500,得到 %d %s", status, raw)
t.Fatalf("坏小项让重算失败,应 500,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "积分重算失败") {
t.Fatalf("错误信息应说明重算失败,得到 %s", raw)
// 人话:必须指名是哪个小项、因为什么,现场才定位得到。
for _, want := range []string{"Z_BROKEN", "损坏项目", "compare"} {
if !strings.Contains(string(raw), want) {
t.Errorf("重算失败提示缺少 %q:%s", want, raw)
}
}
// 换通道与重算同事务:失败即整体回滚,名次表回到导入前(不能变成空的)。
if got := channelRankCount(t, env); got != beforeRanks {
t.Fatalf("重算失败应整体回滚,通道名次 %d 行 → %d 行", beforeRanks, got)
}
if got := medalsByTeam(t, env); !reflect.DeepEqual(got, beforeMedals) {
t.Fatalf("重算失败后奖牌榜应不变:before=%+v after=%+v", beforeMedals, got)
}
// 规则本身已经落库(Apply 先提交),但第一个项目的重算必须随第二个项目一起回滚。
var code string
if err := env.ev.QueryRow(`SELECT p.code FROM event_score_channels c
JOIN scoring_profiles p ON p.id = c.profile_id
WHERE c.event_id = ?`, ids.event).Scan(&code); err != nil {
t.Fatalf("查询导入后的计分通道: %v", err)
}
if code != "team_top6" {
t.Fatalf("规则应已落库(通道改为 team_top6),得到 %q", code)
if code != "individual_top6" {
t.Fatalf("通道替换也必须回滚,得到 %q", code)
}
after := env.ledgerPointsByTeam(t)
if after[ids.teamA] != before[ids.teamA] || after[ids.teamB] != before[ids.teamB] {
t.Fatalf("重算失败应整体回滚,流水不应变化:before=%+v after=%+v", before, after)
afterPoints := env.ledgerPointsByTeam(t)
if !reflect.DeepEqual(afterPoints, beforePoints) {
t.Fatalf("重算失败后流水应不变:before=%+v after=%+v", beforePoints, afterPoints)
}
// 可恢复:按提示给坏小项补一条 basis=manual 的 override,再导入就成功,且名次表被重建。
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackFixedBroken), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("修正坏小项后导入应 200,得到 %d %s", status, raw)
}
if got := channelRankCount(t, env); got <= beforeRanks {
t.Fatalf("修正后名次表应被重建(含 Z_BROKEN 的一行),得到 %d 行", got)
}
if got := medalsByTeam(t, env); got[ids.teamA].Gold == 0 {
t.Fatalf("修正后奖牌榜应恢复,得到 %+v", got)
}
}
+56 -2
View File
@@ -86,7 +86,7 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
`SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
@@ -98,14 +98,19 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
for rows.Next() {
var (
c domain.ScoreChannel
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 汇总口径只认 sum:avg(含缺字段的老库)保持零值,由引擎按缺省 avg 处理。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = domain.AggregateSum
}
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
if countToTeam == 0 {
c.CountToTeam = boolPtr(false)
@@ -545,14 +550,30 @@ func resultStatusPtr(s domain.ResultStatus) *string {
// 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列——
// 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。
//
// 这一列还是 manual 通道的**输入**(个人手工名次走它、团体报名项的团体名次也走它兜底),
// 所以命中「人工名次的录入位」时必须让路,否则同一份数据第二次发布会算出另一个结果:
//
// - 小项配了 [athlete, manual]:每一条成绩的 results.rank 都是操作员填的输入,
// 自动名次覆盖它 → 人工通道下一轮读到的是自动名次(工单 A-2:A 队 18 → 20 分);
// - 小项配了 [team, manual] 且该行是**团体报名项**(AthleteID == 0):它的 results.rank
// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源),
// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。
//
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
inputs, err := manualRankInputEvents(ctx, tx, ranked)
if err != nil {
return err
}
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) {
continue
}
cur, ok := best[rr.ResultID]
if !ok || preferComputedRank(rr, cur) {
best[rr.ResultID] = rr
@@ -566,6 +587,39 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
return nil
}
// manualRankInputs 描述一个小项把 results.rank 当作「人工名次录入位」的两种通道形态。
type manualRankInputs struct {
athleteManual bool // [athlete, manual]:每条成绩的 results.rank 都是人工录入的个人名次
teamManual bool // [team, manual]:团体报名项的 results.rank 是人工录入的团体名次
}
// manualRankInputEvents 查出这些名次所属小项里,哪些把 results.rank 当输入用。
// 只查 ranked 里出现的小项:没有名次可写回时不需要这一趟查询,
// 而「有没有 manual 通道」必须看库(不能只看 ranked——名次还没录时该通道不产出明细)。
func manualRankInputEvents(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) (map[int64]manualRankInputs, error) {
if len(ranked) == 0 {
return nil, nil
}
out := make(map[int64]manualRankInputs)
for _, rr := range ranked {
if rr.EventID == 0 {
continue
}
if _, done := out[rr.EventID]; done {
continue
}
var athlete, team int
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(CASE WHEN scope = 'athlete' AND basis = 'manual' THEN 1 ELSE 0 END), 0),
COALESCE(MAX(CASE WHEN scope = 'team' AND basis = 'manual' THEN 1 ELSE 0 END), 0)
FROM event_score_channels WHERE event_id = ?`, rr.EventID).Scan(&athlete, &team); err != nil {
return nil, internal(err)
}
out[rr.EventID] = manualRankInputs{athleteManual: athlete != 0, teamManual: team != 0}
}
return out, nil
}
// preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。
func preferComputedRank(a, b domain.RankedResult) bool {
if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) {
@@ -0,0 +1,103 @@
package httpapi_test
import (
"encoding/json"
"net/http"
"testing"
)
// TestStandingsMedalRankIndependentOfPoints 是 06 报告 P1④ 的接口级回归用例
// (主人 2026-09-13 拍板:单位奖牌榜独立按金→银→铜排序,与积分榜解耦)。
//
// 造数:A 拿 1 金 10 分,B 拿 1 银 8 分,再给 B 记一笔 30 分的人工调整(不发奖牌)。
// 期望:总积分榜仍按总分排(B 38 分第一),单位奖牌榜把 A 排第一(gold 1 > 0)。
// 修前两个榜同序,奖牌榜会把金牌 1 枚的 A 排在金牌 0 枚的 B 后面,正是报告里的现象。
func TestStandingsMedalRankIndependentOfPoints(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, body := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, body.Error)
}
if status, body := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, body.Error)
}
// 人工调整只加分不发奖牌,正好用来造「金牌多、总分低」。
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
if status, body := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{
"teamId": ids.teamB, "delta": 30, "category": "bonus", "reason": "接口用例造数",
}, true); status != http.StatusOK {
t.Fatalf("人工调整应 200,得到 %d %+v", status, body.Error)
}
// REST:GET /api/standings 的 rank 与 medalRank 必须是两套口径。
_, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
assertMedalRankDecoupled(t, "REST /api/standings", decodeData[[]map[string]any](t, resp))
// 大屏那条路:standings 变更的载荷(change_log 里固化的那份,WS 原样推送)同源。
var payload string
if err := env.ev.QueryRow(`SELECT payload_json FROM change_log
WHERE entity_type = 'standings' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil {
t.Fatalf("读取 standings 变更: %v", err)
}
var pushed []map[string]any
if err := json.Unmarshal([]byte(payload), &pushed); err != nil {
t.Fatalf("standings 载荷应为数组: %v (%s)", err, payload)
}
assertMedalRankDecoupled(t, "WS standings 载荷", pushed)
}
// assertMedalRankDecoupled 断言榜单载荷里「积分榜按总分、奖牌榜按金牌」两套名次并存。
func assertMedalRankDecoupled(t *testing.T, label string, rows []map[string]any) {
t.Helper()
// 字段缺失时给一句人话,不要用裸类型断言(旧服务端的载荷里没有 medalRank)。
num := func(row map[string]any, key string) float64 {
value, ok := row[key].(float64)
if !ok {
t.Fatalf("%s:字段 %q 不是数字(缺失 / 类型不对):%v", label, key, row[key])
}
return value
}
byCode := make(map[string]map[string]any, len(rows))
for _, row := range rows {
code, _ := row["teamCode"].(string)
byCode[code] = row
}
if len(byCode) != 2 {
t.Fatalf("%s:应含 2 个单位,得到 %d(%v)", label, len(byCode), rows)
}
a, b := byCode["A"], byCode["B"]
// 总积分榜:B 靠 30 分人工调整反超,仍是 38 分第一。
if got := num(b, "totalPoints"); got != 38 {
t.Fatalf("%s:B 总分 = %v,期望 38(8 + 调整 30)", label, got)
}
if num(b, "rank") != 1 || num(a, "rank") != 2 {
t.Fatalf("%s:总积分榜名次应 B 在 A 前(B 38 分),得到 A:%v B:%v",
label, a["rank"], b["rank"])
}
// 单位奖牌榜:A 有 1 金(B 无牌),名次必须独立算成第一。
if num(a, "gold") != 1 || num(b, "gold") != 0 {
t.Fatalf("%s:奖牌数不对,A 应 1 金、B 应 0 金:%v %v", label, a, b)
}
if num(a, "medalRank") != 1 || num(b, "medalRank") != 2 {
t.Fatalf("%s:单位奖牌榜名次应 A 第一、B 第二(按金牌),得到 A:%v B:%v",
label, a["medalRank"], b["medalRank"])
}
}
+1 -1
View File
@@ -266,7 +266,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
}
assertKeys(t, "standings[0]", rows[0], []string{
"teamId", "teamCode", "teamName", "shortName", "color", "logoPath",
"rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
"rank", "medalRank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
})
}
+9 -3
View File
@@ -232,15 +232,21 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []doma
activeTeams = append(activeTeams, t)
}
}
rows := domain.BuildTeamStandings(domain.StandingsInput{
in := domain.StandingsInput{
Teams: activeTeams,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitTieBreak(values["standings.tie_break"]),
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
})
snap.Standings = dto.StandingRows(rows, teams)
}
// 单位奖牌榜的名次独立于总积分榜(金→银→铜→编码),随 medalRank 下发;
// 与 REST、xlsx 导出走的是同一个 domain.BuildMedalStandings。
snap.Standings = dto.StandingRows(
domain.BuildTeamStandings(in),
domain.BuildMedalStandings(in),
teams,
)
return nil
}
+2 -1
View File
@@ -120,7 +120,8 @@ func TestSnapshotWireContract(t *testing.T) {
if len(rows) != 1 {
t.Fatalf("榜单行数应为 1,得到 %d", len(rows))
}
for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank"} {
// medalRank 是单位奖牌榜的独立名次,必须随快照下发,否则大屏奖牌页只能退回积分榜名次。
for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank", "medalRank"} {
if _, ok := rows[0][key]; !ok {
t.Fatalf("standings 缺少契约字段 %s: %s", key, top["standings"])
}
+211
View File
@@ -0,0 +1,211 @@
// Package revisions 从 result_revisions 的版本链里还原「这条成绩被改过几次、最后一次因为什么改」。
//
// 两个消费方:单位积分明细的屏幕展示(httpapi.teamBreakdown)与 xlsx 导出(export.loadTeamDetailRows)。
// 口径必须同源 —— 屏幕上写着「已修订 ×2」而导出件写「已修订 ×1」,就是同一份数据两处打架,
// 所以查询与判定都收在这里,调用方只负责排版。
//
// 判定「被改过」的判据(保守优先,宁可不显示数字也不误报):
// - 身份用 (event_id, entry_id):results.entry_id 唯一,且「退回草稿 / 作废成绩」只改这一行的
// 状态、不换报名项,重新发布也只会作废旧流水、写新流水。所以按这条线数历史版本,
// 不会被「重新发布产生新流水行」打断(ledger_ids 写入侧早已不写,result_id 也会随发布变化)。
// - 只比「录进去的成绩」本身:成绩文本 / 数值 / 单位 / 并列 / 状态。
// 刻意不比 rank 与 state:名次是引擎每次发布按当前成绩重算的(别人改了成绩,我的名次也会变),
// state 是发布动作自己写的,两者都会在「这条成绩一个字没改」的情况下逐次发布都不同。
// - 至少要见过两次快照才可能报数:第一次出现是基线,不计;一次都没出现过(例如升级前就发布、
// 没有版本记录)则整条不返回,调用方据此显示为空。
package revisions
import (
"context"
"database/sql"
"strings"
)
// Key 是一条成绩在版本链里的身份:同一个小项里的同一个报名项。
type Key struct {
EventID int64
EntryID int64
}
// KeyFor 返回一条流水在版本链里的身份;返回零值表示这条流水追不了修订痕迹(调用方按空显示)。
//
// 只认**成绩流水本身**(source_type = 'result'):破纪录加分与人工调整是站在成绩旁边的另一笔分,
// 把成绩的修订次数挂到它们头上,等于对外说「那笔加分被改过」—— 那不是事实,正是要避免的误报。
// 聚合流水(按队内平均)与团体手工名次没有单个报名项,同样返回零值。
func KeyFor(sourceType string, eventID, entryID int64) Key {
if sourceType != "result" || eventID == 0 || entryID == 0 {
return Key{}
}
return Key{EventID: eventID, EntryID: entryID}
}
// Trail 是一条成绩的修订痕迹。Count == 0 表示追溯成功但没有改动过(调用方不应显示角标)。
type Trail struct {
Count int
// At 是最后一次改动落库的时间,SQLite datetime('now') 的原样字符串(UTC,不带时区)。
At string
// Reason 是最后一次改动的原因;确实没记录过就是空串 —— 宁可不写也不编。
Reason string
// ActorID 是最后一次改动的操作人(指向系统库 users,跨库不能建外键,解析姓名由调用方负责)。
ActorID *int64
}
// Queryer 是 *sql.DB / *sql.Tx 的最小公共面。
type Queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
// snapshotColumns 从快照里的每条成绩对象上取出的字段。
//
// 取的就是 httpapi 的 resultDTO 序列化后的 JSON 键(该类型不导出,改字段名要同步这里)。
// 只取判定用得到的几项:rank / state / verifiedBy 之类故意不取,见包注释。
const snapshotColumns = `json_extract(v.value, '$.entryId'),
COALESCE(json_extract(v.value, '$.resultText'), ''),
json_extract(v.value, '$.resultValue'),
COALESCE(json_extract(v.value, '$.unit'), ''),
COALESCE(json_extract(v.value, '$.isTie'), 0),
COALESCE(json_extract(v.value, '$.status'), ''),
COALESCE(json_extract(v.value, '$.note'), '')`
// chunk 是一次 IN 查询里最多带几个 Key:事件与报名项各一个 IN 列表,参数个数翻倍,
// SQLite 的宿主参数上限(默认 32766)不该被一次明细请求摸到。
const chunk = 100
// Load 汇总这些成绩的修订痕迹。返回的 map 里只包含「在版本链里出现过」的 Key。
//
// 读不出来(快照损坏等)时返回错误,由调用方决定降级:明细是展示层的东西,
// 不该因为追溯不出来就整个接口失败。
func Load(ctx context.Context, q Queryer, keys []Key) (map[Key]Trail, error) {
out := make(map[Key]Trail)
seen := make(map[Key]bool)
unique := make([]Key, 0, len(keys))
for _, k := range keys {
if k.EventID == 0 || k.EntryID == 0 || seen[k] {
continue
}
seen[k] = true
unique = append(unique, k)
}
for start := 0; start < len(unique); start += chunk {
end := start + chunk
if end > len(unique) {
end = len(unique)
}
batch := unique[start:end]
eventIDs := make([]any, 0, len(batch))
entryIDs := make([]any, 0, len(batch))
wanted := make(map[Key]bool, len(batch))
for _, k := range batch {
eventIDs = append(eventIDs, k.EventID)
entryIDs = append(entryIDs, k.EntryID)
wanted[k] = true
}
// json_each 把快照数组摊成一行行成绩,只把命中的那几行传回来 ——
// 事件攒了几十版快照、每版几百条成绩时,不值得整个 JSON 拉回来在 Go 里解析。
query := `SELECT r.event_id, r.created_at, COALESCE(r.message, ''), r.actor_id, ` + snapshotColumns + `
FROM result_revisions r, json_each(r.snapshot_json) v
WHERE r.event_id IN (` + placeholders(len(eventIDs)) + `)
AND json_extract(v.value, '$.entryId') IN (` + placeholders(len(entryIDs)) + `)
ORDER BY r.event_id, r.id`
args := append(append([]any{}, eventIDs...), entryIDs...)
if err := scan(ctx, q, query, args, wanted, out); err != nil {
return nil, err
}
}
return out, nil
}
// snapshotValue 是一条成绩在某一版快照里的「录进去的成绩」。
type snapshotValue struct {
text string
value sql.NullFloat64
unit string
tie int
status string
}
// changed 判断这一版是不是和上一版录的不是同一个成绩了。
//
// 浮点按原值比较:正负零、NaN 这类边界在成绩录入里不会出现(服务端只接受有限数),
// 而 11.58 与 11.580000000000002 这种舍入差异本就不该被当成「改过」。
func (v snapshotValue) changed(o snapshotValue) bool {
if v.text != o.text || v.unit != o.unit || v.tie != o.tie || v.status != o.status {
return true
}
if v.value.Valid != o.value.Valid {
return true
}
return v.value.Valid && v.value.Float64 != o.value.Float64
}
func scan(ctx context.Context, q Queryer, query string, args []any, wanted map[Key]bool, out map[Key]Trail) error {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
prev := make(map[Key]snapshotValue, len(wanted))
for rows.Next() {
var (
eventID, entryID int64
createdAt, message string
actorID sql.NullInt64
note string
value sql.NullFloat64
text, unit, status string
tie int
)
if err := rows.Scan(&eventID, &createdAt, &message, &actorID, &entryID, &text, &value,
&unit, &tie, &status, &note); err != nil {
return err
}
key := Key{EventID: eventID, EntryID: entryID}
if !wanted[key] {
continue
}
cur := snapshotValue{text: text, value: value, unit: unit, tie: tie, status: status}
// 第一次出现只是基线(「发布」本身不是修订),从第二次起才谈得上「改没改」。
if before, ok := prev[key]; ok {
if before.changed(cur) {
trail := out[key]
trail.Count++
trail.At = createdAt
trail.Reason = reasonOf(note, message)
trail.ActorID = nil
if actorID.Valid {
id := actorID.Int64
trail.ActorID = &id
}
out[key] = trail
}
} else {
if _, ok := out[key]; !ok {
out[key] = Trail{} // 见过的标记:Count 0 = 追溯成功、没改过
}
}
prev[key] = cur
}
return rows.Err()
}
// reasonOf 取这次改动的原因,先具体后笼统:
//
// - results.note 是这条成绩自己的备注,「退回草稿 / 作废成绩」时写下的理由就落在它上面;
// - r.message 是修订行自带的消息,目前只有发布时「跳过 N 条只填了成绩文本的草稿」这一种,
// 是**发布级**说明,挂到某一条成绩头上未必对,所以只在前者没有时才用。
//
// 两个都没有就返回空串:调用方只显示时间,不编原因。
func reasonOf(note, message string) string {
if s := strings.TrimSpace(note); s != "" {
return s
}
return strings.TrimSpace(message)
}
func placeholders(n int) string {
if n == 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
+229
View File
@@ -0,0 +1,229 @@
package revisions_test
import (
"context"
"database/sql"
"encoding/json"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/revisions"
)
// 一版快照里的成绩,字段名与 httpapi.resultDTO 的 JSON 对齐(只列判定用得到的)。
type snapResult struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
ResultValue *float64 `json:"resultValue"`
Unit string `json:"unit"`
IsTie bool `json:"isTie"`
Status string `json:"status"`
State string `json:"state"`
Note *string `json:"note"`
}
type revision struct {
eventID int64
message string
actorID any
created string
results []snapResult
}
func newDB(t *testing.T, rows []revision) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file::memory:?cache=shared")
if err != nil {
t.Fatalf("打开内存库: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if _, err := db.Exec(`CREATE TABLE result_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, action TEXT NOT NULL,
snapshot_json TEXT NOT NULL, actor_id INTEGER, message TEXT, created_at TEXT NOT NULL)`); err != nil {
t.Fatalf("建表: %v", err)
}
for _, row := range rows {
raw, err := json.Marshal(row.results)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
if _, err := db.Exec(`INSERT INTO result_revisions (event_id, action, snapshot_json, actor_id, message, created_at)
VALUES (?, 'publish', ?, ?, ?, ?)`, row.eventID, string(raw), row.actorID, row.message, row.created); err != nil {
t.Fatalf("插入修订: %v", err)
}
}
return db
}
func f64(v float64) *float64 { return &v }
func str(v string) *string { return &v }
// result 造一条成绩快照;名次与 state 每次都不同,用来证明它们不参与判定。
func result(entryID int64, rank int, text string, value float64, state string) snapResult {
return snapResult{
EntryID: entryID, Rank: &rank, ResultText: text, ResultValue: f64(value),
Unit: "s", Status: "ok", State: state,
}
}
func load(t *testing.T, db *sql.DB, keys ...revisions.Key) map[revisions.Key]revisions.Trail {
t.Helper()
trails, err := revisions.Load(context.Background(), db, keys)
if err != nil {
t.Fatalf("Load: %v", err)
}
return trails
}
// 发布 → 改成绩 → 重新发布:能读出「改过 1 次」、最后一次的时间与原因。
func TestLoadCountsRepublishAfterEdit(t *testing.T) {
db := newDB(t, []revision{
{eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{
result(11, 1, "11.58", 11.58, "published"),
result(12, 2, "11.92", 11.92, "published"),
}},
{eventID: 1, created: "2026-09-13 02:50:00", results: []snapResult{
// 退回草稿:只有 state 变,成绩一个字没动。
result(11, 1, "11.58", 11.58, "draft"),
result(12, 2, "11.92", 11.92, "published"),
}},
{eventID: 1, message: "", actorID: 7, created: "2026-09-13 02:58:00", results: []snapResult{
result(11, 1, "11.42", 11.42, "published"),
result(12, 3, "11.92", 11.92, "published"),
}},
})
trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 12})
got := trails[revisions.Key{EventID: 1, EntryID: 11}]
if got.Count != 1 {
t.Fatalf("11 号成绩改过 1 次,得到 %d", got.Count)
}
if got.At != "2026-09-13 02:58:00" {
t.Fatalf("最后一次修订时间 = %q", got.At)
}
// 12 号成绩一个字没改,只有名次被引擎重算(2 → 3):不能算修订。
if other, ok := trails[revisions.Key{EventID: 1, EntryID: 12}]; !ok || other.Count != 0 {
t.Fatalf("没改过的成绩不该报修订,得到 %+v(存在=%v)", other, ok)
}
}
// 原因优先取这条成绩自己的备注(「退回草稿」写下的理由),修订行的 message 只作兜底。
func TestLoadPrefersResultNoteAsReason(t *testing.T) {
db := newDB(t, []revision{
{eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{
result(11, 1, "11.58", 11.58, "published"),
}},
{eventID: 1, message: "跳过 3 条只填了成绩文本的草稿", actorID: 7, created: "2026-09-13 02:58:00",
results: []snapResult{func() snapResult {
r := result(11, 1, "11.42", 11.42, "published")
r.Note = str("成绩录入笔误")
return r
}()}},
})
got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}]
if got.Reason != "成绩录入笔误" {
t.Fatalf("原因应取成绩备注,得到 %q", got.Reason)
}
if got.ActorID == nil || *got.ActorID != 7 {
t.Fatalf("操作人 = %v,期望 7", got.ActorID)
}
}
// 没有备注时退回修订行的 message;两者都没有就是空串(调用方只显示时间,不编原因)。
func TestLoadReasonFallsBackToMessageThenEmpty(t *testing.T) {
db := newDB(t, []revision{
{eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}},
{eventID: 1, message: "现场复核后更正", created: "2026-09-13 02:58:00",
results: []snapResult{result(11, 1, "11.42", 11.42, "published")}},
{eventID: 1, created: "2026-09-13 03:10:00", results: []snapResult{result(11, 1, "11.30", 11.30, "published")}},
})
got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}]
if got.Count != 2 {
t.Fatalf("改了两次应报 2,得到 %d", got.Count)
}
if got.Reason != "" {
t.Fatalf("最后一次没写原因就应留空,得到 %q", got.Reason)
}
if got.At != "2026-09-13 03:10:00" {
t.Fatalf("最后一次修订时间 = %q", got.At)
}
}
// 一次都没在版本链里出现过(没有基线)时不返回该 Key —— 调用方据此显示为空,而不是 0。
func TestLoadOmitsKeysWithoutBaseline(t *testing.T) {
db := newDB(t, []revision{
{eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}},
})
trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 99},
revisions.Key{EventID: 2, EntryID: 11})
if _, ok := trails[revisions.Key{EventID: 1, EntryID: 99}]; ok {
t.Fatalf("没出现在任何快照里的成绩不该有痕迹")
}
if _, ok := trails[revisions.Key{EventID: 2, EntryID: 11}]; ok {
t.Fatalf("别的项目下的同号报名项不该被认成同一条成绩")
}
if got := trails[revisions.Key{EventID: 1, EntryID: 11}]; got.Count != 0 {
t.Fatalf("只发布过一次的成绩应追溯得到、且没改过,得到 %+v", got)
}
}
// 「作废 / 改判」改的是状态不是成绩文本:也算改过(否则现场看不出这格被改判过)。
func TestLoadCountsStatusChange(t *testing.T) {
db := newDB(t, []revision{
{eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}},
{eventID: 1, created: "2026-09-13 03:00:00", results: []snapResult{func() snapResult {
r := result(11, 1, "11.58", 11.58, "published")
r.Status = "dq"
return r
}()}},
})
if got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}]; got.Count != 1 {
t.Fatalf("改判(状态变化)应算改过 1 次,得到 %+v", got)
}
}
// 同一条成绩的小项下别的成绩反复改,不能把它的修订次数带上去。
func TestLoadKeepsEventsApart(t *testing.T) {
db := newDB(t, []revision{
{eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{
result(11, 1, "11.58", 11.58, "published"), result(21, 1, "22.10", 22.10, "published"),
}},
{eventID: 1, created: "2026-09-13 03:00:00", results: []snapResult{
result(11, 1, "11.58", 11.58, "published"), result(21, 1, "21.80", 21.80, "published"),
}},
{eventID: 2, created: "2026-09-13 03:10:00", results: []snapResult{result(11, 1, "11.42", 11.42, "published")}},
})
trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 21},
revisions.Key{EventID: 2, EntryID: 11})
if got := trails[revisions.Key{EventID: 1, EntryID: 11}].Count; got != 0 {
t.Fatalf("同一小项里别人改了,自己不算改过,得到 %d", got)
}
if got := trails[revisions.Key{EventID: 1, EntryID: 21}].Count; got != 1 {
t.Fatalf("真正改了的那条应报 1,得到 %d", got)
}
if got := trails[revisions.Key{EventID: 2, EntryID: 11}].Count; got != 0 {
t.Fatalf("另一个小项里的同号报名项不该受牵连,得到 %d", got)
}
}
// 破纪录加分 / 人工调整 / 聚合流水没有「一条成绩」可追:KeyFor 不给它们身份,
// 免得把成绩的修订说成「那笔加分被改过」。
func TestKeyForOnlyAcceptsScoreLedger(t *testing.T) {
if got := revisions.KeyFor("record_bonus", 1, 11); got != (revisions.Key{}) {
t.Fatalf("破纪录加分不该挂成绩的修订痕迹,得到 %+v", got)
}
if got := revisions.KeyFor("adjustment", 1, 11); got != (revisions.Key{}) {
t.Fatalf("人工调整不该挂成绩的修订痕迹,得到 %+v", got)
}
if got := revisions.KeyFor("result", 0, 11); got != (revisions.Key{}) {
t.Fatalf("没有小项的流水追不了,得到 %+v", got)
}
if got := revisions.KeyFor("result", 1, 0); got != (revisions.Key{}) {
t.Fatalf("聚合流水没有报名项,追不了,得到 %+v", got)
}
if got := revisions.KeyFor("result", 1, 11); got != (revisions.Key{EventID: 1, EntryID: 11}) {
t.Fatalf("成绩流水应给出身份,得到 %+v", got)
}
}
@@ -0,0 +1,220 @@
package rules
import (
"database/sql"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 聚合口径(basis=aggregate 的 avg / sum)在规则包这条路上的口径:
// 解析认 avg/sum、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum),
// 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。
//
// 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和,
// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径。
const aggregateModePackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo-rules, name: 示例规则包 }
standings: { individualToTeamWeight: 1, tieBreak: [points, gold, silver, bronze, teamCode] }
profiles:
- code: team_top6
name: 团体项目前六名
awardRanks: 2
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
recordBonus: { enabled: false, mode: max }
manualAdjustment: { enabled: false, reasonRequired: true }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: team_top6 }
eventOverrides:
- eventCode: EVT_SUM
name: 团体引体向上(总和)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }
- eventCode: EVT_AVG
name: 团体引体向上(平均,显式写缺省值)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }
`
// seedAggregateEvents 建两个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。
func seedAggregateEvents(t *testing.T, db *sql.DB) {
t.Helper()
sportID := insertID(t, db, `INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`)
for _, ev := range []struct{ code, name string }{
{"EVT_SUM", "团体引体向上(总和)"},
{"EVT_AVG", "团体引体向上(平均,显式写缺省值)"},
} {
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier)
VALUES (?, ?, ?, 'individual_team', 'auto', 'count', 'desc', 1, 1)`, sportID, ev.code, ev.name); err != nil {
t.Fatalf("插入项目 %s: %v", ev.code, err)
}
}
}
// TestParseAggregateMode 守解析:avg / sum 都认,缺省即 avg,拼错的取值必须报错。
func TestParseAggregateMode(t *testing.T) {
pack, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
overrides := map[string]Channel{}
for _, ov := range pack.EventOverrides {
overrides[ov.EventCode] = (*ov.Channels)[0]
}
if got := overrides["EVT_SUM"].AggregatesBy(); got != domain.AggregateSum {
t.Errorf("aggregateMode: sum 应解析成 sum,实际 %q", got)
}
// 显式写 avg 与不写等价(缺省值),解析结果都要是 avg。
if got := overrides["EVT_AVG"].AggregatesBy(); got != domain.AggregateAvg {
t.Errorf("aggregateMode: avg 应解析成 avg,实际 %q", got)
}
// 普通通道(basis=result)不写该字段时同样是缺省 avg,不影响任何行为。
tmpl := pack.ChannelTemplates[0].Channels[0]
if tmpl.AggregateMode != "" || tmpl.AggregatesBy() != domain.AggregateAvg {
t.Errorf("缺省通道的汇总口径应为空(等价 avg),实际 %q", tmpl.AggregateMode)
}
// 拼错的取值必须报错:静默按 avg 处理会让现场以为切到了总和。
bad := strings.Replace(aggregateModePackYAML, "aggregateMode: sum", "aggregateMode: biggest", 1)
if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) {
t.Fatalf("非法聚合口径应报校验错误,实际 %v", err)
} else if !strings.Contains(err.Error(), "aggregateMode") {
t.Errorf("报错应点明 aggregateMode 字段,实际 %v", err)
}
}
// TestApplyAndExportAggregateMode 守落库与导出:
// sum 落库并导出回来;显式 avg 落库成 avg、导出时按缺省省略(导出 → 导入不产生多余差异)。
func TestApplyAndExportAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
modeOf := func(code string) string {
t.Helper()
var mode string
if err := db.QueryRow(`SELECT c.aggregate_mode FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = ? AND c.basis = 'aggregate'`, code).Scan(&mode); err != nil {
t.Fatalf("读取 %s 的聚合口径: %v", code, err)
}
return mode
}
if got := modeOf("EVT_SUM"); got != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 落库 = %q,期望 sum", got)
}
// 缺省口径也落成具体值 avg,引擎侧不必再判空。
if got := modeOf("EVT_AVG"); got != string(domain.AggregateAvg) {
t.Errorf("EVT_AVG 落库 = %q,期望 avg", got)
}
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
exportedMode := map[string]string{}
for _, ov := range exported.EventOverrides {
exportedMode[ov.EventCode] = (*ov.Channels)[0].AggregateMode
}
// sum 是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把总和口径丢成平均。
if exportedMode["EVT_SUM"] != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"])
}
// avg 是缺省值,导出文件里不出现,往返比对不会凭空多出差异。
if exportedMode["EVT_AVG"] != "" {
t.Errorf("EVT_AVG 导出 = %q,期望省略(avg 是缺省值)", exportedMode["EVT_AVG"])
}
// 导出的包重新导入:库里已有 sum / avg,差异必须为空。
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs.Channels)
}
}
// TestDiffIgnoresExplicitDefaultAggregateMode 守差异比对:
// 规则包显式写 avg 与库里缺省值等价,不能被当成「通道改动」而触发一次无意义的重算。
func TestDiffIgnoresExplicitDefaultAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
// 库里 EVT_AVG 是 avg(导出时按缺省省略),规则包再显式写一遍 avg:语义相同,不该有差异。
if cs, err := Diff(cur, next); err != nil {
t.Fatalf("Diff: %v", err)
} else if !cs.Empty() {
t.Fatalf("显式 avg 与缺省 avg 等价,不应有差异: %+v", cs.Channels)
}
// 对照:把 EVT_AVG 换成 sum 就必须报出一次通道改动(否则现场改了口径却不重算)。
target := strings.Replace(aggregateModePackYAML,
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }",
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }", 1)
changed, err := Parse([]byte(target))
if err != nil {
t.Fatalf("Parse 改口径后的规则包: %v", err)
}
cs, err := Diff(cur, changed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
hit := false
for _, ch := range cs.Channels {
if ch.EventCode == "EVT_AVG" {
hit = true
}
}
if !hit {
t.Fatalf("把口径从 avg 改成 sum 必须报出通道改动,实际 %+v", cs.Channels)
}
}
// insertID 执行一条 INSERT 并返回自增 id。
func insertID(t *testing.T, db *sql.DB, query string, args ...any) int64 {
t.Helper()
res, err := db.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("获取 id: %v", err)
}
return id
}
+38 -17
View File
@@ -17,14 +17,44 @@ type ApplyResult struct {
EventsSkipped []string `json:"eventsSkipped,omitempty"`
}
// Apply 把差异预览落地。规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,
// 而不是逐条套用 diff 列表——这样重复导入同一份规则包是幂等的。
// Apply 把差异预览落地(自带事务)。
//
// 规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,而不是逐条套用 diff 列表——
// 这样重复导入同一份规则包是幂等的。
// 只填了 Next 的变更集(例如确认导入时重新 Parse 上传内容)同样可以调用,等价于强制全量导入。
//
// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。
// 需要「换通道」与「重算」同事务时用 ApplyTx(规则包导入走的是那条路,见其注释)。
func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("开启事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := ApplyTx(tx, cs)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交导入事务: %w", err)
}
return res, nil
}
// ApplyTx 在调用方给定的**未提交**事务里落地规则包变更,自己不提交。
//
// 为什么要有这个入口:换通道会先 `DELETE FROM event_score_channels` 再插新行,而 channel_ranks
// 的 channel_id 挂的是 `ON DELETE CASCADE`——通道一删,该通道的名次就跟着没了。名次由紧随其后的
// 全量重算重建,两者分属不同事务时,只要重算失败(例如某个小项配不出名次),名次表就停在
// 「已经被清空、又补不回来」的中间态:榜单分还在、奖牌全 0,而且自愈与重试导入都会因同一个小项
// 再次失败(审查 04 P0-1,实测 2 行 → 0 行、金牌 1 → 0)。
// 共用一个事务后,重算失败即整包回滚,名次表回到导入前的状态。
//
// 落地顺序:模板 → 通道 → 删除无主模板 → 项目开关 → 赛事设置。
// 通道先改完再删模板,避免「模板被换掉」时误判引用。
// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。
func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
// 现状快照在**本事务内**读取(loadSnapshot(tx)),保证看到的就是即将写入的这份数据。
func ApplyTx(tx *sql.Tx, cs *ChangeSet) (*ApplyResult, error) {
if cs == nil || cs.Next == nil {
return nil, ErrNilChangeSet
}
@@ -33,7 +63,7 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
return nil, err
}
cur, err := Load(db)
cur, err := loadSnapshot(tx)
if err != nil {
return nil, err
}
@@ -42,12 +72,6 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
return nil, err
}
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("开启事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
res := &ApplyResult{EventsSkipped: unknown}
// 1. 模板全量 upsert,并建立「编码 → id」映射供通道引用。
@@ -85,8 +109,9 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if !ok {
return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile)
}
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
string(ch.AggregatesBy()),
boolToInt(ch.CountsToTeam()), boolToInt(ch.CountsToMedals()), boolToInt(ch.CountsToTeamMedals())); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
@@ -149,10 +174,6 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
}
res.SettingsWritten++
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交导入事务: %w", err)
}
return res, nil
}
+139
View File
@@ -0,0 +1,139 @@
package rules
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 本文件钉住工单 E:分值表的档位超出「录取档位数(awardRanks)」时必须拒收。
//
// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
// 而本包原先只拒 awardRanks < 0:一份「配到第 7 档、录取名次还留着 6」的规则包能过校验、
// 安静导入——第 7 档在模板页上写着分,榜上恒为 0,界面上看不出任何异常(审查 01 P1)。
// awardRanks: 0 表示不按名次封顶(既有语义),这种包必须继续放行。
// sevenRankPackYAML 是「配到第 7 档」的规则包;录取名次由用例替换成 6 / 0。
const sevenRankPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 第七档 }
profiles:
- code: p7
name: 前七名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 5 }
- { rank: 5, points: 4 }
- { rank: 6, points: 3 }
- { rank: 7, points: 2 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: p7 }
`
// 录取名次 6、分值表到第 7 档:导入必须整体拒收,且说明要说清「哪一档超了」「怎么改」。
func TestParseRejectsAwardRanksOverflow(t *testing.T) {
_, err := Parse([]byte(sevenRankPackYAML))
if !errors.Is(err, ErrValidation) {
t.Fatalf("第 7 档 + awardRanks=6 应报 ErrValidation,实际 %v", err)
}
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("应返回 *ValidationError,实际 %T", err)
}
var found bool
for _, is := range ve.Issues {
if is.Path != "profiles[0].awardRanks" {
continue
}
found = true
// 现场要能照着改:说清超出的档位、当前录取名次,以及「0 表示不封顶」这个出口。
for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} {
if !strings.Contains(is.Msg, want) {
t.Fatalf("说明里缺少 %q:%s", want, is.Msg)
}
}
}
if !found {
t.Fatalf("问题应挂在 profiles[0].awardRanks 上,实际 %+v", ve.Issues)
}
}
// 这条检查只在**写入**侧生效:导出(Marshal)走的是 Validate,必须继续容忍越档模板。
// 库里修好之前存下的历史配置如果让导不出来,GET /api/rules/export 与赛事项目包(备份)会一起 500,
// 而它们正是现场排障与备份的入口——所以导出侧宁可原样导出,由用户在模板页上改。
func TestMarshalStillAllowsLegacyAwardRanksOverflow(t *testing.T) {
pack := &RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
Meta: Meta{Code: "legacy", Name: "历史越档模板"},
Profiles: []Profile{{
Code: "p7", Name: "前七名", AwardRanks: 6, TieMode: domain.TieSkip,
Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}},
}},
}
if err := pack.Validate(); err != nil {
t.Fatalf("导出侧校验不该拦越档模板,实际 %v", err)
}
if _, err := Marshal(pack); err != nil {
t.Fatalf("越档模板必须还能导出,实际 %v", err)
}
// 但同一份配置从导入进来时就要被拒(Parse → ValidateForImport)。
if _, err := Parse([]byte(sevenRankPackYAML)); !errors.Is(err, ErrValidation) {
t.Fatalf("导入侧应拒收越档模板,实际 %v", err)
}
}
// 两种合法形态不得被误杀:正好相等(第 6 档 + awardRanks 6)、不封顶(第 7 档 + awardRanks 0)。
func TestParseAcceptsAwardRanksCoveringRules(t *testing.T) {
cases := map[string]string{
"正好相等": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 7", 1),
"不封顶": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 0", 1),
}
for name, yaml := range cases {
t.Run(name, func(t *testing.T) {
pack, err := Parse([]byte(yaml))
if err != nil {
t.Fatalf("应解析成功,实际 %v", err)
}
if got := len(pack.Profiles[0].Rules); got != 7 {
t.Fatalf("分值表应保留 7 档,实际 %d", got)
}
})
}
}
// AwardRanksOverflow 是保存模板与导入规则包共用的判定,边界直接钉在函数上。
func TestAwardRanksOverflowBoundaries(t *testing.T) {
cases := []struct {
awardRanks int
maxRuleRank int
overflow bool
}{
{0, 10, false}, // 0 = 不封顶
{-1, 10, false}, // 负数由别处拦,这里不重复报
{6, 0, false}, // 没有分值明细
{6, 6, false}, // 正好覆盖
{7, 6, false}, // 录取名次多于档位:合法(后面几档没人拿得到)
{6, 7, true},
{6, 10, true},
}
for _, tc := range cases {
msg := AwardRanksOverflow(tc.awardRanks, tc.maxRuleRank)
if tc.overflow != (msg != "") {
t.Fatalf("awardRanks=%d maxRuleRank=%d 应 overflow=%v,实际 %q",
tc.awardRanks, tc.maxRuleRank, tc.overflow, msg)
}
}
// 超出的档位取的是「最大的那一档」,不是第一条超出的档位。
if msg := AwardRanksOverflow(6, 10); !strings.Contains(msg, "第 10 档") {
t.Fatalf("应点名最大的超档档位,实际 %q", msg)
}
}
+8 -6
View File
@@ -32,14 +32,16 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致;
// 汇总口径落库时补成具体值(avg / sum),缺省即 avg。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
out := domain.ScoreChannel{
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
SortOrder: sortOrder,
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
SortOrder: sortOrder,
AggregateMode: c.AggregatesBy(),
}
if !c.CountsToTeam() {
out.CountToTeam = BoolPtr(false)
+69 -7
View File
@@ -4,6 +4,9 @@ import (
"errors"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ChangeKind 是差异类型。
@@ -181,14 +184,18 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
}
if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil {
targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...)
continue
}
for _, t := range next.ChannelTemplates {
if matchEvent(t.Match, ev) {
targets[ev.Code] = append([]Channel(nil), t.Channels...)
break
} else {
for _, t := range next.ChannelTemplates {
if matchEvent(t.Match, ev) {
targets[ev.Code] = append([]Channel(nil), t.Channels...)
break
}
}
}
// 目标通道算出来了,落地前先确认它在这个小项上跑得起来(见 checkCompareCompatible)。
if err := checkCompareCompatible(ev, targets[ev.Code]); err != nil {
return nil, nil, err
}
}
var unknown []string
@@ -201,6 +208,57 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
return targets, unknown, nil
}
// CompareConflictError 是「规则包给 compare=none 的小项配了需要比较方向的通道」这一配置错误。
//
// 带小项编码与名称是必须的:导入失败的提示如果只说「basis=result 的通道要求小项配置
// compare=asc/desc」,现场在一场几十上百个小项的赛事里根本找不到是哪一个。
type CompareConflictError struct {
EventCode string
EventName string
// Bases 是冲突的通道口径(result / aggregate),按通道出现顺序、已去重。
Bases []string
}
func (e *CompareConflictError) Error() string {
return fmt.Sprintf("小项 %s(%s)配置的是 compare=none(名次靠人工录入),规则包却给它配了 basis=%s 的通道:"+
"这类通道要先按成绩排出名次,compare=none 时排不出来,导入后这个小项算不了分。"+
"请在规则包里为它写一段 eventOverrides,把 channels 改成 basis=manual"+
"(个人名次用 scope: athlete,团体名次用 scope: team),或调整通道模板的 match 条件别匹配到它。",
e.EventCode, e.EventName, strings.Join(e.Bases, "、"))
}
// checkCompareCompatible 拦下「compare=none 的小项被配上 basis=result / basis=aggregate 通道」。
//
// 为什么要在这里拦:这两种口径都要先按成绩排名,而 compare=none(名次靠人工录入)没有名次可算,
// domain.ScoreEvent 会直接返回 ErrCompareRequired。以前这条配置能过,代价是导入「先换通道」已经提交
// (换通道时 channel_ranks 被外键级联清空)、紧接着的全量重算才报错,名次表就停在空表状态,
// 而且自愈与重试导入都会因同一个小项再次失败(审查 04 P0-1)。
// 现在改成在**落地之前**整包拒收:一个字节都不写,失败提示直接指出哪个小项、为什么。
//
// 只校验本次要写入的目标通道(模板套用与显式 override 一视同仁):库里既有的、规则包没覆盖到的
// 通道不动它——那属于「改配置」而不是「导入规则包」的事,越权清理会误伤别人手工配好的设置。
func checkCompareCompatible(ev EventInfo, channels []Channel) error {
if len(channels) == 0 || ev.Compare != domain.CompareNone {
return nil
}
var bases []string
seen := map[string]bool{}
for _, ch := range channels {
if ch.Basis != domain.BasisResult && ch.Basis != domain.BasisAggregate {
continue
}
if seen[string(ch.Basis)] {
continue
}
seen[string(ch.Basis)] = true
bases = append(bases, string(ch.Basis))
}
if len(bases) == 0 {
return nil
}
return &CompareConflictError{EventCode: ev.Code, EventName: ev.Name, Bases: bases}
}
func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
curByCode := make(map[string]EventInfo, len(cur.Events))
for _, ev := range cur.Events {
@@ -240,7 +298,8 @@ func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
}
// channelsEqual 逐字段比较目标通道与库中通道。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比;
// 汇总口径同理按「缺省即 avg」比,避免规则包显式写 avg 与库里缺省值被当成一次改动。
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
@@ -249,6 +308,9 @@ func channelsEqual(a, b []Channel) bool {
if a[i].Scope != b[i].Scope || a[i].Basis != b[i].Basis || a[i].Profile != b[i].Profile {
return false
}
if a[i].AggregatesBy() != b[i].AggregatesBy() {
return false
}
if a[i].CountsToTeam() != b[i].CountsToTeam() {
return false
}
+91
View File
@@ -1,6 +1,8 @@
package rules
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
@@ -258,6 +260,95 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
})
}
// compare=none 的个人小项(名次靠人工录入)不能被配上 basis=result / basis=aggregate 的通道:
// 这两种口径都要先按成绩排名,而 compare=none 没有名次可算,算分时必然返回 ErrCompareRequired。
// 以前这条配置能过,代价是「先换通道」(channel_ranks 被外键级联清空)已经提交、紧接着的重算才失败,
// 名次表停在空表状态且补不回来(审查 04 P0-1)。现在必须在落地之前整包拒收。
func TestResolveTargetsRejectsCompareRequiredChannelOnCompareNoneEvent(t *testing.T) {
cur := currentSnapshot(t)
cur.Events = append(cur.Events, EventInfo{
ID: 9, Code: "EVT_MANUAL", Name: "手榴弹投准",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
Compare: domain.CompareNone, Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}},
})
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
t.Run("模板套出来的 basis=result 被拦下", func(t *testing.T) {
_, _, err := resolveTargets(cur, next)
var conflict *CompareConflictError
if !errors.As(err, &conflict) {
t.Fatalf("compare=none 的小项被配上 basis=result 应报 CompareConflictError,得到 %v", err)
}
if conflict.EventCode != "EVT_MANUAL" || conflict.EventName != "手榴弹投准" {
t.Errorf("错误里必须指名是哪个小项,得到 %+v", conflict)
}
// 人话:说清哪个小项、为什么、怎么改。
msg := conflict.Error()
for _, want := range []string{"EVT_MANUAL", "手榴弹投准", "compare=none", "basis=result", "eventOverrides", "basis=manual"} {
if !strings.Contains(msg, want) {
t.Errorf("错误信息缺少 %q:%s", want, msg)
}
}
// 预览阶段就必须失败:不能预览说「就改这些」,落地时才炸。
if _, err := Diff(cur, next); !errors.As(err, &conflict) {
t.Errorf("Diff 也应报 CompareConflictError,得到 %v", err)
}
})
t.Run("显式 override 写 basis=result 一样被拦下", func(t *testing.T) {
patched := *next
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
EventCode: "EVT_MANUAL",
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
})
var conflict *CompareConflictError
if _, _, err := resolveTargets(cur, &patched); !errors.As(err, &conflict) {
t.Errorf("override 显式写了 basis=result 也应拦下,得到 %v", err)
}
})
t.Run("override 改成 basis=manual 即放行", func(t *testing.T) {
patched := *next
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
EventCode: "EVT_MANUAL",
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}}),
})
targets, _, err := resolveTargets(cur, &patched)
if err != nil {
t.Fatalf("basis=manual 不该被拦:%v", err)
}
if got := targets["EVT_MANUAL"]; len(got) != 1 || got[0].Basis != domain.BasisManual {
t.Errorf("EVT_MANUAL 的目标通道 = %+v,期望一条 basis=manual", got)
}
// 规则包没覆盖到的其它小项不受影响。
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Basis != domain.BasisResult {
t.Errorf("EVT_IND 的目标通道 = %+v,期望模板给的 basis=result", got)
}
})
t.Run("不计分的小项不参与校验", func(t *testing.T) {
unscored := currentSnapshot(t)
unscored.Events = append(unscored.Events, EventInfo{
ID: 10, Code: "EVT_OFF", Name: "不计分手工名次",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
Compare: domain.CompareNone, Scored: false, Multiplier: 1,
})
targets, _, err := resolveTargets(unscored, next)
if err != nil {
t.Fatalf("只排名不计分的小项不该被拦:%v", err)
}
if got, ok := targets["EVT_OFF"]; !ok || len(got) != 0 {
t.Errorf("不计分小项的目标通道 = %+v,期望空", got)
}
})
}
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。
+2
View File
@@ -15,4 +15,6 @@
// yaml, err := rules.Marshal(out)
//
// 导入必须先预览再落地,且落地后必须由调用方全量重算积分(规则包一变,历史积分都得跟着变)。
// 调用方若能把「落地」与「重算」放进同一个事务(规则包导入就是这么做的),用 ApplyTx 代替 Apply:
// 换通道会按外键级联清空 channel_ranks,两步分开提交时重算一失败名次表就补不回来了。
package rules
@@ -0,0 +1,153 @@
package rules
import (
"strings"
"testing"
)
// 随包规则包(rules/2026-驭电利刃杯军事运动会.yaml)里两个模板的「末位归零」都是关的:
// 主人 2026-09-13 的最终口径是「规则里面没配置的名次分数就是 0 分」——分只按表发,
// 系统不该替他多发或扣分。这个开关做的是另一件事:参赛人数不超过表里档位数时,
// 把名次最末的那一位也抹成 0(3 人参赛 + 6 档模板 → 第 3 名本该按表拿 6 分,却被归零),
// 那件事主人没要。一旦这里变成 true,重新导入规则包就会把这个开关静默打开,末位又丢分。
//
// 为什么不直接读 rules/ 目录那个文件:规则包不进版本库(.gitignore 的 rules/*),
// 换台机器跑测试就读不到。所以这里放一份与随包文件同构的节选样本(只有本用例用得上的字段),
// 测的是「解析链路不会丢掉这个开关」——Parse → Canonical → ToDomain 任一环丢字段都会被抓到。
const militaryGamesPackYAML = `format: scoreboard-rules
formatVersion: 1
meta:
code: 2026-yudianliren-military-games
name: 2026 驭电利刃杯军事运动会 · 积分规则
version: 1.1.0
updatedAt: "2026-09-13"
author: AskaEth
note: 个人/团体项目均录取前六名
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
medalCountsTeamEvents: true
showIndividualBoard: true
showIndividualMedals: true
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
lastPlaceZero: false
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 3 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
- code: team_top6
name: 团体项目前六名
awardRanks: 6
lastPlaceZero: false
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
- { rank: 3, points: 8 }
- { rank: 4, points: 4 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
recordBonus:
enabled: false
mode: max
levels:
- { code: meet, name: 赛会纪录, points: 2 }
manualAdjustment:
enabled: false
reasonRequired: true
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- match: { eventType: team }
channels:
- { scope: team, basis: manual, profile: team_top6 }
- match: { eventType: match }
channels:
- { scope: team, basis: manual, profile: team_top6 }
- match: { eventType: individual_team }
channels:
- { scope: athlete, basis: result, profile: individual_top6, countToTeam: false }
- { scope: team, basis: aggregate, profile: team_top6 }
eventOverrides:
- eventCode: TEAM_PULLUP
name: 团体引体向上
channels:
- { scope: athlete, basis: result, profile: individual_top6, countToTeam: false }
- { scope: team, basis: aggregate, profile: team_top6 }
- eventCode: FLIGHT_FITNESS
name: 飞行人员战斗体能
scored: false
`
func TestParseLastPlaceZeroDisabledForBothProfiles(t *testing.T) {
if n := strings.Count(militaryGamesPackYAML, "lastPlaceZero: false"); n != 2 {
t.Fatalf("内嵌样本应给两个模板各写一处 lastPlaceZero: false,实际 %d 处(样本与随包文件不同构了)", n)
}
pack, err := Parse([]byte(militaryGamesPackYAML))
if err != nil {
t.Fatalf("解析随包规则包样本失败: %v", err)
}
index := pack.ProfileIndex()
for _, code := range []string{"individual_top6", "team_top6"} {
prof, ok := index[code]
if !ok {
t.Fatalf("随包规则包里缺少模板 %s", code)
}
if prof.LastPlaceZero {
t.Errorf("模板 %s 解析后 lastPlaceZero=true,应随主人的最终口径为 false(只按表发分)", code)
}
// 导入会把模板转成 domain.ScoringProfile 落库,转换丢字段同样等于「静默打开」。
if dom := prof.ToDomain(0); dom.LastPlaceZero {
t.Errorf("模板 %s 转成 domain.ScoringProfile 后 lastPlaceZero=true,落库时会被打开", code)
}
}
}
// 上面那条只有在「开关真会被读出来」时才有意义,所以补反向对照:
// 同一份样本把值改成 true / 整行删掉,解析结果必须跟着变——
// 证明拿到 false 是因为 YAML 里写着 false(或没写),而不是解析器恒给 false。
func TestParseLastPlaceZeroFollowsYAML(t *testing.T) {
cases := []struct {
name string
yaml string
want bool
}{
{
name: "显式 true",
yaml: strings.ReplaceAll(militaryGamesPackYAML, "lastPlaceZero: false", "lastPlaceZero: true"),
want: true,
},
{
name: "整行删掉(未配置,与显式 false 等价)",
yaml: strings.ReplaceAll(militaryGamesPackYAML, " lastPlaceZero: false\n", ""),
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.yaml == militaryGamesPackYAML {
t.Fatal("样本没有被改动,用例跑不出结论")
}
pack, err := Parse([]byte(tc.yaml))
if err != nil {
t.Fatalf("解析样本失败: %v", err)
}
for _, prof := range pack.Profiles {
if prof.LastPlaceZero != tc.want {
t.Errorf("模板 %s 的 lastPlaceZero=%v,期望 %v", prof.Code, prof.LastPlaceZero, tc.want)
}
}
})
}
}
+6
View File
@@ -44,6 +44,12 @@ func Parse(data []byte) (*RulePack, error) {
if err := canonical.Validate(); err != nil {
return nil, err
}
// 导入是写入路径:在解析阶段就拦下「这份配置算不出分」的情况(例如分值表配到第 7 档、
// awardRanks 还是 6,第 7 档进来就会静默变 0 分)。导出侧不走这里——历史库里已经存在的
// 越档模板照样要能导出来(见 Validate 与 ValidateForImport 的注释)。
if err := canonical.ValidateForImport(); err != nil {
return nil, err
}
return &canonical, nil
}
+36 -15
View File
@@ -19,7 +19,18 @@ type EventInfo struct {
Scored bool
Multiplier float64
AwardMedals bool
Channels []Channel
// Compare 是排序方向。必须读出来:basis=result / basis=aggregate 的通道要先按成绩排名,
// compare=none(名次靠人工录入)的小项配不了它们(见 resolveTargets 的守卫)。
Compare domain.CompareMode
Channels []Channel
}
// queryer 是 *sql.DB 与 *sql.Tx 共有的读接口。
// Load 既要在库上读,也要在导入事务**内部**读:ApplyTx 必须看到与自己写入同一个事务的一致视图,
// 否则「读到的现状」可能已被别处的改动覆盖,按过期现状算出的目标通道会写错。
type queryer interface {
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
}
// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。
@@ -40,19 +51,22 @@ func (s *Snapshot) EventByCode(code string) (EventInfo, bool) {
}
// Load 从赛事库读取当前规则状态。规则包元数据(meta)库里不存,保持为空。
func Load(db *sql.DB) (*Snapshot, error) {
func Load(db *sql.DB) (*Snapshot, error) { return loadSnapshot(db) }
// loadSnapshot 是 Load 的实现体,接受 *sql.DB 或 *sql.Tx。
func loadSnapshot(q queryer) (*Snapshot, error) {
pack := &RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
}
profileByID, profiles, err := loadProfiles(db)
profileByID, profiles, err := loadProfiles(q)
if err != nil {
return nil, err
}
pack.Profiles = profiles
settings, err := loadSettings(db)
settings, err := loadSettings(q)
if err != nil {
return nil, err
}
@@ -60,7 +74,7 @@ func Load(db *sql.DB) (*Snapshot, error) {
pack.RecordBonus = recordBonusFromSettings(settings)
pack.ManualAdjustment = manualAdjustmentFromSettings(settings)
events, err := loadEvents(db, profileByID)
events, err := loadEvents(q, profileByID)
if err != nil {
return nil, err
}
@@ -114,8 +128,8 @@ func Export(db *sql.DB, meta Meta) (*RulePack, error) {
return &canonical, nil
}
func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) {
rows, err := db.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
func loadProfiles(q queryer) (map[int64]string, []Profile, error) {
rows, err := q.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles ORDER BY code`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板: %w", err)
@@ -150,7 +164,7 @@ func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) {
return nil, nil, fmt.Errorf("读取积分模板: %w", err)
}
ruleRows, err := db.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`)
ruleRows, err := q.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板明细: %w", err)
}
@@ -178,8 +192,8 @@ func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) {
return byID, profiles, nil
}
func loadSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
func loadSettings(q queryer) (map[string]string, error) {
rows, err := q.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("查询赛事设置: %w", err)
}
@@ -207,8 +221,8 @@ func settingByKey(db *sql.DB, key string) (string, error) {
return value, nil
}
func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier, e.award_medals
func loadEvents(q queryer, profileByID map[int64]string) ([]EventInfo, error) {
rows, err := q.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.compare, e.scored, e.multiplier, e.award_medals
FROM events e JOIN sports s ON s.id = e.sport_id
ORDER BY e.sort_order, e.code`)
if err != nil {
@@ -225,11 +239,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
sportCode string
eventType string
resultType string
compare string
scoredRaw int
multiplier float64
awardMedalsRaw int
)
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil {
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &compare, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
index[id] = len(events)
@@ -240,6 +255,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Compare: domain.CompareMode(compare),
Scored: scoredRaw != 0,
Multiplier: multiplier,
AwardMedals: awardMedalsRaw != 0,
@@ -249,7 +265,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
return nil, fmt.Errorf("读取项目: %w", err)
}
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team, c.count_to_medals, c.count_to_team_medals
chRows, err := q.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.aggregate_mode, c.count_to_team, c.count_to_medals, c.count_to_team_medals
FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id
ORDER BY c.event_id, c.sort_order, c.id`)
if err != nil {
@@ -264,11 +280,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
basis string
code string
sortOrd int
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &aggregateMode, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
@@ -280,6 +297,10 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
Basis: domain.Basis(basis),
Profile: code,
}
// 只把非缺省的口径写进导出文件:avg 是缺省值,写了会让「导出 → 导入」凭空多出差异。
if aggregateMode == string(domain.AggregateSum) {
ch.AggregateMode = string(domain.AggregateSum)
}
// 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。
if countToTeam == 0 {
ch.CountToTeam = BoolPtr(false)
+13 -1
View File
@@ -96,11 +96,15 @@ type Rule struct {
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由 AggregateMode 决定
// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均(缺省)/ sum 按成员总和。
// 只对 aggregate 通道有意义;缺省(不写)等价于 avg,与库里的默认值一致。
AggregateMode string `yaml:"aggregateMode,omitempty" json:"aggregateMode,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
// 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"`
@@ -112,6 +116,14 @@ type Channel struct {
CountToTeamMedals *bool `yaml:"countToTeamMedals,omitempty" json:"countToTeamMedals,omitempty"`
}
// AggregatesBy 返回补齐缺省值后的汇总口径:未配置或写 avg 时按 avg(与库里的默认值一致)。
func (c Channel) AggregatesBy() domain.AggregateMode {
if c.AggregateMode == string(domain.AggregateSum) {
return domain.AggregateSum
}
return domain.AggregateAvg
}
// CountsToTeam 返回补齐缺省值后的开关(缺省 true)。
func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) }
+55
View File
@@ -51,6 +51,13 @@ func (l *issueList) addf(path, format string, args ...any) {
}
// Validate 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。
//
// 它服务的**不只是**导入:导出(Marshal → Validate)走的是同一条路,而导出正是现场备份与排障的
// 入口(GET /api/rules/export、赛事项目包里的 rules.yaml)。因此这里只放「这份文件是不是一份
// 能读懂的规则包」,不放「这份配置能不能算分」——库里修好之前存下的历史配置(awardRanks 与
// 分值表对不上的模板)如果让导不出来,等于把备份入口一并打掉(同类伤疤见
// httpapi 的 TestUpdateProfileCodeRequiredKeepsRulesExportUsable)。
// 写入前才该拦的检查放在 ValidateForImport。
func (p *RulePack) Validate() error {
if p.Format != FormatName {
return fmt.Errorf("%w: format=%q,期望 %q", ErrNotRulePack, p.Format, FormatName)
@@ -92,6 +99,31 @@ func (p *RulePack) Validate() error {
return nil
}
// ValidateForImport 是导入侧在 Validate 之外额外拦下的「这份配置根本算不出分」检查。
//
// 目前只有一条:分值表的档位超出录取档位数(awardRanks)。见 AwardRanksOverflow。
// 单独成一个方法而不是塞进 Validate,是因为 Validate 同时被导出路径用着——历史库里已经存在的
// 越档模板必须照样能导出(否则备份与规则导出会 500),但绝不能再从导入这条路进来。
func (p *RulePack) ValidateForImport() error {
var l issueList
for i, prof := range p.Profiles {
maxRank := 0
for _, r := range prof.Rules {
if r.Rank > maxRank {
maxRank = r.Rank
}
}
if msg := AwardRanksOverflow(prof.AwardRanks, maxRank); msg != "" {
// 走 %s 而不是把 msg 当格式串:文案以后加个百分号就会把消息打坏。
l.addf(fmt.Sprintf("profiles[%d].awardRanks", i), "%s", msg)
}
}
if len(l.issues) > 0 {
return &ValidationError{Issues: l.issues}
}
return nil
}
func validateTieBreak(l *issueList, tieBreak []string) {
seen := make(map[string]bool, len(tieBreak))
for i, key := range tieBreak {
@@ -145,6 +177,24 @@ func validateProfile(l *issueList, path string, prof Profile, seen map[string]bo
}
}
// AwardRanksOverflow 在「分值表的档位超出录取档位数」时返回一句人话说明,没问题时返回空串。
//
// 为什么要拦:算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
// 于是「分值表配到第 10 档、录取名次还留着 6」这种配置能安静落库——第 7~10 档在模板页上看着有分,
// 算出来恒为 0,榜上没有任何异常提示(审查 01 P1)。
// 模板保存(httpapi/repo.go 的 validateProfileAwardCoverage)与规则包导入(ValidateForImport)
// 共用这一句说明:同一份配置在两条路上必须得到同样的说法,现场才能照着改。
//
// awardRanks <= 0(0 表示不按名次封顶)是既有语义,一律放行,并在文案里把这个出口告诉用户。
func AwardRanksOverflow(awardRanks, maxRuleRank int) string {
if awardRanks <= 0 || maxRuleRank <= awardRanks {
return ""
}
return fmt.Sprintf("你配了第 %d 档,但录取名次(awardRanks)只到第 %d 档,第 %d 档及以后不会计分:"+
"要么把录取名次改成 %d 或 0(0 表示不按名次封顶),要么删掉第 %d 档之后的档位",
maxRuleRank, awardRanks, awardRanks+1, maxRuleRank, awardRanks)
}
func validateRecordBonus(l *issueList, rb RecordBonus) {
if rb.Mode != "" && rb.Mode != domain.RecordBonusMax && rb.Mode != domain.RecordBonusSum {
l.addf("recordBonus.mode", "只能是 max / sum,当前 %q", rb.Mode)
@@ -226,6 +276,11 @@ func validateChannels(l *issueList, path string, channels []Channel, profiles ma
} else if !profiles[ch.Profile] {
l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile)
}
// 汇总口径只有 avg / sum 两个取值;不写 = avg(缺省),拼错的取值必须报错而不是被当成 avg。
if ch.AggregateMode != "" &&
ch.AggregateMode != string(domain.AggregateAvg) && ch.AggregateMode != string(domain.AggregateSum) {
l.addf(cpath+".aggregateMode", "只能是 avg / sum,当前 %q", ch.AggregateMode)
}
// 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。
key := string(ch.Scope) + "/" + string(ch.Basis)
if seen[key] {
@@ -0,0 +1,27 @@
-- 清理升级前留下的「孤儿流水」:删掉一个「只有名次、没有报名项」的小项时
-- (球类那种形态,名次直接按单位录进 channel_manual_ranks),名下的成绩流水
-- 被 score_ledger.event_id 的 ON DELETE SET NULL 置空,而 voided 仍然是 0 ——
-- 分数留在榜上,此后任何按 event_id 的重算都再也打不到这些行,只能改库。
--
-- 写侧已经补上了「删小项时在同一事务里作废」(httpapi/repo_events.go 的 deleteEvent),
-- 但**升级前已经产生**的那批不会被自动清掉,现场确认删过带分的小项。这条迁移就是那一次性清理。
--
-- 只作废 source_type IN ('result', 'record_bonus'):
-- - 这两类流水全部由计分引擎产出,引擎一直把 EventID 写进库(domain/scoring.go),
-- 所以「event_id 为空」只可能来自小项被删后的置空,即孤儿;
-- - 人工调整(source_type = 'adjustment')**合法地**就是 event_id 为 NULL
-- (写入口 httpapi/repo_misc.go 的 createAdjustment,见 domain/ledger.go 的说明),
-- 一刀切会把合法的调整分一起吞掉。
--
-- 作废而不是删除:流水行要留着当审计痕迹(voided_at + void_reason 说明「谁也没做,是升级清理」)。
-- voided_by 刻意留 NULL:这次清理没有人操作,编一个操作人就是假账。
--
-- 幂等:条件里的 voided = 0 让重复执行只剩空更新;迁移本身还由 schema_migrations 记账只放行一次,
-- 所以「重复启动 / 手工重放这条 SQL」都不会重复作废,也不会覆盖已有流水的作废时间与理由。
UPDATE score_ledger
SET voided = 1,
voided_at = datetime('now'),
void_reason = 'event_id 已失效(历史孤儿)'
WHERE voided = 0
AND event_id IS NULL
AND source_type IN ('result', 'record_bonus');
@@ -0,0 +1,13 @@
-- 聚合通道(basis=aggregate)的汇总口径。
--
-- 背景:团体分要能现场在「按成员平均」与「按成员总和」之间切。此前只有平均值一种口径
-- (平均值内部的分母口径是 0017 加的 events.avg_mode),这里把「汇总方式」按通道存下来。
--
-- 只加列、不动既有数据行:老通道一律取默认值 'avg',升级前后榜单一分不差。
-- 取值:
-- avg:团体成绩 = 该主体成员成绩的平均值(缺省,与升级前行为一致);
-- sum:团体成绩 = 该主体成员成绩的总和。
-- 汇总口径与 avg_mode 是两个维度:sum 下 avg_mode 只决定「一个有效成绩都没有的单位
-- 算不算 0 分参与排名」(valid 不参与 / all 参与),见 domain/scoring.go 的 scoreByAggregate。
ALTER TABLE event_score_channels ADD COLUMN aggregate_mode TEXT NOT NULL DEFAULT 'avg'
CHECK (aggregate_mode IN ('avg', 'sum'));
@@ -0,0 +1,262 @@
package event
import (
"context"
"database/sql"
"io"
"io/fs"
"log/slog"
"reflect"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
// 孤儿流水的统一口径:计分引擎产出的成绩分与破纪录加分,但 event_id 已被置空。
const orphanLedgerCond = `voided = 0 AND event_id IS NULL AND source_type IN ('result', 'record_bonus')`
// 0022 一次性清理升级前留下的孤儿流水:删掉「只有名次、没有报名项」的小项时,
// score_ledger.event_id 被 ON DELETE SET NULL 置空而 voided 仍是 0,分数留在榜上、
// 之后任何按 event_id 的重算都打不到这些行。写侧(deleteEvent)已补上作废,这条迁移管历史那批。
//
// 三条底线:
// - 只清 result / record_bonus。人工调整合法地就是 event_id 为 NULL,一刀切会吞掉合法调整分;
// - 作废而不是删除,留 voided_at + void_reason 当审计痕迹,且不能覆盖已有的作废理由与时间;
// - 幂等:连跑两次,榜与每一行都必须一模一样。
func TestMigration0022VoidsHistoricalOrphanLedger(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0022") // 回到「升级前」:库结构到 0021 为止,0022 还没跑过
// 造现场:E1 是还在的小项;E2 是那种「只有团体名次、没有报名项」的小项,被删掉过。
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E2', '篮球', 'team', 'manual', 'none', 'none', 'published')`,
// 行 1:健康流水,挂在还在的 E1 上。
`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
VALUES (1, 1, 'result', 1, 10)`,
// 行 2、3:升级前的孤儿——删 E2 时 event_id 被置空、voided 还是 0(这条迁移要清的就是它们)。
`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
VALUES (1, 2, 'result', 2, 4)`,
`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
VALUES (1, 2, 'record_bonus', 2, 2)`,
// 行 4:合法的人工调整,event_id 本来就允许为空,必须原样保留(防误伤的关键一条)。
`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (1, 'adjustment', 9, 7)`,
// 行 5:升级前已经被正确作废过的孤儿,理由/时间都不能被这次清理改写。
`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points, voided, voided_at, void_reason)
VALUES (1, 2, 'result', 3, 1, 1, '2026-09-01 08:00:00', '小项删除,本小项积分流水一并作废')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造升级前数据 %q: %v", s, err)
}
}
// 旧删除路径:老 deleteEvent 只删小项本身(子表先删),流水只被外键置空。
if _, err := db.Exec(`DELETE FROM events WHERE id = 2`); err != nil {
t.Fatalf("按旧路径删除小项 E2: %v", err)
}
// 升级前:两条孤儿还活着,且分真的挂在榜上(这就是现场看到的错榜)。
if got := queryInt(t, db, `SELECT count(*) FROM score_ledger WHERE `+orphanLedgerCond); got != 2 {
t.Fatalf("升级前应有 2 条孤儿流水,得到 %d", got)
}
if got := boardPoints(t, db, 1); got != 23 {
t.Fatalf("升级前孤儿分应还在榜上(10+4+2+7=23),得到 %v", got)
}
// 走真实升级路径(main.go 打开旧库后补跑的就是它),跑两次验幂等。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 1 次迁移: %v", err)
}
afterFirst := dumpLedger(t, db)
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 2 次迁移: %v", err)
}
if got := dumpLedger(t, db); !reflect.DeepEqual(got, afterFirst) {
t.Errorf("重复迁移改动了流水:\n第一次 %+v\n第二次 %+v", afterFirst, got)
}
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
// 幂等还要经得起「迁移被重放」:store.migrate 的注释写明,schema_migrations 少一行
// (手工删行 / 从别处拷来的库)就会把跑过的迁移再跑一遍。这里直接重放这条 SQL 的正文,
// 结果必须与第一次一致——不改动分值,也不重写已作废流水的作废时间。
if _, err := db.Exec(readMigration(t, "0022")); err != nil {
t.Fatalf("重放 0022: %v", err)
}
if got := dumpLedger(t, db); !reflect.DeepEqual(got, afterFirst) {
t.Errorf("重放 0022 改动了流水:\n重放前 %+v\n重放后 %+v", afterFirst, got)
}
// 作废是作废、不是删除:五行流水一行不少。
if got := countRows(t, db, "score_ledger"); got != 5 {
t.Fatalf("升级后流水行数 = %d,期望 5(作废要留审计痕迹,不能删行)", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM score_ledger WHERE `+orphanLedgerCond); got != 0 {
t.Errorf("升级后仍剩 %d 条存活孤儿流水", got)
}
// 工单①:行 2、3 两条孤儿被作废,且写明时间与原因;voided_by 留空表示「不是人做的,是升级清理」。
for _, id := range []int64{2, 3} {
var (
voided int
voidedBy sql.NullInt64
voidedAt sql.NullString
voidReason sql.NullString
)
if err := db.QueryRow(`SELECT voided, voided_by, voided_at, void_reason FROM score_ledger WHERE id = ?`, id).
Scan(&voided, &voidedBy, &voidedAt, &voidReason); err != nil {
t.Fatalf("读流水 %d: %v", id, err)
}
if voided != 1 {
t.Errorf("孤儿流水 %d 应被作废,voided = %d", id, voided)
}
if !voidedAt.Valid || voidedAt.String == "" {
t.Errorf("孤儿流水 %d 缺少作废时间: %+v", id, voidedAt)
}
if !voidReason.Valid || voidReason.String != "event_id 已失效(历史孤儿)" {
t.Errorf("孤儿流水 %d 的作废原因不对: %+v", id, voidReason)
}
if voidedBy.Valid {
t.Errorf("孤儿流水 %d 的 voided_by 应留空(没有操作人),得到 %d", id, voidedBy.Int64)
}
}
// 工单②:合法调整分原样保留——没被作废、没有理由、分值不变。
var (
points float64
voided int
voidedAt sql.NullString
voidReason sql.NullString
)
if err := db.QueryRow(`SELECT points, voided, voided_at, void_reason FROM score_ledger WHERE id = 4`).
Scan(&points, &voided, &voidedAt, &voidReason); err != nil {
t.Fatalf("读调整流水: %v", err)
}
if points != 7 || voided != 0 || voidedAt.Valid || voidReason.Valid {
t.Errorf("人工调整被误伤: points=%v voided=%d voided_at=%+v void_reason=%+v", points, voided, voidedAt, voidReason)
}
// 行 1:健康流水(小项还在)不受影响。
if got := queryInt(t, db, `SELECT voided FROM score_ledger WHERE id = 1`); got != 0 {
t.Errorf("挂在还活着的小项上的流水被误作废: voided = %d", got)
}
// 行 5:早已作废的那条保留自己的理由与时间,不被这次清理重写。
var (
oldAt sql.NullString
oldReason sql.NullString
)
if err := db.QueryRow(`SELECT voided_at, void_reason FROM score_ledger WHERE id = 5`).Scan(&oldAt, &oldReason); err != nil {
t.Fatalf("读老作废流水: %v", err)
}
if !oldAt.Valid || oldAt.String != "2026-09-01 08:00:00" ||
!oldReason.Valid || oldReason.String != "小项删除,本小项积分流水一并作废" {
t.Errorf("老作废痕迹被改写: voided_at=%+v void_reason=%+v", oldAt, oldReason)
}
// 榜单:孤儿分(4 与 2)消失,只留下健康分 10 与合法调整 7。连跑两次后仍是 17。
if got := boardPoints(t, db, 1); got != 17 {
t.Errorf("升级后单位总分应只剩 10+7=17,得到 %v", got)
}
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("清理后存在外键悬挂")
}
}
// readMigration 取出嵌入的赛事库迁移正文(按文件名前缀找),
// 用于在测试里直接重放某一条迁移,验证 SQL 自身的幂等。
func readMigration(t *testing.T, prefix string) string {
t.Helper()
fsys, err := fs.Sub(migrations.EventFS, "event")
if err != nil {
t.Fatalf("定位迁移目录: %v", err)
}
names, err := fs.Glob(fsys, prefix+"*.sql")
if err != nil {
t.Fatalf("列出迁移: %v", err)
}
if len(names) != 1 {
t.Fatalf("迁移 %s*.sql 命中 %d 个文件: %v", prefix, len(names), names)
}
body, err := fs.ReadFile(fsys, names[0])
if err != nil {
t.Fatalf("读取迁移 %s: %v", names[0], err)
}
return string(body)
}
// boardPoints 走真实的大屏读路径(实时快照)取某单位的总分:
// 「孤儿分是否还挂在榜上」要按大屏看到的口径判定,而不是数 score_ledger 的行数。
func boardPoints(t *testing.T, db *sql.DB, teamID int64) float64 {
t.Helper()
hub := realtime.NewHub(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
defer func() { _ = hub.Close() }()
snap, err := hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("取榜单快照: %v", err)
}
for _, row := range snap.Standings {
if row.TeamID == teamID {
return row.TotalPoints
}
}
t.Fatalf("榜单里没有单位 %d: %+v", teamID, snap.Standings)
return 0
}
// ledgerRow 是流水行的完整快照,用于比对「连跑两次」有没有改动任何一格。
type ledgerRow struct {
id int64
points float64
voided int
voidedBy sql.NullInt64
voidedAt sql.NullString
voidReason sql.NullString
}
func dumpLedger(t *testing.T, db *sql.DB) []ledgerRow {
t.Helper()
rows, err := db.Query(`SELECT id, points, voided, voided_by, voided_at, void_reason
FROM score_ledger ORDER BY id`)
if err != nil {
t.Fatalf("读流水: %v", err)
}
defer func() { _ = rows.Close() }()
var out []ledgerRow
for rows.Next() {
var r ledgerRow
if err := rows.Scan(&r.id, &r.points, &r.voided, &r.voidedBy, &r.voidedAt, &r.voidReason); err != nil {
t.Fatalf("解析流水: %v", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历流水: %v", err)
}
return out
}
func queryInt(t *testing.T, db *sql.DB, query string, args ...any) int {
t.Helper()
var n int
if err := db.QueryRow(query, args...).Scan(&n); err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
return n
}
@@ -0,0 +1,99 @@
package event
import (
"database/sql"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0023:聚合通道(basis=aggregate)的汇总口径 aggregate_mode。
//
// 三条底线:
// - 老库升级后每一行都拿到默认值 avg —— 升级前只有「按成员平均」一种口径,榜单必须一分不差;
// - 其它列与行数一行不动(这是纯加列迁移);
// - 取值只允许 avg / sum,拼错的取值在写侧就被 CHECK 挡下;
// - 幂等:连跑两次不报错、不重复登记。
func TestMigration0023ChannelAggregateMode(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0023") // 回到「升级前」:库结构到 0022 为止
var teamProfile int64
if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (1, 'TEAM_PULLUP', '团体引体向上', 'individual_team', 'auto', 'count', 'desc', 1, 2, 'published')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (1, 'team', 'aggregate', ?, 1, 1)`, teamProfile); err != nil {
t.Fatalf("造老库通道: %v", err)
}
// 升级前:这一列还不该存在。
var probe sql.NullString
if err := db.QueryRow(`SELECT aggregate_mode FROM event_score_channels`).Scan(&probe); err == nil {
t.Fatal("升级前不应存在 aggregate_mode 列")
} else if !strings.Contains(err.Error(), "aggregate_mode") {
t.Fatalf("查询报错应点明缺列 aggregate_mode,实际 %v", err)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 老行取默认值 avg,其它列原样。
var (
scope, basis, mode string
countToTeam int
sortOrder int
)
if err := db.QueryRow(`SELECT scope, basis, aggregate_mode, count_to_team, sort_order
FROM event_score_channels WHERE event_id = 1`).
Scan(&scope, &basis, &mode, &countToTeam, &sortOrder); err != nil {
t.Fatalf("读取升级后的通道: %v", err)
}
if scope != "team" || basis != "aggregate" || countToTeam != 1 || sortOrder != 1 {
t.Fatalf("升级不应改动既有列:scope=%q basis=%q count_to_team=%d sort_order=%d", scope, basis, countToTeam, sortOrder)
}
if mode != "avg" {
t.Fatalf("老通道的汇总口径 = %q,期望默认值 avg(等于升级前的「按成员平均」)", mode)
}
// 取值只有 avg / sum:拼错的必须被 CHECK 挡下,而不是静默当成某个口径。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'aggregate', ?, 2, 'biggest')`, teamProfile); err == nil {
t.Fatal("非法 aggregate_mode 应被 CHECK 拒绝")
}
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'aggregate', ?, 2, 'sum')`, teamProfile); err != nil {
t.Fatalf("写入 sum 口径应成功: %v", err)
}
// 幂等:再跑一次不报错、行数不变、登记仍只有一条。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
var rows, registered int
if err := db.QueryRow(`SELECT COUNT(*) FROM event_score_channels`).Scan(&rows); err != nil {
t.Fatalf("统计通道: %v", err)
}
if rows != 2 {
t.Fatalf("通道数 = %d,期望 2", rows)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE name = '0023_channel_aggregate_mode.sql'`).Scan(&registered); err != nil {
t.Fatalf("查询迁移登记: %v", err)
}
if registered != 1 {
t.Fatalf("迁移登记数 = %d,期望 1", registered)
}
}