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

+12 -4
View File
@@ -13,6 +13,13 @@ const rankEpsilon = 1e-9
type ScoreOptions struct {
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
IncludeDrafts bool
// RankOnly 为 true 时本次只产出名次、不产出任何流水分值(发布侧的「只更新名次」)。
//
// 与「小项没有计分通道」那条退化路径同一个出口:名次照算(预赛排名次是刚需),
// 但每条名次的 Points 归零、Ledger 为空、破纪录加分也不产生。
// 区别只在于通道**仍然逐条照原样参与排名**——退化路径会把小项压成一条合成通道,
// 那会改掉名次口径与主体,不是这里想要的。
RankOnly bool
}
// EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。
@@ -91,12 +98,13 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
}
}
var out EventScore
rankOnly := len(in.Channels) == 0
// 只排名不计分:scored=false 或没有计分通道时都不产出任何流水(含破纪录加分)。
scoreEnabled := in.Event.Scored && !rankOnly
noChannels := len(in.Channels) == 0
// 只排名不计分:scored=false、没有计分通道、或本次声明「只更新名次」时都不产出任何流水
// (含破纪录加分)。
scoreEnabled := in.Event.Scored && !noChannels && !in.Options.RankOnly
channels := sortedChannels(in.Channels)
if rankOnly {
if noChannels {
channels = []ScoreChannel{rankOnlyChannel(in)}
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (s *server) saveDraftHandler(w http.ResponseWriter, r *http.Request) (any,
}
func (s *server) publishHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in publishPayload
var in publishResultsPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
+14
View File
@@ -33,6 +33,20 @@ type publishPayload struct {
RequestID string `json:"requestId"`
}
// publishResultsPayload 是 POST /api/results/publish 的请求体:在发布请求体上多一个发布口径。
//
// 单独一个类型(而不是把字段塞进 publishPayload)是因为「撤销发布 / 立即结算」没有
// 「按决赛发布」这回事:共用一个结构只会让它们静默吃下一个毫无作用的字段。
type publishResultsPayload struct {
publishPayload
// RankOnly 为 true 时本次发布只更新名次:写 results.rank,但不产生积分流水、
// 不重建通道名次、不发奖牌、不动榜单分数(名次照旧更新——预赛也要排名次)。
//
// 缺省 / false = 计分发布,与改动前逐字节一致:老客户端(不带这个字段)与勾选了
// 「以决赛成绩发布」的新客户端走的是同一条路。
RankOnly bool `json:"rankOnly"`
}
type voidResultPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
@@ -0,0 +1,379 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 本文件钉住「以决赛成绩发布」这个新勾选项(发布请求的 rankOnly 字段)。
//
// 口径:
// - 缺省 / false = 与改动前逐字节一致的计分发布(算分、发牌、上榜);
// - true = 只更新名次:写 results.rank,不写积分流水、不重建 channel_ranks、不动榜单。
//
// 前半段(golden 快照)是「勾上时 = 现状路径」的门槛:改动前先把计分发布的整份写库结果
// 落成基线,改动后逐字节对拍。后半段是新行为的红绿用例。
// ---------- 计分发布的整份快照(改动前采集的基线) ----------
// snapshotMode 是「发布请求里 rankOnly 怎么给」的三种给法。
type snapshotMode int
const (
// rankOnlyOmitted 完全不带这个字段:老客户端 / 老脚本的调用形态。
rankOnlyOmitted snapshotMode = iota
// rankOnlyFalse 显式 false:新客户端「勾了以决赛成绩发布」的调用形态。
rankOnlyFalse
// rankOnlyTrue 显式 true:新客户端「没勾」的调用形态。
rankOnlyTrue
)
// publishWith 按 mode 调发布接口(rankOnly 完全按 mode 决定带不带、带什么)。
func publishWith(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string, mode snapshotMode) {
t.Helper()
body := map[string]any{"eventId": eventID, "requestId": tag + "-publish"}
switch mode {
case rankOnlyOmitted:
case rankOnlyFalse:
body["rankOnly"] = false
case rankOnlyTrue:
body["rankOnly"] = true
}
status, resp := env.do(client, http.MethodPost, "/api/results/publish", body, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
}
}
// legacyScenarioSnapshot 复刻「改动前」的计分发布现场,并把结果整份 dump 成可逐字节比较的文本。
//
// 场景刻意铺宽一点,让快照覆盖发布真正会写的东西:
// - 单通道个人项目(按成绩自动排名 → results.rank / 流水 / channel_ranks / 榜单奖牌);
// - 双通道项目(个人自动 + 团体手工),团体名次按单位录入(这类流水没有报名项,
// 按组别重算时的作废范围与其它流水不同)。
//
// 快照里的 id、分数、名次全部来自数据库自增与引擎输出,跑两次逐字节一致(本文件下方用例自证)。
func legacyScenarioSnapshot(t *testing.T, mode snapshotMode) string {
t.Helper()
env, ids, client := recorderWithSeed(t)
// 场景一:单通道个人项目(seed 自带),两条成绩。
if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"requestId": "snap-1-draft",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
publishWith(t, env, client, ids.event, "snap-1", mode)
// 场景二:双通道(个人自动 + 团体手工),团体名次按单位录入。
eventID, categoryID, entries := seedDualChannelTeamEvent(t, env, ids)
if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID,
"categoryId": categoryID,
"requestId": "snap-2-draft",
"results": []map[string]any{
{"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
{"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
{"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
var teamC int64
if err := env.ev.QueryRow(`SELECT id FROM teams WHERE code = 'C'`).Scan(&teamC); err != nil {
t.Fatalf("读 C 队: %v", err)
}
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 2},
{"teamId": ids.teamB, "rank": 1},
{"teamId": teamC, "rank": 3},
}); status != http.StatusOK {
t.Fatalf("保存团体名次应 200,得到 %d %+v", status, resp.Error)
}
publishWith(t, env, client, eventID, "snap-2", mode)
var b []byte
b = append(b, fmt.Sprintf("### results(id / entry_id / rank / value / state)\n%s\n",
tableDump(t, env, `SELECT id, entry_id, COALESCE(rank, -1) AS rank, COALESCE(result_value, -1) AS value, state
FROM results ORDER BY entry_id`))...)
b = append(b, fmt.Sprintf("### score_ledger(全部列,含作废)\n%s\n",
tableDump(t, env, `SELECT id, team_id, COALESCE(athlete_id, -1) AS athlete, COALESCE(entry_id, -1) AS entry,
COALESCE(result_id, -1) AS result, source_type, source_id, points, count_to_team, voided
FROM score_ledger ORDER BY id`))...)
b = append(b, fmt.Sprintf("### channel_ranks\n%s\n",
tableDump(t, env, `SELECT id, event_id, category_id, channel_id, basis, scope, subject_key,
COALESCE(entry_id, -1) AS entry, COALESCE(result_id, -1) AS result,
COALESCE(athlete_id, -1) AS athlete, COALESCE(team_id, -1) AS team,
rank, points, COALESCE(result_value, -1) AS value, COALESCE(status, '-') AS status
FROM channel_ranks ORDER BY id`))...)
b = append(b, fmt.Sprintf("### channel_manual_ranks\n%s\n",
tableDump(t, env, `SELECT id, event_id, category_id, team_id, scope, basis, rank
FROM channel_manual_ranks ORDER BY id`))...)
b = append(b, fmt.Sprintf("### result_revisions(action / message)\n%s\n",
tableDump(t, env, `SELECT id, event_id, action, COALESCE(message, '') AS message
FROM result_revisions ORDER BY id`))...)
status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK {
t.Fatalf("读积分榜应 200,得到 %d", status)
}
b = append(b, fmt.Sprintf("### GET /api/standings\n%s\n", resp.Data)...)
status, resp = env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
if status != http.StatusOK {
t.Fatalf("读运动员榜应 200,得到 %d", status)
}
b = append(b, fmt.Sprintf("### GET /api/standings/athletes\n%s\n", resp.Data)...)
return string(b)
}
// legacyPublishSnapshotGolden 是**改动前**(HEAD)跑同一段场景落下来的基线。
// 采集方式:把这棵树停在改动前,用与 legacyScenarioSnapshot 逐行相同的代码把 dump 写盘
// (~/.tmp/sbdump/publish_before.txt),再原样贴进来;跑两次确认逐字节一致后才作为基线。
const legacyPublishSnapshotGolden = `### results(id / entry_id / rank / value / state)
id=1 entry_id=1 rank=1 value=11.58 state=published
id=2 entry_id=2 rank=2 value=11.92 state=published
id=3 entry_id=3 rank=-1 value=90 state=published
id=4 entry_id=4 rank=-1 value=80 state=published
id=5 entry_id=5 rank=-1 value=70 state=published
### score_ledger(全部列,含作废)
id=1 team_id=1 athlete=1 entry=1 result=1 source_type=result source_id=1 points=10 count_to_team=1 voided=0
id=2 team_id=2 athlete=2 entry=2 result=2 source_type=result source_id=2 points=8 count_to_team=1 voided=0
id=3 team_id=2 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=15 count_to_team=1 voided=1
id=4 team_id=1 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=10 count_to_team=1 voided=1
id=5 team_id=3 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=8 count_to_team=1 voided=1
id=6 team_id=1 athlete=-1 entry=3 result=3 source_type=result source_id=3 points=10 count_to_team=1 voided=0
id=7 team_id=2 athlete=-1 entry=4 result=4 source_type=result source_id=4 points=8 count_to_team=1 voided=0
id=8 team_id=3 athlete=-1 entry=5 result=5 source_type=result source_id=5 points=6 count_to_team=1 voided=0
id=9 team_id=2 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=15 count_to_team=1 voided=0
id=10 team_id=1 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=10 count_to_team=1 voided=0
id=11 team_id=3 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=8 count_to_team=1 voided=0
### channel_ranks
id=1 event_id=1 category_id=1 channel_id=1 basis=result scope=athlete subject_key=e:1 entry=1 result=1 athlete=1 team=1 rank=1 points=10 value=11.58 status=ok
id=2 event_id=1 category_id=1 channel_id=1 basis=result scope=athlete subject_key=e:2 entry=2 result=2 athlete=2 team=2 rank=2 points=8 value=11.92 status=ok
id=6 event_id=2 category_id=2 channel_id=2 basis=result scope=athlete subject_key=e:3 entry=3 result=3 athlete=-1 team=1 rank=1 points=10 value=90 status=ok
id=7 event_id=2 category_id=2 channel_id=2 basis=result scope=athlete subject_key=e:4 entry=4 result=4 athlete=-1 team=2 rank=2 points=8 value=80 status=ok
id=8 event_id=2 category_id=2 channel_id=2 basis=result scope=athlete subject_key=e:5 entry=5 result=5 athlete=-1 team=3 rank=3 points=6 value=70 status=ok
id=9 event_id=2 category_id=2 channel_id=3 basis=manual scope=team subject_key=t:2 entry=-1 result=-1 athlete=-1 team=2 rank=1 points=15 value=-1 status=-
id=10 event_id=2 category_id=2 channel_id=3 basis=manual scope=team subject_key=t:1 entry=-1 result=-1 athlete=-1 team=1 rank=2 points=10 value=-1 status=-
id=11 event_id=2 category_id=2 channel_id=3 basis=manual scope=team subject_key=t:3 entry=-1 result=-1 athlete=-1 team=3 rank=3 points=8 value=-1 status=-
### channel_manual_ranks
id=1 event_id=2 category_id=2 team_id=1 scope=team basis=manual rank=2
id=2 event_id=2 category_id=2 team_id=2 scope=team basis=manual rank=1
id=3 event_id=2 category_id=2 team_id=3 scope=team basis=manual rank=3
### result_revisions(action / message)
id=1 event_id=1 action=publish message=
id=2 event_id=2 action=publish message=
### GET /api/standings
[{"teamId":2,"teamCode":"B","teamName":"第二分公司","shortName":"二司","color":null,"logoPath":null,"rank":1,"medalRank":2,"gold":1,"silver":2,"bronze":0,"medalTotal":3,"totalPoints":31,"rankChange":null},{"teamId":1,"teamCode":"A","teamName":"第一分公司","shortName":"一司","color":null,"logoPath":null,"rank":2,"medalRank":1,"gold":2,"silver":1,"bronze":0,"medalTotal":3,"totalPoints":30,"rankChange":null},{"teamId":3,"teamCode":"C","teamName":"第三分公司","shortName":"三司","color":null,"logoPath":null,"rank":3,"medalRank":3,"gold":0,"silver":0,"bronze":2,"medalTotal":2,"totalPoints":14,"rankChange":null}]
### GET /api/standings/athletes
[{"rank":1,"athleteId":1,"athleteName":"张伟","gender":"男","teamId":1,"teamCode":"A","teamName":"第一分公司","shortName":"一司","logoPath":null,"totalPoints":10,"gold":1,"silver":0,"bronze":0},{"rank":2,"athleteId":2,"athleteName":"李强","gender":"男","teamId":2,"teamCode":"B","teamName":"第二分公司","shortName":"二司","logoPath":null,"totalPoints":8,"gold":0,"silver":1,"bronze":0}]
`
// 勾上(显式 false)与不带字段(老客户端)都必须与改动前的字节完全一致:
// 名次、流水(含作废行与 id)、通道名次、团体手工名次、修订记录、两张榜。
func TestPublishScoringSnapshotMatchesPreChangeBaseline(t *testing.T) {
for _, tc := range []struct {
name string
mode snapshotMode
}{
{"不带 rankOnly 字段(老客户端)", rankOnlyOmitted},
{"显式 rankOnly=false(勾了以决赛成绩发布)", rankOnlyFalse},
} {
t.Run(tc.name, func(t *testing.T) {
got := legacyScenarioSnapshot(t, tc.mode)
if got != legacyPublishSnapshotGolden {
t.Fatalf("计分发布的写库结果与改动前基线不一致:\n--- 改动前 ---\n%s\n--- 现在 ---\n%s", legacyPublishSnapshotGolden, got)
}
})
}
}
// ---------- 只更新名次(rankOnly=true) ----------
// 非决赛发布:名次照旧更新(results.rank 写回),但不产生积分流水、不重建通道名次、不动榜单。
func TestPublishRankOnlyUpdatesRanksWithoutPoints(t *testing.T) {
env, ids, client := recorderWithSeed(t)
if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"requestId": "ro-draft",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
publishWith(t, env, client, ids.event, "ro", rankOnlyTrue)
// 名次照旧更新:预赛也要排名次。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 2, ids.event)
if got := rankOfEntry(t, env, ids.entryA); got != 1 {
t.Fatalf("最快的应第 1 名,得到 %d", got)
}
if got := rankOfEntry(t, env, ids.entryB); got != 2 {
t.Fatalf("第二快的应第 2 名,得到 %d", got)
}
// 不产生积分流水,也不落通道名次(通道名次是奖牌与榜单明细的来源,落了就等于发牌)。
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger`, 0)
assertCount(t, env, `SELECT COUNT(*) FROM channel_ranks`, 0)
// 榜单:总分与奖牌都不动。
for _, teamID := range []int64{ids.teamA, ids.teamB} {
if got := teamPoints(t, env, client, teamID); got != 0 {
t.Fatalf("单位 %d 的总分应为 0,得到 %v", teamID, got)
}
}
medals := medalsByTeam(t, env)
for _, teamID := range []int64{ids.teamA, ids.teamB} {
if m := medals[teamID]; m.Gold != 0 || m.Silver != 0 || m.Bronze != 0 {
t.Fatalf("单位 %d 不该拿到奖牌,得到 %+v", teamID, m)
}
}
// 已发布成绩的分下发里也不该出现分数。
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/results?eventId=%d", ids.event), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读成绩列表应 200,得到 %d %+v", status, resp.Error)
}
page := decodeData[struct {
Items []map[string]any `json:"items"`
}](t, resp)
if len(page.Items) != 2 {
t.Fatalf("成绩列表应有 2 条,得到 %d", len(page.Items))
}
for _, row := range page.Items {
if points, ok := row["points"]; ok && points != nil {
t.Fatalf("只更新名次的发布不该给出得分,得到 %v(成绩 %v)", points, row["id"])
}
}
}
// 非决赛发布不许动已有的积分流水:决赛那份分是有效的,不该被一次预赛发布抹掉。
//
// 这里刻意造出「名次变了、分还是旧的」:先把决赛成绩计分发布,再改成绩、按只更新名次发布。
// 名次会跟着新成绩换位,但流水与通道名次保持决赛那一版(并在界面上提示,见录入页确认框)。
func TestPublishRankOnlyKeepsExistingScoringLedger(t *testing.T) {
env, ids, client := recorderWithSeed(t)
if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"requestId": "keep-draft",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
// 决赛:勾上发布(= 现状路径)。
publishWith(t, env, client, ids.event, "keep-final", rankOnlyFalse)
ledgerBefore := tableDump(t, env, `SELECT id, team_id, entry_id, points, voided FROM score_ledger ORDER BY id`)
ranksBefore := tableDump(t, env, `SELECT id, subject_key, rank, points FROM channel_ranks ORDER BY id`)
standingsBefore := standingsDump(t, env, client)
// 成绩改过来(现场就是「退回草稿改完再发布」,这里直接改库,把被测面收窄到发布本身)。
env.exec(t, `UPDATE results SET result_value = 11.92, result_text = '11.92' WHERE entry_id = ?`, ids.entryA)
env.exec(t, `UPDATE results SET result_value = 11.58, result_text = '11.58' WHERE entry_id = ?`, ids.entryB)
// 预赛:不勾,只更新名次。
publishWith(t, env, client, ids.event, "keep-prelim", rankOnlyTrue)
// 名次跟着新成绩换了位。
if got := rankOfEntry(t, env, ids.entryA); got != 2 {
t.Fatalf("改了成绩后 A 应退到第 2 名,得到 %d", got)
}
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
t.Fatalf("改了成绩后 B 应升到第 1 名,得到 %d", got)
}
// 决赛那份流水一个字节都不能动(id 与 points 都不变,也不许新作废)。
if got := tableDump(t, env, `SELECT id, team_id, entry_id, points, voided FROM score_ledger ORDER BY id`); got != ledgerBefore {
t.Fatalf("只更新名次的发布动了已有流水:\n--- 发布前 ---\n%s\n--- 发布后 ---\n%s", ledgerBefore, got)
}
// 通道名次(奖牌与榜单明细的来源)同样不动。
if got := tableDump(t, env, `SELECT id, subject_key, rank, points FROM channel_ranks ORDER BY id`); got != ranksBefore {
t.Fatalf("只更新名次的发布重建了通道名次:\n--- 发布前 ---\n%s\n--- 发布后 ---\n%s", ranksBefore, got)
}
// 榜单总分与奖牌不动。
if got := standingsDump(t, env, client); got != standingsBefore {
t.Fatalf("只更新名次的发布改了榜单:\n--- 发布前 ---\n%s\n--- 发布后 ---\n%s", standingsBefore, got)
}
}
// 预赛(只更新名次)之后照样能按决赛发布:名次、流水、奖牌一次补齐。
func TestPublishFinalAfterRankOnlyAwardsPoints(t *testing.T) {
env, ids, client := recorderWithSeed(t)
if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"requestId": "flow-draft",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
publishWith(t, env, client, ids.event, "flow-prelim", rankOnlyTrue)
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger`, 0)
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
t.Fatalf("预赛里 B 应第 1 名,得到 %d", got)
}
// 决赛成绩(现场是改完再发一次),勾上发布。
env.exec(t, `UPDATE results SET result_value = 11.58, result_text = '11.58' WHERE entry_id = ?`, ids.entryA)
env.exec(t, `UPDATE results SET result_value = 11.92, result_text = '11.92' WHERE entry_id = ?`, ids.entryB)
publishWith(t, env, client, ids.event, "flow-final", rankOnlyFalse)
if got := rankOfEntry(t, env, ids.entryA); got != 1 {
t.Fatalf("决赛里 A 应第 1 名,得到 %d", got)
}
if got := rankOfEntry(t, env, ids.entryB); got != 2 {
t.Fatalf("决赛里 B 应第 2 名,得到 %d", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
t.Fatalf("决赛后 A 单位应 10 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
t.Fatalf("决赛后 B 单位应 8 分,得到 %v", got)
}
medals := medalsByTeam(t, env)
if m := medals[ids.teamA]; m.Gold != 1 {
t.Fatalf("决赛后 A 单位应 1 金,得到 %+v", m)
}
if m := medals[ids.teamB]; m.Silver != 1 {
t.Fatalf("决赛后 B 单位应 1 银,得到 %+v", m)
}
assertCount(t, env, `SELECT COUNT(*) FROM channel_ranks`, 2)
}
// standingsDump 把两张榜的读数摊成一段可比对文本(榜单是发布对外可见的最终产物)。
func standingsDump(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
out := ""
for _, path := range []string{"/api/standings", "/api/standings/athletes"} {
status, resp := env.do(client, http.MethodGet, path, nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读 %s 应 200,得到 %d %+v", path, status, resp.Error)
}
out += path + "\n" + string(resp.Data) + "\n"
}
return out
}
@@ -0,0 +1,44 @@
package httpapi_test
import (
"bytes"
"net/http"
"testing"
)
// 只更新名次的那一批成绩,不许被「老库升级自愈」的全量重算算成分。
//
// 自愈的判据里有 channel_ranks 为空这一条,而只更新名次的发布恰恰不写 channel_ranks,
// 一旦自愈照旧触发,现场重启一次服务端预赛就会变成计分发布(还会发出奖牌)。
func TestHealDoesNotScoreRankOnlyPublish(t *testing.T) {
env, ids, client := recorderWithSeed(t)
if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"requestId": "heal-ro-draft",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
publishWith(t, env, client, ids.event, "heal-ro", rankOnlyTrue)
// 这正是老库的形态(channel_ranks 空 + 有已发布名次),只差没有流水。
if n := channelRankCount(t, env); n != 0 {
t.Fatalf("只更新名次的发布不该写 channel_ranks,得到 %d 行", n)
}
var logs bytes.Buffer
if res := runHeal(t, env, &logs); res.Triggered {
t.Fatalf("流水为空的库不该触发全量重算(会把预赛算成分):%+v\n%s", res, logs.String())
}
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger`, 0)
if got := rankOfEntry(t, env, ids.entryA); got != 1 {
t.Fatalf("自愈不该动名次:A 应仍是第 1 名,得到 %d", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("自愈后 A 单位仍应 0 分,得到 %v", got)
}
}
+10 -4
View File
@@ -74,14 +74,20 @@ func HealChannelRanksOnce(ctx context.Context, opts Options) (HealResult, error)
// channelRankHealNeeded 读出自愈的两个判据,返回「有已发布名次的小项数」与是否需要触发。
// 两条读数走同一次查询,避免两次查询之间库被改动导致判据互不自洽。
//
// 第三个判据(流水)是「只更新名次」发布加进来的:自愈补的是**老库升级后没回填的名次**,
// 那种库一定结算过(发布时会写流水)。而只更新名次的发布不写任何流水,光看前两个判据
// 分不出「老库」与「只排名发布过的新库」——现场只要重启一次服务端,自愈的全量重算就会把
// 预赛成绩算成分、发出奖牌,正好抵消掉这个功能。流水为空时不动它(名次与奖牌本来也没被结算过)。
func channelRankHealNeeded(ctx context.Context, event *sql.DB) (eventCount int, needed bool, err error) {
var rankRows, publishedRankedEvents int64
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)`,
).Scan(&rankRows, &publishedRankedEvents); err != nil {
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, nil
return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0 && scoredLedgerRows > 0, nil
}
+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
}