diff --git a/apps/backend/src/domain/consignment-whitelist.test.ts b/apps/backend/src/domain/consignment-whitelist.test.ts new file mode 100644 index 00000000..27e320b5 --- /dev/null +++ b/apps/backend/src/domain/consignment-whitelist.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { canWorkerPublishConsignment } from './consignment-whitelist.js' + +test('白名单关闭时所有打手都能发布寄卖', () => { + assert.equal( + canWorkerPublishConsignment({ whitelistEnabled: false, workerCanConsign: false }), + true, + ) + assert.equal( + canWorkerPublishConsignment({ whitelistEnabled: false, workerCanConsign: true }), + true, + ) +}) + +test('白名单开启时仅开通权限的打手能发布', () => { + assert.equal( + canWorkerPublishConsignment({ whitelistEnabled: true, workerCanConsign: false }), + false, + ) + assert.equal( + canWorkerPublishConsignment({ whitelistEnabled: true, workerCanConsign: true }), + true, + ) +}) diff --git a/apps/backend/src/domain/consignment-whitelist.ts b/apps/backend/src/domain/consignment-whitelist.ts new file mode 100644 index 00000000..4a78c846 --- /dev/null +++ b/apps/backend/src/domain/consignment-whitelist.ts @@ -0,0 +1,12 @@ +/** + * 寄卖白名单总开关。 + * + * 开启:只有 worker.can_consign 的打手能发布新寄卖。 + * 关闭:所有打手都能发布;打手身上的白名单标记仍保留,方便再次开启。 + */ +export function canWorkerPublishConsignment(input: { + whitelistEnabled: boolean + workerCanConsign: boolean +}): boolean { + return !input.whitelistEnabled || input.workerCanConsign +} diff --git a/apps/backend/src/services/worker-platform/admin-worker-consignment-permission.test.ts b/apps/backend/src/services/worker-platform/admin-worker-consignment-permission.test.ts index d6b00e03..70e53239 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-consignment-permission.test.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-consignment-permission.test.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import test from 'node:test' import { mapWorkerUser } from './worker-finance-mappers.js' +import { parseAdminWorkerConsignmentPermission } from './admin-worker-user-service.js' import type { WorkerUserRow } from '../../repositories/worker-platform/types.js' test('mapWorkerUser 准确回显打手寄卖白名单权限与激活时间', () => { @@ -48,3 +49,12 @@ test('管理员打手管理路由注册了寄卖白名单权限更新接口', as '路由必须调用 updateAdminWorkerConsignmentPermission', ) }) + +test('更新寄卖权限必须显式传开关,空 body 不得默认关闭', () => { + assert.throws( + () => parseAdminWorkerConsignmentPermission({}), + (error: unknown) => (error as { errorCode?: string }).errorCode === 'can_consign_required', + ) + assert.equal(parseAdminWorkerConsignmentPermission({ canConsign: true }), true) + assert.equal(parseAdminWorkerConsignmentPermission({ enabled: false }), false) +}) 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 2bffffcc..c4051c07 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 @@ -180,12 +180,23 @@ export async function updateAdminWorkerWithdrawalBlacklist( return { worker: mapWorkerUser(updated || worker) } } +export function parseAdminWorkerConsignmentPermission(payload: JsonObject = {}): boolean { + const raw = payload.canConsign ?? payload.enabled + if (raw === undefined || raw === null || raw === '') { + throw createHttpError('请明确指定寄卖权限开关', { + statusCode: 400, + errorCode: 'can_consign_required', + }) + } + return normalizeBoolean(raw, false) +} + export async function updateAdminWorkerConsignmentPermission( workerId: number | string, payload: JsonObject = {}, ) { const worker = await getRequiredWorker(workerId) - const canConsign = normalizeBoolean(payload.canConsign ?? payload.enabled, false) + const canConsign = parseAdminWorkerConsignmentPermission(payload) const now = nowIso() const updated = await updateWorkerConsignmentPermission({ workerId: worker.id, diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index f5bca967..9b1a0c4f 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -87,8 +87,8 @@ test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀 test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截', async () => { const serviceSource = await readServiceSource() assert.ok( - serviceSource.includes('!worker.can_consign'), - '服务端必须拦截未开通寄卖权限的打手', + serviceSource.includes('canWorkerPublishConsignment('), + '服务端必须按白名单总开关决定是否拦截未开通打手', ) assert.ok( serviceSource.includes("errorCode: 'consignment_permission_required'"), diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index 5159fb8e..432519cd 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -12,6 +12,7 @@ import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/wor import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-order-query-repo.js' import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js' +import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js' import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js' import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js' import type { JsonObject } from '../../types/json.js' @@ -20,7 +21,10 @@ import { generateConsignmentSubmissionNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js' import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js' -import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js' +import { + getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen, + getWorkerFinanceConfig, +} from './worker-finance-config-service.js' import { confirmAdminWorkOrderFriendAdded } from './admin-work-order-gift-service.js' import { submitWorkerOrderAcceptance } from './worker-acceptance-service.js' import type { WorkerSession } from './worker-session-context-service.js' @@ -144,8 +148,13 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb if (!worker) { throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' }) } - if (!worker.can_consign) { - throw createHttpError('您暂未开通寄卖权限,请联系客服开通', { + if ( + !canWorkerPublishConsignment({ + whitelistEnabled: getWorkerFinanceConfig().consignmentWhitelistEnabled, + workerCanConsign: Boolean(worker.can_consign), + }) + ) { + throw createHttpError('您暂未开通寄卖权限,请联系客服申请开通', { statusCode: 403, errorCode: 'consignment_permission_required', }) diff --git a/apps/backend/src/services/worker-platform/worker-finance-config-service.ts b/apps/backend/src/services/worker-platform/worker-finance-config-service.ts index 4106caef..36fe1883 100644 --- a/apps/backend/src/services/worker-platform/worker-finance-config-service.ts +++ b/apps/backend/src/services/worker-platform/worker-finance-config-service.ts @@ -12,6 +12,11 @@ export type WorkerFinanceConfig = { rewardUnfreezeEnabled: boolean /** 苏尔南寄卖发布所需履约押金(分),默认 2000 = ¥20 */ consignmentPublishDepositFen: number + /** + * 寄卖白名单总开关。开启后仅 can_consign 打手可发布;关闭后所有打手可发布。 + * 缺省视为开启,保持当前白名单行为。 + */ + consignmentWhitelistEnabled: boolean recharge: { enabled: boolean channelName: string @@ -71,6 +76,9 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo 0, 1_000_000, ), + consignmentWhitelistEnabled: + source.consignmentWhitelistEnabled !== false && + source.consignment_whitelist_enabled !== false, recharge: { enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true, channelName: String(recharge.channelName || recharge.channel_name || '').trim(), @@ -97,6 +105,7 @@ export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig { depositUnfreezeDays: 3, rewardUnfreezeEnabled: true, consignmentPublishDepositFen: 2000, + consignmentWhitelistEnabled: true, recharge: { enabled: true, channelName: '', @@ -120,6 +129,7 @@ function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceC depositUnfreezeDays: config.depositUnfreezeDays, rewardUnfreezeEnabled: config.rewardUnfreezeEnabled, consignmentPublishDepositFen: config.consignmentPublishDepositFen, + consignmentWhitelistEnabled: config.consignmentWhitelistEnabled, recharge: { ...config.recharge, qrCodeImage: config.recharge.qrCodeImage 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 f894d4b6..13630d50 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 @@ -402,6 +402,19 @@ test('寄卖发布押金配置保留显式零值并回退非法值', () => { ) }) +test('寄卖白名单总开关缺省开启,显式 false 才全员开放', () => { + assert.equal(normalizeWorkerFinanceConfig({}).consignmentWhitelistEnabled, true) + assert.equal( + normalizeWorkerFinanceConfig({ consignmentWhitelistEnabled: false }) + .consignmentWhitelistEnabled, + false, + ) + assert.equal( + normalizeWorkerFinanceConfig({ consignmentWhitelistEnabled: true }).consignmentWhitelistEnabled, + true, + ) +}) + test('寄卖押金释放先锁钱包再读流水,冻结额不足时拒绝部分退款', async () => { const statements: string[] = [] const client = { diff --git a/apps/frontend/src/components/worker/WorkerContactModal.tsx b/apps/frontend/src/components/worker/WorkerContactModal.tsx index c6172f75..c2fea43a 100644 --- a/apps/frontend/src/components/worker/WorkerContactModal.tsx +++ b/apps/frontend/src/components/worker/WorkerContactModal.tsx @@ -10,8 +10,12 @@ export type WorkerContactModalProps = { worker?: WorkerUser | null financeConfig?: WorkerFinanceConfig | null title?: string + alertMessage?: string } +const DEFAULT_ALERT_MESSAGE = + '如需取消已接订单,请扫码联系客服处理。充值、提现与审核问题也由后台人工客服处理。' + export function WorkerContactModal({ open, onClose, @@ -19,6 +23,7 @@ export function WorkerContactModal({ worker, financeConfig, title = '联系客服', + alertMessage = DEFAULT_ALERT_MESSAGE, }: WorkerContactModalProps) { return ( - + {financeConfig?.adminContact?.wechatQrCodeImage ? (
+ + + - {worker.canConsign ? '已开通寄卖' : '未开通(白名单限制)'} + {worker.canConsign ? '已加入白名单' : '未加入白名单'} {worker.consignmentActivatedAt ? ( diff --git a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx index aad8ea42..b67588ea 100644 --- a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx @@ -23,11 +23,13 @@ import { useSearchParams } from 'react-router' import { creditAdminWorkerWallet, + fetchAdminWorkerFinanceConfig, fetchAdminWorkerLevels, fetchAdminWorkerUsers, fetchAdminWorkerWithdrawalAccounts, resetAdminWorkerPassword, reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, saveAdminWorkerPersonalDepositFreeAmount, saveAdminWorkerWithdrawalAccount, updateAdminWorkerWithdrawalBlacklist, @@ -205,6 +207,13 @@ export default function WorkersPanel() { queryFn: () => fetchAdminWorkerLevels(), }) const workersPagination = workersQuery.data?.data.pagination + const financeConfigQuery = useQuery({ + queryKey: ['admin-worker-platform-finance-config'], + queryFn: () => fetchAdminWorkerFinanceConfig(), + }) + const consignmentWhitelistEnabled = + financeConfigQuery.data?.data.consignmentWhitelistEnabled !== false + const [updatingWhitelist, setUpdatingWhitelist] = useState(false) useEffect(() => { // 从工单流转记录等入口带 ?workerId= 定位到打手后,自动打开一次详情,关闭后不再重复弹。 @@ -225,6 +234,9 @@ export default function WorkersPanel() { queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'], }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-finance-config'], + }), ]) } @@ -466,47 +478,117 @@ export default function WorkersPanel() { } } - const [updatingConsignmentWorkerId, setUpdatingConsignmentWorkerId] = useState( - null, + const [updatingConsignmentWorkerIds, setUpdatingConsignmentWorkerIds] = useState>( + () => new Set(), ) + function markConsignmentWorkerUpdating(workerId: number, updating: boolean) { + setUpdatingConsignmentWorkerIds((prev) => { + const next = new Set(prev) + if (updating) next.add(workerId) + else next.delete(workerId) + return next + }) + } + + function applyUpdatedWorker(row: WorkerUser, updatedWorker: WorkerUser) { + queryClient.setQueriesData<{ data: { items: WorkerUser[]; pagination: any } }>( + { queryKey: ['admin-worker-platform-workers'] }, + (old) => { + if (!old?.data?.items) return old + return { + ...old, + data: { + ...old.data, + items: old.data.items.map((item) => + item.workerId === row.workerId ? { ...item, ...updatedWorker } : item, + ), + }, + } + }, + ) + setDetailWorker((current) => + current?.workerId === row.workerId ? { ...current, ...updatedWorker } : current, + ) + } + async function handleToggleConsignmentPermission(row: WorkerUser, checked: boolean) { - setUpdatingConsignmentWorkerId(row.workerId) + markConsignmentWorkerUpdating(row.workerId, true) try { const res = await updateAdminWorkerConsignmentPermission(row.workerId, { canConsign: checked, }) const updatedWorker = res.data?.worker if (updatedWorker) { - queryClient.setQueriesData<{ data: { items: WorkerUser[]; pagination: any } }>( - { queryKey: ['admin-worker-platform-workers'] }, - (old) => { - if (!old?.data?.items) return old - return { - ...old, - data: { - ...old.data, - items: old.data.items.map((item) => - item.workerId === row.workerId ? { ...item, ...updatedWorker } : item, - ), - }, - } - }, - ) + applyUpdatedWorker(row, updatedWorker) } message.success( checked - ? `已为打手 ${row.displayName || row.username} 开通寄卖权限` - : `已关闭打手 ${row.displayName || row.username} 的寄卖权限`, + ? `已为打手 ${row.displayName || row.username} 加入寄卖白名单` + : `已将打手 ${row.displayName || row.username} 移出寄卖白名单`, ) await refreshWorkers() - } catch (err: any) { + } catch (err: unknown) { message.error(err instanceof Error ? err.message : '更新寄卖权限失败') } finally { - setUpdatingConsignmentWorkerId(null) + markConsignmentWorkerUpdating(row.workerId, false) } } + function requestToggleConsignmentPermission(row: WorkerUser, checked: boolean) { + if (checked) { + void handleToggleConsignmentPermission(row, true) + return + } + modal.confirm({ + title: '移出寄卖白名单?', + content: `确认关闭打手“${row.displayName || row.username}”的寄卖权限?白名单开启时,该打手将无法发布新寄卖;已在途的寄卖不受影响。`, + okText: '确认关闭', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: () => handleToggleConsignmentPermission(row, false), + }) + } + + async function handleToggleConsignmentWhitelist(checked: boolean) { + const current = financeConfigQuery.data?.data + if (!current) { + message.error('资金配置尚未加载完成,请稍后重试') + return + } + modal.confirm({ + title: checked ? '开启寄卖白名单?' : '关闭寄卖白名单?', + content: checked + ? '开启后只有白名单中的打手可以发布寄卖,未开通的打手会被拦截。' + : '关闭后所有打手都可以发布寄卖。打手身上的白名单标记会保留,方便再次开启。', + okText: checked ? '确认开启' : '确认关闭', + okButtonProps: checked ? undefined : { danger: true }, + cancelText: '取消', + onOk: async () => { + setUpdatingWhitelist(true) + try { + await saveAdminWorkerFinanceConfig({ + depositUnfreezeDays: current.depositUnfreezeDays, + rewardUnfreezeEnabled: current.rewardUnfreezeEnabled, + consignmentPublishDepositFen: current.consignmentPublishDepositFen, + consignmentWhitelistEnabled: checked, + recharge: current.recharge, + adminContact: current.adminContact, + withdraw: current.withdraw, + }) + message.success(checked ? '已开启寄卖白名单' : '已关闭寄卖白名单,全部打手可发布') + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-finance-config'], + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '更新寄卖白名单失败') + } finally { + setUpdatingWhitelist(false) + } + }, + }) + } + const columns: TableColumnsType = [ { title: '打手', @@ -598,16 +680,24 @@ export default function WorkersPanel() { ), }, { - title: '寄卖权限', - width: 120, + title: '寄卖白名单', + width: 130, render: (_, row) => ( - handleToggleConsignmentPermission(row, checked)} - /> + + requestToggleConsignmentPermission(row, checked)} + /> + ), }, { @@ -778,7 +868,33 @@ export default function WorkersPanel() { ) : null} - + + 寄卖白名单 + void handleToggleConsignmentWhitelist(checked)} + /> + + } + > + rowKey="workerId" loading={workersQuery.isLoading} diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index b778d100..253ff511 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -33,6 +33,7 @@ import { Tabs, Tag, Typography, + Spin, } from 'antd' import { useEffect, useMemo, useRef, useState } from 'react' @@ -54,6 +55,7 @@ import { SURNAN_PUBLISH_ZONE_OPTIONS, } from '@/utils/consignment-spec' import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown' +import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist' import { formatDateTime } from '@/utils/date-time' import { useIsMobile } from '@/utils/use-is-mobile' @@ -200,13 +202,13 @@ export default function WorkerConsignmentPage() { const publishDepositYuan = publishDepositFen / 100 const availableAmount = Number(worker?.wallet?.availableAmount || 0) const canPublish = availableAmount >= publishDepositFen - const canConsign = worker?.canConsign !== false + const canConsign = canWorkerPublishConsignment({ + profileReady: !profileQuery.isLoading && Boolean(worker), + whitelistEnabled: financeConfig?.consignmentWhitelistEnabled !== false, + workerCanConsign: worker?.canConsign === true, + }) const [contactOpen, setContactOpen] = useState(false) - const reviewingCount = records.filter((item) => item.group === 'reviewing').length - const activeCount = records.filter((item) => item.group === 'active').length - const finishedCount = records.filter((item) => item.group === 'finished').length - // 阶段 → 分组由后端下发(item.group),这里只把它映射到筛选角标计数上 const groupCounts = useMemo(() => { const stageGroups = new Map() @@ -527,7 +529,7 @@ export default function WorkerConsignmentPage() {
待审核 - {reviewingCount} + {groupCounts.reviewing} 单
@@ -546,8 +548,8 @@ export default function WorkerConsignmentPage() {
履约中 - {activeCount} - 把 + {groupCounts.active} + 单
@@ -565,8 +567,8 @@ export default function WorkerConsignmentPage() {
已完成 - {finishedCount} - 把 + {groupCounts.finished} + 单
@@ -588,7 +590,11 @@ export default function WorkerConsignmentPage() { 发布寄卖 ), - children: !canConsign ? ( + children: profileQuery.isLoading ? ( +
+ +
+ ) : !canConsign ? (
- 为保障寄卖道具交付质量与履约安全,平台当前实行白名单审核制。请联系在线客服申请开通寄卖权限。 + 平台当前开启寄卖白名单。请联系客服申请开通,由后台运营开通后即可发布苏尔南寄卖。