feat(order-service): 重构分类资料收集与订单状态链路
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
import { WORK_ORDER_STATUS } from '../src/domain/work-order-status.js'
|
||||
import { createWorkOrder } from '../src/repositories/worker-platform/work-order-management-repo.js'
|
||||
import type { WorkOrderRow } from '../src/repositories/worker-platform/types.js'
|
||||
import {
|
||||
publishAdminOrderServiceProfile,
|
||||
saveAdminOrderServiceProfile,
|
||||
type OrderServiceProfileConfig,
|
||||
} from '../src/services/worker-platform/order-service-profile-service.js'
|
||||
import {
|
||||
getOrderServiceByPlatformOrderId,
|
||||
submitOrderServiceMaterial,
|
||||
} from '../src/services/worker-platform/order-service-public-service.js'
|
||||
import { processDueOrderServiceAutoPublishJobs } from '../src/services/worker-platform/order-service-auto-publish-service.js'
|
||||
import { randomId } from '../src/utils/random.js'
|
||||
import { nowIso } from '../src/utils/time.js'
|
||||
|
||||
if (process.env.ORDER_SERVICE_SMOKE !== '1') {
|
||||
throw new Error('请显式设置 ORDER_SERVICE_SMOKE=1 后运行;该脚本只用于本地测试库')
|
||||
}
|
||||
|
||||
const categoryId = Number(process.env.ORDER_SERVICE_SMOKE_CATEGORY_ID || 1)
|
||||
const platformOrderId = `SMOKE-${Date.now()}`
|
||||
let workOrder: WorkOrderRow | null = null
|
||||
let originalProfile: {
|
||||
id: number
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
guideImage: Record<string, unknown>
|
||||
fields: OrderServiceProfileConfig['fields']
|
||||
screenshots: OrderServiceProfileConfig['screenshots']
|
||||
autoPublish: OrderServiceProfileConfig['autoPublish']
|
||||
submitText: string
|
||||
successText: string
|
||||
} | null = null
|
||||
|
||||
try {
|
||||
const profileResult = await query<{
|
||||
id: number
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
guide_image_json: Record<string, unknown>
|
||||
fields_json: OrderServiceProfileConfig['fields']
|
||||
screenshots_json: Record<string, unknown>
|
||||
auto_publish_json: Record<string, unknown>
|
||||
submit_text: string
|
||||
success_text: string
|
||||
current_version_id: number
|
||||
}>(
|
||||
`
|
||||
SELECT p.*
|
||||
FROM order_service_profiles p
|
||||
WHERE p.category_id = $1
|
||||
AND p.current_version_id IS NOT NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[categoryId],
|
||||
)
|
||||
const profile = profileResult.rows[0]
|
||||
assert.ok(profile, '测试分类缺少已发布订单服务配置')
|
||||
originalProfile = {
|
||||
id: Number(profile.id),
|
||||
name: profile.name,
|
||||
title: profile.title,
|
||||
description: profile.description,
|
||||
guideImage: profile.guide_image_json,
|
||||
fields: profile.fields_json,
|
||||
screenshots: profile.screenshots_json as OrderServiceProfileConfig['screenshots'],
|
||||
autoPublish: profile.auto_publish_json as OrderServiceProfileConfig['autoPublish'],
|
||||
submitText: profile.submit_text,
|
||||
successText: profile.success_text,
|
||||
}
|
||||
|
||||
const smokeFields: OrderServiceProfileConfig['fields'] = [
|
||||
{
|
||||
key: 'account',
|
||||
label: '游戏账号',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '请输入账号',
|
||||
helpText: '',
|
||||
options: [],
|
||||
sortOrder: 10,
|
||||
},
|
||||
]
|
||||
const saved = await saveAdminOrderServiceProfile({
|
||||
categoryId,
|
||||
name: '本地订单服务烟测',
|
||||
title: '本地订单服务烟测',
|
||||
description: '测试完成后自动恢复',
|
||||
guideImage: {},
|
||||
fields: smokeFields,
|
||||
screenshots: {
|
||||
enabled: false,
|
||||
required: false,
|
||||
minCount: 0,
|
||||
maxCount: 3,
|
||||
label: '截图',
|
||||
helpText: '',
|
||||
},
|
||||
autoPublish: {
|
||||
enabled: true,
|
||||
requireVoucherConsume: true,
|
||||
retryMax: 1,
|
||||
},
|
||||
submitText: '提交烟测资料',
|
||||
successText: '烟测资料已提交',
|
||||
})
|
||||
const draftStatus = await query<{ status: string }>(
|
||||
`SELECT status FROM order_service_profiles WHERE id = $1`,
|
||||
[saved.profile.id],
|
||||
)
|
||||
assert.equal(draftStatus.rows[0]?.status, 'draft')
|
||||
const stillPublished = await getPublishedProfile(categoryId)
|
||||
assert.equal(stillPublished.title, originalProfile.title)
|
||||
|
||||
const published = await publishAdminOrderServiceProfile(saved.profile.id)
|
||||
const publishedConfig = await getPublishedProfile(categoryId)
|
||||
assert.equal(publishedConfig.title, '本地订单服务烟测')
|
||||
|
||||
workOrder = await createWorkOrder({
|
||||
workOrderNo: `WKSMOKE${Date.now()}`,
|
||||
platformOrderId,
|
||||
productName: '订单服务烟测商品',
|
||||
categoryId,
|
||||
status: WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
rewardAmount: 100,
|
||||
requiredDepositAmount: 0,
|
||||
depositThresholdAmount: 0,
|
||||
sharingEnabled: false,
|
||||
sharingTotalQuantity: 1,
|
||||
sharingUnitReward: 0,
|
||||
timeoutMinutes: 30,
|
||||
timeoutPolicy: 'reopen',
|
||||
acceptanceMode: 'standard',
|
||||
materialJson: JSON.stringify({ source: { smoke: true } }),
|
||||
requirementJson: JSON.stringify({ fields: smokeFields }),
|
||||
serviceProfileId: saved.profile.id,
|
||||
serviceProfileVersionId: published.versionId,
|
||||
serviceProfileSnapshotJson: JSON.stringify({
|
||||
profileId: saved.profile.id,
|
||||
categoryId,
|
||||
versionId: published.versionId,
|
||||
versionNo: published.versionNo,
|
||||
config: publishedConfig,
|
||||
}),
|
||||
now: nowIso(),
|
||||
})
|
||||
assert.ok(workOrder, '创建烟测工单失败')
|
||||
|
||||
const publicInfo = await getOrderServiceByPlatformOrderId(platformOrderId)
|
||||
assert.equal(publicInfo.items[0]?.title, '本地订单服务烟测')
|
||||
assert.equal(publicInfo.items[0]?.canSubmit, true)
|
||||
|
||||
await saveAdminOrderServiceProfile({
|
||||
categoryId,
|
||||
name: '未发布修改',
|
||||
title: '未发布修改不应泄漏',
|
||||
description: 'draft-only',
|
||||
guideImage: {},
|
||||
fields: smokeFields,
|
||||
screenshots: {
|
||||
enabled: false,
|
||||
required: false,
|
||||
minCount: 0,
|
||||
maxCount: 3,
|
||||
label: '截图',
|
||||
helpText: '',
|
||||
},
|
||||
autoPublish: {
|
||||
enabled: true,
|
||||
requireVoucherConsume: true,
|
||||
retryMax: 1,
|
||||
},
|
||||
submitText: '不应泄漏',
|
||||
successText: '不应泄漏',
|
||||
})
|
||||
const afterDraftEdit = await getOrderServiceByPlatformOrderId(platformOrderId)
|
||||
assert.equal(afterDraftEdit.items[0]?.title, '本地订单服务烟测')
|
||||
|
||||
await submitOrderServiceMaterial(platformOrderId, {
|
||||
workOrderId: workOrder.id,
|
||||
fields: { account: 'smoke-account' },
|
||||
screenshots: [],
|
||||
})
|
||||
await assert.rejects(() =>
|
||||
submitOrderServiceMaterial(platformOrderId, {
|
||||
workOrderId: workOrder?.id,
|
||||
fields: { account: 'changed' },
|
||||
screenshots: [],
|
||||
}),
|
||||
)
|
||||
|
||||
await waitForAutoPublishJob(workOrder.id)
|
||||
const afterPublish = await getOrderServiceByPlatformOrderId(platformOrderId)
|
||||
assert.equal(afterPublish.items[0]?.state, WORK_ORDER_STATUS.OPEN)
|
||||
assert.match(afterPublish.items[0]?.message || '', /高峰期/)
|
||||
|
||||
const job = await query<{ status: string; attempt_count: number }>(
|
||||
`SELECT status, attempt_count FROM order_service_auto_publish_jobs WHERE work_order_id = $1`,
|
||||
[workOrder.id],
|
||||
)
|
||||
assert.equal(job.rows[0]?.status, 'succeeded')
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
categoryId,
|
||||
profileId: saved.profile.id,
|
||||
versionNo: published.versionNo,
|
||||
platformOrderId,
|
||||
finalStatus: afterPublish.items[0]?.state,
|
||||
finalMessage: afterPublish.items[0]?.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
if (workOrder) {
|
||||
await query('DELETE FROM work_orders WHERE id = $1', [workOrder.id]).catch(() => undefined)
|
||||
}
|
||||
if (originalProfile) {
|
||||
const restored = await saveAdminOrderServiceProfile({
|
||||
categoryId,
|
||||
name: originalProfile.name,
|
||||
title: originalProfile.title,
|
||||
description: originalProfile.description,
|
||||
guideImage: originalProfile.guideImage,
|
||||
fields: originalProfile.fields,
|
||||
screenshots: originalProfile.screenshots,
|
||||
autoPublish: originalProfile.autoPublish,
|
||||
submitText: originalProfile.submitText,
|
||||
successText: originalProfile.successText,
|
||||
}).catch(() => null)
|
||||
if (restored) {
|
||||
await publishAdminOrderServiceProfile(restored.profile.id).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
await closeDb()
|
||||
}
|
||||
|
||||
async function getPublishedProfile(categoryIdValue: number) {
|
||||
const result = await query<{ config_json: OrderServiceProfileConfig }>(
|
||||
`
|
||||
SELECT v.config_json
|
||||
FROM order_service_profiles p
|
||||
JOIN order_service_profile_versions v ON v.id = p.current_version_id
|
||||
WHERE p.category_id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[categoryIdValue],
|
||||
)
|
||||
const config = result.rows[0]?.config_json
|
||||
assert.ok(config, '未找到已发布订单服务配置')
|
||||
return config
|
||||
}
|
||||
|
||||
async function waitForAutoPublishJob(workOrderId: number) {
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline) {
|
||||
await Promise.all([
|
||||
processDueOrderServiceAutoPublishJobs(10),
|
||||
processDueOrderServiceAutoPublishJobs(10),
|
||||
])
|
||||
const result = await query<{ status: string }>(
|
||||
`SELECT status FROM order_service_auto_publish_jobs WHERE work_order_id = $1`,
|
||||
[workOrderId],
|
||||
)
|
||||
const status = result.rows[0]?.status
|
||||
if (status === 'succeeded' || status === 'failed' || status === 'skipped') return
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error('自动发布任务在 5 秒内未结束')
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import process from 'node:process'
|
||||
|
||||
import adminRouter from './routes/admin.js'
|
||||
import affiliateDashRouter from './routes/affiliate-dash.js'
|
||||
import collectRouter from './routes/collect.js'
|
||||
import orderServiceRouter from './routes/order-service.js'
|
||||
import filesRouter from './routes/files.js'
|
||||
import claimsRouter from './routes/claims.js'
|
||||
import dcAuthRouter from './routes/dcauth.js'
|
||||
@@ -112,7 +112,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
|
||||
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
|
||||
app.use('/api/v1/redeem', redeemRouter)
|
||||
app.use('/api/v1/claim', claimsRouter)
|
||||
app.use('/api/v1/collect', collectRouter)
|
||||
app.use('/api/v1/o', orderServiceRouter)
|
||||
app.use('/api/v1/pdd/extension', pddExtensionRouter)
|
||||
app.use('/api/v1/files', filesRouter)
|
||||
app.use('/api/v1/worker', workerRouter)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
-- 128_order_service_profiles.sql
|
||||
--
|
||||
-- 分类级订单服务配置:资料收集字段、引导图、截图规则与自动发布策略。
|
||||
-- 工单创建时固化配置快照,后续修改配置不影响已创建工单。
|
||||
|
||||
ALTER TABLE work_categories
|
||||
ADD COLUMN IF NOT EXISTS default_work_rule_id BIGINT REFERENCES work_product_rules(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN work_categories.default_work_rule_id IS
|
||||
'分类默认接单模板;商品级映射可覆盖,未命中时整个分类使用该模板';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_service_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
category_id BIGINT NOT NULL REFERENCES work_categories(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
guide_image_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
fields_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
screenshots_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
auto_publish_json JSONB NOT NULL DEFAULT '{"enabled":false}'::jsonb,
|
||||
submit_text TEXT NOT NULL DEFAULT '提交资料',
|
||||
success_text TEXT NOT NULL DEFAULT '资料已提交,请等待处理',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
current_version_id BIGINT,
|
||||
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT order_service_profiles_status_check
|
||||
CHECK (status IN ('draft', 'published', 'archived'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_order_service_profiles_category
|
||||
ON order_service_profiles (category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_service_profiles_status
|
||||
ON order_service_profiles (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_service_profile_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
profile_id BIGINT NOT NULL REFERENCES order_service_profiles(id) ON DELETE CASCADE,
|
||||
version_no INTEGER NOT NULL,
|
||||
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
published_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_order_service_profile_versions_no
|
||||
UNIQUE (profile_id, version_no)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_order_service_profiles_current_version'
|
||||
) THEN
|
||||
ALTER TABLE order_service_profiles
|
||||
ADD CONSTRAINT fk_order_service_profiles_current_version
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES order_service_profile_versions(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS service_profile_id BIGINT REFERENCES order_service_profiles(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS service_profile_version_id BIGINT REFERENCES order_service_profile_versions(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS service_profile_snapshot_json JSONB;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_service_profile
|
||||
ON work_orders (service_profile_id, service_profile_version_id)
|
||||
WHERE service_profile_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE order_service_profiles IS
|
||||
'分类级订单服务配置:资料字段、引导图、截图规则与自动发布策略';
|
||||
COMMENT ON COLUMN work_orders.service_profile_snapshot_json IS
|
||||
'工单创建时固化的订单服务配置快照;配置后续变更不影响该工单';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 129_order_service_auto_publish_jobs.sql
|
||||
--
|
||||
-- 资料提交后自动发布工单。任务先落库,外部电子凭证核销失败时可以重试。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_service_auto_publish_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
result_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT order_service_auto_publish_jobs_status_check
|
||||
CHECK (status IN ('pending', 'succeeded', 'failed', 'skipped'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_order_service_auto_publish_pending
|
||||
ON order_service_auto_publish_jobs (work_order_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_service_auto_publish_due
|
||||
ON order_service_auto_publish_jobs (next_run_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
COMMENT ON TABLE order_service_auto_publish_jobs IS
|
||||
'客户资料提交后的自动发布任务;核销失败可按配置重试';
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 130_customer_notifications.sql
|
||||
--
|
||||
-- 客户订单通知事件模型。首版只落事件,不发送短信;
|
||||
-- 后续接入 SMS Provider 时消费 pending 任务即可。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_order_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE CASCADE,
|
||||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'sms',
|
||||
status TEXT NOT NULL DEFAULT 'skipped',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
template_key TEXT NOT NULL DEFAULT '',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
sent_at TIMESTAMPTZ,
|
||||
next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT customer_order_notifications_channel_check
|
||||
CHECK (channel IN ('web', 'sms')),
|
||||
CONSTRAINT customer_order_notifications_status_check
|
||||
CHECK (status IN ('pending', 'sent', 'failed', 'skipped'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_customer_order_notifications_dedupe
|
||||
ON customer_order_notifications (dedupe_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_order_notifications_pending
|
||||
ON customer_order_notifications (next_run_at)
|
||||
WHERE status = 'pending';
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 131_order_service_hardening.sql
|
||||
--
|
||||
-- 订单服务配置以“分类 -> 当前发布版本”为线上唯一读取源。
|
||||
-- 分类与接单模板的绑定沿用 work_product_rules.category_id,不再维护反向默认模板。
|
||||
|
||||
ALTER TABLE work_categories
|
||||
DROP COLUMN IF EXISTS default_work_rule_id;
|
||||
|
||||
ALTER TABLE order_service_auto_publish_jobs
|
||||
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS locked_by TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS finished_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE order_service_auto_publish_jobs
|
||||
DROP CONSTRAINT IF EXISTS order_service_auto_publish_jobs_status_check;
|
||||
|
||||
ALTER TABLE order_service_auto_publish_jobs
|
||||
ADD CONSTRAINT order_service_auto_publish_jobs_status_check
|
||||
CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'skipped'));
|
||||
|
||||
DROP INDEX IF EXISTS uq_order_service_auto_publish_pending;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_order_service_auto_publish_active
|
||||
ON order_service_auto_publish_jobs (work_order_id)
|
||||
WHERE status IN ('pending', 'processing');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_service_auto_publish_lease
|
||||
ON order_service_auto_publish_jobs (locked_until)
|
||||
WHERE status = 'processing';
|
||||
|
||||
COMMENT ON COLUMN order_service_auto_publish_jobs.locked_until IS
|
||||
'任务处理租约到期时间;进程中断后可被其它 worker 重新领取';
|
||||
COMMENT ON COLUMN order_service_auto_publish_jobs.locked_by IS
|
||||
'领取任务的进程或实例标识';
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
export type FileAssetRow = {
|
||||
@@ -87,8 +89,12 @@ export async function createFileAsset(input: CreateFileAssetInput): Promise<File
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getFileAssetByObjectKey(objectKey: string): Promise<FileAssetRow | null> {
|
||||
const result = await query<FileAssetRow>(
|
||||
export async function getFileAssetByObjectKey(
|
||||
objectKey: string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<FileAssetRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<FileAssetRow>(
|
||||
'SELECT * FROM file_assets WHERE object_key = $1 AND status = $2 LIMIT 1',
|
||||
[String(objectKey || '').trim(), 'active'],
|
||||
)
|
||||
|
||||
@@ -29,3 +29,4 @@ export * from './product-match-repo.js'
|
||||
export * from './sms-code-repo.js'
|
||||
export * from './consignment-repo.js'
|
||||
export * from './consignment-contact-repo.js'
|
||||
export * from './order-service-profile-repo.js'
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query } from '../../db/client.js'
|
||||
|
||||
export type OrderServiceProfileRow = {
|
||||
id: number
|
||||
category_id: number
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
guide_image_json: Record<string, unknown>
|
||||
fields_json: Array<Record<string, unknown>>
|
||||
screenshots_json: Record<string, unknown>
|
||||
auto_publish_json: Record<string, unknown>
|
||||
submit_text: string
|
||||
success_text: string
|
||||
status: string
|
||||
current_version_id: number | null
|
||||
current_version_no: number | null
|
||||
created_by_admin_user_id: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type OrderServicePublishedProfileRow = {
|
||||
profile_id: number
|
||||
category_id: number
|
||||
version_id: number
|
||||
version_no: number
|
||||
config_json: Record<string, unknown>
|
||||
}
|
||||
|
||||
const PROFILE_SELECT = `
|
||||
SELECT
|
||||
p.*,
|
||||
v.version_no AS current_version_no
|
||||
FROM order_service_profiles p
|
||||
LEFT JOIN order_service_profile_versions v ON v.id = p.current_version_id
|
||||
`
|
||||
|
||||
function mapProfile(row: OrderServiceProfileRow): OrderServiceProfileRow {
|
||||
return {
|
||||
...row,
|
||||
id: Number(row.id),
|
||||
category_id: Number(row.category_id),
|
||||
current_version_id:
|
||||
row.current_version_id === null || row.current_version_id === undefined
|
||||
? null
|
||||
: Number(row.current_version_id),
|
||||
current_version_no:
|
||||
row.current_version_no === null || row.current_version_no === undefined
|
||||
? null
|
||||
: Number(row.current_version_no),
|
||||
created_by_admin_user_id:
|
||||
row.created_by_admin_user_id === null || row.created_by_admin_user_id === undefined
|
||||
? null
|
||||
: Number(row.created_by_admin_user_id),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrderServiceProfileByCategory(
|
||||
categoryId: number | string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<OrderServiceProfileRow>(
|
||||
`${PROFILE_SELECT} WHERE p.category_id = $1 LIMIT 1`,
|
||||
[Number(categoryId)],
|
||||
)
|
||||
return result.rows[0] ? mapProfile(result.rows[0]) : null
|
||||
}
|
||||
|
||||
export async function getOrderServiceProfileById(
|
||||
profileId: number | string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<OrderServiceProfileRow>(
|
||||
`${PROFILE_SELECT} WHERE p.id = $1 LIMIT 1`,
|
||||
[Number(profileId)],
|
||||
)
|
||||
return result.rows[0] ? mapProfile(result.rows[0]) : null
|
||||
}
|
||||
|
||||
export async function listOrderServiceProfiles(): Promise<OrderServiceProfileRow[]> {
|
||||
const result = await query<OrderServiceProfileRow>(
|
||||
`${PROFILE_SELECT} ORDER BY p.updated_at DESC, p.id DESC`,
|
||||
)
|
||||
return result.rows.map(mapProfile)
|
||||
}
|
||||
|
||||
export async function upsertOrderServiceProfile(input: {
|
||||
categoryId: number
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
guideImage: Record<string, unknown>
|
||||
fields: Array<Record<string, unknown>>
|
||||
screenshots: Record<string, unknown>
|
||||
autoPublish: Record<string, unknown>
|
||||
submitText: string
|
||||
successText: string
|
||||
adminUserId: number | null
|
||||
}): Promise<OrderServiceProfileRow> {
|
||||
const result = await query<OrderServiceProfileRow>(
|
||||
`
|
||||
INSERT INTO order_service_profiles (
|
||||
category_id, name, title, description,
|
||||
guide_image_json, fields_json, screenshots_json, auto_publish_json,
|
||||
submit_text, success_text, status, created_by_admin_user_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb,
|
||||
$9, $10, 'draft', $11
|
||||
)
|
||||
ON CONFLICT (category_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
guide_image_json = EXCLUDED.guide_image_json,
|
||||
fields_json = EXCLUDED.fields_json,
|
||||
screenshots_json = EXCLUDED.screenshots_json,
|
||||
auto_publish_json = EXCLUDED.auto_publish_json,
|
||||
submit_text = EXCLUDED.submit_text,
|
||||
success_text = EXCLUDED.success_text,
|
||||
current_version_id = CASE
|
||||
WHEN order_service_profiles.status = 'archived' THEN NULL
|
||||
ELSE order_service_profiles.current_version_id
|
||||
END,
|
||||
status = 'draft',
|
||||
updated_at = NOW()
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.categoryId,
|
||||
input.name,
|
||||
input.title,
|
||||
input.description,
|
||||
JSON.stringify(input.guideImage),
|
||||
JSON.stringify(input.fields),
|
||||
JSON.stringify(input.screenshots),
|
||||
JSON.stringify(input.autoPublish),
|
||||
input.submitText,
|
||||
input.successText,
|
||||
input.adminUserId,
|
||||
],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
if (!row) throw new Error('order service profile upsert returned no row')
|
||||
return mapProfile(row)
|
||||
}
|
||||
|
||||
export async function archiveOrderServiceProfile(
|
||||
profileId: number | string,
|
||||
): Promise<OrderServiceProfileRow | null> {
|
||||
const result = await query<OrderServiceProfileRow>(
|
||||
`UPDATE order_service_profiles SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`,
|
||||
[Number(profileId)],
|
||||
)
|
||||
return result.rows[0] ? mapProfile(result.rows[0]) : null
|
||||
}
|
||||
|
||||
export async function createOrderServiceProfileVersion(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
profileId: number
|
||||
config: Record<string, unknown>
|
||||
adminUserId: number | null
|
||||
},
|
||||
): Promise<{ versionId: number; versionNo: number }> {
|
||||
const versionResult = await client.query<{ next_version_no: number }>(
|
||||
`
|
||||
SELECT COALESCE(MAX(version_no), 0) + 1 AS next_version_no
|
||||
FROM order_service_profile_versions
|
||||
WHERE profile_id = $1
|
||||
`,
|
||||
[input.profileId],
|
||||
)
|
||||
const versionNo = Math.max(1, Number(versionResult.rows[0]?.next_version_no || 1))
|
||||
const inserted = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO order_service_profile_versions (
|
||||
profile_id, version_no, config_json, published_by_admin_user_id
|
||||
) VALUES ($1, $2, $3::jsonb, $4)
|
||||
RETURNING id
|
||||
`,
|
||||
[input.profileId, versionNo, JSON.stringify(input.config), input.adminUserId],
|
||||
)
|
||||
return {
|
||||
versionId: Number(inserted.rows[0]?.id || 0),
|
||||
versionNo,
|
||||
}
|
||||
}
|
||||
|
||||
export async function setOrderServiceProfileCurrentVersion(
|
||||
client: PoolClient,
|
||||
input: { profileId: number; versionId: number },
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`UPDATE order_service_profiles SET current_version_id = $2, status = 'published', updated_at = NOW() WHERE id = $1`,
|
||||
[input.profileId, input.versionId],
|
||||
)
|
||||
}
|
||||
|
||||
export async function getOrderServiceProfileVersion(
|
||||
versionId: number | string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<{
|
||||
id: number
|
||||
profile_id: number
|
||||
version_no: number
|
||||
config_json: Record<string, unknown>
|
||||
} | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<{
|
||||
id: number
|
||||
profile_id: number
|
||||
version_no: number
|
||||
config_json: Record<string, unknown>
|
||||
}>(
|
||||
`SELECT id, profile_id, version_no, config_json FROM order_service_profile_versions WHERE id = $1 LIMIT 1`,
|
||||
[Number(versionId)],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return row
|
||||
? {
|
||||
id: Number(row.id),
|
||||
profile_id: Number(row.profile_id),
|
||||
version_no: Number(row.version_no),
|
||||
config_json: row.config_json,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export async function getPublishedOrderServiceProfileByCategory(
|
||||
categoryId: number | string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<OrderServicePublishedProfileRow | null> {
|
||||
const db = client || { query }
|
||||
const result = await db.query<OrderServicePublishedProfileRow>(
|
||||
`
|
||||
SELECT
|
||||
p.id AS profile_id,
|
||||
p.category_id,
|
||||
v.id AS version_id,
|
||||
v.version_no,
|
||||
v.config_json
|
||||
FROM order_service_profiles p
|
||||
JOIN order_service_profile_versions v ON v.id = p.current_version_id
|
||||
WHERE p.category_id = $1
|
||||
AND p.status <> 'archived'
|
||||
LIMIT 1
|
||||
`,
|
||||
[Number(categoryId)],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return row
|
||||
? {
|
||||
profile_id: Number(row.profile_id),
|
||||
category_id: Number(row.category_id),
|
||||
version_id: Number(row.version_id),
|
||||
version_no: Number(row.version_no),
|
||||
config_json: row.config_json,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export async function updateWorkOrderServiceProfile(input: {
|
||||
workOrderId: number | string
|
||||
profileId: number | null
|
||||
versionId: number | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
now: string
|
||||
client?: Pick<PoolClient, 'query'>
|
||||
}): Promise<void> {
|
||||
const db = input.client || { query }
|
||||
await db.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET service_profile_id = $2,
|
||||
service_profile_version_id = $3,
|
||||
service_profile_snapshot_json = $4::jsonb,
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
Number(input.workOrderId),
|
||||
input.profileId,
|
||||
input.versionId,
|
||||
input.snapshot === null ? null : JSON.stringify(input.snapshot),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -408,6 +408,9 @@ export type WorkOrderRow = {
|
||||
user_payment_amount_updated_by?: string | null
|
||||
material_json: string | Record<string, unknown>
|
||||
requirement_json: string | Record<string, unknown>
|
||||
service_profile_id?: number | null
|
||||
service_profile_version_id?: number | null
|
||||
service_profile_snapshot_json?: string | Record<string, unknown> | null
|
||||
acceptance_json: string | Record<string, unknown>
|
||||
draft_acceptance_json: string | Record<string, unknown>
|
||||
admin_note: string
|
||||
@@ -634,5 +637,8 @@ export type CreateWorkOrderInput = {
|
||||
giftCooldownMinutes?: number
|
||||
materialJson: string
|
||||
requirementJson: string
|
||||
serviceProfileId?: number | null
|
||||
serviceProfileVersionId?: number | null
|
||||
serviceProfileSnapshotJson?: string | null
|
||||
now: string
|
||||
}
|
||||
|
||||
@@ -23,12 +23,16 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
|
||||
sharing_unit_reward, timeout_minutes, timeout_policy,
|
||||
acceptance_mode, gift_phase, gift_cooldown_hours, gift_cooldown_minutes,
|
||||
material_json, requirement_json, source_order_amount_fen, created_at, updated_at
|
||||
material_json, requirement_json,
|
||||
service_profile_id, service_profile_version_id, service_profile_snapshot_json,
|
||||
source_order_amount_fen, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18, $19, $20, $21,
|
||||
$22::jsonb, $23::jsonb, $24, $25, $26
|
||||
$22::jsonb, $23::jsonb,
|
||||
$24, $25, $26::jsonb,
|
||||
$27, $28, $29
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
@@ -58,6 +62,9 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
normalizeWorkOrderGiftCooldownMinutes(input.giftCooldownMinutes),
|
||||
input.materialJson,
|
||||
input.requirementJson,
|
||||
input.serviceProfileId || null,
|
||||
input.serviceProfileVersionId || null,
|
||||
input.serviceProfileSnapshotJson || null,
|
||||
// 与 material_json 同源:统计要用的付款金额落到列上,避免每次刷新解析全表 JSON
|
||||
resolveSourceOrderAmountFen(input.materialJson),
|
||||
input.now,
|
||||
|
||||
@@ -25,12 +25,18 @@ import {
|
||||
saveAdminWorkProductRule,
|
||||
saveAdminWorkProductRuleMapping,
|
||||
saveAdminWorkerAnnouncementConfig,
|
||||
archiveAdminOrderServiceProfile,
|
||||
listAdminOrderServiceAutoPublishJobs,
|
||||
listAdminOrderServiceProfiles,
|
||||
publishAdminOrderServiceProfile,
|
||||
saveAdminOrderServiceProfile,
|
||||
saveAdminWorkerFinanceConfig,
|
||||
saveAdminWorkerHallConfig,
|
||||
saveAdminWorkerLevel,
|
||||
saveAdminWorkerPlatformNotificationConfig,
|
||||
saveAdminWorkerProductMatchConfig,
|
||||
saveAdminWorkerProductNameSuffixConfig,
|
||||
retryAdminOrderServiceAutoPublishJob,
|
||||
syncAdminWorkProductRulePrice,
|
||||
testAdminKuaishouProductMatch,
|
||||
testAdminProductMatchByOrderItem,
|
||||
@@ -137,6 +143,71 @@ router.delete(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/order-service/profiles',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => listAdminOrderServiceProfiles(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取订单服务配置失败',
|
||||
scope: '[admin/worker-platform/order-service/profiles]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/order-service/profiles',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => saveAdminOrderServiceProfile(req.body || {}), {
|
||||
successMessage: '订单服务配置已保存',
|
||||
errorMessage: '保存订单服务配置失败',
|
||||
scope: '[admin/worker-platform/order-service/profiles]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'order_service_profile_saved',
|
||||
targetType: 'order_service_profile',
|
||||
targetId: String((data as { profile?: { id?: number } })?.profile?.id || ''),
|
||||
data: data as Record<string, unknown>,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/order-service/profiles/:profileId/publish',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
publishAdminOrderServiceProfile(
|
||||
String(req.params.profileId || ''),
|
||||
req.adminSession?.userId ?? null,
|
||||
),
|
||||
{
|
||||
successMessage: '订单服务配置已发布',
|
||||
errorMessage: '发布订单服务配置失败',
|
||||
scope: '[admin/worker-platform/order-service/profiles/:profileId/publish]',
|
||||
audit: (req) => ({
|
||||
action: 'order_service_profile_published',
|
||||
targetType: 'order_service_profile',
|
||||
targetId: String(req.params.profileId || ''),
|
||||
data: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/order-service/profiles/:profileId/archive',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => archiveAdminOrderServiceProfile(String(req.params.profileId || '')), {
|
||||
successMessage: '订单服务配置已归档',
|
||||
errorMessage: '归档订单服务配置失败',
|
||||
scope: '[admin/worker-platform/order-service/profiles/:profileId/archive]',
|
||||
audit: (req) => ({
|
||||
action: 'order_service_profile_archived',
|
||||
targetType: 'order_service_profile',
|
||||
targetId: String(req.params.profileId || ''),
|
||||
data: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/product-rules',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
@@ -466,4 +537,30 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/order-service/auto-publish-jobs',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => listAdminOrderServiceAutoPublishJobs(req.query.status), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取自动发布任务失败',
|
||||
scope: '[admin/worker-platform/order-service/auto-publish-jobs]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/order-service/auto-publish-jobs/:jobId/retry',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => retryAdminOrderServiceAutoPublishJob(String(req.params.jobId || '')), {
|
||||
successMessage: '自动发布任务已重新排队',
|
||||
errorMessage: '重试自动发布任务失败',
|
||||
scope: '[admin/worker-platform/order-service/auto-publish-jobs/:jobId/retry]',
|
||||
audit: (req) => ({
|
||||
action: 'order_service_auto_publish_retried',
|
||||
targetType: 'order_service_auto_publish_job',
|
||||
targetId: String(req.params.jobId || ''),
|
||||
data: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
createFailureCooldownMiddleware,
|
||||
createRateLimitMiddleware,
|
||||
getBodyFieldRateLimitKey,
|
||||
getParamRateLimitKey,
|
||||
} from '../middleware/rate-limit.js'
|
||||
import {
|
||||
collectLookupWorkOrder,
|
||||
collectSubmitWorkOrder,
|
||||
getPublicWorkOrderMaterialLink,
|
||||
getPublicWorkOrderMaterialLinkUploadContext,
|
||||
submitPublicWorkOrderMaterialLink,
|
||||
} from '../services/worker-platform/index.js'
|
||||
import { uploadFileAsset } from '../services/file-storage/file-storage-service.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
import { uploadSingleFile } from './file-upload.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const collectRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect',
|
||||
windowMs: 60_000,
|
||||
max: 60,
|
||||
key: getBodyFieldRateLimitKey('orderNo'),
|
||||
})
|
||||
|
||||
const materialLinkReadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:read',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
key: getParamRateLimitKey('code'),
|
||||
})
|
||||
|
||||
/** 平台订单号可被枚举,额外加一层只按 IP 的总限流,避免换 code 绕过单链接限流。 */
|
||||
const materialLinkReadIpRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:read:ip',
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
})
|
||||
|
||||
/** 查询失败(404)后按 IP 冷却 2 秒;正常查询不计数,避免误伤用户。 */
|
||||
const materialLinkReadFailureCooldown = createFailureCooldownMiddleware({
|
||||
scope: 'collect:link:read:fail:ip',
|
||||
cooldownMs: 2_000,
|
||||
})
|
||||
|
||||
const materialLinkWriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:write',
|
||||
windowMs: 60_000,
|
||||
max: 20,
|
||||
key: getParamRateLimitKey('code'),
|
||||
})
|
||||
|
||||
const materialLinkWriteIpRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:write:ip',
|
||||
windowMs: 60_000,
|
||||
max: 40,
|
||||
})
|
||||
|
||||
const materialLinkUploadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:upload',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('code'),
|
||||
})
|
||||
|
||||
const materialLinkUploadIpRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:upload:ip',
|
||||
windowMs: 60_000,
|
||||
max: 60,
|
||||
})
|
||||
|
||||
router.post(
|
||||
'/lookup',
|
||||
collectRateLimit,
|
||||
createRouteHandler((req) => collectLookupWorkOrder(req.body || {}), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '查询待完善订单失败',
|
||||
scope: '[collect/lookup]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/submit',
|
||||
collectRateLimit,
|
||||
createRouteHandler((req) => collectSubmitWorkOrder(req.body || {}), {
|
||||
successMessage: '资料已提交',
|
||||
errorMessage: '提交补充资料失败',
|
||||
scope: '[collect/submit]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/upload',
|
||||
collectRateLimit,
|
||||
uploadSingleFile,
|
||||
createRouteHandler(
|
||||
(req) => {
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: req.body?.scene || 'collect-material',
|
||||
uploaderType: 'collect',
|
||||
uploaderId: String(req.body?.orderNo || 'anonymous'),
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[collect/upload]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/link/:code',
|
||||
materialLinkReadFailureCooldown,
|
||||
materialLinkReadIpRateLimit,
|
||||
materialLinkReadRateLimit,
|
||||
createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), {
|
||||
errorMessage: '读取补资料链接失败',
|
||||
scope: '[collect/link/:code]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/link/:code/submit',
|
||||
materialLinkWriteIpRateLimit,
|
||||
materialLinkWriteRateLimit,
|
||||
createRouteHandler(
|
||||
(req) => submitPublicWorkOrderMaterialLink(String(req.params.code || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '资料已提交',
|
||||
errorMessage: '提交补资料失败',
|
||||
scope: '[collect/link/:code/submit]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/link/:code/upload',
|
||||
materialLinkUploadIpRateLimit,
|
||||
materialLinkUploadRateLimit,
|
||||
uploadSingleFile,
|
||||
createRouteHandler(
|
||||
async (req) => {
|
||||
const { order } = await getPublicWorkOrderMaterialLinkUploadContext(
|
||||
String(req.params.code || ''),
|
||||
req.query.workOrderId,
|
||||
)
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: req.body?.scene || 'collect-material',
|
||||
uploaderType: 'collect',
|
||||
uploaderId: `work-order-${order.id}`,
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[collect/link/:code/upload]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
createFailureCooldownMiddleware,
|
||||
createRateLimitMiddleware,
|
||||
getParamRateLimitKey,
|
||||
} from '../middleware/rate-limit.js'
|
||||
import {
|
||||
getOrderServiceByPlatformOrderId,
|
||||
getOrderServiceUploadContext,
|
||||
submitOrderServiceMaterial,
|
||||
} from '../services/worker-platform/index.js'
|
||||
import {
|
||||
ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES,
|
||||
uploadFileAsset,
|
||||
} from '../services/file-storage/file-storage-service.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
import { uploadSingleFile } from './file-upload.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const readRateLimit = createRateLimitMiddleware({
|
||||
scope: 'order-service:read',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
key: getParamRateLimitKey('orderNo'),
|
||||
})
|
||||
|
||||
const readIpRateLimit = createRateLimitMiddleware({
|
||||
scope: 'order-service:read:ip',
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
})
|
||||
|
||||
const readFailureCooldown = createFailureCooldownMiddleware({
|
||||
scope: 'order-service:read:fail:ip',
|
||||
cooldownMs: 2_000,
|
||||
})
|
||||
|
||||
const writeRateLimit = createRateLimitMiddleware({
|
||||
scope: 'order-service:write',
|
||||
windowMs: 60_000,
|
||||
max: 20,
|
||||
key: getParamRateLimitKey('orderNo'),
|
||||
})
|
||||
|
||||
const writeIpRateLimit = createRateLimitMiddleware({
|
||||
scope: 'order-service:write:ip',
|
||||
windowMs: 60_000,
|
||||
max: 40,
|
||||
})
|
||||
|
||||
const uploadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'order-service:upload',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('orderNo'),
|
||||
})
|
||||
|
||||
const uploadIpRateLimit = createRateLimitMiddleware({
|
||||
scope: 'order-service:upload:ip',
|
||||
windowMs: 60_000,
|
||||
max: 60,
|
||||
})
|
||||
|
||||
router.get(
|
||||
'/:orderNo',
|
||||
readFailureCooldown,
|
||||
readIpRateLimit,
|
||||
readRateLimit,
|
||||
createRouteHandler((req) => getOrderServiceByPlatformOrderId(req.params.orderNo), {
|
||||
errorMessage: '读取订单服务信息失败',
|
||||
scope: '[order-service/:orderNo]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/material',
|
||||
writeIpRateLimit,
|
||||
writeRateLimit,
|
||||
createRouteHandler((req) => submitOrderServiceMaterial(req.params.orderNo, req.body || {}), {
|
||||
successMessage: '资料已提交',
|
||||
errorMessage: '提交订单资料失败',
|
||||
scope: '[order-service/:orderNo/material]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/files',
|
||||
uploadIpRateLimit,
|
||||
uploadRateLimit,
|
||||
uploadSingleFile,
|
||||
createRouteHandler(
|
||||
async (req) => {
|
||||
const { order } = await getOrderServiceUploadContext(
|
||||
req.params.orderNo,
|
||||
req.query.workOrderId,
|
||||
)
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: 'collect-material',
|
||||
uploaderType: 'collect',
|
||||
uploaderId: `work-order-${order.id}`,
|
||||
allowedContentTypes: ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES,
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[order-service/:orderNo/files]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -9,7 +9,7 @@ import { createRouteHandler } from '../utils/http.js'
|
||||
*
|
||||
* 公开接口(无需登录),但按「订单号 + IP」限流:这是无 token 方案的第一道防线
|
||||
* (见 docs/抖音-码速达/05-最小接入方案分析.md §3.2)。
|
||||
* 与 `/collect`(routes/collect.ts)完全同型。
|
||||
* 订单资料与进度查询已统一到 `/api/v1/o`(routes/order-service.ts)。
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
|
||||
@@ -59,7 +59,12 @@ const ALLOWED_CONTENT_TYPES = new Set([
|
||||
'video/webm',
|
||||
])
|
||||
|
||||
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
export const ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
])
|
||||
const IMAGE_CONTENT_TYPES = ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES
|
||||
|
||||
export type UploadedFileDto = {
|
||||
objectKey: string
|
||||
@@ -78,6 +83,7 @@ type UploadFileAssetInput = {
|
||||
scene: unknown
|
||||
uploaderType: string
|
||||
uploaderId: string
|
||||
allowedContentTypes?: ReadonlySet<string>
|
||||
/** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */
|
||||
durationSeconds?: unknown
|
||||
}
|
||||
@@ -92,11 +98,17 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
|
||||
const contentType = normalizeContentType(file.mimetype, file.buffer)
|
||||
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
||||
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_type_invalid',
|
||||
})
|
||||
const allowedContentTypes = input.allowedContentTypes || ALLOWED_CONTENT_TYPES
|
||||
if (!allowedContentTypes.has(contentType)) {
|
||||
throw createHttpError(
|
||||
input.allowedContentTypes
|
||||
? '文件不符合规则,仅支持 JPG、PNG、WebP 图片'
|
||||
: '文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频',
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_type_invalid',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const isVideo = VIDEO_CONTENT_TYPES.has(contentType)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
export async function enqueueCustomerOrderNotification(input: {
|
||||
orderId?: number | string | null
|
||||
orderItemId?: number | string | null
|
||||
workOrderId?: number | string | null
|
||||
eventType: string
|
||||
templateKey?: string
|
||||
payload?: Record<string, unknown>
|
||||
}) {
|
||||
const dedupeKey = [
|
||||
'order',
|
||||
input.orderId || 0,
|
||||
input.workOrderId || input.orderItemId || 0,
|
||||
input.eventType,
|
||||
].join(':')
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO customer_order_notifications (
|
||||
order_id, order_item_id, work_order_id, event_type,
|
||||
channel, status, phone, template_key, payload_json,
|
||||
dedupe_key, next_run_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
'sms', 'skipped', '', $5, $6::jsonb,
|
||||
$7, NOW()
|
||||
)
|
||||
ON CONFLICT (dedupe_key) DO NOTHING
|
||||
`,
|
||||
[
|
||||
input.orderId ? Number(input.orderId) : null,
|
||||
input.orderItemId ? Number(input.orderItemId) : null,
|
||||
input.workOrderId ? Number(input.workOrderId) : null,
|
||||
input.eventType,
|
||||
input.templateKey || input.eventType,
|
||||
JSON.stringify(input.payload || {}),
|
||||
dedupeKey,
|
||||
],
|
||||
)
|
||||
return { dedupeKey, at: nowIso() }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const PRODUCT_NORMALIZE_JOB_ID = 'product-normalize'
|
||||
const COST_SNAPSHOT_JOB_ID = 'cost-snapshot'
|
||||
const SALES_FACTS_JOB_ID = 'sales-facts'
|
||||
const LEDGER_POST_JOB_ID = 'ledger-post'
|
||||
const ORDER_SERVICE_AUTO_PUBLISH_JOB_ID = 'order-service-auto-publish'
|
||||
|
||||
export function getScheduledJobsFilePath() {
|
||||
return SCHEDULED_JOBS_FILE_PATH
|
||||
@@ -89,6 +90,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
const hasCostSnapshot = jobs.some((item) => item.id === COST_SNAPSHOT_JOB_ID)
|
||||
const hasSalesFacts = jobs.some((item) => item.id === SALES_FACTS_JOB_ID)
|
||||
const hasLedgerPost = jobs.some((item) => item.id === LEDGER_POST_JOB_ID)
|
||||
const hasOrderServiceAutoPublish = jobs.some(
|
||||
(item) => item.id === ORDER_SERVICE_AUTO_PUBLISH_JOB_ID,
|
||||
)
|
||||
|
||||
if (!hasWorkOrderTimeout) {
|
||||
jobs.push(createDefaultWorkOrderTimeoutJob())
|
||||
@@ -111,6 +115,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
if (!hasCostSnapshot) jobs.push(createDefaultCostSnapshotJob())
|
||||
if (!hasSalesFacts) jobs.push(createDefaultSalesFactsJob())
|
||||
if (!hasLedgerPost) jobs.push(createDefaultLedgerPostJob())
|
||||
if (!hasOrderServiceAutoPublish) jobs.push(createDefaultOrderServiceAutoPublishJob())
|
||||
|
||||
return {
|
||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
|
||||
@@ -158,6 +163,9 @@ function normalizeScheduledJob(rawValue: unknown) {
|
||||
if (type === 'ledger_post') {
|
||||
return normalizeLedgerPostJob(rawValue)
|
||||
}
|
||||
if (type === 'order_service_auto_publish') {
|
||||
return normalizeOrderServiceAutoPublishJob(rawValue)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -228,6 +236,7 @@ function createDefaultScheduledJobsConfig() {
|
||||
createDefaultCostSnapshotJob(),
|
||||
createDefaultSalesFactsJob(),
|
||||
createDefaultLedgerPostJob(),
|
||||
createDefaultOrderServiceAutoPublishJob(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -300,6 +309,19 @@ function normalizeSalesFactsJob(rawValue: JsonObject) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderServiceAutoPublishJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
return {
|
||||
id: ORDER_SERVICE_AUTO_PUBLISH_JOB_ID,
|
||||
type: 'order_service_auto_publish',
|
||||
enabled: rawValue.enabled !== false,
|
||||
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
|
||||
config: {
|
||||
limit: normalizeRangeInteger(config.limit, 50, 1, 200),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLedgerPostJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
return {
|
||||
@@ -353,6 +375,16 @@ function createDefaultCostSnapshotJob() {
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultOrderServiceAutoPublishJob() {
|
||||
return {
|
||||
id: ORDER_SERVICE_AUTO_PUBLISH_JOB_ID,
|
||||
type: 'order_service_auto_publish',
|
||||
enabled: true,
|
||||
intervalSeconds: 60,
|
||||
config: { limit: 50 },
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultSalesFactsJob() {
|
||||
return {
|
||||
id: SALES_FACTS_JOB_ID,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { query } from '../../db/client.js'
|
||||
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
|
||||
import { runAffiliateFallback } from '../fulfillment/affiliate-fallback-service.js'
|
||||
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
|
||||
import { processDueOrderServiceAutoPublishJobs } from '../worker-platform/order-service-auto-publish-service.js'
|
||||
import {
|
||||
resolveExceptionsForItemsByReason,
|
||||
resolveOrphanCostExceptions,
|
||||
@@ -497,6 +498,16 @@ export async function runSalesFactsJob(job: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOrderServiceAutoPublishJobs(job: JsonObject = {}) {
|
||||
const config = jobConfig(job)
|
||||
const result = await processDueOrderServiceAutoPublishJobs(clampInt(config.limit, 50, 1, 200))
|
||||
return {
|
||||
status: 'ok',
|
||||
message: `订单服务自动发布:处理 ${result.scanned},成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`,
|
||||
...result,
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLedgerPostJob(job: JsonObject = {}) {
|
||||
const config = jobConfig(job)
|
||||
const days = clampInt(config.days, 3, 1, 60)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
runChannelAssignJob,
|
||||
runCostSnapshotJob,
|
||||
runLedgerPostJob,
|
||||
runOrderServiceAutoPublishJobs,
|
||||
runProductNormalizeJob,
|
||||
runSalesFactsJob,
|
||||
} from './finance-pipeline-jobs.js'
|
||||
@@ -230,6 +231,9 @@ function dispatchJob(job: JsonObject) {
|
||||
if (job.type === 'ledger_post') {
|
||||
return runLedgerPostJob(job)
|
||||
}
|
||||
if (job.type === 'order_service_auto_publish') {
|
||||
return runOrderServiceAutoPublishJobs(job)
|
||||
}
|
||||
|
||||
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
|
||||
statusCode: 400,
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import {
|
||||
consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||
@@ -342,7 +344,11 @@ export async function reopenAdminCancelledWorkOrder(workOrderId: number | string
|
||||
return { order: mapWorkOrderAdmin(updated) }
|
||||
}
|
||||
|
||||
export async function publishAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
||||
export async function publishAdminWorkOrder(
|
||||
workOrderId: number | string,
|
||||
actorName = '',
|
||||
options: { requireVoucherConsume?: boolean } = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) {
|
||||
throw createHttpError('只有未分配订单可以发布到大厅', {
|
||||
@@ -358,21 +364,9 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const published = await publishUnassignedWorkOrder({
|
||||
workOrderId: workOrder.id,
|
||||
now,
|
||||
actorName,
|
||||
})
|
||||
if (!published.order) {
|
||||
throw createHttpError('订单状态已变化,无法发布,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_publish_conflict',
|
||||
})
|
||||
}
|
||||
|
||||
let voucherConsume: WorkOrderVoucherConsumeResult
|
||||
try {
|
||||
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(published.order)
|
||||
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder)
|
||||
} catch (error) {
|
||||
voucherConsume = {
|
||||
ok: false,
|
||||
@@ -390,15 +384,46 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
||||
actorType: 'system',
|
||||
actorId: 'kuaishou_send_code',
|
||||
eventType: 'publish_voucher_consume_failed',
|
||||
fromStatus: WORK_ORDER_STATUS.OPEN,
|
||||
toStatus: WORK_ORDER_STATUS.OPEN,
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify(voucherConsume),
|
||||
now: nowIso(),
|
||||
})
|
||||
if (options.requireVoucherConsume === true) {
|
||||
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_publish_voucher_consume_failed',
|
||||
context: { voucherConsume },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const published = await publishUnassignedWorkOrder({
|
||||
workOrderId: workOrder.id,
|
||||
now,
|
||||
actorName,
|
||||
})
|
||||
if (!published.order) {
|
||||
throw createHttpError('订单状态已变化,无法发布,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_publish_conflict',
|
||||
})
|
||||
}
|
||||
|
||||
const currentOrder = await getRequiredWorkOrder(workOrder.id)
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true })
|
||||
await enqueueCustomerOrderNotification({
|
||||
orderId: currentOrder.order_id,
|
||||
orderItemId: currentOrder.order_item_id,
|
||||
workOrderId: currentOrder.id,
|
||||
eventType: 'work_order_published',
|
||||
payload: { source: 'work_order_publish' },
|
||||
}).catch((error) => {
|
||||
logWarn('[work-order/publish]', '客户发布通知写入失败', {
|
||||
workOrderId: currentOrder.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
const hallConfig = getWorkerHallConfig()
|
||||
const hallEntry = await listWorkOrders({
|
||||
page: 1,
|
||||
|
||||
@@ -531,7 +531,14 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
if (existingRule && enabled) {
|
||||
await assertWorkOrderTemplateCanEnable(existingRule.id)
|
||||
}
|
||||
const fields = normalizeRequirementFieldsFromPayload(payload)
|
||||
const hasRequirementPayload = ['fields', 'requirement', 'fieldsText', 'requiredFieldsText'].some(
|
||||
(key) => hasOwnPayload(payload, key),
|
||||
)
|
||||
const fields = hasRequirementPayload
|
||||
? normalizeRequirementFieldsFromPayload(payload)
|
||||
: normalizeRequirementFieldsFromPayload({
|
||||
fields: safeParseJson(existingRule?.requirement_json).fields,
|
||||
})
|
||||
const ruleKey = existingRule?.rule_key || randomId('rule-')
|
||||
const hasCategoryPayload =
|
||||
hasOwnPayload(payload, 'categoryId') || hasOwnPayload(payload, 'category_id')
|
||||
|
||||
@@ -13,7 +13,6 @@ export * from './worker-profile-service.js'
|
||||
export * from './worker-hall-service.js'
|
||||
export * from './worker-order-timeout-service.js'
|
||||
export * from './worker-order-realtime-service.js'
|
||||
export * from './worker-collect-service.js'
|
||||
export * from './worker-acceptance-service.js'
|
||||
export * from './worker-gift-material-service.js'
|
||||
export * from './worker-my-order-service.js'
|
||||
@@ -67,3 +66,6 @@ export * from './sync-worker-orders-service.js'
|
||||
export * from './consignment-contact-service.js'
|
||||
export * from './consignment-pending-match-service.js'
|
||||
export * from './work-order-material-link-service.js'
|
||||
export * from './order-service-profile-service.js'
|
||||
export * from './order-service-public-service.js'
|
||||
export * from './order-service-auto-publish-service.js'
|
||||
|
||||
@@ -84,7 +84,6 @@ export {
|
||||
|
||||
export {
|
||||
isWorkerOrderVipEvidencePending,
|
||||
mapCollectLookupOrder,
|
||||
mapWorkCategory,
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderForHall,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { publishAdminWorkOrder } from './admin-work-order-management-service.js'
|
||||
|
||||
type Queryable = Pick<PoolClient, 'query'>
|
||||
|
||||
type AutoPublishJobRow = {
|
||||
id: number
|
||||
work_order_id: number
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'skipped'
|
||||
attempt_count: number
|
||||
max_attempts: number
|
||||
next_run_at: string
|
||||
last_error: string
|
||||
result_json: Record<string, unknown>
|
||||
locked_until: string | null
|
||||
locked_by: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
const ACTIVE_JOB_STATUSES = ['pending', 'processing'] as const
|
||||
const JOB_STATUSES = new Set(['pending', 'processing', 'succeeded', 'failed', 'skipped'])
|
||||
const JOB_LEASE_SECONDS = 120
|
||||
|
||||
export async function enqueueOrderServiceAutoPublish(
|
||||
input: {
|
||||
workOrderId: number | string
|
||||
maxAttempts?: number
|
||||
},
|
||||
client?: Queryable,
|
||||
) {
|
||||
const workOrderId = Number(input.workOrderId)
|
||||
if (!Number.isInteger(workOrderId) || workOrderId <= 0) return null
|
||||
const maxAttempts = Math.max(1, Math.min(11, Math.trunc(Number(input.maxAttempts ?? 4) || 4)))
|
||||
const db = client || { query }
|
||||
const inserted = await db.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO order_service_auto_publish_jobs (work_order_id, max_attempts)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[workOrderId, maxAttempts],
|
||||
)
|
||||
if (inserted.rows[0]) return Number(inserted.rows[0].id)
|
||||
|
||||
const active = await db.query<{ id: number }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM order_service_auto_publish_jobs
|
||||
WHERE work_order_id = $1
|
||||
AND status = ANY($2::text[])
|
||||
LIMIT 1
|
||||
`,
|
||||
[workOrderId, [...ACTIVE_JOB_STATUSES]],
|
||||
)
|
||||
return active.rows[0] ? Number(active.rows[0].id) : null
|
||||
}
|
||||
|
||||
export async function processDueOrderServiceAutoPublishJobs(limitInput: unknown = 10) {
|
||||
const limit = Math.min(200, Math.max(1, Math.trunc(Number(limitInput) || 10)))
|
||||
const workerId = `order-service:${process.pid}`
|
||||
const pending = await query<AutoPublishJobRow>(
|
||||
`
|
||||
WITH due AS (
|
||||
SELECT id
|
||||
FROM order_service_auto_publish_jobs
|
||||
WHERE (
|
||||
(status = 'pending' AND next_run_at <= NOW())
|
||||
OR (status = 'processing' AND locked_until < NOW())
|
||||
)
|
||||
ORDER BY next_run_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $1
|
||||
)
|
||||
UPDATE order_service_auto_publish_jobs job
|
||||
SET status = 'processing',
|
||||
attempt_count = job.attempt_count + 1,
|
||||
locked_until = NOW() + ($2::text || ' seconds')::interval,
|
||||
locked_by = $3,
|
||||
updated_at = NOW()
|
||||
FROM due
|
||||
WHERE job.id = due.id
|
||||
RETURNING job.*
|
||||
`,
|
||||
[limit, String(JOB_LEASE_SECONDS), workerId],
|
||||
)
|
||||
|
||||
const succeeded: number[] = []
|
||||
const failed: number[] = []
|
||||
const skipped: number[] = []
|
||||
for (const job of pending.rows) {
|
||||
const outcome = await processAutoPublishJob(job).catch(async (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return retryOrFailJob(job, message)
|
||||
})
|
||||
if (outcome === 'succeeded') succeeded.push(Number(job.id))
|
||||
if (outcome === 'failed') failed.push(Number(job.id))
|
||||
if (outcome === 'skipped') skipped.push(Number(job.id))
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: pending.rows.length,
|
||||
succeeded: succeeded.length,
|
||||
failed: failed.length,
|
||||
skipped: skipped.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function processAutoPublishJob(
|
||||
job: AutoPublishJobRow,
|
||||
): Promise<'succeeded' | 'failed' | 'skipped'> {
|
||||
const workOrderId = Number(job.work_order_id)
|
||||
const orderResult = await query<{
|
||||
status: string
|
||||
reward_amount: number
|
||||
service_profile_snapshot_json: unknown
|
||||
}>(
|
||||
`
|
||||
SELECT status, reward_amount, service_profile_snapshot_json
|
||||
FROM work_orders
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[workOrderId],
|
||||
)
|
||||
const order = orderResult.rows[0]
|
||||
if (!order || order.status !== WORK_ORDER_STATUS.UNASSIGNED) {
|
||||
await finishJob(job, 'skipped', '订单已不在待分配状态', {})
|
||||
return 'skipped'
|
||||
}
|
||||
if (Number(order.reward_amount || 0) <= 0) {
|
||||
await finishJob(job, 'failed', '接单金额为 0,不能自动发布', {})
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const snapshot = safeParseJson(order.service_profile_snapshot_json)
|
||||
const autoPublish =
|
||||
snapshot.autoPublish && typeof snapshot.autoPublish === 'object'
|
||||
? (snapshot.autoPublish as Record<string, unknown>)
|
||||
: {}
|
||||
const requireVoucherConsume = autoPublish.requireVoucherConsume !== false
|
||||
|
||||
try {
|
||||
const result = await publishAdminWorkOrder(workOrderId, '系统:订单服务自动发布', {
|
||||
requireVoucherConsume,
|
||||
})
|
||||
await finishJob(job, 'succeeded', '', result)
|
||||
return 'succeeded'
|
||||
} catch (error) {
|
||||
return retryOrFailJob(job, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function retryOrFailJob(job: AutoPublishJobRow, errorMessage: string): Promise<'failed'> {
|
||||
if (Number(job.attempt_count || 0) >= Number(job.max_attempts || 4)) {
|
||||
await finishJob(job, 'failed', errorMessage, {})
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const delaySeconds = Math.min(300, 15 * 2 ** Math.max(0, Number(job.attempt_count || 1) - 1))
|
||||
await query(
|
||||
`
|
||||
UPDATE order_service_auto_publish_jobs
|
||||
SET status = 'pending',
|
||||
last_error = $2,
|
||||
next_run_at = NOW() + ($3::text || ' seconds')::interval,
|
||||
locked_until = NULL,
|
||||
locked_by = '',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[Number(job.id), errorMessage, String(delaySeconds)],
|
||||
)
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
async function finishJob(
|
||||
job: AutoPublishJobRow,
|
||||
status: 'succeeded' | 'failed' | 'skipped',
|
||||
errorMessage: string,
|
||||
result: Record<string, unknown>,
|
||||
) {
|
||||
const now = nowIso()
|
||||
await query(
|
||||
`
|
||||
UPDATE order_service_auto_publish_jobs
|
||||
SET status = $2,
|
||||
last_error = $3,
|
||||
result_json = $4::jsonb,
|
||||
locked_until = NULL,
|
||||
locked_by = '',
|
||||
finished_at = $5,
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
AND status = 'processing'
|
||||
`,
|
||||
[Number(job.id), status, errorMessage, JSON.stringify(result), now],
|
||||
)
|
||||
await query(
|
||||
`
|
||||
INSERT INTO work_order_events (
|
||||
work_order_id, actor_type, actor_id, event_type,
|
||||
from_status, to_status, payload_json, created_at
|
||||
)
|
||||
SELECT
|
||||
$2, 'system', 'order_service_auto_publish', $3,
|
||||
status, status, $4::jsonb, $5
|
||||
FROM work_orders WHERE id = $1
|
||||
`,
|
||||
[
|
||||
Number(job.work_order_id),
|
||||
Number(job.work_order_id),
|
||||
`auto_publish_${status}`,
|
||||
JSON.stringify({ jobId: Number(job.id), status, error: errorMessage, result }),
|
||||
now,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export async function listAdminOrderServiceAutoPublishJobs(statusInput: unknown = '') {
|
||||
const rawStatus = String(statusInput || '').trim()
|
||||
const status = JOB_STATUSES.has(rawStatus) ? rawStatus : ''
|
||||
const result = await query(
|
||||
`
|
||||
SELECT job.*, wo.product_name, wo.platform_order_id, wo.status AS work_order_status
|
||||
FROM order_service_auto_publish_jobs job
|
||||
JOIN work_orders wo ON wo.id = job.work_order_id
|
||||
${status ? 'WHERE job.status = $1' : ''}
|
||||
ORDER BY job.updated_at DESC, job.id DESC
|
||||
LIMIT 100
|
||||
`,
|
||||
status ? [status] : [],
|
||||
)
|
||||
return {
|
||||
items: result.rows.map((row: Record<string, unknown>) => ({
|
||||
id: Number(row.id),
|
||||
workOrderId: Number(row.work_order_id),
|
||||
status: String(row.status || ''),
|
||||
attemptCount: Number(row.attempt_count || 0),
|
||||
maxAttempts: Number(row.max_attempts || 0),
|
||||
nextRunAt: row.next_run_at || null,
|
||||
lastError: String(row.last_error || ''),
|
||||
result: safeParseJson(row.result_json),
|
||||
productName: String(row.product_name || ''),
|
||||
platformOrderId: String(row.platform_order_id || ''),
|
||||
workOrderStatus: String(row.work_order_status || ''),
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryAdminOrderServiceAutoPublishJob(jobIdInput: number | string) {
|
||||
const jobId = Number(jobIdInput)
|
||||
if (!Number.isInteger(jobId) || jobId <= 0) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 400,
|
||||
errorCode: 'order_service_auto_publish_job_invalid',
|
||||
})
|
||||
}
|
||||
return withTransaction(async (client) => {
|
||||
const locked = await client.query<AutoPublishJobRow>(
|
||||
`SELECT * FROM order_service_auto_publish_jobs WHERE id = $1 FOR UPDATE`,
|
||||
[jobId],
|
||||
)
|
||||
const job = locked.rows[0]
|
||||
if (!job) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'order_service_auto_publish_job_not_found',
|
||||
})
|
||||
}
|
||||
if (job.status === 'processing') {
|
||||
throw createHttpError('任务正在处理,请稍后刷新', {
|
||||
statusCode: 409,
|
||||
errorCode: 'order_service_auto_publish_job_processing',
|
||||
})
|
||||
}
|
||||
if (job.status !== 'failed' && job.status !== 'skipped') {
|
||||
throw createHttpError('只有失败或跳过的任务可以重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'order_service_auto_publish_job_status_invalid',
|
||||
})
|
||||
}
|
||||
const active = await client.query<{ id: number }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM order_service_auto_publish_jobs
|
||||
WHERE work_order_id = $1
|
||||
AND status IN ('pending', 'processing')
|
||||
AND id <> $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[job.work_order_id, jobId],
|
||||
)
|
||||
if (active.rows[0]) {
|
||||
throw createHttpError('该工单已有待处理的自动发布任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'order_service_auto_publish_job_already_active',
|
||||
})
|
||||
}
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
UPDATE order_service_auto_publish_jobs
|
||||
SET status = 'pending',
|
||||
attempt_count = 0,
|
||||
next_run_at = NOW(),
|
||||
last_error = '',
|
||||
locked_until = NULL,
|
||||
locked_by = '',
|
||||
finished_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`,
|
||||
[jobId],
|
||||
)
|
||||
return { jobId: Number(result.rows[0]?.id || 0) }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
isOrderServiceMaterialRequired,
|
||||
normalizeProfileConfig,
|
||||
} from './order-service-profile-service.js'
|
||||
|
||||
function config(patch: Record<string, unknown> = {}) {
|
||||
return normalizeProfileConfig({
|
||||
name: '测试配置',
|
||||
fields: [
|
||||
{
|
||||
key: 'gameId',
|
||||
label: '游戏编号',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
screenshots: {
|
||||
enabled: true,
|
||||
required: false,
|
||||
minCount: 0,
|
||||
maxCount: 5,
|
||||
},
|
||||
autoPublish: {
|
||||
enabled: false,
|
||||
requireVoucherConsume: true,
|
||||
retryMax: 3,
|
||||
},
|
||||
...patch,
|
||||
})
|
||||
}
|
||||
|
||||
test('订单服务资料判断:所有字段选填且截图不强制时不阻塞建单', () => {
|
||||
assert.equal(isOrderServiceMaterialRequired(config()), false)
|
||||
})
|
||||
|
||||
test('订单服务资料判断:必填字段或必填截图都会让工单进入待补资料', () => {
|
||||
assert.equal(
|
||||
isOrderServiceMaterialRequired(
|
||||
config({
|
||||
fields: [
|
||||
{
|
||||
key: 'gameId',
|
||||
label: '游戏编号',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
isOrderServiceMaterialRequired(
|
||||
config({
|
||||
screenshots: {
|
||||
enabled: true,
|
||||
required: true,
|
||||
minCount: 1,
|
||||
maxCount: 5,
|
||||
},
|
||||
}),
|
||||
),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('订单服务配置规范化:未知字段类型降级为文本,空下拉字段丢弃', () => {
|
||||
const normalized = normalizeProfileConfig({
|
||||
fields: [
|
||||
{ key: 'a', label: 'A', type: 'unknown' },
|
||||
{ key: 'b', label: 'B', type: 'select', options: [] },
|
||||
],
|
||||
})
|
||||
assert.equal(normalized.fields.length, 1)
|
||||
assert.equal(normalized.fields[0]?.type, 'text')
|
||||
})
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
archiveOrderServiceProfile,
|
||||
createOrderServiceProfileVersion,
|
||||
getOrderServiceProfileVersion,
|
||||
getPublishedOrderServiceProfileByCategory,
|
||||
listOrderServiceProfiles,
|
||||
setOrderServiceProfileCurrentVersion,
|
||||
upsertOrderServiceProfile,
|
||||
type OrderServiceProfileRow,
|
||||
} from '../../repositories/worker-platform/order-service-profile-repo.js'
|
||||
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
|
||||
import { normalizeUploadedFiles } from './worker-input-normalizers.js'
|
||||
|
||||
export type OrderServiceFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
|
||||
|
||||
export type OrderServiceField = {
|
||||
key: string
|
||||
label: string
|
||||
type: OrderServiceFieldType
|
||||
required: boolean
|
||||
placeholder: string
|
||||
helpText: string
|
||||
options: string[]
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type OrderServiceScreenshots = {
|
||||
enabled: boolean
|
||||
required: boolean
|
||||
minCount: number
|
||||
maxCount: number
|
||||
label: string
|
||||
helpText: string
|
||||
}
|
||||
|
||||
export type OrderServiceAutoPublish = {
|
||||
enabled: boolean
|
||||
requireVoucherConsume: boolean
|
||||
retryMax: number
|
||||
}
|
||||
|
||||
export type OrderServiceProfileConfig = {
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
guideImage: Record<string, unknown>
|
||||
fields: OrderServiceField[]
|
||||
screenshots: OrderServiceScreenshots
|
||||
autoPublish: OrderServiceAutoPublish
|
||||
submitText: string
|
||||
successText: string
|
||||
}
|
||||
|
||||
export type OrderServiceProfileSnapshot = {
|
||||
profileId: number
|
||||
categoryId: number
|
||||
versionId: number
|
||||
versionNo: number
|
||||
config: OrderServiceProfileConfig
|
||||
}
|
||||
|
||||
const FIELD_TYPES = new Set<OrderServiceFieldType>([
|
||||
'text',
|
||||
'textarea',
|
||||
'select',
|
||||
'radio',
|
||||
'number',
|
||||
'date',
|
||||
])
|
||||
|
||||
export async function listAdminOrderServiceProfiles() {
|
||||
const [categories, profiles] = await Promise.all([
|
||||
listAllWorkCategories(),
|
||||
listOrderServiceProfiles(),
|
||||
])
|
||||
return {
|
||||
categories: categories.map((category) => ({
|
||||
categoryId: Number(category.id),
|
||||
categoryKey: category.category_key,
|
||||
name: category.name,
|
||||
parentId: category.parent_id ? Number(category.parent_id) : null,
|
||||
status: category.status,
|
||||
})),
|
||||
profiles: profiles.map(mapAdminProfile),
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAdminOrderServiceProfile(payload: JsonObject = {}) {
|
||||
const categoryId = Number(payload.categoryId ?? payload.category_id ?? 0)
|
||||
if (!Number.isInteger(categoryId) || categoryId <= 0) {
|
||||
throw createHttpError('请选择分类', {
|
||||
statusCode: 400,
|
||||
errorCode: 'order_service_category_required',
|
||||
})
|
||||
}
|
||||
const categoryResult = await query<{ id: number }>(
|
||||
'SELECT id FROM work_categories WHERE id = $1 LIMIT 1',
|
||||
[categoryId],
|
||||
)
|
||||
if (!categoryResult.rows[0]) {
|
||||
throw createHttpError('分类不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_category_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const fields = normalizeFields(payload.fields ?? payload.fields_json)
|
||||
const screenshots = normalizeScreenshots(payload.screenshots ?? payload.screenshots_json)
|
||||
const autoPublish = normalizeAutoPublish(payload.autoPublish ?? payload.auto_publish_json)
|
||||
const guideImage = normalizeGuideImage(payload.guideImage ?? payload.guide_image_json)
|
||||
const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null
|
||||
|
||||
const saved = await upsertOrderServiceProfile({
|
||||
categoryId,
|
||||
name: text(payload.name, 80) || '订单服务配置',
|
||||
title: text(payload.title, 80),
|
||||
description: text(payload.description, 300),
|
||||
guideImage,
|
||||
fields,
|
||||
screenshots,
|
||||
autoPublish,
|
||||
submitText: text(payload.submitText, 30) || '提交资料',
|
||||
successText: text(payload.successText, 80) || '资料已提交,请等待处理',
|
||||
adminUserId,
|
||||
})
|
||||
return { profile: mapAdminProfile(saved) }
|
||||
}
|
||||
|
||||
export async function publishAdminOrderServiceProfile(
|
||||
profileIdInput: number | string,
|
||||
adminUserIdInput: unknown = null,
|
||||
) {
|
||||
const profileId = Number(profileIdInput)
|
||||
if (!Number.isInteger(profileId) || profileId <= 0) {
|
||||
throw createHttpError('配置不存在', {
|
||||
statusCode: 400,
|
||||
errorCode: 'order_service_profile_invalid',
|
||||
})
|
||||
}
|
||||
const adminUserId = Number(adminUserIdInput ?? 0) || null
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const locked = await client.query<OrderServiceProfileRow>(
|
||||
`SELECT * FROM order_service_profiles WHERE id = $1 FOR UPDATE`,
|
||||
[profileId],
|
||||
)
|
||||
const profile = locked.rows[0]
|
||||
if (!profile) {
|
||||
throw createHttpError('订单服务配置不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'order_service_profile_not_found',
|
||||
})
|
||||
}
|
||||
const config = buildProfileConfig(profile)
|
||||
if (config.fields.length === 0) {
|
||||
throw createHttpError('至少需要配置一个资料字段', {
|
||||
statusCode: 400,
|
||||
errorCode: 'order_service_fields_required',
|
||||
})
|
||||
}
|
||||
const { versionId, versionNo } = await createOrderServiceProfileVersion(client, {
|
||||
profileId,
|
||||
config,
|
||||
adminUserId,
|
||||
})
|
||||
await setOrderServiceProfileCurrentVersion(client, { profileId, versionId })
|
||||
return { versionNo, profileId, versionId }
|
||||
})
|
||||
}
|
||||
|
||||
export async function archiveAdminOrderServiceProfile(profileIdInput: number | string) {
|
||||
const profileId = Number(profileIdInput)
|
||||
const profile = await archiveOrderServiceProfile(profileId)
|
||||
if (!profile) {
|
||||
throw createHttpError('订单服务配置不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'order_service_profile_not_found',
|
||||
})
|
||||
}
|
||||
return { profile: mapAdminProfile(profile) }
|
||||
}
|
||||
|
||||
function mapAdminProfile(row: OrderServiceProfileRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
categoryId: Number(row.category_id),
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
guideImage: row.guide_image_json,
|
||||
fields: row.fields_json,
|
||||
screenshots: row.screenshots_json,
|
||||
autoPublish: row.auto_publish_json,
|
||||
submitText: row.submit_text,
|
||||
successText: row.success_text,
|
||||
status: row.status,
|
||||
currentVersionId: row.current_version_id,
|
||||
currentVersionNo: row.current_version_no,
|
||||
updatedAt: row.updated_at,
|
||||
config: buildProfileConfig(row),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildProfileConfig(row: {
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
guide_image_json: unknown
|
||||
fields_json: unknown
|
||||
screenshots_json: unknown
|
||||
auto_publish_json: unknown
|
||||
submit_text: string
|
||||
success_text: string
|
||||
}): OrderServiceProfileConfig & { guideImage: Record<string, unknown> } {
|
||||
return {
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
guideImage: normalizeGuideImage(row.guide_image_json),
|
||||
fields: normalizeFields(row.fields_json),
|
||||
screenshots: normalizeScreenshots(row.screenshots_json),
|
||||
autoPublish: normalizeAutoPublish(row.auto_publish_json),
|
||||
submitText: row.submit_text || '提交资料',
|
||||
successText: row.success_text || '资料已提交,请等待处理',
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublishedOrderServiceProfileSnapshotByCategory(
|
||||
categoryId: number | string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileSnapshot | null> {
|
||||
const row = await getPublishedOrderServiceProfileByCategory(categoryId, client)
|
||||
if (!row) return null
|
||||
return {
|
||||
profileId: Number(row.profile_id),
|
||||
categoryId: Number(row.category_id),
|
||||
versionId: Number(row.version_id),
|
||||
versionNo: Number(row.version_no),
|
||||
config: normalizeProfileConfig(row.config_json),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrderServiceProfileSnapshotByVersion(input: {
|
||||
profileId: number | string
|
||||
categoryId: number | string
|
||||
versionId: number | string
|
||||
client?: Pick<PoolClient, 'query'>
|
||||
}): Promise<OrderServiceProfileSnapshot | null> {
|
||||
const version = await getOrderServiceProfileVersion(input.versionId, input.client)
|
||||
if (!version) return null
|
||||
return {
|
||||
profileId: Number(input.profileId),
|
||||
categoryId: Number(input.categoryId),
|
||||
versionId: Number(version.id),
|
||||
versionNo: Number(version.version_no),
|
||||
config: normalizeProfileConfig(version.config_json),
|
||||
}
|
||||
}
|
||||
|
||||
export function isOrderServiceMaterialRequired(config: OrderServiceProfileConfig): boolean {
|
||||
if (config.fields.some((field) => field.required)) return true
|
||||
return config.screenshots.enabled && config.screenshots.minCount > 0
|
||||
}
|
||||
|
||||
export function normalizeProfileConfig(value: JsonObject): OrderServiceProfileConfig {
|
||||
return {
|
||||
name: text(value.name, 80) || '订单服务配置',
|
||||
title: text(value.title, 80),
|
||||
description: text(value.description, 300),
|
||||
guideImage: normalizeGuideImage(value.guideImage ?? value.guide_image),
|
||||
fields: normalizeFields(value.fields),
|
||||
screenshots: normalizeScreenshots(value.screenshots),
|
||||
autoPublish: normalizeAutoPublish(value.autoPublish),
|
||||
submitText: text(value.submitText, 30) || '提交资料',
|
||||
successText: text(value.successText, 80) || '资料已提交,请等待处理',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFields(value: unknown): OrderServiceField[] {
|
||||
const rows = Array.isArray(value) ? value : []
|
||||
const seen = new Set<string>()
|
||||
return rows
|
||||
.map((row, index): OrderServiceField | null => {
|
||||
const source = row && typeof row === 'object' ? (row as JsonObject) : {}
|
||||
const key = text(source.key ?? source.fieldKey, 60).replace(/[^a-zA-Z0-9_]/g, '')
|
||||
if (!key || seen.has(key)) return null
|
||||
seen.add(key)
|
||||
const rawType = text(source.type, 20)
|
||||
const type = (
|
||||
FIELD_TYPES.has(rawType as OrderServiceFieldType) ? rawType : 'text'
|
||||
) as OrderServiceFieldType
|
||||
const options = Array.isArray(source.options)
|
||||
? source.options.map((option) => text(option, 60)).filter(Boolean)
|
||||
: []
|
||||
if (type !== 'select' && type !== 'radio')
|
||||
return {
|
||||
key,
|
||||
label: text(source.label, 60) || key,
|
||||
type,
|
||||
required: source.required !== false,
|
||||
placeholder: text(source.placeholder, 100),
|
||||
helpText: text(source.helpText ?? source.help_text, 200),
|
||||
options: [],
|
||||
sortOrder: Number(source.sortOrder ?? source.sort_order ?? (index + 1) * 10),
|
||||
}
|
||||
if (options.length === 0) return null
|
||||
return {
|
||||
key,
|
||||
label: text(source.label, 60) || key,
|
||||
type,
|
||||
required: source.required !== false,
|
||||
placeholder: '',
|
||||
helpText: text(source.helpText ?? source.help_text, 200),
|
||||
options,
|
||||
sortOrder: Number(source.sortOrder ?? source.sort_order ?? (index + 1) * 10),
|
||||
}
|
||||
})
|
||||
.filter((row): row is OrderServiceField => Boolean(row))
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
}
|
||||
|
||||
function normalizeScreenshots(value: unknown): OrderServiceScreenshots {
|
||||
const source = value && typeof value === 'object' ? (value as JsonObject) : {}
|
||||
const enabled = source.enabled !== false
|
||||
const required = enabled && source.required !== false
|
||||
const maxCount = clampInteger(source.maxCount ?? source.max_count, 5, 1, 10)
|
||||
const requestedMinCount = clampInteger(
|
||||
source.minCount ?? source.min_count,
|
||||
required ? 1 : 0,
|
||||
0,
|
||||
maxCount,
|
||||
)
|
||||
return {
|
||||
enabled,
|
||||
required,
|
||||
minCount: required ? Math.max(1, requestedMinCount) : requestedMinCount,
|
||||
maxCount,
|
||||
label: text(source.label, 40) || '买家主页截图',
|
||||
helpText: text(source.helpText ?? source.help_text, 200),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAutoPublish(value: unknown): OrderServiceAutoPublish {
|
||||
const source = value && typeof value === 'object' ? (value as JsonObject) : {}
|
||||
return {
|
||||
enabled: source.enabled === true,
|
||||
requireVoucherConsume: source.requireVoucherConsume !== false,
|
||||
retryMax: clampInteger(source.retryMax ?? source.retry_max, 3, 0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGuideImage(value: unknown): Record<string, unknown> {
|
||||
const normalized = normalizeUploadedFiles([value])[0]
|
||||
return normalized ? { ...normalized } : {}
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, Math.trunc(parsed)))
|
||||
}
|
||||
|
||||
function text(value: unknown, maxLength: number) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
resolveOrderServiceStatusLabel,
|
||||
resolveOrderServiceStatusMessage,
|
||||
} from './order-service-public-service.js'
|
||||
|
||||
test('订单服务状态文案:待抢单提示高峰期等待', () => {
|
||||
const message = resolveOrderServiceStatusMessage({
|
||||
status: WORK_ORDER_STATUS.OPEN,
|
||||
canSubmit: false,
|
||||
})
|
||||
assert.match(message, /高峰期/)
|
||||
assert.match(message, /耐心等待/)
|
||||
})
|
||||
|
||||
test('订单服务状态文案:接单与完成提示查看邮箱', () => {
|
||||
assert.match(
|
||||
resolveOrderServiceStatusMessage({
|
||||
status: WORK_ORDER_STATUS.IN_PROGRESS,
|
||||
canSubmit: false,
|
||||
}),
|
||||
/30-60 分钟.*邮箱/,
|
||||
)
|
||||
assert.match(
|
||||
resolveOrderServiceStatusMessage({
|
||||
status: WORK_ORDER_STATUS.ACCEPTED,
|
||||
canSubmit: false,
|
||||
}),
|
||||
/检查邮件.*客服/,
|
||||
)
|
||||
})
|
||||
|
||||
test('订单服务状态文案:待补资料不显示只读提示', () => {
|
||||
assert.equal(
|
||||
resolveOrderServiceStatusMessage({
|
||||
status: WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
canSubmit: true,
|
||||
}),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('订单服务状态标签使用客户端语义', () => {
|
||||
assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.OPEN), '等待接单')
|
||||
assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.IN_PROGRESS), '处理中')
|
||||
assert.equal(resolveOrderServiceStatusLabel(WORK_ORDER_STATUS.ACCEPTED), '已完成')
|
||||
assert.equal(resolveOrderServiceStatusLabel('unknown', '处理中'), '处理中')
|
||||
})
|
||||
@@ -0,0 +1,492 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEventWithClient,
|
||||
getWorkOrderForUpdateWithClient,
|
||||
updateWorkOrderMaterialWithClient,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
import { getFileAssetByObjectKey } from '../../repositories/file-asset-repo.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js'
|
||||
import {
|
||||
ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES,
|
||||
buildFileUrl,
|
||||
refreshUploadedFileUrls,
|
||||
type UploadedFileDto,
|
||||
} from '../file-storage/file-storage-service.js'
|
||||
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
|
||||
import {
|
||||
pickSubmittedMaterialFields,
|
||||
resolvePublicMaterialOrderState,
|
||||
throwPublicMaterialBlocked,
|
||||
} from './work-order-material-link-service.js'
|
||||
import {
|
||||
getOrderServiceProfileSnapshotByVersion,
|
||||
normalizeProfileConfig,
|
||||
type OrderServiceField,
|
||||
type OrderServiceProfileConfig,
|
||||
} from './order-service-profile-service.js'
|
||||
import {
|
||||
enqueueOrderServiceAutoPublish,
|
||||
processDueOrderServiceAutoPublishJobs,
|
||||
} from './order-service-auto-publish-service.js'
|
||||
|
||||
const PUBLIC_STATUS_MESSAGES: Record<string, string> = {
|
||||
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '请补充订单资料。',
|
||||
[WORK_ORDER_STATUS.UNASSIGNED]: '资料已提交,客服正在安排处理,请耐心等待。',
|
||||
[WORK_ORDER_STATUS.OPEN]: '您的订单已经发布,当前处于高峰期,请耐心等待。',
|
||||
[WORK_ORDER_STATUS.IN_PROGRESS]:
|
||||
'您的订单已经有人接单,请您 30-60 分钟内注意查看邮箱,确认是否收到相关邮件。',
|
||||
[WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '交付内容已提交,正在确认。',
|
||||
[WORK_ORDER_STATUS.ACCEPTED]: '订单已完成,请检查邮件是否收到。如有问题,请及时联系客服处理。',
|
||||
[WORK_ORDER_STATUS.PROBLEM]: '订单需要客服介入处理,请联系客服。',
|
||||
[WORK_ORDER_STATUS.CANCELLED]: '订单已取消。',
|
||||
}
|
||||
|
||||
const PUBLIC_STATUS_LABELS: Record<string, string> = {
|
||||
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '待补充资料',
|
||||
[WORK_ORDER_STATUS.UNASSIGNED]: '待安排',
|
||||
[WORK_ORDER_STATUS.OPEN]: '等待接单',
|
||||
[WORK_ORDER_STATUS.IN_PROGRESS]: '处理中',
|
||||
[WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '待确认',
|
||||
[WORK_ORDER_STATUS.ACCEPTED]: '已完成',
|
||||
[WORK_ORDER_STATUS.PROBLEM]: '客服处理中',
|
||||
[WORK_ORDER_STATUS.CANCELLED]: '已取消',
|
||||
}
|
||||
|
||||
export type OrderServicePublicItem = {
|
||||
workOrderId: number
|
||||
productName: string
|
||||
categoryName: string
|
||||
title: string
|
||||
description: string
|
||||
submitText: string
|
||||
successText: string
|
||||
state: string
|
||||
stateLabel: string
|
||||
message: string
|
||||
canSubmit: boolean
|
||||
blockedReason: string
|
||||
fields?: OrderServiceField[]
|
||||
values?: Record<string, string>
|
||||
screenshots?: Array<Record<string, unknown>>
|
||||
screenshotsRule?: OrderServiceProfileConfig['screenshots']
|
||||
guideImage?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function resolveOrderServiceStatusLabel(status: string, fallback = '处理中') {
|
||||
return PUBLIC_STATUS_LABELS[String(status || '').trim()] || fallback
|
||||
}
|
||||
|
||||
export function resolveOrderServiceStatusMessage(input: {
|
||||
status: string
|
||||
canSubmit: boolean
|
||||
fallbackMessage?: string
|
||||
}) {
|
||||
if (input.canSubmit) return ''
|
||||
return (
|
||||
PUBLIC_STATUS_MESSAGES[String(input.status || '').trim()] ||
|
||||
String(input.fallbackMessage || '').trim() ||
|
||||
'订单状态已更新,如有疑问请联系客服。'
|
||||
)
|
||||
}
|
||||
|
||||
export async function getOrderServiceByPlatformOrderId(platformOrderIdInput: unknown) {
|
||||
const platformOrderId = normalizeOrderNo(platformOrderIdInput)
|
||||
const orders = await listWorkOrdersByPlatformOrderId(platformOrderId)
|
||||
if (orders.length === 0) {
|
||||
throw notFound()
|
||||
}
|
||||
|
||||
const items = await Promise.all(orders.map((order) => mapPublicOrderItem(order)))
|
||||
return {
|
||||
platformOrderId,
|
||||
items,
|
||||
pendingCount: items.filter((item) => item.canSubmit).length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitOrderServiceMaterial(
|
||||
platformOrderIdInput: unknown,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const platformOrderId = normalizeOrderNo(platformOrderIdInput)
|
||||
const target = await resolveTarget(platformOrderId, payload.workOrderId)
|
||||
const targetState = resolvePublicMaterialOrderState(target)
|
||||
if (!targetState.canSubmit) throwPublicMaterialBlocked(targetState)
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
const order = await getWorkOrderForUpdateWithClient(client, target.id)
|
||||
if (!order) throw notFound()
|
||||
if (String(order.platform_order_id || '').trim() !== platformOrderId) throw notFound()
|
||||
const state = resolvePublicMaterialOrderState(order)
|
||||
if (!state.canSubmit) throwPublicMaterialBlocked(state)
|
||||
|
||||
const profileConfig = await resolveProfileConfig(order, client)
|
||||
const fields = profileConfig?.fields || []
|
||||
if (!profileConfig || fields.length === 0) {
|
||||
throw createHttpError('该订单尚未配置资料收集字段,请联系客服', {
|
||||
statusCode: 409,
|
||||
errorCode: 'order_service_fields_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const submittedFields = pickSubmittedMaterialFields(
|
||||
fields,
|
||||
normalizeSubmittedFields(payload.fields || {}),
|
||||
)
|
||||
const missing = fields.filter(
|
||||
(field) => field.required && !String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
if (missing.length > 0) {
|
||||
throw createHttpError(
|
||||
`请填写完整必填资料:${missing.map((field) => field.label).join('、')}`,
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'material_link_fields_incomplete',
|
||||
context: { missingFields: missing.map((field) => field.key) },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const screenshotRule = profileConfig.screenshots
|
||||
const requestedScreenshots = screenshotRule.enabled
|
||||
? normalizeUploadedFiles(payload.screenshots || [])
|
||||
: []
|
||||
const screenshots = await resolveOwnedOrderServiceScreenshots(
|
||||
requestedScreenshots,
|
||||
Number(order.id),
|
||||
client,
|
||||
)
|
||||
if (screenshotRule.enabled && screenshots.length < screenshotRule.minCount) {
|
||||
throw createHttpError(`${screenshotRule.label}至少需要 ${screenshotRule.minCount} 张`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'order_service_screenshots_required',
|
||||
})
|
||||
}
|
||||
if (screenshots.length > screenshotRule.maxCount) {
|
||||
throw createHttpError(`${screenshotRule.label}最多 ${screenshotRule.maxCount} 张`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'material_link_screenshots_too_many',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(order.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete: true,
|
||||
source: 'order_service',
|
||||
},
|
||||
}
|
||||
const updated = await updateWorkOrderMaterialWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
status: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
materialJson: JSON.stringify(material),
|
||||
now,
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('更新订单资料失败,请稍后重试', {
|
||||
statusCode: 500,
|
||||
errorCode: 'material_link_update_failed',
|
||||
})
|
||||
}
|
||||
|
||||
// 把分类配置同步到工单 requirement,保证打手端/后台兼容旧双读链路。
|
||||
await client.query(
|
||||
`UPDATE work_orders SET requirement_json = $2::jsonb, updated_at = $3 WHERE id = $1`,
|
||||
[order.id, JSON.stringify({ fields }), now],
|
||||
)
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
actorType: 'collect',
|
||||
actorId: '客户按订单号提交资料',
|
||||
eventType: 'material_completed',
|
||||
fromStatus: order.status,
|
||||
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
payloadJson: JSON.stringify({
|
||||
fields: submittedFields,
|
||||
complete: true,
|
||||
source: 'order_service',
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
if (profileConfig.autoPublish.enabled) {
|
||||
await enqueueOrderServiceAutoPublish(
|
||||
{
|
||||
workOrderId: Number(order.id),
|
||||
maxAttempts: profileConfig.autoPublish.retryMax + 1,
|
||||
},
|
||||
client,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
order: updated,
|
||||
autoPublishEnabled: profileConfig.autoPublish.enabled,
|
||||
successText: profileConfig.successText,
|
||||
}
|
||||
})
|
||||
|
||||
await enqueueCustomerOrderNotification({
|
||||
orderId: target.order_id,
|
||||
orderItemId: target.order_item_id,
|
||||
workOrderId: result.order.id,
|
||||
eventType: 'material_submitted',
|
||||
payload: { source: 'order_service' },
|
||||
}).catch(() => undefined)
|
||||
|
||||
try {
|
||||
await resolveAdminNotificationEntity(
|
||||
'work_order',
|
||||
Number(result.order.id),
|
||||
'material_completed',
|
||||
)
|
||||
} catch (error) {
|
||||
logWarn('[order-service]', '补资料完成通知处理失败', {
|
||||
workOrderId: result.order.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
|
||||
|
||||
if (result.autoPublishEnabled) {
|
||||
void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
complete: true,
|
||||
workOrderId: Number(result.order.id),
|
||||
order: mapSafeOrder(result.order),
|
||||
successText: result.successText,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrderServiceUploadContext(
|
||||
platformOrderIdInput: unknown,
|
||||
workOrderIdInput: unknown,
|
||||
) {
|
||||
const platformOrderId = normalizeOrderNo(platformOrderIdInput)
|
||||
const order = await resolveTarget(platformOrderId, workOrderIdInput)
|
||||
const state = resolvePublicMaterialOrderState(order)
|
||||
if (!state.canSubmit) throwPublicMaterialBlocked(state)
|
||||
return { order }
|
||||
}
|
||||
|
||||
async function listWorkOrdersByPlatformOrderId(platformOrderId: string) {
|
||||
const result = await query<WorkOrderRow>(
|
||||
`
|
||||
SELECT wo.*, wc.name AS category_name, parent_wc.name AS parent_category_name,
|
||||
COALESCE(share.active_share_quantity, 0)::int AS active_share_quantity
|
||||
FROM work_orders wo
|
||||
LEFT JOIN work_categories wc ON wc.id = wo.category_id
|
||||
LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(quantity) AS active_share_quantity
|
||||
FROM work_order_shares share
|
||||
WHERE share.work_order_id = wo.id AND share.status <> 'cancelled'
|
||||
) share ON TRUE
|
||||
WHERE wo.platform_order_id = $1
|
||||
ORDER BY wo.id ASC
|
||||
`,
|
||||
[platformOrderId],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function resolveTarget(platformOrderId: string, workOrderIdInput: unknown) {
|
||||
const workOrderId = Number(workOrderIdInput ?? 0)
|
||||
if (Number.isInteger(workOrderId) && workOrderId > 0) {
|
||||
const result = await query<WorkOrderRow>(
|
||||
`SELECT wo.*, wc.name AS category_name, parent_wc.name AS parent_category_name,
|
||||
COALESCE(share.active_share_quantity, 0)::int AS active_share_quantity
|
||||
FROM work_orders wo
|
||||
LEFT JOIN work_categories wc ON wc.id = wo.category_id
|
||||
LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(quantity) AS active_share_quantity
|
||||
FROM work_order_shares share
|
||||
WHERE share.work_order_id = wo.id AND share.status <> 'cancelled'
|
||||
) share ON TRUE
|
||||
WHERE wo.id = $1 LIMIT 1`,
|
||||
[workOrderId],
|
||||
)
|
||||
const order = result.rows[0]
|
||||
if (!order || String(order.platform_order_id || '').trim() !== platformOrderId) throw notFound()
|
||||
return order
|
||||
}
|
||||
|
||||
const orders = await listWorkOrdersByPlatformOrderId(platformOrderId)
|
||||
if (orders.length === 0) throw notFound()
|
||||
if (orders.length > 1) {
|
||||
throw createHttpError('该订单号下有多个商品,请先选择商品', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_work_order_required',
|
||||
})
|
||||
}
|
||||
return orders[0]!
|
||||
}
|
||||
|
||||
async function mapPublicOrderItem(order: WorkOrderRow): Promise<OrderServicePublicItem> {
|
||||
const state = resolvePublicMaterialOrderState(order)
|
||||
const profile = await resolveProfileConfig(order)
|
||||
const material = safeParseJson(order.material_json)
|
||||
const collect = material.collect && typeof material.collect === 'object' ? material.collect : {}
|
||||
const values = normalizeSubmittedFields(collect.fields || {})
|
||||
const screenshots = normalizeUploadedFiles(collect.screenshots || [])
|
||||
.map(refreshUploadedFileUrls)
|
||||
.filter(Boolean)
|
||||
|
||||
const base: OrderServicePublicItem = {
|
||||
workOrderId: Number(order.id),
|
||||
productName: String(order.product_name || ''),
|
||||
categoryName: String(order.parent_category_name || order.category_name || ''),
|
||||
title: profile?.title || profile?.name || '订单服务',
|
||||
description: profile?.description || '',
|
||||
submitText: profile?.submitText || '提交资料',
|
||||
successText: profile?.successText || '资料已提交,请等待处理',
|
||||
state: order.status,
|
||||
stateLabel: resolveOrderServiceStatusLabel(order.status, state.statusLabel),
|
||||
message: resolveOrderServiceStatusMessage({
|
||||
status: order.status,
|
||||
canSubmit: state.canSubmit,
|
||||
fallbackMessage: state.message,
|
||||
}),
|
||||
canSubmit: state.canSubmit,
|
||||
blockedReason: state.blockedReason,
|
||||
}
|
||||
|
||||
if (!state.canSubmit) return base
|
||||
|
||||
const fields = profile?.fields || []
|
||||
return {
|
||||
...base,
|
||||
fields,
|
||||
values,
|
||||
screenshots,
|
||||
screenshotsRule: profile?.screenshots || {
|
||||
enabled: true,
|
||||
required: true,
|
||||
minCount: 0,
|
||||
maxCount: 5,
|
||||
label: '买家主页截图',
|
||||
helpText: '',
|
||||
},
|
||||
guideImage: profile?.guideImage || {},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProfileConfig(
|
||||
order: WorkOrderRow,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileConfig | null> {
|
||||
const snapshot = safeParseJson(order.service_profile_snapshot_json)
|
||||
const config = snapshot.config
|
||||
if (config && typeof config === 'object' && Array.isArray((config as JsonObject).fields)) {
|
||||
return normalizeProfileConfig(config as JsonObject)
|
||||
}
|
||||
if (order.category_id && order.service_profile_id && order.service_profile_version_id) {
|
||||
const snapshotFromVersion = await getOrderServiceProfileSnapshotByVersion({
|
||||
profileId: order.service_profile_id,
|
||||
categoryId: order.category_id,
|
||||
versionId: order.service_profile_version_id,
|
||||
...(client ? { client } : {}),
|
||||
})
|
||||
if (snapshotFromVersion) return snapshotFromVersion.config
|
||||
}
|
||||
const legacy = safeParseJson(order.requirement_json)
|
||||
if (Array.isArray(legacy.fields)) {
|
||||
return {
|
||||
name: '通用资料',
|
||||
title: '',
|
||||
description: '',
|
||||
guideImage: {},
|
||||
fields: legacy.fields as OrderServiceField[],
|
||||
screenshots: {
|
||||
enabled: true,
|
||||
required: true,
|
||||
minCount: 0,
|
||||
maxCount: 5,
|
||||
label: '买家主页截图',
|
||||
helpText: '',
|
||||
},
|
||||
autoPublish: { enabled: false, requireVoucherConsume: true, retryMax: 3 },
|
||||
submitText: '提交资料',
|
||||
successText: '资料已提交,请等待处理',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveOwnedOrderServiceScreenshots(
|
||||
files: ReturnType<typeof normalizeUploadedFiles>,
|
||||
workOrderId: number,
|
||||
client: Pick<PoolClient, 'query'>,
|
||||
): Promise<UploadedFileDto[]> {
|
||||
const expectedUploaderId = `work-order-${workOrderId}`
|
||||
const result: UploadedFileDto[] = []
|
||||
for (const file of files) {
|
||||
const objectKey = String(file.objectKey || '').trim()
|
||||
const asset = objectKey ? await getFileAssetByObjectKey(objectKey, client) : null
|
||||
if (
|
||||
!asset ||
|
||||
asset.status !== 'active' ||
|
||||
asset.uploader_type !== 'collect' ||
|
||||
asset.uploader_id !== expectedUploaderId ||
|
||||
asset.scene !== 'collect-material' ||
|
||||
!ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES.has(String(asset.content_type || '').trim())
|
||||
) {
|
||||
throw createHttpError('截图文件无效,请重新上传', {
|
||||
statusCode: 400,
|
||||
errorCode: 'order_service_screenshot_invalid',
|
||||
})
|
||||
}
|
||||
result.push({
|
||||
objectKey: asset.object_key,
|
||||
url: buildFileUrl(asset.object_key),
|
||||
thumbnailUrl: buildFileUrl(asset.thumbnail_object_key || asset.object_key),
|
||||
mediumUrl: buildFileUrl(asset.medium_object_key || asset.object_key),
|
||||
filename: asset.original_filename,
|
||||
contentType: asset.content_type,
|
||||
size: Number(asset.size_bytes || 0),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function mapSafeOrder(order: WorkOrderRow) {
|
||||
return {
|
||||
workOrderNo: order.work_order_no,
|
||||
platformOrderId: order.platform_order_id,
|
||||
productName: order.product_name,
|
||||
status: order.status,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderNo(value: unknown) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (!normalized) throw notFound()
|
||||
return normalized
|
||||
}
|
||||
|
||||
function notFound() {
|
||||
return createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'order_service_not_found',
|
||||
})
|
||||
}
|
||||
@@ -56,6 +56,10 @@ import {
|
||||
} from '../fulfillment/internal-sku-resolver.js'
|
||||
import { loadActiveCarrierEvidence } from '../fulfillment/carrier-evidence-service.js'
|
||||
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
|
||||
import {
|
||||
getPublishedOrderServiceProfileSnapshotByCategory,
|
||||
isOrderServiceMaterialRequired,
|
||||
} from './order-service-profile-service.js'
|
||||
|
||||
export function shouldBootstrapWorkOrderChannel(
|
||||
plan: InternalSkuChannelPlan | undefined,
|
||||
@@ -343,7 +347,12 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
continue
|
||||
}
|
||||
|
||||
const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
|
||||
const serviceProfile = rule.category_id
|
||||
? await getPublishedOrderServiceProfileSnapshotByCategory(rule.category_id)
|
||||
: null
|
||||
const fields = serviceProfile
|
||||
? normalizeRequirementFields(serviceProfile.config.fields)
|
||||
: normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
|
||||
const skuQuantity = resolveSkuNameQuantity(item.sku_name)
|
||||
const { sharingTotalQuantity, sharingUnitReward, rewardAmount } = resolveWorkOrderRulePricing({
|
||||
skuQuantity,
|
||||
@@ -359,7 +368,9 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
? false
|
||||
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity)
|
||||
const now = nowIso()
|
||||
const materialComplete = fields.every((field) => !field.required)
|
||||
const materialComplete = serviceProfile
|
||||
? !isOrderServiceMaterialRequired(serviceProfile.config)
|
||||
: fields.every((field) => !field.required)
|
||||
// 工单素材 = source 摘要 +(抖音)该单已有的券快照:打手要能看到「交付什么券」。
|
||||
// 券通常在发券后回填(certificate-service),这里覆盖「运营补模板后重新匹配」的建单路径。
|
||||
let material: JsonObject = {
|
||||
@@ -408,6 +419,17 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(rule.gift_cooldown_minutes),
|
||||
materialJson: JSON.stringify(material),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
serviceProfileId: serviceProfile?.profileId || null,
|
||||
serviceProfileVersionId: serviceProfile?.versionId || null,
|
||||
serviceProfileSnapshotJson: serviceProfile
|
||||
? JSON.stringify({
|
||||
profileId: serviceProfile.profileId,
|
||||
categoryId: serviceProfile.categoryId,
|
||||
versionId: serviceProfile.versionId,
|
||||
versionNo: serviceProfile.versionNo,
|
||||
config: serviceProfile.config,
|
||||
})
|
||||
: null,
|
||||
now,
|
||||
})
|
||||
if (!workOrder) {
|
||||
@@ -446,6 +468,8 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
ruleKey: rule.rule_key,
|
||||
serviceProfileId: serviceProfile?.profileId || null,
|
||||
serviceProfileVersionId: serviceProfile?.versionId || null,
|
||||
orderId: order.id,
|
||||
orderItemId: item.id,
|
||||
}),
|
||||
|
||||
@@ -26,10 +26,7 @@ import {
|
||||
import { DEFAULT_DEPOSIT_THRESHOLD_AMOUNT } from './worker-finance-mappers.js'
|
||||
import { normalizeUploadedFiles } from './worker-input-normalizers.js'
|
||||
import { resolveFreezeDepositAmount } from './worker-permission-utils.js'
|
||||
import {
|
||||
mapAcceptanceForResponse,
|
||||
resolveCollectSubmittedFieldValues,
|
||||
} from './work-order-material-mappers.js'
|
||||
import { mapAcceptanceForResponse } from './work-order-material-mappers.js'
|
||||
import { resolveRequirementFields } from './work-order-requirement-fields.js'
|
||||
|
||||
export function mapWorkCategory(
|
||||
@@ -972,16 +969,3 @@ export function mapWorkOrderPublic(workOrder: WorkOrderRow) {
|
||||
status: workOrder.status,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCollectLookupOrder(workOrder: WorkOrderRow) {
|
||||
const material = mapWorkOrderMaterial(workOrder.material_json)
|
||||
return {
|
||||
...mapWorkOrderPublic(workOrder),
|
||||
fields: resolveRequirementFields(workOrder),
|
||||
values: resolveCollectSubmittedFieldValues(workOrder),
|
||||
screenshots:
|
||||
material.collect && Array.isArray(material.collect.screenshots)
|
||||
? material.collect.screenshots
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatPublicMaterialStatus,
|
||||
pickPendingMaterialOrders,
|
||||
pickSubmittedMaterialFields,
|
||||
resolveMaterialResetBlockReason,
|
||||
resolvePublicMaterialOrderState,
|
||||
@@ -130,22 +129,6 @@ test('客服重置补资料:只允许待分配、未指派、无份额、未
|
||||
)
|
||||
})
|
||||
|
||||
test('公开补资料:只查询待完善工单,其他状态一律不返回', () => {
|
||||
const orders = [
|
||||
{ status: 'pending_material' },
|
||||
{ status: 'unassigned' },
|
||||
{ status: 'open' },
|
||||
{ status: 'accepted' },
|
||||
{ status: 'cancelled' },
|
||||
] as WorkOrderRow[]
|
||||
|
||||
assert.deepEqual(
|
||||
pickPendingMaterialOrders(orders).map((order) => order.status),
|
||||
['pending_material'],
|
||||
)
|
||||
assert.deepEqual(pickPendingMaterialOrders([]), [])
|
||||
})
|
||||
|
||||
test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
|
||||
const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], {
|
||||
gameId: '123',
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEventWithClient,
|
||||
getWorkOrderForUpdateWithClient,
|
||||
listWorkOrdersByPlatformOrderId,
|
||||
updateWorkOrderMaterialWithClient,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import {
|
||||
mapCollectLookupOrder,
|
||||
normalizeOptionalId,
|
||||
normalizeSubmittedFields,
|
||||
normalizeUploadedFiles,
|
||||
resolveRequirementFields,
|
||||
} from './mappers.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
|
||||
const MAX_MATERIAL_SCREENSHOTS = 5
|
||||
|
||||
const PUBLIC_MATERIAL_STATUS_LABELS: Record<string, string> = {
|
||||
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善',
|
||||
@@ -53,18 +30,11 @@ export type PublicMaterialOrderState = {
|
||||
|
||||
export type MaterialResetBlockReason = 'status' | 'assigned' | 'shares' | 'published' | ''
|
||||
|
||||
/** 公开页状态文案;后台状态列/时间线也统一用「待分配」。 */
|
||||
export function formatPublicMaterialStatus(status: string): string {
|
||||
const normalized = String(status || '').trim()
|
||||
return PUBLIC_MATERIAL_STATUS_LABELS[normalized] || normalized || '未知状态'
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开补资料页的准入判断。
|
||||
*
|
||||
* 只有「待完善 + 未指派 + 无拼单份额」可以提交;待分配视为已提交,
|
||||
* 其他状态只展示状态与客服提示,不再展示表单。
|
||||
*/
|
||||
export function resolvePublicMaterialOrderState(
|
||||
order: Pick<WorkOrderRow, 'status' | 'assigned_worker_id' | 'active_share_quantity'>,
|
||||
): PublicMaterialOrderState {
|
||||
@@ -114,12 +84,6 @@ export function resolvePublicMaterialOrderState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服重置补资料状态的前置条件。
|
||||
*
|
||||
* 只允许「待分配 + 未指派 + 无份额 + 未发布」的工单回到待完善;
|
||||
* 其他状态一律拒绝,避免把已进入接单流程的工单改回可编辑。
|
||||
*/
|
||||
export function resolveMaterialResetBlockReason(
|
||||
order: Pick<
|
||||
WorkOrderRow,
|
||||
@@ -140,12 +104,6 @@ export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReaso
|
||||
return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态'
|
||||
}
|
||||
|
||||
/**
|
||||
* 只保留工单 requirement 里定义过的字段。
|
||||
*
|
||||
* 公开提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的
|
||||
* 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。
|
||||
*/
|
||||
export function pickSubmittedMaterialFields(
|
||||
fields: Array<{ key: string }>,
|
||||
submitted: Record<string, string>,
|
||||
@@ -154,7 +112,7 @@ export function pickSubmittedMaterialFields(
|
||||
return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key)))
|
||||
}
|
||||
|
||||
function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
|
||||
export function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
|
||||
if (state.blockedReason === 'already_submitted') {
|
||||
throw createHttpError(state.message, {
|
||||
statusCode: 409,
|
||||
@@ -166,272 +124,3 @@ function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
function mapPublicOrder(order: WorkOrderRow) {
|
||||
return {
|
||||
workOrderNo: order.work_order_no,
|
||||
platformOrderId: order.platform_order_id,
|
||||
productName: order.product_name,
|
||||
status: order.status,
|
||||
}
|
||||
}
|
||||
|
||||
function mapPublicMaterialOrder(order: WorkOrderRow) {
|
||||
const state = resolvePublicMaterialOrderState(order)
|
||||
const base = {
|
||||
workOrderId: Number(order.id),
|
||||
workOrderNo: order.work_order_no,
|
||||
platformOrderId: order.platform_order_id,
|
||||
productName: order.product_name,
|
||||
status: order.status,
|
||||
statusLabel: state.statusLabel,
|
||||
canSubmit: state.canSubmit,
|
||||
blockedReason: state.blockedReason,
|
||||
message: state.message,
|
||||
}
|
||||
if (!state.canSubmit) return base
|
||||
|
||||
const mapped = mapCollectLookupOrder(order)
|
||||
return {
|
||||
...base,
|
||||
fields: mapped.fields,
|
||||
values: mapped.values,
|
||||
screenshots: mapped.screenshots,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePublicMaterialTarget(
|
||||
platformOrderId: string,
|
||||
workOrderIdInput: unknown,
|
||||
): Promise<WorkOrderRow> {
|
||||
const workOrderId = normalizeOptionalId(workOrderIdInput)
|
||||
if (workOrderId) {
|
||||
const order = await getRequiredWorkOrder(workOrderId)
|
||||
if (String(order.platform_order_id || '').trim() !== platformOrderId) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_work_order_invalid',
|
||||
})
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
const orders = await listWorkOrdersByPlatformOrderId(platformOrderId)
|
||||
if (orders.length === 0) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
if (orders.length > 1) {
|
||||
throw createHttpError('该订单号下有多个商品,请先选择商品', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_work_order_required',
|
||||
})
|
||||
}
|
||||
return orders[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页只允许查询待完善工单。
|
||||
*
|
||||
* 待分配、待抢单、代练中、已验收、已取消等状态一律不返回,
|
||||
* 避免客户用订单号看到已完成订单的商品与状态。
|
||||
*/
|
||||
export function pickPendingMaterialOrders(orders: WorkOrderRow[]): WorkOrderRow[] {
|
||||
return orders.filter((order) => order.status === WORK_ORDER_STATUS.PENDING_MATERIAL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页按平台订单号读取补资料内容。
|
||||
*
|
||||
* 不再要求客服先生成链接;订单号存在且仍有待完善工单时才返回内容。
|
||||
* 只返回待完善工单;已提交(待分配)及其他状态统一提示无需补充资料。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLink(platformOrderId: string) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const orders = await listWorkOrdersByPlatformOrderId(code)
|
||||
if (orders.length === 0) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const pendingOrders = pickPendingMaterialOrders(orders)
|
||||
if (pendingOrders.length === 0) {
|
||||
throw createHttpError('该订单当前无需补充资料', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_pending',
|
||||
})
|
||||
}
|
||||
|
||||
const publicOrders = pendingOrders.map(mapPublicMaterialOrder)
|
||||
return {
|
||||
platformOrderId: code,
|
||||
orders: publicOrders,
|
||||
pendingCount: publicOrders.filter((order) => order.canSubmit).length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页提交资料。
|
||||
*
|
||||
* 一次性语义完全由工单状态保证:只有 `pending_material` 能成功提交,
|
||||
* 提交后改成 `unassigned`;并发的第二个提交会锁到 `unassigned` 并返回 409。
|
||||
*/
|
||||
export async function submitPublicWorkOrderMaterialLink(
|
||||
platformOrderId: string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const target = await resolvePublicMaterialTarget(code, payload.workOrderId)
|
||||
const targetState = resolvePublicMaterialOrderState(target)
|
||||
if (!targetState.canSubmit) throwPublicMaterialBlocked(targetState)
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
// 锁工单:并发提交/客服重置都串行化,状态是唯一的一次性凭据。
|
||||
const order = await getWorkOrderForUpdateWithClient(client, target.id)
|
||||
if (!order) {
|
||||
throw createHttpError('接单订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_not_found',
|
||||
})
|
||||
}
|
||||
if (String(order.platform_order_id || '').trim() !== code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_work_order_invalid',
|
||||
})
|
||||
}
|
||||
const orderState = resolvePublicMaterialOrderState(order)
|
||||
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
|
||||
|
||||
const fields = resolveRequirementFields(order)
|
||||
const submittedFields = pickSubmittedMaterialFields(
|
||||
fields,
|
||||
normalizeSubmittedFields(payload.fields || {}),
|
||||
)
|
||||
const missing = fields.filter(
|
||||
(field) => field.required && !String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
if (missing.length > 0) {
|
||||
throw createHttpError(
|
||||
`请填写完整必填资料:${missing.map((field) => field.label).join('、')}`,
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'material_link_fields_incomplete',
|
||||
context: { missingFields: missing.map((field) => field.key) },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || [])
|
||||
if (screenshots.length > MAX_MATERIAL_SCREENSHOTS) {
|
||||
throw createHttpError(`买家主页截图最多 ${MAX_MATERIAL_SCREENSHOTS} 张`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'material_link_screenshots_too_many',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(order.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete: true,
|
||||
source: 'customer_order_no',
|
||||
},
|
||||
}
|
||||
|
||||
const updated = await updateWorkOrderMaterialWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
status: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
materialJson: JSON.stringify(material),
|
||||
now,
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('更新订单资料失败,请稍后重试', {
|
||||
statusCode: 500,
|
||||
errorCode: 'material_link_update_failed',
|
||||
})
|
||||
}
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
actorType: 'collect',
|
||||
actorId: '客户按订单号补资料',
|
||||
eventType: 'material_completed',
|
||||
fromStatus: order.status,
|
||||
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
payloadJson: JSON.stringify({
|
||||
fields: submittedFields,
|
||||
complete: true,
|
||||
source: 'customer_order_no',
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
return { order: updated }
|
||||
})
|
||||
|
||||
// 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。
|
||||
try {
|
||||
await resolveAdminNotificationEntity(
|
||||
'work_order',
|
||||
Number(result.order.id),
|
||||
'material_completed',
|
||||
)
|
||||
} catch (error) {
|
||||
logWarn('[work-order-material-link]', '补资料完成通知处理失败', {
|
||||
workOrderId: result.order.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
|
||||
|
||||
return {
|
||||
complete: true,
|
||||
workOrderId: Number(result.order.id),
|
||||
order: mapPublicOrder(result.order),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前校验平台订单号 / workOrderId 与工单状态;返回工单供文件归属使用。
|
||||
* 注意:这里不消耗提交机会,只有最终提交才把状态改成待分配。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLinkUploadContext(
|
||||
platformOrderId: string,
|
||||
workOrderId?: unknown,
|
||||
) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('订单号无效,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const order = await resolvePublicMaterialTarget(code, workOrderId)
|
||||
const orderState = resolvePublicMaterialOrderState(order)
|
||||
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
|
||||
|
||||
return { order, now: nowIso() }
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEvent,
|
||||
listPendingMaterialWorkOrdersByPlatformOrderId,
|
||||
updateWorkOrder,
|
||||
type WorkOrderRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import {
|
||||
mapCollectLookupOrder,
|
||||
mapWorkOrderPublic,
|
||||
normalizeOptionalId,
|
||||
normalizeSubmittedFields,
|
||||
normalizeUploadedFiles,
|
||||
resolveRequirementFields,
|
||||
} from './mappers.js'
|
||||
|
||||
export async function collectLookupWorkOrder(payload: JsonObject = {}) {
|
||||
const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim()
|
||||
if (!orderNo) {
|
||||
throw createHttpError('请填写订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'collect_order_no_required',
|
||||
})
|
||||
}
|
||||
const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo)
|
||||
if (workOrders.length === 0) {
|
||||
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_order_not_found',
|
||||
})
|
||||
}
|
||||
const [primaryOrder] = workOrders
|
||||
if (!primaryOrder) {
|
||||
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_order_not_found',
|
||||
})
|
||||
}
|
||||
return {
|
||||
order: mapWorkOrderPublic(primaryOrder),
|
||||
fields: resolveRequirementFields(primaryOrder),
|
||||
orders: workOrders.map(mapCollectLookupOrder),
|
||||
pendingCount: workOrders.length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectSubmitWorkOrder(payload: JsonObject = {}) {
|
||||
const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim()
|
||||
if (!orderNo) {
|
||||
throw createHttpError('请填写订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'collect_order_no_required',
|
||||
})
|
||||
}
|
||||
const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo)
|
||||
if (workOrders.length === 0) {
|
||||
throw createHttpError('未找到待完善订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_order_not_found',
|
||||
})
|
||||
}
|
||||
const workOrder = resolveCollectSubmitTargetWorkOrder(
|
||||
orderNo,
|
||||
workOrders,
|
||||
payload.workOrderId || payload.work_order_id,
|
||||
)
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete,
|
||||
},
|
||||
}
|
||||
const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
status: nextStatus,
|
||||
material_json: material,
|
||||
updated_at: now,
|
||||
})
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'collect',
|
||||
actorId: orderNo,
|
||||
eventType: 'material_submitted',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: nextStatus,
|
||||
payloadJson: JSON.stringify({ fields: submittedFields, complete }),
|
||||
now,
|
||||
})
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id) })
|
||||
return {
|
||||
order: mapWorkOrderPublic(updated || workOrder),
|
||||
complete,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCollectSubmitTargetWorkOrder(
|
||||
orderNo: string,
|
||||
workOrders: WorkOrderRow[],
|
||||
requestedWorkOrderId: unknown,
|
||||
): WorkOrderRow {
|
||||
const workOrderId = normalizeOptionalId(requestedWorkOrderId)
|
||||
if (workOrderId) {
|
||||
const matched = workOrders.find((item) => Number(item.id) === workOrderId)
|
||||
if (matched) return matched
|
||||
throw createHttpError('待完善订单不存在或已更新,请刷新后重试', {
|
||||
statusCode: 404,
|
||||
errorCode: 'collect_work_order_not_found',
|
||||
context: { orderNo, workOrderId },
|
||||
})
|
||||
}
|
||||
if (workOrders.length === 1) {
|
||||
const [singleWorkOrder] = workOrders
|
||||
if (singleWorkOrder) return singleWorkOrder
|
||||
}
|
||||
throw createHttpError('该订单存在多个待完善商品,请逐个提交资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'collect_work_order_required',
|
||||
context: { orderNo, pendingCount: workOrders.length },
|
||||
})
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
mapFinanceRequest,
|
||||
mapWalletLedger,
|
||||
maskWorkerLeaderboardDisplayName,
|
||||
resolveCollectSubmitTargetWorkOrder,
|
||||
resolveAdminMaterialRewardAmount,
|
||||
resolveFreezeDepositAmount,
|
||||
resolveAdminMockWorkOrderInput,
|
||||
@@ -2297,55 +2296,6 @@ test('后台更新资料禁止修改已有参与者的拼单价格', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
|
||||
const workOrder = buildWorkOrderRow()
|
||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
|
||||
assert.equal(selected.id, workOrder.id)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder requires explicit work order id when multiple orders are pending', () => {
|
||||
const workOrders = [
|
||||
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
|
||||
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
|
||||
]
|
||||
|
||||
assert.throws(
|
||||
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, ''),
|
||||
(error: unknown) =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(error as { statusCode?: number }).statusCode === 409 &&
|
||||
(error as { errorCode?: string }).errorCode === 'collect_work_order_required',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder returns the matching work order when workOrderId is provided', () => {
|
||||
const workOrders = [
|
||||
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
|
||||
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
|
||||
]
|
||||
|
||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, '2')
|
||||
assert.equal(selected.id, 2)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder rejects unknown workOrderId', () => {
|
||||
const workOrders = [buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' })]
|
||||
|
||||
assert.throws(
|
||||
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, 999),
|
||||
(error: unknown) =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(error as { statusCode?: number }).statusCode === 404 &&
|
||||
(error as { errorCode?: string }).errorCode === 'collect_work_order_not_found',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('资金流水映射展示关联的平台订单号', () => {
|
||||
const ledger = {
|
||||
id: 1,
|
||||
|
||||
@@ -77,9 +77,3 @@ export {
|
||||
submitWorkerOrderAcceptance,
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
} from './worker-acceptance-service.js'
|
||||
|
||||
export {
|
||||
collectLookupWorkOrder,
|
||||
collectSubmitWorkOrder,
|
||||
resolveCollectSubmitTargetWorkOrder,
|
||||
} from './worker-collect-service.js'
|
||||
|
||||
Reference in New Issue
Block a user