Files
Scoreboard/server/internal/httpapi/entry_athletes.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

281 lines
11 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
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
})
}