// Package realtime 实现 /ws 实时同步中心。 // // 协议以 devdocs/05-接口与实时协议.md 第 1–5 节为唯一规范: // 版本号取自 change_log.id,客户端按 version 去重,增量补不上就回落全量快照。 package realtime import ( "encoding/json" "fmt" "strings" "time" "unicode" ) // 事件名与协议第 3 节的表格一一对应。 const ( EventHello = "hello" EventSnapshot = "snapshot" EventCatchup = "catchup" EventChange = "change" EventResync = "resync" EventAck = "ack" EventHeartbeat = "heartbeat" EventDisplayCommand = "display:command" ) // 房间名。admin: 由 AdminRoom 生成,用于定向通知。 // // admin: 目前没有生产推送方(只有测试用 BroadcastTo 往它发过消息): // 保留它是因为 devdocs/05 §1 把「admin:(定向通知)」写成了协议的一部分, // 要删就得连着文档一起改,而文档不在本轮可改范围内——见审查清单 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" // CmdSchedule 整份替换「赛事日程侧边栏」配置(payload 形状见 dto.ScheduleConfig): // 服务端校验后一次请求里既转发给大屏、也落库到 settings,刷新 / 重启不丢(与公告同路)。 CmdSchedule = "schedule" ) var displayActions = map[string]bool{ CmdGoto: true, CmdLock: true, CmdUnlock: true, CmdReload: true, CmdAnnounce: true, CmdAnnounceClear: true, CmdShowDetail: true, CmdHideDetail: true, CmdSchedule: 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=),局域网浏览器留空。 Screen string `json:"screen,omitempty"` // Device 是浏览器自己拼好的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`), // DeviceRaw 是原始 UA(排障用)。两者都 omitempty:**老客户端不带这两个字段,服务端行为 // 与今天逐字一致**(管理端列表退回显示实例 id)。 // // 服务端不解析 UA,但也不能全信这两个值:它们是客户端自报的自由文本,会直接进管理端列表。 // 长度按**字符**截断、控制字符一律清掉,收口在 sanitizeDeviceText(registerDisplay 调用)。 Device string `json:"device,omitempty"` DeviceRaw string `json:"deviceRaw,omitempty"` } // 客户端自报文本的长度上限,按**字符**(rune)不是字节算: // `安卓 13 · Chrome 120` 这种标签按字节截会切出半个汉字,显示成乱码方框。 const ( maxDeviceChars = 80 maxDeviceRawChars = 300 ) // CleanUserText 清洗客户端自报的自由文本:去掉全部控制字符(换行、制表、退格、DEL…)与首尾空白。 // // 这些值最终要显示在管理端「在线大屏」列表里,一个带换行的 device 能把整行排版顶歪; // 服务端是唯一的收口处,不能指望每个客户端都干净。TrimSpace 之后为空即视作「没报」,返回空串。 // // 保留字符间的普通空格与各种可见符号:设备标签本来就长这样,不该被服务端改写。 func CleanUserText(s string) string { if s == "" { return "" } var b strings.Builder b.Grow(len(s)) for _, r := range s { if unicode.IsControl(r) { continue } b.WriteRune(r) } return strings.TrimSpace(b.String()) } // TruncateChars 按字符(rune)截断到 maxChars 个;maxChars <= 0 表示不截断。 // 会切断代理对时以整字符为界(Go 的 range 只落在字符起始字节上),不会切出半个 UTF-8 序列。 func TruncateChars(s string, maxChars int) string { if maxChars <= 0 { return s } count := 0 for i := range s { if count == maxChars { return s[:i] } count++ } return s } // sanitizeDeviceText 是 hello 里设备字段的收口:先清洗再截断。 // 没有这两个字段的老客户端拿到的是空串,注册表按「没报」处理。 func sanitizeDeviceText(s string, maxChars int) string { return TruncateChars(CleanUserText(s), maxChars) } // 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) }