Files
Scoreboard/server/internal/httpapi/payload.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

270 lines
11 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
// 本文件是各写接口的请求体契约;字段名与 web/src/shared/types.ts 对齐。
type loginInput struct {
Username string `json:"username"`
Password string `json:"password"`
RequestID string `json:"requestId"`
}
type saveDraftPayload struct {
EventID int64 `json:"eventId"`
CategoryID *int64 `json:"categoryId"`
Results []draftResultInput `json:"results"`
RequestID string `json:"requestId"`
}
type draftResultInput struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText *string `json:"resultText"`
ResultValue *float64 `json:"resultValue"`
Unit *string `json:"unit"`
IsTie bool `json:"isTie"`
Status string `json:"status"`
PointsOverride *float64 `json:"pointsOverride"`
Note *string `json:"note"`
}
type publishPayload struct {
EventID int64 `json:"eventId"`
CategoryID *int64 `json:"categoryId"`
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"`
}
// entryHeatPayload 是批量设置批次的请求体:heat 为 null / 缺省表示清除批次(未分批)。
type entryHeatPayload struct {
EntryIDs []int64 `json:"entryIds"`
Heat *int64 `json:"heat"`
RequestID string `json:"requestId"`
}
// entryTeamSharesPayload 是 PUT /api/entries/{id}/team-shares 的请求体:整组替换一条报名的份额。
// 不传重量字段(weight 缺省 1)就是均分;medal 缺省 true = 这枚奖牌整枚记给该单位。
// 必须包含主单位(entries.team_id),且每个单位只能出现一次——校验见 validateEntryShares。
type entryTeamSharesPayload struct {
TeamShares []entryTeamShareDTO `json:"teamShares"`
RequestID string `json:"requestId"`
}
// entrySquadPayload 是 PUT /api/entries/{id}/squad 的请求体:改一条团体报名的队名。
// 队名是自由文本,服务端只 trim 首尾空白;空串(或只有空白)是**合法值**,
// 表示「不区分队伍」,不是「清掉这条报名」(见迁移 0025 与 updateEntrySquad)。
type entrySquadPayload struct {
Squad string `json:"squad"`
RequestID string `json:"requestId"`
}
// entryAthletesPayload 是 PUT /api/entries/{id}/athletes 的请求体:整组替换一条报名的队员。
// 空数组是**合法值**(清空队员 = 只报单位,与升级前一致);只认 athleteId。
type entryAthletesPayload struct {
Athletes []entryAthleteDTO `json:"athletes"`
RequestID string `json:"requestId"`
}
// entryBatchPayload 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
type entryBatchPayload struct {
Action string `json:"action"`
IDs []int64 `json:"ids"`
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
type adjustmentInput struct {
TeamID int64 `json:"teamId"`
Delta float64 `json:"delta"`
Category string `json:"category"`
Reason string `json:"reason"`
EventID *int64 `json:"eventId"`
RequestID string `json:"requestId"`
}
type recordBreakInput struct {
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText"`
RequestID string `json:"requestId"`
}
// reopenResultPayload 是 POST /api/results/{id}/reopen 的请求体:
// 把一条成绩退回草稿让录入员直接改,理由必填(与作废同口径)。
type reopenResultPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
// rejectRecordBreakPayload 是 POST /api/record-breaks/{id}/reject 的请求体:
// 驳回 / 作废一条破纪录登记,理由必填并落到 record_breaks.note。
type rejectRecordBreakPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
// recordEntryInput 是 POST /api/records 的请求体:手工登记一条历史纪录。
//
// source 只接受 history(缺省即 history):source='current' 是「确认破纪录」写出来的产物,
// 客户端造不得——那会让一条没人批准过的成绩看起来像是本届刚破的纪录。
type recordEntryInput struct {
EventID int64 `json:"eventId"`
Level string `json:"level"`
// ResultValue 与 ResultText 至少给一个:纪录可以只有数值(10.5)或只有文本('10秒5')。
ResultValue *float64 `json:"resultValue"`
ResultText *string `json:"resultText"`
HolderName *string `json:"holderName"`
HolderTeamID *int64 `json:"holderTeamId"`
AchievedAt *string `json:"achievedAt"`
Source string `json:"source"`
RequestID string `json:"requestId"`
}
// displayCommandInput 是大屏控制指令的请求体。
type displayCommandInput struct {
Action string `json:"action"`
Payload any `json:"payload"`
// Targets 是要定向控制的大屏实例 id(GET /api/display/instances 的 id);
// 留空表示广播给所有大屏——公告这类全屏指令走的就是广播。
Targets []string `json:"targets"`
RequestID string `json:"requestId"`
}
type voidAdjustmentPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
type settingsPatch struct {
Event *settingsEventPatch `json:"event"`
Display *settingsDisplayPatch `json:"display"`
Standings *settingsStandingsPatch `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval *bool `json:"requireApproval"`
RequestID string `json:"requestId"`
}
type settingsEventPatch struct {
Name *string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
type settingsDisplayPatch struct {
Background *string `json:"background"`
Logo *string `json:"logo"`
Title *string `json:"title"`
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSeconds *float64 `json:"rotateSeconds"`
// FontTitle / FontBody 是字体 id;null 表示不改动(清空要传空串)。
FontTitle *string `json:"fontTitle"`
FontBody *string `json:"fontBody"`
// LiveEvents 是「实时赛况」人工指定的当前项目 id(最多 2 个)。
// nil 表示不改动;传 [] 表示清空人工指定。
LiveEvents *[]int64 `json:"liveEvents"`
// LiveSource 是当前项目的判定方式(manual / auto);nil 表示不改动。
LiveSource *string `json:"liveSource"`
}
type settingsStandingsPatch struct {
IndividualToTeamWeight *float64 `json:"individualToTeamWeight"`
RankingMode *string `json:"rankingMode"`
ShowIndividualBoard *bool `json:"showIndividualBoard"`
ShowIndividualMedals *bool `json:"showIndividualMedals"`
// LiveOnPublish 是「分批发布时是否实时更新各单位总分与奖牌榜」;nil 表示不改动。
LiveOnPublish *bool `json:"liveOnPublish"`
}
// sessionSettingsPatch 是 PUT /api/system/session 的请求体:
// 只改「登录有效期(小时)」这一个系统级配置。
type sessionSettingsPatch struct {
SessionHours *int `json:"sessionHours"`
RequestID string `json:"requestId"`
}
type categoriesPayload struct {
Categories []categoryDTO `json:"categories"`
}
// athletesBatchPayload 是运动员批量动作的请求体:action 决定停用还是彻底删除。
type athletesBatchPayload struct {
Action string `json:"action"`
IDs []int64 `json:"ids"`
// DryRun 只统计不改库:前端用它先看清「会删掉多少、重算哪些小项」再确认。
DryRun bool `json:"dryRun"`
}
type channelsPayload struct {
Channels []channelDTO `json:"channels"`
}
// teamRanksPayload 是 PUT /api/events/{id}/categories/{cid}/team-ranks 的请求体:
// 一次提交某组别某类通道(当前只认 scope=team + basis=manual)的整组团体名次。
// ranks 里的 rank 为 0 表示清空该单位的记录;未出现在 ranks 里的单位保持原值,
// 录入页只需提交改动过的行,误传部分数据不会把没提到的单位一并清掉。
type teamRanksPayload struct {
Scope string `json:"scope"`
Basis string `json:"basis"`
Ranks []teamManualRankDTO `json:"ranks"`
}
// ---------- 用户管理 ----------
type createUserInput struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
RequestID string `json:"requestId"`
}
// updateUserInput 只带需要改的字段:nil 表示保持原值。
type updateUserInput struct {
DisplayName *string `json:"displayName"`
Role *string `json:"role"`
Active *bool `json:"active"`
RequestID string `json:"requestId"`
}
// resetUserPasswordInput 的 password 留空时由服务端随机生成。
type resetUserPasswordInput struct {
Password string `json:"password"`
RequestID string `json:"requestId"`
}
// restoreBackupPayload 是 POST /api/backup/restore 的请求体。
//
// File 只接受备份目录里的裸文件名(不接受路径);Kind 是**要覆盖的那份库**
// (备份列表里的 dbKind)——安全副本的性质是 safety,但它同样属于某一份库,
// 拿它退回上一个时间点是正当操作,所以这里仍只认 system / event。
// 服务端再按文件名校验一遍(对不上直接拒绝):恢复错库的代价是两份数据一起完蛋。
type restoreBackupPayload struct {
File string `json:"file"`
Kind string `json:"kind"`
RequestID string `json:"requestId"`
}