## 需求(主人原话)
> 只有在最后一轮(决赛)的成绩才算分,你给发布按钮边上带一个选项,勾选之后**当前小项/组以决赛成绩发布**,
> 不然的话就**只更新名次(非决赛) 不加分**。
**设计好处**:不引入「轮次」模型,而是**在已有的「发布」动作上加一个口径** —— 分轮次比赛时,同一批人各轮成绩各发各的,
榜单只认决赛那一轮的分。引擎本来就有一条「只排名不计分」的通路,这次是**把它在发布入口露出来**,没有另造算分分支。
## 契约
`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` 两张)。
270 lines
11 KiB
Go
270 lines
11 KiB
Go
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"`
|
||
}
|