Files
zt-online-game-backend/internal/wsgateway/server.go
T

96 lines
5.3 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 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 字节)
}