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, scoredLedgerRows 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), (SELECT COUNT(*) FROM score_ledger WHERE source_type IN ('result', 'record_bonus'))`, ).Scan(&rankRows, &publishedRankedEvents, &scoredLedgerRows); err != nil { return 0, false, fmt.Errorf("检查通道名次自愈条件: %w", err) } return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0 && scoredLedgerRows > 0, nil }