feat: 新增昵称增加好友和注册用户昵称时的昵称判断

This commit is contained in:
zerosaturation 2026-04-21 16:08:55 +08:00
parent 55104d5aef
commit f426b84c0a
11 changed files with 609 additions and 158 deletions

View File

@ -36,14 +36,14 @@ func NewSocialController(dubboClient *client.Client) (*SocialController, error)
}, nil }, nil
} }
// SendFriendRequest 发送好友请求 // SendFriendRequest 发送好友请求(支持按昵称搜索)
// @Summary 发送好友请求 // @Summary 发送好友请求
// @Description 向指定用户发送好友请求 // @Description 向指定用户发送好友请求,或按昵称搜索匹配用户
// @Tags social // @Tags social
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BearerAuth // @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 // @Success 200 {object} response.Response
// @Router /api/v1/social/friend-requests [post] // @Router /api/v1/social/friend-requests [post]
func (ctrl *SocialController) SendFriendRequest(c *gin.Context) { func (ctrl *SocialController) SendFriendRequest(c *gin.Context) {
@ -62,8 +62,10 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) {
// 解析请求参数 // 解析请求参数
var req struct { var req struct {
FriendUserID int64 `json:"friend_user_id" binding:"required"` FriendUserID int64 `json:"friend_user_id"`
Message string `json:"message"` Message string `json:"message"`
Nickname string `json:"nickname"`
SearchMode bool `json:"search_mode"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -71,6 +73,16 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) {
return 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 // 设置上下文和 Dubbo attachments
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@ -81,15 +93,20 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) {
}) })
// 调用 RPC // 调用 RPC
resp, err := ctrl.socialService.SendFriendRequest(ctx, &pbSocial.SendFriendRequestRequest{ rpcReq := &pbSocial.SendFriendRequestRequest{
FriendUserId: req.FriendUserID, FriendUserId: req.FriendUserID,
Message: req.Message, Message: req.Message,
}) Nickname: req.Nickname,
SearchMode: req.SearchMode,
}
resp, err := ctrl.socialService.SendFriendRequest(ctx, rpcReq)
if err != nil { if err != nil {
logger.Logger.Error("SendFriendRequest RPC failed", logger.Logger.Error("SendFriendRequest RPC failed",
zap.Int64("user_id", userID.(int64)), zap.Int64("user_id", userID.(int64)),
zap.Int64("friend_user_id", req.FriendUserID), zap.Int64("friend_user_id", req.FriendUserID),
zap.String("nickname", req.Nickname),
zap.Bool("search_mode", req.SearchMode),
zap.Error(err), zap.Error(err),
) )
response.Error(c, http.StatusInternalServerError, "服务调用失败") response.Error(c, http.StatusInternalServerError, "服务调用失败")
@ -102,6 +119,28 @@ func (ctrl *SocialController) SendFriendRequest(c *gin.Context) {
return 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{ response.Success(c, gin.H{
"request_id": resp.RequestId, "request_id": resp.RequestId,
"status": resp.Status, "status": resp.Status,

View File

@ -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{
// 政治敏感词(示例,实际需根据需求配置)
"台独",
"藏独",
"疆独",
"分裂",
"颠覆",
// 色情低俗(示例)
"色情",
"黄色",
"赌博",
"诈骗",
// 其他违禁词(示例)
"暴力",
"恐怖",
"毒品",
"枪支",
}

View File

@ -320,6 +320,8 @@ type SendFriendRequestRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` 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 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"` // 请求附带消息(可选) 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -368,6 +370,20 @@ func (x *SendFriendRequestRequest) GetMessage() string {
return "" 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 { type SendFriendRequestResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
@ -375,6 +391,7 @@ type SendFriendRequestResponse struct {
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // 请求状态 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"` // 创建时间 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"` // 过期时间 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -444,6 +461,13 @@ func (x *SendFriendRequestResponse) GetExpiresAt() int64 {
return 0 return 0
} }
func (x *SendFriendRequestResponse) GetMatchedUsers() []*FanProfileSearchResult {
if x != nil {
return x.MatchedUsers
}
return nil
}
// 获取好友请求列表 // 获取好友请求列表
type GetFriendRequestsRequest struct { type GetFriendRequestsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -2165,10 +2189,13 @@ const file_social_proto_rawDesc = "" +
"\rfriend_avatar\x18\n" + "\rfriend_avatar\x18\n" +
" \x01(\tR\ffriendAvatar\x12(\n" + " \x01(\tR\ffriendAvatar\x12(\n" +
"\x10friend_fan_level\x18\v \x01(\x05R\x0efriendFanLevel\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" + "\x18SendFriendRequestRequest\x12$\n" +
"\x0efriend_user_id\x18\x01 \x01(\x03R\ffriendUserId\x12\x18\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" + "\x19SendFriendRequestResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x1d\n" + "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x1d\n" +
"\n" + "\n" +
@ -2177,7 +2204,8 @@ const file_social_proto_rawDesc = "" +
"\n" + "\n" +
"created_at\x18\x04 \x01(\x03R\tcreatedAt\x12\x1d\n" + "created_at\x18\x04 \x01(\x03R\tcreatedAt\x12\x1d\n" +
"\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" + "\x18GetFriendRequestsRequest\x12\x12\n" +
"\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x12\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{ var file_social_proto_depIdxs = []int32{
33, // 0: topfans.social.SendFriendRequestResponse.base:type_name -> topfans.common.BaseResponse 33, // 0: topfans.social.SendFriendRequestResponse.base:type_name -> topfans.common.BaseResponse
33, // 1: topfans.social.GetFriendRequestsResponse.base:type_name -> topfans.common.BaseResponse 20, // 1: topfans.social.SendFriendRequestResponse.matched_users:type_name -> topfans.social.FanProfileSearchResult
0, // 2: topfans.social.GetFriendRequestsResponse.items:type_name -> topfans.social.FriendRequest 33, // 2: topfans.social.GetFriendRequestsResponse.base:type_name -> topfans.common.BaseResponse
33, // 3: topfans.social.HandleFriendRequestResponse.base:type_name -> topfans.common.BaseResponse 0, // 3: topfans.social.GetFriendRequestsResponse.items:type_name -> topfans.social.FriendRequest
33, // 4: topfans.social.GetFriendListResponse.base:type_name -> topfans.common.BaseResponse 33, // 4: topfans.social.HandleFriendRequestResponse.base:type_name -> topfans.common.BaseResponse
1, // 5: topfans.social.GetFriendListResponse.items:type_name -> topfans.social.Friendship 33, // 5: topfans.social.GetFriendListResponse.base:type_name -> topfans.common.BaseResponse
33, // 6: topfans.social.DeleteFriendResponse.base:type_name -> topfans.common.BaseResponse 1, // 6: topfans.social.GetFriendListResponse.items:type_name -> topfans.social.Friendship
33, // 7: topfans.social.SetFriendRemarkResponse.base:type_name -> topfans.common.BaseResponse 33, // 7: topfans.social.DeleteFriendResponse.base:type_name -> topfans.common.BaseResponse
33, // 8: topfans.social.CheckFriendshipResponse.base:type_name -> topfans.common.BaseResponse 33, // 8: topfans.social.SetFriendRemarkResponse.base:type_name -> topfans.common.BaseResponse
33, // 9: topfans.social.GetFriendCountResponse.base:type_name -> topfans.common.BaseResponse 33, // 9: topfans.social.CheckFriendshipResponse.base:type_name -> topfans.common.BaseResponse
33, // 10: topfans.social.SearchUserForFriendResponse.base:type_name -> topfans.common.BaseResponse 33, // 10: topfans.social.GetFriendCountResponse.base:type_name -> topfans.common.BaseResponse
20, // 11: topfans.social.SearchUserForFriendResponse.user:type_name -> topfans.social.FanProfileSearchResult 33, // 11: topfans.social.SearchUserForFriendResponse.base:type_name -> topfans.common.BaseResponse
33, // 12: topfans.social.GetRandomUsersResponse.base:type_name -> topfans.common.BaseResponse 20, // 12: topfans.social.SearchUserForFriendResponse.user:type_name -> topfans.social.FanProfileSearchResult
21, // 13: topfans.social.GetRandomUsersResponse.users:type_name -> topfans.social.RandomUser 33, // 13: topfans.social.GetRandomUsersResponse.base:type_name -> topfans.common.BaseResponse
33, // 14: topfans.social.GetUsersPagedResponse.base:type_name -> topfans.common.BaseResponse 21, // 14: topfans.social.GetRandomUsersResponse.users:type_name -> topfans.social.RandomUser
24, // 15: topfans.social.GetUsersPagedResponse.users:type_name -> topfans.social.PagedUser 33, // 15: topfans.social.GetUsersPagedResponse.base:type_name -> topfans.common.BaseResponse
33, // 16: topfans.social.LikeAssetResponse.base:type_name -> topfans.common.BaseResponse 24, // 16: topfans.social.GetUsersPagedResponse.users:type_name -> topfans.social.PagedUser
33, // 17: topfans.social.UnlikeAssetResponse.base:type_name -> topfans.common.BaseResponse 33, // 17: topfans.social.LikeAssetResponse.base:type_name -> topfans.common.BaseResponse
33, // 18: topfans.social.CheckAssetLikeResponse.base:type_name -> topfans.common.BaseResponse 33, // 18: topfans.social.UnlikeAssetResponse.base:type_name -> topfans.common.BaseResponse
2, // 19: topfans.social.SocialService.SendFriendRequest:input_type -> topfans.social.SendFriendRequestRequest 33, // 19: topfans.social.CheckAssetLikeResponse.base:type_name -> topfans.common.BaseResponse
4, // 20: topfans.social.SocialService.GetFriendRequests:input_type -> topfans.social.GetFriendRequestsRequest 2, // 20: topfans.social.SocialService.SendFriendRequest:input_type -> topfans.social.SendFriendRequestRequest
6, // 21: topfans.social.SocialService.HandleFriendRequest:input_type -> topfans.social.HandleFriendRequestRequest 4, // 21: topfans.social.SocialService.GetFriendRequests:input_type -> topfans.social.GetFriendRequestsRequest
8, // 22: topfans.social.SocialService.GetFriendList:input_type -> topfans.social.GetFriendListRequest 6, // 22: topfans.social.SocialService.HandleFriendRequest:input_type -> topfans.social.HandleFriendRequestRequest
10, // 23: topfans.social.SocialService.DeleteFriend:input_type -> topfans.social.DeleteFriendRequest 8, // 23: topfans.social.SocialService.GetFriendList:input_type -> topfans.social.GetFriendListRequest
12, // 24: topfans.social.SocialService.SetFriendRemark:input_type -> topfans.social.SetFriendRemarkRequest 10, // 24: topfans.social.SocialService.DeleteFriend:input_type -> topfans.social.DeleteFriendRequest
14, // 25: topfans.social.SocialService.CheckFriendship:input_type -> topfans.social.CheckFriendshipRequest 12, // 25: topfans.social.SocialService.SetFriendRemark:input_type -> topfans.social.SetFriendRemarkRequest
16, // 26: topfans.social.SocialService.GetFriendCount:input_type -> topfans.social.GetFriendCountRequest 14, // 26: topfans.social.SocialService.CheckFriendship:input_type -> topfans.social.CheckFriendshipRequest
18, // 27: topfans.social.SocialService.SearchUserForFriend:input_type -> topfans.social.SearchUserForFriendRequest 16, // 27: topfans.social.SocialService.GetFriendCount:input_type -> topfans.social.GetFriendCountRequest
22, // 28: topfans.social.SocialService.GetRandomUsers:input_type -> topfans.social.GetRandomUsersRequest 18, // 28: topfans.social.SocialService.SearchUserForFriend:input_type -> topfans.social.SearchUserForFriendRequest
25, // 29: topfans.social.SocialService.GetUsersPaged:input_type -> topfans.social.GetUsersPagedRequest 22, // 29: topfans.social.SocialService.GetRandomUsers:input_type -> topfans.social.GetRandomUsersRequest
27, // 30: topfans.social.SocialService.LikeAsset:input_type -> topfans.social.LikeAssetRequest 25, // 30: topfans.social.SocialService.GetUsersPaged:input_type -> topfans.social.GetUsersPagedRequest
29, // 31: topfans.social.SocialService.UnlikeAsset:input_type -> topfans.social.UnlikeAssetRequest 27, // 31: topfans.social.SocialService.LikeAsset:input_type -> topfans.social.LikeAssetRequest
31, // 32: topfans.social.SocialService.CheckAssetLike:input_type -> topfans.social.CheckAssetLikeRequest 29, // 32: topfans.social.SocialService.UnlikeAsset:input_type -> topfans.social.UnlikeAssetRequest
3, // 33: topfans.social.SocialService.SendFriendRequest:output_type -> topfans.social.SendFriendRequestResponse 31, // 33: topfans.social.SocialService.CheckAssetLike:input_type -> topfans.social.CheckAssetLikeRequest
5, // 34: topfans.social.SocialService.GetFriendRequests:output_type -> topfans.social.GetFriendRequestsResponse 3, // 34: topfans.social.SocialService.SendFriendRequest:output_type -> topfans.social.SendFriendRequestResponse
7, // 35: topfans.social.SocialService.HandleFriendRequest:output_type -> topfans.social.HandleFriendRequestResponse 5, // 35: topfans.social.SocialService.GetFriendRequests:output_type -> topfans.social.GetFriendRequestsResponse
9, // 36: topfans.social.SocialService.GetFriendList:output_type -> topfans.social.GetFriendListResponse 7, // 36: topfans.social.SocialService.HandleFriendRequest:output_type -> topfans.social.HandleFriendRequestResponse
11, // 37: topfans.social.SocialService.DeleteFriend:output_type -> topfans.social.DeleteFriendResponse 9, // 37: topfans.social.SocialService.GetFriendList:output_type -> topfans.social.GetFriendListResponse
13, // 38: topfans.social.SocialService.SetFriendRemark:output_type -> topfans.social.SetFriendRemarkResponse 11, // 38: topfans.social.SocialService.DeleteFriend:output_type -> topfans.social.DeleteFriendResponse
15, // 39: topfans.social.SocialService.CheckFriendship:output_type -> topfans.social.CheckFriendshipResponse 13, // 39: topfans.social.SocialService.SetFriendRemark:output_type -> topfans.social.SetFriendRemarkResponse
17, // 40: topfans.social.SocialService.GetFriendCount:output_type -> topfans.social.GetFriendCountResponse 15, // 40: topfans.social.SocialService.CheckFriendship:output_type -> topfans.social.CheckFriendshipResponse
19, // 41: topfans.social.SocialService.SearchUserForFriend:output_type -> topfans.social.SearchUserForFriendResponse 17, // 41: topfans.social.SocialService.GetFriendCount:output_type -> topfans.social.GetFriendCountResponse
23, // 42: topfans.social.SocialService.GetRandomUsers:output_type -> topfans.social.GetRandomUsersResponse 19, // 42: topfans.social.SocialService.SearchUserForFriend:output_type -> topfans.social.SearchUserForFriendResponse
26, // 43: topfans.social.SocialService.GetUsersPaged:output_type -> topfans.social.GetUsersPagedResponse 23, // 43: topfans.social.SocialService.GetRandomUsers:output_type -> topfans.social.GetRandomUsersResponse
28, // 44: topfans.social.SocialService.LikeAsset:output_type -> topfans.social.LikeAssetResponse 26, // 44: topfans.social.SocialService.GetUsersPaged:output_type -> topfans.social.GetUsersPagedResponse
30, // 45: topfans.social.SocialService.UnlikeAsset:output_type -> topfans.social.UnlikeAssetResponse 28, // 45: topfans.social.SocialService.LikeAsset:output_type -> topfans.social.LikeAssetResponse
32, // 46: topfans.social.SocialService.CheckAssetLike:output_type -> topfans.social.CheckAssetLikeResponse 30, // 46: topfans.social.SocialService.UnlikeAsset:output_type -> topfans.social.UnlikeAssetResponse
33, // [33:47] is the sub-list for method output_type 32, // 47: topfans.social.SocialService.CheckAssetLike:output_type -> topfans.social.CheckAssetLikeResponse
19, // [19:33] is the sub-list for method input_type 34, // [34:48] is the sub-list for method output_type
19, // [19:19] is the sub-list for extension type_name 20, // [20:34] is the sub-list for method input_type
19, // [19:19] is the sub-list for extension extendee 20, // [20:20] is the sub-list for extension type_name
0, // [0:19] is the sub-list for field 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() } func init() { file_social_proto_init() }

View File

@ -3,6 +3,8 @@ package validator
import ( import (
"regexp" "regexp"
"strings" "strings"
"github.com/topfans/backend/pkg/filter"
) )
const ( const (
@ -11,14 +13,16 @@ const (
// MaxPasswordLength 最大密码长度 // MaxPasswordLength 最大密码长度
MaxPasswordLength = 50 MaxPasswordLength = 50
// MinNicknameLength 最小昵称长度 // MinNicknameLength 最小昵称长度
MinNicknameLength = 1 MinNicknameLength = 2
// MaxNicknameLength 最大昵称长度 // MaxNicknameLength 最大昵称长度
MaxNicknameLength = 50 MaxNicknameLength = 20
) )
var ( var (
// 中国手机号正则表达式 // 中国手机号正则表达式
mobileRegex = regexp.MustCompile(`^1[3-9]\d{9}$`) mobileRegex = regexp.MustCompile(`^1[3-9]\d{9}$`)
// 昵称正则:中文、英文、数字、下划线,首字符不能是数字或下划线
nicknameRegex = regexp.MustCompile(`^[一-龥a-zA-Z][一-龥a-zA-Z0-9_]*$`)
) )
// ValidateMobile 验证手机号格式 // ValidateMobile 验证手机号格式
@ -53,14 +57,26 @@ func ValidateNickname(nickname string) (bool, string) {
} }
nickname = strings.TrimSpace(nickname) nickname = strings.TrimSpace(nickname)
if len(nickname) < MinNicknameLength { // 使用 rune 计数,支持中文等多字节字符
runeCount := len([]rune(nickname))
if runeCount < MinNicknameLength {
return false, "nickname too short" return false, "nickname too short"
} }
if len(nickname) > MaxNicknameLength { if runeCount > MaxNicknameLength {
return false, "nickname too long" 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, "" return true, ""
} }

View File

@ -54,6 +54,8 @@ message Friendship {
message SendFriendRequestRequest { message SendFriendRequestRequest {
int64 friend_user_id = 1; // IDID int64 friend_user_id = 1; // IDID
string message = 2; // string message = 2; //
string nickname = 3; // friend_user_id
bool search_mode = 4; // true=false=
} }
message SendFriendRequestResponse { message SendFriendRequestResponse {
@ -62,6 +64,7 @@ message SendFriendRequestResponse {
string status = 3; // string status = 3; //
int64 created_at = 4; // int64 created_at = 4; //
int64 expires_at = 5; // int64 expires_at = 5; //
repeated FanProfileSearchResult matched_users = 6; // search_mode=true
} }
// //

View File

@ -95,6 +95,12 @@ type SocialRepository interface {
// GetFanProfileByUserIDAndStarID 根据userID和starID获取粉丝档案 // GetFanProfileByUserIDAndStarID 根据userID和starID获取粉丝档案
GetFanProfileByUserIDAndStarID(userID, starID int64) (*models.FanProfile, error) GetFanProfileByUserIDAndStarID(userID, starID int64) (*models.FanProfile, error)
// GetFanProfilesByNickname 根据昵称模糊搜索粉丝档案(同一明星下)
// starID: 明星ID
// nickname: 昵称关键词
// limit: 返回数量限制
GetFanProfilesByNickname(starID int64, nickname string, limit int) ([]*models.FanProfile, error)
} }
// RandomUserInfo 随机用户信息 // RandomUserInfo 随机用户信息
@ -498,3 +504,25 @@ func (r *socialRepositoryImpl) GetFanProfileByUserIDAndStarID(userID, starID int
} }
return &profile, nil 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
}

View File

@ -122,14 +122,32 @@
<!-- 卡片内容 --> <!-- 卡片内容 -->
<view class="card-content add-friend-card-content"> <view class="card-content add-friend-card-content">
<!-- 搜索模式切换 -->
<view class="search-mode-switch">
<view
class="mode-btn"
:class="{ active: searchMode === 'uid' }"
@click="switchSearchMode('uid')"
>
<text class="mode-btn-text">UID搜索</text>
</view>
<view
class="mode-btn"
:class="{ active: searchMode === 'nickname' }"
@click="switchSearchMode('nickname')"
>
<text class="mode-btn-text">昵称搜索</text>
</view>
</view>
<!-- 搜索框 --> <!-- 搜索框 -->
<view class="search-wrapper"> <view class="search-wrapper">
<image class="search-icon" src="/static/icon/search.png" mode="aspectFit"></image> <image class="search-icon" src="/static/icon/search.png" mode="aspectFit"></image>
<input <input
class="search-input" class="search-input"
v-model="searchUid" v-model="searchUid"
placeholder="输入uid进行用户搜索" :placeholder="searchMode === 'uid' ? '输入uid进行用户搜索' : '输入昵称进行用户搜索'"
type="number" :type="searchMode === 'uid' ? 'number' : 'text'"
/> />
<view <view
v-show="searchUid" v-show="searchUid"
@ -152,8 +170,8 @@
<text class="error-text">{{ searchError }}</text> <text class="error-text">{{ searchError }}</text>
</view> </view>
<!-- 搜索结果卡片 --> <!-- UID搜索结果卡片 -->
<view v-else-if="searchResult" class="friend-card"> <view v-else-if="searchMode === 'uid' && searchResult" class="friend-card">
<view class="friend-avatar"> <view class="friend-avatar">
<Avatar <Avatar
:userId="searchResult.user_id" :userId="searchResult.user_id"
@ -176,6 +194,46 @@
</button> </button>
</view> </view>
</view> </view>
<!-- 昵称搜索结果列表 -->
<view v-else-if="searchMode === 'nickname' && nicknameSearchResults.length > 0" class="nickname-results">
<text class="results-count">找到 {{ nicknameSearchResults.length }} 个匹配用户</text>
<view
v-for="user in nicknameSearchResults"
:key="user.user_id"
class="friend-card"
>
<view class="friend-avatar">
<Avatar
:userId="user.user_id"
:nickname="user.nickname"
:avatarUrl="user.avatar_url"
:size="100"
:borderWidth="4"
:showLevel="true"
:level="user.fan_level"
:enableCache="false"
/>
</view>
<view class="friend-info">
<text class="friend-nickname">{{ user.nickname }}</text>
<text class="friend-uid">UID: {{ user.user_id }}</text>
<text class="user-relation-status" :class="'status-' + user.relationship_status">
{{ getRelationStatusText(user.relationship_status) }}
</text>
</view>
<view class="friend-actions">
<button
v-if="user.can_send_request"
class="add-friend-btn"
@click="handleAddFriend(user.user_id, user.nickname)"
>
添加好友
</button>
<text v-else class="cannot-add-hint">无法添加</text>
</view>
</view>
</view>
</view> </view>
<!-- 分隔线 --> <!-- 分隔线 -->
@ -360,8 +418,14 @@
// UID // UID
const searchUid = ref(''); 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 searchError = ref(''); //
const isSearching = ref(false); // const isSearching = ref(false); //
let searchTimer = null; // let searchTimer = null; //
@ -520,6 +584,15 @@
// //
const clearSearch = () => { const clearSearch = () => {
searchUid.value = ''; searchUid.value = '';
nicknameSearchResults.value = [];
searchResult.value = null;
searchError.value = '';
};
//
const switchSearchMode = (mode) => {
searchMode.value = mode;
clearSearch();
}; };
// //
@ -531,6 +604,7 @@
// //
searchResult.value = null; searchResult.value = null;
nicknameSearchResults.value = [];
searchError.value = ''; searchError.value = '';
// //
@ -542,6 +616,9 @@
searchTimer = setTimeout(async () => { searchTimer = setTimeout(async () => {
try { try {
isSearching.value = true; isSearching.value = true;
if (searchMode.value === 'uid') {
// UID
const res = await searchUserApi(newValue.trim()); const res = await searchUserApi(newValue.trim());
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
@ -555,6 +632,28 @@
}; };
searchError.value = ''; 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) { } catch (error) {
// 404 // 404
if (error.statusCode === 404 || error.message.includes('404')) { if (error.statusCode === 404 || error.message.includes('404')) {
@ -657,6 +756,18 @@
return statusMap[status] || status; return statusMap[status] || status;
}; };
//
const getRelationStatusText = (status) => {
const statusMap = {
'stranger': '陌生人',
'friend': '已是好友',
'pending_sent': '已发送请求',
'pending_received': '待接受',
'rejected': '已被拒绝'
};
return statusMap[status] || status;
};
// //
const loadReceivedRequests = async (page) => { const loadReceivedRequests = async (page) => {
if (loadingReceivedRequests.value) return; if (loadingReceivedRequests.value) return;
@ -1156,6 +1267,62 @@
transition: transform 0.2s ease; 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 { .clear-btn:active {
transform: scale(0.9); transform: scale(0.9);
} }

View File

@ -264,6 +264,7 @@ import { onReady } from "@dcloudio/uni-app";
import Header from '../components/Header.vue'; import Header from '../components/Header.vue';
import Avatar from '../components/Avatar.vue'; import Avatar from '../components/Avatar.vue';
import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi, getOssPresignedUrlApi } from '@/utils/api'; 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 { clearAvatarCache, downloadAndCacheAvatar } from '@/utils/avatarCache';
import GuideListModal from '@/components/GuideListModal.vue'; import GuideListModal from '@/components/GuideListModal.vue';
import GuideOverlay from '@/components/GuideOverlay.vue'; import GuideOverlay from '@/components/GuideOverlay.vue';
@ -479,17 +480,10 @@ const confirmChangeNickname = async () => {
const trimmedNickname = newNickname.value.trim(); const trimmedNickname = newNickname.value.trim();
// //
if (!trimmedNickname) { const validation = validateNickname(trimmedNickname);
if (!validation.valid) {
uni.showToast({ uni.showToast({
title: '请输入昵称', title: validation.message,
icon: 'none'
});
return;
}
if (trimmedNickname.length > 20) {
uni.showToast({
title: '昵称不能超过20个字符',
icon: 'none' icon: 'none'
}); });
return; return;
@ -693,17 +687,10 @@ const confirmAddIdentity = async () => {
// //
const trimmedNickname = newIdentityNickname.value.trim(); const trimmedNickname = newIdentityNickname.value.trim();
if (!trimmedNickname) { const validation = validateNickname(trimmedNickname);
if (!validation.valid) {
uni.showToast({ uni.showToast({
title: '请输入昵称', title: validation.message,
icon: 'none'
});
return;
}
if (trimmedNickname.length > 20) {
uni.showToast({
title: '昵称不能超过20个字符',
icon: 'none' icon: 'none'
}); });
return; return;

View File

@ -53,6 +53,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import Avatar from '../components/Avatar.vue'; import Avatar from '../components/Avatar.vue';
import { checkNicknameApi } from '@/utils/api.js'; import { checkNicknameApi } from '@/utils/api.js';
import { validateNickname } from '@/utils/validator.js';
// //
const nickname = ref(''); const nickname = ref('');
@ -86,19 +87,12 @@ const goBack = () => {
// //
const handleNext = async () => { const handleNext = async () => {
// //
if (!nickname.value || !nickname.value.trim()) { const trimmedNickname = nickname.value.trim();
errorMessage.value = '请输入昵称'; const validation = validateNickname(trimmedNickname);
if (!validation.valid) {
errorMessage.value = validation.message;
uni.showToast({ uni.showToast({
title: '请输入昵称', title: validation.message,
icon: 'none'
});
return;
}
if (nickname.value.trim().length > 20) {
errorMessage.value = '昵称不能超过20个字符';
uni.showToast({
title: '昵称不能超过20个字符',
icon: 'none' icon: 'none'
}); });
return; return;

View File

@ -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({ return request({
url: '/api/v1/social/friend-requests', url: '/api/v1/social/friend-requests',
method: 'POST', method: 'POST',
data: { data
friend_user_id: friendUserId
}
}) })
} }

View File

@ -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: ''
}
}