新增 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
+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 字节)
}