## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
427 lines
16 KiB
Go
427 lines
16 KiB
Go
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
|
||
}
|