Files
Scoreboard/server/internal/httpapi/team_ranks.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

361 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}