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 }