From 1216423350fbaff2651f24741d9f6371e5e5363e Mon Sep 17 00:00:00 2001 From: lan Date: Thu, 12 Mar 2026 23:40:21 +0800 Subject: [PATCH] feat(group): refactor group invite workflow for improved user experience - Simplify InviteMembers to always send invite card directly to invitee - Modify RespondInvite to check group join type (anyone vs need approval) before adding member - Update SetGroupAddRequest to properly handle invite flow with invitee agreement and admin approval stages - Add case-insensitive search using LOWER() in user repository - Add random offset (0-50) for first page of recommended posts - Update dependencies: remove gorilla/websocket, promote gomail to direct dependency --- go.mod | 3 +- internal/handler/message_handler.go | 2 +- internal/repository/user_repo.go | 9 +- internal/service/group_service.go | 187 ++++++++++++++++++---------- internal/service/post_service.go | 13 +- 5 files changed, 138 insertions(+), 76 deletions(-) diff --git a/go.mod b/go.mod index 0466572..1d28a8d 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.5.0 - github.com/gorilla/websocket v1.5.3 github.com/gorse-io/gorse-go v0.5.0-alpha.3 github.com/minio/minio-go/v7 v7.0.66 github.com/redis/go-redis/v9 v9.3.1 @@ -18,6 +17,7 @@ require ( gorm.io/driver/postgres v1.5.4 gorm.io/driver/sqlite v1.5.4 gorm.io/gorm v1.25.5 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) require ( @@ -80,7 +80,6 @@ require ( golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/handler/message_handler.go b/internal/handler/message_handler.go index 7e8df93..9fb59ac 100644 --- a/internal/handler/message_handler.go +++ b/internal/handler/message_handler.go @@ -423,7 +423,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) { response.BadRequest(c, "detail_type is required") return } - if params.Segments == nil || len(params.Segments) == 0 { + if len(params.Segments) == 0 { response.BadRequest(c, "segments is required") return } diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 788e9e9..df1386d 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -2,6 +2,7 @@ package repository import ( "carrot_bbs/internal/model" + "strings" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -341,8 +342,12 @@ func (r *UserRepository) Search(keyword string, page, pageSize int) ([]*model.Us keyword, ) } else { - searchPattern := "%" + keyword + "%" - query = query.Where("username LIKE ? OR nickname LIKE ? OR bio LIKE ?", searchPattern, searchPattern, searchPattern) + // SQLite: 使用 LOWER() 实现大小写不敏感的模糊搜索 + lowerSearchPattern := "%" + strings.ToLower(keyword) + "%" + query = query.Where( + "LOWER(username) LIKE ? OR LOWER(nickname) LIKE ? OR LOWER(bio) LIKE ?", + lowerSearchPattern, lowerSearchPattern, lowerSearchPattern, + ) } } diff --git a/internal/service/group_service.go b/internal/service/group_service.go index 8fe7a1c..6aa0117 100644 --- a/internal/service/group_service.go +++ b/internal/service/group_service.go @@ -649,7 +649,6 @@ func (s *groupService) InviteMembers(userID string, groupID string, memberIDs [] if !isInviterMember { return ErrNotGroupMember } - isInviterAdmin := s.IsGroupAdmin(userID, groupID) inviter, _ := s.userRepo.GetByID(userID) inviterName := "群成员" @@ -685,7 +684,7 @@ func (s *groupService) InviteMembers(userID string, groupID string, memberIDs [] if _, err := s.requestRepo.GetPendingByGroupAndTarget(groupID, memberID, model.GroupJoinRequestTypeInvite); err == nil { continue - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + } else if !errors.Is(err, gorm.ErrRecordNotFound) { continue } expireAt := time.Now().Add(72 * time.Hour) @@ -702,42 +701,9 @@ func (s *groupService) InviteMembers(userID string, groupID string, memberIDs [] continue } - if isInviterAdmin { - // 群主/管理员邀请:直接发送邀请卡片,等待被邀请人确认 - s.sendGroupInviteToTarget(group, req, inviterName, inviterAvatar) - createdCount++ - continue - } - - inviteeName := "用户" - inviteeAvatar := "" - if invitee, e := s.userRepo.GetByID(memberID); e == nil && invitee != nil { - if invitee.Nickname != "" { - inviteeName = invitee.Nickname - } - inviteeAvatar = invitee.Avatar - } - reviewerIDs := s.collectGroupReviewerIDs(group.ID, group.OwnerID, userID) - for _, reviewerID := range reviewerIDs { - s.createSystemNotification(reviewerID, model.SysNotifyGroupJoinApply, inviterName+" 邀请 "+inviteeName+" 加入群聊 "+group.Name+",请审批", &model.SystemNotificationExtra{ - ActorIDStr: userID, - 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: memberID, - TargetUserName: inviteeName, - TargetUserAvatar: inviteeAvatar, - }) - } + // 无论是否需要审批,都先发送邀请卡片给被邀请人 + // 被邀请人同意后,再根据群的策略决定是否需要管理员审批 + s.sendGroupInviteToTarget(group, req, inviterName, inviterAvatar) createdCount++ } @@ -792,7 +758,7 @@ func (s *groupService) JoinGroup(userID string, groupID string) error { applicantAvatar, ) return ErrJoinRequestPending - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } @@ -861,22 +827,81 @@ func (s *groupService) RespondInvite(userID string, flag string, approve bool, r } now := time.Now() - req.ReviewerID = userID - req.ReviewedAt = &now req.Reason = reason if approve { - if err := s.addMemberToGroupAndConversation(group, userID, userID); err != nil { - return err + // 检查群的加群策略 + 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 { + if u.Nickname != "" { + targetUserName = u.Nickname + } + targetUserAvatar = u.Avatar + } + + // 获取邀请人信息 + inviterName := "群成员" + inviterAvatar := "" + if u, e := s.userRepo.GetByID(req.InitiatorID); e == nil && u != nil { + if u.Nickname != "" { + inviterName = 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), + }) } - 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 { req.Status = model.GroupJoinRequestStatusRejected s.createSystemNotification(req.InitiatorID, model.SysNotifySystem, "你发出的群邀请已被拒绝", &model.SystemNotificationExtra{ @@ -906,9 +931,18 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo if req.Status != model.GroupJoinRequestStatusPending { return ErrGroupRequestHandled } - if req.RequestType == model.GroupJoinRequestTypeInvite && req.ReviewerID != "" { - // invite 类型中 reviewerID 非空表示已完成管理员审批,等待被邀请人确认 - return ErrGroupRequestHandled + // 检查 invite 类型的请求: + // - ReviewerID 非空表示被邀请人已同意,等待管理员审批 + // - ReviewerID 为空表示被邀请人还未同意,管理员不能提前审批 + if req.RequestType == model.GroupJoinRequestTypeInvite { + if req.ReviewerID == "" { + // 被邀请人还未同意,管理员不能提前审批 + return &ServiceError{Code: 400, Message: "被邀请人尚未同意邀请,无法审批"} + } + if req.ReviewerID != req.TargetUserID { + // ReviewerID 不是被邀请人,说明已经被其他人处理过 + return ErrGroupRequestHandled + } } if !s.IsGroupAdmin(userID, req.GroupID) { return ErrNotGroupAdmin @@ -923,8 +957,7 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo } now := time.Now() - req.ReviewerID = userID - req.ReviewedAt = &now + // 保存管理员ID到ReviewerID(覆盖被邀请人的ID) req.Reason = reason targetUserName := "用户" if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil && u.Nickname != "" { @@ -941,29 +974,36 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo if approve { if req.RequestType == model.GroupJoinRequestTypeInvite { - // 管理员审批通过后,不直接拉人;发送邀请卡片等待对方确认 - inviterName := "群成员" - inviterAvatar := "" - if u, e := s.userRepo.GetByID(req.InitiatorID); e == nil && u != nil { - if u.Nickname != "" { - inviterName = u.Nickname - } - inviterAvatar = u.Avatar + // 被邀请人已同意,管理员审批通过后直接进群 + if err := s.addMemberToGroupAndConversation(group, req.TargetUserID, userID); err != nil { + return err } - s.sendGroupInviteToTarget(group, req, inviterName, inviterAvatar) - s.createSystemNotification(req.InitiatorID, model.SysNotifySystem, "你邀请 "+targetUserName+" 入群已通过审批,等待对方确认", &model.SystemNotificationExtra{ + 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.GroupJoinRequestStatusPending), + 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, @@ -996,8 +1036,19 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo } } 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, diff --git a/internal/service/post_service.go b/internal/service/post_service.go index b4b7049..09cf309 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "math/rand" "strings" "time" @@ -255,7 +256,7 @@ func (s *PostService) List(ctx context.Context, page, pageSize int, userID strin } cacheKey := cache.PostListKey("latest", visibilityUserKey, page, pageSize) - result, err := cache.GetOrLoadTyped[*PostListResult]( + result, err := cache.GetOrLoadTyped( s.cache, cacheKey, postListTTL, @@ -595,8 +596,14 @@ func (s *PostService) GetRecommendedPosts(ctx context.Context, userID string, pa return s.GetHotPosts(ctx, page, pageSize) } - // 计算偏移量 - offset := (page - 1) * pageSize + // 计算偏移量:第一页使用随机偏移量,增加推荐多样性 + var offset int + if page == 1 { + // 第一页随机偏移 0-50,让每次刷新看到不同的推荐内容 + offset = rand.Intn(51) + } else { + offset = (page - 1) * pageSize + } // 从Gorse获取推荐列表 // 多取1条用于判断是否还有下一页