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
}
// 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,

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"`
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() }

View File

@ -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, ""
}

View File

@ -54,6 +54,8 @@ message Friendship {
message SendFriendRequestRequest {
int64 friend_user_id = 1; // IDID
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
}
//

View File

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

View File

@ -122,18 +122,36 @@
<!-- 卡片内容 -->
<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">
<image class="search-icon" src="/static/icon/search.png" mode="aspectFit"></image>
<input
class="search-input"
v-model="searchUid"
placeholder="输入uid进行用户搜索"
type="number"
<input
class="search-input"
v-model="searchUid"
:placeholder="searchMode === 'uid' ? '输入uid进行用户搜索' : '输入昵称进行用户搜索'"
:type="searchMode === 'uid' ? 'number' : 'text'"
/>
<view
v-show="searchUid"
class="clear-btn"
<view
v-show="searchUid"
class="clear-btn"
@click="clearSearch"
>
<image class="clear-icon" src="/static/icon/cancel.png" mode="aspectFit"></image>
@ -146,37 +164,77 @@
<view v-if="isSearching" class="search-status">
<text class="status-text">搜索中...</text>
</view>
<!-- 搜索错误 -->
<view v-else-if="searchError" class="search-status">
<text class="error-text">{{ searchError }}</text>
</view>
<!-- 搜索结果卡片 -->
<view v-else-if="searchResult" class="friend-card">
<view class="friend-avatar">
<Avatar
:userId="searchResult.user_id"
:nickname="searchResult.nickname"
:avatarUrl="searchResult.avatar_url"
:size="100"
:borderWidth="4"
:showLevel="true"
:level="searchResult.fan_level"
:enableCache="false"
/>
</view>
<view class="friend-info">
<text class="friend-nickname">{{ searchResult.nickname }}</text>
<text class="friend-uid">UID: {{ searchResult.user_id }}</text>
</view>
<view class="friend-actions">
<button class="add-friend-btn" @click="handleAddFriend(searchResult.user_id, searchResult.nickname)">
添加好友
</button>
</view>
<!-- UID搜索结果卡片 -->
<view v-else-if="searchMode === 'uid' && searchResult" class="friend-card">
<view class="friend-avatar">
<Avatar
:userId="searchResult.user_id"
:nickname="searchResult.nickname"
:avatarUrl="searchResult.avatar_url"
:size="100"
:borderWidth="4"
:showLevel="true"
:level="searchResult.fan_level"
:enableCache="false"
/>
</view>
<view class="friend-info">
<text class="friend-nickname">{{ searchResult.nickname }}</text>
<text class="friend-uid">UID: {{ searchResult.user_id }}</text>
</view>
<view class="friend-actions">
<button class="add-friend-btn" @click="handleAddFriend(searchResult.user_id, searchResult.nickname)">
添加好友
</button>
</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 class="divider"></view>
@ -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);
}

View File

@ -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;

View File

@ -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 = '';
//

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

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