修复接单平台并发、分页与补资料
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildFileUrl,
|
||||
normalizeStoredFileUrl,
|
||||
refreshStoredFileUrl,
|
||||
verifyFileAccessSignature,
|
||||
} from './file-storage-service.js'
|
||||
|
||||
const NOW_SECONDS = 1_800_000_000
|
||||
const OBJECT_KEY = 'worker-acceptance/2026/07/26/example.jpg'
|
||||
|
||||
test('file access URL carries a valid short-lived signature', () => {
|
||||
const url = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
|
||||
const parsed = new URL(url, 'http://localhost')
|
||||
|
||||
assert.equal(parsed.searchParams.get('key'), OBJECT_KEY)
|
||||
assert.doesNotThrow(() =>
|
||||
verifyFileAccessSignature(
|
||||
OBJECT_KEY,
|
||||
parsed.searchParams.get('expires'),
|
||||
parsed.searchParams.get('signature'),
|
||||
NOW_SECONDS + 899,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('file access URL rejects expired and tampered signatures', () => {
|
||||
const parsed = new URL(buildFileUrl(OBJECT_KEY, NOW_SECONDS), 'http://localhost')
|
||||
const expires = parsed.searchParams.get('expires')
|
||||
const signature = parsed.searchParams.get('signature')
|
||||
|
||||
assert.throws(() => verifyFileAccessSignature(OBJECT_KEY, expires, signature, NOW_SECONDS + 901))
|
||||
assert.throws(() =>
|
||||
verifyFileAccessSignature(`${OBJECT_KEY}.changed`, expires, signature, NOW_SECONDS),
|
||||
)
|
||||
})
|
||||
|
||||
test('stored file URL drops signatures and refreshes them for responses', () => {
|
||||
const signedUrl = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
|
||||
const storedUrl = normalizeStoredFileUrl(signedUrl)
|
||||
const refreshedUrl = refreshStoredFileUrl(storedUrl)
|
||||
|
||||
assert.equal(storedUrl, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`)
|
||||
assert.match(refreshedUrl, /[?&]expires=\d+/)
|
||||
assert.match(refreshedUrl, /[?&]signature=/)
|
||||
assert.equal(
|
||||
refreshStoredFileUrl('https://example.com/image.jpg'),
|
||||
'https://example.com/image.jpg',
|
||||
)
|
||||
})
|
||||
@@ -13,6 +13,7 @@ const IMAGE_VARIANT_THUMB = 'thumb'
|
||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
const THUMB_MAX_SIDE = 480
|
||||
const MEDIUM_MAX_SIDE = 1280
|
||||
const FILE_URL_TTL_SECONDS = 15 * 60
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
|
||||
|
||||
@@ -98,9 +99,9 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fileUrl = buildFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl
|
||||
const fileUrl = buildUnsignedFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
|
||||
const asset = await createFileAsset({
|
||||
objectKey,
|
||||
scene,
|
||||
@@ -134,8 +135,13 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
|
||||
export async function getStoredFileObject(
|
||||
key: unknown,
|
||||
variant: unknown = '',
|
||||
access: { expires?: unknown; signature?: unknown } = {},
|
||||
) {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
verifyFileAccessSignature(objectKey, access.expires, access.signature)
|
||||
const variantName = normalizeVariant(variant)
|
||||
const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
|
||||
let lastError: unknown = null
|
||||
@@ -159,8 +165,67 @@ export function getMaxUploadSizeBytes() {
|
||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
||||
}
|
||||
|
||||
export function buildFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}`
|
||||
export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const normalizedKey = normalizeObjectKey(objectKey)
|
||||
const expires = nowSeconds + FILE_URL_TTL_SECONDS
|
||||
const signature = createFileAccessSignature(normalizedKey, expires)
|
||||
return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}`
|
||||
}
|
||||
|
||||
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') {
|
||||
const rawUrl = String(value || '').trim()
|
||||
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
|
||||
return objectKey ? buildFileUrl(objectKey) : rawUrl
|
||||
}
|
||||
|
||||
export function normalizeStoredFileUrl(value: unknown, fallbackObjectKey = '') {
|
||||
const rawUrl = String(value || '').trim()
|
||||
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
|
||||
return objectKey ? buildUnsignedFileUrl(objectKey) : rawUrl
|
||||
}
|
||||
|
||||
export function refreshUploadedFileUrls(value: unknown) {
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
const objectKey = String(source.objectKey || source.object_key || '').trim()
|
||||
if (!objectKey) return source
|
||||
return {
|
||||
...source,
|
||||
objectKey,
|
||||
url: buildFileUrl(objectKey),
|
||||
thumbnailUrl: refreshStoredFileUrl(source.thumbnailUrl || source.thumbnail_url, objectKey),
|
||||
mediumUrl: refreshStoredFileUrl(source.mediumUrl || source.medium_url, objectKey),
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyFileAccessSignature(
|
||||
objectKey: string,
|
||||
expiresValue: unknown,
|
||||
signatureValue: unknown,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
) {
|
||||
const expires = Number(expiresValue || 0)
|
||||
const signature = String(signatureValue || '').trim()
|
||||
if (!Number.isSafeInteger(expires) || expires < nowSeconds || !signature) {
|
||||
throw createHttpError('文件访问链接无效或已过期', {
|
||||
statusCode: 403,
|
||||
errorCode: 'file_access_expired',
|
||||
})
|
||||
}
|
||||
const expected = createFileAccessSignature(normalizeObjectKey(objectKey), expires)
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const actualBuffer = Buffer.from(signature)
|
||||
if (
|
||||
expectedBuffer.length !== actualBuffer.length ||
|
||||
!crypto.timingSafeEqual(expectedBuffer, actualBuffer)
|
||||
) {
|
||||
throw createHttpError('文件访问签名不正确', {
|
||||
statusCode: 403,
|
||||
errorCode: 'file_access_signature_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function imageVariantKey(objectKey: string, variant: string) {
|
||||
@@ -183,15 +248,45 @@ function mapUploadedFile(
|
||||
): UploadedFileDto {
|
||||
return {
|
||||
objectKey: asset?.object_key || fallback.objectKey,
|
||||
url: asset?.url || fallback.fileUrl,
|
||||
thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl,
|
||||
mediumUrl: asset?.medium_url || fallback.mediumUrl,
|
||||
url: buildFileUrl(asset?.object_key || fallback.objectKey),
|
||||
thumbnailUrl: buildFileUrl(
|
||||
asset?.thumbnail_object_key ||
|
||||
extractObjectKeyFromFileUrl(fallback.thumbnailUrl) ||
|
||||
fallback.objectKey,
|
||||
),
|
||||
mediumUrl: buildFileUrl(
|
||||
asset?.medium_object_key ||
|
||||
extractObjectKeyFromFileUrl(fallback.mediumUrl) ||
|
||||
fallback.objectKey,
|
||||
),
|
||||
filename: asset?.original_filename || fallback.filename,
|
||||
contentType: asset?.content_type || fallback.contentType,
|
||||
size: Number(asset?.size_bytes || fallback.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function buildUnsignedFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}`
|
||||
}
|
||||
|
||||
function createFileAccessSignature(objectKey: string, expires: number) {
|
||||
const secret =
|
||||
String(runtimeConfig.admin.sessionSecret || '').trim() ||
|
||||
String(runtimeConfig.storage.secretAccessKey || '').trim()
|
||||
return crypto.createHmac('sha256', secret).update(`${objectKey}\n${expires}`).digest('base64url')
|
||||
}
|
||||
|
||||
function extractObjectKeyFromFileUrl(value: string) {
|
||||
if (!value) return ''
|
||||
try {
|
||||
const parsed = new URL(value, 'http://localhost')
|
||||
if (parsed.pathname !== '/api/v1/files/object') return ''
|
||||
return normalizeObjectKey(parsed.searchParams.get('key'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentType(mimeType: unknown, data: Buffer) {
|
||||
const detected = detectContentType(data)
|
||||
if (detected) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform-repo.js'
|
||||
import { resolveCollectSubmitTargetWorkOrder } from './worker-platform-service.js'
|
||||
|
||||
function buildWorkOrderRow(
|
||||
overrides: Partial<WorkOrderRow> = {},
|
||||
): WorkOrderRow {
|
||||
return {
|
||||
id: 1,
|
||||
work_order_no: 'WO0001',
|
||||
order_id: 101,
|
||||
order_item_id: 201,
|
||||
task_id: null,
|
||||
platform_order_id: 'ORDER-001',
|
||||
product_name: '测试商品',
|
||||
category_id: null,
|
||||
status: 'pending_material',
|
||||
reward_amount: 2500,
|
||||
required_deposit_amount: 0,
|
||||
deposit_threshold_amount: 20000,
|
||||
assigned_worker_id: null,
|
||||
material_json: '{}',
|
||||
requirement_json: '{"fields":[]}',
|
||||
acceptance_json: '{}',
|
||||
problem_note: '',
|
||||
published_at: null,
|
||||
assigned_at: null,
|
||||
submitted_at: null,
|
||||
accepted_at: null,
|
||||
created_at: '2026-07-28T00:00:00.000Z',
|
||||
updated_at: '2026-07-28T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
|
||||
const workOrder = buildWorkOrderRow()
|
||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
|
||||
assert.equal(selected.id, workOrder.id)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder requires explicit work order id when multiple orders are pending', () => {
|
||||
const workOrders = [
|
||||
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
|
||||
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
|
||||
]
|
||||
|
||||
assert.throws(
|
||||
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, ''),
|
||||
(error: unknown) =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(error as { statusCode?: number }).statusCode === 409 &&
|
||||
(error as { errorCode?: string }).errorCode === 'collect_work_order_required',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder returns the matching work order when workOrderId is provided', () => {
|
||||
const workOrders = [
|
||||
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
|
||||
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
|
||||
]
|
||||
|
||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, '2')
|
||||
assert.equal(selected.id, 2)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder rejects unknown workOrderId', () => {
|
||||
const workOrders = [buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' })]
|
||||
|
||||
assert.throws(
|
||||
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, 999),
|
||||
(error: unknown) =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(error as { statusCode?: number }).statusCode === 404 &&
|
||||
(error as { errorCode?: string }).errorCode === 'collect_work_order_not_found',
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -11,21 +11,19 @@ import {
|
||||
createWorkOrder,
|
||||
createWorkOrderEvent,
|
||||
createWorkerUser,
|
||||
findPendingMaterialWorkOrderByPlatformOrderId,
|
||||
getWorkCategoryByKey,
|
||||
incrementWorkerSessionVersion,
|
||||
getWorkOrderById,
|
||||
getWorkOrderByOrderItemId,
|
||||
getWorkerLevelById,
|
||||
getWorkerLevelByKey,
|
||||
getWorkerUserById,
|
||||
getWorkerUserByUsername,
|
||||
listAllWorkCategories,
|
||||
listPendingMaterialWorkOrdersByPlatformOrderId,
|
||||
grabWorkOrder,
|
||||
listWorkCategories,
|
||||
listWorkOrders,
|
||||
listWorkProductRules,
|
||||
listWorkerLevels,
|
||||
listWorkerUsers,
|
||||
resolveProblemWorkOrder,
|
||||
updateWorkOrder,
|
||||
updateWorkerUser,
|
||||
upsertWorkCategory,
|
||||
@@ -36,6 +34,8 @@ import {
|
||||
type WorkProductRuleRow,
|
||||
type WorkerLevelRow,
|
||||
type WorkerUserRow,
|
||||
type GrabWorkOrderFailureReason,
|
||||
type ProblemWorkOrderResolutionAction,
|
||||
} from '../../repositories/worker-platform-repo.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
@@ -43,6 +43,11 @@ import { createHttpError } from '../../utils/http.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
import { nowIso, addHours } from '../../utils/time.js'
|
||||
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
normalizeStoredFileUrl,
|
||||
refreshStoredFileUrl,
|
||||
refreshUploadedFileUrls,
|
||||
} from '../file-storage/file-storage-service.js'
|
||||
|
||||
export type WorkerSession = {
|
||||
sessionId: string
|
||||
@@ -56,12 +61,19 @@ export type WorkerSession = {
|
||||
const DEFAULT_LEVEL_KEY = 'regular'
|
||||
const DEFAULT_CATEGORY_KEY = 'default'
|
||||
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
|
||||
const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
|
||||
'worker_auth_required',
|
||||
'worker_auth_invalid',
|
||||
'worker_auth_expired',
|
||||
'worker_auth_user_invalid',
|
||||
'worker_auth_stale',
|
||||
])
|
||||
|
||||
export async function ensureWorkerPlatformDefaults() {
|
||||
const now = nowIso()
|
||||
const level = await upsertWorkerLevel({
|
||||
levelKey: DEFAULT_LEVEL_KEY,
|
||||
name: '普通接单员',
|
||||
name: '普通打手',
|
||||
sortOrder: 100,
|
||||
status: 'active',
|
||||
permissionJson: JSON.stringify({
|
||||
@@ -152,6 +164,27 @@ export async function loginWorker(username: unknown, password: unknown) {
|
||||
return createWorkerSession(worker)
|
||||
}
|
||||
|
||||
export async function logoutWorkerSession(token: unknown) {
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!normalizedToken) {
|
||||
return { success: true, revoked: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await verifyWorkerSessionToken(normalizedToken)
|
||||
const revokedWorker = await incrementWorkerSessionVersion(session.workerId, nowIso())
|
||||
return {
|
||||
success: true,
|
||||
revoked: Boolean(revokedWorker),
|
||||
}
|
||||
} catch (error) {
|
||||
if (isIgnorableWorkerAuthError(error)) {
|
||||
return { success: true, revoked: false }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSession> {
|
||||
ensureWorkerAuthConfigured()
|
||||
const normalizedToken = String(token || '').trim()
|
||||
@@ -301,16 +334,14 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(worker.id),
|
||||
depositAmount: freezeAmount,
|
||||
maxActiveOrders: permissions.maxActiveOrders,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!grabbed) {
|
||||
throw createHttpError('抢单失败,订单可能已被抢走', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_grab_conflict',
|
||||
})
|
||||
if (!grabbed.order) {
|
||||
throwGrabWorkOrderFailure(grabbed.failureReason)
|
||||
}
|
||||
|
||||
return { order: mapWorkOrderForWorker(grabbed, permissions) }
|
||||
return { order: mapWorkOrderForWorker(grabbed.order, permissions) }
|
||||
}
|
||||
|
||||
export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) {
|
||||
@@ -404,16 +435,25 @@ export async function collectLookupWorkOrder(payload: JsonObject = {}) {
|
||||
errorCode: 'collect_order_no_required',
|
||||
})
|
||||
}
|
||||
const workOrder = await findPendingMaterialWorkOrderByPlatformOrderId(orderNo)
|
||||
if (!workOrder) {
|
||||
const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo)
|
||||
if (workOrders.length === 0) {
|
||||
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_order_not_found',
|
||||
})
|
||||
}
|
||||
const [primaryOrder] = workOrders
|
||||
if (!primaryOrder) {
|
||||
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_order_not_found',
|
||||
})
|
||||
}
|
||||
return {
|
||||
order: mapWorkOrderPublic(workOrder),
|
||||
fields: resolveRequirementFields(workOrder),
|
||||
order: mapWorkOrderPublic(primaryOrder),
|
||||
fields: resolveRequirementFields(primaryOrder),
|
||||
orders: workOrders.map(mapCollectLookupOrder),
|
||||
pendingCount: workOrders.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,13 +465,18 @@ export async function collectSubmitWorkOrder(payload: JsonObject = {}) {
|
||||
errorCode: 'collect_order_no_required',
|
||||
})
|
||||
}
|
||||
const workOrder = await findPendingMaterialWorkOrderByPlatformOrderId(orderNo)
|
||||
if (!workOrder) {
|
||||
const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo)
|
||||
if (workOrders.length === 0) {
|
||||
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_order_not_found',
|
||||
})
|
||||
}
|
||||
const workOrder = resolveCollectSubmitTargetWorkOrder(
|
||||
orderNo,
|
||||
workOrders,
|
||||
payload.workOrderId || payload.work_order_id,
|
||||
)
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
@@ -499,7 +544,7 @@ export async function saveAdminWorkCategory(payload: JsonObject = {}) {
|
||||
export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||
const now = nowIso()
|
||||
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
|
||||
const name = String(payload.name || '').trim() || '普通接单员'
|
||||
const name = String(payload.name || '').trim() || '普通打手'
|
||||
const permissions = {
|
||||
depositFreeAmount: normalizeAmountFen(
|
||||
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
|
||||
@@ -844,6 +889,42 @@ export async function markAdminWorkOrderProblem(
|
||||
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||
}
|
||||
|
||||
export async function resolveAdminProblemWorkOrder(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.PROBLEM) {
|
||||
throw createHttpError('只有问题单可以执行处置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_problem_resolution_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const action = normalizeProblemResolutionAction(payload.action)
|
||||
const note = String(payload.note || '').trim()
|
||||
if (!note) {
|
||||
throw createHttpError('请填写处置备注', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_problem_resolution_note_required',
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await resolveProblemWorkOrder({
|
||||
workOrderId: workOrder.id,
|
||||
action,
|
||||
note,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('问题单处置失败,订单状态可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_problem_resolution_conflict',
|
||||
})
|
||||
}
|
||||
return { order: mapWorkOrderAdmin(updated) }
|
||||
}
|
||||
|
||||
export async function acceptAdminWorkOrder(workOrderId: number | string) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
|
||||
@@ -989,6 +1070,41 @@ function createWorkerSession(worker: WorkerUserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCollectSubmitTargetWorkOrder(
|
||||
orderNo: string,
|
||||
workOrders: WorkOrderRow[],
|
||||
requestedWorkOrderId: unknown,
|
||||
): WorkOrderRow {
|
||||
const workOrderId = normalizeOptionalId(requestedWorkOrderId)
|
||||
if (workOrderId) {
|
||||
const matched = workOrders.find((item) => Number(item.id) === workOrderId)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
throw createHttpError('待完善订单不存在或已更新,请刷新后重试', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_work_order_not_found',
|
||||
context: { orderNo, workOrderId },
|
||||
})
|
||||
}
|
||||
|
||||
if (workOrders.length === 1) {
|
||||
const [singleWorkOrder] = workOrders
|
||||
if (singleWorkOrder) {
|
||||
return singleWorkOrder
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError('该订单存在多个待完善商品,请逐个提交资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'collect_work_order_required',
|
||||
context: {
|
||||
orderNo,
|
||||
pendingCount: workOrders.length,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function ensureWorkerAuthConfigured() {
|
||||
if (String(runtimeConfig.admin?.sessionSecret || '').trim()) {
|
||||
return
|
||||
@@ -1197,7 +1313,7 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
),
|
||||
material: safeParseJson(workOrder.material_json),
|
||||
requirement: safeParseJson(workOrder.requirement_json),
|
||||
acceptance: safeParseJson(workOrder.acceptance_json),
|
||||
acceptance: mapAcceptanceForResponse(workOrder.acceptance_json),
|
||||
problemNote: workOrder.problem_note || '',
|
||||
worker: workOrder.assigned_worker_id
|
||||
? {
|
||||
@@ -1219,14 +1335,17 @@ function mapWorkOrderForWorker(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
) {
|
||||
const mapped = mapWorkOrderAdmin(workOrder)
|
||||
const { requiredDepositAmount, depositThresholdAmount, worker: _worker, ...visibleOrder } = mapped
|
||||
return {
|
||||
...mapWorkOrderAdmin(workOrder),
|
||||
...visibleOrder,
|
||||
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkOrderPublic(workOrder: WorkOrderRow) {
|
||||
return {
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no,
|
||||
platformOrderId: workOrder.platform_order_id,
|
||||
productName: workOrder.product_name,
|
||||
@@ -1234,6 +1353,14 @@ function mapWorkOrderPublic(workOrder: WorkOrderRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapCollectLookupOrder(workOrder: WorkOrderRow) {
|
||||
return {
|
||||
...mapWorkOrderPublic(workOrder),
|
||||
fields: resolveRequirementFields(workOrder),
|
||||
values: resolveCollectSubmittedFieldValues(workOrder),
|
||||
}
|
||||
}
|
||||
|
||||
function mapWallet(wallet: Awaited<ReturnType<typeof addWorkerWalletCredit>>) {
|
||||
return {
|
||||
workerId: Number(wallet?.worker_id || 0),
|
||||
@@ -1406,9 +1533,12 @@ function normalizeUploadedFiles(value: unknown) {
|
||||
if (!objectKey && !url) return null
|
||||
return {
|
||||
objectKey,
|
||||
url,
|
||||
thumbnailUrl: String(source.thumbnailUrl || source.thumbnail_url || url).trim(),
|
||||
mediumUrl: String(source.mediumUrl || source.medium_url || url).trim(),
|
||||
url: normalizeStoredFileUrl(url, objectKey),
|
||||
thumbnailUrl: normalizeStoredFileUrl(
|
||||
source.thumbnailUrl || source.thumbnail_url || url,
|
||||
objectKey,
|
||||
),
|
||||
mediumUrl: normalizeStoredFileUrl(source.mediumUrl || source.medium_url || url, objectKey),
|
||||
filename: String(source.filename || '').trim(),
|
||||
contentType: String(source.contentType || source.content_type || '').trim(),
|
||||
size: normalizeInteger(source.size, 0),
|
||||
@@ -1429,12 +1559,66 @@ function normalizeUploadedFiles(value: unknown) {
|
||||
)
|
||||
}
|
||||
|
||||
function mapAcceptanceForResponse(value: unknown) {
|
||||
const acceptance = safeParseJson(value)
|
||||
const files = normalizeUploadedFiles(acceptance.files).map((file) =>
|
||||
refreshUploadedFileUrls(file),
|
||||
)
|
||||
const imageUrls = normalizeStringArray(acceptance.imageUrls).map((url) =>
|
||||
refreshStoredFileUrl(url),
|
||||
)
|
||||
return {
|
||||
...acceptance,
|
||||
files,
|
||||
imageUrls,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) {
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
const collect =
|
||||
material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect)
|
||||
? safeParseJson(material.collect)
|
||||
: {}
|
||||
return normalizeSubmittedFields(collect.fields)
|
||||
}
|
||||
|
||||
function normalizeReviewStatus(value: unknown) {
|
||||
const status = String(value || '').trim()
|
||||
if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status
|
||||
return 'pending_review'
|
||||
}
|
||||
|
||||
function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
|
||||
const action = String(value || '').trim()
|
||||
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
|
||||
return action as ProblemWorkOrderResolutionAction
|
||||
}
|
||||
throw createHttpError('问题单处置方式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_problem_resolution_action_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
function throwGrabWorkOrderFailure(failureReason: GrabWorkOrderFailureReason | null): never {
|
||||
if (failureReason === 'worker_active_order_limit') {
|
||||
throw createHttpError('已达到当前等级最大同时接单量', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_active_order_limit',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'worker_deposit_insufficient') {
|
||||
throw createHttpError('余额不足,无法冻结所需押金', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_deposit_insufficient',
|
||||
})
|
||||
}
|
||||
throw createHttpError('抢单失败,订单可能已被抢走', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_grab_conflict',
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeEnabledStatus(value: unknown) {
|
||||
return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active'
|
||||
}
|
||||
@@ -1520,3 +1704,9 @@ function normalizeAmountFen(value: unknown, fallback: number): number {
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback
|
||||
return Math.round(parsed * 100)
|
||||
}
|
||||
|
||||
function isIgnorableWorkerAuthError(error: unknown) {
|
||||
return IGNORABLE_WORKER_AUTH_ERROR_CODES.has(
|
||||
String((error as { errorCode?: string })?.errorCode || '').trim(),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user