From d787d49d4868af8d74fbe69eb05a38479f198624 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:18:09 +0800 Subject: [PATCH] search phone --- config/openim-rpc-user.yml | 3 ++ internal/api/router.go | 2 + internal/api/user.go | 4 ++ internal/rpc/user/user.go | 67 +++++++++++++++++++++++++ pkg/common/config/config.go | 4 ++ pkg/common/storage/controller/user.go | 7 +++ pkg/common/storage/database/mgo/user.go | 21 +++++--- pkg/common/storage/database/user.go | 1 + 8 files changed, 103 insertions(+), 6 deletions(-) diff --git a/config/openim-rpc-user.yml b/config/openim-rpc-user.yml index 7da94ca0d..c3e14bfdf 100644 --- a/config/openim-rpc-user.yml +++ b/config/openim-rpc-user.yml @@ -15,3 +15,6 @@ prometheus: # Prometheus listening ports, must be consistent with the number of rpc.ports # It will only take effect when autoSetPorts is set to false. ports: [ 12320 ] + +# GetUserByPhone: false = ignore phone_visibility when searching by phone; true = enforce phone_visibility (hidden / friends-only). +phoneSearchVisibility: false diff --git a/internal/api/router.go b/internal/api/router.go index 7e3c6a669..5eae5b8ac 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -163,6 +163,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/set_phone_visibility", u.SetPhoneVisibility) userRouterGroup.POST("/set_call_accept_setting", u.SetCallAcceptSetting) userRouterGroup.POST("/set_msg_receive_setting", u.SetMsgReceiveSetting) + // 根据手机号精确查找用户(phoneSearchVisibility=true 时遵守 phone_visibility 设置) + userRouterGroup.POST("/get_user_by_phone", u.GetUserByPhone) // 全局黑名单管理(仅管理员) userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) diff --git a/internal/api/user.go b/internal/api/user.go index 8482bf59f..5bb154481 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -317,3 +317,7 @@ func (u *UserApi) SetCallAcceptSetting(c *gin.Context) { func (u *UserApi) SetMsgReceiveSetting(c *gin.Context) { a2r.Call(c, user.UserClient.SetMsgReceiveSetting, u.Client) } + +func (u *UserApi) GetUserByPhone(c *gin.Context) { + a2r.Call(c, user.UserClient.GetUserByPhone, u.Client) +} diff --git a/internal/rpc/user/user.go b/internal/rpc/user/user.go index d45d4d995..cf9f55528 100644 --- a/internal/rpc/user/user.go +++ b/internal/rpc/user/user.go @@ -18,6 +18,7 @@ import ( "context" "errors" "math/rand" + "regexp" "strings" "sync" "time" @@ -53,6 +54,10 @@ import ( "google.golang.org/grpc" ) +// phoneRe 仅校验手机号的基本数字格式,不强制区号/国家码前缀。 +// 规则:纯数字,长度 5-20 位,允许可选的 + 前缀(如 +86...)。 +var phoneRe = regexp.MustCompile(`^\+?\d{5,20}$`) + type userServer struct { pbuser.UnimplementedUserServer online cache.OnlineCache @@ -296,6 +301,9 @@ func (s *userServer) SetPhoneVisibility(ctx context.Context, req *pbuser.SetPhon if req.PhoneVisibility < 0 || req.PhoneVisibility > 2 { return nil, errs.ErrArgs.WrapMsg("phoneVisibility must be 0, 1 or 2") } + if req.Phone != "" && !phoneRe.MatchString(req.Phone) { + return nil, errs.ErrArgs.WrapMsg("phone must contain digits only (5-20 digits), optionally prefixed with +") + } if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil { log.ZWarn(ctx, "SetPhoneVisibility: access denied", err, "opUserID", mcontext.GetOpUserID(ctx), "targetUserID", req.UserID) @@ -384,6 +392,65 @@ func (s *userServer) SetMsgReceiveSetting(ctx context.Context, req *pbuser.SetMs return &pbuser.SetMsgReceiveSettingResp{}, nil } +// GetUserByPhone 根据精确手机号查询用户。 +// +// phoneSearchVisibility=false(默认)时忽略 phone_visibility,任何人均可搜到。 +// phoneSearchVisibility=true 时按 phone_visibility 过滤: +// - Hidden(2) → 非管理员不可搜到 +// - Friends(1) → 仅好友/管理员可搜到 +// - Public(0) → 任何人均可搜到 +// +// 返回空 userInfo 并不代表错误,调用方应以 nil userInfo 判断"未找到"。 +func (s *userServer) GetUserByPhone(ctx context.Context, req *pbuser.GetUserByPhoneReq) (*pbuser.GetUserByPhoneResp, error) { + if req.Phone == "" { + return nil, errs.ErrArgs.WrapMsg("phone is required") + } + if !phoneRe.MatchString(req.Phone) { + return nil, errs.ErrArgs.WrapMsg("phone must contain digits only (5-20 digits), optionally prefixed with +") + } + + dbUser, err := s.db.FindByPhone(ctx, req.Phone) + if err != nil { + if errs.ErrRecordNotFound.Is(err) { + // 手机号未注册,返回空响应而非错误,避免枚举攻击 + return &pbuser.GetUserByPhoneResp{}, nil + } + log.ZError(ctx, "GetUserByPhone: FindByPhone failed", err, + "opUserID", mcontext.GetOpUserID(ctx), "phone", req.Phone) + return nil, err + } + + // 仅在 phoneSearchVisibility=true 时才按 phone_visibility 过滤,默认跳过 + if s.config.RpcConfig.PhoneSearchVisibility { + callerID := mcontext.GetOpUserID(ctx) + isAdmin := datautil.Contain(callerID, s.config.Share.IMAdminUserID...) + + switch dbUser.PhoneVisibility { + case tablerelation.PhoneVisibilityHidden: + // 完全隐藏:非管理员无法通过手机号搜到该用户 + if !isAdmin { + return &pbuser.GetUserByPhoneResp{}, nil + } + case tablerelation.PhoneVisibilityFriends: + // 仅好友可搜索 + if !isAdmin && callerID != dbUser.UserID { + isFriend, err := s.relationClient.IsFriend(ctx, callerID, dbUser.UserID) + if err != nil { + log.ZError(ctx, "GetUserByPhone: IsFriend failed", err, + "callerID", callerID, "targetUserID", dbUser.UserID) + return nil, err + } + if !isFriend { + return &pbuser.GetUserByPhoneResp{}, nil + } + } + } + } + + pbUser := convert.UserDB2Pb(dbUser) + return &pbuser.GetUserByPhoneResp{UserInfo: pbUser}, nil +} + func (s *userServer) AccountCheck(ctx context.Context, req *pbuser.AccountCheckReq) (resp *pbuser.AccountCheckResp, err error) { resp = &pbuser.AccountCheckResp{} if datautil.Duplicate(req.CheckUserIDs) { diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index 5c30dea06..05616abea 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -364,6 +364,10 @@ type User struct { Ports []int `mapstructure:"ports"` } `mapstructure:"rpc"` Prometheus Prometheus `mapstructure:"prometheus"` + // PhoneSearchVisibility 控制 GetUserByPhone 是否尊重 phone_visibility 设置。 + // false(默认):任何人均可通过手机号搜到用户,忽略 phone_visibility; + // true:按 phone_visibility 过滤(Hidden 不可搜,Friends 仅好友可搜)。 + PhoneSearchVisibility bool `mapstructure:"phoneSearchVisibility"` } type Redis struct { diff --git a/pkg/common/storage/controller/user.go b/pkg/common/storage/controller/user.go index 3f34481a3..db056ae9a 100644 --- a/pkg/common/storage/controller/user.go +++ b/pkg/common/storage/controller/user.go @@ -37,6 +37,9 @@ type UserDatabase interface { Find(ctx context.Context, userIDs []string) (users []*model.User, err error) // Find userInfo By Nickname FindByNickname(ctx context.Context, nickname string) (users []*model.User, err error) + // FindByPhone looks up a single user by exact phone number. + // Returns errs.ErrRecordNotFound if no user has the given phone. + FindByPhone(ctx context.Context, phone string) (user *model.User, err error) // Find notificationAccounts FindNotification(ctx context.Context, level int64) (users []*model.User, err error) // Create Insert multiple external guarantees that the userID is not repeated and does not exist in the storage @@ -135,6 +138,10 @@ func (u *userDatabase) FindByNickname(ctx context.Context, nickname string) (use return u.userDB.TakeByNickname(ctx, nickname) } +func (u *userDatabase) FindByPhone(ctx context.Context, phone string) (*model.User, error) { + return u.userDB.FindByPhone(ctx, phone) +} + func (u *userDatabase) FindNotification(ctx context.Context, level int64) (users []*model.User, err error) { return u.userDB.TakeNotification(ctx, level) } diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index ee92b7554..b2e437f14 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -32,13 +32,18 @@ import ( func NewUserMongo(db *mongo.Database) (database.User, error) { coll := db.Collection(database.UserName) - _, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ - Keys: bson.D{ - {Key: "user_id", Value: 1}, + indexes := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "user_id", Value: 1}}, + Options: options.Index().SetUnique(true), }, - Options: options.Index().SetUnique(true), - }) - if err != nil { + { + // 支持按手机号快速查找,phone 为空串的文档不参与索引 + Keys: bson.D{{Key: "phone", Value: 1}}, + Options: options.Index().SetSparse(true), + }, + } + if _, err := coll.Indexes().CreateMany(context.Background(), indexes); err != nil { return nil, errs.Wrap(err) } return &UserMgo{coll: coll}, nil @@ -75,6 +80,10 @@ func (u *UserMgo) TakeByNickname(ctx context.Context, nickname string) (user []* return mongoutil.Find[*model.User](ctx, u.coll, bson.M{"nickname": nickname}) } +func (u *UserMgo) FindByPhone(ctx context.Context, phone string) (*model.User, error) { + return mongoutil.FindOne[*model.User](ctx, u.coll, bson.M{"phone": phone}) +} + func (u *UserMgo) Page(ctx context.Context, pagination pagination.Pagination) (count int64, users []*model.User, err error) { return mongoutil.FindPage[*model.User](ctx, u.coll, bson.M{}, pagination) } diff --git a/pkg/common/storage/database/user.go b/pkg/common/storage/database/user.go index 4ddc8285f..7e7df0836 100644 --- a/pkg/common/storage/database/user.go +++ b/pkg/common/storage/database/user.go @@ -29,6 +29,7 @@ type User interface { Take(ctx context.Context, userID string) (user *model.User, err error) TakeNotification(ctx context.Context, level int64) (user []*model.User, err error) TakeByNickname(ctx context.Context, nickname string) (user []*model.User, err error) + FindByPhone(ctx context.Context, phone string) (user *model.User, err error) Page(ctx context.Context, pagination pagination.Pagination) (count int64, users []*model.User, err error) PageFindUser(ctx context.Context, level1 int64, level2 int64, pagination pagination.Pagination) (count int64, users []*model.User, err error) PageFindUserWithKeyword(ctx context.Context, level1 int64, level2 int64, userID, nickName string, pagination pagination.Pagination) (count int64, users []*model.User, err error)