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