From f426b84c0a38a27a6a6f84d80769c834745f8741 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Tue, 21 Apr 2026 16:08:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=98=B5=E7=A7=B0?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A5=BD=E5=8F=8B=E5=92=8C=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=98=B5=E7=A7=B0=E6=97=B6=E7=9A=84=E6=98=B5?= =?UTF-8?q?=E7=A7=B0=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/controller/social_controller.go | 51 +++- backend/pkg/filter/sensitive_filter.go | 151 ++++++++++ backend/pkg/proto/social/social.pb.go | 149 ++++++---- backend/pkg/validator/validator.go | 24 +- backend/proto/social.proto | 3 + .../repository/social_repository.go | 28 ++ frontend/pages/components/FriendsContent.vue | 269 ++++++++++++++---- frontend/pages/profile/profile.vue | 27 +- frontend/pages/profile/setNickname.vue | 20 +- frontend/utils/api.js | 16 +- frontend/utils/validator.js | 29 ++ 11 files changed, 609 insertions(+), 158 deletions(-) create mode 100644 backend/pkg/filter/sensitive_filter.go diff --git a/backend/gateway/controller/social_controller.go b/backend/gateway/controller/social_controller.go index fc8df5b..f980a44 100644 --- a/backend/gateway/controller/social_controller.go +++ b/backend/gateway/controller/social_controller.go @@ -36,14 +36,14 @@ func NewSocialController(dubboClient *client.Client) (*SocialController, error) }, nil } -// SendFriendRequest 发送好友请求 +// SendFriendRequest 发送好友请求(支持按昵称搜索) // @Summary 发送好友请求 -// @Description 向指定用户发送好友请求 +// @Description 向指定用户发送好友请求,或按昵称搜索匹配用户 // @Tags social // @Accept json // @Produce json // @Security BearerAuth -// @Param request body object{friend_user_id=integer,message=string} true "好友请求参数" +// @Param request body object{friend_user_id=integer,message=string,nickname=string,search_mode=boolean} true "好友请求参数" // @Success 200 {object} response.Response // @Router /api/v1/social/friend-requests [post] func (ctrl *SocialController) SendFriendRequest(c *gin.Context) { @@ -62,8 +62,10 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) { // 解析请求参数 var req struct { - FriendUserID int64 `json:"friend_user_id" binding:"required"` + FriendUserID int64 `json:"friend_user_id"` Message string `json:"message"` + Nickname string `json:"nickname"` + SearchMode bool `json:"search_mode"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -71,6 +73,16 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) { return } + // 验证参数:search_mode=true 时需要 nickname,否则需要 friend_user_id + if req.SearchMode && req.Nickname == "" { + response.Error(c, http.StatusBadRequest, "搜索模式下昵称不能为空") + return + } + if !req.SearchMode && req.FriendUserID == 0 && req.Nickname == "" { + response.Error(c, http.StatusBadRequest, "friend_user_id 或 nickname 不能同时为空") + return + } + // 设置上下文和 Dubbo attachments ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -81,15 +93,20 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) { }) // 调用 RPC - resp, err := ctrl.socialService.SendFriendRequest(ctx, &pbSocial.SendFriendRequestRequest{ + rpcReq := &pbSocial.SendFriendRequestRequest{ FriendUserId: req.FriendUserID, Message: req.Message, - }) + Nickname: req.Nickname, + SearchMode: req.SearchMode, + } + resp, err := ctrl.socialService.SendFriendRequest(ctx, rpcReq) if err != nil { logger.Logger.Error("SendFriendRequest RPC failed", zap.Int64("user_id", userID.(int64)), zap.Int64("friend_user_id", req.FriendUserID), + zap.String("nickname", req.Nickname), + zap.Bool("search_mode", req.SearchMode), zap.Error(err), ) response.Error(c, http.StatusInternalServerError, "服务调用失败") @@ -102,6 +119,28 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) { return } + // 如果是搜索模式,返回匹配用户列表 + if req.SearchMode { + users := make([]map[string]interface{}, 0, len(resp.MatchedUsers)) + for _, user := range resp.MatchedUsers { + users = append(users, map[string]interface{}{ + "fan_profile_id": user.FanProfileId, + "user_id": user.UserId, + "nickname": user.Nickname, + "avatar": user.Avatar, + "fan_level": user.FanLevel, + "relationship_status": user.RelationshipStatus, + "can_send_request": user.CanSendRequest, + "cooldown_ends_at": user.CooldownEndsAt, + }) + } + response.Success(c, gin.H{ + "users": users, + }) + return + } + + // 正常发送请求模式 response.Success(c, gin.H{ "request_id": resp.RequestId, "status": resp.Status, diff --git a/backend/pkg/filter/sensitive_filter.go b/backend/pkg/filter/sensitive_filter.go new file mode 100644 index 0000000..fafc386 --- /dev/null +++ b/backend/pkg/filter/sensitive_filter.go @@ -0,0 +1,151 @@ +package filter + +import ( + "sync" +) + +// SensitiveFilter 敏感词过滤器(DFA 算法) +type SensitiveFilter struct { + mu sync.RWMutex + root *node + pattern map[string]bool // 存储完整敏感词用于快速查找 +} + +type node struct { + children map[rune]*node + isEnd bool +} + +var ( + defaultFilter *SensitiveFilter + once sync.Once +) + +// NewSensitiveFilter 创建新的敏感词过滤器 +func NewSensitiveFilter() *SensitiveFilter { + return &SensitiveFilter{ + root: &node{children: make(map[rune]*node)}, + pattern: make(map[string]bool), + } +} + +// GetDefault 获取全局默认过滤器(单例模式) +func GetDefault() *SensitiveFilter { + once.Do(func() { + defaultFilter = NewSensitiveFilter() + // 加载默认敏感词列表 + defaultFilter.LoadWordList(defaultWordList) + }) + return defaultFilter +} + +// LoadWordList 加载敏感词列表 +func (f *SensitiveFilter) LoadWordList(words []string) error { + f.mu.Lock() + defer f.mu.Unlock() + + for _, word := range words { + if word == "" { + continue + } + f.pattern[word] = true + f.insert(word) + } + return nil +} + +// insert 将词语插入到 DFA 树中 +func (f *SensitiveFilter) insert(word string) { + current := f.root + for _, char := range word { + if _, ok := current.children[char]; !ok { + current.children[char] = &node{children: make(map[rune]*node)} + } + current = current.children[char] + } + current.isEnd = true +} + +// Contains 检测文本中是否包含敏感词 +func (f *SensitiveFilter) Contains(text string) bool { + if text == "" { + return false + } + + f.mu.RLock() + defer f.mu.RUnlock() + + return f.search(text) +} + +// search 在文本中搜索敏感词 +func (f *SensitiveFilter) search(text string) bool { + for i := 0; i < len(text); i++ { + current := f.root + j := i + for j < len(text) { + char := rune(text[j]) + if child, ok := current.children[char]; ok { + current = child + if current.isEnd { + return true + } + j++ + } else { + break + } + } + } + return false +} + +// FindAll 查找文本中所有敏感词 +func (f *SensitiveFilter) FindAll(text string) []string { + if text == "" { + return nil + } + + f.mu.RLock() + defer f.mu.RUnlock() + + var results []string + for i := 0; i < len(text); i++ { + current := f.root + j := i + word := "" + for j < len(text) { + char := rune(text[j]) + if child, ok := current.children[char]; ok { + current = child + word += string(char) + if current.isEnd { + results = append(results, word) + } + j++ + } else { + break + } + } + } + return results +} + +// 默认敏感词列表(初期使用基础敏感词,后续可扩展为从数据库或配置加载) +var defaultWordList = []string{ + // 政治敏感词(示例,实际需根据需求配置) + "台独", + "藏独", + "疆独", + "分裂", + "颠覆", + // 色情低俗(示例) + "色情", + "黄色", + "赌博", + "诈骗", + // 其他违禁词(示例) + "暴力", + "恐怖", + "毒品", + "枪支", +} diff --git a/backend/pkg/proto/social/social.pb.go b/backend/pkg/proto/social/social.pb.go index 7a9758d..4cdd8c0 100644 --- a/backend/pkg/proto/social/social.pb.go +++ b/backend/pkg/proto/social/social.pb.go @@ -320,6 +320,8 @@ type SendFriendRequestRequest struct { state protoimpl.MessageState `protogen:"open.v1"` FriendUserId int64 `protobuf:"varint,1,opt,name=friend_user_id,json=friendUserId,proto3" json:"friend_user_id,omitempty"` // 好友用户ID(要添加的用户ID) Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // 请求附带消息(可选) + Nickname string `protobuf:"bytes,3,opt,name=nickname,proto3" json:"nickname,omitempty"` // 昵称(用于按昵称搜索,与 friend_user_id 二选一) + SearchMode bool `protobuf:"varint,4,opt,name=search_mode,json=searchMode,proto3" json:"search_mode,omitempty"` // 搜索模式(true=仅搜索返回匹配用户,false=正常发送请求) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -368,13 +370,28 @@ func (x *SendFriendRequestRequest) GetMessage() string { return "" } +func (x *SendFriendRequestRequest) GetNickname() string { + if x != nil { + return x.Nickname + } + return "" +} + +func (x *SendFriendRequestRequest) GetSearchMode() bool { + if x != nil { + return x.SearchMode + } + return false +} + type SendFriendRequestResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` - RequestId int64 `protobuf:"varint,2,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // 创建的请求ID - Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // 请求状态 - CreatedAt int64 `protobuf:"varint,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // 创建时间 - ExpiresAt int64 `protobuf:"varint,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // 过期时间 + state protoimpl.MessageState `protogen:"open.v1"` + Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + RequestId int64 `protobuf:"varint,2,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // 创建的请求ID + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // 请求状态 + CreatedAt int64 `protobuf:"varint,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // 创建时间 + ExpiresAt int64 `protobuf:"varint,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // 过期时间 + MatchedUsers []*FanProfileSearchResult `protobuf:"bytes,6,rep,name=matched_users,json=matchedUsers,proto3" json:"matched_users,omitempty"` // 匹配的用户列表(仅 search_mode=true 时返回) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -444,6 +461,13 @@ func (x *SendFriendRequestResponse) GetExpiresAt() int64 { return 0 } +func (x *SendFriendRequestResponse) GetMatchedUsers() []*FanProfileSearchResult { + if x != nil { + return x.MatchedUsers + } + return nil +} + // 获取好友请求列表 type GetFriendRequestsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2165,10 +2189,13 @@ const file_social_proto_rawDesc = "" + "\rfriend_avatar\x18\n" + " \x01(\tR\ffriendAvatar\x12(\n" + "\x10friend_fan_level\x18\v \x01(\x05R\x0efriendFanLevel\x12#\n" + - "\rfriend_social\x18\f \x01(\x05R\ffriendSocial\"Z\n" + + "\rfriend_social\x18\f \x01(\x05R\ffriendSocial\"\x97\x01\n" + "\x18SendFriendRequestRequest\x12$\n" + "\x0efriend_user_id\x18\x01 \x01(\x03R\ffriendUserId\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\xc2\x01\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1a\n" + + "\bnickname\x18\x03 \x01(\tR\bnickname\x12\x1f\n" + + "\vsearch_mode\x18\x04 \x01(\bR\n" + + "searchMode\"\x8f\x02\n" + "\x19SendFriendRequestResponse\x120\n" + "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x1d\n" + "\n" + @@ -2177,7 +2204,8 @@ const file_social_proto_rawDesc = "" + "\n" + "created_at\x18\x04 \x01(\x03R\tcreatedAt\x12\x1d\n" + "\n" + - "expires_at\x18\x05 \x01(\x03R\texpiresAt\"w\n" + + "expires_at\x18\x05 \x01(\x03R\texpiresAt\x12K\n" + + "\rmatched_users\x18\x06 \x03(\v2&.topfans.social.FanProfileSearchResultR\fmatchedUsers\"w\n" + "\x18GetFriendRequestsRequest\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x12\n" + @@ -2353,57 +2381,58 @@ var file_social_proto_goTypes = []any{ } var file_social_proto_depIdxs = []int32{ 33, // 0: topfans.social.SendFriendRequestResponse.base:type_name -> topfans.common.BaseResponse - 33, // 1: topfans.social.GetFriendRequestsResponse.base:type_name -> topfans.common.BaseResponse - 0, // 2: topfans.social.GetFriendRequestsResponse.items:type_name -> topfans.social.FriendRequest - 33, // 3: topfans.social.HandleFriendRequestResponse.base:type_name -> topfans.common.BaseResponse - 33, // 4: topfans.social.GetFriendListResponse.base:type_name -> topfans.common.BaseResponse - 1, // 5: topfans.social.GetFriendListResponse.items:type_name -> topfans.social.Friendship - 33, // 6: topfans.social.DeleteFriendResponse.base:type_name -> topfans.common.BaseResponse - 33, // 7: topfans.social.SetFriendRemarkResponse.base:type_name -> topfans.common.BaseResponse - 33, // 8: topfans.social.CheckFriendshipResponse.base:type_name -> topfans.common.BaseResponse - 33, // 9: topfans.social.GetFriendCountResponse.base:type_name -> topfans.common.BaseResponse - 33, // 10: topfans.social.SearchUserForFriendResponse.base:type_name -> topfans.common.BaseResponse - 20, // 11: topfans.social.SearchUserForFriendResponse.user:type_name -> topfans.social.FanProfileSearchResult - 33, // 12: topfans.social.GetRandomUsersResponse.base:type_name -> topfans.common.BaseResponse - 21, // 13: topfans.social.GetRandomUsersResponse.users:type_name -> topfans.social.RandomUser - 33, // 14: topfans.social.GetUsersPagedResponse.base:type_name -> topfans.common.BaseResponse - 24, // 15: topfans.social.GetUsersPagedResponse.users:type_name -> topfans.social.PagedUser - 33, // 16: topfans.social.LikeAssetResponse.base:type_name -> topfans.common.BaseResponse - 33, // 17: topfans.social.UnlikeAssetResponse.base:type_name -> topfans.common.BaseResponse - 33, // 18: topfans.social.CheckAssetLikeResponse.base:type_name -> topfans.common.BaseResponse - 2, // 19: topfans.social.SocialService.SendFriendRequest:input_type -> topfans.social.SendFriendRequestRequest - 4, // 20: topfans.social.SocialService.GetFriendRequests:input_type -> topfans.social.GetFriendRequestsRequest - 6, // 21: topfans.social.SocialService.HandleFriendRequest:input_type -> topfans.social.HandleFriendRequestRequest - 8, // 22: topfans.social.SocialService.GetFriendList:input_type -> topfans.social.GetFriendListRequest - 10, // 23: topfans.social.SocialService.DeleteFriend:input_type -> topfans.social.DeleteFriendRequest - 12, // 24: topfans.social.SocialService.SetFriendRemark:input_type -> topfans.social.SetFriendRemarkRequest - 14, // 25: topfans.social.SocialService.CheckFriendship:input_type -> topfans.social.CheckFriendshipRequest - 16, // 26: topfans.social.SocialService.GetFriendCount:input_type -> topfans.social.GetFriendCountRequest - 18, // 27: topfans.social.SocialService.SearchUserForFriend:input_type -> topfans.social.SearchUserForFriendRequest - 22, // 28: topfans.social.SocialService.GetRandomUsers:input_type -> topfans.social.GetRandomUsersRequest - 25, // 29: topfans.social.SocialService.GetUsersPaged:input_type -> topfans.social.GetUsersPagedRequest - 27, // 30: topfans.social.SocialService.LikeAsset:input_type -> topfans.social.LikeAssetRequest - 29, // 31: topfans.social.SocialService.UnlikeAsset:input_type -> topfans.social.UnlikeAssetRequest - 31, // 32: topfans.social.SocialService.CheckAssetLike:input_type -> topfans.social.CheckAssetLikeRequest - 3, // 33: topfans.social.SocialService.SendFriendRequest:output_type -> topfans.social.SendFriendRequestResponse - 5, // 34: topfans.social.SocialService.GetFriendRequests:output_type -> topfans.social.GetFriendRequestsResponse - 7, // 35: topfans.social.SocialService.HandleFriendRequest:output_type -> topfans.social.HandleFriendRequestResponse - 9, // 36: topfans.social.SocialService.GetFriendList:output_type -> topfans.social.GetFriendListResponse - 11, // 37: topfans.social.SocialService.DeleteFriend:output_type -> topfans.social.DeleteFriendResponse - 13, // 38: topfans.social.SocialService.SetFriendRemark:output_type -> topfans.social.SetFriendRemarkResponse - 15, // 39: topfans.social.SocialService.CheckFriendship:output_type -> topfans.social.CheckFriendshipResponse - 17, // 40: topfans.social.SocialService.GetFriendCount:output_type -> topfans.social.GetFriendCountResponse - 19, // 41: topfans.social.SocialService.SearchUserForFriend:output_type -> topfans.social.SearchUserForFriendResponse - 23, // 42: topfans.social.SocialService.GetRandomUsers:output_type -> topfans.social.GetRandomUsersResponse - 26, // 43: topfans.social.SocialService.GetUsersPaged:output_type -> topfans.social.GetUsersPagedResponse - 28, // 44: topfans.social.SocialService.LikeAsset:output_type -> topfans.social.LikeAssetResponse - 30, // 45: topfans.social.SocialService.UnlikeAsset:output_type -> topfans.social.UnlikeAssetResponse - 32, // 46: topfans.social.SocialService.CheckAssetLike:output_type -> topfans.social.CheckAssetLikeResponse - 33, // [33:47] is the sub-list for method output_type - 19, // [19:33] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 20, // 1: topfans.social.SendFriendRequestResponse.matched_users:type_name -> topfans.social.FanProfileSearchResult + 33, // 2: topfans.social.GetFriendRequestsResponse.base:type_name -> topfans.common.BaseResponse + 0, // 3: topfans.social.GetFriendRequestsResponse.items:type_name -> topfans.social.FriendRequest + 33, // 4: topfans.social.HandleFriendRequestResponse.base:type_name -> topfans.common.BaseResponse + 33, // 5: topfans.social.GetFriendListResponse.base:type_name -> topfans.common.BaseResponse + 1, // 6: topfans.social.GetFriendListResponse.items:type_name -> topfans.social.Friendship + 33, // 7: topfans.social.DeleteFriendResponse.base:type_name -> topfans.common.BaseResponse + 33, // 8: topfans.social.SetFriendRemarkResponse.base:type_name -> topfans.common.BaseResponse + 33, // 9: topfans.social.CheckFriendshipResponse.base:type_name -> topfans.common.BaseResponse + 33, // 10: topfans.social.GetFriendCountResponse.base:type_name -> topfans.common.BaseResponse + 33, // 11: topfans.social.SearchUserForFriendResponse.base:type_name -> topfans.common.BaseResponse + 20, // 12: topfans.social.SearchUserForFriendResponse.user:type_name -> topfans.social.FanProfileSearchResult + 33, // 13: topfans.social.GetRandomUsersResponse.base:type_name -> topfans.common.BaseResponse + 21, // 14: topfans.social.GetRandomUsersResponse.users:type_name -> topfans.social.RandomUser + 33, // 15: topfans.social.GetUsersPagedResponse.base:type_name -> topfans.common.BaseResponse + 24, // 16: topfans.social.GetUsersPagedResponse.users:type_name -> topfans.social.PagedUser + 33, // 17: topfans.social.LikeAssetResponse.base:type_name -> topfans.common.BaseResponse + 33, // 18: topfans.social.UnlikeAssetResponse.base:type_name -> topfans.common.BaseResponse + 33, // 19: topfans.social.CheckAssetLikeResponse.base:type_name -> topfans.common.BaseResponse + 2, // 20: topfans.social.SocialService.SendFriendRequest:input_type -> topfans.social.SendFriendRequestRequest + 4, // 21: topfans.social.SocialService.GetFriendRequests:input_type -> topfans.social.GetFriendRequestsRequest + 6, // 22: topfans.social.SocialService.HandleFriendRequest:input_type -> topfans.social.HandleFriendRequestRequest + 8, // 23: topfans.social.SocialService.GetFriendList:input_type -> topfans.social.GetFriendListRequest + 10, // 24: topfans.social.SocialService.DeleteFriend:input_type -> topfans.social.DeleteFriendRequest + 12, // 25: topfans.social.SocialService.SetFriendRemark:input_type -> topfans.social.SetFriendRemarkRequest + 14, // 26: topfans.social.SocialService.CheckFriendship:input_type -> topfans.social.CheckFriendshipRequest + 16, // 27: topfans.social.SocialService.GetFriendCount:input_type -> topfans.social.GetFriendCountRequest + 18, // 28: topfans.social.SocialService.SearchUserForFriend:input_type -> topfans.social.SearchUserForFriendRequest + 22, // 29: topfans.social.SocialService.GetRandomUsers:input_type -> topfans.social.GetRandomUsersRequest + 25, // 30: topfans.social.SocialService.GetUsersPaged:input_type -> topfans.social.GetUsersPagedRequest + 27, // 31: topfans.social.SocialService.LikeAsset:input_type -> topfans.social.LikeAssetRequest + 29, // 32: topfans.social.SocialService.UnlikeAsset:input_type -> topfans.social.UnlikeAssetRequest + 31, // 33: topfans.social.SocialService.CheckAssetLike:input_type -> topfans.social.CheckAssetLikeRequest + 3, // 34: topfans.social.SocialService.SendFriendRequest:output_type -> topfans.social.SendFriendRequestResponse + 5, // 35: topfans.social.SocialService.GetFriendRequests:output_type -> topfans.social.GetFriendRequestsResponse + 7, // 36: topfans.social.SocialService.HandleFriendRequest:output_type -> topfans.social.HandleFriendRequestResponse + 9, // 37: topfans.social.SocialService.GetFriendList:output_type -> topfans.social.GetFriendListResponse + 11, // 38: topfans.social.SocialService.DeleteFriend:output_type -> topfans.social.DeleteFriendResponse + 13, // 39: topfans.social.SocialService.SetFriendRemark:output_type -> topfans.social.SetFriendRemarkResponse + 15, // 40: topfans.social.SocialService.CheckFriendship:output_type -> topfans.social.CheckFriendshipResponse + 17, // 41: topfans.social.SocialService.GetFriendCount:output_type -> topfans.social.GetFriendCountResponse + 19, // 42: topfans.social.SocialService.SearchUserForFriend:output_type -> topfans.social.SearchUserForFriendResponse + 23, // 43: topfans.social.SocialService.GetRandomUsers:output_type -> topfans.social.GetRandomUsersResponse + 26, // 44: topfans.social.SocialService.GetUsersPaged:output_type -> topfans.social.GetUsersPagedResponse + 28, // 45: topfans.social.SocialService.LikeAsset:output_type -> topfans.social.LikeAssetResponse + 30, // 46: topfans.social.SocialService.UnlikeAsset:output_type -> topfans.social.UnlikeAssetResponse + 32, // 47: topfans.social.SocialService.CheckAssetLike:output_type -> topfans.social.CheckAssetLikeResponse + 34, // [34:48] is the sub-list for method output_type + 20, // [20:34] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_social_proto_init() } diff --git a/backend/pkg/validator/validator.go b/backend/pkg/validator/validator.go index b5ed550..127a1bb 100644 --- a/backend/pkg/validator/validator.go +++ b/backend/pkg/validator/validator.go @@ -3,6 +3,8 @@ package validator import ( "regexp" "strings" + + "github.com/topfans/backend/pkg/filter" ) const ( @@ -11,14 +13,16 @@ const ( // MaxPasswordLength 最大密码长度 MaxPasswordLength = 50 // MinNicknameLength 最小昵称长度 - MinNicknameLength = 1 + MinNicknameLength = 2 // MaxNicknameLength 最大昵称长度 - MaxNicknameLength = 50 + MaxNicknameLength = 20 ) var ( // 中国手机号正则表达式 mobileRegex = regexp.MustCompile(`^1[3-9]\d{9}$`) + // 昵称正则:中文、英文、数字、下划线,首字符不能是数字或下划线 + nicknameRegex = regexp.MustCompile(`^[一-龥a-zA-Z][一-龥a-zA-Z0-9_]*$`) ) // ValidateMobile 验证手机号格式 @@ -53,14 +57,26 @@ func ValidateNickname(nickname string) (bool, string) { } nickname = strings.TrimSpace(nickname) - if len(nickname) < MinNicknameLength { + // 使用 rune 计数,支持中文等多字节字符 + runeCount := len([]rune(nickname)) + if runeCount < MinNicknameLength { return false, "nickname too short" } - if len(nickname) > MaxNicknameLength { + if runeCount > MaxNicknameLength { return false, "nickname too long" } + // 格式校验:中文、英文、数字、下划线,首字符不能是数字或下划线 + if !nicknameRegex.MatchString(nickname) { + return false, "invalid nickname format" + } + + // 敏感词校验 + if filter.GetDefault().Contains(nickname) { + return false, "nickname contains sensitive words" + } + return true, "" } diff --git a/backend/proto/social.proto b/backend/proto/social.proto index d6dba59..7120ef0 100644 --- a/backend/proto/social.proto +++ b/backend/proto/social.proto @@ -54,6 +54,8 @@ message Friendship { message SendFriendRequestRequest { int64 friend_user_id = 1; // 好友用户ID(要添加的用户ID) string message = 2; // 请求附带消息(可选) + string nickname = 3; // 昵称(用于按昵称搜索,与 friend_user_id 二选一) + bool search_mode = 4; // 搜索模式(true=仅搜索返回匹配用户,false=正常发送请求) } message SendFriendRequestResponse { @@ -62,6 +64,7 @@ message SendFriendRequestResponse { string status = 3; // 请求状态 int64 created_at = 4; // 创建时间 int64 expires_at = 5; // 过期时间 + repeated FanProfileSearchResult matched_users = 6; // 匹配的用户列表(仅 search_mode=true 时返回) } // 获取好友请求列表 diff --git a/backend/services/socialService/repository/social_repository.go b/backend/services/socialService/repository/social_repository.go index 493cf32..c200c18 100644 --- a/backend/services/socialService/repository/social_repository.go +++ b/backend/services/socialService/repository/social_repository.go @@ -95,6 +95,12 @@ type SocialRepository interface { // GetFanProfileByUserIDAndStarID 根据userID和starID获取粉丝档案 GetFanProfileByUserIDAndStarID(userID, starID int64) (*models.FanProfile, error) + + // GetFanProfilesByNickname 根据昵称模糊搜索粉丝档案(同一明星下) + // starID: 明星ID + // nickname: 昵称关键词 + // limit: 返回数量限制 + GetFanProfilesByNickname(starID int64, nickname string, limit int) ([]*models.FanProfile, error) } // RandomUserInfo 随机用户信息 @@ -498,3 +504,25 @@ func (r *socialRepositoryImpl) GetFanProfileByUserIDAndStarID(userID, starID int } return &profile, nil } + +// GetFanProfilesByNickname 根据昵称模糊搜索粉丝档案(同一明星下) +func (r *socialRepositoryImpl) GetFanProfilesByNickname(starID int64, nickname string, limit int) ([]*models.FanProfile, error) { + if limit <= 0 { + limit = 10 // 默认返回10条 + } + if limit > 50 { + limit = 50 // 最大限制50条 + } + + var profiles []*models.FanProfile + err := r.db.Where("star_id = ? AND is_active = ? AND nickname LIKE ?", starID, true, "%"+nickname+"%"). + Order("level DESC, updated_at DESC"). + Limit(limit). + Find(&profiles).Error + + if err != nil { + return nil, err + } + + return profiles, nil +} diff --git a/frontend/pages/components/FriendsContent.vue b/frontend/pages/components/FriendsContent.vue index 5cf4388..bed5e03 100644 --- a/frontend/pages/components/FriendsContent.vue +++ b/frontend/pages/components/FriendsContent.vue @@ -122,18 +122,36 @@ + + + + UID搜索 + + + 昵称搜索 + + + - - @@ -146,37 +164,77 @@ 搜索中... - + {{ searchError }} - - - - - - - - {{ searchResult.nickname }} - UID: {{ searchResult.user_id }} - - - - + + + + + + + + {{ searchResult.nickname }} + UID: {{ searchResult.user_id }} + + + + - + + + + 找到 {{ nicknameSearchResults.length }} 个匹配用户 + + + + + + {{ user.nickname }} + UID: {{ user.user_id }} + + {{ getRelationStatusText(user.relationship_status) }} + + + + + 无法添加 + + + + @@ -359,9 +417,15 @@ // 搜索UID const searchUid = ref(''); - + + // 搜索模式:uid / nickname + const searchMode = ref('uid'); + + // 昵称搜索结果列表 + const nicknameSearchResults = ref([]); + // 搜索相关 - const searchResult = ref(null); // 搜索结果用户 + const searchResult = ref(null); // 搜索结果用户(UID模式) const searchError = ref(''); // 搜索错误信息 const isSearching = ref(false); // 搜索中状态 let searchTimer = null; // 搜索防抖定时器 @@ -520,6 +584,15 @@ // 清空搜索框 const clearSearch = () => { searchUid.value = ''; + nicknameSearchResults.value = []; + searchResult.value = null; + searchError.value = ''; + }; + + // 切换搜索模式 + const switchSearchMode = (mode) => { + searchMode.value = mode; + clearSearch(); }; // 监听搜索框变化,实现自动搜索 @@ -528,32 +601,58 @@ if (searchTimer) { clearTimeout(searchTimer); } - + // 清空之前的结果 searchResult.value = null; + nicknameSearchResults.value = []; searchError.value = ''; - + // 如果搜索框为空,直接返回 if (!newValue || newValue.trim() === '') { return; } - + // 设置新的定时器,500ms 后执行搜索 searchTimer = setTimeout(async () => { try { isSearching.value = true; - const res = await searchUserApi(newValue.trim()); - - if (res.code === 200 && res.data) { - // 解析头像URL - const realAvatarUrl = res.data.avatar_url - ? await getFriendAvatarRealUrl(res.data.avatar_url) - : ''; - searchResult.value = { - ...res.data, - avatar_url: realAvatarUrl - }; - searchError.value = ''; + + if (searchMode.value === 'uid') { + // UID 搜索模式 + const res = await searchUserApi(newValue.trim()); + + if (res.code === 200 && res.data) { + // 解析头像URL + const realAvatarUrl = res.data.avatar_url + ? await getFriendAvatarRealUrl(res.data.avatar_url) + : ''; + searchResult.value = { + ...res.data, + avatar_url: realAvatarUrl + }; + searchError.value = ''; + } + } else { + // 昵称搜索模式 + const res = await sendFriendRequestApi(null, newValue.trim(), true); + + if (res.code === 200 && res.data) { + // 解析每个用户的头像URL + const users = res.data.users || []; + const processedUsers = await Promise.all( + users.map(async (user) => { + const realAvatarUrl = user.avatar + ? await getFriendAvatarRealUrl(user.avatar) + : ''; + return { + ...user, + avatar_url: realAvatarUrl + }; + }) + ); + nicknameSearchResults.value = processedUsers; + searchError.value = ''; + } } } catch (error) { // 处理 404 错误 @@ -656,6 +755,18 @@ }; return statusMap[status] || status; }; + + // 获取关系状态文本 + const getRelationStatusText = (status) => { + const statusMap = { + 'stranger': '陌生人', + 'friend': '已是好友', + 'pending_sent': '已发送请求', + 'pending_received': '待接受', + 'rejected': '已被拒绝' + }; + return statusMap[status] || status; + }; // 加载收到的好友请求 const loadReceivedRequests = async (page) => { @@ -1156,6 +1267,62 @@ transition: transform 0.2s ease; } +/* 搜索模式切换 */ +.search-mode-switch { + width: 100%; + display: flex; + gap: 20rpx; + margin-bottom: 20rpx; +} + +.mode-btn { + flex: 1; + padding: 20rpx; + border-radius: 20rpx; + background: rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +.mode-btn.active { + background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%); +} + +.mode-btn-text { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.8); + font-weight: bold; +} + +.mode-btn.active .mode-btn-text { + color: #ffffff; +} + +/* 昵称搜索结果 */ +.nickname-results { + width: 100%; +} + +.results-count { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 20rpx; + display: block; +} + +.user-relation-status { + font-size: 24rpx; + color: #666; + margin-top: 8rpx; +} + +.cannot-add-hint { + font-size: 24rpx; + color: #999; +} + .clear-btn:active { transform: scale(0.9); } diff --git a/frontend/pages/profile/profile.vue b/frontend/pages/profile/profile.vue index a95ae31..b9c07e7 100644 --- a/frontend/pages/profile/profile.vue +++ b/frontend/pages/profile/profile.vue @@ -264,6 +264,7 @@ import { onReady } from "@dcloudio/uni-app"; import Header from '../components/Header.vue'; import Avatar from '../components/Avatar.vue'; import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi, getOssPresignedUrlApi } from '@/utils/api'; +import { validateNickname } from '@/utils/validator.js'; import { clearAvatarCache, downloadAndCacheAvatar } from '@/utils/avatarCache'; import GuideListModal from '@/components/GuideListModal.vue'; import GuideOverlay from '@/components/GuideOverlay.vue'; @@ -479,17 +480,10 @@ const confirmChangeNickname = async () => { const trimmedNickname = newNickname.value.trim(); // 验证昵称 - if (!trimmedNickname) { + const validation = validateNickname(trimmedNickname); + if (!validation.valid) { uni.showToast({ - title: '请输入昵称', - icon: 'none' - }); - return; - } - - if (trimmedNickname.length > 20) { - uni.showToast({ - title: '昵称不能超过20个字符', + title: validation.message, icon: 'none' }); return; @@ -693,17 +687,10 @@ const confirmAddIdentity = async () => { // 验证昵称 const trimmedNickname = newIdentityNickname.value.trim(); - if (!trimmedNickname) { + const validation = validateNickname(trimmedNickname); + if (!validation.valid) { uni.showToast({ - title: '请输入昵称', - icon: 'none' - }); - return; - } - - if (trimmedNickname.length > 20) { - uni.showToast({ - title: '昵称不能超过20个字符', + title: validation.message, icon: 'none' }); return; diff --git a/frontend/pages/profile/setNickname.vue b/frontend/pages/profile/setNickname.vue index dff2b43..f457b2e 100644 --- a/frontend/pages/profile/setNickname.vue +++ b/frontend/pages/profile/setNickname.vue @@ -53,6 +53,7 @@ import { ref } from 'vue'; import Avatar from '../components/Avatar.vue'; import { checkNicknameApi } from '@/utils/api.js'; +import { validateNickname } from '@/utils/validator.js'; // 响应式数据 const nickname = ref(''); @@ -86,24 +87,17 @@ const goBack = () => { // 下一步 const handleNext = async () => { // 验证昵称 - if (!nickname.value || !nickname.value.trim()) { - errorMessage.value = '请输入昵称'; + const trimmedNickname = nickname.value.trim(); + const validation = validateNickname(trimmedNickname); + if (!validation.valid) { + errorMessage.value = validation.message; uni.showToast({ - title: '请输入昵称', + title: validation.message, icon: 'none' }); return; } - - if (nickname.value.trim().length > 20) { - errorMessage.value = '昵称不能超过20个字符'; - uni.showToast({ - title: '昵称不能超过20个字符', - icon: 'none' - }); - return; - } - + errorMessage.value = ''; // 防止重复点击 diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 2e495a5..92fd7cb 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -239,13 +239,21 @@ export function getSentFriendRequestsApi(page = 1, pageSize = 10) { } // 发送好友请求(添加好友) -export function sendFriendRequestApi(friendUserId) { +// friendUserId: 好友用户ID(可选) +// nickname: 昵称(可选,与 friendUserId 二选一) +// searchMode: 是否为搜索模式(true=仅搜索返回匹配用户,false=正常发送请求) +export function sendFriendRequestApi(friendUserId = null, nickname = '', searchMode = true) { + const data = {} + if (searchMode && nickname) { + data.nickname = nickname + data.search_mode = true + } else if (friendUserId) { + data.friend_user_id = friendUserId + } return request({ url: '/api/v1/social/friend-requests', method: 'POST', - data: { - friend_user_id: friendUserId - } + data }) } diff --git a/frontend/utils/validator.js b/frontend/utils/validator.js index c2c03dd..c094c41 100644 --- a/frontend/utils/validator.js +++ b/frontend/utils/validator.js @@ -39,3 +39,32 @@ export function validatePassword(password) { } } +// 验证昵称(2-20字符,中文、英文、数字、下划线,首字符不能是数字或下划线) +export function validateNickname(nickname) { + if (!nickname) { + return { + valid: false, + message: '请输入昵称' + } + } + // 使用 [...nickname].length 计算字符数(支持中文) + const len = [...nickname].length + if (len < 2 || len > 20) { + return { + valid: false, + message: '昵称长度为2-20字符' + } + } + // 格式校验:中文、英文、数字、下划线,首字符不能是数字或下划线 + if (!/^[一-龥a-zA-Z][一-龥a-zA-Z0-9_]*$/.test(nickname)) { + return { + valid: false, + message: '昵称只能使用中文、英文、数字、下划线,首字符不能是数字或下划线' + } + } + return { + valid: true, + message: '' + } +} +