From 706c6000cb62c96e3d1523643c795115395ae3e5 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:00:54 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E9=BB=91=E5=90=8D=E5=8D=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/init.go | 7 +- internal/api/router.go | 23 +++ internal/api/user_global_black.go | 173 ++++++++++++++++++ internal/rpc/auth/auth.go | 57 +++++- pkg/common/cmd/api.go | 1 + pkg/common/cmd/auth.go | 1 + pkg/common/servererrs/code.go | 1 + pkg/common/servererrs/predefine.go | 1 + .../storage/controller/user_global_black.go | 45 +++++ .../storage/database/mgo/user_global_black.go | 89 +++++++++ pkg/common/storage/database/name.go | 1 + .../storage/database/user_global_black.go | 22 +++ pkg/common/storage/model/user_global_black.go | 12 ++ pkg/rpcli/auth.go | 5 + scripts/global_blacklist_api.sh | 171 +++++++++++++++++ 15 files changed, 600 insertions(+), 9 deletions(-) create mode 100644 internal/api/user_global_black.go create mode 100644 pkg/common/storage/controller/user_global_black.go create mode 100644 pkg/common/storage/database/mgo/user_global_black.go create mode 100644 pkg/common/storage/database/user_global_black.go create mode 100644 pkg/common/storage/model/user_global_black.go create mode 100755 scripts/global_blacklist_api.sh diff --git a/internal/api/init.go b/internal/api/init.go index 3fb5364f3..0ac30924f 100644 --- a/internal/api/init.go +++ b/internal/api/init.go @@ -30,9 +30,10 @@ import ( ) type Config struct { - API config.API - Share config.Share - Discovery config.Discovery + API config.API + Share config.Share + Discovery config.Discovery + MongodbConfig config.Mongo } func Start(ctx context.Context, index int, cfg *Config) error { diff --git a/internal/api/router.go b/internal/api/router.go index bad891fda..ce4649129 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -23,8 +23,11 @@ import ( "github.com/go-playground/validator/v10" "github.com/openimsdk/open-im-server/v3/pkg/common/prommetrics" "github.com/openimsdk/open-im-server/v3/pkg/common/servererrs" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo" "github.com/openimsdk/protocol/constant" "github.com/openimsdk/tools/apiresp" + "github.com/openimsdk/tools/db/mongoutil" "github.com/openimsdk/tools/discovery" "github.com/openimsdk/tools/log" "github.com/openimsdk/tools/mw" @@ -53,6 +56,20 @@ func prommetricsGin() gin.HandlerFunc { } func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, config *Config) (*gin.Engine, error) { + mgocli, err := mongoutil.NewMongoDB(ctx, config.MongodbConfig.Build()) + if err != nil { + return nil, err + } + userGlobalBlackDB, err := mgo.NewUserGlobalBlackMongo(mgocli.GetDB()) + if err != nil { + return nil, err + } + userDB, err := mgo.NewUserMongo(mgocli.GetDB()) + if err != nil { + return nil, err + } + blacklistCtrl := controller.NewUserGlobalBlackDatabase(userGlobalBlackDB) + authConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Auth) if err != nil { return nil, err @@ -98,6 +115,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co r.Use(prommetricsGin(), gin.RecoveryWithWriter(gin.DefaultErrorWriter, mw.GinPanicErr), mw.CorsHandler(), mw.GinParseOperationID(), GinParseToken(rpcli.NewAuthClient(authConn))) u := NewUserApi(user.NewUserClient(userConn), client, config.Share.RpcRegisterName) m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID) + bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn)) userRouterGroup := r.Group("/user") { userRouterGroup.POST("/user_register", u.UserRegister) @@ -123,6 +141,11 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/add_notification_account", u.AddNotificationAccount) userRouterGroup.POST("/update_notification_account", u.UpdateNotificationAccountInfo) userRouterGroup.POST("/search_notification_account", u.SearchNotificationAccount) + + // 全局黑名单管理(仅管理员) + userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) + userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) + userRouterGroup.POST("/get_global_blacklist", bl.GetGlobalBlacklist) } // friend routing group { diff --git a/internal/api/user_global_black.go b/internal/api/user_global_black.go new file mode 100644 index 000000000..36f6aaedc --- /dev/null +++ b/internal/api/user_global_black.go @@ -0,0 +1,173 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/openimsdk/open-im-server/v3/pkg/authverify" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/open-im-server/v3/pkg/rpcli" + "github.com/openimsdk/protocol/constant" + "github.com/openimsdk/protocol/sdkws" + "github.com/openimsdk/tools/apiresp" + "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" + "github.com/openimsdk/tools/mcontext" +) + +type UserGlobalBlackApi struct { + blacklistDB controller.UserGlobalBlackDatabase + userDB database.User + imAdminUserIDs []string + authClient *rpcli.AuthClient +} + +func NewUserGlobalBlackApi(blacklistDB controller.UserGlobalBlackDatabase, userDB database.User, imAdminUserIDs []string, authClient *rpcli.AuthClient) UserGlobalBlackApi { + return UserGlobalBlackApi{blacklistDB: blacklistDB, userDB: userDB, imAdminUserIDs: imAdminUserIDs, authClient: authClient} +} + +type addGlobalBlacklistReq struct { + Nicknames []string `json:"nicknames" binding:"required,min=1"` + Reason string `json:"reason"` +} + +type removeGlobalBlacklistReq struct { + Nicknames []string `json:"nicknames" binding:"required,min=1"` +} + +type getGlobalBlacklistReq struct { + Pagination *sdkws.RequestPagination `json:"pagination" binding:"required"` +} + +type globalBlackItem struct { + UserID string `json:"userID"` + Nickname string `json:"nickname"` + OperatorID string `json:"operatorID"` + Reason string `json:"reason"` + CreateTime int64 `json:"createTime"` +} + +type getGlobalBlacklistResp struct { + Total int64 `json:"total"` + Blacks []globalBlackItem `json:"blacks"` +} + +// AddGlobalBlacklist 管理员将用户加入全局黑名单,并立即踢下线(所有平台 token 标记 KickedToken) +func (b *UserGlobalBlackApi) AddGlobalBlacklist(c *gin.Context) { + var req addGlobalBlacklistReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + if err := authverify.CheckAdmin(c, b.imAdminUserIDs); err != nil { + apiresp.GinError(c, err) + return + } + operatorID := mcontext.GetOpUserID(c) + blacks := make([]*model.UserGlobalBlack, 0, len(req.Nicknames)) + for _, nickname := range req.Nicknames { + users, err := b.userDB.TakeByNickname(c, nickname) + if err != nil { + apiresp.GinError(c, err) + return + } + if len(users) == 0 { + apiresp.GinError(c, errs.ErrRecordNotFound.WrapMsg("nickname not found", "nickname", nickname)) + return + } + if len(users) > 1 { + apiresp.GinError(c, errs.ErrArgs.WrapMsg("nickname matched multiple users", "nickname", nickname)) + return + } + blacks = append(blacks, &model.UserGlobalBlack{ + UserID: users[0].UserID, + Nickname: users[0].Nickname, + OperatorID: operatorID, + Reason: req.Reason, + }) + } + if err := b.blacklistDB.AddBlack(c, blacks); err != nil { + apiresp.GinError(c, err) + return + } + // 黑名单写入成功后,对每个被封禁用户的所有非管理员平台执行 force_logout: + // 1. 断开 WS 长连接(msggateway.KickUserOffline) + // 2. 将 Redis 中该平台的所有 token 标记为 KickedToken + for _, black := range blacks { + for platformID := range constant.PlatformID2Name { + if int32(platformID) == constant.AdminPlatformID { + continue + } + if err := b.authClient.ForceLogout(c, black.UserID, int32(platformID)); err != nil { + // 踢下线失败不阻断主流程,记录警告即可 + log.ZWarn(c, "AddGlobalBlacklist: ForceLogout failed", err, + "userID", black.UserID, "platformID", platformID) + } + } + } + apiresp.GinSuccess(c, nil) +} + +// RemoveGlobalBlacklist 管理员从全局黑名单移除用户 +func (b *UserGlobalBlackApi) RemoveGlobalBlacklist(c *gin.Context) { + var req removeGlobalBlacklistReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + if err := authverify.CheckAdmin(c, b.imAdminUserIDs); err != nil { + apiresp.GinError(c, err) + return + } + userIDs := make([]string, 0, len(req.Nicknames)) + for _, nickname := range req.Nicknames { + users, err := b.userDB.TakeByNickname(c, nickname) + if err != nil { + apiresp.GinError(c, err) + return + } + if len(users) == 0 { + apiresp.GinError(c, errs.ErrRecordNotFound.WrapMsg("nickname not found", "nickname", nickname)) + return + } + if len(users) > 1 { + apiresp.GinError(c, errs.ErrArgs.WrapMsg("nickname matched multiple users", "nickname", nickname)) + return + } + userIDs = append(userIDs, users[0].UserID) + } + if err := b.blacklistDB.RemoveBlack(c, userIDs); err != nil { + apiresp.GinError(c, err) + return + } + apiresp.GinSuccess(c, nil) +} + +// GetGlobalBlacklist 管理员分页查询全局黑名单 +func (b *UserGlobalBlackApi) GetGlobalBlacklist(c *gin.Context) { + var req getGlobalBlacklistReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + if err := authverify.CheckAdmin(c, b.imAdminUserIDs); err != nil { + apiresp.GinError(c, err) + return + } + total, blacks, err := b.blacklistDB.GetBlackList(c, req.Pagination) + if err != nil { + apiresp.GinError(c, err) + return + } + items := make([]globalBlackItem, 0, len(blacks)) + for _, blk := range blacks { + items = append(items, globalBlackItem{ + UserID: blk.UserID, + Nickname: blk.Nickname, + OperatorID: blk.OperatorID, + Reason: blk.Reason, + CreateTime: blk.CreateTime.UnixMilli(), + }) + } + apiresp.GinSuccess(c, getGlobalBlacklistResp{Total: total, Blacks: items}) +} diff --git a/internal/rpc/auth/auth.go b/internal/rpc/auth/auth.go index a7055f6a5..9a909c520 100644 --- a/internal/rpc/auth/auth.go +++ b/internal/rpc/auth/auth.go @@ -22,6 +22,8 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/common/config" redis2 "github.com/openimsdk/open-im-server/v3/pkg/common/storage/cache/redis" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo" + "github.com/openimsdk/tools/db/mongoutil" "github.com/openimsdk/tools/db/redisutil" "github.com/openimsdk/tools/utils/datautil" "github.com/redis/go-redis/v9" @@ -42,6 +44,7 @@ import ( type authServer struct { pbauth.UnimplementedAuthServer + blacklistDB controller.UserGlobalBlackDatabase authDatabase controller.AuthDatabase RegisterCenter discovery.SvcDiscoveryRegistry config *Config @@ -49,10 +52,11 @@ type authServer struct { } type Config struct { - RpcConfig config.Auth - RedisConfig config.Redis - Share config.Share - Discovery config.Discovery + RpcConfig config.Auth + RedisConfig config.Redis + MongodbConfig config.Mongo + Share config.Share + Discovery config.Discovery } func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryRegistry, server *grpc.Server) error { @@ -60,6 +64,14 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + mgocli, err := mongoutil.NewMongoDB(ctx, config.MongodbConfig.Build()) + if err != nil { + return err + } + userGlobalBlackDB, err := mgo.NewUserGlobalBlackMongo(mgocli.GetDB()) + if err != nil { + return err + } userConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.User) if err != nil { return err @@ -73,8 +85,9 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg config.Share.MultiLogin, config.Share.IMAdminUserID, ), - config: config, - userClient: rpcli.NewUserClient(userConn), + config: config, + blacklistDB: controller.NewUserGlobalBlackDatabase(userGlobalBlackDB), + userClient: rpcli.NewUserClient(userConn), }) return nil } @@ -126,6 +139,16 @@ func (s *authServer) GetUserToken(ctx context.Context, req *pbauth.GetUserTokenR if user.AppMangerLevel >= constant.AppNotificationAdmin { return nil, errs.ErrArgs.WrapMsg("app account can`t get token") } + + blocked, _ := s.blacklistDB.IsBlocked(ctx, req.UserID) + if blocked { + // Blacklisted users should be actively kicked to invalidate existing sessions. + if kickErr := s.forceKickOffAllPlatforms(ctx, req.UserID); kickErr != nil { + log.ZWarn(ctx, "GetUserToken forceKickOffAllPlatforms failed", kickErr, "userID", req.UserID) + } + log.ZWarn(ctx, "GetUserToken is blocked", errors.New("user is in global blacklist, userID="+req.UserID), "userID", req.UserID, "blocked", blocked) + return nil, servererrs.ErrUserBlocked.WithDetail("user is in global blacklist, userID=" + req.UserID) + } token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(req.PlatformID)) if err != nil { return nil, err @@ -144,6 +167,16 @@ func (s *authServer) parseToken(ctx context.Context, tokensString string) (claim if isAdmin { return claims, nil } + // 非管理员用户检查全局黑名单 + blocked, _ := s.blacklistDB.IsBlocked(ctx, claims.UserID) + if blocked { + // Blacklisted users should be actively kicked to invalidate existing sessions. + if kickErr := s.forceKickOffAllPlatforms(ctx, claims.UserID); kickErr != nil { + log.ZWarn(ctx, "parseToken forceKickOffAllPlatforms failed", kickErr, "userID", claims.UserID) + } + log.ZWarn(ctx, "parseToken is blocked", errors.New("user is in global blacklist, userID="+claims.UserID), "userID", claims.UserID, "blocked", blocked) + return nil, servererrs.ErrUserBlocked.WithDetail("user is in global blacklist, userID=" + claims.UserID) + } m, err := s.authDatabase.GetTokensWithoutError(ctx, claims.UserID, claims.PlatformID) if err != nil { return nil, err @@ -218,6 +251,18 @@ func (s *authServer) forceKickOff(ctx context.Context, userID string, platformID return nil } +func (s *authServer) forceKickOffAllPlatforms(ctx context.Context, userID string) error { + for platformID := range constant.PlatformID2Name { + if int32(platformID) == constant.AdminPlatformID { + continue + } + if err := s.forceKickOff(ctx, userID, int32(platformID)); err != nil { + return err + } + } + return nil +} + func (s *authServer) InvalidateToken(ctx context.Context, req *pbauth.InvalidateTokenReq) (*pbauth.InvalidateTokenResp, error) { m, err := s.authDatabase.GetTokensWithoutError(ctx, req.UserID, int(req.PlatformID)) if err != nil && !errors.Is(err, redis.Nil) { diff --git a/pkg/common/cmd/api.go b/pkg/common/cmd/api.go index 4088ecd09..365bd6c69 100644 --- a/pkg/common/cmd/api.go +++ b/pkg/common/cmd/api.go @@ -37,6 +37,7 @@ func NewApiCmd() *ApiCmd { OpenIMAPICfgFileName: &apiConfig.API, ShareFileName: &apiConfig.Share, DiscoveryConfigFilename: &apiConfig.Discovery, + MongodbConfigFileName: &apiConfig.MongodbConfig, } ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap)) ret.ctx = context.WithValue(context.Background(), "version", version.Version) diff --git a/pkg/common/cmd/auth.go b/pkg/common/cmd/auth.go index 54f65bc37..ff7e4229e 100644 --- a/pkg/common/cmd/auth.go +++ b/pkg/common/cmd/auth.go @@ -38,6 +38,7 @@ func NewAuthRpcCmd() *AuthRpcCmd { OpenIMRPCAuthCfgFileName: &authConfig.RpcConfig, RedisConfigFileName: &authConfig.RedisConfig, ShareFileName: &authConfig.Share, + MongodbConfigFileName: &authConfig.MongodbConfig, DiscoveryConfigFilename: &authConfig.Discovery, } ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap)) diff --git a/pkg/common/servererrs/code.go b/pkg/common/servererrs/code.go index 87ccef827..9e4fe9129 100644 --- a/pkg/common/servererrs/code.go +++ b/pkg/common/servererrs/code.go @@ -54,6 +54,7 @@ const ( // Account error codes. UserIDNotFoundError = 1101 // UserID does not exist or is not registered RegisteredAlreadyError = 1102 // user is already registered + UserBlockedError = 1103 // user is blocked (global blacklist) // Group error codes. GroupIDNotFoundError = 1201 // GroupID does not exist diff --git a/pkg/common/servererrs/predefine.go b/pkg/common/servererrs/predefine.go index 127311341..ed44817a2 100644 --- a/pkg/common/servererrs/predefine.go +++ b/pkg/common/servererrs/predefine.go @@ -29,6 +29,7 @@ var ( ErrRecordNotFound = errs.NewCodeError(RecordNotFoundError, "RecordNotFoundError") ErrUserIDNotFound = errs.NewCodeError(UserIDNotFoundError, "UserIDNotFoundError") + ErrUserBlocked = errs.NewCodeError(UserBlockedError, "UserBlockedError") ErrGroupIDNotFound = errs.NewCodeError(GroupIDNotFoundError, "GroupIDNotFoundError") ErrGroupIDExisted = errs.NewCodeError(GroupIDExisted, "GroupIDExisted") diff --git a/pkg/common/storage/controller/user_global_black.go b/pkg/common/storage/controller/user_global_black.go new file mode 100644 index 000000000..f415f8a3f --- /dev/null +++ b/pkg/common/storage/controller/user_global_black.go @@ -0,0 +1,45 @@ +package controller + +import ( + "context" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/db/pagination" +) + +// UserGlobalBlackDatabase 全局黑名单业务接口 +type UserGlobalBlackDatabase interface { + // AddBlack 将用户加入全局黑名单 + AddBlack(ctx context.Context, blacks []*model.UserGlobalBlack) error + // RemoveBlack 按昵称将用户从全局黑名单移除 + RemoveBlack(ctx context.Context, nicknames []string) error + // IsBlocked 检查用户是否在全局黑名单 + IsBlocked(ctx context.Context, userID string) (bool, error) + // GetBlackList 分页获取黑名单列表 + GetBlackList(ctx context.Context, pagination pagination.Pagination) (count int64, blacks []*model.UserGlobalBlack, err error) +} + +type userGlobalBlackDatabase struct { + db database.UserGlobalBlack +} + +func NewUserGlobalBlackDatabase(db database.UserGlobalBlack) UserGlobalBlackDatabase { + return &userGlobalBlackDatabase{db: db} +} + +func (u *userGlobalBlackDatabase) AddBlack(ctx context.Context, blacks []*model.UserGlobalBlack) error { + return u.db.Add(ctx, blacks) +} + +func (u *userGlobalBlackDatabase) RemoveBlack(ctx context.Context, nicknames []string) error { + return u.db.Remove(ctx, nicknames) +} + +func (u *userGlobalBlackDatabase) IsBlocked(ctx context.Context, userID string) (bool, error) { + return u.db.IsBlocked(ctx, userID) +} + +func (u *userGlobalBlackDatabase) GetBlackList(ctx context.Context, pagination pagination.Pagination) (int64, []*model.UserGlobalBlack, error) { + return u.db.Page(ctx, pagination) +} diff --git a/pkg/common/storage/database/mgo/user_global_black.go b/pkg/common/storage/database/mgo/user_global_black.go new file mode 100644 index 000000000..686c2bf3f --- /dev/null +++ b/pkg/common/storage/database/mgo/user_global_black.go @@ -0,0 +1,89 @@ +package mgo + +import ( + "context" + "time" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/db/mongoutil" + "github.com/openimsdk/tools/db/pagination" + "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewUserGlobalBlackMongo(db *mongo.Database) (database.UserGlobalBlack, error) { + coll := db.Collection(database.UserGlobalBlackName) + _, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: "user_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + return nil, errs.Wrap(err) + } + return &UserGlobalBlackMgo{coll: coll}, nil +} + +type UserGlobalBlackMgo struct { + coll *mongo.Collection +} + +func (u *UserGlobalBlackMgo) Add(ctx context.Context, blacks []*model.UserGlobalBlack) error { + for _, b := range blacks { + if b.CreateTime.IsZero() { + b.CreateTime = time.Now() + } + } + // 使用 upsert 避免重复插入报错 + for _, b := range blacks { + filter := bson.M{"user_id": b.UserID} + update := bson.M{ + "$set": bson.M{ + "nickname": b.Nickname, + "operator_id": b.OperatorID, + "reason": b.Reason, + }, + "$setOnInsert": bson.M{ + "user_id": b.UserID, + "create_time": b.CreateTime, + }, + } + opts := options.Update().SetUpsert(true) + if _, err := u.coll.UpdateOne(ctx, filter, update, opts); err != nil { + return errs.Wrap(err) + } + } + return nil +} + +func (u *UserGlobalBlackMgo) Remove(ctx context.Context, users []string) error { + if len(users) == 0 { + return nil + } + _, err := u.coll.DeleteMany(ctx, bson.M{"user_id": bson.M{"$in": users}}) + return errs.Wrap(err) +} + +func (u *UserGlobalBlackMgo) Find(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) { + if len(userIDs) == 0 { + return nil, nil + } + return mongoutil.Find[*model.UserGlobalBlack](ctx, u.coll, bson.M{"user_id": bson.M{"$in": userIDs}}) +} + +func (u *UserGlobalBlackMgo) IsBlocked(ctx context.Context, userID string) (bool, error) { + count, err := u.coll.CountDocuments(ctx, bson.M{"user_id": userID}) + if err != nil { + log.ZWarn(ctx, "IsBlocked failed", err, "collection", database.UserGlobalBlackName, "userID", userID, "count", count) + return false, nil + } + + return count > 0, nil +} + +func (u *UserGlobalBlackMgo) Page(ctx context.Context, pagination pagination.Pagination) (int64, []*model.UserGlobalBlack, error) { + return mongoutil.FindPage[*model.UserGlobalBlack](ctx, u.coll, bson.M{}, pagination) +} diff --git a/pkg/common/storage/database/name.go b/pkg/common/storage/database/name.go index 748bd844d..4b1af74a8 100644 --- a/pkg/common/storage/database/name.go +++ b/pkg/common/storage/database/name.go @@ -17,4 +17,5 @@ const ( UserName = "user" SeqConversationName = "seq" SeqUserName = "seq_user" + UserGlobalBlackName = "user_global_black_list" ) diff --git a/pkg/common/storage/database/user_global_black.go b/pkg/common/storage/database/user_global_black.go new file mode 100644 index 000000000..82f52c493 --- /dev/null +++ b/pkg/common/storage/database/user_global_black.go @@ -0,0 +1,22 @@ +package database + +import ( + "context" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/db/pagination" +) + +// UserGlobalBlack 全局黑名单持久化接口 +type UserGlobalBlack interface { + // Add 批量添加用户到全局黑名单 + Add(ctx context.Context, blacks []*model.UserGlobalBlack) error + // Remove 按昵称从全局黑名单移除用户 + Remove(ctx context.Context, nicknames []string) error + // Find 查询指定用户是否在黑名单(返回在黑名单中的记录) + Find(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) + // IsBlocked 检查单个用户是否在黑名单 + IsBlocked(ctx context.Context, userID string) (bool, error) + // Page 分页查询黑名单列表 + Page(ctx context.Context, pagination pagination.Pagination) (count int64, blacks []*model.UserGlobalBlack, err error) +} diff --git a/pkg/common/storage/model/user_global_black.go b/pkg/common/storage/model/user_global_black.go new file mode 100644 index 000000000..a0329cf86 --- /dev/null +++ b/pkg/common/storage/model/user_global_black.go @@ -0,0 +1,12 @@ +package model + +import "time" + +// UserGlobalBlack 全局黑名单记录,被加入黑名单的用户无法登录 +type UserGlobalBlack struct { + UserID string `bson:"user_id"` + Nickname string `bson:"nickname"` + OperatorID string `bson:"operator_id"` + Reason string `bson:"reason"` + CreateTime time.Time `bson:"create_time"` +} diff --git a/pkg/rpcli/auth.go b/pkg/rpcli/auth.go index 17fc6ea28..9761c26e4 100644 --- a/pkg/rpcli/auth.go +++ b/pkg/rpcli/auth.go @@ -2,6 +2,7 @@ package rpcli import ( "context" + "github.com/openimsdk/protocol/auth" "google.golang.org/grpc" ) @@ -28,3 +29,7 @@ func (x *AuthClient) InvalidateToken(ctx context.Context, req *auth.InvalidateTo func (x *AuthClient) ParseToken(ctx context.Context, token string) (*auth.ParseTokenResp, error) { return x.AuthClient.ParseToken(ctx, &auth.ParseTokenReq{Token: token}) } + +func (x *AuthClient) ForceLogout(ctx context.Context, userID string, platformID int32) error { + return ignoreResp(x.AuthClient.ForceLogout(ctx, &auth.ForceLogoutReq{UserID: userID, PlatformID: platformID})) +} diff --git a/scripts/global_blacklist_api.sh b/scripts/global_blacklist_api.sh new file mode 100755 index 000000000..da7ad89f0 --- /dev/null +++ b/scripts/global_blacklist_api.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 统一通过 API 新链路管理全局黑名单(按 nickname) +# +# 用法: +# 1) 添加 +# ./scripts/global_blacklist_api.sh add "alice,bob" [reason] +# +# 2) 删除 +# ./scripts/global_blacklist_api.sh remove "alice,bob" +# +# 3) 查询 +# ./scripts/global_blacklist_api.sh list [pageNumber] [showNumber] +# +# 环境变量(可覆盖): +# OPENIM_API_ADDR 默认: http://127.0.0.1:10002 +# ADMIN_TOKEN 管理员 token(如未提供则自动调用 /auth/get_admin_token 获取) +# OPENIM_SECRET 获取管理员 token 所需 secret,默认: openIM123 +# ADMIN_USER_ID 获取管理员 token 所需 userID,默认: imAdmin + +OPENIM_API_ADDR="${OPENIM_API_ADDR:-http://127.0.0.1:10002}" +ADMIN_TOKEN="${ADMIN_TOKEN:-}" +OPENIM_SECRET="${OPENIM_SECRET:-openIM123}" +ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}" +OPERATION_ID="${OPERATION_ID:-gb_$(date +%s)_$RANDOM}" + +ACTION="${1:-}" +NICKNAMES_RAW="${2:-}" +REASON="${3:-manual_by_api_script}" +PAGE_NUMBER="${2:-1}" +SHOW_NUMBER="${3:-20}" + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +nicknames_csv_to_json_array() { + local csv="$1" + local arr_json="[" + local first=1 + local item + + IFS=',' read -r -a _items <<< "$csv" + for item in "${_items[@]}"; do + item="$(trim "$item")" + [[ -z "$item" ]] && continue + if [[ $first -eq 1 ]]; then + arr_json="${arr_json}\"${item}\"" + first=0 + else + arr_json="${arr_json},\"${item}\"" + fi + done + arr_json="${arr_json}]" + + if [[ "$arr_json" == "[]" ]]; then + die "nicknames 为空,请传入逗号分隔昵称,如 \"alice,bob\"" + fi + printf '%s' "$arr_json" +} + +get_admin_token() { + local uid body resp token last_resp + local -a candidates=("${ADMIN_USER_ID}" "openIM123456" "imAdmin") + last_resp="" + + for uid in "${candidates[@]}"; do + body="{\"secret\":\"${OPENIM_SECRET}\",\"userID\":\"${uid}\"}" + resp="$(curl -sS -X POST "${OPENIM_API_ADDR}/auth/get_admin_token" \ + -H "Content-Type: application/json" \ + -H "operationID: ${OPERATION_ID}" \ + -d "$body")" + last_resp="$resp" + + token="$(python3 - <<'PY' "$resp" +import json +import sys + +raw = sys.argv[1] +try: + obj = json.loads(raw) +except Exception: + print("") + raise SystemExit(0) + +token = "" +if isinstance(obj, dict): + data = obj.get("data") + if isinstance(data, dict): + token = data.get("token") or data.get("Token") or "" + if not token: + token = obj.get("token") or obj.get("Token") or "" +print(token) +PY +)" + if [[ -n "$token" ]]; then + echo "自动获取管理员 token 成功,userID=${uid}" >&2 + printf '%s' "$token" + return 0 + fi + done + + echo "get_admin_token raw response: $last_resp" >&2 + die "自动获取管理员 token 失败,请检查 OPENIM_API_ADDR/OPENIM_SECRET/ADMIN_USER_ID(当前: ${ADMIN_USER_ID}),或直接设置 ADMIN_TOKEN" +} + +call_api() { + local path="$1" + local body="$2" + local token="$3" + + curl -sS -X POST "${OPENIM_API_ADDR}${path}" \ + -H "Content-Type: application/json" \ + -H "operationID: ${OPERATION_ID}" \ + -H "token: ${token}" \ + -d "$body" +} + +if [[ -z "$ACTION" ]]; then + cat <<'EOF' +用法: + 添加: ./scripts/global_blacklist_api.sh add "alice,bob" [reason] + 删除: ./scripts/global_blacklist_api.sh remove "alice,bob" + 查询: ./scripts/global_blacklist_api.sh list [pageNumber] [showNumber] +EOF + exit 1 +fi + +if [[ -z "$ADMIN_TOKEN" ]]; then + echo "ADMIN_TOKEN 未设置,尝试自动获取管理员 token..." + ADMIN_TOKEN="$(get_admin_token)" +fi + +case "$ACTION" in + add) + [[ -z "$NICKNAMES_RAW" ]] && die "add 需要 nicknames 参数" + NICKNAMES_JSON="$(nicknames_csv_to_json_array "$NICKNAMES_RAW")" + BODY="{\"nicknames\":${NICKNAMES_JSON},\"reason\":\"${REASON}\"}" + echo ">>> POST /user/add_global_blacklist" + call_api "/user/add_global_blacklist" "$BODY" "$ADMIN_TOKEN" + ;; + + remove) + [[ -z "$NICKNAMES_RAW" ]] && die "remove 需要 nicknames 参数" + NICKNAMES_JSON="$(nicknames_csv_to_json_array "$NICKNAMES_RAW")" + BODY="{\"nicknames\":${NICKNAMES_JSON}}" + echo ">>> POST /user/remove_global_blacklist" + call_api "/user/remove_global_blacklist" "$BODY" "$ADMIN_TOKEN" + ;; + + list) + BODY="{\"pagination\":{\"pageNumber\":${PAGE_NUMBER},\"showNumber\":${SHOW_NUMBER}}}" + echo ">>> POST /user/get_global_blacklist" + call_api "/user/get_global_blacklist" "$BODY" "$ADMIN_TOKEN" + ;; + + *) + die "不支持的 action: ${ACTION}(仅支持 add/remove/list)" + ;; +esac + +echo From f9d3221df095567fd09880ecce910cabe03559d5 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:28:03 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=85=B1=E5=90=8C?= =?UTF-8?q?=E7=BE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 + go.sum | 2 - internal/api/group.go | 4 ++ internal/api/router.go | 1 + internal/rpc/group/group.go | 44 ++++++++++++++ scripts/get_common_group.sh | 112 ++++++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 2 deletions(-) create mode 100755 scripts/get_common_group.sh diff --git a/go.mod b/go.mod index 3e996b857..6b7bf7006 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/openimsdk/open-im-server/v3 go 1.25.0 +replace github.com/openimsdk/protocol => ../protocol + require ( firebase.google.com/go/v4 v4.14.1 github.com/dtm-labs/rockscache v0.1.1 diff --git a/go.sum b/go.sum index e9677acb9..c0aa7a720 100644 --- a/go.sum +++ b/go.sum @@ -354,8 +354,6 @@ github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= github.com/openimsdk/gomake v0.0.17 h1:q8haP48VOH45WhJRiLj1YSBJyUFJqD8CTedH65i1YH8= github.com/openimsdk/gomake v0.0.17/go.mod h1:nnjS8yCtrPJAt1knMbyPiUwCH2gpyBzj/EZAONfUOXg= -github.com/openimsdk/protocol v0.0.73-alpha.12 h1:2NYawXeHChYUeSme6QJ9pOLh+Empce2WmwEtbP4JvKk= -github.com/openimsdk/protocol v0.0.73-alpha.12/go.mod h1:WF7EuE55vQvpyUAzDXcqg+B+446xQyEba0X35lTINmw= github.com/openimsdk/tools v0.0.50-alpha.113 h1:rhLWaSJuhjgJFNVzmpChLCG7dPXS0+bte+CPI0008Us= github.com/openimsdk/tools v0.0.50-alpha.113/go.mod h1:x9i/e+WJFW4tocy6RNJQ9NofQiP3KJ1Y576/06TqOG4= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= diff --git a/internal/api/group.go b/internal/api/group.go index 926d19a8a..9a2ffda06 100644 --- a/internal/api/group.go +++ b/internal/api/group.go @@ -169,3 +169,7 @@ func (o *GroupApi) GetFullJoinGroupIDs(c *gin.Context) { func (o *GroupApi) GetGroupApplicationUnhandledCount(c *gin.Context) { a2r.Call(c, group.GroupClient.GetGroupApplicationUnhandledCount, o.Client) } + +func (o *GroupApi) GetCommonGroupsWithFriend(c *gin.Context) { + a2r.Call(c, group.GroupClient.GetCommonGroupsWithFriend, o.Client) +} diff --git a/internal/api/router.go b/internal/api/router.go index bad891fda..896d0b558 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -187,6 +187,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co groupRouterGroup.POST("/get_full_group_member_user_ids", g.GetFullGroupMemberUserIDs) groupRouterGroup.POST("/get_full_join_group_ids", g.GetFullJoinGroupIDs) groupRouterGroup.POST("/get_group_application_unhandled_count", g.GetGroupApplicationUnhandledCount) + groupRouterGroup.POST("/get_common_groups_with_friend", g.GetCommonGroupsWithFriend) } // certificate { diff --git a/internal/rpc/group/group.go b/internal/rpc/group/group.go index 778316e5c..3f97927f3 100644 --- a/internal/rpc/group/group.go +++ b/internal/rpc/group/group.go @@ -358,6 +358,50 @@ func (s *groupServer) GetJoinedGroupList(ctx context.Context, req *pbgroup.GetJo return &resp, nil } +func (g *groupServer) GetCommonGroupsWithFriend(ctx context.Context, req *pbgroup.GetCommonGroupsWithFriendReq) (*pbgroup.GetCommonGroupsWithFriendResp, error) { + if req.FriendUserID == "" { + return nil, errs.ErrArgs.WrapMsg("friendUserID empty") + } + opUserID := mcontext.GetOpUserID(ctx) + if opUserID == "" { + return nil, errs.ErrNoPermission.WrapMsg("op user id empty") + } + + selfGroupIDs, err := g.db.FindJoinGroupID(ctx, opUserID) + if err != nil { + return nil, err + } + if len(selfGroupIDs) == 0 { + return &pbgroup.GetCommonGroupsWithFriendResp{ + Total: 0, + Groups: []*sdkws.GroupInfo{}, + }, nil + } + + friendMembers, err := g.db.FindGroupMemberUser(ctx, selfGroupIDs, req.FriendUserID) + if err != nil { + return nil, err + } + if len(friendMembers) == 0 { + return &pbgroup.GetCommonGroupsWithFriendResp{ + Total: 0, + Groups: []*sdkws.GroupInfo{}, + }, nil + } + + commonGroupIDs := datautil.Distinct(datautil.Slice(friendMembers, func(e *model.GroupMember) string { + return e.GroupID + })) + groups, err := g.getGroupsInfo(ctx, commonGroupIDs) + if err != nil { + return nil, err + } + return &pbgroup.GetCommonGroupsWithFriendResp{ + Total: uint32(len(groups)), + Groups: groups, + }, nil +} + func (s *groupServer) InviteUserToGroup(ctx context.Context, req *pbgroup.InviteUserToGroupReq) (*pbgroup.InviteUserToGroupResp, error) { if len(req.InvitedUserIDs) == 0 { return nil, errs.ErrArgs.WrapMsg("user empty") diff --git a/scripts/get_common_group.sh b/scripts/get_common_group.sh new file mode 100755 index 000000000..bf98e82b9 --- /dev/null +++ b/scripts/get_common_group.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ====== 按需修改 ====== +API_BASE="${API_BASE:-http://127.0.0.1:10002}" # 你的 open-im-api 地址 +SELF_USER_ID="${SELF_USER_ID:-3932647710}" # 当前登录用户(拿 token 的用户) +#FRIEND_USER_ID="${FRIEND_USER_ID:-4391832441}" # 要查询共同群的好友 +FRIEND_USER_ID="${FRIEND_USER_ID:-9607566286}" # 要查询共同群的好友 +PLATFORM_ID="${PLATFORM_ID:-2}" # 1=iOS, 2=Android, 3=Windows... +ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}" # 管理员账号(用于签发用户 token) +ADMIN_SECRET="${ADMIN_SECRET:-openIM123}" # 配置中的 share.secret +DEBUG="${DEBUG:-0}" # DEBUG=1 打印请求/响应明细 +# ===================== + +debug_log() { + if [[ "${DEBUG}" == "1" ]]; then + echo "[DEBUG] $*" + fi +} + +print_json_safe() { + local raw="${1:-}" + if echo "${raw}" | jq -e . >/dev/null 2>&1; then + echo "${raw}" | jq . + else + echo "${raw}" + fi +} + +# 1) 先拿 user token(如果你已有 token,可跳过这一步,直接 export TOKEN=xxx) +if [[ -z "${TOKEN:-}" ]]; then + if [[ -z "${ADMIN_SECRET}" ]]; then + echo "缺少 ADMIN_SECRET,请先导出:export ADMIN_SECRET='你的share.secret'" + exit 1 + fi + + echo "获取管理员 token: ${ADMIN_USER_ID}" + OP_ID_ADMIN="op_admin_$(date +%s)" + debug_log "POST ${API_BASE}/auth/get_admin_token" + debug_log "operationID: ${OP_ID_ADMIN}" + debug_log "admin req body: {\"userID\":\"${ADMIN_USER_ID}\",\"secret\":\"***\"}" + ADMIN_RESP=$( + curl -sS -X POST "${API_BASE}/auth/get_admin_token" \ + -H 'Content-Type: application/json' \ + -H "operationID: ${OP_ID_ADMIN}" \ + -d "$(cat <}" + if [[ -z "${ADMIN_TOKEN}" ]]; then + echo "获取管理员 token 失败,响应如下:" + print_json_safe "${ADMIN_RESP}" + exit 1 + fi + + echo "获取用户 token: ${SELF_USER_ID}" + OP_ID_USER="op_user_$(date +%s)" + debug_log "POST ${API_BASE}/auth/get_user_token" + debug_log "operationID: ${OP_ID_USER}" + debug_log "user req body: {\"userID\":\"${SELF_USER_ID}\",\"platformID\":${PLATFORM_ID}}" + USER_RESP=$( + curl -sS -X POST "${API_BASE}/auth/get_user_token" \ + -H 'Content-Type: application/json' \ + -H "operationID: ${OP_ID_USER}" \ + -H "token: ${ADMIN_TOKEN}" \ + -d "$(cat <}" +fi + +if [[ -z "${TOKEN}" ]]; then + echo "获取用户 token 失败,响应如下:" + print_json_safe "${USER_RESP:-}" + echo "提示:请确认 SELF_USER_ID 用户已注册存在,或手动传入 TOKEN 后重试。" + exit 1 +fi + +OP_ID="op_$(date +%s)" + +# 2) 调共同群接口 +echo "查询共同群: self=${SELF_USER_ID}, friend=${FRIEND_USER_ID}" +REQ_BODY="$(cat < Date: Sat, 28 Mar 2026 21:51:54 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=85=B1=E5=90=8C?= =?UTF-8?q?=E7=BE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/get_common_group.sh | 54 ++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/scripts/get_common_group.sh b/scripts/get_common_group.sh index bf98e82b9..5936ec612 100755 --- a/scripts/get_common_group.sh +++ b/scripts/get_common_group.sh @@ -3,13 +3,21 @@ set -euo pipefail # ====== 按需修改 ====== API_BASE="${API_BASE:-http://127.0.0.1:10002}" # 你的 open-im-api 地址 -SELF_USER_ID="${SELF_USER_ID:-3932647710}" # 当前登录用户(拿 token 的用户) -#FRIEND_USER_ID="${FRIEND_USER_ID:-4391832441}" # 要查询共同群的好友 -FRIEND_USER_ID="${FRIEND_USER_ID:-9607566286}" # 要查询共同群的好友 +SELF_USER_ID="${SELF_USER_ID:-4642714021}" # 当前登录用户(拿 token 的用户) +#FRIEND_USER_ID="${FRIEND_USER_ID:-1971806090}" # 要查询共同群的好友 +FRIEND_USER_ID="${FRIEND_USER_ID:-3870738564}" # 要查询共同群的好友 PLATFORM_ID="${PLATFORM_ID:-2}" # 1=iOS, 2=Android, 3=Windows... ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}" # 管理员账号(用于签发用户 token) ADMIN_SECRET="${ADMIN_SECRET:-openIM123}" # 配置中的 share.secret -DEBUG="${DEBUG:-0}" # DEBUG=1 打印请求/响应明细 +DEBUG="${DEBUG:-1}" # DEBUG=1 打印请求/响应明细 +# RecordNotFoundError(errCode=1004)常见于 get_user_token: +# 服务端会查用户是否存在(user RPC GetDesignateUsers);若 SELF_USER_ID 未注册, +# 返回空列表后 rpcli.firstValue 会包装为 ErrRecordNotFound(errDlt: record not found)。 +# 处理:先注册该用户,或 export SELF_USER_ID=已存在用户,或 export TOKEN=已有用户 token 跳过拉 token。 +# +# HTTP 404 + 响应体 "404 page not found"(Gin):当前连上的 API 进程路由表里没有该路径。 +# 本仓库已注册 POST /group/get_common_groups_with_friend(见 internal/api/router.go)。 +# 处理:用当前代码重新编译/替换镜像并重启 openim-api,或确认 API_BASE 指向的就是带该路由的实例(无错误路径前缀/反代截断)。 # ===================== debug_log() { @@ -86,7 +94,15 @@ fi if [[ -z "${TOKEN}" ]]; then echo "获取用户 token 失败,响应如下:" print_json_safe "${USER_RESP:-}" - echo "提示:请确认 SELF_USER_ID 用户已注册存在,或手动传入 TOKEN 后重试。" + USER_ERR_CODE="$(echo "${USER_RESP:-}" | jq -r '.errCode // empty')" + if [[ "${USER_ERR_CODE}" == "1004" ]]; then + echo "" + echo "【排查】errCode 1004 (RecordNotFoundError):当前请求的 userID 在用户库中不存在。" + echo " - 服务端路径:auth GetUserToken → user GetDesignateUsers → 未命中则空结果 → record not found" + echo " - 请先将 SELF_USER_ID=${SELF_USER_ID} 注册进系统,或改用已存在用户,或: export TOKEN='你的用户token'" + else + echo "提示:请确认 SELF_USER_ID 已注册、ADMIN_SECRET 与部署一致,或手动 export TOKEN 后重试。" + fi exit 1 fi @@ -103,10 +119,28 @@ JSON debug_log "POST ${API_BASE}/group/get_common_groups_with_friend" debug_log "operationID: ${OP_ID}" debug_log "group req body: ${REQ_BODY}" -GROUP_RESP="$(curl -sS -X POST "${API_BASE}/group/get_common_groups_with_friend" \ - -H 'Content-Type: application/json' \ - -H "token: ${TOKEN}" \ - -H "operationID: ${OP_ID}" \ - -d "${REQ_BODY}")" +GROUP_BODY="$(mktemp)" +GROUP_HTTP_CODE="$( + curl -sS -o "${GROUP_BODY}" -w "%{http_code}" -X POST "${API_BASE}/group/get_common_groups_with_friend" \ + -H 'Content-Type: application/json' \ + -H "token: ${TOKEN}" \ + -H "operationID: ${OP_ID}" \ + -d "${REQ_BODY}" +)" +GROUP_RESP="$(cat "${GROUP_BODY}")" +rm -f "${GROUP_BODY}" +debug_log "group HTTP status: ${GROUP_HTTP_CODE}" debug_log "group raw resp: ${GROUP_RESP}" print_json_safe "${GROUP_RESP}" +if [[ "${GROUP_HTTP_CODE}" == "404" ]] || [[ "${GROUP_RESP}" == "404 page not found" ]]; then + echo "" + echo "【排查】HTTP 404:Gin 未匹配到路由,通常表示当前运行的 openim-api 版本过旧,不含 get_common_groups_with_friend。" + echo " - 期望路径: POST ${API_BASE}/group/get_common_groups_with_friend" + echo " - 请用本仓库代码重新构建并重启 API,或核对 API_BASE / 网关是否多删、少拼了路径前缀。" + exit 1 +fi +if [[ "${GROUP_HTTP_CODE}" != "200" ]]; then + echo "" + echo "【提示】HTTP 状态码: ${GROUP_HTTP_CODE}(非 200),请结合响应体与网关/鉴权配置排查。" + exit 1 +fi From d1f83cf2be763264533d1f96561fe5f8c673b598 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:02:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E5=85=A8=E5=B1=80=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/user_global_black.go | 56 +++++++------------ .../storage/controller/user_global_black.go | 8 +-- .../storage/database/user_global_black.go | 4 +- scripts/global_blacklist_api.sh | 28 +++++----- 4 files changed, 40 insertions(+), 56 deletions(-) diff --git a/internal/api/user_global_black.go b/internal/api/user_global_black.go index 36f6aaedc..543960aa0 100644 --- a/internal/api/user_global_black.go +++ b/internal/api/user_global_black.go @@ -27,12 +27,12 @@ func NewUserGlobalBlackApi(blacklistDB controller.UserGlobalBlackDatabase, userD } type addGlobalBlacklistReq struct { - Nicknames []string `json:"nicknames" binding:"required,min=1"` - Reason string `json:"reason"` + UserIDs []string `json:"userIDs" binding:"required,min=1"` + Reason string `json:"reason"` } type removeGlobalBlacklistReq struct { - Nicknames []string `json:"nicknames" binding:"required,min=1"` + UserIDs []string `json:"userIDs" binding:"required,min=1"` } type getGlobalBlacklistReq struct { @@ -64,24 +64,25 @@ func (b *UserGlobalBlackApi) AddGlobalBlacklist(c *gin.Context) { return } operatorID := mcontext.GetOpUserID(c) - blacks := make([]*model.UserGlobalBlack, 0, len(req.Nicknames)) - for _, nickname := range req.Nicknames { - users, err := b.userDB.TakeByNickname(c, nickname) - if err != nil { - apiresp.GinError(c, err) - return - } - if len(users) == 0 { - apiresp.GinError(c, errs.ErrRecordNotFound.WrapMsg("nickname not found", "nickname", nickname)) - return - } - if len(users) > 1 { - apiresp.GinError(c, errs.ErrArgs.WrapMsg("nickname matched multiple users", "nickname", nickname)) + foundUsers, err := b.userDB.Find(c, req.UserIDs) + if err != nil { + apiresp.GinError(c, err) + return + } + userMap := make(map[string]*model.User, len(foundUsers)) + for _, u := range foundUsers { + userMap[u.UserID] = u + } + blacks := make([]*model.UserGlobalBlack, 0, len(req.UserIDs)) + for _, userID := range req.UserIDs { + u, ok := userMap[userID] + if !ok { + apiresp.GinError(c, errs.ErrRecordNotFound.WrapMsg("userID not found", "userID", userID)) return } blacks = append(blacks, &model.UserGlobalBlack{ - UserID: users[0].UserID, - Nickname: users[0].Nickname, + UserID: u.UserID, + Nickname: u.Nickname, OperatorID: operatorID, Reason: req.Reason, }) @@ -119,24 +120,7 @@ func (b *UserGlobalBlackApi) RemoveGlobalBlacklist(c *gin.Context) { apiresp.GinError(c, err) return } - userIDs := make([]string, 0, len(req.Nicknames)) - for _, nickname := range req.Nicknames { - users, err := b.userDB.TakeByNickname(c, nickname) - if err != nil { - apiresp.GinError(c, err) - return - } - if len(users) == 0 { - apiresp.GinError(c, errs.ErrRecordNotFound.WrapMsg("nickname not found", "nickname", nickname)) - return - } - if len(users) > 1 { - apiresp.GinError(c, errs.ErrArgs.WrapMsg("nickname matched multiple users", "nickname", nickname)) - return - } - userIDs = append(userIDs, users[0].UserID) - } - if err := b.blacklistDB.RemoveBlack(c, userIDs); err != nil { + if err := b.blacklistDB.RemoveBlack(c, req.UserIDs); err != nil { apiresp.GinError(c, err) return } diff --git a/pkg/common/storage/controller/user_global_black.go b/pkg/common/storage/controller/user_global_black.go index f415f8a3f..ba1448237 100644 --- a/pkg/common/storage/controller/user_global_black.go +++ b/pkg/common/storage/controller/user_global_black.go @@ -12,8 +12,8 @@ import ( type UserGlobalBlackDatabase interface { // AddBlack 将用户加入全局黑名单 AddBlack(ctx context.Context, blacks []*model.UserGlobalBlack) error - // RemoveBlack 按昵称将用户从全局黑名单移除 - RemoveBlack(ctx context.Context, nicknames []string) error + // RemoveBlack 按 userID 将用户从全局黑名单移除 + RemoveBlack(ctx context.Context, userIDs []string) error // IsBlocked 检查用户是否在全局黑名单 IsBlocked(ctx context.Context, userID string) (bool, error) // GetBlackList 分页获取黑名单列表 @@ -32,8 +32,8 @@ func (u *userGlobalBlackDatabase) AddBlack(ctx context.Context, blacks []*model. return u.db.Add(ctx, blacks) } -func (u *userGlobalBlackDatabase) RemoveBlack(ctx context.Context, nicknames []string) error { - return u.db.Remove(ctx, nicknames) +func (u *userGlobalBlackDatabase) RemoveBlack(ctx context.Context, userIDs []string) error { + return u.db.Remove(ctx, userIDs) } func (u *userGlobalBlackDatabase) IsBlocked(ctx context.Context, userID string) (bool, error) { diff --git a/pkg/common/storage/database/user_global_black.go b/pkg/common/storage/database/user_global_black.go index 82f52c493..a30dbaadf 100644 --- a/pkg/common/storage/database/user_global_black.go +++ b/pkg/common/storage/database/user_global_black.go @@ -11,8 +11,8 @@ import ( type UserGlobalBlack interface { // Add 批量添加用户到全局黑名单 Add(ctx context.Context, blacks []*model.UserGlobalBlack) error - // Remove 按昵称从全局黑名单移除用户 - Remove(ctx context.Context, nicknames []string) error + // Remove 按 userID 从全局黑名单移除用户 + Remove(ctx context.Context, userIDs []string) error // Find 查询指定用户是否在黑名单(返回在黑名单中的记录) Find(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) // IsBlocked 检查单个用户是否在黑名单 diff --git a/scripts/global_blacklist_api.sh b/scripts/global_blacklist_api.sh index da7ad89f0..c8bf470ed 100755 --- a/scripts/global_blacklist_api.sh +++ b/scripts/global_blacklist_api.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -# 统一通过 API 新链路管理全局黑名单(按 nickname) +# 统一通过 API 新链路管理全局黑名单(按 userID) # # 用法: # 1) 添加 -# ./scripts/global_blacklist_api.sh add "alice,bob" [reason] +# ./scripts/global_blacklist_api.sh add "user001,user002" [reason] # # 2) 删除 -# ./scripts/global_blacklist_api.sh remove "alice,bob" +# ./scripts/global_blacklist_api.sh remove "user001,user002" # # 3) 查询 # ./scripts/global_blacklist_api.sh list [pageNumber] [showNumber] @@ -26,7 +26,7 @@ ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}" OPERATION_ID="${OPERATION_ID:-gb_$(date +%s)_$RANDOM}" ACTION="${1:-}" -NICKNAMES_RAW="${2:-}" +USERIDS_RAW="${2:-}" REASON="${3:-manual_by_api_script}" PAGE_NUMBER="${2:-1}" SHOW_NUMBER="${3:-20}" @@ -43,7 +43,7 @@ trim() { printf '%s' "$s" } -nicknames_csv_to_json_array() { +userids_csv_to_json_array() { local csv="$1" local arr_json="[" local first=1 @@ -63,7 +63,7 @@ nicknames_csv_to_json_array() { arr_json="${arr_json}]" if [[ "$arr_json" == "[]" ]]; then - die "nicknames 为空,请传入逗号分隔昵称,如 \"alice,bob\"" + die "userIDs 为空,请传入逗号分隔的 userID,如 \"user001,user002\"" fi printf '%s' "$arr_json" } @@ -128,8 +128,8 @@ call_api() { if [[ -z "$ACTION" ]]; then cat <<'EOF' 用法: - 添加: ./scripts/global_blacklist_api.sh add "alice,bob" [reason] - 删除: ./scripts/global_blacklist_api.sh remove "alice,bob" + 添加: ./scripts/global_blacklist_api.sh add "user001,user002" [reason] + 删除: ./scripts/global_blacklist_api.sh remove "user001,user002" 查询: ./scripts/global_blacklist_api.sh list [pageNumber] [showNumber] EOF exit 1 @@ -142,17 +142,17 @@ fi case "$ACTION" in add) - [[ -z "$NICKNAMES_RAW" ]] && die "add 需要 nicknames 参数" - NICKNAMES_JSON="$(nicknames_csv_to_json_array "$NICKNAMES_RAW")" - BODY="{\"nicknames\":${NICKNAMES_JSON},\"reason\":\"${REASON}\"}" + [[ -z "$USERIDS_RAW" ]] && die "add 需要 userIDs 参数" + USERIDS_JSON="$(userids_csv_to_json_array "$USERIDS_RAW")" + BODY="{\"userIDs\":${USERIDS_JSON},\"reason\":\"${REASON}\"}" echo ">>> POST /user/add_global_blacklist" call_api "/user/add_global_blacklist" "$BODY" "$ADMIN_TOKEN" ;; remove) - [[ -z "$NICKNAMES_RAW" ]] && die "remove 需要 nicknames 参数" - NICKNAMES_JSON="$(nicknames_csv_to_json_array "$NICKNAMES_RAW")" - BODY="{\"nicknames\":${NICKNAMES_JSON}}" + [[ -z "$USERIDS_RAW" ]] && die "remove 需要 userIDs 参数" + USERIDS_JSON="$(userids_csv_to_json_array "$USERIDS_RAW")" + BODY="{\"userIDs\":${USERIDS_JSON}}" echo ">>> POST /user/remove_global_blacklist" call_api "/user/remove_global_blacklist" "$BODY" "$ADMIN_TOKEN" ;; From 98b6b249ee12be789596692079d9930f9625bec7 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:41:43 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=85=B1=E5=90=8C?= =?UTF-8?q?=E6=89=80=E5=9C=A8=E7=9A=84=E7=BE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/openim-rpc-group.yml | 1 + internal/rpc/group/group.go | 21 ++++++++++++++++++++- pkg/common/config/config.go | 1 + scripts/get_common_group.sh | 4 ++-- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/config/openim-rpc-group.yml b/config/openim-rpc-group.yml index a8c2d5ec1..0f42f7661 100644 --- a/config/openim-rpc-group.yml +++ b/config/openim-rpc-group.yml @@ -18,3 +18,4 @@ prometheus: enableHistoryForNewMembers: true +commonGroupsLimitWithFriend: 3 diff --git a/internal/rpc/group/group.go b/internal/rpc/group/group.go index 3f97927f3..c7880d360 100644 --- a/internal/rpc/group/group.go +++ b/internal/rpc/group/group.go @@ -19,6 +19,7 @@ import ( "fmt" "math/big" "math/rand" + "sort" "strconv" "strings" "time" @@ -371,6 +372,7 @@ func (g *groupServer) GetCommonGroupsWithFriend(ctx context.Context, req *pbgrou if err != nil { return nil, err } + if len(selfGroupIDs) == 0 { return &pbgroup.GetCommonGroupsWithFriendResp{ Total: 0, @@ -382,6 +384,7 @@ func (g *groupServer) GetCommonGroupsWithFriend(ctx context.Context, req *pbgrou if err != nil { return nil, err } + if len(friendMembers) == 0 { return &pbgroup.GetCommonGroupsWithFriendResp{ Total: 0, @@ -392,12 +395,28 @@ func (g *groupServer) GetCommonGroupsWithFriend(ctx context.Context, req *pbgrou commonGroupIDs := datautil.Distinct(datautil.Slice(friendMembers, func(e *model.GroupMember) string { return e.GroupID })) + groups, err := g.getGroupsInfo(ctx, commonGroupIDs) if err != nil { return nil, err } + + // Keep response deterministic by sorting common groups with member count descending. + sort.SliceStable(groups, func(i, j int) bool { + return groups[i].MemberCount > groups[j].MemberCount + }) + total := len(groups) + + limit := g.config.RpcConfig.CommonGroupsLimitWithFriend + if limit <= 0 { + limit = 3 + } + if len(groups) > limit { + groups = groups[:limit] + } + return &pbgroup.GetCommonGroupsWithFriendResp{ - Total: uint32(len(groups)), + Total: uint32(total), Groups: groups, }, nil } diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index 4cd202db4..df92eed4c 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -278,6 +278,7 @@ type Group struct { } `mapstructure:"rpc"` Prometheus Prometheus `mapstructure:"prometheus"` EnableHistoryForNewMembers bool `mapstructure:"enableHistoryForNewMembers"` + CommonGroupsLimitWithFriend int `mapstructure:"commonGroupsLimitWithFriend"` } type Msg struct { diff --git a/scripts/get_common_group.sh b/scripts/get_common_group.sh index 5936ec612..8fc0ac22f 100755 --- a/scripts/get_common_group.sh +++ b/scripts/get_common_group.sh @@ -3,9 +3,9 @@ set -euo pipefail # ====== 按需修改 ====== API_BASE="${API_BASE:-http://127.0.0.1:10002}" # 你的 open-im-api 地址 -SELF_USER_ID="${SELF_USER_ID:-4642714021}" # 当前登录用户(拿 token 的用户) +SELF_USER_ID="${SELF_USER_ID:-5694418935}" # 当前登录用户(拿 token 的用户) #FRIEND_USER_ID="${FRIEND_USER_ID:-1971806090}" # 要查询共同群的好友 -FRIEND_USER_ID="${FRIEND_USER_ID:-3870738564}" # 要查询共同群的好友 +FRIEND_USER_ID="${FRIEND_USER_ID:-1011009748}" # 要查询共同群的好友 PLATFORM_ID="${PLATFORM_ID:-2}" # 1=iOS, 2=Android, 3=Windows... ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}" # 管理员账号(用于签发用户 token) ADMIN_SECRET="${ADMIN_SECRET:-openIM123}" # 配置中的 share.secret From c56a3e1d5f9e773d9d3c5411b231f10359ddf633 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:06:35 +0800 Subject: [PATCH 6/6] Add protocol submodule --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6b7bf7006..b7293c2bd 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/openimsdk/open-im-server/v3 go 1.25.0 -replace github.com/openimsdk/protocol => ../protocol +replace github.com/openimsdk/protocol => ./protocol require ( firebase.google.com/go/v4 v4.14.1