Merge pull request #19 from sok-im/feature/delete_user

Feature/delete user
This commit is contained in:
haoyunlt 2026-05-07 21:17:00 +08:00 committed by GitHub
commit d0d897b9b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 160 additions and 2 deletions

View File

@ -18,7 +18,7 @@ prometheus:
maxConcurrentWorkers: 3 maxConcurrentWorkers: 3
#Use geTui for offline push notifications, or choose fcm or jpush; corresponding configuration settings must be specified. #Use geTui for offline push notifications, or choose fcm or jpush; corresponding configuration settings must be specified.
enable: geTui enable: fcm
geTui: geTui:
pushUrl: https://restapi.getui.com/v2/$appId pushUrl: https://restapi.getui.com/v2/$appId
masterSecret: masterSecret:
@ -28,7 +28,7 @@ geTui:
channelName: channelName:
fcm: fcm:
# Prioritize using file paths. If the file path is empty, use URL # 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. authURL: # Must start with https or http.
jpush: jpush:
appKey: appKey:

144
internal/api/delete_user.go Normal file
View File

@ -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)
}

View File

@ -137,6 +137,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID) m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID)
cp := NewCaptchaApi(pbcaptcha.NewCaptchaClient(captchaConn)) cp := NewCaptchaApi(pbcaptcha.NewCaptchaClient(captchaConn))
bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn)) 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) phoneSN := NewPhoneSNApi(phoneSNDB)
userRouterGroup := r.Group("/user") 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("/add_global_blacklist", bl.AddGlobalBlacklist)
userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist) userRouterGroup.POST("/remove_global_blacklist", bl.RemoveGlobalBlacklist)
userRouterGroup.POST("/get_global_blacklist", bl.GetGlobalBlacklist) userRouterGroup.POST("/get_global_blacklist", bl.GetGlobalBlacklist)
userRouterGroup.POST("/delete_user", du.DeleteUser)
} }
// friend routing group // friend routing group
{ {

View File

@ -375,6 +375,14 @@ func (u *UserMgo) CountRangeEverydayTotal(ctx context.Context, start time.Time,
return res, nil 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) { func (u *UserMgo) SortQuery(ctx context.Context, userIDName map[string]string, asc bool) ([]*model.User, error) {
if len(userIDName) == 0 { if len(userIDName) == 0 {
return nil, nil return nil, nil

View File

@ -45,6 +45,9 @@ type User interface {
SortQuery(ctx context.Context, userIDName map[string]string, asc bool) ([]*model.User, error) 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 // CRUD user command
AddUserCommand(ctx context.Context, userID string, Type int32, UUID string, value string, ex string) error 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 DeleteUserCommand(ctx context.Context, userID string, Type int32, UUID string) error