Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db73e8f820 | ||
|
|
a2cf48f5fc |
@@ -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';
|
||||
@@ -537,6 +537,8 @@ export type WorkOrderCapabilities = {
|
||||
canPin: boolean
|
||||
canUnpublish: boolean
|
||||
canUpdateMaterial: boolean
|
||||
/** 待分配且未指派、无份额、未发布时,客服可以把补资料状态重置回待完善。 */
|
||||
canResetMaterial: boolean
|
||||
canAccept: boolean
|
||||
canMarkProblem: boolean
|
||||
canDelete: boolean
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -29,21 +29,40 @@ 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,
|
||||
})
|
||||
|
||||
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 +107,39 @@ router.post(
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/link/:token',
|
||||
'/link/:code',
|
||||
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 +151,7 @@ router.post(
|
||||
{
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[collect/link/:token/upload]',
|
||||
scope: '[collect/link/:code/upload]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
+116
-50
@@ -2,67 +2,133 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
resolveWorkOrderMaterialLinkUnavailableReason,
|
||||
formatPublicMaterialStatus,
|
||||
pickSubmittedMaterialFields,
|
||||
resolveMaterialResetBlockReason,
|
||||
resolvePublicMaterialOrderState,
|
||||
} from './work-order-material-link-service.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('补资料链接:未知状态一律视为失效,避免误放行', () => {
|
||||
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,133 @@ 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 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 mapped = mapCollectLookupOrder(order)
|
||||
const publicOrders = orders.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 +331,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(order.material_json),
|
||||
collect: {
|
||||
@@ -311,7 +339,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload:
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete: true,
|
||||
source: 'customer_link',
|
||||
source: 'customer_order_no',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -328,33 +356,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 +391,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]'
|
||||
}
|
||||
@@ -46,6 +46,9 @@ 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 CollectMaterialLinkEntryPage = lazy(
|
||||
() => import('@/pages/collect/CollectMaterialLinkEntryPage'),
|
||||
)
|
||||
const CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
|
||||
@@ -124,8 +127,8 @@ export default function App() {
|
||||
return <RedeemApplication />
|
||||
}
|
||||
|
||||
// 一次性补资料链接(`/supplement/<token>`):路径式公开页,先于站点判断,
|
||||
// 保证在后台域名和打手端域名下都能打开。
|
||||
// 补资料页(`/supplement`):路径式公开页,先于站点判断,
|
||||
// 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。
|
||||
if (isCollectMaterialLinkPath(window.location.pathname)) {
|
||||
return <CollectMaterialLinkApplication />
|
||||
}
|
||||
@@ -235,9 +238,13 @@ function ClaimApplication() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性补资料链接(`/supplement/<token>`)的独立路由树。
|
||||
* 补资料页(`/supplement`)的独立路由树。
|
||||
*
|
||||
* 路径式公开页,不带 hash;页面只读取 token,不依赖后台会话。
|
||||
* - `/supplement`:输入平台订单号;
|
||||
* - `/supplement/<订单号>`:直接打开对应订单的补资料页;
|
||||
* - 其余子路径:交给补资料页展示「订单号无效」。
|
||||
*
|
||||
* 路径式公开页,不带 hash;不依赖后台会话。
|
||||
*/
|
||||
function CollectMaterialLinkApplication() {
|
||||
useClearStrayHash()
|
||||
@@ -246,7 +253,8 @@ function CollectMaterialLinkApplication() {
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/supplement/:token" element={<CollectMaterialLinkPage />} />
|
||||
<Route path="/supplement" element={<CollectMaterialLinkEntryPage />} />
|
||||
<Route path="/supplement/:code" element={<CollectMaterialLinkPage />} />
|
||||
<Route path="*" element={<CollectMaterialLinkPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -21,7 +21,7 @@ type EventNodeStyle = {
|
||||
/** 工单状态中文名 */
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending_material: '待完善资料',
|
||||
unassigned: '未分配',
|
||||
unassigned: '待分配',
|
||||
open: '待抢单',
|
||||
in_progress: '代练中',
|
||||
pending_acceptance: '待验收',
|
||||
@@ -176,6 +176,14 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
return { title: '完善了订单资料', color: 'cyan' }
|
||||
case 'material_updated':
|
||||
return { title: '更新了订单资料', color: 'cyan' }
|
||||
case 'material_reset':
|
||||
return {
|
||||
title: note ? `客服重置补资料状态:${note}` : '客服重置补资料状态',
|
||||
description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel(
|
||||
event.toStatus,
|
||||
)}`,
|
||||
color: 'orange',
|
||||
}
|
||||
|
||||
// —— 好友赠送节点 ——
|
||||
case 'gift_material_submitted':
|
||||
|
||||
@@ -28,7 +28,7 @@ type ImageUploadProps = {
|
||||
captureGlobalPaste?: boolean
|
||||
/** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */
|
||||
allowCamera?: boolean
|
||||
/** 自定义上传函数;公开一次性链接用它把文件传到 token 校验过的上传接口。 */
|
||||
/** 自定义上传函数;公开一次性链接用它把文件传到带订单号 / workOrderId 校验的上传接口。 */
|
||||
uploader?: ImageUploadUploader
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ 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'
|
||||
@@ -45,7 +44,6 @@ export default function AnnouncementPanel() {
|
||||
<DepositUnfreezeCard />
|
||||
<MockCustomShopsCard />
|
||||
<HallConfigPanel />
|
||||
<MaterialLinkConfigPanel />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
cancelAdminWorkOrderSharingPart,
|
||||
completeAdminWorkOrderGiftCooldown,
|
||||
confirmAdminWorkOrderFriend,
|
||||
createAdminWorkOrderMaterialLink,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
deleteAdminWorkOrder,
|
||||
disposeAdminWorkOrderShareProblem,
|
||||
@@ -78,6 +77,7 @@ import {
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
resetAdminWorkOrderGiftMaterial,
|
||||
resetAdminWorkOrderMaterialStatus,
|
||||
resolveAdminProblemWorkOrder,
|
||||
submitAdminWorkOrderMaterial,
|
||||
unpublishAdminWorkOrder,
|
||||
@@ -106,6 +106,7 @@ import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildCollectMaterialLinkUrl } from '@/utils/collect-link'
|
||||
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
||||
import {
|
||||
DeadlineCountdown,
|
||||
@@ -203,10 +204,11 @@ 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'
|
||||
const platformOrderId = String(order.platformOrderId || '').trim()
|
||||
return (status === 'pending_material' || status === 'unassigned') && Boolean(platformOrderId)
|
||||
}
|
||||
|
||||
/** 剪贴板 API 在 HTTP + IP 下可能不可用,补一个 textarea 兜底。 */
|
||||
@@ -351,11 +353,7 @@ 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 [materialLinkFallback, setMaterialLinkFallback] = useState<{ url: string } | null>(null)
|
||||
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
|
||||
const [reduceShare, setReduceShare] = useState<WorkOrderShare | null>(null)
|
||||
@@ -1491,7 +1489,7 @@ export default function WorkOrdersPanel() {
|
||||
modal.confirm({
|
||||
title: `确认删除订单 ${getDisplayOrderNo(row)}?`,
|
||||
content:
|
||||
'删除后工单将从列表中移除,且不可恢复。仅未分配、待完善或已取消且无拼单参与的工单可删除。',
|
||||
'删除后工单将从列表中移除,且不可恢复。仅待分配、待完善或已取消且无拼单参与的工单可删除。',
|
||||
okText: '确认删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
@@ -1577,6 +1575,22 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
function requestResetMaterial(row: WorkOrder) {
|
||||
modal.confirm({
|
||||
title: '重置补资料状态?',
|
||||
content:
|
||||
'将把该工单重置为“待完善”,客户可用订单号重新提交一次资料。已填资料会保留,客户重新提交后覆盖;此操作会记录在订单流转记录中。',
|
||||
okText: '确认重置',
|
||||
okButtonProps: { type: 'primary' },
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
runAction(
|
||||
() => resetAdminWorkOrderMaterialStatus(row.workOrderId),
|
||||
'已重置为待完善,客户刷新补资料页即可重新提交',
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function copyBuyerMaterialInfo(order: WorkOrder) {
|
||||
const items = getMaterialDetailItems(order).filter((item) => String(item.value || '').trim())
|
||||
if (items.length === 0) {
|
||||
@@ -1635,24 +1649,23 @@ export default function WorkOrdersPanel() {
|
||||
}
|
||||
|
||||
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 platformOrderId = String(order.platformOrderId || '').trim()
|
||||
if (!platformOrderId) {
|
||||
message.error('复制补资料链接失败:缺少平台订单号')
|
||||
return
|
||||
}
|
||||
const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId)
|
||||
if (!url) {
|
||||
message.error('复制补资料链接失败:链接地址为空')
|
||||
return
|
||||
}
|
||||
const copied = await copyTextToClipboard(url)
|
||||
if (copied) {
|
||||
message.success(`补资料链接已复制(订单号 ${platformOrderId})`)
|
||||
return
|
||||
}
|
||||
// 剪贴板 API 不可用时退回弹窗,让客服手动复制。
|
||||
setMaterialLinkFallback({ url })
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkOrder> = [
|
||||
@@ -1914,13 +1927,13 @@ export default function WorkOrdersPanel() {
|
||||
onClick={() => {
|
||||
modal.confirm({
|
||||
title: '确认重新启用该订单?',
|
||||
content: '订单将恢复为未分配状态。请确认资料与金额后,再发布到抢单大厅。',
|
||||
content: '订单将恢复为待分配状态。请确认资料与金额后,再发布到抢单大厅。',
|
||||
okText: '重新启用',
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
runAction(
|
||||
() => reopenAdminCancelledWorkOrder(row.workOrderId),
|
||||
'订单已恢复为未分配状态,可编辑后发布',
|
||||
'订单已恢复为待分配状态,可编辑后发布',
|
||||
),
|
||||
})
|
||||
}}
|
||||
@@ -1967,13 +1980,21 @@ export default function WorkOrdersPanel() {
|
||||
{row.capabilities?.canUpdateMaterial && canUpdateMaterial && canCopyMaterialLink(row) ? (
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
loading={materialLinkLoadingId === row.workOrderId}
|
||||
onClick={() => void copyMaterialLink(row)}
|
||||
title="生成一次性补资料链接(有效期内仅可提交一次)"
|
||||
title="复制补资料链接(按平台订单号打开)"
|
||||
>
|
||||
复制补资料链接
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canResetMaterial && canUpdateMaterial ? (
|
||||
<Button
|
||||
icon={<RollbackOutlined />}
|
||||
onClick={() => requestResetMaterial(row)}
|
||||
title="把已提交的补资料重置为待完善,客户可重新提交一次"
|
||||
>
|
||||
重置补资料
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canPublish && canPublishOrder ? (
|
||||
<Button
|
||||
icon={<SendOutlined />}
|
||||
@@ -2006,11 +2027,11 @@ export default function WorkOrdersPanel() {
|
||||
onClick={() => {
|
||||
modal.confirm({
|
||||
title: '确认下架该订单?',
|
||||
content: '下架后订单会回到未分配状态,不再在抢单大厅展示。',
|
||||
content: '下架后订单会回到待分配状态,不再在抢单大厅展示。',
|
||||
okText: '确认下架',
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
runAction(() => unpublishAdminWorkOrder(row.workOrderId), '订单已撤回到未分配'),
|
||||
runAction(() => unpublishAdminWorkOrder(row.workOrderId), '订单已撤回到待分配'),
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -3251,7 +3272,7 @@ export default function WorkOrdersPanel() {
|
||||
{materialLinkFallback ? (
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
链接仅可提交一次,{formatAdminDateTime(materialLinkFallback.expiresAt)} 前有效。
|
||||
该链接按平台订单号打开,可查看该订单号下全部工单的补资料状态;每个工单仅可提交一次。
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={materialLinkFallback.url}
|
||||
|
||||
@@ -205,7 +205,7 @@ export function formatMoney(value: number | undefined) {
|
||||
export function formatStatus(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
pending_material: '待完善',
|
||||
unassigned: '未分配',
|
||||
unassigned: '待分配',
|
||||
open: '待抢单',
|
||||
in_progress: '代练中',
|
||||
pending_acceptance: '待验收',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ArrowRightOutlined } from '@ant-design/icons'
|
||||
import { App, Button, Card, Form, Input, Space, Typography } from 'antd'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { normalizeOrderNoInput } from '@/utils/collect-link'
|
||||
import { useCollectLinkPageMeta } from './use-collect-link-page-meta'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
/**
|
||||
* 补资料入口页(`/supplement/`)。
|
||||
*
|
||||
* 客户只知道平台订单号时,从这里输入订单号再跳到 `/supplement/<订单号>`;
|
||||
* 如果客服已经发了完整链接,可以直接打开链接跳过这一步。
|
||||
*/
|
||||
export default function CollectMaterialLinkEntryPage() {
|
||||
const { message } = App.useApp()
|
||||
const navigate = useNavigate()
|
||||
useCollectLinkPageMeta('订单资料补充')
|
||||
|
||||
function handleFinish(values: { orderNo?: string }) {
|
||||
const orderNo = normalizeOrderNoInput(values.orderNo)
|
||||
if (!orderNo) {
|
||||
message.warning('请输入订单号')
|
||||
return
|
||||
}
|
||||
void navigate(`/supplement/${encodeURIComponent(orderNo)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3} className="collect-link-title">
|
||||
订单资料补充
|
||||
</Title>
|
||||
<Text type="secondary">请输入下单时使用的订单号,进入补资料页面。</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" onFinish={handleFinish}>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input autoComplete="off" allowClear placeholder="请输入订单号" size="large" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="submit"
|
||||
block
|
||||
icon={<ArrowRightOutlined />}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
如果客服已经发给你完整链接,直接打开链接即可。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -13,17 +13,23 @@ import {
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { uploadCollectMaterialLinkFile } from '@/services/files'
|
||||
import { fetchCollectMaterialLink, submitCollectMaterialLink } from '@/services/worker'
|
||||
import type { CollectMaterialLinkInfo, UploadedFile } from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import type {
|
||||
CollectMaterialLinkInfo,
|
||||
CollectMaterialLinkOrder,
|
||||
UploadedFile,
|
||||
} from '@/types/worker-platform'
|
||||
import { useCollectLinkPageMeta } from './use-collect-link-page-meta'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确'
|
||||
|
||||
type LinkLoadError = {
|
||||
code: string
|
||||
message: string
|
||||
@@ -38,135 +44,140 @@ function resolveErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
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_not_found' || code === 'material_link_work_order_invalid') {
|
||||
return '订单号无效'
|
||||
}
|
||||
if (code === 'material_link_work_order_required') return '请选择要补充的商品'
|
||||
if (code === 'material_link_order_status_invalid') return '订单状态已变化'
|
||||
return '无法打开链接'
|
||||
return '暂时无法打开页面'
|
||||
}
|
||||
|
||||
const STATUS_TAG_COLORS: Record<string, string> = {
|
||||
pending_material: 'orange',
|
||||
unassigned: 'blue',
|
||||
open: 'cyan',
|
||||
in_progress: 'processing',
|
||||
pending_acceptance: 'purple',
|
||||
problem: 'red',
|
||||
accepted: 'green',
|
||||
cancelled: 'default',
|
||||
}
|
||||
|
||||
function resolveStatusColor(status: string): string {
|
||||
return STATUS_TAG_COLORS[status] || 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户侧一次性补资料页。
|
||||
* 客户侧补资料页。
|
||||
*
|
||||
* 只渲染 requirement 里的动态字段与买家主页截图,不展示发单价格、押金、
|
||||
* 打手、后台备注等任何后台字段;提交成功后链接立即失效。
|
||||
* 只要知道平台订单号就能打开:一个订单号下可能有多个商品工单,页面按 orders
|
||||
* 渲染多张卡片。只有「待完善 + 未指派 + 无份额」的工单返回表单,其余工单只展示
|
||||
* 商品、工单号、状态与客服提示,不暴露已填资料和截图。
|
||||
* 提交成功后工单进入「待分配」,同一工单不能再提交,需联系客服重置。
|
||||
*/
|
||||
export default function CollectMaterialLinkPage() {
|
||||
const { message } = App.useApp()
|
||||
const { token: routeToken } = useParams<{ token?: string }>()
|
||||
const token = String(routeToken || '').trim()
|
||||
const { code: routeCode } = useParams<{ code?: string }>()
|
||||
const code = String(routeCode || '').trim()
|
||||
|
||||
useCollectLinkPageMeta('订单资料补充')
|
||||
|
||||
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[]>([])
|
||||
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
|
||||
const submittingRef = useRef(false)
|
||||
const requestSeqRef = useRef(0)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const previousTitle = document.title
|
||||
document.title = '订单资料补充'
|
||||
/**
|
||||
* 读取订单资料状态。
|
||||
*
|
||||
* silent=true 用于提交后刷新:不切整页 loading,避免页面闪烁丢焦点;
|
||||
* 同时用请求序号丢弃过期响应,防止快速重试时旧数据覆盖新数据。
|
||||
*/
|
||||
const loadInfo = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
const seq = requestSeqRef.current + 1
|
||||
requestSeqRef.current = seq
|
||||
|
||||
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) {
|
||||
if (!code) {
|
||||
setInfo(null)
|
||||
setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE })
|
||||
setLoading(false)
|
||||
setLoadError({
|
||||
code: 'material_link_not_found',
|
||||
message: '链接无效,请确认链接是否完整',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setSubmitted(false)
|
||||
if (!options.silent) setLoading(true)
|
||||
try {
|
||||
const response = await fetchCollectMaterialLink(token)
|
||||
if (cancelled) return
|
||||
const response = await fetchCollectMaterialLink(code)
|
||||
if (seq !== requestSeqRef.current) return
|
||||
const orders = response.data.orders || []
|
||||
if (orders.length === 0) {
|
||||
setInfo(null)
|
||||
setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE })
|
||||
return
|
||||
}
|
||||
setInfo(response.data)
|
||||
setLoadError(null)
|
||||
setScreenshots(response.data.screenshots || [])
|
||||
form.resetFields()
|
||||
form.setFieldsValue(response.data.values || {})
|
||||
setScreenshotsByOrder((previous) => {
|
||||
// 只保留仍可提交商品的本地截图,避免刷新后覆盖用户刚上传的图片;
|
||||
// 不可提交商品不再展示资料,直接丢弃。
|
||||
const next: Record<number, UploadedFile[]> = {}
|
||||
for (const order of orders) {
|
||||
if (!order.canSubmit) continue
|
||||
next[order.workOrderId] = previous[order.workOrderId] ?? order.screenshots ?? []
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
if (seq !== requestSeqRef.current) return
|
||||
setInfo(null)
|
||||
setLoadError({
|
||||
code: resolveErrorCode(error),
|
||||
message: resolveErrorMessage(error),
|
||||
message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE,
|
||||
})
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
if (seq === requestSeqRef.current && !options.silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[code],
|
||||
)
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [form, token])
|
||||
useEffect(() => {
|
||||
void loadInfo()
|
||||
}, [loadInfo])
|
||||
|
||||
async function handleSubmit(values: Record<string, string>) {
|
||||
if (!token) return
|
||||
setSubmitting(true)
|
||||
async function handleSubmit(order: CollectMaterialLinkOrder, values: Record<string, string>) {
|
||||
if (!code) return
|
||||
// 多工单时用户可能连点不同卡片;前端只允许一个提交在途,避免列表状态互相覆盖。
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setSubmittingWorkOrderId(order.workOrderId)
|
||||
try {
|
||||
await submitCollectMaterialLink(token, { fields: values, screenshots })
|
||||
setSubmitted(true)
|
||||
await submitCollectMaterialLink(code, {
|
||||
workOrderId: order.workOrderId,
|
||||
fields: values,
|
||||
screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [],
|
||||
})
|
||||
message.success('资料已提交,订单已进入待分配')
|
||||
await loadInfo({ silent: 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
|
||||
}
|
||||
const errorCode = resolveErrorCode(error)
|
||||
message.error(resolveErrorMessage(error))
|
||||
// 状态类错误说明工单已被并发提交、被客服重置或流转到其他状态:
|
||||
// 以服务端状态为准静默刷新,页面切换成只读卡片而不是整页报错。
|
||||
if (
|
||||
errorCode === 'material_link_already_submitted' ||
|
||||
errorCode === 'material_link_order_status_invalid' ||
|
||||
errorCode === 'material_link_work_order_invalid' ||
|
||||
errorCode === 'material_link_not_found'
|
||||
) {
|
||||
await loadInfo({ silent: true })
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
submittingRef.current = false
|
||||
setSubmittingWorkOrderId(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,115 +187,106 @@ export default function CollectMaterialLinkPage() {
|
||||
{loading ? (
|
||||
<div className="collect-link-state">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
<Text type="secondary">正在读取补资料链接...</Text>
|
||||
<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 || '请联系客服重新获取链接'}
|
||||
/>
|
||||
)
|
||||
<Result
|
||||
status="warning"
|
||||
title={resolveErrorTitle(loadError.code)}
|
||||
subTitle={loadError.message || NOT_FOUND_MESSAGE}
|
||||
extra={
|
||||
<Link to="/supplement">
|
||||
<Button>重新输入订单号</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : info ? (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3} className="collect-link-title">
|
||||
订单资料补充
|
||||
</Title>
|
||||
<Text type="secondary">请补充以下资料,提交后客服会尽快处理。</Text>
|
||||
<Text type="secondary">
|
||||
{info.pendingCount > 0
|
||||
? '请补充以下资料,提交后客服会尽快处理。'
|
||||
: '以下为该订单当前的资料状态,如需修改请联系客服。'}
|
||||
</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>
|
||||
{info.pendingCount > 1 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前订单有 ${info.pendingCount} 个待完善商品,请逐个提交`}
|
||||
description="每个商品的资料仅可提交一次,提交成功后该商品进入待分配。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="链接仅可提交一次"
|
||||
description="提交成功后链接立即失效;如需再次修改资料,请联系客服重新获取链接。"
|
||||
/>
|
||||
{info.pendingCount === 0 ? (
|
||||
<Alert type="info" showIcon message="该订单当前不可修改资料,如需修改请联系客服" />
|
||||
) : null}
|
||||
|
||||
<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,
|
||||
}))}
|
||||
/>
|
||||
{info.orders.map((order) => (
|
||||
<Card
|
||||
key={order.workOrderId}
|
||||
className="collect-link-order-card"
|
||||
size="small"
|
||||
title={order.productName || '订单商品'}
|
||||
extra={<Tag color={resolveStatusColor(order.status)}>{order.statusLabel}</Tag>}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<div className="collect-link-meta">
|
||||
<div>
|
||||
<span>工单号</span>
|
||||
<strong>{order.workOrderNo || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>订单号</span>
|
||||
<strong>{order.platformOrderId || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.canSubmit ? (
|
||||
<>
|
||||
{info.pendingCount === 1 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该商品资料仅可提交一次"
|
||||
description="提交成功后该商品进入待分配,不能再修改;如需修改请联系客服。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MaterialLinkOrderForm
|
||||
order={order}
|
||||
code={code}
|
||||
screenshots={
|
||||
screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? []
|
||||
}
|
||||
submitting={submittingWorkOrderId === order.workOrderId}
|
||||
disabled={submittingWorkOrderId !== 0}
|
||||
onScreenshotsChange={(files) =>
|
||||
setScreenshotsByOrder((previous) => ({
|
||||
...previous,
|
||||
[order.workOrderId]: files,
|
||||
}))
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(order, values)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input autoComplete="off" placeholder={`请输入${field.label}`} />
|
||||
<Alert
|
||||
type={order.blockedReason === 'already_submitted' ? 'info' : 'warning'}
|
||||
showIcon
|
||||
message={order.message || '当前不能修改资料,如需修改请联系客服。'}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
本页面仅用于补充订单资料,请勿将链接转发给他人。
|
||||
本页面仅用于补充订单资料,请妥善保管订单号。
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
@@ -292,3 +294,83 @@ export default function CollectMaterialLinkPage() {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
type MaterialLinkOrderFormProps = {
|
||||
order: CollectMaterialLinkOrder
|
||||
code: string
|
||||
screenshots: UploadedFile[]
|
||||
submitting: boolean
|
||||
disabled: boolean
|
||||
onScreenshotsChange: (files: UploadedFile[]) => void
|
||||
onSubmit: (values: Record<string, string>) => void
|
||||
}
|
||||
|
||||
function MaterialLinkOrderForm({
|
||||
order,
|
||||
code,
|
||||
screenshots,
|
||||
submitting,
|
||||
disabled,
|
||||
onScreenshotsChange,
|
||||
onSubmit,
|
||||
}: MaterialLinkOrderFormProps) {
|
||||
return (
|
||||
<Form layout="vertical" initialValues={order.values || {}} onFinish={onSubmit}>
|
||||
{(order.fields ?? []).map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={
|
||||
<Space size={6}>
|
||||
<span>{field.label}</span>
|
||||
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
|
||||
</Space>
|
||||
}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{field.type === 'select' && (field.options || []).length > 0 ? (
|
||||
<Radio.Group
|
||||
options={(field.options || []).map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Input autoComplete="off" placeholder={`请输入${field.label}`} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
<Form.Item label="买家主页截图">
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="collect"
|
||||
maxCount={5}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
value={screenshots}
|
||||
onChange={onScreenshotsChange}
|
||||
uploader={(file, scene) =>
|
||||
uploadCollectMaterialLinkFile(file, scene, code, order.workOrderId)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="collect-link-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
提交资料
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 公开补资料页共用的 document.title / robots / referrer 设置。
|
||||
*
|
||||
* 入口页和补资料页都是可被直接访问的公开路径,必须禁止搜索引擎收录,
|
||||
* 并避免平台订单号通过 referrer 泄漏给第三方。
|
||||
*/
|
||||
export function useCollectLinkPageMeta(title: string) {
|
||||
useEffect(() => {
|
||||
const previousTitle = document.title
|
||||
document.title = 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()
|
||||
}
|
||||
}, [title])
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
WorkerDepositUnfreeze,
|
||||
WorkerFinanceConfig,
|
||||
WorkerHallConfig,
|
||||
WorkerMaterialLinkConfig,
|
||||
WorkerRiskConfig,
|
||||
WorkerRiskOverview,
|
||||
WorkerRiskEventList,
|
||||
@@ -102,17 +101,6 @@ 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')
|
||||
}
|
||||
@@ -769,9 +757,10 @@ 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 resetAdminWorkOrderMaterialStatus(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/reset-material`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,16 +22,22 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope)
|
||||
/**
|
||||
* 一次性补资料链接的截图上传。
|
||||
*
|
||||
* 与公开 collect/upload 的区别:先把 token 放在路径里,后端会校验链接
|
||||
* 是否仍可用;链接已使用/过期时不会落文件。
|
||||
* 与公开 collect/upload 的区别:路径里带平台订单号,多工单时再带 workOrderId;
|
||||
* 后端会校验对应链接是否仍可用,链接已使用/过期时不会落文件。
|
||||
*/
|
||||
export async function uploadCollectMaterialLinkFile(file: File, scene: string, token: string) {
|
||||
export async function uploadCollectMaterialLinkFile(
|
||||
file: File,
|
||||
scene: string,
|
||||
code: string,
|
||||
workOrderId?: number,
|
||||
) {
|
||||
const uploadTarget = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', uploadTarget)
|
||||
form.append('scene', scene)
|
||||
const query = workOrderId ? `?workOrderId=${encodeURIComponent(String(workOrderId))}` : ''
|
||||
const response = await apiPostForm<{ file: UploadedFile }>(
|
||||
`/api/v1/collect/link/${encodeURIComponent(token)}/upload`,
|
||||
`/api/v1/collect/link/${encodeURIComponent(code)}/upload${query}`,
|
||||
form,
|
||||
)
|
||||
return response.data.file
|
||||
|
||||
@@ -381,19 +381,20 @@ export function submitCollectOrder(payload: {
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCollectMaterialLink(token: string) {
|
||||
return apiGet<CollectMaterialLinkInfo>(`/api/v1/collect/link/${encodeURIComponent(token)}`)
|
||||
export function fetchCollectMaterialLink(code: string) {
|
||||
return apiGet<CollectMaterialLinkInfo>(`/api/v1/collect/link/${encodeURIComponent(code)}`)
|
||||
}
|
||||
|
||||
export function submitCollectMaterialLink(
|
||||
token: string,
|
||||
code: string,
|
||||
payload: {
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
},
|
||||
) {
|
||||
return apiPost<CollectMaterialLinkSubmitResult>(
|
||||
`/api/v1/collect/link/${encodeURIComponent(token)}/submit`,
|
||||
`/api/v1/collect/link/${encodeURIComponent(code)}/submit`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -499,6 +499,11 @@
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
/* 一单多工单时,内层商品卡片比外层容器更紧凑 */
|
||||
.collect-link-order-card .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.collect-link-title {
|
||||
margin: 0 0 4px !important;
|
||||
}
|
||||
|
||||
@@ -334,11 +334,6 @@ export type WorkerHallConfig = {
|
||||
queuedOrderCount: number
|
||||
}
|
||||
|
||||
/** 一次性补资料链接配置:有效期只影响新生成的链接。 */
|
||||
export type WorkerMaterialLinkConfig = {
|
||||
ttlMinutes: number
|
||||
}
|
||||
|
||||
export type WorkerWithdrawalAccount = {
|
||||
accountChannel: string
|
||||
accountName: string
|
||||
@@ -647,6 +642,8 @@ export type WorkOrder = {
|
||||
canPin: boolean
|
||||
canUnpublish: boolean
|
||||
canUpdateMaterial: boolean
|
||||
/** 待分配且未指派、无份额、未发布时,客服可以把补资料状态重置回待完善。 */
|
||||
canResetMaterial?: boolean
|
||||
canAccept: boolean
|
||||
canMarkProblem: boolean
|
||||
canDelete: boolean
|
||||
@@ -867,22 +864,39 @@ export type CollectLookupResponse = {
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
/** 一次性补资料链接的公开只读信息(不含价格/押金/打手等后台字段)。 */
|
||||
export type CollectMaterialLinkInfo = {
|
||||
order: {
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
}
|
||||
fields: CollectField[]
|
||||
export type CollectMaterialLinkBlockedReason =
|
||||
| 'already_submitted'
|
||||
| 'status'
|
||||
| 'assigned'
|
||||
| 'shares'
|
||||
| ''
|
||||
|
||||
/** 公开补资料页的单个工单;只有 canSubmit=true 时才有字段/已填值/截图。 */
|
||||
export type CollectMaterialLinkOrder = {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
statusLabel: string
|
||||
canSubmit: boolean
|
||||
blockedReason: CollectMaterialLinkBlockedReason
|
||||
message: string
|
||||
fields?: CollectField[]
|
||||
values?: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
/** 按平台订单号读取到的补资料信息;一单多工单时 orders 会有多条。 */
|
||||
export type CollectMaterialLinkInfo = {
|
||||
platformOrderId: string
|
||||
orders: CollectMaterialLinkOrder[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
export type CollectMaterialLinkSubmitResult = {
|
||||
complete: boolean
|
||||
workOrderId: number
|
||||
order: {
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 一次性补资料链接的路径解析。
|
||||
*
|
||||
* 公开链接形如:`https://<域名>/supplement/<token>`,与后台 HashRouter 完全隔离。
|
||||
* 命名空间判定用于路由分发:`/supplement` 及子路径整段交给公开页,
|
||||
* 避免落到后台路由或打手端 SPA。
|
||||
* 公开链接有两种形态:
|
||||
* - `https://<域名>/supplement/`:输入平台订单号;
|
||||
* - `https://<域名>/supplement/<平台订单号>`:直接打开对应订单的补资料页。
|
||||
* 与后台 HashRouter 完全隔离。
|
||||
*/
|
||||
|
||||
export function isCollectMaterialLinkPath(pathname: unknown): boolean {
|
||||
@@ -11,14 +12,44 @@ export function isCollectMaterialLinkPath(pathname: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 只认 `/supplement/<一段 token>`:
|
||||
* 只认 `/supplement/<一段 code>`:
|
||||
* - `/supplement/abc` 命中,结尾多余斜杠容忍;
|
||||
* - `/supplement`、`/supplement/a/b` 不命中(页面展示链接无效)。
|
||||
* - `/supplement`、`/supplement/a/b` 不命中(由入口页/页面自己展示无效)。
|
||||
*/
|
||||
export function resolveCollectMaterialLinkToken(pathname: unknown): string {
|
||||
export function resolveCollectMaterialLinkCode(pathname: unknown): string {
|
||||
const segments = splitPathSegments(pathname)
|
||||
if (segments.length !== 2 || segments[0] !== 'supplement') return ''
|
||||
return decodeTokenSegment(segments[1])
|
||||
return decodePathSegment(segments[1])
|
||||
}
|
||||
|
||||
/** 统一拼补资料链接;origin 结尾多余斜杠会被去掉。 */
|
||||
export function buildCollectMaterialLinkUrl(origin: unknown, code: unknown): string {
|
||||
const base = String(origin ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
const normalized = String(code ?? '').trim()
|
||||
if (!base || !normalized) return ''
|
||||
return `${base}/supplement/${encodeURIComponent(normalized)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口页输入归一化:去空格;如果用户粘贴了完整 `/supplement/<订单号>` 链接,
|
||||
* 自动提取最后一段,避免把整条 URL 当成订单号。
|
||||
*/
|
||||
export function normalizeOrderNoInput(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const marker = '/supplement/'
|
||||
const index = raw.indexOf(marker)
|
||||
if (index < 0) return raw
|
||||
|
||||
const segment = raw
|
||||
.slice(index + marker.length)
|
||||
.split(/[?#]/)[0]
|
||||
.replace(/\/+$/, '')
|
||||
.trim()
|
||||
return decodePathSegment(segment)
|
||||
}
|
||||
|
||||
function splitPathSegments(pathname: unknown): string[] {
|
||||
@@ -28,7 +59,7 @@ function splitPathSegments(pathname: unknown): string[] {
|
||||
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function decodeTokenSegment(segment: string): string {
|
||||
function decodePathSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
|
||||
@@ -2,31 +2,65 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildCollectMaterialLinkUrl,
|
||||
isCollectMaterialLinkPath,
|
||||
resolveCollectMaterialLinkToken,
|
||||
normalizeOrderNoInput,
|
||||
resolveCollectMaterialLinkCode,
|
||||
} from '../src/utils/collect-link.ts'
|
||||
|
||||
test('补资料链接:/supplement/<token> 命中并解析 token', () => {
|
||||
assert.equal(isCollectMaterialLinkPath('/supplement/abc123'), true)
|
||||
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123'), 'abc123')
|
||||
test('补资料链接:/supplement/<订单号> 命中并解析 code', () => {
|
||||
assert.equal(isCollectMaterialLinkPath('/supplement/2630501851236676'), true)
|
||||
assert.equal(resolveCollectMaterialLinkCode('/supplement/2630501851236676'), '2630501851236676')
|
||||
})
|
||||
|
||||
test('补资料链接:入口页 /supplement 命中命名空间但没有 code', () => {
|
||||
assert.equal(isCollectMaterialLinkPath('/supplement'), true)
|
||||
assert.equal(isCollectMaterialLinkPath('/supplement/'), true)
|
||||
assert.equal(resolveCollectMaterialLinkCode('/supplement'), '')
|
||||
assert.equal(resolveCollectMaterialLinkCode('/supplement/'), '')
|
||||
})
|
||||
|
||||
test('补资料链接:容忍结尾斜杠、查询串与 hash', () => {
|
||||
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123/'), 'abc123')
|
||||
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123?from=chat'), 'abc123')
|
||||
assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123#top'), 'abc123')
|
||||
assert.equal(resolveCollectMaterialLinkCode('/supplement/2630501851236676/'), '2630501851236676')
|
||||
assert.equal(
|
||||
resolveCollectMaterialLinkCode('/supplement/2630501851236676?from=chat'),
|
||||
'2630501851236676',
|
||||
)
|
||||
assert.equal(
|
||||
resolveCollectMaterialLinkCode('/supplement/2630501851236676#top'),
|
||||
'2630501851236676',
|
||||
)
|
||||
})
|
||||
|
||||
test('补资料链接:命名空间命中但缺少 token 时返回空串', () => {
|
||||
assert.equal(isCollectMaterialLinkPath('/supplement'), true)
|
||||
assert.equal(isCollectMaterialLinkPath('/supplement/'), true)
|
||||
assert.equal(resolveCollectMaterialLinkToken('/supplement'), '')
|
||||
assert.equal(resolveCollectMaterialLinkToken('/supplement/a/b'), '')
|
||||
test('补资料链接:多段路径不解析 code', () => {
|
||||
assert.equal(resolveCollectMaterialLinkCode('/supplement/a/b'), '')
|
||||
})
|
||||
|
||||
test('补资料链接:其它路径不命中', () => {
|
||||
for (const pathname of ['/', '/claim/abc', '/collect', '/supplementx/abc', '']) {
|
||||
assert.equal(isCollectMaterialLinkPath(pathname), false, pathname)
|
||||
assert.equal(resolveCollectMaterialLinkToken(pathname), '', pathname)
|
||||
assert.equal(resolveCollectMaterialLinkCode(pathname), '', pathname)
|
||||
}
|
||||
})
|
||||
|
||||
test('补资料链接:URL 构建去掉 origin 结尾斜杠并编码', () => {
|
||||
assert.equal(
|
||||
buildCollectMaterialLinkUrl('https://ks.khhao.com/', '2630501851236676'),
|
||||
'https://ks.khhao.com/supplement/2630501851236676',
|
||||
)
|
||||
assert.equal(
|
||||
buildCollectMaterialLinkUrl('https://ks.khhao.com', 'abc/def'),
|
||||
'https://ks.khhao.com/supplement/abc%2Fdef',
|
||||
)
|
||||
assert.equal(buildCollectMaterialLinkUrl('', 'abc'), '')
|
||||
assert.equal(buildCollectMaterialLinkUrl('https://ks.khhao.com', ''), '')
|
||||
})
|
||||
|
||||
test('补资料入口:输入归一化支持订单号与完整链接', () => {
|
||||
assert.equal(normalizeOrderNoInput(' 2630501851236676 '), '2630501851236676')
|
||||
assert.equal(
|
||||
normalizeOrderNoInput('https://ks.khhao.com/supplement/2630501851236676?from=chat#top'),
|
||||
'2630501851236676',
|
||||
)
|
||||
assert.equal(normalizeOrderNoInput(''), '')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user