feat(order-service): 重构分类资料收集与订单状态链路
This commit is contained in:
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
fields_json: OrderServiceProfileConfig['fields']
|
||||
screenshots_json: Record<string, unknown>
|
||||
auto_publish_json: Record<string, unknown>
|
||||
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 秒内未结束')
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
'工单创建时固化的订单服务配置快照;配置后续变更不影响该工单';
|
||||
@@ -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
|
||||
'客户资料提交后的自动发布任务;核销失败可按配置重试';
|
||||
@@ -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';
|
||||
@@ -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
|
||||
'领取任务的进程或实例标识';
|
||||
@@ -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<File
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getFileAssetByObjectKey(objectKey: string): Promise<FileAssetRow | null> {
|
||||
const result = await query<FileAssetRow>(
|
||||
export async function getFileAssetByObjectKey(
|
||||
objectKey: string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<FileAssetRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<FileAssetRow>(
|
||||
'SELECT * FROM file_assets WHERE object_key = $1 AND status = $2 LIMIT 1',
|
||||
[String(objectKey || '').trim(), 'active'],
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, unknown>
|
||||
fields_json: Array<Record<string, unknown>>
|
||||
screenshots_json: Record<string, unknown>
|
||||
auto_publish_json: Record<string, unknown>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<OrderServiceProfileRow>(
|
||||
`${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<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<OrderServiceProfileRow>(
|
||||
`${PROFILE_SELECT} WHERE p.id = $1 LIMIT 1`,
|
||||
[Number(profileId)],
|
||||
)
|
||||
return result.rows[0] ? mapProfile(result.rows[0]) : null
|
||||
}
|
||||
|
||||
export async function listOrderServiceProfiles(): Promise<OrderServiceProfileRow[]> {
|
||||
const result = await query<OrderServiceProfileRow>(
|
||||
`${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<string, unknown>
|
||||
fields: Array<Record<string, unknown>>
|
||||
screenshots: Record<string, unknown>
|
||||
autoPublish: Record<string, unknown>
|
||||
submitText: string
|
||||
successText: string
|
||||
adminUserId: number | null
|
||||
}): Promise<OrderServiceProfileRow> {
|
||||
const result = await query<OrderServiceProfileRow>(
|
||||
`
|
||||
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<OrderServiceProfileRow | null> {
|
||||
const result = await query<OrderServiceProfileRow>(
|
||||
`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<string, unknown>
|
||||
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<void> {
|
||||
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<PoolClient, 'query'>,
|
||||
): Promise<{
|
||||
id: number
|
||||
profile_id: number
|
||||
version_no: number
|
||||
config_json: Record<string, unknown>
|
||||
} | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<{
|
||||
id: number
|
||||
profile_id: number
|
||||
version_no: number
|
||||
config_json: Record<string, unknown>
|
||||
}>(
|
||||
`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<PoolClient, 'query'>,
|
||||
): Promise<OrderServicePublishedProfileRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<OrderServicePublishedProfileRow>(
|
||||
`
|
||||
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<string, unknown> | null
|
||||
now: string
|
||||
client?: Pick<PoolClient, 'query'>
|
||||
}): Promise<void> {
|
||||
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,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -408,6 +408,9 @@ export type WorkOrderRow = {
|
||||
user_payment_amount_updated_by?: string | null
|
||||
material_json: string | Record<string, unknown>
|
||||
requirement_json: string | Record<string, unknown>
|
||||
service_profile_id?: number | null
|
||||
service_profile_version_id?: number | null
|
||||
service_profile_snapshot_json?: string | Record<string, unknown> | null
|
||||
acceptance_json: string | Record<string, unknown>
|
||||
draft_acceptance_json: string | Record<string, unknown>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,12 +23,16 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
|
||||
sharing_unit_reward, timeout_minutes, timeout_policy,
|
||||
acceptance_mode, gift_phase, gift_cooldown_hours, gift_cooldown_minutes,
|
||||
material_json, requirement_json, source_order_amount_fen, created_at, updated_at
|
||||
material_json, requirement_json,
|
||||
service_profile_id, service_profile_version_id, service_profile_snapshot_json,
|
||||
source_order_amount_fen, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18, $19, $20, $21,
|
||||
$22::jsonb, $23::jsonb, $24, $25, $26
|
||||
$22::jsonb, $23::jsonb,
|
||||
$24, $25, $26::jsonb,
|
||||
$27, $28, $29
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
@@ -58,6 +62,9 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
normalizeWorkOrderGiftCooldownMinutes(input.giftCooldownMinutes),
|
||||
input.materialJson,
|
||||
input.requirementJson,
|
||||
input.serviceProfileId || null,
|
||||
input.serviceProfileVersionId || null,
|
||||
input.serviceProfileSnapshotJson || null,
|
||||
// 与 material_json 同源:统计要用的付款金额落到列上,避免每次刷新解析全表 JSON
|
||||
resolveSourceOrderAmountFen(input.materialJson),
|
||||
input.now,
|
||||
|
||||
@@ -25,12 +25,18 @@ import {
|
||||
saveAdminWorkProductRule,
|
||||
saveAdminWorkProductRuleMapping,
|
||||
saveAdminWorkerAnnouncementConfig,
|
||||
archiveAdminOrderServiceProfile,
|
||||
listAdminOrderServiceAutoPublishJobs,
|
||||
listAdminOrderServiceProfiles,
|
||||
publishAdminOrderServiceProfile,
|
||||
saveAdminOrderServiceProfile,
|
||||
saveAdminWorkerFinanceConfig,
|
||||
saveAdminWorkerHallConfig,
|
||||
saveAdminWorkerLevel,
|
||||
saveAdminWorkerPlatformNotificationConfig,
|
||||
saveAdminWorkerProductMatchConfig,
|
||||
saveAdminWorkerProductNameSuffixConfig,
|
||||
retryAdminOrderServiceAutoPublishJob,
|
||||
syncAdminWorkProductRulePrice,
|
||||
testAdminKuaishouProductMatch,
|
||||
testAdminProductMatchByOrderItem,
|
||||
@@ -137,6 +143,71 @@ router.delete(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/order-service/profiles',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => 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<string, unknown>,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<string>
|
||||
/** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */
|
||||
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)
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}) {
|
||||
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() }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -84,7 +84,6 @@ export {
|
||||
|
||||
export {
|
||||
isWorkerOrderVipEvidencePending,
|
||||
mapCollectLookupOrder,
|
||||
mapWorkCategory,
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderForHall,
|
||||
|
||||
@@ -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<PoolClient, 'query'>
|
||||
|
||||
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<string, unknown>
|
||||
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<AutoPublishJobRow>(
|
||||
`
|
||||
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<string, unknown>)
|
||||
: {}
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<string, unknown>) => ({
|
||||
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<AutoPublishJobRow>(
|
||||
`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) }
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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')
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
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<OrderServiceFieldType>([
|
||||
'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<OrderServiceProfileRow>(
|
||||
`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<string, unknown> } {
|
||||
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<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileSnapshot | null> {
|
||||
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<PoolClient, 'query'>
|
||||
}): Promise<OrderServiceProfileSnapshot | null> {
|
||||
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<string>()
|
||||
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<string, unknown> {
|
||||
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)
|
||||
}
|
||||
@@ -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', '处理中'), '处理中')
|
||||
})
|
||||
@@ -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<string, string> = {
|
||||
[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<string, string> = {
|
||||
[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<string, string>
|
||||
screenshots?: Array<Record<string, unknown>>
|
||||
screenshotsRule?: OrderServiceProfileConfig['screenshots']
|
||||
guideImage?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<WorkOrderRow>(
|
||||
`
|
||||
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<WorkOrderRow>(
|
||||
`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<OrderServicePublicItem> {
|
||||
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<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileConfig | null> {
|
||||
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<typeof normalizeUploadedFiles>,
|
||||
workOrderId: number,
|
||||
client: Pick<PoolClient, 'query'>,
|
||||
): Promise<UploadedFileDto[]> {
|
||||
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',
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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<WorkOrderRow, 'status' | 'assigned_worker_id' | 'active_share_quantity'>,
|
||||
): 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<string, string>,
|
||||
@@ -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<WorkOrderRow> {
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -77,9 +77,3 @@ export {
|
||||
submitWorkerOrderAcceptance,
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
} from './worker-acceptance-service.js'
|
||||
|
||||
export {
|
||||
collectLookupWorkOrder,
|
||||
collectSubmitWorkOrder,
|
||||
resolveCollectSubmitTargetWorkOrder,
|
||||
} from './worker-collect-service.js'
|
||||
|
||||
+46
-36
@@ -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 <RedeemApplication />
|
||||
}
|
||||
|
||||
// 补资料页(`/supplement`):路径式公开页,先于站点判断,
|
||||
// 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。
|
||||
if (isCollectMaterialLinkPath(window.location.pathname)) {
|
||||
return <CollectMaterialLinkApplication />
|
||||
// 订单服务页(`/o`):路径式公开页,先于站点判断,
|
||||
// 保证后台域名和打手端域名下都能打开。旧 /supplement 也统一跳到这里。
|
||||
if (isOrderServicePath(window.location.pathname)) {
|
||||
return <OrderServiceApplication />
|
||||
}
|
||||
|
||||
// 旧 /supplement 短期兼容:统一跳到 /o。
|
||||
if (isLegacySupplementPath(window.location.pathname)) {
|
||||
return <CollectRedirectApplication />
|
||||
}
|
||||
|
||||
if (isWorkerSite()) {
|
||||
@@ -138,6 +143,29 @@ export default function App() {
|
||||
return <AdminApplication />
|
||||
}
|
||||
|
||||
function OrderServiceApplication() {
|
||||
useClearStrayHash()
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/o" element={<OrderServicePage />} />
|
||||
<Route path="/o/:orderNo" element={<OrderServicePage />} />
|
||||
<Route path="*" element={<OrderServicePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectRedirectApplication() {
|
||||
useEffect(() => {
|
||||
const orderNo = resolveLegacySupplementOrderNo(window.location.pathname)
|
||||
window.location.replace(orderNo ? `/o/${encodeURIComponent(orderNo)}` : '/o')
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function AdminApplication() {
|
||||
return (
|
||||
<HashRouter>
|
||||
@@ -193,7 +221,7 @@ function AdminApplication() {
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/claim/:token" element={<ClaimPage />} />
|
||||
<Route path="/collect" element={<CollectPage />} />
|
||||
<Route path="/collect" element={<CollectHashRedirectApplication />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -201,6 +229,13 @@ function AdminApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
function CollectHashRedirectApplication() {
|
||||
useEffect(() => {
|
||||
window.location.replace('/o')
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function RedeemApplication() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -239,31 +274,6 @@ function ClaimApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补资料页(`/supplement`)的独立路由树。
|
||||
*
|
||||
* - `/supplement`:输入平台订单号;
|
||||
* - `/supplement/<订单号>`:直接打开对应订单的补资料页;
|
||||
* - 其余子路径:交给补资料页展示「订单号无效」。
|
||||
*
|
||||
* 路径式公开页,不带 hash;不依赖后台会话。
|
||||
*/
|
||||
function CollectMaterialLinkApplication() {
|
||||
useClearStrayHash()
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/supplement" element={<CollectMaterialLinkEntryPage />} />
|
||||
<Route path="/supplement/:code" element={<CollectMaterialLinkPage />} />
|
||||
<Route path="*" element={<CollectMaterialLinkPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 领取入口(`/h5` 命名空间)的独立路由树。
|
||||
*
|
||||
|
||||
@@ -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: <AnnouncementPanel /> }]
|
||||
: []),
|
||||
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
||||
{ key: 'order-service', label: '订单服务', children: <OrderServicePanel /> },
|
||||
{ key: 'product-match', label: '匹配诊断', children: <ProductMatchPanel /> },
|
||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
||||
|
||||
@@ -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<OrderServiceFormValues>()
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
|
||||
const [fields, setFields] = useState<OrderServiceField[]>([])
|
||||
const [guideImage, setGuideImage] = useState<UploadedFile | Record<string, unknown> | 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<OrderServiceField>) {
|
||||
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) => (
|
||||
<Tag color={value === 'active' ? 'green' : 'default'}>
|
||||
{value === 'active' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单服务',
|
||||
width: 180,
|
||||
render: (_: unknown, row: { categoryId: number }) => {
|
||||
const rowProfile = profiles.find((item) => item.categoryId === row.categoryId)
|
||||
if (!rowProfile) return <Tag>未配置</Tag>
|
||||
if (rowProfile.status === 'draft') {
|
||||
return (
|
||||
<Tag color={rowProfile.currentVersionNo ? 'orange' : 'default'}>
|
||||
{rowProfile.currentVersionNo
|
||||
? `有未发布修改(线上 v${rowProfile.currentVersionNo})`
|
||||
: '草稿'}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color={rowProfile.status === 'published' ? 'green' : 'default'}>
|
||||
{rowProfile.status === 'published'
|
||||
? `已发布 v${rowProfile.currentVersionNo || 1}`
|
||||
: rowProfile.status}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, row: { categoryId: number }) => (
|
||||
<Button size="small" onClick={() => setSelectedCategoryId(row.categoryId)}>
|
||||
配置
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[profiles],
|
||||
)
|
||||
|
||||
const jobColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '订单',
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{row.productName || '-'}</Text>
|
||||
<Text type="secondary">{row.platformOrderId || '-'}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '重试',
|
||||
width: 90,
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) =>
|
||||
`${row.attemptCount}/${row.maxAttempts}`,
|
||||
},
|
||||
{
|
||||
title: '失败原因',
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||||
<Text type="danger">{row.lastError || '未知错误'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) =>
|
||||
row.updatedAt ? formatAdminDateTime(row.updatedAt) : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||||
<Button
|
||||
size="small"
|
||||
loading={retryingJobId === row.id}
|
||||
onClick={() => void handleRetryJob(row)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[retryingJobId],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card
|
||||
size="small"
|
||||
title="订单服务配置"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={profilesQuery.isFetching || jobsQuery.isFetching}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="一个分类使用一套订单服务配置"
|
||||
description="接单模板仍通过“所属分类”绑定分类;客户资料字段只在这里维护,不再在接单模板里重复配置。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey="categoryId"
|
||||
size="small"
|
||||
loading={profilesQuery.isLoading}
|
||||
dataSource={categories}
|
||||
columns={categoryColumns}
|
||||
pagination={false}
|
||||
rowClassName={(row: { categoryId: number }) =>
|
||||
row.categoryId === selectedCategoryId ? 'ant-table-row-selected' : ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{selectedCategory ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={`配置:${selectedCategory.name}`}
|
||||
extra={
|
||||
<Space size={6} wrap>
|
||||
<Tag color={profile?.status === 'published' ? 'green' : 'orange'}>
|
||||
{profile?.status === 'published'
|
||||
? `线上 v${profile.currentVersionNo || 1}`
|
||||
: profile?.currentVersionNo
|
||||
? `线上 v${profile.currentVersionNo},当前有草稿`
|
||||
: '未发布'}
|
||||
</Tag>
|
||||
<Button loading={publishing} onClick={() => void handlePublish()}>
|
||||
发布新版本
|
||||
</Button>
|
||||
{profile ? (
|
||||
<Button danger loading={publishing} onClick={() => void handleArchive()}>
|
||||
归档
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="配置名称" name="name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="客户页标题" name="title">
|
||||
<Input placeholder="例如:蛋仔派对资料填写" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="页面说明" name="description">
|
||||
<Input placeholder="显示在客户页顶部" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="提交按钮文案" name="submitText">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="提交成功文案" name="successText">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="用户引导图(一张)" style={{ marginBottom: 16 }}>
|
||||
<ImageUpload
|
||||
scene="order-service"
|
||||
scope="admin"
|
||||
maxCount={1}
|
||||
value={guideImage ? [guideImage as UploadedFile] : []}
|
||||
onChange={(files) => setGuideImage(files[0] || null)}
|
||||
uploader={(file, scene) => uploadFile(file, scene, 'admin')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="资料字段"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={addField} size="small">
|
||||
添加字段
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<Empty description="至少添加一个资料字段后才能发布" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={`${field.key}-${index}`} size="small">
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={5}>
|
||||
<Input
|
||||
addonBefore="Key"
|
||||
value={field.key}
|
||||
onChange={(event) => updateField(index, { key: event.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={5}>
|
||||
<Input
|
||||
addonBefore="名称"
|
||||
value={field.label}
|
||||
onChange={(event) => updateField(index, { label: event.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={field.type}
|
||||
onChange={(value) => updateField(index, { type: value, options: [] })}
|
||||
options={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'textarea', label: '长文本' },
|
||||
{ value: 'select', label: '下拉' },
|
||||
{ value: 'radio', label: '单选' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Space>
|
||||
<Text type="secondary">必填</Text>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
onChange={(value) => updateField(index, { required: value })}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Space>
|
||||
<Button
|
||||
disabled={index === 0}
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => moveField(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
disabled={index === fields.length - 1}
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => moveField(index, 1)}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeField(index)}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Input
|
||||
addonBefore="占位提示"
|
||||
value={field.placeholder || ''}
|
||||
onChange={(event) =>
|
||||
updateField(index, { placeholder: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Input
|
||||
addonBefore="填写说明"
|
||||
value={field.helpText || ''}
|
||||
onChange={(event) =>
|
||||
updateField(index, { helpText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
{(field.type === 'select' || field.type === 'radio') && (
|
||||
<Col span={24}>
|
||||
<Input
|
||||
addonBefore="选项"
|
||||
placeholder="选项1,选项2,选项3"
|
||||
value={(field.options || []).join(',')}
|
||||
onChange={(event) =>
|
||||
updateField(index, {
|
||||
options: event.target.value
|
||||
.split(/[,,]/)
|
||||
.map((option) => option.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card size="small" title="截图规则">
|
||||
<Form.Item name={['screenshots', 'label']} label="截图标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'helpText']} label="截图说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space size={24} wrap>
|
||||
<Form.Item
|
||||
name={['screenshots', 'enabled']}
|
||||
label="启用"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['screenshots', 'required']}
|
||||
label="必填"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'minCount']} label="最少">
|
||||
<Input type="number" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'maxCount']} label="最多">
|
||||
<Input type="number" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card size="small" title="自动发布并核销">
|
||||
<Space direction="vertical" className="full-width">
|
||||
<Space size={24} wrap>
|
||||
<Form.Item
|
||||
name={['autoPublish', 'enabled']}
|
||||
label="开启"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['autoPublish', 'requireVoucherConsume']}
|
||||
label="要求核销"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['autoPublish', 'retryMax']} label="额外重试次数">
|
||||
<Input type="number" min={0} max={10} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Text type="secondary">
|
||||
开启后,客户提交完整资料会按当前工单快照自动发布;要求核销时,核销失败不会发布到大厅。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
htmlType="button"
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
applyProfile(profile)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={saving} type="primary">
|
||||
保存草稿
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
<Card size="small">
|
||||
<Empty description="请选择分类" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="自动发布失败任务"
|
||||
extra={<Tag color={failedJobs.length > 0 ? 'red' : 'default'}>{failedJobs.length}</Tag>}
|
||||
>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={jobsQuery.isLoading}
|
||||
dataSource={failedJobs}
|
||||
columns={jobColumns}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeFieldOrder(fields: OrderServiceField[]) {
|
||||
return fields.map((field, index) => ({
|
||||
...field,
|
||||
sortOrder: (index + 1) * 10,
|
||||
}))
|
||||
}
|
||||
|
||||
function findDuplicateKeys(fields: OrderServiceField[]) {
|
||||
const seen = new Set<string>()
|
||||
const duplicates = new Set<string>()
|
||||
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]
|
||||
}
|
||||
@@ -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() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 资料字段 */}
|
||||
<Form.Item
|
||||
label="资料字段(工单要求打手提交的资料)"
|
||||
name="fieldsText"
|
||||
extra="格式:key:名称#选项1,选项2(每行一个字段)"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder={
|
||||
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="客户资料字段已统一到「订单服务」配置"
|
||||
description="接单模板只负责匹配、价格、拼单和时效;请按分类到「接单平台 → 订单服务」维护客户填写字段。"
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Divider style={{ margin: '14px 0 12px' }} />
|
||||
|
||||
@@ -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() {
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => void copyMaterialLink(row)}
|
||||
title="复制补资料链接(按平台订单号打开)"
|
||||
title="复制订单服务链接(按平台订单号打开)"
|
||||
>
|
||||
复制补资料链接
|
||||
复制订单服务链接
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canResetMaterial && canUpdateMaterial ? (
|
||||
|
||||
@@ -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 (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3} className="collect-link-title">
|
||||
订单资料补充
|
||||
</Title>
|
||||
<Text type="secondary">请输入下单时使用的订单号,进入补资料页面。</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" onFinish={handleFinish}>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input autoComplete="off" allowClear placeholder="请输入订单号" size="large" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="submit"
|
||||
block
|
||||
icon={<ArrowRightOutlined />}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
如果客服已经发给你完整链接,直接打开链接即可。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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<CollectMaterialLinkInfo | null>(null)
|
||||
const [loadError, setLoadError] = useState<LinkLoadError | null>(null)
|
||||
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
|
||||
const submittingRef = useRef(false)
|
||||
const requestSeqRef = useRef(0)
|
||||
/** React StrictMode 开发环境会重复执行 mount effect,这里按订单号去重,避免刷新时打两次查询接口。 */
|
||||
const loadedCodeRef = useRef<string | null>(null)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
const [submittedSuccess, setSubmittedSuccess] = useState(false)
|
||||
const [submittedNames, setSubmittedNames] = useState<string[]>([])
|
||||
|
||||
/**
|
||||
* 读取订单资料状态。
|
||||
*
|
||||
* 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<number, UploadedFile[]> = {}
|
||||
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<string, string>) {
|
||||
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 (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
{loading ? (
|
||||
<div className="collect-link-state">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
<Text type="secondary">正在读取订单资料...</Text>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<Result
|
||||
status={loadError.code === 'material_link_not_pending' ? 'info' : 'warning'}
|
||||
title={resolveErrorTitle(loadError.code)}
|
||||
subTitle={
|
||||
loadError.code === 'material_link_not_pending'
|
||||
? undefined
|
||||
: loadError.code === 'rate_limited'
|
||||
? '请稍后再试'
|
||||
: loadError.message || NOT_FOUND_MESSAGE
|
||||
}
|
||||
extra={
|
||||
<Link to="/supplement">
|
||||
<Button>重新输入订单号</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : submittedSuccess ? (
|
||||
<div className="collect-link-success">
|
||||
<CheckCircleFilled className="collect-link-success-icon" />
|
||||
<Title level={4} className="collect-link-success-title">
|
||||
提交成功
|
||||
</Title>
|
||||
<Text type="secondary">资料已提交;如需修改请联系客服。</Text>
|
||||
<div className="collect-link-success-products">
|
||||
<span>本次提交</span>
|
||||
<strong>{submittedProductNames || '订单资料'}</strong>
|
||||
</div>
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
本页面仅用于补充订单资料,请妥善保管订单号。
|
||||
</Text>
|
||||
</div>
|
||||
) : info ? (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
{info.pendingCount > 0 ? (
|
||||
<Text type="secondary">请补充以下资料,提交后客服会尽快处理。</Text>
|
||||
) : null}
|
||||
|
||||
{info.pendingCount > 1 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前订单有 ${info.pendingCount} 个商品待补充资料,请逐个提交`}
|
||||
description="每个商品的资料仅可提交一次,提交成功后不能再修改。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{info.pendingCount === 0 ? (
|
||||
<Alert type="warning" showIcon message={emptyStateMessage} />
|
||||
) : null}
|
||||
|
||||
{info.orders.map((order) => (
|
||||
<Card key={order.workOrderId} className="collect-link-order-card" size="small">
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<div className="collect-link-order-head">
|
||||
<div className="collect-link-order-name">
|
||||
<span>{order.productName || '订单商品'}</span>
|
||||
</div>
|
||||
<div className="collect-link-order-no">
|
||||
<span>订单号</span>
|
||||
<strong>{order.platformOrderId || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.canSubmit ? (
|
||||
<>
|
||||
{info.pendingCount === 1 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该商品资料仅可提交一次"
|
||||
description="提交成功后不能再修改;如需修改请联系客服。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MaterialLinkOrderForm
|
||||
order={order}
|
||||
code={code}
|
||||
screenshots={
|
||||
screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? []
|
||||
}
|
||||
submitting={submittingWorkOrderId === order.workOrderId}
|
||||
disabled={submittingWorkOrderId !== 0}
|
||||
onScreenshotsChange={(files) =>
|
||||
setScreenshotsByOrder((previous) => ({
|
||||
...previous,
|
||||
[order.workOrderId]: files,
|
||||
}))
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(order, values)}
|
||||
/>
|
||||
</>
|
||||
) : info.pendingCount > 0 ? (
|
||||
<Alert
|
||||
type={order.blockedReason === 'already_submitted' ? 'info' : 'warning'}
|
||||
showIcon
|
||||
message={resolveMaterialOrderMessage(order)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
本页面仅用于补充订单资料,请妥善保管订单号。
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
type MaterialLinkOrderFormProps = {
|
||||
order: CollectMaterialLinkOrder
|
||||
code: string
|
||||
screenshots: UploadedFile[]
|
||||
submitting: boolean
|
||||
disabled: boolean
|
||||
onScreenshotsChange: (files: UploadedFile[]) => void
|
||||
onSubmit: (values: Record<string, string>) => void
|
||||
}
|
||||
|
||||
function MaterialLinkOrderForm({
|
||||
order,
|
||||
code,
|
||||
screenshots,
|
||||
submitting,
|
||||
disabled,
|
||||
onScreenshotsChange,
|
||||
onSubmit,
|
||||
}: MaterialLinkOrderFormProps) {
|
||||
return (
|
||||
<Form layout="vertical" initialValues={order.values || {}} onFinish={onSubmit}>
|
||||
{(order.fields ?? []).map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={
|
||||
<Space size={6}>
|
||||
<span>{field.label}</span>
|
||||
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
|
||||
</Space>
|
||||
}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{field.type === 'select' && (field.options || []).length > 0 ? (
|
||||
<Radio.Group
|
||||
options={(field.options || []).map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Input autoComplete="off" placeholder={`请输入${field.label}`} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
<Form.Item label="买家主页截图">
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="collect"
|
||||
maxCount={5}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
value={screenshots}
|
||||
onChange={onScreenshotsChange}
|
||||
uploader={(file, scene) =>
|
||||
uploadCollectMaterialLinkFile(file, scene, code, order.workOrderId)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="collect-link-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
提交资料
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -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<CollectLookupOrder[]>([])
|
||||
const [lookupLoading, setLookupLoading] = useState(false)
|
||||
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
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<string, string>) {
|
||||
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 (
|
||||
<main className="collect-page">
|
||||
<Card className="collect-card" bordered={false}>
|
||||
<Typography.Title level={3}>订单资料补充</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
请填写正确订单号,匹配成功后继续补充资料。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form layout="vertical" onFinish={submitLookup}>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
className="collect-order-input"
|
||||
rules={[{ required: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input placeholder="订单号" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SearchOutlined />}
|
||||
loading={lookupLoading}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form>
|
||||
|
||||
{complete ? (
|
||||
<Result
|
||||
status="success"
|
||||
title="资料已提交完成"
|
||||
subTitle="订单已进入待分配状态,请等待处理。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!complete && lookupOrders.length > 1 ? (
|
||||
<Typography.Paragraph type="secondary" className="platform-section-gap">
|
||||
当前订单有 {lookupOrders.length} 个待完善商品,请逐个提交资料。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
|
||||
{lookupOrders.map((currentOrder) => (
|
||||
<Card
|
||||
key={currentOrder.workOrderId}
|
||||
className="platform-section-gap"
|
||||
title={currentOrder.productName}
|
||||
extra={<Typography.Text type="secondary">{currentOrder.workOrderNo}</Typography.Text>}
|
||||
size="small"
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单号:{currentOrder.platformOrderId}
|
||||
</Typography.Paragraph>
|
||||
<Form
|
||||
key={currentOrder.workOrderId}
|
||||
layout="vertical"
|
||||
initialValues={currentOrder.values || {}}
|
||||
onFinish={(values) => submitMaterial(currentOrder, values)}
|
||||
>
|
||||
{currentOrder.fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
name={field.key}
|
||||
rules={
|
||||
field.required ? [{ required: true, message: `请选择/填写${field.label}` }] : []
|
||||
}
|
||||
>
|
||||
{field.type === 'select' && (field.options || []).length > 0 ? (
|
||||
<Radio.Group
|
||||
options={(field.options || []).map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Input />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item label="买家主页截图">
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="collect"
|
||||
maxCount={5}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
value={
|
||||
screenshotsByOrder[currentOrder.workOrderId] ?? currentOrder.screenshots ?? []
|
||||
}
|
||||
onChange={(files) =>
|
||||
setScreenshotsByOrder((prev) => ({
|
||||
...prev,
|
||||
[currentOrder.workOrderId]: files,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submittingWorkOrderId === currentOrder.workOrderId}
|
||||
>
|
||||
提交资料
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
|
||||
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'
|
||||
)
|
||||
}
|
||||
@@ -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<OrderServiceInfo | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState<{ code: string; message: string } | null>(null)
|
||||
const [submittedNames, setSubmittedNames] = useState<string[]>([])
|
||||
const [lastSuccessText, setLastSuccessText] = useState('')
|
||||
const [submittingId, setSubmittingId] = useState(0)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
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<string, unknown>) {
|
||||
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 (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3}>订单服务</Title>
|
||||
<Text type="secondary">请输入下单时使用的订单号,补充资料或查看进度。</Text>
|
||||
</div>
|
||||
<Form layout="vertical" onFinish={handleSearch}>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
autoComplete="off"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="请输入订单号"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button block htmlType="submit" size="large" type="primary">
|
||||
查询订单
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const pageConfig = info?.items[0]
|
||||
const pendingItems = (info?.items || []).filter((item) => item.canSubmit)
|
||||
|
||||
return (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
{loading ? (
|
||||
<div className="collect-link-state">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
<Text type="secondary">正在读取订单信息...</Text>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<Result
|
||||
status={loadError.code === 'rate_limited' ? 'warning' : 'info'}
|
||||
title={resolveErrorTitle(loadError.code)}
|
||||
subTitle={loadError.message}
|
||||
extra={<Button onClick={() => void load()}>刷新重试</Button>}
|
||||
/>
|
||||
) : info ? (
|
||||
<Space direction="vertical" size={14} className="full-width">
|
||||
<div className="collect-link-heading">
|
||||
<div>
|
||||
<Title level={4}>{pageConfig?.title || '订单服务'}</Title>
|
||||
<Text type="secondary">订单号:{info.platformOrderId}</Text>
|
||||
{pageConfig?.description ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Text type="secondary">{pageConfig.description}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
size="small"
|
||||
onClick={() => void load({ silent: true })}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{submittedNames.length > 0 ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={lastSuccessText || '资料已提交'}
|
||||
description={`${submittedNames.join('、')};页面会每 15 秒自动更新订单状态。`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{pendingItems.length > 1 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前订单有 ${pendingItems.length} 个商品需要补充资料`}
|
||||
description="请逐个商品提交;每个商品资料仅可提交一次。"
|
||||
/>
|
||||
) : pendingItems.length === 1 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该商品资料仅可提交一次"
|
||||
description="提交成功后不能再修改;如需修改请联系客服。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{info.items.map((item) =>
|
||||
item.canSubmit ? (
|
||||
<OrderServiceForm
|
||||
key={item.workOrderId}
|
||||
item={item}
|
||||
code={orderNo}
|
||||
submitting={submittingId === item.workOrderId}
|
||||
disabled={submittingId !== 0}
|
||||
screenshots={screenshotsByOrder[item.workOrderId] ?? item.screenshots ?? []}
|
||||
onScreenshotsChange={(files) => {
|
||||
setScreenshotsByOrder((current) => ({
|
||||
...current,
|
||||
[item.workOrderId]: files,
|
||||
}))
|
||||
}}
|
||||
onSubmit={(values) => void handleSubmit(item, values)}
|
||||
/>
|
||||
) : (
|
||||
<Card key={item.workOrderId} size="small">
|
||||
<Space direction="vertical" size={6} className="full-width">
|
||||
<Space size={8} wrap>
|
||||
<strong>{item.productName || item.categoryName || '订单商品'}</strong>
|
||||
<Tag color={item.state === 'accepted' ? 'green' : 'blue'}>
|
||||
{item.stateLabel}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary">{item.message}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
资料提交后如需修改,请联系客服。
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, unknown>) => void
|
||||
}) {
|
||||
const rule = item.screenshotsRule || {
|
||||
enabled: false,
|
||||
required: false,
|
||||
minCount: 0,
|
||||
maxCount: 5,
|
||||
label: '截图',
|
||||
helpText: '',
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" className="collect-link-order-card">
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<div className="collect-link-order-head">
|
||||
<div className="collect-link-order-name">
|
||||
<span>{item.productName || item.categoryName || '订单商品'}</span>
|
||||
</div>
|
||||
<div className="collect-link-order-no">
|
||||
<span>分类</span>
|
||||
<strong>{item.categoryName || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.title ? <Title level={5}>{item.title}</Title> : null}
|
||||
{item.description ? <Text type="secondary">{item.description}</Text> : null}
|
||||
|
||||
{item.guideImage && 'url' in item.guideImage && item.guideImage.url ? (
|
||||
<img
|
||||
alt="填写引导图"
|
||||
src={String(item.guideImage.url)}
|
||||
style={{ width: '100%', borderRadius: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{item.fields && item.fields.length > 0 ? (
|
||||
<Form layout="vertical" initialValues={item.values || {}} onFinish={onSubmit}>
|
||||
{item.fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={
|
||||
<Space size={6}>
|
||||
<span>{field.label}</span>
|
||||
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={field.helpText}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderFieldInput(field)}
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
{rule.enabled ? (
|
||||
<Form.Item
|
||||
label={
|
||||
<Space size={6}>
|
||||
<span>{rule.label}</span>
|
||||
<Tag color={rule.required ? 'orange' : 'default'}>
|
||||
{rule.required ? '必填' : '选填'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={rule.helpText}
|
||||
>
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="collect"
|
||||
maxCount={rule.maxCount}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
value={screenshots}
|
||||
onChange={onScreenshotsChange}
|
||||
uploader={async (file, scene) => {
|
||||
const response = await uploadOrderServiceFile(
|
||||
file,
|
||||
scene,
|
||||
code,
|
||||
item.workOrderId,
|
||||
)
|
||||
return response.data.file
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<div className="collect-link-actions">
|
||||
<Button
|
||||
block
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
disabled={disabled}
|
||||
type="primary"
|
||||
>
|
||||
{item.submitText || '提交资料'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<Alert type="warning" showIcon message="该商品尚未配置资料收集字段,请联系客服。" />
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function renderFieldInput(field: NonNullable<OrderServiceItem['fields']>[number]) {
|
||||
const placeholder = field.placeholder || `请输入${field.label}`
|
||||
if (field.type === 'textarea') {
|
||||
return <Input.TextArea autoComplete="off" placeholder={placeholder} rows={3} />
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={placeholder}
|
||||
options={(field.options || []).map((option) => ({ label: option, value: option }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (field.type === 'radio') {
|
||||
return (
|
||||
<Radio.Group
|
||||
options={(field.options || []).map((option) => ({ label: option, value: option }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
return <InputNumber style={{ width: '100%' }} placeholder={placeholder} />
|
||||
}
|
||||
if (field.type === 'date') {
|
||||
return <DatePicker style={{ width: '100%' }} placeholder={placeholder} />
|
||||
}
|
||||
return <Input autoComplete="off" placeholder={placeholder} />
|
||||
}
|
||||
|
||||
function normalizeFormFieldValues(item: OrderServiceItem, values: Record<string, unknown>) {
|
||||
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()]
|
||||
}),
|
||||
)
|
||||
}
|
||||
+4
-13
@@ -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()
|
||||
@@ -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<string, unknown>
|
||||
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<AdminOrderServiceProfileList>(
|
||||
'/api/v1/admin/worker-platform/order-service/profiles',
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminOrderServiceProfile(payload: {
|
||||
categoryId: number
|
||||
name: string
|
||||
title?: string
|
||||
description?: string
|
||||
guideImage?: UploadedFile | Record<string, unknown>
|
||||
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`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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<CollectLookupResponse>('/api/v1/collect/lookup', { orderNo })
|
||||
}
|
||||
|
||||
export function submitCollectOrder(payload: {
|
||||
orderNo: string
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
}) {
|
||||
return apiPost<{ complete: boolean; order: Record<string, unknown> }>(
|
||||
'/api/v1/collect/submit',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCollectMaterialLink(code: string) {
|
||||
return apiGet<CollectMaterialLinkInfo>(`/api/v1/collect/link/${encodeURIComponent(code)}`)
|
||||
}
|
||||
|
||||
export function submitCollectMaterialLink(
|
||||
code: string,
|
||||
payload: {
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
},
|
||||
) {
|
||||
return apiPost<CollectMaterialLinkSubmitResult>(
|
||||
`/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<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
screenshotsRule?: OrderServiceScreenshots
|
||||
guideImage?: UploadedFile | Record<string, unknown>
|
||||
}
|
||||
|
||||
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<OrderServiceInfo>(`/api/v1/o/${encodeURIComponent(orderNo)}`)
|
||||
}
|
||||
|
||||
export function submitOrderServiceMaterial(
|
||||
orderNo: string,
|
||||
payload: {
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
},
|
||||
) {
|
||||
return apiPost<OrderServiceSubmitResult>(
|
||||
`/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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>
|
||||
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<string, string>
|
||||
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
|
||||
|
||||
@@ -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<string, string[][]> = {
|
||||
'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']],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(''), '')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user