Files
Scoreboard/server/internal/realtime/hub.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

879 lines
26 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* 调整。
const (
defaultHeartbeatInterval = 10 * time.Second
defaultMaxCatchup = 500
defaultSlowLag = 500
defaultHelloTimeout = 15 * time.Second
defaultWriteTimeout = 10 * time.Second
defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
// 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)
// 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
// 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,
}
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 调整心跳间隔并立即生效;用于现场调优与测试。
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) 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(2)
go func() { defer loops.Done(); c.readLoop(ctx) }()
go func() { defer loops.Done(); c.writeLoop(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, p.Page, p.Screen)
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 {
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 {
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))。
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
}
wanted := make(map[string]bool, len(targets))
for _, id := range targets {
if id = strings.TrimSpace(id); id != "" {
wanted[id] = true
}
}
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), targets)
}
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)
h.touchInstance(c, now)
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
}