新增 WebSocket 网关:连接管理、心跳保活、限流与消息路由

This commit is contained in:
2026-09-27 19:26:55 +08:00
parent ff9add8655
commit 99d1827262
11 changed files with 632 additions and 8 deletions
+5
View File
@@ -68,3 +68,8 @@ WS:
Compression: false # 是否启用 permessage-deflate 压缩
MultiDevice: false # false=同账号新连接踢掉旧连接
OriginPatterns: [] # 允许的 Origin 域名(浏览器调试用,小游戏连接不带 Origin)
ReconnectGraceSec: 30 # 掉线后保留座位的时长(秒),超时未重连则判负或平局
RoomIdleTimeoutSec: 300 # 房间空闲自动关闭的时长(秒)
MatchTimeoutSec: 60 # 匹配排队超过该时长(秒)自动取消
QueueTickMs: 1000 # 匹配队列的扫描间隔(毫秒)
MaxRooms: 1000 # 同时存在的房间数上限,防止内存失控
+9 -8
View File
@@ -3,12 +3,13 @@ package global
import "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/setting"
var (
ServerSetting *setting.ServerSettingS
LogSetting *setting.LogSettingS
MysqlSetting *setting.MysqlSettingS
RedisSetting *setting.RedisSettingS
JWTSetting *setting.JWTSettingS
UploadSetting *setting.UploadSettingS
CosSetting *setting.CosSettingS
WechatMiniSetting *setting.WechatMiniSettingS
ServerSetting *setting.ServerSettingS // HTTP 服务配置(域名、运行模式、端口、读写超时)
LogSetting *setting.LogSettingS // 日志配置(路径、文件名、级别、轮转参数)
MysqlSetting *setting.MysqlSettingS // MySQL 连接与连接池配置
RedisSetting *setting.RedisSettingS // Redis 配置(当前仅读取,不建连接)
JWTSetting *setting.JWTSettingS // JWT 配置(密钥、签发方、双令牌有效期)
UploadSetting *setting.UploadSettingS // 上传配置(临时目录、大小上限、扩展名白名单)
CosSetting *setting.CosSettingS // 腾讯云 COS 配置(密钥、上传/下载域名、CDN 鉴权密钥)
WechatMiniSetting *setting.WechatMiniSettingS // 微信小程序配置(AppID / AppSecret)
WSSetting *setting.WSSettingS // WebSocket 网关配置(新增:供 internal/wsgateway 读取)
)
+1
View File
@@ -24,6 +24,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect
+2
View File
@@ -16,6 +16,8 @@ github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+4
View File
@@ -5,6 +5,7 @@ import (
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/middleware"
v1 "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers/api/v1"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
"github.com/gin-gonic/gin"
)
@@ -25,6 +26,9 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// WebSocket 建连入口:必须放在鉴权组之外,token 走 query 由 ServeWS 自行校验。
r.GET("/ws", wsgateway.ServeWS)
// handler 实例
userHandler := v1.NewUserHandler()
+266
View File
@@ -0,0 +1,266 @@
package wsgateway // 包名声明:服务端连接对象,属于传输层
import (
"context" // 标准库:连接的生命周期上下文,关闭即取消
"errors" // 标准库:用 errors.Is 判断上下文取消、连接关闭等错误
"log/slog" // 标准库:结构化日志,记录异常读写与限流踢线
"net" // 标准库:net.ErrClosed 用于识别「连接已关闭」类错误
"sync" // 标准库:Mutex 保护活跃时间与限流计数,Once 保证关闭幂等
"time" // 标准库:超时时间、心跳周期、限流窗口都依赖时间
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" // 本项目:读取 global.WSSetting 的传输层参数
"github.com/coder/websocket" // 第三方:coder/websocket,负责帧读写、ping、关闭
)
// pongWait 返回「多久没收到消息就判死」的时长,对应配置的 ReadTimeout。
func pongWait() time.Duration {
if global.WSSetting != nil && global.WSSetting.ReadTimeout > 0 { // 配置存在且为正数
return time.Duration(global.WSSetting.ReadTimeout) * time.Second // 按配置(秒)换算为 Duration
}
return 60 * time.Second // 配置缺失时的兜底值,避免 0 值导致连接立刻被判死
}
// writeWait 返回单次写或 ping 的超时时长,对应配置的 WriteTimeout。
func writeWait() time.Duration {
if global.WSSetting != nil && global.WSSetting.WriteTimeout > 0 { // 配置存在且为正数
return time.Duration(global.WSSetting.WriteTimeout) * time.Second // 按配置换算
}
return 10 * time.Second // 兜底 10 秒
}
// pingPeriod 返回服务端主动 ping 的周期,对应配置的 PingInterval。
func pingPeriod() time.Duration {
if global.WSSetting != nil && global.WSSetting.PingInterval > 0 { // 配置存在且为正数
return time.Duration(global.WSSetting.PingInterval) * time.Second // 按配置换算
}
return 25 * time.Second // 兜底 25 秒
}
// queueSize 返回发送队列长度,对应配置的 SendQueueSize。
func queueSize() int {
if global.WSSetting != nil && global.WSSetting.SendQueueSize > 0 { // 配置存在且为正数
return global.WSSetting.SendQueueSize // 使用配置值
}
return 64 // 兜底 64 条
}
// Connection 表示服务端持有的一条 WebSocket 连接(不是客户端)。
// 每条连接由两个 goroutine 驱动:ReadPump 收、WritePump 发,中间用 send 队列解耦。
//
// 说明:coder/websocket 会自动应答协议级 ping 帧,但该应答对 Read 不可见,
// 因此「死连接」判定由 WritePump 侧的空闲检查负责。
type Connection struct {
hub *Hub // 所属 Hub:用于注销自己、以及业务层反向查找
conn *websocket.Conn // 底层 ws 连接句柄:真正的帧读写入口
userID int64 // 该连接归属的用户 ID:由建连时的 JWT 解析得到
send chan []byte // 发送队列:业务侧只入队,写 goroutine 负责真正发送
closed chan struct{} // 关闭信号:close 后所有 select 立即感知
once sync.Once // 关闭幂等:Close 可能被读泵、写泵、Hub 多处调用
ctx context.Context // 连接生命周期上下文:关闭时取消,供 handler 起的 goroutine 感知
cancel context.CancelFunc // 取消函数:Close 时调用,触发 ctx 取消
mu sync.Mutex // 保护下面三个字段(活跃时间、限流窗口起点、限流计数)
lastActive time.Time // 最近一次收到客户端消息的时间:判死连接用
winStart time.Time // 限流窗口起点:每秒重置一次
winCount int // 当前窗口内已收到的消息数
}
// NewConnection 为一条已升级的连接创建对象。
func NewConnection(hub *Hub, conn *websocket.Conn, userID int64) *Connection {
ctx, cancel := context.WithCancel(context.Background()) // 以根 context 派生可取消的连接上下文
return &Connection{
hub: hub, // 绑定所属 Hub
conn: conn, // 保存底层连接
userID: userID, // 保存用户 ID
send: make(chan []byte, queueSize()), // 按配置创建带缓冲的发送队列
closed: make(chan struct{}), // 创建关闭信号通道
ctx: ctx, // 保存上下文
cancel: cancel, // 保存取消函数,供 Close 调用
lastActive: time.Now(), // 初始活跃时间为当前时间,避免刚连上就被判死
}
}
// UserID 返回该连接对应的用户 ID。
func (c *Connection) UserID() int64 {
return c.userID // 直接返回建立连接时保存的 ID
}
// Context 返回连接生命周期上下文:handler 内起 goroutine 时用它感知断连。
func (c *Connection) Context() context.Context {
return c.ctx // 返回内部上下文
}
// Send 把一条已编码的消息放入发送队列;队列满视为慢客户端并断开。
func (c *Connection) Send(data []byte) bool {
select {
case <-c.closed: // 连接已关闭
return false // 投递失败
default: // 未关闭则继续往下走,避免阻塞
}
select {
case c.send <- data: // 尝试入队成功
return true // 投递成功
case <-c.closed: // 入队期间连接被关闭
return false // 投递失败
default: // 队列已满:说明客户端消费跟不上
slog.WarnContext(c.ctx, "ws send queue full, close slow client", "user_id", c.userID) // 记录慢客户端日志
c.Close() // 主动断开,避免内存被堆积消息撑爆
return false // 投递失败
}
}
// SendEnvelope 把信封编码后入队发送。
func (c *Connection) SendEnvelope(typ string, seq int64, payload any) bool {
data, err := Encode(typ, seq, payload) // 先序列化为 JSON 字节
if err != nil { // 序列化失败(payload 含不可序列化字段)
slog.ErrorContext(c.ctx, "ws encode envelope failed", "type", typ, "error", err) // 记录错误日志
return false // 发送失败
}
return c.Send(data) // 入队发送
}
// SendError 便捷地发一条错误下行消息。
func (c *Connection) SendError(seq int64, code int, msg string) bool {
return c.SendEnvelope(TypeError, seq, ErrorPayload{Code: code, Msg: msg}) // 组装 ErrorPayload 并以 error 类型下发
}
// Close 关闭连接,多处调用只生效一次。
func (c *Connection) Close() {
c.once.Do(func() { // 用 Once 保证幂等,避免重复 close 通道 panic
close(c.closed) // 广播关闭信号,读写泵立即退出
c.cancel() // 取消连接上下文,通知所有派生 goroutine
_ = c.conn.Close(websocket.StatusNormalClosure, "") // 以正常关闭状态码关闭底层连接,忽略返回错误
})
}
// touch 把活跃时间刷新为当前时间,表示刚收到客户端消息。
func (c *Connection) touch() {
c.mu.Lock() // 加锁:与 idleFor 共享字段
c.lastActive = time.Now() // 更新活跃时间
c.mu.Unlock() // 解锁
}
// idleFor 返回距上次收到消息已经过去多久。
func (c *Connection) idleFor() time.Duration {
c.mu.Lock() // 加锁读取
defer c.mu.Unlock() // 函数返回时解锁
return time.Since(c.lastActive) // 计算与当前时间的差值
}
// allow 做固定窗口限流:每秒消息数超过 MaxMsgPerSec 就返回 false。
func (c *Connection) allow() bool {
limit := 0 // 默认不限流
if global.WSSetting != nil { // 配置存在
limit = global.WSSetting.MaxMsgPerSec // 取配置的每秒上限
}
if limit <= 0 { // 未配置或配置为 0
return true // 不做限制
}
now := time.Now() // 取当前时间
c.mu.Lock() // 加锁:修改窗口计数
defer c.mu.Unlock() // 返回时解锁
if now.Sub(c.winStart) >= time.Second { // 距窗口起点已超过 1 秒
c.winStart = now // 开启新窗口
c.winCount = 0 // 计数归零
}
c.winCount++ // 本条消息计入当前窗口
return c.winCount <= limit // 未超过上限才放行
}
// ReadPump 是读循环:收帧 → 更新活跃时间 → 限流 → 解码 → 心跳 → 路由分发。
// 该函数阻塞运行,一旦返回就表示连接结束,defer 里完成注销与关闭。
func (c *Connection) ReadPump() {
defer func() { // 退出前必定执行清理
c.hub.Unregister(c) // 从 Hub 表中摘掉自己
c.Close() // 关闭连接(幂等)
}()
for { // 持续读取,直到出错或连接关闭
mt, data, err := c.conn.Read(c.ctx) // 阻塞读取一帧;coder/websocket 会自动应答协议级 ping,这里只拿到业务消息
if err != nil { // 读取出错:可能是正常关闭、上下文取消或对端异常断开
if !isNormalClose(err) { // 过滤掉正常关闭类错误,避免噪音日志
slog.WarnContext(c.ctx, "ws read error", "user_id", c.userID, "error", err) // 记录异常读错误
}
return // 退出读循环
}
if mt != websocket.MessageText { // 只接受文本帧(业务信封统一用 JSON 文本)
continue // 二进制帧直接忽略,继续读下一条
}
c.touch() // 收到消息:刷新活跃时间,避免被判死
if !c.allow() { // 超过每秒消息数上限
slog.WarnContext(c.ctx, "ws message rate exceeded", "user_id", c.userID) // 记录限流日志
c.SendError(0, 0, "消息频率超限") // 先回一条错误告知客户端
return // 再断开连接
}
env, err := Decode(data) // 把 JSON 字节解析成统一信封
if err != nil { // 信封格式不合法
c.SendError(0, 0, "消息格式错误: "+err.Error()) // 回错误但不断开,允许客户端继续尝试
continue // 继续读下一条
}
if env.Type == TypePing { // 应用层心跳
c.SendEnvelope(TypePong, env.Seq, nil) // 原样带回 seq 回一条 pong
continue // 心跳不进业务路由
}
// TODO(第二步):大厅与房间的 handler 都从这里进入(hall.match / room.action 等)。
c.hub.Route(c.ctx, c, env) // 按类型分发给已注册的业务处理器
}
}
// WritePump 是写循环:发送队列 + 主动 ping 保活 + 空闲超时判死。
func (c *Connection) WritePump() {
ticker := time.NewTicker(pingPeriod()) // 按配置周期触发心跳与空闲检查
defer func() { // 退出前清理
ticker.Stop() // 停止定时器,避免泄漏
c.Close() // 关闭连接(幂等)
}()
for { // 循环处理三类事件
select {
case <-c.closed: // 连接被关闭
return // 退出写循环
case msg := <-c.send: // 有待发送的下行消息
ctx, cancel := context.WithTimeout(c.ctx, writeWait()) // 为本次写操作加超时,防止对端不读导致永久阻塞
err := c.conn.Write(ctx, websocket.MessageText, msg) // 写出一帧文本消息
cancel() // 立即释放超时上下文资源
if err != nil { // 写失败:连接多半已不可用
return // 退出写循环,由 defer 关闭
}
case <-ticker.C: // 心跳周期到点
if idle := c.idleFor(); idle > pongWait() { // 超过 ReadTimeout 未收到任何客户端消息
slog.InfoContext(c.ctx, "ws idle timeout, close connection",
"user_id", c.userID, "idle", idle.String()) // 记录判死日志
_ = c.conn.Close(websocket.StatusPolicyViolation, "idle timeout") // 以策略违规码关闭,告知对端原因
return // 退出写循环
}
ctx, cancel := context.WithTimeout(c.ctx, writeWait()) // 为 ping 加超时
err := c.conn.Ping(ctx) // 发送协议级 ping,探测链路与对端存活
cancel() // 释放上下文
if err != nil { // ping 失败:连接已死
return // 退出写循环
}
}
}
}
// isNormalClose 判断错误是否属于「正常结束」,避免把正常断开记成异常告警。
func isNormalClose(err error) bool {
if err == nil { // 没有错误
return true // 视为正常
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, net.ErrClosed) {
// 上下文被取消(我们主动关闭)、超时、或底层连接已关闭,都属于预期内的结束
return true // 视为正常
}
return websocket.CloseStatus(err) != -1 // 有明确关闭状态码说明是对端发起的正常关闭
}
+89
View File
@@ -0,0 +1,89 @@
// Package wsgateway 提供 WebSocket 长连接网关。
// 职责边界:本包只做「传输」——建连鉴权、连接管理、心跳保活、限流、消息编解码与路由分发;
// 不理解任何玩法语义,大厅与房间的业务由 internal/hall、internal/game 注册 handler 后接入。
package wsgateway // 包名声明:本目录下所有文件同属 wsgateway 包
import (
"encoding/json" // 标准库:用于把信封与业务体序列化为 JSON、以及把上行 JSON 反序列化
"fmt" // 标准库:用 fmt.Errorf 包装错误,保留上下文
"time" // 标准库:取当前时间戳,填充 hello 下行消息中的 server_time
)
// ===== 上行消息类型:客户端 → 服务端 =====
const (
TypePing = "ping" // 应用层心跳:客户端周期性发送,服务端原样回 pong
// TODO(第二步 大厅):在此补充 TypeHallMatch = "hall.match"、TypeHallCancel = "hall.cancel"
// TODO(第二步 房间):在此补充 TypeRoomJoin = "room.join"、TypeRoomReady = "room.ready"、
// TypeRoomAction = "room.action"、TypeRoomLeave = "room.leave"、
// TypeRoomReconnect = "room.reconnect"
)
// ===== 下行消息类型:服务端 → 客户端 =====
const (
TypeHello = "hello" // 建连成功后下发的首条消息,携带 userID 与心跳建议间隔
TypePong = "pong" // 对上行 ping 的应答
TypeEvent = "event" // 业务事件统一信封:房间状态、对局事件等都走这个类型,payload 由各模块自定义
TypeError = "error" // 错误下行:参数错误、未支持类型、业务错误统一走这里
)
// Envelope 是上下行消息的统一信封。
// 约定:业务差异全部放进 payload,网关只认识 type 与 seq,不解析业务字段。
type Envelope struct {
Type string `json:"type"` // 消息类型:决定路由到哪个 handler
Seq int64 `json:"seq,omitempty"` // 客户端自增序号:服务端回包原样带回,便于客户端对账;为空则不输出该字段
Payload json.RawMessage `json:"payload,omitempty"` // 业务体:保持原始字节,由具体模块的 handler 自行反序列化
}
// ErrorPayload 是 type=error 时的下行体。
// TODO(第二步):Code 建议复用 pkg/errcode 的业务码(例如 errcode.InvalidParams.Code()),保持前后端错误码一致。
type ErrorPayload struct {
Code int `json:"code"` // 业务错误码
Msg string `json:"msg"` // 错误描述,可直接展示给用户
Details string `json:"details,omitempty"` // 可选的补充信息(如参数校验详情),为空则不输出
}
// HelloPayload 是 type=hello 时的下行体。
type HelloPayload struct {
UserID int64 `json:"user_id"` // 服务端从 token 解析出的用户 ID
PingInterval int `json:"ping_interval"` // 心跳间隔(秒):客户端按此间隔发送 {"type":"ping"}
ServerTime int64 `json:"server_time"` // 服务端当前秒级时间戳:供客户端校正时钟
}
// Encode 把任意 payload 打包成信封并返回 JSON 字节。
func Encode(typ string, seq int64, payload any) ([]byte, error) {
env := Envelope{Type: typ, Seq: seq} // 先构造信封,填入类型与序号
if payload != nil { // payload 为空(如 pong)时跳过序列化
raw, err := json.Marshal(payload) // 把业务体序列化为 JSON 字节
if err != nil { // 序列化失败通常意味着 payload 里有不可序列化的字段
return nil, fmt.Errorf("marshal payload: %w", err) // 包装错误并返回给调用方
}
env.Payload = raw // 把序列化结果放入信封
}
return json.Marshal(env) // 把整个信封序列化为最终要发送的字节
}
// Decode 把上行 JSON 字节解析为信封。
func Decode(data []byte) (*Envelope, error) {
var env Envelope // 声明目标结构体
if err := json.Unmarshal(data, &env); err != nil { // 反序列化上行数据
return nil, fmt.Errorf("unmarshal envelope: %w", err) // 格式错误:返回给上层,由上层回 error 消息
}
if env.Type == "" { // 缺少 type 字段无法路由
return nil, fmt.Errorf("envelope type is empty") // 返回明确的错误原因
}
return &env, nil // 返回解析成功的信封
}
// BindPayload 把信封中的 payload 解析到调用方给定的结构体,供各模块 handler 使用。
func BindPayload(env *Envelope, out any) error {
if len(env.Payload) == 0 { // 上行未携带业务体
return fmt.Errorf("payload is empty") // 返回错误,handler 可据此回参数错误
}
return json.Unmarshal(env.Payload, out) // 按 out 的类型反序列化
}
// nowUnix 返回当前秒级时间戳。
func nowUnix() int64 {
return time.Now().Unix() // 取当前时间并转换为 Unix 秒
}
+133
View File
@@ -0,0 +1,133 @@
package wsgateway // 包名声明:与 envelope.go、connection.go、server.go 同包
import (
"context" // 标准库:handler 的入参上下文,连接关闭即取消
"log/slog" // 标准库:结构化日志,未注册消息类型时打 debug 日志
"sync" // 标准库:RWMutex,保护 Hub 内部的两张表
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" // 本项目:读取 global.WSSetting 判断是否允许多设备
)
// Handler 是业务消息处理器签名,由 hall / room 模块实现并注册到 Hub。
// ctx 随连接关闭而取消:handler 内部若起 goroutine,必须监听它;绝不要把 *gin.Context 带进来。
type Handler func(ctx context.Context, c *Connection, env *Envelope)
// DefaultHub 是进程级默认 Hub 单例。
// 若将来需要注入(方便单测),可改为在 main 里 NewHub() 并传给 routers.NewRouter。
var DefaultHub = NewHub() // 包初始化时即创建,server.go 与路由共用这一个实例
// Hub 是连接注册表与消息路由器,本身不理解任何玩法。
type Hub struct {
mu sync.RWMutex // 读写锁:保护下面两个 map,读多写少所以用 RWMutex
conns map[int64]*Connection // userID -> 连接:一个用户当前对应的那条连接
handlers map[string]Handler // 消息类型 -> 处理器:决定上行消息由谁处理
}
// NewHub 构造一个空的 Hub。
func NewHub() *Hub {
return &Hub{
conns: make(map[int64]*Connection), // 初始化连接表,避免 nil map 写入 panic
handlers: make(map[string]Handler), // 初始化处理器表,避免 nil map 写入 panic
}
}
// RegisterHandler 注册某类消息的处理函数。
// TODO(第二步):hall 注册 hall.match / hall.cancel;房间注册 room.join / room.action / room.leave 等。
func (h *Hub) RegisterHandler(msgType string, fn Handler) {
h.mu.Lock() // 加写锁:修改 handlers 表
defer h.mu.Unlock() // 函数返回时解锁
h.handlers[msgType] = fn // 覆盖式注册:同名类型以最后注册者为准
}
// Register 把新建连接登记到 Hub;MultiDevice=false 时先踢掉同账号的旧连接。
func (h *Hub) Register(c *Connection) {
h.mu.Lock() // 加写锁:可能修改 conns 表
defer h.mu.Unlock() // 返回时解锁
if global.WSSetting == nil || !global.WSSetting.MultiDevice { // 未配置或明确禁止多设备:执行踢线
if old, ok := h.conns[c.UserID()]; ok && old != c { // 该账号已在线且不是同一条连接
slog.InfoContext(c.Context(), "ws kick old connection", "user_id", c.UserID()) // 记录踢线日志,便于排查重复登录
old.Close() // 关闭旧连接:旧连接的读写泵会随之退出
delete(h.conns, c.UserID()) // 从表中移除旧连接,给新连接腾位置
}
}
h.conns[c.UserID()] = c // 登记新连接
}
// Unregister 从表中注销连接;只有表中仍是自己时才删除,避免误删踢线后新建的连接。
func (h *Hub) Unregister(c *Connection) {
h.mu.Lock() // 加写锁:修改 conns 表
defer h.mu.Unlock() // 返回时解锁
if cur, ok := h.conns[c.UserID()]; ok && cur == c { // 表中存在且指向同一条连接
delete(h.conns, c.UserID()) // 安全删除
}
}
// Get 取指定用户当前在线的连接。
func (h *Hub) Get(userID int64) (*Connection, bool) {
h.mu.RLock() // 加读锁:只读不写
defer h.mu.RUnlock() // 返回时解锁
c, ok := h.conns[userID] // 查表
return c, ok // 返回连接与是否在线
}
// Online 返回当前在线连接数(默认一人一条连接,即在线用户数)。
func (h *Hub) Online() int {
h.mu.RLock() // 加读锁
defer h.mu.RUnlock() // 返回时解锁
return len(h.conns) // 表长度即在线数
}
// Route 按消息类型分发上行消息;未注册的类型直接回错误,不静默丢弃,方便客户端自测。
func (h *Hub) Route(ctx context.Context, c *Connection, env *Envelope) {
h.mu.RLock() // 加读锁:查 handlers 表
fn, ok := h.handlers[env.Type] // 按类型找处理器
h.mu.RUnlock() // 立即解锁,避免业务执行期间占用锁
if !ok { // 没找到对应处理器
slog.DebugContext(ctx, "ws unsupported message type", "type", env.Type, "user_id", c.UserID()) // 记录调试日志
c.SendError(env.Seq, 0, "暂不支持的消息类型: "+env.Type) // 回一条错误信封,带上原 seq 便于客户端定位
return // 结束分发
}
fn(ctx, c, env) // 交给业务处理器执行
}
// SendTo 向单个用户下发一条已编码的消息;返回 false 表示已离线或发送队列已满。
func (h *Hub) SendTo(userID int64, data []byte) bool {
h.mu.RLock() // 加读锁:查表
c, ok := h.conns[userID] // 找到目标连接
h.mu.RUnlock() // 解锁
if !ok { // 用户不在线
return false // 返回投递失败,调用方可决定是否落库或重连后再发
}
return c.Send(data) // 入队发送
}
// Broadcast 向指定用户集合下发同一条消息。
// TODO(第二步):房间内广播就走这里——玩法产出的 Event 由房间翻译成信封后调用本方法。
func (h *Hub) Broadcast(userIDs []int64, data []byte) {
h.mu.RLock() // 加读锁
targets := make([]*Connection, 0, len(userIDs)) // 预分配切片,容量等于目标数
for _, uid := range userIDs { // 遍历目标用户 ID
if c, ok := h.conns[uid]; ok { // 在线才加入投递列表
targets = append(targets, c) // 收集连接指针
}
}
h.mu.RUnlock() // 解锁:后续发送不持锁,避免慢客户端阻塞注册表
for _, c := range targets { // 逐个投递
c.Send(data) // 入队发送(队列满会自行断开慢客户端)
}
}
// BroadcastAll 向全部在线连接下发,适用于大厅公告、全服通知等。
func (h *Hub) BroadcastAll(data []byte) {
h.mu.RLock() // 加读锁
targets := make([]*Connection, 0, len(h.conns)) // 预分配切片,容量等于当前在线数
for _, c := range h.conns { // 遍历所有连接
targets = append(targets, c) // 收集连接指针
}
h.mu.RUnlock() // 解锁后再发送
for _, c := range targets { // 逐个投递
c.Send(data) // 入队发送
}
}
+95
View File
@@ -0,0 +1,95 @@
package wsgateway // 包名声明:HTTP 升级入口,属于传输层
import (
"log/slog" // 标准库:记录升级失败与建连日志
"net/http" // 标准库:HTTP 状态码常量
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" // 本项目:读取 WS 配置(心跳、Origin、压缩、单帧上限)
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/lzcwyjwt" // 本项目:解析 access token,拿到 userID
"github.com/coder/websocket" // 第三方:提供 Accept 完成 WebSocket 握手
"github.com/gin-gonic/gin" // 第三方:HTTP 框架,本函数是 gin handler
)
// ServeWS 是 WebSocket 建连入口:token 鉴权 → Accept 升级 → 注册 Hub → 启动读写泵。
//
// 小程序 ws 无法自定义请求头,因此 access token 通过 query 传入:/ws?token=xxx
func ServeWS(c *gin.Context) {
token := c.Query("token") // 从 URL query 中取 token
if token == "" { // 未携带 token
c.JSON(http.StatusUnauthorized, gin.H{"code": 11004, "msg": "Token 缺失"}) // 返回 401(此时尚未升级,可正常写 JSON)
return // 结束请求
}
// TODO(可选):改为一次性 ticket(HTTP 换 ticket,ws 用 ticket),避免长 token 落在 URL 与访问日志里。
claims, err := lzcwyjwt.ParseAccessToken(token) // 解析并校验 access token(签名、签发方、有效期、类型)
if err != nil { // token 无效或已过期
c.JSON(http.StatusUnauthorized, gin.H{"code": 11001, "msg": "Token 鉴权失败"}) // 返回 401
return // 结束请求
}
// Accept 失败时库已写入 HTTP 错误响应,这里不再写 body,避免重复写响应头。
conn, err := websocket.Accept(c.Writer, c.Request, acceptOptions()) // 完成握手,拿到长连接句柄
if err != nil { // 握手失败(协议不支持、Origin 不匹配等)
slog.Warn("ws accept failed", "user_id", claims.UserID, "error", err) // 记录告警日志
return // 结束请求
}
if limit := readLimitBytes(); limit > 0 { // 配置了单帧上限
conn.SetReadLimit(limit) // 收紧单帧上限(库默认 32768 字节),防止超大帧打爆内存
}
connection := NewConnection(DefaultHub, conn, claims.UserID) // 为这条连接创建服务端连接对象
DefaultHub.Register(connection) // 登记到 Hub(必要时踢掉同账号旧连接)
interval := 25 // 默认心跳间隔 25 秒
if global.WSSetting != nil && global.WSSetting.PingInterval > 0 { // 配置了心跳间隔
interval = global.WSSetting.PingInterval // 使用配置值
}
connection.SendEnvelope(TypeHello, 0, HelloPayload{ // 下发建连成功的首条消息
UserID: connection.UserID(), // 回显用户 ID
PingInterval: interval, // 告诉客户端按此间隔发应用层 ping
ServerTime: nowUnix(), // 回显服务端时间,供客户端校正
})
slog.Info("ws connected", "user_id", connection.UserID()) // 记录建连日志
go connection.WritePump() // 另起 goroutine 跑写循环(发送 + ping + 判死)
connection.ReadPump() // 在当前 goroutine 跑读循环:阻塞直到连接结束才返回
}
// acceptOptions 按 WS 配置构造 Accept 选项(每次建连构造一次,便于配置变更后生效)。
//
// 来源校验语义(与 coder/websocket 文档一致):
// - 未配置 OriginPatterns:关闭同源校验(InsecureSkipVerify),放行所有来源;
// 小程序连接通常不带 Origin,且安全已由 token 鉴权兜底。
// - 配置了 OriginPatterns:按 host 模式匹配(支持 path.Match 通配,如 "*.example.com")。
func acceptOptions() *websocket.AcceptOptions {
opts := &websocket.AcceptOptions{} // 创建空选项
if global.WSSetting == nil { // 配置完全缺失:放行并直接返回
opts.InsecureSkipVerify = true // 关闭 Origin 校验
return opts // 直接返回
}
if len(global.WSSetting.OriginPatterns) == 0 { // 未配置允许列表
// 官方建议:放行任意来源用 InsecureSkipVerify,而不是把 "*" 写进 OriginPatterns。
opts.InsecureSkipVerify = true // 关闭 Origin 校验
} else { // 配置了允许列表
opts.OriginPatterns = global.WSSetting.OriginPatterns // 交给库按 host 模式匹配(支持 path.Match 通配)
}
if global.WSSetting.Compression { // 配置开启压缩
opts.CompressionMode = websocket.CompressionNoContextTakeover // 启用压缩但不接管上下文(更省内存)
} else { // 未开启
opts.CompressionMode = websocket.CompressionDisabled // 显式关闭压缩
}
return opts // 返回构造好的选项
}
// readLimitBytes 返回单帧上限(字节),对应配置的 MaxMessageBytes。
func readLimitBytes() int64 {
if global.WSSetting != nil && global.WSSetting.MaxMessageBytes > 0 { // 配置存在且为正数
return int64(global.WSSetting.MaxMessageBytes) // 使用配置值
}
return 0 // 0 表示不额外收紧,沿用库默认值(32768 字节)
}
+7
View File
@@ -117,6 +117,9 @@ func setupSetting() error {
if err := s.ReadSection("WechatMini", &global.WechatMiniSetting); err != nil {
return err
}
if err := s.ReadSection("WS", &global.WSSetting); err != nil { // 读取 WS 配置段到全局变量
return err // 读取或反序列化失败直接返回,启动阶段 panic 暴露问题
}
// ===== 必填校验:缺失即启动失败,避免运行期才发现配置错误 =====
if global.ServerSetting == nil {
@@ -148,6 +151,10 @@ func setupSetting() error {
if global.WechatMiniSetting == nil {
global.WechatMiniSetting = &setting.WechatMiniSettingS{}
}
// 其余 WS 默认值(心跳/超时/限流/队列)都在 internal/wsgateway 内部兜底,这里只避免 nil 解引用。
if global.WSSetting == nil { // 配置文件里没有 WS 段
global.WSSetting = &setting.WSSettingS{} // 赋一个零值结构体,保证各处读取不 panic
}
return nil
}
+21
View File
@@ -75,6 +75,27 @@ type WechatMiniSettingS struct {
AppSecret string
}
// WSSettingS WebSocket 网关配置。
// 前半段供 internal/wsgateway 传输层使用;后半段是第二步房间与匹配要用的预留项。
type WSSettingS struct {
PingInterval int // 心跳间隔(秒):服务端按此周期发 ping,也下发给客户端作为应用层心跳建议值
ReadTimeout int // 读超时(秒):超过该时长未收到任何客户端消息即判定死连接并断开
WriteTimeout int // 写超时(秒):单次 Write 或 Ping 的最长等待时间
MaxMessageBytes int // 单帧上限(字节):超过该大小的消息由库直接拒绝
MaxMsgPerSec int // 单连接每秒消息数上限:用于限流,超出即断开
SendQueueSize int // 发送队列长度:写满说明客户端消费太慢(慢客户端),直接断开
Compression bool // 是否启用 permessage-deflate 压缩
MultiDevice bool // 是否允许多设备同时在线:false 表示同账号新连接踢掉旧连接
OriginPatterns []string // 允许的 Origin 域名列表:浏览器调试用,小游戏连接通常不带 Origin
// ===== 以下为房间 / 匹配预留(第二步填充) =====
ReconnectGraceSec int // 掉线后保留座位的时长(秒):超时未重连则判负或平局
RoomIdleTimeoutSec int // 房间空闲自动关闭的时长(秒)
MatchTimeoutSec int // 匹配排队超过该时长(秒)自动取消
QueueTickMs int // 匹配队列的扫描间隔(毫秒)
MaxRooms int // 同时存在的房间数上限,防止内存失控
}
func (s *Setting) ReadSection(k string, v any) error {
err := s.vp.UnmarshalKey(k, v)
if err != nil {