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 }) }