- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
172 lines
4.8 KiB
Go
172 lines
4.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 生成,用于定向通知。
|
|
const (
|
|
RoomDisplay = "display"
|
|
RoomAdmin = "admin"
|
|
)
|
|
|
|
// AdminRoom 返回某个管理端用户的定向房间名。
|
|
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"
|
|
)
|
|
|
|
var displayActions = map[string]bool{
|
|
CmdGoto: true,
|
|
CmdLock: true,
|
|
CmdUnlock: true,
|
|
CmdReload: true,
|
|
CmdAnnounce: 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 载荷。
|
|
// instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」,
|
|
// 老客户端不带这两个字段也能正常工作。
|
|
type helloPayload struct {
|
|
Role string `json:"role"`
|
|
LastVersion *int64 `json:"lastVersion,omitempty"`
|
|
DisplayKey string `json:"displayKey,omitempty"`
|
|
InstanceID string `json:"instanceId,omitempty"`
|
|
UserID int64 `json:"userId,omitempty"`
|
|
Page string `json:"page,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) }
|