fix(consignment): 打通寄卖权限鉴权、指派占库竞态与撤销守卫

- 后台权限白名单补齐 consignment.view/manage/review/fulfill,
  避免在「角色权限」页保存任意角色时静默清除寄卖权限
- 寄卖后台 19 个接口由 requireAdminRoles 改为 requireAdminPermission,
  使 view/manage/review/fulfill 四个权限真正生效
- 指派打手履约改为先原子占库(WHERE status = 'in_stock'),
  失败自动回滚库存,消除并发下同一件现货被两张工单同时认领
- 撤销寄售履约拒绝押金履约模式(worker_deposit),
  避免打手押金不释放造成资金挂账;前端同步隐藏该入口
- 撤销回库不再强制覆盖 allow_matching,保留运营手动关闭的匹配开关
- 新增 4 个回归测试:权限白名单与迁移一致性、占库/回滚条件、撤销守卫
This commit is contained in:
yml2213
2026-09-16 11:38:08 +08:00
parent 6b9407967b
commit e828e677a5
6 changed files with 324 additions and 94 deletions
@@ -542,6 +542,81 @@ export async function updateConsignmentInventory(
return res.rows[0] || null
}
/**
* 以“条件更新”方式原子占用在库物品(指派打手押金履约)。
*
* 必须在指派打手之前调用:并发下只有一个调用能拿到 `status = 'in_stock'` 的那一行,
* 避免同一件现货被两张工单同时认领。
* 返回 null 表示物品已被其他操作占用。
*/
export async function claimConsignmentInventoryForWorkerWithClient(
client: PoolClient,
id: number | string,
input: { workerId: number; now: string; allocationStatus?: string },
): Promise<ConsignmentInventoryRow | null> {
const res = await client.query<ConsignmentInventoryRow>(
`
UPDATE consignment_inventories
SET
status = 'consumed',
allocation_status = $1,
worker_id = $2,
consumed_at = $3,
updated_at = $3
WHERE id = $4 AND status = 'in_stock'
RETURNING *
`,
[input.allocationStatus || '已指派打手(押金履约中)', input.workerId, input.now, id],
)
return res.rows[0] || null
}
export async function claimConsignmentInventoryForWorker(
id: number | string,
input: { workerId: number; now: string; allocationStatus?: string },
): Promise<ConsignmentInventoryRow | null> {
return withTransaction((client) =>
claimConsignmentInventoryForWorkerWithClient(client, id, input),
)
}
export async function releaseConsignmentInventoryClaimWithClient(
client: PoolClient,
id: number | string,
input: { now: string; reason?: string },
): Promise<ConsignmentInventoryRow | null> {
const res = await client.query<ConsignmentInventoryRow>(
`
UPDATE consignment_inventories
SET
status = 'in_stock',
allocation_status = '待安排',
worker_id = NULL,
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
remark = CASE
WHEN $2 = '' THEN remark
ELSE remark || ' [指派回滚: ' || $2 || ']'
END,
updated_at = $1
WHERE id = $3 AND status = 'consumed'
RETURNING *
`,
[input.now, input.reason || '', id],
)
return res.rows[0] || null
}
export async function releaseConsignmentInventoryClaim(
id: number | string,
input: { now: string; reason?: string },
): Promise<ConsignmentInventoryRow | null> {
return withTransaction((client) => releaseConsignmentInventoryClaimWithClient(client, id, input))
}
export async function archiveConsignmentInventoryWithClient(
client: PoolClient,
id: number | string,
@@ -22,7 +22,7 @@ import {
fulfillWorkOrderWithConsignment,
revokeWorkOrderConsignmentFulfillment,
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
import { createJsonHandler, requireAdminRoles } from '../session.js'
import { createJsonHandler, requireAdminPermission } from '../session.js'
const router = Router()
@@ -30,7 +30,7 @@ const router = Router()
router.get(
'/worker-platform/consignment/inventories',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.view'),
createJsonHandler((req) => fetchAdminConsignmentInventories(req.query), {
successMessage: 'ok',
errorMessage: '读取寄卖库存失败',
@@ -40,7 +40,7 @@ router.get(
router.get(
'/worker-platform/consignment/stats',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.view'),
createJsonHandler(() => fetchAdminConsignmentStats(), {
successMessage: 'ok',
errorMessage: '读取寄卖统计失败',
@@ -50,7 +50,7 @@ router.get(
router.get(
'/worker-platform/consignment/inventories/:id',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.view'),
createJsonHandler((req) => getAdminConsignmentInventory(String(req.params.id || '')), {
successMessage: 'ok',
errorMessage: '读取寄卖物品详情失败',
@@ -60,7 +60,7 @@ router.get(
router.post(
'/worker-platform/consignment/inventories',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) => createAdminConsignmentInventory(req.body, req.adminSession?.username || 'admin'),
{
@@ -73,7 +73,7 @@ router.post(
router.put(
'/worker-platform/consignment/inventories/:id',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) =>
updateAdminConsignmentInventory(
@@ -91,7 +91,7 @@ router.put(
router.patch(
'/worker-platform/consignment/inventories/:id/matching',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) =>
toggleAdminConsignmentMatching(String(req.params.id || ''), Boolean(req.body?.allowMatching)),
@@ -105,7 +105,7 @@ router.patch(
router.post(
'/worker-platform/consignment/inventories/:id/archive',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.manage'),
createJsonHandler((req) => archiveAdminConsignmentInventory(String(req.params.id || '')), {
successMessage: '物品已下架作废',
errorMessage: '作废寄卖物品失败',
@@ -115,7 +115,7 @@ router.post(
router.post(
'/worker-platform/consignment/inventories/:id/return',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) =>
returnAdminConsignmentInventory(
@@ -135,7 +135,7 @@ router.post(
router.get(
'/worker-platform/consignment/links',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.view'),
createJsonHandler(() => fetchAdminConsignmentLinks(), {
successMessage: 'ok',
errorMessage: '读取收集链接列表失败',
@@ -145,7 +145,7 @@ router.get(
router.post(
'/worker-platform/consignment/links',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) => createAdminConsignmentLink(req.body, req.adminSession?.username || 'admin'),
{
@@ -158,7 +158,7 @@ router.post(
router.put(
'/worker-platform/consignment/links/:id',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.manage'),
createJsonHandler((req) => updateAdminConsignmentLink(String(req.params.id || ''), req.body), {
successMessage: '收集链接已更新',
errorMessage: '更新收集链接失败',
@@ -170,7 +170,7 @@ router.put(
router.get(
'/worker-platform/consignment/submissions',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.view'),
createJsonHandler((req) => fetchAdminConsignmentSubmissions(req.query), {
successMessage: 'ok',
errorMessage: '读取提报列表失败',
@@ -180,7 +180,7 @@ router.get(
router.get(
'/worker-platform/consignment/submissions/:id',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.view'),
createJsonHandler((req) => getAdminConsignmentSubmission(String(req.params.id || '')), {
successMessage: 'ok',
errorMessage: '读取提报详情失败',
@@ -190,7 +190,7 @@ router.get(
router.post(
'/worker-platform/consignment/submissions/:id/approve',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.review'),
createJsonHandler(
(req) =>
approveAdminConsignmentSubmission(
@@ -208,7 +208,7 @@ router.post(
router.post(
'/worker-platform/consignment/submissions/:id/reject',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.review'),
createJsonHandler(
(req) =>
rejectAdminConsignmentSubmission(
@@ -228,7 +228,7 @@ router.post(
router.get(
'/worker-platform/consignment/matches/:workOrderId',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.view'),
createJsonHandler((req) => checkWorkOrderInventoryMatches(String(req.params.workOrderId || '')), {
successMessage: 'ok',
errorMessage: '检查寄售库存匹配失败',
@@ -238,7 +238,7 @@ router.get(
router.post(
'/worker-platform/consignment/fulfill',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.fulfill'),
createJsonHandler(
(req) =>
fulfillWorkOrderWithConsignment({
@@ -256,7 +256,7 @@ router.post(
router.post(
'/worker-platform/consignment/assign-worker',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.fulfill'),
createJsonHandler(
(req) =>
assignWorkOrderWithConsignmentWorker({
@@ -275,7 +275,7 @@ router.post(
router.post(
'/worker-platform/consignment/revoke',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminPermission('consignment.fulfill'),
createJsonHandler(
(req) =>
revokeWorkOrderConsignmentFulfillment({
@@ -21,6 +21,10 @@ export const ADMIN_PERMISSION_KEYS = [
'worker_order.manage_problem',
'worker_order.deduct_deposit',
'worker_cancel_request.review',
'consignment.view',
'consignment.manage',
'consignment.review',
'consignment.fulfill',
] as const
export type AdminPermissionKey = (typeof ADMIN_PERMISSION_KEYS)[number]
@@ -3,9 +3,10 @@ import { withTransaction } from '../../db/client.js'
import { WORK_ORDER_STATUS, type WorkOrderStatus } from '../../domain/work-order-status.js'
import {
type ConsignmentInventoryRow,
claimConsignmentInventoryForWorker,
getConsignmentInventoryById,
listActiveConsignmentInventoriesForMatching,
updateConsignmentInventory,
releaseConsignmentInventoryClaim,
} from '../../repositories/worker-platform/consignment-repo.js'
import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
@@ -310,82 +311,134 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
})
}
// 1. 调用接单平台统一指派流程(进行打手可用性校验、余额与押金冻结、电子凭证核销、工单状态流转到 in_progress)
const assignResult = await assignAdminWorkOrderToWorker(
workOrder.id,
{ workerId: targetWorkerId },
input.actorName,
)
const now = nowIso()
const material = safeParseJson(workOrder.material_json)
// 2. 将寄售手枪信息写入工单 material
const updatedMaterial = {
...material,
consignment: {
inventoryId: Number(inventory.id),
inventoryNo: inventory.inventory_no,
itemName: inventory.item_name,
system: inventory.system,
serverZone: inventory.server_zone,
series: inventory.series,
diamondLevel: inventory.diamond_level,
brillianceCount: inventory.brilliance_count,
costPriceFen: inventory.cost_price_fen,
workerId: Number(targetWorkerId),
workerName: inventory.worker_name || '',
sourceSubmissionId: inventory.source_submission_id
? Number(inventory.source_submission_id)
: null,
fulfillmentType: 'worker_deposit',
assignedAt: now,
assignedBy: input.actorName,
},
}
const rewardAmountYuan =
Number(inventory.cost_price_fen || 0) > 0
? Number((Number(inventory.cost_price_fen) / 100).toFixed(2))
: Number(workOrder.reward_amount || 0)
await updateWorkOrder(workOrder.id, {
material_json: updatedMaterial,
reward_amount: rewardAmountYuan,
updated_at: now,
})
// 3. 将寄卖库存置为 consumed 已出库/已安排打手
const updatedInventory = await updateConsignmentInventory(inventory.id, {
status: 'consumed',
allocationStatus: '已指派打手(押金履约中)',
// 1. 先原子占用库存:并发指派时只有一方能把 status 从 in_stock 改成 consumed,
// 避免同一件现货被两张工单同时认领。
const claimed = await claimConsignmentInventoryForWorker(inventory.id, {
workerId: Number(targetWorkerId),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: input.actorName,
eventType: 'consignment_worker_assigned',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
payloadJson: JSON.stringify({
inventoryId: inventory.id,
inventoryNo: inventory.inventory_no,
workerId: targetWorkerId,
costPriceFen: inventory.cost_price_fen,
}),
now,
})
const refreshedOrder = await getWorkOrderById(workOrder.id)
return {
order: mapWorkOrderAdmin(refreshedOrder || (assignResult.order as any)),
inventory: mapConsignmentInventory(updatedInventory!),
voucherConsume: assignResult.voucherConsume,
if (!claimed) {
throw createHttpError('寄卖库存已被其他操作占用,请刷新重试', {
statusCode: 409,
errorCode: 'consignment_inventory_race_condition',
})
}
try {
// 2. 调用接单平台统一指派流程(进行打手可用性校验、余额与押金冻结、电子凭证核销、工单状态流转到 in_progress)
const assignResult = await assignAdminWorkOrderToWorker(
workOrder.id,
{ workerId: targetWorkerId },
input.actorName,
)
const material = safeParseJson(workOrder.material_json)
// 3. 将寄售手枪信息写入工单 material
const updatedMaterial = {
...material,
consignment: {
inventoryId: Number(claimed.id),
inventoryNo: claimed.inventory_no,
itemName: claimed.item_name,
system: claimed.system,
serverZone: claimed.server_zone,
series: claimed.series,
diamondLevel: claimed.diamond_level,
brillianceCount: claimed.brilliance_count,
costPriceFen: claimed.cost_price_fen,
workerId: Number(targetWorkerId),
workerName: claimed.worker_name || '',
sourceSubmissionId: claimed.source_submission_id
? Number(claimed.source_submission_id)
: null,
fulfillmentType: 'worker_deposit',
assignedAt: now,
assignedBy: input.actorName,
},
}
const rewardAmountYuan =
Number(claimed.cost_price_fen || 0) > 0
? Number((Number(claimed.cost_price_fen) / 100).toFixed(2))
: Number(workOrder.reward_amount || 0)
await updateWorkOrder(workOrder.id, {
material_json: updatedMaterial,
reward_amount: rewardAmountYuan,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: input.actorName,
eventType: 'consignment_worker_assigned',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
payloadJson: JSON.stringify({
inventoryId: claimed.id,
inventoryNo: claimed.inventory_no,
workerId: targetWorkerId,
costPriceFen: claimed.cost_price_fen,
}),
now,
})
const refreshedOrder = await getWorkOrderById(workOrder.id)
return {
order: mapWorkOrderAdmin(refreshedOrder || (assignResult.order as any)),
inventory: mapConsignmentInventory(claimed),
voucherConsume: assignResult.voucherConsume,
}
} catch (error) {
// 指派/写资料失败:立刻把现货放回在库池,避免库存被长期占用而无法再匹配。
// 回滚失败不能掩盖原始错误,仅在库存备注中保留痕迹。
try {
await releaseConsignmentInventoryClaim(inventory.id, {
now: nowIso(),
reason: '指派打手失败自动回滚',
})
} catch {
// 忽略:以原始错误为准
}
throw error
}
}
/**
* 校验工单上的寄售信息是否可走「撤销寄售履约」流程,并返回关联库存 ID。
*
* 只有“内部现货占库”履约才能走本流程;押金履约模式(worker_deposit)由打手接单并
* 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」,
* 否则打手押金不会释放,会造成资金挂账。
*/
export function resolveRevocableConsignmentInventoryId(
consignment: Record<string, unknown>,
): number {
const inventoryId = consignment.inventoryId ? Number(consignment.inventoryId) : 0
if (!inventoryId) {
throw createHttpError('该工单没有内部寄售履约记录,无法撤销', {
statusCode: 409,
errorCode: 'consignment_fulfillment_missing',
})
}
if (
String(consignment.fulfillmentType || '') === CONSIGNMENT_FULFILLMENT_TYPE.WORKER_DEPOSIT ||
Number(consignment.workerId || 0) > 0
) {
throw createHttpError(
'该工单为押金履约模式,请使用「撤单并退回大厅」或「撤单并取消订单」处理',
{
statusCode: 409,
errorCode: 'consignment_fulfillment_worker_mode',
},
)
}
return inventoryId
}
/**
@@ -407,7 +460,7 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
const material = safeParseJson(workOrder.material_json)
const consignmentInfo = safeParseJson(material.consignment)
const inventoryId = consignmentInfo.inventoryId ? Number(consignmentInfo.inventoryId) : null
const inventoryId = resolveRevocableConsignmentInventoryId(consignmentInfo)
const now = nowIso()
@@ -451,7 +504,6 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
allow_matching = TRUE,
remark = remark || ' [撤销回库: ' || $1 || ']',
updated_at = $2
WHERE id = $3
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import type { PoolClient } from 'pg'
import type {
@@ -57,6 +60,102 @@ import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
releaseConsignmentPublishDepositWithClient,
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
import {
claimConsignmentInventoryForWorkerWithClient,
releaseConsignmentInventoryClaimWithClient,
} from '../../repositories/worker-platform/consignment-repo.js'
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
import { resolveRevocableConsignmentInventoryId } from './consignment-fulfillment-service.js'
test('后台权限白名单覆盖全部迁移登记的权限,避免保存角色权限时被静默清除', () => {
const migrationsDir = fileURLToPath(new URL('../../db/migrations', import.meta.url))
const registered = new Set<string>()
for (const file of fs.readdirSync(migrationsDir)) {
if (!file.endsWith('.sql')) continue
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8')
// 只解析 INSERT INTO admin_permissions 的 VALUES 元组,避免误抓其他点号字符串。
for (const block of sql.split(/INSERT INTO admin_permissions/i).slice(1)) {
const body = block.split(/ON CONFLICT/i)[0] || ''
for (const match of body.matchAll(/\(\s*'([a-z_]+(?:\.[a-z_]+)+)'/g)) {
registered.add(match[1]!)
}
}
}
assert.ok(registered.size > 0, '应能从迁移中解析出权限键')
const whitelist = new Set<string>(ADMIN_PERMISSION_KEYS)
const missing = [...registered].filter((key) => !whitelist.has(key)).sort()
assert.deepEqual(
missing,
[],
`以下权限已在迁移中登记但不在 ADMIN_PERMISSION_KEYS 中,保存角色权限时会被静默删除:${missing.join(', ')}`,
)
})
test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用返回 null', async () => {
const statements: string[] = []
let matched = false
const client = {
query: async (sql: string) => {
statements.push(sql)
return { rows: matched ? [{ id: 9, inventory_no: 'INV2609150001' }] : [] }
},
} as unknown as PoolClient
const missed = await claimConsignmentInventoryForWorkerWithClient(client, 9, {
workerId: 3,
now: '2026-09-15T00:00:00.000Z',
})
assert.equal(missed, null)
matched = true
const claimed = await claimConsignmentInventoryForWorkerWithClient(client, 9, {
workerId: 3,
now: '2026-09-15T00:00:00.000Z',
})
assert.equal(claimed?.inventory_no, 'INV2609150001')
assert.ok(statements[0]!.includes("WHERE id = $4 AND status = 'in_stock'"))
assert.ok(statements[0]!.includes('RETURNING *'))
})
test('寄卖指派失败回滚只在物品仍为 consumed 时放回在库', async () => {
const statements: string[] = []
const client = {
query: async (sql: string) => {
statements.push(sql)
return { rows: [] }
},
} as unknown as PoolClient
const released = await releaseConsignmentInventoryClaimWithClient(client, 9, {
now: '2026-09-15T00:00:00.000Z',
reason: '指派打手失败自动回滚',
})
assert.equal(released, null)
assert.ok(statements[0]!.includes("WHERE id = $3 AND status = 'consumed'"))
})
test('撤销寄售履约仅允许内部现货占库,押金履约必须走撤单流程', () => {
assert.equal(
resolveRevocableConsignmentInventoryId({ inventoryId: 12, fulfillmentType: 'internal_stock' }),
12,
)
const expectRejected = (payload: Record<string, unknown>, errorCode: string) => {
assert.throws(
() => resolveRevocableConsignmentInventoryId(payload),
(error: unknown) => (error as { errorCode?: string }).errorCode === errorCode,
)
}
expectRejected({}, 'consignment_fulfillment_missing')
expectRejected(
{ inventoryId: 12, fulfillmentType: 'worker_deposit' },
'consignment_fulfillment_worker_mode',
)
expectRejected({ inventoryId: 12, workerId: 7 }, 'consignment_fulfillment_worker_mode')
})
test('寄卖发布押金配置保留显式零值并回退非法值', () => {
assert.equal(
@@ -1454,7 +1454,7 @@ export default function WorkOrdersPanel() {
扣押金
</Button>
) : null}
{(row.material as any)?.consignment ? (
{isInternalConsignmentOrder(row) ? (
<Button
icon={<RollbackOutlined />}
danger