From 4d54f1b03a5d4c9413bcefdb2b275d6d4b795941 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 17 Jun 2026 07:03:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=A2=9E=E5=8A=A0=E6=92=A4?= =?UTF-8?q?=E9=94=80=E7=94=A8=E6=88=B7=E5=AE=9E=E5=90=8D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实名姓名与收款账户不一致会导致收款码登记失败,新增后台撤销实名能力: - 新增 user:revoke_realname 权限,授予超级管理员与运营角色 - 撤销时重置用户实名状态为未认证并删除实名记录,使其可重新认证,写入审计日志 - 用户管理页新增实名状态列与"撤销实名"操作(含原因弹窗) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/modules/adminuser/dto.go | 4 ++ backend/internal/modules/adminuser/handler.go | 22 ++++++ .../internal/modules/adminuser/repository.go | 37 ++++++++++ backend/internal/modules/adminuser/service.go | 11 +++ backend/internal/router/router.go | 1 + ...000007_user_revoke_realname_permission.sql | 28 ++++++++ frontend/src/features/admin/api/adminUsers.ts | 17 ++++- .../features/admin/views/AdminUsersView.vue | 69 ++++++++++++++++++- 8 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/000007_user_revoke_realname_permission.sql diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index 391a212..345f41d 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -24,6 +24,10 @@ type FreezeRequest struct { Reason string `json:"reason"` } +type RevokeRealnameRequest struct { + Reason string `json:"reason"` +} + type DepositFreeQuotaRequest struct { AmountCent int64 `json:"amount_cent"` } diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index ffd48c3..1778160 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -110,6 +110,26 @@ func (h *Handler) SetDepositFreeQuota(c *gin.Context) { response.OK(c, item) } +func (h *Handler) RevokeRealname(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + userID, ok := parseID(c) + if !ok { + return + } + var req RevokeRealnameRequest + _ = c.ShouldBindJSON(&req) + item, err := h.service.RevokeRealname(c.Request.Context(), adminID, userID, req, auditMeta(c)) + if err != nil { + writeAdminUserError(c, err) + return + } + response.OK(c, item) +} + func currentAdminID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextAdminID) if !ok { @@ -142,6 +162,8 @@ func writeAdminUserError(c *gin.Context, err error) { response.ServiceUnavailable(c, "数据库未连接") case errors.Is(err, ErrInvalidUser): response.BadRequest(c, "用户不符合规则") + case errors.Is(err, ErrRealnameNotRevocable): + response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息") case IsNotFound(err): response.Error(c, http.StatusNotFound, "not_found", "用户不存在") default: diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 2cb0327..7965660 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -136,6 +136,43 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui return r.Find(ctx, userID) } +// RevokeRealname 撤销用户实名:将用户实名状态重置为未认证,并删除实名记录, +// 使用户可重新发起实名认证(用于实名姓名与收款账户不一致导致收款码登记失败的场景)。 +func (r *Repository) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return err + } + var realnameCount int64 + if err := tx.Model(&model.UserRealname{}).Where("user_id = ?", userID).Count(&realnameCount).Error; err != nil { + return err + } + // 已是未认证且无实名记录,无可撤销内容。 + if user.RealnameStatus == "unverified" && realnameCount == 0 { + return ErrRealnameNotRevocable + } + beforeRealnameStatus := user.RealnameStatus + user.RealnameStatus = "unverified" + if err := tx.Save(&user).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.UserRealname{}).Error; err != nil { + return err + } + return appendAuditLog(tx, adminID, "admin_user.revoke_realname", user.ID, meta, map[string]any{ + "user_id": user.ID, + "reason": req.Reason, + "before_realname_status": beforeRealnameStatus, + "after_realname_status": user.RealnameStatus, + }) + }) + if err != nil { + return nil, err + } + return r.Find(ctx, userID) +} + func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error) { var row userRow err := r.db.WithContext(ctx).Table("users AS u"). diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index 8302fe5..0153dad 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -8,6 +8,7 @@ import ( var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidUser = errors.New("invalid user") + ErrRealnameNotRevocable = errors.New("realname not revocable") ) type Service struct { @@ -54,3 +55,13 @@ func (s *Service) SetDepositFreeQuota(ctx context.Context, adminID uint64, userI } return s.repo.SetDepositFreeQuota(ctx, adminID, userID, req, meta) } + +func (s *Service) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 { + return nil, ErrInvalidUser + } + return s.repo.RevokeRealname(ctx, adminID, userID, req, meta) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 43e1fcf..27e91a7 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -482,6 +482,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze) adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze) adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota) + adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname) adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) diff --git a/backend/migrations/000007_user_revoke_realname_permission.sql b/backend/migrations/000007_user_revoke_realname_permission.sql new file mode 100644 index 0000000..9d73634 --- /dev/null +++ b/backend/migrations/000007_user_revoke_realname_permission.sql @@ -0,0 +1,28 @@ +-- +goose Up + +-- 新增“撤销用户实名”权限:用于实名姓名与收款账户不一致导致收款码登记失败时, +-- 由后台撤销用户实名,让用户可重新发起实名认证。 +INSERT INTO permissions (code, name, resource, action) VALUES +('user:revoke_realname', '撤销用户实名', 'user', 'revoke_realname') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +-- super_admin 拥有全部权限,补授新权限。 +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'super_admin' AND p.code = 'user:revoke_realname'; + +-- ops(运营)负责用户管理,授予撤销实名权限。 +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'ops' AND p.code = 'user:revoke_realname'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'user:revoke_realname'; + +DELETE FROM permissions WHERE code = 'user:revoke_realname'; diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index 10a61c9..47d2d90 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -31,9 +31,12 @@ export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQ const params: Record = { page, page_size: pageSize } if (query.keyword) params.keyword = query.keyword if (query.status) params.status = query.status - const { data } = await apiClient.get>>('/admin/users', { - params, - }) + const { data } = await apiClient.get>>( + '/admin/users', + { + params, + } + ) return data.data } @@ -56,3 +59,11 @@ export async function setAdminUserDepositFreeQuota(id: number, amountCent: numbe ) return data.data } + +export async function revokeAdminUserRealname(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/users/${id}/revoke-realname`, + { reason } + ) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index f9ccb3f..db5e17d 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -7,6 +7,7 @@ import { reactive, ref } from 'vue' import { fetchAdminUsers, freezeAdminUser, + revokeAdminUserRealname, setAdminUserDepositFreeQuota, unfreezeAdminUser, type AdminUserItem, @@ -14,14 +15,16 @@ import { } from '@/features/admin/api/adminUsers' import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money' import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable' -import { userStatusLabel } from '@/shared/utils/statusLabels' +import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' import AdminTablePagination from '../components/AdminTablePagination.vue' const submitting = ref(false) const activeUser = ref(null) const quotaUser = ref(null) +const realnameUser = ref(null) const freezeReason = ref('') +const revokeRealnameReason = ref('') const quotaAmount = ref(0) const filters = reactive({ @@ -104,6 +107,26 @@ async function handleUnfreeze(row: AdminUserItem) { } } +function openRevokeRealname(row: AdminUserItem) { + realnameUser.value = row + revokeRealnameReason.value = '' +} + +async function handleRevokeRealname() { + if (!realnameUser.value) return + submitting.value = true + try { + await revokeAdminUserRealname(realnameUser.value.id, revokeRealnameReason.value) + ElMessage.success('已撤销实名,用户可重新认证') + realnameUser.value = null + await loadUsers() + } catch (error) { + ElMessage.error(readError(error, '撤销实名失败')) + } finally { + submitting.value = false + } +} + function moneyCent(value: number | string | undefined) { return formatCent(Number(value || 0)) } @@ -161,6 +184,9 @@ function moneyCent(value: number | string | undefined) { + + + @@ -173,9 +199,18 @@ function moneyCent(value: number | string | undefined) { - +