Files
Scoreboard/server/internal/realtime/protocol.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

186 lines
5.8 KiB
Go

// Package realtime 实现 /ws 实时同步中心。
//
// 协议以 devdocs/05-接口与实时协议.md 第 1–5 节为唯一规范:
// 版本号取自 change_log.id,客户端按 version 去重,增量补不上就回落全量快照。
package realtime
import (
"encoding/json"
"fmt"
"time"
)
// 事件名与协议第 3 节的表格一一对应。
const (
EventHello = "hello"
EventSnapshot = "snapshot"
EventCatchup = "catchup"
EventChange = "change"
EventResync = "resync"
EventAck = "ack"
EventHeartbeat = "heartbeat"
EventDisplayCommand = "display:command"
)
// 房间名。admin:<userId> 由 AdminRoom 生成,用于定向通知。
//
// admin:<userId> 目前没有生产推送方(只有测试用 BroadcastTo 往它发过消息):
// 保留它是因为 devdocs/05 §1 把「admin:<userId>(定向通知)」写成了协议的一部分,
// 要删就得连着文档一起改,而文档不在本轮可改范围内——见审查清单 C36。
const (
RoomDisplay = "display"
RoomAdmin = "admin"
)
// AdminRoom 返回某个管理端用户的定向房间名。
//
// 只有带会话的管理端连接会加入这个房间(见 handleHello),身份来自会话而非客户端自报;
// 「guest 不能进管理端房间」这条鉴权语义由测试钉住,删房间会一起删掉那条覆盖。
func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) }
// 连接角色。
const (
RoleDisplay = "display"
RoleAdmin = "admin"
)
// change.action 取值。
const (
ActionUpsert = "upsert"
ActionVoid = "void"
ActionReplace = "replace"
)
// display:command 支持的 action。
const (
CmdGoto = "goto"
CmdLock = "lock"
CmdUnlock = "unlock"
CmdReload = "reload"
CmdAnnounce = "announce"
CmdShowDetail = "show-detail"
CmdHideDetail = "hide-detail"
// CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。
CmdAnnounceClear = "announce_clear"
)
var displayActions = map[string]bool{
CmdGoto: true,
CmdLock: true,
CmdUnlock: true,
CmdReload: true,
CmdAnnounce: true,
CmdAnnounceClear: true,
CmdShowDetail: true,
CmdHideDetail: true,
}
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
func ValidDisplayAction(action string) bool { return displayActions[action] }
// envelope 是所有消息的统一外壳:{ "event": "...", "data": { ... } }。
type envelope struct {
Event string `json:"event"`
Data json.RawMessage `json:"data,omitempty"`
}
// encode 把事件名与载荷打包成一条文本帧。
func encode(event string, data any) ([]byte, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("编码 %s 载荷: %w", event, err)
}
return json.Marshal(envelope{Event: event, Data: raw})
}
// helloPayload 是 C→S 的 hello 载荷。
//
// role 只是「申请」:服务端以会话身份为准,未登录连接申请 admin 会被拒绝,
// 因此这里没有 userId —— 定向房间的 userId 只能来自会话。
// instanceId / page / screen 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页、哪块屏」,
// 老客户端不带这几个字段也能正常工作。
type helloPayload struct {
Role string `json:"role"`
LastVersion *int64 `json:"lastVersion,omitempty"`
DisplayKey string `json:"displayKey,omitempty"`
InstanceID string `json:"instanceId,omitempty"`
Page string `json:"page,omitempty"`
// Screen 是 Console 从 URL 带下来的屏号(screen=<display.id>),局域网浏览器留空。
Screen string `json:"screen,omitempty"`
}
// ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。
type ackPayload struct {
Version int64 `json:"version"`
}
// Change 是变更的最小单元;Version 只用于服务端排序与去重,不上线路。
type Change struct {
Entity string `json:"entity"`
ID int64 `json:"id,omitempty"`
Action string `json:"action"`
Data json.RawMessage `json:"data,omitempty"`
Version int64 `json:"-"`
}
// changePayload 是 change 事件的载荷。
type changePayload struct {
Version int64 `json:"version"`
Changes []Change `json:"changes"`
}
// catchupPayload 是 catchup 事件的载荷。
type catchupPayload struct {
FromVersion int64 `json:"fromVersion"`
Version int64 `json:"version"`
Changes []Change `json:"changes"`
}
// heartbeatPayload 是 heartbeat 事件的载荷。
type heartbeatPayload struct {
Version int64 `json:"version"`
ServerTime string `json:"serverTime"`
}
// DisplayCommand 是下发给大屏的 display:command 载荷。
type DisplayCommand struct {
Action string `json:"action"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewDisplayCommand 构造一条大屏指令;action 不在协议清单内时返回错误。
func NewDisplayCommand(action string, payload any) (DisplayCommand, error) {
if !ValidDisplayAction(action) {
return DisplayCommand{}, fmt.Errorf("未知的大屏指令 %q", action)
}
cmd := DisplayCommand{Action: action}
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return DisplayCommand{}, fmt.Errorf("编码大屏指令 %s 的 payload: %w", action, err)
}
cmd.Payload = raw
}
return cmd, nil
}
// pageFromPayload 从 goto 指令的 payload 里取目标页名,兼容字符串与对象两种写法。
func pageFromPayload(payload json.RawMessage) string {
if len(payload) == 0 {
return ""
}
var s string
if err := json.Unmarshal(payload, &s); err == nil {
return s
}
var obj struct {
Page string `json:"page"`
}
if err := json.Unmarshal(payload, &obj); err == nil {
return obj.Page
}
return ""
}
func nowISO(t time.Time) string { return t.Format(time.RFC3339Nano) }