## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
281 lines
11 KiB
Go
281 lines
11 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"net/http"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||
)
|
||
|
||
// 本文件是「团体报名的队员」(devdocs/20)的读写。
|
||
//
|
||
// 表达方式:**队员是报名上的人员关联**(entry_athletes),不是新的一条报名,也不改任何唯一键。
|
||
// 与联队(entry_team_shares)对称而语义相反:那边是「一条报名 → 多个单位、按权重**拆**分」,
|
||
// 这边是「一条报名 → 多个运动员、每人各拿**整份**分」(个人奖牌也是每人各一枚)。
|
||
//
|
||
// 写侧只认**运动员 id**:把姓名 / 号码布认成人是导入与界面的事(导入口径见 devdocs/20 §三:
|
||
// 队员名单列可以填号码布),这里只负责把已经认准的身份留在报名上,不做二次猜测。
|
||
|
||
// maxEntryAthletes 是单条报名的队员上限。现场最大的团体项目(篮球、拔河)也就十几人,
|
||
// 给到 30 已经远超需要;上限的作用是拦住「一整格都是 id」这类误填——每个队员都会产出
|
||
// 一条名次与一条个人流水,放进来多少就落多少。
|
||
const maxEntryAthletes = 30
|
||
|
||
// entryAthletesReady 判断队员表在不在。
|
||
//
|
||
// 与 entryTeamSharesReady 同一套:读路径要能读「还没跑到 0026 的库」(迁移在服务启动时补跑,
|
||
// 但榜单可能在那之前被读一次)。表不在 = 库里没有任何队员关联 = 只报单位,正是老数据本来
|
||
// 就该得到的结果,所以按「没有队员」处理是**精确**的,不是容错降级。
|
||
func entryAthletesReady(ctx context.Context, q queryer) (bool, error) {
|
||
var n int
|
||
if err := q.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil {
|
||
return false, err
|
||
}
|
||
return n != 0, nil
|
||
}
|
||
|
||
// loadEntryAthletesQ 读一条 SQL 里给定范围内报名的队员,按 entry_id 归组,供引擎使用。
|
||
// eventID 为 0 表示全库(导出与快照用,与它们读 channel_ranks 的范围一致)。
|
||
//
|
||
// 排序按 (sort_order, athlete_id):写侧按请求给的顺序编号,展示顺序稳定;
|
||
// 引擎不依赖顺序(每个队员各拿一整份分,与先后无关)。
|
||
func loadEntryAthletesQ(ctx context.Context, q queryer, eventID int64) (map[int64][]domain.EntryAthlete, error) {
|
||
ready, err := entryAthletesReady(ctx, q)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
if !ready {
|
||
return nil, nil
|
||
}
|
||
query := `SELECT ea.entry_id, ea.athlete_id, COALESCE(a.name, '')
|
||
FROM entry_athletes ea
|
||
JOIN athletes a ON a.id = ea.athlete_id`
|
||
args := []any{}
|
||
if eventID != 0 {
|
||
query += ` JOIN entries e ON e.id = ea.entry_id WHERE e.event_id = ?`
|
||
args = append(args, eventID)
|
||
}
|
||
query += ` ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`
|
||
|
||
rows, err := q.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make(map[int64][]domain.EntryAthlete)
|
||
for rows.Next() {
|
||
var (
|
||
entryID int64
|
||
m domain.EntryAthlete
|
||
)
|
||
if err := rows.Scan(&entryID, &m.AthleteID, &m.Name); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out[entryID] = append(out[entryID], m)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// loadEntryAthletesDTOs 读某小项全部报名的队员读数形态:带上号码布(运动员的唯一标识),
|
||
// 前端据此显示「姓名(号码布)」并让人对得上下一次导入写的是谁。
|
||
func (s *server) loadEntryAthletesDTOs(ctx context.Context, q queryer, eventID int64) (map[int64][]entryAthleteDTO, error) {
|
||
ready, err := entryAthletesReady(ctx, q)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
if !ready {
|
||
return nil, nil
|
||
}
|
||
rows, err := q.QueryContext(ctx,
|
||
`SELECT ea.entry_id, ea.athlete_id, a.name, a.bib
|
||
FROM entry_athletes ea
|
||
JOIN entries e ON e.id = ea.entry_id
|
||
JOIN athletes a ON a.id = ea.athlete_id
|
||
WHERE e.event_id = ?
|
||
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make(map[int64][]entryAthleteDTO)
|
||
for rows.Next() {
|
||
var (
|
||
entryID int64
|
||
item entryAthleteDTO
|
||
bib sql.NullString
|
||
)
|
||
if err := rows.Scan(&entryID, &item.AthleteID, &item.Name, &bib); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
item.Bib = strPtr(bib)
|
||
out[entryID] = append(out[entryID], item)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// entryAthletesOf 把一条报名的队员补齐成「始终有值」的数组:没有队员时是空数组而不是 null,
|
||
// 前端不必为「有没有队员」写两套渲染(与 memberNames 那种可空文本不同,队员是一组关联)。
|
||
func entryAthletesOf(configured []entryAthleteDTO) []entryAthleteDTO {
|
||
if len(configured) == 0 {
|
||
return []entryAthleteDTO{}
|
||
}
|
||
return configured
|
||
}
|
||
|
||
// replaceEntryAthletes 按给定顺序重写一条报名的队员(先删后插,幂等)。
|
||
//
|
||
// 为什么每次都重写、而不是「有队员才写」:报名可以被改成另一个样子(换人、减人、清空)。
|
||
// 只增不减会留下旧队员,那条报名以后一直按早就改掉的人头发个人分。
|
||
// 传空数组是**合法值**:清空队员 = 只报单位(与升级前一致)。
|
||
func replaceEntryAthletes(ctx context.Context, q queryer, entryID int64, athletes []entryAthleteDTO) error {
|
||
if _, err := q.ExecContext(ctx, `DELETE FROM entry_athletes WHERE entry_id = ?`, entryID); err != nil {
|
||
return internal(err)
|
||
}
|
||
for i, a := range athletes {
|
||
if _, err := q.ExecContext(ctx,
|
||
`INSERT INTO entry_athletes (entry_id, athlete_id, sort_order) VALUES (?, ?, ?)`,
|
||
entryID, a.AthleteID, i); err != nil {
|
||
return internal(err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateEntryAthletes 校验一次队员改动,返回给人看的 400 错误。
|
||
//
|
||
// 规则(每一条都对着一种会算错的写法):
|
||
// - athleteId 必填且必须存在:队员身份只认运动员档案,姓名与号码布只是显示;
|
||
// - 同一个人只能出现一次:重复会让同一条成绩给同一个人记两份分、两枚牌;
|
||
// - 人数不超过 maxEntryAthletes:拦住「一整格都是 id」这类误填。
|
||
func (s *server) validateEntryAthletes(ctx context.Context, athletes []entryAthleteDTO) error {
|
||
if len(athletes) > maxEntryAthletes {
|
||
return badRequest(fmt.Sprintf("一条报名的队员最多 %d 人:队伍人数不可能这么多,请检查是不是误填", maxEntryAthletes), nil)
|
||
}
|
||
ids := make([]int64, 0, len(athletes))
|
||
seen := make(map[int64]bool, len(athletes))
|
||
for i, a := range athletes {
|
||
if a.AthleteID == 0 {
|
||
return badRequest("athletes[].athleteId 必填:队员身份只认运动员档案里的那个人(不是姓名文字)", i)
|
||
}
|
||
if seen[a.AthleteID] {
|
||
return badRequest(fmt.Sprintf("athletes 里运动员 %d 出现了两次:同一条报名里一个人只能占一个位置", a.AthleteID), i)
|
||
}
|
||
seen[a.AthleteID] = true
|
||
ids = append(ids, a.AthleteID)
|
||
}
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
|
||
args := make([]any, 0, len(ids))
|
||
for _, id := range ids {
|
||
args = append(args, id)
|
||
}
|
||
rows, err := s.ev.QueryContext(ctx,
|
||
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
exists := make(map[int64]bool, len(ids))
|
||
for rows.Next() {
|
||
var id int64
|
||
if err := rows.Scan(&id); err != nil {
|
||
return internal(err)
|
||
}
|
||
exists[id] = true
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
missing := make([]int64, 0)
|
||
for _, id := range ids {
|
||
if !exists[id] {
|
||
missing = append(missing, id)
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
return badRequest("athleteId 不存在(人员已被彻底删除时也会查不到)", missing)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// updateEntryAthletes 整组替换一条报名的队员并重算它所在的小项组别。
|
||
//
|
||
// 必须重算(与改份额那条接口同口径,而与改队名那条不同):队员决定「哪些人拿到个人分与
|
||
// 个人奖牌」,改完不重算就是「界面显示换人了、榜单还是旧人」。队名 / 批次那类纯展示字段
|
||
// 才走「不重算」那条路。
|
||
func (s *server) updateEntryAthletes(ctx context.Context, entryID int64, athletes []entryAthleteDTO, actorID int64) (entryDTO, error) {
|
||
entry, err := s.getEntry(ctx, entryID)
|
||
if err != nil {
|
||
return entryDTO{}, err
|
||
}
|
||
// 带运动员的报名自己就是那个人的身份((组别, 单位, 运动员) 唯一),再挂队员等于说
|
||
// 「这条报名既是一个人又是一支队」,两条口径会同时往个人榜上写。直接拒掉并说清原因。
|
||
if entry.AthleteID != nil {
|
||
return entryDTO{}, badRequest("这条报名带着运动员(个人报名):它的身份就是那一个运动员,不能再挂队员——队员只属于「只报单位」的团体报名", nil)
|
||
}
|
||
if err := s.validateEntryAthletes(ctx, athletes); err != nil {
|
||
return entryDTO{}, err
|
||
}
|
||
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return entryDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
if err := replaceEntryAthletes(ctx, tx, entryID, athletes); err != nil {
|
||
return entryDTO{}, err
|
||
}
|
||
score, err := recomputeLedgerTx(ctx, tx, entry.EventID, entry.CategoryID, actorID, "报名队员变更后重算")
|
||
if err != nil {
|
||
return entryDTO{}, err
|
||
}
|
||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||
return entryDTO{}, err
|
||
}
|
||
if err := s.bumpEvent(ctx, tx, entry.EventID); err != nil {
|
||
return entryDTO{}, err
|
||
}
|
||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||
return entryDTO{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return entryDTO{}, internal(err)
|
||
}
|
||
return s.getEntry(ctx, entryID)
|
||
}
|
||
|
||
// updateEntryAthletesHandler 是 PUT /api/entries/{id}/athletes:
|
||
// 整组替换一条团体报名的队员(空数组 = 清空,只报单位),成功后返回这条报名的完整读数。
|
||
func (s *server) updateEntryAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in entryAthletesPayload
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||
before, err := s.getEntry(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.updateEntryAthletes(r.Context(), id, in.Athletes, middleware.User(r.Context()).ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "entry.athletes.update", "entry", id,
|
||
map[string]any{"athletes": before.Athletes}, map[string]any{"athletes": updated.Athletes})
|
||
return updated, nil
|
||
})
|
||
}
|