## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
250 lines
12 KiB
Go
250 lines
12 KiB
Go
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)
|
||
}
|
||
}
|