feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名

口径(用户确认):
- 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数)
- 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分
- 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、
  没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存

服务端:
- 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team;
  events 加 avg_mode(默认 valid)
- 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team →
  默认 athlete(result, 不计入单位) + team(aggregate 队内平均)
- 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名
  (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与)

前端:
- 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」
- 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存
- 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致)
- 大屏/管理端团体成绩只显示数值,不出现「平均」字样

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 12:02:00 +08:00
1 parent 592e47aef7
commit 6bd7bd1627
47 files changed
+1770 -153

No files matched your search

+9 -2
View File
@@ -192,9 +192,16 @@ func testTeams(ids ...int64) []Team {
}
func ledgerFor(teamID int64, points float64) LedgerEntry {
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points}
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points, CountToTeam: true}
}
func ledgerForAthlete(teamID, athleteID int64, points float64) LedgerEntry {
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points}
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points, CountToTeam: true}
}
// ledgerForPersonal 是「不计入单位总分」的流水:只记在个人头上(如「个人+团体」项目的个人通道)。
func ledgerForPersonal(teamID, athleteID int64, points float64) LedgerEntry {
e := ledgerForAthlete(teamID, athleteID, points)
e.CountToTeam = false
return e
}
+3 -1
View File
@@ -9,13 +9,14 @@ type TeamPoints struct {
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
// count_to_team = 0 的流水(如「个人+团体」项目的个人通道)只记在个人头上,不计入单位总分。
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
if weight <= 0 {
weight = 1
}
out := make(map[int64]TeamPoints)
for _, e := range entries {
if e.Voided {
if e.Voided || !e.CountToTeam {
continue
}
p := out[e.TeamID]
@@ -31,6 +32,7 @@ func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
}
// SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。
// 不计入单位总分的流水(count_to_team = 0)照样算在个人头上,个人榜不受影响。
func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
out := make(map[int64]float64)
for _, e := range entries {
+15
View File
@@ -33,3 +33,18 @@ func TestSumByAthlete(t *testing.T) {
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
}
}
// 「个人+团体」项目的个人通道:分只记个人,不进单位总分。
func TestSumByTeamSkipsPersonalOnly(t *testing.T) {
ledger := []LedgerEntry{
ledgerFor(1, 10),
ledgerForPersonal(1, 101, 8),
}
got := SumByTeam(ledger, 1)
if !nearlyEqual(got[1].Total, 10) || !nearlyEqual(got[1].Individual, 0) {
t.Fatalf("单位1汇总 = %+v,期望总分 10 且个人分不进单位", got[1])
}
if !nearlyEqual(SumByAthlete(ledger)[101], 8) {
t.Fatalf("个人榜应保留该流水:%v", SumByAthlete(ledger))
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestMatrix12_ManualPenaltyReorders(t *testing.T) {
// 人工扣分:单位1 -3 分(本期入口关闭,这里直接构造调整流水验证榜单口径)。
in.Ledger = append(in.Ledger, LedgerEntry{
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3,
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3, CountToTeam: true,
})
after := BuildTeamStandings(in)
+9 -8
View File
@@ -148,14 +148,15 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
for _, s := range e.resolvedShares(in.Options.UnionAttribution) {
out = append(out, LedgerEntry{
TeamID: s.TeamID,
AthleteID: athleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: e.ID,
SourceType: SourceRecordBonus,
SourceID: e.ID,
Points: b.Points * s.Weight,
TeamID: s.TeamID,
AthleteID: athleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: e.ID,
SourceType: SourceRecordBonus,
SourceID: e.ID,
Points: b.Points * s.Weight,
CountToTeam: true,
})
}
}
+84 -31
View File
@@ -156,15 +156,23 @@ type candidate struct {
rankIn int // manual 通道录入的名次
}
// entryMatches 判断报名项是否属于本次计分的小项与组别。
func entryMatches(in EventScoreInput, e Entry) bool {
if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID {
return false
}
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
return false
}
return true
}
// collectCandidates 过滤出本通道真正参与排名的报名项:
// 只取已发布、状态正常、且带有本通道所需数据的成绩。
// 只取已发布、状态正常、且带有本通道所需数据的成绩。聚合通道不用本函数,见 scoreByAggregate。
func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) {
if ch.Basis == BasisResult && in.Event.Compare == CompareNone {
return nil, ErrCompareRequired
}
if ch.Basis == BasisAggregate && in.Event.Compare == CompareNone {
return nil, ErrCompareRequired
}
byEntry := make(map[int64]Result, len(in.Results))
for _, r := range in.Results {
@@ -173,10 +181,7 @@ func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error)
out := make([]candidate, 0, len(in.Entries))
for _, e := range in.Entries {
if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID {
continue
}
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
if !entryMatches(in, e) {
continue
}
if !e.Status.rankable() {
@@ -200,7 +205,7 @@ func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error)
continue
}
c.rankIn = r.Rank
default: // result / aggregate 都按成绩值排序
default: // result 按成绩值排序
if r.ResultValue == nil {
continue
}
@@ -362,41 +367,72 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
return rr
}
// scoreByAggregate 先把同一主体的成绩汇总成一个值(求和),再按值排名(预留能力)。
// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩取平均,
// 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。
// 平均值分母由项目的 avg_mode 决定:
// - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分;
// - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。
// 没有报名项的主体不会成桶,自然不参与。
//
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
cands, err := collectCandidates(in, ch)
if err != nil {
return nil, err
if in.Event.Compare == CompareNone {
return nil, ErrCompareRequired
}
byEntry := make(map[int64]Result, len(in.Results))
for _, r := range in.Results {
byEntry[r.EntryID] = r
}
type bucket struct {
subject int64
entry Entry
total float64
sum float64 // 有效成绩之和
valid int // 有有效成绩的人数(valid 口径的分母)
roster int // 报名人数(all 口径的分母)
}
order := make([]int64, 0)
buckets := make(map[int64]*bucket)
for _, c := range cands {
subject := c.entry.TeamID
for _, e := range in.Entries {
if !entryMatches(in, e) || !e.Status.rankable() {
continue
}
subject := e.TeamID
if ch.Scope == ScopeAthlete {
subject = c.entry.AthleteID
subject = e.AthleteID
}
if subject == 0 {
continue
}
b, ok := buckets[subject]
if !ok {
b = &bucket{subject: subject, entry: c.entry}
b = &bucket{subject: subject, entry: e}
buckets[subject] = b
order = append(order, subject)
}
b.total += c.value
b.roster++
r, found := byEntry[e.ID]
if v, ok := validResultValue(r, found, in.Options); ok {
b.sum += v
b.valid++
}
}
agg := make([]candidate, 0, len(order))
for _, id := range order {
b := buckets[id]
c := candidate{entry: b.entry, subject: b.subject, value: b.total}
var value float64
if in.Event.avgMode() == AvgModeAll {
// 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。
value = b.sum / float64(b.roster)
} else {
if b.valid == 0 {
// 一个人都没有效成绩的单位不参与团体排名,也不得分。
continue
}
value = b.sum / float64(b.valid)
}
c := candidate{entry: b.entry, subject: b.subject, value: value}
c.entry.ID = 0 // 聚合结果不对应单条报名项
agg = append(agg, c)
}
@@ -456,8 +492,24 @@ func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= rankEpsilon
}
// validResultValue 判断一条成绩是否算「有效成绩」:成绩存在、已发布(或允许草稿)、
// 状态正常,且有可比较的数值。聚合通道按 avg_mode 决定没成绩的人算不算进分母。
func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
if !found {
return 0, false
}
if r.State == ResultStateDraft && !opts.IncludeDrafts {
return 0, false
}
if !r.Status.rankable() || r.ResultValue == nil {
return 0, false
}
return *r.ResultValue, true
}
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位,
// 联队按份额拆分;0 分不产生流水,避免流水表被零分噪声撑大。
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry {
var out []LedgerEntry
for _, rr := range ranked {
@@ -481,16 +533,17 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
continue
}
out = append(out, LedgerEntry{
TeamID: s.TeamID,
AthleteID: athleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: rr.EntryID,
ResultID: rr.ResultID,
ChannelID: ch.ID,
SourceType: SourceResult,
SourceID: sourceID,
Points: pts,
TeamID: s.TeamID,
AthleteID: athleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: rr.EntryID,
ResultID: rr.ResultID,
ChannelID: ch.ID,
SourceType: SourceResult,
SourceID: sourceID,
Points: pts,
CountToTeam: ch.countToTeam(),
})
}
}
+160 -6
View File
@@ -191,7 +191,7 @@ func TestScopeAndLedgerAttribution(t *testing.T) {
})
}
// TestAggregateChannel 覆盖 aggregate 通道:先按主体汇总(求和)、再排名。
// TestAggregateChannel 覆盖 aggregate 通道:先按主体取平均、再排名。
func TestAggregateChannel(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
@@ -206,8 +206,8 @@ func TestAggregateChannel(t *testing.T) {
wantA float64
wantB float64
}{
{"求和后升序", CompareAsc, 15, 10},
{"求和后降序", CompareDesc, 10, 15},
{"平均后升序", CompareAsc, 15, 10},
{"平均后降序", CompareDesc, 10, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -227,10 +227,11 @@ func TestAggregateChannel(t *testing.T) {
})
}
t.Run("汇总后同分并列", func(t *testing.T) {
t.Run("平均后同分并列顺延", func(t *testing.T) {
// 1001 两人平均 (2+6)/2 = 4,与 1002 的 4 并列。
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 3},
{entryID: 1, athleteID: 1, teamID: 1001, value: 2},
{entryID: 2, athleteID: 2, teamID: 1001, value: 6},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
})
es := mustScore(t, EventScoreInput{
@@ -243,9 +244,162 @@ func TestAggregateChannel(t *testing.T) {
if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 15) {
t.Fatalf("同分并列 = %v,期望各 15", got)
}
for _, rr := range es.Ranked {
if rr.Scope != ScopeTeam || rr.Rank != 1 {
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
}
}
})
}
// TestAggregateAvgMode 覆盖聚合通道的平均值分母口径:
// valid 只算有有效成绩的人,all 把没成绩的人按 0 计入(分母 = 报名人数)。
func TestAggregateAvgMode(t *testing.T) {
// 1001 一人有成绩(10)、一人没成绩;1002 两人各 4 / 6。
// valid:1001 平均 10,1002 平均 5 → 1002 第一;
// all:1001 平均 10/2 = 5,与 1002 并列 → 各得第一名。
noValueBuild := 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},
})
}
// 1001 的第二个报名项连成绩行都没有(现场最常见的「没录成绩」):
// valid:1001 平均 10,1002 平均 6 → 1002 第一;all:1001 平均 10/2 = 5 → 1001 第一。
missingBuild := func() ([]Entry, []Result) {
return []Entry{
{ID: 1, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 1, AthleteName: "甲"},
{ID: 2, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 2, AthleteName: "乙"},
{ID: 3, EventID: 1, CategoryID: 1, TeamID: 1002, AthleteID: 3, AthleteName: "丙"},
}, []Result{
{ID: 100, EventID: 1, EntryID: 1, ResultValue: ptr(10), State: ResultStatePublished},
{ID: 300, EventID: 1, EntryID: 3, ResultValue: ptr(6), State: ResultStatePublished},
}
}
cases := []struct {
name string
build func() ([]Entry, []Result)
mode AvgMode
firstID int64
want1001 float64
want1002 float64
}{
{"只算有成绩的人", noValueBuild, AvgModeValid, 1002, 10, 15},
{"没成绩的按 0 计入", noValueBuild, AvgModeAll, 0, 15, 15},
{"未录成绩也算分母(valid)", missingBuild, AvgModeValid, 1002, 10, 15},
{"未录成绩也算分母(all)", missingBuild, AvgModeAll, 1001, 15, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := tc.build()
ev := teamEvent(1)
ev.AvgMode = tc.mode
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], tc.want1001) || !nearlyEqual(got[1002], tc.want1002) {
t.Fatalf("平均口径 %s 得分 = %v,期望 1001:%v / 1002:%v", tc.mode, got, tc.want1001, tc.want1002)
}
if tc.firstID != 0 {
for _, rr := range es.Ranked {
if rr.TeamID == tc.firstID && rr.Rank != 1 {
t.Fatalf("单位 %d 名次 = %d,期望第 1 名", tc.firstID, rr.Rank)
}
}
}
})
}
t.Run("全队都没有有效成绩时不参与团体排名", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, noValue: true},
{entryID: 2, athleteID: 2, teamID: 1001, status: ResultStatusDNS},
{entryID: 3, athleteID: 3, teamID: 1002, value: 6},
{entryID: 4, athleteID: 4, teamID: 1002, value: 8},
})
ev := teamEvent(1)
ev.AvgMode = AvgModeValid
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if _, ok := got[1001]; ok {
t.Fatalf("无有效成绩的单位不应得分,实际 %v", got)
}
if !nearlyEqual(got[1002], 15) {
t.Fatalf("1002 得分 = %v,期望 15(顺延后独占第一)", got[1002])
}
for _, rr := range es.Ranked {
if rr.TeamID == 1001 {
t.Fatalf("无有效成绩的单位不应上榜:%+v", rr)
}
}
})
}
// TestAggregateChannelCountToTeam 覆盖「个人+团体」双通道:
// 个人通道的流水只记个人(count_to_team = 0),团体聚合通道的流水进单位总分。
func TestAggregateChannelCountToTeam(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
{entryID: 2, athleteID: 2, teamID: 1001, value: 14},
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
})
notTeam := false
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1, CountToTeam: &notTeam},
teamAggregateChannel(2),
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
var personal, teamLedger []LedgerEntry
for _, l := range es.Ledger {
if l.CountToTeam {
teamLedger = append(teamLedger, l)
} else {
personal = append(personal, l)
}
}
if len(personal) != 3 {
t.Fatalf("个人通道流水 = %d 条,期望 3 条", len(personal))
}
for _, l := range personal {
if l.AthleteID == 0 {
t.Fatalf("个人通道流水应记在个人头上:%+v", l)
}
}
// 个人通道的 10/8/6 分不进单位总分;团体通道各队平均 13 / 13 → 并列各 15。
sums := SumByTeam(es.Ledger, 1)
if !nearlyEqual(sums[1001].Total, 15) || !nearlyEqual(sums[1002].Total, 15) {
t.Fatalf("单位总分 = %+v,期望各 15(个人通道不计入)", sums)
}
if !nearlyEqual(sums[1001].Individual, 0) {
t.Fatalf("不计入单位总分的个人流水被算进 Individual:%+v", sums[1001])
}
// 个人榜照常算上个人通道的分:12/13/14 米按升序拿 10/8/6。
athletePoints := SumByAthlete(es.Ledger)
if !nearlyEqual(athletePoints[1], 10) || !nearlyEqual(athletePoints[3], 8) || !nearlyEqual(athletePoints[2], 6) {
t.Fatalf("个人榜 = %v,期望 1:10 / 3:8 / 2:6", athletePoints)
}
if len(teamLedger) != 2 {
t.Fatalf("团体通道流水 = %d 条,期望 2 条", len(teamLedger))
}
}
// TestStatusExclusions 覆盖弃权 / DNS / DSQ 等「0 分且不占名次」的情形。
func TestStatusExclusions(t *testing.T) {
cases := []struct {
+22
View File
@@ -360,3 +360,25 @@ func TestAthleteStandingsTieBreakConfig(t *testing.T) {
t.Fatalf("配置 [teamCode] 榜首 = 运动员 %d,期望 2(单位编码 A < B)", cfg[0].AthleteID)
}
}
// 「个人+团体」项目的双计:个人通道的分不进单位总分,只有团体通道的分参与单位排名。
func TestTeamStandingsIgnoresPersonalOnlyLedger(t *testing.T) {
ledger := []LedgerEntry{
ledgerForPersonal(1, 101, 10), // 个人通道:单位1 不进分
ledgerFor(2, 8), // 团体通道
}
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger})
if rows[0].TeamID != 2 {
t.Fatalf("榜首 = 单位 %d,期望 2(个人通道不计入单位总分)", rows[0].TeamID)
}
if !nearlyEqual(rows[1].Points, 0) || !nearlyEqual(rows[1].IndividualPoints, 0) {
t.Fatalf("单位1 = %+v,期望 0 分", rows[1])
}
athlete := BuildAthleteStandings(StandingsInput{
Teams: testTeams(1, 2), Athletes: []Athlete{{ID: 101, Name: "张三", TeamID: 1}}, Ledger: ledger,
})
if len(athlete) != 1 || !nearlyEqual(athlete[0].Points, 10) {
t.Fatalf("个人榜 = %+v,期望仍保留个人通道的 10 分", athlete)
}
}
+36 -7
View File
@@ -25,6 +25,16 @@ const (
ResultModeManual ResultMode = "manual"
)
// AvgMode 团体成绩取平均时的分母口径,只对 basis=aggregate 的通道有效。
type AvgMode string
const (
// AvgModeValid 只算有有效成绩的人:某单位一个人都没成绩就不参与团体排名。
AvgModeValid AvgMode = "valid"
// AvgModeAll 谁都算,没成绩的人按 0 计入,分母 = 该单位在该组别的报名人数。
AvgModeAll AvgMode = "all"
)
// ResultType 成绩的物理量类型,决定比较与展示方式。
type ResultType string
@@ -209,6 +219,8 @@ type Event struct {
Venue string `json:"venue,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Remark string `json:"remark,omitempty"`
// AvgMode 团体成绩(basis=aggregate 通道)取平均时的分母口径;空值按 valid 处理。
AvgMode AvgMode `json:"avgMode"`
}
// EventCategory 组别;名次在组别内有效,组别权重乘在成绩分上。
@@ -225,8 +237,7 @@ type EventCategory struct {
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
// basis=aggregate 时固定按「同一主体成绩之和」汇总:汇总口径不可配置,
// 库里也没有对应的列,因此不再保留 avg/max/min/count 这类配置项。
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
type ScoreChannel struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
@@ -234,6 +245,9 @@ type ScoreChannel struct {
Basis Basis `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。
// 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `json:"countToTeam,omitempty"`
}
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
@@ -310,11 +324,13 @@ type LedgerEntry struct {
SourceType SourceType `json:"sourceType"`
SourceID int64 `json:"sourceId"`
Points float64 `json:"points"`
Voided bool `json:"voided"`
VoidedBy string `json:"voidedBy,omitempty"`
VoidReason string `json:"voidReason,omitempty"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
// CountToTeam 为 false 时该流水只记在个人头上,不计入单位总分(库中默认 1)。
CountToTeam bool `json:"countToTeam"`
Voided bool `json:"voided"`
VoidedBy string `json:"voidedBy,omitempty"`
VoidReason string `json:"voidReason,omitempty"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
// StandingsRow 总积分榜的一行。
@@ -429,6 +445,19 @@ func (e Event) multiplier() float64 {
return e.Multiplier
}
// avgMode 未配置或配错时按 valid 处理(与库里的默认值一致)。
func (e Event) avgMode() AvgMode {
if e.AvgMode == AvgModeAll {
return AvgModeAll
}
return AvgModeValid
}
// countToTeam 未配置时按 true 处理:只有显式关闭的通道才不进单位总分。
func (c ScoreChannel) countToTeam() bool {
return c.CountToTeam == nil || *c.CountToTeam
}
func (c EventCategory) multiplier() float64 {
if c.Multiplier <= 0 {
return 1
+2
View File
@@ -23,6 +23,7 @@ type Event struct {
Venue *string `json:"venue"`
SortOrder int `json:"sortOrder"`
Remark *string `json:"remark"`
AvgMode string `json:"avgMode"`
}
// EventFromDomain 把领域小项转换成线上结构。
@@ -44,5 +45,6 @@ func EventFromDomain(e domain.Event) Event {
Venue: nilIfEmpty(e.Venue),
SortOrder: e.SortOrder,
Remark: nilIfEmpty(e.Remark),
AvgMode: string(e.AvgMode),
}
}
+9 -5
View File
@@ -247,7 +247,7 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
rows, err := db.Query(`
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
COALESCE(entry_id, 0), COALESCE(result_id, 0),
source_type, source_id, points, voided, COALESCE(void_reason, '')
source_type, source_id, points, voided, COALESCE(void_reason, ''), count_to_team
FROM score_ledger ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("export: 读取积分流水: %w", err)
@@ -256,13 +256,17 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
var out []domain.LedgerEntry
for rows.Next() {
var e domain.LedgerEntry
var voided int
var (
e domain.LedgerEntry
voided int
countToTeam int
)
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID,
&e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason); err != nil {
&e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason, &countToTeam); err != nil {
return nil, fmt.Errorf("export: 扫描积分流水: %w", err)
}
e.Voided = voided != 0
e.CountToTeam = countToTeam != 0
out = append(out, e)
}
if err := rows.Err(); err != nil {
@@ -548,7 +552,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN results r ON r.id = l.result_id
LEFT JOIN athletes a ON a.id = l.athlete_id
WHERE l.team_id = ? AND l.voided = 0
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, teamID)
if err != nil {
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
+32
View File
@@ -191,3 +191,35 @@ func TestTeamDetailTotalAppliesWeight(t *testing.T) {
t.Errorf("合计 = %v,期望 20(10 × 系数 2)", total)
}
}
// 「个人+团体」项目的个人通道流水(count_to_team = 0)不进单位总分,
// 导出侧与 REST / WS 快照必须同口径。
func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 单位 1 再加一条「只记个人」的流水:导出总分不得变化。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (1, 1, 1, 1, 1, 'result', 1, 7, 0)`); err != nil {
t.Fatalf("写入个人通道流水: %v", err)
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
for _, row := range wb.Team {
if row.TeamID != 1 {
continue
}
if row.Points != 10 {
t.Fatalf("单位1 总分 = %v,期望 10(不计入单位总分的流水应被跳过)", row.Points)
}
}
// 个人榜照常算上这条流水:10 + 7 = 17。
for _, row := range wb.Athlete {
if row.AthleteID == 1 && row.Points != 17 {
t.Fatalf("个人榜得分 = %v,期望 17", row.Points)
}
}
}
+4
View File
@@ -54,6 +54,9 @@ type channelDTO struct {
Basis string `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
CountToTeam *bool `json:"countToTeam"`
}
type eventDTO struct {
@@ -73,6 +76,7 @@ type eventDTO struct {
Venue *string `json:"venue"`
SortOrder int `json:"sortOrder"`
Remark *string `json:"remark"`
AvgMode string `json:"avgMode"`
Categories []categoryDTO `json:"categories,omitempty"`
Channels []channelDTO `json:"channels,omitempty"`
}
@@ -0,0 +1,216 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 「个人+团体」的算分口径(REST 端到端):
// - 个人通道按个人成绩排名,分只记在个人头上,不进单位总分;
// - 团体通道把同单位成员成绩取平均后排名,这部分才进单位总分。
func TestIndividualTeamDoubleCountStandings(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 events SET event_type = 'individual_team', result_type = 'count',
compare = 'desc', avg_mode = 'valid' WHERE id = ?`, ids.event)
var individualProfile, teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&individualProfile); err != nil {
t.Fatalf("读取个人模板: %v", err)
}
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体模板: %v", err)
}
// 请求体里个人通道显式关闭 countToTeam,团体通道不写该字段(缺省计入)。
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
map[string]any{"channels": []map[string]any{
{"scope": "athlete", "basis": "result", "profileId": individualProfile, "sortOrder": 0, "countToTeam": false},
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1},
}}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存通道应 200,得到 %d %+v", status, env2.Error)
}
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/channels", ids.event), nil, true)
channels := decodeData[[]map[string]any](t, env2)
if len(channels) != 2 {
t.Fatalf("通道数 = %d,期望 2", len(channels))
}
if got := channels[0]["countToTeam"]; got != false {
t.Fatalf("个人通道 countToTeam = %v,期望 false", got)
}
if got := channels[1]["countToTeam"]; got != true {
t.Fatalf("缺省通道 countToTeam = %v,期望 true", got)
}
// 单位 A 一人 10,单位 B 一人 20:个人通道 8/10,团体通道 10/15。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 10, "status": "ok"},
{"entryId": ids.entryB, "resultValue": 20, "status": "ok"},
},
}, true)
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)
}
// 只算团体通道:A 第 2 名 10 分,B 第 1 名 15 分。
if points["A"] != 10 || points["B"] != 15 {
t.Fatalf("单位总分 = %v,期望 A:10 / B:15(个人通道不计入)", points)
}
// 个人榜照常保留个人通道的分:B(20)10 分排第一,A(10)8 分排第二。
_, env2 = env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
athletes := decodeData[[]map[string]any](t, env2)
if len(athletes) != 2 {
t.Fatalf("个人榜行数 = %d,期望 2", len(athletes))
}
if got := athletes[0]["totalPoints"].(float64); got != 10 {
t.Fatalf("个人榜榜首得分 = %v,期望 10", got)
}
if got := athletes[1]["totalPoints"].(float64); got != 8 {
t.Fatalf("个人榜第二名得分 = %v,期望 8(个人通道的分保留)", got)
}
// 单位积分明细与总积分榜同口径:不计入单位总分的流水不出现在明细里。
_, env2 = env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
breakdown := decodeData[map[string]any](t, env2)
if got := breakdown["totalPoints"].(float64); got != 10 {
t.Fatalf("单位 A 明细合计 = %v,期望 10(与总积分榜一致)", got)
}
items := breakdown["items"].([]any)
if len(items) != 1 {
t.Fatalf("单位 A 明细行数 = %d,期望 1(只有团体通道的流水)", len(items))
}
}
// avg_mode 决定团体平均值的分母:valid 只算有成绩的人,all 把没成绩的人按 0 计入。
func TestIndividualTeamAvgModeChangesRanking(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")
// A 两人(只有一人录成绩 10),B 两人(6 / 7)。
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status, avg_mode)
VALUES (?, 'E_BOTH', '团体引体向上', 'individual_team', 'auto', 'count',
'desc', 1, 1, 'ongoing', 'valid')`, ids.sport)
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王五', '男', ?, 1)`, ids.teamA)
athB2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵六', '男', ?, 1)`, ids.teamB)
entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, ids.athleteA)
env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, athA2)
entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, ids.athB)
entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, athB2)
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)
}
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'aggregate', ?, 0)`, eventID, teamProfile)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID,
"results": []map[string]any{
{"entryId": entryA1, "resultValue": 10, "status": "ok"},
{"entryId": entryB1, "resultValue": 6, "status": "ok"},
{"entryId": entryB2, "resultValue": 7, "status": "ok"},
},
}, true)
standings := func(name string) map[string]float64 {
t.Helper()
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": eventID,
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("%s发布应 200,得到 %d %+v", name, status, env2.Error)
}
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
out := map[string]float64{}
for _, row := range decodeData[[]map[string]any](t, env2) {
out[row["teamCode"].(string)] = row["totalPoints"].(float64)
}
return out
}
// valid:A 平均 10/1 = 10,B 平均 6.5 → A 第一 15 分、B 第二 10 分。
if got := standings("valid"); got["A"] != 15 || got["B"] != 10 {
t.Fatalf("valid 口径总分 = %v,期望 A:15 / B:10", got)
}
// all:A 平均 (10+0)/2 = 5,B 平均 6.5 → B 第一 15 分、A 第二 10 分。
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
"sportId": ids.sport, "code": "E_BOTH", "name": "团体引体向上", "eventType": "individual_team",
"resultMode": "auto", "resultType": "count", "compare": "desc", "scored": true,
"multiplier": 1, "status": "ongoing", "avgMode": "all",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改 avgMode 应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2)["avgMode"]; got != "all" {
t.Fatalf("改回 avgMode = %v,期望 all", got)
}
if got := standings("all"); got["A"] != 10 || got["B"] != 15 {
t.Fatalf("all 口径总分 = %v,期望 A:10 / B:15", got)
}
}
// avgMode 的读写与校验:缺省 valid、非法值 400。
func TestEventAvgModeRoundTrip(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")
// 老客户端不传 avgMode:按 valid 存。
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["avgMode"] != "valid" {
t.Fatalf("缺省 avgMode = %v,期望 valid", created["avgMode"])
}
eventID := int64(created["id"].(float64))
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true)
if got := decodeData[map[string]any](t, env2); got["avgMode"] != "valid" {
t.Fatalf("读回 avgMode = %v,期望 valid", got["avgMode"])
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
"avgMode": "bogus",
}, true)
if status != http.StatusBadRequest {
t.Fatalf("非法 avgMode 应 400,得到 %d %+v", status, env2.Error)
}
}
+10
View File
@@ -59,6 +59,16 @@ func nullI64(p *int64) any {
return *p
}
func boolPtr(v bool) *bool { return &v }
// boolToInt 把布尔写给 SQLite 的 0/1 列。
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
func nullInt(p *int) any {
if p == nil {
return nil
+44 -13
View File
@@ -6,13 +6,14 @@ import (
"errors"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- events ----------
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark`
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode`
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
var (
@@ -21,7 +22,7 @@ func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
)
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
&scheduledAt, &venue, &e.SortOrder, &note)
&scheduledAt, &venue, &e.SortOrder, &note, &e.AvgMode)
e.ScheduledAt = strPtr(scheduledAt)
e.Venue = strPtr(venue)
e.Remark = strPtr(note)
@@ -88,6 +89,10 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
@@ -96,13 +101,13 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
res, err := tx.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark))
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
@@ -127,6 +132,10 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
@@ -136,13 +145,13 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
res, err := tx.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
WHERE id = ?`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), id)
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
@@ -211,6 +220,18 @@ func multiplierOrOne(v float64) float64 {
return v
}
// normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。
func normalizeAvgMode(v string) (string, error) {
switch v {
case "":
return string(domain.AvgModeValid), nil
case string(domain.AvgModeValid), string(domain.AvgModeAll):
return v, nil
default:
return "", badRequest("avgMode 只能是 valid / all", v)
}
}
// ---------- categories ----------
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
@@ -289,7 +310,7 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
@@ -298,15 +319,25 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
out := make([]channelDTO, 0)
for rows.Next() {
var c channelDTO
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
var (
c channelDTO
countToTeam int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
return nil, internal(err)
}
c.CountToTeam = boolPtr(countToTeam != 0)
out = append(out, c)
}
return out, rows.Err()
}
// channelCountsToTeam 读请求体里的通道开关:缺省(null)按 true 处理,
// 保证不带该字段的旧客户端保存通道时不会把单位总分关掉。
func channelCountsToTeam(c channelDTO) bool {
return c.CountToTeam == nil || *c.CountToTeam
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
@@ -330,9 +361,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder); err != nil {
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
}
+3 -2
View File
@@ -464,7 +464,8 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
if err != nil {
return teamBreakdownDTO{}, err
}
// 合计必须与总积分榜同口径:仅记单位的流水直加,记到个人的流水乘折算系数。
// 合计必须与总积分榜同口径:只算计入单位总分的流水,
// 其中仅记单位的直加,记到个人的乘折算系数。
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return teamBreakdownDTO{}, err
@@ -482,7 +483,7 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN athletes a ON a.id = l.athlete_id
LEFT JOIN results r ON r.id = l.result_id
WHERE l.team_id = ? AND l.voided = 0
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
ORDER BY l.id DESC`, teamID)
if err != nil {
return teamBreakdownDTO{}, internal(err)
+10 -8
View File
@@ -263,7 +263,7 @@ func joinAnd(parts []string) string {
func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) {
return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
source_type, source_id, points, voided, created_at, count_to_team FROM score_ledger WHERE voided = 0`)
}
func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) {
@@ -276,15 +276,16 @@ func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]d
out := make([]domain.LedgerEntry, 0)
for rows.Next() {
var (
l domain.LedgerEntry
athlete sql.NullInt64
event sql.NullInt64
entry sql.NullInt64
result sql.NullInt64
created sql.NullString
l domain.LedgerEntry
athlete sql.NullInt64
event sql.NullInt64
entry sql.NullInt64
result sql.NullInt64
created sql.NullString
countToTeam int
)
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); err != nil {
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created, &countToTeam); err != nil {
return nil, internal(err)
}
l.AthleteID = athlete.Int64
@@ -292,6 +293,7 @@ func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]d
l.EntryID = entry.Int64
l.ResultID = result.Int64
l.CreatedAt = created.String
l.CountToTeam = countToTeam != 0
out = append(out, l)
}
return out, rows.Err()
+15 -8
View File
@@ -29,10 +29,10 @@ func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, er
)
err := q.QueryRowContext(ctx,
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode
FROM events WHERE id = ?`, eventID).
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark)
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode)
if errors.Is(err, sql.ErrNoRows) {
return domain.Event{}, notFound("小项不存在")
}
@@ -80,7 +80,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
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
@@ -89,10 +89,17 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
out := make([]domain.ScoreChannel, 0)
for rows.Next() {
var c domain.ScoreChannel
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
var (
c domain.ScoreChannel
countToTeam int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
return nil, internal(err)
}
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分)。
if countToTeam == 0 {
c.CountToTeam = boolPtr(false)
}
out = append(out, c)
}
return out, rows.Err()
@@ -379,10 +386,10 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
}
for _, l := range ledger {
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil {
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
return domain.EventScore{}, internal(err)
}
}
+8 -6
View File
@@ -176,7 +176,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status,
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, '')
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, ''), avg_mode
FROM events
ORDER BY sort_order, id`)
if err != nil {
@@ -198,7 +198,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
if err := rows.Scan(
&e.ID, &e.SportID, &e.Code, &e.Name, &eventType, &resultMode, &resultType, &compare,
&recordEligible, &scored, &e.Multiplier, &status,
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark,
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark, &e.AvgMode,
); err != nil {
return fmt.Errorf("解析小项: %w", err)
}
@@ -259,7 +259,7 @@ func splitTieBreak(raw string) []string {
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points, count_to_team
FROM score_ledger WHERE voided = 0 ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("读取积分流水: %w", err)
@@ -269,13 +269,15 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
var out []domain.LedgerEntry
for rows.Next() {
var (
e domain.LedgerEntry
sourceType string
e domain.LedgerEntry
sourceType string
countToTeam int
)
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points); err != nil {
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points, &countToTeam); err != nil {
return nil, fmt.Errorf("解析积分流水: %w", err)
}
e.SourceType = domain.SourceType(sourceType)
e.CountToTeam = countToTeam != 0
out = append(out, e)
}
return out, rows.Err()
+3 -2
View File
@@ -85,8 +85,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)
VALUES (?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i); err != nil {
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
boolToInt(ch.CountsToTeam())); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
}
+93
View File
@@ -363,6 +363,90 @@ func TestApplyNilChangeSet(t *testing.T) {
}
}
// 「个人+团体」默认模板的落库 / 导出 / 差异比对:
// 个人通道 countToTeam=false(只记个人),团体通道缺省 true(进单位总分)。
func TestApplyIndividualTeamTemplateCountToTeam(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_type, compare, scored, multiplier)
VALUES (1, 'EVT_BOTH', '团体引体向上', 'individual_team', 'count', 'desc', 1, 1)`); err != nil {
t.Fatalf("插入双计项目: %v", err)
}
next, err := Parse([]byte(individualTeamPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
rows, err := db.Query(`SELECT c.scope, c.basis, c.count_to_team FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_BOTH' ORDER BY c.sort_order, c.id`)
if err != nil {
t.Fatalf("查询通道: %v", err)
}
defer func() { _ = rows.Close() }()
type want struct {
scope, basis string
countToTeam int
}
wants := []want{{"athlete", "result", 0}, {"team", "aggregate", 1}}
i := 0
for rows.Next() {
var scope, basis string
var countToTeam int
if err := rows.Scan(&scope, &basis, &countToTeam); err != nil {
t.Fatalf("扫描通道: %v", err)
}
if i >= len(wants) || scope != wants[i].scope || basis != wants[i].basis || countToTeam != wants[i].countToTeam {
t.Fatalf("第 %d 条通道 = %s/%s/%d,期望 %+v", i, scope, basis, countToTeam, wants[i])
}
i++
}
if i != len(wants) {
t.Fatalf("EVT_BOTH 通道数 = %d,期望 %d", i, len(wants))
}
// 导出:只有显式关闭的通道写 false,缺省的团体通道不带该字段。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
var chans []Channel
for _, ov := range exported.EventOverrides {
if ov.EventCode == "EVT_BOTH" && ov.Channels != nil {
chans = *ov.Channels
}
}
if len(chans) != 2 {
t.Fatalf("导出通道数 = %d,期望 2(%+v)", len(chans), chans)
}
if chans[0].CountToTeam == nil || chans[0].CountsToTeam() {
t.Errorf("个人通道应导出 countToTeam: false,实际 %+v", chans[0].CountToTeam)
}
if chans[1].CountToTeam != nil || !chans[1].CountsToTeam() {
t.Errorf("团体通道应保持缺省 true,实际 %+v", chans[1].CountToTeam)
}
// 导出结果重新导入必须无差异:*bool 与缺省值不能凭空造出差异。
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 确认导入时通常只剩目标规则包(预览的 ChangeSet 不跨请求),这条路要能走通。
func TestApplyAcceptsChangeSetWithOnlyNext(t *testing.T) {
db := openEventDB(t)
@@ -421,3 +505,12 @@ func queryString(t *testing.T, db *sql.DB, query string) string {
}
return v
}
func mustMarshal(t *testing.T, pack *RulePack) []byte {
t.Helper()
raw, err := Marshal(pack)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
return raw
}
+6 -1
View File
@@ -32,14 +32,19 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
// countToTeam 缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
return domain.ScoreChannel{
out := domain.ScoreChannel{
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
SortOrder: sortOrder,
}
if !c.CountsToTeam() {
out.CountToTeam = BoolPtr(false)
}
return out
}
// ProfileIndex 按模板编码建索引,便于通道引用解析。
+6 -1
View File
@@ -239,12 +239,17 @@ func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
return out
}
// channelsEqual 逐字段比较目标通道与库中通道。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比。
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
if a[i].Scope != b[i].Scope || a[i].Basis != b[i].Basis || a[i].Profile != b[i].Profile {
return false
}
if a[i].CountsToTeam() != b[i].CountsToTeam() {
return false
}
}
+14 -3
View File
@@ -260,6 +260,7 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。
const individualTeamPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 个人+团体模板 }
@@ -282,8 +283,8 @@ profiles:
channelTemplates:
- match: { eventType: individual_team }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- { scope: team, basis: manual, profile: team_top6 }
- { scope: athlete, basis: result, profile: individual_top6, countToTeam: false }
- { scope: team, basis: aggregate, profile: team_top6 }
`
func TestResolveTargetsIndividualTeamTemplate(t *testing.T) {
@@ -313,7 +314,17 @@ func TestResolveTargetsIndividualTeamTemplate(t *testing.T) {
if got[0].Scope != domain.ScopeAthlete || got[0].Basis != domain.BasisResult || got[0].Profile != "individual_top6" {
t.Errorf("个人通道 = %+v", got[0])
}
if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisManual || got[1].Profile != "team_top6" {
if got[0].CountsToTeam() {
t.Error("个人通道不应计入单位总分")
}
if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisAggregate || got[1].Profile != "team_top6" {
t.Errorf("团体通道 = %+v", got[1])
}
if !got[1].CountsToTeam() {
t.Error("团体通道应计入单位总分")
}
// 解析缺省:没写 countToTeam 的通道一律计入单位总分,且不写成显式值。
if got[1].CountToTeam != nil {
t.Errorf("团体通道 countToTeam 应保持缺省(nil),实际 %v", *got[1].CountToTeam)
}
}
+15 -9
View File
@@ -243,7 +243,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
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team
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 {
@@ -253,24 +253,30 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
for chRows.Next() {
var (
eventID int64
scope string
basis string
code string
sortOrd int
eventID int64
scope string
basis string
code string
sortOrd int
countToTeam int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd); err != nil {
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
if !ok {
continue
}
events[idx].Channels = append(events[idx].Channels, Channel{
ch := Channel{
Scope: domain.Scope(scope),
Basis: domain.Basis(basis),
Profile: code,
})
}
// 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。
if countToTeam == 0 {
ch.CountToTeam = BoolPtr(false)
}
events[idx].Channels = append(events[idx].Channels, ch)
}
if err := chRows.Err(); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
+7 -1
View File
@@ -96,13 +96,19 @@ type Rule struct {
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
// basis=aggregate 固定按同一主体成绩之和汇总;汇总口径不可配置(库里没有对应列)。
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 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"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
// 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"`
}
// CountsToTeam 返回补齐缺省值后的开关(缺省 true)。
func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) }
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
type ChannelMatch struct {
EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"`