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:
bujinyuan
2026-03-04 14:58:23 +08:00
parent ad76d9a613
commit 19979da59e
77 changed files with 10053 additions and 490 deletions

View File

@@ -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 用户简要信息(内部使用)

View File

@@ -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)
}