feat(scoring): 新增「队内最差(最后一名冲线)」聚合口径;版本 dev_0.5.11

## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。

## 口径(这里最容易写反,所以定死)
新增 `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` 两张)。
This commit is contained in:
AskaEth committed 2026-09-16 23:55:12 +08:00
1 parent a85115c96e
commit 48208f395b
30 files changed
+1394 -85

No files matched your search

@@ -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)
}
}
+3 -2
View File
@@ -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。响应里始终是具体值。
+6 -8
View File
@@ -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)
+3 -4
View File
@@ -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)