## 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 例 + 语法检查。
361 lines
13 KiB
Go
361 lines
13 KiB
Go
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
|
||
}
|