From 48208f395bb4cabb6e07b0c9617722dfb4c90c3b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 16 Sep 2026 23:55:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(scoring):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E9=98=9F=E5=86=85=E6=9C=80=E5=B7=AE=EF=BC=88=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E5=90=8D=E5=86=B2=E7=BA=BF=EF=BC=89=E3=80=8D=E8=81=9A?= =?UTF-8?q?=E5=90=88=E5=8F=A3=E5=BE=84=EF=BC=9B=E7=89=88=E6=9C=AC=20dev=5F?= =?UTF-8?q?0.5.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 需求(主人原话) > 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。 > 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。 ## 口径(这里最容易写反,所以定死) 新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让 「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**: | 项目 `compare` | 含义 | 队内最差 = | | --- | --- | --- | | `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 | | `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 | 方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`, 两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。 ## 改动 - **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**, 取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回** (父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。 - **引擎**:`scoreByAggregate` 支持第三种口径。 - **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。 - **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**), 提示文案写明两种方向,不丢一个 `worst` 让人猜。 - **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」, 这次不允许再开一个新口子。 - 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。 ## 两条边界(都是刻意的) - **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。 否则谁都可以挂一个弃赛的人在队里垫底。 - **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。 若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。 ## 验证 - **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。 - 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。 - 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。 ## 版本 `dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。 --- VERSION | 2 +- console/installer/art/1x/brand.bmp | Bin 316854 -> 316854 bytes console/installer/art/2x/brand.bmp | Bin 1267254 -> 1267254 bytes console/installer/art/version.txt | 2 +- console/package.json | 2 +- docs/使用手册.md | 14 +- docs/升级说明.md | 41 ++- .../domain/aggregate_modes_golden_test.go | 167 +++++++++++ .../internal/domain/aggregate_worst_test.go | 268 ++++++++++++++++++ server/internal/domain/scoring.go | 61 +++- server/internal/domain/types.go | 27 +- .../httpapi/channel_aggregate_worst_test.go | 164 +++++++++++ server/internal/httpapi/dto.go | 5 +- server/internal/httpapi/repo_events.go | 14 +- server/internal/httpapi/scoring.go | 7 +- server/internal/rules/aggregate_mode_test.go | 35 ++- server/internal/rules/snapshot.go | 4 +- server/internal/rules/types.go | 9 +- server/internal/rules/validate.go | 6 +- .../0027_channel_aggregate_mode_worst.sql | 64 +++++ .../migrations/event/migration_0027_test.go | 218 ++++++++++++++ web/src/admin/api/scoring.ts | 60 ++-- .../components/baseData/ChannelsSection.vue | 43 ++- .../baseData/SportsEventsSection.vue | 13 +- .../tests/channels-aggregate-mode.test.ts | 25 +- .../tests/scoring-aggregate-worst.test.ts | 180 ++++++++++++ web/src/shared/channels.ts | 2 +- web/src/shared/rules.ts | 4 +- web/src/shared/tests/rules.test.ts | 27 ++ web/src/shared/types.ts | 15 +- 30 files changed, 1394 insertions(+), 85 deletions(-) create mode 100644 server/internal/domain/aggregate_modes_golden_test.go create mode 100644 server/internal/domain/aggregate_worst_test.go create mode 100644 server/internal/httpapi/channel_aggregate_worst_test.go create mode 100644 server/migrations/event/0027_channel_aggregate_mode_worst.sql create mode 100644 server/migrations/event/migration_0027_test.go create mode 100644 web/src/admin/tests/scoring-aggregate-worst.test.ts diff --git a/VERSION b/VERSION index a48bb21..3be45bd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.10 +dev_0.5.11 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index 8dd9d3ee3e2b683c7df17db2b1ceb92bf7200dd0..969de11c180f6a64147b3b52b4d518806fe12d76 100644 GIT binary patch delta 429 zcmdnCT6o)P;SIVun|&EonI;G2E#>#s;`7$v@zvx7AuXQGVT@u-lVu8*N`#w8_-XS8 z>Ip@dNrf4S15u=zWT2kl=14|irpa%Lm&&!LdIGg3*(*;j44YLEGrKZwLT*r$xirw2 zKwZJj@r=AollPP@mGD&K^4AgY*A;YE;b@F=ZcTFcP~`{!n*_A0zjBeBhbkvTg_|;a zL#$IvqMN%4J5WV{4u61-z-IrdO-#~w)NJl)m1LT1T0chut_Wfk+#sN%fhw4q<|B-8 zlw+)ovWKexS_L!)=$=b0Fhv2ng7G$reK~>639fMW02S?K5(T=pGR%5WLuz$|ttZ$s z5dA<)O1l>E0|U-Og#+mHV0~dfZ9ZV2$}mlK>Rkc$r8b|R7T@MzMkS`nq7zmCV?u+e X`RSzgr;`|gmba*>>;8W&Je zXS#Q3usP5rJ{mkzio()eG{A-lZuYO*!~{|UL=$p@a=rAu)VP4CFDEeH*T7SaBT!dx zbAKz)*{1b#aH%tds*4BN$<#C-Zl_OakQv0z$%SF*t}r|MTY>5>wak}>xUDF_WKw>J zqby^zxirWFh9Z7I5A?L|W)cMkPhWOmPp0p*lE{)kQ)mG6v`R8fF6~;x@1w~J4L}=E v00P5ThH0`>?+PBEC~~OFGfftqumT!2&4(wnAD+Mn#7scUy#4S5md`E#ef5Ge diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index e5a03f5166a9b8300982d1e417a9c9385d67b987..8fdb38a4493f964772bdbb02458cf040c9771f35 100644 GIT binary patch delta 826 zcmdnC#&_Eq--a!W(MP2Hbp#+NKt}+9bOi!*1-6$RVQgiU_EP8eRO137Zy-|V1|lyt zE-!U1Zw;=^WlR<<+x^ZldNHc{YVoxtyH78PoLm?RL=*FZC*_AsDGZxZ6h0v@D8xwA zSBp1bb1jn|%l6lo8GXSTfQptjXDp~oUec7luBUKSNAAMgNQ7rtV2#3^sbvYqb( z9{oW1bzkwz`~Ai%zx5pxZs!4w?_p!_3Jh708gC7rNHZy5L@sJbonMoOfD?jCmI8#fbq)@Ov!<|focvjQ<25VHd@2M}`t gF&7YX12GQ}^8zs+5c31E01yiTvCwuCOJOEi06zBd{r~^~ delta 1067 zcmZ8g$xjn;6wVASP$*mZcCYi!4QODHKBj z(YS@6fe$8=?VPJsd`F=zhsY@nRv_&m`?yBGOkF2_z9(6Ep zCp-3*+~ZB9Oj?9REeed%8Xmjw1v%4@6BRuTMf?7dS-Z}l5bC7iwVH(05$pC-S6it} zuZScR51qwhRbL4OBB9~cn0NsO^iGOJ;DL>Se+I3W}+Qeb#@$49(X|K@>u6Ls+3ZjGsI9cRCNX7M(Yc4 zu2hRT<4|>}zh-N}d8aUilgN($q)<@&gJY7mCagEncb_2}MsfvlXXFBJTLqMMl_@!; zggL-RH6n7xuG{quca^2%{Eq`ZJP$0nd-YirazUk9#HL8;JRV!~iz+5M3Det7vUX~f zXPEFED*B<~6m$QC{!0|hwBFub9PTbxogtEvi|$?~6GY4)SaLZu$kfTg$C_>+wZo|4 zwuZ9??#^{EJHDX~t>S-!Ff~U=m;4cnXqP#6-|zM`6rW}ql|rn|=6t{P+C?lCsR?Vq zJQ9ORrpTm4+KN-RpSXN|RXAfD@CZ}H9;{Dwz4X{BGsR?ztVBv*<*|e3Xe}iLZ*8>s z!(4A;elnZ{sXUeMG-)7x%2pO^E?G)8I9@^s(Bw4b;IrRR%WYU|$>nzsJCUNPKmuiE zDB}owsUTqGWAqB)w5>d_IQ-sw@5TKVNZeX*!1SO4=cV)vKM6rSIyE+M*;xnITZ%5} z)5vpX960S$xz j 1001 的 1 个 → 1002 第一。 + if !nearlyEqual(got[1002].value, 5) || got[1002].rank != 1 || !nearlyEqual(got[1002].points, 15) { + t.Errorf("1002 应取队内最少的 5 个、第 1 名 15 分,实际 %+v", got[1002]) + } + if !nearlyEqual(got[1001].value, 1) || got[1001].rank != 2 || !nearlyEqual(got[1001].points, 10) { + t.Errorf("1001 应取队内最少的 1 个、第 2 名 10 分,实际 %+v", got[1001]) + } + + // 平均口径下名次恰好相反(1001 平均 10.5 > 1002 平均 5.5)——两种口径不能互相顶替。 + avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results)) + if avg[1001].rank != 1 || avg[1002].rank != 2 { + t.Fatalf("avg 口径下应 1001 第一,实际 %+v", avg) + } + if got[1001].rank == avg[1001].rank { + t.Fatalf("worst 与 avg 的名次应互换:worst=%+v avg=%+v", got, avg) + } +} + +// TestAggregateWorstTie:两支队伍的「最差成绩」相同即并列,并列口径沿用积分模板的 tieMode(默认顺延)。 +func TestAggregateWorstTie(t *testing.T) { + entries, results := fixture([]participant{ + // 1001:10 / 20 —— 最慢 20。 + {entryID: 1, athleteID: 1, teamID: 1001, value: 10}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 20}, + // 1002:单人 20 —— 同样是最慢 20,并列。 + {entryID: 3, athleteID: 3, teamID: 1002, value: 20}, + // 1003:25。 + {entryID: 4, athleteID: 4, teamID: 1003, value: 25}, + }) + ev := teamEvent(1) + ev.Compare = CompareAsc + + got := aggregateByTeam(t, worstScore(t, ev, entries, results)) + if got[1001].rank != 1 || got[1002].rank != 1 { + t.Errorf("最差成绩相同的两队应并列第 1,实际 1001=%+v 1002=%+v", got[1001], got[1002]) + } + if !nearlyEqual(got[1001].points, 15) || !nearlyEqual(got[1002].points, 15) { + t.Errorf("并列第 1 的两队各得 15 分,实际 %v / %v", got[1001].points, got[1002].points) + } + // 并列顺延:第三名那队拿第 3 名那份分(8),不是第 2 名(10)。 + if got[1003].rank != 3 || !nearlyEqual(got[1003].points, 8) { + t.Errorf("并列之后应顺延到第 3 名 8 分,实际 %+v", got[1003]) + } +} + +// TestAggregateWorstSkipsSubjectWithoutAnyValidResult:一个有效成绩都没有的主体不参与排名、不得分, +// 与 avg 的 valid 口径同一条规则 —— 没人冲线就没有「最后一名冲线」可言。 +// +// 关键是**不能**把「没成绩」当成 0 去比:asc 项目里 0 是最快的,会让一支没人完赛的队伍白拿第一; +// desc 项目里 0 又是最差的,同样荒谬。avg_mode=all 也照样不参与(worst 没有分母可用)。 +func TestAggregateWorstSkipsSubjectWithoutAnyValidResult(t *testing.T) { + build := func() ([]Entry, []Result) { + return fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 12}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 20}, + // 1002 全队一条有效成绩都没有(都还没录)。 + {entryID: 3, athleteID: 3, teamID: 1002, noValue: true}, + {entryID: 4, athleteID: 4, teamID: 1002, noValue: true}, + }) + } + + for _, tc := range []struct { + name string + compare CompareMode + avgMode AvgMode + }{ + {"asc + valid", CompareAsc, AvgModeValid}, + {"asc + all", CompareAsc, AvgModeAll}, + {"desc + valid", CompareDesc, AvgModeValid}, + {"desc + all", CompareDesc, AvgModeAll}, + } { + t.Run(tc.name, func(t *testing.T) { + entries, results := build() + ev := teamEvent(1) + ev.Compare = tc.compare + ev.AvgMode = tc.avgMode + + got := aggregateByTeam(t, worstScore(t, ev, entries, results)) + if got[1002].rank != 0 || got[1002].points != 0 { + t.Fatalf("全队没有有效成绩的 1002 不应参与排名,实际 %+v", got[1002]) + } + // 1001 独自参与,拿第 1 名的分(不是被「0 分的空队」挤到第 2 名)。 + if got[1001].rank != 1 || !nearlyEqual(got[1001].points, 15) { + t.Fatalf("1001 应第 1 名 15 分,实际 %+v", got[1001]) + } + }) + } +} + +// TestAggregateWorstIgnoresNonRegisteredEntries:弃权 / 失格(status 非 registered)的报名项 +// 既不算参与、也不算成绩 —— 它的成绩再差也不能代表队伍。 +// +// 现场的含义:一支队伍有人弃赛,队伍的成绩由**实际出赛的人**里最差的那个决定, +// 而不是由「没来的人」决定(否则弃赛反而是最差成绩,谁都愿意报一个弃赛的人垫底)。 +func TestAggregateWorstIgnoresNonRegisteredEntries(t *testing.T) { + entries, results := fixture([]participant{ + // 1001:一人 30 分钟正常完赛,另一人 50 分钟但弃赛 —— 队伍成绩取 30。 + {entryID: 1, athleteID: 1, teamID: 1001, value: 30}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 50, entryStatus: EntryStatusScratched}, + // 1002:一人 40 分钟完赛,另一人失格 —— 队伍成绩取 40。 + {entryID: 3, athleteID: 3, teamID: 1002, value: 40}, + {entryID: 4, athleteID: 4, teamID: 1002, value: 99, entryStatus: EntryStatusDQ}, + }) + ev := teamEvent(1) + ev.Compare = CompareAsc + + got := aggregateByTeam(t, worstScore(t, ev, entries, results)) + if !nearlyEqual(got[1001].value, 30) || got[1001].rank != 1 { + t.Errorf("1001 应取实际出赛里最慢的 30 分钟、第 1 名,实际 %+v", got[1001]) + } + if !nearlyEqual(got[1002].value, 40) || got[1002].rank != 2 { + t.Errorf("1002 应取实际出赛的 40 分钟、第 2 名,实际 %+v", got[1002]) + } +} + +// TestAggregateWorstAgainstAvgAndSum:同一组数据下三种口径各给一个值,互不相等 —— +// 现场切换口径时看得见差别(也说明 worst 不是 avg / sum 的另一种写法)。 +func TestAggregateWorstAgainstAvgAndSum(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 10}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 30}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 20}, + {entryID: 4, athleteID: 4, teamID: 1002, value: 21}, + }) + ev := teamEvent(1) + ev.Compare = CompareAsc + + worst := aggregateByTeam(t, worstScore(t, ev, entries, results)) + avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results)) + sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results)) + + // worst:1002(21)快于 1001(30); + // avg:1002(20.5)快于 1001(20)—— 等等,平均下 1001 是 20、1002 是 20.5,所以 avg 里 1001 第一; + // sum:1001 40 > 1002 41,所以 sum 里 1002 第一。 + if worst[1002].rank != 1 || worst[1001].rank != 2 { + t.Errorf("worst 下应 1002 第 1,实际 %+v", worst) + } + if avg[1001].rank != 1 || avg[1002].rank != 2 { + t.Errorf("avg 下应 1001 第 1(20 < 20.5),实际 %+v", avg) + } + if worst[1001].value == avg[1001].value || worst[1001].value == sum[1001].value { + t.Errorf("三种口径的汇总值不该相同:worst=%v avg=%v sum=%v", + worst[1001].value, avg[1001].value, sum[1001].value) + } +} + +// TestAggregateModeWorstNormalization:worst 是合法取值,认不出的取值仍退回 avg。 +func TestAggregateModeWorstNormalization(t *testing.T) { + if got := (ScoreChannel{AggregateMode: AggregateWorst}).aggregateMode(); got != AggregateWorst { + t.Fatalf("worst 应原样保留,实际 %q", got) + } + if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg { + t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got) + } + for _, bogus := range []AggregateMode{"", "worse", "WORST", "min", "max", "biggest"} { + if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg { + t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got) + } + } +} + +// TestAggregateWorstLedgerAndRankedShape:worst 口径产出的流水与名次明细形状与既有口径一致 +// (EntryID=0 的聚合名次、TeamID 是主体、Shares 独占),免得读侧(榜单 / 导出)按别的形状解析。 +func TestAggregateWorstLedgerAndRankedShape(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 12}, + {entryID: 2, athleteID: 2, teamID: 1002, value: 20}, + }) + ev := teamEvent(1) + ev.Compare = CompareAsc + es := worstScore(t, ev, entries, results) + + if len(es.Ranked) != 2 { + t.Fatalf("应产出 2 条名次明细,实际 %+v", es.Ranked) + } + for _, rr := range es.Ranked { + if rr.EntryID != 0 || rr.ResultID != 0 { + t.Errorf("聚合名次不应挂报名项 / 成绩:%+v", rr) + } + if rr.Scope != ScopeTeam || rr.Basis != BasisAggregate { + t.Errorf("名次明细的 scope/basis 应照通道来:%+v", rr) + } + if !reflect.DeepEqual(rr.Shares, wholeShare(rr.TeamID)) { + t.Errorf("聚合名次按主体单位独占份额,实际 %+v", rr.Shares) + } + } + got := pointsByTeam(es.Ledger) + if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 10) { + t.Fatalf("单位流水 = %v,期望 1001=15 1002=10", got) + } +} diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index ac1c587..8b09ed0 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -591,7 +591,10 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po // // 汇总口径(通道的 aggregate_mode,缺省 avg): // - avg:团体成绩 = 成员成绩的平均值; -// - sum:团体成绩 = 成员成绩的总和。 +// - sum:团体成绩 = 成员成绩的总和; +// - worst:团体成绩 = 成员成绩里**最差的那一份**。方向随项目的 compare 自动取,见 worseValue: +// compare=asc(越小越好,如时间)→ 取最大,即**最后一名冲线**的那个人; +// compare=desc(越大越好,如次数)→ 取最小,即**做得最少**的那个。 // // avg 的平均值分母由项目的 avg_mode 决定: // - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分; @@ -601,7 +604,14 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po // sum 只累加有效成绩(没成绩的人按 0 计入和不计入是一样的和),所以 avg_mode 在这里只剩一个作用: // 决定「一个有效成绩都没有的单位算不算 0 分参与排名」——valid 不参与、all 参与,与 avg 完全一致。 // -// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。 +// worst 没有分母,「没成绩按 0 计入」对它也就无从谈起,所以 avg_mode 在这个口径下不起任何作用: +// 一个有效成绩都没有的主体一律不参与排名、不得分(与 avg 的 valid 口径同一条规则)。 +// **绝不能**改成「按 0 参与」:0 在 asc 项目里是全场最快、在 desc 项目里是全场最差, +// 无论哪一边,都会让一支没人完赛(或全队都还没录成绩)的队伍白拿名次与分。 +// +// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致; +// 在 worst 下这条尤其要紧:队里有人弃赛时,队伍成绩由**实际出赛的人**里最差的那个决定, +// 而不是由「没来的人」决定(否则谁都想在队里挂一个弃赛的人来垫底)。 func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { if in.Event.Compare == CompareNone { return nil, ErrCompareRequired @@ -613,11 +623,13 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil } type bucket struct { - subject int64 - entry Entry - sum float64 // 有效成绩之和 - valid int // 有有效成绩的人数(valid 口径的分母) - roster int // 报名人数(all 口径的分母) + subject int64 + entry Entry + sum float64 // 有效成绩之和 + valid int // 有有效成绩的人数(valid 口径的分母) + roster int // 报名人数(all 口径的分母) + worst float64 // 队内最差的那一份有效成绩(worst 口径) + hasWorst bool // 是否已经取到过一份有效成绩 } order := make([]int64, 0) buckets := make(map[int64]*bucket) @@ -643,20 +655,32 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil if v, ok := validResultValue(r, found, in.Options); ok { b.sum += v b.valid++ + if !b.hasWorst || worseValue(v, b.worst, in.Event.Compare) { + b.worst = v + b.hasWorst = true + } } } - sumMode := ch.aggregateMode() == AggregateSum + mode := ch.aggregateMode() allMode := in.Event.avgMode() == AvgModeAll agg := make([]candidate, 0, len(order)) for _, id := range order { b := buckets[id] - if b.valid == 0 && !allMode { - // 一个人都没有效成绩的单位不参与团体排名,也不得分(两种汇总口径都一样)。 + switch { + case mode == AggregateWorst: + // 队内最差:一个有效成绩都没有的主体不参与排名(worst 没有分母,avg_mode 在这里不适用)。 + if !b.hasWorst { + continue + } + case b.valid == 0 && !allMode: + // 一个人都没有效成绩的单位不参与团体排名,也不得分(avg 与 sum 都一样)。 continue } var value float64 switch { - case sumMode: + case mode == AggregateWorst: + value = b.worst + case mode == AggregateSum: // 求和口径:只累加有效成绩(没成绩按 0 计入与否不改变和)。 value = b.sum case allMode: @@ -734,6 +758,21 @@ func nearlyEqual(a, b float64) bool { return math.Abs(a-b) <= rankEpsilon } +// worseValue 判断 a 是不是比 b 更「差」——**方向由项目的 compare 决定,这是本口径最容易写反的一处**: +// +// compare=asc (越小越好,如时间):更差 = 更大 → 队内最差是**最后一名冲线**的那个人; +// compare=desc(越大越好,如次数):更差 = 更小 → 队内最差是**做得最少**的那个人。 +// +// 写死成 min 或 max 都会让一半的项目算反:越野这类时间项目会把「跑得最快的人」当成全队成绩、 +// 引体向上这类次数项目会把「做得最多的人」当成全队成绩。两种错法在代码上都读得通 +// (`v < worst` 看着很正常),所以用 asc / desc 双向用例钉住,别凭直觉改。 +func worseValue(a, b float64, compare CompareMode) bool { + if compare == CompareDesc { + return a < b + } + return a > b +} + // validResultValue 判断一条成绩是否算「有效成绩」:成绩存在、已发布(或允许草稿)、 // 状态正常,且有可比较的数值。聚合通道按 avg_mode 决定没成绩的人算不算进分母。 func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) { diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index 0fb04d7..a29b03c 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -44,6 +44,12 @@ const ( AggregateAvg AggregateMode = "avg" // AggregateSum 按主体成员成绩的总和排名。 AggregateSum AggregateMode = "sum" + // AggregateWorst 按主体成员成绩里**最差的那一份**排名(队内最差 / 最后一名冲线)。 + // + // 刻意不叫 min / max:它到底是取最小还是最大**取决于项目的比较方向** + // (asc 越小越好 → 取最大,即最后一名冲线;desc 越大越好 → 取最小,即做得最少的那个), + // 叫 min 或 max 都会让后来人把其中一半的项目写反,见 scoreByAggregate 的说明。 + AggregateWorst AggregateMode = "worst" ) // ResultType 成绩的物理量类型,决定比较与展示方式。 @@ -241,7 +247,7 @@ type EventCategory struct { // ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。 // 一个项目可配 0..N 个通道;0 个表示只排名不计分。 // basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由本通道的 aggregate_mode 决定 -// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。 +// (avg 平均 / sum 求和 / worst 队内最差),平均值内部的分母口径由项目的 avg_mode 决定。 type ScoreChannel struct { ID int64 `json:"id"` EventID int64 `json:"eventId"` @@ -519,9 +525,28 @@ func (c ScoreChannel) aggregateMode() AggregateMode { if c.AggregateMode == AggregateSum { return AggregateSum } + if c.AggregateMode == AggregateWorst { + return AggregateWorst + } return AggregateAvg } +// AggregateModeOf 把库里 / 请求体里的汇总口径字符串还原成引擎口径;认不出的取值退回缺省。 +// +// 缺省(avg)刻意返回**零值**而不是 "avg":这一列在通道响应体与导出的规则包里都按 omitempty 下发, +// 填上 "avg" 会让老数据的响应体与导出文件凭空多出一个字段(老库 / 老客户端的对拍当场对不上)。 +// 三个读路径(REST 列表、计分读通道、规则包导出)都走它,免得各写一份、漏掉新取值。 +func AggregateModeOf(stored string) AggregateMode { + switch AggregateMode(stored) { + case AggregateSum: + return AggregateSum + case AggregateWorst: + return AggregateWorst + default: + return "" + } +} + // CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。 func (c ScoreChannel) CountsToMedals() bool { return c.CountToMedals == nil || *c.CountToMedals diff --git a/server/internal/httpapi/channel_aggregate_worst_test.go b/server/internal/httpapi/channel_aggregate_worst_test.go new file mode 100644 index 0000000..dfc260c --- /dev/null +++ b/server/internal/httpapi/channel_aggregate_worst_test.go @@ -0,0 +1,164 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// 聚合口径第三个取值 worst(按队内最差排名)的 REST 契约与端到端效果。 +// +// 契约: +// - 请求体写 worst 能存下、读回来还是 worst; +// - 认不出的取值仍 400(新增取值不能顺带把校验放松成「什么都收」)。 +// +// 效果(越野这类 compare=asc 的项目,按最后一名冲线排名): +// - 缺省 avg 与 worst 的名次**必须不同**——这组数据是特意挑的,两者分叉,串了口径当场看得出来; +// - 落库的 channel_ranks.result_value 就是队内最差的那一份成绩(不是平均、不是最快); +// - 全队一条有效成绩都没有(这里用「没录」实现)→ 不参与排名、不得分。 +func TestChannelAggregateModeWorst(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) + } + + // 5 公里越野:compare=asc(时间,越小越好)——最后一名冲线的队伍排前面。 + ev := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'MARCH', '5 公里越野', 'team', 'auto', 'time', 'asc', 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) + athB1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('丙', '男', ?, 1)`, ids.teamB) + athB2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('丁', '男', ?, 1)`, ids.teamB) + // 第三个单位:全队一条有效成绩都不录,用来钉住「没有效成绩不参与排名」。 + teamC := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`) + athC1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('戊', '男', ?, 1)`, teamC) + 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, athB1) + entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamB, athB2) + env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, teamC, athC1) + + chans := fmt.Sprintf("/api/events/%d/channels", ev) + setMode := func(mode string) { + 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) + } + } + 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 + } + // 落库的团体成绩:聚合名次不挂报名项,主体就是单位(subject_key = 't:<单位>')。 + channelRankValue := func(teamID int64) (float64, int, bool) { + t.Helper() + var value float64 + var rank int + err := env.ev.QueryRow(`SELECT result_value, rank FROM channel_ranks + WHERE event_id = ? AND subject_key = ?`, ev, fmt.Sprintf("t:%d", teamID)).Scan(&value, &rank) + if err != nil { + return 0, 0, false + } + return value, rank, true + } + + // A 队 12 + 20 分钟、B 队 18 + 19 分钟、C 队(还没录成绩)。 + status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ev, + "results": []map[string]any{ + {"entryId": entryA1, "resultValue": 12, "unit": "分", "status": "ok"}, + {"entryId": entryA2, "resultValue": 20, "unit": "分", "status": "ok"}, + {"entryId": entryB1, "resultValue": 18, "unit": "分", "status": "ok"}, + {"entryId": entryB2, "resultValue": 19, "unit": "分", "status": "ok"}, + }, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + + // 1) 缺省 avg:A 队平均 16 快于 B 队 18.5 —— A 第一(15 分)、B 第二(10 分)。 + setMode("") + if status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ev}, true); status != http.StatusOK || !resp.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error) + } + if got := teamLedger(); got[ids.teamA] != 15 || got[ids.teamB] != 10 { + t.Fatalf("avg 口径下单位流水 = %+v,期望 A=15 B=10", got) + } + if value, _, ok := channelRankValue(ids.teamA); !ok || value != 16 { + t.Fatalf("avg 口径下 A 队落库成绩 = %v,期望平均 16", value) + } + + // 2) 现场切成「按队内最低分」:A 队最慢 20 分钟、B 队最慢 19 分钟 —— B 第一、A 第二,与平均口径正好互换。 + setMode("worst") + if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 { + t.Fatalf("worst 口径下单位流水 = %+v,期望 B=15 A=10(按最后一名冲线)", got) + } + if value, rank, ok := channelRankValue(ids.teamB); !ok || value != 19 || rank != 1 { + t.Fatalf("worst 口径下 B 队落库成绩 = %v 第 %d 名,期望 19 第 1 名", value, rank) + } + if value, rank, ok := channelRankValue(ids.teamA); !ok || value != 20 || rank != 2 { + t.Fatalf("worst 口径下 A 队落库成绩 = %v 第 %d 名,期望 20 第 2 名", value, rank) + } + // 读回来还是 worst(缺省值才不下发,非缺省值必须下发)。 + status, resp = env.do(client, http.MethodGet, chans, nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[[]map[string]any](t, resp); got[0]["aggregateMode"] != "worst" { + t.Fatalf("通道列表应带 aggregateMode=worst,得到 %+v", got[0]) + } + var stored string + if err := env.ev.QueryRow(`SELECT aggregate_mode FROM event_score_channels WHERE event_id = ?`, ev).Scan(&stored); err != nil { + t.Fatalf("读取落库的聚合口径: %v", err) + } + if stored != "worst" { + t.Fatalf("落库的聚合口径 = %q,期望 worst", stored) + } + + // 3) 全队没有有效成绩的 C 队不参与排名、不得分(worst 没有分母,avg_mode 在这里不适用)。 + if _, _, ok := channelRankValue(teamC); ok { + t.Fatal("一条有效成绩都没有的 C 队不应出现在团体名次里") + } + if got := teamLedger(); got[teamC] != 0 { + t.Fatalf("C 队不应得分,实际流水 %+v", got) + } + + // 4) 认不出的取值仍 400,且不把已配好的 worst 改坏。 + status, resp = env.do(client, http.MethodPut, chans, map[string]any{ + "channels": []map[string]any{ + {"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1, "aggregateMode": "min"}, + }, + }, 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 := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 { + t.Fatalf("被拒的请求不应改动已发布的榜,实际 %+v", got) + } +} diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index 2921a90..fef4e67 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -56,9 +56,10 @@ type channelDTO struct { Basis string `json:"basis"` ProfileID int64 `json:"profileId"` SortOrder int `json:"sortOrder"` - // AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均 / sum 按成员总和。 + // AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均 / sum 按成员总和 / + // worst 按队内最差(方向随小项的 compare 走:asc 取最后一名冲线、desc 取做得最少的那个)。 // 请求体里缺省(空串)按 avg 处理,不带该字段的旧客户端保存通道时口径不变。 - // 响应里只下发非缺省值(sum);缺省(avg)不下发,老数据的响应体因此一个字节都不变。 + // 响应里只下发非缺省值(sum / worst);缺省(avg)不下发,老数据的响应体因此一个字节都不变。 AggregateMode string `json:"aggregateMode,omitempty"` // CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理, // 「个人+团体」项目的个人通道传 false。响应里始终是具体值。 diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index 7a5567b..e0a9445 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -670,10 +670,8 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, &countToMedals, &countToTeamMedals); err != nil { return nil, internal(err) } - // 只下发非缺省口径(sum):avg 是缺省值,老数据的响应体因此一个字节都不变。 - if aggregateMode == string(domain.AggregateSum) { - c.AggregateMode = aggregateMode - } + // 只下发非缺省口径(sum / worst):avg 是缺省值,老数据的响应体因此一个字节都不变。 + c.AggregateMode = string(domain.AggregateModeOf(aggregateMode)) c.CountToTeam = boolPtr(countToTeam != 0) c.CountToMedals = boolPtr(countToMedals != 0) c.CountToTeamMedals = boolPtr(countToTeamMedals != 0) @@ -697,8 +695,8 @@ func channelFlag(v *bool) bool { // channelAggregateMode 读请求体里的聚合口径:缺省(空串)落成 avg, // 保证不带该字段的旧客户端保存通道时口径不变(与库里的默认值一致)。 func channelAggregateMode(c channelDTO) string { - if c.AggregateMode == string(domain.AggregateSum) { - return string(domain.AggregateSum) + if mode := domain.AggregateModeOf(c.AggregateMode); mode != "" { + return string(mode) } return string(domain.AggregateAvg) } @@ -748,9 +746,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe return nil, badRequest("通道 basis 非法", i) } switch c.AggregateMode { - case "", string(domain.AggregateAvg), string(domain.AggregateSum): + case "", string(domain.AggregateAvg), string(domain.AggregateSum), string(domain.AggregateWorst): default: - return nil, badRequest("通道 aggregateMode 只能是 avg/sum", i) + return nil, badRequest("通道 aggregateMode 只能是 avg/sum/worst", i) } if c.ProfileID == 0 { return nil, badRequest("通道 profileId 必填", i) diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index db5c2fb..81fe481 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -107,10 +107,9 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor &countToMedals, &countToTeamMedals); err != nil { return nil, internal(err) } - // 汇总口径只认 sum:avg(含缺字段的老库)保持零值,由引擎按缺省 avg 处理。 - if aggregateMode == string(domain.AggregateSum) { - c.AggregateMode = domain.AggregateSum - } + // 汇总口径只认非缺省的取值(sum / worst):avg(含缺字段的老库)保持零值, + // 由引擎按缺省 avg 处理,响应体里也不多出这个字段。 + c.AggregateMode = domain.AggregateModeOf(aggregateMode) // 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。 if countToTeam == 0 { c.CountToTeam = boolPtr(false) diff --git a/server/internal/rules/aggregate_mode_test.go b/server/internal/rules/aggregate_mode_test.go index 6716970..435999c 100644 --- a/server/internal/rules/aggregate_mode_test.go +++ b/server/internal/rules/aggregate_mode_test.go @@ -9,12 +9,13 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) -// 聚合口径(basis=aggregate 的 avg / sum)在规则包这条路上的口径: -// 解析认 avg/sum、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum), +// 聚合口径(basis=aggregate 的 avg / sum / worst)在规则包这条路上的口径: +// 解析认 avg/sum/worst、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum / worst), // 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。 // // 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和, -// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径。 +// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径;越野 / 行军这类 +// 「取团队最后一人的成绩」的项目要能写 worst。 const aggregateModePackYAML = `format: scoreboard-rules formatVersion: 1 meta: { code: demo-rules, name: 示例规则包 } @@ -42,15 +43,20 @@ eventOverrides: name: 团体引体向上(平均,显式写缺省值) channels: - { scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg } + - eventCode: EVT_WORST + name: 5 公里越野(队内最差) + channels: + - { scope: team, basis: aggregate, profile: team_top6, aggregateMode: worst } ` -// seedAggregateEvents 建两个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。 +// 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", "团体引体向上(平均,显式写缺省值)"}, + {"EVT_WORST", "5 公里越野(队内最差)"}, } { 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 { @@ -76,6 +82,10 @@ func TestParseAggregateMode(t *testing.T) { if got := overrides["EVT_AVG"].AggregatesBy(); got != domain.AggregateAvg { t.Errorf("aggregateMode: avg 应解析成 avg,实际 %q", got) } + // worst = 按队内最差排名(方向随小项的 compare 走),取值本身不带方向,别读成 min。 + if got := overrides["EVT_WORST"].AggregatesBy(); got != domain.AggregateWorst { + t.Errorf("aggregateMode: worst 应解析成 worst,实际 %q", got) + } // 普通通道(basis=result)不写该字段时同样是缺省 avg,不影响任何行为。 tmpl := pack.ChannelTemplates[0].Channels[0] if tmpl.AggregateMode != "" || tmpl.AggregatesBy() != domain.AggregateAvg { @@ -89,6 +99,15 @@ func TestParseAggregateMode(t *testing.T) { } else if !strings.Contains(err.Error(), "aggregateMode") { t.Errorf("报错应点明 aggregateMode 字段,实际 %v", err) } + + // 「取最小」不能当取值写:worst 的方向由小项的 compare 决定,写 min 的项目一半会算反, + // 所以 min / max 都必须是**非法取值**(报错让人改用 worst),而不是被静默当成某个方向。 + for _, wrong := range []string{"min", "max"} { + bad := strings.Replace(aggregateModePackYAML, "aggregateMode: worst", "aggregateMode: "+wrong, 1) + if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) { + t.Errorf("aggregateMode: %s 应报校验错误(改用 worst),实际 %v", wrong, err) + } + } } // TestApplyAndExportAggregateMode 守落库与导出: @@ -121,6 +140,9 @@ func TestApplyAndExportAggregateMode(t *testing.T) { if got := modeOf("EVT_AVG"); got != string(domain.AggregateAvg) { t.Errorf("EVT_AVG 落库 = %q,期望 avg", got) } + if got := modeOf("EVT_WORST"); got != string(domain.AggregateWorst) { + t.Errorf("EVT_WORST 落库 = %q,期望 worst", got) + } exported, err := Export(db, Meta{Code: "demo", Name: "导出"}) if err != nil { @@ -130,10 +152,13 @@ func TestApplyAndExportAggregateMode(t *testing.T) { for _, ov := range exported.EventOverrides { exportedMode[ov.EventCode] = (*ov.Channels)[0].AggregateMode } - // sum 是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把总和口径丢成平均。 + // sum / worst 都是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把它们丢成平均。 if exportedMode["EVT_SUM"] != string(domain.AggregateSum) { t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"]) } + if exportedMode["EVT_WORST"] != string(domain.AggregateWorst) { + t.Errorf("EVT_WORST 导出 = %q,期望 worst", exportedMode["EVT_WORST"]) + } // avg 是缺省值,导出文件里不出现,往返比对不会凭空多出差异。 if exportedMode["EVT_AVG"] != "" { t.Errorf("EVT_AVG 导出 = %q,期望省略(avg 是缺省值)", exportedMode["EVT_AVG"]) diff --git a/server/internal/rules/snapshot.go b/server/internal/rules/snapshot.go index ba48114..4587fc9 100644 --- a/server/internal/rules/snapshot.go +++ b/server/internal/rules/snapshot.go @@ -298,9 +298,7 @@ func loadEvents(q queryer, profileByID map[int64]string) ([]EventInfo, error) { Profile: code, } // 只把非缺省的口径写进导出文件:avg 是缺省值,写了会让「导出 → 导入」凭空多出差异。 - if aggregateMode == string(domain.AggregateSum) { - ch.AggregateMode = string(domain.AggregateSum) - } + ch.AggregateMode = string(domain.AggregateModeOf(aggregateMode)) // 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。 if countToTeam == 0 { ch.CountToTeam = BoolPtr(false) diff --git a/server/internal/rules/types.go b/server/internal/rules/types.go index cd50e17..94e603b 100644 --- a/server/internal/rules/types.go +++ b/server/internal/rules/types.go @@ -97,12 +97,13 @@ type Rule struct { // Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。 // basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由 AggregateMode 决定 -// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。 +// (avg 平均 / sum 求和 / worst 队内最差),平均值内部的分母口径由项目的 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 按成员总和。 + // AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均(缺省)/ sum 按成员总和 / + // worst 按队内最差(方向随小项的 compare 走:asc 取最后一名冲线、desc 取做得最少的那个)。 // 只对 aggregate 通道有意义;缺省(不写)等价于 avg,与库里的默认值一致。 AggregateMode string `yaml:"aggregateMode,omitempty" json:"aggregateMode,omitempty"` // CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。 @@ -118,8 +119,8 @@ type Channel struct { // AggregatesBy 返回补齐缺省值后的汇总口径:未配置或写 avg 时按 avg(与库里的默认值一致)。 func (c Channel) AggregatesBy() domain.AggregateMode { - if c.AggregateMode == string(domain.AggregateSum) { - return domain.AggregateSum + if mode := domain.AggregateModeOf(c.AggregateMode); mode != "" { + return mode } return domain.AggregateAvg } diff --git a/server/internal/rules/validate.go b/server/internal/rules/validate.go index 6f5c2ab..397a38c 100644 --- a/server/internal/rules/validate.go +++ b/server/internal/rules/validate.go @@ -295,10 +295,10 @@ 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。 + // 汇总口径只有 avg / sum / worst 三个取值;不写 = avg(缺省),拼错的取值必须报错而不是被当成 avg。 if ch.AggregateMode != "" && - ch.AggregateMode != string(domain.AggregateAvg) && ch.AggregateMode != string(domain.AggregateSum) { - l.addf(cpath+".aggregateMode", "只能是 avg / sum,当前 %q", ch.AggregateMode) + ch.AggregateMode != string(domain.AggregateAvg) && domain.AggregateModeOf(ch.AggregateMode) == "" { + l.addf(cpath+".aggregateMode", "只能是 avg / sum / worst,当前 %q", ch.AggregateMode) } // 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。 key := string(ch.Scope) + "/" + string(ch.Basis) diff --git a/server/migrations/event/0027_channel_aggregate_mode_worst.sql b/server/migrations/event/0027_channel_aggregate_mode_worst.sql new file mode 100644 index 0000000..c5f58b0 --- /dev/null +++ b/server/migrations/event/0027_channel_aggregate_mode_worst.sql @@ -0,0 +1,64 @@ +-- 聚合通道(basis=aggregate)的汇总口径新增第三个取值 worst:**按队内最差排名**。 +-- +-- 现场:有的项目取「团队最后一人的成绩」来排名 —— 越野 / 行军这类**最后一名冲线**的项目, +-- 以及引体向上这类「按做得最少的那个算」的项目。同一个「队内最差」,方向随项目的 compare 自动取 +-- (asc 取最大 = 最后一名冲线;desc 取最小 = 做得最少的那个),见 domain/scoring.go 的 worseValue。 +-- +-- 为什么叫 worst 而不是 min / max:它到底是取最小还是取最大取决于项目的比较方向, +-- 叫 min 或 max 会让后来人把其中一半的项目写反(那份成绩的语义是「队里最差的那一份」, +-- 而不是「最小的那一份」)。 +-- +-- 这一列在 0023 上是 `CHECK (aggregate_mode IN ('avg', 'sum'))`,SQLite **改不了已存在的 CHECK**, +-- 只能按官方推荐的做法重建表(写法与 0012 / 0017 的重建一致):建新表 → 拷数据 → 删旧表 → 改名。 +-- +-- 重建父表有一处**必须处理干净**的连带影响:channel_ranks.channel_id 是指向本表的 +-- `ON DELETE CASCADE` 外键,删掉父表会让子表的名次**被级联清空** —— 那些名次是现场已经发布过的 +-- 榜(谁第几名、多少分),丢了不会报错,只是榜单静默变空,而且没人会想到要重新发布一遍。 +-- 所以下面先把子表整体寄存在临时表里,重建完再原样灌回(id、列值一字不改,外键与唯一索引照旧)。 +-- 临时表在迁移事务结束的同一个连接内使用,迁移跑完即随连接消失,不在库里留残迹。 +-- +-- 老数据零变化:每条老通道的 aggregate_mode 原样搬过去(默认仍是 'avg'),其它列一列不改, +-- id 也原样保留(channel_ranks 的外键挂在它上面);升级前后榜单与上一版一字不差。 +PRAGMA defer_foreign_keys = ON; + +-- 1) 子表名次先寄存(DROP 父表会级联把它们清掉)。 +CREATE TEMP TABLE channel_ranks_keep AS SELECT * FROM channel_ranks; + +-- 2) 新表:只放开 aggregate_mode 的取值,其它列定义与 0004 + 0017 + 0020 + 0023 逐字一致。 +CREATE TABLE event_score_channels_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id), + scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')), + basis TEXT NOT NULL CHECK (basis IN ('result', 'manual', 'aggregate')), + profile_id INTEGER NOT NULL REFERENCES scoring_profiles(id), + sort_order INTEGER NOT NULL DEFAULT 0, + count_to_team INTEGER NOT NULL DEFAULT 1 CHECK (count_to_team IN (0, 1)), + count_to_medals INTEGER NOT NULL DEFAULT 1 CHECK (count_to_medals IN (0, 1)), + count_to_team_medals INTEGER NOT NULL DEFAULT 1 CHECK (count_to_team_medals IN (0, 1)), + aggregate_mode TEXT NOT NULL DEFAULT 'avg' + CHECK (aggregate_mode IN ('avg', 'sum', 'worst')), + UNIQUE (event_id, scope, basis) +); + +INSERT INTO event_score_channels_new (id, event_id, scope, basis, profile_id, sort_order, + count_to_team, count_to_medals, count_to_team_medals, aggregate_mode) +SELECT id, event_id, scope, basis, profile_id, sort_order, + count_to_team, count_to_medals, count_to_team_medals, aggregate_mode +FROM event_score_channels; + +-- 3) 换名(父表被 DROP 时子表名次被级联清空,下面原样灌回)。 +DROP TABLE event_score_channels; +ALTER TABLE event_score_channels_new RENAME TO event_score_channels; + +CREATE INDEX IF NOT EXISTS idx_event_score_channels_event_id ON event_score_channels(event_id); + +-- 4) 名次原样回填(含 id 与 updated_at;AUTOINCREMENT 的号也会随之接上)。 +INSERT INTO channel_ranks (id, event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, athlete_id, team_id, rank, points, result_value, + status, updated_at) +SELECT id, event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, athlete_id, team_id, rank, points, result_value, + status, updated_at +FROM channel_ranks_keep; + +DROP TABLE channel_ranks_keep; diff --git a/server/migrations/event/migration_0027_test.go b/server/migrations/event/migration_0027_test.go new file mode 100644 index 0000000..d81552d --- /dev/null +++ b/server/migrations/event/migration_0027_test.go @@ -0,0 +1,218 @@ +package event + +import ( + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// 0027:聚合通道的汇总口径放开到第三个取值 worst(按队内最差排名)。 +// +// 五条底线: +// - 老数据零变化:每条老通道的 aggregate_mode 原样保留(0023 的默认 avg 与后配的 sum), +// 其它列一列不改、行数一行不少; +// - **子表 channel_ranks 的名次一行不少**(这条迁移要重建父表,父表被 DROP 时会级联清空子表, +// 必须原样灌回); +// - id 不能漂:老通道的 id 是 channel_ranks 的外键,换号就等于把名次全删了; +// - 取值放开到 avg / sum / worst,拼错的仍被 CHECK 挡下; +// - 幂等:连跑两次不报错、不重复登记。 +func TestMigration0027ChannelAggregateModeWorst(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0027") // 回到「升级前」:库结构到 0026 为止 + + 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, 'MARCH', '5 公里越野', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, + `INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`, + // 三条老通道:缺省 avg、显式 sum、以及一条非聚合通道(它也有这列,同样要原样留着)。 + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) + VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) + VALUES (1, 'athlete', 'aggregate', 1, 2, 'sum')`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) + VALUES (1, 'team', 'manual', 1, 3)`, + // 子表里两条名次:重建父表时必须原样回来(挂的是通道 2)。 + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value) + VALUES (1, 1, 2, 'aggregate', 'team', 't:1', 1, 15, 12.5)`, + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value) + VALUES (1, 1, 2, 'aggregate', 'team', 't:2', 2, 10, 13)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造老库数据 %q: %v", s, err) + } + } + // 升级前:worst 这一取值还不该被接受。 + if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err == nil { + t.Fatal("升级前不应接受 aggregate_mode = 'worst'") + } + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("升级赛事库: %v", err) + } + + // 老行逐列原样(id 尤其不能漂:channel_ranks 的外键挂在它上面)。 + want := []struct { + id int64 + scope string + basis string + mode string + sortOrd int + }{ + {1, "team", "aggregate", "avg", 1}, + {2, "athlete", "aggregate", "sum", 2}, + {3, "team", "manual", "avg", 3}, + } + rows, err := db.Query(`SELECT id, scope, basis, aggregate_mode, sort_order FROM event_score_channels ORDER BY id`) + if err != nil { + t.Fatalf("读取升级后的通道: %v", err) + } + var seen []struct { + id int64 + scope string + basis string + mode string + sortOrd int + } + for rows.Next() { + var got struct { + id int64 + scope string + basis string + mode string + sortOrd int + } + if err := rows.Scan(&got.id, &got.scope, &got.basis, &got.mode, &got.sortOrd); err != nil { + _ = rows.Close() + t.Fatalf("扫描通道: %v", err) + } + seen = append(seen, got) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + t.Fatalf("遍历通道: %v", err) + } + _ = rows.Close() + if len(seen) != len(want) { + t.Fatalf("通道数 = %d,期望 %d:%+v", len(seen), len(want), seen) + } + for i := range want { + if seen[i] != want[i] { + t.Fatalf("第 %d 条通道 = %+v,期望 %+v", i+1, seen[i], want[i]) + } + } + + // 子表名次一行不少、内容一字不差。 + var ranks int + if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&ranks); err != nil { + t.Fatalf("统计通道名次: %v", err) + } + if ranks != 2 { + t.Fatalf("channel_ranks 行数 = %d,期望 2(重建父表时被级联清空了?)", ranks) + } + var subjectKey, basis, scope string + var rank int + var points, resultValue float64 + if err := db.QueryRow(`SELECT subject_key, basis, scope, rank, points, result_value FROM channel_ranks + WHERE channel_id = 2 AND rank = 1`). + Scan(&subjectKey, &basis, &scope, &rank, &points, &resultValue); err != nil { + t.Fatalf("读取保留下来的通道名次: %v", err) + } + if subjectKey != "t:1" || basis != "aggregate" || scope != "team" || rank != 1 || points != 15 || resultValue != 12.5 { + t.Fatalf("通道名次被改动了:%s/%s/%s rank=%d points=%v value=%v", subjectKey, basis, scope, rank, points, resultValue) + } + // 外键仍然指着同一张表:级联删除还要照旧生效。 + var channelRanksDDL string + if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_ranks'`).Scan(&channelRanksDDL); err != nil { + t.Fatalf("读取 channel_ranks 定义: %v", err) + } + if !strings.Contains(channelRanksDDL, "REFERENCES event_score_channels(id) ON DELETE CASCADE") { + t.Fatalf("channel_ranks 对通道的外键定义被改动了:\n%s", channelRanksDDL) + } + + // 取值放开:worst 能写进去,拼错的仍被挡下。 + if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err != nil { + t.Fatalf("升级后应能写入 aggregate_mode = 'worst': %v", err) + } + if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) + VALUES (1, 'athlete', 'manual', 1, 4, 'biggest')`); err == nil { + t.Fatal("非法 aggregate_mode 应被 CHECK 拒绝") + } + // 新增通道时自增号接着往下走(重建表不能把 id 序列重置成 1,那会与既有名次撞号)。 + if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) + VALUES (1, 'athlete', 'manual', 1, 4)`); err != nil { + t.Fatalf("新增通道: %v", err) + } + var maxID int64 + if err := db.QueryRow(`SELECT MAX(id) FROM event_score_channels`).Scan(&maxID); err != nil { + t.Fatalf("读取通道最大 id: %v", err) + } + if maxID != 4 { + t.Fatalf("新增通道的 id = %d,期望 4(自增号应接着老数据往下走)", maxID) + } + + // 幂等:再跑一次不报错、登记仍只有一条。 + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("重复升级应无副作用: %v", err) + } + var registered, total int + if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE name = '0027_channel_aggregate_mode_worst.sql'`).Scan(®istered); err != nil { + t.Fatalf("查询迁移登记: %v", err) + } + if registered != 1 { + t.Fatalf("迁移登记数 = %d,期望 1", registered) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM event_score_channels`).Scan(&total); err != nil { + t.Fatalf("统计通道: %v", err) + } + if total != 4 { + t.Fatalf("重复升级后通道数 = %d,期望 4", total) + } +} + +// 重建父表最容易出的事故是「外键与级联被改坏」:空子表也要能照常写名次、照常级联删除。 +func TestMigration0027ForeignKeyStillCascades(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0027") + + stmts := []string{ + `INSERT INTO sports(code, name) VALUES ('SP9', '测试')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES ((SELECT id FROM sports WHERE code = 'SP9'), 'X', 'x', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, + `INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) + VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`, + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points) + VALUES (1, 1, 1, 'aggregate', 'team', 't:1', 1, 15)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造老库数据 %q: %v", s, err) + } + } + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("升级赛事库: %v", err) + } + + // 新口径的通道能照常落库。 + if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) + VALUES (1, 'athlete', 'aggregate', 1, 2, 'worst')`); err != nil { + t.Fatalf("写入 worst 通道: %v", err) + } + // 级联删除照旧:删掉通道 1,挂它的名次跟着没。 + if _, err := db.Exec(`DELETE FROM event_score_channels WHERE id = 1`); err != nil { + t.Fatalf("删除通道: %v", err) + } + var left int + if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks WHERE channel_id = 1`).Scan(&left); err != nil { + t.Fatalf("统计级联后的名次: %v", err) + } + if left != 0 { + t.Fatalf("重建后级联删除失效:通道删了还留着 %d 条名次", left) + } +} diff --git a/web/src/admin/api/scoring.ts b/web/src/admin/api/scoring.ts index 1134441..777a293 100644 --- a/web/src/admin/api/scoring.ts +++ b/web/src/admin/api/scoring.ts @@ -298,31 +298,40 @@ function sortManual(cands: Candidate[]): void { } /** - * 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和),再按汇总值排名 + * 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和 / worst 取队内最差),再按汇总值排名 * (scoring.go scoreByAggregate)。 * * 汇总口径来自通道的 aggregateMode(缺省 avg);平均值分母由 avgMode 决定: * valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,没成绩的按 0 计入。 * sum 只累加有效成绩,avgMode 在这里只决定「一个有效成绩都没有的主体算不算 0 分参与排名」。 + * worst 没有分母,avgMode 在这里**不起作用**:一个有效成绩都没有的主体一律不参与排名 + * (与 avg 的 valid 同一条规则)——不能把「没成绩」当成 0 去比:asc 里 0 最快、desc 里 0 最差, + * 两边都会让「没人完赛」的队白拿名次。 * scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。 */ function aggregateCandidates( rows: readonly ScorableRow[], channel: ScoreChannel, avgMode: AvgMode, + compare: CompareMode, ): Candidate[] { - const sumMode = normalizeAggregateMode(channel.aggregateMode) === 'sum' + const mode = normalizeAggregateMode(channel.aggregateMode) + const sumMode = mode === 'sum' + const worstMode = mode === 'worst' const order: number[] = [] - const buckets = new Map() + const buckets = new Map< + number, + { row: ScorableRow; total: number; valid: number; registered: number; worst: number | null } + >() for (const row of rows) { const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0) if (subject === 0) continue const value = row.status === 'ok' ? row.resultValue : null let bucket = buckets.get(subject) if (bucket === undefined) { - // valid 口径下没有任何有效成绩的主体不参与排名(两种汇总口径都一样)。 + // valid 口径下没有任何有效成绩的主体不参与排名(三种汇总口径都一样)。 if (value === null && avgMode !== 'all') continue - bucket = { row, total: 0, valid: 0, registered: 0 } + bucket = { row, total: 0, valid: 0, registered: 0, worst: null } buckets.set(subject, bucket) order.push(subject) } @@ -330,25 +339,44 @@ function aggregateCandidates( if (value !== null) { bucket.total += value bucket.valid += 1 + if (bucket.worst === null || worseThan(value, bucket.worst, compare)) bucket.worst = value } } - return order.map((subject) => { + const out: Candidate[] = [] + for (const subject of order) { const bucket = buckets.get(subject) as { row: ScorableRow total: number valid: number registered: number + worst: number | null } + if (worstMode && bucket.worst === null) continue const count = avgMode === 'all' ? bucket.registered : bucket.valid - const value = sumMode ? bucket.total : count > 0 ? bucket.total / count : 0 - return { - row: bucket.row, - value, - rankIn: 0, - subject, - teamId: bucket.row.teamId, - } - }) + const value = worstMode + ? (bucket.worst as number) + : sumMode + ? bucket.total + : count > 0 + ? bucket.total / count + : 0 + out.push({ row: bucket.row, value, rankIn: 0, subject, teamId: bucket.row.teamId }) + } + return out +} + +/** + * 判断 a 是不是比 b 更「差」——**方向由小项的 compare 决定,这是最容易写反的一处** + * (scoring.go 的 worseValue 同款): + * + * asc (越小越好,如时间):更差 = 更大 →「队内最差」是**最后一名冲线**的那个; + * desc(越大越好,如次数):更差 = 更小 →「队内最差」是**做得最少**的那个。 + * + * 写死成 min 或 max 都会让一半的项目算反(越野会取到跑得最快的人、引体向上会取到做得最多的人), + * 而两种错法读起来都很正常,所以预览与后端各有 asc / desc 的双向用例钉着。 + */ +function worseThan(a: number, b: number, compare: CompareMode): boolean { + return compare === 'desc' ? a < b : a > b } function scoreChannel( @@ -368,7 +396,7 @@ function scoreChannel( let groups: TieGroup[] if (basis === 'aggregate') { - const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode)) + const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode), event.compare) sortByValue(aggregated, event.compare) groups = groupByValue(aggregated) } else if (basis === 'manual') { diff --git a/web/src/admin/components/baseData/ChannelsSection.vue b/web/src/admin/components/baseData/ChannelsSection.vue index afd5687..d1cea08 100644 --- a/web/src/admin/components/baseData/ChannelsSection.vue +++ b/web/src/admin/components/baseData/ChannelsSection.vue @@ -33,14 +33,32 @@ const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [ ] /** - * 聚合口径的两个选项:现场在「按成员平均」与「按成员总和」之间快速切。 + * 聚合口径的三个选项:现场在「按成员平均」「按成员总和」「按队内最低分」之间快速切。 * 与 basis=aggregate 配套使用,点一下即选中(不做二次确认,现场要的是快)。 + * + * 「队内最低分」= 队内最差的那一份成绩(不是名字里的「最低」那么简单):升序项目(时间,越小越好) + * 取的是**跑得最慢的那个人**(最后一名冲线),降序项目(次数,越大越好)取的是**做得最少的那个**, + * 方向跟着小项的「比较方向」自动走 —— 所以提示里必须把两种方向都写出来,只写「最低」会让人按字面理解。 */ const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint: string }> = [ { value: 'avg', label: '平均', hint: '团体成绩 = 该单位成员成绩的平均值(缺省)' }, { value: 'sum', label: '总和', hint: '团体成绩 = 该单位成员成绩的总和' }, + { + value: 'worst', + label: '队内最低分', + hint: + '团体成绩 = 该单位成员里最差的那一份:升序项目(时间)取最后一名冲线的那个人,' + + '降序项目(次数)取做得最少的那个(方向跟着小项的「比较方向」走)', + }, ] +/** 行内小结:一句话说清这一行的团体成绩是怎么来的,现场核对时照这一行读。 */ +const AGGREGATE_MODE_SUMMARIES: Record = { + avg: '团体成绩 = 该单位成员成绩的平均值', + sum: '团体成绩 = 该单位成员成绩的总和', + worst: '团体成绩 = 该单位成员里最差的那一份(时间项目取最后一名冲线的人 / 次数项目取做得最少的那个)', +} + interface RowError { scope?: string basis?: string @@ -231,11 +249,14 @@ async function save(): Promise {
- 团体分口径:名次来源选「按队内平均」的通道,可在「聚合口径」一列点平均 / 总和直接切换—— - 「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和;切换后点「保存全部」生效, - 该小项立即重算。两种口径的排名在各队人数相同时一致,人数不同时可能不同。 + 团体分口径:名次来源选「按队内平均」的通道,可在「聚合口径」一列点平均 / 总和 / 队内最低分直接切换—— + 「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和, + 「队内最低分」= 该单位成员里最差的那一份成绩(时间项目取最后一名冲线的那个人,次数项目取做得最少的那个, + 方向跟着小项的「比较方向」自动走——越野 / 行军这类项目要的就是它);切换后点「保存全部」生效, + 该小项立即重算。平均与总和两种口径的排名在各队人数相同时一致,人数不同时可能不同。 平均值口径(只算有效成绩 / 全员计入)仍按小项的「平均值口径」设置,求和时它只决定 - 「一个人都没成绩的单位算不算 0 分参与排名」。 + 「一个人都没成绩的单位算不算 0 分参与排名」,「队内最低分」下它不起作用: + 一个人都没成绩的单位一律不参与排名(没人冲线就没有「最后一名」可言)。
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。 新增行的默认值按小项类型来:「个人+团体」的个人通道默认「不计入」(与规则包模板一致), @@ -271,15 +292,15 @@ async function save(): Promise { - 团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }} + {{ AGGREGATE_MODE_SUMMARIES[row.aggregateMode] }} {{ errors[row.key]?.basis }} {{ errors[row.key]?.scope }}