feat(worker-platform): 实现打手寄卖白名单权限控制与存量发布打手平滑过渡
1. 新增数据库迁移 082_worker_consignment_whitelist.sql,worker_users 表增加 can_consign 与 consignment_activated_at 字段,存量在 consignment_submissions 提报过的打手默认自动开启; 2. 打手端提报接口 publishWorkerConsignment 增加白名单权限服务端强校验拦截(403 consignment_permission_required); 3. 管理后台打手管理表格增加「寄卖权限」实时 Switch 开关列与打手详情抽屉权限展示,支持客服即时开通或关闭打手寄卖权限; 4. 打手端我要寄卖页面在未开通权限时展示专属白名单引导卡片,一键呼起联系客服开通寄卖弹窗; 5. 补充寄卖权限拦截与路由单测,前端59项单测与后端507项单测全部通过。
This commit is contained in:
@@ -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
|
||||||
|
);
|
||||||
@@ -45,6 +45,8 @@ export type WorkerUserRow = {
|
|||||||
is_withdrawal_blacklisted?: boolean
|
is_withdrawal_blacklisted?: boolean
|
||||||
withdrawal_blacklist_reason?: string | null
|
withdrawal_blacklist_reason?: string | null
|
||||||
withdrawal_blacklisted_at?: string | null
|
withdrawal_blacklisted_at?: string | null
|
||||||
|
can_consign?: boolean
|
||||||
|
consignment_activated_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkerUserStatistics = {
|
export type WorkerUserStatistics = {
|
||||||
|
|||||||
@@ -478,3 +478,29 @@ export async function consumeWorkerRenameCard(input: {
|
|||||||
}
|
}
|
||||||
return getWorkerUserById(input.workerId)
|
return getWorkerUserById(input.workerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateWorkerConsignmentPermission(input: {
|
||||||
|
workerId: number | string
|
||||||
|
canConsign: boolean
|
||||||
|
now: string
|
||||||
|
}): Promise<WorkerUserRow | null> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
saveAdminWorkerWithdrawalAccount,
|
saveAdminWorkerWithdrawalAccount,
|
||||||
setAdminWorkerPersonalDepositFreeAmount,
|
setAdminWorkerPersonalDepositFreeAmount,
|
||||||
updateAdminWorkerWithdrawalBlacklist,
|
updateAdminWorkerWithdrawalBlacklist,
|
||||||
|
updateAdminWorkerConsignmentPermission,
|
||||||
} from '../../../services/worker-platform/index.js'
|
} from '../../../services/worker-platform/index.js'
|
||||||
import { createJsonHandler, requireAdminRoles } from '../session.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<string, unknown>) : {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/worker-platform/workers/:workerId/wallet-credit',
|
'/worker-platform/workers/:workerId/wallet-credit',
|
||||||
requireAdminRoles(['admin', 'operator']),
|
requireAdminRoles(['admin', 'operator']),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
updateWorkerPassword,
|
updateWorkerPassword,
|
||||||
updateWorkerUser,
|
updateWorkerUser,
|
||||||
updateWorkerWithdrawalBlacklist,
|
updateWorkerWithdrawalBlacklist,
|
||||||
|
updateWorkerConsignmentPermission,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
import type { JsonObject } from '../../types/json.js'
|
import type { JsonObject } from '../../types/json.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
@@ -178,3 +179,18 @@ export async function updateAdminWorkerWithdrawalBlacklist(
|
|||||||
})
|
})
|
||||||
return { worker: mapWorkerUser(updated || worker) }
|
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) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 错误码',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
|||||||
if (!worker) {
|
if (!worker) {
|
||||||
throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
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 system = String(payload.system || '').trim()
|
||||||
const serverZone = String(payload.serverZone || '').trim()
|
const serverZone = String(payload.serverZone || '').trim()
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export function mapWorkerUser(worker: WorkerUserRow) {
|
|||||||
renameUnlockedAt: worker.last_renamed_at
|
renameUnlockedAt: worker.last_renamed_at
|
||||||
? new Date(Date.parse(worker.last_renamed_at) + WORKER_RENAME_COOLDOWN_MS).toISOString()
|
? new Date(Date.parse(worker.last_renamed_at) + WORKER_RENAME_COOLDOWN_MS).toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
canConsign: Boolean(worker.can_consign),
|
||||||
|
consignmentActivatedAt: worker.consignment_activated_at || null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,18 @@ function OverviewSection({ workerId }: { workerId: number }) {
|
|||||||
<Tag color="green">正常可提现</Tag>
|
<Tag color="green">正常可提现</Tag>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="寄卖权限" span={2}>
|
||||||
|
<Space>
|
||||||
|
<Tag color={worker.canConsign ? 'green' : 'default'}>
|
||||||
|
{worker.canConsign ? '已开通寄卖' : '未开通(白名单限制)'}
|
||||||
|
</Tag>
|
||||||
|
{worker.consignmentActivatedAt ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
开通时间:{formatAdminDateTime(worker.consignmentActivatedAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
<Card size="small" title="钱包">
|
<Card size="small" title="钱包">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
saveAdminWorkerPersonalDepositFreeAmount,
|
saveAdminWorkerPersonalDepositFreeAmount,
|
||||||
saveAdminWorkerWithdrawalAccount,
|
saveAdminWorkerWithdrawalAccount,
|
||||||
updateAdminWorkerWithdrawalBlacklist,
|
updateAdminWorkerWithdrawalBlacklist,
|
||||||
|
updateAdminWorkerConsignmentPermission,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import ImageUpload from '@/components/files/ImageUpload'
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
import type {
|
import type {
|
||||||
@@ -464,6 +466,27 @@ export default function WorkersPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [updatingConsignmentWorkerId, setUpdatingConsignmentWorkerId] = useState<number | null>(
|
||||||
|
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<WorkerUser> = [
|
const columns: TableColumnsType<WorkerUser> = [
|
||||||
{
|
{
|
||||||
title: '打手',
|
title: '打手',
|
||||||
@@ -554,6 +577,19 @@ export default function WorkersPanel() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '寄卖权限',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Switch
|
||||||
|
checkedChildren="已开通"
|
||||||
|
unCheckedChildren="未开通"
|
||||||
|
checked={row.canConsign}
|
||||||
|
loading={updatingConsignmentWorkerId === row.workerId}
|
||||||
|
onChange={(checked) => handleToggleConsignmentPermission(row, checked)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '冻结原因',
|
title: '冻结原因',
|
||||||
width: 220,
|
width: 220,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
CustomerServiceOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import ImageUpload from '@/components/files/ImageUpload'
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
|
||||||
import {
|
import {
|
||||||
confirmWorkerConsignmentFriendAdded,
|
confirmWorkerConsignmentFriendAdded,
|
||||||
fetchWorkerConsignmentPriceSuggestion,
|
fetchWorkerConsignmentPriceSuggestion,
|
||||||
@@ -198,6 +200,8 @@ export default function WorkerConsignmentPage() {
|
|||||||
const publishDepositYuan = publishDepositFen / 100
|
const publishDepositYuan = publishDepositFen / 100
|
||||||
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
||||||
const canPublish = availableAmount >= publishDepositFen
|
const canPublish = availableAmount >= publishDepositFen
|
||||||
|
const canConsign = worker?.canConsign !== false
|
||||||
|
const [contactOpen, setContactOpen] = useState(false)
|
||||||
|
|
||||||
const reviewingCount = records.filter((item) => item.group === 'reviewing').length
|
const reviewingCount = records.filter((item) => item.group === 'reviewing').length
|
||||||
const activeCount = records.filter((item) => item.group === 'active').length
|
const activeCount = records.filter((item) => item.group === 'active').length
|
||||||
@@ -338,6 +342,11 @@ export default function WorkerConsignmentPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublishSubmit(values: Record<string, any>) {
|
async function handlePublishSubmit(values: Record<string, any>) {
|
||||||
|
if (!canConsign) {
|
||||||
|
message.warning('您暂未开通寄卖权限,请联系客服申请开通')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!canPublish) {
|
if (!canPublish) {
|
||||||
message.warning(`可用余额不足 ¥${publishDepositYuan.toFixed(0)},请先联系客服充值履约押金`)
|
message.warning(`可用余额不足 ¥${publishDepositYuan.toFixed(0)},请先联系客服充值履约押金`)
|
||||||
return
|
return
|
||||||
@@ -579,7 +588,33 @@ export default function WorkerConsignmentPage() {
|
|||||||
<DollarCircleOutlined /> 发布寄卖
|
<DollarCircleOutlined /> 发布寄卖
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: (
|
children: !canConsign ? (
|
||||||
|
<div style={{ padding: '36px 16px', textAlign: 'center' }}>
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
<div style={{ maxWidth: 420, margin: '0 auto' }}>
|
||||||
|
<Typography.Title level={5} style={{ marginBottom: 8 }}>
|
||||||
|
寄卖功能暂未开通
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 13, marginBottom: 20 }}
|
||||||
|
>
|
||||||
|
为保障寄卖道具交付质量与履约安全,平台当前实行白名单审核制。请联系在线客服申请开通寄卖权限。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CustomerServiceOutlined />}
|
||||||
|
onClick={() => setContactOpen(true)}
|
||||||
|
>
|
||||||
|
联系客服开通寄卖
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
@@ -929,6 +964,15 @@ export default function WorkerConsignmentPage() {
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<WorkerContactModal
|
||||||
|
open={contactOpen}
|
||||||
|
onClose={() => setContactOpen(false)}
|
||||||
|
isMobile={isMobile}
|
||||||
|
worker={worker}
|
||||||
|
financeConfig={financeConfig}
|
||||||
|
title="联系客服开通寄卖"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
export function fetchAdminWorkerWalletLedgers(
|
||||||
workerId: number | string,
|
workerId: number | string,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export type WorkerUser = {
|
|||||||
lastRenamedAt: string | null
|
lastRenamedAt: string | null
|
||||||
/** 上次改名后的一年锁定期结束时间 */
|
/** 上次改名后的一年锁定期结束时间 */
|
||||||
renameUnlockedAt: string | null
|
renameUnlockedAt: string | null
|
||||||
|
/** 是否开通寄卖权限(白名单开关) */
|
||||||
|
canConsign: boolean
|
||||||
|
/** 开通寄卖权限时间 */
|
||||||
|
consignmentActivatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkerUserStatistics = {
|
export type WorkerUserStatistics = {
|
||||||
|
|||||||
@@ -241,3 +241,32 @@
|
|||||||
- 前端 `format:check`、`lint:check`、`typecheck`、`build` 均零错误零警告通过;
|
- 前端 `format:check`、`lint:check`、`typecheck`、`build` 均零错误零警告通过;
|
||||||
- 后端 504 个自动化测试全绿(**501 pass / 3 skipped / 0 fail**)。
|
- 后端 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` 零错误全绿通过。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user