feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,171 @@
|
||||
// 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) }
|
||||
Reference in new issue
Block a user