Merge branch 'dev'

This commit is contained in:
2026-11-02 12:04:32 +08:00
38 changed files with 1615 additions and 942 deletions
@@ -9,8 +9,6 @@ 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,12 @@
-- 113_drop_work_order_material_links.sql —— 下线旧的「一次性补资料链接 token」体系
--
-- 背景:补资料页已改为按平台订单号直连(/supplement/:订单号),
-- 不再需要客服先生成 token 链接:
-- - 后端:管理端 /orders/:id/material-link、链接仓储读写、material-link-config 配置接口
-- - 前端:补资料链接有效期设置面板、生成链接请求
-- - 数据:work_order_material_links 表、app_config_entries.work_order_material_link
-- 历史 token 记录已无任何读取入口,直接随表删除。
DROP TABLE IF EXISTS work_order_material_links;
DELETE FROM app_config_entries WHERE config_key = 'work_order_material_link';
+95 -4
View File
@@ -2,7 +2,11 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import type { NextFunction, Request, Response } from 'express'
import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js'
import {
createFailureCooldownMiddleware,
createRateLimitMiddleware,
resetRateLimitBucketsForTest,
} from './rate-limit.js'
test('createRateLimitMiddleware allows requests within the window', () => {
resetRateLimitBucketsForTest()
@@ -96,13 +100,13 @@ test('createRateLimitMiddleware can cap an IP across different accounts', () =>
assert.equal(response.statusCode, 429)
})
function createMockRequest(): Request {
function createMockRequest(ip = '127.0.0.1'): Request {
return {
ip: '127.0.0.1',
ip,
originalUrl: '/test',
headers: {},
socket: {
remoteAddress: '127.0.0.1',
remoteAddress: ip,
},
} as Request
}
@@ -137,10 +141,97 @@ function createMockResponse(): Response & {
}
}
type MockResponseWithEvents = Response & {
statusCode: number
headers: Record<string, string>
body: any
emit: (event: string) => void
}
function createMockResponseWithFinish(): MockResponseWithEvents {
const listeners: Record<string, Array<() => void>> = {}
const res = createMockResponse() as MockResponseWithEvents
res.on = (event, handler) => {
const handlers = listeners[event] || []
handlers.push(handler)
listeners[event] = handlers
return res
}
res.emit = (event: string) => {
for (const handler of listeners[event] || []) handler()
}
return res
}
test('failure cooldown 只对 404 生效,按 IP 隔离且到期恢复', async () => {
resetRateLimitBucketsForTest()
const limiter = createFailureCooldownMiddleware({
scope: 'test:failure',
cooldownMs: 60,
})
const req = createMockRequest('10.0.0.1')
const first = createMockResponseWithFinish()
let nextCount = 0
limiter(req, first, () => {
nextCount += 1
})
first.status(404).json({ code: 1 })
first.emit('finish')
const blocked = createMockResponseWithFinish()
limiter(req, blocked, () => {
nextCount += 1
})
assert.equal(nextCount, 1)
assert.equal(blocked.statusCode, 429)
assert.equal(blocked.headers['Retry-After'], '1')
assert.equal(blocked.body.errorCode, 'rate_limited')
const otherIp = createMockResponseWithFinish()
limiter(createMockRequest('10.0.0.2'), otherIp, () => {
nextCount += 1
})
assert.equal(nextCount, 2)
assert.equal(otherIp.statusCode, 200)
await new Promise((resolve) => setTimeout(resolve, 80))
const recovered = createMockResponseWithFinish()
limiter(req, recovered, () => {
nextCount += 1
})
assert.equal(nextCount, 3)
assert.equal(recovered.statusCode, 200)
})
test('failure cooldown 不记录成功查询', () => {
resetRateLimitBucketsForTest()
const limiter = createFailureCooldownMiddleware({
scope: 'test:failure-success',
cooldownMs: 60_000,
})
const req = createMockRequest('10.0.0.3')
const first = createMockResponseWithFinish()
limiter(req, first, () => {})
first.status(200).json({ code: 0 })
first.emit('finish')
const second = createMockResponseWithFinish()
let called = false
limiter(req, second, () => {
called = true
})
assert.equal(called, true)
assert.equal(second.statusCode, 200)
})
test('限流桶有数量硬上限:key 含用户可控字段时不能无限膨胀', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(new URL('./rate-limit.ts', import.meta.url), 'utf8')
// collect 路由的限流 key 里带请求体 orderNo(可被构造),只按过期清理挡不住高基数灌键
assert.ok(src.includes('MAX_RATE_LIMIT_BUCKETS'), '缺少限流桶数量上限')
assert.ok(src.includes('buckets.size > MAX_RATE_LIMIT_BUCKETS'), '必须在清理时按上限淘汰最老的桶')
assert.ok(src.includes('MAX_FAILURE_COOLDOWN_KEYS'), '缺少失败冷却 key 数量上限')
})
+81
View File
@@ -18,6 +18,14 @@ type RateLimitOptions = {
onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void
}
type FailureCooldownOptions = {
scope: string
cooldownMs: number
/** 只对这些响应状态码触发冷却,默认 404。 */
statusCodes?: number[]
key?: RateLimitKeyResolver
}
type RateLimitBucket = {
count: number
resetAt: number
@@ -28,6 +36,11 @@ let lastCleanupAt = 0
/** 限流桶数量硬上限:key 含请求体字段时可被构造成高基数,必须兜底防止内存无限增长。 */
const MAX_RATE_LIMIT_BUCKETS = 50_000
/** 失败冷却:仅记录指定状态码(默认 404)后的 IP,冷却期内直接拒绝。 */
const failureCooldowns = new Map<string, number>()
let lastFailureCleanupAt = 0
const MAX_FAILURE_COOLDOWN_KEYS = 50_000
export function createRateLimitMiddleware({
scope,
windowMs,
@@ -82,9 +95,54 @@ export function createRateLimitMiddleware({
}
}
export function createFailureCooldownMiddleware({
scope,
cooldownMs,
statusCodes = [404],
key = defaultRateLimitKey,
}: FailureCooldownOptions) {
const normalizedScope = String(scope || 'failure-cooldown').trim() || 'failure-cooldown'
const normalizedCooldownMs = Math.max(0, Number(cooldownMs || 0))
const trackedStatusCodes = new Set(statusCodes.map((statusCode) => Number(statusCode)))
return (req: Request, res: Response, next: NextFunction): void => {
const now = Date.now()
cleanupExpiredFailureCooldowns(now)
const bucketKey = `${normalizedScope}:${key(req)}`
const cooldownUntil = failureCooldowns.get(bucketKey) || 0
if (cooldownUntil > now) {
const retryAfterSeconds = Math.max(1, Math.ceil((cooldownUntil - now) / 1000))
res.setHeader('Retry-After', String(retryAfterSeconds))
const message = '查询过于频繁,请稍后再试'
const error = createHttpError(message, {
statusCode: 429,
errorCode: 'rate_limited',
})
res.status(429).json(buildErrorPayload(error, message))
return
}
// 响应结束后再判断:只有失败(默认 404)才进入冷却,正常查询不计数。
res.on('finish', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
failureCooldowns.delete(bucketKey)
return
}
if (!trackedStatusCodes.has(Number(res.statusCode))) return
failureCooldowns.set(bucketKey, Date.now() + normalizedCooldownMs)
})
next()
}
}
export function resetRateLimitBucketsForTest(): void {
buckets.clear()
failureCooldowns.clear()
lastCleanupAt = 0
lastFailureCleanupAt = 0
}
export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver {
@@ -111,6 +169,29 @@ function normalizeKeyPart(value: unknown): string {
return normalized || 'unknown'
}
function cleanupExpiredFailureCooldowns(now: number): void {
if (now - lastFailureCleanupAt < 60_000) {
return
}
lastFailureCleanupAt = now
for (const [key, cooldownUntil] of failureCooldowns.entries()) {
if (cooldownUntil <= now) {
failureCooldowns.delete(key)
}
}
if (failureCooldowns.size > MAX_FAILURE_COOLDOWN_KEYS) {
const overflow = failureCooldowns.size - MAX_FAILURE_COOLDOWN_KEYS
let removed = 0
for (const key of failureCooldowns.keys()) {
failureCooldowns.delete(key)
removed += 1
if (removed >= overflow) break
}
}
}
function cleanupExpiredBuckets(now: number): void {
if (now - lastCleanupAt < 60_000) {
return
@@ -537,6 +537,8 @@ export type WorkOrderCapabilities = {
canPin: boolean
canUnpublish: boolean
canUpdateMaterial: boolean
/** 待分配且未指派、无份额、未发布时,客服可以把补资料状态重置回待完善。 */
canResetMaterial: boolean
canAccept: boolean
canMarkProblem: boolean
canDelete: boolean
@@ -143,6 +143,7 @@ export async function assignWorkOrderToWorker(input: {
order: WorkOrderRow | null
failureReason:
| 'work_order_not_assignable'
| 'work_order_has_shares'
| 'worker_active_order_limit'
| 'worker_deposit_insufficient'
| null
@@ -167,6 +168,19 @@ export async function assignWorkOrderToWorker(input: {
}
const fromStatus = workOrder.status
// 已有拼单份额的订单不能按整单指派:否则会出现“整单接单人 + 其他人的份额”混在一起。
const shareCountResult = await client.query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM work_order_shares
WHERE work_order_id = $1 AND status <> 'cancelled'
`,
[input.workOrderId],
)
if (Number(shareCountResult.rows[0]?.total || 0) > 0) {
return { order: null, failureReason: 'work_order_has_shares' }
}
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, input.workerId)
const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId)
@@ -193,6 +207,9 @@ export async function assignWorkOrderToWorker(input: {
friend_added_at = NULL,
gift_available_at = NULL,
${CLEAR_DELIVERY_RESIDUE_SQL},
-- 管理端/寄卖履约都是整单指派,不会创建 work_order_shares;
-- 必须同时清掉拼单标记,避免出现“已开拼单但没有份额”的脏状态。
sharing_enabled = false,
updated_at = $2
WHERE id = $4
`,
@@ -1,152 +1,32 @@
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
}
/**
* 锁工单并回读「有效拼单份额」。
*
* `SELECT * FROM work_orders` 本身没有聚合字段,直接看 `active_share_quantity`
* 会永远是 0,导致补资料绕过「已有拼单份额不允许改资料」的守卫。
*/
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',
`SELECT wo.*,
COALESCE((
SELECT SUM(wos.quantity)
FROM work_order_shares wos
WHERE wos.work_order_id = wo.id AND wos.status != 'cancelled'
), 0)::int AS active_share_quantity
FROM work_orders wo
WHERE wo.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' AND expires_at > $2
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: {
@@ -165,3 +45,32 @@ export async function updateWorkOrderMaterialWithClient(
)
return result.rows[0] || null
}
/**
* 客服重置补资料状态:只允许「待分配、未指派、未发布」的工单回到待完善。
*
* 服务层已经在事务里锁工单并校验过拼单份额;这里的 WHERE 条件再兜一层,
* 防止并发或调用方漏校验。
*/
export async function resetWorkOrderMaterialStatusWithClient(
client: PoolClient,
input: {
workOrderId: number | string
fromStatus: string
toStatus: string
now: string
},
): Promise<WorkOrderRow | null> {
const result = await client.query<WorkOrderRow>(
`UPDATE work_orders
SET status = $2, updated_at = $3
WHERE id = $1
AND status = $4
AND assigned_worker_id IS NULL
AND published_at IS NULL
AND hall_queued_at IS NULL
RETURNING *`,
[Number(input.workOrderId), input.toStatus, input.now, input.fromStatus],
)
return result.rows[0] || null
}
@@ -13,7 +13,6 @@ import {
getAdminWorkerPlatformNotificationConfig,
getAdminWorkerPlatformSummary,
getAdminWorkerProductMatchConfig,
getWorkerMaterialLinkConfig,
listAdminKuaishouMatchSources,
listAdminWorkCategories,
listAdminWorkProductMatchLogs,
@@ -30,7 +29,6 @@ import {
saveAdminWorkerLevel,
saveAdminWorkerPlatformNotificationConfig,
saveAdminWorkerProductMatchConfig,
saveWorkerMaterialLinkConfig,
syncAdminWorkProductRulePrice,
testAdminKuaishouProductMatch,
testAdminProductMatchByOrderItem,
@@ -295,32 +293,6 @@ 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,7 +9,6 @@ import {
completeAdminWorkOrderGiftCooldown,
confirmAdminWorkOrderFriendAdded,
createAdminMockWorkOrder,
createAdminWorkOrderMaterialLink,
deleteAdminMockCustomShop,
deleteAdminWorkOrder,
getAdminWorkOrderEvents,
@@ -23,6 +22,7 @@ import {
reopenAdminCancelledWorkOrder,
reprocessAdminKuaishouSendCodeWorkOrders,
resetAdminWorkOrderGiftMaterial,
resetAdminWorkOrderMaterialStatus,
submitAdminWorkOrderMaterial,
unassignAdminWorkOrder,
unpublishAdminWorkOrder,
@@ -187,30 +187,23 @@ router.post(
)
router.post(
'/worker-platform/orders/:workOrderId/material-link',
'/worker-platform/orders/:workOrderId/reset-material',
requireAdminPermission('worker_order.update_material'),
createJsonHandler(
(req) =>
createAdminWorkOrderMaterialLink(
resetAdminWorkOrderMaterialStatus(
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',
successMessage: '补资料状态已重置为待完善',
errorMessage: '重置补资料状态失败',
scope: '[admin/worker-platform/orders/:workOrderId/reset-material]',
audit: (req) => ({
action: 'work_order_material_reset',
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,
}
: {},
data: {},
}),
},
),
+43 -12
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import {
createFailureCooldownMiddleware,
createRateLimitMiddleware,
getBodyFieldRateLimitKey,
getParamRateLimitKey,
@@ -29,21 +30,46 @@ const materialLinkReadRateLimit = createRateLimitMiddleware({
scope: 'collect:link:read',
windowMs: 60_000,
max: 120,
key: getParamRateLimitKey('token'),
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('token'),
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('token'),
key: getParamRateLimitKey('code'),
})
const materialLinkUploadIpRateLimit = createRateLimitMiddleware({
scope: 'collect:link:upload:ip',
windowMs: 60_000,
max: 60,
})
router.post(
@@ -88,35 +114,40 @@ router.post(
)
router.get(
'/link/:token',
'/link/:code',
materialLinkReadFailureCooldown,
materialLinkReadIpRateLimit,
materialLinkReadRateLimit,
createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.token || '')), {
createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), {
errorMessage: '读取补资料链接失败',
scope: '[collect/link/:token]',
scope: '[collect/link/:code]',
}),
)
router.post(
'/link/:token/submit',
'/link/:code/submit',
materialLinkWriteIpRateLimit,
materialLinkWriteRateLimit,
createRouteHandler(
(req) => submitPublicWorkOrderMaterialLink(String(req.params.token || ''), req.body || {}),
(req) => submitPublicWorkOrderMaterialLink(String(req.params.code || ''), req.body || {}),
{
successMessage: '资料已提交',
errorMessage: '提交补资料失败',
scope: '[collect/link/:token/submit]',
scope: '[collect/link/:code/submit]',
},
),
)
router.post(
'/link/:token/upload',
'/link/:code/upload',
materialLinkUploadIpRateLimit,
materialLinkUploadRateLimit,
uploadSingleFile,
createRouteHandler(
async (req) => {
const { order } = await getPublicWorkOrderMaterialLinkUploadContext(
String(req.params.token || ''),
String(req.params.code || ''),
req.query.workOrderId,
)
return uploadFileAsset({
file: req.file,
@@ -128,7 +159,7 @@ router.post(
{
successMessage: '文件已上传',
errorMessage: '文件上传失败',
scope: '[collect/link/:token/upload]',
scope: '[collect/link/:code/upload]',
},
),
)
@@ -4,6 +4,7 @@ import {
countWorkerActiveOrders,
createWorkOrderEvent,
getWorkerUserById,
listWorkOrderShares,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
@@ -64,6 +65,18 @@ export async function assignAdminWorkOrderToWorker(
})
}
// 已有拼单份额的订单不能再按整单指派;放在核销电子凭证之前,
// 避免“凭证已核销但指派失败”的副作用。
const activeShares = (await listWorkOrderShares(workOrder.id)).filter(
(share) => share.status !== 'cancelled',
)
if (activeShares.length > 0) {
throw createHttpError('该订单已有拼单份额,不能按整单指派;请使用拼单管理', {
statusCode: 409,
errorCode: 'work_order_has_shares',
})
}
const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderAssign(workOrder)
if (!voucherConsume.ok) {
const failedAt = nowIso()
@@ -112,6 +125,15 @@ export async function assignAdminWorkOrderToWorker(
{ statusCode: 409, errorCode: 'worker_deposit_insufficient' },
)
}
if (failureReason === 'work_order_has_shares') {
throw createHttpError(
resolveAssignFailureMessage(
voucherConsume.voucherCount,
'该订单已有拼单份额,不能按整单指派;请使用拼单管理',
),
{ statusCode: 409, errorCode: 'work_order_has_shares' },
)
}
if (!updated) {
throw createHttpError(
resolveAssignFailureMessage(voucherConsume.voucherCount, '指派失败,订单状态可能已变化'),
@@ -0,0 +1,74 @@
import { withTransaction } from '../../db/client.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
createWorkOrderEventWithClient,
getWorkOrderForUpdateWithClient,
resetWorkOrderMaterialStatusWithClient,
} from '../../repositories/worker-platform/index.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import { mapWorkOrderAdmin } from './mappers.js'
import {
resolveMaterialResetBlockMessage,
resolveMaterialResetBlockReason,
} from './work-order-material-link-service.js'
/**
* 客服重置补资料状态:把「待分配」工单改回「待完善」,让客户可以凭订单号重新提交。
*
* 只允许未指派、无拼单份额、未发布的工单;原资料保留,客户刷新补资料页后
* 会看到之前填过的内容,可以在此基础上修改。
*/
export async function resetAdminWorkOrderMaterialStatus(
workOrderId: number | string,
actorName = '',
) {
const result = await withTransaction(async (client) => {
const order = await getWorkOrderForUpdateWithClient(client, workOrderId)
if (!order) {
throw createHttpError('接单订单不存在', {
statusCode: 404,
errorCode: 'work_order_not_found',
})
}
const blockReason = resolveMaterialResetBlockReason(order)
if (blockReason) {
throw createHttpError(resolveMaterialResetBlockMessage(blockReason), {
statusCode: 409,
errorCode: 'work_order_material_reset_invalid',
})
}
const now = nowIso()
const updated = await resetWorkOrderMaterialStatusWithClient(client, {
workOrderId: order.id,
fromStatus: WORK_ORDER_STATUS.UNASSIGNED,
toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL,
now,
})
if (!updated) {
throw createHttpError('重置补资料状态失败,订单可能已变化', {
statusCode: 409,
errorCode: 'work_order_material_reset_failed',
})
}
await createWorkOrderEventWithClient(client, {
workOrderId: order.id,
actorType: 'admin',
actorId: String(actorName || '').trim(),
eventType: 'material_reset',
fromStatus: WORK_ORDER_STATUS.UNASSIGNED,
toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL,
payloadJson: JSON.stringify({ source: 'admin_reset' }),
now,
})
return { order: updated }
})
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
return { order: mapWorkOrderAdmin(result.order) }
}
@@ -37,6 +37,7 @@ export * from './admin-worker-cancel-service.js'
export * from './admin-work-order-sharing-service.js'
export * from './admin-work-order-realtime-service.js'
export * from './admin-work-order-material-service.js'
export * from './admin-work-order-material-reset-service.js'
export * from './admin-work-order-gift-service.js'
export * from './admin-mock-work-order-service.js'
export * from './admin-mock-custom-shop-service.js'
@@ -64,4 +65,3 @@ 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'
@@ -290,6 +290,12 @@ export function mapWorkOrderAdmin(
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
workOrder.status === WORK_ORDER_STATUS.OPEN ||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS,
canResetMaterial:
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED &&
!workOrder.assigned_worker_id &&
!hasActiveShares &&
!workOrder.published_at &&
!workOrder.hall_queued_at,
canAccept:
status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE ||
(isFriendGift &&
@@ -2,67 +2,151 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveWorkOrderMaterialLinkUnavailableReason,
formatPublicMaterialStatus,
pickPendingMaterialOrders,
pickSubmittedMaterialFields,
resolveMaterialResetBlockReason,
resolvePublicMaterialOrderState,
} from './work-order-material-link-service.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
const NOW = '2026-10-31T12:00:00.000Z'
test('公开补资料:只有待完善且未指派/无份额可提交', () => {
const editable = resolvePublicMaterialOrderState({
status: 'pending_material',
assigned_worker_id: null,
active_share_quantity: 0,
})
assert.equal(editable.canSubmit, true)
assert.equal(editable.blockedReason, '')
assert.equal(editable.statusLabel, '待完善')
test('补资料链接:active 且未过期时可用', () => {
const alreadySubmitted = resolvePublicMaterialOrderState({
status: 'unassigned',
assigned_worker_id: null,
active_share_quantity: 0,
})
assert.equal(alreadySubmitted.canSubmit, false)
assert.equal(alreadySubmitted.blockedReason, 'already_submitted')
assert.equal(alreadySubmitted.statusLabel, '待分配')
assert.match(alreadySubmitted.message, /联系客服/)
const assigned = resolvePublicMaterialOrderState({
status: 'pending_material',
assigned_worker_id: 9,
active_share_quantity: 0,
})
assert.equal(assigned.canSubmit, false)
assert.equal(assigned.blockedReason, 'assigned')
const shares = resolvePublicMaterialOrderState({
status: 'pending_material',
assigned_worker_id: null,
active_share_quantity: 2,
})
assert.equal(shares.canSubmit, false)
assert.equal(shares.blockedReason, 'shares')
const open = resolvePublicMaterialOrderState({
status: 'open',
assigned_worker_id: null,
active_share_quantity: 0,
})
assert.equal(open.canSubmit, false)
assert.equal(open.blockedReason, 'status')
assert.equal(open.statusLabel, '待抢单')
assert.match(open.message, /联系客服/)
})
test('公开补资料:状态文案覆盖待分配与后续流程', () => {
assert.equal(formatPublicMaterialStatus('pending_material'), '待完善')
assert.equal(formatPublicMaterialStatus('unassigned'), '待分配')
assert.equal(formatPublicMaterialStatus('in_progress'), '代练中')
assert.equal(formatPublicMaterialStatus('pending_acceptance'), '待验收')
assert.equal(formatPublicMaterialStatus('accepted'), '已验收')
assert.equal(formatPublicMaterialStatus('cancelled'), '已取消')
assert.equal(formatPublicMaterialStatus('unknown_status'), 'unknown_status')
})
test('客服重置补资料:只允许待分配、未指派、无份额、未发布', () => {
assert.equal(
resolveWorkOrderMaterialLinkUnavailableReason(
{ status: 'active', expires_at: '2026-10-31T13:00:00.000Z' },
NOW,
),
resolveMaterialResetBlockReason({
status: 'unassigned',
assigned_worker_id: null,
active_share_quantity: 0,
published_at: null,
hall_queued_at: null,
}),
'',
)
})
test('补资料链接:active 但已过期按过期处理', () => {
assert.equal(
resolveWorkOrderMaterialLinkUnavailableReason({ status: 'active', expires_at: NOW }, NOW),
'expired',
resolveMaterialResetBlockReason({
status: 'pending_material',
assigned_worker_id: null,
active_share_quantity: 0,
published_at: null,
hall_queued_at: null,
}),
'status',
)
assert.equal(
resolveMaterialResetBlockReason({
status: 'unassigned',
assigned_worker_id: 9,
active_share_quantity: 0,
published_at: null,
hall_queued_at: null,
}),
'assigned',
)
assert.equal(
resolveMaterialResetBlockReason({
status: 'unassigned',
assigned_worker_id: null,
active_share_quantity: 2,
published_at: null,
hall_queued_at: null,
}),
'shares',
)
assert.equal(
resolveMaterialResetBlockReason({
status: 'unassigned',
assigned_worker_id: null,
active_share_quantity: 0,
published_at: '2026-11-01T00:00:00.000Z',
hall_queued_at: null,
}),
'published',
)
assert.equal(
resolveMaterialResetBlockReason({
status: 'unassigned',
assigned_worker_id: null,
active_share_quantity: 0,
published_at: null,
hall_queued_at: '2026-11-01T00:00:00.000Z',
}),
'published',
)
})
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('公开补资料:只查询待完善工单,其他状态一律不返回', () => {
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('补资料链接:未知状态一律视为失效,避免误放行', () => {
assert.equal(
resolveWorkOrderMaterialLinkUnavailableReason(
{
status: 'unknown' as never,
expires_at: '2026-10-31T13:00:00.000Z',
},
NOW,
),
'revoked',
)
})
test('补资料链接:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], {
gameId: '123',
gameNickname: '张三',
@@ -1,19 +1,10 @@
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,
listWorkOrdersByPlatformOrderId,
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'
@@ -25,44 +16,134 @@ import { resolveAdminNotificationEntity } from '../admin/admin-notification-serv
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import {
mapCollectLookupOrder,
mapWorkOrderAdmin,
normalizeOptionalId,
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' | ''
const PUBLIC_MATERIAL_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.PROBLEM]: '问题单',
[WORK_ORDER_STATUS.ACCEPTED]: '已验收',
[WORK_ORDER_STATUS.CANCELLED]: '已取消',
vip_evidence_pending: '待补图',
}
export type PublicMaterialOrderBlockReason =
| 'already_submitted'
| 'status'
| 'assigned'
| 'shares'
| ''
export type PublicMaterialOrderState = {
canSubmit: boolean
blockedReason: PublicMaterialOrderBlockReason
statusLabel: string
message: string
}
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 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'
export function resolvePublicMaterialOrderState(
order: Pick<WorkOrderRow, 'status' | 'assigned_worker_id' | 'active_share_quantity'>,
): PublicMaterialOrderState {
const statusLabel = formatPublicMaterialStatus(order.status)
if (order.status === WORK_ORDER_STATUS.UNASSIGNED) {
return {
canSubmit: false,
blockedReason: 'already_submitted',
statusLabel,
message: '资料已提交,订单已进入待分配;如需修改请联系客服。',
}
}
if (order.status !== WORK_ORDER_STATUS.PENDING_MATERIAL) {
return {
canSubmit: false,
blockedReason: 'status',
statusLabel,
message: `当前状态为「${statusLabel}」,不能修改资料;如需修改请联系客服。`,
}
}
if (order.assigned_worker_id) {
return {
canSubmit: false,
blockedReason: 'assigned',
statusLabel,
message: '订单已指派打手,不能修改资料;如需修改请联系客服。',
}
}
if (Number(order.active_share_quantity || 0) > 0) {
return {
canSubmit: false,
blockedReason: 'shares',
statusLabel,
message: '订单已有拼单份额,不能修改资料;如需修改请联系客服。',
}
}
return {
canSubmit: true,
blockedReason: '',
statusLabel,
message: '',
}
}
/**
* 客服重置补资料状态的前置条件。
*
* 只允许「待分配 + 未指派 + 无份额 + 未发布」的工单回到待完善;
* 其他状态一律拒绝,避免把已进入接单流程的工单改回可编辑。
*/
export function resolveMaterialResetBlockReason(
order: Pick<
WorkOrderRow,
'status' | 'assigned_worker_id' | 'active_share_quantity' | 'published_at' | 'hall_queued_at'
>,
): MaterialResetBlockReason {
if (order.status !== WORK_ORDER_STATUS.UNASSIGNED) return 'status'
if (order.assigned_worker_id) return 'assigned'
if (Number(order.active_share_quantity || 0) > 0) return 'shares'
if (order.published_at || order.hall_queued_at) return 'published'
return ''
}
export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReason): string {
if (reason === 'assigned') return '订单已指派打手,不能重置补资料状态'
if (reason === 'shares') return '订单已有拼单份额,不能重置补资料状态'
if (reason === 'published') return '订单已发布到接单大厅,不能重置补资料状态'
return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态'
}
/**
* 只保留工单 requirement 里定义过的字段。
*
* 公开链接的提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的
* 公开提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的
* 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。
*/
export function pickSubmittedMaterialFields(
@@ -73,57 +154,19 @@ export function pickSubmittedMaterialFields(
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',
function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
if (state.blockedReason === 'already_submitted') {
throw createHttpError(state.message, {
statusCode: 409,
errorCode: 'material_link_already_submitted',
})
}
if (reason === 'expired') {
throw createHttpError('链接已过期,请联系客服重新获取', {
statusCode: 410,
errorCode: 'material_link_expired',
})
}
throw createHttpError('链接已失效,请联系客服重新获取', {
statusCode: 410,
errorCode: 'material_link_revoked',
throw createHttpError(state.message, {
statusCode: 409,
errorCode: 'material_link_order_status_invalid',
})
}
/**
* 公开链接只对「待完善 / 未分配」开放,且不能已经指派打手或有进行中的拼单份额。
* 这三个条件同时满足,提交后才可以安全地把状态改成「未分配」。
*/
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,
@@ -133,149 +176,150 @@ function mapPublicOrder(order: WorkOrderRow) {
}
}
/** 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()
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,
}
}
/**
* 管理端生成/复用一次性链接。
*
* 幂等策略:同一工单已有未过期 active 链接时直接返回,避免客服连续点两次
* 把刚发出去的链接顶掉;只有已使用/已过期/已撤销时才生成新 token。
*/
export async function createAdminWorkOrderMaterialLink(
workOrderId: number | string,
actorName = '',
) {
const { ttlMinutes } = getWorkerMaterialLinkConfig()
return withTransaction(async (client) => {
const order = await getWorkOrderForUpdateWithClient(client, workOrderId)
if (!order) {
throw createHttpError('接单订单不存在', {
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: 'work_order_not_found',
errorCode: 'material_link_work_order_invalid',
})
}
return order
}
if (!mapWorkOrderAdmin(order).capabilities.canUpdateMaterial) {
throw createHttpError('当前状态不能生成补资料链接', {
statusCode: 409,
errorCode: 'material_link_status_invalid',
})
}
assertWorkOrderAllowedForMaterialLink(order)
// 锁到手后再取时间,避免等锁期间旧链接过期却仍按旧时间复用。
const now = nowIso()
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,
const orders = await listWorkOrdersByPlatformOrderId(platformOrderId)
if (orders.length === 0) {
throw createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'material_link_not_found',
})
return {
token: link.token,
expiresAt: toIsoTimestamp(link.expires_at),
reused: false,
}
})
}
if (orders.length > 1) {
throw createHttpError('该订单号下有多个商品,请先选择商品', {
statusCode: 409,
errorCode: 'material_link_work_order_required',
})
}
return orders[0]!
}
/**
* 公开页读取链接内容。
* 公开页只允许查询待完善工单。
*
* 只返回客户补资料需要的最少信息:商品/单号、动态字段、已填值、截图、过期时间。
* 不返回发单价格、押金、成本、打手、后台备注等任何后台字段。
* 待分配、待抢单、代练中、已验收、已取消等状态一律不返回,
* 避免客户用订单号看到已完成订单的商品与状态。
*/
export async function getPublicWorkOrderMaterialLink(token: string) {
const link = await findWorkOrderMaterialLinkByToken(token)
if (!link) {
throw createHttpError('链接无效,请确认链接是否完整', {
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 now = nowIso()
const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now)
if (reason) {
if (reason === 'expired') {
await markWorkOrderMaterialLinkExpired(link.id, now)
}
throwMaterialLinkUnavailable(reason)
const orders = await listWorkOrdersByPlatformOrderId(code)
if (orders.length === 0) {
throw createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'material_link_not_found',
})
}
const order = await getRequiredWorkOrder(link.work_order_id)
assertWorkOrderAllowedForMaterialLink(order)
const pendingOrders = pickPendingMaterialOrders(orders)
if (pendingOrders.length === 0) {
throw createHttpError('该订单当前无需补充资料', {
statusCode: 404,
errorCode: 'material_link_not_pending',
})
}
const mapped = mapCollectLookupOrder(order)
const publicOrders = pendingOrders.map(mapPublicMaterialOrder)
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),
platformOrderId: code,
orders: publicOrders,
pendingCount: publicOrders.filter((order) => order.canSubmit).length,
}
}
/**
* 公开页提交资料。
*
* 一次性语义:只有「完整提交」成功时才把 token 标为 used;
* 少填必填项、订单状态变化、网络失败等都会在事务里回滚,链接继续可用。
* 一次性语义完全由工单状态保证:只有 `pending_material` 能成功提交,
* 提交后改成 `unassigned`;并发的第二个提交会锁到 `unassigned` 并返回 409。
*/
export async function submitPublicWorkOrderMaterialLink(token: string, payload: JsonObject = {}) {
const preliminary = await findWorkOrderMaterialLinkByToken(token)
if (!preliminary) {
throw createHttpError('链接无效,请确认链接是否完整', {
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, preliminary.work_order_id)
// 锁工单:并发提交/客服重置都串行化,状态是唯一的一次性凭据。
const order = await getWorkOrderForUpdateWithClient(client, target.id)
if (!order) {
throw createHttpError('接单订单不存在', {
statusCode: 404,
errorCode: 'work_order_not_found',
})
}
const link = await getWorkOrderMaterialLinkForUpdateWithClient(client, preliminary.id)
if (!link) {
throw createHttpError('链接无效,请确认链接是否完整', {
if (String(order.platform_order_id || '').trim() !== code) {
throw createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'material_link_not_found',
errorCode: 'material_link_work_order_invalid',
})
}
// 锁到手后再取时间:等锁期间链接可能已经过期,不能沿用进入事务前的时间。
const now = nowIso()
assertMaterialLinkRecordUsable(link, now)
assertWorkOrderAllowedForMaterialLink(order)
const orderState = resolvePublicMaterialOrderState(order)
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
const fields = resolveRequirementFields(order)
const submittedFields = pickSubmittedMaterialFields(
@@ -304,6 +348,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
})
}
const now = nowIso()
const material = {
...safeParseJson(order.material_json),
collect: {
@@ -311,7 +356,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
screenshots,
submittedAt: now,
complete: true,
source: 'customer_link',
source: 'customer_order_no',
},
}
@@ -328,33 +373,22 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
})
}
const used = await markWorkOrderMaterialLinkUsedWithClient(client, link.id, now)
if (!used) {
// 并发提交,或刚好在写入瞬间过期:按当前状态给出准确原因,事务整体回滚。
const latest = await getWorkOrderMaterialLinkForUpdateWithClient(client, link.id)
if (latest) {
const reason = resolveWorkOrderMaterialLinkUnavailableReason(latest, now)
if (reason) throwMaterialLinkUnavailable(reason)
}
throwMaterialLinkUnavailable('used')
}
await createWorkOrderEventWithClient(client, {
workOrderId: order.id,
actorType: 'collect',
actorId: '客户补资料链接',
actorId: '客户按订单号补资料',
eventType: 'material_completed',
fromStatus: order.status,
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
payloadJson: JSON.stringify({
fields: submittedFields,
complete: true,
source: 'customer_link',
source: 'customer_order_no',
}),
now,
})
return { order: updated, link: used }
return { order: updated }
})
// 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。
@@ -374,28 +408,30 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
return {
complete: true,
workOrderId: Number(result.order.id),
order: mapPublicOrder(result.order),
}
}
/**
* 上传前校验 token 与工单状态;返回工单 id 供文件归属使用。
* 注意:这里不消耗 token,只有最终提交才消耗。
* 上传前校验平台订单号 / workOrderId 与工单状态;返回工单供文件归属使用。
* 注意:这里不消耗提交机会,只有最终提交才把状态改成待分配。
*/
export async function getPublicWorkOrderMaterialLinkUploadContext(token: string) {
const link = await findWorkOrderMaterialLinkByToken(token)
if (!link) {
throw createHttpError('链接无效,请确认链接是否完整', {
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 now = nowIso()
assertMaterialLinkRecordUsable(link, now)
const order = await resolvePublicMaterialTarget(code, workOrderId)
const orderState = resolvePublicMaterialOrderState(order)
if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState)
const order = await getRequiredWorkOrder(link.work_order_id)
assertWorkOrderAllowedForMaterialLink(order)
return { link, order, now }
return { order, now: nowIso() }
}
@@ -1,41 +0,0 @@
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 })
})
@@ -1,62 +0,0 @@
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]'
}