diff --git a/config/openim-push.yml b/config/openim-push.yml index 1bb84a172..64acb577f 100644 --- a/config/openim-push.yml +++ b/config/openim-push.yml @@ -18,7 +18,7 @@ prometheus: maxConcurrentWorkers: 3 #Use geTui for offline push notifications, or choose fcm or jpush; corresponding configuration settings must be specified. -enable: geTui +enable: fcm geTui: pushUrl: https://restapi.getui.com/v2/$appId masterSecret: @@ -28,7 +28,7 @@ geTui: channelName: fcm: # Prioritize using file paths. If the file path is empty, use URL - filePath: # File path is concatenated with the parameters passed in through - c(`mage` default pass in `config/`) and filePath. + filePath: sokim-firebase-adminsdk.json # File path is concatenated with the parameters passed in through - c(`mage` default pass in `config/`) and filePath. authURL: # Must start with https or http. jpush: appKey: diff --git a/internal/api/delete_user.go b/internal/api/delete_user.go new file mode 100644 index 000000000..81c87103f --- /dev/null +++ b/internal/api/delete_user.go @@ -0,0 +1,144 @@ +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/database" + "github.com/openimsdk/open-im-server/v3/pkg/rpcli" + "github.com/openimsdk/protocol/constant" + "github.com/openimsdk/protocol/group" + "github.com/openimsdk/protocol/relation" + "github.com/openimsdk/protocol/sdkws" + "github.com/openimsdk/tools/apiresp" + "github.com/openimsdk/tools/errs" + "github.com/openimsdk/tools/log" +) + +// DeleteUserApi handles real account deletion (hard delete). +// It follows the same direct-DB pattern as UserGlobalBlackApi. +type DeleteUserApi struct { + userDB database.User + authClient *rpcli.AuthClient + groupClient group.GroupClient + friendClient relation.FriendClient + imAdminUserIDs []string +} + +func NewDeleteUserApi( + userDB database.User, + authClient *rpcli.AuthClient, + groupClient group.GroupClient, + friendClient relation.FriendClient, + imAdminUserIDs []string, +) *DeleteUserApi { + return &DeleteUserApi{ + userDB: userDB, + authClient: authClient, + groupClient: groupClient, + friendClient: friendClient, + imAdminUserIDs: imAdminUserIDs, + } +} + +type deleteUserReq struct { + UserID string `json:"userID" binding:"required"` +} + +// DeleteUser permanently deletes a user account and cleans up associated data. +// Steps: force-logout → delete friends → quit/kick groups → hard-delete user doc. +// Caller must be the same user as userID, or an IM admin (see CheckAccessV3). +func (d *DeleteUserApi) DeleteUser(c *gin.Context) { + var req deleteUserReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + // Only the user themselves (or an IM admin) may delete the account. + if err := authverify.CheckAccessV3(c, req.UserID, d.imAdminUserIDs); err != nil { + apiresp.GinError(c, err) + return + } + + // 1. Verify user exists + users, err := d.userDB.Find(c, []string{req.UserID}) + if err != nil { + apiresp.GinError(c, err) + return + } + if len(users) == 0 { + apiresp.GinError(c, errs.ErrRecordNotFound.WrapMsg("user not found", "userID", req.UserID)) + return + } + + // 2. Force logout from every platform + for platformID := range constant.PlatformID2Name { + if int32(platformID) == constant.AdminPlatformID { + continue + } + if err := d.authClient.ForceLogout(c, req.UserID, int32(platformID)); err != nil { + log.ZWarn(c, "DeleteUser: ForceLogout failed", err, "userID", req.UserID, "platformID", platformID) + } + } + + // 3. Delete all friendships (both directions: target→friend and friend→target) + friendIDsResp, err := d.friendClient.GetFriendIDs(c, &relation.GetFriendIDsReq{UserID: req.UserID}) + if err != nil { + log.ZWarn(c, "DeleteUser: GetFriendIDs failed", err, "userID", req.UserID) + } else { + for _, friendID := range friendIDsResp.FriendIDs { + // Remove from target user's friend list + if _, err := d.friendClient.DeleteFriend(c, &relation.DeleteFriendReq{ + OwnerUserID: req.UserID, + FriendUserID: friendID, + }); err != nil { + log.ZWarn(c, "DeleteUser: DeleteFriend (owner→friend) failed", err, + "ownerUserID", req.UserID, "friendUserID", friendID) + } + // Remove from the friend's friend list + //if _, err := d.friendClient.DeleteFriend(c, &relation.DeleteFriendReq{ + // OwnerUserID: friendID, + // FriendUserID: req.UserID, + //}); err != nil { + // log.ZWarn(c, "DeleteUser: DeleteFriend (friend→owner) failed", err, + // "ownerUserID", friendID, "friendUserID", req.UserID) + //} + } + } + + // 4. Quit / kick from all joined groups (paginated, page size 100) + pageNumber := int32(1) + const pageSize = int32(100) + for { + groupListResp, err := d.groupClient.GetJoinedGroupList(c, &group.GetJoinedGroupListReq{ + FromUserID: req.UserID, + Pagination: &sdkws.RequestPagination{PageNumber: pageNumber, ShowNumber: pageSize}, + }) + if err != nil { + log.ZWarn(c, "DeleteUser: GetJoinedGroupList failed", err, "userID", req.UserID, "page", pageNumber) + break + } + for _, g := range groupListResp.Groups { + if _, err := d.groupClient.QuitGroup(c, &group.QuitGroupReq{ + GroupID: g.GroupID, + UserID: req.UserID, + }); err != nil { + log.ZWarn(c, "DeleteUser: QuitGroup failed", err, "userID", req.UserID, "groupID", g.GroupID) + } + } + if int32(len(groupListResp.Groups)) < pageSize { + break + } + pageNumber++ + } + + // 5. Hard-delete user document from MongoDB. + // Redis cache will become stale and expire via TTL; the user can no longer + // authenticate because their tokens were already invalidated in step 2. + if err := d.userDB.Delete(c, []string{req.UserID}); err != nil { + apiresp.GinError(c, err) + return + } + + log.ZInfo(c, "DeleteUser: user deleted", "userID", req.UserID) + apiresp.GinSuccess(c, nil) +} diff --git a/internal/api/router.go b/internal/api/router.go index 91866804d..9430cc7ed 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -137,6 +137,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID) cp := NewCaptchaApi(pbcaptcha.NewCaptchaClient(captchaConn)) bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn)) + du := NewDeleteUserApi(userDB, rpcli.NewAuthClient(authConn), group.NewGroupClient(groupConn), relation.NewFriendClient(friendConn), config.Share.IMAdminUserID) phoneSN := NewPhoneSNApi(phoneSNDB) userRouterGroup := r.Group("/user") { @@ -178,6 +179,8 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/add_global_blacklist", bl.AddGlobalBlacklist) userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) userRouterGroup.POST("/get_global_blacklist", bl.GetGlobalBlacklist) + + userRouterGroup.POST("/delete_user", du.DeleteUser) } // friend routing group { diff --git a/pkg/common/storage/database/mgo/user.go b/pkg/common/storage/database/mgo/user.go index d5f64a5ab..9ba0a0514 100644 --- a/pkg/common/storage/database/mgo/user.go +++ b/pkg/common/storage/database/mgo/user.go @@ -375,6 +375,14 @@ func (u *UserMgo) CountRangeEverydayTotal(ctx context.Context, start time.Time, return res, nil } +func (u *UserMgo) Delete(ctx context.Context, userIDs []string) error { + if len(userIDs) == 0 { + return nil + } + _, err := u.coll.DeleteMany(ctx, bson.M{"user_id": bson.M{"$in": userIDs}}) + return errs.Wrap(err) +} + func (u *UserMgo) SortQuery(ctx context.Context, userIDName map[string]string, asc bool) ([]*model.User, error) { if len(userIDName) == 0 { return nil, nil diff --git a/pkg/common/storage/database/user.go b/pkg/common/storage/database/user.go index 2682bc780..eb5685ee0 100644 --- a/pkg/common/storage/database/user.go +++ b/pkg/common/storage/database/user.go @@ -45,6 +45,9 @@ type User interface { SortQuery(ctx context.Context, userIDName map[string]string, asc bool) ([]*model.User, error) + // Delete permanently removes user documents by userID. + Delete(ctx context.Context, userIDs []string) error + // CRUD user command AddUserCommand(ctx context.Context, userID string, Type int32, UUID string, value string, ex string) error DeleteUserCommand(ctx context.Context, userID string, Type int32, UUID string) error