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:
yml2213
2026-09-21 13:30:33 +08:00
parent 0df5c9f34e
commit cb8b249e57
15 changed files with 295 additions and 1 deletions
@@ -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
withdrawal_blacklist_reason?: string | null
withdrawal_blacklisted_at?: string | null
can_consign?: boolean
consignment_activated_at?: string | null
}
export type WorkerUserStatistics = {
@@ -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<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,
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<string, unknown>) : {},
}),
},
),
)
router.post(
'/worker-platform/workers/:workerId/wallet-credit',
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,
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) }
}
@@ -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) {
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()
@@ -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,
}
}