Files
Scoreboard/server/internal/httpapi/recompute_heal.go
T
AskaEth c5ce1878fc feat(publish): 「以决赛成绩发布」开关 —— 非决赛发布只更新名次、不计分;版本 dev_0.5.8
## 需求(主人原话)
> 只有在最后一轮(决赛)的成绩才算分,你给发布按钮边上带一个选项,勾选之后**当前小项/组以决赛成绩发布**,
> 不然的话就**只更新名次(非决赛) 不加分**。

**设计好处**:不引入「轮次」模型,而是**在已有的「发布」动作上加一个口径** —— 分轮次比赛时,同一批人各轮成绩各发各的,
榜单只认决赛那一轮的分。引擎本来就有一条「只排名不计分」的通路,这次是**把它在发布入口露出来**,没有另造算分分支。

## 契约
`POST /api/results/publish` 请求体新增 **`rankOnly: boolean`**:
- **缺省 / `false`** = 计分发布,**与改动前逐字节一致**(老客户端不带这个字段走现状路径);
- **`true`** = 本次只更新名次。

**只更新名次时写**:`results.state/verified_by/verified_at`(照旧)、`results.rank`(预赛排名次是刚需)、
整小项录完时的 `events.status`、`result_revisions`(message 写明本次口径)、`change_log(entity=result)`(大屏据此重拉名次)。
**不写**:`score_ledger`(**既不加,也不作废任何已有流水**)、`channel_ranks`(不重建也不删除)、榜单快照版本。

⇒ 于是会出现预期的「**名次变了、分还是旧的**」(本组上次按决赛发布过时)—— 确认框与录入页常驻提示都点名说了这件事。

## 界面
- 录入页「发布」按钮**左边**:勾选框「**以决赛成绩发布(勾选才计分发牌)**」,**默认不勾**;
  旁边徽标随状态切「**只更新名次(不计分)**」(警示色)/「**计分发布(算分发牌)**」(绿色)。
- 确认框:标题切「确认按决赛成绩发布该组?」/「确认发布该组(只更新名次)?」;不勾时不再报「影响 N 个单位、最大分差 X 分」;
  已有在榜分时加一句「本组已有 X 分在榜…那份积分与奖牌会原样保留、不随名次变动」。
- **常驻「在榜口径」提示**:系统不记「上次勾了什么」,判据用**榜面事实**(已发布成绩的流水合计)——
  「本组在榜上只有名次(上次是按只更新名次发布的)」/「本组在榜上是已计分的成绩(已有 X 分来自上一次按决赛发布)」。
- **发布成功后勾选复位**:勾选是「这一次发布」的口径,不是页面设置(免得换小项时把预赛当决赛算分)。
- ⚠️ **审核页是第二个发布入口**,同样加了口径 —— 否则从审核页发布预赛会被**悄悄算成分**。

## 兼容性(回答「能不能兼容现有数据包」)
- **没有任何数据库变更、没有迁移**:纯行为改动,多带一个请求字段。
- 旧客户端 / 旧数据包 / 旧库照旧工作;不带该字段时与改动前**逐字节一致**(用改动前采集的整份写库 golden 对拍证明)。
- 反向(用新版导出的项目包去旧版本导入)仍是不支持的,与之前几版一致。

## 验证
- **先红后绿**:改动前跑新用例,红在「只更新名次的发布动了已有流水」(贴出 `score_ledger` 前后对比);
- 另有 `rank_only_heal_test.go` 钉住「只更新名次发布过的库,**启动自愈不许把它算成分**」;
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** 0 fail / Console 68 例 0 fail。

## 文档
`docs/升级说明.md` 新增 `dev_0.5.8` 一节。

## 版本
`dev_0.5.7` → **`dev_0.5.8`**(无数据迁移);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 22:57:03 +08:00

94 lines
4.6 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, 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
}