diff --git a/apps/backend/src/db/migrations/082_worker_consignment_whitelist.sql b/apps/backend/src/db/migrations/082_worker_consignment_whitelist.sql new file mode 100644 index 00000000..4750bb3f --- /dev/null +++ b/apps/backend/src/db/migrations/082_worker_consignment_whitelist.sql @@ -0,0 +1,24 @@ +-- 082_worker_consignment_whitelist.sql —— 打手寄卖白名单激活控制与存量平滑过渡。 + +-- 1. worker_users 增加寄卖权限开关及激活时间 +ALTER TABLE worker_users + ADD COLUMN IF NOT EXISTS can_consign BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS consignment_activated_at TIMESTAMPTZ; + +COMMENT ON COLUMN worker_users.can_consign IS '是否开通寄卖权限(白名单模式:默认关闭,需客服开通)'; +COMMENT ON COLUMN worker_users.consignment_activated_at IS '开通寄卖权限的时间'; + +-- 2. 寄卖权限字段索引(用于后台快速筛选已开通/未开通打手) +CREATE INDEX IF NOT EXISTS idx_worker_users_can_consign + ON worker_users (can_consign); + +-- 3. 存量老打手平滑过渡:历史上在 consignment_submissions 中提报过的打手,默认自动开通寄卖权限 +UPDATE worker_users +SET + can_consign = TRUE, + consignment_activated_at = NOW() +WHERE id IN ( + SELECT DISTINCT worker_id + FROM consignment_submissions + WHERE worker_id IS NOT NULL +); diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index d42184bf..190da7cf 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -45,6 +45,8 @@ export type WorkerUserRow = { is_withdrawal_blacklisted?: boolean withdrawal_blacklist_reason?: string | null withdrawal_blacklisted_at?: string | null + can_consign?: boolean + consignment_activated_at?: string | null } export type WorkerUserStatistics = { diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index 6fd53d70..27ef60b9 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -478,3 +478,29 @@ export async function consumeWorkerRenameCard(input: { } return getWorkerUserById(input.workerId) } + +export async function updateWorkerConsignmentPermission(input: { + workerId: number | string + canConsign: boolean + now: string +}): Promise { + const result = await query<{ id: number }>( + ` + UPDATE worker_users + SET + can_consign = $1, + consignment_activated_at = CASE + WHEN $1 = TRUE THEN COALESCE(consignment_activated_at, $2::timestamptz) + ELSE consignment_activated_at + END, + updated_at = $2 + WHERE id = $3 + RETURNING id + `, + [input.canConsign, input.now, Number(input.workerId)], + ) + if (!result.rows[0]) { + return null + } + return getWorkerUserById(input.workerId) +} diff --git a/apps/backend/src/routes/admin/worker-platform/workers.ts b/apps/backend/src/routes/admin/worker-platform/workers.ts index b54fd0b1..c782b99c 100644 --- a/apps/backend/src/routes/admin/worker-platform/workers.ts +++ b/apps/backend/src/routes/admin/worker-platform/workers.ts @@ -12,6 +12,7 @@ import { saveAdminWorkerWithdrawalAccount, setAdminWorkerPersonalDepositFreeAmount, updateAdminWorkerWithdrawalBlacklist, + updateAdminWorkerConsignmentPermission, } from '../../../services/worker-platform/index.js' import { createJsonHandler, requireAdminRoles } from '../session.js' @@ -141,6 +142,26 @@ router.post( ), ) +router.post( + '/worker-platform/workers/:workerId/consignment-permission', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + updateAdminWorkerConsignmentPermission(String(req.params.workerId || ''), req.body || {}), + { + successMessage: '打手寄卖权限已更新', + errorMessage: '更新打手寄卖权限失败', + scope: '[admin/worker-platform/workers/:workerId/consignment-permission]', + audit: (req, data) => ({ + action: 'worker_consignment_permission_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-consignment-permission.test.ts b/apps/backend/src/services/worker-platform/admin-worker-consignment-permission.test.ts new file mode 100644 index 00000000..d6b00e03 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-worker-consignment-permission.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { mapWorkerUser } from './worker-finance-mappers.js' +import type { WorkerUserRow } from '../../repositories/worker-platform/types.js' + +test('mapWorkerUser 准确回显打手寄卖白名单权限与激活时间', () => { + const fakeWorker = { + id: 101, + username: 'worker_a', + display_name: '打手A', + phone: '13800138000', + status: 'active', + worker_type: 'external', + created_at: '2026-09-01T00:00:00.000Z', + updated_at: '2026-09-01T00:00:00.000Z', + can_consign: true, + consignment_activated_at: '2026-09-20T12:00:00.000Z', + } as unknown as WorkerUserRow + + const mapped = mapWorkerUser(fakeWorker) + assert.equal(mapped.canConsign, true) + assert.equal(mapped.consignmentActivatedAt, '2026-09-20T12:00:00.000Z') + + const unactivatedWorker = { + ...fakeWorker, + can_consign: false, + consignment_activated_at: null, + } as unknown as WorkerUserRow + + const mappedUnactivated = mapWorkerUser(unactivatedWorker) + assert.equal(mappedUnactivated.canConsign, false) + assert.equal(mappedUnactivated.consignmentActivatedAt, null) +}) + +test('管理员打手管理路由注册了寄卖白名单权限更新接口', async () => { + const routeSource = await readFile( + new URL('../../routes/admin/worker-platform/workers.ts', import.meta.url), + 'utf8', + ) + assert.ok( + routeSource.includes("'/worker-platform/workers/:workerId/consignment-permission'"), + '必须注册 consignment-permission 路由', + ) + assert.ok( + routeSource.includes('updateAdminWorkerConsignmentPermission'), + '路由必须调用 updateAdminWorkerConsignmentPermission', + ) +}) 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 5af44738..2bffffcc 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 @@ -7,6 +7,7 @@ import { updateWorkerPassword, updateWorkerUser, updateWorkerWithdrawalBlacklist, + updateWorkerConsignmentPermission, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -178,3 +179,18 @@ export async function updateAdminWorkerWithdrawalBlacklist( }) return { worker: mapWorkerUser(updated || worker) } } + +export async function updateAdminWorkerConsignmentPermission( + workerId: number | string, + payload: JsonObject = {}, +) { + const worker = await getRequiredWorker(workerId) + const canConsign = normalizeBoolean(payload.canConsign ?? payload.enabled, false) + const now = nowIso() + const updated = await updateWorkerConsignmentPermission({ + workerId: worker.id, + canConsign, + now, + }) + return { worker: mapWorkerUser(updated || worker) } +} 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 5fe2139c..f5bca967 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 @@ -83,3 +83,15 @@ test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀 '超限必须返回专门的错误码', ) }) + +test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截', async () => { + const serviceSource = await readServiceSource() + assert.ok( + serviceSource.includes('!worker.can_consign'), + '服务端必须拦截未开通寄卖权限的打手', + ) + assert.ok( + serviceSource.includes("errorCode: 'consignment_permission_required'"), + '未开通必须返回专门的 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 6be08e8f..5159fb8e 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -144,6 +144,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb if (!worker) { throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' }) } + if (!worker.can_consign) { + throw createHttpError('您暂未开通寄卖权限,请联系客服开通', { + statusCode: 403, + errorCode: 'consignment_permission_required', + }) + } const system = String(payload.system || '').trim() const serverZone = String(payload.serverZone || '').trim() 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 67ae516c..d33cb6eb 100644 --- a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts +++ b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts @@ -110,6 +110,8 @@ export function mapWorkerUser(worker: WorkerUserRow) { renameUnlockedAt: worker.last_renamed_at ? new Date(Date.parse(worker.last_renamed_at) + WORKER_RENAME_COOLDOWN_MS).toISOString() : null, + canConsign: Boolean(worker.can_consign), + consignmentActivatedAt: worker.consignment_activated_at || null, } } diff --git a/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx b/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx index a217b354..fb9f11f3 100644 --- a/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx @@ -174,6 +174,18 @@ function OverviewSection({ workerId }: { workerId: number }) { 正常可提现 )} + + + + {worker.canConsign ? '已开通寄卖' : '未开通(白名单限制)'} + + {worker.consignmentActivatedAt ? ( + + 开通时间:{formatAdminDateTime(worker.consignmentActivatedAt)} + + ) : null} + + diff --git a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx index 6e71fb42..ba3d0fb5 100644 --- a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx @@ -11,6 +11,7 @@ import { Modal, Select, Space, + Switch, Table, Tag, Tooltip, @@ -30,6 +31,7 @@ import { saveAdminWorkerPersonalDepositFreeAmount, saveAdminWorkerWithdrawalAccount, updateAdminWorkerWithdrawalBlacklist, + updateAdminWorkerConsignmentPermission, } from '@/services/admin' import ImageUpload from '@/components/files/ImageUpload' import type { @@ -464,6 +466,27 @@ export default function WorkersPanel() { } } + const [updatingConsignmentWorkerId, setUpdatingConsignmentWorkerId] = useState( + null, + ) + + async function handleToggleConsignmentPermission(row: WorkerUser, checked: boolean) { + setUpdatingConsignmentWorkerId(row.workerId) + try { + await updateAdminWorkerConsignmentPermission(row.workerId, { canConsign: checked }) + message.success( + checked + ? `已为打手 ${row.displayName || row.username} 开通寄卖权限` + : `已关闭打手 ${row.displayName || row.username} 的寄卖权限`, + ) + await queryClient.invalidateQueries({ queryKey: ['admin-workers'] }) + } catch (err: any) { + message.error(err instanceof Error ? err.message : '更新寄卖权限失败') + } finally { + setUpdatingConsignmentWorkerId(null) + } + } + const columns: TableColumnsType = [ { title: '打手', @@ -554,6 +577,19 @@ export default function WorkersPanel() { ), }, + { + title: '寄卖权限', + width: 120, + render: (_, row) => ( + handleToggleConsignmentPermission(row, checked)} + /> + ), + }, { title: '冻结原因', width: 220, diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index c2aa423c..b778d100 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -11,6 +11,7 @@ import { ReloadOutlined, SafetyCertificateOutlined, UserOutlined, + CustomerServiceOutlined, } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { @@ -36,6 +37,7 @@ import { import { useEffect, useMemo, useRef, useState } from 'react' import ImageUpload from '@/components/files/ImageUpload' +import { WorkerContactModal } from '@/components/worker/WorkerContactModal' import { confirmWorkerConsignmentFriendAdded, fetchWorkerConsignmentPriceSuggestion, @@ -198,6 +200,8 @@ 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 [contactOpen, setContactOpen] = useState(false) const reviewingCount = records.filter((item) => item.group === 'reviewing').length const activeCount = records.filter((item) => item.group === 'active').length @@ -338,6 +342,11 @@ export default function WorkerConsignmentPage() { } async function handlePublishSubmit(values: Record) { + if (!canConsign) { + message.warning('您暂未开通寄卖权限,请联系客服申请开通') + return + } + if (!canPublish) { message.warning(`可用余额不足 ¥${publishDepositYuan.toFixed(0)},请先联系客服充值履约押金`) return @@ -579,7 +588,33 @@ export default function WorkerConsignmentPage() { 发布寄卖 ), - children: ( + children: !canConsign ? ( +
+ + + 寄卖功能暂未开通 + + + 为保障寄卖道具交付质量与履约安全,平台当前实行白名单审核制。请联系在线客服申请开通寄卖权限。 + + +
+ } + /> + + ) : (
+ + setContactOpen(false)} + isMobile={isMobile} + worker={worker} + financeConfig={financeConfig} + title="联系客服开通寄卖" + /> ) } diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 85678d1a..727321f2 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -197,6 +197,16 @@ export function updateAdminWorkerWithdrawalBlacklist( ) } +export function updateAdminWorkerConsignmentPermission( + workerId: number | string, + payload: { canConsign: boolean }, +) { + return apiPost<{ worker: WorkerUser }>( + `/api/v1/admin/worker-platform/workers/${workerId}/consignment-permission`, + payload, + ) +} + export function fetchAdminWorkerWalletLedgers( workerId: number | string, params?: Record, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 24029cbd..f6e2a4c6 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -54,6 +54,10 @@ export type WorkerUser = { lastRenamedAt: string | null /** 上次改名后的一年锁定期结束时间 */ renameUnlockedAt: string | null + /** 是否开通寄卖权限(白名单开关) */ + canConsign: boolean + /** 开通寄卖权限时间 */ + consignmentActivatedAt: string | null } export type WorkerUserStatistics = { diff --git a/docs/寄卖库存优化现状与进度追踪.md b/docs/寄卖库存优化现状与进度追踪.md index e322113f..89522e71 100644 --- a/docs/寄卖库存优化现状与进度追踪.md +++ b/docs/寄卖库存优化现状与进度追踪.md @@ -241,3 +241,32 @@ - 前端 `format:check`、`lint:check`、`typecheck`、`build` 均零错误零警告通过; - 后端 504 个自动化测试全绿(**501 pass / 3 skipped / 0 fail**)。 +--- + +## 九、 专项优化:打手寄卖白名单开关控制与存量平滑过渡(2026-09-21 已闭环) + +### 1. 现状与用户诉求(User Feedback & Target) +- **用户反馈**:“寄卖系统 我想增加一个类似白名单 激活模式, 如果有新的注册的人员, 默认无法开启寄卖; 必须联系客服... 方案 A 就够了, 能实现控制 一个开关也行, 然后 存量老打手平滑过渡 发布过的默认开启”。 +- **目标**: + 1. 实行寄卖白名单准入机制:新注册打手默认不开启寄卖,防止虚假提报与黑产刷单; + 2. 存量老打手平滑过渡:历史上在 `consignment_submissions` 中发布过寄卖的打手,在迁移中自动批量赋权开启; + 3. 后台提供开关:客服/运营在打手管理表格与详情抽屉中可一键开通/关闭打手寄卖权限; + 4. 打手端友好提示与拦截:未开通寄卖权限的打手在「我要寄卖」页面展示专属引导卡片,一键唤起客服弹窗;后端接口进行服务端强拦截(403 `consignment_permission_required`)。 + +### 2. 证据分类与实现细节(Evidence & Implementation) +- **已知信息 [确信度: 100%]**: + 1. `worker_users` 表增加了 `can_consign`(布尔值,默认 `FALSE`)与 `consignment_activated_at`(开通时间戳); + 2. 迁移文件 `082_worker_consignment_whitelist.sql` 通过 `IN (SELECT DISTINCT worker_id FROM consignment_submissions WHERE worker_id IS NOT NULL)` 实现存量打手秒级平滑过渡; + 3. 打手端提报接口 `publishWorkerConsignment` 增加了 `if (!worker.can_consign) throw ...` 强校验; + 4. 前端管理后台 `WorkersPanel.tsx` 增加了「寄卖权限」列,直接嵌入 Switch 开关实时切换;打手详情抽屉 `WorkerDetailDrawer.tsx` 补充了权限和开通时间回显。 + +### 3. 验证与门禁报告(Quality Gate Status) +- **后端单测与类型检查 [确信度: 100%]**: + - 新增 `admin-worker-consignment-permission.test.ts` 及 `consignment-worker-service.test.ts` 权限拦截测试; + - 后端 507 项单测全绿(**504 pass / 3 skipped / 0 fail**); + - 后端 `typecheck`、`check:sql` 全部通过; +- **前端门禁与生产构建 [确信度: 100%]**: + - 前端 59 项单元测试全绿(**59 pass / 0 fail**); + - 前端 `format:check`、`lint:check`、`typecheck`、`build` 零错误全绿通过。 + +