Files
Scoreboard/server/internal/realtime/client.go
T
AskaEth 8dd599bd97 fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:

导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向

导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)

错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)

大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
  字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
  scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)

文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)

自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
2026-09-11 15:35:42 +08:00

202 lines
5.5 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
instanceID string
// settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
settingsAdmin bool
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) *client {
return &client{
hub: h,
conn: conn,
remoteAddr: remoteAddr,
send: make(chan []byte, defaultSendBuffer),
done: make(chan struct{}),
sessionRole: sessionRole,
sessionUserID: sessionUserID,
settingsAdmin: settingsAdmin,
}
}
// 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)
}
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 里跑,HTTP 中间件的 recover 覆盖不到,必须各自兜底。
func (c *client) readLoop(ctx context.Context) {
defer c.stop(websocket.StatusNormalClosure, "")
defer c.guardLoop("读")
for {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
readCtx := ctx
var cancel context.CancelFunc
if !greeted {
readCtx, cancel = context.WithTimeout(ctx, c.hub.helloTimeout())
}
_, data, err := c.conn.Read(readCtx)
if cancel != nil {
cancel()
}
if err != nil {
return
}
c.handleMessage(ctx, data)
}
}
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
}
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
}
}
}
}