Files
Scoreboard/server/internal/httpapi/recompute_heal.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

88 lines
3.9 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"
"errors"
"fmt"
"log/slog"
"time"
)
// HealResult 是一次启动自愈的结果,供调用方记日志、也供测试断言「到底触发没有」。
type HealResult struct {
// Triggered 表示两个触发条件同时成立、本次确实执行了全量重算。
Triggered bool
// EventCount 是「有已发布名次的小项数」,即这次自愈要修的那些小项的规模。
EventCount int
// LedgerRows 是重算写回的积分流水条数(recomputeAllEvents 的返回值)。
LedgerRows int
}
// HealChannelRanksOnce 修「老库升级后奖牌榜整块归零」。
//
// 背景:迁移 0020 只建 channel_ranks 表、不回填历史名次;而奖牌统计与导出侧现在
// 都只读这张表(httpapi.buildRanked / realtime.snapshot.loadRanked / export.loadRanked)。
// 于是老库升级完成到「下一次发布 / 重算」之间表是空的,大屏与管理面板的金银铜全是 0,
// 现场正开着运动会时这是不可接受的。启动时补一次全量重算即可把名次按通道重建出来。
//
// 触发条件(必须同时成立):
// 1. channel_ranks 一行都没有。表非空说明发布 / 重算已经建过名次,不需要也不该再动它;
// 2. results 里存在「已发布且名次为正」的行。说明库里确实有该显示在奖牌榜上的历史成绩。
//
// 两者同时成立才是「老库升级」这一种形态:全新库的表是空的但没有已发布成绩,
// 无成绩的库同样是空的但没有名次可补,都不触发。
// 规则包导入、发布、模板改动等既有路径本来就会重算(写成非空),
// 所以自愈成功后下次启动自然不再触发。
//
// 调用方必须在**开始监听端口之前**同步调用:一旦对外服务,大屏会先拿到一帧空白奖牌榜。
// 参数沿用 httpapi.Options,只用到 Logger 与 Event,方便 main 用同一份配置调用。
func HealChannelRanksOnce(ctx context.Context, opts Options) (HealResult, error) {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
if opts.Event == nil {
return HealResult{}, errors.New("赛事库未接入,无法自愈通道名次")
}
eventCount, needed, err := channelRankHealNeeded(ctx, opts.Event)
if err != nil {
return HealResult{}, err
}
if !needed {
return HealResult{EventCount: eventCount}, nil
}
logger.Info("检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算",
"events", eventCount)
// 重新组装一个最小 server:recomputeAllEvents 只依赖 s.ev 与 s.logger,
// 复用同一份实现(含事务语义)而不是另起一套重算,避免两条路径的结果漂移。
srv := &server{logger: logger, ev: opts.Event}
start := time.Now()
rows, err := srv.recomputeAllEvents(ctx, 0)
elapsed := time.Since(start)
if err != nil {
return HealResult{Triggered: true, EventCount: eventCount}, err
}
logger.Info("一次性全量重算完成,通道名次与奖牌榜已补齐",
"events", eventCount, "ledgerRows", rows, "elapsed", elapsed)
return HealResult{Triggered: true, EventCount: eventCount, LedgerRows: rows}, nil
}
// channelRankHealNeeded 读出自愈的两个判据,返回「有已发布名次的小项数」与是否需要触发。
// 两条读数走同一次查询,避免两次查询之间库被改动导致判据互不自洽。
func channelRankHealNeeded(ctx context.Context, event *sql.DB) (eventCount int, needed bool, err error) {
var rankRows, publishedRankedEvents int64
if err := event.QueryRowContext(ctx,
`SELECT (SELECT COUNT(*) FROM channel_ranks),
(SELECT COUNT(DISTINCT event_id) FROM results
WHERE state = 'published' AND rank IS NOT NULL AND rank > 0)`,
).Scan(&rankRows, &publishedRankedEvents); err != nil {
return 0, false, fmt.Errorf("检查通道名次自愈条件: %w", err)
}
return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0, nil
}