Files
backend/internal/handler/ws_handler.go

855 lines
22 KiB
Go
Raw Normal View History

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"with_you/internal/dto"
apperrors "with_you/internal/errors"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/pkg/ws"
"with_you/internal/repository"
"with_you/internal/service"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
return isAllowedWebSocketOrigin(origin)
},
HandshakeTimeout: 10 * time.Second,
}
var allowedWebSocketOrigins = []string{
"https://withyou.littlelan.cn",
"https://admin.littlelan.cn",
"https://browser.littlelan.cn",
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
}
func isAllowedWebSocketOrigin(origin string) bool {
for _, o := range allowedWebSocketOrigins {
if o == origin {
return true
}
}
if strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "http://127.0.0.1") {
return true
}
if strings.HasPrefix(origin, "https://") && strings.HasSuffix(origin, ".littlelan.cn") {
return true
}
return false
}
// WSHandler WebSocket处理器
type WSHandler struct {
publisher ws.MessagePublisher
chatService service.ChatService
groupService service.GroupService
jwtService *service.JWTService
callService service.CallService
userRepo repository.UserRepository
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(
publisher ws.MessagePublisher,
chatService service.ChatService,
groupService service.GroupService,
jwtService *service.JWTService,
callService service.CallService,
userRepo repository.UserRepository,
) *WSHandler {
return &WSHandler{
publisher: publisher,
chatService: chatService,
groupService: groupService,
jwtService: jwtService,
callService: callService,
userRepo: userRepo,
}
}
// HandleWebSocket WebSocket连接处理
// GET /api/v1/realtime/ws?token=xxx
func (h *WSHandler) HandleWebSocket(c *gin.Context) {
// 1. 认证 - 支持Query参数和Header两种方式
token := c.Query("token")
if token == "" {
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
prefix, rest, found := strings.Cut(authHeader, " ")
if found && prefix == "Bearer" {
token = rest
}
}
}
if token == "" {
response.Unauthorized(c, "token is required")
return
}
claims, err := h.jwtService.ParseToken(token)
if err != nil {
response.Unauthorized(c, "invalid token")
return
}
userID := claims.UserID
zap.L().Info("WebSocket connection attempt",
zap.String("user_id", userID),
)
// 2. 升级HTTP连接为WebSocket
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
zap.L().Error("Failed to upgrade WebSocket connection",
zap.String("user_id", userID),
zap.Error(err),
)
return
}
// 3. 创建客户端
clientID := atomic.AddUint64(&h.clientSeq, 1)
compressEnabled := c.Query("compress") == "1"
client := &ws.Client{
ID: clientID,
UserID: userID,
Send: make(chan []byte, defaultUserBufferSize),
Quit: make(chan struct{}),
PendingAcks: make(map[string]time.Time),
CompressEnabled: compressEnabled,
}
// 4. 注册客户端
regErr := h.hub().Register(client)
refactor: improve system stability, performance, and code structure This commit introduces several architectural improvements and optimizations across the codebase: - **Performance & Reliability**: - Implemented Redis pipelining in `ConversationCache.CacheMessage` to reduce network round-trips. - Added a circuit breaker to the JPush client to prevent cascading failures. - Introduced batch deletion and batch member addition capabilities in repositories. - Added message idempotency support using `client_msg_id` and a Redis-based cache. - Optimized WebSocket handling with connection limits (total and per-user) and improved error logging. - **Code Refactoring**: - Refactored `Router` to use a `RouterDeps` struct, simplifying the constructor and improving maintainability. - Unified model ID generation logic using new `id_helper.go` (supporting UUID and Snowflake). - Standardized JSON serialization/deserialization in models using `json_helper.go`. - Refactored DTO conversion logic, specifically for `UserResponse` (using functional options) and `Report` responses. - Removed redundant/deprecated DTOs like `PostDetailResponse` and `TradeItemDetailResponse`. - **Cache Improvements**: - Enhanced `LayeredCache` with `SetRaw` to avoid double-encoding when promoting values from Redis to local cache. - Added `DeleteBatch` support to the cache interface. - **Other Changes**: - Cleaned up `config.go` by removing redundant default values and explicit environment variable overrides. - Improved WebSocket registration flow to handle connection limits gracefully.
2026-05-04 13:07:03 +08:00
if regErr != nil {
zap.L().Warn("WebSocket registration rejected",
zap.String("user_id", userID),
zap.Error(regErr),
)
conn.WriteMessage(websocket.CloseMessage,
[]byte(`{"type":"error","payload":{"code":"connection_limit","message":"too many connections"}}`))
conn.Close()
return
}
defer h.hub().Unregister(client)
zap.L().Info("WebSocket client connected",
zap.String("user_id", userID),
zap.Uint64("client_id", clientID),
)
feat(core): optimize performance and reliability through batching and redis-backed unread counts This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability: - **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information. - **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread:hash:{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user. - **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation. - **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees. - **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability. - **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
2026-05-04 18:31:03 +08:00
// 5. 提示客户端进行 seq 同步
syncMsg := ws.ResponseMessage{
EventID: h.publisher.NextID(),
feat(core): optimize performance and reliability through batching and redis-backed unread counts This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability: - **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information. - **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread:hash:{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user. - **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation. - **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees. - **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability. - **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
2026-05-04 18:31:03 +08:00
Type: "sync_required",
TS: time.Now().UnixMilli(),
}
if syncData, err := json.Marshal(syncMsg); err == nil {
select {
case <-client.Quit:
case client.Send <- syncData:
}
feat(core): optimize performance and reliability through batching and redis-backed unread counts This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability: - **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information. - **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread:hash:{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user. - **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation. - **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees. - **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability. - **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
2026-05-04 18:31:03 +08:00
}
// 6. 启动读写goroutine
go h.writePump(conn, client)
h.readPump(conn, client)
zap.L().Info("WebSocket client disconnected",
zap.String("user_id", userID),
zap.Uint64("client_id", clientID),
)
}
// readPump 读取客户端消息
func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
defer func() {
zap.L().Info("WebSocket readPump exiting",
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
)
conn.Close()
}()
conn.SetReadLimit(256 * 1024) // 256KB
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
zap.L().Debug("WebSocket pong received",
zap.String("user_id", client.UserID),
)
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
zap.L().Info("WebSocket readPump started",
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
)
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
zap.L().Debug("WebSocket read error",
zap.String("user_id", client.UserID),
zap.Error(err),
)
} else {
zap.L().Info("WebSocket connection closed",
zap.String("user_id", client.UserID),
zap.Error(err),
)
}
break
}
// 解析消息(支持 gzip 解压)
var rawData []byte
if client.CompressEnabled && ws.IsCompressed(message) {
decompressed, dErr := ws.DefaultCompressor.Decompress(message)
if dErr != nil {
h.publisher.SendError(client, "decompress_error", "failed to decompress gzip message")
continue
}
rawData = decompressed
} else {
rawData = message
}
var msg ws.Message
if err := json.Unmarshal(rawData, &msg); err != nil {
h.publisher.SendError(client, "parse_error", "invalid message format")
continue
}
zap.L().Debug("WebSocket message received",
zap.String("user_id", client.UserID),
zap.String("type", msg.Type),
)
// 处理消息
h.handleMessage(client, &msg)
}
}
// writePump 向客户端发送消息
func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
conn.Close()
}()
for {
select {
case <-client.Quit:
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
case message, ok := <-client.Send:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if client.CompressEnabled {
buf := make([]byte, 0, len(message)+64)
buf = append(buf, message...)
n := len(client.Send)
for i := 0; i < n; i++ {
buf = append(buf, '\n')
buf = append(buf, <-client.Send...)
}
compressed, cErr := ws.DefaultCompressor.Compress(buf)
if cErr != nil {
zap.L().Warn("WebSocket compress error, sending raw",
zap.String("user_id", client.UserID),
zap.Error(cErr),
)
w, err := conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(buf)
w.Close()
} else {
w, err := conn.NextWriter(websocket.BinaryMessage)
if err != nil {
return
}
w.Write(compressed)
w.Close()
}
} else {
w, err := conn.NextWriter(websocket.TextMessage)
if err != nil {
zap.L().Warn("WebSocket write error (NextWriter)",
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
zap.Error(err),
)
return
}
w.Write(message)
n := len(client.Send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-client.Send)
}
if err := w.Close(); err != nil {
zap.L().Warn("WebSocket write error (Close)",
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
zap.Error(err),
)
return
}
}
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// handleMessage 处理客户端消息
func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
ctx := context.Background()
switch msg.Type {
case "ping":
h.handlePing(client)
case "chat":
h.handleChat(ctx, client, msg.Payload)
case "typing":
h.handleTyping(ctx, client, msg.Payload)
case "read":
h.handleRead(ctx, client, msg.Payload)
case "recall":
h.handleRecall(ctx, client, msg.Payload)
feat(core): optimize performance and reliability through batching and redis-backed unread counts This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability: - **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information. - **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread:hash:{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user. - **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation. - **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees. - **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability. - **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
2026-05-04 18:31:03 +08:00
case "ack":
h.handleAck(client, msg.Payload)
// 通话信令
case "call_invite":
h.handleCallInvite(ctx, client, msg.Payload)
case "call_group_invite":
h.handleCallGroupInvite(ctx, client, msg.Payload)
case "call_answer":
h.handleCallAnswer(ctx, client, msg.Payload)
case "call_reject":
h.handleCallReject(ctx, client, msg.Payload)
case "call_busy":
h.handleCallBusy(ctx, client, msg.Payload)
case "call_ready":
h.handleCallReady(ctx, client, msg.Payload)
case "call_end":
h.handleCallEnd(ctx, client, msg.Payload)
case "call_mute":
h.handleCallMute(ctx, client, msg.Payload)
case "call_participant_join":
h.handleCallParticipantJoin(ctx, client, msg.Payload)
default:
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.publisher.NextID(),
Type: "pong",
TS: time.Now().UnixMilli(),
}
data, _ := json.Marshal(pong)
select {
case <-client.Quit:
case client.Send <- data:
}
}
// handleChat 处理聊天消息
func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload json.RawMessage) {
if !h.isVerified(ctx, client) {
return
}
var req struct {
ConversationID string `json:"conversation_id"`
DetailType string `json:"detail_type"`
Segments model.MessageSegments `json:"segments"`
ReplyToID *string `json:"reply_to_id,omitempty"`
refactor: improve system stability, performance, and code structure This commit introduces several architectural improvements and optimizations across the codebase: - **Performance & Reliability**: - Implemented Redis pipelining in `ConversationCache.CacheMessage` to reduce network round-trips. - Added a circuit breaker to the JPush client to prevent cascading failures. - Introduced batch deletion and batch member addition capabilities in repositories. - Added message idempotency support using `client_msg_id` and a Redis-based cache. - Optimized WebSocket handling with connection limits (total and per-user) and improved error logging. - **Code Refactoring**: - Refactored `Router` to use a `RouterDeps` struct, simplifying the constructor and improving maintainability. - Unified model ID generation logic using new `id_helper.go` (supporting UUID and Snowflake). - Standardized JSON serialization/deserialization in models using `json_helper.go`. - Refactored DTO conversion logic, specifically for `UserResponse` (using functional options) and `Report` responses. - Removed redundant/deprecated DTOs like `PostDetailResponse` and `TradeItemDetailResponse`. - **Cache Improvements**: - Enhanced `LayeredCache` with `SetRaw` to avoid double-encoding when promoting values from Redis to local cache. - Added `DeleteBatch` support to the cache interface. - **Other Changes**: - Cleaned up `config.go` by removing redundant default values and explicit environment variable overrides. - Improved WebSocket registration flow to handle connection limits gracefully.
2026-05-04 13:07:03 +08:00
ClientMsgID string `json:"client_msg_id,omitempty"`
}
if err := json.Unmarshal(payload, &req); err != nil {
h.publisher.SendError(client, "parse_error", "invalid chat message format")
return
}
if req.ConversationID == "" {
h.publisher.SendError(client, "invalid_params", "conversation_id is required")
return
}
if len(req.Segments) == 0 {
h.publisher.SendError(client, "invalid_params", "segments is required")
return
}
// 发送消息
refactor: improve system stability, performance, and code structure This commit introduces several architectural improvements and optimizations across the codebase: - **Performance & Reliability**: - Implemented Redis pipelining in `ConversationCache.CacheMessage` to reduce network round-trips. - Added a circuit breaker to the JPush client to prevent cascading failures. - Introduced batch deletion and batch member addition capabilities in repositories. - Added message idempotency support using `client_msg_id` and a Redis-based cache. - Optimized WebSocket handling with connection limits (total and per-user) and improved error logging. - **Code Refactoring**: - Refactored `Router` to use a `RouterDeps` struct, simplifying the constructor and improving maintainability. - Unified model ID generation logic using new `id_helper.go` (supporting UUID and Snowflake). - Standardized JSON serialization/deserialization in models using `json_helper.go`. - Refactored DTO conversion logic, specifically for `UserResponse` (using functional options) and `Report` responses. - Removed redundant/deprecated DTOs like `PostDetailResponse` and `TradeItemDetailResponse`. - **Cache Improvements**: - Enhanced `LayeredCache` with `SetRaw` to avoid double-encoding when promoting values from Redis to local cache. - Added `DeleteBatch` support to the cache interface. - **Other Changes**: - Cleaned up `config.go` by removing redundant default values and explicit environment variable overrides. - Improved WebSocket registration flow to handle connection limits gracefully.
2026-05-04 13:07:03 +08:00
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
if err != nil {
h.publisher.SendError(client, "send_failed", err.Error())
return
}
// 发送成功响应
detailType := "private"
if conv, _ := h.chatService.GetConversationByID(ctx, req.ConversationID, client.UserID); conv != nil && conv.Type == model.ConversationTypeGroup {
detailType = "group"
}
resp := dto.WSEventResponse{
ID: message.ID,
Time: message.CreatedAt.UnixMilli(),
Type: "message",
DetailType: detailType,
ConversationID: req.ConversationID,
Seq: message.Seq,
Segments: req.Segments,
SenderID: client.UserID,
}
msg := ws.ResponseMessage{
EventID: h.publisher.NextID(),
Type: "message_sent",
TS: time.Now().UnixMilli(),
Payload: resp,
}
data, _ := json.Marshal(msg)
select {
case <-client.Quit:
case client.Send <- data:
}
}
// handleTyping 处理输入状态
func (h *WSHandler) handleTyping(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
ConversationID string `json:"conversation_id"`
IsTyping bool `json:"is_typing"`
}
if err := json.Unmarshal(payload, &req); err != nil {
return
}
if req.ConversationID == "" {
return
}
// 输入状态通常不需要持久化直接通过ChatService的SendTyping处理
// 这里简化处理,直接推送
h.chatService.SendTyping(ctx, client.UserID, req.ConversationID)
}
// handleRead 处理已读标记
func (h *WSHandler) handleRead(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
ConversationID string `json:"conversation_id"`
Seq int64 `json:"seq"`
}
if err := json.Unmarshal(payload, &req); err != nil {
return
}
if req.ConversationID == "" || req.Seq <= 0 {
return
}
err := h.chatService.MarkAsRead(ctx, req.ConversationID, client.UserID, req.Seq)
if err != nil {
zap.L().Debug("Failed to mark as read via WebSocket",
zap.String("user_id", client.UserID),
zap.String("conversation_id", req.ConversationID),
zap.Error(err),
)
}
}
// handleRecall 处理消息撤回
func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload json.RawMessage) {
if !h.isVerified(ctx, client) {
return
}
var req struct {
MessageID string `json:"message_id"`
}
if err := json.Unmarshal(payload, &req); err != nil {
h.publisher.SendError(client, "parse_error", "invalid recall message format")
return
}
if req.MessageID == "" {
h.publisher.SendError(client, "invalid_params", "message_id is required")
return
}
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
if err != nil {
h.publisher.SendError(client, "recall_failed", err.Error())
return
}
// 发送撤回成功响应
resp := ws.ResponseMessage{
EventID: h.publisher.NextID(),
Type: "message_recalled",
TS: time.Now().UnixMilli(),
Payload: map[string]string{"message_id": req.MessageID},
}
data, _ := json.Marshal(resp)
select {
case <-client.Quit:
case client.Send <- data:
}
}
const defaultUserBufferSize = 128
feat(core): optimize performance and reliability through batching and redis-backed unread counts This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability: - **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information. - **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread:hash:{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user. - **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation. - **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees. - **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability. - **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
2026-05-04 18:31:03 +08:00
// handleAck 处理客户端ACK确认
func (h *WSHandler) handleAck(client *ws.Client, payload json.RawMessage) {
var req struct {
MessageID string `json:"message_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.MessageID == "" {
return
}
h.hub().AckMessage(client.UserID, req.MessageID)
feat(core): optimize performance and reliability through batching and redis-backed unread counts This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability: - **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information. - **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread:hash:{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user. - **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation. - **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees. - **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability. - **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
2026-05-04 18:31:03 +08:00
}
// isVerified 检查用户是否已通过身份认证
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
user, err := h.userRepo.GetByID(client.UserID)
if err != nil {
h.publisher.SendError(client, "internal_error", "获取用户信息失败")
return false
}
if user.VerificationStatus != model.VerificationStatusApproved {
h.publisher.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
return false
}
return true
}
// ==================== 通话信令处理 ====================
// handleCallInvite 处理呼叫邀请
func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, payload json.RawMessage) {
if !h.isVerified(ctx, client) {
return
}
var req struct {
CalleeID string `json:"callee_id"`
ConversationID string `json:"conversation_id"`
MediaType string `json:"call_type"` // voice 或 video
}
if err := json.Unmarshal(payload, &req); err != nil {
h.publisher.SendError(client, "parse_error", "invalid call_invite format")
return
}
if req.CalleeID == "" || req.ConversationID == "" {
h.publisher.SendError(client, "invalid_params", "callee_id and conversation_id are required")
return
}
// 前端传的 call_type 是 voice/video默认为 voice
mediaType := req.MediaType
if mediaType != "video" {
mediaType = "voice"
}
call, _, err := h.callService.Invite(ctx, client.UserID, req.CalleeID, req.ConversationID, mediaType)
if err != nil {
zap.L().Warn("Failed to invite call",
zap.String("caller_id", client.UserID),
zap.String("callee_id", req.CalleeID),
zap.Error(err),
)
if errors.Is(err, apperrors.ErrCallInProgress) {
h.publisher.SendError(client, "call_in_progress", err.Error())
return
}
h.publisher.SendError(client, "call_invite_failed", err.Error())
return
}
// 返回给呼叫方的响应
resp := ws.ResponseMessage{
EventID: h.publisher.NextID(),
Type: "call_invited",
TS: time.Now().UnixMilli(),
Payload: map[string]any{
"call_id": call.ID,
"conversation_id": req.ConversationID,
"callee_id": req.CalleeID,
"lifetime": service.CallLifetimeMs,
},
}
data, _ := json.Marshal(resp)
select {
case <-client.Quit:
case client.Send <- data:
}
}
// handleCallGroupInvite 处理群组通话邀请
func (h *WSHandler) handleCallGroupInvite(ctx context.Context, client *ws.Client, payload json.RawMessage) {
if !h.isVerified(ctx, client) {
return
}
var req struct {
GroupID string `json:"group_id"`
ConversationID string `json:"conversation_id"`
MediaType string `json:"call_type"`
}
if err := json.Unmarshal(payload, &req); err != nil {
h.publisher.SendError(client, "parse_error", "invalid call_group_invite format")
return
}
if req.GroupID == "" || req.ConversationID == "" {
h.publisher.SendError(client, "invalid_params", "group_id and conversation_id are required")
return
}
mediaType := req.MediaType
if mediaType != "video" {
mediaType = "voice"
}
call, err := h.callService.GroupInvite(ctx, client.UserID, req.GroupID, req.ConversationID, mediaType)
if err != nil {
zap.L().Warn("Failed to invite group call",
zap.String("caller_id", client.UserID),
zap.String("group_id", req.GroupID),
zap.Error(err),
)
h.publisher.SendError(client, "call_group_invite_failed", err.Error())
return
}
resp := ws.ResponseMessage{
EventID: h.publisher.NextID(),
Type: "call_invited",
TS: time.Now().UnixMilli(),
Payload: map[string]any{
"call_id": call.ID,
"conversation_id": req.ConversationID,
"group_id": req.GroupID,
"call_type": "group",
"lifetime": service.CallLifetimeMs,
},
}
data, _ := json.Marshal(resp)
select {
case <-client.Quit:
case client.Send <- data:
}
}
// handleCallAnswer 处理接听
func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.publisher.SendError(client, "invalid_params", "call_id is required")
return
}
call, err := h.callService.Accept(ctx, req.CallID, client.UserID)
if err != nil {
// === 区分已接听错误 ===
if errors.Is(err, apperrors.ErrCallAlreadyAnswered) {
h.publisher.SendError(client, "call_already_answered", "通话已被其他设备接听")
return
}
h.publisher.SendError(client, "call_accept_failed", err.Error())
return
}
resp := ws.ResponseMessage{
EventID: h.publisher.NextID(),
Type: "call_accepted",
TS: time.Now().UnixMilli(),
Payload: map[string]any{
"call_id": call.ID,
"started_at": call.StartedAt,
},
}
data, _ := json.Marshal(resp)
select {
case <-client.Quit:
case client.Send <- data:
}
}
// handleCallReject 处理拒绝
func (h *WSHandler) handleCallReject(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.publisher.SendError(client, "invalid_params", "call_id is required")
return
}
if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil {
h.publisher.SendError(client, "call_reject_failed", err.Error())
}
}
// handleCallBusy 处理忙线
func (h *WSHandler) handleCallBusy(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.publisher.SendError(client, "invalid_params", "call_id is required")
return
}
if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil {
h.publisher.SendError(client, "call_busy_failed", err.Error())
}
}
// handleCallReady 处理通话就绪LiveKit 房间连接成功)
func (h *WSHandler) handleCallReady(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.publisher.SendError(client, "invalid_params", "call_id is required")
return
}
if err := h.callService.Ready(ctx, req.CallID, client.UserID); err != nil {
h.publisher.SendError(client, "call_ready_failed", err.Error())
}
}
// handleCallEnd 处理结束通话
func (h *WSHandler) handleCallEnd(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
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.publisher.SendError(client, "call_end_failed", err.Error())
return
}
resp := ws.ResponseMessage{
EventID: h.publisher.NextID(),
Type: "call_ended",
TS: time.Now().UnixMilli(),
Payload: map[string]any{
"call_id": call.ID,
"duration": call.Duration,
},
}
data, _ := json.Marshal(resp)
select {
case <-client.Quit:
case client.Send <- data:
}
}
// handleCallMute 处理静音
func (h *WSHandler) handleCallMute(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
Muted bool `json:"muted"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
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.publisher.SendError(client, "call_mute_failed", err.Error())
}
}
// handleCallParticipantJoin 处理群组通话参与者加入
func (h *WSHandler) handleCallParticipantJoin(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.publisher.SendError(client, "invalid_params", "call_id is required")
return
}
if err := h.callService.ParticipantJoin(ctx, req.CallID, client.UserID); err != nil {
h.publisher.SendError(client, "call_participant_join_failed", err.Error())
}
}