feat(order-service): 重构分类资料收集与订单状态链路

This commit is contained in:
yml2213
2026-11-03 19:46:49 +08:00
parent 6e4758a3c7
commit 9f14e8de7a
59 changed files with 4866 additions and 1734 deletions
+281
View File
@@ -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 秒内未结束')
}
+2 -2
View File
@@ -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
-171
View File
@@ -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
+119
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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()]
}),
)
}
@@ -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`,
)
}
+4 -30
View File
@@ -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)。
+90 -38
View File
@@ -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,
)
}
+7
View File
@@ -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']],
-68
View File
@@ -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
}
}
-66
View File
@@ -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')
})