后台增加撤销用户实名功能
实名姓名与收款账户不一致会导致收款码登记失败,新增后台撤销实名能力: - 新增 user:revoke_realname 权限,授予超级管理员与运营角色 - 撤销时重置用户实名状态为未认证并删除实名记录,使其可重新认证,写入审计日志 - 用户管理页新增实名状态列与"撤销实名"操作(含原因弹窗) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8192344027
commit
4d54f1b03a
@@ -24,6 +24,10 @@ type FreezeRequest struct {
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RevokeRealnameRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
type DepositFreeQuotaRequest struct {
|
type DepositFreeQuotaRequest struct {
|
||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,26 @@ func (h *Handler) SetDepositFreeQuota(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||||
value, ok := c.Get(middleware.ContextAdminID)
|
value, ok := c.Get(middleware.ContextAdminID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -142,6 +162,8 @@ func writeAdminUserError(c *gin.Context, err error) {
|
|||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
case errors.Is(err, ErrInvalidUser):
|
case errors.Is(err, ErrInvalidUser):
|
||||||
response.BadRequest(c, "用户不符合规则")
|
response.BadRequest(c, "用户不符合规则")
|
||||||
|
case errors.Is(err, ErrRealnameNotRevocable):
|
||||||
|
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
|
||||||
case IsNotFound(err):
|
case IsNotFound(err):
|
||||||
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
|
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -136,6 +136,43 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
|
|||||||
return r.Find(ctx, userID)
|
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) {
|
func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error) {
|
||||||
var row userRow
|
var row userRow
|
||||||
err := r.db.WithContext(ctx).Table("users AS u").
|
err := r.db.WithContext(ctx).Table("users AS u").
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
ErrInvalidUser = errors.New("invalid user")
|
ErrInvalidUser = errors.New("invalid user")
|
||||||
|
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
|
||||||
adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze)
|
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/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", requirePerm("order:view"), orderHandler.AdminList)
|
||||||
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
||||||
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -31,9 +31,12 @@ export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQ
|
|||||||
const params: Record<string, unknown> = { page, page_size: pageSize }
|
const params: Record<string, unknown> = { page, page_size: pageSize }
|
||||||
if (query.keyword) params.keyword = query.keyword
|
if (query.keyword) params.keyword = query.keyword
|
||||||
if (query.status) params.status = query.status
|
if (query.status) params.status = query.status
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
||||||
|
'/admin/users',
|
||||||
|
{
|
||||||
params,
|
params,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,3 +59,11 @@ export async function setAdminUserDepositFreeQuota(id: number, amountCent: numbe
|
|||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revokeAdminUserRealname(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||||
|
`/admin/users/${id}/revoke-realname`,
|
||||||
|
{ reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { reactive, ref } from 'vue'
|
|||||||
import {
|
import {
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
freezeAdminUser,
|
freezeAdminUser,
|
||||||
|
revokeAdminUserRealname,
|
||||||
setAdminUserDepositFreeQuota,
|
setAdminUserDepositFreeQuota,
|
||||||
unfreezeAdminUser,
|
unfreezeAdminUser,
|
||||||
type AdminUserItem,
|
type AdminUserItem,
|
||||||
@@ -14,14 +15,16 @@ import {
|
|||||||
} from '@/features/admin/api/adminUsers'
|
} from '@/features/admin/api/adminUsers'
|
||||||
import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
|
import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
|
||||||
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
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 { formatDateTime } from '@/shared/utils/time'
|
||||||
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const activeUser = ref<AdminUserItem | null>(null)
|
const activeUser = ref<AdminUserItem | null>(null)
|
||||||
const quotaUser = ref<AdminUserItem | null>(null)
|
const quotaUser = ref<AdminUserItem | null>(null)
|
||||||
|
const realnameUser = ref<AdminUserItem | null>(null)
|
||||||
const freezeReason = ref('')
|
const freezeReason = ref('')
|
||||||
|
const revokeRealnameReason = ref('')
|
||||||
const quotaAmount = ref(0)
|
const quotaAmount = ref(0)
|
||||||
|
|
||||||
const filters = reactive<AdminUserQuery>({
|
const filters = reactive<AdminUserQuery>({
|
||||||
@@ -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) {
|
function moneyCent(value: number | string | undefined) {
|
||||||
return formatCent(Number(value || 0))
|
return formatCent(Number(value || 0))
|
||||||
}
|
}
|
||||||
@@ -161,6 +184,9 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="状态" width="110">
|
<el-table-column label="状态" width="110">
|
||||||
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="实名状态" width="110">
|
||||||
|
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="免押额度" width="130">
|
<el-table-column label="免押额度" width="130">
|
||||||
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_quota_cent) }}</template>
|
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_quota_cent) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -173,9 +199,18 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="注册时间" min-width="180">
|
<el-table-column label="注册时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="220">
|
<el-table-column label="操作" width="320">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="openDepositQuota(row)">免押</el-button>
|
<el-button size="small" @click="openDepositQuota(row)">免押</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.realname_status !== 'unverified'"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="openRevokeRealname(row)"
|
||||||
|
>
|
||||||
|
撤销实名
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -229,6 +264,36 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
:model-value="!!realnameUser"
|
||||||
|
title="撤销实名认证"
|
||||||
|
width="560px"
|
||||||
|
@update:model-value="realnameUser = null"
|
||||||
|
>
|
||||||
|
<div v-if="realnameUser" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
<strong>{{ realnameUser.phone }}</strong> · {{ realnameUser.nickname }}
|
||||||
|
</p>
|
||||||
|
<el-alert
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="撤销后该用户实名信息将被清除,需重新完成实名认证后才能下单与登记收款码。"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="revokeRealnameReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="填写撤销原因(如实名姓名与收款账户不一致),便于审计追踪"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="realnameUser = null">取消</el-button>
|
||||||
|
<el-button type="warning" :loading="submitting" @click="handleRevokeRealname"
|
||||||
|
>确认撤销</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="!!quotaUser"
|
:model-value="!!quotaUser"
|
||||||
title="设置免押额度"
|
title="设置免押额度"
|
||||||
|
|||||||
Reference in New Issue
Block a user