From 9f14e8de7ae2228c615b213b4826af6da5c70955 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 19:46:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(order-service):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E8=B5=84=E6=96=99=E6=94=B6=E9=9B=86=E4=B8=8E?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=81=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/scripts/order-service-smoke.ts | 281 +++++++ apps/backend/src/app.ts | 4 +- .../migrations/128_order_service_profiles.sql | 74 ++ .../129_order_service_auto_publish_jobs.sql | 29 + .../migrations/130_customer_notifications.sql | 35 + .../131_order_service_hardening.sql | 34 + .../src/repositories/file-asset-repo.ts | 10 +- .../src/repositories/worker-platform/index.ts | 1 + .../order-service-profile-repo.ts | 294 ++++++++ .../src/repositories/worker-platform/types.ts | 6 + .../work-order-management-repo.ts | 11 +- .../routes/admin/worker-platform/config.ts | 97 +++ apps/backend/src/routes/collect.ts | 171 ----- apps/backend/src/routes/order-service.ts | 119 +++ apps/backend/src/routes/redeem.ts | 2 +- .../file-storage/file-storage-service.ts | 24 +- .../customer-order-notification-service.ts | 43 ++ .../src/services/scheduler/config-service.ts | 32 + .../scheduler/finance-pipeline-jobs.ts | 11 + .../services/scheduler/scheduler-service.ts | 4 + .../admin-work-order-management-service.ts | 57 +- .../admin-worker-catalog-service.ts | 9 +- .../src/services/worker-platform/index.ts | 4 +- .../src/services/worker-platform/mappers.ts | 1 - .../order-service-auto-publish-service.ts | 328 +++++++++ .../order-service-profile-service.test.ts | 79 ++ .../order-service-profile-service.ts | 374 ++++++++++ .../order-service-public-service.test.ts | 51 ++ .../order-service-public-service.ts | 492 +++++++++++++ .../sync-worker-orders-service.ts | 28 +- .../work-order-display-mappers.ts | 18 +- .../work-order-material-link-service.test.ts | 17 - .../work-order-material-link-service.ts | 313 +------- .../worker-platform/worker-collect-service.ts | 136 ---- .../worker-platform-service.test.ts | 50 -- .../worker-platform/worker-service.ts | 6 - apps/frontend/src/App.tsx | 82 ++- .../pages/admin/AdminWorkerPlatformPage.tsx | 2 + .../pages/admin/panels/OrderServicePanel.tsx | 689 ++++++++++++++++++ .../pages/admin/panels/ProductRulesPanel.tsx | 21 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 10 +- .../collect/CollectMaterialLinkEntryPage.tsx | 67 -- .../pages/collect/CollectMaterialLinkPage.tsx | 405 ---------- .../src/pages/collect/CollectPage.tsx | 186 ----- .../src/pages/public/OrderServicePage.tsx | 498 +++++++++++++ .../use-order-service-page-meta.ts} | 17 +- .../src/services/admin/worker-platform.ts | 126 ++++ apps/frontend/src/services/files.ts | 34 +- apps/frontend/src/services/worker.ts | 128 +++- apps/frontend/src/styles/client.css | 7 + apps/frontend/src/types/worker-platform.ts | 62 -- .../src/utils/admin-worker-platform-tabs.ts | 6 + apps/frontend/src/utils/collect-link.ts | 68 -- apps/frontend/src/utils/order-service-link.ts | 61 ++ apps/frontend/tests/collect-link.test.ts | 66 -- .../frontend/tests/order-service-link.test.ts | 36 + docs/README.md | 4 +- docs/订单全流程与内部SKU架构.md | 576 +++++++++++++++ docs/订单服务收集与状态页设计-2026-11-03.md | 204 ++++++ 59 files changed, 4866 insertions(+), 1734 deletions(-) create mode 100644 apps/backend/scripts/order-service-smoke.ts create mode 100644 apps/backend/src/db/migrations/128_order_service_profiles.sql create mode 100644 apps/backend/src/db/migrations/129_order_service_auto_publish_jobs.sql create mode 100644 apps/backend/src/db/migrations/130_customer_notifications.sql create mode 100644 apps/backend/src/db/migrations/131_order_service_hardening.sql create mode 100644 apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts delete mode 100644 apps/backend/src/routes/collect.ts create mode 100644 apps/backend/src/routes/order-service.ts create mode 100644 apps/backend/src/services/notification/customer-order-notification-service.ts create mode 100644 apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts create mode 100644 apps/backend/src/services/worker-platform/order-service-profile-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/order-service-profile-service.ts create mode 100644 apps/backend/src/services/worker-platform/order-service-public-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/order-service-public-service.ts delete mode 100644 apps/backend/src/services/worker-platform/worker-collect-service.ts create mode 100644 apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx delete mode 100644 apps/frontend/src/pages/collect/CollectMaterialLinkEntryPage.tsx delete mode 100644 apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx delete mode 100644 apps/frontend/src/pages/collect/CollectPage.tsx create mode 100644 apps/frontend/src/pages/public/OrderServicePage.tsx rename apps/frontend/src/pages/{collect/use-collect-link-page-meta.ts => public/use-order-service-page-meta.ts} (68%) delete mode 100644 apps/frontend/src/utils/collect-link.ts create mode 100644 apps/frontend/src/utils/order-service-link.ts delete mode 100644 apps/frontend/tests/collect-link.test.ts create mode 100644 apps/frontend/tests/order-service-link.test.ts create mode 100644 docs/订单全流程与内部SKU架构.md create mode 100644 docs/订单服务收集与状态页设计-2026-11-03.md diff --git a/apps/backend/scripts/order-service-smoke.ts b/apps/backend/scripts/order-service-smoke.ts new file mode 100644 index 00000000..919519e9 --- /dev/null +++ b/apps/backend/scripts/order-service-smoke.ts @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict' +import process from 'node:process' + +import { closeDb, query } from '../src/db/client.js' +import { WORK_ORDER_STATUS } from '../src/domain/work-order-status.js' +import { createWorkOrder } from '../src/repositories/worker-platform/work-order-management-repo.js' +import type { WorkOrderRow } from '../src/repositories/worker-platform/types.js' +import { + publishAdminOrderServiceProfile, + saveAdminOrderServiceProfile, + type OrderServiceProfileConfig, +} from '../src/services/worker-platform/order-service-profile-service.js' +import { + getOrderServiceByPlatformOrderId, + submitOrderServiceMaterial, +} from '../src/services/worker-platform/order-service-public-service.js' +import { processDueOrderServiceAutoPublishJobs } from '../src/services/worker-platform/order-service-auto-publish-service.js' +import { randomId } from '../src/utils/random.js' +import { nowIso } from '../src/utils/time.js' + +if (process.env.ORDER_SERVICE_SMOKE !== '1') { + throw new Error('请显式设置 ORDER_SERVICE_SMOKE=1 后运行;该脚本只用于本地测试库') +} + +const categoryId = Number(process.env.ORDER_SERVICE_SMOKE_CATEGORY_ID || 1) +const platformOrderId = `SMOKE-${Date.now()}` +let workOrder: WorkOrderRow | null = null +let originalProfile: { + id: number + name: string + title: string + description: string + guideImage: Record + fields: OrderServiceProfileConfig['fields'] + screenshots: OrderServiceProfileConfig['screenshots'] + autoPublish: OrderServiceProfileConfig['autoPublish'] + submitText: string + successText: string +} | null = null + +try { + const profileResult = await query<{ + id: number + name: string + title: string + description: string + guide_image_json: Record + fields_json: OrderServiceProfileConfig['fields'] + screenshots_json: Record + auto_publish_json: Record + submit_text: string + success_text: string + current_version_id: number + }>( + ` + SELECT p.* + FROM order_service_profiles p + WHERE p.category_id = $1 + AND p.current_version_id IS NOT NULL + LIMIT 1 + `, + [categoryId], + ) + const profile = profileResult.rows[0] + assert.ok(profile, '测试分类缺少已发布订单服务配置') + originalProfile = { + id: Number(profile.id), + name: profile.name, + title: profile.title, + description: profile.description, + guideImage: profile.guide_image_json, + fields: profile.fields_json, + screenshots: profile.screenshots_json as OrderServiceProfileConfig['screenshots'], + autoPublish: profile.auto_publish_json as OrderServiceProfileConfig['autoPublish'], + submitText: profile.submit_text, + successText: profile.success_text, + } + + const smokeFields: OrderServiceProfileConfig['fields'] = [ + { + key: 'account', + label: '游戏账号', + type: 'text', + required: true, + placeholder: '请输入账号', + helpText: '', + options: [], + sortOrder: 10, + }, + ] + const saved = await saveAdminOrderServiceProfile({ + categoryId, + name: '本地订单服务烟测', + title: '本地订单服务烟测', + description: '测试完成后自动恢复', + guideImage: {}, + fields: smokeFields, + screenshots: { + enabled: false, + required: false, + minCount: 0, + maxCount: 3, + label: '截图', + helpText: '', + }, + autoPublish: { + enabled: true, + requireVoucherConsume: true, + retryMax: 1, + }, + submitText: '提交烟测资料', + successText: '烟测资料已提交', + }) + const draftStatus = await query<{ status: string }>( + `SELECT status FROM order_service_profiles WHERE id = $1`, + [saved.profile.id], + ) + assert.equal(draftStatus.rows[0]?.status, 'draft') + const stillPublished = await getPublishedProfile(categoryId) + assert.equal(stillPublished.title, originalProfile.title) + + const published = await publishAdminOrderServiceProfile(saved.profile.id) + const publishedConfig = await getPublishedProfile(categoryId) + assert.equal(publishedConfig.title, '本地订单服务烟测') + + workOrder = await createWorkOrder({ + workOrderNo: `WKSMOKE${Date.now()}`, + platformOrderId, + productName: '订单服务烟测商品', + categoryId, + status: WORK_ORDER_STATUS.PENDING_MATERIAL, + rewardAmount: 100, + requiredDepositAmount: 0, + depositThresholdAmount: 0, + sharingEnabled: false, + sharingTotalQuantity: 1, + sharingUnitReward: 0, + timeoutMinutes: 30, + timeoutPolicy: 'reopen', + acceptanceMode: 'standard', + materialJson: JSON.stringify({ source: { smoke: true } }), + requirementJson: JSON.stringify({ fields: smokeFields }), + serviceProfileId: saved.profile.id, + serviceProfileVersionId: published.versionId, + serviceProfileSnapshotJson: JSON.stringify({ + profileId: saved.profile.id, + categoryId, + versionId: published.versionId, + versionNo: published.versionNo, + config: publishedConfig, + }), + now: nowIso(), + }) + assert.ok(workOrder, '创建烟测工单失败') + + const publicInfo = await getOrderServiceByPlatformOrderId(platformOrderId) + assert.equal(publicInfo.items[0]?.title, '本地订单服务烟测') + assert.equal(publicInfo.items[0]?.canSubmit, true) + + await saveAdminOrderServiceProfile({ + categoryId, + name: '未发布修改', + title: '未发布修改不应泄漏', + description: 'draft-only', + guideImage: {}, + fields: smokeFields, + screenshots: { + enabled: false, + required: false, + minCount: 0, + maxCount: 3, + label: '截图', + helpText: '', + }, + autoPublish: { + enabled: true, + requireVoucherConsume: true, + retryMax: 1, + }, + submitText: '不应泄漏', + successText: '不应泄漏', + }) + const afterDraftEdit = await getOrderServiceByPlatformOrderId(platformOrderId) + assert.equal(afterDraftEdit.items[0]?.title, '本地订单服务烟测') + + await submitOrderServiceMaterial(platformOrderId, { + workOrderId: workOrder.id, + fields: { account: 'smoke-account' }, + screenshots: [], + }) + await assert.rejects(() => + submitOrderServiceMaterial(platformOrderId, { + workOrderId: workOrder?.id, + fields: { account: 'changed' }, + screenshots: [], + }), + ) + + await waitForAutoPublishJob(workOrder.id) + const afterPublish = await getOrderServiceByPlatformOrderId(platformOrderId) + assert.equal(afterPublish.items[0]?.state, WORK_ORDER_STATUS.OPEN) + assert.match(afterPublish.items[0]?.message || '', /高峰期/) + + const job = await query<{ status: string; attempt_count: number }>( + `SELECT status, attempt_count FROM order_service_auto_publish_jobs WHERE work_order_id = $1`, + [workOrder.id], + ) + assert.equal(job.rows[0]?.status, 'succeeded') + + console.log( + JSON.stringify( + { + ok: true, + categoryId, + profileId: saved.profile.id, + versionNo: published.versionNo, + platformOrderId, + finalStatus: afterPublish.items[0]?.state, + finalMessage: afterPublish.items[0]?.message, + }, + null, + 2, + ), + ) +} finally { + if (workOrder) { + await query('DELETE FROM work_orders WHERE id = $1', [workOrder.id]).catch(() => undefined) + } + if (originalProfile) { + const restored = await saveAdminOrderServiceProfile({ + categoryId, + name: originalProfile.name, + title: originalProfile.title, + description: originalProfile.description, + guideImage: originalProfile.guideImage, + fields: originalProfile.fields, + screenshots: originalProfile.screenshots, + autoPublish: originalProfile.autoPublish, + submitText: originalProfile.submitText, + successText: originalProfile.successText, + }).catch(() => null) + if (restored) { + await publishAdminOrderServiceProfile(restored.profile.id).catch(() => undefined) + } + } + await closeDb() +} + +async function getPublishedProfile(categoryIdValue: number) { + const result = await query<{ config_json: OrderServiceProfileConfig }>( + ` + SELECT v.config_json + FROM order_service_profiles p + JOIN order_service_profile_versions v ON v.id = p.current_version_id + WHERE p.category_id = $1 + LIMIT 1 + `, + [categoryIdValue], + ) + const config = result.rows[0]?.config_json + assert.ok(config, '未找到已发布订单服务配置') + return config +} + +async function waitForAutoPublishJob(workOrderId: number) { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + await Promise.all([ + processDueOrderServiceAutoPublishJobs(10), + processDueOrderServiceAutoPublishJobs(10), + ]) + const result = await query<{ status: string }>( + `SELECT status FROM order_service_auto_publish_jobs WHERE work_order_id = $1`, + [workOrderId], + ) + const status = result.rows[0]?.status + if (status === 'succeeded' || status === 'failed' || status === 'skipped') return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('自动发布任务在 5 秒内未结束') +} diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index b775fc1d..13b2d67c 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -4,7 +4,7 @@ import process from 'node:process' import adminRouter from './routes/admin.js' import affiliateDashRouter from './routes/affiliate-dash.js' -import collectRouter from './routes/collect.js' +import orderServiceRouter from './routes/order-service.js' import filesRouter from './routes/files.js' import claimsRouter from './routes/claims.js' import dcAuthRouter from './routes/dcauth.js' @@ -112,7 +112,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter) app.use('/api/v1/redeem', redeemRouter) app.use('/api/v1/claim', claimsRouter) - app.use('/api/v1/collect', collectRouter) + app.use('/api/v1/o', orderServiceRouter) app.use('/api/v1/pdd/extension', pddExtensionRouter) app.use('/api/v1/files', filesRouter) app.use('/api/v1/worker', workerRouter) diff --git a/apps/backend/src/db/migrations/128_order_service_profiles.sql b/apps/backend/src/db/migrations/128_order_service_profiles.sql new file mode 100644 index 00000000..d8dbee3b --- /dev/null +++ b/apps/backend/src/db/migrations/128_order_service_profiles.sql @@ -0,0 +1,74 @@ +-- 128_order_service_profiles.sql +-- +-- 分类级订单服务配置:资料收集字段、引导图、截图规则与自动发布策略。 +-- 工单创建时固化配置快照,后续修改配置不影响已创建工单。 + +ALTER TABLE work_categories + ADD COLUMN IF NOT EXISTS default_work_rule_id BIGINT REFERENCES work_product_rules(id) ON DELETE SET NULL; + +COMMENT ON COLUMN work_categories.default_work_rule_id IS + '分类默认接单模板;商品级映射可覆盖,未命中时整个分类使用该模板'; + +CREATE TABLE IF NOT EXISTS order_service_profiles ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL REFERENCES work_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + guide_image_json JSONB NOT NULL DEFAULT '{}'::jsonb, + fields_json JSONB NOT NULL DEFAULT '[]'::jsonb, + screenshots_json JSONB NOT NULL DEFAULT '{}'::jsonb, + auto_publish_json JSONB NOT NULL DEFAULT '{"enabled":false}'::jsonb, + submit_text TEXT NOT NULL DEFAULT '提交资料', + success_text TEXT NOT NULL DEFAULT '资料已提交,请等待处理', + status TEXT NOT NULL DEFAULT 'draft', + current_version_id BIGINT, + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT order_service_profiles_status_check + CHECK (status IN ('draft', 'published', 'archived')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_order_service_profiles_category + ON order_service_profiles (category_id); +CREATE INDEX IF NOT EXISTS idx_order_service_profiles_status + ON order_service_profiles (status); + +CREATE TABLE IF NOT EXISTS order_service_profile_versions ( + id BIGSERIAL PRIMARY KEY, + profile_id BIGINT NOT NULL REFERENCES order_service_profiles(id) ON DELETE CASCADE, + version_no INTEGER NOT NULL, + config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + published_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_order_service_profile_versions_no + UNIQUE (profile_id, version_no) +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_order_service_profiles_current_version' + ) THEN + ALTER TABLE order_service_profiles + ADD CONSTRAINT fk_order_service_profiles_current_version + FOREIGN KEY (current_version_id) + REFERENCES order_service_profile_versions(id) ON DELETE SET NULL; + END IF; +END $$; + +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS service_profile_id BIGINT REFERENCES order_service_profiles(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS service_profile_version_id BIGINT REFERENCES order_service_profile_versions(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS service_profile_snapshot_json JSONB; + +CREATE INDEX IF NOT EXISTS idx_work_orders_service_profile + ON work_orders (service_profile_id, service_profile_version_id) + WHERE service_profile_id IS NOT NULL; + +COMMENT ON TABLE order_service_profiles IS + '分类级订单服务配置:资料字段、引导图、截图规则与自动发布策略'; +COMMENT ON COLUMN work_orders.service_profile_snapshot_json IS + '工单创建时固化的订单服务配置快照;配置后续变更不影响该工单'; diff --git a/apps/backend/src/db/migrations/129_order_service_auto_publish_jobs.sql b/apps/backend/src/db/migrations/129_order_service_auto_publish_jobs.sql new file mode 100644 index 00000000..8e7987f2 --- /dev/null +++ b/apps/backend/src/db/migrations/129_order_service_auto_publish_jobs.sql @@ -0,0 +1,29 @@ +-- 129_order_service_auto_publish_jobs.sql +-- +-- 资料提交后自动发布工单。任务先落库,外部电子凭证核销失败时可以重试。 + +CREATE TABLE IF NOT EXISTS order_service_auto_publish_jobs ( + id BIGSERIAL PRIMARY KEY, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_error TEXT NOT NULL DEFAULT '', + result_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT order_service_auto_publish_jobs_status_check + CHECK (status IN ('pending', 'succeeded', 'failed', 'skipped')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_order_service_auto_publish_pending + ON order_service_auto_publish_jobs (work_order_id) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_order_service_auto_publish_due + ON order_service_auto_publish_jobs (next_run_at) + WHERE status = 'pending'; + +COMMENT ON TABLE order_service_auto_publish_jobs IS + '客户资料提交后的自动发布任务;核销失败可按配置重试'; diff --git a/apps/backend/src/db/migrations/130_customer_notifications.sql b/apps/backend/src/db/migrations/130_customer_notifications.sql new file mode 100644 index 00000000..84bacd75 --- /dev/null +++ b/apps/backend/src/db/migrations/130_customer_notifications.sql @@ -0,0 +1,35 @@ +-- 130_customer_notifications.sql +-- +-- 客户订单通知事件模型。首版只落事件,不发送短信; +-- 后续接入 SMS Provider 时消费 pending 任务即可。 + +CREATE TABLE IF NOT EXISTS customer_order_notifications ( + id BIGSERIAL PRIMARY KEY, + order_id BIGINT REFERENCES orders(id) ON DELETE CASCADE, + order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, + work_order_id BIGINT REFERENCES work_orders(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'sms', + status TEXT NOT NULL DEFAULT 'skipped', + phone TEXT NOT NULL DEFAULT '', + template_key TEXT NOT NULL DEFAULT '', + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + dedupe_key TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + sent_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT customer_order_notifications_channel_check + CHECK (channel IN ('web', 'sms')), + CONSTRAINT customer_order_notifications_status_check + CHECK (status IN ('pending', 'sent', 'failed', 'skipped')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_customer_order_notifications_dedupe + ON customer_order_notifications (dedupe_key); + +CREATE INDEX IF NOT EXISTS idx_customer_order_notifications_pending + ON customer_order_notifications (next_run_at) + WHERE status = 'pending'; diff --git a/apps/backend/src/db/migrations/131_order_service_hardening.sql b/apps/backend/src/db/migrations/131_order_service_hardening.sql new file mode 100644 index 00000000..7a321a14 --- /dev/null +++ b/apps/backend/src/db/migrations/131_order_service_hardening.sql @@ -0,0 +1,34 @@ +-- 131_order_service_hardening.sql +-- +-- 订单服务配置以“分类 -> 当前发布版本”为线上唯一读取源。 +-- 分类与接单模板的绑定沿用 work_product_rules.category_id,不再维护反向默认模板。 + +ALTER TABLE work_categories + DROP COLUMN IF EXISTS default_work_rule_id; + +ALTER TABLE order_service_auto_publish_jobs + ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS locked_by TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS finished_at TIMESTAMPTZ; + +ALTER TABLE order_service_auto_publish_jobs + DROP CONSTRAINT IF EXISTS order_service_auto_publish_jobs_status_check; + +ALTER TABLE order_service_auto_publish_jobs + ADD CONSTRAINT order_service_auto_publish_jobs_status_check + CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'skipped')); + +DROP INDEX IF EXISTS uq_order_service_auto_publish_pending; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_order_service_auto_publish_active + ON order_service_auto_publish_jobs (work_order_id) + WHERE status IN ('pending', 'processing'); + +CREATE INDEX IF NOT EXISTS idx_order_service_auto_publish_lease + ON order_service_auto_publish_jobs (locked_until) + WHERE status = 'processing'; + +COMMENT ON COLUMN order_service_auto_publish_jobs.locked_until IS + '任务处理租约到期时间;进程中断后可被其它 worker 重新领取'; +COMMENT ON COLUMN order_service_auto_publish_jobs.locked_by IS + '领取任务的进程或实例标识'; diff --git a/apps/backend/src/repositories/file-asset-repo.ts b/apps/backend/src/repositories/file-asset-repo.ts index 57563110..d85d2a79 100644 --- a/apps/backend/src/repositories/file-asset-repo.ts +++ b/apps/backend/src/repositories/file-asset-repo.ts @@ -1,3 +1,5 @@ +import type { PoolClient } from 'pg' + import { query } from '../db/client.js' export type FileAssetRow = { @@ -87,8 +89,12 @@ export async function createFileAsset(input: CreateFileAssetInput): Promise { - const result = await query( +export async function getFileAssetByObjectKey( + objectKey: string, + client?: Pick, +): Promise { + const db = client || { query } + const result = await db.query( 'SELECT * FROM file_assets WHERE object_key = $1 AND status = $2 LIMIT 1', [String(objectKey || '').trim(), 'active'], ) diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index 3f82ac52..c22fd0bd 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -29,3 +29,4 @@ export * from './product-match-repo.js' export * from './sms-code-repo.js' export * from './consignment-repo.js' export * from './consignment-contact-repo.js' +export * from './order-service-profile-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts new file mode 100644 index 00000000..f538d698 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts @@ -0,0 +1,294 @@ +import type { PoolClient } from 'pg' + +import { query } from '../../db/client.js' + +export type OrderServiceProfileRow = { + id: number + category_id: number + name: string + title: string + description: string + guide_image_json: Record + fields_json: Array> + screenshots_json: Record + auto_publish_json: Record + submit_text: string + success_text: string + status: string + current_version_id: number | null + current_version_no: number | null + created_by_admin_user_id: number | null + created_at: string + updated_at: string +} + +export type OrderServicePublishedProfileRow = { + profile_id: number + category_id: number + version_id: number + version_no: number + config_json: Record +} + +const PROFILE_SELECT = ` + SELECT + p.*, + v.version_no AS current_version_no + FROM order_service_profiles p + LEFT JOIN order_service_profile_versions v ON v.id = p.current_version_id +` + +function mapProfile(row: OrderServiceProfileRow): OrderServiceProfileRow { + return { + ...row, + id: Number(row.id), + category_id: Number(row.category_id), + current_version_id: + row.current_version_id === null || row.current_version_id === undefined + ? null + : Number(row.current_version_id), + current_version_no: + row.current_version_no === null || row.current_version_no === undefined + ? null + : Number(row.current_version_no), + created_by_admin_user_id: + row.created_by_admin_user_id === null || row.created_by_admin_user_id === undefined + ? null + : Number(row.created_by_admin_user_id), + } +} + +export async function getOrderServiceProfileByCategory( + categoryId: number | string, + client?: Pick, +): Promise { + const db = client || { query } + const result = await db.query( + `${PROFILE_SELECT} WHERE p.category_id = $1 LIMIT 1`, + [Number(categoryId)], + ) + return result.rows[0] ? mapProfile(result.rows[0]) : null +} + +export async function getOrderServiceProfileById( + profileId: number | string, + client?: Pick, +): Promise { + const db = client || { query } + const result = await db.query( + `${PROFILE_SELECT} WHERE p.id = $1 LIMIT 1`, + [Number(profileId)], + ) + return result.rows[0] ? mapProfile(result.rows[0]) : null +} + +export async function listOrderServiceProfiles(): Promise { + const result = await query( + `${PROFILE_SELECT} ORDER BY p.updated_at DESC, p.id DESC`, + ) + return result.rows.map(mapProfile) +} + +export async function upsertOrderServiceProfile(input: { + categoryId: number + name: string + title: string + description: string + guideImage: Record + fields: Array> + screenshots: Record + autoPublish: Record + submitText: string + successText: string + adminUserId: number | null +}): Promise { + const result = await query( + ` + INSERT INTO order_service_profiles ( + category_id, name, title, description, + guide_image_json, fields_json, screenshots_json, auto_publish_json, + submit_text, success_text, status, created_by_admin_user_id + ) VALUES ( + $1, $2, $3, $4, + $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb, + $9, $10, 'draft', $11 + ) + ON CONFLICT (category_id) DO UPDATE SET + name = EXCLUDED.name, + title = EXCLUDED.title, + description = EXCLUDED.description, + guide_image_json = EXCLUDED.guide_image_json, + fields_json = EXCLUDED.fields_json, + screenshots_json = EXCLUDED.screenshots_json, + auto_publish_json = EXCLUDED.auto_publish_json, + submit_text = EXCLUDED.submit_text, + success_text = EXCLUDED.success_text, + current_version_id = CASE + WHEN order_service_profiles.status = 'archived' THEN NULL + ELSE order_service_profiles.current_version_id + END, + status = 'draft', + updated_at = NOW() + RETURNING * + `, + [ + input.categoryId, + input.name, + input.title, + input.description, + JSON.stringify(input.guideImage), + JSON.stringify(input.fields), + JSON.stringify(input.screenshots), + JSON.stringify(input.autoPublish), + input.submitText, + input.successText, + input.adminUserId, + ], + ) + const row = result.rows[0] + if (!row) throw new Error('order service profile upsert returned no row') + return mapProfile(row) +} + +export async function archiveOrderServiceProfile( + profileId: number | string, +): Promise { + const result = await query( + `UPDATE order_service_profiles SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`, + [Number(profileId)], + ) + return result.rows[0] ? mapProfile(result.rows[0]) : null +} + +export async function createOrderServiceProfileVersion( + client: PoolClient, + input: { + profileId: number + config: Record + adminUserId: number | null + }, +): Promise<{ versionId: number; versionNo: number }> { + const versionResult = await client.query<{ next_version_no: number }>( + ` + SELECT COALESCE(MAX(version_no), 0) + 1 AS next_version_no + FROM order_service_profile_versions + WHERE profile_id = $1 + `, + [input.profileId], + ) + const versionNo = Math.max(1, Number(versionResult.rows[0]?.next_version_no || 1)) + const inserted = await client.query<{ id: number }>( + ` + INSERT INTO order_service_profile_versions ( + profile_id, version_no, config_json, published_by_admin_user_id + ) VALUES ($1, $2, $3::jsonb, $4) + RETURNING id + `, + [input.profileId, versionNo, JSON.stringify(input.config), input.adminUserId], + ) + return { + versionId: Number(inserted.rows[0]?.id || 0), + versionNo, + } +} + +export async function setOrderServiceProfileCurrentVersion( + client: PoolClient, + input: { profileId: number; versionId: number }, +): Promise { + await client.query( + `UPDATE order_service_profiles SET current_version_id = $2, status = 'published', updated_at = NOW() WHERE id = $1`, + [input.profileId, input.versionId], + ) +} + +export async function getOrderServiceProfileVersion( + versionId: number | string, + client?: Pick, +): Promise<{ + id: number + profile_id: number + version_no: number + config_json: Record +} | null> { + const db = client || { query } + const result = await db.query<{ + id: number + profile_id: number + version_no: number + config_json: Record + }>( + `SELECT id, profile_id, version_no, config_json FROM order_service_profile_versions WHERE id = $1 LIMIT 1`, + [Number(versionId)], + ) + const row = result.rows[0] + return row + ? { + id: Number(row.id), + profile_id: Number(row.profile_id), + version_no: Number(row.version_no), + config_json: row.config_json, + } + : null +} + +export async function getPublishedOrderServiceProfileByCategory( + categoryId: number | string, + client?: Pick, +): Promise { + const db = client || { query } + const result = await db.query( + ` + SELECT + p.id AS profile_id, + p.category_id, + v.id AS version_id, + v.version_no, + v.config_json + FROM order_service_profiles p + JOIN order_service_profile_versions v ON v.id = p.current_version_id + WHERE p.category_id = $1 + AND p.status <> 'archived' + LIMIT 1 + `, + [Number(categoryId)], + ) + const row = result.rows[0] + return row + ? { + profile_id: Number(row.profile_id), + category_id: Number(row.category_id), + version_id: Number(row.version_id), + version_no: Number(row.version_no), + config_json: row.config_json, + } + : null +} + +export async function updateWorkOrderServiceProfile(input: { + workOrderId: number | string + profileId: number | null + versionId: number | null + snapshot: Record | null + now: string + client?: Pick +}): Promise { + const db = input.client || { query } + await db.query( + ` + UPDATE work_orders + SET service_profile_id = $2, + service_profile_version_id = $3, + service_profile_snapshot_json = $4::jsonb, + updated_at = $5 + WHERE id = $1 + `, + [ + Number(input.workOrderId), + input.profileId, + input.versionId, + input.snapshot === null ? null : JSON.stringify(input.snapshot), + input.now, + ], + ) +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index f2c73c0e..d2b90511 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -408,6 +408,9 @@ export type WorkOrderRow = { user_payment_amount_updated_by?: string | null material_json: string | Record requirement_json: string | Record + service_profile_id?: number | null + service_profile_version_id?: number | null + service_profile_snapshot_json?: string | Record | null acceptance_json: string | Record draft_acceptance_json: string | Record admin_note: string @@ -634,5 +637,8 @@ export type CreateWorkOrderInput = { giftCooldownMinutes?: number materialJson: string requirementJson: string + serviceProfileId?: number | null + serviceProfileVersionId?: number | null + serviceProfileSnapshotJson?: string | null now: string } diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts index 1204bd79..f9a4411a 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts @@ -23,12 +23,16 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise listAdminOrderServiceProfiles(), { + successMessage: 'ok', + errorMessage: '读取订单服务配置失败', + scope: '[admin/worker-platform/order-service/profiles]', + }), +) + +router.post( + '/worker-platform/order-service/profiles', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => saveAdminOrderServiceProfile(req.body || {}), { + successMessage: '订单服务配置已保存', + errorMessage: '保存订单服务配置失败', + scope: '[admin/worker-platform/order-service/profiles]', + audit: (_req, data) => ({ + action: 'order_service_profile_saved', + targetType: 'order_service_profile', + targetId: String((data as { profile?: { id?: number } })?.profile?.id || ''), + data: data as Record, + }), + }), +) + +router.post( + '/worker-platform/order-service/profiles/:profileId/publish', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + publishAdminOrderServiceProfile( + String(req.params.profileId || ''), + req.adminSession?.userId ?? null, + ), + { + successMessage: '订单服务配置已发布', + errorMessage: '发布订单服务配置失败', + scope: '[admin/worker-platform/order-service/profiles/:profileId/publish]', + audit: (req) => ({ + action: 'order_service_profile_published', + targetType: 'order_service_profile', + targetId: String(req.params.profileId || ''), + data: {}, + }), + }, + ), +) + +router.post( + '/worker-platform/order-service/profiles/:profileId/archive', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => archiveAdminOrderServiceProfile(String(req.params.profileId || '')), { + successMessage: '订单服务配置已归档', + errorMessage: '归档订单服务配置失败', + scope: '[admin/worker-platform/order-service/profiles/:profileId/archive]', + audit: (req) => ({ + action: 'order_service_profile_archived', + targetType: 'order_service_profile', + targetId: String(req.params.profileId || ''), + data: {}, + }), + }), +) + router.get( '/worker-platform/product-rules', requireAdminRoles(['admin', 'operator', 'support']), @@ -466,4 +537,30 @@ router.post( }), ) +router.get( + '/worker-platform/order-service/auto-publish-jobs', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => listAdminOrderServiceAutoPublishJobs(req.query.status), { + successMessage: 'ok', + errorMessage: '读取自动发布任务失败', + scope: '[admin/worker-platform/order-service/auto-publish-jobs]', + }), +) + +router.post( + '/worker-platform/order-service/auto-publish-jobs/:jobId/retry', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => retryAdminOrderServiceAutoPublishJob(String(req.params.jobId || '')), { + successMessage: '自动发布任务已重新排队', + errorMessage: '重试自动发布任务失败', + scope: '[admin/worker-platform/order-service/auto-publish-jobs/:jobId/retry]', + audit: (req) => ({ + action: 'order_service_auto_publish_retried', + targetType: 'order_service_auto_publish_job', + targetId: String(req.params.jobId || ''), + data: {}, + }), + }), +) + export default router diff --git a/apps/backend/src/routes/collect.ts b/apps/backend/src/routes/collect.ts deleted file mode 100644 index 3fabb592..00000000 --- a/apps/backend/src/routes/collect.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Router } from 'express' - -import { - createFailureCooldownMiddleware, - 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' -import { uploadSingleFile } from './file-upload.js' - -const router = Router() - -const collectRateLimit = createRateLimitMiddleware({ - scope: 'collect', - windowMs: 60_000, - max: 60, - key: getBodyFieldRateLimitKey('orderNo'), -}) - -const materialLinkReadRateLimit = createRateLimitMiddleware({ - scope: 'collect:link:read', - windowMs: 60_000, - max: 120, - key: getParamRateLimitKey('code'), -}) - -/** 平台订单号可被枚举,额外加一层只按 IP 的总限流,避免换 code 绕过单链接限流。 */ -const materialLinkReadIpRateLimit = createRateLimitMiddleware({ - scope: 'collect:link:read:ip', - windowMs: 60_000, - 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, - max: 20, - 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('code'), -}) - -const materialLinkUploadIpRateLimit = createRateLimitMiddleware({ - scope: 'collect:link:upload:ip', - windowMs: 60_000, - max: 60, -}) - -router.post( - '/lookup', - collectRateLimit, - createRouteHandler((req) => collectLookupWorkOrder(req.body || {}), { - successMessage: 'ok', - errorMessage: '查询待完善订单失败', - scope: '[collect/lookup]', - }), -) - -router.post( - '/submit', - collectRateLimit, - createRouteHandler((req) => collectSubmitWorkOrder(req.body || {}), { - successMessage: '资料已提交', - errorMessage: '提交补充资料失败', - scope: '[collect/submit]', - }), -) - -router.post( - '/upload', - collectRateLimit, - uploadSingleFile, - createRouteHandler( - (req) => { - return uploadFileAsset({ - file: req.file, - scene: req.body?.scene || 'collect-material', - uploaderType: 'collect', - uploaderId: String(req.body?.orderNo || 'anonymous'), - }) - }, - { - successMessage: '文件已上传', - errorMessage: '文件上传失败', - scope: '[collect/upload]', - }, - ), -) - -router.get( - '/link/:code', - materialLinkReadFailureCooldown, - materialLinkReadIpRateLimit, - materialLinkReadRateLimit, - createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), { - errorMessage: '读取补资料链接失败', - scope: '[collect/link/:code]', - }), -) - -router.post( - '/link/:code/submit', - materialLinkWriteIpRateLimit, - materialLinkWriteRateLimit, - createRouteHandler( - (req) => submitPublicWorkOrderMaterialLink(String(req.params.code || ''), req.body || {}), - { - successMessage: '资料已提交', - errorMessage: '提交补资料失败', - scope: '[collect/link/:code/submit]', - }, - ), -) - -router.post( - '/link/:code/upload', - materialLinkUploadIpRateLimit, - materialLinkUploadRateLimit, - uploadSingleFile, - createRouteHandler( - async (req) => { - const { order } = await getPublicWorkOrderMaterialLinkUploadContext( - String(req.params.code || ''), - req.query.workOrderId, - ) - return uploadFileAsset({ - file: req.file, - scene: req.body?.scene || 'collect-material', - uploaderType: 'collect', - uploaderId: `work-order-${order.id}`, - }) - }, - { - successMessage: '文件已上传', - errorMessage: '文件上传失败', - scope: '[collect/link/:code/upload]', - }, - ), -) - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) - -export default router diff --git a/apps/backend/src/routes/order-service.ts b/apps/backend/src/routes/order-service.ts new file mode 100644 index 00000000..743671d1 --- /dev/null +++ b/apps/backend/src/routes/order-service.ts @@ -0,0 +1,119 @@ +import { Router } from 'express' + +import { + createFailureCooldownMiddleware, + createRateLimitMiddleware, + getParamRateLimitKey, +} from '../middleware/rate-limit.js' +import { + getOrderServiceByPlatformOrderId, + getOrderServiceUploadContext, + submitOrderServiceMaterial, +} from '../services/worker-platform/index.js' +import { + ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, + uploadFileAsset, +} from '../services/file-storage/file-storage-service.js' +import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' +import { uploadSingleFile } from './file-upload.js' + +const router = Router() + +const readRateLimit = createRateLimitMiddleware({ + scope: 'order-service:read', + windowMs: 60_000, + max: 120, + key: getParamRateLimitKey('orderNo'), +}) + +const readIpRateLimit = createRateLimitMiddleware({ + scope: 'order-service:read:ip', + windowMs: 60_000, + max: 300, +}) + +const readFailureCooldown = createFailureCooldownMiddleware({ + scope: 'order-service:read:fail:ip', + cooldownMs: 2_000, +}) + +const writeRateLimit = createRateLimitMiddleware({ + scope: 'order-service:write', + windowMs: 60_000, + max: 20, + key: getParamRateLimitKey('orderNo'), +}) + +const writeIpRateLimit = createRateLimitMiddleware({ + scope: 'order-service:write:ip', + windowMs: 60_000, + max: 40, +}) + +const uploadRateLimit = createRateLimitMiddleware({ + scope: 'order-service:upload', + windowMs: 60_000, + max: 30, + key: getParamRateLimitKey('orderNo'), +}) + +const uploadIpRateLimit = createRateLimitMiddleware({ + scope: 'order-service:upload:ip', + windowMs: 60_000, + max: 60, +}) + +router.get( + '/:orderNo', + readFailureCooldown, + readIpRateLimit, + readRateLimit, + createRouteHandler((req) => getOrderServiceByPlatformOrderId(req.params.orderNo), { + errorMessage: '读取订单服务信息失败', + scope: '[order-service/:orderNo]', + }), +) + +router.post( + '/:orderNo/material', + writeIpRateLimit, + writeRateLimit, + createRouteHandler((req) => submitOrderServiceMaterial(req.params.orderNo, req.body || {}), { + successMessage: '资料已提交', + errorMessage: '提交订单资料失败', + scope: '[order-service/:orderNo/material]', + }), +) + +router.post( + '/:orderNo/files', + uploadIpRateLimit, + uploadRateLimit, + uploadSingleFile, + createRouteHandler( + async (req) => { + const { order } = await getOrderServiceUploadContext( + req.params.orderNo, + req.query.workOrderId, + ) + return uploadFileAsset({ + file: req.file, + scene: 'collect-material', + uploaderType: 'collect', + uploaderId: `work-order-${order.id}`, + allowedContentTypes: ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, + }) + }, + { + successMessage: '文件已上传', + errorMessage: '文件上传失败', + scope: '[order-service/:orderNo/files]', + }, + ), +) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router diff --git a/apps/backend/src/routes/redeem.ts b/apps/backend/src/routes/redeem.ts index 8721f452..d1fd0124 100644 --- a/apps/backend/src/routes/redeem.ts +++ b/apps/backend/src/routes/redeem.ts @@ -9,7 +9,7 @@ import { createRouteHandler } from '../utils/http.js' * * 公开接口(无需登录),但按「订单号 + IP」限流:这是无 token 方案的第一道防线 * (见 docs/抖音-码速达/05-最小接入方案分析.md §3.2)。 - * 与 `/collect`(routes/collect.ts)完全同型。 + * 订单资料与进度查询已统一到 `/api/v1/o`(routes/order-service.ts)。 */ const router = Router() diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index 2d01e067..ec1c2192 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -59,7 +59,12 @@ const ALLOWED_CONTENT_TYPES = new Set([ 'video/webm', ]) -const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) +export const ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', +]) +const IMAGE_CONTENT_TYPES = ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES export type UploadedFileDto = { objectKey: string @@ -78,6 +83,7 @@ type UploadFileAssetInput = { scene: unknown uploaderType: string uploaderId: string + allowedContentTypes?: ReadonlySet /** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */ durationSeconds?: unknown } @@ -92,11 +98,17 @@ export async function uploadFileAsset(input: UploadFileAssetInput) { } const contentType = normalizeContentType(file.mimetype, file.buffer) - if (!ALLOWED_CONTENT_TYPES.has(contentType)) { - throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', { - statusCode: 400, - errorCode: 'upload_file_type_invalid', - }) + const allowedContentTypes = input.allowedContentTypes || ALLOWED_CONTENT_TYPES + if (!allowedContentTypes.has(contentType)) { + throw createHttpError( + input.allowedContentTypes + ? '文件不符合规则,仅支持 JPG、PNG、WebP 图片' + : '文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', + { + statusCode: 400, + errorCode: 'upload_file_type_invalid', + }, + ) } const isVideo = VIDEO_CONTENT_TYPES.has(contentType) diff --git a/apps/backend/src/services/notification/customer-order-notification-service.ts b/apps/backend/src/services/notification/customer-order-notification-service.ts new file mode 100644 index 00000000..e10f6c96 --- /dev/null +++ b/apps/backend/src/services/notification/customer-order-notification-service.ts @@ -0,0 +1,43 @@ +import { query } from '../../db/client.js' +import { nowIso } from '../../utils/time.js' + +export async function enqueueCustomerOrderNotification(input: { + orderId?: number | string | null + orderItemId?: number | string | null + workOrderId?: number | string | null + eventType: string + templateKey?: string + payload?: Record +}) { + const dedupeKey = [ + 'order', + input.orderId || 0, + input.workOrderId || input.orderItemId || 0, + input.eventType, + ].join(':') + + await query( + ` + INSERT INTO customer_order_notifications ( + order_id, order_item_id, work_order_id, event_type, + channel, status, phone, template_key, payload_json, + dedupe_key, next_run_at + ) VALUES ( + $1, $2, $3, $4, + 'sms', 'skipped', '', $5, $6::jsonb, + $7, NOW() + ) + ON CONFLICT (dedupe_key) DO NOTHING + `, + [ + input.orderId ? Number(input.orderId) : null, + input.orderItemId ? Number(input.orderItemId) : null, + input.workOrderId ? Number(input.workOrderId) : null, + input.eventType, + input.templateKey || input.eventType, + JSON.stringify(input.payload || {}), + dedupeKey, + ], + ) + return { dedupeKey, at: nowIso() } +} diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index 923f9078..0537c89c 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -18,6 +18,7 @@ const PRODUCT_NORMALIZE_JOB_ID = 'product-normalize' const COST_SNAPSHOT_JOB_ID = 'cost-snapshot' const SALES_FACTS_JOB_ID = 'sales-facts' const LEDGER_POST_JOB_ID = 'ledger-post' +const ORDER_SERVICE_AUTO_PUBLISH_JOB_ID = 'order-service-auto-publish' export function getScheduledJobsFilePath() { return SCHEDULED_JOBS_FILE_PATH @@ -89,6 +90,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) { const hasCostSnapshot = jobs.some((item) => item.id === COST_SNAPSHOT_JOB_ID) const hasSalesFacts = jobs.some((item) => item.id === SALES_FACTS_JOB_ID) const hasLedgerPost = jobs.some((item) => item.id === LEDGER_POST_JOB_ID) + const hasOrderServiceAutoPublish = jobs.some( + (item) => item.id === ORDER_SERVICE_AUTO_PUBLISH_JOB_ID, + ) if (!hasWorkOrderTimeout) { jobs.push(createDefaultWorkOrderTimeoutJob()) @@ -111,6 +115,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) { if (!hasCostSnapshot) jobs.push(createDefaultCostSnapshotJob()) if (!hasSalesFacts) jobs.push(createDefaultSalesFactsJob()) if (!hasLedgerPost) jobs.push(createDefaultLedgerPostJob()) + if (!hasOrderServiceAutoPublish) jobs.push(createDefaultOrderServiceAutoPublishJob()) return { enabled: typeof source.enabled === 'boolean' ? source.enabled : true, @@ -158,6 +163,9 @@ function normalizeScheduledJob(rawValue: unknown) { if (type === 'ledger_post') { return normalizeLedgerPostJob(rawValue) } + if (type === 'order_service_auto_publish') { + return normalizeOrderServiceAutoPublishJob(rawValue) + } return null } @@ -228,6 +236,7 @@ function createDefaultScheduledJobsConfig() { createDefaultCostSnapshotJob(), createDefaultSalesFactsJob(), createDefaultLedgerPostJob(), + createDefaultOrderServiceAutoPublishJob(), ], } } @@ -300,6 +309,19 @@ function normalizeSalesFactsJob(rawValue: JsonObject) { } } +function normalizeOrderServiceAutoPublishJob(rawValue: JsonObject) { + const config = isPlainObject(rawValue.config) ? rawValue.config : {} + return { + id: ORDER_SERVICE_AUTO_PUBLISH_JOB_ID, + type: 'order_service_auto_publish', + enabled: rawValue.enabled !== false, + intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), + config: { + limit: normalizeRangeInteger(config.limit, 50, 1, 200), + }, + } +} + function normalizeLedgerPostJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { @@ -353,6 +375,16 @@ function createDefaultCostSnapshotJob() { } } +function createDefaultOrderServiceAutoPublishJob() { + return { + id: ORDER_SERVICE_AUTO_PUBLISH_JOB_ID, + type: 'order_service_auto_publish', + enabled: true, + intervalSeconds: 60, + config: { limit: 50 }, + } +} + function createDefaultSalesFactsJob() { return { id: SALES_FACTS_JOB_ID, diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index 37f3758c..90b0fd4b 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -3,6 +3,7 @@ import { query } from '../../db/client.js' import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js' import { runAffiliateFallback } from '../fulfillment/affiliate-fallback-service.js' import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js' +import { processDueOrderServiceAutoPublishJobs } from '../worker-platform/order-service-auto-publish-service.js' import { resolveExceptionsForItemsByReason, resolveOrphanCostExceptions, @@ -497,6 +498,16 @@ export async function runSalesFactsJob(job: JsonObject = {}) { } } +export async function runOrderServiceAutoPublishJobs(job: JsonObject = {}) { + const config = jobConfig(job) + const result = await processDueOrderServiceAutoPublishJobs(clampInt(config.limit, 50, 1, 200)) + return { + status: 'ok', + message: `订单服务自动发布:处理 ${result.scanned},成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`, + ...result, + } +} + export async function runLedgerPostJob(job: JsonObject = {}) { const config = jobConfig(job) const days = clampInt(config.days, 3, 1, 60) diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index 8a407e25..537d9b92 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -11,6 +11,7 @@ import { runChannelAssignJob, runCostSnapshotJob, runLedgerPostJob, + runOrderServiceAutoPublishJobs, runProductNormalizeJob, runSalesFactsJob, } from './finance-pipeline-jobs.js' @@ -230,6 +231,9 @@ function dispatchJob(job: JsonObject) { if (job.type === 'ledger_post') { return runLedgerPostJob(job) } + if (job.type === 'order_service_auto_publish') { + return runOrderServiceAutoPublishJobs(job) + } throw createHttpError(`不支持的定时任务类型:${job.type}`, { statusCode: 400, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index f09e6fe3..030c8fa9 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -16,8 +16,10 @@ import { } from '../../repositories/worker-platform/index.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 { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' +import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js' import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' import { consumeIndustryVouchersBeforeWorkOrderPublish, @@ -342,7 +344,11 @@ export async function reopenAdminCancelledWorkOrder(workOrderId: number | string return { order: mapWorkOrderAdmin(updated) } } -export async function publishAdminWorkOrder(workOrderId: number | string, actorName = '') { +export async function publishAdminWorkOrder( + workOrderId: number | string, + actorName = '', + options: { requireVoucherConsume?: boolean } = {}, +) { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) { throw createHttpError('只有未分配订单可以发布到大厅', { @@ -358,21 +364,9 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN } const now = nowIso() - const published = await publishUnassignedWorkOrder({ - workOrderId: workOrder.id, - now, - actorName, - }) - if (!published.order) { - throw createHttpError('订单状态已变化,无法发布,请刷新后重试', { - statusCode: 409, - errorCode: 'work_order_publish_conflict', - }) - } - let voucherConsume: WorkOrderVoucherConsumeResult try { - voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(published.order) + voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder) } catch (error) { voucherConsume = { ok: false, @@ -390,15 +384,46 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN actorType: 'system', actorId: 'kuaishou_send_code', eventType: 'publish_voucher_consume_failed', - fromStatus: WORK_ORDER_STATUS.OPEN, - toStatus: WORK_ORDER_STATUS.OPEN, + fromStatus: workOrder.status, + toStatus: workOrder.status, payloadJson: JSON.stringify(voucherConsume), now: nowIso(), }) + if (options.requireVoucherConsume === true) { + throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', { + statusCode: 409, + errorCode: 'work_order_publish_voucher_consume_failed', + context: { voucherConsume }, + }) + } + } + + const published = await publishUnassignedWorkOrder({ + workOrderId: workOrder.id, + now, + actorName, + }) + if (!published.order) { + throw createHttpError('订单状态已变化,无法发布,请刷新后重试', { + statusCode: 409, + errorCode: 'work_order_publish_conflict', + }) } const currentOrder = await getRequiredWorkOrder(workOrder.id) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true }) + await enqueueCustomerOrderNotification({ + orderId: currentOrder.order_id, + orderItemId: currentOrder.order_item_id, + workOrderId: currentOrder.id, + eventType: 'work_order_published', + payload: { source: 'work_order_publish' }, + }).catch((error) => { + logWarn('[work-order/publish]', '客户发布通知写入失败', { + workOrderId: currentOrder.id, + error: error instanceof Error ? error.message : String(error), + }) + }) const hallConfig = getWorkerHallConfig() const hallEntry = await listWorkOrders({ page: 1, diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index 31fd115a..43a7cfbe 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -531,7 +531,14 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { if (existingRule && enabled) { await assertWorkOrderTemplateCanEnable(existingRule.id) } - const fields = normalizeRequirementFieldsFromPayload(payload) + const hasRequirementPayload = ['fields', 'requirement', 'fieldsText', 'requiredFieldsText'].some( + (key) => hasOwnPayload(payload, key), + ) + const fields = hasRequirementPayload + ? normalizeRequirementFieldsFromPayload(payload) + : normalizeRequirementFieldsFromPayload({ + fields: safeParseJson(existingRule?.requirement_json).fields, + }) const ruleKey = existingRule?.rule_key || randomId('rule-') const hasCategoryPayload = hasOwnPayload(payload, 'categoryId') || hasOwnPayload(payload, 'category_id') diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 29aaf7b4..7dd2c6ab 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -13,7 +13,6 @@ export * from './worker-profile-service.js' export * from './worker-hall-service.js' export * from './worker-order-timeout-service.js' export * from './worker-order-realtime-service.js' -export * from './worker-collect-service.js' export * from './worker-acceptance-service.js' export * from './worker-gift-material-service.js' export * from './worker-my-order-service.js' @@ -67,3 +66,6 @@ export * from './sync-worker-orders-service.js' export * from './consignment-contact-service.js' export * from './consignment-pending-match-service.js' export * from './work-order-material-link-service.js' +export * from './order-service-profile-service.js' +export * from './order-service-public-service.js' +export * from './order-service-auto-publish-service.js' diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 4cb76d59..c912447c 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -84,7 +84,6 @@ export { export { isWorkerOrderVipEvidencePending, - mapCollectLookupOrder, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderForHall, diff --git a/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts b/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts new file mode 100644 index 00000000..722072ba --- /dev/null +++ b/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts @@ -0,0 +1,328 @@ +import process from 'node:process' + +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { publishAdminWorkOrder } from './admin-work-order-management-service.js' + +type Queryable = Pick + +type AutoPublishJobRow = { + id: number + work_order_id: number + status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'skipped' + attempt_count: number + max_attempts: number + next_run_at: string + last_error: string + result_json: Record + locked_until: string | null + locked_by: string + finished_at: string | null +} + +const ACTIVE_JOB_STATUSES = ['pending', 'processing'] as const +const JOB_STATUSES = new Set(['pending', 'processing', 'succeeded', 'failed', 'skipped']) +const JOB_LEASE_SECONDS = 120 + +export async function enqueueOrderServiceAutoPublish( + input: { + workOrderId: number | string + maxAttempts?: number + }, + client?: Queryable, +) { + const workOrderId = Number(input.workOrderId) + if (!Number.isInteger(workOrderId) || workOrderId <= 0) return null + const maxAttempts = Math.max(1, Math.min(11, Math.trunc(Number(input.maxAttempts ?? 4) || 4))) + const db = client || { query } + const inserted = await db.query<{ id: number }>( + ` + INSERT INTO order_service_auto_publish_jobs (work_order_id, max_attempts) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + RETURNING id + `, + [workOrderId, maxAttempts], + ) + if (inserted.rows[0]) return Number(inserted.rows[0].id) + + const active = await db.query<{ id: number }>( + ` + SELECT id + FROM order_service_auto_publish_jobs + WHERE work_order_id = $1 + AND status = ANY($2::text[]) + LIMIT 1 + `, + [workOrderId, [...ACTIVE_JOB_STATUSES]], + ) + return active.rows[0] ? Number(active.rows[0].id) : null +} + +export async function processDueOrderServiceAutoPublishJobs(limitInput: unknown = 10) { + const limit = Math.min(200, Math.max(1, Math.trunc(Number(limitInput) || 10))) + const workerId = `order-service:${process.pid}` + const pending = await query( + ` + WITH due AS ( + SELECT id + FROM order_service_auto_publish_jobs + WHERE ( + (status = 'pending' AND next_run_at <= NOW()) + OR (status = 'processing' AND locked_until < NOW()) + ) + ORDER BY next_run_at, id + FOR UPDATE SKIP LOCKED + LIMIT $1 + ) + UPDATE order_service_auto_publish_jobs job + SET status = 'processing', + attempt_count = job.attempt_count + 1, + locked_until = NOW() + ($2::text || ' seconds')::interval, + locked_by = $3, + updated_at = NOW() + FROM due + WHERE job.id = due.id + RETURNING job.* + `, + [limit, String(JOB_LEASE_SECONDS), workerId], + ) + + const succeeded: number[] = [] + const failed: number[] = [] + const skipped: number[] = [] + for (const job of pending.rows) { + const outcome = await processAutoPublishJob(job).catch(async (error) => { + const message = error instanceof Error ? error.message : String(error) + return retryOrFailJob(job, message) + }) + if (outcome === 'succeeded') succeeded.push(Number(job.id)) + if (outcome === 'failed') failed.push(Number(job.id)) + if (outcome === 'skipped') skipped.push(Number(job.id)) + } + + return { + scanned: pending.rows.length, + succeeded: succeeded.length, + failed: failed.length, + skipped: skipped.length, + } +} + +async function processAutoPublishJob( + job: AutoPublishJobRow, +): Promise<'succeeded' | 'failed' | 'skipped'> { + const workOrderId = Number(job.work_order_id) + const orderResult = await query<{ + status: string + reward_amount: number + service_profile_snapshot_json: unknown + }>( + ` + SELECT status, reward_amount, service_profile_snapshot_json + FROM work_orders + WHERE id = $1 + LIMIT 1 + `, + [workOrderId], + ) + const order = orderResult.rows[0] + if (!order || order.status !== WORK_ORDER_STATUS.UNASSIGNED) { + await finishJob(job, 'skipped', '订单已不在待分配状态', {}) + return 'skipped' + } + if (Number(order.reward_amount || 0) <= 0) { + await finishJob(job, 'failed', '接单金额为 0,不能自动发布', {}) + return 'failed' + } + + const snapshot = safeParseJson(order.service_profile_snapshot_json) + const autoPublish = + snapshot.autoPublish && typeof snapshot.autoPublish === 'object' + ? (snapshot.autoPublish as Record) + : {} + const requireVoucherConsume = autoPublish.requireVoucherConsume !== false + + try { + const result = await publishAdminWorkOrder(workOrderId, '系统:订单服务自动发布', { + requireVoucherConsume, + }) + await finishJob(job, 'succeeded', '', result) + return 'succeeded' + } catch (error) { + return retryOrFailJob(job, error instanceof Error ? error.message : String(error)) + } +} + +async function retryOrFailJob(job: AutoPublishJobRow, errorMessage: string): Promise<'failed'> { + if (Number(job.attempt_count || 0) >= Number(job.max_attempts || 4)) { + await finishJob(job, 'failed', errorMessage, {}) + return 'failed' + } + + const delaySeconds = Math.min(300, 15 * 2 ** Math.max(0, Number(job.attempt_count || 1) - 1)) + await query( + ` + UPDATE order_service_auto_publish_jobs + SET status = 'pending', + last_error = $2, + next_run_at = NOW() + ($3::text || ' seconds')::interval, + locked_until = NULL, + locked_by = '', + updated_at = NOW() + WHERE id = $1 + `, + [Number(job.id), errorMessage, String(delaySeconds)], + ) + return 'failed' +} + +async function finishJob( + job: AutoPublishJobRow, + status: 'succeeded' | 'failed' | 'skipped', + errorMessage: string, + result: Record, +) { + const now = nowIso() + await query( + ` + UPDATE order_service_auto_publish_jobs + SET status = $2, + last_error = $3, + result_json = $4::jsonb, + locked_until = NULL, + locked_by = '', + finished_at = $5, + updated_at = $5 + WHERE id = $1 + AND status = 'processing' + `, + [Number(job.id), status, errorMessage, JSON.stringify(result), now], + ) + await query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, + from_status, to_status, payload_json, created_at + ) + SELECT + $2, 'system', 'order_service_auto_publish', $3, + status, status, $4::jsonb, $5 + FROM work_orders WHERE id = $1 + `, + [ + Number(job.work_order_id), + Number(job.work_order_id), + `auto_publish_${status}`, + JSON.stringify({ jobId: Number(job.id), status, error: errorMessage, result }), + now, + ], + ) +} + +export async function listAdminOrderServiceAutoPublishJobs(statusInput: unknown = '') { + const rawStatus = String(statusInput || '').trim() + const status = JOB_STATUSES.has(rawStatus) ? rawStatus : '' + const result = await query( + ` + SELECT job.*, wo.product_name, wo.platform_order_id, wo.status AS work_order_status + FROM order_service_auto_publish_jobs job + JOIN work_orders wo ON wo.id = job.work_order_id + ${status ? 'WHERE job.status = $1' : ''} + ORDER BY job.updated_at DESC, job.id DESC + LIMIT 100 + `, + status ? [status] : [], + ) + return { + items: result.rows.map((row: Record) => ({ + id: Number(row.id), + workOrderId: Number(row.work_order_id), + status: String(row.status || ''), + attemptCount: Number(row.attempt_count || 0), + maxAttempts: Number(row.max_attempts || 0), + nextRunAt: row.next_run_at || null, + lastError: String(row.last_error || ''), + result: safeParseJson(row.result_json), + productName: String(row.product_name || ''), + platformOrderId: String(row.platform_order_id || ''), + workOrderStatus: String(row.work_order_status || ''), + createdAt: row.created_at || null, + updatedAt: row.updated_at || null, + })), + } +} + +export async function retryAdminOrderServiceAutoPublishJob(jobIdInput: number | string) { + const jobId = Number(jobIdInput) + if (!Number.isInteger(jobId) || jobId <= 0) { + throw createHttpError('任务不存在', { + statusCode: 400, + errorCode: 'order_service_auto_publish_job_invalid', + }) + } + return withTransaction(async (client) => { + const locked = await client.query( + `SELECT * FROM order_service_auto_publish_jobs WHERE id = $1 FOR UPDATE`, + [jobId], + ) + const job = locked.rows[0] + if (!job) { + throw createHttpError('任务不存在', { + statusCode: 404, + errorCode: 'order_service_auto_publish_job_not_found', + }) + } + if (job.status === 'processing') { + throw createHttpError('任务正在处理,请稍后刷新', { + statusCode: 409, + errorCode: 'order_service_auto_publish_job_processing', + }) + } + if (job.status !== 'failed' && job.status !== 'skipped') { + throw createHttpError('只有失败或跳过的任务可以重试', { + statusCode: 409, + errorCode: 'order_service_auto_publish_job_status_invalid', + }) + } + const active = await client.query<{ id: number }>( + ` + SELECT id + FROM order_service_auto_publish_jobs + WHERE work_order_id = $1 + AND status IN ('pending', 'processing') + AND id <> $2 + LIMIT 1 + `, + [job.work_order_id, jobId], + ) + if (active.rows[0]) { + throw createHttpError('该工单已有待处理的自动发布任务', { + statusCode: 409, + errorCode: 'order_service_auto_publish_job_already_active', + }) + } + const result = await client.query<{ id: number }>( + ` + UPDATE order_service_auto_publish_jobs + SET status = 'pending', + attempt_count = 0, + next_run_at = NOW(), + last_error = '', + locked_until = NULL, + locked_by = '', + finished_at = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING id + `, + [jobId], + ) + return { jobId: Number(result.rows[0]?.id || 0) } + }) +} diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts new file mode 100644 index 00000000..21555cd8 --- /dev/null +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + isOrderServiceMaterialRequired, + normalizeProfileConfig, +} from './order-service-profile-service.js' + +function config(patch: Record = {}) { + return normalizeProfileConfig({ + name: '测试配置', + fields: [ + { + key: 'gameId', + label: '游戏编号', + type: 'text', + required: false, + }, + ], + screenshots: { + enabled: true, + required: false, + minCount: 0, + maxCount: 5, + }, + autoPublish: { + enabled: false, + requireVoucherConsume: true, + retryMax: 3, + }, + ...patch, + }) +} + +test('订单服务资料判断:所有字段选填且截图不强制时不阻塞建单', () => { + assert.equal(isOrderServiceMaterialRequired(config()), false) +}) + +test('订单服务资料判断:必填字段或必填截图都会让工单进入待补资料', () => { + assert.equal( + isOrderServiceMaterialRequired( + config({ + fields: [ + { + key: 'gameId', + label: '游戏编号', + type: 'text', + required: true, + }, + ], + }), + ), + true, + ) + assert.equal( + isOrderServiceMaterialRequired( + config({ + screenshots: { + enabled: true, + required: true, + minCount: 1, + maxCount: 5, + }, + }), + ), + true, + ) +}) + +test('订单服务配置规范化:未知字段类型降级为文本,空下拉字段丢弃', () => { + const normalized = normalizeProfileConfig({ + fields: [ + { key: 'a', label: 'A', type: 'unknown' }, + { key: 'b', label: 'B', type: 'select', options: [] }, + ], + }) + assert.equal(normalized.fields.length, 1) + assert.equal(normalized.fields[0]?.type, 'text') +}) diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.ts new file mode 100644 index 00000000..c0844791 --- /dev/null +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.ts @@ -0,0 +1,374 @@ +import type { PoolClient } from 'pg' + +import { withTransaction } from '../../db/client.js' +import { query } from '../../db/client.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { + archiveOrderServiceProfile, + createOrderServiceProfileVersion, + getOrderServiceProfileVersion, + getPublishedOrderServiceProfileByCategory, + listOrderServiceProfiles, + setOrderServiceProfileCurrentVersion, + upsertOrderServiceProfile, + type OrderServiceProfileRow, +} from '../../repositories/worker-platform/order-service-profile-repo.js' +import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js' +import { normalizeUploadedFiles } from './worker-input-normalizers.js' + +export type OrderServiceFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date' + +export type OrderServiceField = { + key: string + label: string + type: OrderServiceFieldType + required: boolean + placeholder: string + helpText: string + options: string[] + sortOrder: number +} + +export type OrderServiceScreenshots = { + enabled: boolean + required: boolean + minCount: number + maxCount: number + label: string + helpText: string +} + +export type OrderServiceAutoPublish = { + enabled: boolean + requireVoucherConsume: boolean + retryMax: number +} + +export type OrderServiceProfileConfig = { + name: string + title: string + description: string + guideImage: Record + fields: OrderServiceField[] + screenshots: OrderServiceScreenshots + autoPublish: OrderServiceAutoPublish + submitText: string + successText: string +} + +export type OrderServiceProfileSnapshot = { + profileId: number + categoryId: number + versionId: number + versionNo: number + config: OrderServiceProfileConfig +} + +const FIELD_TYPES = new Set([ + 'text', + 'textarea', + 'select', + 'radio', + 'number', + 'date', +]) + +export async function listAdminOrderServiceProfiles() { + const [categories, profiles] = await Promise.all([ + listAllWorkCategories(), + listOrderServiceProfiles(), + ]) + return { + categories: categories.map((category) => ({ + categoryId: Number(category.id), + categoryKey: category.category_key, + name: category.name, + parentId: category.parent_id ? Number(category.parent_id) : null, + status: category.status, + })), + profiles: profiles.map(mapAdminProfile), + } +} + +export async function saveAdminOrderServiceProfile(payload: JsonObject = {}) { + const categoryId = Number(payload.categoryId ?? payload.category_id ?? 0) + if (!Number.isInteger(categoryId) || categoryId <= 0) { + throw createHttpError('请选择分类', { + statusCode: 400, + errorCode: 'order_service_category_required', + }) + } + const categoryResult = await query<{ id: number }>( + 'SELECT id FROM work_categories WHERE id = $1 LIMIT 1', + [categoryId], + ) + if (!categoryResult.rows[0]) { + throw createHttpError('分类不存在', { + statusCode: 404, + errorCode: 'work_category_not_found', + }) + } + + const fields = normalizeFields(payload.fields ?? payload.fields_json) + const screenshots = normalizeScreenshots(payload.screenshots ?? payload.screenshots_json) + const autoPublish = normalizeAutoPublish(payload.autoPublish ?? payload.auto_publish_json) + const guideImage = normalizeGuideImage(payload.guideImage ?? payload.guide_image_json) + const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null + + const saved = await upsertOrderServiceProfile({ + categoryId, + name: text(payload.name, 80) || '订单服务配置', + title: text(payload.title, 80), + description: text(payload.description, 300), + guideImage, + fields, + screenshots, + autoPublish, + submitText: text(payload.submitText, 30) || '提交资料', + successText: text(payload.successText, 80) || '资料已提交,请等待处理', + adminUserId, + }) + return { profile: mapAdminProfile(saved) } +} + +export async function publishAdminOrderServiceProfile( + profileIdInput: number | string, + adminUserIdInput: unknown = null, +) { + const profileId = Number(profileIdInput) + if (!Number.isInteger(profileId) || profileId <= 0) { + throw createHttpError('配置不存在', { + statusCode: 400, + errorCode: 'order_service_profile_invalid', + }) + } + const adminUserId = Number(adminUserIdInput ?? 0) || null + + return withTransaction(async (client) => { + const locked = await client.query( + `SELECT * FROM order_service_profiles WHERE id = $1 FOR UPDATE`, + [profileId], + ) + const profile = locked.rows[0] + if (!profile) { + throw createHttpError('订单服务配置不存在', { + statusCode: 404, + errorCode: 'order_service_profile_not_found', + }) + } + const config = buildProfileConfig(profile) + if (config.fields.length === 0) { + throw createHttpError('至少需要配置一个资料字段', { + statusCode: 400, + errorCode: 'order_service_fields_required', + }) + } + const { versionId, versionNo } = await createOrderServiceProfileVersion(client, { + profileId, + config, + adminUserId, + }) + await setOrderServiceProfileCurrentVersion(client, { profileId, versionId }) + return { versionNo, profileId, versionId } + }) +} + +export async function archiveAdminOrderServiceProfile(profileIdInput: number | string) { + const profileId = Number(profileIdInput) + const profile = await archiveOrderServiceProfile(profileId) + if (!profile) { + throw createHttpError('订单服务配置不存在', { + statusCode: 404, + errorCode: 'order_service_profile_not_found', + }) + } + return { profile: mapAdminProfile(profile) } +} + +function mapAdminProfile(row: OrderServiceProfileRow) { + return { + id: Number(row.id), + categoryId: Number(row.category_id), + name: row.name, + title: row.title, + description: row.description, + guideImage: row.guide_image_json, + fields: row.fields_json, + screenshots: row.screenshots_json, + autoPublish: row.auto_publish_json, + submitText: row.submit_text, + successText: row.success_text, + status: row.status, + currentVersionId: row.current_version_id, + currentVersionNo: row.current_version_no, + updatedAt: row.updated_at, + config: buildProfileConfig(row), + } +} + +export function buildProfileConfig(row: { + name: string + title: string + description: string + guide_image_json: unknown + fields_json: unknown + screenshots_json: unknown + auto_publish_json: unknown + submit_text: string + success_text: string +}): OrderServiceProfileConfig & { guideImage: Record } { + return { + name: row.name, + title: row.title, + description: row.description, + guideImage: normalizeGuideImage(row.guide_image_json), + fields: normalizeFields(row.fields_json), + screenshots: normalizeScreenshots(row.screenshots_json), + autoPublish: normalizeAutoPublish(row.auto_publish_json), + submitText: row.submit_text || '提交资料', + successText: row.success_text || '资料已提交,请等待处理', + } +} + +export async function getPublishedOrderServiceProfileSnapshotByCategory( + categoryId: number | string, + client?: Pick, +): Promise { + const row = await getPublishedOrderServiceProfileByCategory(categoryId, client) + if (!row) return null + return { + profileId: Number(row.profile_id), + categoryId: Number(row.category_id), + versionId: Number(row.version_id), + versionNo: Number(row.version_no), + config: normalizeProfileConfig(row.config_json), + } +} + +export async function getOrderServiceProfileSnapshotByVersion(input: { + profileId: number | string + categoryId: number | string + versionId: number | string + client?: Pick +}): Promise { + const version = await getOrderServiceProfileVersion(input.versionId, input.client) + if (!version) return null + return { + profileId: Number(input.profileId), + categoryId: Number(input.categoryId), + versionId: Number(version.id), + versionNo: Number(version.version_no), + config: normalizeProfileConfig(version.config_json), + } +} + +export function isOrderServiceMaterialRequired(config: OrderServiceProfileConfig): boolean { + if (config.fields.some((field) => field.required)) return true + return config.screenshots.enabled && config.screenshots.minCount > 0 +} + +export function normalizeProfileConfig(value: JsonObject): OrderServiceProfileConfig { + return { + name: text(value.name, 80) || '订单服务配置', + title: text(value.title, 80), + description: text(value.description, 300), + guideImage: normalizeGuideImage(value.guideImage ?? value.guide_image), + fields: normalizeFields(value.fields), + screenshots: normalizeScreenshots(value.screenshots), + autoPublish: normalizeAutoPublish(value.autoPublish), + submitText: text(value.submitText, 30) || '提交资料', + successText: text(value.successText, 80) || '资料已提交,请等待处理', + } +} + +function normalizeFields(value: unknown): OrderServiceField[] { + const rows = Array.isArray(value) ? value : [] + const seen = new Set() + return rows + .map((row, index): OrderServiceField | null => { + const source = row && typeof row === 'object' ? (row as JsonObject) : {} + const key = text(source.key ?? source.fieldKey, 60).replace(/[^a-zA-Z0-9_]/g, '') + if (!key || seen.has(key)) return null + seen.add(key) + const rawType = text(source.type, 20) + const type = ( + FIELD_TYPES.has(rawType as OrderServiceFieldType) ? rawType : 'text' + ) as OrderServiceFieldType + const options = Array.isArray(source.options) + ? source.options.map((option) => text(option, 60)).filter(Boolean) + : [] + if (type !== 'select' && type !== 'radio') + return { + key, + label: text(source.label, 60) || key, + type, + required: source.required !== false, + placeholder: text(source.placeholder, 100), + helpText: text(source.helpText ?? source.help_text, 200), + options: [], + sortOrder: Number(source.sortOrder ?? source.sort_order ?? (index + 1) * 10), + } + if (options.length === 0) return null + return { + key, + label: text(source.label, 60) || key, + type, + required: source.required !== false, + placeholder: '', + helpText: text(source.helpText ?? source.help_text, 200), + options, + sortOrder: Number(source.sortOrder ?? source.sort_order ?? (index + 1) * 10), + } + }) + .filter((row): row is OrderServiceField => Boolean(row)) + .sort((left, right) => left.sortOrder - right.sortOrder) +} + +function normalizeScreenshots(value: unknown): OrderServiceScreenshots { + const source = value && typeof value === 'object' ? (value as JsonObject) : {} + const enabled = source.enabled !== false + const required = enabled && source.required !== false + const maxCount = clampInteger(source.maxCount ?? source.max_count, 5, 1, 10) + const requestedMinCount = clampInteger( + source.minCount ?? source.min_count, + required ? 1 : 0, + 0, + maxCount, + ) + return { + enabled, + required, + minCount: required ? Math.max(1, requestedMinCount) : requestedMinCount, + maxCount, + label: text(source.label, 40) || '买家主页截图', + helpText: text(source.helpText ?? source.help_text, 200), + } +} + +function normalizeAutoPublish(value: unknown): OrderServiceAutoPublish { + const source = value && typeof value === 'object' ? (value as JsonObject) : {} + return { + enabled: source.enabled === true, + requireVoucherConsume: source.requireVoucherConsume !== false, + retryMax: clampInteger(source.retryMax ?? source.retry_max, 3, 0, 10), + } +} + +function normalizeGuideImage(value: unknown): Record { + const normalized = normalizeUploadedFiles([value])[0] + return normalized ? { ...normalized } : {} +} + +function clampInteger(value: unknown, fallback: number, min: number, max: number) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, Math.trunc(parsed))) +} + +function text(value: unknown, maxLength: number) { + return String(value ?? '') + .trim() + .slice(0, maxLength) +} diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.test.ts b/apps/backend/src/services/worker-platform/order-service-public-service.test.ts new file mode 100644 index 00000000..1b3a2b6c --- /dev/null +++ b/apps/backend/src/services/worker-platform/order-service-public-service.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + resolveOrderServiceStatusLabel, + resolveOrderServiceStatusMessage, +} from './order-service-public-service.js' + +test('订单服务状态文案:待抢单提示高峰期等待', () => { + const message = resolveOrderServiceStatusMessage({ + status: WORK_ORDER_STATUS.OPEN, + canSubmit: false, + }) + assert.match(message, /高峰期/) + assert.match(message, /耐心等待/) +}) + +test('订单服务状态文案:接单与完成提示查看邮箱', () => { + assert.match( + resolveOrderServiceStatusMessage({ + status: WORK_ORDER_STATUS.IN_PROGRESS, + canSubmit: false, + }), + /30-60 分钟.*邮箱/, + ) + assert.match( + resolveOrderServiceStatusMessage({ + status: WORK_ORDER_STATUS.ACCEPTED, + canSubmit: false, + }), + /检查邮件.*客服/, + ) +}) + +test('订单服务状态文案:待补资料不显示只读提示', () => { + assert.equal( + resolveOrderServiceStatusMessage({ + status: WORK_ORDER_STATUS.PENDING_MATERIAL, + canSubmit: true, + }), + '', + ) +}) + +test('订单服务状态标签使用客户端语义', () => { + assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.OPEN), '等待接单') + assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.IN_PROGRESS), '处理中') + assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.ACCEPTED), '已完成') + assert.equal(resolveOrderServiceStatusLabel('unknown', '处理中'), '处理中') +}) diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts new file mode 100644 index 00000000..a9858884 --- /dev/null +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -0,0 +1,492 @@ +import type { PoolClient } from 'pg' + +import { withTransaction } from '../../db/client.js' +import { query } from '../../db/client.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + createWorkOrderEventWithClient, + getWorkOrderForUpdateWithClient, + updateWorkOrderMaterialWithClient, +} from '../../repositories/worker-platform/index.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' +import { getFileAssetByObjectKey } from '../../repositories/file-asset-repo.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 { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js' +import { + ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, + buildFileUrl, + refreshUploadedFileUrls, + type UploadedFileDto, +} from '../file-storage/file-storage-service.js' +import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js' +import { + pickSubmittedMaterialFields, + resolvePublicMaterialOrderState, + throwPublicMaterialBlocked, +} from './work-order-material-link-service.js' +import { + getOrderServiceProfileSnapshotByVersion, + normalizeProfileConfig, + type OrderServiceField, + type OrderServiceProfileConfig, +} from './order-service-profile-service.js' +import { + enqueueOrderServiceAutoPublish, + processDueOrderServiceAutoPublishJobs, +} from './order-service-auto-publish-service.js' + +const PUBLIC_STATUS_MESSAGES: Record = { + [WORK_ORDER_STATUS.PENDING_MATERIAL]: '请补充订单资料。', + [WORK_ORDER_STATUS.UNASSIGNED]: '资料已提交,客服正在安排处理,请耐心等待。', + [WORK_ORDER_STATUS.OPEN]: '您的订单已经发布,当前处于高峰期,请耐心等待。', + [WORK_ORDER_STATUS.IN_PROGRESS]: + '您的订单已经有人接单,请您 30-60 分钟内注意查看邮箱,确认是否收到相关邮件。', + [WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '交付内容已提交,正在确认。', + [WORK_ORDER_STATUS.ACCEPTED]: '订单已完成,请检查邮件是否收到。如有问题,请及时联系客服处理。', + [WORK_ORDER_STATUS.PROBLEM]: '订单需要客服介入处理,请联系客服。', + [WORK_ORDER_STATUS.CANCELLED]: '订单已取消。', +} + +const PUBLIC_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.ACCEPTED]: '已完成', + [WORK_ORDER_STATUS.PROBLEM]: '客服处理中', + [WORK_ORDER_STATUS.CANCELLED]: '已取消', +} + +export type OrderServicePublicItem = { + workOrderId: number + productName: string + categoryName: string + title: string + description: string + submitText: string + successText: string + state: string + stateLabel: string + message: string + canSubmit: boolean + blockedReason: string + fields?: OrderServiceField[] + values?: Record + screenshots?: Array> + screenshotsRule?: OrderServiceProfileConfig['screenshots'] + guideImage?: Record +} + +export function resolveOrderServiceStatusLabel(status: string, fallback = '处理中') { + return PUBLIC_STATUS_LABELS[String(status || '').trim()] || fallback +} + +export function resolveOrderServiceStatusMessage(input: { + status: string + canSubmit: boolean + fallbackMessage?: string +}) { + if (input.canSubmit) return '' + return ( + PUBLIC_STATUS_MESSAGES[String(input.status || '').trim()] || + String(input.fallbackMessage || '').trim() || + '订单状态已更新,如有疑问请联系客服。' + ) +} + +export async function getOrderServiceByPlatformOrderId(platformOrderIdInput: unknown) { + const platformOrderId = normalizeOrderNo(platformOrderIdInput) + const orders = await listWorkOrdersByPlatformOrderId(platformOrderId) + if (orders.length === 0) { + throw notFound() + } + + const items = await Promise.all(orders.map((order) => mapPublicOrderItem(order))) + return { + platformOrderId, + items, + pendingCount: items.filter((item) => item.canSubmit).length, + } +} + +export async function submitOrderServiceMaterial( + platformOrderIdInput: unknown, + payload: JsonObject = {}, +) { + const platformOrderId = normalizeOrderNo(platformOrderIdInput) + const target = await resolveTarget(platformOrderId, payload.workOrderId) + const targetState = resolvePublicMaterialOrderState(target) + if (!targetState.canSubmit) throwPublicMaterialBlocked(targetState) + + const result = await withTransaction(async (client) => { + const order = await getWorkOrderForUpdateWithClient(client, target.id) + if (!order) throw notFound() + if (String(order.platform_order_id || '').trim() !== platformOrderId) throw notFound() + const state = resolvePublicMaterialOrderState(order) + if (!state.canSubmit) throwPublicMaterialBlocked(state) + + const profileConfig = await resolveProfileConfig(order, client) + const fields = profileConfig?.fields || [] + if (!profileConfig || fields.length === 0) { + throw createHttpError('该订单尚未配置资料收集字段,请联系客服', { + statusCode: 409, + errorCode: 'order_service_fields_missing', + }) + } + + 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 screenshotRule = profileConfig.screenshots + const requestedScreenshots = screenshotRule.enabled + ? normalizeUploadedFiles(payload.screenshots || []) + : [] + const screenshots = await resolveOwnedOrderServiceScreenshots( + requestedScreenshots, + Number(order.id), + client, + ) + if (screenshotRule.enabled && screenshots.length < screenshotRule.minCount) { + throw createHttpError(`${screenshotRule.label}至少需要 ${screenshotRule.minCount} 张`, { + statusCode: 400, + errorCode: 'order_service_screenshots_required', + }) + } + if (screenshots.length > screenshotRule.maxCount) { + throw createHttpError(`${screenshotRule.label}最多 ${screenshotRule.maxCount} 张`, { + statusCode: 400, + errorCode: 'material_link_screenshots_too_many', + }) + } + + const now = nowIso() + const material = { + ...safeParseJson(order.material_json), + collect: { + fields: submittedFields, + screenshots, + submittedAt: now, + complete: true, + source: 'order_service', + }, + } + 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', + }) + } + + // 把分类配置同步到工单 requirement,保证打手端/后台兼容旧双读链路。 + await client.query( + `UPDATE work_orders SET requirement_json = $2::jsonb, updated_at = $3 WHERE id = $1`, + [order.id, JSON.stringify({ fields }), now], + ) + + 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: 'order_service', + }), + now, + }) + + if (profileConfig.autoPublish.enabled) { + await enqueueOrderServiceAutoPublish( + { + workOrderId: Number(order.id), + maxAttempts: profileConfig.autoPublish.retryMax + 1, + }, + client, + ) + } + + return { + order: updated, + autoPublishEnabled: profileConfig.autoPublish.enabled, + successText: profileConfig.successText, + } + }) + + await enqueueCustomerOrderNotification({ + orderId: target.order_id, + orderItemId: target.order_item_id, + workOrderId: result.order.id, + eventType: 'material_submitted', + payload: { source: 'order_service' }, + }).catch(() => undefined) + + try { + await resolveAdminNotificationEntity( + 'work_order', + Number(result.order.id), + 'material_completed', + ) + } catch (error) { + logWarn('[order-service]', '补资料完成通知处理失败', { + workOrderId: result.order.id, + error: error instanceof Error ? error.message : String(error), + }) + } + publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) }) + + if (result.autoPublishEnabled) { + void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined) + } + + return { + complete: true, + workOrderId: Number(result.order.id), + order: mapSafeOrder(result.order), + successText: result.successText, + } +} + +export async function getOrderServiceUploadContext( + platformOrderIdInput: unknown, + workOrderIdInput: unknown, +) { + const platformOrderId = normalizeOrderNo(platformOrderIdInput) + const order = await resolveTarget(platformOrderId, workOrderIdInput) + const state = resolvePublicMaterialOrderState(order) + if (!state.canSubmit) throwPublicMaterialBlocked(state) + return { order } +} + +async function listWorkOrdersByPlatformOrderId(platformOrderId: string) { + const result = await query( + ` + SELECT wo.*, wc.name AS category_name, parent_wc.name AS parent_category_name, + COALESCE(share.active_share_quantity, 0)::int AS active_share_quantity + FROM work_orders wo + LEFT JOIN work_categories wc ON wc.id = wo.category_id + LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id + LEFT JOIN LATERAL ( + SELECT SUM(quantity) AS active_share_quantity + FROM work_order_shares share + WHERE share.work_order_id = wo.id AND share.status <> 'cancelled' + ) share ON TRUE + WHERE wo.platform_order_id = $1 + ORDER BY wo.id ASC + `, + [platformOrderId], + ) + return result.rows +} + +async function resolveTarget(platformOrderId: string, workOrderIdInput: unknown) { + const workOrderId = Number(workOrderIdInput ?? 0) + if (Number.isInteger(workOrderId) && workOrderId > 0) { + const result = await query( + `SELECT wo.*, wc.name AS category_name, parent_wc.name AS parent_category_name, + COALESCE(share.active_share_quantity, 0)::int AS active_share_quantity + FROM work_orders wo + LEFT JOIN work_categories wc ON wc.id = wo.category_id + LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id + LEFT JOIN LATERAL ( + SELECT SUM(quantity) AS active_share_quantity + FROM work_order_shares share + WHERE share.work_order_id = wo.id AND share.status <> 'cancelled' + ) share ON TRUE + WHERE wo.id = $1 LIMIT 1`, + [workOrderId], + ) + const order = result.rows[0] + if (!order || String(order.platform_order_id || '').trim() !== platformOrderId) throw notFound() + return order + } + + const orders = await listWorkOrdersByPlatformOrderId(platformOrderId) + if (orders.length === 0) throw notFound() + if (orders.length > 1) { + throw createHttpError('该订单号下有多个商品,请先选择商品', { + statusCode: 409, + errorCode: 'material_link_work_order_required', + }) + } + return orders[0]! +} + +async function mapPublicOrderItem(order: WorkOrderRow): Promise { + const state = resolvePublicMaterialOrderState(order) + const profile = await resolveProfileConfig(order) + const material = safeParseJson(order.material_json) + const collect = material.collect && typeof material.collect === 'object' ? material.collect : {} + const values = normalizeSubmittedFields(collect.fields || {}) + const screenshots = normalizeUploadedFiles(collect.screenshots || []) + .map(refreshUploadedFileUrls) + .filter(Boolean) + + const base: OrderServicePublicItem = { + workOrderId: Number(order.id), + productName: String(order.product_name || ''), + categoryName: String(order.parent_category_name || order.category_name || ''), + title: profile?.title || profile?.name || '订单服务', + description: profile?.description || '', + submitText: profile?.submitText || '提交资料', + successText: profile?.successText || '资料已提交,请等待处理', + state: order.status, + stateLabel: resolveOrderServiceStatusLabel(order.status, state.statusLabel), + message: resolveOrderServiceStatusMessage({ + status: order.status, + canSubmit: state.canSubmit, + fallbackMessage: state.message, + }), + canSubmit: state.canSubmit, + blockedReason: state.blockedReason, + } + + if (!state.canSubmit) return base + + const fields = profile?.fields || [] + return { + ...base, + fields, + values, + screenshots, + screenshotsRule: profile?.screenshots || { + enabled: true, + required: true, + minCount: 0, + maxCount: 5, + label: '买家主页截图', + helpText: '', + }, + guideImage: profile?.guideImage || {}, + } +} + +async function resolveProfileConfig( + order: WorkOrderRow, + client?: Pick, +): Promise { + const snapshot = safeParseJson(order.service_profile_snapshot_json) + const config = snapshot.config + if (config && typeof config === 'object' && Array.isArray((config as JsonObject).fields)) { + return normalizeProfileConfig(config as JsonObject) + } + if (order.category_id && order.service_profile_id && order.service_profile_version_id) { + const snapshotFromVersion = await getOrderServiceProfileSnapshotByVersion({ + profileId: order.service_profile_id, + categoryId: order.category_id, + versionId: order.service_profile_version_id, + ...(client ? { client } : {}), + }) + if (snapshotFromVersion) return snapshotFromVersion.config + } + const legacy = safeParseJson(order.requirement_json) + if (Array.isArray(legacy.fields)) { + return { + name: '通用资料', + title: '', + description: '', + guideImage: {}, + fields: legacy.fields as OrderServiceField[], + screenshots: { + enabled: true, + required: true, + minCount: 0, + maxCount: 5, + label: '买家主页截图', + helpText: '', + }, + autoPublish: { enabled: false, requireVoucherConsume: true, retryMax: 3 }, + submitText: '提交资料', + successText: '资料已提交,请等待处理', + } + } + return null +} + +async function resolveOwnedOrderServiceScreenshots( + files: ReturnType, + workOrderId: number, + client: Pick, +): Promise { + const expectedUploaderId = `work-order-${workOrderId}` + const result: UploadedFileDto[] = [] + for (const file of files) { + const objectKey = String(file.objectKey || '').trim() + const asset = objectKey ? await getFileAssetByObjectKey(objectKey, client) : null + if ( + !asset || + asset.status !== 'active' || + asset.uploader_type !== 'collect' || + asset.uploader_id !== expectedUploaderId || + asset.scene !== 'collect-material' || + !ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES.has(String(asset.content_type || '').trim()) + ) { + throw createHttpError('截图文件无效,请重新上传', { + statusCode: 400, + errorCode: 'order_service_screenshot_invalid', + }) + } + result.push({ + objectKey: asset.object_key, + url: buildFileUrl(asset.object_key), + thumbnailUrl: buildFileUrl(asset.thumbnail_object_key || asset.object_key), + mediumUrl: buildFileUrl(asset.medium_object_key || asset.object_key), + filename: asset.original_filename, + contentType: asset.content_type, + size: Number(asset.size_bytes || 0), + }) + } + return result +} + +function mapSafeOrder(order: WorkOrderRow) { + return { + workOrderNo: order.work_order_no, + platformOrderId: order.platform_order_id, + productName: order.product_name, + status: order.status, + } +} + +function normalizeOrderNo(value: unknown) { + const normalized = String(value ?? '').trim() + if (!normalized) throw notFound() + return normalized +} + +function notFound() { + return createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'order_service_not_found', + }) +} diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 323bed0b..42b77a61 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -56,6 +56,10 @@ import { } from '../fulfillment/internal-sku-resolver.js' import { loadActiveCarrierEvidence } from '../fulfillment/carrier-evidence-service.js' import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js' +import { + getPublishedOrderServiceProfileSnapshotByCategory, + isOrderServiceMaterialRequired, +} from './order-service-profile-service.js' export function shouldBootstrapWorkOrderChannel( plan: InternalSkuChannelPlan | undefined, @@ -343,7 +347,12 @@ export async function syncWorkerOrdersForSourceOrder( continue } - const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields) + const serviceProfile = rule.category_id + ? await getPublishedOrderServiceProfileSnapshotByCategory(rule.category_id) + : null + const fields = serviceProfile + ? normalizeRequirementFields(serviceProfile.config.fields) + : normalizeRequirementFields(safeParseJson(rule.requirement_json).fields) const skuQuantity = resolveSkuNameQuantity(item.sku_name) const { sharingTotalQuantity, sharingUnitReward, rewardAmount } = resolveWorkOrderRulePricing({ skuQuantity, @@ -359,7 +368,9 @@ export async function syncWorkerOrdersForSourceOrder( ? false : resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity) const now = nowIso() - const materialComplete = fields.every((field) => !field.required) + const materialComplete = serviceProfile + ? !isOrderServiceMaterialRequired(serviceProfile.config) + : fields.every((field) => !field.required) // 工单素材 = source 摘要 +(抖音)该单已有的券快照:打手要能看到「交付什么券」。 // 券通常在发券后回填(certificate-service),这里覆盖「运营补模板后重新匹配」的建单路径。 let material: JsonObject = { @@ -408,6 +419,17 @@ export async function syncWorkerOrdersForSourceOrder( giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(rule.gift_cooldown_minutes), materialJson: JSON.stringify(material), requirementJson: JSON.stringify({ fields }), + serviceProfileId: serviceProfile?.profileId || null, + serviceProfileVersionId: serviceProfile?.versionId || null, + serviceProfileSnapshotJson: serviceProfile + ? JSON.stringify({ + profileId: serviceProfile.profileId, + categoryId: serviceProfile.categoryId, + versionId: serviceProfile.versionId, + versionNo: serviceProfile.versionNo, + config: serviceProfile.config, + }) + : null, now, }) if (!workOrder) { @@ -446,6 +468,8 @@ export async function syncWorkerOrdersForSourceOrder( toStatus: workOrder.status, payloadJson: JSON.stringify({ ruleKey: rule.rule_key, + serviceProfileId: serviceProfile?.profileId || null, + serviceProfileVersionId: serviceProfile?.versionId || null, orderId: order.id, orderItemId: item.id, }), 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 d2de63c7..20c7a034 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 @@ -26,10 +26,7 @@ import { import { DEFAULT_DEPOSIT_THRESHOLD_AMOUNT } from './worker-finance-mappers.js' import { normalizeUploadedFiles } from './worker-input-normalizers.js' import { resolveFreezeDepositAmount } from './worker-permission-utils.js' -import { - mapAcceptanceForResponse, - resolveCollectSubmittedFieldValues, -} from './work-order-material-mappers.js' +import { mapAcceptanceForResponse } from './work-order-material-mappers.js' import { resolveRequirementFields } from './work-order-requirement-fields.js' export function mapWorkCategory( @@ -972,16 +969,3 @@ export function mapWorkOrderPublic(workOrder: WorkOrderRow) { status: workOrder.status, } } - -export function mapCollectLookupOrder(workOrder: WorkOrderRow) { - const material = mapWorkOrderMaterial(workOrder.material_json) - return { - ...mapWorkOrderPublic(workOrder), - fields: resolveRequirementFields(workOrder), - values: resolveCollectSubmittedFieldValues(workOrder), - screenshots: - material.collect && Array.isArray(material.collect.screenshots) - ? material.collect.screenshots - : [], - } -} 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 6a894ef9..fb530e4b 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,7 +3,6 @@ import test from 'node:test' import { formatPublicMaterialStatus, - pickPendingMaterialOrders, pickSubmittedMaterialFields, resolveMaterialResetBlockReason, resolvePublicMaterialOrderState, @@ -130,22 +129,6 @@ 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 3c310555..ef6d085d 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,29 +1,6 @@ -import { withTransaction } from '../../db/client.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' -import { - createWorkOrderEventWithClient, - getWorkOrderForUpdateWithClient, - listWorkOrdersByPlatformOrderId, - updateWorkOrderMaterialWithClient, -} 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, - normalizeOptionalId, - normalizeSubmittedFields, - normalizeUploadedFiles, - resolveRequirementFields, -} from './mappers.js' -import { getRequiredWorkOrder } from './worker-session-context-service.js' - -const MAX_MATERIAL_SCREENSHOTS = 5 const PUBLIC_MATERIAL_STATUS_LABELS: Record = { [WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善', @@ -53,18 +30,11 @@ export type PublicMaterialOrderState = { 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 resolvePublicMaterialOrderState( order: Pick, ): PublicMaterialOrderState { @@ -114,12 +84,6 @@ export function resolvePublicMaterialOrderState( } } -/** - * 客服重置补资料状态的前置条件。 - * - * 只允许「待分配 + 未指派 + 无份额 + 未发布」的工单回到待完善; - * 其他状态一律拒绝,避免把已进入接单流程的工单改回可编辑。 - */ export function resolveMaterialResetBlockReason( order: Pick< WorkOrderRow, @@ -140,12 +104,6 @@ export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReaso return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态' } -/** - * 只保留工单 requirement 里定义过的字段。 - * - * 公开提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的 - * 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。 - */ export function pickSubmittedMaterialFields( fields: Array<{ key: string }>, submitted: Record, @@ -154,7 +112,7 @@ export function pickSubmittedMaterialFields( return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key))) } -function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never { +export function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never { if (state.blockedReason === 'already_submitted') { throw createHttpError(state.message, { statusCode: 409, @@ -166,272 +124,3 @@ function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never { errorCode: 'material_link_order_status_invalid', }) } - -function mapPublicOrder(order: WorkOrderRow) { - return { - workOrderNo: order.work_order_no, - platformOrderId: order.platform_order_id, - productName: order.product_name, - status: order.status, - } -} - -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]! -} - -/** - * 公开页只允许查询待完善工单。 - * - * 待分配、待抢单、代练中、已验收、已取消等状态一律不返回, - * 避免客户用订单号看到已完成订单的商品与状态。 - */ -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() - if (!code) { - throw createHttpError('订单号无效,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'material_link_not_found', - }) - } - - const orders = await listWorkOrdersByPlatformOrderId(code) - if (orders.length === 0) { - throw createHttpError('订单号无效,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'material_link_not_found', - }) - } - - 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, - pendingCount: publicOrders.filter((order) => order.canSubmit).length, - } -} - -/** - * 公开页提交资料。 - * - * 一次性语义完全由工单状态保证:只有 `pending_material` 能成功提交, - * 提交后改成 `unassigned`;并发的第二个提交会锁到 `unassigned` 并返回 409。 - */ -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, target.id) - if (!order) { - throw createHttpError('接单订单不存在', { - statusCode: 404, - errorCode: 'work_order_not_found', - }) - } - if (String(order.platform_order_id || '').trim() !== code) { - throw createHttpError('订单号无效,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'material_link_work_order_invalid', - }) - } - const orderState = resolvePublicMaterialOrderState(order) - if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState) - - 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 now = nowIso() - const material = { - ...safeParseJson(order.material_json), - collect: { - fields: submittedFields, - screenshots, - submittedAt: now, - complete: true, - source: 'customer_order_no', - }, - } - - 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', - }) - } - - 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_order_no', - }), - now, - }) - - return { order: updated } - }) - - // 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。 - 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, - workOrderId: Number(result.order.id), - order: mapPublicOrder(result.order), - } -} - -/** - * 上传前校验平台订单号 / workOrderId 与工单状态;返回工单供文件归属使用。 - * 注意:这里不消耗提交机会,只有最终提交才把状态改成待分配。 - */ -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 order = await resolvePublicMaterialTarget(code, workOrderId) - const orderState = resolvePublicMaterialOrderState(order) - if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState) - - return { order, now: nowIso() } -} diff --git a/apps/backend/src/services/worker-platform/worker-collect-service.ts b/apps/backend/src/services/worker-platform/worker-collect-service.ts deleted file mode 100644 index 3a5346ee..00000000 --- a/apps/backend/src/services/worker-platform/worker-collect-service.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' -import { - createWorkOrderEvent, - listPendingMaterialWorkOrdersByPlatformOrderId, - updateWorkOrder, - type WorkOrderRow, -} from '../../repositories/worker-platform/index.js' -import type { JsonObject } from '../../types/json.js' -import { createHttpError } from '../../utils/http.js' -import { nowIso } from '../../utils/time.js' -import { safeParseJson } from '../admin/admin-query-utils.js' -import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' -import { - mapCollectLookupOrder, - mapWorkOrderPublic, - normalizeOptionalId, - normalizeSubmittedFields, - normalizeUploadedFiles, - resolveRequirementFields, -} from './mappers.js' - -export async function collectLookupWorkOrder(payload: JsonObject = {}) { - const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() - if (!orderNo) { - throw createHttpError('请填写订单号', { - statusCode: 400, - errorCode: 'collect_order_no_required', - }) - } - const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo) - if (workOrders.length === 0) { - throw createHttpError('未找到待完善订单,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'collect_order_not_found', - }) - } - const [primaryOrder] = workOrders - if (!primaryOrder) { - throw createHttpError('未找到待完善订单,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'collect_order_not_found', - }) - } - return { - order: mapWorkOrderPublic(primaryOrder), - fields: resolveRequirementFields(primaryOrder), - orders: workOrders.map(mapCollectLookupOrder), - pendingCount: workOrders.length, - } -} - -export async function collectSubmitWorkOrder(payload: JsonObject = {}) { - const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() - if (!orderNo) { - throw createHttpError('请填写订单号', { - statusCode: 400, - errorCode: 'collect_order_no_required', - }) - } - const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo) - if (workOrders.length === 0) { - throw createHttpError('未找到待完善订单,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'collect_order_not_found', - }) - } - const workOrder = resolveCollectSubmitTargetWorkOrder( - orderNo, - workOrders, - payload.workOrderId || payload.work_order_id, - ) - - const fields = resolveRequirementFields(workOrder) - const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) - const complete = fields.every( - (field) => !field.required || String(submittedFields[field.key] || '').trim(), - ) - const now = nowIso() - const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files) - const material = { - ...safeParseJson(workOrder.material_json), - collect: { - fields: submittedFields, - screenshots, - submittedAt: now, - complete, - }, - } - const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL - const updated = await updateWorkOrder(workOrder.id, { - status: nextStatus, - material_json: material, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'collect', - actorId: orderNo, - eventType: 'material_submitted', - fromStatus: workOrder.status, - toStatus: nextStatus, - payloadJson: JSON.stringify({ fields: submittedFields, complete }), - now, - }) - publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id) }) - return { - order: mapWorkOrderPublic(updated || workOrder), - complete, - } -} - -export function resolveCollectSubmitTargetWorkOrder( - orderNo: string, - workOrders: WorkOrderRow[], - requestedWorkOrderId: unknown, -): WorkOrderRow { - const workOrderId = normalizeOptionalId(requestedWorkOrderId) - if (workOrderId) { - const matched = workOrders.find((item) => Number(item.id) === workOrderId) - if (matched) return matched - throw createHttpError('待完善订单不存在或已更新,请刷新后重试', { - statusCode: 404, - errorCode: 'collect_work_order_not_found', - context: { orderNo, workOrderId }, - }) - } - if (workOrders.length === 1) { - const [singleWorkOrder] = workOrders - if (singleWorkOrder) return singleWorkOrder - } - throw createHttpError('该订单存在多个待完善商品,请逐个提交资料', { - statusCode: 409, - errorCode: 'collect_work_order_required', - context: { orderNo, pendingCount: workOrders.length }, - }) -} diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 7ae97e9e..64b129da 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -44,7 +44,6 @@ import { mapFinanceRequest, mapWalletLedger, maskWorkerLeaderboardDisplayName, - resolveCollectSubmitTargetWorkOrder, resolveAdminMaterialRewardAmount, resolveFreezeDepositAmount, resolveAdminMockWorkOrderInput, @@ -2297,55 +2296,6 @@ test('后台更新资料禁止修改已有参与者的拼单价格', () => { ) }) -test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => { - const workOrder = buildWorkOrderRow() - const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '') - assert.equal(selected.id, workOrder.id) -}) - -test('resolveCollectSubmitTargetWorkOrder requires explicit work order id when multiple orders are pending', () => { - const workOrders = [ - buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }), - buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }), - ] - - assert.throws( - () => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, ''), - (error: unknown) => - Boolean( - error && - typeof error === 'object' && - (error as { statusCode?: number }).statusCode === 409 && - (error as { errorCode?: string }).errorCode === 'collect_work_order_required', - ), - ) -}) - -test('resolveCollectSubmitTargetWorkOrder returns the matching work order when workOrderId is provided', () => { - const workOrders = [ - buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }), - buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }), - ] - - const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, '2') - assert.equal(selected.id, 2) -}) - -test('resolveCollectSubmitTargetWorkOrder rejects unknown workOrderId', () => { - const workOrders = [buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' })] - - assert.throws( - () => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, 999), - (error: unknown) => - Boolean( - error && - typeof error === 'object' && - (error as { statusCode?: number }).statusCode === 404 && - (error as { errorCode?: string }).errorCode === 'collect_work_order_not_found', - ), - ) -}) - test('资金流水映射展示关联的平台订单号', () => { const ledger = { id: 1, diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 0a594506..d08258f3 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -77,9 +77,3 @@ export { submitWorkerOrderAcceptance, supplementWorkerAcceptedOrderEvidence, } from './worker-acceptance-service.js' - -export { - collectLookupWorkOrder, - collectSubmitWorkOrder, - resolveCollectSubmitTargetWorkOrder, -} from './worker-collect-service.js' diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index b95b8795..11e2415c 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -12,7 +12,11 @@ import { import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path' -import { isCollectMaterialLinkPath } from '@/utils/collect-link' +import { + isLegacySupplementPath, + isOrderServicePath, + resolveLegacySupplementOrderNo, +} from '@/utils/order-service-link' import { isRedeemPath } from '@/utils/redeem' import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site' import { hasWorkerSession } from '@/utils/worker-auth' @@ -43,11 +47,7 @@ const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage')) const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage')) 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 OrderServicePage = lazy(() => import('@/pages/public/OrderServicePage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage')) const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage')) @@ -125,10 +125,15 @@ export default function App() { return } - // 补资料页(`/supplement`):路径式公开页,先于站点判断, - // 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。 - if (isCollectMaterialLinkPath(window.location.pathname)) { - return + // 订单服务页(`/o`):路径式公开页,先于站点判断, + // 保证后台域名和打手端域名下都能打开。旧 /supplement 也统一跳到这里。 + if (isOrderServicePath(window.location.pathname)) { + return + } + + // 旧 /supplement 短期兼容:统一跳到 /o。 + if (isLegacySupplementPath(window.location.pathname)) { + return } if (isWorkerSite()) { @@ -138,6 +143,29 @@ export default function App() { return } +function OrderServiceApplication() { + useClearStrayHash() + return ( + + }> + + } /> + } /> + } /> + + + + ) +} + +function CollectRedirectApplication() { + useEffect(() => { + const orderNo = resolveLegacySupplementOrderNo(window.location.pathname) + window.location.replace(orderNo ? `/o/${encodeURIComponent(orderNo)}` : '/o') + }, []) + return null +} + function AdminApplication() { return ( @@ -193,7 +221,7 @@ function AdminApplication() { } /> - } /> + } /> } /> @@ -201,6 +229,13 @@ function AdminApplication() { ) } +function CollectHashRedirectApplication() { + useEffect(() => { + window.location.replace('/o') + }, []) + return null +} + function RedeemApplication() { return ( @@ -239,31 +274,6 @@ function ClaimApplication() { ) } -/** - * 补资料页(`/supplement`)的独立路由树。 - * - * - `/supplement`:输入平台订单号; - * - `/supplement/<订单号>`:直接打开对应订单的补资料页; - * - 其余子路径:交给补资料页展示「订单号无效」。 - * - * 路径式公开页,不带 hash;不依赖后台会话。 - */ -function CollectMaterialLinkApplication() { - useClearStrayHash() - - return ( - - }> - - } /> - } /> - } /> - - - - ) -} - /** * H5 领取入口(`/h5` 命名空间)的独立路由树。 * diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index d1c59f43..2454bd86 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -23,6 +23,7 @@ import FinancePanel from './panels/FinancePanel' import LeaderboardPanel from './panels/LeaderboardPanel' import LevelsPanel from './panels/LevelsPanel' import NotificationsPanel from './panels/NotificationsPanel' +import OrderServicePanel from './panels/OrderServicePanel' import ProductMatchPanel from './panels/ProductMatchPanel' import ProductRulesPanel from './panels/ProductRulesPanel' import RiskControlPanel from './panels/RiskControlPanel' @@ -169,6 +170,7 @@ export default function AdminWorkerPlatformPage() { ? [{ key: 'announcement', label: '杂项配置', children: }] : []), { key: 'rules', label: '接单模板', children: }, + { key: 'order-service', label: '订单服务', children: }, { key: 'product-match', label: '匹配诊断', children: }, { key: 'categories', label: '分类', children: }, { key: 'workers', label: '打手', children: }, diff --git a/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx b/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx new file mode 100644 index 00000000..93e44b6d --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx @@ -0,0 +1,689 @@ +import { + ArrowDownOutlined, + ArrowUpOutlined, + DeleteOutlined, + PlusOutlined, + ReloadOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + Col, + Empty, + Form, + Input, + Row, + Select, + Space, + Switch, + Tag, + Typography, +} from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' + +import PercentTable from '@/components/common/PercentTable' +import ImageUpload from '@/components/files/ImageUpload' +import { + archiveAdminOrderServiceProfile, + fetchAdminOrderServiceAutoPublishJobs, + fetchAdminOrderServiceProfiles, + publishAdminOrderServiceProfile, + retryAdminOrderServiceAutoPublishJob, + saveAdminOrderServiceProfile, + type AdminOrderServiceAutoPublishJob, + type AdminOrderServiceProfile, + type OrderServiceField, +} from '@/services/admin/worker-platform' +import { uploadFile } from '@/services/files' +import type { UploadedFile } from '@/types/worker-platform' +import { formatAdminDateTime } from '@/utils/admin-time' + +const { Text, Title } = Typography + +type ScreenshotForm = { + enabled: boolean + required: boolean + minCount: number + maxCount: number + label: string + helpText: string +} + +type AutoPublishForm = { + enabled: boolean + requireVoucherConsume: boolean + retryMax: number +} + +type OrderServiceFormValues = { + name: string + title: string + description: string + submitText: string + successText: string + screenshots: ScreenshotForm + autoPublish: AutoPublishForm +} + +const DEFAULT_SCREENSHOTS: ScreenshotForm = { + enabled: true, + required: true, + minCount: 1, + maxCount: 5, + label: '买家主页截图', + helpText: '', +} + +const DEFAULT_AUTO_PUBLISH: AutoPublishForm = { + enabled: false, + requireVoucherConsume: true, + retryMax: 3, +} + +export default function OrderServicePanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const [fields, setFields] = useState([]) + const [guideImage, setGuideImage] = useState | null>(null) + const [saving, setSaving] = useState(false) + const [publishing, setPublishing] = useState(false) + const [retryingJobId, setRetryingJobId] = useState(0) + + const profilesQuery = useQuery({ + queryKey: ['admin-order-service-profiles'], + queryFn: fetchAdminOrderServiceProfiles, + }) + const jobsQuery = useQuery({ + queryKey: ['admin-order-service-auto-publish-jobs', 'failed'], + queryFn: () => fetchAdminOrderServiceAutoPublishJobs('failed'), + }) + + const categories = profilesQuery.data?.data.categories || [] + const profiles = profilesQuery.data?.data.profiles || [] + const failedJobs = jobsQuery.data?.data.items || [] + const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null + const profile = profiles.find((item) => item.categoryId === selectedCategoryId) || null + + useEffect(() => { + if (selectedCategoryId !== null || categories.length === 0) return + setSelectedCategoryId(categories[0]?.categoryId || null) + }, [categories, selectedCategoryId]) + + useEffect(() => { + applyProfile(profile) + }, [profile, selectedCategoryId]) + + function applyProfile(nextProfile: AdminOrderServiceProfile | null) { + form.setFieldsValue({ + name: nextProfile?.name || selectedCategory?.name || '', + title: nextProfile?.title || '', + description: nextProfile?.description || '', + submitText: nextProfile?.submitText || '提交资料', + successText: nextProfile?.successText || '资料已提交,请等待处理', + screenshots: nextProfile?.screenshots || DEFAULT_SCREENSHOTS, + autoPublish: nextProfile?.autoPublish || DEFAULT_AUTO_PUBLISH, + }) + setFields(normalizeFieldOrder(nextProfile?.fields || [])) + setGuideImage(nextProfile?.guideImage || null) + } + + async function refresh() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-order-service-profiles'] }), + queryClient.invalidateQueries({ queryKey: ['admin-order-service-auto-publish-jobs'] }), + queryClient.invalidateQueries({ queryKey: ['admin-categories'] }), + ]) + } + + function addField() { + setFields((current) => + normalizeFieldOrder([ + ...current, + { + key: `field${Date.now().toString(36)}`, + label: '新字段', + type: 'text', + required: true, + placeholder: '', + helpText: '', + options: [], + sortOrder: (current.length + 1) * 10, + }, + ]), + ) + } + + function updateField(index: number, patch: Partial) { + setFields((current) => + current.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)), + ) + } + + function moveField(index: number, direction: -1 | 1) { + const next = index + direction + if (next < 0 || next >= fields.length) return + setFields((current) => { + const rows = [...current] + const [row] = rows.splice(index, 1) + if (!row) return current + rows.splice(next, 0, row) + return normalizeFieldOrder(rows) + }) + } + + function removeField(index: number) { + setFields((current) => normalizeFieldOrder(current.filter((_, rowIndex) => rowIndex !== index))) + } + + async function handleSave(values: OrderServiceFormValues) { + if (!selectedCategoryId) return + const normalizedFields = normalizeFieldOrder(fields) + const duplicateKeys = findDuplicateKeys(normalizedFields) + if (duplicateKeys.length > 0) { + message.error(`字段 Key 不能重复:${duplicateKeys.join('、')}`) + return + } + setSaving(true) + try { + await saveAdminOrderServiceProfile({ + categoryId: selectedCategoryId, + name: values.name || selectedCategory?.name || '订单服务配置', + title: values.title || '', + description: values.description || '', + guideImage: guideImage || {}, + fields: normalizedFields, + screenshots: values.screenshots, + autoPublish: values.autoPublish, + submitText: values.submitText, + successText: values.successText, + }) + message.success('草稿已保存;点击“发布新版本”后才会影响新工单') + await refresh() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } finally { + setSaving(false) + } + } + + async function handlePublish() { + if (!profile) { + message.warning('请先保存配置草稿') + return + } + setPublishing(true) + try { + const response = await publishAdminOrderServiceProfile(profile.id) + message.success(`已发布 v${response.data.versionNo}`) + await refresh() + } catch (error) { + message.error(error instanceof Error ? error.message : '发布失败') + } finally { + setPublishing(false) + } + } + + async function handleArchive() { + if (!profile) return + setPublishing(true) + try { + await archiveAdminOrderServiceProfile(profile.id) + message.success('配置已归档;已有工单快照不受影响') + await refresh() + } catch (error) { + message.error(error instanceof Error ? error.message : '归档失败') + } finally { + setPublishing(false) + } + } + + async function handleRetryJob(job: AdminOrderServiceAutoPublishJob) { + setRetryingJobId(job.id) + try { + await retryAdminOrderServiceAutoPublishJob(job.id) + message.success('自动发布任务已重新排队') + await refresh() + } catch (error) { + message.error(error instanceof Error ? error.message : '重试失败') + } finally { + setRetryingJobId(0) + } + } + + const categoryColumns = useMemo( + () => [ + { title: '分类', dataIndex: 'name' }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {value === 'active' ? '启用' : '停用'} + + ), + }, + { + title: '订单服务', + width: 180, + render: (_: unknown, row: { categoryId: number }) => { + const rowProfile = profiles.find((item) => item.categoryId === row.categoryId) + if (!rowProfile) return 未配置 + if (rowProfile.status === 'draft') { + return ( + + {rowProfile.currentVersionNo + ? `有未发布修改(线上 v${rowProfile.currentVersionNo})` + : '草稿'} + + ) + } + return ( + + {rowProfile.status === 'published' + ? `已发布 v${rowProfile.currentVersionNo || 1}` + : rowProfile.status} + + ) + }, + }, + { + title: '操作', + width: 90, + render: (_: unknown, row: { categoryId: number }) => ( + + ), + }, + ], + [profiles], + ) + + const jobColumns = useMemo( + () => [ + { + title: '订单', + render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => ( + + {row.productName || '-'} + {row.platformOrderId || '-'} + + ), + }, + { + title: '重试', + width: 90, + render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => + `${row.attemptCount}/${row.maxAttempts}`, + }, + { + title: '失败原因', + render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => ( + {row.lastError || '未知错误'} + ), + }, + { + title: '更新时间', + width: 180, + render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => + row.updatedAt ? formatAdminDateTime(row.updatedAt) : '-', + }, + { + title: '操作', + width: 100, + render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => ( + + ), + }, + ], + [retryingJobId], + ) + + return ( +
+ } + loading={profilesQuery.isFetching || jobsQuery.isFetching} + onClick={() => void refresh()} + > + 刷新 + + } + > + + + row.categoryId === selectedCategoryId ? 'ant-table-row-selected' : '' + } + /> + + + {selectedCategory ? ( + + + {profile?.status === 'published' + ? `线上 v${profile.currentVersionNo || 1}` + : profile?.currentVersionNo + ? `线上 v${profile.currentVersionNo},当前有草稿` + : '未发布'} + + + {profile ? ( + + ) : null} + + } + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setGuideImage(files[0] || null)} + uploader={(file, scene) => uploadFile(file, scene, 'admin')} + /> + + + } onClick={addField} size="small"> + 添加字段 + + } + style={{ marginBottom: 16 }} + > + {fields.length === 0 ? ( + + ) : ( + + {fields.map((field, index) => ( + + + + updateField(index, { key: event.target.value })} + /> + + + updateField(index, { label: event.target.value })} + /> + + + + updateField(index, { placeholder: event.target.value }) + } + /> + + + + updateField(index, { helpText: event.target.value }) + } + /> + + {(field.type === 'select' || field.type === 'radio') && ( + + + updateField(index, { + options: event.target.value + .split(/[,,]/) + .map((option) => option.trim()) + .filter(Boolean), + }) + } + /> + + )} + + + ))} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 开启后,客户提交完整资料会按当前工单快照自动发布;要求核销时,核销失败不会发布到大厅。 + + + + + + + + + + +
+
+ ) : ( + + + + )} + + 0 ? 'red' : 'default'}>{failedJobs.length}} + > + + +
+ ) +} + +function normalizeFieldOrder(fields: OrderServiceField[]) { + return fields.map((field, index) => ({ + ...field, + sortOrder: (index + 1) * 10, + })) +} + +function findDuplicateKeys(fields: OrderServiceField[]) { + const seen = new Set() + const duplicates = new Set() + for (const field of fields) { + const key = String(field.key || '').trim() + if (!key) continue + if (seen.has(key)) duplicates.add(key) + seen.add(key) + } + return [...duplicates] +} diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index 9d6c346a..68ae2e02 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -364,7 +364,7 @@ export default function ProductRulesPanel() { ? Number(values.sharingTotalQuantity || 0) > 0 || Number(values.sharingUnitReward || 0) > 0 : hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward) - const { giftCooldownUnit, giftCooldownValue, ...payload } = values + const { giftCooldownUnit, giftCooldownValue, fieldsText: _fieldsText, ...payload } = values const res = await saveAdminWorkProductRule({ ...payload, ruleId: editingRule?.ruleId, @@ -1148,19 +1148,12 @@ export default function ProductRulesPanel() { - {/* 资料字段 */} - - - + diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index f84a9a27..6c3b0ba9 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -107,7 +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 { buildOrderServiceUrl } from '@/utils/order-service-link' import { applyProductNameSourceSuffix, resolveProductNameSourceSuffix, @@ -1673,14 +1673,14 @@ export default function WorkOrdersPanel() { message.error('复制补资料链接失败:缺少平台订单号') return } - const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId) + const url = buildOrderServiceUrl(window.location.origin, platformOrderId) if (!url) { message.error('复制补资料链接失败:链接地址为空') return } const copied = await copyTextToClipboard(url) if (copied) { - message.success(`补资料链接已复制(订单号 ${platformOrderId})`) + message.success(`订单服务链接已复制(订单号 ${platformOrderId})`) return } // 剪贴板 API 不可用时退回弹窗,让客服手动复制。 @@ -2002,9 +2002,9 @@ export default function WorkOrdersPanel() { ) : null} {row.capabilities?.canResetMaterial && canUpdateMaterial ? ( diff --git a/apps/frontend/src/pages/collect/CollectMaterialLinkEntryPage.tsx b/apps/frontend/src/pages/collect/CollectMaterialLinkEntryPage.tsx deleted file mode 100644 index 7c07c7e1..00000000 --- a/apps/frontend/src/pages/collect/CollectMaterialLinkEntryPage.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { ArrowRightOutlined } from '@ant-design/icons' -import { App, Button, Card, Form, Input, Space, Typography } from 'antd' -import { useNavigate } from 'react-router' - -import { normalizeOrderNoInput } from '@/utils/collect-link' -import { useCollectLinkPageMeta } from './use-collect-link-page-meta' - -const { Text, Title } = Typography - -/** - * 补资料入口页(`/supplement/`)。 - * - * 客户只知道平台订单号时,从这里输入订单号再跳到 `/supplement/<订单号>`; - * 如果客服已经发了完整链接,可以直接打开链接跳过这一步。 - */ -export default function CollectMaterialLinkEntryPage() { - const { message } = App.useApp() - const navigate = useNavigate() - useCollectLinkPageMeta('订单资料补充') - - function handleFinish(values: { orderNo?: string }) { - const orderNo = normalizeOrderNoInput(values.orderNo) - if (!orderNo) { - message.warning('请输入订单号') - return - } - void navigate(`/supplement/${encodeURIComponent(orderNo)}`) - } - - return ( -
- - -
- - 订单资料补充 - - 请输入下单时使用的订单号,进入补资料页面。 -
- -
- - - - -
- - - 如果客服已经发给你完整链接,直接打开链接即可。 - -
-
-
- ) -} diff --git a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx deleted file mode 100644 index 01f72948..00000000 --- a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx +++ /dev/null @@ -1,405 +0,0 @@ -import { CheckCircleFilled, LoadingOutlined } from '@ant-design/icons' -import { - Alert, - App, - Button, - Card, - Form, - Input, - Radio, - Result, - Space, - Spin, - Tag, - Typography, -} from 'antd' -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, - 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 -} - -function resolveErrorCode(error: unknown): string { - return String((error as { errorCode?: string })?.errorCode || '').trim() -} - -function resolveErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : '操作失败,请稍后重试' -} - -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 '暂时无法打开页面' -} - -/** 公开页不展示「待分配」字样:已提交统一用「资料已提交」表达。 */ -function resolveMaterialOrderMessage(order: CollectMaterialLinkOrder): string { - if (order.blockedReason === 'already_submitted') { - return '资料已提交;如需修改请联系客服。' - } - return order.message || '当前不能修改资料,如需修改请联系客服。' -} - -/** - * 客户侧补资料页。 - * - * 只查询「待完善」工单;其他状态(待分配、已验收等)统一提示无需补充资料, - * 不展示商品、订单号等信息。页面按 orders 渲染多张卡片,只有未指派、无份额的 - * 待完善工单返回表单。提交成功后本地展示成功页;刷新后因工单已变为待分配, - * 会提示「该订单当前无需补充资料」。 - */ -export default function CollectMaterialLinkPage() { - const { message } = App.useApp() - const { code: routeCode } = useParams<{ code?: string }>() - const code = String(routeCode || '').trim() - - useCollectLinkPageMeta('订单资料补充') - - const [loading, setLoading] = useState(true) - const [info, setInfo] = useState(null) - const [loadError, setLoadError] = useState(null) - 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([]) - - /** - * 读取订单资料状态。 - * - * silent=true 用于提交后刷新:不切整页 loading,避免页面闪烁丢焦点; - * 同时用请求序号丢弃过期响应,防止快速重试时旧数据覆盖新数据。 - */ - const loadInfo = useCallback( - async (options: { silent?: boolean } = {}) => { - 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 }) - setLoading(false) - return - } - - if (!options.silent) setLoading(true) - try { - 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) - 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 (seq !== requestSeqRef.current) return - setInfo(null) - setLoadError({ - code: resolveErrorCode(error), - message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE, - }) - } finally { - if (seq === requestSeqRef.current && !options.silent) setLoading(false) - } - }, - [code], - ) - - useEffect(() => { - if (loadedCodeRef.current === code) return - loadedCodeRef.current = code - void loadInfo() - }, [code, loadInfo]) - - async function handleSubmit(order: CollectMaterialLinkOrder, values: Record) { - if (!code) return - // 多工单时用户可能连点不同卡片;前端只允许一个提交在途,避免列表状态互相覆盖。 - if (submittingRef.current) return - submittingRef.current = true - setSubmittingWorkOrderId(order.workOrderId) - try { - await submitCollectMaterialLink(code, { - workOrderId: order.workOrderId, - fields: values, - screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [], - }) - 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)) - // 状态类错误说明工单已被并发提交、被客服重置或流转到其他状态: - // 以服务端状态为准静默刷新,页面切换成只读卡片而不是整页报错。 - 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 { - submittingRef.current = false - setSubmittingWorkOrderId(0) - } - } - - const emptyStateMessage = '该订单当前不可修改资料,如需修改请联系客服。' - const submittedProductNames = submittedNames.join('、') - - return ( -
- - {loading ? ( -
- } size="large" /> - 正在读取订单资料... -
- ) : loadError ? ( - - - - } - /> - ) : submittedSuccess ? ( -
- - - 提交成功 - - 资料已提交;如需修改请联系客服。 -
- 本次提交 - {submittedProductNames || '订单资料'} -
- - 本页面仅用于补充订单资料,请妥善保管订单号。 - -
- ) : info ? ( - - {info.pendingCount > 0 ? ( - 请补充以下资料,提交后客服会尽快处理。 - ) : null} - - {info.pendingCount > 1 ? ( - - ) : null} - - {info.pendingCount === 0 ? ( - - ) : null} - - {info.orders.map((order) => ( - - -
-
- {order.productName || '订单商品'} -
-
- 订单号 - {order.platformOrderId || '-'} -
-
- - {order.canSubmit ? ( - <> - {info.pendingCount === 1 ? ( - - ) : null} - - - setScreenshotsByOrder((previous) => ({ - ...previous, - [order.workOrderId]: files, - })) - } - onSubmit={(values) => handleSubmit(order, values)} - /> - - ) : info.pendingCount > 0 ? ( - - ) : null} -
-
- ))} - - - 本页面仅用于补充订单资料,请妥善保管订单号。 - -
- ) : null} -
-
- ) -} - -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/CollectPage.tsx b/apps/frontend/src/pages/collect/CollectPage.tsx deleted file mode 100644 index 4cb0ca25..00000000 --- a/apps/frontend/src/pages/collect/CollectPage.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { SearchOutlined } from '@ant-design/icons' -import { App, Button, Card, Form, Input, Radio, Result, Space, Typography } from 'antd' -import { useState } from 'react' - -import ImageUpload from '@/components/files/ImageUpload' -import { lookupCollectOrder, submitCollectOrder } from '@/services/worker' -import type { CollectLookupOrder, UploadedFile } from '@/types/worker-platform' - -export default function CollectPage() { - const { message } = App.useApp() - const [orderNo, setOrderNo] = useState('') - const [lookupOrders, setLookupOrders] = useState([]) - const [lookupLoading, setLookupLoading] = useState(false) - const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0) - const [screenshotsByOrder, setScreenshotsByOrder] = useState>({}) - const [complete, setComplete] = useState(false) - - async function submitLookup(values: { orderNo: string }) { - setLookupLoading(true) - setComplete(false) - try { - const nextOrderNo = String(values.orderNo || '').trim() - setOrderNo(nextOrderNo) - await refreshLookupOrders(nextOrderNo) - } catch (error) { - setLookupOrders([]) - message.error(error instanceof Error ? error.message : '查询失败') - } finally { - setLookupLoading(false) - } - } - - async function submitMaterial(order: CollectLookupOrder, values: Record) { - setSubmittingWorkOrderId(order.workOrderId) - try { - const response = await submitCollectOrder({ - orderNo, - workOrderId: order.workOrderId, - fields: values, - screenshots: screenshotsByOrder[order.workOrderId] || [], - }) - message.success(response.data.complete ? '当前商品资料已完善' : '当前商品资料已提交') - await refreshLookupOrders(orderNo, { allowCompleteWhenNotFound: true }) - } catch (error) { - message.error(error instanceof Error ? error.message : '提交失败') - } finally { - setSubmittingWorkOrderId(0) - } - } - - return ( -
- - 订单资料补充 - - 请填写正确订单号,匹配成功后继续补充资料。 - - -
- - - - - - -
- - {complete ? ( - - ) : null} - - {!complete && lookupOrders.length > 1 ? ( - - 当前订单有 {lookupOrders.length} 个待完善商品,请逐个提交资料。 - - ) : null} - - {lookupOrders.map((currentOrder) => ( - {currentOrder.workOrderNo}} - size="small" - > - - 订单号:{currentOrder.platformOrderId} - -
submitMaterial(currentOrder, values)} - > - {currentOrder.fields.map((field) => ( - - {field.type === 'select' && (field.options || []).length > 0 ? ( - ({ - label: option, - value: option, - }))} - /> - ) : ( - - )} - - ))} - - - setScreenshotsByOrder((prev) => ({ - ...prev, - [currentOrder.workOrderId]: files, - })) - } - /> - - -
-
- ))} -
-
- ) - - async function refreshLookupOrders( - currentOrderNo: string, - options: { allowCompleteWhenNotFound?: boolean } = {}, - ) { - try { - const response = await lookupCollectOrder(currentOrderNo) - setLookupOrders(response.data.orders || []) - setComplete(false) - } catch (error) { - if (options.allowCompleteWhenNotFound && isCollectOrderNotFoundError(error)) { - setLookupOrders([]) - setComplete(true) - return - } - throw error - } - } -} - -function isCollectOrderNotFoundError(error: unknown) { - return ( - String((error as { errorCode?: string })?.errorCode || '').trim() === 'collect_order_not_found' - ) -} diff --git a/apps/frontend/src/pages/public/OrderServicePage.tsx b/apps/frontend/src/pages/public/OrderServicePage.tsx new file mode 100644 index 00000000..e855b2f2 --- /dev/null +++ b/apps/frontend/src/pages/public/OrderServicePage.tsx @@ -0,0 +1,498 @@ +import { + CheckCircleFilled, + LoadingOutlined, + ReloadOutlined, + SearchOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + DatePicker, + Form, + Input, + InputNumber, + Radio, + Result, + Select, + Space, + Spin, + Tag, + Typography, +} from 'antd' +import dayjs, { type Dayjs } from 'dayjs' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router' + +import ImageUpload from '@/components/files/ImageUpload' +import { + fetchOrderService, + submitOrderServiceMaterial, + uploadOrderServiceFile, + type OrderServiceInfo, + type OrderServiceItem, +} from '@/services/worker' +import type { UploadedFile } from '@/types/worker-platform' +import { normalizeOrderServiceInput } from '@/utils/order-service-link' +import { useOrderServicePageMeta } from './use-order-service-page-meta' + +const { Text, Title } = Typography +const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确' +const STATUS_REFRESH_INTERVAL_MS = 15_000 + +function resolveErrorCode(error: unknown) { + return String((error as { errorCode?: string })?.errorCode || '').trim() +} + +function resolveErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '操作失败,请稍后重试' +} + +function resolveErrorTitle(code: string) { + if (code === 'order_service_not_found') return '订单号无效' + if (code === 'rate_limited') return '查询过于频繁' + return '暂时无法打开页面' +} + +export default function OrderServicePage() { + const { message } = App.useApp() + const navigate = useNavigate() + const { orderNo: routeOrderNo } = useParams<{ orderNo?: string }>() + const orderNo = String(routeOrderNo || '').trim() + const [info, setInfo] = useState(null) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState<{ code: string; message: string } | null>(null) + const [submittedNames, setSubmittedNames] = useState([]) + const [lastSuccessText, setLastSuccessText] = useState('') + const [submittingId, setSubmittingId] = useState(0) + const [screenshotsByOrder, setScreenshotsByOrder] = useState>({}) + const submittingRef = useRef(false) + const requestSeqRef = useRef(0) + const loadedOrderRef = useRef('') + + useOrderServicePageMeta(info?.items[0]?.title || '订单服务') + + const load = useCallback( + async (options: { silent?: boolean } = {}) => { + const seq = requestSeqRef.current + 1 + requestSeqRef.current = seq + if (!options.silent) { + setSubmittedNames([]) + setLastSuccessText('') + setLoading(true) + } + if (!orderNo) { + setInfo(null) + setLoadError(null) + setLoading(false) + return + } + try { + const response = await fetchOrderService(orderNo) + if (seq !== requestSeqRef.current) return + setInfo(response.data) + setLoadError(null) + } catch (error) { + if (seq !== requestSeqRef.current) return + if (options.silent && info) return + setInfo(null) + setLoadError({ + code: resolveErrorCode(error), + message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE, + }) + } finally { + if (seq === requestSeqRef.current && !options.silent) setLoading(false) + } + }, + [info, orderNo], + ) + + useEffect(() => { + if (loadedOrderRef.current === orderNo) return + loadedOrderRef.current = orderNo + setScreenshotsByOrder({}) + if (!orderNo) { + setInfo(null) + setLoadError(null) + return + } + void load() + }, [load, orderNo]) + + useEffect(() => { + if (!orderNo) return + const refresh = () => { + if (document.visibilityState === 'visible' && !submittingRef.current) { + void load({ silent: true }) + } + } + const timer = window.setInterval(refresh, STATUS_REFRESH_INTERVAL_MS) + document.addEventListener('visibilitychange', refresh) + window.addEventListener('focus', refresh) + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', refresh) + window.removeEventListener('focus', refresh) + } + }, [load, orderNo]) + + function handleSearch(values: { orderNo?: string }) { + const nextOrderNo = normalizeOrderServiceInput(values.orderNo) + if (!nextOrderNo) { + message.warning('请输入订单号') + return + } + void navigate(`/o/${encodeURIComponent(nextOrderNo)}`) + } + + async function handleSubmit(item: OrderServiceItem, values: Record) { + if (!orderNo || submittingRef.current) return + const screenshots = screenshotsByOrder[item.workOrderId] ?? item.screenshots ?? [] + const screenshotRule = item.screenshotsRule + if ( + screenshotRule?.enabled && + screenshotRule.minCount > 0 && + screenshots.length < screenshotRule.minCount + ) { + message.error(`${screenshotRule.label}至少需要 ${screenshotRule.minCount} 张`) + return + } + + submittingRef.current = true + setSubmittingId(item.workOrderId) + try { + const response = await submitOrderServiceMaterial(orderNo, { + workOrderId: item.workOrderId, + fields: normalizeFormFieldValues(item, values), + screenshots, + }) + setSubmittedNames((current) => [ + ...current, + item.productName || item.categoryName || '订单商品', + ]) + setLastSuccessText(response.data.successText || item.successText || '资料已提交') + await load({ silent: true }) + } catch (error) { + message.error(resolveErrorMessage(error)) + const code = resolveErrorCode(error) + if ( + code === 'order_service_not_found' || + code === 'material_link_already_submitted' || + code === 'material_link_order_status_invalid' + ) { + await load({ silent: true }) + } + } finally { + submittingRef.current = false + setSubmittingId(0) + } + } + + if (!orderNo) { + return ( +
+ + +
+ 订单服务 + 请输入下单时使用的订单号,补充资料或查看进度。 +
+
+ + } + placeholder="请输入订单号" + size="large" + /> + + +
+
+
+
+ ) + } + + const pageConfig = info?.items[0] + const pendingItems = (info?.items || []).filter((item) => item.canSubmit) + + return ( +
+ + {loading ? ( +
+ } size="large" /> + 正在读取订单信息... +
+ ) : loadError ? ( + void load()}>刷新重试} + /> + ) : info ? ( + +
+
+ {pageConfig?.title || '订单服务'} + 订单号:{info.platformOrderId} + {pageConfig?.description ? ( +
+ {pageConfig.description} +
+ ) : null} +
+ +
+ + {submittedNames.length > 0 ? ( + + ) : null} + + {pendingItems.length > 1 ? ( + + ) : pendingItems.length === 1 ? ( + + ) : null} + + {info.items.map((item) => + item.canSubmit ? ( + { + setScreenshotsByOrder((current) => ({ + ...current, + [item.workOrderId]: files, + })) + }} + onSubmit={(values) => void handleSubmit(item, values)} + /> + ) : ( + + + + {item.productName || item.categoryName || '订单商品'} + + {item.stateLabel} + + + {item.message} + + + ), + )} + + + 资料提交后如需修改,请联系客服。 + +
+ ) : null} +
+
+ ) +} + +function OrderServiceForm({ + item, + code, + submitting, + disabled, + screenshots, + onScreenshotsChange, + onSubmit, +}: { + item: OrderServiceItem + code: string + submitting: boolean + disabled: boolean + screenshots: UploadedFile[] + onScreenshotsChange: (files: UploadedFile[]) => void + onSubmit: (values: Record) => void +}) { + const rule = item.screenshotsRule || { + enabled: false, + required: false, + minCount: 0, + maxCount: 5, + label: '截图', + helpText: '', + } + + return ( + + +
+
+ {item.productName || item.categoryName || '订单商品'} +
+
+ 分类 + {item.categoryName || '-'} +
+
+ + {item.title ? {item.title} : null} + {item.description ? {item.description} : null} + + {item.guideImage && 'url' in item.guideImage && item.guideImage.url ? ( + 填写引导图 + ) : null} + + {item.fields && item.fields.length > 0 ? ( +
+ {item.fields.map((field) => ( + + {field.label} + {field.required ? 必填 : 选填} + + } + extra={field.helpText} + rules={ + field.required + ? [{ required: true, whitespace: true, message: `请填写${field.label}` }] + : undefined + } + > + {renderFieldInput(field)} + + ))} + + {rule.enabled ? ( + + {rule.label} + + {rule.required ? '必填' : '选填'} + + + } + extra={rule.helpText} + > + { + const response = await uploadOrderServiceFile( + file, + scene, + code, + item.workOrderId, + ) + return response.data.file + }} + /> + + ) : null} + +
+ +
+
+ ) : ( + + )} +
+
+ ) +} + +function renderFieldInput(field: NonNullable[number]) { + const placeholder = field.placeholder || `请输入${field.label}` + if (field.type === 'textarea') { + return + } + if (field.type === 'select') { + return ( + +} + +function normalizeFormFieldValues(item: OrderServiceItem, values: Record) { + const fields = new Map((item.fields || []).map((field) => [field.key, field])) + return Object.fromEntries( + Object.entries(values).map(([key, value]) => { + const field = fields.get(key) + if (field?.type === 'date' && value && dayjs.isDayjs(value)) { + return [key, (value as Dayjs).format('YYYY-MM-DD')] + } + return [key, String(value ?? '').trim()] + }), + ) +} diff --git a/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts b/apps/frontend/src/pages/public/use-order-service-page-meta.ts similarity index 68% rename from apps/frontend/src/pages/collect/use-collect-link-page-meta.ts rename to apps/frontend/src/pages/public/use-order-service-page-meta.ts index 6547c6cd..6439b62a 100644 --- a/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts +++ b/apps/frontend/src/pages/public/use-order-service-page-meta.ts @@ -1,12 +1,7 @@ import { useEffect } from 'react' -/** - * 公开补资料页共用的 document.title / robots / referrer 设置。 - * - * 入口页和补资料页都是可被直接访问的公开路径,必须禁止搜索引擎收录, - * 并避免平台订单号通过 referrer 泄漏给第三方。 - */ -export function useCollectLinkPageMeta(title: string) { +/** 公开订单服务页不收录,且不向第三方泄漏订单号 Referer。 */ +export function useOrderServicePageMeta(title: string) { useEffect(() => { const previousTitle = document.title document.title = title @@ -28,18 +23,14 @@ export function useCollectLinkPageMeta(title: string) { if (created) { created.remove() } else if (existing) { - if (previous === null) { - existing.removeAttribute('content') - } else { - existing.setAttribute('content', previous) - } + 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() diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 865edd89..d471d6f5 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -1108,3 +1108,129 @@ export function revokeAdminWorkerRiskBan(banId: string, reason?: string) { { reason }, ) } + +export type OrderServiceField = { + key: string + label: string + type: 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date' + required: boolean + placeholder?: string + helpText?: string + options?: string[] + sortOrder?: number +} + +export type AdminOrderServiceProfile = { + id: number + categoryId: number + name: string + title: string + description: string + guideImage: UploadedFile | Record + fields: OrderServiceField[] + screenshots: { + enabled: boolean + required: boolean + minCount: number + maxCount: number + label: string + helpText?: string + } + autoPublish: { + enabled: boolean + requireVoucherConsume: boolean + retryMax: number + } + submitText: string + successText: string + status: 'draft' | 'published' | 'archived' | string + currentVersionNo: number | null + updatedAt: string +} + +export type AdminOrderServiceCategory = { + categoryId: number + categoryKey: string + name: string + parentId?: number | null + status: string +} + +export type AdminOrderServiceProfileList = { + categories: AdminOrderServiceCategory[] + profiles: AdminOrderServiceProfile[] +} + +export function fetchAdminOrderServiceProfiles() { + return apiGet( + '/api/v1/admin/worker-platform/order-service/profiles', + ) +} + +export function saveAdminOrderServiceProfile(payload: { + categoryId: number + name: string + title?: string + description?: string + guideImage?: UploadedFile | Record + fields: OrderServiceField[] + screenshots: { + enabled: boolean + required: boolean + minCount: number + maxCount: number + label: string + helpText?: string + } + autoPublish: { + enabled: boolean + requireVoucherConsume: boolean + retryMax: number + } + submitText?: string + successText?: string +}) { + return apiPost<{ profile: AdminOrderServiceProfile }>( + '/api/v1/admin/worker-platform/order-service/profiles', + payload, + ) +} + +export function publishAdminOrderServiceProfile(profileId: number) { + return apiPost<{ versionNo: number }>( + `/api/v1/admin/worker-platform/order-service/profiles/${profileId}/publish`, + ) +} + +export function archiveAdminOrderServiceProfile(profileId: number) { + return apiPost<{ profile: AdminOrderServiceProfile }>( + `/api/v1/admin/worker-platform/order-service/profiles/${profileId}/archive`, + ) +} + +export type AdminOrderServiceAutoPublishJob = { + id: number + workOrderId: number + status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'skipped' | string + attemptCount: number + maxAttempts: number + nextRunAt: string | null + lastError: string + productName: string + platformOrderId: string + workOrderStatus: string + updatedAt: string | null +} + +export function fetchAdminOrderServiceAutoPublishJobs(status = 'failed') { + return apiGet<{ items: AdminOrderServiceAutoPublishJob[] }>( + '/api/v1/admin/worker-platform/order-service/auto-publish-jobs', + { status }, + ) +} + +export function retryAdminOrderServiceAutoPublishJob(jobId: number) { + return apiPost<{ jobId: number }>( + `/api/v1/admin/worker-platform/order-service/auto-publish-jobs/${jobId}/retry`, + ) +} diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts index ddc7a8b1..d0a6540c 100644 --- a/apps/frontend/src/services/files.ts +++ b/apps/frontend/src/services/files.ts @@ -5,44 +5,18 @@ import { optimizeImageForUpload } from '@/utils/image-upload' type UploadScope = 'admin' | 'worker' | 'collect' export async function uploadFile(file: File, scene: string, scope: UploadScope) { + if (scope === 'collect') { + throw new Error('订单服务页面必须使用订单号上传入口') + } const uploadTarget = await optimizeImageForUpload(file, scene) const form = new FormData() form.append('file', uploadTarget) form.append('scene', scene) - const endpoint = - scope === 'admin' - ? '/api/v1/admin/files/upload' - : scope === 'collect' - ? '/api/v1/collect/upload' - : '/api/v1/worker/files/upload' + const endpoint = scope === 'admin' ? '/api/v1/admin/files/upload' : '/api/v1/worker/files/upload' const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form) return response.data.file } -/** - * 一次性补资料链接的截图上传。 - * - * 与公开 collect/upload 的区别:路径里带平台订单号,多工单时再带 workOrderId; - * 后端会校验对应链接是否仍可用,链接已使用/过期时不会落文件。 - */ -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(code)}/upload${query}`, - form, - ) - return response.data.file -} - /** * 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。 * 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。 diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 6ef1b4ad..818ebdb4 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -1,8 +1,6 @@ -import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/http' +import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from '@/lib/http' +import { optimizeImageForUpload } from '@/utils/image-upload' import type { - CollectLookupResponse, - CollectMaterialLinkInfo, - CollectMaterialLinkSubmitResult, UploadedFile, WorkOrder, WorkOrderEvent, @@ -372,40 +370,6 @@ export function supplementWorkerAcceptedOrderEvidence( ) } -export function lookupCollectOrder(orderNo: string) { - return apiPost('/api/v1/collect/lookup', { orderNo }) -} - -export function submitCollectOrder(payload: { - orderNo: string - workOrderId?: number - fields: Record - screenshots?: UploadedFile[] -}) { - return apiPost<{ complete: boolean; order: Record }>( - '/api/v1/collect/submit', - payload, - ) -} - -export function fetchCollectMaterialLink(code: string) { - return apiGet(`/api/v1/collect/link/${encodeURIComponent(code)}`) -} - -export function submitCollectMaterialLink( - code: string, - payload: { - workOrderId?: number - fields: Record - screenshots?: UploadedFile[] - }, -) { - return apiPost( - `/api/v1/collect/link/${encodeURIComponent(code)}/submit`, - payload, - ) -} - // ---------------- 打手端:我要寄卖与手枪库存 ---------------- export type WorkerConsignmentPublishPayload = { @@ -591,3 +555,91 @@ export function fetchWorkerConsignmentPriceSuggestion(params: { matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] }>('/api/v1/worker/consignment/price-suggestion', params) } + +export type OrderServiceField = { + key: string + label: string + type: 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date' + required: boolean + placeholder?: string + helpText?: string + options?: string[] + sortOrder?: number +} + +export type OrderServiceScreenshots = { + enabled: boolean + required: boolean + minCount: number + maxCount: number + label: string + helpText?: string +} + +export type OrderServiceItem = { + workOrderId: number + productName: string + categoryName: string + title: string + description: string + submitText: string + successText: string + state: string + stateLabel: string + message: string + canSubmit: boolean + blockedReason: '' | 'already_submitted' | 'status' | 'assigned' | 'shares' + fields?: OrderServiceField[] + values?: Record + screenshots?: UploadedFile[] + screenshotsRule?: OrderServiceScreenshots + guideImage?: UploadedFile | Record +} + +export type OrderServiceInfo = { + platformOrderId: string + items: OrderServiceItem[] + pendingCount: number +} + +export type OrderServiceSubmitResult = { + complete: boolean + workOrderId: number + successText: string + order: { platformOrderId: string; productName: string; status: string } +} + +export function fetchOrderService(orderNo: string) { + return apiGet(`/api/v1/o/${encodeURIComponent(orderNo)}`) +} + +export function submitOrderServiceMaterial( + orderNo: string, + payload: { + workOrderId?: number + fields: Record + screenshots?: UploadedFile[] + }, +) { + return apiPost( + `/api/v1/o/${encodeURIComponent(orderNo)}/material`, + payload, + ) +} + +export async function uploadOrderServiceFile( + file: File, + scene: string, + orderNo: string, + workOrderId?: number, +) { + const optimized = await optimizeImageForUpload(file, scene) + const form = new FormData() + form.append('file', optimized) + form.append('scene', scene) + const query = workOrderId ? `?workOrderId=${encodeURIComponent(String(workOrderId))}` : '' + return apiPostForm<{ file: UploadedFile }>( + `/api/v1/o/${encodeURIComponent(orderNo)}/files${query}`, + form, + ) +} diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index ab5b65d8..f64d8e96 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -508,6 +508,13 @@ margin: 0 0 4px !important; } +.collect-link-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + /* 商品名与订单号放在同一个信息头里,避免标题和灰色信息块分离 */ .collect-link-order-head { display: flex; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 4180cf9f..ee7808ab 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -884,68 +884,6 @@ export type CollectField = { options?: string[] } -export type CollectLookupOrder = { - workOrderId: number - workOrderNo: string - platformOrderId: string - productName: string - status: string - fields: CollectField[] - values?: Record - screenshots?: UploadedFile[] -} - -export type CollectLookupResponse = { - order: Pick< - WorkOrder, - 'workOrderId' | 'workOrderNo' | 'platformOrderId' | 'productName' | 'status' - > - fields: CollectField[] - orders: CollectLookupOrder[] - pendingCount: number -} - -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[] -} - -/** 按平台订单号读取到的补资料信息;一单多工单时 orders 会有多条。 */ -export type CollectMaterialLinkInfo = { - platformOrderId: string - orders: CollectMaterialLinkOrder[] - pendingCount: number -} - -export type CollectMaterialLinkSubmitResult = { - complete: boolean - workOrderId: number - order: { - workOrderNo: string - platformOrderId: string - productName: string - status: string - } -} - /** 大厅风控阈值(后台可调,保存即热生效) */ export type WorkerRiskConfig = { enabled: boolean diff --git a/apps/frontend/src/utils/admin-worker-platform-tabs.ts b/apps/frontend/src/utils/admin-worker-platform-tabs.ts index 20c46856..35febb87 100644 --- a/apps/frontend/src/utils/admin-worker-platform-tabs.ts +++ b/apps/frontend/src/utils/admin-worker-platform-tabs.ts @@ -22,6 +22,7 @@ export const WORKER_PLATFORM_TAB_KEYS: readonly string[] = [ 'after-sales', 'announcement', 'rules', + 'order-service', 'product-match', 'categories', 'workers', @@ -49,6 +50,11 @@ export const WORKER_PLATFORM_TAB_QUERY_KEYS: Record = { 'after-sales': [['admin-after-sales-orders'], ['admin-after-sales-stats']], feedbacks: [['admin-feedbacks']], rules: [['admin-product-rules']], + 'order-service': [ + ['admin-order-service-profiles'], + ['admin-order-service-auto-publish-jobs'], + ['admin-categories'], + ], 'product-match': [['admin-product-match']], categories: [['admin-categories']], workers: [['admin-workers'], ['admin-worker-users']], diff --git a/apps/frontend/src/utils/collect-link.ts b/apps/frontend/src/utils/collect-link.ts deleted file mode 100644 index a80145b0..00000000 --- a/apps/frontend/src/utils/collect-link.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * 一次性补资料链接的路径解析。 - * - * 公开链接有两种形态: - * - `https://<域名>/supplement/`:输入平台订单号; - * - `https://<域名>/supplement/<平台订单号>`:直接打开对应订单的补资料页。 - * 与后台 HashRouter 完全隔离。 - */ - -export function isCollectMaterialLinkPath(pathname: unknown): boolean { - return splitPathSegments(pathname)[0] === 'supplement' -} - -/** - * 只认 `/supplement/<一段 code>`: - * - `/supplement/abc` 命中,结尾多余斜杠容忍; - * - `/supplement`、`/supplement/a/b` 不命中(由入口页/页面自己展示无效)。 - */ -export function resolveCollectMaterialLinkCode(pathname: unknown): string { - const segments = splitPathSegments(pathname) - if (segments.length !== 2 || segments[0] !== 'supplement') return '' - 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[] { - const normalized = String(pathname ?? '').trim() - if (!normalized.startsWith('/')) return [] - - return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean) -} - -function decodePathSegment(segment: string): string { - try { - return decodeURIComponent(segment) - } catch { - return segment - } -} diff --git a/apps/frontend/src/utils/order-service-link.ts b/apps/frontend/src/utils/order-service-link.ts new file mode 100644 index 00000000..73993613 --- /dev/null +++ b/apps/frontend/src/utils/order-service-link.ts @@ -0,0 +1,61 @@ +/** 客户订单服务页路径解析:/o 与 /o/:orderNo。 */ +export function isOrderServicePath(pathname: unknown): boolean { + const segments = splitPathSegments(pathname) + return segments[0] === 'o' +} + +export function isLegacySupplementPath(pathname: unknown): boolean { + return splitPathSegments(pathname)[0] === 'supplement' +} + +export function resolveLegacySupplementOrderNo(pathname: unknown): string { + const segments = splitPathSegments(pathname) + if (segments.length !== 2 || segments[0] !== 'supplement') return '' + return decodePathSegment(segments[1]) +} + +export function resolveOrderServiceOrderNo(pathname: unknown): string { + const segments = splitPathSegments(pathname) + if (segments.length !== 2 || segments[0] !== 'o') return '' + return decodePathSegment(segments[1]) +} + +export function buildOrderServiceUrl(origin: unknown, orderNo: unknown): string { + const base = String(origin ?? '') + .trim() + .replace(/\/+$/, '') + const order = String(orderNo ?? '').trim() + if (!base || !order) return '' + return `${base}/o/${encodeURIComponent(order)}` +} + +export function normalizeOrderServiceInput(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + for (const marker of ['/o/', '/supplement/']) { + const index = raw.indexOf(marker) + if (index < 0) continue + return decodePathSegment( + raw + .slice(index + marker.length) + .split(/[?#]/)[0] + .replace(/\/+$/, '') + .trim(), + ) + } + return raw +} + +function splitPathSegments(pathname: unknown): string[] { + const normalized = String(pathname ?? '').trim() + if (!normalized.startsWith('/')) return [] + return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean) +} + +function decodePathSegment(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 deleted file mode 100644 index b1377cbc..00000000 --- a/apps/frontend/tests/collect-link.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { - buildCollectMaterialLinkUrl, - isCollectMaterialLinkPath, - normalizeOrderNoInput, - resolveCollectMaterialLinkCode, -} from '../src/utils/collect-link.ts' - -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(resolveCollectMaterialLinkCode('/supplement/2630501851236676/'), '2630501851236676') - assert.equal( - resolveCollectMaterialLinkCode('/supplement/2630501851236676?from=chat'), - '2630501851236676', - ) - assert.equal( - resolveCollectMaterialLinkCode('/supplement/2630501851236676#top'), - '2630501851236676', - ) -}) - -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(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(''), '') -}) diff --git a/apps/frontend/tests/order-service-link.test.ts b/apps/frontend/tests/order-service-link.test.ts new file mode 100644 index 00000000..a3d898db --- /dev/null +++ b/apps/frontend/tests/order-service-link.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildOrderServiceUrl, + isLegacySupplementPath, + isOrderServicePath, + normalizeOrderServiceInput, + resolveLegacySupplementOrderNo, + resolveOrderServiceOrderNo, +} from '../src/utils/order-service-link.ts' + +test('订单服务路径:/o 与 /o/:orderNo', () => { + assert.equal(isOrderServicePath('/o'), true) + assert.equal(isOrderServicePath('/o/2630501851236676'), true) + assert.equal(isOrderServicePath('/order/2630501851236676'), false) + assert.equal(resolveOrderServiceOrderNo('/o/2630501851236676'), '2630501851236676') + assert.equal(resolveOrderServiceOrderNo('/o/2630501851236676/'), '2630501851236676') +}) + +test('旧 /supplement 只用于兼容跳转', () => { + assert.equal(isLegacySupplementPath('/supplement'), true) + assert.equal(isLegacySupplementPath('/supplement/2630501851236676'), true) + assert.equal(resolveLegacySupplementOrderNo('/supplement/2630501851236676'), '2630501851236676') + assert.equal(resolveLegacySupplementOrderNo('/supplement/a/b'), '') +}) + +test('订单服务链接构建与输入归一化', () => { + assert.equal( + buildOrderServiceUrl('https://ks.khhao.com/', '2630501851236676'), + 'https://ks.khhao.com/o/2630501851236676', + ) + assert.equal(buildOrderServiceUrl('', 'abc'), '') + assert.equal(normalizeOrderServiceInput(' https://ks.khhao.com/o/abc?from=chat '), 'abc') + assert.equal(normalizeOrderServiceInput('https://ks.khhao.com/supplement/abc'), 'abc') +}) diff --git a/docs/README.md b/docs/README.md index 3a7410eb..b4be8e8b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # order_site 文档索引 -> 最后校准:2026-10-27,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复 + 财务 P0 控制,迁移基线收敛为 `103`–`105`;**未部署生产**)。 +> 最后补充:2026-11-03,新增《订单全流程与内部 SKU 架构》,代码基线 `main` / `6e4758a3`。 > 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地已校准到 105(103 财务基础、104 财务控制/L1、105 拼单/寄卖客服),生产按收敛后的 102 → 105 路径执行。历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。 > 本文是 `docs/` 的唯一入口。**整体财务流程看 [财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md)**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。 > 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。 @@ -130,6 +130,8 @@ | --- | --- | --- | | [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL | | [财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md) | 现行 | 断代后财务全流程:六条管道、成本快照、事实、总账、退款、异常、运维与测试 | +| [订单全流程与内部SKU架构.md](订单全流程与内部SKU架构.md) | 现行 | 平台入单、中文内部 SKU、渠道判定、四类履约载体、商品归一与财务落账的完整链路 | +| [订单服务收集与状态页设计-2026-11-03.md](订单服务收集与状态页设计-2026-11-03.md) | 现行 | 分类级资料配置、发布版本与工单快照、`/o` 客户状态页、自动发布核销和通知预留 | | [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`105`;履约回填;迁移基线与本地库同步;索引与性能 | | [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) | | [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 | diff --git a/docs/订单全流程与内部SKU架构.md b/docs/订单全流程与内部SKU架构.md new file mode 100644 index 00000000..f13dcdc7 --- /dev/null +++ b/docs/订单全流程与内部SKU架构.md @@ -0,0 +1,576 @@ +--- +type: 架构设计 +description: 从平台入单、内部中文 SKU、履约渠道与载体,到商品归一、成本快照和财务事实的完整订单链路。 +tags: [订单, 履约, 内部SKU, 渠道, 成本, 财务] +status: stable +--- + +# 订单全流程与内部 SKU 架构 + +> 状态:**现行**。最后校准:2026-11-03。 +> 代码基线:`main` / `6e4758a3`。 +> 相关文档:[履约与财务分流统一方案-2026-11-02.md](履约与财务分流统一方案-2026-11-02.md)、[财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md)、[成本中心与履约模型.md](成本中心与履约模型.md)、[接单模板匹配引擎重构设计.md](接单模板匹配引擎重构设计.md)。 + +## 一、核心结论 + +当前系统以**中文内部 SKU**作为业务路由主键: + +```text +平台订单 + → 解析中文内部 SKU + → internal_sku_channels 决定履约渠道 + → 创建真实履约载体 + → 商品归一 + → 成本快照与销售事实 + → 财务总账 +``` + +四个概念必须分开: + +| 概念 | 回答的问题 | 主要存储 | +| ------------ | ------------------ | -------------------------------------------------- | +| 中文内部 SKU | 业务上这是什么商品 | `order_items.manual_sku_key`、快照字段、多张配置表 | +| 履约渠道 | 这单由谁执行 | `internal_sku_channels` | +| 履约载体 | 实际执行对象是什么 | `work_orders` / `fulfillment_tasks` / 人工配置 | +| 标准商品 | 财务上归到哪个商品 | `product_catalog` | + +一句话记忆: + +> **内部 SKU 决定业务路由,标准商品决定财务归属,接单模板决定接单平台具体创建哪种工单。** + +## 二、订单入口 + +所有来源最终汇入统一订单服务: + +```text +apps/backend/src/services/order/order-service.ts +upsertOrderFromSource() +``` + +### 2.1 91 卡券 + +```text +/open-91 下单接口 +→ buildOpen91SourceEvent +→ upsertOrderFromSource +``` + +91 负责订单骨架;快手 send-code 可补充商品与 SKU 信息。两者在业务上属于同一个 `kuaishou-91` 来源,但落库的 `orders.provider` 仍可能是 `91kaquan`。 + +### 2.2 抖音码速达 + +```text +trade_TradePaid Webhook +→ 拉取 order.orderDetail +→ buildDouyinMashidaSourceEvent +→ upsertOrderFromSource +``` + +随后生成电子凭证、同步平台券码、发送买家领取链接。 + +### 2.3 拼多多 + +采用两段式: + +```text +PDD Cookie/MMS 拉单 +→ pdd_raw_orders 原始表 +→ mapPddOrderToSourceEvent +→ upsertOrderFromSource +``` + +原始订单与业务订单分离,便于失败重放。 + +## 三、统一入单与内部 SKU + +### 3.1 入单主流程 + +```text +SourceOrderEvent + → resolveOrderItemForFulfillment() + → 必要时查询 internal_sku_mappings + → resolveInternalSkuKey() + → 写入 orders / order_items + → 写入收入分摊 +``` + +核心代码: + +- `apps/backend/src/services/fulfillment/product-resolution-service.ts` +- `apps/backend/src/services/fulfillment/internal-sku-resolver.ts` +- `apps/backend/src/repositories/order-item-repo.ts` + +### 3.2 内部 SKU 的文本规则 + +内部 SKU 统一规范化: + +```text +NFKC +去掉全部空白 +转小写 +``` + +只有包含中文的值才能成为内部 SKU。纯数字码、英文平台 SKU 必须先经过映射。 + +### 3.3 `manual_sku_key` 生成规则 + +`order_items.manual_sku_key` 是数据库生成列,取值顺序: + +```text +spec_json.skuNick +→ spec_json.outSkuSn +→ sku_code +→ sku_name +``` + +对应迁移: + +```text +apps/backend/src/db/migrations/106_manual_sku_key.sql +``` + +### 3.4 平台字段映射为中文内部 SKU + +当平台只提供纯数字码或非中文 SKU 时,使用: + +```text +internal_sku_mappings +``` + +字段: + +```text +provider +shop_id +match_type sku_code / sku_nick / out_sku_sn / spec / sku_name +match_value +internal_sku +priority +enabled +source +``` + +匹配优先级: + +```text +精确 provider + shop +> provider +> shop +> 全局 +``` + +对应页面: + +```text +履约配置 → SKU 映射 +``` + +### 3.5 内部 SKU 具体存放在哪里 + +`order_items` 没有单独的 `internal_sku` 列。实际分布在: + +```text +order_items.manual_sku_key 生成列 +order_items.spec_json.skuNick 入单时写入 +order_items.item_snapshot_json.internalSku +``` + +同时可能写入: + +```json +{ + "internalSku": "5个潮汐礼券", + "internalSkuChannel": "work_order", + "classification": { + "channel": "work_order", + "canonicalSku": "5个潮汐礼券" + } +} +``` + +内部 SKU 也会出现在: + +```text +internal_sku_mappings.internal_sku +internal_sku_channels.internal_sku +manual_sku_registry.sku_key +manual_fulfillment_configs.sku_key +``` + +订单进入 `assigned` 或 `archived` 后,后续平台重推不会覆盖已固化的内部 SKU 和履约路由快照。 + +## 四、订单状态与履约状态 + +必须区分四层状态: + +| 层 | 字段或表 | 含义 | +| -------------- | ------------------------------------------------------ | ----------------------------------- | +| 平台交易状态 | `orders.order_status` / `orders.pay_status` | 平台订单是否创建、支付、关闭、退款 | +| 订单项路由状态 | `order_items.fulfillment_status` | `pending` / `assigned` / `archived` | +| 履约执行状态 | `work_orders.status` / `fulfillment_tasks.task_status` | 工单或自动发货任务执行到哪一步 | +| 财务状态 | `cost_snapshots` / `sales_daily_facts` / 总账 | 成本、收入事实与记账状态 | + +`order_items.fulfillment_status = assigned` 只表示: + +> 渠道已经确定,并且相应履约条件已具备。 + +它**不代表已经交付完成**。实际完成情况要看工单、任务或人工成本快照。 + +## 五、渠道判定 + +### 5.1 唯一渠道真源 + +```text +internal_sku_channels +``` + +核心字段: + +```text +internal_sku +channel +priority +is_fallback +enabled +canonical_product_id +config_json +source +``` + +### 5.2 主渠道与兜底 + +```text +priority 1–89 主渠道 +priority 90+ 兜底渠道 +is_fallback 是否兜底 +``` + +规则: + +- 同一内部 SKU 只能有一个主渠道; +- 同优先级出现多个主渠道时判定为 `routing_conflict`; +- 主渠道无法创建载体时,才尝试显式兜底渠道。 + +对应页面: + +```text +履约配置 → 渠道规则 +``` + +> `product_channel_capabilities` 主要是历史迁移来源;运行时渠道判断读取的是 `internal_sku_channels`。 + +### 5.3 渠道载体参数 + +| 渠道 | `config_json` 关键字段 | 真实载体 | +| --------------------- | ---------------------- | ------------------- | +| `affiliate_dash` | `affiliateSku` | `fulfillment_tasks` | +| `work_order` | `workRuleKey` | `work_orders` | +| `manual` | `manualConfigId` | 人工履约配置 | +| `third_party_voucher` | `voucherSku` | 无履约载体,只统计 | + +## 六、真实载体与最终渠道 + +渠道判定会同时检查真实载体: + +```text +work_orders 中未取消工单 +fulfillment_tasks 中有效的 affiliate_dash 任务 +fulfillment_tasks 中有效的 manual_dispatch 任务 +``` + +判定顺序: + +```text +1. 运营锁定 → 跳过自动判定 +2. 已有唯一真实载体 → 按载体确定渠道 +3. 多个真实载体 → 冲突 +4. 无载体 → 读取 internal_sku_channels +5. 主渠道创建载体失败 → 尝试显式 fallback +6. 仍失败 → 保持 pending +``` + +常见待处理原因: + +| 原因 | 含义 | +| --------------------------- | -------------------------------------- | +| `no_internal_sku` | 没有解析出中文内部 SKU | +| `no_channel_capability` | 没有匹配的渠道配置 | +| `no_carrier` | 有渠道配置,但任务/工单/人工配置未就绪 | +| `routing_conflict` | 渠道配置冲突 | +| `manual_config_missing` | 人工渠道缺少人工履约配置 | +| `work_rule_missing` | 接单渠道缺少 `workRuleKey` | +| `affiliate_mapping_missing` | 自动发货缺少 affiliate SKU 映射 | +| `existing_carrier_conflict` | 已存在其他真实履约载体 | + +核心代码: + +- `apps/backend/src/services/fulfillment/channel-assign-service.ts` +- `apps/backend/src/services/fulfillment/carrier-creation-service.ts` +- `apps/backend/src/services/fulfillment/carrier-evidence-service.ts` +- `apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts` + +## 七、四种履约方式 + +### 7.1 接单平台 `work_order` + +```text +内部 SKU +→ internal_sku_channels.work_order +→ config_json.workRuleKey +→ 匹配 work_product_rules +→ work_orders +→ 打手大厅 +→ 抢单/接单 +→ in_progress +→ 提交验收资料 +→ pending_acceptance +→ accepted +``` + +接单模板匹配仍然依赖平台字段,例如: + +- 来源、平台、店铺 +- 商品 ID、SKU 编码、SKU 昵称、商品标题 +- 卖家/店铺白名单 +- 数量系列 + +因此存在两层判断: + +```text +内部 SKU 渠道配置:是否走接单平台 +接单模板匹配:具体创建哪一种工单 +``` + +如果旧订单在修复前停在“无渠道能力”,可对待分类队列执行“重试接单匹配”。匹配成功后会创建工单并转为 `assigned/work_order`。 + +核心代码: + +- `apps/backend/src/services/worker-platform/sync-worker-orders-service.ts` +- `apps/backend/src/services/worker-platform/work-product-match-mappers.ts` +- `apps/backend/src/services/fulfillment/pending-channel-repair-service.ts` + +客户资料与订单进度统一通过 `/o/:platformOrderNo` 查询。分类级资料配置、 +工单配置版本快照、自动发布和客户状态文案见 +[订单服务收集与状态页设计-2026-11-03.md](订单服务收集与状态页设计-2026-11-03.md)。 + +### 7.2 自动发货 `affiliate_dash` + +```text +内部 SKU +→ config_json.affiliateSku +→ fulfillment_tasks +→ affiliate-dash 执行器 +→ 领取/发码/核销 +``` + +自动发货必须同时具备: + +- `internal_sku_channels.affiliate_dash` +- 可用的 affiliate SKU 映射 +- 实际创建成功的 `fulfillment_tasks` + +自动任务失败后,如果存在显式人工兜底,可切换为 `manual`。 + +### 7.3 人工客服 `manual` + +```text +内部 SKU +→ manual_fulfillment_configs +→ 人工履约渠道 + 消耗积分 +→ 客服确认履约 +→ 扣备用金或平台积分 +→ 写 manual_case 成本快照 +``` + +保存人工 SKU 配置时会自动: + +- 创建或关联 `man:<内部 SKU>` 标准商品; +- 同步 `internal_sku_channels`; +- 回填最近 30 天订单; +- 重试待处理的人工渠道订单。 + +对应页面: + +```text +商品与成本中心 → 成本配置 → 人工客服 +``` + +### 7.4 第三方卡券 `third_party_voucher` + +```text +内部 SKU +→ internal_sku_channels.third_party_voucher +→ 不创建任务 +→ 不创建工单 +→ 仅参与收入、采购成本与财务统计 +``` + +“无载体”是卡券渠道的正常状态,不属于错误。 + +## 八、商品归一 + +渠道确定后,订单项再进入商品维度: + +```text +order_items.canonical_product_id +→ product_catalog +``` + +标准商品编码前缀: + +| 前缀 | 来源 | +| ------ | ------------ | +| `aff:` | 自动发货 | +| `tpl:` | 接单模板 | +| `wo:` | 历史工单证据 | +| `tpv:` | 第三方卡券 | +| `man:` | 人工客服 | + +商品归一顺序: + +```text +人工配置指定的商品 +→ 接单模板对应商品 +→ affiliate 商品 +→ tpv 商品 +→ 中文内部 SKU 命中 canonical_name +→ channel_product_mappings 历史映射兜底 +``` + +注意: + +> 商品映射成功不代表渠道路由成功。 +> `channel_product_mappings` 解决“财务上归到哪个商品”,`internal_sku_channels` 解决“由谁履约”。 + +对应页面: + +```text +商品与成本中心 → 渠道映射 +商品与成本中心 → 标准商品 +``` + +## 九、财务链路 + +```text +channel_assign +→ carrier_dispatch +→ product_normalize +→ cost_snapshot +→ sales_facts +→ ledger_post +``` + +| 管道 | 作用 | 产物 | +| ------------------- | ----------------------------------- | ----------------------------------- | +| `channel_assign` | 规则 + 载体证据确定渠道 | `fulfillment_channel/status/source` | +| `carrier_dispatch` | 补人工配置、补工单/任务、发现缺载体 | 载体与异常队列 | +| `product_normalize` | 订单项关联标准商品 | `canonical_product_id` | +| `cost_snapshot` | 按渠道与成本版本计算成本 | `cost_snapshots` | +| `sales_facts` | 收入、退款、成本汇总 | `sales_daily_facts` | +| `ledger_post` | 事实转复式记账 | `finance_journal_entries` | + +核心代码: + +```text +apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +``` + +## 十、配置总表 + +| 要解决的问题 | 后台位置 | 数据表 | +| --------------------- | -------------------------------------- | --------------------------------------------------- | +| 平台码 → 中文内部 SKU | 履约配置 → SKU 映射 | `internal_sku_mappings` | +| 内部 SKU → 履约渠道 | 履约配置 → 渠道规则 | `internal_sku_channels` | +| 平台 SKU → 财务商品 | 商品与成本中心 → 渠道映射 | `channel_product_mappings` | +| 标准商品与成本版本 | 商品与成本中心 → 标准商品 | `product_catalog` / `cost_versions` | +| 人工积分与现金成本 | 商品与成本中心 → 成本配置 → 人工客服 | `manual_fulfillment_configs` | +| 接单模板及匹配范围 | 接单平台 → 接单模板 | `work_product_rules` / `work_product_rule_mappings` | +| 自动发货 SKU 映射 | 平台配置 / 自动发货配置 | affiliate 配置 + `fulfillment_tasks` | +| 第三方卡券 | 商品与成本中心 → 成本配置 → 第三方卡券 | `tpv:` 商品 + `internal_sku_channels` | + +## 十一、常见误区 + +### 11.1 “商品映射有了,为什么还是待分类?” + +因为商品映射和渠道路由是两套配置: + +```text +平台 SKU → 财务商品 channel_product_mappings +内部 SKU → 履约渠道 internal_sku_channels +``` + +两者不能互相替代。 + +### 11.2 “接单模板存在,为什么没有自动建工单?” + +还需要满足: + +- 内部 SKU 已配置为 `work_order`,或能通过模板自动补渠道; +- 模板的店铺/卖家范围、SKU 或名称条件实际命中; +- 模板处于启用状态; +- 自动建单开关允许自动创建。 + +### 11.3 “内部 SKU 相似,为什么没有复用配置?” + +内部 SKU 按规范化文本精确匹配。例如: + +```text +银月精灵/星谷精灵 +(2选1)银月精灵/星谷精灵 +银月精灵/星谷精灵【2选1】 +``` + +在当前模型中仍是三个不同键,除非通过映射统一。 + +### 11.4 “待分类行能不能直接删除?” + +待分类队列来自真实订单项,不建议提供直接删除。正确方式是修复配置并重新分流: + +- 修 `internal_sku_mappings` +- 修 `internal_sku_channels` +- 修接单模板匹配范围 +- 修人工履约配置 +- 点击“重试接单匹配” +- 执行财务/履约管道 + +直接删除会掩盖未履约收入和财务缺口。 + +## 十二、关键表速查 + +| 表 | 职责 | +| ---------------------------- | ----------------------------------------------- | +| `orders` | 平台订单交易头 | +| `order_items` | 商品明细、收入、内部 SKU 派生字段、履约路由结果 | +| `internal_sku_mappings` | 平台字段 → 中文内部 SKU | +| `internal_sku_channels` | 中文内部 SKU → 履约渠道、优先级、兜底、载体参数 | +| `manual_sku_registry` | 扫描到的人工履约 SKU 清单 | +| `manual_fulfillment_configs` | 人工履约渠道、积分和成本模式 | +| `work_product_rules` | 接单模板 | +| `work_product_rule_mappings` | 接单模板匹配范围 | +| `work_orders` | 接单平台真实履约载体 | +| `fulfillment_tasks` | 自动发货等执行任务 | +| `product_catalog` | 标准商品 | +| `channel_product_mappings` | 平台 SKU → 标准商品 | +| `cost_versions` | 成本金额版本 | +| `cost_snapshots` | 订单项成本事实 | +| `sales_daily_facts` | 按日期、店铺、商品、渠道汇总的销售事实 | +| `finance_journal_entries` | 财务总账凭证 | + +## 十三、关键代码入口 + +| 文件 | 职责 | +| -------------------------------------------------------------------------- | ----------------------------------------------- | +| `apps/backend/src/services/order/order-service.ts` | 所有来源统一订单落库与消费者分流 | +| `apps/backend/src/services/fulfillment/product-resolution-service.ts` | 平台字段归一、内部 SKU 解析、affiliate SKU 映射 | +| `apps/backend/src/services/fulfillment/internal-sku-resolver.ts` | 内部 SKU 文本规范化与 TS 解析口径 | +| `apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts` | 平台字段 → 内部 SKU 映射 | +| `apps/backend/src/services/fulfillment/intake-channel-decision-service.ts` | 入单时应用内部 SKU 渠道计划 | +| `apps/backend/src/services/fulfillment/internal-sku-channel-service.ts` | 读取、规划、写入内部 SKU 渠道 | +| `apps/backend/src/services/fulfillment/channel-assign-service.ts` | 基于规则与真实载体确定最终渠道 | +| `apps/backend/src/services/fulfillment/carrier-creation-service.ts` | 幂等创建工单/任务/确认人工配置 | +| `apps/backend/src/services/worker-platform/sync-worker-orders-service.ts` | 接单模板匹配与工单创建 | +| `apps/backend/src/services/order/delivery-task-service.ts` | 自动发货等履约任务创建 | +| `apps/backend/src/services/finance/manual-fulfillment-service.ts` | 人工确认履约、扣款与成本快照 | +| `apps/backend/src/services/cost-center/product-normalize-service.ts` | 订单项 → 标准商品 | +| `apps/backend/src/services/scheduler/finance-pipeline-jobs.ts` | 渠道、载体、商品、成本、事实、总账管道 | +| `apps/backend/src/services/fulfillment/pending-channel-repair-service.ts` | 待分类订单重试接单模板匹配 | diff --git a/docs/订单服务收集与状态页设计-2026-11-03.md b/docs/订单服务收集与状态页设计-2026-11-03.md new file mode 100644 index 00000000..5d1c89d0 --- /dev/null +++ b/docs/订单服务收集与状态页设计-2026-11-03.md @@ -0,0 +1,204 @@ +--- +type: 架构设计 +description: 分类级订单服务配置、配置版本快照、/o 客户资料与订单状态页、自动发布及短信通知预留。 +tags: [订单服务, 资料收集, 工单, 自动发布, 通知] +status: stable +--- + +# 订单服务收集与状态页设计 + +> 状态:现行。最后校准:2026-11-03。代码分支:`dev_11.3`。 + +## 1. 目标 + +旧的 `/collect`、`/supplement` 和通用资料收集方式已经下线。新方案统一使用: + +```text +/o 输入订单号 +/o/:platformOrderNo 查询资料与订单进度 +/api/v1/o/* 公开 API +``` + +一个接单分类对应一套订单服务配置。接单模板继续通过 `work_product_rules.category_id` 绑定分类,同一分类下的所有模板复用该分类的客户资料配置。 + +## 2. 配置真源 + +### 2.1 草稿与发布版本 + +配置表: + +```text +order_service_profiles +order_service_profile_versions +``` + +- 后台保存只修改 profile 草稿。 +- 点击“发布新版本”后,将当前草稿写入不可变版本。 +- 新工单只读取 `current_version_id` 对应的版本 JSON。 +- 已发布后继续编辑会把 profile 标记为 `draft`,但线上继续使用上一个已发布版本。 +- 归档只阻止新工单再选择该配置,不影响已有工单快照。 + +### 2.2 分类与接单模板 + +不维护“分类默认接单模板”这一反向字段。分类关系以接单模板自己的 `category_id` 为准: + +```text +订单项 → 接单模板 → category_id → 当前发布的订单服务版本 +``` + +这样不会出现“先确定分类才能选默认模板、但分类又来自模板”的循环依赖。 + +## 3. 工单快照 + +建工单时在同一业务步骤内固化: + +```text +work_orders.service_profile_id +work_orders.service_profile_version_id +work_orders.service_profile_snapshot_json +``` + +快照包含: + +```json +{ + "profileId": 1, + "categoryId": 1, + "versionId": 2, + "versionNo": 2, + "config": {} +} +``` + +工单创建时,如果分类存在已发布订单服务配置: + +- `requirement_json` 从快照字段生成。 +- 存在必填字段或必填截图时,工单进入 `pending_material`。 +- 所有字段选填且截图不要求时,工单直接进入 `unassigned`。 +- 后续修改配置不会影响该工单。 + +历史工单没有快照时继续读取其 `requirement_json`,不会突然切换到新分类配置。 + +## 4. 客户页面 + +`/o` 支持: + +- 按平台订单号查询一单多工单。 +- 动态文本、长文本、下拉、单选、数字、日期字段。 +- 一张分类引导图。 +- 图片截图规则,必填时最少 1 张。 +- 提交后禁止再次修改。 +- 每 15 秒、页面重新可见或窗口重新聚焦时刷新状态。 + +客户状态文案: + +| 状态 | 文案方向 | +| --- | --- | +| `pending_material` | 请补充资料 | +| `unassigned` | 已提交,等待客服安排 | +| `open` | 已发布大厅,高峰期请耐心等待 | +| `in_progress` | 已接单,30–60 分钟内检查邮箱 | +| `pending_acceptance` | 交付已提交,正在确认 | +| `accepted` | 已完成,检查邮件并联系客服处理问题 | +| `problem` | 客服处理中 | +| `cancelled` | 订单已取消 | + +## 5. 文件上传 + +公开上传入口: + +```text +POST /api/v1/o/:platformOrderNo/files?workOrderId=... +``` + +上传文件记录归属: + +```text +uploader_type = collect +uploader_id = work-order- +scene = collect-material +``` + +最终提交时逐个校验 `file_assets`: + +- 文件存在且状态为 active。 +- 上传者类型和工单 ID 一致。 +- scene 必须为 `collect-material`。 +- 客户截图只接受 JPG、PNG、WebP。 + +客户端上传返回的文件对象不会被直接信任。 + +## 6. 自动发布与核销 + +开启自动发布后,客户提交资料与自动发布任务在同一数据库事务内完成。 + +任务表: + +```text +order_service_auto_publish_jobs +``` + +任务状态: + +```text +pending → processing → succeeded + ↘ pending(可重试) + ↘ failed +pending/processing → skipped +``` + +并发安全: + +- `pending + processing` 对同一工单唯一。 +- worker 使用一条 `UPDATE ... FROM (SELECT ... FOR UPDATE SKIP LOCKED)` 原子领取。 +- `locked_until` 作为租约,进程中断后可被重新领取。 + +核销规则: + +- `requireVoucherConsume=true`:核销失败时不发布到大厅,任务进入重试。 +- `requireVoucherConsume=false`:核销尽力执行,失败不阻止发布。 +- `retryMax` 转为任务总尝试次数 `retryMax + 1`。 +- 后台展示失败原因并允许重置任务重试。 + +## 7. 通知预留 + +通知表: + +```text +customer_order_notifications +``` + +首版只记录事件,不实际发送短信。当前接入: + +- `material_submitted` +- `work_order_published` + +后续接短信时消费 pending 通知即可;用户状态提示不依赖短信。 + +## 8. 已下线 + +- `/collect` 页面和 hash 入口。 +- `/api/v1/collect` 路由。 +- `worker-collect-service`。 +- 前端旧 collect API 封装和类型。 +- `/supplement` 只保留重定向到 `/o`。 + +## 9. 本地验证 + +```bash +cd apps/backend +DATABASE_URL='postgres://postgres:postgres@127.0.0.1:5432/order_site' npm run db:migrate + +ORDER_SERVICE_SMOKE=1 \ +DATABASE_URL='postgres://postgres:postgres@127.0.0.1:5432/order_site' \ +npm exec tsx scripts/order-service-smoke.ts +``` + +烟测覆盖: + +1. 草稿发布与版本读取。 +2. 工单配置快照。 +3. 客户资料提交。 +4. 禁止重复提交。 +5. 自动发布任务处理。 +6. 发布后客户页显示“高峰期”状态。