From a2cf48f5fc6263ea979f6f283a054d2e7177d0fd Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 18:12:44 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(collect):=20=E8=A1=A5=E8=B5=84?= =?UTF-8?q?=E6=96=99=E6=94=B9=E4=B8=BA=E8=AE=A2=E5=8D=95=E5=8F=B7=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E=E5=B9=B6=E6=8C=89=E7=8A=B6=E6=80=81=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公开补资料页 /supplement/:订单号 直接按平台订单号查询,不再依赖链接记录;旧 token 链接一律 404 - 仅「待完善 + 未指派 + 无拼单份额」可提交,提交后状态变为待分配,重复提交返回 409 - 非可编辑工单只返回商品、工单号、状态与客服提示,不返回已填资料和截图 - 管理端新增「重置补资料」:待分配且未指派、无份额、未发布时一键重置回待完善 - 新增 canResetMaterial 能力与 material_reset 流转事件,工单状态文案统一为「待分配」 --- .../src/repositories/worker-platform/types.ts | 2 + .../work-order-material-link-repo.ts | 108 ++-- .../routes/admin/worker-platform/orders.ts | 24 + apps/backend/src/routes/collect.ts | 47 +- ...admin-work-order-material-reset-service.ts | 74 +++ .../src/services/worker-platform/index.ts | 1 + .../work-order-display-mappers.ts | 6 + .../work-order-material-link-service.test.ts | 188 +++++-- .../work-order-material-link-service.ts | 399 ++++++++++----- apps/frontend/src/App.tsx | 18 +- .../src/components/WorkOrderEventTimeline.tsx | 10 +- .../src/components/files/ImageUpload.tsx | 2 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 63 ++- .../src/pages/admin/panels/shared.tsx | 2 +- .../collect/CollectMaterialLinkEntryPage.tsx | 67 +++ .../pages/collect/CollectMaterialLinkPage.tsx | 468 ++++++++++-------- .../collect/use-collect-link-page-meta.ts | 49 ++ .../src/services/admin/worker-platform.ts | 15 +- apps/frontend/src/services/files.ts | 14 +- apps/frontend/src/services/worker.ts | 9 +- apps/frontend/src/styles/client.css | 5 + apps/frontend/src/types/worker-platform.ts | 39 +- apps/frontend/src/utils/collect-link.ts | 47 +- apps/frontend/tests/collect-link.test.ts | 60 ++- 24 files changed, 1223 insertions(+), 494 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts create mode 100644 apps/frontend/src/pages/collect/CollectMaterialLinkEntryPage.tsx create mode 100644 apps/frontend/src/pages/collect/use-collect-link-page-meta.ts diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 82d44b51..9511a1b0 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -537,6 +537,8 @@ export type WorkOrderCapabilities = { canPin: boolean canUnpublish: boolean canUpdateMaterial: boolean + /** 待分配且未指派、无份额、未发布时,客服可以把补资料状态重置回待完善。 */ + canResetMaterial: boolean canAccept: boolean canMarkProblem: boolean canDelete: boolean diff --git a/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts index 79ef6438..0fdcc06b 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts @@ -1,6 +1,5 @@ import type { PoolClient } from 'pg' -import { query } from '../../db/client.js' import type { WorkOrderRow } from './types.js' export type WorkOrderMaterialLinkStatus = 'active' | 'used' | 'expired' | 'revoked' @@ -17,52 +16,31 @@ export type WorkOrderMaterialLinkRow = { updated_at: string } -export async function findWorkOrderMaterialLinkByToken( - token: string, -): Promise { - const result = await query( - 'SELECT * FROM work_order_material_links WHERE token = $1 LIMIT 1', - [String(token || '').trim()], - ) - return result.rows[0] || null -} - -export async function findActiveWorkOrderMaterialLink( - workOrderId: number | string, - now: string, -): Promise { - const result = await query( - `SELECT * FROM work_order_material_links - WHERE work_order_id = $1 AND status = 'active' AND expires_at > $2 - ORDER BY id DESC - LIMIT 1`, - [Number(workOrderId), now], - ) - return result.rows[0] || null -} - +/** + * 锁工单并回读「有效拼单份额」。 + * + * `SELECT * FROM work_orders` 本身没有聚合字段,直接看 `active_share_quantity` + * 会永远是 0,导致补资料绕过「已有拼单份额不允许改资料」的守卫。 + */ export async function getWorkOrderForUpdateWithClient( client: PoolClient, workOrderId: number | string, ): Promise { const result = await client.query( - 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + `SELECT wo.*, + COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id AND wos.status != 'cancelled' + ), 0)::int AS active_share_quantity + FROM work_orders wo + WHERE wo.id = $1 + FOR UPDATE`, [Number(workOrderId)], ) return result.rows[0] || null } -export async function getWorkOrderMaterialLinkForUpdateWithClient( - client: PoolClient, - linkId: number | string, -): Promise { - const result = await client.query( - 'SELECT * FROM work_order_material_links WHERE id = $1 FOR UPDATE', - [Number(linkId)], - ) - return result.rows[0] || null -} - export async function findActiveWorkOrderMaterialLinkWithClient( client: PoolClient, workOrderId: number | string, @@ -120,33 +98,6 @@ export async function createWorkOrderMaterialLinkWithClient( return result.rows[0]! } -export async function markWorkOrderMaterialLinkUsedWithClient( - client: PoolClient, - linkId: number | string, - now: string, -): Promise { - const result = await client.query( - `UPDATE work_order_material_links - SET status = 'used', used_at = $2, updated_at = $2 - WHERE id = $1 AND status = 'active' AND expires_at > $2 - RETURNING *`, - [Number(linkId), now], - ) - return result.rows[0] || null -} - -export async function markWorkOrderMaterialLinkExpired( - linkId: number | string, - now: string, -): Promise { - await query( - `UPDATE work_order_material_links - SET status = 'expired', updated_at = $2 - WHERE id = $1 AND status = 'active'`, - [Number(linkId), now], - ) -} - export async function updateWorkOrderMaterialWithClient( client: PoolClient, input: { @@ -165,3 +116,32 @@ export async function updateWorkOrderMaterialWithClient( ) return result.rows[0] || null } + +/** + * 客服重置补资料状态:只允许「待分配、未指派、未发布」的工单回到待完善。 + * + * 服务层已经在事务里锁工单并校验过拼单份额;这里的 WHERE 条件再兜一层, + * 防止并发或调用方漏校验。 + */ +export async function resetWorkOrderMaterialStatusWithClient( + client: PoolClient, + input: { + workOrderId: number | string + fromStatus: string + toStatus: string + now: string + }, +): Promise { + const result = await client.query( + `UPDATE work_orders + SET status = $2, updated_at = $3 + WHERE id = $1 + AND status = $4 + AND assigned_worker_id IS NULL + AND published_at IS NULL + AND hall_queued_at IS NULL + RETURNING *`, + [Number(input.workOrderId), input.toStatus, input.now, input.fromStatus], + ) + return result.rows[0] || null +} diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 81da73c4..3e08cbf2 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -23,6 +23,7 @@ import { reopenAdminCancelledWorkOrder, reprocessAdminKuaishouSendCodeWorkOrders, resetAdminWorkOrderGiftMaterial, + resetAdminWorkOrderMaterialStatus, submitAdminWorkOrderMaterial, unassignAdminWorkOrder, unpublishAdminWorkOrder, @@ -216,6 +217,29 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/reset-material', + requireAdminPermission('worker_order.update_material'), + createJsonHandler( + (req) => + resetAdminWorkOrderMaterialStatus( + String(req.params.workOrderId || ''), + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '补资料状态已重置为待完善', + errorMessage: '重置补资料状态失败', + scope: '[admin/worker-platform/orders/:workOrderId/reset-material]', + audit: (req) => ({ + action: 'work_order_material_reset', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: {}, + }), + }, + ), +) + router.put( '/worker-platform/orders/:workOrderId', requireAdminPermission('worker_order.edit'), diff --git a/apps/backend/src/routes/collect.ts b/apps/backend/src/routes/collect.ts index b13354b9..21ed5db7 100644 --- a/apps/backend/src/routes/collect.ts +++ b/apps/backend/src/routes/collect.ts @@ -29,21 +29,40 @@ const materialLinkReadRateLimit = createRateLimitMiddleware({ scope: 'collect:link:read', windowMs: 60_000, max: 120, - key: getParamRateLimitKey('token'), + key: getParamRateLimitKey('code'), +}) + +/** 平台订单号可被枚举,额外加一层只按 IP 的总限流,避免换 code 绕过单链接限流。 */ +const materialLinkReadIpRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:read:ip', + windowMs: 60_000, + max: 300, }) const materialLinkWriteRateLimit = createRateLimitMiddleware({ scope: 'collect:link:write', windowMs: 60_000, max: 20, - key: getParamRateLimitKey('token'), + key: getParamRateLimitKey('code'), +}) + +const materialLinkWriteIpRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:write:ip', + windowMs: 60_000, + max: 40, }) const materialLinkUploadRateLimit = createRateLimitMiddleware({ scope: 'collect:link:upload', windowMs: 60_000, max: 30, - key: getParamRateLimitKey('token'), + key: getParamRateLimitKey('code'), +}) + +const materialLinkUploadIpRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:upload:ip', + windowMs: 60_000, + max: 60, }) router.post( @@ -88,35 +107,39 @@ router.post( ) router.get( - '/link/:token', + '/link/:code', + materialLinkReadIpRateLimit, materialLinkReadRateLimit, - createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.token || '')), { + createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), { errorMessage: '读取补资料链接失败', - scope: '[collect/link/:token]', + scope: '[collect/link/:code]', }), ) router.post( - '/link/:token/submit', + '/link/:code/submit', + materialLinkWriteIpRateLimit, materialLinkWriteRateLimit, createRouteHandler( - (req) => submitPublicWorkOrderMaterialLink(String(req.params.token || ''), req.body || {}), + (req) => submitPublicWorkOrderMaterialLink(String(req.params.code || ''), req.body || {}), { successMessage: '资料已提交', errorMessage: '提交补资料失败', - scope: '[collect/link/:token/submit]', + scope: '[collect/link/:code/submit]', }, ), ) router.post( - '/link/:token/upload', + '/link/:code/upload', + materialLinkUploadIpRateLimit, materialLinkUploadRateLimit, uploadSingleFile, createRouteHandler( async (req) => { const { order } = await getPublicWorkOrderMaterialLinkUploadContext( - String(req.params.token || ''), + String(req.params.code || ''), + req.query.workOrderId, ) return uploadFileAsset({ file: req.file, @@ -128,7 +151,7 @@ router.post( { successMessage: '文件已上传', errorMessage: '文件上传失败', - scope: '[collect/link/:token/upload]', + scope: '[collect/link/:code/upload]', }, ), ) diff --git a/apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts new file mode 100644 index 00000000..bdc54054 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts @@ -0,0 +1,74 @@ +import { withTransaction } from '../../db/client.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + createWorkOrderEventWithClient, + getWorkOrderForUpdateWithClient, + resetWorkOrderMaterialStatusWithClient, +} from '../../repositories/worker-platform/index.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { mapWorkOrderAdmin } from './mappers.js' +import { + resolveMaterialResetBlockMessage, + resolveMaterialResetBlockReason, +} from './work-order-material-link-service.js' + +/** + * 客服重置补资料状态:把「待分配」工单改回「待完善」,让客户可以凭订单号重新提交。 + * + * 只允许未指派、无拼单份额、未发布的工单;原资料保留,客户刷新补资料页后 + * 会看到之前填过的内容,可以在此基础上修改。 + */ +export async function resetAdminWorkOrderMaterialStatus( + workOrderId: number | string, + actorName = '', +) { + const result = await withTransaction(async (client) => { + const order = await getWorkOrderForUpdateWithClient(client, workOrderId) + if (!order) { + throw createHttpError('接单订单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + + const blockReason = resolveMaterialResetBlockReason(order) + if (blockReason) { + throw createHttpError(resolveMaterialResetBlockMessage(blockReason), { + statusCode: 409, + errorCode: 'work_order_material_reset_invalid', + }) + } + + const now = nowIso() + const updated = await resetWorkOrderMaterialStatusWithClient(client, { + workOrderId: order.id, + fromStatus: WORK_ORDER_STATUS.UNASSIGNED, + toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL, + now, + }) + if (!updated) { + throw createHttpError('重置补资料状态失败,订单可能已变化', { + statusCode: 409, + errorCode: 'work_order_material_reset_failed', + }) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: order.id, + actorType: 'admin', + actorId: String(actorName || '').trim(), + eventType: 'material_reset', + fromStatus: WORK_ORDER_STATUS.UNASSIGNED, + toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL, + payloadJson: JSON.stringify({ source: 'admin_reset' }), + now, + }) + + return { order: updated } + }) + + publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) }) + return { order: mapWorkOrderAdmin(result.order) } +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 5374b17a..9b800c83 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -37,6 +37,7 @@ export * from './admin-worker-cancel-service.js' export * from './admin-work-order-sharing-service.js' export * from './admin-work-order-realtime-service.js' export * from './admin-work-order-material-service.js' +export * from './admin-work-order-material-reset-service.js' export * from './admin-work-order-gift-service.js' export * from './admin-mock-work-order-service.js' export * from './admin-mock-custom-shop-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index c0f18d70..26e3fbec 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -290,6 +290,12 @@ export function mapWorkOrderAdmin( workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || workOrder.status === WORK_ORDER_STATUS.OPEN || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS, + canResetMaterial: + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && + !workOrder.assigned_worker_id && + !hasActiveShares && + !workOrder.published_at && + !workOrder.hall_queued_at, canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE || (isFriendGift && diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts index 2d819c1f..88ca0fad 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts @@ -2,63 +2,173 @@ import assert from 'node:assert/strict' import test from 'node:test' import { - resolveWorkOrderMaterialLinkUnavailableReason, + formatPublicMaterialStatus, pickSubmittedMaterialFields, + resolveMaterialResetBlockReason, + resolvePublicMaterialOrderState, + resolveWorkOrderMaterialLinkOrderBlockReason, } from './work-order-material-link-service.js' -const NOW = '2026-10-31T12:00:00.000Z' +test('公开补资料:只有待完善且未指派/无份额可提交', () => { + const editable = resolvePublicMaterialOrderState({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 0, + }) + assert.equal(editable.canSubmit, true) + assert.equal(editable.blockedReason, '') + assert.equal(editable.statusLabel, '待完善') -test('补资料链接:active 且未过期时可用', () => { + const alreadySubmitted = resolvePublicMaterialOrderState({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + }) + assert.equal(alreadySubmitted.canSubmit, false) + assert.equal(alreadySubmitted.blockedReason, 'already_submitted') + assert.equal(alreadySubmitted.statusLabel, '待分配') + assert.match(alreadySubmitted.message, /联系客服/) + + const assigned = resolvePublicMaterialOrderState({ + status: 'pending_material', + assigned_worker_id: 9, + active_share_quantity: 0, + }) + assert.equal(assigned.canSubmit, false) + assert.equal(assigned.blockedReason, 'assigned') + + const shares = resolvePublicMaterialOrderState({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 2, + }) + assert.equal(shares.canSubmit, false) + assert.equal(shares.blockedReason, 'shares') + + const open = resolvePublicMaterialOrderState({ + status: 'open', + assigned_worker_id: null, + active_share_quantity: 0, + }) + assert.equal(open.canSubmit, false) + assert.equal(open.blockedReason, 'status') + assert.equal(open.statusLabel, '待抢单') + assert.match(open.message, /联系客服/) +}) + +test('公开补资料:状态文案覆盖待分配与后续流程', () => { + assert.equal(formatPublicMaterialStatus('pending_material'), '待完善') + assert.equal(formatPublicMaterialStatus('unassigned'), '待分配') + assert.equal(formatPublicMaterialStatus('in_progress'), '代练中') + assert.equal(formatPublicMaterialStatus('pending_acceptance'), '待验收') + assert.equal(formatPublicMaterialStatus('accepted'), '已验收') + assert.equal(formatPublicMaterialStatus('cancelled'), '已取消') + assert.equal(formatPublicMaterialStatus('unknown_status'), 'unknown_status') +}) + +test('客服重置补资料:只允许待分配、未指派、无份额、未发布', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'active', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: null, + hall_queued_at: null, + }), '', ) -}) - -test('补资料链接:active 但已过期按过期处理', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason({ status: 'active', expires_at: NOW }, NOW), - 'expired', + resolveMaterialResetBlockReason({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: null, + hall_queued_at: null, + }), + 'status', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: 9, + active_share_quantity: 0, + published_at: null, + hall_queued_at: null, + }), + 'assigned', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 2, + published_at: null, + hall_queued_at: null, + }), + 'shares', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: '2026-11-01T00:00:00.000Z', + hall_queued_at: null, + }), + 'published', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: null, + hall_queued_at: '2026-11-01T00:00:00.000Z', + }), + 'published', ) }) -test('补资料链接:used / expired / revoked 分别返回对应原因', () => { +test('管理端链接准入:待完善/待分配可用,已指派/有份额/其他状态拒绝', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'used', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), - 'used', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 0, + }), + '', ) assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'expired', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), - 'expired', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + }), + '', ) assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'revoked', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), - 'revoked', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'open', + assigned_worker_id: null, + active_share_quantity: 0, + }), + 'status', ) -}) - -test('补资料链接:未知状态一律视为失效,避免误放行', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { - status: 'unknown' as never, - expires_at: '2026-10-31T13:00:00.000Z', - }, - NOW, - ), - 'revoked', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'pending_material', + assigned_worker_id: 9, + active_share_quantity: 0, + }), + 'assigned', + ) + assert.equal( + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 2, + }), + 'shares', ) }) diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts index 4c9decf2..c449c3a1 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -5,15 +5,11 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { createWorkOrderEventWithClient, createWorkOrderMaterialLinkWithClient, - findWorkOrderMaterialLinkByToken, findActiveWorkOrderMaterialLinkWithClient, getWorkOrderForUpdateWithClient, - getWorkOrderMaterialLinkForUpdateWithClient, - markWorkOrderMaterialLinkExpired, - markWorkOrderMaterialLinkUsedWithClient, + listWorkOrdersByPlatformOrderId, revokeActiveWorkOrderMaterialLinksWithClient, updateWorkOrderMaterialWithClient, - type WorkOrderMaterialLinkRow, } from '../../repositories/worker-platform/index.js' import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' import type { JsonObject } from '../../types/json.js' @@ -26,6 +22,7 @@ import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-servi import { mapCollectLookupOrder, mapWorkOrderAdmin, + normalizeOptionalId, normalizeSubmittedFields, normalizeUploadedFiles, resolveRequirementFields, @@ -40,29 +37,142 @@ const ALLOWED_MATERIAL_LINK_ORDER_STATUSES = new Set([ const MAX_MATERIAL_SCREENSHOTS = 5 -export type WorkOrderMaterialLinkUnavailableReason = 'used' | 'expired' | 'revoked' | '' +const PUBLIC_MATERIAL_STATUS_LABELS: Record = { + [WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善', + [WORK_ORDER_STATUS.UNASSIGNED]: '待分配', + [WORK_ORDER_STATUS.OPEN]: '待抢单', + [WORK_ORDER_STATUS.IN_PROGRESS]: '代练中', + [WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '待验收', + [WORK_ORDER_STATUS.PROBLEM]: '问题单', + [WORK_ORDER_STATUS.ACCEPTED]: '已验收', + [WORK_ORDER_STATUS.CANCELLED]: '已取消', + vip_evidence_pending: '待补图', +} + +export type WorkOrderMaterialLinkOrderBlockReason = 'status' | 'assigned' | 'shares' | '' + +export type PublicMaterialOrderBlockReason = + | 'already_submitted' + | 'status' + | 'assigned' + | 'shares' + | '' + +export type PublicMaterialOrderState = { + canSubmit: boolean + blockedReason: PublicMaterialOrderBlockReason + statusLabel: string + message: string +} + +export type MaterialResetBlockReason = 'status' | 'assigned' | 'shares' | 'published' | '' + +/** 公开页状态文案;后台状态列/时间线也统一用「待分配」。 */ +export function formatPublicMaterialStatus(status: string): string { + const normalized = String(status || '').trim() + return PUBLIC_MATERIAL_STATUS_LABELS[normalized] || normalized || '未知状态' +} /** - * 判断链接是否还能用。 + * 公开补资料页的准入判断。 * - * 只做纯函数判断,方便单测覆盖;数据库状态与过期时间都由调用方传入。 + * 只有「待完善 + 未指派 + 无拼单份额」可以提交;待分配视为已提交, + * 其他状态只展示状态与客服提示,不再展示表单。 */ -export function resolveWorkOrderMaterialLinkUnavailableReason( - link: Pick, - now: string, -): WorkOrderMaterialLinkUnavailableReason { - if (link.status === 'used') return 'used' - if (link.status === 'revoked') return 'revoked' - if (link.status === 'expired') return 'expired' - if (link.status !== 'active') return 'revoked' - if (new Date(link.expires_at).getTime() <= new Date(now).getTime()) return 'expired' +export function resolvePublicMaterialOrderState( + order: Pick, +): PublicMaterialOrderState { + const statusLabel = formatPublicMaterialStatus(order.status) + + if (order.status === WORK_ORDER_STATUS.UNASSIGNED) { + return { + canSubmit: false, + blockedReason: 'already_submitted', + statusLabel, + message: '资料已提交,订单已进入待分配;如需修改请联系客服。', + } + } + + if (order.status !== WORK_ORDER_STATUS.PENDING_MATERIAL) { + return { + canSubmit: false, + blockedReason: 'status', + statusLabel, + message: `当前状态为「${statusLabel}」,不能修改资料;如需修改请联系客服。`, + } + } + + if (order.assigned_worker_id) { + return { + canSubmit: false, + blockedReason: 'assigned', + statusLabel, + message: '订单已指派打手,不能修改资料;如需修改请联系客服。', + } + } + + if (Number(order.active_share_quantity || 0) > 0) { + return { + canSubmit: false, + blockedReason: 'shares', + statusLabel, + message: '订单已有拼单份额,不能修改资料;如需修改请联系客服。', + } + } + + return { + canSubmit: true, + blockedReason: '', + statusLabel, + message: '', + } +} + +/** + * 客服重置补资料状态的前置条件。 + * + * 只允许「待分配 + 未指派 + 无份额 + 未发布」的工单回到待完善; + * 其他状态一律拒绝,避免把已进入接单流程的工单改回可编辑。 + */ +export function resolveMaterialResetBlockReason( + order: Pick< + WorkOrderRow, + 'status' | 'assigned_worker_id' | 'active_share_quantity' | 'published_at' | 'hall_queued_at' + >, +): MaterialResetBlockReason { + if (order.status !== WORK_ORDER_STATUS.UNASSIGNED) return 'status' + if (order.assigned_worker_id) return 'assigned' + if (Number(order.active_share_quantity || 0) > 0) return 'shares' + if (order.published_at || order.hall_queued_at) return 'published' + return '' +} + +export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReason): string { + if (reason === 'assigned') return '订单已指派打手,不能重置补资料状态' + if (reason === 'shares') return '订单已有拼单份额,不能重置补资料状态' + if (reason === 'published') return '订单已发布到接单大厅,不能重置补资料状态' + return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态' +} + +/** + * 管理端生成/复用一次性链接的准入判断。 + * + * 用户要求保留现有“复制补资料链接”按钮,所以这里仍按原口径允许 + * 待完善 / 待分配,并拒绝已指派/有份额的工单。 + */ +export function resolveWorkOrderMaterialLinkOrderBlockReason( + order: Pick, +): WorkOrderMaterialLinkOrderBlockReason { + if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) return 'status' + if (order.assigned_worker_id) return 'assigned' + if (Number(order.active_share_quantity || 0) > 0) return 'shares' return '' } /** * 只保留工单 requirement 里定义过的字段。 * - * 公开链接的提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的 + * 公开提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的 * 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。 */ export function pickSubmittedMaterialFields( @@ -73,57 +183,42 @@ export function pickSubmittedMaterialFields( return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key))) } -function throwMaterialLinkUnavailable( - reason: Exclude, -): never { - if (reason === 'used') { - throw createHttpError('链接已使用,如需修改资料请联系客服重新获取', { - statusCode: 410, - errorCode: 'material_link_used', +function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never { + if (state.blockedReason === 'already_submitted') { + throw createHttpError(state.message, { + statusCode: 409, + errorCode: 'material_link_already_submitted', }) } - if (reason === 'expired') { - throw createHttpError('链接已过期,请联系客服重新获取', { - statusCode: 410, - errorCode: 'material_link_expired', - }) - } - throw createHttpError('链接已失效,请联系客服重新获取', { - statusCode: 410, - errorCode: 'material_link_revoked', + throw createHttpError(state.message, { + statusCode: 409, + errorCode: 'material_link_order_status_invalid', }) } -/** - * 公开链接只对「待完善 / 未分配」开放,且不能已经指派打手或有进行中的拼单份额。 - * 这三个条件同时满足,提交后才可以安全地把状态改成「未分配」。 - */ +/** 管理端生成补资料链接时仍沿用旧的准入文案。 */ function assertWorkOrderAllowedForMaterialLink(order: WorkOrderRow): void { - if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) { - throw createHttpError('订单当前状态不允许补资料,请联系客服重新获取链接', { + const reason = resolveWorkOrderMaterialLinkOrderBlockReason(order) + if (reason === 'status') { + throw createHttpError('当前状态不能生成补资料链接', { statusCode: 409, - errorCode: 'material_link_order_status_invalid', + errorCode: 'material_link_status_invalid', }) } - if (order.assigned_worker_id) { - throw createHttpError('订单已指派打手,不能通过公开链接修改资料', { + if (reason === 'assigned') { + throw createHttpError('订单已指派打手,不能生成补资料链接', { statusCode: 409, - errorCode: 'material_link_order_status_invalid', + errorCode: 'material_link_status_invalid', }) } - if (Number(order.active_share_quantity || 0) > 0) { - throw createHttpError('订单已有拼单份额,不能通过公开链接修改资料', { + if (reason === 'shares') { + throw createHttpError('订单已有拼单份额,不能生成补资料链接', { statusCode: 409, - errorCode: 'material_link_order_status_invalid', + errorCode: 'material_link_status_invalid', }) } } -function assertMaterialLinkRecordUsable(link: WorkOrderMaterialLinkRow, now: string): void { - const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now) - if (reason) throwMaterialLinkUnavailable(reason) -} - function mapPublicOrder(order: WorkOrderRow) { return { workOrderNo: order.work_order_no, @@ -139,11 +234,67 @@ function toIsoTimestamp(value: string): string { return Number.isNaN(date.getTime()) ? String(value || '') : date.toISOString() } +function mapPublicMaterialOrder(order: WorkOrderRow) { + const state = resolvePublicMaterialOrderState(order) + const base = { + workOrderId: Number(order.id), + workOrderNo: order.work_order_no, + platformOrderId: order.platform_order_id, + productName: order.product_name, + status: order.status, + statusLabel: state.statusLabel, + canSubmit: state.canSubmit, + blockedReason: state.blockedReason, + message: state.message, + } + if (!state.canSubmit) return base + + const mapped = mapCollectLookupOrder(order) + return { + ...base, + fields: mapped.fields, + values: mapped.values, + screenshots: mapped.screenshots, + } +} + +async function resolvePublicMaterialTarget( + platformOrderId: string, + workOrderIdInput: unknown, +): Promise { + const workOrderId = normalizeOptionalId(workOrderIdInput) + if (workOrderId) { + const order = await getRequiredWorkOrder(workOrderId) + if (String(order.platform_order_id || '').trim() !== platformOrderId) { + throw createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'material_link_work_order_invalid', + }) + } + return order + } + + const orders = await listWorkOrdersByPlatformOrderId(platformOrderId) + if (orders.length === 0) { + throw createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'material_link_not_found', + }) + } + if (orders.length > 1) { + throw createHttpError('该订单号下有多个商品,请先选择商品', { + statusCode: 409, + errorCode: 'material_link_work_order_required', + }) + } + return orders[0]! +} + /** * 管理端生成/复用一次性链接。 * - * 幂等策略:同一工单已有未过期 active 链接时直接返回,避免客服连续点两次 - * 把刚发出去的链接顶掉;只有已使用/已过期/已撤销时才生成新 token。 + * 用户要求保留现有“复制补资料链接”按钮,因此链接记录仍会生成; + * 但公开页已经不再依赖它,复制出的 `/supplement/<平台订单号>` 始终可用。 */ export async function createAdminWorkOrderMaterialLink( workOrderId: number | string, @@ -166,6 +317,15 @@ export async function createAdminWorkOrderMaterialLink( errorCode: 'material_link_status_invalid', }) } + + const platformOrderId = String(order.platform_order_id || '').trim() + if (!platformOrderId) { + throw createHttpError('该工单没有平台订单号,不能生成补资料链接', { + statusCode: 409, + errorCode: 'material_link_platform_order_missing', + }) + } + assertWorkOrderAllowedForMaterialLink(order) // 锁到手后再取时间,避免等锁期间旧链接过期却仍按旧时间复用。 @@ -174,6 +334,7 @@ export async function createAdminWorkOrderMaterialLink( if (existing) { return { token: existing.token, + platformOrderId, expiresAt: toIsoTimestamp(existing.expires_at), reused: true, } @@ -192,6 +353,7 @@ export async function createAdminWorkOrderMaterialLink( return { token: link.token, + platformOrderId, expiresAt: toIsoTimestamp(link.expires_at), reused: false, } @@ -199,83 +361,76 @@ export async function createAdminWorkOrderMaterialLink( } /** - * 公开页读取链接内容。 + * 公开页按平台订单号读取补资料内容。 * - * 只返回客户补资料需要的最少信息:商品/单号、动态字段、已填值、截图、过期时间。 - * 不返回发单价格、押金、成本、打手、后台备注等任何后台字段。 + * 不再要求客服先生成链接;只要订单号存在,就返回该订单号下所有工单的 + * 状态与准入信息。只有可提交的待完善工单返回字段/已填值/截图,其他状态 + * 只返回商品、工单号、状态和客服提示。 */ -export async function getPublicWorkOrderMaterialLink(token: string) { - const link = await findWorkOrderMaterialLinkByToken(token) - if (!link) { - throw createHttpError('链接无效,请确认链接是否完整', { +export async function getPublicWorkOrderMaterialLink(platformOrderId: string) { + const code = String(platformOrderId || '').trim() + if (!code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, errorCode: 'material_link_not_found', }) } - const now = nowIso() - const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now) - if (reason) { - if (reason === 'expired') { - await markWorkOrderMaterialLinkExpired(link.id, now) - } - throwMaterialLinkUnavailable(reason) + const orders = await listWorkOrdersByPlatformOrderId(code) + if (orders.length === 0) { + throw createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'material_link_not_found', + }) } - const order = await getRequiredWorkOrder(link.work_order_id) - assertWorkOrderAllowedForMaterialLink(order) - - const mapped = mapCollectLookupOrder(order) + const publicOrders = orders.map(mapPublicMaterialOrder) return { - order: { - workOrderNo: mapped.workOrderNo, - platformOrderId: mapped.platformOrderId, - productName: mapped.productName, - status: mapped.status, - }, - fields: mapped.fields, - values: mapped.values, - screenshots: mapped.screenshots, - expiresAt: toIsoTimestamp(link.expires_at), + platformOrderId: code, + orders: publicOrders, + pendingCount: publicOrders.filter((order) => order.canSubmit).length, } } /** * 公开页提交资料。 * - * 一次性语义:只有「完整提交」成功时才把 token 标为 used; - * 少填必填项、订单状态变化、网络失败等都会在事务里回滚,链接继续可用。 + * 一次性语义完全由工单状态保证:只有 `pending_material` 能成功提交, + * 提交后改成 `unassigned`;并发的第二个提交会锁到 `unassigned` 并返回 409。 */ -export async function submitPublicWorkOrderMaterialLink(token: string, payload: JsonObject = {}) { - const preliminary = await findWorkOrderMaterialLinkByToken(token) - if (!preliminary) { - throw createHttpError('链接无效,请确认链接是否完整', { +export async function submitPublicWorkOrderMaterialLink( + platformOrderId: string, + payload: JsonObject = {}, +) { + const code = String(platformOrderId || '').trim() + if (!code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, errorCode: 'material_link_not_found', }) } + const target = await resolvePublicMaterialTarget(code, payload.workOrderId) + const targetState = resolvePublicMaterialOrderState(target) + if (!targetState.canSubmit) throwPublicMaterialBlocked(targetState) + const result = await withTransaction(async (client) => { - // 先锁工单再锁链接,和后台生成链接保持同样的加锁顺序,避免死锁。 - const order = await getWorkOrderForUpdateWithClient(client, preliminary.work_order_id) + // 锁工单:并发提交/客服重置都串行化,状态是唯一的一次性凭据。 + const order = await getWorkOrderForUpdateWithClient(client, target.id) if (!order) { throw createHttpError('接单订单不存在', { statusCode: 404, errorCode: 'work_order_not_found', }) } - const link = await getWorkOrderMaterialLinkForUpdateWithClient(client, preliminary.id) - if (!link) { - throw createHttpError('链接无效,请确认链接是否完整', { + if (String(order.platform_order_id || '').trim() !== code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, - errorCode: 'material_link_not_found', + errorCode: 'material_link_work_order_invalid', }) } - - // 锁到手后再取时间:等锁期间链接可能已经过期,不能沿用进入事务前的时间。 - const now = nowIso() - assertMaterialLinkRecordUsable(link, now) - assertWorkOrderAllowedForMaterialLink(order) + const orderState = resolvePublicMaterialOrderState(order) + if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState) const fields = resolveRequirementFields(order) const submittedFields = pickSubmittedMaterialFields( @@ -304,6 +459,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: }) } + const now = nowIso() const material = { ...safeParseJson(order.material_json), collect: { @@ -311,7 +467,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: screenshots, submittedAt: now, complete: true, - source: 'customer_link', + source: 'customer_order_no', }, } @@ -328,33 +484,22 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: }) } - const used = await markWorkOrderMaterialLinkUsedWithClient(client, link.id, now) - if (!used) { - // 并发提交,或刚好在写入瞬间过期:按当前状态给出准确原因,事务整体回滚。 - const latest = await getWorkOrderMaterialLinkForUpdateWithClient(client, link.id) - if (latest) { - const reason = resolveWorkOrderMaterialLinkUnavailableReason(latest, now) - if (reason) throwMaterialLinkUnavailable(reason) - } - throwMaterialLinkUnavailable('used') - } - await createWorkOrderEventWithClient(client, { workOrderId: order.id, actorType: 'collect', - actorId: '客户补资料链接', + actorId: '客户按订单号补资料', eventType: 'material_completed', fromStatus: order.status, toStatus: WORK_ORDER_STATUS.UNASSIGNED, payloadJson: JSON.stringify({ fields: submittedFields, complete: true, - source: 'customer_link', + source: 'customer_order_no', }), now, }) - return { order: updated, link: used } + return { order: updated } }) // 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。 @@ -374,28 +519,30 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: return { complete: true, + workOrderId: Number(result.order.id), order: mapPublicOrder(result.order), } } /** - * 上传前校验 token 与工单状态;返回工单 id 供文件归属使用。 - * 注意:这里不消耗 token,只有最终提交才消耗。 + * 上传前校验平台订单号 / workOrderId 与工单状态;返回工单供文件归属使用。 + * 注意:这里不消耗提交机会,只有最终提交才把状态改成待分配。 */ -export async function getPublicWorkOrderMaterialLinkUploadContext(token: string) { - const link = await findWorkOrderMaterialLinkByToken(token) - if (!link) { - throw createHttpError('链接无效,请确认链接是否完整', { +export async function getPublicWorkOrderMaterialLinkUploadContext( + platformOrderId: string, + workOrderId?: unknown, +) { + const code = String(platformOrderId || '').trim() + if (!code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, errorCode: 'material_link_not_found', }) } - const now = nowIso() - assertMaterialLinkRecordUsable(link, now) + const order = await resolvePublicMaterialTarget(code, workOrderId) + const orderState = resolvePublicMaterialOrderState(order) + if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState) - const order = await getRequiredWorkOrder(link.work_order_id) - assertWorkOrderAllowedForMaterialLink(order) - - return { link, order, now } + return { order, now: nowIso() } } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index d7c4cf57..14b4f951 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -46,6 +46,9 @@ const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlat const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage')) const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage')) const CollectPage = lazy(() => import('@/pages/collect/CollectPage')) +const CollectMaterialLinkEntryPage = lazy( + () => import('@/pages/collect/CollectMaterialLinkEntryPage'), +) const CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage')) @@ -124,8 +127,8 @@ export default function App() { return } - // 一次性补资料链接(`/supplement/`):路径式公开页,先于站点判断, - // 保证在后台域名和打手端域名下都能打开。 + // 一次性补资料链接(`/supplement`):路径式公开页,先于站点判断, + // 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。 if (isCollectMaterialLinkPath(window.location.pathname)) { return } @@ -235,9 +238,13 @@ function ClaimApplication() { } /** - * 一次性补资料链接(`/supplement/`)的独立路由树。 + * 一次性补资料链接(`/supplement`)的独立路由树。 * - * 路径式公开页,不带 hash;页面只读取 token,不依赖后台会话。 + * - `/supplement`:输入平台订单号; + * - `/supplement/<订单号>`:直接打开对应订单的补资料页; + * - 其余子路径:交给补资料页展示「链接无效」。 + * + * 路径式公开页,不带 hash;不依赖后台会话。 */ function CollectMaterialLinkApplication() { useClearStrayHash() @@ -246,7 +253,8 @@ function CollectMaterialLinkApplication() { }> - } /> + } /> + } /> } /> diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index c1341ce8..aa6f3ca1 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -21,7 +21,7 @@ type EventNodeStyle = { /** 工单状态中文名 */ const STATUS_LABELS: Record = { pending_material: '待完善资料', - unassigned: '未分配', + unassigned: '待分配', open: '待抢单', in_progress: '代练中', pending_acceptance: '待验收', @@ -176,6 +176,14 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { return { title: '完善了订单资料', color: 'cyan' } case 'material_updated': return { title: '更新了订单资料', color: 'cyan' } + case 'material_reset': + return { + title: note ? `客服重置补资料状态:${note}` : '客服重置补资料状态', + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'orange', + } // —— 好友赠送节点 —— case 'gift_material_submitted': diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 895dfa71..7eec1328 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -28,7 +28,7 @@ type ImageUploadProps = { captureGlobalPaste?: boolean /** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */ allowCamera?: boolean - /** 自定义上传函数;公开一次性链接用它把文件传到 token 校验过的上传接口。 */ + /** 自定义上传函数;公开一次性链接用它把文件传到带订单号 / workOrderId 校验的上传接口。 */ uploader?: ImageUploadUploader } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index eae5b3b6..39d0be61 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -78,6 +78,7 @@ import { reduceAdminWorkOrderShareQuantity, reopenAdminCancelledWorkOrder, resetAdminWorkOrderGiftMaterial, + resetAdminWorkOrderMaterialStatus, resolveAdminProblemWorkOrder, submitAdminWorkOrderMaterial, unpublishAdminWorkOrder, @@ -106,6 +107,7 @@ import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' +import { buildCollectMaterialLinkUrl } from '@/utils/collect-link' import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { DeadlineCountdown, @@ -203,10 +205,11 @@ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | return Object.keys(consignment).length > 0 ? (consignment as ConsignmentFulfillmentInfo) : null } -/** 公开补资料链接只在「待完善 / 未分配」阶段生成;已进大厅或已接单不允许客户改资料。 */ +/** 公开补资料链接只在「待完善 / 待分配」且有平台订单号时生成;已进大厅或已接单不允许客户改资料。 */ function canCopyMaterialLink(order: WorkOrder): boolean { const status = String(order.rawStatus || order.status || '') - return status === 'pending_material' || status === 'unassigned' + const platformOrderId = String(order.platformOrderId || '').trim() + return (status === 'pending_material' || status === 'unassigned') && Boolean(platformOrderId) } /** 剪贴板 API 在 HTTP + IP 下可能不可用,补一个 textarea 兜底。 */ @@ -1491,7 +1494,7 @@ export default function WorkOrdersPanel() { modal.confirm({ title: `确认删除订单 ${getDisplayOrderNo(row)}?`, content: - '删除后工单将从列表中移除,且不可恢复。仅未分配、待完善或已取消且无拼单参与的工单可删除。', + '删除后工单将从列表中移除,且不可恢复。仅待分配、待完善或已取消且无拼单参与的工单可删除。', okText: '确认删除', okButtonProps: { danger: true }, cancelText: '取消', @@ -1577,6 +1580,22 @@ export default function WorkOrdersPanel() { }) } + function requestResetMaterial(row: WorkOrder) { + modal.confirm({ + title: '重置补资料状态?', + content: + '将把该工单重置为“待完善”,客户可用订单号重新提交一次资料。已填资料会保留,客户重新提交后覆盖;此操作会记录在订单流转记录中。', + okText: '确认重置', + okButtonProps: { type: 'primary' }, + cancelText: '取消', + onOk: () => + runAction( + () => resetAdminWorkOrderMaterialStatus(row.workOrderId), + '已重置为待完善,客户刷新补资料页即可重新提交', + ), + }) + } + function copyBuyerMaterialInfo(order: WorkOrder) { const items = getMaterialDetailItems(order).filter((item) => String(item.value || '').trim()) if (items.length === 0) { @@ -1638,12 +1657,24 @@ export default function WorkOrdersPanel() { setMaterialLinkLoadingId(order.workOrderId) try { const response = await createAdminWorkOrderMaterialLink(order.workOrderId) - const url = `${window.location.origin}/supplement/${encodeURIComponent(response.data.token)}` + // 新后端返回 platformOrderId;旧后端缺失时回退 token,仅用于前后端滚动发布。 + const platformOrderId = String( + response.data.platformOrderId || response.data.token || '', + ).trim() + if (!platformOrderId) { + message.error('生成补资料链接失败:缺少平台订单号') + return + } + const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId) + if (!url) { + message.error('生成补资料链接失败:链接地址为空') + return + } const copied = await copyTextToClipboard(url) const expiresText = formatAdminDateTime(response.data.expiresAt) if (copied) { message.success( - `${response.data.reused ? '已复制现有链接' : '已生成并复制新链接'},${expiresText} 前有效,仅可提交一次`, + `${response.data.reused ? '已复制现有链接' : '已生成并复制链接'}(订单号 ${platformOrderId}),${expiresText} 前有效;该订单号下已生成链接的待补商品会一起显示`, ) } else { setMaterialLinkFallback({ url, expiresAt: response.data.expiresAt }) @@ -1914,13 +1945,13 @@ export default function WorkOrdersPanel() { onClick={() => { modal.confirm({ title: '确认重新启用该订单?', - content: '订单将恢复为未分配状态。请确认资料与金额后,再发布到抢单大厅。', + content: '订单将恢复为待分配状态。请确认资料与金额后,再发布到抢单大厅。', okText: '重新启用', cancelText: '取消', onOk: () => runAction( () => reopenAdminCancelledWorkOrder(row.workOrderId), - '订单已恢复为未分配状态,可编辑后发布', + '订单已恢复为待分配状态,可编辑后发布', ), }) }} @@ -1969,11 +2000,20 @@ export default function WorkOrdersPanel() { icon={} loading={materialLinkLoadingId === row.workOrderId} onClick={() => void copyMaterialLink(row)} - title="生成一次性补资料链接(有效期内仅可提交一次)" + title="生成一次性补资料链接(按平台订单号打开;有效期内每个工单仅可提交一次)" > 复制补资料链接 ) : null} + {row.capabilities?.canResetMaterial && canUpdateMaterial ? ( + + ) : null} {row.capabilities?.canPublish && canPublishOrder ? ( + + + + 如果客服已经发给你完整链接,直接打开链接即可。 + + + + + ) +} diff --git a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx index cf89a755..b83a462d 100644 --- a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx +++ b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx @@ -1,4 +1,4 @@ -import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons' +import { LoadingOutlined } from '@ant-design/icons' import { Alert, App, @@ -13,17 +13,23 @@ import { Tag, Typography, } from 'antd' -import { useEffect, useState } from 'react' -import { useParams } from 'react-router' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useParams } from 'react-router' import ImageUpload from '@/components/files/ImageUpload' import { uploadCollectMaterialLinkFile } from '@/services/files' import { fetchCollectMaterialLink, submitCollectMaterialLink } from '@/services/worker' -import type { CollectMaterialLinkInfo, UploadedFile } from '@/types/worker-platform' -import { formatDateTime } from '@/utils/date-time' +import type { + CollectMaterialLinkInfo, + CollectMaterialLinkOrder, + UploadedFile, +} from '@/types/worker-platform' +import { useCollectLinkPageMeta } from './use-collect-link-page-meta' const { Text, Title } = Typography +const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确' + type LinkLoadError = { code: string message: string @@ -38,135 +44,140 @@ function resolveErrorMessage(error: unknown): string { } function resolveErrorTitle(code: string): string { - if (code === 'material_link_expired') return '链接已过期' - if (code === 'material_link_revoked') return '链接已失效' - if (code === 'material_link_not_found') return '链接无效' + if (code === 'material_link_not_found' || code === 'material_link_work_order_invalid') { + return '订单号无效' + } + if (code === 'material_link_work_order_required') return '请选择要补充的商品' if (code === 'material_link_order_status_invalid') return '订单状态已变化' - return '无法打开链接' + return '暂时无法打开页面' +} + +const STATUS_TAG_COLORS: Record = { + pending_material: 'orange', + unassigned: 'blue', + open: 'cyan', + in_progress: 'processing', + pending_acceptance: 'purple', + problem: 'red', + accepted: 'green', + cancelled: 'default', +} + +function resolveStatusColor(status: string): string { + return STATUS_TAG_COLORS[status] || 'default' } /** - * 客户侧一次性补资料页。 + * 客户侧补资料页。 * - * 只渲染 requirement 里的动态字段与买家主页截图,不展示发单价格、押金、 - * 打手、后台备注等任何后台字段;提交成功后链接立即失效。 + * 只要知道平台订单号就能打开:一个订单号下可能有多个商品工单,页面按 orders + * 渲染多张卡片。只有「待完善 + 未指派 + 无份额」的工单返回表单,其余工单只展示 + * 商品、工单号、状态与客服提示,不暴露已填资料和截图。 + * 提交成功后工单进入「待分配」,同一工单不能再提交,需联系客服重置。 */ export default function CollectMaterialLinkPage() { const { message } = App.useApp() - const { token: routeToken } = useParams<{ token?: string }>() - const token = String(routeToken || '').trim() + const { code: routeCode } = useParams<{ code?: string }>() + const code = String(routeCode || '').trim() + + useCollectLinkPageMeta('订单资料补充') - const [form] = Form.useForm>() const [loading, setLoading] = useState(true) const [info, setInfo] = useState(null) const [loadError, setLoadError] = useState(null) - const [submitting, setSubmitting] = useState(false) - const [submitted, setSubmitted] = useState(false) - const [screenshots, setScreenshots] = useState([]) + const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0) + const submittingRef = useRef(false) + const requestSeqRef = useRef(0) + const [screenshotsByOrder, setScreenshotsByOrder] = useState>({}) - useEffect(() => { - const previousTitle = document.title - document.title = '订单资料补充' + /** + * 读取订单资料状态。 + * + * silent=true 用于提交后刷新:不切整页 loading,避免页面闪烁丢焦点; + * 同时用请求序号丢弃过期响应,防止快速重试时旧数据覆盖新数据。 + */ + const loadInfo = useCallback( + async (options: { silent?: boolean } = {}) => { + const seq = requestSeqRef.current + 1 + requestSeqRef.current = seq - function applyMeta(name: string, content: string) { - const existing = document.querySelector(`meta[name="${name}"]`) - const previous = existing?.getAttribute('content') ?? null - let created: HTMLMetaElement | null = null - if (existing) { - existing.setAttribute('content', content) - } else { - created = document.createElement('meta') - created.setAttribute('name', name) - created.setAttribute('content', content) - document.head.appendChild(created) - } - - return () => { - if (created) { - created.remove() - } else if (existing) { - if (previous === null) { - existing.removeAttribute('content') - } else { - existing.setAttribute('content', previous) - } - } - } - } - - const restoreRobots = applyMeta('robots', 'noindex,nofollow') - const restoreReferrer = applyMeta('referrer', 'no-referrer') - - return () => { - document.title = previousTitle - restoreRobots() - restoreReferrer() - } - }, []) - - useEffect(() => { - let cancelled = false - - async function load() { - if (!token) { + if (!code) { + setInfo(null) + setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE }) setLoading(false) - setLoadError({ - code: 'material_link_not_found', - message: '链接无效,请确认链接是否完整', - }) return } - setLoading(true) - setSubmitted(false) + if (!options.silent) setLoading(true) try { - const response = await fetchCollectMaterialLink(token) - if (cancelled) return + const response = await fetchCollectMaterialLink(code) + if (seq !== requestSeqRef.current) return + const orders = response.data.orders || [] + if (orders.length === 0) { + setInfo(null) + setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE }) + return + } setInfo(response.data) setLoadError(null) - setScreenshots(response.data.screenshots || []) - form.resetFields() - form.setFieldsValue(response.data.values || {}) + setScreenshotsByOrder((previous) => { + // 只保留仍可提交商品的本地截图,避免刷新后覆盖用户刚上传的图片; + // 不可提交商品不再展示资料,直接丢弃。 + const next: Record = {} + for (const order of orders) { + if (!order.canSubmit) continue + next[order.workOrderId] = previous[order.workOrderId] ?? order.screenshots ?? [] + } + return next + }) } catch (error) { - if (cancelled) return + if (seq !== requestSeqRef.current) return setInfo(null) setLoadError({ code: resolveErrorCode(error), - message: resolveErrorMessage(error), + message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE, }) } finally { - if (!cancelled) setLoading(false) + if (seq === requestSeqRef.current && !options.silent) setLoading(false) } - } + }, + [code], + ) - void load() - return () => { - cancelled = true - } - }, [form, token]) + useEffect(() => { + void loadInfo() + }, [loadInfo]) - async function handleSubmit(values: Record) { - if (!token) return - setSubmitting(true) + async function handleSubmit(order: CollectMaterialLinkOrder, values: Record) { + if (!code) return + // 多工单时用户可能连点不同卡片;前端只允许一个提交在途,避免列表状态互相覆盖。 + if (submittingRef.current) return + submittingRef.current = true + setSubmittingWorkOrderId(order.workOrderId) try { - await submitCollectMaterialLink(token, { fields: values, screenshots }) - setSubmitted(true) + await submitCollectMaterialLink(code, { + workOrderId: order.workOrderId, + fields: values, + screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [], + }) + message.success('资料已提交,订单已进入待分配') + await loadInfo({ silent: true }) } catch (error) { - const code = resolveErrorCode(error) - if ( - code === 'material_link_used' || - code === 'material_link_expired' || - code === 'material_link_revoked' || - code === 'material_link_not_found' || - code === 'material_link_order_status_invalid' - ) { - setInfo(null) - setLoadError({ code, message: resolveErrorMessage(error) }) - return - } + const errorCode = resolveErrorCode(error) message.error(resolveErrorMessage(error)) + // 状态类错误说明工单已被并发提交、被客服重置或流转到其他状态: + // 以服务端状态为准静默刷新,页面切换成只读卡片而不是整页报错。 + if ( + errorCode === 'material_link_already_submitted' || + errorCode === 'material_link_order_status_invalid' || + errorCode === 'material_link_work_order_invalid' || + errorCode === 'material_link_not_found' + ) { + await loadInfo({ silent: true }) + } } finally { - setSubmitting(false) + submittingRef.current = false + setSubmittingWorkOrderId(0) } } @@ -176,115 +187,106 @@ export default function CollectMaterialLinkPage() { {loading ? (
} size="large" /> - 正在读取补资料链接... + 正在读取订单资料...
- ) : submitted ? ( - ) : loadError ? ( - loadError.code === 'material_link_used' ? ( - } - title="资料已提交" - subTitle="该链接已经使用过;如需再次修改资料,请联系客服重新获取链接。" - /> - ) : ( - - ) + + + + } + /> ) : info ? (
订单资料补充 - 请补充以下资料,提交后客服会尽快处理。 + + {info.pendingCount > 0 + ? '请补充以下资料,提交后客服会尽快处理。' + : '以下为该订单当前的资料状态,如需修改请联系客服。'} +
-
-
- 商品 - {info.order.productName || '-'} -
-
- 工单号 - {info.order.workOrderNo || '-'} -
-
- 订单号 - {info.order.platformOrderId || '-'} -
-
- 有效期至 - {formatDateTime(info.expiresAt)} -
-
+ {info.pendingCount > 1 ? ( + + ) : null} - + {info.pendingCount === 0 ? ( + + ) : null} -
- {info.fields.map((field) => ( - - {field.label} - {field.required ? 必填 : 选填} - - } - rules={ - field.required - ? [{ required: true, whitespace: true, message: `请填写${field.label}` }] - : undefined - } - > - {field.type === 'select' && (field.options || []).length > 0 ? ( - ({ - label: option, - value: option, - }))} - /> + {info.orders.map((order) => ( + {order.statusLabel}} + > + +
+
+ 工单号 + {order.workOrderNo || '-'} +
+
+ 订单号 + {order.platformOrderId || '-'} +
+
+ + {order.canSubmit ? ( + <> + {info.pendingCount === 1 ? ( + + ) : null} + + + setScreenshotsByOrder((previous) => ({ + ...previous, + [order.workOrderId]: files, + })) + } + onSubmit={(values) => handleSubmit(order, values)} + /> + ) : ( - + )} -
- ))} - - - uploadCollectMaterialLinkFile(file, scene, token)} - /> - - -
- -
-
+
+ + ))} - 本页面仅用于补充订单资料,请勿将链接转发给他人。 + 本页面仅用于补充订单资料,请妥善保管订单号。 ) : null} @@ -292,3 +294,83 @@ export default function CollectMaterialLinkPage() { ) } + +type MaterialLinkOrderFormProps = { + order: CollectMaterialLinkOrder + code: string + screenshots: UploadedFile[] + submitting: boolean + disabled: boolean + onScreenshotsChange: (files: UploadedFile[]) => void + onSubmit: (values: Record) => void +} + +function MaterialLinkOrderForm({ + order, + code, + screenshots, + submitting, + disabled, + onScreenshotsChange, + onSubmit, +}: MaterialLinkOrderFormProps) { + return ( +
+ {(order.fields ?? []).map((field) => ( + + {field.label} + {field.required ? 必填 : 选填} + + } + rules={ + field.required + ? [{ required: true, whitespace: true, message: `请填写${field.label}` }] + : undefined + } + > + {field.type === 'select' && (field.options || []).length > 0 ? ( + ({ + label: option, + value: option, + }))} + /> + ) : ( + + )} + + ))} + + + + uploadCollectMaterialLinkFile(file, scene, code, order.workOrderId) + } + /> + + +
+ +
+
+ ) +} diff --git a/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts b/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts new file mode 100644 index 00000000..6547c6cd --- /dev/null +++ b/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts @@ -0,0 +1,49 @@ +import { useEffect } from 'react' + +/** + * 公开补资料页共用的 document.title / robots / referrer 设置。 + * + * 入口页和补资料页都是可被直接访问的公开路径,必须禁止搜索引擎收录, + * 并避免平台订单号通过 referrer 泄漏给第三方。 + */ +export function useCollectLinkPageMeta(title: string) { + useEffect(() => { + const previousTitle = document.title + document.title = title + + function applyMeta(name: string, content: string) { + const existing = document.querySelector(`meta[name="${name}"]`) + const previous = existing?.getAttribute('content') ?? null + let created: HTMLMetaElement | null = null + if (existing) { + existing.setAttribute('content', content) + } else { + created = document.createElement('meta') + created.setAttribute('name', name) + created.setAttribute('content', content) + document.head.appendChild(created) + } + + return () => { + if (created) { + created.remove() + } else if (existing) { + if (previous === null) { + existing.removeAttribute('content') + } else { + existing.setAttribute('content', previous) + } + } + } + } + + const restoreRobots = applyMeta('robots', 'noindex,nofollow') + const restoreReferrer = applyMeta('referrer', 'no-referrer') + + return () => { + document.title = previousTitle + restoreRobots() + restoreReferrer() + } + }, [title]) +} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index df50a26c..cb1b0494 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -770,8 +770,19 @@ export function submitAdminWorkOrderMaterial( } export function createAdminWorkOrderMaterialLink(workOrderId: number) { - return apiPost<{ token: string; expiresAt: string; reused: boolean }>( - `/api/v1/admin/worker-platform/orders/${workOrderId}/material-link`, + return apiPost<{ + token: string + /** 公开 URL 使用的平台订单号;旧后端可能没有该字段,前端仅用于滚动发布兜底。 */ + platformOrderId?: string + expiresAt: string + reused: boolean + }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/material-link`) +} + +/** 客服重置补资料:仅「待分配 + 未指派 + 无份额 + 未发布」可重置回待完善。 */ +export function resetAdminWorkOrderMaterialStatus(workOrderId: number) { + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/reset-material`, ) } diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts index 5c32d562..ddc7a8b1 100644 --- a/apps/frontend/src/services/files.ts +++ b/apps/frontend/src/services/files.ts @@ -22,16 +22,22 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope) /** * 一次性补资料链接的截图上传。 * - * 与公开 collect/upload 的区别:先把 token 放在路径里,后端会校验链接 - * 是否仍可用;链接已使用/过期时不会落文件。 + * 与公开 collect/upload 的区别:路径里带平台订单号,多工单时再带 workOrderId; + * 后端会校验对应链接是否仍可用,链接已使用/过期时不会落文件。 */ -export async function uploadCollectMaterialLinkFile(file: File, scene: string, token: string) { +export async function uploadCollectMaterialLinkFile( + file: File, + scene: string, + code: string, + workOrderId?: number, +) { const uploadTarget = await optimizeImageForUpload(file, scene) const form = new FormData() form.append('file', uploadTarget) form.append('scene', scene) + const query = workOrderId ? `?workOrderId=${encodeURIComponent(String(workOrderId))}` : '' const response = await apiPostForm<{ file: UploadedFile }>( - `/api/v1/collect/link/${encodeURIComponent(token)}/upload`, + `/api/v1/collect/link/${encodeURIComponent(code)}/upload${query}`, form, ) return response.data.file diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 70f282a0..c6130c8f 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -381,19 +381,20 @@ export function submitCollectOrder(payload: { ) } -export function fetchCollectMaterialLink(token: string) { - return apiGet(`/api/v1/collect/link/${encodeURIComponent(token)}`) +export function fetchCollectMaterialLink(code: string) { + return apiGet(`/api/v1/collect/link/${encodeURIComponent(code)}`) } export function submitCollectMaterialLink( - token: string, + code: string, payload: { + workOrderId?: number fields: Record screenshots?: UploadedFile[] }, ) { return apiPost( - `/api/v1/collect/link/${encodeURIComponent(token)}/submit`, + `/api/v1/collect/link/${encodeURIComponent(code)}/submit`, payload, ) } diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index cc11c0b8..373a1d6c 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -499,6 +499,11 @@ padding: 28px; } +/* 一单多工单时,内层商品卡片比外层容器更紧凑 */ +.collect-link-order-card .ant-card-body { + padding: 16px; +} + .collect-link-title { margin: 0 0 4px !important; } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 6a34deeb..1fa4c993 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -647,6 +647,8 @@ export type WorkOrder = { canPin: boolean canUnpublish: boolean canUpdateMaterial: boolean + /** 待分配且未指派、无份额、未发布时,客服可以把补资料状态重置回待完善。 */ + canResetMaterial?: boolean canAccept: boolean canMarkProblem: boolean canDelete: boolean @@ -867,22 +869,39 @@ export type CollectLookupResponse = { pendingCount: number } -/** 一次性补资料链接的公开只读信息(不含价格/押金/打手等后台字段)。 */ -export type CollectMaterialLinkInfo = { - order: { - workOrderNo: string - platformOrderId: string - productName: string - status: string - } - fields: CollectField[] +export type CollectMaterialLinkBlockedReason = + | 'already_submitted' + | 'status' + | 'assigned' + | 'shares' + | '' + +/** 公开补资料页的单个工单;只有 canSubmit=true 时才有字段/已填值/截图。 */ +export type CollectMaterialLinkOrder = { + workOrderId: number + workOrderNo: string + platformOrderId: string + productName: string + status: string + statusLabel: string + canSubmit: boolean + blockedReason: CollectMaterialLinkBlockedReason + message: string + fields?: CollectField[] values?: Record screenshots?: UploadedFile[] - expiresAt: string +} + +/** 按平台订单号读取到的补资料信息;一单多工单时 orders 会有多条。 */ +export type CollectMaterialLinkInfo = { + platformOrderId: string + orders: CollectMaterialLinkOrder[] + pendingCount: number } export type CollectMaterialLinkSubmitResult = { complete: boolean + workOrderId: number order: { workOrderNo: string platformOrderId: string diff --git a/apps/frontend/src/utils/collect-link.ts b/apps/frontend/src/utils/collect-link.ts index 9ca1c4f6..a80145b0 100644 --- a/apps/frontend/src/utils/collect-link.ts +++ b/apps/frontend/src/utils/collect-link.ts @@ -1,9 +1,10 @@ /** * 一次性补资料链接的路径解析。 * - * 公开链接形如:`https://<域名>/supplement/`,与后台 HashRouter 完全隔离。 - * 命名空间判定用于路由分发:`/supplement` 及子路径整段交给公开页, - * 避免落到后台路由或打手端 SPA。 + * 公开链接有两种形态: + * - `https://<域名>/supplement/`:输入平台订单号; + * - `https://<域名>/supplement/<平台订单号>`:直接打开对应订单的补资料页。 + * 与后台 HashRouter 完全隔离。 */ export function isCollectMaterialLinkPath(pathname: unknown): boolean { @@ -11,14 +12,44 @@ export function isCollectMaterialLinkPath(pathname: unknown): boolean { } /** - * 只认 `/supplement/<一段 token>`: + * 只认 `/supplement/<一段 code>`: * - `/supplement/abc` 命中,结尾多余斜杠容忍; - * - `/supplement`、`/supplement/a/b` 不命中(页面展示链接无效)。 + * - `/supplement`、`/supplement/a/b` 不命中(由入口页/页面自己展示无效)。 */ -export function resolveCollectMaterialLinkToken(pathname: unknown): string { +export function resolveCollectMaterialLinkCode(pathname: unknown): string { const segments = splitPathSegments(pathname) if (segments.length !== 2 || segments[0] !== 'supplement') return '' - return decodeTokenSegment(segments[1]) + return decodePathSegment(segments[1]) +} + +/** 统一拼补资料链接;origin 结尾多余斜杠会被去掉。 */ +export function buildCollectMaterialLinkUrl(origin: unknown, code: unknown): string { + const base = String(origin ?? '') + .trim() + .replace(/\/+$/, '') + const normalized = String(code ?? '').trim() + if (!base || !normalized) return '' + return `${base}/supplement/${encodeURIComponent(normalized)}` +} + +/** + * 入口页输入归一化:去空格;如果用户粘贴了完整 `/supplement/<订单号>` 链接, + * 自动提取最后一段,避免把整条 URL 当成订单号。 + */ +export function normalizeOrderNoInput(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + + const marker = '/supplement/' + const index = raw.indexOf(marker) + if (index < 0) return raw + + const segment = raw + .slice(index + marker.length) + .split(/[?#]/)[0] + .replace(/\/+$/, '') + .trim() + return decodePathSegment(segment) } function splitPathSegments(pathname: unknown): string[] { @@ -28,7 +59,7 @@ function splitPathSegments(pathname: unknown): string[] { return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean) } -function decodeTokenSegment(segment: string): string { +function decodePathSegment(segment: string): string { try { return decodeURIComponent(segment) } catch { diff --git a/apps/frontend/tests/collect-link.test.ts b/apps/frontend/tests/collect-link.test.ts index 9bf34d9e..b1377cbc 100644 --- a/apps/frontend/tests/collect-link.test.ts +++ b/apps/frontend/tests/collect-link.test.ts @@ -2,31 +2,65 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + buildCollectMaterialLinkUrl, isCollectMaterialLinkPath, - resolveCollectMaterialLinkToken, + normalizeOrderNoInput, + resolveCollectMaterialLinkCode, } from '../src/utils/collect-link.ts' -test('补资料链接:/supplement/ 命中并解析 token', () => { - assert.equal(isCollectMaterialLinkPath('/supplement/abc123'), true) - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123'), 'abc123') +test('补资料链接:/supplement/<订单号> 命中并解析 code', () => { + assert.equal(isCollectMaterialLinkPath('/supplement/2630501851236676'), true) + assert.equal(resolveCollectMaterialLinkCode('/supplement/2630501851236676'), '2630501851236676') +}) + +test('补资料链接:入口页 /supplement 命中命名空间但没有 code', () => { + assert.equal(isCollectMaterialLinkPath('/supplement'), true) + assert.equal(isCollectMaterialLinkPath('/supplement/'), true) + assert.equal(resolveCollectMaterialLinkCode('/supplement'), '') + assert.equal(resolveCollectMaterialLinkCode('/supplement/'), '') }) test('补资料链接:容忍结尾斜杠、查询串与 hash', () => { - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123/'), 'abc123') - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123?from=chat'), 'abc123') - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123#top'), 'abc123') + assert.equal(resolveCollectMaterialLinkCode('/supplement/2630501851236676/'), '2630501851236676') + assert.equal( + resolveCollectMaterialLinkCode('/supplement/2630501851236676?from=chat'), + '2630501851236676', + ) + assert.equal( + resolveCollectMaterialLinkCode('/supplement/2630501851236676#top'), + '2630501851236676', + ) }) -test('补资料链接:命名空间命中但缺少 token 时返回空串', () => { - assert.equal(isCollectMaterialLinkPath('/supplement'), true) - assert.equal(isCollectMaterialLinkPath('/supplement/'), true) - assert.equal(resolveCollectMaterialLinkToken('/supplement'), '') - assert.equal(resolveCollectMaterialLinkToken('/supplement/a/b'), '') +test('补资料链接:多段路径不解析 code', () => { + assert.equal(resolveCollectMaterialLinkCode('/supplement/a/b'), '') }) test('补资料链接:其它路径不命中', () => { for (const pathname of ['/', '/claim/abc', '/collect', '/supplementx/abc', '']) { assert.equal(isCollectMaterialLinkPath(pathname), false, pathname) - assert.equal(resolveCollectMaterialLinkToken(pathname), '', pathname) + assert.equal(resolveCollectMaterialLinkCode(pathname), '', pathname) } }) + +test('补资料链接:URL 构建去掉 origin 结尾斜杠并编码', () => { + assert.equal( + buildCollectMaterialLinkUrl('https://ks.khhao.com/', '2630501851236676'), + 'https://ks.khhao.com/supplement/2630501851236676', + ) + assert.equal( + buildCollectMaterialLinkUrl('https://ks.khhao.com', 'abc/def'), + 'https://ks.khhao.com/supplement/abc%2Fdef', + ) + assert.equal(buildCollectMaterialLinkUrl('', 'abc'), '') + assert.equal(buildCollectMaterialLinkUrl('https://ks.khhao.com', ''), '') +}) + +test('补资料入口:输入归一化支持订单号与完整链接', () => { + assert.equal(normalizeOrderNoInput(' 2630501851236676 '), '2630501851236676') + assert.equal( + normalizeOrderNoInput('https://ks.khhao.com/supplement/2630501851236676?from=chat#top'), + '2630501851236676', + ) + assert.equal(normalizeOrderNoInput(''), '') +}) From db73e8f820219ce859d93ac8977d02eb24069817 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 18:26:07 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(collect):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A7=E8=A1=A5=E8=B5=84=E6=96=99=E9=93=BE=E6=8E=A5=20token?= =?UTF-8?q?=20=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除管理端生成补资料链接接口、链接仓储读写与 TTL 配置服务 - 删除补资料链接配置面板及前端相关服务、类型 - 新增迁移 113:删除 work_order_material_links 表与 work_order_material_link 配置 - 「复制补资料链接」改为本地复制 /supplement/<平台订单号>,不再请求后端 --- apps/backend/src/config/app-config-keys.ts | 2 - .../113_drop_work_order_material_links.sql | 12 ++ .../work-order-material-link-repo.ts | 71 ---------- .../routes/admin/worker-platform/config.ts | 28 ---- .../routes/admin/worker-platform/orders.ts | 31 ----- .../src/services/worker-platform/index.ts | 1 - .../work-order-material-link-service.test.ts | 46 +------ .../work-order-material-link-service.ts | 128 ------------------ ...orker-material-link-config-service.test.ts | 41 ------ .../worker-material-link-config-service.ts | 62 --------- apps/frontend/src/App.tsx | 6 +- .../pages/admin/panels/AnnouncementPanel.tsx | 2 - .../admin/panels/MaterialLinkConfigPanel.tsx | 74 ---------- .../pages/admin/panels/WorkOrdersPanel.tsx | 60 +++----- .../src/services/admin/worker-platform.ts | 22 --- apps/frontend/src/types/worker-platform.ts | 5 - 16 files changed, 36 insertions(+), 555 deletions(-) create mode 100644 apps/backend/src/db/migrations/113_drop_work_order_material_links.sql delete mode 100644 apps/backend/src/services/worker-platform/worker-material-link-config-service.test.ts delete mode 100644 apps/backend/src/services/worker-platform/worker-material-link-config-service.ts delete mode 100644 apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 2b2e71af..9be4b937 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -9,8 +9,6 @@ export const APP_CONFIG_KEYS = { workerHall: 'worker_hall', workerRiskControl: 'worker_risk_control', workerMockShops: 'worker_mock_shops', - /** 接单工单:一次性补资料链接配置(有效期分钟数) */ - workOrderMaterialLink: 'work_order_material_link', kuaishouIndustrySource: 'kuaishou_industry_source', affiliateDash: 'affiliate_dash', pddSource: 'pdd_source', diff --git a/apps/backend/src/db/migrations/113_drop_work_order_material_links.sql b/apps/backend/src/db/migrations/113_drop_work_order_material_links.sql new file mode 100644 index 00000000..e389097b --- /dev/null +++ b/apps/backend/src/db/migrations/113_drop_work_order_material_links.sql @@ -0,0 +1,12 @@ +-- 113_drop_work_order_material_links.sql —— 下线旧的「一次性补资料链接 token」体系 +-- +-- 背景:补资料页已改为按平台订单号直连(/supplement/:订单号), +-- 不再需要客服先生成 token 链接: +-- - 后端:管理端 /orders/:id/material-link、链接仓储读写、material-link-config 配置接口 +-- - 前端:补资料链接有效期设置面板、生成链接请求 +-- - 数据:work_order_material_links 表、app_config_entries.work_order_material_link +-- 历史 token 记录已无任何读取入口,直接随表删除。 + +DROP TABLE IF EXISTS work_order_material_links; + +DELETE FROM app_config_entries WHERE config_key = 'work_order_material_link'; diff --git a/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts index 0fdcc06b..f1e719b0 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts @@ -2,20 +2,6 @@ import type { PoolClient } from 'pg' import type { WorkOrderRow } from './types.js' -export type WorkOrderMaterialLinkStatus = 'active' | 'used' | 'expired' | 'revoked' - -export type WorkOrderMaterialLinkRow = { - id: number - work_order_id: number - token: string - status: WorkOrderMaterialLinkStatus - expires_at: string - used_at: string | null - created_by: string - created_at: string - updated_at: string -} - /** * 锁工单并回读「有效拼单份额」。 * @@ -41,63 +27,6 @@ export async function getWorkOrderForUpdateWithClient( return result.rows[0] || null } -export async function findActiveWorkOrderMaterialLinkWithClient( - client: PoolClient, - workOrderId: number | string, - now: string, -): Promise { - const result = await client.query( - `SELECT * FROM work_order_material_links - WHERE work_order_id = $1 AND status = 'active' AND expires_at > $2 - ORDER BY id DESC - LIMIT 1`, - [Number(workOrderId), now], - ) - return result.rows[0] || null -} - -/** 重新生成前清掉旧 active:已过期标 expired,未过期标 revoked。 */ -export async function revokeActiveWorkOrderMaterialLinksWithClient( - client: PoolClient, - workOrderId: number | string, - now: string, -): Promise { - const result = await client.query( - `UPDATE work_order_material_links - SET status = CASE WHEN expires_at <= $2 THEN 'expired' ELSE 'revoked' END, - updated_at = $2 - WHERE work_order_id = $1 AND status = 'active'`, - [Number(workOrderId), now], - ) - return result.rowCount || 0 -} - -export async function createWorkOrderMaterialLinkWithClient( - client: PoolClient, - input: { - workOrderId: number | string - token: string - expiresAt: string - createdBy: string - now: string - }, -): Promise { - const result = await client.query( - `INSERT INTO work_order_material_links ( - work_order_id, token, status, expires_at, created_by, created_at, updated_at - ) VALUES ($1, $2, 'active', $3, $4, $5, $5) - RETURNING *`, - [ - Number(input.workOrderId), - input.token, - input.expiresAt, - String(input.createdBy || '').trim(), - input.now, - ], - ) - return result.rows[0]! -} - export async function updateWorkOrderMaterialWithClient( client: PoolClient, input: { diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 901a88f5..fdacc2c2 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -13,7 +13,6 @@ import { getAdminWorkerPlatformNotificationConfig, getAdminWorkerPlatformSummary, getAdminWorkerProductMatchConfig, - getWorkerMaterialLinkConfig, listAdminKuaishouMatchSources, listAdminWorkCategories, listAdminWorkProductMatchLogs, @@ -30,7 +29,6 @@ import { saveAdminWorkerLevel, saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerProductMatchConfig, - saveWorkerMaterialLinkConfig, syncAdminWorkProductRulePrice, testAdminKuaishouProductMatch, testAdminProductMatchByOrderItem, @@ -295,32 +293,6 @@ router.post( }), ) -router.get( - '/worker-platform/material-link-config', - requireAdminRoles(['admin', 'operator']), - createJsonHandler(() => getWorkerMaterialLinkConfig(), { - successMessage: 'ok', - errorMessage: '读取补资料链接配置失败', - scope: '[admin/worker-platform/material-link-config]', - }), -) - -router.post( - '/worker-platform/material-link-config', - requireAdminRoles(['admin', 'operator']), - createJsonHandler((req) => saveWorkerMaterialLinkConfig(req.body || {}), { - successMessage: '补资料链接配置已保存', - errorMessage: '保存补资料链接配置失败', - scope: '[admin/worker-platform/material-link-config]', - audit: (_req, data) => ({ - action: 'worker_material_link_config_saved', - targetType: 'work_order_material_link_config', - targetId: 'work_order_material_link', - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }), -) - router.post( '/worker-platform/announcement', requireAdminRoles(['admin']), diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 3e08cbf2..19c49fb3 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -9,7 +9,6 @@ import { completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriendAdded, createAdminMockWorkOrder, - createAdminWorkOrderMaterialLink, deleteAdminMockCustomShop, deleteAdminWorkOrder, getAdminWorkOrderEvents, @@ -187,36 +186,6 @@ router.post( ), ) -router.post( - '/worker-platform/orders/:workOrderId/material-link', - requireAdminPermission('worker_order.update_material'), - createJsonHandler( - (req) => - createAdminWorkOrderMaterialLink( - String(req.params.workOrderId || ''), - req.adminSession?.displayName || req.adminSession?.username || '', - ), - { - successMessage: '补资料链接已生成', - errorMessage: '生成补资料链接失败', - scope: '[admin/worker-platform/orders/:workOrderId/material-link]', - audit: (req, data) => ({ - action: 'work_order_material_link_created', - targetType: 'work_order', - targetId: String(req.params.workOrderId || ''), - // 审计只记录有效期与是否复用,绝不落 token。 - data: - data && typeof data === 'object' - ? { - expiresAt: (data as Record).expiresAt, - reused: (data as Record).reused, - } - : {}, - }), - }, - ), -) - router.post( '/worker-platform/orders/:workOrderId/reset-material', requireAdminPermission('worker_order.update_material'), diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 9b800c83..ca46aaa8 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -65,4 +65,3 @@ export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' export * from './consignment-contact-service.js' export * from './work-order-material-link-service.js' -export * from './worker-material-link-config-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts index 88ca0fad..b17da133 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts @@ -6,7 +6,6 @@ import { pickSubmittedMaterialFields, resolveMaterialResetBlockReason, resolvePublicMaterialOrderState, - resolveWorkOrderMaterialLinkOrderBlockReason, } from './work-order-material-link-service.js' test('公开补资料:只有待完善且未指派/无份额可提交', () => { @@ -129,50 +128,7 @@ test('客服重置补资料:只允许待分配、未指派、无份额、未 ) }) -test('管理端链接准入:待完善/待分配可用,已指派/有份额/其他状态拒绝', () => { - assert.equal( - resolveWorkOrderMaterialLinkOrderBlockReason({ - status: 'pending_material', - assigned_worker_id: null, - active_share_quantity: 0, - }), - '', - ) - assert.equal( - resolveWorkOrderMaterialLinkOrderBlockReason({ - status: 'unassigned', - assigned_worker_id: null, - active_share_quantity: 0, - }), - '', - ) - assert.equal( - resolveWorkOrderMaterialLinkOrderBlockReason({ - status: 'open', - assigned_worker_id: null, - active_share_quantity: 0, - }), - 'status', - ) - assert.equal( - resolveWorkOrderMaterialLinkOrderBlockReason({ - status: 'pending_material', - assigned_worker_id: 9, - active_share_quantity: 0, - }), - 'assigned', - ) - assert.equal( - resolveWorkOrderMaterialLinkOrderBlockReason({ - status: 'pending_material', - assigned_worker_id: null, - active_share_quantity: 2, - }), - 'shares', - ) -}) - -test('补资料链接:提交字段只保留 requirements 白名单,额外字段被丢弃', () => { +test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => { const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], { gameId: '123', gameNickname: '张三', diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts index c449c3a1..6ae31497 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -1,14 +1,9 @@ -import { randomBytes } from 'node:crypto' - import { withTransaction } from '../../db/client.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { createWorkOrderEventWithClient, - createWorkOrderMaterialLinkWithClient, - findActiveWorkOrderMaterialLinkWithClient, getWorkOrderForUpdateWithClient, listWorkOrdersByPlatformOrderId, - revokeActiveWorkOrderMaterialLinksWithClient, updateWorkOrderMaterialWithClient, } from '../../repositories/worker-platform/index.js' import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' @@ -21,20 +16,13 @@ import { resolveAdminNotificationEntity } from '../admin/admin-notification-serv import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' import { mapCollectLookupOrder, - mapWorkOrderAdmin, normalizeOptionalId, normalizeSubmittedFields, normalizeUploadedFiles, resolveRequirementFields, } from './mappers.js' -import { getWorkerMaterialLinkConfig } from './worker-material-link-config-service.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' -const ALLOWED_MATERIAL_LINK_ORDER_STATUSES = new Set([ - WORK_ORDER_STATUS.PENDING_MATERIAL, - WORK_ORDER_STATUS.UNASSIGNED, -]) - const MAX_MATERIAL_SCREENSHOTS = 5 const PUBLIC_MATERIAL_STATUS_LABELS: Record = { @@ -49,8 +37,6 @@ const PUBLIC_MATERIAL_STATUS_LABELS: Record = { vip_evidence_pending: '待补图', } -export type WorkOrderMaterialLinkOrderBlockReason = 'status' | 'assigned' | 'shares' | '' - export type PublicMaterialOrderBlockReason = | 'already_submitted' | 'status' @@ -154,21 +140,6 @@ export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReaso return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态' } -/** - * 管理端生成/复用一次性链接的准入判断。 - * - * 用户要求保留现有“复制补资料链接”按钮,所以这里仍按原口径允许 - * 待完善 / 待分配,并拒绝已指派/有份额的工单。 - */ -export function resolveWorkOrderMaterialLinkOrderBlockReason( - order: Pick, -): WorkOrderMaterialLinkOrderBlockReason { - if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) return 'status' - if (order.assigned_worker_id) return 'assigned' - if (Number(order.active_share_quantity || 0) > 0) return 'shares' - return '' -} - /** * 只保留工单 requirement 里定义过的字段。 * @@ -196,29 +167,6 @@ function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never { }) } -/** 管理端生成补资料链接时仍沿用旧的准入文案。 */ -function assertWorkOrderAllowedForMaterialLink(order: WorkOrderRow): void { - const reason = resolveWorkOrderMaterialLinkOrderBlockReason(order) - if (reason === 'status') { - throw createHttpError('当前状态不能生成补资料链接', { - statusCode: 409, - errorCode: 'material_link_status_invalid', - }) - } - if (reason === 'assigned') { - throw createHttpError('订单已指派打手,不能生成补资料链接', { - statusCode: 409, - errorCode: 'material_link_status_invalid', - }) - } - if (reason === 'shares') { - throw createHttpError('订单已有拼单份额,不能生成补资料链接', { - statusCode: 409, - errorCode: 'material_link_status_invalid', - }) - } -} - function mapPublicOrder(order: WorkOrderRow) { return { workOrderNo: order.work_order_no, @@ -228,12 +176,6 @@ function mapPublicOrder(order: WorkOrderRow) { } } -/** PostgreSQL 返回的 timestamptz 可能是 "YYYY-MM-DD HH:mm:ss" 字符串,统一转 ISO。 */ -function toIsoTimestamp(value: string): string { - const date = new Date(value) - return Number.isNaN(date.getTime()) ? String(value || '') : date.toISOString() -} - function mapPublicMaterialOrder(order: WorkOrderRow) { const state = resolvePublicMaterialOrderState(order) const base = { @@ -290,76 +232,6 @@ async function resolvePublicMaterialTarget( return orders[0]! } -/** - * 管理端生成/复用一次性链接。 - * - * 用户要求保留现有“复制补资料链接”按钮,因此链接记录仍会生成; - * 但公开页已经不再依赖它,复制出的 `/supplement/<平台订单号>` 始终可用。 - */ -export async function createAdminWorkOrderMaterialLink( - workOrderId: number | string, - actorName = '', -) { - const { ttlMinutes } = getWorkerMaterialLinkConfig() - - return withTransaction(async (client) => { - const order = await getWorkOrderForUpdateWithClient(client, workOrderId) - if (!order) { - throw createHttpError('接单订单不存在', { - statusCode: 404, - errorCode: 'work_order_not_found', - }) - } - - if (!mapWorkOrderAdmin(order).capabilities.canUpdateMaterial) { - throw createHttpError('当前状态不能生成补资料链接', { - statusCode: 409, - errorCode: 'material_link_status_invalid', - }) - } - - const platformOrderId = String(order.platform_order_id || '').trim() - if (!platformOrderId) { - throw createHttpError('该工单没有平台订单号,不能生成补资料链接', { - statusCode: 409, - errorCode: 'material_link_platform_order_missing', - }) - } - - assertWorkOrderAllowedForMaterialLink(order) - - // 锁到手后再取时间,避免等锁期间旧链接过期却仍按旧时间复用。 - const now = nowIso() - const existing = await findActiveWorkOrderMaterialLinkWithClient(client, order.id, now) - if (existing) { - return { - token: existing.token, - platformOrderId, - expiresAt: toIsoTimestamp(existing.expires_at), - reused: true, - } - } - - await revokeActiveWorkOrderMaterialLinksWithClient(client, order.id, now) - const token = randomBytes(24).toString('base64url') - const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString() - const link = await createWorkOrderMaterialLinkWithClient(client, { - workOrderId: order.id, - token, - expiresAt, - createdBy: String(actorName || '').trim(), - now, - }) - - return { - token: link.token, - platformOrderId, - expiresAt: toIsoTimestamp(link.expires_at), - reused: false, - } - }) -} - /** * 公开页按平台订单号读取补资料内容。 * diff --git a/apps/backend/src/services/worker-platform/worker-material-link-config-service.test.ts b/apps/backend/src/services/worker-platform/worker-material-link-config-service.test.ts deleted file mode 100644 index 2c4d2ea8..00000000 --- a/apps/backend/src/services/worker-platform/worker-material-link-config-service.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { - createDefaultWorkerMaterialLinkConfig, - normalizeWorkerMaterialLinkConfig, - WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES, - WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES, - WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES, -} from './worker-material-link-config-service.js' - -test('补资料链接配置:默认 30 分钟', () => { - assert.deepEqual(createDefaultWorkerMaterialLinkConfig(), { - ttlMinutes: WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES, - }) - assert.equal(WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES, 30) - assert.deepEqual(normalizeWorkerMaterialLinkConfig(undefined), { ttlMinutes: 30 }) -}) - -test('补资料链接配置:接受 ttlMinutes / ttl_minutes / 数字字符串', () => { - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 45 }), { ttlMinutes: 45 }) - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttl_minutes: '10' }), { ttlMinutes: 10 }) - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 90.4 }), { ttlMinutes: 90 }) -}) - -test('补资料链接配置:越界值收敛到 1 - 10080 分钟', () => { - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 0 }), { - ttlMinutes: WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES, - }) - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: -5 }), { ttlMinutes: 1 }) - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 999999 }), { - ttlMinutes: WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES, - }) - assert.equal(WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES, 10080) -}) - -test('补资料链接配置:非法输入回退默认值,不会写入 NaN', () => { - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 'abc' }), { ttlMinutes: 30 }) - assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: null }), { ttlMinutes: 30 }) - assert.deepEqual(normalizeWorkerMaterialLinkConfig('not-an-object'), { ttlMinutes: 30 }) -}) diff --git a/apps/backend/src/services/worker-platform/worker-material-link-config-service.ts b/apps/backend/src/services/worker-platform/worker-material-link-config-service.ts deleted file mode 100644 index f53d076a..00000000 --- a/apps/backend/src/services/worker-platform/worker-material-link-config-service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' -import type { JsonObject } from '../../types/json.js' -import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' - -/** 补资料链接默认有效期(分钟):30 分钟。 */ -export const WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES = 30 -/** 允许配置的最短有效期(分钟)。 */ -export const WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES = 1 -/** 允许配置的最长有效期(分钟):7 天。 */ -export const WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES = 7 * 24 * 60 - -export type WorkerMaterialLinkConfig = { - /** 新生成的补资料链接有效期,单位分钟。 */ - ttlMinutes: number -} - -/** 读取配置(内存缓存),未配置时返回默认 30 分钟。 */ -export function getWorkerMaterialLinkConfig(): WorkerMaterialLinkConfig { - return readAppConfigEntry({ - configKey: APP_CONFIG_KEYS.workOrderMaterialLink, - fallback: createDefaultWorkerMaterialLinkConfig, - normalize: normalizeWorkerMaterialLinkConfig, - }) -} - -export async function saveWorkerMaterialLinkConfig( - rawValue: unknown, -): Promise { - return saveAppConfigEntry({ - configKey: APP_CONFIG_KEYS.workOrderMaterialLink, - value: rawValue, - normalize: normalizeWorkerMaterialLinkConfig, - }) -} - -export function createDefaultWorkerMaterialLinkConfig(): WorkerMaterialLinkConfig { - return { ttlMinutes: WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES } -} - -export function normalizeWorkerMaterialLinkConfig(rawValue: unknown): WorkerMaterialLinkConfig { - const source = isPlainObject(rawValue) ? rawValue : {} - const raw = source.ttlMinutes ?? source.ttl_minutes - if (raw === undefined || raw === null || raw === '') { - return createDefaultWorkerMaterialLinkConfig() - } - - const value = Number(raw) - if (!Number.isFinite(value)) { - return createDefaultWorkerMaterialLinkConfig() - } - - return { - ttlMinutes: Math.min( - WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES, - Math.max(WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES, Math.round(value)), - ), - } -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 14b4f951..15749f23 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -127,7 +127,7 @@ export default function App() { return } - // 一次性补资料链接(`/supplement`):路径式公开页,先于站点判断, + // 补资料页(`/supplement`):路径式公开页,先于站点判断, // 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。 if (isCollectMaterialLinkPath(window.location.pathname)) { return @@ -238,11 +238,11 @@ function ClaimApplication() { } /** - * 一次性补资料链接(`/supplement`)的独立路由树。 + * 补资料页(`/supplement`)的独立路由树。 * * - `/supplement`:输入平台订单号; * - `/supplement/<订单号>`:直接打开对应订单的补资料页; - * - 其余子路径:交给补资料页展示「链接无效」。 + * - 其余子路径:交给补资料页展示「订单号无效」。 * * 路径式公开页,不带 hash;不依赖后台会话。 */ diff --git a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx index ccdb9915..9cb5a206 100644 --- a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx @@ -32,7 +32,6 @@ import type { WorkerAnnouncementConfig } from '@/types/worker-platform' import { formatAdminDateTime } from '@/utils/admin-time' import HallConfigPanel from './HallConfigPanel' -import MaterialLinkConfigPanel from './MaterialLinkConfigPanel' function isDepositUnfreezeJob(job: AdminScheduledJobItem) { return job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze' @@ -45,7 +44,6 @@ export default function AnnouncementPanel() { - ) } diff --git a/apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx deleted file mode 100644 index 423596cd..00000000 --- a/apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { App, Button, Card, Form, InputNumber, Space, Typography } from 'antd' -import { useEffect, useState } from 'react' - -import { - fetchAdminWorkerMaterialLinkConfig, - saveAdminWorkerMaterialLinkConfig, -} from '@/services/admin' -import type { WorkerMaterialLinkConfig } from '@/types/worker-platform' - -export default function MaterialLinkConfigPanel() { - const { message } = App.useApp() - const queryClient = useQueryClient() - const [form] = Form.useForm() - const [saving, setSaving] = useState(false) - - const configQuery = useQuery({ - queryKey: ['admin-worker-material-link-config'], - queryFn: fetchAdminWorkerMaterialLinkConfig, - }) - const config = configQuery.data?.data - - useEffect(() => { - if (config) { - form.setFieldsValue({ ttlMinutes: config.ttlMinutes }) - } - }, [config, form]) - - async function saveConfig(values: WorkerMaterialLinkConfig) { - setSaving(true) - try { - await saveAdminWorkerMaterialLinkConfig({ ttlMinutes: Number(values.ttlMinutes) }) - await queryClient.invalidateQueries({ queryKey: ['admin-worker-material-link-config'] }) - message.success('补资料链接配置已保存') - } catch (error) { - message.error(error instanceof Error ? error.message : '保存补资料链接配置失败') - } finally { - setSaving(false) - } - } - - return ( - - - - 客服在「接单工单」复制的一次性补资料链接,客户只能修改补资料内容;提交成功后工单转为「未分配」, - 链接立即失效,需要重新发送。修改有效期只影响新生成的链接。 - -
- - - - - - -
-
-
- ) -} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 39d0be61..c41b8855 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -60,7 +60,6 @@ import { cancelAdminWorkOrderSharingPart, completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriend, - createAdminWorkOrderMaterialLink, deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, disposeAdminWorkOrderShareProblem, @@ -205,7 +204,7 @@ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | return Object.keys(consignment).length > 0 ? (consignment as ConsignmentFulfillmentInfo) : null } -/** 公开补资料链接只在「待完善 / 待分配」且有平台订单号时生成;已进大厅或已接单不允许客户改资料。 */ +/** 补资料链接按平台订单号打开;只有待完善 / 待分配且存在平台订单号时才展示复制入口。 */ function canCopyMaterialLink(order: WorkOrder): boolean { const status = String(order.rawStatus || order.status || '') const platformOrderId = String(order.platformOrderId || '').trim() @@ -354,11 +353,7 @@ export default function WorkOrdersPanel() { const [materialOrder, setMaterialOrder] = useState(null) const [materialScreenshots, setMaterialScreenshots] = useState([]) const [materialPasteText, setMaterialPasteText] = useState('') - const [materialLinkLoadingId, setMaterialLinkLoadingId] = useState(null) - const [materialLinkFallback, setMaterialLinkFallback] = useState<{ - url: string - expiresAt: string - } | null>(null) + const [materialLinkFallback, setMaterialLinkFallback] = useState<{ url: string } | null>(null) const [sharingOrder, setSharingOrder] = useState(null) const [cancelShare, setCancelShare] = useState(null) const [reduceShare, setReduceShare] = useState(null) @@ -1654,36 +1649,23 @@ export default function WorkOrdersPanel() { } async function copyMaterialLink(order: WorkOrder) { - setMaterialLinkLoadingId(order.workOrderId) - try { - const response = await createAdminWorkOrderMaterialLink(order.workOrderId) - // 新后端返回 platformOrderId;旧后端缺失时回退 token,仅用于前后端滚动发布。 - const platformOrderId = String( - response.data.platformOrderId || response.data.token || '', - ).trim() - if (!platformOrderId) { - message.error('生成补资料链接失败:缺少平台订单号') - return - } - const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId) - if (!url) { - message.error('生成补资料链接失败:链接地址为空') - return - } - const copied = await copyTextToClipboard(url) - const expiresText = formatAdminDateTime(response.data.expiresAt) - if (copied) { - message.success( - `${response.data.reused ? '已复制现有链接' : '已生成并复制链接'}(订单号 ${platformOrderId}),${expiresText} 前有效;该订单号下已生成链接的待补商品会一起显示`, - ) - } else { - setMaterialLinkFallback({ url, expiresAt: response.data.expiresAt }) - } - } catch (error) { - message.error(error instanceof Error ? error.message : '生成补资料链接失败') - } finally { - setMaterialLinkLoadingId(null) + const platformOrderId = String(order.platformOrderId || '').trim() + if (!platformOrderId) { + message.error('复制补资料链接失败:缺少平台订单号') + return } + const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId) + if (!url) { + message.error('复制补资料链接失败:链接地址为空') + return + } + const copied = await copyTextToClipboard(url) + if (copied) { + message.success(`补资料链接已复制(订单号 ${platformOrderId})`) + return + } + // 剪贴板 API 不可用时退回弹窗,让客服手动复制。 + setMaterialLinkFallback({ url }) } const columns: TableColumnsType = [ @@ -1998,9 +1980,8 @@ export default function WorkOrdersPanel() { {row.capabilities?.canUpdateMaterial && canUpdateMaterial && canCopyMaterialLink(row) ? ( @@ -3291,8 +3272,7 @@ export default function WorkOrdersPanel() { {materialLinkFallback ? ( - 同一订单号下已生成链接的多个待补工单会显示在同一个页面,每个工单各自仅可提交一次; - {formatAdminDateTime(materialLinkFallback.expiresAt)} 前有效。 + 该链接按平台订单号打开,可查看该订单号下全部工单的补资料状态;每个工单仅可提交一次。 ('/api/v1/admin/worker-platform/hall-config', payload) } -export function fetchAdminWorkerMaterialLinkConfig() { - return apiGet('/api/v1/admin/worker-platform/material-link-config') -} - -export function saveAdminWorkerMaterialLinkConfig(payload: WorkerMaterialLinkConfig) { - return apiPost( - '/api/v1/admin/worker-platform/material-link-config', - payload, - ) -} - export function fetchAdminWorkerAnnouncement() { return apiGet('/api/v1/admin/worker-platform/announcement') } @@ -769,16 +757,6 @@ export function submitAdminWorkOrderMaterial( ) } -export function createAdminWorkOrderMaterialLink(workOrderId: number) { - return apiPost<{ - token: string - /** 公开 URL 使用的平台订单号;旧后端可能没有该字段,前端仅用于滚动发布兜底。 */ - platformOrderId?: string - expiresAt: string - reused: boolean - }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/material-link`) -} - /** 客服重置补资料:仅「待分配 + 未指派 + 无份额 + 未发布」可重置回待完善。 */ export function resetAdminWorkOrderMaterialStatus(workOrderId: number) { return apiPost<{ order: WorkOrder }>( diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 1fa4c993..f7f8b30b 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -334,11 +334,6 @@ export type WorkerHallConfig = { queuedOrderCount: number } -/** 一次性补资料链接配置:有效期只影响新生成的链接。 */ -export type WorkerMaterialLinkConfig = { - ttlMinutes: number -} - export type WorkerWithdrawalAccount = { accountChannel: string accountName: string From a9e2de443e597430df6dfb164e09ef05731b1e6a Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 2 Nov 2026 10:38:09 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(collect):=20=E8=A1=A5=E8=B5=84?= =?UTF-8?q?=E6=96=99=E6=9F=A5=E8=AF=A2=E4=BB=85=E9=99=90=E5=BE=85=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=B9=B6=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公开查询只返回待完善工单,待分配/已验收等状态提示「该订单当前无需补充资料」 - 404 失败查询按 IP 冷却 2 秒,成功查询不计数;前端显示「查询过于频繁」 - 提交成功后本地展示成功页,刷新后按严格模式提示无需补充资料 - 收敛补资料页重复提示,移除状态标签与工单号,商品与订单号合并展示 - 修复 React StrictMode 下刷新重复请求查询接口的问题 --- .../backend/src/middleware/rate-limit.test.ts | 99 ++++++++++++- apps/backend/src/middleware/rate-limit.ts | 81 +++++++++++ apps/backend/src/routes/collect.ts | 8 ++ .../work-order-material-link-service.test.ts | 18 +++ .../work-order-material-link-service.ts | 25 +++- .../pages/collect/CollectMaterialLinkPage.tsx | 133 +++++++++++------- apps/frontend/src/styles/client.css | 104 +++++++++++--- 7 files changed, 391 insertions(+), 77 deletions(-) diff --git a/apps/backend/src/middleware/rate-limit.test.ts b/apps/backend/src/middleware/rate-limit.test.ts index 14722cd9..bf67040e 100644 --- a/apps/backend/src/middleware/rate-limit.test.ts +++ b/apps/backend/src/middleware/rate-limit.test.ts @@ -2,7 +2,11 @@ import test from 'node:test' import assert from 'node:assert/strict' import type { NextFunction, Request, Response } from 'express' -import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js' +import { + createFailureCooldownMiddleware, + createRateLimitMiddleware, + resetRateLimitBucketsForTest, +} from './rate-limit.js' test('createRateLimitMiddleware allows requests within the window', () => { resetRateLimitBucketsForTest() @@ -96,13 +100,13 @@ test('createRateLimitMiddleware can cap an IP across different accounts', () => assert.equal(response.statusCode, 429) }) -function createMockRequest(): Request { +function createMockRequest(ip = '127.0.0.1'): Request { return { - ip: '127.0.0.1', + ip, originalUrl: '/test', headers: {}, socket: { - remoteAddress: '127.0.0.1', + remoteAddress: ip, }, } as Request } @@ -137,10 +141,97 @@ function createMockResponse(): Response & { } } +type MockResponseWithEvents = Response & { + statusCode: number + headers: Record + body: any + emit: (event: string) => void +} + +function createMockResponseWithFinish(): MockResponseWithEvents { + const listeners: Record void>> = {} + const res = createMockResponse() as MockResponseWithEvents + res.on = (event, handler) => { + const handlers = listeners[event] || [] + handlers.push(handler) + listeners[event] = handlers + return res + } + res.emit = (event: string) => { + for (const handler of listeners[event] || []) handler() + } + return res +} + +test('failure cooldown 只对 404 生效,按 IP 隔离且到期恢复', async () => { + resetRateLimitBucketsForTest() + + const limiter = createFailureCooldownMiddleware({ + scope: 'test:failure', + cooldownMs: 60, + }) + const req = createMockRequest('10.0.0.1') + const first = createMockResponseWithFinish() + let nextCount = 0 + limiter(req, first, () => { + nextCount += 1 + }) + first.status(404).json({ code: 1 }) + first.emit('finish') + + const blocked = createMockResponseWithFinish() + limiter(req, blocked, () => { + nextCount += 1 + }) + assert.equal(nextCount, 1) + assert.equal(blocked.statusCode, 429) + assert.equal(blocked.headers['Retry-After'], '1') + assert.equal(blocked.body.errorCode, 'rate_limited') + + const otherIp = createMockResponseWithFinish() + limiter(createMockRequest('10.0.0.2'), otherIp, () => { + nextCount += 1 + }) + assert.equal(nextCount, 2) + assert.equal(otherIp.statusCode, 200) + + await new Promise((resolve) => setTimeout(resolve, 80)) + + const recovered = createMockResponseWithFinish() + limiter(req, recovered, () => { + nextCount += 1 + }) + assert.equal(nextCount, 3) + assert.equal(recovered.statusCode, 200) +}) + +test('failure cooldown 不记录成功查询', () => { + resetRateLimitBucketsForTest() + + const limiter = createFailureCooldownMiddleware({ + scope: 'test:failure-success', + cooldownMs: 60_000, + }) + const req = createMockRequest('10.0.0.3') + const first = createMockResponseWithFinish() + limiter(req, first, () => {}) + first.status(200).json({ code: 0 }) + first.emit('finish') + + const second = createMockResponseWithFinish() + let called = false + limiter(req, second, () => { + called = true + }) + assert.equal(called, true) + assert.equal(second.statusCode, 200) +}) + test('限流桶有数量硬上限:key 含用户可控字段时不能无限膨胀', async () => { const { readFile } = await import('node:fs/promises') const src = await readFile(new URL('./rate-limit.ts', import.meta.url), 'utf8') // collect 路由的限流 key 里带请求体 orderNo(可被构造),只按过期清理挡不住高基数灌键 assert.ok(src.includes('MAX_RATE_LIMIT_BUCKETS'), '缺少限流桶数量上限') assert.ok(src.includes('buckets.size > MAX_RATE_LIMIT_BUCKETS'), '必须在清理时按上限淘汰最老的桶') + assert.ok(src.includes('MAX_FAILURE_COOLDOWN_KEYS'), '缺少失败冷却 key 数量上限') }) diff --git a/apps/backend/src/middleware/rate-limit.ts b/apps/backend/src/middleware/rate-limit.ts index 1355208c..559a7e3c 100644 --- a/apps/backend/src/middleware/rate-limit.ts +++ b/apps/backend/src/middleware/rate-limit.ts @@ -18,6 +18,14 @@ type RateLimitOptions = { onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void } +type FailureCooldownOptions = { + scope: string + cooldownMs: number + /** 只对这些响应状态码触发冷却,默认 404。 */ + statusCodes?: number[] + key?: RateLimitKeyResolver +} + type RateLimitBucket = { count: number resetAt: number @@ -28,6 +36,11 @@ let lastCleanupAt = 0 /** 限流桶数量硬上限:key 含请求体字段时可被构造成高基数,必须兜底防止内存无限增长。 */ const MAX_RATE_LIMIT_BUCKETS = 50_000 +/** 失败冷却:仅记录指定状态码(默认 404)后的 IP,冷却期内直接拒绝。 */ +const failureCooldowns = new Map() +let lastFailureCleanupAt = 0 +const MAX_FAILURE_COOLDOWN_KEYS = 50_000 + export function createRateLimitMiddleware({ scope, windowMs, @@ -82,9 +95,54 @@ export function createRateLimitMiddleware({ } } +export function createFailureCooldownMiddleware({ + scope, + cooldownMs, + statusCodes = [404], + key = defaultRateLimitKey, +}: FailureCooldownOptions) { + const normalizedScope = String(scope || 'failure-cooldown').trim() || 'failure-cooldown' + const normalizedCooldownMs = Math.max(0, Number(cooldownMs || 0)) + const trackedStatusCodes = new Set(statusCodes.map((statusCode) => Number(statusCode))) + + return (req: Request, res: Response, next: NextFunction): void => { + const now = Date.now() + cleanupExpiredFailureCooldowns(now) + + const bucketKey = `${normalizedScope}:${key(req)}` + const cooldownUntil = failureCooldowns.get(bucketKey) || 0 + if (cooldownUntil > now) { + const retryAfterSeconds = Math.max(1, Math.ceil((cooldownUntil - now) / 1000)) + res.setHeader('Retry-After', String(retryAfterSeconds)) + + const message = '查询过于频繁,请稍后再试' + const error = createHttpError(message, { + statusCode: 429, + errorCode: 'rate_limited', + }) + res.status(429).json(buildErrorPayload(error, message)) + return + } + + // 响应结束后再判断:只有失败(默认 404)才进入冷却,正常查询不计数。 + res.on('finish', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + failureCooldowns.delete(bucketKey) + return + } + if (!trackedStatusCodes.has(Number(res.statusCode))) return + failureCooldowns.set(bucketKey, Date.now() + normalizedCooldownMs) + }) + + next() + } +} + export function resetRateLimitBucketsForTest(): void { buckets.clear() + failureCooldowns.clear() lastCleanupAt = 0 + lastFailureCleanupAt = 0 } export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver { @@ -111,6 +169,29 @@ function normalizeKeyPart(value: unknown): string { return normalized || 'unknown' } +function cleanupExpiredFailureCooldowns(now: number): void { + if (now - lastFailureCleanupAt < 60_000) { + return + } + + lastFailureCleanupAt = now + for (const [key, cooldownUntil] of failureCooldowns.entries()) { + if (cooldownUntil <= now) { + failureCooldowns.delete(key) + } + } + + if (failureCooldowns.size > MAX_FAILURE_COOLDOWN_KEYS) { + const overflow = failureCooldowns.size - MAX_FAILURE_COOLDOWN_KEYS + let removed = 0 + for (const key of failureCooldowns.keys()) { + failureCooldowns.delete(key) + removed += 1 + if (removed >= overflow) break + } + } +} + function cleanupExpiredBuckets(now: number): void { if (now - lastCleanupAt < 60_000) { return diff --git a/apps/backend/src/routes/collect.ts b/apps/backend/src/routes/collect.ts index 21ed5db7..3fabb592 100644 --- a/apps/backend/src/routes/collect.ts +++ b/apps/backend/src/routes/collect.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { + createFailureCooldownMiddleware, createRateLimitMiddleware, getBodyFieldRateLimitKey, getParamRateLimitKey, @@ -39,6 +40,12 @@ const materialLinkReadIpRateLimit = createRateLimitMiddleware({ max: 300, }) +/** 查询失败(404)后按 IP 冷却 2 秒;正常查询不计数,避免误伤用户。 */ +const materialLinkReadFailureCooldown = createFailureCooldownMiddleware({ + scope: 'collect:link:read:fail:ip', + cooldownMs: 2_000, +}) + const materialLinkWriteRateLimit = createRateLimitMiddleware({ scope: 'collect:link:write', windowMs: 60_000, @@ -108,6 +115,7 @@ router.post( router.get( '/link/:code', + materialLinkReadFailureCooldown, materialLinkReadIpRateLimit, materialLinkReadRateLimit, createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), { diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts index b17da133..6a894ef9 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts @@ -3,10 +3,12 @@ import test from 'node:test' import { formatPublicMaterialStatus, + pickPendingMaterialOrders, pickSubmittedMaterialFields, resolveMaterialResetBlockReason, resolvePublicMaterialOrderState, } from './work-order-material-link-service.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' test('公开补资料:只有待完善且未指派/无份额可提交', () => { const editable = resolvePublicMaterialOrderState({ @@ -128,6 +130,22 @@ test('客服重置补资料:只允许待分配、未指派、无份额、未 ) }) +test('公开补资料:只查询待完善工单,其他状态一律不返回', () => { + const orders = [ + { status: 'pending_material' }, + { status: 'unassigned' }, + { status: 'open' }, + { status: 'accepted' }, + { status: 'cancelled' }, + ] as WorkOrderRow[] + + assert.deepEqual( + pickPendingMaterialOrders(orders).map((order) => order.status), + ['pending_material'], + ) + assert.deepEqual(pickPendingMaterialOrders([]), []) +}) + test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => { const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], { gameId: '123', diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts index 6ae31497..3c310555 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -232,12 +232,21 @@ async function resolvePublicMaterialTarget( return orders[0]! } +/** + * 公开页只允许查询待完善工单。 + * + * 待分配、待抢单、代练中、已验收、已取消等状态一律不返回, + * 避免客户用订单号看到已完成订单的商品与状态。 + */ +export function pickPendingMaterialOrders(orders: WorkOrderRow[]): WorkOrderRow[] { + return orders.filter((order) => order.status === WORK_ORDER_STATUS.PENDING_MATERIAL) +} + /** * 公开页按平台订单号读取补资料内容。 * - * 不再要求客服先生成链接;只要订单号存在,就返回该订单号下所有工单的 - * 状态与准入信息。只有可提交的待完善工单返回字段/已填值/截图,其他状态 - * 只返回商品、工单号、状态和客服提示。 + * 不再要求客服先生成链接;订单号存在且仍有待完善工单时才返回内容。 + * 只返回待完善工单;已提交(待分配)及其他状态统一提示无需补充资料。 */ export async function getPublicWorkOrderMaterialLink(platformOrderId: string) { const code = String(platformOrderId || '').trim() @@ -256,7 +265,15 @@ export async function getPublicWorkOrderMaterialLink(platformOrderId: string) { }) } - const publicOrders = orders.map(mapPublicMaterialOrder) + const pendingOrders = pickPendingMaterialOrders(orders) + if (pendingOrders.length === 0) { + throw createHttpError('该订单当前无需补充资料', { + statusCode: 404, + errorCode: 'material_link_not_pending', + }) + } + + const publicOrders = pendingOrders.map(mapPublicMaterialOrder) return { platformOrderId: code, orders: publicOrders, diff --git a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx index b83a462d..01f72948 100644 --- a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx +++ b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx @@ -1,4 +1,4 @@ -import { LoadingOutlined } from '@ant-design/icons' +import { CheckCircleFilled, LoadingOutlined } from '@ant-design/icons' import { Alert, App, @@ -47,33 +47,28 @@ function resolveErrorTitle(code: string): string { if (code === 'material_link_not_found' || code === 'material_link_work_order_invalid') { return '订单号无效' } + if (code === 'material_link_not_pending') return '该订单当前无需补充资料' + if (code === 'rate_limited') return '查询过于频繁' if (code === 'material_link_work_order_required') return '请选择要补充的商品' if (code === 'material_link_order_status_invalid') return '订单状态已变化' return '暂时无法打开页面' } -const STATUS_TAG_COLORS: Record = { - pending_material: 'orange', - unassigned: 'blue', - open: 'cyan', - in_progress: 'processing', - pending_acceptance: 'purple', - problem: 'red', - accepted: 'green', - cancelled: 'default', -} - -function resolveStatusColor(status: string): string { - return STATUS_TAG_COLORS[status] || 'default' +/** 公开页不展示「待分配」字样:已提交统一用「资料已提交」表达。 */ +function resolveMaterialOrderMessage(order: CollectMaterialLinkOrder): string { + if (order.blockedReason === 'already_submitted') { + return '资料已提交;如需修改请联系客服。' + } + return order.message || '当前不能修改资料,如需修改请联系客服。' } /** * 客户侧补资料页。 * - * 只要知道平台订单号就能打开:一个订单号下可能有多个商品工单,页面按 orders - * 渲染多张卡片。只有「待完善 + 未指派 + 无份额」的工单返回表单,其余工单只展示 - * 商品、工单号、状态与客服提示,不暴露已填资料和截图。 - * 提交成功后工单进入「待分配」,同一工单不能再提交,需联系客服重置。 + * 只查询「待完善」工单;其他状态(待分配、已验收等)统一提示无需补充资料, + * 不展示商品、订单号等信息。页面按 orders 渲染多张卡片,只有未指派、无份额的 + * 待完善工单返回表单。提交成功后本地展示成功页;刷新后因工单已变为待分配, + * 会提示「该订单当前无需补充资料」。 */ export default function CollectMaterialLinkPage() { const { message } = App.useApp() @@ -88,7 +83,11 @@ export default function CollectMaterialLinkPage() { const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0) const submittingRef = useRef(false) const requestSeqRef = useRef(0) + /** React StrictMode 开发环境会重复执行 mount effect,这里按订单号去重,避免刷新时打两次查询接口。 */ + const loadedCodeRef = useRef(null) const [screenshotsByOrder, setScreenshotsByOrder] = useState>({}) + const [submittedSuccess, setSubmittedSuccess] = useState(false) + const [submittedNames, setSubmittedNames] = useState([]) /** * 读取订单资料状态。 @@ -101,6 +100,13 @@ export default function CollectMaterialLinkPage() { const seq = requestSeqRef.current + 1 requestSeqRef.current = seq + if (!options.silent) { + // 新订单号/刷新页面时清空上一次提交成功状态;提交后不再回查 + // (严格模式只允许查询待完善订单,回查会变成「无需补充资料」)。 + setSubmittedSuccess(false) + setSubmittedNames([]) + } + if (!code) { setInfo(null) setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE }) @@ -145,8 +151,10 @@ export default function CollectMaterialLinkPage() { ) useEffect(() => { + if (loadedCodeRef.current === code) return + loadedCodeRef.current = code void loadInfo() - }, [loadInfo]) + }, [code, loadInfo]) async function handleSubmit(order: CollectMaterialLinkOrder, values: Record) { if (!code) return @@ -160,8 +168,19 @@ export default function CollectMaterialLinkPage() { fields: values, screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [], }) - message.success('资料已提交,订单已进入待分配') - await loadInfo({ silent: true }) + message.success('提交成功') + const submittedName = String(order.productName || '').trim() || '订单商品' + setSubmittedNames((previous) => [...previous, submittedName]) + const remainingOrders = (info?.orders || []).filter( + (item) => item.workOrderId !== order.workOrderId, + ) + if (remainingOrders.length > 0) { + await loadInfo({ silent: true }) + } else { + // 严格模式只查询待完善订单:最后一个商品提交后不再回查, + // 直接本地展示成功页,避免刷新成「无需补充资料」。 + setSubmittedSuccess(true) + } } catch (error) { const errorCode = resolveErrorCode(error) message.error(resolveErrorMessage(error)) @@ -181,6 +200,9 @@ export default function CollectMaterialLinkPage() { } } + const emptyStateMessage = '该订单当前不可修改资料,如需修改请联系客服。' + const submittedProductNames = submittedNames.join('、') + return (
@@ -191,56 +213,63 @@ export default function CollectMaterialLinkPage() { ) : loadError ? ( } /> + ) : submittedSuccess ? ( +
+ + + 提交成功 + + 资料已提交;如需修改请联系客服。 +
+ 本次提交 + {submittedProductNames || '订单资料'} +
+ + 本页面仅用于补充订单资料,请妥善保管订单号。 + +
) : info ? ( -
- - 订单资料补充 - - - {info.pendingCount > 0 - ? '请补充以下资料,提交后客服会尽快处理。' - : '以下为该订单当前的资料状态,如需修改请联系客服。'} - -
+ {info.pendingCount > 0 ? ( + 请补充以下资料,提交后客服会尽快处理。 + ) : null} {info.pendingCount > 1 ? ( ) : null} {info.pendingCount === 0 ? ( - + ) : null} {info.orders.map((order) => ( - {order.statusLabel}} - > + -
-
- 工单号 - {order.workOrderNo || '-'} +
+
+ {order.productName || '订单商品'}
-
+
订单号 {order.platformOrderId || '-'}
@@ -253,7 +282,7 @@ export default function CollectMaterialLinkPage() { type="warning" showIcon message="该商品资料仅可提交一次" - description="提交成功后该商品进入待分配,不能再修改;如需修改请联系客服。" + description="提交成功后不能再修改;如需修改请联系客服。" /> ) : null} @@ -274,13 +303,13 @@ export default function CollectMaterialLinkPage() { onSubmit={(values) => handleSubmit(order, values)} /> - ) : ( + ) : info.pendingCount > 0 ? ( - )} + ) : null} ))} diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index 373a1d6c..ab5b65d8 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -508,30 +508,42 @@ margin: 0 0 4px !important; } -.collect-link-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 20px; - padding: 14px 16px; - background: #f8fafc; - border-radius: 10px; +/* 商品名与订单号放在同一个信息头里,避免标题和灰色信息块分离 */ +.collect-link-order-head { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 6px 20px; + padding-bottom: 12px; + border-bottom: 1px solid #eef2f7; } -.collect-link-meta > div { +.collect-link-order-name { min-width: 0; display: flex; - flex-direction: column; - gap: 2px; + align-items: center; + flex-wrap: wrap; + gap: 8px; + color: #0f172a; + font-size: 16px; + font-weight: 700; + overflow-wrap: anywhere; } -.collect-link-meta span { +.collect-link-order-no { + min-width: 0; + display: flex; + align-items: baseline; + gap: 6px; color: #64748b; font-size: 12px; } -.collect-link-meta strong { +.collect-link-order-no strong { color: #0f172a; font-size: 14px; + font-weight: 600; overflow-wrap: anywhere; } @@ -544,6 +556,56 @@ gap: 16px; } +/* 提交成功:居中的成功状态,不再展示订单号 */ +.collect-link-success { + min-height: 380px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px 12px 8px; + text-align: center; +} + +.collect-link-success-icon { + font-size: 64px; + color: #22c55e; + filter: drop-shadow(0 10px 20px rgba(34, 197, 94, 0.18)); +} + +.collect-link-success-title { + margin: 2px 0 0 !important; + color: #0f172a; +} + +.collect-link-success-products { + max-width: 100%; + margin-top: 10px; + padding: 12px 22px; + border: 1px solid #dcfce7; + border-radius: 12px; + background: #f0fdf4; + display: flex; + flex-direction: column; + gap: 4px; +} + +.collect-link-success-products span { + color: #16a34a; + font-size: 12px; +} + +.collect-link-success-products strong { + color: #166534; + font-size: 15px; + overflow-wrap: anywhere; +} + +.collect-link-success .collect-link-footnote { + margin-top: 18px; +} + .collect-link-actions { display: flex; justify-content: flex-end; @@ -569,11 +631,6 @@ padding: 18px; } - .collect-link-meta { - grid-template-columns: 1fr; - gap: 8px; - } - .collect-link-actions { justify-content: stretch; } @@ -591,6 +648,19 @@ .collect-link-card .ant-card-body { padding: 14px; } + + .collect-link-success { + min-height: 320px; + padding: 16px 4px 4px; + } + + .collect-link-success-icon { + font-size: 52px; + } + + .collect-link-success-products { + padding: 10px 16px; + } } /* ===== 兑换中心 (Redeem Page) ===== */ From db3f477ab393fac79289227cf9807fbd58c3cbda Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 2 Nov 2026 12:02:24 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(worker):=20=E6=95=B4=E5=8D=95=E6=8C=87?= =?UTF-8?q?=E6=B4=BE=E6=8B=BC=E5=8D=95=E8=AE=A2=E5=8D=95=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端:拼单订单整单指派给本人时放行验收 - 后端:整单指派清掉 sharing_enabled;已有有效份额则拒绝 --- .../worker-platform/work-order-grab-repo.ts | 17 +++++ .../admin-work-order-assignment-service.ts | 22 ++++++ .../src/pages/worker/WorkerOrdersPage.tsx | 14 +++- .../src/utils/worker-order-acceptance.ts | 24 ++++++ .../tests/worker-order-acceptance.test.ts | 76 +++++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/utils/worker-order-acceptance.ts create mode 100644 apps/frontend/tests/worker-order-acceptance.test.ts diff --git a/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts index cff7767b..9f21b15f 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts @@ -143,6 +143,7 @@ export async function assignWorkOrderToWorker(input: { order: WorkOrderRow | null failureReason: | 'work_order_not_assignable' + | 'work_order_has_shares' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null @@ -167,6 +168,19 @@ export async function assignWorkOrderToWorker(input: { } const fromStatus = workOrder.status + // 已有拼单份额的订单不能按整单指派:否则会出现“整单接单人 + 其他人的份额”混在一起。 + const shareCountResult = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status <> 'cancelled' + `, + [input.workOrderId], + ) + if (Number(shareCountResult.rows[0]?.total || 0) > 0) { + return { order: null, failureReason: 'work_order_has_shares' } + } + await ensureWorkerWalletWithClient(client, input.workerId, input.now) const wallet = await getWorkerWalletWithClient(client, input.workerId) const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) @@ -193,6 +207,9 @@ export async function assignWorkOrderToWorker(input: { friend_added_at = NULL, gift_available_at = NULL, ${CLEAR_DELIVERY_RESIDUE_SQL}, + -- 管理端/寄卖履约都是整单指派,不会创建 work_order_shares; + -- 必须同时清掉拼单标记,避免出现“已开拼单但没有份额”的脏状态。 + sharing_enabled = false, updated_at = $2 WHERE id = $4 `, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts index 13190bcf..0038ee98 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts @@ -4,6 +4,7 @@ import { countWorkerActiveOrders, createWorkOrderEvent, getWorkerUserById, + listWorkOrderShares, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -64,6 +65,18 @@ export async function assignAdminWorkOrderToWorker( }) } + // 已有拼单份额的订单不能再按整单指派;放在核销电子凭证之前, + // 避免“凭证已核销但指派失败”的副作用。 + const activeShares = (await listWorkOrderShares(workOrder.id)).filter( + (share) => share.status !== 'cancelled', + ) + if (activeShares.length > 0) { + throw createHttpError('该订单已有拼单份额,不能按整单指派;请使用拼单管理', { + statusCode: 409, + errorCode: 'work_order_has_shares', + }) + } + const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderAssign(workOrder) if (!voucherConsume.ok) { const failedAt = nowIso() @@ -112,6 +125,15 @@ export async function assignAdminWorkOrderToWorker( { statusCode: 409, errorCode: 'worker_deposit_insufficient' }, ) } + if (failureReason === 'work_order_has_shares') { + throw createHttpError( + resolveAssignFailureMessage( + voucherConsume.voucherCount, + '该订单已有拼单份额,不能按整单指派;请使用拼单管理', + ), + { statusCode: 409, errorCode: 'work_order_has_shares' }, + ) + } if (!updated) { throw createHttpError( resolveAssignFailureMessage(voucherConsume.voucherCount, '指派失败,订单状态可能已变化'), diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 21f32d75..4f1b1094 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -24,7 +24,9 @@ import type { UploadedFile, WorkOrder, WorkerAcceptanceVideo } from '@/types/wor import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { formatDateTime } from '@/utils/date-time' import { useIsMobile } from '@/utils/use-is-mobile' +import { shouldBlockAcceptanceForMissingShares } from '@/utils/worker-order-acceptance' import { buildWorkerOrderQuery } from '@/utils/worker-order-query' +import { getWorkerId } from '@/utils/worker-auth' import { WorkerOrderDesktopList } from './WorkerOrderDesktopList' import { WorkerOrderDetailDrawer } from './WorkerOrderDetailDrawer' import { WorkerOrderMobileList } from './WorkerOrderMobileList' @@ -324,8 +326,16 @@ export default function WorkerOrdersPage() { function openAcceptanceModal(order: WorkOrder) { const shares = getOrderShares(order) - // 整单(非拼单)本来就没有份额,不能拦;只有拼单订单缺份额时才提示刷新。 - if (shares.length === 0 && order.sharing?.enabled === true) { + // 整单(非拼单)本来就没有份额,不能拦;拼单订单被管理端整单指派给本人时, + // 后端也会按整单验收,同样不能拦。只有“拼单缺份额且不是指派给本人”才提示刷新。 + if ( + shouldBlockAcceptanceForMissingShares({ + shareCount: shares.length, + sharingEnabled: order.sharing?.enabled === true, + assignedWorkerId: order.worker?.workerId, + currentWorkerId: getWorkerId(), + }) + ) { message.warning('未找到可验收的拼单份额,请刷新后重试') return } diff --git a/apps/frontend/src/utils/worker-order-acceptance.ts b/apps/frontend/src/utils/worker-order-acceptance.ts new file mode 100644 index 00000000..0a7d3bd5 --- /dev/null +++ b/apps/frontend/src/utils/worker-order-acceptance.ts @@ -0,0 +1,24 @@ +/** + * 打手验收前置判断:拼单订单缺份额时是否应该拦截。 + * + * 管理端「指派打手」是整单指派,不会创建 work_order_shares; + * 这类订单虽然 sharing.enabled=true,但接单人就是当前打手, + * 必须按整单验收放行,否则会被前端防呆误拦。 + */ +export type AcceptanceShareGuardInput = { + shareCount: number + sharingEnabled: boolean + assignedWorkerId: number | null | undefined + currentWorkerId: number +} + +export function shouldBlockAcceptanceForMissingShares(input: AcceptanceShareGuardInput): boolean { + if (input.shareCount > 0) return false + if (input.sharingEnabled !== true) return false + + const currentWorkerId = Number(input.currentWorkerId || 0) + // 本地会话里拿不到打手 ID 时保持旧行为,避免误放行。 + if (!Number.isFinite(currentWorkerId) || currentWorkerId <= 0) return true + + return Number(input.assignedWorkerId || 0) !== currentWorkerId +} diff --git a/apps/frontend/tests/worker-order-acceptance.test.ts b/apps/frontend/tests/worker-order-acceptance.test.ts new file mode 100644 index 00000000..51137217 --- /dev/null +++ b/apps/frontend/tests/worker-order-acceptance.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { shouldBlockAcceptanceForMissingShares } from '../src/utils/worker-order-acceptance.ts' + +test('拼单缺份额:整单指派给当前打手时放行', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: 1013, + currentWorkerId: 1013, + }), + false, + ) +}) + +test('拼单缺份额:指派给其他打手时拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: 999, + currentWorkerId: 1013, + }), + true, + ) +}) + +test('拼单缺份额:没有接单人时拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: null, + currentWorkerId: 1013, + }), + true, + ) +}) + +test('有份额时按正常份额流程处理,不拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 1, + sharingEnabled: true, + assignedWorkerId: 1013, + currentWorkerId: 1013, + }), + false, + ) +}) + +test('非拼单整单没有份额,不拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: false, + assignedWorkerId: 1013, + currentWorkerId: 1013, + }), + false, + ) +}) + +test('本地取不到当前打手 ID 时保持旧行为(拦截)', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: 1013, + currentWorkerId: 0, + }), + true, + ) +})