package service import ( "cmp" "context" "errors" "fmt" "maps" "slices" "strconv" "time" "with_you/internal/cache" apperrors "with_you/internal/errors" "with_you/internal/model" "with_you/internal/pkg/cursor" "with_you/internal/pkg/utils" "with_you/internal/pkg/ws" "with_you/internal/repository" "go.uber.org/zap" "gorm.io/gorm" ) // 缓存TTL常量 const ( GroupMembersTTL = 120 * time.Second // 群组成员缓存120秒 GroupMembersNullTTL = 5 * time.Second GroupCacheJitter = 0.1 ) // 群组服务错误定义 - 使用统一的 AppError var ( ErrGroupNotFound = apperrors.ErrGroupNotFound ErrNotGroupMember = apperrors.ErrNotGroupMember ErrNotGroupAdmin = apperrors.ErrNotGroupAdmin ErrNotGroupOwner = apperrors.ErrNotGroupOwner ErrGroupFull = apperrors.ErrGroupFull ErrAlreadyMember = apperrors.ErrAlreadyGroupMember ErrCannotRemoveOwner = apperrors.ErrCannotRemoveOwner ErrCannotMuteOwner = apperrors.ErrCannotMuteOwner ErrMuted = apperrors.ErrMuted ErrMuteAllEnabled = apperrors.ErrMuteAllEnabled ErrCannotJoin = apperrors.ErrCannotJoin ErrJoinRequestPending = apperrors.ErrJoinRequestPending ErrGroupRequestNotFound = apperrors.ErrGroupRequestNotFound ErrGroupRequestHandled = apperrors.ErrGroupRequestHandled ErrNotRequestTarget = apperrors.ErrNotRequestTarget ErrNoEligibleInvitee = apperrors.ErrNoEligibleInvitee ErrNotMutualFollow = apperrors.ErrNotMutualFollow ErrInviteNotAccepted = apperrors.ErrInviteNotAccepted ) // GroupService 群组服务接口 type GroupService interface { // 群组管理 CreateGroup(ownerID string, name string, description string, memberIDs []string) (*model.Group, error) GetGroupByID(id string) (*model.Group, error) UpdateGroup(userID string, groupID string, updates map[string]any) error DissolveGroup(userID string, groupID string) error TransferOwner(userID string, groupID string, newOwnerID string) error GetUserGroups(userID string, page, pageSize int) ([]model.Group, int64, error) GetMemberCount(groupID string) (int, error) // 成员管理 InviteMembers(userID string, groupID string, memberIDs []string) error JoinGroup(userID string, groupID string) error RespondInvite(userID string, flag string, approve bool, reason string) error SetGroupAddRequest(userID string, flag string, approve bool, reason string) error LeaveGroup(userID string, groupID string) error RemoveMember(userID string, groupID string, targetUserID string) error GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error) SetMemberRole(userID string, groupID string, targetUserID string, role string) error SetMemberNickname(userID string, groupID string, nickname string) error MuteMember(userID string, groupID string, targetUserID string, muted bool) error // 群设置 SetMuteAll(userID string, groupID string, muteAll bool) error SetJoinType(userID string, groupID string, joinType int) error // 群公告 CreateAnnouncement(userID string, groupID string, content string) (*model.GroupAnnouncement, error) GetAnnouncements(groupID string, page, pageSize int) ([]model.GroupAnnouncement, int64, error) DeleteAnnouncement(userID string, announcementID string) error // 权限检查 CanSendGroupMessage(userID string, groupID string) error IsGroupAdmin(userID string, groupID string) bool IsGroupOwner(userID string, groupID string) bool // 获取成员信息 GetMember(groupID string, userID string) (*model.GroupMember, error) // 游标分页方法 GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error) GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error) } // GroupMembersResult 群组成员缓存结果 type GroupMembersResult struct { Members []model.GroupMember Total int64 } // groupService 群组服务实现 type groupService struct { groupRepo repository.GroupRepository userRepo repository.UserRepository messageRepo repository.MessageRepository requestRepo repository.GroupJoinRequestRepository notifyRepo repository.SystemNotificationRepository wsHub *ws.Hub cache cache.Cache } // NewGroupService 创建群组服务 func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.UserRepository, messageRepo repository.MessageRepository, requestRepo repository.GroupJoinRequestRepository, notifyRepo repository.SystemNotificationRepository, wsHub *ws.Hub, cacheBackend cache.Cache) GroupService { return &groupService{ groupRepo: groupRepo, userRepo: userRepo, messageRepo: messageRepo, requestRepo: requestRepo, notifyRepo: notifyRepo, wsHub: wsHub, cache: cacheBackend, } } type groupNoticeData struct { UserID string `json:"user_id,omitempty"` Username string `json:"username,omitempty"` OperatorID string `json:"operator_id,omitempty"` } type groupNoticeMessage struct { NoticeType string `json:"notice_type"` GroupID string `json:"group_id"` Data groupNoticeData `json:"data"` Timestamp int64 `json:"timestamp"` MessageID string `json:"message_id,omitempty"` Seq int64 `json:"seq,omitempty"` } func (s *groupService) publishGroupNotice(groupID string, notice groupNoticeMessage) { members, _, err := s.groupRepo.GetMembers(groupID, 1, 1000) if err != nil { zap.L().Error("获取群成员失败", zap.String("groupID", groupID), zap.Error(err), ) return } if s.wsHub != nil { for _, m := range members { s.wsHub.PublishToUser(m.UserID, "group_notice", notice) } } } // invalidateConversationCachesAfterSystemMessage 系统消息写入后失效相关缓存 func (s *groupService) invalidateConversationCachesAfterSystemMessage(conversationID string) { if conversationID == "" || s.messageRepo == nil { return } // 新系统消息会影响消息分页列表 cache.InvalidateMessagePages(s.cache, conversationID) // 参与者列表可能发生变化(加群/退群)后,这里统一清理一次 s.cache.Delete(cache.ParticipantListKey(conversationID)) participants, err := s.messageRepo.GetConversationParticipants(conversationID) if err != nil { return } for _, p := range participants { if p == nil || p.UserID == "" { continue } // 会话最后消息、未读数会变化,清理用户维度缓存 cache.InvalidateConversationList(s.cache, p.UserID) cache.InvalidateUnreadConversation(s.cache, p.UserID) cache.InvalidateUnreadDetail(s.cache, p.UserID, conversationID) } } // invalidateConversationCachesAfterMembershipChange 成员变更后失效相关缓存 func (s *groupService) invalidateConversationCachesAfterMembershipChange(conversationID, userID string) { if conversationID == "" { return } s.cache.Delete(cache.ParticipantListKey(conversationID)) if userID != "" { s.cache.Delete(cache.ParticipantKey(conversationID, userID)) cache.InvalidateConversationList(s.cache, userID) cache.InvalidateUnreadConversation(s.cache, userID) cache.InvalidateUnreadDetail(s.cache, userID, conversationID) } } // ==================== 群组管理 ==================== // CreateGroup 创建群组 func (s *groupService) CreateGroup(ownerID string, name string, description string, memberIDs []string) (*model.Group, error) { // 创建群组(ID会在BeforeCreate中自动生成) group := &model.Group{ Name: name, Description: description, OwnerID: ownerID, MemberCount: 0, MaxMembers: 500, JoinType: model.JoinTypeAnyone, MuteAll: false, } // 保存群组 if err := s.groupRepo.Create(group); err != nil { return nil, err } // 添加群主为成员 ownerMember := &model.GroupMember{ GroupID: group.ID, UserID: ownerID, Role: model.GroupRoleOwner, JoinTime: time.Now(), } if err := s.groupRepo.AddMember(ownerMember); err != nil { // 回滚:删除群组 _ = s.groupRepo.Delete(group.ID) return nil, err } // 邀请初始成员 if len(memberIDs) > 0 { for _, memberID := range memberIDs { if memberID == ownerID { continue // 跳过群主 } member := &model.GroupMember{ GroupID: group.ID, UserID: memberID, Role: model.GroupRoleMember, JoinTime: time.Now(), } if err := s.groupRepo.AddMember(member); err != nil { // 单个成员添加失败不回滚整个操作 continue } } } // 创建群组会话(Conversation) conversationID, err := utils.GetSnowflake().GenerateID() if err != nil { conversationID = int64(time.Now().UnixNano()) } conversation := &model.Conversation{ ID: strconv.FormatInt(conversationID, 10), Type: model.ConversationTypeGroup, GroupID: &group.ID, } // 收集所有参与者 participants := make([]string, 0, len(memberIDs)+1) participants = append(participants, ownerID) for _, memberID := range memberIDs { if memberID != ownerID { participants = append(participants, memberID) } } // 使用仓储创建会话和参与者 if err := s.messageRepo.CreateConversationWithParticipants(conversation, participants); err != nil { // 记录错误但不影响群组创建成功 } return group, nil } // GetGroupByID 根据ID获取群组 func (s *groupService) GetGroupByID(id string) (*model.Group, error) { group, err := s.groupRepo.GetByID(id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrGroupNotFound } return nil, err } return group, nil } // GetMemberCount 实时获取群成员数量 func (s *groupService) GetMemberCount(groupID string) (int, error) { count, err := s.groupRepo.GetMemberCount(groupID) if err != nil { return 0, err } return int(count), nil } // UpdateGroup 更新群组信息 func (s *groupService) UpdateGroup(userID string, groupID string, updates map[string]any) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 检查权限:只有群主和管理员可以更新群信息 if !s.IsGroupAdmin(userID, groupID) { return ErrNotGroupAdmin } // 不允许直接修改的字段 delete(updates, "id") delete(updates, "owner_id") delete(updates, "member_count") delete(updates, "created_at") // 应用更新 if name, ok := updates["name"].(string); ok { group.Name = name } if description, ok := updates["description"].(string); ok { group.Description = description } if avatar, ok := updates["avatar"].(string); ok { group.Avatar = avatar } return s.groupRepo.Update(group) } // DissolveGroup 解散群组 func (s *groupService) DissolveGroup(userID string, groupID string) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 检查权限:只有群主可以解散群 if group.OwnerID != userID { return ErrNotGroupOwner } // 先删除群组对应的会话(包括参与者、消息) if s.messageRepo != nil { if err := s.messageRepo.DeleteConversationByGroupID(groupID); err != nil { zap.L().Warn("删除会话失败", zap.String("groupID", groupID), zap.Error(err), ) // 继续删除群组,不因为会话删除失败而中断 } } return s.groupRepo.Delete(groupID) } // TransferOwner 转让群主 func (s *groupService) TransferOwner(userID string, groupID string, newOwnerID string) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 检查权限:只有群主可以转让群主 if group.OwnerID != userID { return ErrNotGroupOwner } // 检查新群主是否是群成员 isMember, err := s.groupRepo.IsMember(groupID, newOwnerID) if err != nil { return err } if !isMember { return ErrNotGroupMember } // 使用仓储的 TransferOwner 方法(在事务中更新) return s.groupRepo.TransferOwner(groupID, userID, newOwnerID) } // GetUserGroups 获取用户加入的群组列表 func (s *groupService) GetUserGroups(userID string, page, pageSize int) ([]model.Group, int64, error) { return s.groupRepo.GetUserGroups(userID, page, pageSize) } // ==================== 成员管理 ==================== func (s *groupService) newRequestFlag() string { id, err := utils.GetSnowflake().GenerateID() if err != nil { return strconv.FormatInt(time.Now().UnixNano(), 10) } return strconv.FormatInt(id, 10) } func (s *groupService) createSystemNotification(receiverID string, notifyType model.SystemNotificationType, content string, extra *model.SystemNotificationExtra) { if s.notifyRepo == nil { return } notification := &model.SystemNotification{ ReceiverID: receiverID, Type: notifyType, Content: content, ExtraData: extra, } if err := s.notifyRepo.Create(notification); err != nil { zap.L().Warn("create system notification failed", zap.String("component", "groupService"), zap.String("receiverID", receiverID), zap.String("type", string(notifyType)), zap.Error(err), ) } } func (s *groupService) broadcastMemberJoinNotice(groupID string, targetUserID string, operatorID string) { if groupID == "" || targetUserID == "" { return } targetUserName := "用户" if targetUser, err := s.userRepo.GetByID(targetUserID); err == nil && targetUser != nil && targetUser.Nickname != "" { targetUserName = targetUser.Nickname } noticeContent := "\"" + targetUserName + "\" 加入了群聊" var savedMessage *model.Message if s.messageRepo != nil { conv, err := s.messageRepo.GetConversationByGroupID(groupID) if err == nil && conv != nil { msg := &model.Message{ ConversationID: conv.ID, SenderID: model.SystemSenderIDStr, Segments: model.MessageSegments{ {Type: "text", Data: map[string]any{"text": noticeContent}}, }, Status: model.MessageStatusNormal, Category: model.CategoryNotification, } if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil { zap.L().Warn("保存入群提示消息失败", zap.String("component", "broadcastMemberJoinNotice"), zap.String("groupID", groupID), zap.String("userID", targetUserID), zap.Error(err), ) } else { savedMessage = msg s.invalidateConversationCachesAfterSystemMessage(conv.ID) } } else { zap.L().Warn("获取群组会话失败", zap.String("component", "broadcastMemberJoinNotice"), zap.String("groupID", groupID), zap.Error(err), ) } } noticeMsg := groupNoticeMessage{ NoticeType: "member_join", GroupID: groupID, Data: groupNoticeData{ UserID: targetUserID, Username: targetUserName, OperatorID: operatorID, }, Timestamp: time.Now().UnixMilli(), } if savedMessage != nil { noticeMsg.MessageID = savedMessage.ID noticeMsg.Seq = savedMessage.Seq } s.publishGroupNotice(groupID, noticeMsg) } func (s *groupService) addMemberToGroupAndConversation(group *model.Group, userID string, operatorID string) error { if group == nil { return ErrGroupNotFound } isMember, err := s.groupRepo.IsMember(group.ID, userID) if err != nil { return err } if isMember { return nil } memberCount, err := s.groupRepo.GetMemberCount(group.ID) if err != nil { return err } if int(memberCount) >= group.MaxMembers { return ErrGroupFull } member := &model.GroupMember{ GroupID: group.ID, UserID: userID, Role: model.GroupRoleMember, JoinTime: time.Now(), } if err := s.groupRepo.AddMember(member); err != nil { return err } if s.messageRepo != nil { conv, err := s.messageRepo.GetConversationByGroupID(group.ID) if err == nil && conv != nil { if err := s.messageRepo.AddParticipant(conv.ID, userID); err != nil { zap.L().Warn("添加会话参与者失败", zap.String("component", "addMemberToGroupAndConversation"), zap.String("groupID", group.ID), zap.String("userID", userID), zap.Error(err), ) } s.invalidateConversationCachesAfterMembershipChange(conv.ID, userID) } } cache.InvalidateGroupMembers(s.cache, group.ID) s.broadcastMemberJoinNotice(group.ID, userID, operatorID) return nil } func (s *groupService) collectGroupReviewerIDs(groupID string, ownerID string, skipUserID string) []string { reviewerSet := make(map[string]struct{}) if ownerID != "" && ownerID != skipUserID { reviewerSet[ownerID] = struct{}{} } if reviewers, err := s.groupRepo.GetAdmins(groupID); err == nil { for _, reviewer := range reviewers { if reviewer.UserID == "" || reviewer.UserID == skipUserID { continue } reviewerSet[reviewer.UserID] = struct{}{} } } return slices.Collect(maps.Keys(reviewerSet)) } func (s *groupService) notifyJoinApplyReviewers( group *model.Group, req *model.GroupJoinRequest, applicantName string, applicantAvatar string, targetUserID string, targetUserName string, targetUserAvatar string, ) { if group == nil || req == nil { return } reviewerIDs := s.collectGroupReviewerIDs(group.ID, group.OwnerID, req.InitiatorID) for _, reviewerID := range reviewerIDs { s.createSystemNotification(reviewerID, model.SysNotifyGroupJoinApply, applicantName+" 申请加入群聊 "+group.Name, &model.SystemNotificationExtra{ ActorIDStr: req.InitiatorID, ActorName: applicantName, AvatarURL: applicantAvatar, TargetID: req.Flag, TargetTitle: group.Name, TargetType: string(model.GroupJoinRequestTypeJoinApply), GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, GroupDescription: group.Description, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(req.Status), TargetUserID: targetUserID, TargetUserName: targetUserName, TargetUserAvatar: targetUserAvatar, }) } } func (s *groupService) sendGroupInviteToTarget(group *model.Group, req *model.GroupJoinRequest, inviterName, inviterAvatar string) { if group == nil || req == nil { return } s.createSystemNotification(req.TargetUserID, model.SysNotifyGroupInvite, inviterName+" 邀请你加入群聊 "+group.Name, &model.SystemNotificationExtra{ ActorIDStr: req.InitiatorID, ActorName: inviterName, AvatarURL: inviterAvatar, TargetID: req.Flag, TargetTitle: group.Name, TargetType: string(model.GroupJoinRequestTypeInvite), GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, GroupDescription: group.Description, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(req.Status), TargetUserID: req.TargetUserID, TargetUserName: "", TargetUserAvatar: "", }) } // InviteMembers 邀请成员 func (s *groupService) InviteMembers(userID string, groupID string, memberIDs []string) error { group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } isInviterMember, err := s.groupRepo.IsMember(groupID, userID) if err != nil { return err } if !isInviterMember { return ErrNotGroupMember } inviter, _ := s.userRepo.GetByID(userID) inviterName := "群成员" inviterAvatar := "" if inviter != nil { inviterName = cmp.Or(inviter.Nickname, "群成员") inviterAvatar = inviter.Avatar } createdCount := 0 for _, memberID := range memberIDs { if memberID == "" || memberID == userID { continue } isMember, err := s.groupRepo.IsMember(groupID, memberID) if err != nil { continue } if isMember { continue } isFollowing, err := s.userRepo.IsFollowing(userID, memberID) if err != nil || !isFollowing { continue } isFollowedBack, err := s.userRepo.IsFollowing(memberID, userID) if err != nil || !isFollowedBack { continue } if _, err := s.requestRepo.GetPendingByGroupAndTarget(groupID, memberID, model.GroupJoinRequestTypeInvite); err == nil { continue } else if !errors.Is(err, gorm.ErrRecordNotFound) { continue } expireAt := time.Now().Add(72 * time.Hour) req := &model.GroupJoinRequest{ Flag: s.newRequestFlag(), GroupID: groupID, InitiatorID: userID, TargetUserID: memberID, RequestType: model.GroupJoinRequestTypeInvite, Status: model.GroupJoinRequestStatusPending, ExpireAt: &expireAt, } if err := s.requestRepo.Create(req); err != nil { continue } // 无论是否需要审批,都先发送邀请卡片给被邀请人 // 被邀请人同意后,再根据群的策略决定是否需要管理员审批 s.sendGroupInviteToTarget(group, req, inviterName, inviterAvatar) createdCount++ } if createdCount == 0 { return ErrNoEligibleInvitee } return nil } // JoinGroup 加入群组 func (s *groupService) JoinGroup(userID string, groupID string) error { group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 检查是否已经是群成员 isMember, err := s.groupRepo.IsMember(groupID, userID) if err != nil { return err } if isMember { return ErrAlreadyMember } if group.JoinType == model.JoinTypeForbidden { return ErrCannotJoin } if group.JoinType == model.JoinTypeApproval { if pendingReq, err := s.requestRepo.GetPendingByGroupAndTarget(groupID, userID, model.GroupJoinRequestTypeJoinApply); err == nil { applicant, _ := s.userRepo.GetByID(userID) applicantName := "用户" applicantAvatar := "" if applicant != nil { applicantName = cmp.Or(applicant.Nickname, "用户") applicantAvatar = applicant.Avatar } // 已有待审批单时补发一次提醒,避免管理端漏看 s.notifyJoinApplyReviewers( group, pendingReq, applicantName, applicantAvatar, userID, applicantName, applicantAvatar, ) return ErrJoinRequestPending } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } applicant, _ := s.userRepo.GetByID(userID) applicantName := "用户" applicantAvatar := "" if applicant != nil { applicantName = cmp.Or(applicant.Nickname, "用户") applicantAvatar = applicant.Avatar } expireAt := time.Now().Add(72 * time.Hour) req := &model.GroupJoinRequest{ Flag: s.newRequestFlag(), GroupID: groupID, InitiatorID: userID, TargetUserID: userID, RequestType: model.GroupJoinRequestTypeJoinApply, Status: model.GroupJoinRequestStatusPending, ExpireAt: &expireAt, } if err := s.requestRepo.Create(req); err != nil { return err } s.notifyJoinApplyReviewers( group, req, applicantName, applicantAvatar, userID, applicantName, applicantAvatar, ) return ErrJoinRequestPending } return s.addMemberToGroupAndConversation(group, userID, userID) } func (s *groupService) RespondInvite(userID string, flag string, approve bool, reason string) error { req, err := s.requestRepo.GetByFlag(flag) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupRequestNotFound } return err } if req.RequestType != model.GroupJoinRequestTypeInvite { return ErrGroupRequestNotFound } if req.Status != model.GroupJoinRequestStatusPending { return ErrGroupRequestHandled } if req.TargetUserID != userID { return ErrNotRequestTarget } group, err := s.groupRepo.GetByID(req.GroupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } now := time.Now() req.Reason = reason if approve { // 检查群的加群策略 if group.JoinType == model.JoinTypeAnyone { // 允许任何人加入,直接进群 if err := s.addMemberToGroupAndConversation(group, userID, userID); err != nil { return err } req.Status = model.GroupJoinRequestStatusAccepted s.createSystemNotification(req.InitiatorID, model.SysNotifySystem, "你发出的群邀请已被接受", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusAccepted), }) } else { // 需要审批或不允许加入时,被邀请人同意后通知管理员审批 // 设置 ReviewerID 为被邀请人表示被邀请人已同意 req.ReviewerID = userID req.ReviewedAt = &now // 获取被邀请人信息 targetUserName := "用户" targetUserAvatar := "" if u, e := s.userRepo.GetByID(userID); e == nil && u != nil { targetUserName = cmp.Or(u.Nickname, "用户") targetUserAvatar = u.Avatar } // 获取邀请人信息 inviterName := "群成员" inviterAvatar := "" if u, e := s.userRepo.GetByID(req.InitiatorID); e == nil && u != nil { inviterName = cmp.Or(u.Nickname, "群成员") inviterAvatar = u.Avatar } // 通知管理员审批 reviewerIDs := s.collectGroupReviewerIDs(group.ID, group.OwnerID, req.InitiatorID) for _, reviewerID := range reviewerIDs { s.createSystemNotification(reviewerID, model.SysNotifyGroupJoinApply, inviterName+" 邀请 "+targetUserName+" 加入群聊 "+group.Name+",被邀请人已同意,请审批", &model.SystemNotificationExtra{ ActorIDStr: req.InitiatorID, ActorName: inviterName, AvatarURL: inviterAvatar, TargetID: req.Flag, TargetTitle: group.Name, TargetType: string(model.GroupJoinRequestTypeInvite), GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, GroupDescription: group.Description, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(req.Status), TargetUserID: userID, TargetUserName: targetUserName, TargetUserAvatar: targetUserAvatar, }) } // 通知邀请人:被邀请人已同意,等待管理员审批 s.createSystemNotification(req.InitiatorID, model.SysNotifySystem, targetUserName+" 已接受你的群邀请,等待管理员审批", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(req.Status), }) } } else { req.Status = model.GroupJoinRequestStatusRejected s.createSystemNotification(req.InitiatorID, model.SysNotifySystem, "你发出的群邀请已被拒绝", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusRejected), Reason: reason, }) } return s.requestRepo.Update(req) } func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bool, reason string) error { req, err := s.requestRepo.GetByFlag(flag) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupRequestNotFound } return err } if req.RequestType != model.GroupJoinRequestTypeJoinApply && req.RequestType != model.GroupJoinRequestTypeInvite { return ErrGroupRequestNotFound } if req.Status != model.GroupJoinRequestStatusPending { return ErrGroupRequestHandled } // 检查 invite 类型的请求: // - ReviewerID 非空表示被邀请人已同意,等待管理员审批 // - ReviewerID 为空表示被邀请人还未同意,管理员不能提前审批 if req.RequestType == model.GroupJoinRequestTypeInvite { if req.ReviewerID == "" { // 被邀请人还未同意,管理员不能提前审批 return ErrInviteNotAccepted } if req.ReviewerID != req.TargetUserID { // ReviewerID 不是被邀请人,说明已经被其他人处理过 return ErrGroupRequestHandled } } if !s.IsGroupAdmin(userID, req.GroupID) { return ErrNotGroupAdmin } group, err := s.groupRepo.GetByID(req.GroupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } now := time.Now() // 保存管理员ID到ReviewerID(覆盖被邀请人的ID) req.Reason = reason targetUserName := "用户" if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil { targetUserName = cmp.Or(u.Nickname, "用户") } reviewerName := "管理员" reviewerAvatar := "" if u, e := s.userRepo.GetByID(userID); e == nil && u != nil { reviewerName = cmp.Or(u.Nickname, "管理员") reviewerAvatar = u.Avatar } if approve { if req.RequestType == model.GroupJoinRequestTypeInvite { // 被邀请人已同意,管理员审批通过后直接进群 if err := s.addMemberToGroupAndConversation(group, req.TargetUserID, userID); err != nil { return err } req.Status = model.GroupJoinRequestStatusAccepted req.ReviewerID = userID req.ReviewedAt = &now s.createSystemNotification(req.TargetUserID, model.SysNotifyGroupJoinApproved, "你接受的群邀请已通过审批,你已加入群聊 "+group.Name, &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusAccepted), }) s.createSystemNotification(req.InitiatorID, model.SysNotifySystem, "你邀请 "+targetUserName+" 入群已通过审批", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusAccepted), }) } else { if err := s.addMemberToGroupAndConversation(group, req.TargetUserID, userID); err != nil { return err } req.Status = model.GroupJoinRequestStatusAccepted req.ReviewerID = userID req.ReviewedAt = &now s.createSystemNotification(req.TargetUserID, model.SysNotifyGroupJoinApproved, "你申请加入群聊 "+group.Name+" 已通过", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusAccepted), }) } // 同步通知其他可审批人:该请求已被处理 reviewerIDs := s.collectGroupReviewerIDs(group.ID, group.OwnerID, "") for _, reviewerID := range reviewerIDs { if reviewerID == userID { continue } s.createSystemNotification(reviewerID, model.SysNotifyGroupJoinApply, reviewerName+" 已同意该入群请求", &model.SystemNotificationExtra{ ActorIDStr: userID, ActorName: reviewerName, AvatarURL: reviewerAvatar, GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, GroupDescription: group.Description, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusAccepted), TargetUserID: req.TargetUserID, TargetUserName: targetUserName, }) } } else { req.Status = model.GroupJoinRequestStatusRejected req.ReviewerID = userID req.ReviewedAt = &now if req.RequestType == model.GroupJoinRequestTypeInvite { // 被邀请人已同意但管理员拒绝,通知被邀请人和邀请人 s.createSystemNotification(req.TargetUserID, model.SysNotifyGroupJoinRejected, "你接受的群邀请未通过审批", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusRejected), Reason: reason, }) s.createSystemNotification(req.InitiatorID, model.SysNotifySystem, "你邀请 "+targetUserName+" 入群未通过审批", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusRejected), Reason: reason, }) } else { s.createSystemNotification(req.TargetUserID, model.SysNotifyGroupJoinRejected, "你申请加入群聊 "+group.Name+" 被拒绝", &model.SystemNotificationExtra{ GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusRejected), Reason: reason, }) } // 同步通知其他可审批人:该请求已被处理 reviewerIDs := s.collectGroupReviewerIDs(group.ID, group.OwnerID, "") for _, reviewerID := range reviewerIDs { if reviewerID == userID { continue } s.createSystemNotification(reviewerID, model.SysNotifyGroupJoinApply, reviewerName+" 已拒绝该入群请求", &model.SystemNotificationExtra{ ActorIDStr: userID, ActorName: reviewerName, AvatarURL: reviewerAvatar, GroupID: group.ID, GroupName: group.Name, GroupAvatar: group.Avatar, GroupDescription: group.Description, Flag: req.Flag, RequestType: string(req.RequestType), RequestStatus: string(model.GroupJoinRequestStatusRejected), Reason: reason, TargetUserID: req.TargetUserID, TargetUserName: targetUserName, }) } } return s.requestRepo.Update(req) } // LeaveGroup 退出群组 func (s *groupService) LeaveGroup(userID string, groupID string) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 群主不能退出群 if group.OwnerID == userID { return ErrCannotRemoveOwner } // 移除群成员 if err := s.groupRepo.RemoveMember(groupID, userID); err != nil { return err } // 移除会话参与者记录 // 根据群组ID查找对应的会话 conv, err := s.messageRepo.GetConversationByGroupID(groupID) if err != nil { // 如果找不到会话,记录日志但不阻塞退出群流程 fmt.Printf("[WARN] LeaveGroup: conversation not found for group %s, error: %v\n", groupID, err) } else { // 移除该用户在会话中的参与者记录 if err := s.messageRepo.RemoveParticipant(conv.ID, userID); err != nil { // 如果移除参与者失败,记录日志但不阻塞退出群流程 fmt.Printf("[WARN] LeaveGroup: failed to remove participant %s from conversation %s, error: %v\n", userID, conv.ID, err) } s.invalidateConversationCachesAfterMembershipChange(conv.ID, userID) } // 失效群组成员缓存 cache.InvalidateGroupMembers(s.cache, groupID) return nil } // RemoveMember 移除成员 func (s *groupService) RemoveMember(userID string, groupID string, targetUserID string) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 不能移除群主 if targetUserID == group.OwnerID { return ErrCannotRemoveOwner } // 检查权限 targetRole, err := s.groupRepo.GetMemberRole(groupID, targetUserID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotGroupMember } return err } // 群主可以移除任何人 // 管理员只能移除普通成员 if group.OwnerID != userID { if targetRole == model.GroupRoleOwner { return ErrNotGroupOwner } if targetRole == model.GroupRoleAdmin && !s.IsGroupOwner(userID, groupID) { return ErrNotGroupAdmin } } // 移除群成员 if err := s.groupRepo.RemoveMember(groupID, targetUserID); err != nil { return err } // 同时移除会话参与者 if s.messageRepo != nil { conv, err := s.messageRepo.GetConversationByGroupID(groupID) if err == nil && conv != nil { if err := s.messageRepo.RemoveParticipant(conv.ID, targetUserID); err != nil { zap.L().Warn("移除会话参与者失败", zap.String("component", "RemoveMember"), zap.String("groupID", groupID), zap.String("userID", targetUserID), zap.Error(err), ) } s.invalidateConversationCachesAfterMembershipChange(conv.ID, targetUserID) } } // 失效群组成员缓存 cache.InvalidateGroupMembers(s.cache, groupID) return nil } // GetMembers 获取群成员列表(带缓存) func (s *groupService) GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error) { cacheSettings := cache.GetSettings() groupMembersTTL := cacheSettings.GroupMembersTTL if groupMembersTTL <= 0 { groupMembersTTL = GroupMembersTTL } nullTTL := cacheSettings.NullTTL if nullTTL <= 0 { nullTTL = GroupMembersNullTTL } jitter := cacheSettings.JitterRatio if jitter <= 0 { jitter = GroupCacheJitter } // 生成缓存键 cacheKey := cache.GroupMembersKey(groupID, page, pageSize) result, err := cache.GetOrLoadTyped[*GroupMembersResult]( s.cache, cacheKey, groupMembersTTL, jitter, nullTTL, func() (*GroupMembersResult, error) { members, total, err := s.groupRepo.GetMembers(groupID, page, pageSize) if err != nil { return nil, err } return &GroupMembersResult{ Members: members, Total: total, }, nil }, ) if err != nil { return nil, 0, err } if result == nil { return []model.GroupMember{}, 0, nil } return result.Members, result.Total, nil } // SetMemberRole 设置成员角色 func (s *groupService) SetMemberRole(userID string, groupID string, targetUserID string, role string) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 检查权限:只有群主可以设置角色 if group.OwnerID != userID { return ErrNotGroupOwner } // 不能修改群主的角色 if targetUserID == group.OwnerID { return ErrCannotRemoveOwner } // 验证角色值 if role != model.GroupRoleAdmin && role != model.GroupRoleMember { return errors.New("无效的角色") } err = s.groupRepo.SetMemberRole(groupID, targetUserID, role) if err != nil { return err } // 失效群组成员缓存 cache.InvalidateGroupMembers(s.cache, groupID) return nil } // SetMemberNickname 设置群内昵称 func (s *groupService) SetMemberNickname(userID string, groupID string, nickname string) error { // 获取成员信息 member, err := s.groupRepo.GetMember(groupID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotGroupMember } return err } member.Nickname = nickname err = s.groupRepo.UpdateMember(member) if err != nil { return err } // 失效群组成员缓存 cache.InvalidateGroupMembers(s.cache, groupID) return nil } // MuteMember 禁言成员 func (s *groupService) MuteMember(userID string, groupID string, targetUserID string, muted bool) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 不能禁言群主 if targetUserID == group.OwnerID { return ErrCannotMuteOwner } // 检查权限:群主可以禁言任何人,管理员只能禁言普通成员 if group.OwnerID != userID { targetRole, err := s.groupRepo.GetMemberRole(groupID, targetUserID) if err != nil { return err } if targetRole == model.GroupRoleAdmin { return ErrNotGroupAdmin } } member, err := s.groupRepo.GetMember(groupID, targetUserID) if err != nil { zap.L().Warn("获取成员失败", zap.String("component", "MuteMember"), zap.Error(err), ) return err } zap.L().Debug("禁言前状态", zap.String("component", "MuteMember"), zap.Bool("member.Muted", member.Muted), zap.Bool("muted", muted), ) member.Muted = muted if err := s.groupRepo.UpdateMember(member); err != nil { zap.L().Warn("更新成员失败", zap.String("component", "MuteMember"), zap.Error(err), ) return err } zap.L().Debug("禁言状态已更新到数据库", zap.String("component", "MuteMember")) // 验证更新结果 updatedMember, _ := s.groupRepo.GetMember(groupID, targetUserID) if updatedMember != nil { zap.L().Debug("验证成员状态", zap.String("component", "MuteMember"), zap.Bool("member.Muted", updatedMember.Muted), ) } // 获取被禁言用户的显示名称 targetUser, _ := s.userRepo.GetByID(targetUserID) targetUserName := "用户" if targetUser != nil { targetUserName = targetUser.Nickname } // 构建通知内容 noticeType := "muted" noticeContent := "\"" + targetUserName + "\" 已被管理员禁言" if !muted { noticeType = "unmuted" noticeContent = "\"" + targetUserName + "\" 已被管理员解除禁言" } // 保存禁言/解禁消息到数据库 var savedMessage *model.Message if s.messageRepo != nil { // 获取群组会话 conv, err := s.messageRepo.GetConversationByGroupID(groupID) if err == nil && conv != nil { // 创建系统消息 msg := &model.Message{ ConversationID: conv.ID, SenderID: model.SystemSenderIDStr, Segments: model.MessageSegments{ {Type: "text", Data: map[string]any{"text": noticeContent}}, }, Status: model.MessageStatusNormal, Category: model.CategoryNotification, } // 保存消息并获取 seq if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil { zap.L().Warn("保存禁言消息失败", zap.String("component", "MuteMember"), zap.Error(err), ) } else { savedMessage = msg zap.L().Debug("禁言消息已保存", zap.String("component", "MuteMember"), zap.String("msgID", msg.ID), zap.Int64("seq", msg.Seq), ) s.invalidateConversationCachesAfterSystemMessage(conv.ID) } } else { zap.L().Warn("获取群组会话失败", zap.String("component", "MuteMember"), zap.Error(err), ) } } noticeMsg := groupNoticeMessage{ NoticeType: noticeType, GroupID: groupID, Data: groupNoticeData{ UserID: targetUserID, OperatorID: userID, }, Timestamp: time.Now().UnixMilli(), } if savedMessage != nil { noticeMsg.MessageID = savedMessage.ID noticeMsg.Seq = savedMessage.Seq } s.publishGroupNotice(groupID, noticeMsg) // 失效群组成员缓存 cache.InvalidateGroupMembers(s.cache, groupID) return nil } // ==================== 群设置 ==================== // SetMuteAll 全员禁言 func (s *groupService) SetMuteAll(userID string, groupID string, muteAll bool) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 检查权限:只有群主可以设置全员禁言 if group.OwnerID != userID { return ErrNotGroupOwner } group.MuteAll = muteAll return s.groupRepo.Update(group) } // SetJoinType 设置加群方式 func (s *groupService) SetJoinType(userID string, groupID string, joinType int) error { // 检查群组是否存在 group, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGroupNotFound } return err } // 检查权限:只有群主可以设置加群方式 if group.OwnerID != userID { return ErrNotGroupOwner } // 验证加群方式 if joinType < 0 || joinType > 2 { return errors.New("无效的加群方式") } group.JoinType = model.JoinType(joinType) return s.groupRepo.Update(group) } // ==================== 群公告 ==================== // CreateAnnouncement 创建群公告 func (s *groupService) CreateAnnouncement(userID string, groupID string, content string) (*model.GroupAnnouncement, error) { // 检查群组是否存在 _, err := s.groupRepo.GetByID(groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrGroupNotFound } return nil, err } // 检查权限:只有群主和管理员可以发布公告 if !s.IsGroupAdmin(userID, groupID) { return nil, ErrNotGroupAdmin } announcement := &model.GroupAnnouncement{ GroupID: groupID, Content: content, AuthorID: userID, } if err := s.groupRepo.CreateAnnouncement(announcement); err != nil { return nil, err } return announcement, nil } // GetAnnouncements 获取群公告列表 func (s *groupService) GetAnnouncements(groupID string, page, pageSize int) ([]model.GroupAnnouncement, int64, error) { return s.groupRepo.GetAnnouncements(groupID, page, pageSize) } // DeleteAnnouncement 删除群公告 func (s *groupService) DeleteAnnouncement(userID string, announcementID string) error { // 获取公告 announcement, err := s.groupRepo.GetAnnouncementByID(announcementID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("公告不存在") } return err } // 检查群组是否存在 group, err := s.groupRepo.GetByID(announcement.GroupID) if err != nil { return err } // 检查权限:只有群主和管理员可以删除公告 if !s.IsGroupAdmin(userID, group.ID) { return ErrNotGroupAdmin } return s.groupRepo.DeleteAnnouncement(announcementID) } // ==================== 权限检查 ==================== // CanSendGroupMessage 检查是否可以发送群消息 func (s *groupService) CanSendGroupMessage(userID string, groupID string) error { // 检查是否是群成员 member, err := s.groupRepo.GetMember(groupID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotGroupMember } return err } // 检查是否被禁言 if member.Muted { return ErrMuted } // 检查是否全员禁言 group, err := s.groupRepo.GetByID(groupID) if err != nil { return err } if group.MuteAll { return ErrMuteAllEnabled } return nil } // IsGroupAdmin 检查是否是群管理员 func (s *groupService) IsGroupAdmin(userID string, groupID string) bool { role, err := s.groupRepo.GetMemberRole(groupID, userID) if err != nil { return false } return role == model.GroupRoleOwner || role == model.GroupRoleAdmin } // IsGroupOwner 检查是否是群主 func (s *groupService) IsGroupOwner(userID string, groupID string) bool { group, err := s.groupRepo.GetByID(groupID) if err != nil { return false } return group.OwnerID == userID } // GetMember 获取指定用户在群组中的成员信息 func (s *groupService) GetMember(groupID string, userID string) (*model.GroupMember, error) { return s.groupRepo.GetMember(groupID, userID) } // ==================== 游标分页方法 ==================== // GetGroupsByCursor 游标分页获取群组列表 func (s *groupService) GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) { return s.groupRepo.GetGroupsByCursor(ctx, req) } // GetUserGroupsByCursor 游标分页获取用户加入的群组列表 func (s *groupService) GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) { return s.groupRepo.GetUserGroupsByCursor(ctx, userID, req) } // GetMembersByCursor 游标分页获取群成员列表 func (s *groupService) GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error) { return s.groupRepo.GetMembersByCursor(ctx, groupID, req) } // GetAnnouncementsByCursor 游标分页获取群公告列表 func (s *groupService) GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error) { return s.groupRepo.GetAnnouncementsByCursor(ctx, groupID, req) }