Files
Scoreboard/server/internal/httpapi/payload.go
T
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00

233 lines
8.6 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"`
}
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"`
}
// 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"`
}