feat(phase2c): 群聊与已读回执功能完整实现
Phase 2c 全部 14 个 Task 完成,包含: 后端(Go): - MinIO 文件存储服务集成(Docker + Go SDK + 通用上传 API) - Group 模块完整实现(DAO + Service + Controller + Router + Wire) - 18 个群管理 REST API + 11 个 WS 群事件推送 - 群创建/解散/邀请/踢人/退出/转让群主/设管理员/禁言/全体禁言/群公告/群昵称/免打扰/搜索 - IM Service 扩展(群消息发送/撤回 + @提醒 + 管理员无时限撤回) - 已读回执后端(单聊会话级 last_read_msg_id + 群聊消息级 im_message_reads) - 管理端群组管理(列表/详情/解散) - 数据库迁移(3 张新表 + 2 张表字段扩展) 前端(uni-app): - 群聊 Store + API 封装 + 11 个 WS 事件监听 - 已读回执 UI(单聊已读/未读标记 + 群聊 X人已读 + 已读详情页) - 7 个群聊页面(对话/创建/设置/成员/邀请/审批/搜索) - 会话列表改造(全部/单聊/群聊 Tab + @标记 + 免打扰标识) 管理端(Vue 3 + Element Plus): - 群组列表页(搜索/分页/详情弹窗/解散群聊) - 侧边栏群组管理入口 文档同步:进度/架构/设计/API/规范文档全部更新 Made-with: Cursor
This commit is contained in:
@@ -23,25 +23,30 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFriend = errors.New("对方不是你的好友")
|
||||
ErrEmptyContent = errors.New("消息内容不能为空")
|
||||
ErrConvNotFound = errors.New("会话不存在")
|
||||
ErrMsgNotFound = errors.New("消息不存在")
|
||||
ErrNotSender = errors.New("只能撤回自己发送的消息")
|
||||
ErrRecallTimeout = errors.New("超过撤回时限")
|
||||
ErrNotMember = errors.New("你不是该会话的成员")
|
||||
ErrDuplicateMsg = errors.New("重复消息")
|
||||
ErrInvalidMsgType = errors.New("不支持的消息类型")
|
||||
ErrNotFriend = errors.New("对方不是你的好友")
|
||||
ErrEmptyContent = errors.New("消息内容不能为空")
|
||||
ErrConvNotFound = errors.New("会话不存在")
|
||||
ErrMsgNotFound = errors.New("消息不存在")
|
||||
ErrNotSender = errors.New("只能撤回自己发送的消息")
|
||||
ErrRecallTimeout = errors.New("超过撤回时限")
|
||||
ErrNotMember = errors.New("你不是该会话的成员")
|
||||
ErrDuplicateMsg = errors.New("重复消息")
|
||||
ErrInvalidMsgType = errors.New("不支持的消息类型")
|
||||
ErrGroupDissolved = errors.New("群聊已解散")
|
||||
ErrGroupAllMuted = errors.New("当前群已开启全体禁言")
|
||||
ErrUserMuted = errors.New("你已被禁言,无法发送消息")
|
||||
)
|
||||
|
||||
// IMService 即时通讯核心业务服务
|
||||
type IMService struct {
|
||||
convDAO *dao.ConversationDAO
|
||||
msgDAO *dao.MessageDAO
|
||||
pubsub *ws.PubSub
|
||||
rdb *redis.Client
|
||||
friendChecker FriendChecker
|
||||
convDAO *dao.ConversationDAO
|
||||
msgDAO *dao.MessageDAO
|
||||
pubsub *ws.PubSub
|
||||
rdb *redis.Client
|
||||
friendChecker FriendChecker
|
||||
userInfoGetter UserInfoGetter
|
||||
groupInfo GroupInfoGetter
|
||||
readRecorder MessageReadRecorder
|
||||
}
|
||||
|
||||
// NewIMService 创建 IMService 实例
|
||||
@@ -52,6 +57,8 @@ func NewIMService(
|
||||
rdb *redis.Client,
|
||||
friendChecker FriendChecker,
|
||||
userInfoGetter UserInfoGetter,
|
||||
groupInfo GroupInfoGetter,
|
||||
readRecorder MessageReadRecorder,
|
||||
) *IMService {
|
||||
return &IMService{
|
||||
convDAO: convDAO,
|
||||
@@ -60,15 +67,14 @@ func NewIMService(
|
||||
rdb: rdb,
|
||||
friendChecker: friendChecker,
|
||||
userInfoGetter: userInfoGetter,
|
||||
groupInfo: groupInfo,
|
||||
readRecorder: readRecorder,
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage 发送消息(核心流程)
|
||||
// 1. 校验好友关系
|
||||
// 2. 查找或创建会话
|
||||
// 3. 幂等去重(client_msg_id)
|
||||
// 4. 写入消息 + 更新会话最后消息 + 递增对方未读数
|
||||
// 5. 通过 PubSub 推送给接收方
|
||||
// SendMessage 发送消息(核心流程,同时支持单聊和群聊)
|
||||
// 单聊:校验好友关系 → 查找/创建会话 → 写入消息 → 推送给对方
|
||||
// 群聊:校验群成员+禁言 → 写入消息(含 @信息)→ 推送给所有群成员
|
||||
func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||
funcName := "service.im_service.SendMessage"
|
||||
logs.Info(ctx, funcName, "发送消息",
|
||||
@@ -87,40 +93,64 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se
|
||||
}
|
||||
|
||||
convID := req.ConversationID
|
||||
var peerID int64
|
||||
|
||||
if convID == 0 && req.TargetUserID > 0 {
|
||||
isFriend, err := s.friendChecker.IsFriend(ctx, senderID, req.TargetUserID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "检查好友关系失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if !isFriend {
|
||||
return nil, ErrNotFriend
|
||||
}
|
||||
|
||||
conv, err := s.getOrCreatePrivateConversation(ctx, senderID, req.TargetUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
convID = conv.ID
|
||||
peerID = req.TargetUserID
|
||||
return s.sendPrivateMessage(ctx, senderID, req)
|
||||
} else if convID > 0 {
|
||||
member, err := s.convDAO.GetMember(ctx, convID, senderID)
|
||||
conv, err := s.convDAO.GetByID(ctx, convID)
|
||||
if err != nil {
|
||||
return nil, ErrNotMember
|
||||
return nil, ErrConvNotFound
|
||||
}
|
||||
if member == nil {
|
||||
return nil, ErrNotMember
|
||||
if conv.Type == constants.ConversationTypeGroup {
|
||||
return s.sendGroupMessage(ctx, senderID, req)
|
||||
}
|
||||
peerID, err = s.convDAO.GetPeerUserID(ctx, convID, senderID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, ErrConvNotFound
|
||||
return s.sendPrivateMessageByConvID(ctx, senderID, convID, req)
|
||||
}
|
||||
return nil, ErrConvNotFound
|
||||
}
|
||||
|
||||
// sendPrivateMessage 发送单聊消息(首次发送,通过 TargetUserID)
|
||||
func (s *IMService) sendPrivateMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||
funcName := "service.im_service.sendPrivateMessage"
|
||||
|
||||
isFriend, err := s.friendChecker.IsFriend(ctx, senderID, req.TargetUserID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "检查好友关系失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if !isFriend {
|
||||
return nil, ErrNotFriend
|
||||
}
|
||||
|
||||
conv, err := s.getOrCreatePrivateConversation(ctx, senderID, req.TargetUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.writeAndPushPrivateMessage(ctx, senderID, conv.ID, req.TargetUserID, req)
|
||||
}
|
||||
|
||||
// sendPrivateMessageByConvID 发送单聊消息(已有会话 ID)
|
||||
func (s *IMService) sendPrivateMessageByConvID(ctx context.Context, senderID, convID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||
funcName := "service.im_service.sendPrivateMessageByConvID"
|
||||
|
||||
member, err := s.convDAO.GetMember(ctx, convID, senderID)
|
||||
if err != nil || member == nil {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
|
||||
peerID, err := s.convDAO.GetPeerUserID(ctx, convID, senderID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.writeAndPushPrivateMessage(ctx, senderID, convID, peerID, req)
|
||||
}
|
||||
|
||||
// writeAndPushPrivateMessage 单聊:幂等去重 + 写消息 + 推送
|
||||
func (s *IMService) writeAndPushPrivateMessage(ctx context.Context, senderID, convID, peerID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||
funcName := "service.im_service.writeAndPushPrivateMessage"
|
||||
|
||||
if req.ClientMsgID != "" {
|
||||
existing, err := s.msgDAO.FindByClientMsgID(ctx, convID, req.ClientMsgID)
|
||||
@@ -160,40 +190,161 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se
|
||||
|
||||
s.incrementTotalUnread(ctx, peerID)
|
||||
|
||||
pushData := map[string]interface{}{
|
||||
"id": msg.ID,
|
||||
"conversation_id": convID,
|
||||
"sender_id": senderID,
|
||||
"type": msg.Type,
|
||||
"content": msg.Content,
|
||||
"client_msg_id": msg.ClientMsgID,
|
||||
"created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 {
|
||||
pushData["sender_name"] = senderUsers[0].Nickname
|
||||
pushData["sender_avatar"] = senderUsers[0].Avatar
|
||||
}
|
||||
pushData := s.buildMessagePushData(ctx, msg, senderID)
|
||||
pushData["conv_type"] = constants.ConversationTypePrivate
|
||||
s.pushToUser(ctx, peerID, "im.message.new", pushData)
|
||||
|
||||
return s.toMessageDTO(msg), nil
|
||||
}
|
||||
|
||||
// RecallMessage 撤回消息(2分钟内)
|
||||
// 撤回成功后,若被撤回消息是会话最后一条,则同步更新会话预览文本
|
||||
func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID int64) error {
|
||||
// sendGroupMessage 发送群聊消息
|
||||
func (s *IMService) sendGroupMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||
funcName := "service.im_service.sendGroupMessage"
|
||||
convID := req.ConversationID
|
||||
|
||||
member, err := s.convDAO.GetMember(ctx, convID, senderID)
|
||||
if err != nil || member == nil {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
|
||||
if s.groupInfo != nil {
|
||||
groupBrief, gErr := s.groupInfo.GetGroupBrief(ctx, convID)
|
||||
if gErr != nil {
|
||||
logs.Error(ctx, funcName, "获取群信息失败", zap.Error(gErr))
|
||||
return nil, ErrConvNotFound
|
||||
}
|
||||
if groupBrief.Status == constants.GroupStatusDissolved {
|
||||
return nil, ErrGroupDissolved
|
||||
}
|
||||
if groupBrief.IsAllMuted && member.Role < constants.GroupRoleAdmin {
|
||||
return nil, ErrGroupAllMuted
|
||||
}
|
||||
}
|
||||
|
||||
if member.IsMuted {
|
||||
return nil, ErrUserMuted
|
||||
}
|
||||
|
||||
if req.ClientMsgID != "" {
|
||||
existing, err := s.msgDAO.FindByClientMsgID(ctx, convID, req.ClientMsgID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "幂等去重查询失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return s.toMessageDTO(existing), ErrDuplicateMsg
|
||||
}
|
||||
}
|
||||
|
||||
msg := &model.Message{
|
||||
ConversationID: convID,
|
||||
SenderID: senderID,
|
||||
Type: req.Type,
|
||||
Content: req.Content,
|
||||
Status: constants.MessageStatusNormal,
|
||||
ClientMsgID: req.ClientMsgID,
|
||||
}
|
||||
if len(req.AtUserIDs) > 0 {
|
||||
msg.AtUserIDs = req.AtUserIDs
|
||||
}
|
||||
if err := s.msgDAO.Create(ctx, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.convDAO.UpdateLastMessage(ctx, convID, msg.ID, truncateContent(req.Content, 100), senderID, now); err != nil {
|
||||
logs.Error(ctx, funcName, "更新最后消息失败", zap.Error(err))
|
||||
}
|
||||
|
||||
memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, convID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "获取群成员列表失败", zap.Error(err))
|
||||
return s.toMessageDTO(msg), nil
|
||||
}
|
||||
|
||||
dndMap, dndErr := s.convDAO.GetMemberDNDMap(ctx, convID)
|
||||
if dndErr != nil {
|
||||
logs.Error(ctx, funcName, "获取免打扰状态失败", zap.Error(dndErr))
|
||||
dndMap = make(map[int64]bool)
|
||||
}
|
||||
|
||||
pushData := s.buildMessagePushData(ctx, msg, senderID)
|
||||
pushData["conv_type"] = constants.ConversationTypeGroup
|
||||
if len(req.AtUserIDs) > 0 {
|
||||
pushData["at_user_ids"] = req.AtUserIDs
|
||||
}
|
||||
|
||||
for _, uid := range memberIDs {
|
||||
if uid == senderID {
|
||||
continue
|
||||
}
|
||||
if err := s.convDAO.IncrementUnread(ctx, convID, uid); err != nil {
|
||||
logs.Error(ctx, funcName, "递增未读计数失败", zap.Int64("user_id", uid), zap.Error(err))
|
||||
}
|
||||
|
||||
if !dndMap[uid] {
|
||||
s.incrementTotalUnread(ctx, uid)
|
||||
}
|
||||
|
||||
if len(req.AtUserIDs) > 0 {
|
||||
isAtMe := false
|
||||
for _, atID := range req.AtUserIDs {
|
||||
if atID == uid || atID == 0 {
|
||||
isAtMe = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isAtMe {
|
||||
if err := s.convDAO.IncrementAtMeCount(ctx, convID, uid); err != nil {
|
||||
logs.Error(ctx, funcName, "递增@计数失败", zap.Int64("user_id", uid), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.pushToUser(ctx, uid, "im.message.new", pushData)
|
||||
}
|
||||
|
||||
return s.toMessageDTO(msg), nil
|
||||
}
|
||||
|
||||
// RecallMessage 撤回消息
|
||||
// 单聊:只能撤回自己的消息,2 分钟内
|
||||
// 群聊:自己的消息 2 分钟内撤回;群主/管理员可无时限撤回任何消息
|
||||
func (s *IMService) RecallMessage(ctx context.Context, operatorID int64, messageID int64) error {
|
||||
funcName := "service.im_service.RecallMessage"
|
||||
logs.Info(ctx, funcName, "撤回消息",
|
||||
zap.Int64("sender_id", senderID), zap.Int64("message_id", messageID))
|
||||
zap.Int64("operator_id", operatorID), zap.Int64("message_id", messageID))
|
||||
|
||||
msg, err := s.msgDAO.GetByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return ErrMsgNotFound
|
||||
}
|
||||
if msg.SenderID != senderID {
|
||||
return ErrNotSender
|
||||
|
||||
conv, err := s.convDAO.GetByID(ctx, msg.ConversationID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err))
|
||||
return ErrConvNotFound
|
||||
}
|
||||
if time.Since(msg.CreatedAt).Seconds() > float64(constants.MessageRecallTimeLimit) {
|
||||
return ErrRecallTimeout
|
||||
|
||||
isAdmin := false
|
||||
if conv.Type == constants.ConversationTypeGroup {
|
||||
member, mErr := s.convDAO.GetMember(ctx, msg.ConversationID, operatorID)
|
||||
if mErr != nil || member == nil {
|
||||
return ErrNotMember
|
||||
}
|
||||
isAdmin = member.Role >= constants.GroupRoleAdmin
|
||||
}
|
||||
|
||||
if msg.SenderID == operatorID {
|
||||
if time.Since(msg.CreatedAt).Seconds() > float64(constants.MessageRecallTimeLimit) {
|
||||
if !isAdmin {
|
||||
return ErrRecallTimeout
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !isAdmin {
|
||||
return ErrNotSender
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.msgDAO.UpdateStatus(ctx, messageID, constants.MessageStatusRecalled); err != nil {
|
||||
@@ -201,16 +352,31 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID
|
||||
return err
|
||||
}
|
||||
|
||||
conv, err := s.convDAO.GetByID(ctx, msg.ConversationID)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err))
|
||||
} else if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID {
|
||||
senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID})
|
||||
recallText := "撤回了一条消息"
|
||||
recallText := "撤回了一条消息"
|
||||
if msg.SenderID != operatorID && isAdmin {
|
||||
operatorInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{operatorID, msg.SenderID})
|
||||
operatorName := "管理员"
|
||||
senderName := "成员"
|
||||
if infoErr == nil {
|
||||
for _, u := range operatorInfo {
|
||||
if u.ID == operatorID {
|
||||
operatorName = u.Nickname
|
||||
}
|
||||
if u.ID == msg.SenderID {
|
||||
senderName = u.Nickname
|
||||
}
|
||||
}
|
||||
}
|
||||
recallText = fmt.Sprintf("管理员 %s 撤回了 %s 的一条消息", operatorName, senderName)
|
||||
} else {
|
||||
senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{operatorID})
|
||||
if infoErr == nil && len(senderInfo) > 0 {
|
||||
recallText = senderInfo[0].Nickname + " 撤回了一条消息"
|
||||
}
|
||||
if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, senderID, msg.CreatedAt); updateErr != nil {
|
||||
}
|
||||
|
||||
if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID {
|
||||
if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, operatorID, msg.CreatedAt); updateErr != nil {
|
||||
logs.Error(ctx, funcName, "更新会话预览失败", zap.Error(updateErr))
|
||||
}
|
||||
}
|
||||
@@ -221,20 +387,22 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID
|
||||
return nil
|
||||
}
|
||||
for _, uid := range memberIDs {
|
||||
if uid == senderID {
|
||||
if uid == operatorID {
|
||||
continue
|
||||
}
|
||||
s.pushToUser(ctx, uid, "im.message.recalled", map[string]interface{}{
|
||||
"message_id": messageID,
|
||||
"conversation_id": msg.ConversationID,
|
||||
"sender_id": senderID,
|
||||
"operator_id": operatorID,
|
||||
"sender_id": msg.SenderID,
|
||||
"recall_text": recallText,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConversationList 获取会话列表(含对方用户信息)
|
||||
// GetConversationList 获取会话列表(含对方用户信息 / 群聊信息)
|
||||
func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto.ConversationListResponse, error) {
|
||||
funcName := "service.im_service.GetConversationList"
|
||||
logs.Debug(ctx, funcName, "获取会话列表", zap.Int64("user_id", userID))
|
||||
@@ -245,17 +413,20 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
||||
}
|
||||
|
||||
peerIDs := make([]int64, 0, len(convs))
|
||||
groupConvIDs := make([]int64, 0)
|
||||
for _, c := range convs {
|
||||
if c.PeerUserID > 0 {
|
||||
if c.Type == constants.ConversationTypePrivate && c.PeerUserID > 0 {
|
||||
peerIDs = append(peerIDs, c.PeerUserID)
|
||||
} else if c.Type == constants.ConversationTypeGroup {
|
||||
groupConvIDs = append(groupConvIDs, c.ID)
|
||||
}
|
||||
}
|
||||
|
||||
userMap := make(map[int64]*userBrief)
|
||||
if len(peerIDs) > 0 {
|
||||
users, err := s.userInfoGetter.GetUsersByIDs(ctx, peerIDs)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(err))
|
||||
users, uErr := s.userInfoGetter.GetUsersByIDs(ctx, peerIDs)
|
||||
if uErr != nil {
|
||||
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr))
|
||||
} else {
|
||||
for i := range users {
|
||||
u := users[i]
|
||||
@@ -264,17 +435,31 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
||||
}
|
||||
}
|
||||
|
||||
groupMap := make(map[int64]*dto.GroupBrief)
|
||||
if s.groupInfo != nil && len(groupConvIDs) > 0 {
|
||||
for _, convID := range groupConvIDs {
|
||||
brief, gErr := s.groupInfo.GetGroupBrief(ctx, convID)
|
||||
if gErr != nil {
|
||||
logs.Error(ctx, funcName, "获取群信息失败",
|
||||
zap.Int64("conversation_id", convID), zap.Error(gErr))
|
||||
continue
|
||||
}
|
||||
groupMap[convID] = brief
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]dto.ConversationDTO, 0, len(convs))
|
||||
for _, c := range convs {
|
||||
peerID := c.PeerUserID
|
||||
item := dto.ConversationDTO{
|
||||
ID: c.ID,
|
||||
Type: c.Type,
|
||||
PeerUserID: peerID,
|
||||
PeerUserID: c.PeerUserID,
|
||||
LastMsgContent: c.LastMsgContent,
|
||||
LastMsgSenderID: c.LastMsgSenderID,
|
||||
IsPinned: c.IsPinned,
|
||||
UnreadCount: c.UnreadCount,
|
||||
IsDoNotDisturb: c.IsDoNotDisturb,
|
||||
AtMeCount: c.AtMeCount,
|
||||
}
|
||||
if c.ClearBeforeMsgID > 0 && c.LastMessageID != nil && *c.LastMessageID <= c.ClearBeforeMsgID {
|
||||
item.LastMsgContent = ""
|
||||
@@ -283,10 +468,20 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
||||
if c.LastMsgTime != nil {
|
||||
item.LastMsgTime = c.LastMsgTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if brief, ok := userMap[peerID]; ok {
|
||||
item.PeerNickname = brief.Nickname
|
||||
item.PeerAvatar = brief.Avatar
|
||||
|
||||
if c.Type == constants.ConversationTypePrivate {
|
||||
if brief, ok := userMap[c.PeerUserID]; ok {
|
||||
item.PeerNickname = brief.Nickname
|
||||
item.PeerAvatar = brief.Avatar
|
||||
}
|
||||
} else if c.Type == constants.ConversationTypeGroup {
|
||||
if gBrief, ok := groupMap[c.ID]; ok {
|
||||
item.PeerNickname = gBrief.Name
|
||||
item.PeerAvatar = gBrief.Avatar
|
||||
item.GroupID = gBrief.ID
|
||||
}
|
||||
}
|
||||
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
@@ -360,6 +555,133 @@ func (s *IMService) MarkRead(ctx context.Context, userID int64, conversationID i
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkGroupMessagesRead 标记群聊消息已读(消息级别)
|
||||
// 将指定消息标记为已读 + 推送已读计数变化给消息发送者
|
||||
func (s *IMService) MarkGroupMessagesRead(ctx context.Context, userID int64, req *dto.MarkGroupReadRequest) error {
|
||||
funcName := "service.im_service.MarkGroupMessagesRead"
|
||||
logs.Info(ctx, funcName, "群消息标记已读",
|
||||
zap.Int64("user_id", userID), zap.Int64("conversation_id", req.ConversationID),
|
||||
zap.Int("msg_count", len(req.MessageIDs)))
|
||||
|
||||
member, err := s.convDAO.GetMember(ctx, req.ConversationID, userID)
|
||||
if err != nil || member == nil {
|
||||
return ErrNotMember
|
||||
}
|
||||
|
||||
if s.readRecorder == nil || len(req.MessageIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.readRecorder.BatchCreateReads(ctx, req.MessageIDs, userID); err != nil {
|
||||
logs.Error(ctx, funcName, "批量创建已读记录失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if member.AtMeCount > 0 {
|
||||
if err := s.convDAO.ClearAtMeCount(ctx, req.ConversationID, userID); err != nil {
|
||||
logs.Error(ctx, funcName, "清零@计数失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
readCounts, err := s.readRecorder.GetReadCountBatch(ctx, req.MessageIDs)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "获取已读计数失败", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, msgID := range req.MessageIDs {
|
||||
msg, mErr := s.msgDAO.GetByID(ctx, msgID)
|
||||
if mErr != nil || msg.SenderID == userID {
|
||||
continue
|
||||
}
|
||||
count := readCounts[msgID]
|
||||
s.pushToUser(ctx, msg.SenderID, "im.message.read.count", map[string]interface{}{
|
||||
"message_id": msgID,
|
||||
"conversation_id": req.ConversationID,
|
||||
"read_count": count,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMessageReadDetail 获取消息已读详情(已读/未读用户列表)
|
||||
func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, messageID int64) (*dto.GetReadDetailResponse, error) {
|
||||
funcName := "service.im_service.GetMessageReadDetail"
|
||||
logs.Debug(ctx, funcName, "获取已读详情",
|
||||
zap.Int64("user_id", userID), zap.Int64("message_id", messageID))
|
||||
|
||||
msg, err := s.msgDAO.GetByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, ErrMsgNotFound
|
||||
}
|
||||
|
||||
member, err := s.convDAO.GetMember(ctx, msg.ConversationID, userID)
|
||||
if err != nil || member == nil {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
|
||||
memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, msg.ConversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
readUserIDs := make(map[int64]bool)
|
||||
if s.readRecorder != nil {
|
||||
ids, rErr := s.readRecorder.GetReadUserIDs(ctx, messageID)
|
||||
if rErr != nil {
|
||||
logs.Error(ctx, funcName, "获取已读用户列表失败", zap.Error(rErr))
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
readUserIDs[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allUserIDs := make([]int64, 0, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
if id != msg.SenderID {
|
||||
allUserIDs = append(allUserIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
userMap := make(map[int64]*userBrief)
|
||||
if len(allUserIDs) > 0 {
|
||||
users, uErr := s.userInfoGetter.GetUsersByIDs(ctx, allUserIDs)
|
||||
if uErr != nil {
|
||||
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr))
|
||||
} else {
|
||||
for i := range users {
|
||||
u := users[i]
|
||||
userMap[u.ID] = &userBrief{Nickname: u.Nickname, Avatar: u.Avatar}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var readList, unreadList []dto.MessageReadDetailDTO
|
||||
for _, uid := range allUserIDs {
|
||||
item := dto.MessageReadDetailDTO{
|
||||
UserID: uid,
|
||||
}
|
||||
if brief, ok := userMap[uid]; ok {
|
||||
item.UserNickname = brief.Nickname
|
||||
item.UserAvatar = brief.Avatar
|
||||
}
|
||||
if readUserIDs[uid] {
|
||||
readList = append(readList, item)
|
||||
} else {
|
||||
unreadList = append(unreadList, item)
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.GetReadDetailResponse{
|
||||
ReadList: readList,
|
||||
UnreadList: unreadList,
|
||||
ReadCount: len(readList),
|
||||
TotalCount: len(allUserIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PinConversation 置顶/取消置顶会话
|
||||
func (s *IMService) PinConversation(ctx context.Context, userID int64, conversationID int64, isPinned bool) error {
|
||||
funcName := "service.im_service.PinConversation"
|
||||
@@ -580,7 +902,7 @@ func (s *IMService) decrementTotalUnread(ctx context.Context, userID int64, coun
|
||||
|
||||
// toMessageDTO 将 model.Message 转换为 dto.MessageDTO
|
||||
func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO {
|
||||
return &dto.MessageDTO{
|
||||
d := &dto.MessageDTO{
|
||||
ID: m.ID,
|
||||
ConversationID: m.ConversationID,
|
||||
SenderID: m.SenderID,
|
||||
@@ -590,6 +912,28 @@ func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO {
|
||||
ClientMsgID: m.ClientMsgID,
|
||||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if len(m.AtUserIDs) > 0 {
|
||||
d.AtUserIDs = []int64(m.AtUserIDs)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// buildMessagePushData 构建消息推送数据(单聊/群聊通用)
|
||||
func (s *IMService) buildMessagePushData(ctx context.Context, msg *model.Message, senderID int64) map[string]interface{} {
|
||||
pushData := map[string]interface{}{
|
||||
"id": msg.ID,
|
||||
"conversation_id": msg.ConversationID,
|
||||
"sender_id": senderID,
|
||||
"type": msg.Type,
|
||||
"content": msg.Content,
|
||||
"client_msg_id": msg.ClientMsgID,
|
||||
"created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 {
|
||||
pushData["sender_name"] = senderUsers[0].Nickname
|
||||
pushData["sender_avatar"] = senderUsers[0].Avatar
|
||||
}
|
||||
return pushData
|
||||
}
|
||||
|
||||
// userBrief 用户简要信息(内部使用)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
|
||||
authModel "github.com/echochat/backend/app/auth/model"
|
||||
"github.com/echochat/backend/app/dto"
|
||||
)
|
||||
|
||||
// FriendChecker 好友关系校验接口
|
||||
@@ -18,3 +19,19 @@ type FriendChecker interface {
|
||||
type UserInfoGetter interface {
|
||||
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error)
|
||||
}
|
||||
|
||||
// GroupInfoGetter 群信息查询接口(Phase 2c)
|
||||
// 由 group.GroupDAO 隐式实现,通过 Wire 接口注入
|
||||
// IM 模块通过此接口获取群表信息(判断全体禁言、已解散等)
|
||||
// 成员信息(角色/禁言/列表)通过 convDAO 直接查询 im_conversation_members 表
|
||||
type GroupInfoGetter interface {
|
||||
GetGroupBrief(ctx context.Context, conversationID int64) (*dto.GroupBrief, error)
|
||||
}
|
||||
|
||||
// MessageReadRecorder 群消息已读记录接口(Phase 2c)
|
||||
// 由 group.MessageReadDAO 隐式实现
|
||||
type MessageReadRecorder interface {
|
||||
BatchCreateReads(ctx context.Context, messageIDs []int64, userID int64) error
|
||||
GetReadCountBatch(ctx context.Context, messageIDs []int64) (map[int64]int, error)
|
||||
GetReadUserIDs(ctx context.Context, messageID int64) ([]int64, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user