package httpapi import ( "context" "database/sql" "net/http" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" ) // 本文件实现「团体手工名次」的读写(devdocs/17 §七)。 // // 团体名次不再进 results.rank:它属于单位而不是某一条报名项,落在 channel_manual_ranks, // 由 (小项, 组别, 单位, scope, basis) 定位。通道 id 会随规则包导入 / 通道替换而变, // 所以这张表故意不挂通道外键,读写也都按 (scope, basis) 重新找通道。 // teamManualRankDTO 是一条「单位 → 名次」记录;rank 为 0 只出现在请求体里,表示清空。 type teamManualRankDTO struct { TeamID int64 `json:"teamId"` Rank int `json:"rank"` } // teamRankChannelDTO 是团体名次读数里点名用的通道引用:只带渲染与写回所需的字段, // 不把整条通道配置(权重、开关等)复制一份出来,避免两处契约各自漂移。 type teamRankChannelDTO struct { ChannelID int64 `json:"channelId"` Scope string `json:"scope"` Basis string `json:"basis"` ProfileID int64 `json:"profileId"` } // teamRankTeamDTO 是启用单位清单的一行:录入页据此渲染「每个单位一个名次输入框」。 type teamRankTeamDTO struct { TeamID int64 `json:"teamId"` Code string `json:"code"` Name string `json:"name"` ShortName *string `json:"shortName"` } // teamRanksViewDTO 是团体名次读数(GET 的响应体,也是成绩录入接口随行下发的元素)。 // categoryId 只在随行下发时出现:按组别路径取时路径已经表明了组别,不再重复。 type teamRanksViewDTO struct { CategoryID int64 `json:"categoryId,omitempty"` Channels []teamRankChannelDTO `json:"channels"` Teams []teamRankTeamDTO `json:"teams"` Ranks []teamManualRankDTO `json:"ranks"` } // entryBoardDTO 是成绩录入接口(GET /api/events/{id}/entries)的响应体:成绩行 + 团体名次。 // 设计 17 §七要求录入页一次请求拿到两者,避免为了团体名次多开一轮。 // teamRanks 只在存在团体手工通道时非空(按组别各一个视图),没有时是空数组而不是 null。 type entryBoardDTO struct { Entries []entryWithResultDTO `json:"entries"` TeamRanks []teamRanksViewDTO `json:"teamRanks"` } // listEntryBoard 组装成绩录入页的完整读数。 func (s *server) listEntryBoard(ctx context.Context, eventID int64) (entryBoardDTO, error) { entries, err := s.listEntriesWithResults(ctx, eventID) if err != nil { return entryBoardDTO{}, err } views, err := s.teamRanksViews(ctx, s.ev, eventID) if err != nil { return entryBoardDTO{}, err } return entryBoardDTO{Entries: entries, TeamRanks: views}, nil } // teamManualChannels 从整小项的通道里挑出团体手工通道(scope=team + basis=manual)。 // 只有这类通道会读 channel_manual_ranks,录入入口也只认它。 func teamManualChannels(channels []domain.ScoreChannel) []domain.ScoreChannel { out := make([]domain.ScoreChannel, 0, 1) for _, ch := range channels { if ch.Scope == domain.ScopeTeam && ch.Basis == domain.BasisManual { out = append(out, ch) } } return out } // activeTeams 读启用单位清单(active=1 且未假删除);假删除的单位不出现在录入页。 func (s *server) activeTeams(ctx context.Context, q queryer) ([]teamRankTeamDTO, error) { rows, err := q.QueryContext(ctx, `SELECT id, code, name, short_name FROM teams WHERE active = 1 AND deleted_at IS NULL ORDER BY sort_order, id`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]teamRankTeamDTO, 0) for rows.Next() { var ( t teamRankTeamDTO shortName sql.NullString ) if err := rows.Scan(&t.TeamID, &t.Code, &t.Name, &shortName); err != nil { return nil, internal(err) } t.ShortName = strPtr(shortName) out = append(out, t) } return out, rows.Err() } // teamRanksView 组装某组别的团体名次读数:已填名次 + 团体手工通道 + 启用单位清单。 // 没有团体手工通道时 channels 为空、ranks 为空(不是 404):录入页据此隐藏整块录入区。 func (s *server) teamRanksView(ctx context.Context, q queryer, eventID, categoryID int64, withCategoryID bool) (teamRanksViewDTO, error) { channels, err := loadChannelsQ(ctx, q, eventID) if err != nil { return teamRanksViewDTO{}, err } teams, err := s.activeTeams(ctx, q) if err != nil { return teamRanksViewDTO{}, err } ranks, err := loadTeamRanksQ(ctx, q, eventID, categoryID) if err != nil { return teamRanksViewDTO{}, err } view := buildTeamRanksView(channels, teams, ranks[categoryID]) if withCategoryID { view.CategoryID = categoryID } return view, nil } // teamRanksViews 组装整小项的团体名次读数(每个有团体手工通道的组别一个视图), // 供成绩录入接口随行下发:录入页一次请求就拿到「成绩行 + 团体名次」,少发一轮。 // // 三个基础读数各查一次再分发:逐组别调用 teamRanksView 会把通道、单位清单重复查 N 遍。 // 没有团体手工通道的小项直接回空列表——不返回「看起来像有配置」的空视图。 func (s *server) teamRanksViews(ctx context.Context, q queryer, eventID int64) ([]teamRanksViewDTO, error) { channels, err := loadChannelsQ(ctx, q, eventID) if err != nil { return nil, err } if len(teamManualChannels(channels)) == 0 { return []teamRanksViewDTO{}, nil } categories, err := loadCategoriesQ(ctx, q, eventID) if err != nil { return nil, err } teams, err := s.activeTeams(ctx, q) if err != nil { return nil, err } ranks, err := loadTeamRanksQ(ctx, q, eventID, 0) if err != nil { return nil, err } out := make([]teamRanksViewDTO, 0, len(categories)) for _, cat := range categories { view := buildTeamRanksView(channels, teams, ranks[cat.ID]) view.CategoryID = cat.ID out = append(out, view) } return out, nil } // buildTeamRanksView 把已读到的通道 / 单位 / 名次拼成一个视图,不做任何查询。 func buildTeamRanksView(channels []domain.ScoreChannel, teams []teamRankTeamDTO, ranks []domain.TeamManualRank) teamRanksViewDTO { view := teamRanksViewDTO{ Channels: make([]teamRankChannelDTO, 0, 1), Teams: teams, Ranks: make([]teamManualRankDTO, 0, len(ranks)), } for _, ch := range teamManualChannels(channels) { view.Channels = append(view.Channels, teamRankChannelDTO{ ChannelID: ch.ID, Scope: string(ch.Scope), Basis: string(ch.Basis), ProfileID: ch.ProfileID, }) } for _, r := range ranks { view.Ranks = append(view.Ranks, teamManualRankDTO{TeamID: r.TeamID, Rank: r.Rank}) } return view } // updateTeamRanks 写入某组别的团体手工名次并同步重算。 // // 校验口径(devdocs/17 §4.6):rank 为负 → 400(0 是合法的「清空」);teamId 不存在或已假删除 → 400; // scope/basis 与该小项的通道对不上 → 400。同名次允许多个单位(并列),引擎按 tieMode 处理。 // // 写成功后必须重算:名次进通道算分公式,不重算就是「界面显示已保存、榜单还是旧分」。 // 重算会一并重建该组别的 channel_ranks 并推榜单(recomputeLedgerTx + bumpStandings), // 大屏不需要等下一次发布。 func (s *server) updateTeamRanks(ctx context.Context, eventID, categoryID int64, in teamRanksPayload) (teamRanksViewDTO, error) { scope := domain.Scope(strings.TrimSpace(in.Scope)) basis := domain.Basis(strings.TrimSpace(in.Basis)) if scope != domain.ScopeTeam || basis != domain.BasisManual { return teamRanksViewDTO{}, badRequest("团体名次只支持 scope=team + basis=manual", in) } channels, err := loadChannelsQ(ctx, s.ev, eventID) if err != nil { return teamRanksViewDTO{}, err } if len(teamManualChannels(channels)) == 0 { return teamRanksViewDTO{}, badRequest("该小项没有 scope=team + basis=manual 的计分通道", in) } for i, r := range in.Ranks { if r.TeamID == 0 { return teamRanksViewDTO{}, badRequest("ranks[].teamId 必填", i) } if r.Rank < 0 { return teamRanksViewDTO{}, badRequest("ranks[].rank 不能为负数(0 表示清空)", i) } } if err := s.checkTeamRankTargets(ctx, in.Ranks); err != nil { return teamRanksViewDTO{}, err } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return teamRanksViewDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() actorID := middleware.User(ctx).ID for _, r := range in.Ranks { if r.Rank <= 0 { if _, err := tx.ExecContext(ctx, `DELETE FROM channel_manual_ranks WHERE event_id = ? AND category_id = ? AND team_id = ? AND scope = ? AND basis = ?`, eventID, categoryID, r.TeamID, scope, basis); err != nil { return teamRanksViewDTO{}, internal(err) } continue } if _, err := tx.ExecContext(ctx, `INSERT INTO channel_manual_ranks (event_id, category_id, team_id, scope, basis, rank, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(event_id, category_id, team_id, scope, basis) DO UPDATE SET rank = excluded.rank, updated_by = excluded.updated_by, updated_at = datetime('now')`, eventID, categoryID, r.TeamID, scope, basis, r.Rank, nullID(actorID)); err != nil { return teamRanksViewDTO{}, internal(err) } } score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "团体名次录入") if err != nil { return teamRanksViewDTO{}, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return teamRanksViewDTO{}, err } if err := s.bumpStandings(ctx, tx, actorID); err != nil { return teamRanksViewDTO{}, err } if err := tx.Commit(); err != nil { return teamRanksViewDTO{}, internal(err) } return s.teamRanksView(ctx, s.ev, eventID, categoryID, false) } // checkTeamRankTargets 校验写入目标单位存在且未被假删除。 // 只校验要落名次的行(rank > 0):清空(rank = 0)是删除动作,单位后来被删了也照样该能清掉。 // 批量查一次再比对,缺哪个报哪个,现场能直接照着改。 func (s *server) checkTeamRankTargets(ctx context.Context, ranks []teamManualRankDTO) error { ids := make([]int64, 0, len(ranks)) seen := make(map[int64]bool, len(ranks)) for _, r := range ranks { if r.TeamID == 0 || r.Rank <= 0 || seen[r.TeamID] { continue } seen[r.TeamID] = true ids = append(ids, r.TeamID) } 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 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 internal(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 } // teamRanksHandler 是 GET /api/events/{id}/categories/{cid}/team-ranks。 // 录入页(guest 也能看成绩)据此渲染团体名次区块,故与成绩读取同权限。 func (s *server) teamRanksHandler(w http.ResponseWriter, r *http.Request) (any, error) { eventID, err := pathID(r, "id") if err != nil { return nil, err } categoryID, err := pathID(r, "categoryId") if err != nil { return nil, err } // 组别必须属于该小项:路径拼错时要明确报「组别不存在」,不能拿别的小项的组别名次回话。 if _, err := s.getCategory(r.Context(), eventID, categoryID); err != nil { return nil, err } return s.teamRanksView(r.Context(), s.ev, eventID, categoryID, false) } // updateTeamRanksHandler 是 PUT 同路径:写团体手工名次并重算,返回保存后的读数。 func (s *server) updateTeamRanksHandler(w http.ResponseWriter, r *http.Request) (any, error) { eventID, err := pathID(r, "id") if err != nil { return nil, err } categoryID, err := pathID(r, "categoryId") if err != nil { return nil, err } var in teamRanksPayload if err := decodeJSON(r, &in); err != nil { return nil, err } before, err := s.getCategory(r.Context(), eventID, categoryID) if err != nil { return nil, err } updated, err := s.updateTeamRanks(r.Context(), eventID, categoryID, in) if err != nil { return nil, err } s.audit(r.Context(), "event.team_ranks.update", "event_category", categoryID, map[string]any{"category": before, "ranks": in.Ranks}, updated) return updated, nil }