refactor(collect): 移除旧补资料链接 token 体系

- 删除管理端生成补资料链接接口、链接仓储读写与 TTL 配置服务
- 删除补资料链接配置面板及前端相关服务、类型
- 新增迁移 113:删除 work_order_material_links 表与 work_order_material_link 配置
- 「复制补资料链接」改为本地复制 /supplement/<平台订单号>,不再请求后端
This commit is contained in:
2026-11-01 18:26:07 +08:00
parent a2cf48f5fc
commit db73e8f820
16 changed files with 36 additions and 555 deletions
@@ -9,8 +9,6 @@ export const APP_CONFIG_KEYS = {
workerHall: 'worker_hall',
workerRiskControl: 'worker_risk_control',
workerMockShops: 'worker_mock_shops',
/** 接单工单:一次性补资料链接配置(有效期分钟数) */
workOrderMaterialLink: 'work_order_material_link',
kuaishouIndustrySource: 'kuaishou_industry_source',
affiliateDash: 'affiliate_dash',
pddSource: 'pdd_source',
@@ -0,0 +1,12 @@
-- 113_drop_work_order_material_links.sql —— 下线旧的「一次性补资料链接 token」体系
--
-- 背景:补资料页已改为按平台订单号直连(/supplement/:订单号),
-- 不再需要客服先生成 token 链接:
-- - 后端:管理端 /orders/:id/material-link、链接仓储读写、material-link-config 配置接口
-- - 前端:补资料链接有效期设置面板、生成链接请求
-- - 数据:work_order_material_links 表、app_config_entries.work_order_material_link
-- 历史 token 记录已无任何读取入口,直接随表删除。
DROP TABLE IF EXISTS work_order_material_links;
DELETE FROM app_config_entries WHERE config_key = 'work_order_material_link';
@@ -2,20 +2,6 @@ import type { PoolClient } from 'pg'
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
}
/**
* 锁工单并回读「有效拼单份额」。
*
@@ -41,63 +27,6 @@ export async function getWorkOrderForUpdateWithClient(
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 updateWorkOrderMaterialWithClient(
client: PoolClient,
input: {
@@ -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,
@@ -187,36 +186,6 @@ router.post(
),
)
router.post(
'/worker-platform/orders/:workOrderId/material-link',
requireAdminPermission('worker_order.update_material'),
createJsonHandler(
(req) =>
createAdminWorkOrderMaterialLink(
String(req.params.workOrderId || ''),
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '补资料链接已生成',
errorMessage: '生成补资料链接失败',
scope: '[admin/worker-platform/orders/:workOrderId/material-link]',
audit: (req, data) => ({
action: 'work_order_material_link_created',
targetType: 'work_order',
targetId: String(req.params.workOrderId || ''),
// 审计只记录有效期与是否复用,绝不落 token。
data:
data && typeof data === 'object'
? {
expiresAt: (data as Record<string, unknown>).expiresAt,
reused: (data as Record<string, unknown>).reused,
}
: {},
}),
},
),
)
router.post(
'/worker-platform/orders/:workOrderId/reset-material',
requireAdminPermission('worker_order.update_material'),
@@ -65,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'
@@ -6,7 +6,6 @@ import {
pickSubmittedMaterialFields,
resolveMaterialResetBlockReason,
resolvePublicMaterialOrderState,
resolveWorkOrderMaterialLinkOrderBlockReason,
} from './work-order-material-link-service.js'
test('公开补资料:只有待完善且未指派/无份额可提交', () => {
@@ -129,50 +128,7 @@ test('客服重置补资料:只允许待分配、未指派、无份额、未
)
})
test('管理端链接准入:待完善/待分配可用,已指派/有份额/其他状态拒绝', () => {
assert.equal(
resolveWorkOrderMaterialLinkOrderBlockReason({
status: 'pending_material',
assigned_worker_id: null,
active_share_quantity: 0,
}),
'',
)
assert.equal(
resolveWorkOrderMaterialLinkOrderBlockReason({
status: 'unassigned',
assigned_worker_id: null,
active_share_quantity: 0,
}),
'',
)
assert.equal(
resolveWorkOrderMaterialLinkOrderBlockReason({
status: 'open',
assigned_worker_id: null,
active_share_quantity: 0,
}),
'status',
)
assert.equal(
resolveWorkOrderMaterialLinkOrderBlockReason({
status: 'pending_material',
assigned_worker_id: 9,
active_share_quantity: 0,
}),
'assigned',
)
assert.equal(
resolveWorkOrderMaterialLinkOrderBlockReason({
status: 'pending_material',
assigned_worker_id: null,
active_share_quantity: 2,
}),
'shares',
)
})
test('补资料链接:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], {
gameId: '123',
gameNickname: '张三',
@@ -1,14 +1,9 @@
import { randomBytes } from 'node:crypto'
import { withTransaction } from '../../db/client.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
createWorkOrderEventWithClient,
createWorkOrderMaterialLinkWithClient,
findActiveWorkOrderMaterialLinkWithClient,
getWorkOrderForUpdateWithClient,
listWorkOrdersByPlatformOrderId,
revokeActiveWorkOrderMaterialLinksWithClient,
updateWorkOrderMaterialWithClient,
} from '../../repositories/worker-platform/index.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
@@ -21,20 +16,13 @@ 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
const PUBLIC_MATERIAL_STATUS_LABELS: Record<string, string> = {
@@ -49,8 +37,6 @@ const PUBLIC_MATERIAL_STATUS_LABELS: Record<string, string> = {
vip_evidence_pending: '待补图',
}
export type WorkOrderMaterialLinkOrderBlockReason = 'status' | 'assigned' | 'shares' | ''
export type PublicMaterialOrderBlockReason =
| 'already_submitted'
| 'status'
@@ -154,21 +140,6 @@ export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReaso
return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态'
}
/**
* 管理端生成/复用一次性链接的准入判断。
*
* 用户要求保留现有“复制补资料链接”按钮,所以这里仍按原口径允许
* 待完善 / 待分配,并拒绝已指派/有份额的工单。
*/
export function resolveWorkOrderMaterialLinkOrderBlockReason(
order: Pick<WorkOrderRow, 'status' | 'assigned_worker_id' | 'active_share_quantity'>,
): WorkOrderMaterialLinkOrderBlockReason {
if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) return 'status'
if (order.assigned_worker_id) return 'assigned'
if (Number(order.active_share_quantity || 0) > 0) return 'shares'
return ''
}
/**
* 只保留工单 requirement 里定义过的字段。
*
@@ -196,29 +167,6 @@ function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
})
}
/** 管理端生成补资料链接时仍沿用旧的准入文案。 */
function assertWorkOrderAllowedForMaterialLink(order: WorkOrderRow): void {
const reason = resolveWorkOrderMaterialLinkOrderBlockReason(order)
if (reason === 'status') {
throw createHttpError('当前状态不能生成补资料链接', {
statusCode: 409,
errorCode: 'material_link_status_invalid',
})
}
if (reason === 'assigned') {
throw createHttpError('订单已指派打手,不能生成补资料链接', {
statusCode: 409,
errorCode: 'material_link_status_invalid',
})
}
if (reason === 'shares') {
throw createHttpError('订单已有拼单份额,不能生成补资料链接', {
statusCode: 409,
errorCode: 'material_link_status_invalid',
})
}
}
function mapPublicOrder(order: WorkOrderRow) {
return {
workOrderNo: order.work_order_no,
@@ -228,12 +176,6 @@ 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 = {
@@ -290,76 +232,6 @@ async function resolvePublicMaterialTarget(
return orders[0]!
}
/**
* 管理端生成/复用一次性链接。
*
* 用户要求保留现有“复制补资料链接”按钮,因此链接记录仍会生成;
* 但公开页已经不再依赖它,复制出的 `/supplement/<平台订单号>` 始终可用。
*/
export async function createAdminWorkOrderMaterialLink(
workOrderId: number | string,
actorName = '',
) {
const { ttlMinutes } = getWorkerMaterialLinkConfig()
return withTransaction(async (client) => {
const order = await getWorkOrderForUpdateWithClient(client, workOrderId)
if (!order) {
throw createHttpError('接单订单不存在', {
statusCode: 404,
errorCode: 'work_order_not_found',
})
}
if (!mapWorkOrderAdmin(order).capabilities.canUpdateMaterial) {
throw createHttpError('当前状态不能生成补资料链接', {
statusCode: 409,
errorCode: 'material_link_status_invalid',
})
}
const platformOrderId = String(order.platform_order_id || '').trim()
if (!platformOrderId) {
throw createHttpError('该工单没有平台订单号,不能生成补资料链接', {
statusCode: 409,
errorCode: 'material_link_platform_order_missing',
})
}
assertWorkOrderAllowedForMaterialLink(order)
// 锁到手后再取时间,避免等锁期间旧链接过期却仍按旧时间复用。
const now = nowIso()
const existing = await findActiveWorkOrderMaterialLinkWithClient(client, order.id, now)
if (existing) {
return {
token: existing.token,
platformOrderId,
expiresAt: toIsoTimestamp(existing.expires_at),
reused: true,
}
}
await revokeActiveWorkOrderMaterialLinksWithClient(client, order.id, now)
const token = randomBytes(24).toString('base64url')
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString()
const link = await createWorkOrderMaterialLinkWithClient(client, {
workOrderId: order.id,
token,
expiresAt,
createdBy: String(actorName || '').trim(),
now,
})
return {
token: link.token,
platformOrderId,
expiresAt: toIsoTimestamp(link.expires_at),
reused: false,
}
})
}
/**
* 公开页按平台订单号读取补资料内容。
*
@@ -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]'
}
+3 -3
View File
@@ -127,7 +127,7 @@ export default function App() {
return <RedeemApplication />
}
// 一次性补资料链接(`/supplement`):路径式公开页,先于站点判断,
// 补资料页(`/supplement`):路径式公开页,先于站点判断,
// 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。
if (isCollectMaterialLinkPath(window.location.pathname)) {
return <CollectMaterialLinkApplication />
@@ -238,11 +238,11 @@ function ClaimApplication() {
}
/**
* 一次性补资料链接(`/supplement`)的独立路由树。
* 补资料页(`/supplement`)的独立路由树。
*
* - `/supplement`:输入平台订单号;
* - `/supplement/<订单号>`:直接打开对应订单的补资料页;
* - 其余子路径:交给补资料页展示「链接无效」。
* - 其余子路径:交给补资料页展示「订单号无效」。
*
* 路径式公开页,不带 hash;不依赖后台会话。
*/
@@ -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,
@@ -205,7 +204,7 @@ 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 || '')
const platformOrderId = String(order.platformOrderId || '').trim()
@@ -354,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)
@@ -1654,36 +1649,23 @@ export default function WorkOrdersPanel() {
}
async function copyMaterialLink(order: WorkOrder) {
setMaterialLinkLoadingId(order.workOrderId)
try {
const response = await createAdminWorkOrderMaterialLink(order.workOrderId)
// 新后端返回 platformOrderId;旧后端缺失时回退 token,仅用于前后端滚动发布。
const platformOrderId = String(
response.data.platformOrderId || response.data.token || '',
).trim()
if (!platformOrderId) {
message.error('生成补资料链接失败:缺少平台订单号')
return
}
const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId)
if (!url) {
message.error('生成补资料链接失败:链接地址为空')
return
}
const copied = await copyTextToClipboard(url)
const expiresText = formatAdminDateTime(response.data.expiresAt)
if (copied) {
message.success(
`${response.data.reused ? '已复制现有链接' : '已生成并复制链接'}(订单号 ${platformOrderId}),${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> = [
@@ -1998,9 +1980,8 @@ export default function WorkOrdersPanel() {
{row.capabilities?.canUpdateMaterial && canUpdateMaterial && canCopyMaterialLink(row) ? (
<Button
icon={<LinkOutlined />}
loading={materialLinkLoadingId === row.workOrderId}
onClick={() => void copyMaterialLink(row)}
title="生成一次性补资料链接(按平台订单号打开;有效期内每个工单仅可提交一次)"
title="复制补资料链接(按平台订单号打开)"
>
复制补资料链接
</Button>
@@ -3291,8 +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}
@@ -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,16 +757,6 @@ export function submitAdminWorkOrderMaterial(
)
}
export function createAdminWorkOrderMaterialLink(workOrderId: number) {
return apiPost<{
token: string
/** 公开 URL 使用的平台订单号;旧后端可能没有该字段,前端仅用于滚动发布兜底。 */
platformOrderId?: string
expiresAt: string
reused: boolean
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/material-link`)
}
/** 客服重置补资料:仅「待分配 + 未指派 + 无份额 + 未发布」可重置回待完善。 */
export function resetAdminWorkOrderMaterialStatus(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(
@@ -334,11 +334,6 @@ export type WorkerHallConfig = {
queuedOrderCount: number
}
/** 一次性补资料链接配置:有效期只影响新生成的链接。 */
export type WorkerMaterialLinkConfig = {
ttlMinutes: number
}
export type WorkerWithdrawalAccount = {
accountChannel: string
accountName: string