新增 WebSocket 网关:连接管理、心跳保活、限流与消息路由
This commit is contained in:
@@ -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 字节)
|
||||
}
|
||||
Reference in New Issue
Block a user