feat(ws): implement WebSocket cluster mode with Redis Pub/Sub
Introduce a new WebSocket messaging architecture that supports both standalone and cluster modes. This allows for horizontal scaling of WebSocket servers by using Redis Pub/Sub to synchronize messages across multiple instances. Key changes: - Added `ws.MessagePublisher` interface to abstract message distribution. - Implemented `ws.Bus` to handle cluster-mode messaging via Redis. - Added `ws.OnlineTracker` to manage user online status across the cluster. - Refactored multiple services (Chat, Group, Push, Call, etc.) to use the new `MessagePublisher` instead of a concrete `ws.Hub`. - Added WebSocket configuration options (mode, instance ID, channel, TTL, heartbeat) to `config.yaml` and `config.go`. - Updated dependency injection with Wire to support the new publisher and Redis client. - Improved logging by replacing standard `log` with `zap` in several service components.
This commit is contained in:
@@ -67,7 +67,7 @@ func isAllowedWebSocketOrigin(origin string) bool {
|
||||
|
||||
// WSHandler WebSocket处理器
|
||||
type WSHandler struct {
|
||||
wsHub *ws.Hub
|
||||
publisher ws.MessagePublisher
|
||||
chatService service.ChatService
|
||||
groupService service.GroupService
|
||||
jwtService *service.JWTService
|
||||
@@ -76,9 +76,21 @@ type WSHandler struct {
|
||||
clientSeq uint64
|
||||
}
|
||||
|
||||
// hub 获取底层 Hub(用于 Register/Unregister/AckMessage 等本地操作)
|
||||
func (h *WSHandler) hub() *ws.Hub {
|
||||
switch p := h.publisher.(type) {
|
||||
case *ws.Bus:
|
||||
return p.Hub()
|
||||
case *ws.Hub:
|
||||
return p
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewWSHandler 创建WebSocket处理器
|
||||
func NewWSHandler(
|
||||
wsHub *ws.Hub,
|
||||
publisher ws.MessagePublisher,
|
||||
chatService service.ChatService,
|
||||
groupService service.GroupService,
|
||||
jwtService *service.JWTService,
|
||||
@@ -86,7 +98,7 @@ func NewWSHandler(
|
||||
userRepo repository.UserRepository,
|
||||
) *WSHandler {
|
||||
return &WSHandler{
|
||||
wsHub: wsHub,
|
||||
publisher: publisher,
|
||||
chatService: chatService,
|
||||
groupService: groupService,
|
||||
jwtService: jwtService,
|
||||
@@ -148,7 +160,7 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 4. 注册客户端
|
||||
regErr := h.wsHub.Register(client)
|
||||
regErr := h.hub().Register(client)
|
||||
if regErr != nil {
|
||||
zap.L().Warn("WebSocket registration rejected",
|
||||
zap.String("user_id", userID),
|
||||
@@ -159,7 +171,7 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
defer h.wsHub.Unregister(client)
|
||||
defer h.hub().Unregister(client)
|
||||
|
||||
zap.L().Info("WebSocket client connected",
|
||||
zap.String("user_id", userID),
|
||||
@@ -168,7 +180,7 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
||||
|
||||
// 5. 提示客户端进行 seq 同步
|
||||
syncMsg := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
EventID: h.publisher.NextID(),
|
||||
Type: "sync_required",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
@@ -234,7 +246,7 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
||||
// 解析消息
|
||||
var msg ws.Message
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
h.wsHub.SendError(client, "parse_error", "invalid message format")
|
||||
h.publisher.SendError(client, "parse_error", "invalid message format")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -338,14 +350,14 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
|
||||
case "call_mute":
|
||||
h.handleCallMute(ctx, client, msg.Payload)
|
||||
default:
|
||||
h.wsHub.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||
h.publisher.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||
}
|
||||
}
|
||||
|
||||
// handlePing 处理ping消息
|
||||
func (h *WSHandler) handlePing(client *ws.Client) {
|
||||
pong := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
EventID: h.publisher.NextID(),
|
||||
Type: "pong",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
@@ -371,24 +383,24 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
h.wsHub.SendError(client, "parse_error", "invalid chat message format")
|
||||
h.publisher.SendError(client, "parse_error", "invalid chat message format")
|
||||
return
|
||||
}
|
||||
|
||||
if req.ConversationID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "conversation_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "conversation_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Segments) == 0 {
|
||||
h.wsHub.SendError(client, "invalid_params", "segments is required")
|
||||
h.publisher.SendError(client, "invalid_params", "segments is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
||||
if err != nil {
|
||||
h.wsHub.SendError(client, "send_failed", err.Error())
|
||||
h.publisher.SendError(client, "send_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -410,7 +422,7 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
|
||||
}
|
||||
|
||||
msg := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
EventID: h.publisher.NextID(),
|
||||
Type: "message_sent",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: resp,
|
||||
@@ -478,24 +490,24 @@ func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
h.wsHub.SendError(client, "parse_error", "invalid recall message format")
|
||||
h.publisher.SendError(client, "parse_error", "invalid recall message format")
|
||||
return
|
||||
}
|
||||
|
||||
if req.MessageID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "message_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "message_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
|
||||
if err != nil {
|
||||
h.wsHub.SendError(client, "recall_failed", err.Error())
|
||||
h.publisher.SendError(client, "recall_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 发送撤回成功响应
|
||||
resp := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
EventID: h.publisher.NextID(),
|
||||
Type: "message_recalled",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: map[string]string{"message_id": req.MessageID},
|
||||
@@ -517,18 +529,18 @@ func (h *WSHandler) handleAck(client *ws.Client, payload json.RawMessage) {
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.MessageID == "" {
|
||||
return
|
||||
}
|
||||
h.wsHub.AckMessage(client.UserID, req.MessageID)
|
||||
h.hub().AckMessage(client.UserID, req.MessageID)
|
||||
}
|
||||
|
||||
// isVerified 检查用户是否已通过身份认证
|
||||
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
||||
user, err := h.userRepo.GetByID(client.UserID)
|
||||
if err != nil {
|
||||
h.wsHub.SendError(client, "internal_error", "获取用户信息失败")
|
||||
h.publisher.SendError(client, "internal_error", "获取用户信息失败")
|
||||
return false
|
||||
}
|
||||
if user.VerificationStatus != model.VerificationStatusApproved {
|
||||
h.wsHub.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
|
||||
h.publisher.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -548,11 +560,11 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
||||
MediaType string `json:"call_type"` // voice 或 video
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
h.wsHub.SendError(client, "parse_error", "invalid call_invite format")
|
||||
h.publisher.SendError(client, "parse_error", "invalid call_invite format")
|
||||
return
|
||||
}
|
||||
if req.CalleeID == "" || req.ConversationID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "callee_id and conversation_id are required")
|
||||
h.publisher.SendError(client, "invalid_params", "callee_id and conversation_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -570,16 +582,16 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
||||
zap.Error(err),
|
||||
)
|
||||
if errors.Is(err, apperrors.ErrCallInProgress) {
|
||||
h.wsHub.SendError(client, "call_in_progress", err.Error())
|
||||
h.publisher.SendError(client, "call_in_progress", err.Error())
|
||||
return
|
||||
}
|
||||
h.wsHub.SendError(client, "call_invite_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_invite_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回给呼叫方的响应
|
||||
resp := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
EventID: h.publisher.NextID(),
|
||||
Type: "call_invited",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: map[string]any{
|
||||
@@ -602,7 +614,7 @@ func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, pay
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -610,15 +622,15 @@ func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, pay
|
||||
if err != nil {
|
||||
// === 区分已接听错误 ===
|
||||
if errors.Is(err, apperrors.ErrCallAlreadyAnswered) {
|
||||
h.wsHub.SendError(client, "call_already_answered", "通话已被其他设备接听")
|
||||
h.publisher.SendError(client, "call_already_answered", "通话已被其他设备接听")
|
||||
return
|
||||
}
|
||||
h.wsHub.SendError(client, "call_accept_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_accept_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
EventID: h.publisher.NextID(),
|
||||
Type: "call_accepted",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: map[string]any{
|
||||
@@ -639,12 +651,12 @@ func (h *WSHandler) handleCallReject(ctx context.Context, client *ws.Client, pay
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil {
|
||||
h.wsHub.SendError(client, "call_reject_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_reject_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,12 +666,12 @@ func (h *WSHandler) handleCallBusy(ctx context.Context, client *ws.Client, paylo
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil {
|
||||
h.wsHub.SendError(client, "call_busy_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_busy_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,7 +683,7 @@ func (h *WSHandler) handleCallSDP(ctx context.Context, client *ws.Client, payloa
|
||||
SDP json.RawMessage `json:"sdp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" || req.SDPType == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "call_id and sdp_type are required")
|
||||
h.publisher.SendError(client, "invalid_params", "call_id and sdp_type are required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -680,7 +692,7 @@ func (h *WSHandler) handleCallSDP(ctx context.Context, client *ws.Client, payloa
|
||||
"sdp": json.RawMessage(req.SDP),
|
||||
})
|
||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_sdp", signalPayload); err != nil {
|
||||
h.wsHub.SendError(client, "call_sdp_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_sdp_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,7 +703,7 @@ func (h *WSHandler) handleCallICE(ctx context.Context, client *ws.Client, payloa
|
||||
Candidate json.RawMessage `json:"candidate"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -699,7 +711,7 @@ func (h *WSHandler) handleCallICE(ctx context.Context, client *ws.Client, payloa
|
||||
"candidate": json.RawMessage(req.Candidate),
|
||||
})
|
||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_ice", signalPayload); err != nil {
|
||||
h.wsHub.SendError(client, "call_ice_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_ice_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,18 +722,18 @@ func (h *WSHandler) handleCallEnd(ctx context.Context, client *ws.Client, payloa
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
call, err := h.callService.End(ctx, req.CallID, client.UserID, req.Reason)
|
||||
if err != nil {
|
||||
h.wsHub.SendError(client, "call_end_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_end_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
EventID: h.publisher.NextID(),
|
||||
Type: "call_ended",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: map[string]any{
|
||||
@@ -743,11 +755,11 @@ func (h *WSHandler) handleCallMute(ctx context.Context, client *ws.Client, paylo
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.callService.SetMuted(ctx, req.CallID, client.UserID, req.Muted); err != nil {
|
||||
h.wsHub.SendError(client, "call_mute_failed", err.Error())
|
||||
h.publisher.SendError(client, "call_mute_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user