diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index 471c8a45..9871ad22 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -18,6 +18,8 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [ 'CADDY_SITE_ADDR', 'CHOKIDAR_USEPOLLING', 'KUASHOU_INDUSTRY_RATE_LIMIT_MAX', + 'MINIO_API_PORT', + 'MINIO_CONSOLE_PORT', 'MINIO_ROOT_PASSWORD', 'MINIO_ROOT_USER', 'NPM_CONFIG_REGISTRY', diff --git a/apps/backend/src/db/migrations/004_worker_order_platform.sql b/apps/backend/src/db/migrations/004_worker_order_platform.sql index 36d42bde..2e81d632 100644 --- a/apps/backend/src/db/migrations/004_worker_order_platform.sql +++ b/apps/backend/src/db/migrations/004_worker_order_platform.sql @@ -119,13 +119,13 @@ ALTER TABLE worker_wallet_ledgers ADD CONSTRAINT fk_worker_wallet_ledgers_work_order FOREIGN KEY (related_work_order_id) REFERENCES work_orders(id) ON DELETE SET NULL; -COMMENT ON TABLE worker_levels IS '接单员等级与权限配置'; +COMMENT ON TABLE worker_levels IS '打手等级与权限配置'; COMMENT ON COLUMN worker_levels.permission_json IS '等级权限:免押额度、最大同时接单量等'; -COMMENT ON TABLE worker_users IS '接单员账号,注册后需后台审核'; -COMMENT ON TABLE worker_wallets IS '接单员余额与冻结押金汇总'; -COMMENT ON TABLE worker_wallet_ledgers IS '接单员钱包流水,人工充值、冻结、解冻、结算均记录'; +COMMENT ON TABLE worker_users IS '打手账号,注册后需后台审核'; +COMMENT ON TABLE worker_wallets IS '打手余额与冻结押金汇总'; +COMMENT ON TABLE worker_wallet_ledgers IS '打手钱包流水,人工充值、冻结、解冻、结算均记录'; COMMENT ON TABLE work_categories IS '接单大厅分类'; COMMENT ON TABLE work_orders IS '接单平台工单,独立于现有履约任务状态机'; -COMMENT ON COLUMN work_orders.reward_amount IS '接单员看到并结算的金额,单位分'; +COMMENT ON COLUMN work_orders.reward_amount IS '打手看到并结算的金额,单位分'; COMMENT ON COLUMN work_orders.required_deposit_amount IS '单笔工单所需冻结押金,单位分'; COMMENT ON TABLE work_order_events IS '接单工单状态与操作流水'; diff --git a/apps/backend/src/db/migrations/005_file_storage_and_worker_product_rules.sql b/apps/backend/src/db/migrations/005_file_storage_and_worker_product_rules.sql index 0c3b2ca9..9757f432 100644 --- a/apps/backend/src/db/migrations/005_file_storage_and_worker_product_rules.sql +++ b/apps/backend/src/db/migrations/005_file_storage_and_worker_product_rules.sql @@ -63,5 +63,5 @@ COMMENT ON COLUMN file_assets.object_key IS '对象存储 key'; COMMENT ON COLUMN file_assets.thumbnail_object_key IS '缩略图对象 key'; COMMENT ON COLUMN file_assets.medium_object_key IS '中图对象 key'; COMMENT ON TABLE work_product_rules IS '接单平台物品规则:决定哪些源订单商品生成接单工单'; -COMMENT ON COLUMN work_product_rules.reward_amount IS '后台发布给接单员的单笔金额,单位分'; +COMMENT ON COLUMN work_product_rules.reward_amount IS '后台发布给打手的单笔金额,单位分'; COMMENT ON COLUMN work_product_rules.auto_create IS '源订单入库后是否自动创建接单工单'; diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts new file mode 100644 index 00000000..3fdd999f --- /dev/null +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveOutstandingDepositAmount } from './worker-platform-repo.js' + +test('resolveOutstandingDepositAmount deducts released and deducted history', () => { + assert.equal( + resolveOutstandingDepositAmount({ + frozen_amount: 20_000, + released_amount: 5_000, + deducted_amount: 3_000, + }), + 12_000, + ) +}) + +test('resolveOutstandingDepositAmount never returns a negative deposit', () => { + assert.equal( + resolveOutstandingDepositAmount({ + frozen_amount: 10_000, + released_amount: 12_000, + deducted_amount: 1_000, + }), + 0, + ) + assert.equal(resolveOutstandingDepositAmount(null), 0) +}) diff --git a/apps/backend/src/repositories/worker-platform-repo.ts b/apps/backend/src/repositories/worker-platform-repo.ts index 659ec7af..5bddf475 100644 --- a/apps/backend/src/repositories/worker-platform-repo.ts +++ b/apps/backend/src/repositories/worker-platform-repo.ts @@ -104,6 +104,22 @@ export type WorkOrderRow = { worker_display_name?: string } +export type ProblemWorkOrderResolutionAction = + | 'return_to_worker' + | 'reopen' + | 'cancel_release' + | 'cancel_deduct' + +export type GrabWorkOrderFailureReason = + | 'worker_active_order_limit' + | 'worker_deposit_insufficient' + | 'work_order_not_open' + +export type GrabWorkOrderResult = { + order: WorkOrderRow | null + failureReason: GrabWorkOrderFailureReason | null +} + type ListInput = { page?: number pageSize?: number @@ -317,6 +333,25 @@ export async function updateWorkerUser( return getWorkerUserById(workerId) } +export async function incrementWorkerSessionVersion( + workerId: number | string, + now: string, +): Promise { + const result = await query<{ id: number }>( + ` + UPDATE worker_users + SET session_version = session_version + 1, updated_at = $2 + WHERE id = $1 + RETURNING id + `, + [Number(workerId), now], + ) + if (!result.rows[0]) { + return null + } + return getWorkerUserById(workerId) +} + export async function listWorkerUsers({ page = 1, pageSize = 20, @@ -604,6 +639,18 @@ export async function findPendingMaterialWorkOrderByPlatformOrderId( return result.rows[0] || null } +export async function listPendingMaterialWorkOrdersByPlatformOrderId( + platformOrderId: string, +): Promise { + const result = await query( + `${WORK_ORDER_SELECT} + WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' + ORDER BY wo.id ASC`, + [String(platformOrderId || '').trim()], + ) + return result.rows +} + export async function listWorkOrders({ page = 1, pageSize = 20, @@ -690,14 +737,26 @@ export async function grabWorkOrder(input: { workOrderId: number workerId: number depositAmount: number + maxActiveOrders: number now: string -}): Promise { +}): Promise { return withTransaction(async (client) => { await ensureWorkerWalletWithClient(client, input.workerId, input.now) const wallet = await getWorkerWalletWithClient(client, input.workerId) + const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) + if (activeOrderCount >= input.maxActiveOrders) { + return { + order: null, + failureReason: 'worker_active_order_limit', + } + } + const available = Number(wallet?.available_amount || 0) if (available < input.depositAmount) { - return null + return { + order: null, + failureReason: 'worker_deposit_insufficient', + } } const result = await client.query<{ id: number }>( @@ -716,7 +775,10 @@ export async function grabWorkOrder(input: { [input.workerId, input.now, input.workOrderId], ) if (!result.rows[0]) { - return null + return { + order: null, + failureReason: 'work_order_not_open', + } } if (input.depositAmount > 0) { @@ -759,7 +821,10 @@ export async function grabWorkOrder(input: { now: input.now, }) - return getWorkOrderByIdWithClient(client, input.workOrderId) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } }) } @@ -796,18 +861,8 @@ export async function acceptWorkOrderAndSettle(input: { if (workerId > 0) { await ensureWorkerWalletWithClient(client, workerId, input.now) const wallet = await getWorkerWalletWithClient(client, workerId) - const frozenLedgerResult = await client.query<{ amount: number }>( - ` - SELECT COALESCE(ABS(SUM(amount)), 0)::int AS amount - FROM worker_wallet_ledgers - WHERE worker_id = $1 - AND related_work_order_id = $2 - AND ledger_type = 'deposit_freeze' - `, - [workerId, input.workOrderId], - ) const releaseAmount = Math.min( - Number(frozenLedgerResult.rows[0]?.amount || 0), + await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId), Number(wallet?.frozen_deposit_amount || 0), ) const rewardAmount = Number(workOrder.reward_amount || 0) @@ -867,6 +922,124 @@ export async function acceptWorkOrderAndSettle(input: { }) } +export async function resolveProblemWorkOrder(input: { + workOrderId: number + action: ProblemWorkOrderResolutionAction + note: string + now: string +}): Promise { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM work_orders + WHERE id = $1 + AND status = 'problem' + FOR UPDATE + `, + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder) return null + + const shouldReleaseDeposit = ['reopen', 'cancel_release'].includes(input.action) + const shouldDeductDeposit = input.action === 'cancel_deduct' + const workerId = Number(workOrder.assigned_worker_id || 0) + let resolvedDepositAmount = 0 + + if (workerId > 0 && (shouldReleaseDeposit || shouldDeductDeposit)) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + resolvedDepositAmount = Math.min( + await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId), + Number(wallet?.frozen_deposit_amount || 0), + ) + + if (resolvedDepositAmount > 0) { + const nextAvailable = + Number(wallet?.available_amount || 0) + (shouldReleaseDeposit ? resolvedDepositAmount : 0) + const nextFrozen = Math.max( + 0, + Number(wallet?.frozen_deposit_amount || 0) - resolvedDepositAmount, + ) + const ledgerType = shouldReleaseDeposit ? 'deposit_release' : 'deposit_deduction' + const ledgerAmount = shouldReleaseDeposit ? resolvedDepositAmount : -resolvedDepositAmount + const ledgerNote = shouldReleaseDeposit ? '问题单处置释放押金' : '问题单处置扣除押金' + + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, nextFrozen, input.now, workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) + `, + [ + workerId, + ledgerType, + ledgerAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + ledgerNote, + JSON.stringify({ action: input.action, note: input.note }), + input.now, + ], + ) + } + } + + const targetStatus = + input.action === 'return_to_worker' + ? 'in_progress' + : input.action === 'reopen' + ? 'open' + : 'cancelled' + const shouldClearAssignment = input.action === 'reopen' + const shouldClearSubmission = ['return_to_worker', 'reopen'].includes(input.action) + + await client.query( + ` + UPDATE work_orders + SET + status = $1, + assigned_worker_id = CASE WHEN $2 THEN NULL ELSE assigned_worker_id END, + assigned_at = CASE WHEN $2 THEN NULL ELSE assigned_at END, + acceptance_json = CASE WHEN $3 THEN '{}'::jsonb ELSE acceptance_json END, + submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END, + problem_note = CASE WHEN $2 THEN '' ELSE problem_note END, + updated_at = $4 + WHERE id = $5 + `, + [targetStatus, shouldClearAssignment, shouldClearSubmission, input.now, input.workOrderId], + ) + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: '', + eventType: `problem_${input.action}`, + fromStatus: 'problem', + toStatus: targetStatus, + payloadJson: JSON.stringify({ + action: input.action, + note: input.note, + depositAmount: resolvedDepositAmount, + }), + now: input.now, + }) + + return getWorkOrderByIdWithClient(client, input.workOrderId) + }) +} + export async function countWorkerActiveOrders(workerId: number | string): Promise { const result = await query<{ total: number }>( ` @@ -955,12 +1128,67 @@ async function getWorkerWalletWithClient( workerId: number, ): Promise { const result = await client.query( - 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1', + 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1 FOR UPDATE', [workerId], ) return result.rows[0] || null } +async function getOutstandingDepositAmountWithClient( + client: PoolClient, + workerId: number, + workOrderId: number, +) { + const result = await client.query<{ + frozen_amount: number + released_amount: number + deducted_amount: number + }>( + ` + SELECT + COALESCE(SUM(CASE WHEN ledger_type = 'deposit_freeze' THEN ABS(amount) ELSE 0 END), 0)::int AS frozen_amount, + COALESCE(SUM(CASE WHEN ledger_type = 'deposit_release' THEN ABS(amount) ELSE 0 END), 0)::int AS released_amount, + COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)::int AS deducted_amount + FROM worker_wallet_ledgers + WHERE worker_id = $1 + AND related_work_order_id = $2 + `, + [workerId, workOrderId], + ) + return resolveOutstandingDepositAmount(result.rows[0]) +} + +async function countWorkerActiveOrdersWithClient(client: PoolClient, workerId: number): Promise { + const result = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_orders + WHERE assigned_worker_id = $1 + AND status IN ('in_progress', 'pending_acceptance', 'problem') + `, + [workerId], + ) + return Number(result.rows[0]?.total || 0) +} + +export function resolveOutstandingDepositAmount( + totals: + | { + frozen_amount?: number + released_amount?: number + deducted_amount?: number + } + | null + | undefined, +) { + return Math.max( + 0, + Number(totals?.frozen_amount || 0) - + Number(totals?.released_amount || 0) - + Number(totals?.deducted_amount || 0), + ) +} + async function getWorkerUserByIdWithClient( client: PoolClient, workerId: number | string, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 0d4a5148..e23b02ca 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -12,6 +12,7 @@ import { listAdminWorkOrders, markAdminWorkOrderProblem, publishAdminWorkOrder, + resolveAdminProblemWorkOrder, reviewAdminWorkerUser, saveAdminWorkCategory, saveAdminWorkProductRule, @@ -117,7 +118,7 @@ router.get( requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler((req) => listAdminWorkerUsers(req.query), { successMessage: 'ok', - errorMessage: '读取接单员列表失败', + errorMessage: '读取打手列表失败', scope: '[admin/worker-platform/workers]', }), ) @@ -128,8 +129,8 @@ router.post( createJsonHandler( (req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}), { - successMessage: '接单员审核状态已更新', - errorMessage: '审核接单员失败', + successMessage: '打手审核状态已更新', + errorMessage: '审核打手失败', scope: '[admin/worker-platform/workers/:workerId/review]', audit: (req, data) => ({ action: 'worker_reviewed', @@ -147,7 +148,7 @@ router.post( createJsonHandler( (req) => creditAdminWorkerWallet(String(req.params.workerId || ''), req.body || {}), { - successMessage: '接单员余额已增加', + successMessage: '打手余额已增加', errorMessage: '人工充值失败', scope: '[admin/worker-platform/workers/:workerId/wallet-credit]', audit: (req, data) => ({ @@ -256,6 +257,25 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/problem/resolve', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => resolveAdminProblemWorkOrder(String(req.params.workOrderId || ''), req.body || {}), + { + successMessage: '问题单已处置', + errorMessage: '处置问题单失败', + scope: '[admin/worker-platform/orders/:workOrderId/problem/resolve]', + audit: (req, data) => ({ + action: 'work_order_problem_resolved', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/accept', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/routes/files.ts b/apps/backend/src/routes/files.ts index 8bdf44fb..baf54aea 100644 --- a/apps/backend/src/routes/files.ts +++ b/apps/backend/src/routes/files.ts @@ -7,7 +7,10 @@ const router = Router() router.get('/object', async (req, res) => { try { - const object = await getStoredFileObject(req.query.key, req.query.variant) + const object = await getStoredFileObject(req.query.key, req.query.variant, { + expires: req.query.expires, + signature: req.query.signature, + }) res.setHeader('Content-Type', object.contentType || 'application/octet-stream') res.setHeader('Cache-Control', 'private, max-age=300') res.setHeader('X-Content-Type-Options', 'nosniff') diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 3fbe28a7..8b663ae5 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -10,11 +10,13 @@ import { listWorkerHallOrders, listWorkerMyOrders, loginWorker, + logoutWorkerSession, registerWorker, submitWorkerOrderAcceptance, } from '../services/worker-platform/worker-platform-service.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' import { + extractOptionalWorkerBearerToken, extractWorkerBearerToken, getRequiredWorkerSession, requireActiveWorker, @@ -64,7 +66,7 @@ router.get( router.post( '/auth/logout', - createRouteHandler(() => ({ success: true }), { + createRouteHandler((req) => logoutWorkerSession(extractOptionalWorkerBearerToken(req)), { successMessage: '已退出登录', errorMessage: '退出失败', scope: '[worker/auth/logout]', @@ -77,7 +79,7 @@ router.get( '/profile', createRouteHandler((req) => getWorkerProfile(getRequiredWorkerSession(req)), { successMessage: 'ok', - errorMessage: '读取接单员信息失败', + errorMessage: '读取打手信息失败', scope: '[worker/profile]', }), ) diff --git a/apps/backend/src/routes/worker/session.ts b/apps/backend/src/routes/worker/session.ts index bae67685..7273286c 100644 --- a/apps/backend/src/routes/worker/session.ts +++ b/apps/backend/src/routes/worker/session.ts @@ -37,14 +37,19 @@ export function getRequiredWorkerSession(req: Request): WorkerSession { } export function extractWorkerBearerToken(req: Request): string { - const authorization = String(req.headers.authorization || '').trim() - const matched = authorization.match(/^Bearer\s+(.+)$/i) - if (!matched?.[1]) { - throw createHttpError('未登录或登录已失效', { - statusCode: 401, - errorCode: 'worker_auth_required', - }) + const token = extractOptionalWorkerBearerToken(req) + if (token) { + return token } - return matched[1].trim() + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'worker_auth_required', + }) +} + +export function extractOptionalWorkerBearerToken(req: Request): string { + const authorization = String(req.headers.authorization || '').trim() + const matched = authorization.match(/^Bearer\s+(.+)$/i) + return matched?.[1]?.trim() || '' } diff --git a/apps/backend/src/services/file-storage/file-storage-service.test.ts b/apps/backend/src/services/file-storage/file-storage-service.test.ts new file mode 100644 index 00000000..04a60778 --- /dev/null +++ b/apps/backend/src/services/file-storage/file-storage-service.test.ts @@ -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', + ) +}) diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index 52621bd0..8ce2aa50 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -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) + : {} + 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) { diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts new file mode 100644 index 00000000..11a23f37 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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 { + 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', + ), + ) +}) diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.ts b/apps/backend/src/services/worker-platform/worker-platform-service.ts index b146c5f9..b7a20123 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.ts @@ -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 { 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>) { 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(), + ) +} diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 49ecc69d..2f08275d 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -25,7 +25,7 @@ export default function WorkerLayout() { const navigate = useNavigate() const location = useLocation() const { message } = App.useApp() - const username = getWorkerUsername() || '接单员' + const username = getWorkerUsername() || '打手' const status = getWorkerStatus() async function submitLogout() { diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index f43794d4..02d45c30 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -42,6 +42,7 @@ import { fetchAdminWorkOrders, markAdminWorkOrderProblem, publishAdminWorkOrder, + resolveAdminProblemWorkOrder, reviewAdminWorkerUser, saveAdminWorkCategory, saveAdminWorkProductRule, @@ -58,13 +59,17 @@ import type { WorkerLevel, WorkerUser, } from '@/types/worker-platform' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminTablePagination, +} from '@/utils/admin-pagination' export default function AdminWorkerPlatformPage() { return (
}, { key: 'rules', label: '物品规则', children: }, { key: 'categories', label: '分类', children: }, - { key: 'workers', label: '接单员', children: }, + { key: 'workers', label: '打手', children: }, { key: 'levels', label: '等级权限', children: }, ]} /> @@ -92,7 +97,7 @@ function SummaryCards() {
@@ -116,15 +121,20 @@ function WorkOrdersPanel() { const { message } = App.useApp() const queryClient = useQueryClient() const [status, setStatus] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [problemOrder, setProblemOrder] = useState(null) + const [resolutionOrder, setResolutionOrder] = useState(null) const [materialOrder, setMaterialOrder] = useState(null) const [problemForm] = Form.useForm() + const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() const ordersQuery = useQuery({ - queryKey: ['admin-worker-platform-orders', status], - queryFn: () => fetchAdminWorkOrders({ status }), + queryKey: ['admin-worker-platform-orders', status, page, pageSize], + queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }), }) + const ordersPagination = ordersQuery.data?.data.pagination async function refreshAll() { await Promise.all([ @@ -145,25 +155,47 @@ function WorkOrdersPanel() { await action() message.success(successMessage) await refreshAll() + return true } catch (error) { message.error(error instanceof Error ? error.message : '操作失败') + return false } } async function submitProblem(values: { note?: string }) { if (!problemOrder) return - await runAction( + const succeeded = await runAction( () => markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''), '已标记问题单', ) + if (!succeeded) return setProblemOrder(null) problemForm.resetFields() } + async function submitResolution(values: { + action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' + note?: string + }) { + if (!resolutionOrder || !values.action) return + const action = values.action + const succeeded = await runAction( + () => + resolveAdminProblemWorkOrder(resolutionOrder.workOrderId, { + action, + note: values.note || '', + }), + '问题单已处置', + ) + if (!succeeded) return + setResolutionOrder(null) + resolutionForm.resetFields() + } + async function submitMaterial(values: { fields?: Record }) { if (!materialOrder) return - await runAction( + const succeeded = await runAction( () => submitAdminWorkOrderMaterial( materialOrder.workOrderId, @@ -171,6 +203,7 @@ function WorkOrdersPanel() { ), '订单资料已保存', ) + if (!succeeded) return setMaterialOrder(null) materialForm.resetFields() } @@ -227,7 +260,7 @@ function WorkOrdersPanel() { ), }, { - title: '接单员', + title: '打手', width: 140, render: (_, row) => row.worker?.displayName || row.worker?.username || '-', @@ -297,6 +330,11 @@ function WorkOrdersPanel() { 问题单 ) : null} + {row.status === 'problem' ? ( + + ) : null} ), }, @@ -342,8 +380,12 @@ function WorkOrdersPanel() { { value: 'pending_acceptance', label: '待验收' }, { value: 'problem', label: '问题单' }, { value: 'accepted', label: '已验收' }, + { value: 'cancelled', label: '已取消' }, ]} - onChange={setStatus} + onChange={(nextStatus) => { + setStatus(nextStatus) + setPage(1) + }} /> - ) : null} + ))} ) + + async function refreshLookupOrders( + currentOrderNo: string, + options: { allowCompleteWhenNotFound?: boolean } = {}, + ) { + try { + const response = await lookupCollectOrder(currentOrderNo) + setLookupOrders(response.data.orders || []) + setComplete(false) + } catch (error) { + if (options.allowCompleteWhenNotFound && isCollectOrderNotFoundError(error)) { + setLookupOrders([]) + setComplete(true) + return + } + throw error + } + } +} + +function isCollectOrderNotFoundError(error: unknown) { + return String((error as { errorCode?: string })?.errorCode || '').trim() === 'collect_order_not_found' } diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 4bc6665b..17144261 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -1,6 +1,6 @@ import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { App, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from 'antd' +import { App, Button, Card, Empty, Input, Pagination, Space, Spin, Tag, Typography } from 'antd' import { useState } from 'react' import { fetchWorkerHallOrders, grabWorkerOrder } from '@/services/worker' @@ -9,16 +9,20 @@ import type { WorkOrder } from '@/types/worker-platform' export default function WorkerHallPage() { const { message } = App.useApp() const queryClient = useQueryClient() + const [keywordInput, setKeywordInput] = useState('') const [keyword, setKeyword] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) const [grabbingId, setGrabbingId] = useState(0) const ordersQuery = useQuery({ - queryKey: ['worker-hall-orders', keyword], - queryFn: () => fetchWorkerHallOrders({ keyword }), + queryKey: ['worker-hall-orders', keyword, page, pageSize], + queryFn: () => fetchWorkerHallOrders({ keyword, page, pageSize }), retry: false, }) const orders = ordersQuery.data?.data.items || [] + const pagination = ordersQuery.data?.data.pagination async function submitGrab(order: WorkOrder) { setGrabbingId(order.workOrderId) @@ -33,6 +37,11 @@ export default function WorkerHallPage() { } } + function applyKeywordSearch() { + setKeyword(keywordInput.trim()) + setPage(1) + } + return (
@@ -40,13 +49,31 @@ export default function WorkerHallPage() { } - onChange={(event) => setKeyword(event.target.value)} - onPressEnter={() => void ordersQuery.refetch()} + onChange={(event) => { + const nextKeyword = event.target.value + setKeywordInput(nextKeyword) + if (!nextKeyword.trim()) { + setKeyword('') + setPage(1) + } + }} + onPressEnter={applyKeywordSearch} /> - + @@ -79,7 +106,7 @@ export default function WorkerHallPage() { {formatMoney(order.rewardAmount)} - 所需冻结押金:{formatMoney(order.freezeDepositAmount ?? order.requiredDepositAmount)} + 所需冻结押金:{formatMoney(order.freezeDepositAmount)}
) } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index e4131d20..a45aa0ca 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -26,21 +26,25 @@ const STATUS_OPTIONS = [ { value: 'pending_acceptance', label: '待验收' }, { value: 'problem', label: '问题单' }, { value: 'accepted', label: '已验收' }, + { value: 'cancelled', label: '已取消' }, ] export default function WorkerOrdersPage() { const { message } = App.useApp() const queryClient = useQueryClient() const [status, setStatus] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) const [submittingOrder, setSubmittingOrder] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) const [form] = Form.useForm() const ordersQuery = useQuery({ - queryKey: ['worker-my-orders', status], - queryFn: () => fetchWorkerMyOrders({ status }), + queryKey: ['worker-my-orders', status, page, pageSize], + queryFn: () => fetchWorkerMyOrders({ status, page, pageSize }), retry: false, }) + const pagination = ordersQuery.data?.data.pagination async function submitAcceptance(values: { note?: string }) { if (!submittingOrder) return @@ -121,6 +125,7 @@ export default function WorkerOrdersPage() { onClick={() => { setSubmittingOrder(row) setAcceptanceFiles([]) + form.resetFields() }} > 提交验收 @@ -138,7 +143,10 @@ export default function WorkerOrdersPage() { value={status} options={STATUS_OPTIONS} style={{ width: 140 }} - onChange={setStatus} + onChange={(nextStatus) => { + setStatus(nextStatus) + setPage(1) + }} />
- {!worker && !profileQuery.isLoading ? : null} + {!worker && !profileQuery.isLoading ? : null} {worker ? ( <> diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 4795172f..00868929 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -164,6 +164,19 @@ export function markAdminWorkOrderProblem(workOrderId: number, note: string) { ) } +export function resolveAdminProblemWorkOrder( + workOrderId: number, + payload: { + action: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' + note: string + }, +) { + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/problem/resolve`, + payload, + ) +} + export function acceptAdminWorkOrder(workOrderId: number) { return apiPost<{ order: WorkOrder }>( `/api/v1/admin/worker-platform/orders/${workOrderId}/accept`, diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 6e9b5807..c5d2e167 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -1,6 +1,6 @@ import { apiGet, apiPost } from '@/lib/http' import type { - CollectField, + CollectLookupResponse, UploadedFile, WorkOrder, WorkerListResponse, @@ -70,17 +70,12 @@ export function submitWorkerAcceptance( } export function lookupCollectOrder(orderNo: string) { - return apiPost<{ - order: Pick< - WorkOrder, - 'workOrderNo' | 'platformOrderId' | 'productName' | 'status' - > - fields: CollectField[] - }>('/api/v1/collect/lookup', { orderNo }) + return apiPost('/api/v1/collect/lookup', { orderNo }) } export function submitCollectOrder(payload: { orderNo: string + workOrderId?: number fields: Record }) { return apiPost<{ complete: boolean; order: Record }>( diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index f775bce6..e0c924e4 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -98,8 +98,8 @@ export type WorkOrder = { categoryName: string status: WorkOrderStatus rewardAmount: number - requiredDepositAmount: number - depositThresholdAmount: number + requiredDepositAmount?: number + depositThresholdAmount?: number freezeDepositAmount?: number material: Record requirement: Record @@ -146,3 +146,23 @@ export type CollectField = { label: string required: boolean } + +export type CollectLookupOrder = { + workOrderId: number + workOrderNo: string + platformOrderId: string + productName: string + status: string + fields: CollectField[] + values?: Record +} + +export type CollectLookupResponse = { + order: Pick< + WorkOrder, + 'workOrderId' | 'workOrderNo' | 'platformOrderId' | 'productName' | 'status' + > + fields: CollectField[] + orders: CollectLookupOrder[] + pendingCount: number +}