package realtime import ( "crypto/rand" "encoding/hex" "sort" "strconv" "strings" "time" ) // 注册表容量控制:离线实例保留一段时间便于排查,过期即清理; // 没有 displayKey 的客户端每次重连都会生成新实例,必须有上限兜底。 const ( instanceTTL = 10 * time.Minute maxInstances = 200 ) // Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。 type Instance struct { ID string `json:"id"` Online bool `json:"online"` Page string `json:"page,omitempty"` LastHeartbeat time.Time `json:"lastHeartbeat"` ConnectedAt time.Time `json:"connectedAt"` RemoteAddr string `json:"remoteAddr,omitempty"` AckVersion int64 `json:"ackVersion"` Slow bool `json:"slow"` // Screen 是 Console 分配的屏号(URL 里的 screen=), // 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。 // 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。 Screen string `json:"screen"` } // instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。 type instanceState struct { id string page string screen string connectedAt time.Time lastHeartbeat time.Time remoteAddr string ackVersion int64 slow bool conns int } // registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成, // 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。 func (h *Hub) registerDisplay(c *client, id, page, screen string) string { if id == "" { id = "display-" + randomID() } now := time.Now() c.mu.Lock() prev := c.instanceID c.instanceID = id c.mu.Unlock() // 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。 if prev != "" && prev != id { h.releaseDisplayID(prev, c) } h.mu.Lock() defer h.mu.Unlock() h.pruneInstancesLocked(now) st := h.instances[id] if st == nil { st = &instanceState{id: id, connectedAt: now} h.instances[id] = st } if prev != id { st.conns++ } if page != "" { st.page = page } if screen != "" { st.screen = screen } st.lastHeartbeat = now st.remoteAddr = c.remoteAddr return id } // releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查; // 离线超过 instanceTTL 后由 pruneInstancesLocked 清理。 func (h *Hub) releaseDisplay(c *client) { c.mu.Lock() id := c.instanceID c.mu.Unlock() if id == "" { return } h.releaseDisplayID(id, c) } func (h *Hub) releaseDisplayID(id string, c *client) { h.mu.Lock() defer h.mu.Unlock() st := h.instances[id] if st == nil { return } if st.conns > 0 { st.conns-- } st.lastHeartbeat = time.Now() st.ackVersion = c.ackVersion.Load() st.slow = c.slow.Load() } // touchInstance 在收到客户端消息或探测到 pong 时刷新「最后活跃」。 // now 必须是**收到对端帧的时间**,不能是服务端自己想盖的章:这个字段既决定注册表 // 里的「最后心跳」,也是离线实例清理的计时起点(见 pruneInstancesLocked)。 func (h *Hub) touchInstance(c *client, now time.Time) { c.mu.Lock() id := c.instanceID c.mu.Unlock() if id == "" { return } h.mu.Lock() defer h.mu.Unlock() if st := h.instances[id]; st != nil { st.lastHeartbeat = now st.ackVersion = c.ackVersion.Load() st.slow = c.slow.Load() } } // syncInstance 只把连接的 ack / 慢标记同步到注册表,**不碰 lastHeartbeat**。 // 心跳循环用它:服务端自己发过心跳不能算「对端还活着」的证据, // 否则一块拔了网线的屏会一直被刷成「刚刚活跃」。 func (h *Hub) syncInstance(c *client) { c.mu.Lock() id := c.instanceID c.mu.Unlock() if id == "" { return } h.mu.Lock() defer h.mu.Unlock() if st := h.instances[id]; st != nil { st.ackVersion = c.ackVersion.Load() st.slow = c.slow.Load() } } // setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。 // targets 为空表示广播(所有在线实例),否则只更新被点名的那几个实例——定向切页后 // 每块屏的「当前页」必须各算各的。 // // targets 在这里再 TrimSpace 一次是防御性的:调用方(SendCommandToInstances)已经 // 归一化过,但只要有第二个调用方忘了,注册表里就会出现「id 带空格」这类永远匹配不上的 // 死条目,而症状是「指令发出去了、界面上的当前页不变」——很难查。 func (h *Hub) setInstancePage(page string, targets []string) { if page == "" { return } var picked map[string]bool if len(targets) > 0 { picked = make(map[string]bool, len(targets)) for _, id := range targets { if id = strings.TrimSpace(id); id != "" { picked[id] = true } } } h.mu.Lock() defer h.mu.Unlock() for _, st := range h.instances { if st.conns == 0 { continue } if picked != nil && !picked[st.id] { continue } st.page = page } } // pruneInstancesLocked 清理过期离线实例并把注册表封顶到 maxInstances; // 调用方必须持有 h.mu。淘汰只针对离线实例,保证在线屏幕不会被误删。 func (h *Hub) pruneInstancesLocked(now time.Time) { for id, st := range h.instances { if st.conns == 0 && now.Sub(st.lastHeartbeat) > instanceTTL { delete(h.instances, id) } } if len(h.instances) <= maxInstances { return } offline := make([]*instanceState, 0, len(h.instances)) for _, st := range h.instances { if st.conns == 0 { offline = append(offline, st) } } sort.Slice(offline, func(i, j int) bool { return offline[i].lastHeartbeat.Before(offline[j].lastHeartbeat) }) for _, st := range offline { if len(h.instances) <= maxInstances { break } delete(h.instances, st.id) } } // Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。 // // online = 「还有连接挂着」。这里刻意不叠加 TTL 判断:只要连接还在,客户端就是那条 // 连接上的证据——而连接本身已经被 livenessLoop 用 ping/pong 求证过(不回 pong 的连接 // 会先被断开、引用计数归零)。反过来,若在这里按 lastHeartbeat 再判一次, // 一块正常但不发上行帧的大屏(数据没变时它只在收心跳)就会被误判成离线。 func (h *Hub) Instances() []Instance { h.mu.Lock() defer h.mu.Unlock() h.pruneInstancesLocked(time.Now()) out := make([]Instance, 0, len(h.instances)) for _, st := range h.instances { out = append(out, Instance{ ID: st.id, Online: st.conns > 0, Page: st.page, LastHeartbeat: st.lastHeartbeat, ConnectedAt: st.connectedAt, RemoteAddr: st.remoteAddr, AckVersion: st.ackVersion, Slow: st.slow, Screen: st.screen, }) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func randomID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { // 随机源不可用时退回时间戳,实例 id 只需唯一,不承担安全职责。 return strconv.FormatInt(time.Now().UnixNano(), 16) } return hex.EncodeToString(b[:]) }