Files
Scoreboard/server/internal/realtime/client.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

351 lines
12 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"
"encoding/json"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// client 是一条 WebSocket 连接。读写各一个 goroutine,
// 写通过带缓冲的 send 通道串行化,通道满即视为慢客户端。
type client struct {
hub *Hub
conn *websocket.Conn
remoteAddr string
send chan []byte
done chan struct{}
closeOnce sync.Once
closeCode websocket.StatusCode
closeReason string
mu sync.Mutex
greeted bool
// role/userID 是 hello 之后实际生效的身份;sessionRole/sessionUserID 是握手时从会话取到的上限,
// 客户端自报的 role/userId 不能越过它。
role string
userID int64
sessionRole string
sessionUserID int64
// sessionID 是握手时的会话 id(未登录为空):会话撤销后要据此复查并断开本连接。
sessionID string
instanceID string
// settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
settingsAdmin bool
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
// snapshotAt 是本连接最近一次发出整库快照的时间(unix 纳秒)。
// 它是「整库快照窗口」的记账点:resync 与不带 lastVersion 的 hello 都会走到发快照,
// 两者共用一个窗口,不能靠交替发把限流绕过去。
snapshotAt atomic.Int64
// snapshotPending 表示「有请求要过整库快照,但被窗口挡下了」:
// 心跳循环会在窗口过后补发一份,见 flushPendingSnapshot。
// 不能把被挡下的请求直接丢掉——大屏收到它不认识的变更时会主动 resync,
// 丢了就可能让那块屏一直停在旧数据上,直到下一条变更到来。
snapshotPending atomic.Bool
slow atomic.Bool
// lastSeen 是最近一次真正收到对端帧的时间(unix 纳秒),含普通数据帧与 ping 的 pong。
// 「这条连接还活着吗」只认它:服务端自己发过什么、写过几次 socket 都不算证据。
lastSeen atomic.Int64
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) *client {
c := &client{
hub: h,
conn: conn,
remoteAddr: remoteAddr,
send: make(chan []byte, defaultSendBuffer),
done: make(chan struct{}),
sessionRole: id.Role,
sessionUserID: id.UserID,
sessionID: id.SessionID,
settingsAdmin: id.SettingsAdmin,
}
// 建立连接本身就是一次「听到对端」:不初始化的话,存活探测会把刚连上的
// 大屏当成已经静默很久,第一条 ping 立刻发出去。
c.markSeen(time.Now())
return c
}
// markSeen 记下「刚刚听到了对端」。
func (c *client) markSeen(now time.Time) { c.lastSeen.Store(now.UnixNano()) }
// lastSeenAt 返回最近一次听到对端的时间。
func (c *client) lastSeenAt() time.Time { return time.Unix(0, c.lastSeen.Load()) }
// instanceIDValue 读实例 id;调用方拿到的是快照值,不持有锁。
func (c *client) instanceIDValue() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.instanceID
}
// isDone 判断本连接是否已经进入关闭流程。
func (c *client) isDone() bool {
select {
case <-c.done:
return true
default:
return false
}
}
// stop 只负责让读写与存活探测三条 goroutine 退出;真正的关闭动作由 serve 统一执行,
// 避免在持有广播锁时做可能阻塞的关闭握手。
func (c *client) stop(code websocket.StatusCode, reason string) {
c.closeOnce.Do(func() {
c.mu.Lock()
c.closeCode = code
c.closeReason = reason
c.mu.Unlock()
close(c.done)
})
}
// guardLoop 兜住读写 goroutine 里的 panic:HTTP 中间件的 recover 只覆盖 handler 栈,
// 这两条 goroutine 不兜底会把整个进程带走。
func (c *client) guardLoop(name string) {
if r := recover(); r != nil {
c.hub.log.Error("实时循环 panic,已断开连接", "loop", name, "remote", c.remoteAddr, "panic", r)
c.stop(websocket.StatusInternalError, name+"循环异常")
}
}
func (c *client) sendEvent(event string, data any) bool {
msg, err := encode(event, data)
if err != nil {
c.hub.log.Error("编码实时消息失败", "event", event, "err", err)
return false
}
return c.sendRaw(msg)
}
// allowSnapshot 按连接给「整库快照」记账:窗口内只放行一次。
//
// 触发整库快照的入口有两个(resync,以及不带 lastVersion 的 hello),它们共用这一个窗口:
// 一条 15 字节的帧就是一次 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
// 快照读取还在全局发送锁里——不限流的话,一个写坏的客户端就能把库连接与所有人的推送一起堵住。
// 窗口(defaultSnapshotMinInterval)比客户端自己的 3s 冷却短,正常客户端按自己的节奏回来时
// 窗口已经过期,重连补齐行为因此不受影响。
func (c *client) allowSnapshot(minInterval time.Duration) bool {
if minInterval <= 0 {
return true
}
now := time.Now().UnixNano()
for {
last := c.snapshotAt.Load()
if last != 0 && now-last < int64(minInterval) {
return false
}
if c.snapshotAt.CompareAndSwap(last, now) {
return true
}
}
}
func (c *client) sendRaw(msg []byte) bool {
select {
case <-c.done:
return false
default:
}
select {
case c.send <- msg:
return true
default:
c.hub.markSlow(c)
return false
}
}
// readLoop 解析 C→S 消息;握手必须在 helloTimeout 内完成,否则断开,
// 防止「连上就不说话」的空连接一直占着 goroutine 与 fd。
// 读写都在独立 goroutine 里跑,HTTP 中间件的 recover 覆盖不到,必须各自兜底。
func (c *client) readLoop(ctx context.Context) {
defer c.stop(websocket.StatusNormalClosure, "")
defer c.guardLoop("读")
// 握手 deadline 在进入循环时算一次,整个「未握手」阶段共用它。
//
// 以前是每轮 Read 都新建一个 WithTimeout:只要对端在超时前随便发一帧(哪怕是个
// 客户端不认识的事件),计时就被重置,未握手的连接可以无限期挂着——
// 现场用脚本反复重连就能堆出一堆永不释放的连接。绝对 deadline 让「打开连接却不
// hello」在固定时间后必然被断开;已握手的连接不受它限制。
helloDeadline := time.Now().Add(c.hub.helloTimeout())
for {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
readCtx := ctx
var cancel context.CancelFunc
if !greeted {
readCtx, cancel = context.WithDeadline(ctx, helloDeadline)
}
_, data, err := c.conn.Read(readCtx)
if cancel != nil {
cancel()
}
if err != nil {
return
}
// 收到任何一帧都是「对端还活着」的证据,与它是不是合法事件无关。
c.markSeen(time.Now())
c.handleMessage(ctx, data)
}
}
// livenessLoop 定期求证对端还在不在:静默(没有任何 C→S 帧)超过一个探测周期就发一次
// WebSocket ping 并等 pong,连续 maxProbeFailures 次等不到就断开这条连接。
//
// 为什么必须单独探:大屏在数据没变时只收不发(hello 只在连接与翻页时发、ack 只在应用
// 变更时发),所以「没有上行帧」不等于掉线,不能当判据;而 TCP 层面「连接对象还在」
// 什么也证明不了——拔网线、断电、机器休眠都不会让服务端立刻收到错误,内核缓冲会让写
// 一直“成功”,注册表里的「在线」就会永远挂着那块已经不在的屏。
// ping/pong 是唯一的真实往返,且由浏览器在网络层自动应答,不需要前端改代码。
func (c *client) livenessLoop(ctx context.Context) {
defer c.guardLoop("存活探测")
failures := 0
for {
interval := c.hub.livenessIntervalValue()
if interval <= 0 {
// <= 0 表示关闭探测,只给「单独验证某个判定口径」的用例用:
// 生产路径上 NewHub 一定给了默认值,不会走到这里。
return
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-c.done:
timer.Stop()
return
case <-timer.C:
}
if time.Since(c.lastSeenAt()) < interval {
// 静默期内收到过真实上行帧:这条连接有人,不必多问一句。
failures = 0
continue
}
if c.probePeer(ctx) {
failures = 0
continue
}
if ctx.Err() != nil || c.isDone() {
return
}
failures++
if failures < maxProbeFailures {
// 一次不回应可能只是对端正忙(例如正在收一份大快照,写锁被占住),
// 再给一个周期;连续失败才说明这条连接上已经没有人了。
continue
}
c.hub.log.Info("实时连接长时间无响应(未回 pong),按掉线断开",
"remote", c.remoteAddr, "instance", c.instanceIDValue(),
"silent", time.Since(c.lastSeenAt()).Truncate(time.Second).String())
c.stop(unresponsiveCloseCode, "连接无响应")
return
}
}
// probePeer 发一次 ping 并等 pong;返回 true 表示对端应答了。
func (c *client) probePeer(ctx context.Context) bool {
pingCtx, cancel := context.WithTimeout(ctx, c.hub.pongTimeoutValue())
defer cancel()
if err := c.conn.Ping(pingCtx); err != nil {
c.hub.log.Debug("存活探测未收到 pong", "remote", c.remoteAddr, "err", err)
return false
}
now := time.Now()
c.markSeen(now)
// 这次往返是真实的客户端应答,注册表的「最后活跃」也该跟着走。
c.hub.touchInstance(c, now)
return true
}
func (c *client) handleMessage(ctx context.Context, data []byte) {
var env envelope
if err := json.Unmarshal(data, &env); err != nil {
c.hub.log.Debug("忽略无法解析的实时消息", "remote", c.remoteAddr, "err", err)
return
}
switch env.Event {
case EventHello:
var p helloPayload
if err := json.Unmarshal(env.Data, &p); err != nil {
c.hub.log.Debug("hello 载荷不合法", "remote", c.remoteAddr, "err", err)
return
}
c.hub.handleHello(ctx, c, p)
case EventAck:
var p ackPayload
if err := json.Unmarshal(env.Data, &p); err != nil {
c.hub.log.Debug("ack 载荷不合法", "remote", c.remoteAddr, "err", err)
return
}
if p.Version > c.ackVersion.Load() {
c.ackVersion.Store(p.Version)
}
c.hub.touchInstance(c, time.Now())
case EventResync:
// hello 之前的 resync 既没有房间归属,又会提前清掉 hello 超时,直接忽略。
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
c.hub.log.Debug("忽略 hello 之前的 resync", "remote", c.remoteAddr)
return
}
// 整库快照的限流统一在 sendInitial 里判定(resync 与重复 hello 共用同一个窗口)。
c.hub.sendInitial(ctx, c, nil)
default:
// 客户端最常见的错法:只发了 payload,没套 `{event,data}` 外壳。
// 那种帧解出来 Event == "",以前只在 DEBUG 里留一行 —— 现场表现是
// 「连上了但一直没数据,15 秒后被断开」,而且客户端拿不到任何提示。
// 这里升到 WARN 并把「该怎么发」写进日志,省掉一次翻源码。
if strings.TrimSpace(env.Event) == "" {
c.hub.log.Warn("实时帧缺少 event 字段,已忽略(C→S 帧同样要套 {event,data} 外壳)",
"remote", c.remoteAddr)
return
}
c.hub.log.Warn("忽略未知实时事件", "event", env.Event, "remote", c.remoteAddr)
}
}
// writeLoop 是唯一的写者,保证同一连接上的帧顺序。
func (c *client) writeLoop(ctx context.Context) {
defer c.guardLoop("写")
for {
select {
case <-ctx.Done():
return
case <-c.done:
return
case msg := <-c.send:
writeCtx, cancel := context.WithTimeout(ctx, c.hub.writeTimeout())
err := c.conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
if err != nil {
c.stop(websocket.StatusInternalError, "写入失败")
return
}
}
}
}