From 583b9e88b7091f93fb8a930acc9cf962964267ec Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 17 Oct 2026 22:28:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E6=94=AF=E6=8C=81=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E9=82=AE=E7=AE=B1=E5=90=AF=E7=94=A8/=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=8E=A5=E5=8F=A3=E5=8F=8A=E7=94=A8=E6=88=B7=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BA=A7=E8=81=94=E5=88=A0=E9=99=A4=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/security.go | 33 +++++ backend/controllers/user.go | 136 ++++++++++++++++++ backend/listen/http_server/api_v1_compat.go | 2 + backend/listen/http_server/http_server.go | 2 + backend/services/access/catalog.go | 2 + backend/services/externalemail/repository.go | 14 ++ .../views/components/HomeAside/HomeAside.vue | 2 +- 7 files changed, 190 insertions(+), 1 deletion(-) diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 397923be..83abaca5 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -1168,6 +1168,39 @@ func VerifyOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *ht response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w) } +func DeleteExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + runtime := currentSecurityRuntime() + if ctx == nil || ctx.UserID <= 0 || runtime.external == nil { + writeSecurityError(w, response.NeedLogin, "请先登录") + return + } + var input externalEmailRequest + if err := decodeJSONRequest(w, req, &input); err != nil || input.ID <= 0 { + writeSecurityError(w, response.ParamsError, "请求无效") + return + } + if err := runtime.external.Delete(ctx.UserID, input.ID); err != nil { + writeSecurityError(w, response.ParamsError, "外部邮箱删除失败") + return + } + if runtime.verification != nil && runtime.verification.Store != nil { + runtime.verification.Store.InvalidateResource(input.ID, verification.PurposeExternalEmailBind) + runtime.verification.Store.InvalidateResource(input.ID, verification.PurposeExternalEmailChange) + runtime.verification.Store.InvalidateAccount(ctx.UserAccount) + } + if runtime.recovery != nil { + runtime.recovery.InvalidateUser(ctx.UserID) + } + loginVerificationState.Lock() + for id, item := range loginVerificationState.items { + if item.UserID == ctx.UserID { + delete(loginVerificationState.items, id) + } + } + loginVerificationState.Unlock() + response.NewSuccessResponse(map[string]any{"deleted": true}).FPrint(w) +} + func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 || runtime.external == nil { diff --git a/backend/controllers/user.go b/backend/controllers/user.go index b25731aa..d8c00f28 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -603,3 +603,139 @@ func writeUserAuthorizationError(w http.ResponseWriter, err error, recoveryMessa } writeSecurityError(w, response.NoAccessPrivileges, "无权执行该用户管理操作") } + +type userDeleteRequest struct { + Id int `json:"id"` + Account string `json:"account"` +} + +func DeleteUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var reqData userDeleteRequest + if err := decodeUserRequest(w, req, &reqData); err != nil { + response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) + return + } + if reqData.Id == 0 && reqData.Account == "" { + response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) + return + } + + var user models.User + var found bool + var err error + if reqData.Id != 0 { + found, err = db.Instance.Where("id=?", reqData.Id).Get(&user) + } else { + account, validationErr := validation.NormalizeAccount(reqData.Account) + if validationErr != nil { + response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) + return + } + found, err = db.Instance.Where("account=?", account).Get(&user) + } + if err != nil { + log.WithContext(ctx).Errorf("SQL Error: %+v", err) + response.NewErrorResponse(response.ServerError, "查询用户失败", "").FPrint(w) + return + } + if !found || user.ID == 0 { + response.NewErrorResponse(response.ParamsError, "用户不存在", "").FPrint(w) + return + } + + if ctx != nil && ctx.UserID == user.ID { + response.NewErrorResponse(response.ParamsError, "不能删除当前登录的账号", "").FPrint(w) + return + } + + targetRole := user.EffectiveRole() + if !requireAccountScope(ctx, w, user.ID) { + return + } + if err := authorizeCapability(ctx, access.CapabilityUserManage, targetRole, false); err != nil { + response.NewErrorResponse(response.NoAccessPrivileges, "无权删除该用户", "").FPrint(w) + return + } + + if targetRole == access.RoleSuperAdmin { + var superAdminCount int64 + superAdminCount, err = db.Instance.Where("role = ? OR is_admin = 1", string(access.RoleSuperAdmin)).Count(&models.User{}) + if err != nil { + log.WithContext(ctx).WithError(err).Error("count super admin failed") + response.NewErrorResponse(response.ServerError, "检查超级管理员数量失败", "").FPrint(w) + return + } + if superAdminCount <= 1 { + response.NewErrorResponse(response.ParamsError, "系统必须保留至少一个超级管理员,无法删除", "").FPrint(w) + return + } + } + + writeGuard := accountWriteGuard(ctx, user) + tx := db.Instance.NewSession() + if tx == nil { + response.NewErrorResponse(response.ServerError, "删除用户失败", "").FPrint(w) + return + } + defer tx.Close() + if err := tx.Begin(); err != nil { + log.WithContext(ctx).WithError(err).Error("begin user delete transaction failed") + response.NewErrorResponse(response.ServerError, "删除用户失败", "").FPrint(w) + return + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + if err := accountgroup.CheckWrite(tx, writeGuard); err != nil { + _ = tx.Rollback() + writeAccountGroupError(w, err) + return + } + + credentialService := credentialRuntime() + if credentialService != nil { + _ = credentialService.RevokeAllWithSession(tx, user.ID) + } + + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.AccountGroupMember{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.ExternalEmail{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.RecoveryCode{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.WebAuthnCredential{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.ClientCredential{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.ProtocolCredential{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.NotificationGrant{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.UserEmail{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.UserContact{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.Rule{}) + _, _ = tx.Where("user_id = ?", user.ID).Delete(&models.Group{}) + + deleteCond := tx.ID(user.ID) + accountgroup.ApplyScope(deleteCond, ctx.UserID, ctx.EffectiveRole()) + num, err := deleteCond.Delete(&models.User{}) + if err != nil { + _ = tx.Rollback() + log.WithContext(ctx).WithError(err).Error("delete user failed") + response.NewErrorResponse(response.ServerError, "删除用户失败", "").FPrint(w) + return + } + if num == 0 { + _ = tx.Rollback() + response.NewErrorResponse(response.ServerError, "用户删除未生效", "").FPrint(w) + return + } + + if err := tx.Commit(); err != nil { + _ = tx.Rollback() + log.WithContext(ctx).WithError(err).Error("commit user delete failed") + response.NewErrorResponse(response.ServerError, "删除用户失败", "").FPrint(w) + return + } + committed = true + + InvalidateSecurityRuntimeUser(user.ID, user.Account) + response.NewSuccessResponse(map[string]any{"deleted": true, "id": user.ID, "account": user.Account}).FPrint(w) +} diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index e636aaf5..95b32b21 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -54,6 +54,7 @@ func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/security/external-email/change", "/api/security/external-email/change", http.MethodPost, 16<<10, controllers.ChangeOwnExternalEmail) registerV1MethodRoute(mux, "/api/v1/security/external-email/change/verify", "/api/security/external-email/change/verify", http.MethodPost, 16<<10, controllers.VerifyOwnExternalEmail) registerV1MethodRoute(mux, "/api/v1/security/external-email/disable", "/api/security/external-email/disable", http.MethodPost, 16<<10, controllers.DisableExternalEmail) + registerV1MethodRoute(mux, "/api/v1/security/external-email/delete", "/api/security/external-email/delete", http.MethodPost, 16<<10, controllers.DeleteExternalEmail) registerV1MethodRoute(mux, "/api/v1/logout", "/api/logout", http.MethodPost, 4<<10, controllers.Logout) registerV1MethodRoute(mux, "/api/v1/system/restart", "/api/v1/system/restart", http.MethodPost, 4<<10, controllers.RestartSmail) registerV1MethodRoute(mux, "/api/v1/system/restart/preview", "/api/v1/system/restart/preview", http.MethodPost, 4<<10, controllers.PreviewRestartSmail) @@ -83,6 +84,7 @@ func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/rule/del", "/api/rule/del", http.MethodPost, 64<<10, controllers.DelRule) registerV1MethodRoute(mux, "/api/v1/user/create", "/api/user/create", http.MethodPost, 64<<10, controllers.CreateUser) registerV1MethodRoute(mux, "/api/v1/user/edit", "/api/user/edit", http.MethodPost, 64<<10, controllers.EditUser) + registerV1MethodRoute(mux, "/api/v1/user/delete", "/api/user/delete", http.MethodPost, 64<<10, controllers.DeleteUser) registerV1MethodRoute(mux, "/api/v1/user/profile", "/api/user/profile", http.MethodPost, 64<<10, controllers.UpdateProfile) registerV1MethodRoute(mux, "/api/v1/user/info", "/api/user/info", http.MethodPost, 64<<10, controllers.Info) registerV1MethodRoute(mux, "/api/v1/user/list", "/api/user/list", http.MethodPost, 64<<10, controllers.UserList) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index baf4f2b3..90662c5e 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -96,6 +96,7 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/api/security/external-email/change", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.ChangeOwnExternalEmail)))) mux.HandleFunc("/api/security/external-email/change/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyOwnExternalEmail)))) mux.HandleFunc("/api/security/external-email/disable", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DisableExternalEmail)))) + mux.HandleFunc("/api/security/external-email/delete", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DeleteExternalEmail)))) mux.HandleFunc("/api/logout", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.Logout)))) mux.HandleFunc("/api/group", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroup))) mux.HandleFunc("/api/group/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroupList))) @@ -136,6 +137,7 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/attachments/download/", onlyMethod(http.MethodGet, contextIterceptor(controllers.Download))) mux.HandleFunc("/api/user/create", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.CreateUser)))) mux.HandleFunc("/api/user/edit", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.EditUser)))) + mux.HandleFunc("/api/user/delete", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.DeleteUser)))) mux.HandleFunc("/api/user/profile", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UpdateProfile)))) mux.HandleFunc("/api/user/info", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.Info)))) mux.HandleFunc("/api/user/list", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UserList)))) diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index cf94edb0..62a93d07 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -23,6 +23,7 @@ var managementEndpoints = []Endpoint{ {Method: "POST", Path: "/api/user/create", Capability: CapabilityUserCreate}, {Method: "POST", Path: "/api/user/profile", Capability: CapabilityProfileWriteOwn}, {Method: "POST", Path: "/api/user/edit", Capability: CapabilityUserManage}, + {Method: "POST", Path: "/api/user/delete", Capability: CapabilityUserManage}, {Method: "*", Path: "/api/user/security/", Capability: CapabilityUserSecurityManage}, {Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead}, {Method: "POST", Path: "/api/user/list", Capability: CapabilityUserRead}, @@ -51,6 +52,7 @@ var managementEndpoints = []Endpoint{ {Method: "POST", Path: "/api/v1/user/create", Capability: CapabilityUserCreate}, {Method: "POST", Path: "/api/v1/user/profile", Capability: CapabilityProfileWriteOwn}, {Method: "POST", Path: "/api/v1/user/edit", Capability: CapabilityUserManage}, + {Method: "POST", Path: "/api/v1/user/delete", Capability: CapabilityUserManage}, {Method: "*", Path: "/api/v1/user/security/", Capability: CapabilityUserSecurityManage}, {Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead}, {Method: "POST", Path: "/api/v1/user/list", Capability: CapabilityUserRead}, diff --git a/backend/services/externalemail/repository.go b/backend/services/externalemail/repository.go index ad00493d..71b9b3a8 100644 --- a/backend/services/externalemail/repository.go +++ b/backend/services/externalemail/repository.go @@ -411,6 +411,20 @@ func (r *Repository) AbandonPending(userID, id int) error { return nil } +func (r *Repository) Delete(userID, id int) error { + if r == nil || r.engine == nil || userID <= 0 || id <= 0 { + return ErrInvalidBinding + } + affected, err := r.engine.Where("id = ? AND user_id = ?", id, userID).Delete(&models.ExternalEmail{}) + if err != nil { + return fmt.Errorf("delete external email binding: %w", err) + } + if affected == 0 { + return ErrBindingOwnership + } + return nil +} + func (r *Repository) Disable(userID, id int) error { if r == nil || r.engine == nil || userID <= 0 || id <= 0 { return ErrInvalidBinding diff --git a/frontend/src/views/components/HomeAside/HomeAside.vue b/frontend/src/views/components/HomeAside/HomeAside.vue index 6513d34a..eca7d025 100644 --- a/frontend/src/views/components/HomeAside/HomeAside.vue +++ b/frontend/src/views/components/HomeAside/HomeAside.vue @@ -57,7 +57,7 @@ {{ lang.settings }} - V2.11.1 + V2.12.0