本批七项(三处是现场报的 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 与两处前端注释的旧窗口说法一并订正。
960 lines
31 KiB
Go
960 lines
31 KiB
Go
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
|
||
}
|