feat: M3-M7 全模块落地 + 集成接线

- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
package realtime
import (
"context"
"encoding/json"
"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 string
userID int64
instanceID string
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string) *client {
return &client{
hub: h,
conn: conn,
remoteAddr: remoteAddr,
send: make(chan []byte, defaultSendBuffer),
done: make(chan struct{}),
}
}
// 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)
})
}
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 限制,防止空连接占用资源。
func (c *client) readLoop(ctx context.Context) {
defer c.stop(websocket.StatusNormalClosure, "")
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:
c.hub.sendInitial(ctx, c, nil)
default:
c.hub.log.Debug("忽略未知实时事件", "event", env.Event, "remote", c.remoteAddr)
}
}
// writeLoop 是唯一的写者,保证同一连接上的帧顺序。
func (c *client) writeLoop(ctx context.Context) {
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
}
}
}
}