From 15956e704f74c8a91fe1cd3c87c6ca497fb3cf55 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 10:46:31 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(work-order):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=E8=A1=A5=E8=B5=84=E6=96=99=E9=93=BE?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/reconcile-103-108-history.sql | 48 +++ apps/backend/src/config/app-config-keys.ts | 2 + .../109_work_order_material_link.sql | 54 +++ .../src/repositories/worker-platform/index.ts | 1 + .../work-order-material-link-repo.ts | 167 ++++++++ .../routes/admin/worker-platform/config.ts | 28 ++ .../routes/admin/worker-platform/orders.ts | 31 ++ apps/backend/src/routes/collect.ts | 76 +++- .../src/services/worker-platform/index.ts | 2 + .../work-order-material-link-service.test.ts | 74 ++++ .../work-order-material-link-service.ts | 397 ++++++++++++++++++ ...orker-material-link-config-service.test.ts | 41 ++ .../worker-material-link-config-service.ts | 62 +++ apps/frontend/src/App.tsx | 28 ++ .../src/components/files/ImageUpload.tsx | 18 +- .../pages/admin/panels/AnnouncementPanel.tsx | 2 + .../admin/panels/MaterialLinkConfigPanel.tsx | 74 ++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 112 +++++ .../pages/collect/CollectMaterialLinkPage.tsx | 294 +++++++++++++ .../src/services/admin/worker-platform.ts | 18 + apps/frontend/src/services/files.ts | 18 + apps/frontend/src/services/worker.ts | 19 + apps/frontend/src/styles/client.css | 108 +++++ apps/frontend/src/types/worker-platform.ts | 29 ++ apps/frontend/src/utils/collect-link.ts | 37 ++ apps/frontend/tests/collect-link.test.ts | 32 ++ 26 files changed, 1768 insertions(+), 4 deletions(-) create mode 100644 apps/backend/scripts/reconcile-103-108-history.sql create mode 100644 apps/backend/src/db/migrations/109_work_order_material_link.sql create mode 100644 apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-material-link-service.ts create mode 100644 apps/backend/src/services/worker-platform/worker-material-link-config-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/worker-material-link-config-service.ts create mode 100644 apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx create mode 100644 apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx create mode 100644 apps/frontend/src/utils/collect-link.ts create mode 100644 apps/frontend/tests/collect-link.test.ts diff --git a/apps/backend/scripts/reconcile-103-108-history.sql b/apps/backend/scripts/reconcile-103-108-history.sql new file mode 100644 index 00000000..9a7e3bc0 --- /dev/null +++ b/apps/backend/scripts/reconcile-103-108-history.sql @@ -0,0 +1,48 @@ +-- 旧 103–112 → 当前 103–108 迁移历史校准。 +-- +-- 适用场景:数据库已经执行过旧迁移名 +-- 103_product_catalog / 104_cost_center / 105_sales_daily_facts / +-- 106_order_refunds / 107_manual_fulfillment / 108_finance_cost_integrity / +-- 109_work_order_share_participation / 110_work_order_hall_and_shop_indexes / +-- 111_work_order_share_cancel_reason_comment / 112_consignment_contact_supporter +-- 但当前代码已经把 103–108 收敛成新的幂等迁移文件。 +-- +-- 新库不需要执行本脚本,直接按当前迁移文件跑即可。 +-- 执行本脚本后,请运行 npm run db:migrate,让 node-pg-migrate 应用当前 103–108。 +-- +-- 注意:执行前请先做全量数据库备份。本脚本只清理 schema_migrations 元数据, +-- 不删业务表;当前 103–108 迁移文件本身保持幂等,重复执行不会重复建对象。 + +BEGIN; + +-- 保留一份执行前的迁移记录,便于核对;已存在时不覆盖。 +CREATE TABLE IF NOT EXISTS schema_migrations_backup_20261031_before_103 + AS SELECT * FROM schema_migrations; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM schema_migrations WHERE name = '103_product_catalog' + ) THEN + RAISE NOTICE '未检测到旧 103_product_catalog,跳过迁移历史校准'; + RETURN; + END IF; + + DELETE FROM schema_migrations + WHERE name IN ( + '103_product_catalog', + '104_cost_center', + '105_sales_daily_facts', + '106_order_refunds', + '107_manual_fulfillment', + '108_finance_cost_integrity', + '109_work_order_share_participation', + '110_work_order_hall_and_shop_indexes', + '111_work_order_share_cancel_reason_comment', + '112_consignment_contact_supporter' + ); + + RAISE NOTICE '已清理旧 103–112 迁移记录,请执行 npm run db:migrate 应用当前 103–108'; +END $$; + +COMMIT; diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 9be4b937..2b2e71af 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -9,6 +9,8 @@ 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/109_work_order_material_link.sql b/apps/backend/src/db/migrations/109_work_order_material_link.sql new file mode 100644 index 00000000..1b592839 --- /dev/null +++ b/apps/backend/src/db/migrations/109_work_order_material_link.sql @@ -0,0 +1,54 @@ +-- 109_work_order_material_link.sql +-- 接单工单「一次性补资料链接」。 +-- +-- 背景:客服在接单平台给待完善/未分配的工单生成一条客户可访问的链接, +-- 客户无需登录即可补充资料字段与买家主页截图;成功提交一次后链接立即失效, +-- 工单转为「未分配」,需要修改时必须由客服重新生成链接。 +-- +-- 独立于 claim_tokens:claim_tokens 强绑定 fulfillment_tasks, +-- 而这里绑定 work_orders,生命周期与补资料动作一致。 + +CREATE TABLE IF NOT EXISTS work_order_material_links ( + id BIGSERIAL PRIMARY KEY, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'active', + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT work_order_material_links_status_check + CHECK (status IN ('active', 'used', 'expired', 'revoked')) +); + +-- 兼容极端情况下先建表但约束未建成功的库(正常新库会走上面的内联约束)。 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'work_order_material_links_status_check' + ) THEN + ALTER TABLE work_order_material_links + ADD CONSTRAINT work_order_material_links_status_check + CHECK (status IN ('active', 'used', 'expired', 'revoked')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_work_order_material_links_order + ON work_order_material_links (work_order_id, created_at DESC); + +-- 一个工单同时最多一条未使用链接;重新生成前先把旧 active 置为 revoked。 +CREATE UNIQUE INDEX IF NOT EXISTS idx_work_order_material_links_active + ON work_order_material_links (work_order_id) + WHERE status = 'active'; + +COMMENT ON TABLE work_order_material_links IS + '接单工单一次性补资料链接:客户凭 token 提交一次资料后失效'; +COMMENT ON COLUMN work_order_material_links.token IS + 'URL 安全随机 token;只在生成/复制链接时返回给后台'; +COMMENT ON COLUMN work_order_material_links.status IS + 'active 可用 / used 已提交 / expired 已过期 / revoked 已撤销'; +COMMENT ON COLUMN work_order_material_links.created_by IS + '生成链接的后台操作人展示名,用于审计'; diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index 77730d14..8d396dc2 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -17,6 +17,7 @@ export * from './work-order-deposit-query-repo.js' export * from './work-order-deposit-repo.js' export * from './work-order-event-repo.js' export * from './work-order-share-repo.js' +export * from './work-order-material-link-repo.js' export * from './work-category-repo.js' export * from './work-product-rule-repo.js' export * from './worker-order-note-repo.js' 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 new file mode 100644 index 00000000..c8ffc530 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts @@ -0,0 +1,167 @@ +import type { PoolClient } from 'pg' + +import { query } from '../../db/client.js' +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 +} + +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 +} + +export async function getWorkOrderForUpdateWithClient( + client: PoolClient, + workOrderId: number | string, +): Promise { + const result = await client.query( + 'SELECT * FROM work_orders WHERE 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, + 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 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' + 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: { + workOrderId: number | string + status: string + materialJson: string + now: string + }, +): Promise { + const result = await client.query( + `UPDATE work_orders + SET status = $2, material_json = $3::jsonb, updated_at = $4 + WHERE id = $1 + RETURNING *`, + [Number(input.workOrderId), input.status, input.materialJson, input.now], + ) + return result.rows[0] || null +} diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index fdacc2c2..901a88f5 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -13,6 +13,7 @@ import { getAdminWorkerPlatformNotificationConfig, getAdminWorkerPlatformSummary, getAdminWorkerProductMatchConfig, + getWorkerMaterialLinkConfig, listAdminKuaishouMatchSources, listAdminWorkCategories, listAdminWorkProductMatchLogs, @@ -29,6 +30,7 @@ import { saveAdminWorkerLevel, saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerProductMatchConfig, + saveWorkerMaterialLinkConfig, syncAdminWorkProductRulePrice, testAdminKuaishouProductMatch, testAdminProductMatchByOrderItem, @@ -293,6 +295,32 @@ 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 53266cb7..6b09f655 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -9,6 +9,7 @@ import { completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriendAdded, createAdminMockWorkOrder, + createAdminWorkOrderMaterialLink, deleteAdminMockCustomShop, deleteAdminWorkOrder, getAdminWorkOrderEvents, @@ -181,6 +182,36 @@ 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.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 d46a86c4..b13354b9 100644 --- a/apps/backend/src/routes/collect.ts +++ b/apps/backend/src/routes/collect.ts @@ -1,9 +1,16 @@ import { Router } from 'express' -import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js' +import { + createRateLimitMiddleware, + getBodyFieldRateLimitKey, + getParamRateLimitKey, +} from '../middleware/rate-limit.js' import { collectLookupWorkOrder, collectSubmitWorkOrder, + getPublicWorkOrderMaterialLink, + getPublicWorkOrderMaterialLinkUploadContext, + submitPublicWorkOrderMaterialLink, } from '../services/worker-platform/index.js' import { uploadFileAsset } from '../services/file-storage/file-storage-service.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' @@ -18,6 +25,27 @@ const collectRateLimit = createRateLimitMiddleware({ key: getBodyFieldRateLimitKey('orderNo'), }) +const materialLinkReadRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:read', + windowMs: 60_000, + max: 120, + key: getParamRateLimitKey('token'), +}) + +const materialLinkWriteRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:write', + windowMs: 60_000, + max: 20, + key: getParamRateLimitKey('token'), +}) + +const materialLinkUploadRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:upload', + windowMs: 60_000, + max: 30, + key: getParamRateLimitKey('token'), +}) + router.post( '/lookup', collectRateLimit, @@ -59,6 +87,52 @@ router.post( ), ) +router.get( + '/link/:token', + materialLinkReadRateLimit, + createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.token || '')), { + errorMessage: '读取补资料链接失败', + scope: '[collect/link/:token]', + }), +) + +router.post( + '/link/:token/submit', + materialLinkWriteRateLimit, + createRouteHandler( + (req) => submitPublicWorkOrderMaterialLink(String(req.params.token || ''), req.body || {}), + { + successMessage: '资料已提交', + errorMessage: '提交补资料失败', + scope: '[collect/link/:token/submit]', + }, + ), +) + +router.post( + '/link/:token/upload', + materialLinkUploadRateLimit, + uploadSingleFile, + createRouteHandler( + async (req) => { + const { order } = await getPublicWorkOrderMaterialLinkUploadContext( + String(req.params.token || ''), + ) + return uploadFileAsset({ + file: req.file, + scene: req.body?.scene || 'collect-material', + uploaderType: 'collect', + uploaderId: `work-order-${order.id}`, + }) + }, + { + successMessage: '文件已上传', + errorMessage: '文件上传失败', + scope: '[collect/link/:token/upload]', + }, + ), +) + router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)) }) diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index f0b60fd8..5374b17a 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -63,3 +63,5 @@ export * from './worker-hall-config-service.js' 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 new file mode 100644 index 00000000..2d819c1f --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + resolveWorkOrderMaterialLinkUnavailableReason, + pickSubmittedMaterialFields, +} from './work-order-material-link-service.js' + +const NOW = '2026-10-31T12:00:00.000Z' + +test('补资料链接:active 且未过期时可用', () => { + assert.equal( + resolveWorkOrderMaterialLinkUnavailableReason( + { status: 'active', expires_at: '2026-10-31T13:00:00.000Z' }, + NOW, + ), + '', + ) +}) + +test('补资料链接:active 但已过期按过期处理', () => { + assert.equal( + resolveWorkOrderMaterialLinkUnavailableReason({ status: 'active', expires_at: NOW }, NOW), + 'expired', + ) +}) + +test('补资料链接:used / expired / revoked 分别返回对应原因', () => { + assert.equal( + resolveWorkOrderMaterialLinkUnavailableReason( + { status: 'used', expires_at: '2026-10-31T13:00:00.000Z' }, + NOW, + ), + 'used', + ) + assert.equal( + resolveWorkOrderMaterialLinkUnavailableReason( + { status: 'expired', expires_at: '2026-10-31T13:00:00.000Z' }, + NOW, + ), + 'expired', + ) + assert.equal( + resolveWorkOrderMaterialLinkUnavailableReason( + { status: 'revoked', expires_at: '2026-10-31T13:00:00.000Z' }, + NOW, + ), + 'revoked', + ) +}) + +test('补资料链接:未知状态一律视为失效,避免误放行', () => { + assert.equal( + resolveWorkOrderMaterialLinkUnavailableReason( + { + status: 'unknown' as never, + expires_at: '2026-10-31T13:00:00.000Z', + }, + NOW, + ), + 'revoked', + ) +}) + +test('补资料链接:提交字段只保留 requirements 白名单,额外字段被丢弃', () => { + const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], { + gameId: '123', + gameNickname: '张三', + rewardAmount: '999', + status: 'accepted', + }) + + assert.deepEqual(result, { 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 new file mode 100644 index 00000000..6b31c569 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -0,0 +1,397 @@ +import { randomBytes } from 'node:crypto' + +import { withTransaction } from '../../db/client.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + createWorkOrderEventWithClient, + createWorkOrderMaterialLinkWithClient, + findWorkOrderMaterialLinkByToken, + findActiveWorkOrderMaterialLinkWithClient, + getWorkOrderForUpdateWithClient, + getWorkOrderMaterialLinkForUpdateWithClient, + markWorkOrderMaterialLinkExpired, + markWorkOrderMaterialLinkUsedWithClient, + 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' +import { createHttpError } from '../../utils/http.js' +import { logWarn } from '../../utils/logger.js' +import { nowIso } from '../../utils/time.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' +import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { + mapCollectLookupOrder, + mapWorkOrderAdmin, + 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 + +export type WorkOrderMaterialLinkUnavailableReason = 'used' | 'expired' | 'revoked' | '' + +/** + * 判断链接是否还能用。 + * + * 只做纯函数判断,方便单测覆盖;数据库状态与过期时间都由调用方传入。 + */ +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' + return '' +} + +/** + * 只保留工单 requirement 里定义过的字段。 + * + * 公开链接的提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的 + * 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。 + */ +export function pickSubmittedMaterialFields( + fields: Array<{ key: string }>, + submitted: Record, +): Record { + const allowedKeys = new Set(fields.map((field) => field.key)) + 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', + }) + } + if (reason === 'expired') { + throw createHttpError('链接已过期,请联系客服重新获取', { + statusCode: 410, + errorCode: 'material_link_expired', + }) + } + throw createHttpError('链接已失效,请联系客服重新获取', { + statusCode: 410, + errorCode: 'material_link_revoked', + }) +} + +/** + * 公开链接只对「待完善 / 未分配」开放,且不能已经指派打手或有进行中的拼单份额。 + * 这三个条件同时满足,提交后才可以安全地把状态改成「未分配」。 + */ +function assertWorkOrderAllowedForMaterialLink(order: WorkOrderRow): void { + if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) { + throw createHttpError('订单当前状态不允许补资料,请联系客服重新获取链接', { + statusCode: 409, + errorCode: 'material_link_order_status_invalid', + }) + } + if (order.assigned_worker_id) { + throw createHttpError('订单已指派打手,不能通过公开链接修改资料', { + statusCode: 409, + errorCode: 'material_link_order_status_invalid', + }) + } + if (Number(order.active_share_quantity || 0) > 0) { + throw createHttpError('订单已有拼单份额,不能通过公开链接修改资料', { + statusCode: 409, + errorCode: 'material_link_order_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, + platformOrderId: order.platform_order_id, + productName: order.product_name, + status: order.status, + } +} + +/** 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() +} + +/** + * 管理端生成/复用一次性链接。 + * + * 幂等策略:同一工单已有未过期 active 链接时直接返回,避免客服连续点两次 + * 把刚发出去的链接顶掉;只有已使用/已过期/已撤销时才生成新 token。 + */ +export async function createAdminWorkOrderMaterialLink( + workOrderId: number | string, + actorName = '', +) { + const now = nowIso() + 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', + }) + } + assertWorkOrderAllowedForMaterialLink(order) + + const existing = await findActiveWorkOrderMaterialLinkWithClient(client, order.id, now) + if (existing) { + return { + token: existing.token, + 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, + expiresAt: toIsoTimestamp(link.expires_at), + reused: false, + } + }) +} + +/** + * 公开页读取链接内容。 + * + * 只返回客户补资料需要的最少信息:商品/单号、动态字段、已填值、截图、过期时间。 + * 不返回发单价格、押金、成本、打手、后台备注等任何后台字段。 + */ +export async function getPublicWorkOrderMaterialLink(token: string) { + const link = await findWorkOrderMaterialLinkByToken(token) + if (!link) { + 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 order = await getRequiredWorkOrder(link.work_order_id) + assertWorkOrderAllowedForMaterialLink(order) + + const mapped = mapCollectLookupOrder(order) + 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), + } +} + +/** + * 公开页提交资料。 + * + * 一次性语义:只有「完整提交」成功时才把 token 标为 used; + * 少填必填项、订单状态变化、网络失败等都会在事务里回滚,链接继续可用。 + */ +export async function submitPublicWorkOrderMaterialLink(token: string, payload: JsonObject = {}) { + const preliminary = await findWorkOrderMaterialLinkByToken(token) + if (!preliminary) { + throw createHttpError('链接无效,请确认链接是否完整', { + statusCode: 404, + errorCode: 'material_link_not_found', + }) + } + + const now = nowIso() + const result = await withTransaction(async (client) => { + // 先锁工单再锁链接,和后台生成链接保持同样的加锁顺序,避免死锁。 + const order = await getWorkOrderForUpdateWithClient(client, preliminary.work_order_id) + if (!order) { + throw createHttpError('接单订单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + const link = await getWorkOrderMaterialLinkForUpdateWithClient(client, preliminary.id) + if (!link) { + throw createHttpError('链接无效,请确认链接是否完整', { + statusCode: 404, + errorCode: 'material_link_not_found', + }) + } + + assertMaterialLinkRecordUsable(link, now) + assertWorkOrderAllowedForMaterialLink(order) + + const fields = resolveRequirementFields(order) + const submittedFields = pickSubmittedMaterialFields( + fields, + normalizeSubmittedFields(payload.fields || {}), + ) + const missing = fields.filter( + (field) => field.required && !String(submittedFields[field.key] || '').trim(), + ) + if (missing.length > 0) { + throw createHttpError( + `请填写完整必填资料:${missing.map((field) => field.label).join('、')}`, + { + statusCode: 400, + errorCode: 'material_link_fields_incomplete', + context: { missingFields: missing.map((field) => field.key) }, + }, + ) + } + + const screenshots = normalizeUploadedFiles(payload.screenshots || []) + if (screenshots.length > MAX_MATERIAL_SCREENSHOTS) { + throw createHttpError(`买家主页截图最多 ${MAX_MATERIAL_SCREENSHOTS} 张`, { + statusCode: 400, + errorCode: 'material_link_screenshots_too_many', + }) + } + + const material = { + ...safeParseJson(order.material_json), + collect: { + fields: submittedFields, + screenshots, + submittedAt: now, + complete: true, + source: 'customer_link', + }, + } + + const updated = await updateWorkOrderMaterialWithClient(client, { + workOrderId: order.id, + status: WORK_ORDER_STATUS.UNASSIGNED, + materialJson: JSON.stringify(material), + now, + }) + if (!updated) { + throw createHttpError('更新订单资料失败,请稍后重试', { + statusCode: 500, + errorCode: 'material_link_update_failed', + }) + } + + const used = await markWorkOrderMaterialLinkUsedWithClient(client, link.id, now) + if (!used) { + // 并发提交时另一个请求已经把链接用掉,当前事务整体回滚。 + throw createHttpError('链接已使用,如需修改资料请联系客服重新获取', { + statusCode: 410, + errorCode: 'material_link_used', + }) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: order.id, + actorType: 'collect', + actorId: '客户补资料链接', + eventType: 'material_completed', + fromStatus: order.status, + toStatus: WORK_ORDER_STATUS.UNASSIGNED, + payloadJson: JSON.stringify({ + fields: submittedFields, + complete: true, + source: 'customer_link', + }), + now, + }) + + return { order: updated, link: used } + }) + + // 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。 + try { + await resolveAdminNotificationEntity( + 'work_order', + Number(result.order.id), + 'material_completed', + ) + } catch (error) { + logWarn('[work-order-material-link]', '补资料完成通知处理失败', { + workOrderId: result.order.id, + error: error instanceof Error ? error.message : String(error), + }) + } + publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) }) + + return { + complete: true, + order: mapPublicOrder(result.order), + } +} + +/** + * 上传前校验 token 与工单状态;返回工单 id 供文件归属使用。 + * 注意:这里不消耗 token,只有最终提交才消耗。 + */ +export async function getPublicWorkOrderMaterialLinkUploadContext(token: string) { + const link = await findWorkOrderMaterialLinkByToken(token) + if (!link) { + throw createHttpError('链接无效,请确认链接是否完整', { + statusCode: 404, + errorCode: 'material_link_not_found', + }) + } + + const now = nowIso() + assertMaterialLinkRecordUsable(link, now) + + const order = await getRequiredWorkOrder(link.work_order_id) + assertWorkOrderAllowedForMaterialLink(order) + + return { link, order, now } +} 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 new file mode 100644 index 00000000..2c4d2ea8 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-material-link-config-service.test.ts @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000..f53d076a --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-material-link-config-service.ts @@ -0,0 +1,62 @@ +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 1e761bf6..d7c4cf57 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path' +import { isCollectMaterialLinkPath } from '@/utils/collect-link' import { isRedeemPath } from '@/utils/redeem' import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site' import { hasWorkerSession } from '@/utils/worker-auth' @@ -45,6 +46,7 @@ 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 CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage')) const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage')) @@ -122,6 +124,12 @@ export default function App() { return } + // 一次性补资料链接(`/supplement/`):路径式公开页,先于站点判断, + // 保证在后台域名和打手端域名下都能打开。 + if (isCollectMaterialLinkPath(window.location.pathname)) { + return + } + if (isWorkerSite()) { return } @@ -226,6 +234,26 @@ function ClaimApplication() { ) } +/** + * 一次性补资料链接(`/supplement/`)的独立路由树。 + * + * 路径式公开页,不带 hash;页面只读取 token,不依赖后台会话。 + */ +function CollectMaterialLinkApplication() { + useClearStrayHash() + + return ( + + }> + + } /> + } /> + + + + ) +} + /** * H5 领取入口(`/h5` 命名空间)的独立路由树。 * diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 18611d43..895dfa71 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -9,17 +9,27 @@ import type { UploadedFile } from '@/types/worker-platform' import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture' import { useIsMobile } from '@/utils/use-is-mobile' +type ImageUploadScope = 'admin' | 'worker' | 'collect' + +type ImageUploadUploader = ( + file: File, + scene: string, + scope: ImageUploadScope, +) => Promise + type ImageUploadProps = { value?: UploadedFile[] onChange?: (files: UploadedFile[]) => void scene: string - scope: 'admin' | 'worker' | 'collect' + scope: ImageUploadScope maxCount?: number showPasteHint?: boolean /** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */ captureGlobalPaste?: boolean /** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */ allowCamera?: boolean + /** 自定义上传函数;公开一次性链接用它把文件传到 token 校验过的上传接口。 */ + uploader?: ImageUploadUploader } type GlobalPasteTarget = { @@ -67,6 +77,7 @@ export default function ImageUpload({ showPasteHint = false, captureGlobalPaste = false, allowCamera, + uploader, }: ImageUploadProps) { const { message } = App.useApp() const isMobile = useIsMobile() @@ -75,6 +86,7 @@ export default function ImageUpload({ const targetIdRef = useRef(0) const valueRef = useRef(value) valueRef.current = value + const uploadFileFn = uploader || uploadFile const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope, allowCamera }) const cameraInputProps = cameraCaptureInputProps('image') @@ -96,7 +108,7 @@ export default function ImageUpload({ const next = [...valueRef.current] for (const file of images.slice(0, remaining)) { try { - const uploaded = await uploadFile(file, scene, scope) + const uploaded = await uploadFileFn(file, scene, scope) next.push(uploaded) } catch (error) { message.error(resolveUploadErrorMessage(error, source)) @@ -199,7 +211,7 @@ export default function ImageUpload({ options: Parameters>[0], ) { try { - const uploaded = await uploadFile(options.file as File, scene, scope) + const uploaded = await uploadFileFn(options.file as File, scene, scope) // 多文件选择会并发执行 customRequest,必须以 ref 中的最新列表累加,避免后完成的上传覆盖前面的图片。 const next = [...valueRef.current, uploaded].slice(0, maxCount) valueRef.current = next diff --git a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx index 9cb5a206..ccdb9915 100644 --- a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx @@ -32,6 +32,7 @@ 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' @@ -44,6 +45,7 @@ export default function AnnouncementPanel() { + ) } diff --git a/apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx new file mode 100644 index 00000000..423596cd --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/MaterialLinkConfigPanel.tsx @@ -0,0 +1,74 @@ +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 62341f92..0b9e04d2 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -4,6 +4,7 @@ import { DeleteOutlined, EditOutlined, FormOutlined, + LinkOutlined, PushpinOutlined, ReloadOutlined, SearchOutlined, @@ -59,6 +60,7 @@ import { cancelAdminWorkOrderSharingPart, completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriend, + createAdminWorkOrderMaterialLink, deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, disposeAdminWorkOrderShareProblem, @@ -198,6 +200,40 @@ 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' +} + +/** 剪贴板 API 在 HTTP + IP 下可能不可用,补一个 textarea 兜底。 */ +async function copyTextToClipboard(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + // 继续走 execCommand 兜底 + } + + try { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + textarea.style.top = '0' + document.body.appendChild(textarea) + textarea.select() + const ok = document.execCommand('copy') + document.body.removeChild(textarea) + return ok + } catch { + return false + } +} + /** * 寄卖单号 = 来源提报单号(提报单号即寄卖单号),客服界面只认这一个号。 * @@ -312,6 +348,11 @@ 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 [sharingOrder, setSharingOrder] = useState(null) const [cancelShare, setCancelShare] = useState(null) const [reduceShare, setReduceShare] = useState(null) @@ -1590,6 +1631,27 @@ export default function WorkOrdersPanel() { .catch(() => message.error('复制失败,请手动复制')) } + async function copyMaterialLink(order: WorkOrder) { + setMaterialLinkLoadingId(order.workOrderId) + try { + const response = await createAdminWorkOrderMaterialLink(order.workOrderId) + const url = `${window.location.origin}/supplement/${encodeURIComponent(response.data.token)}` + const copied = await copyTextToClipboard(url) + const expiresText = formatAdminDateTime(response.data.expiresAt) + if (copied) { + message.success( + `${response.data.reused ? '已复制现有链接' : '已生成并复制新链接'},${expiresText} 前有效,仅可提交一次`, + ) + } else { + setMaterialLinkFallback({ url, expiresAt: response.data.expiresAt }) + } + } catch (error) { + message.error(error instanceof Error ? error.message : '生成补资料链接失败') + } finally { + setMaterialLinkLoadingId(null) + } + } + const columns: TableColumnsType = [ { title: '订单', @@ -1899,6 +1961,16 @@ export default function WorkOrdersPanel() { {['open', 'in_progress'].includes(row.status) ? '更新资料' : '补资料'} ) : null} + {row.capabilities?.canUpdateMaterial && canUpdateMaterial && canCopyMaterialLink(row) ? ( + + ) : null} {row.capabilities?.canPublish && canPublishOrder ? ( + + + } + > + {materialLinkFallback ? ( + + + 链接仅可提交一次,{formatAdminDateTime(materialLinkFallback.expiresAt)} 前有效。 + + + + ) : null} + + () + const token = String(routeToken || '').trim() + + 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([]) + + useEffect(() => { + const previousTitle = document.title + document.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() + } + }, []) + + useEffect(() => { + let cancelled = false + + async function load() { + if (!token) { + setLoading(false) + setLoadError({ + code: 'material_link_not_found', + message: '链接无效,请确认链接是否完整', + }) + return + } + + setLoading(true) + setSubmitted(false) + try { + const response = await fetchCollectMaterialLink(token) + if (cancelled) return + setInfo(response.data) + setLoadError(null) + setScreenshots(response.data.screenshots || []) + form.resetFields() + form.setFieldsValue(response.data.values || {}) + } catch (error) { + if (cancelled) return + setInfo(null) + setLoadError({ + code: resolveErrorCode(error), + message: resolveErrorMessage(error), + }) + } finally { + if (!cancelled) setLoading(false) + } + } + + void load() + return () => { + cancelled = true + } + }, [form, token]) + + async function handleSubmit(values: Record) { + if (!token) return + setSubmitting(true) + try { + await submitCollectMaterialLink(token, { fields: values, screenshots }) + setSubmitted(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 + } + message.error(resolveErrorMessage(error)) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + {loading ? ( +
+ } size="large" /> + 正在读取补资料链接... +
+ ) : submitted ? ( + + ) : loadError ? ( + loadError.code === 'material_link_used' ? ( + } + title="资料已提交" + subTitle="该链接已经使用过;如需再次修改资料,请联系客服重新获取链接。" + /> + ) : ( + + ) + ) : info ? ( + +
+ + 订单资料补充 + + 请补充以下资料,提交后客服会尽快处理。 +
+ +
+
+ 商品 + {info.order.productName || '-'} +
+
+ 工单号 + {info.order.workOrderNo || '-'} +
+
+ 订单号 + {info.order.platformOrderId || '-'} +
+
+ 有效期至 + {formatDateTime(info.expiresAt)} +
+
+ + + +
+ {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, + }))} + /> + ) : ( + + )} + + ))} + + + uploadCollectMaterialLinkFile(file, scene, token)} + /> + + +
+ +
+
+ + + 本页面仅用于补充订单资料,请勿将链接转发给他人。 + +
+ ) : null} +
+
+ ) +} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index c6669de0..36408eb6 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -15,6 +15,7 @@ import type { WorkerDepositUnfreeze, WorkerFinanceConfig, WorkerHallConfig, + WorkerMaterialLinkConfig, WorkerRiskConfig, WorkerRiskOverview, WorkerRiskEventList, @@ -98,6 +99,17 @@ export function saveAdminWorkerHallConfig( return apiPost('/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') } @@ -754,6 +766,12 @@ 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`, + ) +} + export function publishAdminWorkOrder(workOrderId: number) { return apiPost<{ order: WorkOrder diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts index 59aa00e4..5c32d562 100644 --- a/apps/frontend/src/services/files.ts +++ b/apps/frontend/src/services/files.ts @@ -19,6 +19,24 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope) return response.data.file } +/** + * 一次性补资料链接的截图上传。 + * + * 与公开 collect/upload 的区别:先把 token 放在路径里,后端会校验链接 + * 是否仍可用;链接已使用/过期时不会落文件。 + */ +export async function uploadCollectMaterialLinkFile(file: File, scene: string, token: string) { + const uploadTarget = await optimizeImageForUpload(file, scene) + const form = new FormData() + form.append('file', uploadTarget) + form.append('scene', scene) + const response = await apiPostForm<{ file: UploadedFile }>( + `/api/v1/collect/link/${encodeURIComponent(token)}/upload`, + form, + ) + return response.data.file +} + /** * 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。 * 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。 diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index f234951f..70f282a0 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -1,6 +1,8 @@ import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/http' import type { CollectLookupResponse, + CollectMaterialLinkInfo, + CollectMaterialLinkSubmitResult, UploadedFile, WorkOrder, WorkOrderEvent, @@ -379,6 +381,23 @@ export function submitCollectOrder(payload: { ) } +export function fetchCollectMaterialLink(token: string) { + return apiGet(`/api/v1/collect/link/${encodeURIComponent(token)}`) +} + +export function submitCollectMaterialLink( + token: string, + payload: { + fields: Record + screenshots?: UploadedFile[] + }, +) { + return apiPost( + `/api/v1/collect/link/${encodeURIComponent(token)}/submit`, + payload, + ) +} + // ---------------- 打手端:我要寄卖与手枪库存 ---------------- export type WorkerConsignmentPublishPayload = { diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index d306b8ac..cc11c0b8 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -480,6 +480,114 @@ margin-bottom: 0; } +/* ===== 一次性补资料链接页 (Collect Material Link) ===== */ + +.collect-link-page { + min-height: 100vh; + padding: 32px 16px; + background: #f5f7fb; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.collect-link-card { + width: min(720px, 100%); +} + +.collect-link-card .ant-card-body { + padding: 28px; +} + +.collect-link-title { + 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-meta > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.collect-link-meta span { + color: #64748b; + font-size: 12px; +} + +.collect-link-meta strong { + color: #0f172a; + font-size: 14px; + overflow-wrap: anywhere; +} + +.collect-link-state { + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +} + +.collect-link-actions { + display: flex; + justify-content: flex-end; + margin-top: 4px; +} + +.collect-link-actions .ant-btn { + min-width: 180px; +} + +.collect-link-footnote { + display: block; + text-align: center; + font-size: 12px; +} + +@media (max-width: 720px) { + .collect-link-page { + padding: 14px; + } + + .collect-link-card .ant-card-body { + padding: 18px; + } + + .collect-link-meta { + grid-template-columns: 1fr; + gap: 8px; + } + + .collect-link-actions { + justify-content: stretch; + } + + .collect-link-actions .ant-btn { + width: 100%; + } +} + +@media (max-width: 480px) { + .collect-link-page { + padding: 10px; + } + + .collect-link-card .ant-card-body { + padding: 14px; + } +} + /* ===== 兑换中心 (Redeem Page) ===== */ .redeem-page { diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index d2a91336..93be50d9 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -333,6 +333,11 @@ export type WorkerHallConfig = { queuedOrderCount: number } +/** 一次性补资料链接配置:有效期只影响新生成的链接。 */ +export type WorkerMaterialLinkConfig = { + ttlMinutes: number +} + export type WorkerWithdrawalAccount = { accountChannel: string accountName: string @@ -861,6 +866,30 @@ export type CollectLookupResponse = { pendingCount: number } +/** 一次性补资料链接的公开只读信息(不含价格/押金/打手等后台字段)。 */ +export type CollectMaterialLinkInfo = { + order: { + workOrderNo: string + platformOrderId: string + productName: string + status: string + } + fields: CollectField[] + values?: Record + screenshots?: UploadedFile[] + expiresAt: string +} + +export type CollectMaterialLinkSubmitResult = { + complete: boolean + order: { + workOrderNo: string + platformOrderId: string + productName: string + status: string + } +} + /** 大厅风控阈值(后台可调,保存即热生效) */ export type WorkerRiskConfig = { enabled: boolean diff --git a/apps/frontend/src/utils/collect-link.ts b/apps/frontend/src/utils/collect-link.ts new file mode 100644 index 00000000..9ca1c4f6 --- /dev/null +++ b/apps/frontend/src/utils/collect-link.ts @@ -0,0 +1,37 @@ +/** + * 一次性补资料链接的路径解析。 + * + * 公开链接形如:`https://<域名>/supplement/`,与后台 HashRouter 完全隔离。 + * 命名空间判定用于路由分发:`/supplement` 及子路径整段交给公开页, + * 避免落到后台路由或打手端 SPA。 + */ + +export function isCollectMaterialLinkPath(pathname: unknown): boolean { + return splitPathSegments(pathname)[0] === 'supplement' +} + +/** + * 只认 `/supplement/<一段 token>`: + * - `/supplement/abc` 命中,结尾多余斜杠容忍; + * - `/supplement`、`/supplement/a/b` 不命中(页面展示链接无效)。 + */ +export function resolveCollectMaterialLinkToken(pathname: unknown): string { + const segments = splitPathSegments(pathname) + if (segments.length !== 2 || segments[0] !== 'supplement') return '' + return decodeTokenSegment(segments[1]) +} + +function splitPathSegments(pathname: unknown): string[] { + const normalized = String(pathname ?? '').trim() + if (!normalized.startsWith('/')) return [] + + return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean) +} + +function decodeTokenSegment(segment: string): string { + try { + return decodeURIComponent(segment) + } catch { + return segment + } +} diff --git a/apps/frontend/tests/collect-link.test.ts b/apps/frontend/tests/collect-link.test.ts new file mode 100644 index 00000000..9bf34d9e --- /dev/null +++ b/apps/frontend/tests/collect-link.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + isCollectMaterialLinkPath, + resolveCollectMaterialLinkToken, +} from '../src/utils/collect-link.ts' + +test('补资料链接:/supplement/ 命中并解析 token', () => { + assert.equal(isCollectMaterialLinkPath('/supplement/abc123'), true) + assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123'), 'abc123') +}) + +test('补资料链接:容忍结尾斜杠、查询串与 hash', () => { + assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123/'), 'abc123') + assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123?from=chat'), 'abc123') + assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123#top'), 'abc123') +}) + +test('补资料链接:命名空间命中但缺少 token 时返回空串', () => { + assert.equal(isCollectMaterialLinkPath('/supplement'), true) + assert.equal(isCollectMaterialLinkPath('/supplement/'), true) + assert.equal(resolveCollectMaterialLinkToken('/supplement'), '') + assert.equal(resolveCollectMaterialLinkToken('/supplement/a/b'), '') +}) + +test('补资料链接:其它路径不命中', () => { + for (const pathname of ['/', '/claim/abc', '/collect', '/supplementx/abc', '']) { + assert.equal(isCollectMaterialLinkPath(pathname), false, pathname) + assert.equal(resolveCollectMaterialLinkToken(pathname), '', pathname) + } +}) From 8fb28b98c75367f644f4080e442fa812ef8c5521 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 11:12:08 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(work-order):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E8=A1=A5=E8=B5=84=E6=96=99=E9=93=BE=E6=8E=A5=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../work-order-material-link-repo.ts | 2 +- .../work-order-material-link-service.ts | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) 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 c8ffc530..79ef6438 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 @@ -128,7 +128,7 @@ export async function markWorkOrderMaterialLinkUsedWithClient( 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' + WHERE id = $1 AND status = 'active' AND expires_at > $2 RETURNING *`, [Number(linkId), now], ) 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 6b31c569..4c9decf2 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 @@ -149,7 +149,6 @@ export async function createAdminWorkOrderMaterialLink( workOrderId: number | string, actorName = '', ) { - const now = nowIso() const { ttlMinutes } = getWorkerMaterialLinkConfig() return withTransaction(async (client) => { @@ -169,6 +168,8 @@ export async function createAdminWorkOrderMaterialLink( } assertWorkOrderAllowedForMaterialLink(order) + // 锁到手后再取时间,避免等锁期间旧链接过期却仍按旧时间复用。 + const now = nowIso() const existing = await findActiveWorkOrderMaterialLinkWithClient(client, order.id, now) if (existing) { return { @@ -254,7 +255,6 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: }) } - const now = nowIso() const result = await withTransaction(async (client) => { // 先锁工单再锁链接,和后台生成链接保持同样的加锁顺序,避免死锁。 const order = await getWorkOrderForUpdateWithClient(client, preliminary.work_order_id) @@ -272,6 +272,8 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: }) } + // 锁到手后再取时间:等锁期间链接可能已经过期,不能沿用进入事务前的时间。 + const now = nowIso() assertMaterialLinkRecordUsable(link, now) assertWorkOrderAllowedForMaterialLink(order) @@ -328,11 +330,13 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: const used = await markWorkOrderMaterialLinkUsedWithClient(client, link.id, now) if (!used) { - // 并发提交时另一个请求已经把链接用掉,当前事务整体回滚。 - throw createHttpError('链接已使用,如需修改资料请联系客服重新获取', { - statusCode: 410, - errorCode: 'material_link_used', - }) + // 并发提交,或刚好在写入瞬间过期:按当前状态给出准确原因,事务整体回滚。 + const latest = await getWorkOrderMaterialLinkForUpdateWithClient(client, link.id) + if (latest) { + const reason = resolveWorkOrderMaterialLinkUnavailableReason(latest, now) + if (reason) throwMaterialLinkUnavailable(reason) + } + throwMaterialLinkUnavailable('used') } await createWorkOrderEventWithClient(client, { From 2f20e36a759d72d93e70815a6bb601bc325a337b Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 11:18:20 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(work-order):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E8=A1=A5=E8=B5=84=E6=96=99=E9=A1=B5=E7=A7=BB=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E5=88=86=E9=85=8D=E7=8A=B6=E6=80=81=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx index 9bd617f7..cf89a755 100644 --- a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx +++ b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx @@ -182,7 +182,7 @@ export default function CollectMaterialLinkPage() { ) : loadError ? ( loadError.code === 'material_link_used' ? ( @@ -230,7 +230,7 @@ export default function CollectMaterialLinkPage() { type="warning" showIcon message="链接仅可提交一次" - description="提交成功后链接立即失效,订单会转为未分配;如需再次修改资料,请联系客服重新获取链接。" + description="提交成功后链接立即失效;如需再次修改资料,请联系客服重新获取链接。" />