package httpapi import ( "context" "database/sql" "errors" "fmt" "net/http" "strings" ) // 本文件是「队名」的手工修改:PUT /api/entries/{id}/squad(队名本身见迁移 0025)。 // // 为什么需要这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。 // 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**(老那条仍挂在库里)—— // 「填错一个字就得删掉重建」在现场是不能接受的。 // updateEntrySquad 改一条团体报名的队名,返回更新后的完整读数。 // // **不重算**,这是有意的:squad 只是给现场看的标签(榜单、导出件、大屏把「第一分公司」 // 显示成「第一分公司青年队」)。它不参与名次 / 积分 / 奖牌的任何一个口径——全仓读 squad 的 // 只有导出与报名读数这两处展示代码,引擎(internal/domain)与 score_ledger / results / // channel_ranks 一个字都不看它。所以改它时算分输出的每个字节都不变,这里也就没有 // recomputeLedgerTx:白算一遍不但慢,还会白白推一次榜(大屏会跟着闪一下没变的名次)。 // 同档的另一个先例是批次 entries.heat,同样是纯展示字段,改它也不重算(见 setEntriesHeat)。 func (s *server) updateEntrySquad(ctx context.Context, entryID int64, squad string) (entryDTO, error) { // trim 统一收在这里:导入侧读单元格时已经 trim 过(handlers_config.go 的 cell()), // 手工改走的是另一条路,不 trim 就会把「一队」与「一队 」当成两个队。 squad = strings.TrimSpace(squad) entry, err := s.getEntry(ctx, entryID) if err != nil { return entryDTO{}, err } // 带运动员的报名按 (组别, 单位, 运动员) 唯一,队名根本不参与它的查重键 // (0025 的部分索引带 WHERE athlete_id IS NULL)。写进去既不生效,又会让人以为 // 「这个人报了两个队」,所以直接拒掉并说清原因。 if entry.AthleteID != nil { return entryDTO{}, badRequest("这条报名带着运动员,队名只对团体报名生效:队名分的是「同一个单位在同一个组别的第几支队」,带运动员的报名按(组别、单位、运动员)唯一,改队名变不出第二支队", nil) } // 同组别同单位已经有这个队名时先拦下来:撞索引的报错是英文的约束原文, // 现场看不出是哪个单位、哪个队,也不知道该改哪一格。 var other int64 err = s.ev.QueryRowContext(ctx, `SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ? AND id <> ?`, entry.CategoryID, entry.TeamID, squad, entryID).Scan(&other) switch { case err == nil: return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil) case !errors.Is(err, sql.ErrNoRows): return entryDTO{}, internal(err) } if _, err := s.ev.ExecContext(ctx, `UPDATE entries SET squad = ? WHERE id = ?`, squad, entryID); err != nil { // 预检与 UPDATE 之间不是原子的(两个录入员同时把两条报名改成同一个队名, // 或导入正好在这两步之间落库):真撞上唯一索引时也要翻成同一句人话。 if isUniqueViolation(err) { return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil) } return entryDTO{}, internal(err) } return s.getEntry(ctx, entryID) } // squadTakenMessage 是「这个队名在本组别已经被同单位占了」的现场文案。 // // 队名非空那一档导入侧也在用(importedEntryConflictError 的并发分支):两处说的若不是 // 同一句话,操作员在导入里学到的那句到了改队名页面就不成立,只能重新猜系统在拦什么。 // // 队名留空那一档与导入侧**故意不同**:那边点的是报名表里的「队伍列」,这里没有那一格可点, // 只能点「队名留空」。其余口径两边一致:空 = 不区分队伍,一个单位在一个组别只能有一条。 func squadTakenMessage(teamCode, squad string) string { if squad == "" { return fmt.Sprintf("单位 %s 在本组别已经有一条不分队的团体报名:队名留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请把两支队分别写清队名(例如 一队 / 二队)", teamCode) } return fmt.Sprintf("单位 %s 在本组别已经有一条队伍「%s」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队)", teamCode, squad) } // updateEntrySquadHandler 是 PUT /api/entries/{id}/squad:改一条团体报名的队名, // 成功后返回这条报名的完整读数(与份额那条接口一样,前端拿它直接刷新那一行)。 // // 权限 / CSRF 与 PUT /api/entries/{id}/team-shares 同档:它改的也是报名。 func (s *server) updateEntrySquadHandler(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { return nil, err } var in entrySquadPayload 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.updateEntrySquad(r.Context(), id, in.Squad) if err != nil { return nil, err } s.audit(r.Context(), "entry.squad.update", "entry", id, map[string]any{"squad": before.Squad}, map[string]any{"squad": updated.Squad}) return updated, nil }) }