diff --git a/apps/backend/src/db/migrations/076_worker_withdrawal_blacklist.sql b/apps/backend/src/db/migrations/076_worker_withdrawal_blacklist.sql new file mode 100644 index 00000000..f950b603 --- /dev/null +++ b/apps/backend/src/db/migrations/076_worker_withdrawal_blacklist.sql @@ -0,0 +1,13 @@ +-- 打手提现黑名单: +-- 1) is_withdrawal_blacklisted: 是否被列入提现黑名单(默认 false) +-- 2) withdrawal_blacklist_reason: 拉黑原因/备注 +-- 3) withdrawal_blacklisted_at: 列入黑名单的时间 + +ALTER TABLE worker_users + ADD COLUMN IF NOT EXISTS is_withdrawal_blacklisted BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS withdrawal_blacklist_reason TEXT NULL, + ADD COLUMN IF NOT EXISTS withdrawal_blacklisted_at TIMESTAMPTZ NULL; + +CREATE INDEX IF NOT EXISTS idx_worker_users_withdrawal_blacklisted + ON worker_users (is_withdrawal_blacklisted) + WHERE is_withdrawal_blacklisted = TRUE; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 6f32f90f..d42184bf 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -42,6 +42,9 @@ export type WorkerUserRow = { total_settled_amount?: number rename_cards?: number last_renamed_at?: string | null + is_withdrawal_blacklisted?: boolean + withdrawal_blacklist_reason?: string | null + withdrawal_blacklisted_at?: string | null } export type WorkerUserStatistics = { @@ -453,6 +456,8 @@ export type ListInput = { /** 后台待验收筛选:普通单按接单人,拼单按参与打手等级。 */ workerLevelIds?: number[] categoryId?: number + /** 后台打手列表按提现黑名单筛选。 */ + isWithdrawalBlacklisted?: boolean | null | undefined workerSharingId?: number /** 大厅候选集合的总展示上限;筛选与分页在候选集合内执行。 */ hallCandidateLimit?: number diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index bf61f4e4..6fd53d70 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -240,6 +240,7 @@ export async function listWorkerUsers({ workerType = '', workerId = 0, levelId = 0, + isWithdrawalBlacklisted, }: ListInput = {}): Promise<{ items: WorkerUserRow[]; total: number }> { const { whereClause, params } = buildWorkerUserWhere({ status, @@ -247,6 +248,7 @@ export async function listWorkerUsers({ workerType, workerId, levelId, + isWithdrawalBlacklisted, }) const totalResult = await query<{ total: number }>( `SELECT COUNT(*)::int AS total FROM worker_users wu ${whereClause}`, @@ -267,7 +269,10 @@ export async function listWorkerUsers({ /** 按筛选条件汇总打手,统计范围不受当前分页影响。 */ export async function getWorkerUserStatistics( - input: Pick = {}, + input: Pick< + ListInput, + 'status' | 'keyword' | 'workerType' | 'workerId' | 'levelId' | 'isWithdrawalBlacklisted' + > = {}, ): Promise { const { whereClause, params } = buildWorkerUserWhere(input) const statisticsResult = await query<{ @@ -373,13 +378,17 @@ async function getWorkerUserByIdWithClient( return result.rows[0] || null } -function buildWorkerUserWhere({ +export function buildWorkerUserWhere({ status = '', keyword = '', workerType = '', workerId = 0, levelId = 0, -}: Pick) { + isWithdrawalBlacklisted, +}: Pick< + ListInput, + 'status' | 'keyword' | 'workerType' | 'workerId' | 'levelId' | 'isWithdrawalBlacklisted' +>) { const filters: string[] = [] const params: unknown[] = [] if (status) { @@ -404,12 +413,47 @@ function buildWorkerUserWhere({ params.push(levelId) filters.push(`wu.level_id = $${params.length}`) } + if (isWithdrawalBlacklisted !== undefined && isWithdrawalBlacklisted !== null) { + params.push(Boolean(isWithdrawalBlacklisted)) + filters.push(`wu.is_withdrawal_blacklisted = $${params.length}`) + } return { whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', params, } } +export async function updateWorkerWithdrawalBlacklist(input: { + workerId: number | string + isWithdrawalBlacklisted: boolean + reason: string + now: string +}): Promise { + const result = await query<{ id: number }>( + ` + UPDATE worker_users + SET + is_withdrawal_blacklisted = $1, + withdrawal_blacklist_reason = $2, + withdrawal_blacklisted_at = $3, + updated_at = $4 + WHERE id = $5 + RETURNING id + `, + [ + input.isWithdrawalBlacklisted, + input.isWithdrawalBlacklisted ? input.reason : null, + input.isWithdrawalBlacklisted ? input.now : null, + input.now, + Number(input.workerId), + ], + ) + if (!result.rows[0]) { + return null + } + return getWorkerUserById(input.workerId) +} + /** 消耗一张改名卡并更新昵称;条件更新保证并发下不会超发。 */ export async function consumeWorkerRenameCard(input: { workerId: number diff --git a/apps/backend/src/routes/admin/worker-platform/workers.ts b/apps/backend/src/routes/admin/worker-platform/workers.ts index 38fe00ca..4f398c3f 100644 --- a/apps/backend/src/routes/admin/worker-platform/workers.ts +++ b/apps/backend/src/routes/admin/worker-platform/workers.ts @@ -11,6 +11,7 @@ import { reviewAdminWorkerUser, saveAdminWorkerWithdrawalAccount, setAdminWorkerPersonalDepositFreeAmount, + updateAdminWorkerWithdrawalBlacklist, } from '../../../services/worker-platform/index.js' import { createJsonHandler, requireAdminRoles } from '../session.js' @@ -120,6 +121,26 @@ router.post( ), ) +router.post( + '/worker-platform/workers/:workerId/withdrawal-blacklist', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + updateAdminWorkerWithdrawalBlacklist(String(req.params.workerId || ''), req.body || {}), + { + successMessage: '打手提现黑名单状态已更新', + errorMessage: '更新打手提现黑名单状态失败', + scope: '[admin/worker-platform/workers/:workerId/withdrawal-blacklist]', + audit: (req, data) => ({ + action: 'worker_withdrawal_blacklist_updated', + targetType: 'worker', + targetId: String(req.params.workerId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/workers/:workerId/wallet-credit', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/services/worker-platform/admin-worker-user-service.ts b/apps/backend/src/services/worker-platform/admin-worker-user-service.ts index beb2be26..5af44738 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-user-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-user-service.ts @@ -6,6 +6,7 @@ import { revokeAllWorkerSessions, updateWorkerPassword, updateWorkerUser, + updateWorkerWithdrawalBlacklist, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -31,12 +32,22 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) { const pageSize = normalizePageSize(query.pageSize) const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0 const levelId = normalizeOptionalId(query.levelId ?? query.level_id) || 0 + const rawWithdrawalBlacklisted = + query.isWithdrawalBlacklisted ?? query.is_withdrawal_blacklisted ?? query.withdrawalBlacklisted + const isWithdrawalBlacklisted = + rawWithdrawalBlacklisted !== undefined && + rawWithdrawalBlacklisted !== null && + String(rawWithdrawalBlacklisted).trim() !== '' + ? normalizeBoolean(rawWithdrawalBlacklisted, false) + : undefined + const filters = { workerId, levelId, status: String(query.status || '').trim(), keyword: String(query.keyword || '').trim(), workerType: String(query.workerType || query.worker_type || '').trim(), + isWithdrawalBlacklisted, } const includeStatistics = normalizeBoolean( query.includeStatistics ?? query.include_statistics, @@ -147,3 +158,23 @@ export async function resetAdminWorkerPassword( await revokeAllWorkerSessions(worker.id, now) return { worker: mapWorkerUser(updated || worker), reloginRequired: true } } + +export async function updateAdminWorkerWithdrawalBlacklist( + workerId: number | string, + payload: JsonObject = {}, +) { + const worker = await getRequiredWorker(workerId) + const isWithdrawalBlacklisted = normalizeBoolean( + payload.isWithdrawalBlacklisted ?? payload.blacklisted, + false, + ) + const reason = String(payload.reason || payload.blacklistReason || '').trim() + const now = nowIso() + const updated = await updateWorkerWithdrawalBlacklist({ + workerId: worker.id, + isWithdrawalBlacklisted, + reason, + now, + }) + return { worker: mapWorkerUser(updated || worker) } +} diff --git a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts index 7499c8a3..67ae516c 100644 --- a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts +++ b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts @@ -102,6 +102,9 @@ export function mapWorkerUser(worker: WorkerUserRow) { createdAt: worker.created_at, updatedAt: worker.updated_at, reviewedAt: worker.reviewed_at, + isWithdrawalBlacklisted: Boolean(worker.is_withdrawal_blacklisted), + withdrawalBlacklistReason: worker.withdrawal_blacklist_reason || '', + withdrawalBlacklistedAt: worker.withdrawal_blacklisted_at || null, renameCards: Number(worker.rename_cards ?? 1), lastRenamedAt: worker.last_renamed_at || null, renameUnlockedAt: worker.last_renamed_at diff --git a/apps/backend/src/services/worker-platform/worker-finance-request-service.ts b/apps/backend/src/services/worker-platform/worker-finance-request-service.ts index f6eb1489..706e9a5a 100644 --- a/apps/backend/src/services/worker-platform/worker-finance-request-service.ts +++ b/apps/backend/src/services/worker-platform/worker-finance-request-service.ts @@ -110,6 +110,18 @@ export async function createWorkerWithdrawRequest( ) { requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) + if (worker.is_withdrawal_blacklisted) { + const reason = worker.withdrawal_blacklist_reason?.trim() + throw createHttpError( + reason + ? `您当前已被加入提现黑名单(原因:${reason}),无法发起提现,如有疑问请联系平台客服` + : '您当前已被加入提现黑名单,无法发起提现,如有疑问请联系平台客服', + { + statusCode: 403, + errorCode: 'worker_withdrawal_blacklisted', + }, + ) + } const financeConfig = getWorkerFinanceConfig() if (financeConfig.withdraw.enabled === false) { throw createHttpError('当前提现入口暂未开放,请联系管理员', { diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 06389534..e027e295 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -12,6 +12,7 @@ import type { } from '../../repositories/worker-platform/index.js' import { buildWorkOrderWhere, + buildWorkerUserWhere, resolveWorkOrderShareCancellationStatus, resolveWorkOrderShareDepositAmount, resolveWorkOrderSharingCancellationPlan, @@ -26,6 +27,7 @@ import { canWorkerAutoAcceptWholeOrder, isWorkOrderSharingFilled, isWorkerPasswordStrong, + mapWorkerUser, normalizeWithdrawChannel, normalizeRequirementFields, normalizeWorkerFreezeReason, @@ -1285,3 +1287,65 @@ test('资金流水映射展示关联的平台订单号', () => { assert.equal(mapWalletLedger(ledger)?.relatedPlatformOrderId, '2623301845665410') }) + +test('打手提现黑名单信息映射到前端结构', () => { + const worker = { + id: 99, + username: 'blacklisted_worker', + password_hash: 'hash', + display_name: '违规打手', + phone: '13800000000', + level_id: null, + status: 'active', + worker_type: 'external', + review_note: '', + frozen_reason: '', + frozen_at: null, + session_version: 1, + created_at: '2026-09-01T00:00:00.000Z', + updated_at: '2026-09-02T00:00:00.000Z', + reviewed_at: null, + inviter_id: null, + invite_code: 'ABCDEFGH', + is_withdrawal_blacklisted: true, + withdrawal_blacklist_reason: '账号涉嫌刷单,暂停提现', + withdrawal_blacklisted_at: '2026-09-13T00:00:00.000Z', + } satisfies WorkerUserRow + + const mapped = mapWorkerUser(worker) + assert.equal(mapped.isWithdrawalBlacklisted, true) + assert.equal(mapped.withdrawalBlacklistReason, '账号涉嫌刷单,暂停提现') + assert.equal(mapped.withdrawalBlacklistedAt, '2026-09-13T00:00:00.000Z') + + const normalWorker = { + ...worker, + is_withdrawal_blacklisted: false, + withdrawal_blacklist_reason: null, + withdrawal_blacklisted_at: null, + } satisfies WorkerUserRow + + const normalMapped = mapWorkerUser(normalWorker) + assert.equal(normalMapped.isWithdrawalBlacklisted, false) + assert.equal(normalMapped.withdrawalBlacklistReason, '') + assert.equal(normalMapped.withdrawalBlacklistedAt, null) +}) + +test('打手查询条件支持提现黑名单筛选', () => { + const blacklistedWhere = buildWorkerUserWhere({ + isWithdrawalBlacklisted: true, + }) + assert.match(blacklistedWhere.whereClause, /wu\.is_withdrawal_blacklisted = \$1/) + assert.deepEqual(blacklistedWhere.params, [true]) + + const normalWhere = buildWorkerUserWhere({ + isWithdrawalBlacklisted: false, + }) + assert.match(normalWhere.whereClause, /wu\.is_withdrawal_blacklisted = \$1/) + assert.deepEqual(normalWhere.params, [false]) + + const allWhere = buildWorkerUserWhere({ + isWithdrawalBlacklisted: undefined, + }) + assert.equal(allWhere.whereClause, '') + assert.equal(allWhere.params.length, 0) +}) diff --git a/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx b/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx index c3074a6a..a217b354 100644 --- a/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx @@ -109,6 +109,7 @@ export default function WorkerDetailDrawer({ {formatWorkerStatus(worker.status)} + {worker.isWithdrawalBlacklisted ? 提现黑名单 : null} ) : null } @@ -159,6 +160,20 @@ function OverviewSection({ workerId }: { workerId: number }) { {formatAdminDateTime(worker.createdAt)} + + {worker.isWithdrawalBlacklisted ? ( + + 已加入提现黑名单 + {worker.withdrawalBlacklistReason ? ( + + 原因:{worker.withdrawalBlacklistReason} + + ) : null} + + ) : ( + 正常可提现 + )} + diff --git a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx index b6b5b992..fa7caf6c 100644 --- a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx @@ -1,6 +1,7 @@ import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { + Alert, App, Button, Card, @@ -12,6 +13,7 @@ import { Space, Table, Tag, + Tooltip, Typography, } from 'antd' import type { TableColumnsType } from 'antd' @@ -27,6 +29,7 @@ import { reviewAdminWorkerUser, saveAdminWorkerPersonalDepositFreeAmount, saveAdminWorkerWithdrawalAccount, + updateAdminWorkerWithdrawalBlacklist, } from '@/services/admin' import ImageUpload from '@/components/files/ImageUpload' import type { @@ -119,6 +122,7 @@ export default function WorkersPanel() { const [keywordInput, setKeywordInput] = useState('') const [keyword, setKeyword] = useState('') const [workerType, setWorkerType] = useState('') + const [withdrawalBlacklisted, setWithdrawalBlacklisted] = useState('') const [levelId, setLevelId] = useState(0) const [targetWorkerId, setTargetWorkerId] = useState(0) const [page, setPage] = useState(1) @@ -128,6 +132,8 @@ export default function WorkersPanel() { const [withdrawalAccountWorker, setWithdrawalAccountWorker] = useState(null) const [resetPasswordWorker, setResetPasswordWorker] = useState(null) const [freezeWorker, setFreezeWorker] = useState(null) + const [blacklistWorker, setBlacklistWorker] = useState(null) + const [submittingBlacklist, setSubmittingBlacklist] = useState(false) const [personalDepositFreeWorker, setPersonalDepositFreeWorker] = useState( null, ) @@ -141,6 +147,7 @@ export default function WorkersPanel() { const [withdrawalAccountForm] = Form.useForm() const [resetPasswordForm] = Form.useForm<{ password: string }>() const [freezeForm] = Form.useForm() + const [blacklistForm] = Form.useForm<{ reason?: string }>() const [personalDepositFreeForm] = Form.useForm() const withdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm) const editingAlipayAccount = withdrawalAccounts.find( @@ -158,6 +165,7 @@ export default function WorkersPanel() { setKeyword('') setStatus('') setWorkerType('') + setWithdrawalBlacklisted('') setLevelId(0) setPage(1) }, [searchParams]) @@ -168,6 +176,7 @@ export default function WorkersPanel() { status, keyword, workerType, + withdrawalBlacklisted, levelId, targetWorkerId, page, @@ -178,6 +187,8 @@ export default function WorkersPanel() { status, keyword, workerType, + isWithdrawalBlacklisted: + withdrawalBlacklisted === '' ? undefined : withdrawalBlacklisted === 'true', levelId: levelId || undefined, workerId: targetWorkerId || undefined, includeStatistics: true, @@ -223,6 +234,7 @@ export default function WorkersPanel() { setKeyword('') setStatus('') setWorkerType('') + setWithdrawalBlacklisted('') setLevelId(0) setTargetWorkerId(0) setPage(1) @@ -235,6 +247,50 @@ export default function WorkersPanel() { ) } + function handleAddToBlacklist(worker: WorkerUser) { + setBlacklistWorker(worker) + blacklistForm.resetFields() + } + + async function submitAddToBlacklist(values: { reason?: string }) { + if (!blacklistWorker) return + setSubmittingBlacklist(true) + try { + await updateAdminWorkerWithdrawalBlacklist(blacklistWorker.workerId, { + isWithdrawalBlacklisted: true, + reason: values.reason?.trim() || '', + }) + message.success('已将打手加入提现黑名单') + setBlacklistWorker(null) + blacklistForm.resetFields() + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '加入提现黑名单失败') + } finally { + setSubmittingBlacklist(false) + } + } + + function handleRemoveFromBlacklist(worker: WorkerUser) { + modal.confirm({ + title: '移出提现黑名单?', + content: `确认将打手“${worker.displayName || worker.username}”(UID: ${worker.workerId})移出提现黑名单,恢复其正常提现功能?`, + okText: '确认移出', + cancelText: '取消', + onOk: async () => { + try { + await updateAdminWorkerWithdrawalBlacklist(worker.workerId, { + isWithdrawalBlacklisted: false, + }) + message.success('已移出提现黑名单,提现功能已恢复') + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '移出提现黑名单失败') + } + }, + }) + } + async function review(worker: WorkerUser, nextStatus: string) { try { await reviewAdminWorkerUser(worker.workerId, { status: nextStatus }) @@ -478,9 +534,22 @@ export default function WorkersPanel() { title: '状态', width: 130, render: (_, row) => ( - - {formatWorkerStatus(row.status)} - + + + {formatWorkerStatus(row.status)} + + {row.isWithdrawalBlacklisted ? ( + + 提现黑名单 + + ) : null} + ), }, { @@ -497,7 +566,7 @@ export default function WorkersPanel() { }, { title: '操作', - width: 340, + width: 360, render: (_, row) => ( + {row.isWithdrawalBlacklisted ? ( + + ) : ( + + )} ), }, @@ -607,6 +683,21 @@ export default function WorkersPanel() { setPage(1) }} /> +