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` 两张)。
This commit is contained in:
1 parent
7a3e4ad365
commit
99c91f99f7
57 files changed
+4903
-94
No files matched your search
@@ -525,6 +525,10 @@ type entryImportOutcome struct {
|
||||
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
|
||||
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
|
||||
StatusChanged bool
|
||||
// MembersChanged 表示这次更新改动了报名的**队员关联**(换人、加人)。
|
||||
// 队员决定谁拿到个人分与个人奖牌,所以它必须重算——与 status 跨界同一类「谁还算数」的改动,
|
||||
// 而与姓名 / 号码布 / 队伍 / 批次那类纯展示字段不同(那些改了不重算)。
|
||||
MembersChanged bool
|
||||
// MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。
|
||||
// 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见
|
||||
// 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。
|
||||
@@ -693,6 +697,9 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
|
||||
if err := replaceEntryShares(ctx, s.ev, newID, in.TeamID, in.TeamShares); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
if _, err := s.syncImportedEntryMembers(ctx, newID, in); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}
|
||||
if len(moved) > 0 {
|
||||
// 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分,
|
||||
@@ -722,14 +729,82 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
|
||||
if err := replaceEntryShares(ctx, s.ev, existing, in.TeamID, in.TeamShares); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
membersChanged, err := s.syncImportedEntryMembers(ctx, existing, in)
|
||||
if err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
return entryImportOutcome{
|
||||
EventID: in.EventID,
|
||||
CategoryID: in.CategoryID,
|
||||
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
|
||||
EventID: in.EventID,
|
||||
CategoryID: in.CategoryID,
|
||||
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
|
||||
MembersChanged: membersChanged,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// syncImportedEntryMembers 把导入这一行认出来的队员整组写到报名上(新增与更新两条路都要调),
|
||||
// 返回这次有没有真的改动队员关联。
|
||||
//
|
||||
// **一格认出了人才重写**:认出了号码布的一格就是这条报名的完整队员名单 —— 整组重写(先删后插,
|
||||
// 与份额同一口径),重传同一行幂等,删掉一个号码布那个人就跟着从队里出去。
|
||||
// 一个号码布都认不出来(整列留空、只写姓名、或号码布认不出人)时这一格**只当文字**,
|
||||
// 已有的队员关联一行都不动:
|
||||
// - 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移按「号码布优先、其次姓名+单位唯一」回填
|
||||
// 出来的队员,不能因为重传一份没有号码布的名单就被悄悄清掉(那等于让人无声无息掉出个人积分榜);
|
||||
// - 界面上勾好的队员也不能被「用模板重导一遍」冲掉(模板的队员名单列本来就是空的);
|
||||
// - 要清空队员走界面(PUT /api/entries/{id}/athletes 传空数组),那是明确表达出来的意图。
|
||||
// 这条与「认不上就只当文字、别报错」是同一句话:认不出的格子对这条报名不产生任何影响。
|
||||
//
|
||||
// 返回值只比**人的集合**,不比顺序:队员顺序只影响「队里有谁」的排列,名次与积分不看它
|
||||
// (见 entry_athletes 表注释),所以只是换了个写法重传不该把这一组白算一遍。
|
||||
// 关联真的变了就必须重算——队员决定谁拿到个人分与个人奖牌,不重算就是「名单里换人了、榜上还是旧人」。
|
||||
//
|
||||
// 带运动员的报名一个字节都不写:它的身份就是那一个运动员,队员只属于「只报单位」的团体报名
|
||||
// (与 PUT /api/entries/{id}/athletes 的同一条口径)。
|
||||
func (s *server) syncImportedEntryMembers(ctx context.Context, entryID int64, in entryDTO) (bool, error) {
|
||||
if in.AthleteID != nil || len(in.Athletes) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
before, err := entryAthleteIDsQ(ctx, s.ev, entryID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
after := entryAthleteIDs(in.Athletes)
|
||||
if err := replaceEntryAthletes(ctx, s.ev, entryID, in.Athletes); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !slices.Equal(before, after), nil
|
||||
}
|
||||
|
||||
// entryAthleteIDsQ 读一条报名当前的队员 id(升序),用于判断这次导入有没有改动关联。
|
||||
func entryAthleteIDsQ(ctx context.Context, q queryer, entryID int64) ([]int64, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT athlete_id FROM entry_athletes WHERE entry_id = ? ORDER BY athlete_id`, entryID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := []int64{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// entryAthleteIDs 把要写进去的队员摊成升序的 id 列表(与 entryAthleteIDsQ 同一种形状好比较)。
|
||||
func entryAthleteIDs(athletes []entryAthleteDTO) []int64 {
|
||||
out := make([]int64, 0, len(athletes))
|
||||
for _, a := range athletes {
|
||||
out = append(out, a.AthleteID)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。
|
||||
//
|
||||
// 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串,
|
||||
|
||||
Reference in new issue
Block a user