feat(im): Task 2 - IM DAO 层(会话 + 消息)

ConversationDAO:
- FindPrivateConversation: 单聊会话去重查询
- CreateWithMembers: 事务创建会话+成员
- GetUserConversations: 会话列表(含未读/置顶)
- UpdateLastMessage / IncrementUnread / ClearUnread
- SoftDeleteMember / RestoreMember / UpdateMemberPinned
- GetUnreadConversations: 离线消息推送用

MessageDAO:
- Create / GetByID / GetByConversation(游标分页)
- UpdateStatus(撤回) / DeleteByConversation(清空)
- SearchMessages(全局搜索,JOIN 权限校验)
- FindByClientMsgID(幂等去重)

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-03 10:44:34 +08:00
parent c23b9087b8
commit e091fbccb3
2 changed files with 412 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
package dao
import (
"context"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/im/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MessageDAO 消息数据访问对象
type MessageDAO struct {
db *gorm.DB
}
// NewMessageDAO 创建 MessageDAO 实例
func NewMessageDAO(db *gorm.DB) *MessageDAO {
return &MessageDAO{db: db}
}
// Create 写入一条消息
func (d *MessageDAO) Create(ctx context.Context, msg *model.Message) error {
funcName := "dao.message_dao.Create"
logs.Debug(ctx, funcName, "写入消息",
zap.Int64("conversation_id", msg.ConversationID), zap.Int64("sender_id", msg.SenderID))
err := d.db.WithContext(ctx).Create(msg).Error
if err != nil {
logs.Error(ctx, funcName, "写入消息失败", zap.Error(err))
}
return err
}
// GetByID 根据 ID 查询单条消息
func (d *MessageDAO) GetByID(ctx context.Context, id int64) (*model.Message, error) {
var msg model.Message
err := d.db.WithContext(ctx).First(&msg, id).Error
if err != nil {
return nil, err
}
return &msg, nil
}
// GetByConversation 获取会话的历史消息(游标分页,按 ID 降序)
// beforeID > 0 时作为游标,查询 ID 小于 beforeID 的消息
// beforeID = 0 时查询最新的 limit 条消息
func (d *MessageDAO) GetByConversation(ctx context.Context, conversationID int64, beforeID int64, limit int) ([]model.Message, error) {
funcName := "dao.message_dao.GetByConversation"
logs.Debug(ctx, funcName, "查询历史消息",
zap.Int64("conversation_id", conversationID),
zap.Int64("before_id", beforeID), zap.Int("limit", limit))
query := d.db.WithContext(ctx).
Where("conversation_id = ? AND status != ?", conversationID, constants.MessageStatusDeleted)
if beforeID > 0 {
query = query.Where("id < ?", beforeID)
}
var messages []model.Message
err := query.Order("id DESC").Limit(limit).Find(&messages).Error
if err != nil {
logs.Error(ctx, funcName, "查询历史消息失败", zap.Error(err))
}
return messages, err
}
// UpdateStatus 更新消息状态(撤回/删除)
func (d *MessageDAO) UpdateStatus(ctx context.Context, id int64, status int) error {
funcName := "dao.message_dao.UpdateStatus"
logs.Info(ctx, funcName, "更新消息状态",
zap.Int64("message_id", id), zap.Int("status", status))
return d.db.WithContext(ctx).
Model(&model.Message{}).
Where("id = ?", id).
Update("status", status).Error
}
// DeleteByConversation 软删除会话中所有消息(标记 status=3
func (d *MessageDAO) DeleteByConversation(ctx context.Context, conversationID int64) error {
funcName := "dao.message_dao.DeleteByConversation"
logs.Info(ctx, funcName, "清空会话消息",
zap.Int64("conversation_id", conversationID))
return d.db.WithContext(ctx).
Model(&model.Message{}).
Where("conversation_id = ? AND status = ?", conversationID, constants.MessageStatusNormal).
Update("status", constants.MessageStatusDeleted).Error
}
// SearchMessages 全局消息搜索(按关键词匹配,仅搜索用户所在会话的消息)
// 返回匹配的消息列表(已 JOIN 成员表确保权限)
func (d *MessageDAO) SearchMessages(ctx context.Context, userID int64, keyword string, limit int) ([]MessageSearchResult, error) {
funcName := "dao.message_dao.SearchMessages"
logs.Debug(ctx, funcName, "全局消息搜索",
zap.Int64("user_id", userID), zap.String("keyword", keyword))
var results []MessageSearchResult
err := d.db.WithContext(ctx).
Raw(`SELECT m.id, m.conversation_id, m.sender_id, m.content, m.created_at
FROM im_messages m
JOIN im_conversation_members cm ON cm.conversation_id = m.conversation_id
WHERE cm.user_id = ? AND cm.is_deleted = false
AND m.status = ? AND m.content LIKE ?
ORDER BY m.created_at DESC
LIMIT ?`,
userID, constants.MessageStatusNormal, "%"+keyword+"%", limit).
Scan(&results).Error
if err != nil {
logs.Error(ctx, funcName, "全局消息搜索失败", zap.Error(err))
}
return results, err
}
// MessageSearchResult 搜索结果单条记录
type MessageSearchResult struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"`
SenderID int64 `json:"sender_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
}
// FindByClientMsgID 根据客户端消息 ID 查询(幂等去重用)
func (d *MessageDAO) FindByClientMsgID(ctx context.Context, conversationID int64, clientMsgID string) (*model.Message, error) {
if clientMsgID == "" {
return nil, nil
}
var msg model.Message
err := d.db.WithContext(ctx).
Where("conversation_id = ? AND client_msg_id = ?", conversationID, clientMsgID).
First(&msg).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &msg, nil
}
// GetLatestMessageID 获取会话中最新一条消息的 ID用于标记已读
func (d *MessageDAO) GetLatestMessageID(ctx context.Context, conversationID int64) (int64, error) {
var id int64
err := d.db.WithContext(ctx).
Model(&model.Message{}).
Select("COALESCE(MAX(id), 0)").
Where("conversation_id = ?", conversationID).
Scan(&id).Error
return id, err
}