## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
243 lines
10 KiB
Go
243 lines
10 KiB
Go
package event
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
|
)
|
|
|
|
// 0026:团体报名的「队员身份」(entry_athletes)。
|
|
//
|
|
// 四条底线:
|
|
// - 老数据零变化:既有表(entries / results / score_ledger / channel_ranks)一行一列都不改,
|
|
// 新增的只有这张关联表;
|
|
// - 回填**以号码布为准**、其次「姓名 + 单位」,且只在「同单位同名唯一」时才认,重名与查无此人
|
|
// 一律留空(不硬猜:猜错就是把某人的分记到另一个人头上);
|
|
// - 只碰纯团体报名(athlete_id IS NULL):带运动员的报名自己就是那个人的身份;
|
|
// - 幂等:重复执行迁移(含把这条 SQL 重放一遍)不报错、不重复回填、不覆盖人工改过的关联。
|
|
func TestMigration0026EntryAthletes(t *testing.T) {
|
|
db := openEventDB(t)
|
|
applyBefore(t, db, "0026") // 回到「升级前」:库结构到 0025 为止
|
|
|
|
stmts := []string{
|
|
`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`,
|
|
`INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`,
|
|
// 号码布是运动员的唯一标识(0014 上有部分唯一索引)。
|
|
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('张伟', '男', 1, 'A001')`,
|
|
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A002')`,
|
|
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`,
|
|
// 同单位两个同名的人:回填不许猜(下面这条报名里写的「李强」认不出是哪个)。
|
|
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`,
|
|
// 另一个单位的运动员:号码布能认到他(号码布不分单位),姓名+单位认不到。
|
|
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '女', 2, 'B001')`,
|
|
`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, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`,
|
|
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
|
|
// 1) 姓名 + 单位都认得上:张伟 + 王五(顺序也要留住)。
|
|
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
|
VALUES (1, 1, 1, '["张伟","王五"]', 'registered')`,
|
|
// 2) 号码布优先:写的是 A002(同一个单位的王五)与 B001(另一个单位的赵六)。
|
|
// 用队伍名与上一条区分开(一个单位在一个组别只能有一条不分队的团体报名,见 0025)。
|
|
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
|
|
VALUES (1, 1, 1, '["A002","B001"]', 'registered', '二队')`,
|
|
// 3) 重名(同单位两个李强):不认。
|
|
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
|
VALUES (1, 1, 2, '["李强"]', 'registered')`,
|
|
// 4) 查无此人:不认。
|
|
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
|
|
VALUES (1, 1, 2, '["查无此人"]', 'registered', '二队')`,
|
|
// 5) 带运动员的报名:本来就有身份,不回填。
|
|
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
|
|
VALUES (1, 1, 1, 1, '["张伟"]', 'registered')`,
|
|
// 6) member_names 是坏 JSON(历史上被手工写过非 JSON 文本):按「没有名单」处理,迁移不许失败。
|
|
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
|
|
VALUES (1, 1, 2, '张伟/李强', 'registered', '三队')`,
|
|
}
|
|
for _, s := range stmts {
|
|
if _, err := db.Exec(s); err != nil {
|
|
t.Fatalf("造老库数据 %q: %v", s, err)
|
|
}
|
|
}
|
|
|
|
// 升级前:这张表还不该存在。
|
|
if _, err := db.Exec(`SELECT count(*) FROM entry_athletes`); err == nil {
|
|
t.Fatal("升级前不应存在 entry_athletes 表")
|
|
} else if !strings.Contains(err.Error(), "entry_athletes") {
|
|
t.Fatalf("查询报错应点明缺表 entry_athletes,实际 %v", err)
|
|
}
|
|
|
|
// 既有表在升级前的原始内容:升级后必须逐字节一致。
|
|
untouched := []string{
|
|
`SELECT id, event_id, category_id, team_id, COALESCE(athlete_id, 0), COALESCE(member_names, ''),
|
|
COALESCE(bib, ''), COALESCE(seed, ''), COALESCE(status, ''), squad
|
|
FROM entries ORDER BY id`,
|
|
}
|
|
before := make([]string, len(untouched))
|
|
for i, q := range untouched {
|
|
before[i] = tableText(t, db, q)
|
|
}
|
|
|
|
if err := store.MigrateEvent(db); err != nil {
|
|
t.Fatalf("升级赛事库: %v", err)
|
|
}
|
|
|
|
// 回填结果:只有「认得出」的两种情形落行,顺序按 member_names 的书写顺序。
|
|
want := map[int64][]string{
|
|
1: {"1:0", "2:1"}, // 张伟、王五
|
|
2: {"2:0", "5:1"}, // 号码布 A002 → 王五;号码布 B001 → 赵六(另一个单位也认)
|
|
3: {}, // 重名不猜
|
|
4: {}, // 查无此人不猜
|
|
5: {}, // 带运动员的报名不回填
|
|
6: {}, // 坏 JSON:按没有名单处理
|
|
}
|
|
got := entryAthletesText(t, db)
|
|
for entryID, wantRows := range want {
|
|
if strings.Join(got[entryID], ",") != strings.Join(wantRows, ",") {
|
|
t.Errorf("报名 %d 回填出 %v,期望 %v", entryID, got[entryID], wantRows)
|
|
}
|
|
}
|
|
|
|
// 老数据零变化:既有表的每一行每一个字段都没动。
|
|
for i, q := range untouched {
|
|
if after := tableText(t, db, q); after != before[i] {
|
|
t.Fatalf("升级改动了既有数据(第 %d 条对拍):\n之前\n%s之后\n%s", i+1, before[i], after)
|
|
}
|
|
}
|
|
|
|
// 表结构:主键 (entry_id, athlete_id) 保证同一条报名里同一个人只有一行。
|
|
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 9)`); err == nil {
|
|
t.Error("同一条报名里同一个运动员重复应被主键拒绝")
|
|
}
|
|
// 外键:报名 / 人员不存在都写不进去。
|
|
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (999, 1)`); err == nil {
|
|
t.Error("entry_id 外键应拦住不存在的报名")
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (1, 999)`); err == nil {
|
|
t.Error("athlete_id 外键应拦住不存在的人员")
|
|
}
|
|
|
|
// 幂等:再跑一次迁移,内容不变。
|
|
if err := store.MigrateEvent(db); err != nil {
|
|
t.Fatalf("重复升级应无副作用: %v", err)
|
|
}
|
|
if after := entryAthletesText(t, db); fmt.Sprint(after) != fmt.Sprint(got) {
|
|
t.Fatalf("重复迁移改动了队员关联:\n之前 %v\n之后 %v", got, after)
|
|
}
|
|
// 迁移被重放(schema_migrations 少一行被补跑)时:已有的关联(含人工确认过的)不被覆盖、
|
|
// 不产生重复行(INSERT OR IGNORE 的口径与 0024 的份额回填一致)。
|
|
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 7)`); err != nil {
|
|
t.Fatalf("人工补一条关联: %v", err)
|
|
}
|
|
if _, err := db.Exec(readMigration(t, "0026")); err != nil {
|
|
t.Fatalf("重放 0026: %v", err)
|
|
}
|
|
replayed := entryAthletesText(t, db)
|
|
if rows := replayed[3]; strings.Join(rows, ",") != "3:7" {
|
|
t.Errorf("重放改动了人工确认的关联(顺序必须原样保留):%v", rows)
|
|
}
|
|
if rows := replayed[1]; strings.Join(rows, ",") != "1:0,2:1" {
|
|
t.Errorf("重放后报名 1 的关联应保持不变、也不重复:%v", rows)
|
|
}
|
|
|
|
// 级联:报名删掉带走它的队员行;人员被彻底删除也带走(purgeAthlete 走的就是这条外键)。
|
|
if _, err := db.Exec(`DELETE FROM entries WHERE id = 2`); err != nil {
|
|
t.Fatalf("删报名: %v", err)
|
|
}
|
|
if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE entry_id = 2`); got != 0 {
|
|
t.Errorf("报名删掉后队员行应级联清空,还剩 %d 条", got)
|
|
}
|
|
if _, err := db.Exec(`DELETE FROM athletes WHERE id = 5`); err != nil {
|
|
t.Fatalf("删人员: %v", err)
|
|
}
|
|
if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE athlete_id = 5`); got != 0 {
|
|
t.Errorf("人员删掉后队员行应级联清空,还剩 %d 条", got)
|
|
}
|
|
|
|
// 外键悬挂检查。
|
|
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
|
|
if err != nil {
|
|
t.Fatalf("外键检查: %v", err)
|
|
}
|
|
defer func() { _ = fkRows.Close() }()
|
|
if fkRows.Next() {
|
|
t.Error("迁移后存在外键悬挂")
|
|
}
|
|
|
|
// 迁移登记数对得上(这条迁移被记账一次)。
|
|
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
|
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
|
}
|
|
}
|
|
|
|
// entryAthletesText 把队员关联按报名归组导成 "%d:%d"(运动员 id:顺序)的稳定文本。
|
|
func entryAthletesText(t *testing.T, db *sql.DB) map[int64][]string {
|
|
t.Helper()
|
|
out := make(map[int64][]string)
|
|
rows, err := db.Query(`SELECT entry_id, athlete_id, sort_order FROM entry_athletes
|
|
ORDER BY entry_id, sort_order, athlete_id`)
|
|
if err != nil {
|
|
t.Fatalf("读队员关联: %v", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
for rows.Next() {
|
|
var (
|
|
entryID, athleteID, order int64
|
|
)
|
|
if err := rows.Scan(&entryID, &athleteID, &order); err != nil {
|
|
t.Fatalf("扫描队员关联: %v", err)
|
|
}
|
|
out[entryID] = append(out[entryID], fmt.Sprintf("%d:%d", athleteID, order))
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("遍历队员关联: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// tableText 把一条查询的全部行拼成一份对拍文本(列之间用 | 连)。
|
|
func tableText(t *testing.T, db *sql.DB, query string) string {
|
|
t.Helper()
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
t.Fatalf("查询 %q: %v", query, err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
cols, err := rows.Columns()
|
|
if err != nil {
|
|
t.Fatalf("读列名: %v", err)
|
|
}
|
|
var b strings.Builder
|
|
for rows.Next() {
|
|
cells := make([]any, len(cols))
|
|
ptrs := make([]any, len(cols))
|
|
for i := range cells {
|
|
ptrs[i] = &cells[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
t.Fatalf("扫描 %q: %v", query, err)
|
|
}
|
|
for i, c := range cells {
|
|
if i > 0 {
|
|
b.WriteString("|")
|
|
}
|
|
switch v := c.(type) {
|
|
case nil:
|
|
b.WriteString("<nil>")
|
|
case []byte:
|
|
b.Write(v)
|
|
default:
|
|
fmt.Fprintf(&b, "%v", v)
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("遍历 %q: %v", query, err)
|
|
}
|
|
return b.String()
|
|
}
|