Files
Scoreboard/server/internal/httpapi/entry_athletes_test.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

250 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住「团体报名的队员身份」(devdocs/20)从报到榜的完整链路:
// - 一条团体报名可以关联多个运动员(队员),0 个 = 只报单位(与升级前一致);
// - 队员各拿**一份完整的**个人分、各一枚个人奖牌(不是按份额拆,别与联队抄混);
// - **单位维度一份都不多**:单位总分与单位奖牌仍只算这条报名一次,
// 由通道开关 count_to_team / count_to_team_medals 控制(双打现场是都不计入)。
// seedTeamEntryAthleteChannel 造一个「双打」小项:团体项目 + 一个按成绩排名的**个人**通道
// (现场的双打就是给团体报名配个人通道,分才上得了个人积分榜)。
// 两个单位开关由调用方给:现场是「都不计入」(0/0),默认是都计入(1/1)。
func seedTeamEntryAthleteChannel(t *testing.T, env *testEnv, sportID int64, countToTeam, countToTeamMedals int) (eventID, categoryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, sportID)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order,
count_to_team, count_to_team_medals)
VALUES (?, 'athlete', 'result', ?, 0, ?, ?)`,
eventID, profileIDByCode(t, env, "individual_top6"), countToTeam, countToTeamMedals)
return eventID, categoryID
}
// doublesImportRow 拼一行团体报名(不填姓名 = 团体行,队员名单写两个人名)。
func doublesImportRow(eventCode, category, teamCode string) string {
return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/王五"}, ",")
}
// putEntryAthletes 调 PUT /api/entries/{id}/athletes 整组替换队员。
func putEntryAthletes(t *testing.T, env *testEnv, client *http.Client, entryID int64, athleteIDs ...int64) (int, envelope) {
t.Helper()
items := make([]map[string]any, 0, len(athleteIDs))
for _, id := range athleteIDs {
items = append(items, map[string]any{"athleteId": id})
}
return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": items}, true)
}
// entryAthleteRows 把某小项的队员关联表导成稳定文本。
func entryAthleteRows(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(
`SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order
FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id
JOIN entries e ON e.id = ea.entry_id
WHERE e.event_id = %d
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID))
}
// athleteBoard 是 GET /api/standings/athletes 的原始读数。
func athleteBoard(t *testing.T, env *testEnv, client *http.Client) []map[string]any {
t.Helper()
status, resp := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读个人积分榜应 200,得到 %d %+v", status, resp.Error)
}
return decodeData[[]map[string]any](t, resp)
}
// athleteRow 取个人榜上某个运动员那一行(不在榜上直接失败)。
func athleteRow(t *testing.T, env *testEnv, client *http.Client, athleteID int64) map[string]any {
t.Helper()
for _, row := range athleteBoard(t, env, client) {
if row["athleteId"] == float64(athleteID) {
return row
}
}
t.Fatalf("个人积分榜上没有运动员 %d:%+v", athleteID, athleteBoard(t, env, client))
return nil
}
// assertAthlete 断言个人榜上某人的总分与金银铜。
func assertAthlete(t *testing.T, env *testEnv, client *http.Client, athleteID int64, points float64, gold, silver, bronze int) {
t.Helper()
row := athleteRow(t, env, client, athleteID)
if got := row["totalPoints"].(float64); got != points {
t.Errorf("运动员 %d 个人总分 = %v,期望 %v(整行:%+v)", athleteID, got, points, row)
}
for name, want := range map[string]int{"gold": gold, "silver": silver, "bronze": bronze} {
if got := row[name].(float64); got != float64(want) {
t.Errorf("运动员 %d 的 %s = %v,期望 %d(整行:%+v)", athleteID, name, got, want, row)
}
}
}
// 双打:一条团体报名 + 两个队员 → 每人各拿一份完整的个人分、各一枚个人奖牌;
// 单位维度仍然只算这条报名一次(10 分、1 枚金牌,不是 30 分、3 枚)。
func TestDoublesEntryMembersGetOwnIndividualScore(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
// 双打是同一个单位的两个人:种子里另一个运动员在 B 单位,另外补一个 A 单位的人。
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
// 队员关联:这就是本单新增的写入路径(界面勾了谁,就把它留在报名上)。
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK || !resp.OK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
entryID, ids.athleteA, entryID, partner)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("队员关联落库不对:\n得到 %s期望 %s", got, want)
}
publishResults(t, env, client, eventID, "doubles-members",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 个人榜:两个队员各一份满分(第 1 名 10 分)+ 各一枚金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
// 单位维度:仍然只算这条报名一次 —— 既不是两份 20 分,也不是三枚金牌。
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
}
// 现场的双打口径:通道两个单位开关都关掉 → 单位 0 分 0 牌,但队员的个人分与个人奖牌照发。
func TestDoublesEntryMembersWithTeamFlagsOff(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
publishResults(t, env, client, eventID, "doubles-flags-off",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 单位总分与单位奖牌都不受队员关联影响(双打现场就是「都不计入」)。
assertStanding(t, env, client, ids.teamA, 0, 0, 0, 0)
// 个人分与个人奖牌照发:一人一份满分、一人一枚金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
}
// 报名读数要带上队员(界面据此显示「队里有谁」,并能改)。
func TestEntryReadsIncludeAthletes(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
env.exec(t, `UPDATE athletes SET bib = 'A001' WHERE id = ?`, ids.athleteA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error)
}
board := decodeData[entryBoard](t, resp)
if len(board.Entries) != 1 {
t.Fatalf("应有一条报名,得到 %+v", board.Entries)
}
athletes, _ := board.Entries[0]["athletes"].([]any)
if len(athletes) != 2 {
t.Fatalf("报名读数应带两个队员,得到 %+v", board.Entries[0]["athletes"])
}
first, _ := athletes[0].(map[string]any)
if first["athleteId"] != float64(ids.athleteA) || first["name"] != "张伟" || first["bib"] != "A001" {
t.Fatalf("队员读数应带 athleteId / 姓名 / 号码布,得到 %+v", first)
}
}
// 队员改动要能拦住会算错的写法,并且一律不落库。
func TestUpdateEntryAthletesRejectsBadPayloads(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA); status != http.StatusOK {
t.Fatalf("先设一个队员应 200,得到 %d %+v", status, resp.Error)
}
baseline := entryAthleteRows(t, env, eventID)
// 个人报名(带运动员)不该有队员:它的身份就是那一个运动员。
personalEntryID := ids.entryA
cases := []struct {
name string
path string
body map[string]any
want string
}{
{"运动员不存在", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"athleteId": 999999}}}, "不存在"},
{"缺 athleteId", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"name": "张三"}}}, "athleteId"},
{"同一个人写两次", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}, {"athleteId": ids.athleteA}}}, "两次"},
{"个人报名不能挂队员", fmt.Sprintf("/api/entries/%d/athletes", personalEntryID),
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "个人报名"},
{"报名不存在", "/api/entries/999999/athletes",
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "不存在"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, resp := env.do(client, http.MethodPut, tc.path, tc.body, true)
wantStatus := http.StatusBadRequest
if tc.name == "报名不存在" {
wantStatus = http.StatusNotFound
}
if status != wantStatus {
t.Fatalf("应 %d,得到 %d %+v", wantStatus, status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) {
t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != baseline {
t.Fatalf("被拒的请求改动了队员表:\n之前 %s之后 %s", baseline, got)
}
})
}
// 空数组是合法值:清空队员 = 只报单位(与升级前一致)。
if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{}}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("清空队员应 200,得到 %d %+v", status, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("清空后队员表应为空,得到 %s", got)
}
}