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:
1 parent
d11cab31db
commit
c5ce1878fc
18 files changed
+1039
-36
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user