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` 两张)。
This commit is contained in:
AskaEth committed 2026-09-14 22:57:03 +08:00
1 parent d11cab31db
commit c5ce1878fc
18 files changed
+1039 -36

No files matched your search

+50 -6
View File
@@ -344,8 +344,19 @@ func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.
// ---------- 计分 ----------
// computeEventScore 计算某小项(或某组别)的流水与名次明细。
// computeEventScore 是缺省口径(计分发布)的计分,供发布之外的各条重算路径使用。
//
// ⚠️ 口径只有**发布这一次动作**带得上(见 publishResults 的 RankOnly):本函数是「按此刻已发布
// 的成绩重算 = 计分」,被 立即结算 / 作废成绩 / 批量退赛 / 撤销发布 / 改队员与份额 / 改积分模板 /
// 规则包导入 共用。也就是说:一批「只更新名次」发布的成绩,在这些动作里照样会被算分——它们算的
// 本来就是「此刻 results 里已发布的成绩」,与改动前一致(改动前这些路径也一样会算)。
// 现场若要把某一组彻底排除在计分外,正确做法是把该组退回草稿(撤销发布),而不是只靠勾选。
func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) {
return computeEventScoreOpts(ctx, q, eventID, categoryID, domain.ScoreOptions{})
}
// computeEventScoreOpts 计算某小项(或某组别)的流水与名次明细,并带上引擎口径。
func computeEventScoreOpts(ctx context.Context, q queryer, eventID, categoryID int64, opts domain.ScoreOptions) (domain.EventScore, error) {
ev, err := loadEventQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
@@ -403,6 +414,7 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
RecordBreaks: breaks,
// 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。
TeamRanks: teamRanks[cat.ID],
Options: opts,
})
if err != nil {
return domain.EventScore{}, mapDomainError(err)
@@ -805,6 +817,21 @@ func skipNote(skipped int) string {
return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped)
}
// publishNote 是本条发布写进修订说明的文案:只更新名次时必须留一句——榜单一点没动,
// 事后复盘(变更历史)得能看出这一批是预赛口径,而不是「发布失败」或「没算分」。
// 计分发布照旧只带跳过提示,一个字符都不加(老行为不许多出文案)。
func publishNote(rankOnly bool, skipped int) string {
skip := skipNote(skipped)
if !rankOnly {
return skip
}
const rankOnlyNote = "本次按「只更新名次」发布:名次已更新,未计分、未发奖牌,榜单分数与已有积分不变"
if skip == "" {
return rankOnlyNote
}
return rankOnlyNote + ";" + skip
}
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
var raw string
@@ -815,7 +842,7 @@ func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
return boolDefault(raw, true), nil
}
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
func (s *server) publishResults(ctx context.Context, in publishResultsPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
@@ -883,14 +910,29 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
// 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落,
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
score, err := computeEventScore(ctx, tx, in.EventID, catID)
score, err := computeEventScoreOpts(ctx, tx, in.EventID, catID, domain.ScoreOptions{RankOnly: in.RankOnly})
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil {
if in.RankOnly {
// 只更新名次:名次已写回 results.rank(上面那一步),到此为止——流水与通道名次一个
// 字节都不动。
//
// 为什么不顺手重建通道名次:channel_ranks 只存名次,奖牌是「rank 在前三 + 小项结算
// 奖牌 + 通道发牌」算出来的,**不看分数**。预赛名次写进去 = 给预赛发奖牌;反过来,
// 本组上次若已按决赛计分发布过,重建会把那批名次(连同决赛的奖牌)抹掉,而决赛那份
// 分还留在流水里 —— 榜上就会「分还在、牌没了」。
//
// 为什么不顺手写流水:writeLedgerTx 会先把范围内的旧流水整批作废再写新的,
// 而决赛那份积分是有效的,不该被一次预赛发布抹掉。
//
// 于是本次发布只动 results.rank:录入页的名次列、大屏最新成绩、分项成绩表读的都是它。
// 名单/明细里「名次」关联不到 channel_ranks 时本来就回落到 results.rank(见
// breakdownRankExpr / athleteDetailRankExpr),所以能看到的都跟着更新。
} else if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil {
return nil, err
}
}
@@ -917,14 +959,16 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, skipNote(skipped)); err != nil {
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, publishNote(in.RankOnly, skipped)); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
if settle {
// 只更新名次时榜单同样不推:分数与奖牌一个数字都没变,推一版「没变的榜单」只会让现场
// 以为榜单跟着这批成绩动了。成绩本身的变更由上面 recordChanges 推出去,大屏据此重拉名次。
if settle && !in.RankOnly {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}