2026-04-28 14:53:04 +08:00
|
|
|
|
package handler
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
|
2026-04-22 16:01:59 +08:00
|
|
|
|
"with_you/internal/dto"
|
|
|
|
|
|
"with_you/internal/model"
|
|
|
|
|
|
"with_you/internal/pkg/cursor"
|
|
|
|
|
|
"with_you/internal/pkg/response"
|
|
|
|
|
|
"with_you/internal/pkg/ws"
|
|
|
|
|
|
"with_you/internal/service"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
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
|
|
|
|
// enrichConversations 批量填充会话列表响应数据(解决 N+1 问题)
|
|
|
|
|
|
func (h *MessageHandler) enrichConversations(ctx context.Context, convs []*model.Conversation, userID string) []*dto.ConversationResponse {
|
|
|
|
|
|
if len(convs) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
convIDs := make([]string, len(convs))
|
|
|
|
|
|
for i, c := range convs {
|
|
|
|
|
|
convIDs[i] = c.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 批量查询:4-5 次查询替代 N*5 次
|
|
|
|
|
|
unreadCounts, _ := h.chatService.GetUnreadCountBatch(ctx, userID, convIDs)
|
|
|
|
|
|
lastMessages, _ := h.chatService.GetLastMessagesBatch(ctx, convIDs)
|
|
|
|
|
|
myParticipants, _ := h.messageService.GetMyParticipantsBatch(ctx, convIDs, userID)
|
|
|
|
|
|
allParticipants, _ := h.messageService.GetParticipantsBatch(ctx, convIDs)
|
|
|
|
|
|
|
|
|
|
|
|
// 收集需要额外查询的 ID
|
|
|
|
|
|
var groupIDs []string
|
|
|
|
|
|
var userIDs []string
|
|
|
|
|
|
for _, conv := range convs {
|
|
|
|
|
|
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
|
|
|
|
|
groupIDs = append(groupIDs, *conv.GroupID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, participants := range allParticipants {
|
|
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
if p.UserID != userID {
|
|
|
|
|
|
userIDs = append(userIDs, p.UserID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
memberCounts, _ := h.groupService.GetMemberCountBatch(ctx, groupIDs)
|
|
|
|
|
|
usersMap, _ := h.userService.GetUsersByIDs(ctx, userIDs)
|
|
|
|
|
|
|
|
|
|
|
|
// 组装响应
|
|
|
|
|
|
result := make([]*dto.ConversationResponse, len(convs))
|
|
|
|
|
|
for i, conv := range convs {
|
|
|
|
|
|
unreadCount := unreadCounts[conv.ID]
|
|
|
|
|
|
lastMessage := lastMessages[conv.ID]
|
|
|
|
|
|
|
|
|
|
|
|
myP := myParticipants[conv.ID]
|
|
|
|
|
|
isPinned := myP != nil && myP.IsPinned
|
|
|
|
|
|
notificationMuted := myP != nil && myP.NotificationMuted
|
|
|
|
|
|
|
|
|
|
|
|
var resp *dto.ConversationResponse
|
|
|
|
|
|
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
|
|
|
|
|
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
|
|
|
|
|
if mc, ok := memberCounts[*conv.GroupID]; ok {
|
|
|
|
|
|
resp.MemberCount = int(mc)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
participants := allParticipants[conv.ID]
|
|
|
|
|
|
var users []*model.User
|
|
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
if p.UserID == userID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if u, ok := usersMap[p.UserID]; ok {
|
|
|
|
|
|
users = append(users, u)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
resp = dto.ConvertConversationToResponse(conv, users, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
|
|
|
|
|
}
|
|
|
|
|
|
result[i] = resp
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// MessageHandler 消息处理器
|
|
|
|
|
|
type MessageHandler struct {
|
|
|
|
|
|
chatService service.ChatService
|
|
|
|
|
|
messageService *service.MessageService
|
2026-03-13 09:38:18 +08:00
|
|
|
|
userService service.UserService
|
2026-03-09 21:28:58 +08:00
|
|
|
|
groupService service.GroupService
|
2026-05-06 12:39:11 +08:00
|
|
|
|
wsPublisher ws.MessagePublisher
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewMessageHandler 创建消息处理器
|
2026-05-06 12:39:11 +08:00
|
|
|
|
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return &MessageHandler{
|
|
|
|
|
|
chatService: chatService,
|
|
|
|
|
|
messageService: messageService,
|
|
|
|
|
|
userService: userService,
|
|
|
|
|
|
groupService: groupService,
|
2026-05-06 12:39:11 +08:00
|
|
|
|
wsPublisher: wsPublisher,
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleTyping 输入状态上报
|
|
|
|
|
|
// POST /api/v1/conversations/typing
|
|
|
|
|
|
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-12 08:38:14 +08:00
|
|
|
|
|
|
|
|
|
|
conversationID := getIDParam(c, "id")
|
|
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation id is required")
|
2026-03-10 12:58:23 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-12 08:38:14 +08:00
|
|
|
|
|
|
|
|
|
|
h.chatService.SendTyping(c.Request.Context(), userID, conversationID)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
response.SuccessWithMessage(c, "typing sent", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// GetConversations 获取会话列表
|
|
|
|
|
|
// GET /api/conversations
|
|
|
|
|
|
func (h *MessageHandler) GetConversations(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
// 添加调试日志
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
|
|
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
convs, _, err := h.chatService.GetConversationList(c.Request.Context(), userID, page, pageSize)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalServerError(c, "failed to get conversations")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤掉系统会话(系统通知现在使用独立的表)
|
|
|
|
|
|
filteredConvs := make([]*model.Conversation, 0)
|
|
|
|
|
|
for _, conv := range convs {
|
|
|
|
|
|
if conv.ID != model.SystemConversationID {
|
|
|
|
|
|
filteredConvs = append(filteredConvs, conv)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// 批量填充会话数据(解决 N+1 问题)
|
|
|
|
|
|
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新 total 为过滤后的数量
|
|
|
|
|
|
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CreateConversation 创建私聊会话
|
|
|
|
|
|
// POST /api/conversations
|
|
|
|
|
|
func (h *MessageHandler) CreateConversation(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req dto.CreateConversationRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证目标用户是否存在
|
|
|
|
|
|
targetUser, err := h.userService.GetUserByID(c.Request.Context(), req.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "target user not found")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 不能和自己创建会话
|
|
|
|
|
|
if userID == req.UserID {
|
|
|
|
|
|
response.BadRequest(c, "cannot create conversation with yourself")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conv, err := h.chatService.GetOrCreateConversation(c.Request.Context(), userID, req.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalServerError(c, "failed to create conversation")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取参与者信息
|
|
|
|
|
|
participants := []*model.User{targetUser}
|
|
|
|
|
|
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
|
|
|
|
|
isPinned := myParticipant != nil && myParticipant.IsPinned
|
2026-04-25 21:22:52 +08:00
|
|
|
|
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-04-25 21:22:52 +08:00
|
|
|
|
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetConversationByID 获取会话详情
|
|
|
|
|
|
// GET /api/conversations/:id
|
|
|
|
|
|
func (h *MessageHandler) GetConversationByID(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationIDStr := c.Param("id")
|
|
|
|
|
|
conversationID, err := service.ParseConversationID(conversationIDStr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid conversation id")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conv, err := h.chatService.GetConversationByID(c.Request.Context(), conversationID, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取未读数
|
|
|
|
|
|
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conversationID, userID)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取参与者信息
|
|
|
|
|
|
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前用户的已读位置
|
|
|
|
|
|
myLastReadSeq := int64(0)
|
|
|
|
|
|
isPinned := false
|
2026-04-25 21:22:52 +08:00
|
|
|
|
notificationMuted := false
|
2026-03-09 21:28:58 +08:00
|
|
|
|
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
|
|
|
|
|
for _, p := range allParticipants {
|
|
|
|
|
|
if p.UserID == userID {
|
|
|
|
|
|
myLastReadSeq = p.LastReadSeq
|
|
|
|
|
|
isPinned = p.IsPinned
|
2026-04-25 21:22:52 +08:00
|
|
|
|
notificationMuted = p.NotificationMuted
|
2026-03-09 21:28:58 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取对方用户的已读位置
|
|
|
|
|
|
otherLastReadSeq := int64(0)
|
2026-05-09 17:25:09 +08:00
|
|
|
|
for _, p := range allParticipants {
|
|
|
|
|
|
if p.UserID != userID {
|
|
|
|
|
|
otherLastReadSeq = p.LastReadSeq
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-25 21:22:52 +08:00
|
|
|
|
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetMessages 获取消息列表
|
|
|
|
|
|
// GET /api/conversations/:id/messages
|
|
|
|
|
|
func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationIDStr := c.Param("id")
|
|
|
|
|
|
conversationID, err := service.ParseConversationID(conversationIDStr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid conversation id")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否使用增量同步(after_seq参数)
|
|
|
|
|
|
afterSeqStr := c.Query("after_seq")
|
|
|
|
|
|
if afterSeqStr != "" {
|
|
|
|
|
|
// 增量同步模式
|
|
|
|
|
|
afterSeq, err := strconv.ParseInt(afterSeqStr, 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid after_seq")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
messages, err := h.chatService.GetMessagesAfterSeq(c.Request.Context(), conversationID, userID, afterSeq, limit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为响应格式
|
|
|
|
|
|
result := dto.ConvertMessagesToResponse(messages)
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, &dto.MessageSyncResponse{
|
|
|
|
|
|
Messages: result,
|
|
|
|
|
|
HasMore: len(messages) == limit,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否使用历史消息加载(before_seq参数)
|
|
|
|
|
|
beforeSeqStr := c.Query("before_seq")
|
|
|
|
|
|
if beforeSeqStr != "" {
|
|
|
|
|
|
// 加载更早的消息(下拉加载更多)
|
|
|
|
|
|
beforeSeq, err := strconv.ParseInt(beforeSeqStr, 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid before_seq")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
messages, err := h.chatService.GetMessagesBeforeSeq(c.Request.Context(), conversationID, userID, beforeSeq, limit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为响应格式
|
|
|
|
|
|
result := dto.ConvertMessagesToResponse(messages)
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, &dto.MessageSyncResponse{
|
|
|
|
|
|
Messages: result,
|
|
|
|
|
|
HasMore: len(messages) == limit,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 分页模式
|
|
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
|
|
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
messages, total, err := h.chatService.GetMessages(c.Request.Context(), conversationID, userID, page, pageSize)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为响应格式
|
|
|
|
|
|
result := dto.ConvertMessagesToResponse(messages)
|
|
|
|
|
|
|
|
|
|
|
|
response.Paginated(c, result, total, page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendMessage 发送消息
|
|
|
|
|
|
// POST /api/conversations/:id/messages
|
|
|
|
|
|
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationIDStr := c.Param("id")
|
|
|
|
|
|
conversationID, err := service.ParseConversationID(conversationIDStr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid conversation id")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req dto.SendMessageRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 13:07:03 +08:00
|
|
|
|
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, dto.ConvertMessageToResponse(msg))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleSendMessage RESTful 风格的发送消息端点
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// POST /api/v1/conversations/:id/messages
|
|
|
|
|
|
// 请求体格式: {"detail_type": "private", "segments": [{"type": "text", "data": {"text": "嗨~"}}]}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
conversationID := getIDParam(c, "id")
|
|
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation id is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
var params dto.SendMessageParams
|
|
|
|
|
|
if err := c.ShouldBindJSON(¶ms); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证参数
|
|
|
|
|
|
if params.DetailType == "" {
|
|
|
|
|
|
response.BadRequest(c, "detail_type is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-12 23:40:21 +08:00
|
|
|
|
if len(params.Segments) == 0 {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
response.BadRequest(c, "segments is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送消息
|
2026-05-04 13:07:03 +08:00
|
|
|
|
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID, params.ClientMsgID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 构建 WSEventResponse 格式响应
|
|
|
|
|
|
wsResponse := dto.WSEventResponse{
|
2026-03-28 07:03:21 +08:00
|
|
|
|
ID: msg.ID,
|
|
|
|
|
|
Time: msg.CreatedAt.UnixMilli(),
|
|
|
|
|
|
Type: "message",
|
|
|
|
|
|
DetailType: params.DetailType,
|
|
|
|
|
|
ConversationID: conversationID,
|
|
|
|
|
|
Seq: strconv.FormatInt(msg.Seq, 10),
|
|
|
|
|
|
Segments: params.Segments,
|
|
|
|
|
|
SenderID: userID,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, wsResponse)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleDeleteMsg 撤回消息
|
|
|
|
|
|
// POST /api/v1/messages/delete_msg
|
|
|
|
|
|
// 请求体格式: {"message_id": "xxx"}
|
|
|
|
|
|
func (h *MessageHandler) HandleDeleteMsg(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var params dto.DeleteMsgParams
|
|
|
|
|
|
if err := c.ShouldBindJSON(¶ms); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证参数
|
|
|
|
|
|
if params.MessageID == "" {
|
|
|
|
|
|
response.BadRequest(c, "message_id is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 撤回消息
|
|
|
|
|
|
err := h.chatService.RecallMessage(c.Request.Context(), params.MessageID, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "消息已撤回", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleGetConversationList 获取会话列表
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// GET /api/v1/conversations
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
|
|
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
convs, _, err := h.chatService.GetConversationList(c.Request.Context(), userID, page, pageSize)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalServerError(c, "failed to get conversations")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤掉系统会话(系统通知现在使用独立的表)
|
|
|
|
|
|
filteredConvs := make([]*model.Conversation, 0)
|
|
|
|
|
|
for _, conv := range convs {
|
|
|
|
|
|
if conv.ID != model.SystemConversationID {
|
|
|
|
|
|
filteredConvs = append(filteredConvs, conv)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// 批量填充会话数据(解决 N+1 问题)
|
|
|
|
|
|
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleDeleteConversationForSelf 仅自己删除会话
|
|
|
|
|
|
// DELETE /api/v1/conversations/:id/self
|
|
|
|
|
|
func (h *MessageHandler) HandleDeleteConversationForSelf(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:52:50 +08:00
|
|
|
|
conversationID := getIDParam(c, "id")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation id is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := h.chatService.DeleteConversationForSelf(c.Request.Context(), conversationID, userID); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "conversation deleted for self", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MarkAsRead 标记为已读
|
|
|
|
|
|
// POST /api/conversations/:id/read
|
|
|
|
|
|
func (h *MessageHandler) MarkAsRead(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationIDStr := c.Param("id")
|
|
|
|
|
|
conversationID, err := service.ParseConversationID(conversationIDStr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid conversation id")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req dto.MarkReadRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "last_read_seq is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = h.chatService.MarkAsRead(c.Request.Context(), conversationID, userID, req.LastReadSeq)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "marked as read", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetUnreadCount 获取未读消息总数
|
|
|
|
|
|
// GET /api/conversations/unread/count
|
|
|
|
|
|
func (h *MessageHandler) GetUnreadCount(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
count, err := h.chatService.GetAllUnreadCount(c.Request.Context(), userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalServerError(c, "failed to get unread count")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, &dto.UnreadCountResponse{
|
|
|
|
|
|
TotalUnreadCount: count,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetConversationUnreadCount 获取单个会话的未读数
|
|
|
|
|
|
// GET /api/conversations/:id/unread/count
|
|
|
|
|
|
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationIDStr := c.Param("id")
|
|
|
|
|
|
conversationID, err := service.ParseConversationID(conversationIDStr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid conversation id")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
count, err := h.chatService.GetUnreadCount(c.Request.Context(), conversationID, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, &dto.ConversationUnreadCountResponse{
|
|
|
|
|
|
ConversationID: conversationID,
|
|
|
|
|
|
UnreadCount: count,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RecallMessage 撤回消息
|
|
|
|
|
|
// POST /api/messages/:id/recall
|
|
|
|
|
|
func (h *MessageHandler) RecallMessage(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
messageIDStr := c.Param("id")
|
|
|
|
|
|
// 直接使用 string 类型的 messageID
|
|
|
|
|
|
err := h.chatService.RecallMessage(c.Request.Context(), messageIDStr, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "message recalled", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DeleteMessage 删除消息
|
|
|
|
|
|
// DELETE /api/messages/:id
|
|
|
|
|
|
func (h *MessageHandler) DeleteMessage(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
messageIDStr := c.Param("id")
|
|
|
|
|
|
// 直接使用 string 类型的 messageID
|
|
|
|
|
|
err := h.chatService.DeleteMessage(c.Request.Context(), messageIDStr, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "message deleted", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 辅助函数:验证内容类型
|
|
|
|
|
|
func isValidContentType(contentType model.ContentType) bool {
|
|
|
|
|
|
switch contentType {
|
|
|
|
|
|
case model.ContentTypeText, model.ContentTypeImage, model.ContentTypeVideo, model.ContentTypeAudio, model.ContentTypeFile:
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 辅助函数:获取会话参与者信息
|
|
|
|
|
|
func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) {
|
|
|
|
|
|
// 从repository获取参与者列表
|
|
|
|
|
|
participants, err := h.messageService.GetConversationParticipants(conversationID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取参与者用户信息
|
|
|
|
|
|
var users []*model.User
|
|
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
// 跳过当前用户
|
|
|
|
|
|
if p.UserID == currentUserID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
user, err := h.userService.GetUserByID(ctx, p.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
users = append(users, user)
|
|
|
|
|
|
}
|
|
|
|
|
|
return users, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前用户在会话中的参与者信息
|
|
|
|
|
|
func (h *MessageHandler) getMyConversationParticipant(conversationID string, userID string) (*model.ConversationParticipant, error) {
|
|
|
|
|
|
participants, err := h.messageService.GetConversationParticipants(conversationID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
if p.UserID == userID {
|
|
|
|
|
|
return p, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:52:50 +08:00
|
|
|
|
// getIDParam 从路径参数获取 ID
|
|
|
|
|
|
func getIDParam(c *gin.Context, paramName string) string {
|
|
|
|
|
|
return c.Param(paramName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// ==================== RESTful Action 端点 ====================
|
|
|
|
|
|
|
|
|
|
|
|
// HandleCreateConversation 创建会话
|
|
|
|
|
|
// POST /api/v1/conversations/create
|
|
|
|
|
|
func (h *MessageHandler) HandleCreateConversation(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var params dto.CreateConversationParams
|
|
|
|
|
|
if err := c.ShouldBindJSON(¶ms); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证目标用户是否存在
|
|
|
|
|
|
targetUser, err := h.userService.GetUserByID(c.Request.Context(), params.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "target user not found")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 不能和自己创建会话
|
|
|
|
|
|
if userID == params.UserID {
|
|
|
|
|
|
response.BadRequest(c, "cannot create conversation with yourself")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conv, err := h.chatService.GetOrCreateConversation(c.Request.Context(), userID, params.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalServerError(c, "failed to create conversation")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取参与者信息
|
|
|
|
|
|
participants := []*model.User{targetUser}
|
|
|
|
|
|
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
|
|
|
|
|
isPinned := myParticipant != nil && myParticipant.IsPinned
|
2026-04-25 21:22:52 +08:00
|
|
|
|
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-04-25 21:22:52 +08:00
|
|
|
|
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleGetConversation 获取会话详情
|
2026-03-10 20:52:50 +08:00
|
|
|
|
// GET /api/v1/conversations/:id
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:52:50 +08:00
|
|
|
|
conversationID := getIDParam(c, "id")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation_id is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conv, err := h.chatService.GetConversationByID(c.Request.Context(), conversationID, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取未读数
|
|
|
|
|
|
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conversationID, userID)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取参与者信息
|
|
|
|
|
|
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
|
|
|
|
|
|
2026-05-10 13:36:58 +08:00
|
|
|
|
// 获取当前用户的已读位置(优先从 Redis 缓存读取)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
myLastReadSeq := int64(0)
|
|
|
|
|
|
isPinned := false
|
2026-04-25 21:22:52 +08:00
|
|
|
|
notificationMuted := false
|
2026-03-09 21:28:58 +08:00
|
|
|
|
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
|
|
|
|
|
for _, p := range allParticipants {
|
|
|
|
|
|
if p.UserID == userID {
|
|
|
|
|
|
myLastReadSeq = p.LastReadSeq
|
|
|
|
|
|
isPinned = p.IsPinned
|
2026-04-25 21:22:52 +08:00
|
|
|
|
notificationMuted = p.NotificationMuted
|
2026-03-09 21:28:58 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 13:36:58 +08:00
|
|
|
|
// 获取对方用户的已读位置(优先从 Redis 缓存读取)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
otherLastReadSeq := int64(0)
|
2026-05-09 17:25:09 +08:00
|
|
|
|
for _, p := range allParticipants {
|
|
|
|
|
|
if p.UserID != userID {
|
|
|
|
|
|
otherLastReadSeq = p.LastReadSeq
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-25 21:22:52 +08:00
|
|
|
|
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleGetMessages 获取会话消息
|
2026-03-10 20:52:50 +08:00
|
|
|
|
// GET /api/v1/conversations/:id/messages
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:52:50 +08:00
|
|
|
|
conversationID := getIDParam(c, "id")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation_id is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否使用增量同步(after_seq参数)
|
|
|
|
|
|
afterSeqStr := c.Query("after_seq")
|
|
|
|
|
|
if afterSeqStr != "" {
|
|
|
|
|
|
// 增量同步模式
|
|
|
|
|
|
afterSeq, err := strconv.ParseInt(afterSeqStr, 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid after_seq")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
|
|
|
|
|
|
|
|
|
|
|
messages, err := h.chatService.GetMessagesAfterSeq(c.Request.Context(), conversationID, userID, afterSeq, limit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为响应格式
|
|
|
|
|
|
result := dto.ConvertMessagesToResponse(messages)
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, &dto.MessageSyncResponse{
|
|
|
|
|
|
Messages: result,
|
|
|
|
|
|
HasMore: len(messages) == limit,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否使用历史消息加载(before_seq参数)
|
|
|
|
|
|
beforeSeqStr := c.Query("before_seq")
|
|
|
|
|
|
if beforeSeqStr != "" {
|
|
|
|
|
|
// 加载更早的消息(下拉加载更多)
|
|
|
|
|
|
beforeSeq, err := strconv.ParseInt(beforeSeqStr, 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "invalid before_seq")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
messages, err := h.chatService.GetMessagesBeforeSeq(c.Request.Context(), conversationID, userID, beforeSeq, limit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为响应格式
|
|
|
|
|
|
result := dto.ConvertMessagesToResponse(messages)
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, &dto.MessageSyncResponse{
|
|
|
|
|
|
Messages: result,
|
|
|
|
|
|
HasMore: len(messages) == limit,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 分页模式
|
|
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
|
|
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
messages, total, err := h.chatService.GetMessages(c.Request.Context(), conversationID, userID, page, pageSize)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为响应格式
|
|
|
|
|
|
result := dto.ConvertMessagesToResponse(messages)
|
|
|
|
|
|
|
|
|
|
|
|
response.Paginated(c, result, total, page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleMarkRead 标记已读
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// POST /api/v1/conversations/:id/read
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (h *MessageHandler) HandleMarkRead(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
conversationID := getIDParam(c, "id")
|
|
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation id is required")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
var req dto.MarkReadRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
err := h.chatService.MarkAsRead(c.Request.Context(), conversationID, userID, req.LastReadSeq)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "marked as read", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleSetConversationPinned 设置会话置顶
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// PUT /api/v1/conversations/:id/pinned
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
conversationID := getIDParam(c, "id")
|
|
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation id is required")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
var req struct {
|
|
|
|
|
|
IsPinned bool `json:"is_pinned"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
if err := h.chatService.SetConversationPinned(c.Request.Context(), conversationID, userID, req.IsPinned); err != nil {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "conversation pinned status updated", gin.H{
|
2026-03-12 08:38:14 +08:00
|
|
|
|
"conversation_id": conversationID,
|
|
|
|
|
|
"is_pinned": req.IsPinned,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
|
2026-04-25 21:22:52 +08:00
|
|
|
|
// HandleSetConversationNotificationMuted 设置会话免打扰状态
|
|
|
|
|
|
// PUT /api/v1/conversations/:id/notification_muted
|
|
|
|
|
|
func (h *MessageHandler) HandleSetConversationNotificationMuted(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationID := getIDParam(c, "id")
|
|
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation id is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req struct {
|
|
|
|
|
|
NotificationMuted bool `json:"notification_muted"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := h.chatService.SetConversationNotificationMuted(c.Request.Context(), conversationID, userID, req.NotificationMuted); err != nil {
|
|
|
|
|
|
response.BadRequest(c, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.SuccessWithMessage(c, "conversation notification_muted status updated", gin.H{
|
2026-04-28 14:53:04 +08:00
|
|
|
|
"conversation_id": conversationID,
|
2026-04-25 21:22:52 +08:00
|
|
|
|
"notification_muted": req.NotificationMuted,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
// ==================== Cursor Pagination Handlers ====================
|
|
|
|
|
|
|
|
|
|
|
|
// GetMessagesByCursor 游标分页获取会话消息
|
|
|
|
|
|
// GET /api/v1/conversations/:id/messages/cursor
|
|
|
|
|
|
// 请求参数:
|
|
|
|
|
|
// - cursor: 游标字符串(可选)
|
|
|
|
|
|
// - direction: 分页方向 forward 或 backward(默认 forward)
|
|
|
|
|
|
// - page_size: 每页数量(默认 20,最大 100)
|
|
|
|
|
|
func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationID := getIDParam(c, "id")
|
|
|
|
|
|
if conversationID == "" {
|
|
|
|
|
|
response.BadRequest(c, "conversation_id is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析游标分页参数
|
|
|
|
|
|
pageReq := parseCursorPageRequest(c)
|
|
|
|
|
|
|
|
|
|
|
|
// 调用服务层
|
|
|
|
|
|
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, pageReq)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalServerError(c, "failed to get messages")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为响应格式
|
|
|
|
|
|
items := dto.ConvertMessagesToResponse(result.Items)
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, &dto.MessageCursorPageResponse{
|
|
|
|
|
|
Items: items,
|
|
|
|
|
|
NextCursor: result.NextCursor,
|
|
|
|
|
|
PrevCursor: result.PrevCursor,
|
|
|
|
|
|
HasMore: result.HasMore,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetConversationsByCursor 游标分页获取会话列表
|
|
|
|
|
|
// GET /api/v1/conversations/cursor
|
|
|
|
|
|
// 请求参数:
|
|
|
|
|
|
// - cursor: 游标字符串(可选)
|
|
|
|
|
|
// - direction: 分页方向 forward 或 backward(默认 forward)
|
|
|
|
|
|
// - page_size: 每页数量(默认 20,最大 100)
|
|
|
|
|
|
func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
|
|
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
response.Unauthorized(c, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析游标分页参数
|
|
|
|
|
|
pageReq := parseCursorPageRequest(c)
|
|
|
|
|
|
|
|
|
|
|
|
// 调用服务层
|
|
|
|
|
|
result, err := h.messageService.GetConversationsByCursor(c.Request.Context(), userID, pageReq)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalServerError(c, "failed to get conversations")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤掉系统会话
|
|
|
|
|
|
filteredItems := make([]*model.Conversation, 0)
|
|
|
|
|
|
for _, conv := range result.Items {
|
|
|
|
|
|
if conv.ID != model.SystemConversationID {
|
|
|
|
|
|
filteredItems = append(filteredItems, conv)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// 批量填充会话数据(解决 N+1 问题)
|
|
|
|
|
|
items := h.enrichConversations(c.Request.Context(), filteredItems, userID)
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
response.Success(c, &dto.ConversationCursorPageResponse{
|
|
|
|
|
|
Items: items,
|
|
|
|
|
|
NextCursor: result.NextCursor,
|
|
|
|
|
|
PrevCursor: result.PrevCursor,
|
|
|
|
|
|
HasMore: result.HasMore,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseCursorPageRequest 解析游标分页请求参数
|
|
|
|
|
|
func parseCursorPageRequest(c *gin.Context) *cursor.PageRequest {
|
|
|
|
|
|
cursorStr := c.Query("cursor")
|
|
|
|
|
|
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
|
|
|
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
|
|
|
|
|
}
|