Files
Scoreboard/server/internal/realtime/hub.go
T
AskaEth 56ed90ff8b fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15
本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
2026-09-17 18:19:38 +08:00

960 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package realtime
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 默认参数。
//
// 注意:Set* 目前只有测试在调用——现场调优要求先把它们接到 config.yaml / main.go 上,
// 那是本包之外的决定,所以不要照着 Set* 的存在去宣称「现场可调」。
const (
defaultHeartbeatInterval = 10 * time.Second
defaultMaxCatchup = 500
defaultSlowLag = 500
defaultHelloTimeout = 15 * time.Second
defaultWriteTimeout = 10 * time.Second
defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
// defaultLivenessInterval 是「一点客户端帧都没收到」的静默期上限:超过它就发一次
// WebSocket ping 求证对端还在不在(见 client.livenessLoop)。
//
// 不能把「静默」直接当掉线:大屏在数据没变时本来就不上行。取 15s,配合
// maxProbeFailures = 2 与 defaultPongTimeout = 5s,真正掉线的屏最迟
// 2 × (15s + 5s) = 40s 内被判定并断开;稳态下每块屏每分钟只多发 4 个 ping 帧。
defaultLivenessInterval = 15 * time.Second
// defaultPongTimeout 是等一次 pong 的超时。ping 帧本身要走写锁,正常浏览器在
// 网络层立刻应答,5s 已经宽裕。
defaultPongTimeout = 5 * time.Second
// maxProbeFailures 是连续多少次探测失败才断开:单次失败可能只是对端在忙
// (正在收一份大快照,写锁被占住),连续失败才说明连接上没有人了。
maxProbeFailures = 2
// defaultSnapshotMinInterval 是同一连接两次「整库快照」之间的最小间隔。
//
// 取 2.5s,比客户端自己的 3s 冷却(web/src/shared/realtime.ts 的 resyncCooldownMs)短半秒:
// 客户端按自己的节奏回来时(≥ 它的上次发送 + 3s),服务端的窗口必须已经过期,
// 否则两个等长的窗口会因收发延迟抖动而被随机判定成「超频」,把正常的补齐请求挡掉。
// 反过来,2.5s 也已经把放大面压到「每个连接每 2.5 秒最多一份整库快照」
// (一份 = 一个事务 + 8 条查询,赛事库还是单连接)。
defaultSnapshotMinInterval = 2500 * time.Millisecond
// sessionCheckTimeout 是会话复查查询的超时:系统库同样只有一条连接,
// 被长操作占住时不能把心跳循环一起卡住。
sessionCheckTimeout = 2 * time.Second
)
// slowCloseCode 是踢掉慢客户端时使用的自定义关闭码。
// 不能用 1008:前端把所有 1008 当「登录失效」并永久停止重连,慢客户端被踢后大屏会冻结。
const slowCloseCode = websocket.StatusCode(4000)
// unresponsiveCloseCode 是踢掉「不回 pong」的连接时使用的关闭码。同样避开 1008:
// 那块屏只是掉线了,重连回来就能补上数据,不该被引导去重新登录。
const unresponsiveCloseCode = websocket.StatusCode(4001)
// Hub 是实时同步中心:维护房间、连接、大屏实例注册表与版本号。
//
// 版本号等于 change_log 的最大 id;写入方提交事务后调用 Broadcast 推送变更,
// 断线客户端用 hello.lastVersion 请求增量补齐。
type Hub struct {
db *sql.DB
log *slog.Logger
mu sync.RWMutex
rooms map[string]map[*client]struct{}
clients map[*client]struct{}
instances map[string]*instanceState
version int64
closed bool
// sendMu 串行化「版本推进 + 下发」,保证同一客户端收到的 change 与版本严格递增,
// 也避免初始快照与并发广播交错导致漏变更。
sendMu sync.Mutex
ctx context.Context
cancel context.CancelFunc
hbWG sync.WaitGroup // 心跳循环
connWG sync.WaitGroup // 连接处理
heartbeatInterval time.Duration
heartbeatReset chan struct{}
maxCatchup int
slowLag int64
helloTO time.Duration
writeTO time.Duration
allowedOrigins []string
// snapshotMinInterval 是同一连接两次整库快照之间的最小间隔;<=0 表示不限流。
snapshotMinInterval time.Duration
// livenessInterval 是「一点客户端帧都没收到」的静默期上限;pongTimeout 是等一次
// pong 的超时。两者只影响存活探测(见 client.livenessLoop),默认值见常量区注释;
// livenessInterval <= 0 表示关闭探测(只有测试会这么设)。
livenessInterval time.Duration
pongTimeout time.Duration
// sessionChecker 复查长连接背后的会话是否还有效;未注入时只在握手时校验一次。
sessionChecker SessionChecker
}
// SessionChecker 判定一条会话当前是否仍然有效(未被撤销 / 未过期 / 用户未停用)。
// auth.Service 满足这个接口;测试可注入桩实现。
type SessionChecker interface {
SessionActive(ctx context.Context, sessionID string) (bool, error)
}
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
// 调用方在服务关闭时应调用 Hub.Close。
func NewHub(db *sql.DB, log *slog.Logger) *Hub {
if log == nil {
log = slog.Default()
}
ctx, cancel := context.WithCancel(context.Background())
h := &Hub{
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
snapshotMinInterval: defaultSnapshotMinInterval,
livenessInterval: defaultLivenessInterval,
pongTimeout: defaultPongTimeout,
}
if db != nil {
if v, err := h.currentVersion(context.Background()); err == nil {
h.version = v
} else {
log.Warn("读取初始版本号失败", "err", err)
}
}
h.hbWG.Add(1)
go h.heartbeatLoop()
return h
}
// Close 停止心跳、断开所有连接并等待处理中的连接退出。
func (h *Hub) Close() error {
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return nil
}
h.closed = true
h.mu.Unlock()
h.cancel()
for _, c := range h.roomClients("") {
c.stop(websocket.StatusGoingAway, "服务关闭")
}
h.connWG.Wait()
h.hbWG.Wait()
return nil
}
// Version 返回内存中记录的当前版本号。
func (h *Hub) Version() int64 {
h.mu.RLock()
defer h.mu.RUnlock()
return h.version
}
func (h *Hub) setVersion(v int64) {
if v <= 0 {
return
}
h.mu.Lock()
if v > h.version {
h.version = v
}
h.mu.Unlock()
}
// SetHeartbeatInterval 调整心跳间隔并立即生效。
//
// 唯一调用方是测试(把心跳调快,好在毫秒级跑完「会话撤销即断开」这类用例);
// 生产路径用的是 defaultHeartbeatInterval。要做成现场可调,得先在 main.go 里
// 从 config.yaml 读出来再调这里——本包不读配置,也没有别的消费方。
func (h *Hub) SetHeartbeatInterval(d time.Duration) {
if d <= 0 {
return
}
h.mu.Lock()
h.heartbeatInterval = d
h.mu.Unlock()
select {
case h.heartbeatReset <- struct{}{}:
default:
}
}
// SetMaxCatchup 设置增量补发的条数上限;超过就回落全量快照。同样只有测试在调用。
func (h *Hub) SetMaxCatchup(n int) {
if n <= 0 {
return
}
h.mu.Lock()
h.maxCatchup = n
h.mu.Unlock()
}
// SetSlowLag 设置「慢客户端」的版本落后阈值;<=0 表示不判定。同样只有测试在调用。
func (h *Hub) SetSlowLag(n int64) {
h.mu.Lock()
h.slowLag = n
h.mu.Unlock()
}
// SetAllowedOrigins 设置 WebSocket 跨站 Origin 白名单(path.Match 模式);
// 同源与不带 Origin 的握手始终放行,不受此设置影响。
func (h *Hub) SetAllowedOrigins(origins []string) {
h.mu.Lock()
h.allowedOrigins = append([]string(nil), origins...)
h.mu.Unlock()
}
// SetSessionChecker 注入会话复查器:连接建立后周期性复查会话是否仍然有效,失效即断开。
//
// /ws 的身份在握手时固化,而会话随时可能被撤销(登出、改密、管理员重置密码、停用用户)。
// 不复查的话,那条长连接会一直以原角色收推送——被停用的人只要不关页面,管理端就还挂着。
func (h *Hub) SetSessionChecker(c SessionChecker) {
h.mu.Lock()
h.sessionChecker = c
h.mu.Unlock()
}
// SetSnapshotMinInterval 设置同一连接两次整库快照之间的最小间隔;<= 0 表示不限流(仅测试用)。
//
// 撞到窗口的请求不会拿到第二份全量:客户端本来就刚拿到过一份,心跳里的版本号会让它
// 在需要时重新发起;越过窗口后一切照旧。
func (h *Hub) SetSnapshotMinInterval(d time.Duration) {
h.mu.Lock()
h.snapshotMinInterval = d
h.mu.Unlock()
}
func (h *Hub) sessionCheckerValue() SessionChecker {
h.mu.RLock()
defer h.mu.RUnlock()
return h.sessionChecker
}
func (h *Hub) snapshotMinIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.snapshotMinInterval
}
func (h *Hub) heartbeatIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.heartbeatInterval
}
func (h *Hub) maxCatchupValue() int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.maxCatchup
}
func (h *Hub) slowLagValue() int64 {
h.mu.RLock()
defer h.mu.RUnlock()
return h.slowLag
}
func (h *Hub) helloTimeout() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.helloTO
}
func (h *Hub) writeTimeout() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.writeTO
}
func (h *Hub) livenessIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.livenessInterval
}
func (h *Hub) pongTimeoutValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.pongTimeout
}
func (h *Hub) allowedOriginsValue() []string {
h.mu.RLock()
defer h.mu.RUnlock()
return h.allowedOrigins
}
// Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。
func (h *Hub) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// 同源与不带 Origin 的握手由库放行;跨站需命中 server.allowedOrigins 白名单。
OriginPatterns: h.allowedOriginsValue(),
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "origin", r.Header.Get("Origin"), "err", err)
return
}
h.mu.Lock()
if h.closed {
h.mu.Unlock()
_ = conn.Close(websocket.StatusGoingAway, "服务关闭")
return
}
h.connWG.Add(1)
h.mu.Unlock()
defer h.connWG.Done()
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
h.serve(conn, r.RemoteAddr, wsIdentityFromContext(r.Context()))
})
}
// wsIdentity 是握手时从会话取到的身份;整条连接的生命周期都用它。
//
// sessionID 不进协议,只为会话复查保留:会话被撤销后要据此找到并断开对应的连接。
type wsIdentity struct {
Role string
UserID int64
SessionID string
SettingsAdmin bool
}
// wsIdentityFromContext 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentityFromContext(ctx context.Context) wsIdentity {
id := wsIdentity{}
if sess := middleware.Session(ctx); sess != nil {
id.SessionID = sess.ID
}
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
// 会话无效时 Authenticate 已经降级成 guest,这里拿不到会话 id,
// 因此降级后的连接不会被会话复查误伤。
id.Role = RoleDisplay
return id
}
id.Role = RoleAdmin
id.UserID = u.ID
id.SettingsAdmin = u.Role.Allows(auth.RoleAdmin)
return id
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string, id wsIdentity) {
conn.SetReadLimit(1 << 20)
c := newClient(h, conn, remoteAddr, id)
h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx)
defer cancel()
var loops sync.WaitGroup
loops.Add(3)
go func() { defer loops.Done(); c.readLoop(ctx) }()
go func() { defer loops.Done(); c.writeLoop(ctx) }()
// 存活探测:服务端「连接对象还在」不等于对端还在,注册表的在线判定靠这条循环求证。
go func() { defer loops.Done(); c.livenessLoop(ctx) }()
<-c.done
// 先做关闭握手,再取消读上下文:coder/websocket 在读上下文被取消时会直接
// 关闭底层连接,那样对端只能看到 EOF,收不到策略性关闭码(如 1008)。
// 写循环已因 c.done 退出,读循环会随对端回帧自然结束。
c.mu.Lock()
code, reason := c.closeCode, c.closeReason
c.mu.Unlock()
if code == 0 {
code = websocket.StatusNormalClosure
}
closed := make(chan struct{})
go func() {
defer close(closed)
_ = conn.Close(code, reason)
}()
select {
case <-closed:
case <-time.After(defaultCloseTimeout):
// 对端不回应:cancel 会强制关闭底层连接,读循环随之退出。
}
cancel()
loops.Wait()
h.removeClient(c)
}
func (h *Hub) addClient(c *client) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *Hub) removeClient(c *client) {
h.mu.Lock()
delete(h.clients, c)
for room, set := range h.rooms {
delete(set, c)
if len(set) == 0 {
delete(h.rooms, room)
}
}
h.mu.Unlock()
h.releaseDisplay(c)
}
// join 把连接加入房间;房间用内存 map 维护,不引入实时框架。
func (h *Hub) join(c *client, rooms ...string) {
h.mu.Lock()
defer h.mu.Unlock()
for _, room := range rooms {
if room == "" {
continue
}
set := h.rooms[room]
if set == nil {
set = make(map[*client]struct{})
h.rooms[room] = set
}
set[c] = struct{}{}
}
}
// roomClients 返回房间成员的快照;room 为空表示全部连接。
func (h *Hub) roomClients(room string) []*client {
h.mu.RLock()
defer h.mu.RUnlock()
if room == "" {
out := make([]*client, 0, len(h.clients))
for c := range h.clients {
out = append(out, c)
}
return out
}
set := h.rooms[room]
out := make([]*client, 0, len(set))
for c := range set {
out = append(out, c)
}
return out
}
// handleHello 只信任会话身份:客户端自报的 role/userId 会被忽略,
// 管理端房间必须由登录会话授予,未登录连接请求管理端直接断开。
func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
c.mu.Lock()
sessionRole, sessionUserID := c.sessionRole, c.sessionUserID
c.mu.Unlock()
if p.Role != "" && p.Role != RoleDisplay && p.Role != RoleAdmin {
h.log.Debug("hello 角色非法", "role", p.Role, "remote", c.remoteAddr)
c.stop(websocket.StatusPolicyViolation, "role 非法")
return
}
if p.Role == RoleAdmin && sessionRole != RoleAdmin {
h.log.Warn("未登录连接请求管理端房间,已断开", "remote", c.remoteAddr)
c.stop(websocket.StatusPolicyViolation, "无权订阅管理端房间")
return
}
// 不写 role 时按会话上限走;显式 role=display 允许管理端用户降级成大屏。
role := sessionRole
if p.Role == RoleDisplay {
role = RoleDisplay
}
c.mu.Lock()
c.role = role
c.userID = 0
if role == RoleAdmin {
c.userID = sessionUserID
}
c.mu.Unlock()
if role == RoleAdmin {
rooms := []string{RoomAdmin}
if c.userID > 0 {
rooms = append(rooms, AdminRoom(c.userID))
}
h.join(c, rooms...)
} else {
id := p.InstanceID
if id == "" {
id = p.DisplayKey
}
h.registerDisplay(c, id, displayRegistration{
page: p.Page,
screen: p.Screen,
device: p.Device,
deviceRaw: p.DeviceRaw,
})
h.join(c, RoomDisplay)
}
h.sendInitial(ctx, c, p.LastVersion)
}
// sendInitial 决定首包是 snapshot 还是 catchup:
// 没有 lastVersion、版本超前、或增量条数超阈值,一律回落全量快照。
//
// 两个入口共用这一条路径:hello(首包 / 翻页补的那条)与 resync。
func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.sendMu.Lock()
defer h.sendMu.Unlock()
c.mu.Lock()
c.greeted = true
c.mu.Unlock()
cur, err := h.currentVersion(ctx)
if err != nil {
if dbClosed(err) {
// 恢复/重启窗口(库已经被关掉、文件还没换完)里的新连接不能踢:
// 现场看到的是「每块屏刚连上就被 1011 打回、日志一屏 ERROR」,像恢复把系统
// 搞坏了。与 httpapi/realtime.go 的降级口径一致——这类错误只记 Debug,
// 连接留着(心跳照发,客户端看门狗不会误判),等库回来后的下一次
// resync / 重连自然就能拿到数据。
h.log.Debug("跳过首包:库已关闭(恢复/重启中)", "remote", c.remoteAddr, "err", err)
return
}
h.log.Error("读取版本号失败,无法下发首包", "err", err)
c.stop(websocket.StatusInternalError, "版本号不可用")
return
}
h.setVersion(cur)
if lastVersion == nil || *lastVersion <= 0 || *lastVersion > cur {
h.sendSnapshotGuarded(ctx, c)
return
}
n, err := h.countChangesSince(ctx, *lastVersion)
if err != nil {
h.log.Warn("统计增量失败,回落快照", "err", err)
h.sendSnapshotGuarded(ctx, c)
return
}
if n > h.maxCatchupValue() {
h.sendSnapshotGuarded(ctx, c)
return
}
changes, err := h.changesSince(ctx, *lastVersion, 0)
if err != nil {
h.log.Warn("读取增量失败,回落快照", "err", err)
h.sendSnapshotGuarded(ctx, c)
return
}
// catchup 与 change 一样按连接身份裁剪:重连补齐不能成为读系统配置的后门。
payload := catchupPayload{
FromVersion: *lastVersion,
Version: cur,
Changes: redactChanges(changes, c.settingsAdmin),
}
if !c.sendEvent(EventCatchup, payload) {
return
}
c.lastSent.Store(cur)
c.ackVersion.Store(cur)
}
// sendSnapshotGuarded 是「要发整库快照」的唯一出口(调用方须持有 sendMu,同 sendSnapshotLocked)。
//
// 整库快照的代价是一个 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
// 读取还在全局发送锁里:不限流的话,一条 15 字节的帧(resync,或重复的不带 lastVersion 的
// hello)就能反复触发,把库连接与所有人的推送一起堵住。这里按连接记账,窗口内已经发过
// 整库快照就不再发第二份。
//
// 只拦「整库快照」这一种:带 lastVersion 的 hello 走 catchup(增量)不受影响;
// 每次断线重连都是新连接(新的 client,窗口为空),第一份快照照发。
//
// 被挡下的请求不丢弃,只在连接上挂一个标记,由心跳循环补发(见 flushPendingSnapshot):
// 请求方拿到的是「稍微晚一点的一份」,而不是「什么都没有」。
func (h *Hub) sendSnapshotGuarded(ctx context.Context, c *client) bool {
interval := h.snapshotMinIntervalValue()
if !c.allowSnapshot(interval) {
c.snapshotPending.Store(true)
h.log.Debug("整库快照窗口内,改为心跳补发", "remote", c.remoteAddr, "interval", interval.String())
return false
}
// 这一份已经满足了挂起的请求(否则它会在下个心跳里变成多余的第三份)。
c.snapshotPending.Store(false)
h.sendSnapshotLocked(ctx, c)
return true
}
// flushPendingSnapshot 在心跳里补发被窗口挡下的整库快照;返回 true 表示补发了一份。
//
// 规则:只有在「有待发标记」且「窗口确实已过」时才发,发完清标记——
// 不能做成每次心跳都发一份(那等于把限流从 2.5s 放大成心跳频率)。
// 最迟一个心跳周期(默认 10s)内一定补上,正常客户端感知不到差别。
func (h *Hub) flushPendingSnapshot(ctx context.Context, c *client) bool {
if !c.snapshotPending.Load() {
return false
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
// 加锁后再看一次:期间可能有别的路径已经发过(那时标记已被清掉)。
if !c.snapshotPending.Load() {
return false
}
if !c.allowSnapshot(h.snapshotMinIntervalValue()) {
// 窗口还没过(刚有别的请求发过一份):留给下一个心跳。
return false
}
c.snapshotPending.Store(false)
h.log.Debug("心跳补发挂起的整库快照", "remote", c.remoteAddr)
h.sendSnapshotLocked(ctx, c)
return true
}
// sendSnapshotLocked 必须在持有 sendMu 时调用:快照读取与「已发送版本」的推进
// 必须原子完成,否则并发的 change 会插到快照前面。
func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
snap, err := h.Snapshot(ctx)
if err != nil {
if dbClosed(err) {
// 同 sendInitial:恢复窗口里「快照读不出来」不是故障,是停机动作的一部分。
// 这里既不能记 ERROR,也不能拿 1011 把大屏踢回去重连(那正是现场
// 「恢复把系统搞坏了」的观感来源);连接留着,心跳继续。
h.log.Debug("跳过整库快照:库已关闭(恢复/重启中)", "remote", c.remoteAddr, "err", err)
return
}
h.log.Error("构建快照失败", "err", err)
c.stop(websocket.StatusInternalError, "快照不可用")
return
}
// 快照对所有角色下发,但系统配置只有 admin 能看:非 admin 只保留大屏渲染所需字段。
if !c.settingsAdmin {
snap.Settings = snap.Settings.WithoutAdminFields()
}
if !c.sendEvent(EventSnapshot, snap) {
return
}
c.lastSent.Store(snap.Version)
c.ackVersion.Store(snap.Version)
h.setVersion(snap.Version)
}
// Broadcast 把变更推给所有已握手的连接(大屏与管理端)。
func (h *Hub) Broadcast(changes []Change) { h.fanout("", changes) }
// BroadcastTo 把变更推给指定房间(如 RoomAdmin、AdminRoom(uid))。
//
// 同样只有测试在调用:生产的推送要么是 Broadcast(所有已握手连接),要么是
// SendCommandTo(大屏指令)。保留它是为了让「房间」这层语义有可用的出口,
// 删掉或接到生产调用方都要连 devdocs/05 §1 一起改(审查清单 C36)。
func (h *Hub) BroadcastTo(room string, changes []Change) { h.fanout(room, changes) }
// fanout 在 sendMu 下按版本去重后下发:已经发过的版本不再重复投递。
func (h *Hub) fanout(room string, changes []Change) {
if len(changes) == 0 {
return
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
version := int64(0)
for _, ch := range changes {
if ch.Version > version {
version = ch.Version
}
}
if version == 0 {
version = h.currentVersionCached(h.ctx)
}
h.setVersion(version)
for _, c := range h.roomClients(room) {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
continue
}
last := c.lastSent.Load()
settingsAdmin := c.settingsAdmin
filtered := make([]Change, 0, len(changes))
for _, ch := range changes {
if ch.Version > 0 && ch.Version <= last {
continue
}
filtered = append(filtered, redactChange(ch, settingsAdmin))
}
if len(filtered) == 0 {
continue
}
if !c.sendEvent(EventChange, changePayload{Version: version, Changes: filtered}) {
continue
}
c.lastSent.Store(version)
h.checkSlow(c, version)
}
}
// SendCommand 向所有大屏下发 display:command。
func (h *Hub) SendCommand(cmd DisplayCommand) { h.SendCommandTo(RoomDisplay, cmd) }
// SendCommandTo 向指定房间下发 display:command;非法 action 直接丢弃并记录。
func (h *Hub) SendCommandTo(room string, cmd DisplayCommand) {
if !ValidDisplayAction(cmd.Action) {
h.log.Warn("忽略非法的大屏指令", "action", cmd.Action, "room", room)
return
}
if cmd.Action == CmdGoto && (room == "" || room == RoomDisplay) {
h.setInstancePage(pageFromPayload(cmd.Payload), nil)
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
for _, c := range h.roomClients(room) {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
continue
}
c.sendEvent(EventDisplayCommand, cmd)
}
}
// ErrUnknownAction 表示指令的 action 不在协议清单内。
// 它不是服务端异常:action 是请求体入参,HTTP 层据此用 errors.Is 把它映射成 400,
// 只有真正的下发失败(写 socket 失败等)才该按 5xx 处理。
var ErrUnknownAction = errors.New("realtime: 未知的大屏指令")
// SendCommandToInstances 按实例定向下发 display:command,返回命中的实例数。
//
// targets 是要控制的 instanceID(GET /api/display/instances 里的 id);为空时退化成广播,
// 公告这类全屏指令靠的就是广播,这条语义不能改。已离线的实例不计入命中,
// 调用方据此告诉现场「那块屏没收到」。非法 action 返回错误而不是静默丢弃,
// 定向指令需要一个明确的失败信号。
func (h *Hub) SendCommandToInstances(targets []string, cmd DisplayCommand) (int, error) {
if !ValidDisplayAction(cmd.Action) {
// 包上哨兵,调用方才能把「入参非法」与「下发失败」分开映射成 4xx / 5xx。
return 0, fmt.Errorf("未知的大屏指令 %q: %w", cmd.Action, ErrUnknownAction)
}
if len(targets) == 0 {
h.SendCommand(cmd)
return h.onlineDisplayCount(), nil
}
// 归一化只做一次,并且**匹配与写注册表共用同一个值**:管理端从表格里带过来的 id
// 常有多余空白(粘贴/换行),以前匹配用 TrimSpace、写「当前页」用原值,
// 结果是指令真的发到了那块屏,注册表里的当前页却停在旧值上,
// 管理端下一次轮询又把界面上刚切过去的页显示回来。
wanted := make(map[string]bool, len(targets))
normalized := make([]string, 0, len(targets))
for _, id := range targets {
if id = strings.TrimSpace(id); id != "" {
if !wanted[id] {
wanted[id] = true
normalized = append(normalized, id)
}
}
}
h.sendMu.Lock()
matched := make(map[string]bool, len(wanted))
for _, c := range h.roomClients(RoomDisplay) {
c.mu.Lock()
greeted, id := c.greeted, c.instanceID
c.mu.Unlock()
if !greeted || !wanted[id] {
continue
}
c.sendEvent(EventDisplayCommand, cmd)
matched[id] = true
}
h.sendMu.Unlock()
if cmd.Action == CmdGoto {
h.setInstancePage(pageFromPayload(cmd.Payload), normalized)
}
return len(matched), nil
}
// onlineDisplayCount 统计已握手且在线的实例数,用于告知调用方广播命中了几块屏。
func (h *Hub) onlineDisplayCount() int {
seen := make(map[string]bool)
for _, c := range h.roomClients(RoomDisplay) {
c.mu.Lock()
greeted, id := c.greeted, c.instanceID
c.mu.Unlock()
if greeted && id != "" {
seen[id] = true
}
}
return len(seen)
}
// checkSlow 用 ack 版本差识别慢客户端;超阈值直接断开,由客户端重连后补齐。
func (h *Hub) checkSlow(c *client, version int64) {
lag := h.slowLagValue()
if lag <= 0 {
return
}
if version-c.ackVersion.Load() <= lag {
return
}
h.markSlow(c)
}
func (h *Hub) markSlow(c *client) {
if c.slow.Swap(true) {
return
}
c.mu.Lock()
id := c.instanceID
c.mu.Unlock()
h.mu.Lock()
if st := h.instances[id]; st != nil {
st.slow = true
}
h.mu.Unlock()
h.log.Warn("慢客户端:ack 长时间落后,断开后由重连补齐",
"instance", id, "ackVersion", c.ackVersion.Load(), "remote", c.remoteAddr)
c.stop(slowCloseCode, "ack 落后")
}
// heartbeatLoop 每 10s 下发一次心跳(version + serverTime)。
func (h *Hub) heartbeatLoop() {
defer h.hbWG.Done()
timer := time.NewTimer(h.heartbeatIntervalValue())
defer timer.Stop()
for {
select {
case <-h.ctx.Done():
return
case <-timer.C:
h.beat()
timer.Reset(h.heartbeatIntervalValue())
case <-h.heartbeatReset:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(h.heartbeatIntervalValue())
}
}
}
func (h *Hub) beat() {
version := h.currentVersionCached(h.ctx)
now := time.Now()
data := heartbeatPayload{Version: version, ServerTime: nowISO(now)}
// 挂起补发单独收集,等心跳全部发完再做:补发要现建一份快照(慢,且要抢 sendMu),
// 不能让排在后面的大屏连心跳都跟着等(落线判定的看门狗就在客户端那边盯着心跳)。
var pending []*client
for _, c := range h.roomClients("") {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
continue
}
// 会话失效(撤销 / 过期 / 用户停用)的连接先断开,不再给它下发心跳。
if h.sessionRevoked(c) {
continue
}
c.sendEvent(EventHeartbeat, data)
// 只同步 ack / 慢标记,**不刷 lastHeartbeat**:注册表里的「在线」与「最后活跃」
// 必须来自对端。服务端每 10s 自己给自己盖一个时间戳,会让拔了网线的屏一直
// 显示在线、指令发过去还报「命中 N 块屏」。真正的存活求证在 livenessLoop。
h.syncInstance(c)
h.checkSlow(c, version)
if c.snapshotPending.Load() {
pending = append(pending, c)
}
}
for _, c := range pending {
h.flushPendingSnapshot(h.ctx, c)
}
}
// sessionRevoked 复查连接背后的会话是否仍然有效,失效即发起断开;返回 true 表示已断开。
//
// 会话撤销(登出 / 改密 / 管理员重置密码 / 停用用户)会删掉会话行或停用用户,
// 但 /ws 的身份在握手时就固化了:不复查的话,那条长连接会一直以原角色收推送,
// 只有写操作被 HTTP 侧拦住。复查挂在心跳上,最迟一个心跳周期(默认 10s)内断开。
//
// 只有带会话的连接需要复查:guest 大屏连接没有会话,撤销语义不适用。
// 查询失败(库忙 / 系统库被长操作占住)一律按「仍有效」处理并留痕——
// 一次查询抖动不该把正在比赛的大屏整片踢下线。
func (h *Hub) sessionRevoked(c *client) bool {
checker := h.sessionCheckerValue()
if checker == nil {
return false
}
c.mu.Lock()
sessionID, role := c.sessionID, c.role
c.mu.Unlock()
if sessionID == "" {
return false
}
ctx, cancel := context.WithTimeout(h.ctx, sessionCheckTimeout)
defer cancel()
active, err := checker.SessionActive(ctx, sessionID)
if err != nil {
h.log.Warn("复查会话失败,本次保留连接", "session", sessionID, "remote", c.remoteAddr, "err", err)
return false
}
if active {
return false
}
// 管理端连接按「登录失效」关闭:前端对所有 1008 会停止重连并引导重新登录,
// 这正是被撤销的会话应有的表现。大屏连接改用 1001 让它自动重连——
// 重连时握手会按会话失效降级成 guest(HTTP 侧本来也是这样),
// 现场大屏不该因为操作员登出而永久冻结。
code, reason := websocket.StatusGoingAway, "会话已失效,按游客身份重连"
if role == RoleAdmin {
code, reason = websocket.StatusPolicyViolation, "会话已失效,请重新登录"
}
h.log.Info("会话已失效,断开实时连接", "session", sessionID, "role", role, "remote", c.remoteAddr)
c.stop(code, reason)
return true
}