feat(ws): WebSocket 核心模块(Hub + Client + PubSub + Handler)

- pkg/ws/message.go: 统一消息协议(Message/Response/PushMessage)
- pkg/ws/hub.go: Hub 连接管理(注册/注销/按 userID 查找/在线计数)
- pkg/ws/client.go: 客户端连接封装(readPump/writePump/心跳 30s)
- pkg/ws/pubsub.go: Redis Pub/Sub 消息路由(按用户频道发布/订阅)
- app/ws/handler.go: WebSocket 升级处理(JWT 认证 + 消息分发)
- app/ws/router.go: GET /ws 路由注册
- app/ws/provider.go: Wire Provider Set
- 更新 provider/router 集成 WebSocket 模块

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-02 16:46:17 +08:00
parent d392653446
commit bfb4fb0ccb
13 changed files with 582 additions and 8 deletions

View File

@@ -0,0 +1,123 @@
package ws
import (
"encoding/json"
"time"
"github.com/echochat/backend/pkg/logs"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
const (
writeWait = 10 * time.Second // 写超时
pongWait = 60 * time.Second // 等待 pong 的超时
pingPeriod = 30 * time.Second // 心跳发送间隔(必须小于 pongWait
maxMessageSize = 4096 // 单条消息最大字节数
sendBufSize = 256 // 发送缓冲区大小
)
// Client 封装单个 WebSocket 客户端连接
// 每个连接持有两个 goroutinereadPump读取客户端消息和 writePump写入消息到客户端
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte // 待发送消息缓冲队列
UserID int64 // 关联的用户 ID
}
// NewClient 创建客户端实例
func NewClient(hub *Hub, conn *websocket.Conn, userID int64) *Client {
return &Client{
hub: hub,
conn: conn,
send: make(chan []byte, sendBufSize),
UserID: userID,
}
}
// MessageHandler 消息处理回调函数类型
type MessageHandler func(client *Client, msg *Message)
// ReadPump 读取客户端消息的循环
// 当连接断开或出错时退出,并触发注销流程
func (c *Client) ReadPump(onMessage MessageHandler) {
defer func() {
c.hub.Unregister(c)
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, rawMsg, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
logs.Warn(nil, "ws.client.ReadPump", "WebSocket 异常关闭",
zap.Int64("user_id", c.UserID), zap.Error(err))
}
return
}
var msg Message
if err := json.Unmarshal(rawMsg, &msg); err != nil {
logs.Warn(nil, "ws.client.ReadPump", "消息格式解析失败",
zap.Int64("user_id", c.UserID), zap.Error(err))
continue
}
if onMessage != nil {
onMessage(c, &msg)
}
}
}
// WritePump 向客户端写入消息的循环
// 从 send channel 读取消息并写入 WebSocket同时负责心跳
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
logs.Warn(nil, "ws.client.WritePump", "写入消息失败",
zap.Int64("user_id", c.UserID), zap.Error(err))
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// Send 向客户端发送消息(非阻塞,缓冲区满时丢弃)
func (c *Client) Send(data []byte) bool {
select {
case c.send <- data:
return true
default:
logs.Warn(nil, "ws.client.Send", "发送缓冲区已满,丢弃消息",
zap.Int64("user_id", c.UserID))
return false
}
}