diff --git a/internal/api/user_global_black.go b/internal/api/user_global_black.go index 543960aa0..8efcaf9d0 100644 --- a/internal/api/user_global_black.go +++ b/internal/api/user_global_black.go @@ -29,10 +29,14 @@ func NewUserGlobalBlackApi(blacklistDB controller.UserGlobalBlackDatabase, userD type addGlobalBlacklistReq struct { UserIDs []string `json:"userIDs" binding:"required,min=1"` Reason string `json:"reason"` + // Status 限制类型:1=冻结(可登录,不能收发消息);2=黑名单(不可登录,自动踢下线) + Status int32 `json:"status" binding:"required,oneof=1 2"` } type removeGlobalBlacklistReq struct { UserIDs []string `json:"userIDs" binding:"required,min=1"` + // Status 目标状态:0=恢复正常(同步从 blacklistDB 删除记录);1=冻结;2=黑名单 + Status int32 `json:"status" binding:"oneof=0 1 2"` } type getGlobalBlacklistReq struct { @@ -45,6 +49,8 @@ type globalBlackItem struct { OperatorID string `json:"operatorID"` Reason string `json:"reason"` CreateTime int64 `json:"createTime"` + // Status 限制类型:1=冻结,2=黑名单 + Status int32 `json:"status"` } type getGlobalBlacklistResp struct { @@ -52,7 +58,8 @@ type getGlobalBlacklistResp struct { Blacks []globalBlackItem `json:"blacks"` } -// AddGlobalBlacklist 管理员将用户加入全局黑名单,并立即踢下线(所有平台 token 标记 KickedToken) +// AddGlobalBlacklist 管理员设置用户限制状态。 +// Status=1(冻结):可登录,但不能收发消息;Status=2(黑名单):不可登录,自动踢下线,不能收发消息。 func (b *UserGlobalBlackApi) AddGlobalBlacklist(c *gin.Context) { var req addGlobalBlacklistReq if err := c.ShouldBindJSON(&req); err != nil { @@ -85,31 +92,44 @@ func (b *UserGlobalBlackApi) AddGlobalBlacklist(c *gin.Context) { Nickname: u.Nickname, OperatorID: operatorID, Reason: req.Reason, + Status: req.Status, }) } 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) + // 同步更新 user 集合中的状态字段 + for _, userID := range req.UserIDs { + if err := b.userDB.UpdateByMap(c, userID, map[string]any{"status": req.Status}); err != nil { + log.ZWarn(c, "AddGlobalBlacklist: UpdateByMap status failed", err, + "userID", userID, "status", req.Status) + } + } + // 仅黑名单(Status=2)需要踢下线:断开 WS 长连接并将 token 标记为 KickedToken + if req.Status == model.UserStatusBlacklist { + 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 管理员从全局黑名单移除用户 +// RemoveGlobalBlacklist 管理员更新用户账号状态。 +// 执行顺序: +// 1. 将 user 集合中的 status 字段更新为请求值 +// 2. 仅当 status == 0(恢复正常)时,才从 blacklistDB 删除该用户的限制记录 +// +// 说明:blacklistDB 是 auth/msg 层的拦截依据;状态先落 user 集合, +// 只有确认目标状态为"正常"时才清除黑名单记录,避免状态写入成功但记录未删导致仍被拦截。 func (b *UserGlobalBlackApi) RemoveGlobalBlacklist(c *gin.Context) { var req removeGlobalBlacklistReq if err := c.ShouldBindJSON(&req); err != nil { @@ -120,9 +140,19 @@ func (b *UserGlobalBlackApi) RemoveGlobalBlacklist(c *gin.Context) { apiresp.GinError(c, err) return } - if err := b.blacklistDB.RemoveBlack(c, req.UserIDs); err != nil { - apiresp.GinError(c, err) - return + for _, userID := range req.UserIDs { + if err := b.userDB.UpdateByMap(c, userID, map[string]any{"status": req.Status}); err != nil { + log.ZError(c, "RemoveGlobalBlacklist: UpdateByMap status failed", err, "userID", userID, "status", req.Status) + apiresp.GinError(c, err) + return + } + } + // 只有目标状态为 0(正常)时才删除 blacklistDB 中的限制记录 + if req.Status == model.UserStatusNormal { + if err := b.blacklistDB.RemoveBlack(c, req.UserIDs); err != nil { + apiresp.GinError(c, err) + return + } } apiresp.GinSuccess(c, nil) } @@ -151,6 +181,7 @@ func (b *UserGlobalBlackApi) GetGlobalBlacklist(c *gin.Context) { OperatorID: blk.OperatorID, Reason: blk.Reason, CreateTime: blk.CreateTime.UnixMilli(), + Status: blk.Status, }) } apiresp.GinSuccess(c, getGlobalBlacklistResp{Total: total, Blacks: items}) diff --git a/internal/rpc/auth/auth.go b/internal/rpc/auth/auth.go index bb7a95ce1..d5846b715 100644 --- a/internal/rpc/auth/auth.go +++ b/internal/rpc/auth/auth.go @@ -32,6 +32,7 @@ import ( "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/model" pbauth "github.com/openimsdk/protocol/auth" "github.com/openimsdk/protocol/constant" "github.com/openimsdk/protocol/msggateway" @@ -140,13 +141,13 @@ func (s *authServer) GetUserToken(ctx context.Context, req *pbauth.GetUserTokenR 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. + // 仅黑名单(status=2)禁止登录;冻结(status=1)允许获取 token,仅在收发消息层面拦截 + status, _ := s.blacklistDB.GetStatus(ctx, req.UserID) + if status == model.UserStatusBlacklist { 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) + log.ZWarn(ctx, "GetUserToken is blocked", errors.New("user is in global blacklist, userID="+req.UserID), "userID", req.UserID, "status", status) return nil, servererrs.ErrUserBlocked.WithDetail("user is in global blacklist, userID=" + req.UserID) } token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(req.PlatformID)) @@ -167,14 +168,13 @@ 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. + // 非管理员用户检查全局黑名单:仅 status=2(黑名单)拦截;status=1(冻结)允许通过 token 校验 + status, _ := s.blacklistDB.GetStatus(ctx, claims.UserID) + if status == model.UserStatusBlacklist { 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) + log.ZWarn(ctx, "parseToken is blocked", errors.New("user is in global blacklist, userID="+claims.UserID), "userID", claims.UserID, "status", status) return nil, servererrs.ErrUserBlocked.WithDetail("user is in global blacklist, userID=" + claims.UserID) } m, err := s.authDatabase.GetTokensWithoutError(ctx, claims.UserID, claims.PlatformID) diff --git a/internal/rpc/msg/send.go b/internal/rpc/msg/send.go index f754f9057..e96f0d935 100644 --- a/internal/rpc/msg/send.go +++ b/internal/rpc/msg/send.go @@ -34,6 +34,10 @@ import ( func (m *msgServer) SendMsg(ctx context.Context, req *pbmsg.SendMsgReq) (*pbmsg.SendMsgResp, error) { if req.MsgData != nil { m.encapsulateMsgData(req.MsgData) + // 全局账号状态校验:冻结/黑名单用户不可收发消息 + if err := m.verifyUserStatus(ctx, req); err != nil { + return nil, err + } switch req.MsgData.SessionType { case constant.SingleChatType: return m.sendMsgSingleChat(ctx, req) diff --git a/internal/rpc/msg/server.go b/internal/rpc/msg/server.go index 2b91c4405..de00217ea 100644 --- a/internal/rpc/msg/server.go +++ b/internal/rpc/msg/server.go @@ -71,6 +71,7 @@ type msgServer struct { webhookClient *webhook.Client conversationClient *rpcli.ConversationClient spamReportDB database.SpamReport + globalBlackDB controller.UserGlobalBlackDatabase } func (m *msgServer) addInterceptorHandler(interceptorFunc ...MessageInterceptorFunc) { @@ -127,6 +128,10 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg if err != nil { return err } + globalBlackMgo, err := mgo.NewUserGlobalBlackMongo(mgocli.GetDB()) + if err != nil { + return err + } s := &msgServer{ MsgDatabase: msgDatabase, RegisterCenter: client, @@ -138,6 +143,7 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg webhookClient: webhook.NewWebhookClient(config.WebhooksConfig.URL), conversationClient: conversationClient, spamReportDB: spamReportDB, + globalBlackDB: controller.NewUserGlobalBlackDatabase(globalBlackMgo), } s.notificationSender = notification.NewNotificationSender(&config.NotificationConfig, notification.WithLocalSendMsg(s.SendMsg)) diff --git a/internal/rpc/msg/verify.go b/internal/rpc/msg/verify.go index 8b4d53dd0..1f5c45661 100644 --- a/internal/rpc/msg/verify.go +++ b/internal/rpc/msg/verify.go @@ -21,6 +21,7 @@ import ( "time" "github.com/openimsdk/open-im-server/v3/pkg/common/servererrs" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" "github.com/openimsdk/protocol/constant" "github.com/openimsdk/protocol/msg" "github.com/openimsdk/protocol/sdkws" @@ -50,6 +51,43 @@ type MessageRevoked struct { Seq uint32 `json:"seq"` } +// verifyUserStatus 校验发送方/接收方的全局账号状态。 +// 任意一方处于冻结(1)或黑名单(2)即拒绝消息发送/投递。 +// 通知类消息(NotificationBegin~NotificationEnd)和管理员发送方放行。 +func (m *msgServer) verifyUserStatus(ctx context.Context, data *msg.SendMsgReq) error { + if data == nil || data.MsgData == nil { + return nil + } + if data.MsgData.ContentType >= constant.NotificationBegin && data.MsgData.ContentType <= constant.NotificationEnd { + return nil + } + sendID := data.MsgData.SendID + if datautil.Contain(sendID, m.config.Share.IMAdminUserID...) { + return nil + } + if sendID != "" { + st, err := m.globalBlackDB.GetStatus(ctx, sendID) + if err != nil { + log.ZWarn(ctx, "verifyUserStatus: GetStatus(send) failed", err, "sendID", sendID) + } else if st == model.UserStatusFrozen || st == model.UserStatusBlacklist { + return servererrs.ErrUserBlocked.WithDetail("sender is restricted, status=" + strconv.Itoa(int(st))) + } + } + // 单聊:同时校验接收方状态;群聊接收方拦截在推送层处理 + if data.MsgData.SessionType == constant.SingleChatType { + recvID := data.MsgData.RecvID + if recvID != "" && !datautil.Contain(recvID, m.config.Share.IMAdminUserID...) { + st, err := m.globalBlackDB.GetStatus(ctx, recvID) + if err != nil { + log.ZWarn(ctx, "verifyUserStatus: GetStatus(recv) failed", err, "recvID", recvID) + } else if st == model.UserStatusFrozen || st == model.UserStatusBlacklist { + return servererrs.ErrMsgReceiveNotAllowed.WrapMsg("receiver is restricted") + } + } + } + return nil +} + func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgReq) error { switch data.MsgData.SessionType { case constant.SingleChatType: diff --git a/pkg/common/storage/controller/user_global_black.go b/pkg/common/storage/controller/user_global_black.go index 2a6d114d4..973d87539 100644 --- a/pkg/common/storage/controller/user_global_black.go +++ b/pkg/common/storage/controller/user_global_black.go @@ -14,8 +14,10 @@ type UserGlobalBlackDatabase interface { AddBlack(ctx context.Context, blacks []*model.UserGlobalBlack) error // RemoveBlack 按 userID 将用户从全局黑名单移除 RemoveBlack(ctx context.Context, userIDs []string) error - // IsBlocked 检查用户是否在全局黑名单 + // IsBlocked 检查用户是否在全局黑名单(含冻结) IsBlocked(ctx context.Context, userID string) (bool, error) + // GetStatus 返回用户限制状态:0=正常,1=冻结,2=黑名单 + GetStatus(ctx context.Context, userID string) (int32, error) // FindBlocked 批量查询哪些 userID 在全局黑名单中,返回被封禁的记录 FindBlocked(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) // GetBlackList 分页获取黑名单列表 @@ -42,6 +44,10 @@ func (u *userGlobalBlackDatabase) IsBlocked(ctx context.Context, userID string) return u.db.IsBlocked(ctx, userID) } +func (u *userGlobalBlackDatabase) GetStatus(ctx context.Context, userID string) (int32, error) { + return u.db.GetStatus(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 index 686c2bf3f..8bca466db 100644 --- a/pkg/common/storage/database/mgo/user_global_black.go +++ b/pkg/common/storage/database/mgo/user_global_black.go @@ -37,7 +37,7 @@ func (u *UserGlobalBlackMgo) Add(ctx context.Context, blacks []*model.UserGlobal b.CreateTime = time.Now() } } - // 使用 upsert 避免重复插入报错 + // 使用 upsert 避免重复插入报错;status 也走 $set 以便升级/降级(冻结↔黑名单)时同步更新 for _, b := range blacks { filter := bson.M{"user_id": b.UserID} update := bson.M{ @@ -45,6 +45,7 @@ func (u *UserGlobalBlackMgo) Add(ctx context.Context, blacks []*model.UserGlobal "nickname": b.Nickname, "operator_id": b.OperatorID, "reason": b.Reason, + "status": b.Status, }, "$setOnInsert": bson.M{ "user_id": b.UserID, @@ -59,6 +60,20 @@ func (u *UserGlobalBlackMgo) Add(ctx context.Context, blacks []*model.UserGlobal return nil } +// GetStatus 返回 userID 对应的限制状态: +// 0=正常(无记录),1=冻结,2=黑名单 +func (u *UserGlobalBlackMgo) GetStatus(ctx context.Context, userID string) (int32, error) { + var doc model.UserGlobalBlack + err := u.coll.FindOne(ctx, bson.M{"user_id": userID}, options.FindOne().SetProjection(bson.M{"status": 1})).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return model.UserStatusNormal, nil + } + return model.UserStatusNormal, errs.Wrap(err) + } + return doc.Status, nil +} + func (u *UserGlobalBlackMgo) Remove(ctx context.Context, users []string) error { if len(users) == 0 { return nil diff --git a/pkg/common/storage/database/user_global_black.go b/pkg/common/storage/database/user_global_black.go index a30dbaadf..3c980d9d5 100644 --- a/pkg/common/storage/database/user_global_black.go +++ b/pkg/common/storage/database/user_global_black.go @@ -17,6 +17,8 @@ type UserGlobalBlack interface { Find(ctx context.Context, userIDs []string) ([]*model.UserGlobalBlack, error) // IsBlocked 检查单个用户是否在黑名单 IsBlocked(ctx context.Context, userID string) (bool, error) + // GetStatus 返回用户限制状态:0=正常,1=冻结,2=黑名单 + GetStatus(ctx context.Context, userID string) (int32, error) // Page 分页查询黑名单列表 Page(ctx context.Context, pagination pagination.Pagination) (count int64, blacks []*model.UserGlobalBlack, err error) } diff --git a/pkg/common/storage/model/user.go b/pkg/common/storage/model/user.go index 8c2e18167..dc02fa5cf 100644 --- a/pkg/common/storage/model/user.go +++ b/pkg/common/storage/model/user.go @@ -42,6 +42,14 @@ const ( MsgReceiveSettingNobody int32 = 2 ) +// UserStatus 用户账号状态枚举。 +// 0=正常;1=冻结(可登录,不能收发消息);2=黑名单(不可登录,自动踢下线,不能收发消息) +const ( + UserStatusNormal int32 = 0 + UserStatusFrozen int32 = 1 + UserStatusBlacklist int32 = 2 +) + type User struct { UserID string `bson:"user_id"` Nickname string `bson:"nickname"` @@ -57,6 +65,8 @@ type User struct { PhoneVisibility int32 `bson:"phone_visibility"` CallAcceptSetting int32 `bson:"call_accept_setting"` MsgReceiveSetting int32 `bson:"msg_receive_setting"` + // Status 账号状态:0=正常,1=冻结,2=黑名单 + Status int32 `bson:"status"` } func (u *User) GetNickname() string { diff --git a/pkg/common/storage/model/user_global_black.go b/pkg/common/storage/model/user_global_black.go index a0329cf86..1beac7b37 100644 --- a/pkg/common/storage/model/user_global_black.go +++ b/pkg/common/storage/model/user_global_black.go @@ -2,11 +2,14 @@ package model import "time" -// UserGlobalBlack 全局黑名单记录,被加入黑名单的用户无法登录 +// UserGlobalBlack 全局黑名单/冻结记录。 +// Status: 1=冻结(可登录,不能收发消息);2=黑名单(不可登录,自动踢下线,不能收发消息) 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"` + // Status 限制类型:1=冻结,2=黑名单 + Status int32 `bson:"status"` }