From 90f326b4f4d57822758502dc6d4b01f3e3cdddd8 Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 15:58:00 +0800 Subject: [PATCH 1/3] delete user --- config/openim-push.yml | 4 +- internal/api/delete_user.go | 143 ++++++++++++++++++++++++ internal/api/router.go | 4 + pkg/common/storage/database/mgo/user.go | 8 ++ pkg/common/storage/database/user.go | 3 + 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 internal/api/delete_user.go 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..f62b9ec4c --- /dev/null +++ b/internal/api/delete_user.go @@ -0,0 +1,143 @@ +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. +// Only IM admins may call this endpoint. +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 + } + if err := authverify.CheckAdmin(c, 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..519f3bf31 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,9 @@ 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 From 21088f21768b4fb68fc16d99a1a86cad91faafae Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 18:51:17 +0800 Subject: [PATCH 2/3] delete account --- internal/api/delete_user.go | 5 +++-- internal/api/router.go | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/delete_user.go b/internal/api/delete_user.go index f62b9ec4c..a9a1bb1ca 100644 --- a/internal/api/delete_user.go +++ b/internal/api/delete_user.go @@ -46,14 +46,15 @@ type deleteUserReq struct { // DeleteUser permanently deletes a user account and cleans up associated data. // Steps: force-logout → delete friends → quit/kick groups → hard-delete user doc. -// Only IM admins may call this endpoint. +// 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 } - if err := authverify.CheckAdmin(c, d.imAdminUserIDs); err != nil { + // 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 } diff --git a/internal/api/router.go b/internal/api/router.go index 519f3bf31..9430cc7ed 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -180,7 +180,6 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) userRouterGroup.POST("/get_global_blacklist", bl.GetGlobalBlacklist) - // 真实删除账号(仅管理员) userRouterGroup.POST("/delete_user", du.DeleteUser) } // friend routing group From 6c6292a817297f04bfafc53b13e4ab9529026c6e Mon Sep 17 00:00:00 2001 From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com> Date: Thu, 7 May 2026 21:14:55 +0800 Subject: [PATCH 3/3] delete account --- internal/api/delete_user.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/api/delete_user.go b/internal/api/delete_user.go index a9a1bb1ca..81c87103f 100644 --- a/internal/api/delete_user.go +++ b/internal/api/delete_user.go @@ -95,13 +95,13 @@ func (d *DeleteUserApi) DeleteUser(c *gin.Context) { "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) - } + //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) + //} } }