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:
AskaEth committed 2026-09-14 14:05:48 +08:00
1 parent 7a3e4ad365
commit 99c91f99f7
57 files changed
+4903 -94

No files matched your search

+20 -2
View File
@@ -123,8 +123,13 @@ type entryDTO struct {
Squad string `json:"squad"`
AthleteID *int64 `json:"athleteId"`
MemberNames []string `json:"memberNames"`
Bib *string `json:"bib"`
Seed *string `json:"seed"`
// Athletes 是这条报名的**队员**(团队 / 对抗项目的成员运动员),读侧始终下发数组
// (空数组 = 只报单位,与升级前一致;不是 null,前端不必写两套渲染)。
// 它是人员关联(athleteId),memberNames 只是显示用的文字名单——两者可以同时存在:
// 老数据、以及「人没录档案只登记姓名」的报名就只有 memberNames。
Athletes []entryAthleteDTO `json:"athletes"`
Bib *string `json:"bib"`
Seed *string `json:"seed"`
// Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。
Heat *int64 `json:"heat"`
Status string `json:"status"`
@@ -139,6 +144,19 @@ type entryDTO struct {
TeamShares []entryTeamShareDTO `json:"teamShares"`
}
// entryAthleteDTO 是报名上的一名**队员**(devdocs/20):团队 / 对抗项目的成员运动员。
//
// - athleteId:人员档案的 id,**唯一身份**(认人时以号码布为准,姓名会重名);
// - name / bib:展示字段(前端显示「姓名(号码布)」),写侧不需要带。
//
// 写侧(PUT /api/entries/{id}/athletes)只认 athleteId:把姓名 / 号码布认成人是导入与界面
// 的事(导入的「队员名单」列可以写号码布),服务端不在这里二次猜测。
type entryAthleteDTO struct {
AthleteID int64 `json:"athleteId"`
Name string `json:"name,omitempty"`
Bib *string `json:"bib,omitempty"`
}
// entryTeamShareDTO 是报名份额的一条(联队的一个成员单位)。
//
// - weight:积分份额,**相对权重**(缺省 1)。引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价;
+280
View File
@@ -0,0 +1,280 @@
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
})
}
@@ -0,0 +1,249 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住「团体报名的队员身份」(devdocs/20)从报到榜的完整链路:
// - 一条团体报名可以关联多个运动员(队员),0 个 = 只报单位(与升级前一致);
// - 队员各拿**一份完整的**个人分、各一枚个人奖牌(不是按份额拆,别与联队抄混);
// - **单位维度一份都不多**:单位总分与单位奖牌仍只算这条报名一次,
// 由通道开关 count_to_team / count_to_team_medals 控制(双打现场是都不计入)。
// seedTeamEntryAthleteChannel 造一个「双打」小项:团体项目 + 一个按成绩排名的**个人**通道
// (现场的双打就是给团体报名配个人通道,分才上得了个人积分榜)。
// 两个单位开关由调用方给:现场是「都不计入」(0/0),默认是都计入(1/1)。
func seedTeamEntryAthleteChannel(t *testing.T, env *testEnv, sportID int64, countToTeam, countToTeamMedals int) (eventID, categoryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, sportID)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order,
count_to_team, count_to_team_medals)
VALUES (?, 'athlete', 'result', ?, 0, ?, ?)`,
eventID, profileIDByCode(t, env, "individual_top6"), countToTeam, countToTeamMedals)
return eventID, categoryID
}
// doublesImportRow 拼一行团体报名(不填姓名 = 团体行,队员名单写两个人名)。
func doublesImportRow(eventCode, category, teamCode string) string {
return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/王五"}, ",")
}
// putEntryAthletes 调 PUT /api/entries/{id}/athletes 整组替换队员。
func putEntryAthletes(t *testing.T, env *testEnv, client *http.Client, entryID int64, athleteIDs ...int64) (int, envelope) {
t.Helper()
items := make([]map[string]any, 0, len(athleteIDs))
for _, id := range athleteIDs {
items = append(items, map[string]any{"athleteId": id})
}
return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": items}, true)
}
// entryAthleteRows 把某小项的队员关联表导成稳定文本。
func entryAthleteRows(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(
`SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order
FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id
JOIN entries e ON e.id = ea.entry_id
WHERE e.event_id = %d
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID))
}
// athleteBoard 是 GET /api/standings/athletes 的原始读数。
func athleteBoard(t *testing.T, env *testEnv, client *http.Client) []map[string]any {
t.Helper()
status, resp := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读个人积分榜应 200,得到 %d %+v", status, resp.Error)
}
return decodeData[[]map[string]any](t, resp)
}
// athleteRow 取个人榜上某个运动员那一行(不在榜上直接失败)。
func athleteRow(t *testing.T, env *testEnv, client *http.Client, athleteID int64) map[string]any {
t.Helper()
for _, row := range athleteBoard(t, env, client) {
if row["athleteId"] == float64(athleteID) {
return row
}
}
t.Fatalf("个人积分榜上没有运动员 %d:%+v", athleteID, athleteBoard(t, env, client))
return nil
}
// assertAthlete 断言个人榜上某人的总分与金银铜。
func assertAthlete(t *testing.T, env *testEnv, client *http.Client, athleteID int64, points float64, gold, silver, bronze int) {
t.Helper()
row := athleteRow(t, env, client, athleteID)
if got := row["totalPoints"].(float64); got != points {
t.Errorf("运动员 %d 个人总分 = %v,期望 %v(整行:%+v)", athleteID, got, points, row)
}
for name, want := range map[string]int{"gold": gold, "silver": silver, "bronze": bronze} {
if got := row[name].(float64); got != float64(want) {
t.Errorf("运动员 %d 的 %s = %v,期望 %d(整行:%+v)", athleteID, name, got, want, row)
}
}
}
// 双打:一条团体报名 + 两个队员 → 每人各拿一份完整的个人分、各一枚个人奖牌;
// 单位维度仍然只算这条报名一次(10 分、1 枚金牌,不是 30 分、3 枚)。
func TestDoublesEntryMembersGetOwnIndividualScore(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
// 双打是同一个单位的两个人:种子里另一个运动员在 B 单位,另外补一个 A 单位的人。
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
// 队员关联:这就是本单新增的写入路径(界面勾了谁,就把它留在报名上)。
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK || !resp.OK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
entryID, ids.athleteA, entryID, partner)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("队员关联落库不对:\n得到 %s期望 %s", got, want)
}
publishResults(t, env, client, eventID, "doubles-members",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 个人榜:两个队员各一份满分(第 1 名 10 分)+ 各一枚金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
// 单位维度:仍然只算这条报名一次 —— 既不是两份 20 分,也不是三枚金牌。
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
}
// 现场的双打口径:通道两个单位开关都关掉 → 单位 0 分 0 牌,但队员的个人分与个人奖牌照发。
func TestDoublesEntryMembersWithTeamFlagsOff(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
publishResults(t, env, client, eventID, "doubles-flags-off",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 单位总分与单位奖牌都不受队员关联影响(双打现场就是「都不计入」)。
assertStanding(t, env, client, ids.teamA, 0, 0, 0, 0)
// 个人分与个人奖牌照发:一人一份满分、一人一枚金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
}
// 报名读数要带上队员(界面据此显示「队里有谁」,并能改)。
func TestEntryReadsIncludeAthletes(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
env.exec(t, `UPDATE athletes SET bib = 'A001' WHERE id = ?`, ids.athleteA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error)
}
board := decodeData[entryBoard](t, resp)
if len(board.Entries) != 1 {
t.Fatalf("应有一条报名,得到 %+v", board.Entries)
}
athletes, _ := board.Entries[0]["athletes"].([]any)
if len(athletes) != 2 {
t.Fatalf("报名读数应带两个队员,得到 %+v", board.Entries[0]["athletes"])
}
first, _ := athletes[0].(map[string]any)
if first["athleteId"] != float64(ids.athleteA) || first["name"] != "张伟" || first["bib"] != "A001" {
t.Fatalf("队员读数应带 athleteId / 姓名 / 号码布,得到 %+v", first)
}
}
// 队员改动要能拦住会算错的写法,并且一律不落库。
func TestUpdateEntryAthletesRejectsBadPayloads(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA); status != http.StatusOK {
t.Fatalf("先设一个队员应 200,得到 %d %+v", status, resp.Error)
}
baseline := entryAthleteRows(t, env, eventID)
// 个人报名(带运动员)不该有队员:它的身份就是那一个运动员。
personalEntryID := ids.entryA
cases := []struct {
name string
path string
body map[string]any
want string
}{
{"运动员不存在", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"athleteId": 999999}}}, "不存在"},
{"缺 athleteId", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"name": "张三"}}}, "athleteId"},
{"同一个人写两次", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}, {"athleteId": ids.athleteA}}}, "两次"},
{"个人报名不能挂队员", fmt.Sprintf("/api/entries/%d/athletes", personalEntryID),
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "个人报名"},
{"报名不存在", "/api/entries/999999/athletes",
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "不存在"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, resp := env.do(client, http.MethodPut, tc.path, tc.body, true)
wantStatus := http.StatusBadRequest
if tc.name == "报名不存在" {
wantStatus = http.StatusNotFound
}
if status != wantStatus {
t.Fatalf("应 %d,得到 %d %+v", wantStatus, status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) {
t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != baseline {
t.Fatalf("被拒的请求改动了队员表:\n之前 %s之后 %s", baseline, got)
}
})
}
// 空数组是合法值:清空队员 = 只报单位(与升级前一致)。
if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{}}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("清空队员应 200,得到 %d %+v", status, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("清空后队员表应为空,得到 %s", got)
}
}
@@ -0,0 +1,202 @@
package httpapi_test
import (
"database/sql"
"fmt"
"testing"
)
// 本文件是「老库升级到 0026」这一跳在接口层的验证:拿一个升级前(0025)的库,塞进老数据,
// 走真实升级路径(store.MigrateEvent)后从报名一路打到榜单。
//
// 三条底线:
// - 回填:老报名 member_names 里认得出的人被挂上(号码布优先、姓名+单位其次、重名不猜);
// - 队员拿个人分与个人奖牌(这是本单要的效果);
// - **单位维度不变**:单位总分与单位奖牌仍旧只算这条报名一次。
// seedLegacyDoublesEvent 在升级前的库里造两条「双打」团体报名(A、B 两个单位各一条):
// 团体项目 + 个人通道(现场的双打就是这么配的),队员名单只有文字(老数据的唯一形态)。
// 两个 memberNames 参数分别是 B 单位那条与 A 单位那条的队员名单(NULL 表示没填名单)。
func seedLegacyDoublesEvent(t *testing.T, db *sql.DB, memberNamesB, memberNamesA string) {
t.Helper()
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("造老库数据失败: %v\n%s", err, query)
}
}
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`)
// 号码布是运动员的唯一标识:下面按号码布/姓名两种写法各认一个。
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('张伟', '男', 1, 'A001', 1)`)
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('王五', '男', 1, 'A002', 1)`)
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('李强', '男', 2, 'B001', 1)`)
exec(`INSERT INTO sports(code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status)
VALUES (1, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 1, 'ongoing')`)
exec(`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`)
exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'athlete', 'result', (SELECT id FROM scoring_profiles WHERE code = 'individual_top6'), 0)`)
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
VALUES (1, 1, 2, NULL, ?, 'registered')`, memberNamesB)
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status, squad)
VALUES (1, 1, 1, NULL, ?, 'registered', '')`, memberNamesA)
}
// newLegacyDoublesEnv 造一个「升级前的老库 → 升级 → 起服务」的完整现场。
func newLegacyDoublesEnv(t *testing.T, memberNamesB, memberNamesA string) *testEnv {
t.Helper()
return newTestEnvPrepared(t, func(ev *sql.DB) {
applyEventMigrationsBefore(t, ev, "0026") // 回到「升级前」:库结构到 0025 为止
seedLegacyDoublesEvent(t, ev, memberNamesB, memberNamesA)
}, nil)
}
// 老库升级:名单里认得出的人被回填成队员,于是双打两个人各拿一份个人分、各一枚个人奖牌;
// 而单位维度与升级前逐字节一致(单位只算一次分、只拿一枚牌)。
func TestLegacyDoublesUpgradeBackfillsMembersAndScoresIndividually(t *testing.T) {
// 老报名写的是一半号码布(A001 = 张伟)、一半姓名(王五)——两种写法都该认出来。
// 另一条报名(B 单位)写的是姓名「李强」,也在升级时认出来(下面 dump 里是第 1 行)。
env := newLegacyDoublesEnv(t, `["李强"]`, `["A001","王五"]`)
// 升级那一刻做的回填:两个人都挂上了,顺序按 member_names 的书写顺序。
dump := tableDump(t, env, `SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order
FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id
ORDER BY ea.entry_id, ea.sort_order`)
want := "entry_id=1 athlete_id=3 name=李强 sort_order=0 \n" +
"entry_id=2 athlete_id=1 name=张伟 sort_order=0 \n" +
"entry_id=2 athlete_id=2 name=王五 sort_order=1 \n"
if dump != want {
t.Fatalf("升级回填不对:\n得到 %s期望 %s", dump, want)
}
client, _ := env.client()
env.createUser("recorder", "recorder-pass", "recorder")
env.login(client, "recorder", "recorder-pass")
entryA := entryIDOf(t, env, 1, 1) // A 单位的双打
entryB := entryIDOf(t, env, 2, 1) // B 单位的单人报名
publishResults(t, env, client, 1, "legacy-doubles-upgrade",
map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
)
// 个人榜:双打的两个人各一份满分(第 1 名 10 分)+ 各一枚金牌。
assertAthlete(t, env, client, 1, 10, 1, 0, 0) // 张伟
assertAthlete(t, env, client, 2, 10, 1, 0, 0) // 王五
assertAthlete(t, env, client, 3, 8, 0, 1, 0) // 李强(对照)
// 单位维度:分配了队员也仍然只算这条报名一次(不是 20 分、也不是两枚金牌)。
assertStanding(t, env, client, 1, 10, 1, 0, 0)
assertStanding(t, env, client, 2, 8, 0, 1, 0)
// 流水层面把「不重复计算」摊开看:单位那一份只有一条(athlete_id 为空、count_to_team=1),
// 队员的两条是「只记个人」(count_to_team=0),所以单位总分与单位奖牌都看不到它们。
ledger := tableDump(t, env, `SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
FROM score_ledger WHERE voided = 0 ORDER BY team_id, athlete_id`)
wantLedger := "team_id=1 athlete_id=0 points=10 count_to_team=1 \n" +
"team_id=1 athlete_id=1 points=10 count_to_team=0 \n" +
"team_id=1 athlete_id=2 points=10 count_to_team=0 \n" +
"team_id=2 athlete_id=0 points=8 count_to_team=1 \n" +
"team_id=2 athlete_id=3 points=8 count_to_team=0 \n"
if ledger != wantLedger {
t.Fatalf("流水不对:\n得到 %s期望 %s", ledger, wantLedger)
}
}
// 名单认不出人的老报名(重名、查无此人)升级后照旧:上不了个人榜,单位维度一个字不变。
func TestLegacyDoublesUpgradeWithoutMatchKeepsBehavior(t *testing.T) {
// 两条报名写的名字都查不到人(重名 / 查无此人):一条关联都不许回填。
env := newLegacyDoublesEnv(t, `NULL`, `["查无此人","李强","李强"]`)
if got := countRows(t, env, `SELECT COUNT(*) FROM entry_athletes`); got != 0 {
t.Fatalf("认不出人时不该回填任何关联(B 单位没填名单、A 单位查无此人),得到 %d 条", got)
}
client, _ := env.client()
env.createUser("recorder", "recorder-pass", "recorder")
env.login(client, "recorder", "recorder-pass")
entryA := entryIDOf(t, env, 1, 1)
entryB := entryIDOf(t, env, 2, 1)
publishResults(t, env, client, 1, "legacy-doubles-nomatch",
map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
)
// 单位维度与升级前一样(A 第 1 名 10 分 1 金,B 第 2 名 8 分 1 银)。
assertStanding(t, env, client, 1, 10, 1, 0, 0)
assertStanding(t, env, client, 2, 8, 0, 1, 0)
// 个人榜上一条都没有:队员认不出来就上不了个人榜(这正是回填要解决的事)。
if rows := athleteBoard(t, env, client); len(rows) != 0 {
t.Fatalf("认不出人时个人榜应为空,得到 %+v", rows)
}
}
// 双打 + 双通道(个人成绩通道 + 团体手工通道):连发两次必须逐字节一致。
//
// results.rank 在团体报名项上是「单位名次」的录入位(团体手工通道的兜底来源)。
// 队员的个人副本不许往这一列写回自动名次——否则第二次发布会把自动名次当成团体名次再发一遍分,
// 就是工单 A-1 那个坑,只不过这次是队员副本踩进去的。
func TestDoublesMembersDoNotClobberManualTeamRank(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'DBL2', '羽毛球双打(双通道)', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport), int64(0)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "individual_top6"))
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 1)`, eventID, profileIDByCode(t, env, "team_top6"))
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamA)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != 200 {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
// 团体名次按单位录(第 3 名 = 8 分),双打自己的成绩值给出个人通道的第 1 名(10 分)。
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 3},
}); status != 200 {
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
}
publishResults(t, env, client, eventID, "doubles-dual-p1",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
firstLedger := tableDump(t, env, fmt.Sprintf(
`SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID))
// 名次不许被队员副本写回:团体报名项的 results.rank 仍是「没写」(名次按单位录入在
// channel_manual_ranks 里,这一列是它的兜底录入位,被自动名次覆盖就会在第二次发布时
// 被当成团体名次再发一遍分)。
if got := resultRanksDump(t, env, eventID); got != fmt.Sprintf("entry_id=%d rank=0 value=90 \n", entryID) {
t.Fatalf("团体名次(results.rank 录入位)被自动名次改写了:\n%s", got)
}
// 单位总分 = 个人通道第 1 名的 10 分(报名项那一份)+ 团体手工通道第 3 名的 8 分,只算一次。
// 奖牌 1 金 1 铜是双通道本来就有的口径(两条通道各自发自己那份名次的牌,与队员无关)。
assertStanding(t, env, client, ids.teamA, 18, 1, 0, 1)
// 队员各拿个人通道那一份 10 分与个人金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
publishEvent(t, env, client, eventID, "doubles-dual-p2")
if got := tableDump(t, env, fmt.Sprintf(
`SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID)); got != firstLedger {
t.Fatalf("第二次发布改了流水(同一份数据发两次结果不同):\n第一次\n%s第二次\n%s", firstLedger, got)
}
}
// entryIDOf 取某 (单位, 小项) 的纯团体报名 id。
func entryIDOf(t *testing.T, env *testEnv, teamID, eventID int64) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(
`SELECT id FROM entries WHERE team_id = ? AND event_id = ? AND athlete_id IS NULL`, teamID, eventID).Scan(&id); err != nil {
t.Fatalf("读报名项: %v", err)
}
return id
}
+5
View File
@@ -393,6 +393,11 @@ func (s *server) getEntry(ctx context.Context, entryID int64) (entryDTO, error)
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
}
+176 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/csv"
"encoding/hex"
"encoding/json"
@@ -13,6 +14,7 @@ import (
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
@@ -1189,6 +1191,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
affectedScopes := make([]entryScope, 0, 2)
// 「队员名单」列按号码布认出来、并挂到报名上的队员总数。收尾说一句:这些人会参与个人算分,
// 认不出的照旧只是文字名单——不报错,但现场得知道「这份表到底认上了几个人」。
linkedMembers := 0
for _, table := range tables {
index, err := importColumnIndex(table.rows[0])
if err != nil {
@@ -1447,6 +1452,68 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
squad = ""
}
// 「队员名单」列既是显示用的文字,也能写**号码布**:一格里的每一段先按号码布去
// athletes 认人,认得上就把这条报名跟那个人关联起来(entry_athletes),
// 认不上的照旧只当文字(现状不变,不报错)。号码布是运动员的唯一标识(devdocs/20 §三)。
memberTokens := splitNames(cell("member_names"))
link, err := s.importMemberAthletes(ctx, memberTokens)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err)))
continue
}
for _, name := range link.Repeated {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行:队员名单里的号码布 %s 写了两次,只关联一个人(同一条报名里一个人只占一个位置)", line, name))
}
// 队员只对**纯团体报名**生效:带运动员姓名的行按个人报名处理,那种报名自己就是那个人的
// 身份(见 PUT /api/entries/{id}/athletes 的同一条口径),所以这一格只留文字、不关联。
// 认得出人时才说一句——这一格在个人行上本来就只是备注,空着或只写姓名都不必打扰现场。
if athleteID != nil && len(link.Members) > 0 {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行的队员名单里有 %d 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:"+
"队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人",
line, len(link.Members)))
link.Members = nil
}
// 一条报名的队员上限与 PUT /api/entries/{id}/athletes 共用:超过就整行拦下,不写一条
// 界面上改不动的报名(与联队「一个联队最多 5 个单位」同一条口径)。认不出人的段不计入,
// 所以只写姓名的老表一行都不会被它拦下。
if len(link.Members) > maxEntryAthletes {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
"队员名单里认出了 %d 名运动员,超过一条报名最多 %d 人:请检查这一格是不是把号码布写错了位置",
len(link.Members), maxEntryAthletes))
continue
}
// 号码布认到别的单位的人:照号码布认(与迁移回填同一口径:号码布是唯一标识,不看单位),
// 但要点名说清 —— 团体报名挂在 A 单位、队员却是 B 单位的人,最常见的原因是号码布写错了。
// 联队(team_code 写 A+B)的成员单位都算「本行的单位」:队员本来就该来自其中任何一个,
// 不该报一个会让人去改正确号码布的假警报。
// 这里**不**进冲突面板:那是「要不要用表里的字段覆盖库里这条人员档案」的决策,
// 而队员关联一个字段都不改(与 PUT /api/entries/{id}/athletes 一样只认 athleteId)。
for _, member := range link.Members {
if member.TeamID == teamID || sharesContainTeam(teamShares, member.TeamID) {
continue
}
memberTeam := member.TeamCode
if memberTeam == "" {
memberTeam = "(无单位)"
}
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行:队员名单里的号码布 %s 是单位 %s 的 %s,本行报名挂在单位 %s 名下 —— "+
"号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错",
line, member.Bib, memberTeam, member.Name, teamCode))
}
members := make([]entryAthleteDTO, 0, len(link.Members))
for _, member := range link.Members {
members = append(members, entryAthleteDTO{AthleteID: member.AthleteID})
}
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
@@ -1454,7 +1521,8 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
TeamShares: teamShares,
Squad: squad,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
MemberNames: memberTokens,
Athletes: members,
Bib: nilIfEmpty(bib),
Seed: nilIfEmpty(cell("seed")),
Heat: heat,
@@ -1466,6 +1534,7 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
case outcome.Created:
result.Created++
linkedMembers += len(members)
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
@@ -1476,7 +1545,10 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
}
default:
result.Updated++
if outcome.StatusChanged {
linkedMembers += len(members)
// 队员关联与 status 跨界同一类:它们决定「谁拿到分」,改了就要重算这一组,
// 否则会出现「名单里认出了队员、榜上还没他的分」(与 PUT /api/entries/{id}/athletes 同口径)。
if outcome.StatusChanged || outcome.MembersChanged {
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
}
}
@@ -1519,6 +1591,14 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
result.Notices = append(result.Notices, fmt.Sprintf(
"号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows))
}
// 队员名单认了多少人,用既有 notices 通道说一句:号码布认得出人时,这些人就从「文字名单」
// 变成了「真实的队员」,会各自拿到个人分与个人奖牌(配了个人通道的项目才上个人榜)。
// 认不出的一律照旧只是文字,不报错——人没录档案是常态,这正是降级形态存在的意义。
if linkedMembers > 0 {
result.Notices = append(result.Notices, fmt.Sprintf(
"队员名单列按号码布关联了 %d 名队员(号码布是运动员的唯一标识):配了个人通道的项目,他们会各自拿到个人分与个人奖牌;"+
"只写姓名、或号码布认不出人的照旧只作文字名单", linkedMembers))
}
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
@@ -1574,6 +1654,100 @@ func splitNames(raw string) []string {
return out
}
// importMember 是一格「队员名单」里按号码布认出来的一个人。
//
// 写进报名报的只有 AthleteID(与 PUT /api/entries/{id}/athletes 同一口径:认人是调用方的事);
// 姓名、号码布与单位只用于报告 —— 「认到的是谁、他属于哪个单位」。
type importMember struct {
AthleteID int64
Name string
Bib string
TeamID int64
TeamCode string
}
// importMemberLink 是一格「队员名单」认人的结果。
type importMemberLink struct {
// Members 按这一格里的书写顺序给出认出来的人(同一个号码布写多次只留一次),
// 写进报名的 sort_order 就用这个顺序(与迁移回填「按 member_names 书写顺序」同一口径)。
Members []importMember
// Repeated 是同一格里写了两次以上、且认得出人的号码布,按首次出现顺序。
Repeated []string
}
// importMemberAthletes 把「队员名单」一格里的每一段按**号码布**认成人。
//
// 口径(devdocs/20 §三,主人 2026-09-14 定的):
// - 号码布是运动员的唯一标识(0014 上有部分唯一索引):一段文字与某个运动员的号码布完全相同,
// 那就是他 —— **与单位无关**(迁移里的回填 1 同样不看单位),单位不同只作提示;
// - 一格里的内容同时能当成号码布与姓名时,以号码布为准:先认号码布,命中了就不再看姓名;
// - 认不出的段**保持原样当文字**(member_names 照常保留,现状不变):现场难免有没录档案的人。
// 姓名一律不做匹配——同名会认错人,而认错的分已经上了榜(老数据的姓名回填是迁移里的一次性动作,
// 且要求同单位唯一,导入不重复这条不确定性)。
//
// 一格一次查询:格子里的段数最多十几段,但一份表可能有几百行,逐段查会把导入变成几百次往返。
func (s *server) importMemberAthletes(ctx context.Context, tokens []string) (importMemberLink, error) {
if len(tokens) == 0 {
return importMemberLink{}, nil
}
seen := make(map[string]bool, len(tokens))
args := make([]any, 0, len(tokens))
for _, token := range tokens {
if seen[token] {
continue
}
seen[token] = true
args = append(args, token)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT a.bib, a.id, a.name, a.team_id, COALESCE(t.code, '')
FROM athletes a LEFT JOIN teams t ON t.id = a.team_id
WHERE a.bib IN (`+placeholders(len(args))+`)`, args...)
if err != nil {
return importMemberLink{}, internal(err)
}
defer func() { _ = rows.Close() }()
byBib := make(map[string]importMember, len(args))
for rows.Next() {
var (
m importMember
bib sql.NullString
code string
)
if err := rows.Scan(&bib, &m.AthleteID, &m.Name, &m.TeamID, &code); err != nil {
return importMemberLink{}, internal(err)
}
m.Bib = bib.String
m.TeamCode = code
byBib[m.Bib] = m
}
if err := rows.Err(); err != nil {
return importMemberLink{}, internal(err)
}
var link importMemberLink
emitted := make(map[int64]bool)
for _, token := range tokens {
member, ok := byBib[token]
if !ok {
// 认不出人:照旧只当文字(member_names 已经把它留在库里了)。
continue
}
if emitted[member.AthleteID] {
// 同一个号码布写了两次:同一条报名里一个人只占一个位置(表的主键也这么定),
// 这里就地去掉并记下来,由调用方说一句。
if !slices.Contains(link.Repeated, member.Bib) {
link.Repeated = append(link.Repeated, member.Bib)
}
continue
}
emitted[member.AthleteID] = true
link.Members = append(link.Members, member)
}
return link, nil
}
// ---------- 图片上传与静态访问 ----------
// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节,
@@ -0,0 +1,394 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住「导入侧把队员名单认成人」(devdocs/20 §三):
// - 「队员名单」列除了是显示用的文字,还能写**号码布**:认得上就建立 entry_athletes 关联,
// 认不上照旧只当文字(现状不变,不报错);
// - 号码布是运动员的唯一标识:一格里的内容同时能当成号码布与姓名时,以号码布为准;
// - 新增与更新两条路都要同步队员关联:认出了人的那一格整组重写(与 squad / 份额同一口径),
// 一个号码布都认不出的格子只当文字、不动已有队员(老表与界面两条路的并存,见各用例)。
// memberImportRow 拼一行团体报名(不填姓名 = 团体行),列序与 importHeader 一致。
func memberImportRow(eventCode, category, teamCode, members string) string {
return strings.Join([]string{eventCode, category, teamCode, "",
"", "", "", "", "", "registered", members}, ",")
}
// seedBib 给库里已有的运动员写一个号码布(种子里的运动员默认没有号码布)。
func seedBib(t *testing.T, env *testEnv, athleteID int64, bib string) {
t.Helper()
env.exec(t, `UPDATE athletes SET bib = ? WHERE id = ?`, bib, athleteID)
}
// seedMember 造一个带号码布的运动员(导入要认的就是号码布)。
func seedMember(t *testing.T, env *testEnv, name string, teamID int64, bib string) int64 {
t.Helper()
return env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES (?, '男', ?, ?, 1)`, name, teamID, bib)
}
// entryMemberNames 读一条报名的文字名单原文(member_names 在库里是 JSON 数组文本)。
func entryMemberNames(t *testing.T, env *testEnv, entryID int64) string {
t.Helper()
var raw string
if err := env.ev.QueryRow(`SELECT COALESCE(member_names, '') FROM entries WHERE id = ?`, entryID).Scan(&raw); err != nil {
t.Fatalf("读 member_names: %v", err)
}
return raw
}
// noticesText 把 notices 拼成一段文本,便于断言「说了什么」。
func noticesText(result entryImportResultBody) string {
return strings.Join(result.Notices, "\n")
}
// 号码布认得人:一格里的号码布与该行的文字名单并存 —— 关联建立、文字原样保留,
// 且这些队员当场就能上个人积分榜(导入路径的完整目的)。
func TestImportMemberNamesLinksByBibAndKeepsText(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A002/A001/李四"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("应新建 1 条报名且无报错,得到 %+v", result)
}
entryID := firstEntryID(t, env, eventID)
// 顺序跟这一格的书写顺序走(号码布写在前的排在前)。
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \nentry_id=%d athlete_id=%d name=张伟 sort_order=1 \n",
entryID, partner, entryID, ids.athleteA)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("队员关联应按号码布认出王五与张伟:\n得到 %s期望 %s", got, want)
}
// 认不出的「李四」不报错,文字名单三个都原样留着。
if got, want := entryMemberNames(t, env, entryID), `["A002","A001","李四"]`; got != want {
t.Fatalf("member_names 应原样保留,得到 %s 期望 %s", got, want)
}
if got := noticesText(result); !strings.Contains(got, "关联了 2 名队员") {
t.Fatalf("认了人要说得出来(现场要知道谁上了个人榜),得到 notices:%s", got)
}
// 认出来的人当场参与算分:每人一份满分、各一枚个人奖牌;单位仍然只算这一条报名一次。
publishResults(t, env, client, eventID, "import-members",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
}
// 一格里的内容同时能当成号码布与姓名时,以号码布为准(主人 2026-09-14 定的口径)。
func TestImportMemberNamesPrefersBibOverName(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
byBib := seedMember(t, env, "赵六", ids.teamA, "1001")
byName := seedMember(t, env, "1001", ids.teamA, "")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "1001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=赵六 sort_order=0 \n", entryID, byBib)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("1001 既是号码布又是另一个人的姓名时应关联号码布那个人:\n得到 %s期望 %s", got, want)
}
var linked int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entry_athletes WHERE athlete_id = ?`, byName).Scan(&linked); err != nil {
t.Fatalf("查关联: %v", err)
}
if linked != 0 {
t.Fatalf("不该关联同名的另一个人(%d)", byName)
}
}
// 只写姓名(认不出号码布)时现状不变:只当文字,不报错、不关联。
func TestImportMemberNamesUnknownStaysTextOnly(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
// 库里有个同单位同名的张伟,但号码布列认的是号码布:姓名不做匹配(同名会认错人)。
seedBib(t, env, ids.athleteA, "A001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "张伟/李四"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("只写姓名应照常导入、不报错,得到 %+v", result)
}
entryID := firstEntryID(t, env, eventID)
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("只写姓名不该建立任何关联,得到 %s", got)
}
if got, want := entryMemberNames(t, env, entryID), `["张伟","李四"]`; got != want {
t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want)
}
if got := noticesText(result); strings.Contains(got, "关联") {
t.Fatalf("没认到人时不该说关联,得到 notices:%s", got)
}
}
// 号码布认到的人属于别的单位:照号码布认(号码布是唯一标识,与迁移回填同一口径),
// 但要说清楚是谁、在哪两个单位之间 —— 这是「填错号码布」最容易发生的写法。
func TestImportMemberNamesReportsCrossTeamMember(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athB, "B001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "B001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=李强 sort_order=0 \n", entryID, ids.athB)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("号码布认到的人照关联(不看单位):\n得到 %s期望 %s", got, want)
}
notice := noticesText(result)
for _, part := range []string{"第 2 行", "B001", "李强", "单位 B", "单位 A"} {
if !strings.Contains(notice, part) {
t.Fatalf("跨单位关联要说清 %q,得到 notices:%s", part, notice)
}
}
}
// 后补队员:先导入一条只报单位的团体报名、成绩已经发布上榜;之后同一行补上号码布重传,
// 队员要**当场参与算分**(个人榜上出现他们),不必再发布一次成绩。
// 这与「改了谁算数就必须重算这一组」是同一条口径(updateEntryAthletes 也是这样)。
func TestImportMemberNamesRecomputesAfterUpdate(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", ""))
if status != http.StatusOK || !resp.OK {
t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
publishResults(t, env, client, eventID, "members-later",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 前置条件:还没有队员,个人榜上不该有这两个人。
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("前置条件:首次导入没有队员,得到 %s", got)
}
status, resp = importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A001/A002"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("补队员重传应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 {
t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got)
}
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
}
// 重传同一行(走更新那条路)也要整组重写队员:换人跟得上,不留上一次的旧队员;
// 而认不出号码布的格子(整列留空 / 只写姓名)只当文字,不动已有的队员关联。
func TestImportMemberNamesRewrittenOnUpdate(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
status, resp = importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A002"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 {
t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got)
}
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \n", entryID, partner)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("换人后队员应整组重写:\n得到 %s期望 %s", got, want)
}
// 走界面把队员改成两个人(导入与界面是两条并用的路)。
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("界面设置队员应 200,得到 %d %+v", status, resp.Error)
}
both := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
entryID, ids.athleteA, entryID, partner)
// 这一格一个号码布都认不出来(整列留空 / 只写姓名)时**只当文字**:已有的队员关联一行都不动。
// 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移回填出来的队员、界面上勾好的人,
// 都不该被一份没有号码布的名单悄悄清掉(那等于让人无声无息地掉出个人积分榜)。
for _, cell := range []string{"", "张伟/王五"} {
status, resp = importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", cell))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传(队员列 %q)应 200,得到 %d %+v", cell, status, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != both {
t.Fatalf("队员列 %q 认不出号码布时不该改动已有队员:\n得到 %s期望 %s", cell, got, both)
}
}
}
// 联队(单位编码写 A+B)的队员本来就该来自任何一个成员单位:认人都认,
// 但这类「跨单位」不是填错号码布,不该报假警报去让人改一个正确的号码布。
func TestImportMemberNamesAcceptsJointTeamMembers(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
seedBib(t, env, ids.athB, "B001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A+B", "A001/B001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=李强 sort_order=1 \n",
entryID, ids.athleteA, entryID, ids.athB)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("联队两个成员单位的队员都该关联:\n得到 %s期望 %s", got, want)
}
if got := noticesText(result); strings.Contains(got, "请核对号码布") {
t.Fatalf("联队成员单位的人不该被当成填错号码布,得到 notices:%s", got)
}
}
// 同一格里把同一个号码布写了两次:只关联一次(同一条报名里一个人只能占一个位置),并说一声。
func TestImportMemberNamesDedupesRepeatedBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A001/A001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \n", entryID, ids.athleteA)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("同一个号码布写两次只该关联一次:\n得到 %s期望 %s", got, want)
}
if got := noticesText(result); !strings.Contains(got, "A001") || !strings.Contains(got, "两次") {
t.Fatalf("号码布写了两次要说一声,得到 notices:%s", got)
}
}
// 带运动员姓名的那一行按个人报名处理:队员只属于「只报单位」的团体报名,
// 这一格只作文字保留,并且要说出来(与队伍列同一条口径,不静默丢)。
func TestImportMemberNamesOnIndividualRowIsTextOnly(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
row := strings.Join([]string{"DBL", "男子", "A", "张三", "110101199001011234", "男", "26", "A101",
"", "registered", "A001"}, ",")
status, resp := importCSVWithHeader(t, env, client, importHeader, row)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("个人行应照常导入,得到 %+v", result)
}
var entryID int64
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND athlete_id IS NOT NULL`, eventID).Scan(&entryID); err != nil {
t.Fatalf("读个人报名: %v", err)
}
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("个人报名不该挂队员,得到 %s", got)
}
if got, want := entryMemberNames(t, env, entryID), `["A001"]`; got != want {
t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want)
}
if got := noticesText(result); !strings.Contains(got, "个人报名") {
t.Fatalf("个人行里的队员名单没有关联,要说清楚,得到 notices:%s", got)
}
}
// 分工作表模板(界面上的「补报名 / 团体报名 CSV」走的就是导入)同样认号码布。
func TestImportSheetTemplateLinksMembersByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
data := buildTestXLSX(t, []testSheet{{name: "羽毛球双打@男子", rows: [][]string{
strings.Split(sheetImportHeader, ","),
{"", "", "A", "", "", "", "", "", "", "", "registered", "A001/A002", ""},
}}})
status, resp := env.uploadEntries(t, client, "团体报名.xlsx", data)
if status != http.StatusOK || !resp.OK {
t.Fatalf("分工作表模板应导入成功,得到 %d %+v", status, resp.Error)
}
var eventID int64
if err := env.ev.QueryRow(`SELECT id FROM events WHERE code = 'DBL'`).Scan(&eventID); err != nil {
t.Fatalf("读小项: %v", err)
}
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
entryID, ids.athleteA, entryID, partner)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("分工作表导入也该按号码布关联队员:\n得到 %s期望 %s", got, want)
}
}
// 一格号码布超过一条报名的队员上限(maxEntryAthletes = 30):整行拦下并说清,
// 不静默写成一条界面改不动的报名(与联队「最多 5 个单位」同一条口径)。
func TestImportMemberNamesRejectsTooManyMembers(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
tokens := make([]string, 0, 31)
for i := 1; i <= 31; i++ {
bib := fmt.Sprintf("M%03d", i)
seedMember(t, env, fmt.Sprintf("队员%d", i), ids.teamA, bib)
tokens = append(tokens, bib)
}
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", strings.Join(tokens, "/")))
if status != http.StatusOK || !resp.OK {
t.Fatalf("行级拦截应回 200 + 行级报错,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Skipped != 1 || result.Created != 0 {
t.Fatalf("超过上限的行应被拦下,得到 %+v", result)
}
if got := strings.Join(result.Errors, "|"); !strings.Contains(got, "30") {
t.Fatalf("报错要说清上限是多少,得到 %s", got)
}
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("被拦下的行不该落队员,得到 %s", got)
}
}
+7
View File
@@ -61,6 +61,13 @@ type entrySquadPayload struct {
RequestID string `json:"requestId"`
}
// entryAthletesPayload 是 PUT /api/entries/{id}/athletes 的请求体:整组替换一条报名的队员。
// 空数组是**合法值**(清空队员 = 只报单位,与升级前一致);只认 athleteId。
type entryAthletesPayload struct {
Athletes []entryAthleteDTO `json:"athletes"`
RequestID string `json:"requestId"`
}
// entryBatchPayload 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
+6
View File
@@ -861,8 +861,14 @@ func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, er
if err != nil {
return nil, err
}
// 队员同理:一条报名 0..N 个,单独查一次按 entry_id 挂上(空数组 = 只报单位)。
athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
for i := range out {
out[i].TeamShares = entrySharesOf(shares[out[i].ID], out[i].TeamID, out[i].TeamCode, out[i].TeamName)
out[i].Athletes = entryAthletesOf(athletes[out[i].ID])
}
return out, nil
}
+78 -3
View File
@@ -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)。那一路一律写空串,
+14
View File
@@ -244,8 +244,14 @@ func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]
if err != nil {
return nil, err
}
// 队员(devdocs/20)同理随报名进引擎:每个队员各拿一份完整的个人分(见 domain 的 memberCopy)。
members, err := loadEntryAthletesQ(ctx, q, eventID)
if err != nil {
return nil, err
}
for i := range out {
out[i].TeamShares = shares[out[i].ID]
out[i].Athletes = members[out[i].ID]
}
return out, nil
}
@@ -571,6 +577,11 @@ func resultStatusPtr(s domain.ResultStatus) *string {
// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源),
// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。
//
// 队员的个人副本(MemberCopy)一律不写回:它们是报名项那一份的复制品,写回等于让「同一条报名
// 的每个队员」轮流改写同一个录入位——团体报名项的 results.rank 是团体手工通道的输入,
// 被队员副本覆盖后,第二次发布会把自动名次当成团体名次再发一遍分(A-1 的同一个坑)。
// 该写的那一份是报名项自己(上面第一条规则已经管着它),一条成绩始终只有一个写入者。
//
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
@@ -583,6 +594,9 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
if rr.MemberCopy {
continue
}
if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) {
continue
}
+2
View File
@@ -282,6 +282,8 @@ func (s *server) routes(mux *http.ServeMux) {
// 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。
// 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。
mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler))
// 队员(devdocs/20):整组替换一条团体报名的队员,改完当场重算并推榜——队员决定谁拿个人分、个人奖牌。
mux.HandleFunc("PUT /api/entries/{id}/athletes", s.write(auth.RoleRecorder, s.updateEntryAthletesHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))