Merge branch 'dev'

This commit is contained in:
2026-11-01 12:30:02 +08:00
25 changed files with 1718 additions and 4 deletions
@@ -0,0 +1,48 @@
-- 旧 103–112 → 当前 103–108 迁移历史校准。
--
-- 适用场景:数据库已经执行过旧迁移名
-- 103_product_catalog / 104_cost_center / 105_sales_daily_facts /
-- 106_order_refunds / 107_manual_fulfillment / 108_finance_cost_integrity /
-- 109_work_order_share_participation / 110_work_order_hall_and_shop_indexes /
-- 111_work_order_share_cancel_reason_comment / 112_consignment_contact_supporter
-- 但当前代码已经把 103–108 收敛成新的幂等迁移文件。
--
-- 新库不需要执行本脚本,直接按当前迁移文件跑即可。
-- 执行本脚本后,请运行 npm run db:migrate,让 node-pg-migrate 应用当前 103–108。
--
-- 注意:执行前请先做全量数据库备份。本脚本只清理 schema_migrations 元数据,
-- 不删业务表;当前 103–108 迁移文件本身保持幂等,重复执行不会重复建对象。
BEGIN;
-- 保留一份执行前的迁移记录,便于核对;已存在时不覆盖。
CREATE TABLE IF NOT EXISTS schema_migrations_backup_20261031_before_103
AS SELECT * FROM schema_migrations;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE name = '103_product_catalog'
) THEN
RAISE NOTICE '未检测到旧 103_product_catalog,跳过迁移历史校准';
RETURN;
END IF;
DELETE FROM schema_migrations
WHERE name IN (
'103_product_catalog',
'104_cost_center',
'105_sales_daily_facts',
'106_order_refunds',
'107_manual_fulfillment',
'108_finance_cost_integrity',
'109_work_order_share_participation',
'110_work_order_hall_and_shop_indexes',
'111_work_order_share_cancel_reason_comment',
'112_consignment_contact_supporter'
);
RAISE NOTICE '已清理旧 103–112 迁移记录,请执行 npm run db:migrate 应用当前 103–108';
END $$;
COMMIT;
@@ -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',
@@ -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' 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: {
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,
@@ -183,6 +184,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'),
+75 -1
View File
@@ -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,401 @@
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 { 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 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,
})
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 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',
})
}
// 锁到手后再取时间:等锁期间链接可能已经过期,不能沿用进入事务前的时间。
const now = nowIso()
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) {
// 并发提交,或刚好在写入瞬间过期:按当前状态给出准确原因,事务整体回滚。
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: '客户补资料链接',
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]'
}
+28
View File
@@ -12,6 +12,7 @@ import {
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
import { isCollectMaterialLinkPath } from '@/utils/collect-link'
import { isRedeemPath } from '@/utils/redeem'
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
import { hasWorkerSession } from '@/utils/worker-auth'
@@ -45,6 +46,7 @@ const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlat
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage'))
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
const CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
@@ -122,6 +124,12 @@ export default function App() {
return <RedeemApplication />
}
// 一次性补资料链接(`/supplement/<token>`):路径式公开页,先于站点判断,
// 保证在后台域名和打手端域名下都能打开。
if (isCollectMaterialLinkPath(window.location.pathname)) {
return <CollectMaterialLinkApplication />
}
if (isWorkerSite()) {
return <WorkerApplication />
}
@@ -226,6 +234,26 @@ function ClaimApplication() {
)
}
/**
* 一次性补资料链接(`/supplement/<token>`)的独立路由树。
*
* 路径式公开页,不带 hash;页面只读取 token,不依赖后台会话。
*/
function CollectMaterialLinkApplication() {
useClearStrayHash()
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/supplement/:token" element={<CollectMaterialLinkPage />} />
<Route path="*" element={<CollectMaterialLinkPage />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
/**
* H5 领取入口(`/h5` 命名空间)的独立路由树。
*
@@ -9,17 +9,27 @@ import type { UploadedFile } from '@/types/worker-platform'
import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture'
import { useIsMobile } from '@/utils/use-is-mobile'
type ImageUploadScope = 'admin' | 'worker' | 'collect'
type ImageUploadUploader = (
file: File,
scene: string,
scope: ImageUploadScope,
) => Promise<UploadedFile>
type ImageUploadProps = {
value?: UploadedFile[]
onChange?: (files: UploadedFile[]) => void
scene: string
scope: 'admin' | 'worker' | 'collect'
scope: ImageUploadScope
maxCount?: number
showPasteHint?: boolean
/** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */
captureGlobalPaste?: boolean
/** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */
allowCamera?: boolean
/** 自定义上传函数;公开一次性链接用它把文件传到 token 校验过的上传接口。 */
uploader?: ImageUploadUploader
}
type GlobalPasteTarget = {
@@ -67,6 +77,7 @@ export default function ImageUpload({
showPasteHint = false,
captureGlobalPaste = false,
allowCamera,
uploader,
}: ImageUploadProps) {
const { message } = App.useApp()
const isMobile = useIsMobile()
@@ -75,6 +86,7 @@ export default function ImageUpload({
const targetIdRef = useRef(0)
const valueRef = useRef(value)
valueRef.current = value
const uploadFileFn = uploader || uploadFile
const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope, allowCamera })
const cameraInputProps = cameraCaptureInputProps('image')
@@ -96,7 +108,7 @@ export default function ImageUpload({
const next = [...valueRef.current]
for (const file of images.slice(0, remaining)) {
try {
const uploaded = await uploadFile(file, scene, scope)
const uploaded = await uploadFileFn(file, scene, scope)
next.push(uploaded)
} catch (error) {
message.error(resolveUploadErrorMessage(error, source))
@@ -199,7 +211,7 @@ export default function ImageUpload({
options: Parameters<NonNullable<UploadProps['customRequest']>>[0],
) {
try {
const uploaded = await uploadFile(options.file as File, scene, scope)
const uploaded = await uploadFileFn(options.file as File, scene, scope)
// 多文件选择会并发执行 customRequest,必须以 ref 中的最新列表累加,避免后完成的上传覆盖前面的图片。
const next = [...valueRef.current, uploaded].slice(0, maxCount)
valueRef.current = next
@@ -32,6 +32,7 @@ import type { WorkerAnnouncementConfig } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
import HallConfigPanel from './HallConfigPanel'
import MaterialLinkConfigPanel from './MaterialLinkConfigPanel'
function isDepositUnfreezeJob(job: AdminScheduledJobItem) {
return job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze'
@@ -44,6 +45,7 @@ export default function AnnouncementPanel() {
<DepositUnfreezeCard />
<MockCustomShopsCard />
<HallConfigPanel />
<MaterialLinkConfigPanel />
</Space>
)
}
@@ -0,0 +1,74 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Button, Card, Form, InputNumber, Space, Typography } from 'antd'
import { useEffect, useState } from 'react'
import {
fetchAdminWorkerMaterialLinkConfig,
saveAdminWorkerMaterialLinkConfig,
} from '@/services/admin'
import type { WorkerMaterialLinkConfig } from '@/types/worker-platform'
export default function MaterialLinkConfigPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<WorkerMaterialLinkConfig>()
const [saving, setSaving] = useState(false)
const configQuery = useQuery({
queryKey: ['admin-worker-material-link-config'],
queryFn: fetchAdminWorkerMaterialLinkConfig,
})
const config = configQuery.data?.data
useEffect(() => {
if (config) {
form.setFieldsValue({ ttlMinutes: config.ttlMinutes })
}
}, [config, form])
async function saveConfig(values: WorkerMaterialLinkConfig) {
setSaving(true)
try {
await saveAdminWorkerMaterialLinkConfig({ ttlMinutes: Number(values.ttlMinutes) })
await queryClient.invalidateQueries({ queryKey: ['admin-worker-material-link-config'] })
message.success('补资料链接配置已保存')
} catch (error) {
message.error(error instanceof Error ? error.message : '保存补资料链接配置失败')
} finally {
setSaving(false)
}
}
return (
<Card title="补资料链接" bordered={false} loading={configQuery.isLoading}>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 700 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
客服在「接单工单」复制的一次性补资料链接,客户只能修改补资料内容;提交成功后工单转为「未分配」,
链接立即失效,需要重新发送。修改有效期只影响新生成的链接。
</Typography.Paragraph>
<Form form={form} layout="vertical" onFinish={saveConfig}>
<Form.Item
name="ttlMinutes"
label="链接有效期(分钟)"
extra="默认 30 分钟,可设置 1 - 10080 分钟(7 天)。"
rules={[{ required: true, message: '请输入链接有效期' }]}
style={{ marginBottom: 12 }}
>
<InputNumber
min={1}
max={10080}
precision={0}
style={{ width: 220 }}
addonAfter="分钟"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={saving}>
保存配置
</Button>
</Form.Item>
</Form>
</Space>
</Card>
)
}
@@ -4,6 +4,7 @@ import {
DeleteOutlined,
EditOutlined,
FormOutlined,
LinkOutlined,
PushpinOutlined,
ReloadOutlined,
SearchOutlined,
@@ -59,6 +60,7 @@ import {
cancelAdminWorkOrderSharingPart,
completeAdminWorkOrderGiftCooldown,
confirmAdminWorkOrderFriend,
createAdminWorkOrderMaterialLink,
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder,
disposeAdminWorkOrderShareProblem,
@@ -198,6 +200,40 @@ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo |
return Object.keys(consignment).length > 0 ? (consignment as ConsignmentFulfillmentInfo) : null
}
/** 公开补资料链接只在「待完善 / 未分配」阶段生成;已进大厅或已接单不允许客户改资料。 */
function canCopyMaterialLink(order: WorkOrder): boolean {
const status = String(order.rawStatus || order.status || '')
return status === 'pending_material' || status === 'unassigned'
}
/** 剪贴板 API 在 HTTP + IP 下可能不可用,补一个 textarea 兜底。 */
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
// 继续走 execCommand 兜底
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '0'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
/**
* 寄卖单号 = 来源提报单号(提报单号即寄卖单号),客服界面只认这一个号。
*
@@ -312,6 +348,11 @@ export default function WorkOrdersPanel() {
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
const [materialPasteText, setMaterialPasteText] = useState('')
const [materialLinkLoadingId, setMaterialLinkLoadingId] = useState<number | null>(null)
const [materialLinkFallback, setMaterialLinkFallback] = useState<{
url: string
expiresAt: string
} | null>(null)
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
const [reduceShare, setReduceShare] = useState<WorkOrderShare | null>(null)
@@ -1590,6 +1631,27 @@ export default function WorkOrdersPanel() {
.catch(() => message.error('复制失败,请手动复制'))
}
async function copyMaterialLink(order: WorkOrder) {
setMaterialLinkLoadingId(order.workOrderId)
try {
const response = await createAdminWorkOrderMaterialLink(order.workOrderId)
const url = `${window.location.origin}/supplement/${encodeURIComponent(response.data.token)}`
const copied = await copyTextToClipboard(url)
const expiresText = formatAdminDateTime(response.data.expiresAt)
if (copied) {
message.success(
`${response.data.reused ? '已复制现有链接' : '已生成并复制新链接'},${expiresText} 前有效,仅可提交一次`,
)
} else {
setMaterialLinkFallback({ url, expiresAt: response.data.expiresAt })
}
} catch (error) {
message.error(error instanceof Error ? error.message : '生成补资料链接失败')
} finally {
setMaterialLinkLoadingId(null)
}
}
const columns: TableColumnsType<WorkOrder> = [
{
title: '订单',
@@ -1899,6 +1961,16 @@ export default function WorkOrdersPanel() {
{['open', 'in_progress'].includes(row.status) ? '更新资料' : '补资料'}
</Button>
) : null}
{row.capabilities?.canUpdateMaterial && canUpdateMaterial && canCopyMaterialLink(row) ? (
<Button
icon={<LinkOutlined />}
loading={materialLinkLoadingId === row.workOrderId}
onClick={() => void copyMaterialLink(row)}
title="生成一次性补资料链接(有效期内仅可提交一次)"
>
复制补资料链接
</Button>
) : null}
{row.capabilities?.canPublish && canPublishOrder ? (
<Button
icon={<SendOutlined />}
@@ -3147,6 +3219,46 @@ export default function WorkOrdersPanel() {
</Form>
</Modal>
<Modal
title="补资料链接"
open={Boolean(materialLinkFallback)}
onCancel={() => setMaterialLinkFallback(null)}
footer={
<Space>
<Button onClick={() => setMaterialLinkFallback(null)}>关闭</Button>
<Button
type="primary"
onClick={() => {
if (!materialLinkFallback) return
void copyTextToClipboard(materialLinkFallback.url).then((ok) => {
if (ok) {
message.success('链接已复制')
setMaterialLinkFallback(null)
} else {
message.error('复制失败,请手动选择链接复制')
}
})
}}
>
复制链接
</Button>
</Space>
}
>
{materialLinkFallback ? (
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Typography.Text type="secondary">
链接仅可提交一次,{formatAdminDateTime(materialLinkFallback.expiresAt)} 前有效。
</Typography.Text>
<Input.TextArea
value={materialLinkFallback.url}
readOnly
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Space>
) : null}
</Modal>
<Modal
title={`指派打手 · ${assignOrder?.productName || ''}`}
open={Boolean(assignOrder)}
@@ -0,0 +1,294 @@
import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Form,
Input,
Radio,
Result,
Space,
Spin,
Tag,
Typography,
} from 'antd'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router'
import ImageUpload from '@/components/files/ImageUpload'
import { uploadCollectMaterialLinkFile } from '@/services/files'
import { fetchCollectMaterialLink, submitCollectMaterialLink } from '@/services/worker'
import type { CollectMaterialLinkInfo, UploadedFile } from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
const { Text, Title } = Typography
type LinkLoadError = {
code: string
message: string
}
function resolveErrorCode(error: unknown): string {
return String((error as { errorCode?: string })?.errorCode || '').trim()
}
function resolveErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : '操作失败,请稍后重试'
}
function resolveErrorTitle(code: string): string {
if (code === 'material_link_expired') return '链接已过期'
if (code === 'material_link_revoked') return '链接已失效'
if (code === 'material_link_not_found') return '链接无效'
if (code === 'material_link_order_status_invalid') return '订单状态已变化'
return '无法打开链接'
}
/**
* 客户侧一次性补资料页。
*
* 只渲染 requirement 里的动态字段与买家主页截图,不展示发单价格、押金、
* 打手、后台备注等任何后台字段;提交成功后链接立即失效。
*/
export default function CollectMaterialLinkPage() {
const { message } = App.useApp()
const { token: routeToken } = useParams<{ token?: string }>()
const token = String(routeToken || '').trim()
const [form] = Form.useForm<Record<string, string>>()
const [loading, setLoading] = useState(true)
const [info, setInfo] = useState<CollectMaterialLinkInfo | null>(null)
const [loadError, setLoadError] = useState<LinkLoadError | null>(null)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
useEffect(() => {
const previousTitle = document.title
document.title = '订单资料补充'
function applyMeta(name: string, content: string) {
const existing = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
const previous = existing?.getAttribute('content') ?? null
let created: HTMLMetaElement | null = null
if (existing) {
existing.setAttribute('content', content)
} else {
created = document.createElement('meta')
created.setAttribute('name', name)
created.setAttribute('content', content)
document.head.appendChild(created)
}
return () => {
if (created) {
created.remove()
} else if (existing) {
if (previous === null) {
existing.removeAttribute('content')
} else {
existing.setAttribute('content', previous)
}
}
}
}
const restoreRobots = applyMeta('robots', 'noindex,nofollow')
const restoreReferrer = applyMeta('referrer', 'no-referrer')
return () => {
document.title = previousTitle
restoreRobots()
restoreReferrer()
}
}, [])
useEffect(() => {
let cancelled = false
async function load() {
if (!token) {
setLoading(false)
setLoadError({
code: 'material_link_not_found',
message: '链接无效,请确认链接是否完整',
})
return
}
setLoading(true)
setSubmitted(false)
try {
const response = await fetchCollectMaterialLink(token)
if (cancelled) return
setInfo(response.data)
setLoadError(null)
setScreenshots(response.data.screenshots || [])
form.resetFields()
form.setFieldsValue(response.data.values || {})
} catch (error) {
if (cancelled) return
setInfo(null)
setLoadError({
code: resolveErrorCode(error),
message: resolveErrorMessage(error),
})
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [form, token])
async function handleSubmit(values: Record<string, string>) {
if (!token) return
setSubmitting(true)
try {
await submitCollectMaterialLink(token, { fields: values, screenshots })
setSubmitted(true)
} catch (error) {
const code = resolveErrorCode(error)
if (
code === 'material_link_used' ||
code === 'material_link_expired' ||
code === 'material_link_revoked' ||
code === 'material_link_not_found' ||
code === 'material_link_order_status_invalid'
) {
setInfo(null)
setLoadError({ code, message: resolveErrorMessage(error) })
return
}
message.error(resolveErrorMessage(error))
} finally {
setSubmitting(false)
}
}
return (
<main className="collect-link-page">
<Card className="collect-link-card" bordered={false}>
{loading ? (
<div className="collect-link-state">
<Spin indicator={<LoadingOutlined spin />} size="large" />
<Text type="secondary">正在读取补资料链接...</Text>
</div>
) : submitted ? (
<Result
status="success"
title="资料已提交"
subTitle="链接已失效,如需修改资料,请联系客服重新获取。"
/>
) : loadError ? (
loadError.code === 'material_link_used' ? (
<Result
icon={<CheckCircleOutlined style={{ color: '#16a34a' }} />}
title="资料已提交"
subTitle="该链接已经使用过;如需再次修改资料,请联系客服重新获取链接。"
/>
) : (
<Result
status="warning"
title={resolveErrorTitle(loadError.code)}
subTitle={loadError.message || '请联系客服重新获取链接'}
/>
)
) : info ? (
<Space direction="vertical" size={16} className="full-width">
<div>
<Title level={3} className="collect-link-title">
订单资料补充
</Title>
<Text type="secondary">请补充以下资料,提交后客服会尽快处理。</Text>
</div>
<div className="collect-link-meta">
<div>
<span>商品</span>
<strong>{info.order.productName || '-'}</strong>
</div>
<div>
<span>工单号</span>
<strong>{info.order.workOrderNo || '-'}</strong>
</div>
<div>
<span>订单号</span>
<strong>{info.order.platformOrderId || '-'}</strong>
</div>
<div>
<span>有效期至</span>
<strong>{formatDateTime(info.expiresAt)}</strong>
</div>
</div>
<Alert
type="warning"
showIcon
message="链接仅可提交一次"
description="提交成功后链接立即失效;如需再次修改资料,请联系客服重新获取链接。"
/>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{info.fields.map((field) => (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={6}>
<span>{field.label}</span>
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
</Space>
}
rules={
field.required
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
: undefined
}
>
{field.type === 'select' && (field.options || []).length > 0 ? (
<Radio.Group
options={(field.options || []).map((option) => ({
label: option,
value: option,
}))}
/>
) : (
<Input autoComplete="off" placeholder={`请输入${field.label}`} />
)}
</Form.Item>
))}
<Form.Item label="买家主页截图">
<ImageUpload
scene="collect-material"
scope="collect"
maxCount={5}
showPasteHint
captureGlobalPaste
value={screenshots}
onChange={setScreenshots}
uploader={(file, scene) => uploadCollectMaterialLinkFile(file, scene, token)}
/>
</Form.Item>
<div className="collect-link-actions">
<Button type="primary" size="large" htmlType="submit" loading={submitting}>
提交资料
</Button>
</div>
</Form>
<Text type="secondary" className="collect-link-footnote">
本页面仅用于补充订单资料,请勿将链接转发给他人。
</Text>
</Space>
) : null}
</Card>
</main>
)
}
@@ -15,6 +15,7 @@ import type {
WorkerDepositUnfreeze,
WorkerFinanceConfig,
WorkerHallConfig,
WorkerMaterialLinkConfig,
WorkerRiskConfig,
WorkerRiskOverview,
WorkerRiskEventList,
@@ -101,6 +102,17 @@ export function saveAdminWorkerHallConfig(
return apiPost<WorkerHallConfig>('/api/v1/admin/worker-platform/hall-config', payload)
}
export function fetchAdminWorkerMaterialLinkConfig() {
return apiGet<WorkerMaterialLinkConfig>('/api/v1/admin/worker-platform/material-link-config')
}
export function saveAdminWorkerMaterialLinkConfig(payload: WorkerMaterialLinkConfig) {
return apiPost<WorkerMaterialLinkConfig>(
'/api/v1/admin/worker-platform/material-link-config',
payload,
)
}
export function fetchAdminWorkerAnnouncement() {
return apiGet<WorkerAnnouncementConfig>('/api/v1/admin/worker-platform/announcement')
}
@@ -757,6 +769,12 @@ export function submitAdminWorkOrderMaterial(
)
}
export function createAdminWorkOrderMaterialLink(workOrderId: number) {
return apiPost<{ token: string; expiresAt: string; reused: boolean }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/material-link`,
)
}
export function publishAdminWorkOrder(workOrderId: number) {
return apiPost<{
order: WorkOrder
+18
View File
@@ -19,6 +19,24 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope)
return response.data.file
}
/**
* 一次性补资料链接的截图上传。
*
* 与公开 collect/upload 的区别:先把 token 放在路径里,后端会校验链接
* 是否仍可用;链接已使用/过期时不会落文件。
*/
export async function uploadCollectMaterialLinkFile(file: File, scene: string, token: string) {
const uploadTarget = await optimizeImageForUpload(file, scene)
const form = new FormData()
form.append('file', uploadTarget)
form.append('scene', scene)
const response = await apiPostForm<{ file: UploadedFile }>(
`/api/v1/collect/link/${encodeURIComponent(token)}/upload`,
form,
)
return response.data.file
}
/**
* 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。
* 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。
+19
View File
@@ -1,6 +1,8 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/http'
import type {
CollectLookupResponse,
CollectMaterialLinkInfo,
CollectMaterialLinkSubmitResult,
UploadedFile,
WorkOrder,
WorkOrderEvent,
@@ -379,6 +381,23 @@ export function submitCollectOrder(payload: {
)
}
export function fetchCollectMaterialLink(token: string) {
return apiGet<CollectMaterialLinkInfo>(`/api/v1/collect/link/${encodeURIComponent(token)}`)
}
export function submitCollectMaterialLink(
token: string,
payload: {
fields: Record<string, string>
screenshots?: UploadedFile[]
},
) {
return apiPost<CollectMaterialLinkSubmitResult>(
`/api/v1/collect/link/${encodeURIComponent(token)}/submit`,
payload,
)
}
// ---------------- 打手端:我要寄卖与手枪库存 ----------------
export type WorkerConsignmentPublishPayload = {
+108
View File
@@ -480,6 +480,114 @@
margin-bottom: 0;
}
/* ===== 一次性补资料链接页 (Collect Material Link) ===== */
.collect-link-page {
min-height: 100vh;
padding: 32px 16px;
background: #f5f7fb;
display: flex;
justify-content: center;
align-items: flex-start;
}
.collect-link-card {
width: min(720px, 100%);
}
.collect-link-card .ant-card-body {
padding: 28px;
}
.collect-link-title {
margin: 0 0 4px !important;
}
.collect-link-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 20px;
padding: 14px 16px;
background: #f8fafc;
border-radius: 10px;
}
.collect-link-meta > div {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.collect-link-meta span {
color: #64748b;
font-size: 12px;
}
.collect-link-meta strong {
color: #0f172a;
font-size: 14px;
overflow-wrap: anywhere;
}
.collect-link-state {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.collect-link-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.collect-link-actions .ant-btn {
min-width: 180px;
}
.collect-link-footnote {
display: block;
text-align: center;
font-size: 12px;
}
@media (max-width: 720px) {
.collect-link-page {
padding: 14px;
}
.collect-link-card .ant-card-body {
padding: 18px;
}
.collect-link-meta {
grid-template-columns: 1fr;
gap: 8px;
}
.collect-link-actions {
justify-content: stretch;
}
.collect-link-actions .ant-btn {
width: 100%;
}
}
@media (max-width: 480px) {
.collect-link-page {
padding: 10px;
}
.collect-link-card .ant-card-body {
padding: 14px;
}
}
/* ===== 兑换中心 (Redeem Page) ===== */
.redeem-page {
@@ -334,6 +334,11 @@ export type WorkerHallConfig = {
queuedOrderCount: number
}
/** 一次性补资料链接配置:有效期只影响新生成的链接。 */
export type WorkerMaterialLinkConfig = {
ttlMinutes: number
}
export type WorkerWithdrawalAccount = {
accountChannel: string
accountName: string
@@ -862,6 +867,30 @@ export type CollectLookupResponse = {
pendingCount: number
}
/** 一次性补资料链接的公开只读信息(不含价格/押金/打手等后台字段)。 */
export type CollectMaterialLinkInfo = {
order: {
workOrderNo: string
platformOrderId: string
productName: string
status: string
}
fields: CollectField[]
values?: Record<string, string>
screenshots?: UploadedFile[]
expiresAt: string
}
export type CollectMaterialLinkSubmitResult = {
complete: boolean
order: {
workOrderNo: string
platformOrderId: string
productName: string
status: string
}
}
/** 大厅风控阈值(后台可调,保存即热生效) */
export type WorkerRiskConfig = {
enabled: boolean
+37
View File
@@ -0,0 +1,37 @@
/**
* 一次性补资料链接的路径解析。
*
* 公开链接形如:`https://<域名>/supplement/<token>`,与后台 HashRouter 完全隔离。
* 命名空间判定用于路由分发:`/supplement` 及子路径整段交给公开页,
* 避免落到后台路由或打手端 SPA。
*/
export function isCollectMaterialLinkPath(pathname: unknown): boolean {
return splitPathSegments(pathname)[0] === 'supplement'
}
/**
* 只认 `/supplement/<一段 token>`:
* - `/supplement/abc` 命中,结尾多余斜杠容忍;
* - `/supplement`、`/supplement/a/b` 不命中(页面展示链接无效)。
*/
export function resolveCollectMaterialLinkToken(pathname: unknown): string {
const segments = splitPathSegments(pathname)
if (segments.length !== 2 || segments[0] !== 'supplement') return ''
return decodeTokenSegment(segments[1])
}
function splitPathSegments(pathname: unknown): string[] {
const normalized = String(pathname ?? '').trim()
if (!normalized.startsWith('/')) return []
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
}
function decodeTokenSegment(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
return segment
}
}
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isCollectMaterialLinkPath,
resolveCollectMaterialLinkToken,
} from '../src/utils/collect-link.ts'
test('补资料链接:/supplement/<token> 命中并解析 token', () => {
assert.equal(isCollectMaterialLinkPath('/supplement/abc123'), true)
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123'), 'abc123')
})
test('补资料链接:容忍结尾斜杠、查询串与 hash', () => {
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123/'), 'abc123')
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123?from=chat'), 'abc123')
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123#top'), 'abc123')
})
test('补资料链接:命名空间命中但缺少 token 时返回空串', () => {
assert.equal(isCollectMaterialLinkPath('/supplement'), true)
assert.equal(isCollectMaterialLinkPath('/supplement/'), true)
assert.equal(resolveCollectMaterialLinkToken('/supplement'), '')
assert.equal(resolveCollectMaterialLinkToken('/supplement/a/b'), '')
})
test('补资料链接:其它路径不命中', () => {
for (const pathname of ['/', '/claim/abc', '/collect', '/supplementx/abc', '']) {
assert.equal(isCollectMaterialLinkPath(pathname), false, pathname)
assert.equal(resolveCollectMaterialLinkToken(pathname), '', pathname)
}
})