feat(work-order): 新增一次性补资料链接
This commit is contained in:
@@ -9,6 +9,8 @@ export const APP_CONFIG_KEYS = {
|
||||
workerHall: 'worker_hall',
|
||||
workerRiskControl: 'worker_risk_control',
|
||||
workerMockShops: 'worker_mock_shops',
|
||||
/** 接单工单:一次性补资料链接配置(有效期分钟数) */
|
||||
workOrderMaterialLink: 'work_order_material_link',
|
||||
kuaishouIndustrySource: 'kuaishou_industry_source',
|
||||
affiliateDash: 'affiliate_dash',
|
||||
pddSource: 'pdd_source',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 109_work_order_material_link.sql
|
||||
-- 接单工单「一次性补资料链接」。
|
||||
--
|
||||
-- 背景:客服在接单平台给待完善/未分配的工单生成一条客户可访问的链接,
|
||||
-- 客户无需登录即可补充资料字段与买家主页截图;成功提交一次后链接立即失效,
|
||||
-- 工单转为「未分配」,需要修改时必须由客服重新生成链接。
|
||||
--
|
||||
-- 独立于 claim_tokens:claim_tokens 强绑定 fulfillment_tasks,
|
||||
-- 而这里绑定 work_orders,生命周期与补资料动作一致。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_order_material_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT work_order_material_links_status_check
|
||||
CHECK (status IN ('active', 'used', 'expired', 'revoked'))
|
||||
);
|
||||
|
||||
-- 兼容极端情况下先建表但约束未建成功的库(正常新库会走上面的内联约束)。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'work_order_material_links_status_check'
|
||||
) THEN
|
||||
ALTER TABLE work_order_material_links
|
||||
ADD CONSTRAINT work_order_material_links_status_check
|
||||
CHECK (status IN ('active', 'used', 'expired', 'revoked'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_material_links_order
|
||||
ON work_order_material_links (work_order_id, created_at DESC);
|
||||
|
||||
-- 一个工单同时最多一条未使用链接;重新生成前先把旧 active 置为 revoked。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_order_material_links_active
|
||||
ON work_order_material_links (work_order_id)
|
||||
WHERE status = 'active';
|
||||
|
||||
COMMENT ON TABLE work_order_material_links IS
|
||||
'接单工单一次性补资料链接:客户凭 token 提交一次资料后失效';
|
||||
COMMENT ON COLUMN work_order_material_links.token IS
|
||||
'URL 安全随机 token;只在生成/复制链接时返回给后台';
|
||||
COMMENT ON COLUMN work_order_material_links.status IS
|
||||
'active 可用 / used 已提交 / expired 已过期 / revoked 已撤销';
|
||||
COMMENT ON COLUMN work_order_material_links.created_by IS
|
||||
'生成链接的后台操作人展示名,用于审计';
|
||||
@@ -17,6 +17,7 @@ export * from './work-order-deposit-query-repo.js'
|
||||
export * from './work-order-deposit-repo.js'
|
||||
export * from './work-order-event-repo.js'
|
||||
export * from './work-order-share-repo.js'
|
||||
export * from './work-order-material-link-repo.js'
|
||||
export * from './work-category-repo.js'
|
||||
export * from './work-product-rule-repo.js'
|
||||
export * from './worker-order-note-repo.js'
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query } from '../../db/client.js'
|
||||
import type { WorkOrderRow } from './types.js'
|
||||
|
||||
export type WorkOrderMaterialLinkStatus = 'active' | 'used' | 'expired' | 'revoked'
|
||||
|
||||
export type WorkOrderMaterialLinkRow = {
|
||||
id: number
|
||||
work_order_id: number
|
||||
token: string
|
||||
status: WorkOrderMaterialLinkStatus
|
||||
expires_at: string
|
||||
used_at: string | null
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function findWorkOrderMaterialLinkByToken(
|
||||
token: string,
|
||||
): Promise<WorkOrderMaterialLinkRow | null> {
|
||||
const result = await query<WorkOrderMaterialLinkRow>(
|
||||
'SELECT * FROM work_order_material_links WHERE token = $1 LIMIT 1',
|
||||
[String(token || '').trim()],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findActiveWorkOrderMaterialLink(
|
||||
workOrderId: number | string,
|
||||
now: string,
|
||||
): Promise<WorkOrderMaterialLinkRow | null> {
|
||||
const result = await query<WorkOrderMaterialLinkRow>(
|
||||
`SELECT * FROM work_order_material_links
|
||||
WHERE work_order_id = $1 AND status = 'active' AND expires_at > $2
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[Number(workOrderId), now],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkOrderForUpdateWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number | string,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const result = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[Number(workOrderId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkOrderMaterialLinkForUpdateWithClient(
|
||||
client: PoolClient,
|
||||
linkId: number | string,
|
||||
): Promise<WorkOrderMaterialLinkRow | null> {
|
||||
const result = await client.query<WorkOrderMaterialLinkRow>(
|
||||
'SELECT * FROM work_order_material_links WHERE id = $1 FOR UPDATE',
|
||||
[Number(linkId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findActiveWorkOrderMaterialLinkWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number | string,
|
||||
now: string,
|
||||
): Promise<WorkOrderMaterialLinkRow | null> {
|
||||
const result = await client.query<WorkOrderMaterialLinkRow>(
|
||||
`SELECT * FROM work_order_material_links
|
||||
WHERE work_order_id = $1 AND status = 'active' AND expires_at > $2
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[Number(workOrderId), now],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/** 重新生成前清掉旧 active:已过期标 expired,未过期标 revoked。 */
|
||||
export async function revokeActiveWorkOrderMaterialLinksWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number | string,
|
||||
now: string,
|
||||
): Promise<number> {
|
||||
const result = await client.query(
|
||||
`UPDATE work_order_material_links
|
||||
SET status = CASE WHEN expires_at <= $2 THEN 'expired' ELSE 'revoked' END,
|
||||
updated_at = $2
|
||||
WHERE work_order_id = $1 AND status = 'active'`,
|
||||
[Number(workOrderId), now],
|
||||
)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
export async function createWorkOrderMaterialLinkWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workOrderId: number | string
|
||||
token: string
|
||||
expiresAt: string
|
||||
createdBy: string
|
||||
now: string
|
||||
},
|
||||
): Promise<WorkOrderMaterialLinkRow> {
|
||||
const result = await client.query<WorkOrderMaterialLinkRow>(
|
||||
`INSERT INTO work_order_material_links (
|
||||
work_order_id, token, status, expires_at, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, 'active', $3, $4, $5, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
Number(input.workOrderId),
|
||||
input.token,
|
||||
input.expiresAt,
|
||||
String(input.createdBy || '').trim(),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
return result.rows[0]!
|
||||
}
|
||||
|
||||
export async function markWorkOrderMaterialLinkUsedWithClient(
|
||||
client: PoolClient,
|
||||
linkId: number | string,
|
||||
now: string,
|
||||
): Promise<WorkOrderMaterialLinkRow | null> {
|
||||
const result = await client.query<WorkOrderMaterialLinkRow>(
|
||||
`UPDATE work_order_material_links
|
||||
SET status = 'used', used_at = $2, updated_at = $2
|
||||
WHERE id = $1 AND status = 'active'
|
||||
RETURNING *`,
|
||||
[Number(linkId), now],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function markWorkOrderMaterialLinkExpired(
|
||||
linkId: number | string,
|
||||
now: string,
|
||||
): Promise<void> {
|
||||
await query(
|
||||
`UPDATE work_order_material_links
|
||||
SET status = 'expired', updated_at = $2
|
||||
WHERE id = $1 AND status = 'active'`,
|
||||
[Number(linkId), now],
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateWorkOrderMaterialWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workOrderId: number | string
|
||||
status: string
|
||||
materialJson: string
|
||||
now: string
|
||||
},
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const result = await client.query<WorkOrderRow>(
|
||||
`UPDATE work_orders
|
||||
SET status = $2, material_json = $3::jsonb, updated_at = $4
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[Number(input.workOrderId), input.status, input.materialJson, input.now],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAdminWorkerPlatformNotificationConfig,
|
||||
getAdminWorkerPlatformSummary,
|
||||
getAdminWorkerProductMatchConfig,
|
||||
getWorkerMaterialLinkConfig,
|
||||
listAdminKuaishouMatchSources,
|
||||
listAdminWorkCategories,
|
||||
listAdminWorkProductMatchLogs,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
saveAdminWorkerLevel,
|
||||
saveAdminWorkerPlatformNotificationConfig,
|
||||
saveAdminWorkerProductMatchConfig,
|
||||
saveWorkerMaterialLinkConfig,
|
||||
syncAdminWorkProductRulePrice,
|
||||
testAdminKuaishouProductMatch,
|
||||
testAdminProductMatchByOrderItem,
|
||||
@@ -293,6 +295,32 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/material-link-config',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(() => getWorkerMaterialLinkConfig(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取补资料链接配置失败',
|
||||
scope: '[admin/worker-platform/material-link-config]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/material-link-config',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => saveWorkerMaterialLinkConfig(req.body || {}), {
|
||||
successMessage: '补资料链接配置已保存',
|
||||
errorMessage: '保存补资料链接配置失败',
|
||||
scope: '[admin/worker-platform/material-link-config]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'worker_material_link_config_saved',
|
||||
targetType: 'work_order_material_link_config',
|
||||
targetId: 'work_order_material_link',
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/announcement',
|
||||
requireAdminRoles(['admin']),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
completeAdminWorkOrderGiftCooldown,
|
||||
confirmAdminWorkOrderFriendAdded,
|
||||
createAdminMockWorkOrder,
|
||||
createAdminWorkOrderMaterialLink,
|
||||
deleteAdminMockCustomShop,
|
||||
deleteAdminWorkOrder,
|
||||
getAdminWorkOrderEvents,
|
||||
@@ -181,6 +182,36 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/material-link',
|
||||
requireAdminPermission('worker_order.update_material'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
createAdminWorkOrderMaterialLink(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '补资料链接已生成',
|
||||
errorMessage: '生成补资料链接失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/material-link]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_material_link_created',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
// 审计只记录有效期与是否复用,绝不落 token。
|
||||
data:
|
||||
data && typeof data === 'object'
|
||||
? {
|
||||
expiresAt: (data as Record<string, unknown>).expiresAt,
|
||||
reused: (data as Record<string, unknown>).reused,
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.put(
|
||||
'/worker-platform/orders/:workOrderId',
|
||||
requireAdminPermission('worker_order.edit'),
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import {
|
||||
createRateLimitMiddleware,
|
||||
getBodyFieldRateLimitKey,
|
||||
getParamRateLimitKey,
|
||||
} from '../middleware/rate-limit.js'
|
||||
import {
|
||||
collectLookupWorkOrder,
|
||||
collectSubmitWorkOrder,
|
||||
getPublicWorkOrderMaterialLink,
|
||||
getPublicWorkOrderMaterialLinkUploadContext,
|
||||
submitPublicWorkOrderMaterialLink,
|
||||
} from '../services/worker-platform/index.js'
|
||||
import { uploadFileAsset } from '../services/file-storage/file-storage-service.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
@@ -18,6 +25,27 @@ const collectRateLimit = createRateLimitMiddleware({
|
||||
key: getBodyFieldRateLimitKey('orderNo'),
|
||||
})
|
||||
|
||||
const materialLinkReadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:read',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
const materialLinkWriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:write',
|
||||
windowMs: 60_000,
|
||||
max: 20,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
const materialLinkUploadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:upload',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
router.post(
|
||||
'/lookup',
|
||||
collectRateLimit,
|
||||
@@ -59,6 +87,52 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/link/:token',
|
||||
materialLinkReadRateLimit,
|
||||
createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.token || '')), {
|
||||
errorMessage: '读取补资料链接失败',
|
||||
scope: '[collect/link/:token]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/link/:token/submit',
|
||||
materialLinkWriteRateLimit,
|
||||
createRouteHandler(
|
||||
(req) => submitPublicWorkOrderMaterialLink(String(req.params.token || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '资料已提交',
|
||||
errorMessage: '提交补资料失败',
|
||||
scope: '[collect/link/:token/submit]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/link/:token/upload',
|
||||
materialLinkUploadRateLimit,
|
||||
uploadSingleFile,
|
||||
createRouteHandler(
|
||||
async (req) => {
|
||||
const { order } = await getPublicWorkOrderMaterialLinkUploadContext(
|
||||
String(req.params.token || ''),
|
||||
)
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: req.body?.scene || 'collect-material',
|
||||
uploaderType: 'collect',
|
||||
uploaderId: `work-order-${order.id}`,
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[collect/link/:token/upload]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
@@ -63,3 +63,5 @@ export * from './worker-hall-config-service.js'
|
||||
export * from './worker-acceptance-config-service.js'
|
||||
export * from './sync-worker-orders-service.js'
|
||||
export * from './consignment-contact-service.js'
|
||||
export * from './work-order-material-link-service.js'
|
||||
export * from './worker-material-link-config-service.js'
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
resolveWorkOrderMaterialLinkUnavailableReason,
|
||||
pickSubmittedMaterialFields,
|
||||
} from './work-order-material-link-service.js'
|
||||
|
||||
const NOW = '2026-10-31T12:00:00.000Z'
|
||||
|
||||
test('补资料链接:active 且未过期时可用', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'active', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:active 但已过期按过期处理', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason({ status: 'active', expires_at: NOW }, NOW),
|
||||
'expired',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:used / expired / revoked 分别返回对应原因', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'used', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
'used',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'expired', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
'expired',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{ status: 'revoked', expires_at: '2026-10-31T13:00:00.000Z' },
|
||||
NOW,
|
||||
),
|
||||
'revoked',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:未知状态一律视为失效,避免误放行', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
{
|
||||
status: 'unknown' as never,
|
||||
expires_at: '2026-10-31T13:00:00.000Z',
|
||||
},
|
||||
NOW,
|
||||
),
|
||||
'revoked',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
|
||||
const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], {
|
||||
gameId: '123',
|
||||
gameNickname: '张三',
|
||||
rewardAmount: '999',
|
||||
status: 'accepted',
|
||||
})
|
||||
|
||||
assert.deepEqual(result, { gameId: '123', gameNickname: '张三' })
|
||||
})
|
||||
@@ -0,0 +1,397 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEventWithClient,
|
||||
createWorkOrderMaterialLinkWithClient,
|
||||
findWorkOrderMaterialLinkByToken,
|
||||
findActiveWorkOrderMaterialLinkWithClient,
|
||||
getWorkOrderForUpdateWithClient,
|
||||
getWorkOrderMaterialLinkForUpdateWithClient,
|
||||
markWorkOrderMaterialLinkExpired,
|
||||
markWorkOrderMaterialLinkUsedWithClient,
|
||||
revokeActiveWorkOrderMaterialLinksWithClient,
|
||||
updateWorkOrderMaterialWithClient,
|
||||
type WorkOrderMaterialLinkRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import {
|
||||
mapCollectLookupOrder,
|
||||
mapWorkOrderAdmin,
|
||||
normalizeSubmittedFields,
|
||||
normalizeUploadedFiles,
|
||||
resolveRequirementFields,
|
||||
} from './mappers.js'
|
||||
import { getWorkerMaterialLinkConfig } from './worker-material-link-config-service.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
|
||||
const ALLOWED_MATERIAL_LINK_ORDER_STATUSES = new Set<string>([
|
||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
])
|
||||
|
||||
const MAX_MATERIAL_SCREENSHOTS = 5
|
||||
|
||||
export type WorkOrderMaterialLinkUnavailableReason = 'used' | 'expired' | 'revoked' | ''
|
||||
|
||||
/**
|
||||
* 判断链接是否还能用。
|
||||
*
|
||||
* 只做纯函数判断,方便单测覆盖;数据库状态与过期时间都由调用方传入。
|
||||
*/
|
||||
export function resolveWorkOrderMaterialLinkUnavailableReason(
|
||||
link: Pick<WorkOrderMaterialLinkRow, 'status' | 'expires_at'>,
|
||||
now: string,
|
||||
): WorkOrderMaterialLinkUnavailableReason {
|
||||
if (link.status === 'used') return 'used'
|
||||
if (link.status === 'revoked') return 'revoked'
|
||||
if (link.status === 'expired') return 'expired'
|
||||
if (link.status !== 'active') return 'revoked'
|
||||
if (new Date(link.expires_at).getTime() <= new Date(now).getTime()) return 'expired'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 只保留工单 requirement 里定义过的字段。
|
||||
*
|
||||
* 公开链接的提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的
|
||||
* 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。
|
||||
*/
|
||||
export function pickSubmittedMaterialFields(
|
||||
fields: Array<{ key: string }>,
|
||||
submitted: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const allowedKeys = new Set(fields.map((field) => field.key))
|
||||
return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key)))
|
||||
}
|
||||
|
||||
function throwMaterialLinkUnavailable(
|
||||
reason: Exclude<WorkOrderMaterialLinkUnavailableReason, ''>,
|
||||
): never {
|
||||
if (reason === 'used') {
|
||||
throw createHttpError('链接已使用,如需修改资料请联系客服重新获取', {
|
||||
statusCode: 410,
|
||||
errorCode: 'material_link_used',
|
||||
})
|
||||
}
|
||||
if (reason === 'expired') {
|
||||
throw createHttpError('链接已过期,请联系客服重新获取', {
|
||||
statusCode: 410,
|
||||
errorCode: 'material_link_expired',
|
||||
})
|
||||
}
|
||||
throw createHttpError('链接已失效,请联系客服重新获取', {
|
||||
statusCode: 410,
|
||||
errorCode: 'material_link_revoked',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开链接只对「待完善 / 未分配」开放,且不能已经指派打手或有进行中的拼单份额。
|
||||
* 这三个条件同时满足,提交后才可以安全地把状态改成「未分配」。
|
||||
*/
|
||||
function assertWorkOrderAllowedForMaterialLink(order: WorkOrderRow): void {
|
||||
if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) {
|
||||
throw createHttpError('订单当前状态不允许补资料,请联系客服重新获取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
})
|
||||
}
|
||||
if (order.assigned_worker_id) {
|
||||
throw createHttpError('订单已指派打手,不能通过公开链接修改资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
})
|
||||
}
|
||||
if (Number(order.active_share_quantity || 0) > 0) {
|
||||
throw createHttpError('订单已有拼单份额,不能通过公开链接修改资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_order_status_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function assertMaterialLinkRecordUsable(link: WorkOrderMaterialLinkRow, now: string): void {
|
||||
const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now)
|
||||
if (reason) throwMaterialLinkUnavailable(reason)
|
||||
}
|
||||
|
||||
function mapPublicOrder(order: WorkOrderRow) {
|
||||
return {
|
||||
workOrderNo: order.work_order_no,
|
||||
platformOrderId: order.platform_order_id,
|
||||
productName: order.product_name,
|
||||
status: order.status,
|
||||
}
|
||||
}
|
||||
|
||||
/** PostgreSQL 返回的 timestamptz 可能是 "YYYY-MM-DD HH:mm:ss" 字符串,统一转 ISO。 */
|
||||
function toIsoTimestamp(value: string): string {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? String(value || '') : date.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端生成/复用一次性链接。
|
||||
*
|
||||
* 幂等策略:同一工单已有未过期 active 链接时直接返回,避免客服连续点两次
|
||||
* 把刚发出去的链接顶掉;只有已使用/已过期/已撤销时才生成新 token。
|
||||
*/
|
||||
export async function createAdminWorkOrderMaterialLink(
|
||||
workOrderId: number | string,
|
||||
actorName = '',
|
||||
) {
|
||||
const now = nowIso()
|
||||
const { ttlMinutes } = getWorkerMaterialLinkConfig()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const order = await getWorkOrderForUpdateWithClient(client, workOrderId)
|
||||
if (!order) {
|
||||
throw createHttpError('接单订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (!mapWorkOrderAdmin(order).capabilities.canUpdateMaterial) {
|
||||
throw createHttpError('当前状态不能生成补资料链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'material_link_status_invalid',
|
||||
})
|
||||
}
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
|
||||
const existing = await findActiveWorkOrderMaterialLinkWithClient(client, order.id, now)
|
||||
if (existing) {
|
||||
return {
|
||||
token: existing.token,
|
||||
expiresAt: toIsoTimestamp(existing.expires_at),
|
||||
reused: true,
|
||||
}
|
||||
}
|
||||
|
||||
await revokeActiveWorkOrderMaterialLinksWithClient(client, order.id, now)
|
||||
const token = randomBytes(24).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString()
|
||||
const link = await createWorkOrderMaterialLinkWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
token,
|
||||
expiresAt,
|
||||
createdBy: String(actorName || '').trim(),
|
||||
now,
|
||||
})
|
||||
|
||||
return {
|
||||
token: link.token,
|
||||
expiresAt: toIsoTimestamp(link.expires_at),
|
||||
reused: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页读取链接内容。
|
||||
*
|
||||
* 只返回客户补资料需要的最少信息:商品/单号、动态字段、已填值、截图、过期时间。
|
||||
* 不返回发单价格、押金、成本、打手、后台备注等任何后台字段。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLink(token: string) {
|
||||
const link = await findWorkOrderMaterialLinkByToken(token)
|
||||
if (!link) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now)
|
||||
if (reason) {
|
||||
if (reason === 'expired') {
|
||||
await markWorkOrderMaterialLinkExpired(link.id, now)
|
||||
}
|
||||
throwMaterialLinkUnavailable(reason)
|
||||
}
|
||||
|
||||
const order = await getRequiredWorkOrder(link.work_order_id)
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
|
||||
const mapped = mapCollectLookupOrder(order)
|
||||
return {
|
||||
order: {
|
||||
workOrderNo: mapped.workOrderNo,
|
||||
platformOrderId: mapped.platformOrderId,
|
||||
productName: mapped.productName,
|
||||
status: mapped.status,
|
||||
},
|
||||
fields: mapped.fields,
|
||||
values: mapped.values,
|
||||
screenshots: mapped.screenshots,
|
||||
expiresAt: toIsoTimestamp(link.expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页提交资料。
|
||||
*
|
||||
* 一次性语义:只有「完整提交」成功时才把 token 标为 used;
|
||||
* 少填必填项、订单状态变化、网络失败等都会在事务里回滚,链接继续可用。
|
||||
*/
|
||||
export async function submitPublicWorkOrderMaterialLink(token: string, payload: JsonObject = {}) {
|
||||
const preliminary = await findWorkOrderMaterialLinkByToken(token)
|
||||
if (!preliminary) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const result = await withTransaction(async (client) => {
|
||||
// 先锁工单再锁链接,和后台生成链接保持同样的加锁顺序,避免死锁。
|
||||
const order = await getWorkOrderForUpdateWithClient(client, preliminary.work_order_id)
|
||||
if (!order) {
|
||||
throw createHttpError('接单订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_not_found',
|
||||
})
|
||||
}
|
||||
const link = await getWorkOrderMaterialLinkForUpdateWithClient(client, preliminary.id)
|
||||
if (!link) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
assertMaterialLinkRecordUsable(link, now)
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
|
||||
const fields = resolveRequirementFields(order)
|
||||
const submittedFields = pickSubmittedMaterialFields(
|
||||
fields,
|
||||
normalizeSubmittedFields(payload.fields || {}),
|
||||
)
|
||||
const missing = fields.filter(
|
||||
(field) => field.required && !String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
if (missing.length > 0) {
|
||||
throw createHttpError(
|
||||
`请填写完整必填资料:${missing.map((field) => field.label).join('、')}`,
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'material_link_fields_incomplete',
|
||||
context: { missingFields: missing.map((field) => field.key) },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || [])
|
||||
if (screenshots.length > MAX_MATERIAL_SCREENSHOTS) {
|
||||
throw createHttpError(`买家主页截图最多 ${MAX_MATERIAL_SCREENSHOTS} 张`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'material_link_screenshots_too_many',
|
||||
})
|
||||
}
|
||||
|
||||
const material = {
|
||||
...safeParseJson(order.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete: true,
|
||||
source: 'customer_link',
|
||||
},
|
||||
}
|
||||
|
||||
const updated = await updateWorkOrderMaterialWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
status: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
materialJson: JSON.stringify(material),
|
||||
now,
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('更新订单资料失败,请稍后重试', {
|
||||
statusCode: 500,
|
||||
errorCode: 'material_link_update_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const used = await markWorkOrderMaterialLinkUsedWithClient(client, link.id, now)
|
||||
if (!used) {
|
||||
// 并发提交时另一个请求已经把链接用掉,当前事务整体回滚。
|
||||
throw createHttpError('链接已使用,如需修改资料请联系客服重新获取', {
|
||||
statusCode: 410,
|
||||
errorCode: 'material_link_used',
|
||||
})
|
||||
}
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: order.id,
|
||||
actorType: 'collect',
|
||||
actorId: '客户补资料链接',
|
||||
eventType: 'material_completed',
|
||||
fromStatus: order.status,
|
||||
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
|
||||
payloadJson: JSON.stringify({
|
||||
fields: submittedFields,
|
||||
complete: true,
|
||||
source: 'customer_link',
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
return { order: updated, link: used }
|
||||
})
|
||||
|
||||
// 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。
|
||||
try {
|
||||
await resolveAdminNotificationEntity(
|
||||
'work_order',
|
||||
Number(result.order.id),
|
||||
'material_completed',
|
||||
)
|
||||
} catch (error) {
|
||||
logWarn('[work-order-material-link]', '补资料完成通知处理失败', {
|
||||
workOrderId: result.order.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
|
||||
|
||||
return {
|
||||
complete: true,
|
||||
order: mapPublicOrder(result.order),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前校验 token 与工单状态;返回工单 id 供文件归属使用。
|
||||
* 注意:这里不消耗 token,只有最终提交才消耗。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLinkUploadContext(token: string) {
|
||||
const link = await findWorkOrderMaterialLinkByToken(token)
|
||||
if (!link) {
|
||||
throw createHttpError('链接无效,请确认链接是否完整', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
assertMaterialLinkRecordUsable(link, now)
|
||||
|
||||
const order = await getRequiredWorkOrder(link.work_order_id)
|
||||
assertWorkOrderAllowedForMaterialLink(order)
|
||||
|
||||
return { link, order, now }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
createDefaultWorkerMaterialLinkConfig,
|
||||
normalizeWorkerMaterialLinkConfig,
|
||||
WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES,
|
||||
WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES,
|
||||
WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES,
|
||||
} from './worker-material-link-config-service.js'
|
||||
|
||||
test('补资料链接配置:默认 30 分钟', () => {
|
||||
assert.deepEqual(createDefaultWorkerMaterialLinkConfig(), {
|
||||
ttlMinutes: WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES,
|
||||
})
|
||||
assert.equal(WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES, 30)
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig(undefined), { ttlMinutes: 30 })
|
||||
})
|
||||
|
||||
test('补资料链接配置:接受 ttlMinutes / ttl_minutes / 数字字符串', () => {
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 45 }), { ttlMinutes: 45 })
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttl_minutes: '10' }), { ttlMinutes: 10 })
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 90.4 }), { ttlMinutes: 90 })
|
||||
})
|
||||
|
||||
test('补资料链接配置:越界值收敛到 1 - 10080 分钟', () => {
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 0 }), {
|
||||
ttlMinutes: WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES,
|
||||
})
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: -5 }), { ttlMinutes: 1 })
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 999999 }), {
|
||||
ttlMinutes: WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES,
|
||||
})
|
||||
assert.equal(WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES, 10080)
|
||||
})
|
||||
|
||||
test('补资料链接配置:非法输入回退默认值,不会写入 NaN', () => {
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: 'abc' }), { ttlMinutes: 30 })
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig({ ttlMinutes: null }), { ttlMinutes: 30 })
|
||||
assert.deepEqual(normalizeWorkerMaterialLinkConfig('not-an-object'), { ttlMinutes: 30 })
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
|
||||
|
||||
/** 补资料链接默认有效期(分钟):30 分钟。 */
|
||||
export const WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES = 30
|
||||
/** 允许配置的最短有效期(分钟)。 */
|
||||
export const WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES = 1
|
||||
/** 允许配置的最长有效期(分钟):7 天。 */
|
||||
export const WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES = 7 * 24 * 60
|
||||
|
||||
export type WorkerMaterialLinkConfig = {
|
||||
/** 新生成的补资料链接有效期,单位分钟。 */
|
||||
ttlMinutes: number
|
||||
}
|
||||
|
||||
/** 读取配置(内存缓存),未配置时返回默认 30 分钟。 */
|
||||
export function getWorkerMaterialLinkConfig(): WorkerMaterialLinkConfig {
|
||||
return readAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.workOrderMaterialLink,
|
||||
fallback: createDefaultWorkerMaterialLinkConfig,
|
||||
normalize: normalizeWorkerMaterialLinkConfig,
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveWorkerMaterialLinkConfig(
|
||||
rawValue: unknown,
|
||||
): Promise<WorkerMaterialLinkConfig> {
|
||||
return saveAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.workOrderMaterialLink,
|
||||
value: rawValue,
|
||||
normalize: normalizeWorkerMaterialLinkConfig,
|
||||
})
|
||||
}
|
||||
|
||||
export function createDefaultWorkerMaterialLinkConfig(): WorkerMaterialLinkConfig {
|
||||
return { ttlMinutes: WORK_ORDER_MATERIAL_LINK_DEFAULT_TTL_MINUTES }
|
||||
}
|
||||
|
||||
export function normalizeWorkerMaterialLinkConfig(rawValue: unknown): WorkerMaterialLinkConfig {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const raw = source.ttlMinutes ?? source.ttl_minutes
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return createDefaultWorkerMaterialLinkConfig()
|
||||
}
|
||||
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) {
|
||||
return createDefaultWorkerMaterialLinkConfig()
|
||||
}
|
||||
|
||||
return {
|
||||
ttlMinutes: Math.min(
|
||||
WORK_ORDER_MATERIAL_LINK_MAX_TTL_MINUTES,
|
||||
Math.max(WORK_ORDER_MATERIAL_LINK_MIN_TTL_MINUTES, Math.round(value)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
Reference in New Issue
Block a user