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

427 lines
16 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"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件是「联队 + 份额分成」的读写与解析(devdocs/19)。
//
// 表达方式:**联队是一条报名,不是一个新单位**。新建/修改报名时给多个成员单位(默认均分),
// 分数与奖牌由引擎按份额下发到各单位;entries.team_id 仍是主单位(加号左边那个),
// 既有查询、唯一键、榜单兜底都还认它。
//
// 与导入的关系:报名表的 team_code 列写 `A+B` 就是联队(默认均分),解析见 parseJointTeamCodes。
// maxJointTeamMembers 是联队的成员单位上限。现场最多见过三个单位合并,给到 5 已经远超需要;
// 上限的作用是拦住「一整格都是加号」这类误填,而不是限制业务。
const maxJointTeamMembers = 5
// parseJointTeamCodes 解析报名表 team_code 单元格:`A` → 单单位,`A+B` → 联队(A 是主单位)。
//
// 全角 + 与两侧空格都当作分隔符的一部分(现场用中文输入法填表,打出来的常是全角),
// 但**只做这一层归一**:分段为空(`A+`、`+B`、`A++B`)、重复(`A+A`)、超长一律报错。
// 报错必须说清「这一格该怎么写」——现场看到一句「编码非法」是没法照着改的。
func parseJointTeamCodes(raw string) ([]string, error) {
normalized := strings.ReplaceAll(raw, "+", "+")
parts := strings.Split(normalized, "+")
codes := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
code := strings.TrimSpace(part)
if code == "" {
return nil, fmt.Errorf("单位编码 %s 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B)", raw)
}
if seen[code] {
return nil, fmt.Errorf("单位编码 %s 里同一个单位出现了两次:联队的每个单位只能写一次", raw)
}
seen[code] = true
codes = append(codes, code)
}
if len(codes) > maxJointTeamMembers {
return nil, fmt.Errorf("单位编码 %s 里写了 %d 个单位:一个联队最多 %d 个单位",
raw, len(codes), maxJointTeamMembers)
}
return codes, nil
}
// lookupImportTeams 把 team_code 解析成单位 id 与份额:普通报名一个单位,联队多个(默认均分)。
//
// 返回的份额就是写库要用的那套:每条权重 1(均分),medal 缺省(整枚各记)。
// 主单位是 codes[0](加号左边那个),它进 entries.team_id。
//
// 报错都是给现场看的行级文案,不包 apiError:调用方(导入主循环)用 err.Error() 原样回显,
// 多一层前缀现场反而看不懂。单位不存在时必须点明是联队里的哪一段,否则一行 `A+B` 里
// 找不到 B 的时候,录入员会以为是 A 写错了。
func (s *server) lookupImportTeams(ctx context.Context, teamCode string) ([]int64, []entryTeamShareDTO, error) {
codes, err := parseJointTeamCodes(teamCode)
if err != nil {
return nil, nil, err
}
ids := make([]int64, 0, len(codes))
shares := make([]entryTeamShareDTO, 0, len(codes))
for _, code := range codes {
id, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, code)
if err != nil {
if len(codes) > 1 {
return nil, nil, fmt.Errorf("单位 %s 不存在(来自联队编码 %s)", code, teamCode)
}
return nil, nil, fmt.Errorf("单位 %s 不存在", code)
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, id); err != nil {
return nil, nil, err
}
ids = append(ids, id)
shares = append(shares, entryTeamShareDTO{TeamID: id, Weight: 1})
}
return ids, shares, nil
}
// entryTeamSharesReady 判断份额表在不在。
//
// 读路径要能读「还没跑到 0024 的库」:迁移在服务启动时补跑(main.go),但榜单可能在迁移之前
// 被读一次——0022 的老库回归用例就是在迁移前读榜的,它读的正是升级前那份错榜。
// 表不在 = 库里没有任何联队配置 = 主单位独占 100%,正是老数据本来就该得到的结果,
// 所以按「没有份额」处理是**精确**的,不是容错降级(份额表缺表时不可能丢掉真实配置)。
func entryTeamSharesReady(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_team_shares'`).Scan(&n); err != nil {
return false, err
}
return n != 0, nil
}
// loadEntryTeamSharesQ 读一条 SQL 里给定范围内报名的份额,按 entry_id 归组。
// eventID 为 0 表示全库(导出与快照用,与它们读 channel_ranks 的范围一致)。
//
// 排序按 (sort_order, team_id):主单位排第一是由写侧保证的,引擎不依赖顺序取分,
// 但展示与流水顺序要稳定,否则同一份数据两次导出会排出两个样子。
func loadEntryTeamSharesQ(ctx context.Context, q queryer, eventID int64) (map[int64][]domain.TeamShare, error) {
ready, err := entryTeamSharesReady(ctx, q)
if err != nil {
return nil, internal(err)
}
if !ready {
return nil, nil
}
query := `SELECT s.entry_id, s.team_id, s.points_share, s.medal_share
FROM entry_team_shares s`
args := []any{}
if eventID != 0 {
query += ` JOIN entries e ON e.id = s.entry_id WHERE e.event_id = ?`
args = append(args, eventID)
}
query += ` ORDER BY s.entry_id, s.sort_order, s.team_id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamShare)
for rows.Next() {
var (
entryID int64
s domain.TeamShare
medal int
)
if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil {
return nil, internal(err)
}
if medal == 0 {
excluded := false
s.Medal = &excluded
}
out[entryID] = append(out[entryID], s)
}
return out, rows.Err()
}
// loadEntrySharesDTOs 读某小项全部报名的份额读数形态:带上单位编码 / 名称,
// 前端据此拼「A+B联队」,不必再查一次单位表。
func (s *server) loadEntrySharesDTOs(ctx context.Context, q queryer, eventID int64) (map[int64][]entryTeamShareDTO, error) {
ready, err := entryTeamSharesReady(ctx, q)
if err != nil {
return nil, internal(err)
}
if !ready {
return nil, nil
}
rows, err := q.QueryContext(ctx,
`SELECT s.entry_id, s.team_id, s.points_share, s.medal_share, t.code, t.name
FROM entry_team_shares s
JOIN entries e ON e.id = s.entry_id
JOIN teams t ON t.id = s.team_id
WHERE e.event_id = ?
ORDER BY s.entry_id, s.sort_order, s.team_id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]entryTeamShareDTO)
for rows.Next() {
var (
entryID int64
item entryTeamShareDTO
medal int
)
if err := rows.Scan(&entryID, &item.TeamID, &item.Weight, &medal, &item.TeamCode, &item.TeamName); err != nil {
return nil, internal(err)
}
if medal == 0 {
excluded := false
item.Medal = &excluded
}
out[entryID] = append(out[entryID], item)
}
return out, rows.Err()
}
// entrySharesOf 把一条报名的份额补齐成「至少一条」:库里没有份额行时按主单位独占 100%。
// 与引擎的 domain.ResolveShares 同口径(那边用 []domain.TeamShare,这边还要带展示字段)。
// 主单位的编码 / 名称由调用方给(报名查询里本来就 join 了单位表)。
func entrySharesOf(configured []entryTeamShareDTO, primaryTeamID int64, code, name string) []entryTeamShareDTO {
if len(configured) > 0 {
return configured
}
if primaryTeamID == 0 {
return nil
}
return []entryTeamShareDTO{{TeamID: primaryTeamID, Weight: 1, TeamCode: code, TeamName: name}}
}
// replaceEntryShares 按给定顺序重写一条报名的份额(先删后插,幂等)。
//
// 为什么每次都重写、而不是「有联队才写」:报名可以被改成另一个样子(联队改回单单位、
// 换成员单位)。只增不减会留下旧份额,那条报名以后一直按早就改掉的配置分账。
// 传空份额时按主单位独占补一条,保持「读侧至少一条」这个不变式。
func replaceEntryShares(ctx context.Context, q queryer, entryID, primaryTeamID int64, shares []entryTeamShareDTO) error {
if _, err := q.ExecContext(ctx, `DELETE FROM entry_team_shares WHERE entry_id = ?`, entryID); err != nil {
return internal(err)
}
if len(shares) == 0 {
shares = []entryTeamShareDTO{{TeamID: primaryTeamID, Weight: 1}}
}
for i, s := range shares {
medal := 1
if !entryShareCountsMedal(s) {
medal = 0
}
if _, err := q.ExecContext(ctx,
`INSERT INTO entry_team_shares (entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, ?, ?, ?)`,
entryID, s.TeamID, entryShareWeight(s), medal, i); err != nil {
return internal(err)
}
}
return nil
}
// sharesContainTeam 判断份额里有没有这个单位(导入侧用它核对「主单位还在不在成员里」)。
func sharesContainTeam(shares []entryTeamShareDTO, teamID int64) bool {
for _, s := range shares {
if s.TeamID == teamID {
return true
}
}
return false
}
// entryShareCountsMedal 与 domain.TeamShare.CountsMedal 同一口径:缺省(nil)= 整枚各记。
func entryShareCountsMedal(s entryTeamShareDTO) bool {
return s.Medal == nil || *s.Medal
}
// entryShareWeight 与 domain 的 shareWeight 同一口径:0(不填)按 1 处理,也就是均分。
// 负数在 validateEntryShares 就被拦下,这里的兜底只是让写库不会撞上 CHECK。
func entryShareWeight(s entryTeamShareDTO) float64 {
if s.Weight <= 0 {
return 1
}
return s.Weight
}
// validateEntryShares 校验一次份额改动,返回给人看的 400 错误。
//
// 规则(每一条都对着一种会算错分的写法):
// - 不能为空:份额为空等于没说清这条报名的分该给谁;
// - 每个单位都得存在且没被假删除:删掉的单位不该再收分;
// - 权重不能是负数:份额是相对权重,不填(0)就是 1——「均分」的写法,负数没有任何含义;
// - 同一个单位只能出现一次:重复会让同一条成绩给同一个单位记两份分、两枚牌;
// - 必须包含主单位(entries.team_id):主单位是这条报名在库里的身份(唯一键用它),
// 把它排除在份额之外,只会得到「报名挂在 A 头上、分全给了 B」这种看不见的错。
func (s *server) validateEntryShares(ctx context.Context, primaryTeamID int64, shares []entryTeamShareDTO) error {
if len(shares) == 0 {
return badRequest("teamShares 不能为空:至少要有主单位一条", nil)
}
if len(shares) > maxJointTeamMembers {
return badRequest(fmt.Sprintf("teamShares 最多 %d 个单位:一个联队不需要这么多成员", maxJointTeamMembers), nil)
}
ids := make([]int64, 0, len(shares))
seen := make(map[int64]bool, len(shares))
hasPrimary := false
for i, s := range shares {
if s.TeamID == 0 {
return badRequest("teamShares[].teamId 必填", i)
}
if seen[s.TeamID] {
return badRequest(fmt.Sprintf("teamShares 里单位 %d 出现了两次:同一个单位只能占一份", s.TeamID), i)
}
seen[s.TeamID] = true
if s.Weight < 0 {
return badRequest(fmt.Sprintf("teamShares[].weight 不能为负数(当前 %v):份额是相对权重,不填或填 0 就按 1 算(均分)", s.Weight), i)
}
if s.TeamID == primaryTeamID {
hasPrimary = true
}
ids = append(ids, s.TeamID)
}
if !hasPrimary {
return badRequest(fmt.Sprintf("teamShares 必须包含这条报名的主单位 %d:主单位不能从份额里去掉(换主单位要重新建报名)", primaryTeamID), nil)
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id FROM teams WHERE deleted_at IS NULL AND 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("teamId 不存在或已删除", missing)
}
return nil
}
// loadEntryForShares 读一条报名的落点(小项 / 组别 / 主单位),份额改动要用它决定重算范围。
func (s *server) loadEntryForShares(ctx context.Context, entryID int64) (eventID, categoryID, teamID int64, err error) {
err = s.ev.QueryRowContext(ctx,
`SELECT event_id, category_id, team_id FROM entries WHERE id = ?`, entryID).
Scan(&eventID, &categoryID, &teamID)
if err == sql.ErrNoRows {
return 0, 0, 0, notFound("报名项不存在")
}
if err != nil {
return 0, 0, 0, internal(err)
}
return eventID, categoryID, teamID, nil
}
// updateEntryTeamShares 改写一条报名的份额并重算它所在的小项组别。
//
// 必须重算:份额决定同一条成绩的分记在谁头上,不重算就是「界面显示改好了、榜单还是旧分」。
// 奖牌不需要单独处理——它由读取侧按 entry_team_shares 现算,落地即生效。
func (s *server) updateEntryTeamShares(ctx context.Context, entryID int64, shares []entryTeamShareDTO, actorID int64) (entryDTO, error) {
eventID, categoryID, teamID, err := s.loadEntryForShares(ctx, entryID)
if err != nil {
return entryDTO{}, err
}
if err := s.validateEntryShares(ctx, teamID, shares); 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 := replaceEntryShares(ctx, tx, entryID, teamID, shares); err != nil {
return entryDTO{}, err
}
score, err := recomputeLedgerTx(ctx, tx, eventID, 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, 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)
}
// getEntry 读回单条报名(含份额),供份额改写接口回显。
func (s *server) getEntry(ctx context.Context, entryID int64) (entryDTO, error) {
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, entryID))
if err != nil {
if err == sql.ErrNoRows {
return entryDTO{}, notFound("报名项不存在")
}
return entryDTO{}, internal(err)
}
shares, err := s.loadEntrySharesDTOs(ctx, s.ev, e.EventID)
if err != nil {
return entryDTO{}, err
}
e.TeamShares = entrySharesOf(shares[e.ID], e.TeamID, e.TeamCode, e.TeamName)
athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, e.EventID)
if err != nil {
return entryDTO{}, err
}
e.Athletes = entryAthletesOf(athletes[e.ID])
return e, nil
}
// updateEntryTeamSharesHandler 是 PUT /api/entries/{id}/team-shares:
// 改一条报名的成员单位与权重(联队),成功后返回这条报名的完整读数。
func (s *server) updateEntryTeamSharesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in entryTeamSharesPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getEntry(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEntryTeamShares(r.Context(), id, in.TeamShares, middleware.User(r.Context()).ID)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.team_shares.update", "entry", id,
map[string]any{"teamShares": before.TeamShares}, map[string]any{"teamShares": updated.TeamShares})
return updated, nil
}