feat(外部接口): 新增自动上架成套通知能力
- 新增任务-详情全套入库后的自动上架通知与重试补发能力 - 扩展 product-assets 支持按 materialId 拉取正式资源 - 补充后台通知目标管理、投递记录和对接文档
This commit is contained in:
parent
bc4c609826
commit
02ebaabd0e
@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
|
||||
import { retryExternalNotificationDelivery } from '@/lib/external-auto-listing'
|
||||
|
||||
async function assertAdminPermission() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user || !(session.user as any).id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id
|
||||
const userRole = (session.user as any).role
|
||||
const isSuperAdminUser = await isSuperAdmin(userId)
|
||||
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
|
||||
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ success: false, error: '无权限操作' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
try {
|
||||
const authError = await assertAdminPermission()
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
const deliveryId = String(params?.id || '').trim()
|
||||
if (!deliveryId) {
|
||||
return NextResponse.json({ success: false, error: '投递记录不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
await retryExternalNotificationDelivery(deliveryId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '补发任务已重新入队',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin/external-notification-deliveries/[id]/retry] POST error:', error)
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
52
app/api/admin/external-notification-deliveries/route.ts
Normal file
52
app/api/admin/external-notification-deliveries/route.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
|
||||
import { listExternalNotificationDeliveries } from '@/lib/external-auto-listing'
|
||||
|
||||
async function assertAdminPermission() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user || !(session.user as any).id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id
|
||||
const userRole = (session.user as any).role
|
||||
const isSuperAdminUser = await isSuperAdmin(userId)
|
||||
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
|
||||
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ success: false, error: '无权限访问' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authError = await assertAdminPermission()
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const rawLimit = searchParams.get('limit')
|
||||
const parsedLimit = rawLimit ? Number(rawLimit) : undefined
|
||||
const limit = Number.isFinite(parsedLimit) && parsedLimit! > 0 ? parsedLimit : undefined
|
||||
|
||||
const deliveries = await listExternalNotificationDeliveries({
|
||||
status: searchParams.get('status') || undefined,
|
||||
targetId: searchParams.get('targetId') || undefined,
|
||||
materialId: searchParams.get('materialId') || undefined,
|
||||
limit,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: deliveries,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin/external-notification-deliveries] GET error:', error)
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
77
app/api/admin/external-notification-targets/[id]/route.ts
Normal file
77
app/api/admin/external-notification-targets/[id]/route.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
|
||||
import { updateExternalNotificationTarget } from '@/lib/external-auto-listing'
|
||||
|
||||
async function assertAdminPermission() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user || !(session.user as any).id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id
|
||||
const userRole = (session.user as any).role
|
||||
const isSuperAdminUser = await isSuperAdmin(userId)
|
||||
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
|
||||
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ success: false, error: '无权限操作' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toErrorResponse(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '服务器错误'
|
||||
const status =
|
||||
message === '通知目标不存在'
|
||||
? 404
|
||||
: message.includes('不能为空') ||
|
||||
message.includes('格式不正确') ||
|
||||
message.includes('仅支持') ||
|
||||
message.includes('必须提供')
|
||||
? 400
|
||||
: 500
|
||||
|
||||
return NextResponse.json({ success: false, error: message }, { status })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
try {
|
||||
const authError = await assertAdminPermission()
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
const targetId = String(params?.id || '').trim()
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: '通知目标不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const target = await updateExternalNotificationTarget(targetId, {
|
||||
name: typeof body?.name === 'string' ? body.name : undefined,
|
||||
callbackUrl: typeof body?.callbackUrl === 'string' ? body.callbackUrl : undefined,
|
||||
isActive: typeof body?.isActive === 'boolean' ? body.isActive : undefined,
|
||||
authType: typeof body?.authType === 'string' ? body.authType : undefined,
|
||||
authConfig:
|
||||
body?.authConfig && typeof body.authConfig === 'object' && !Array.isArray(body.authConfig)
|
||||
? body.authConfig
|
||||
: undefined,
|
||||
eventTypes: Array.isArray(body?.eventTypes) ? body.eventTypes : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: target,
|
||||
message: '通知目标更新成功',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin/external-notification-targets/[id]] PUT error:', error)
|
||||
return toErrorResponse(error)
|
||||
}
|
||||
}
|
||||
88
app/api/admin/external-notification-targets/route.ts
Normal file
88
app/api/admin/external-notification-targets/route.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
|
||||
import {
|
||||
createExternalNotificationTarget,
|
||||
listExternalNotificationTargets,
|
||||
} from '@/lib/external-auto-listing'
|
||||
|
||||
async function assertAdminPermission() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user || !(session.user as any).id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id
|
||||
const userRole = (session.user as any).role
|
||||
const isSuperAdminUser = await isSuperAdmin(userId)
|
||||
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
|
||||
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ success: false, error: '无权限访问' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toErrorResponse(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '服务器错误'
|
||||
const status =
|
||||
message.includes('不能为空') ||
|
||||
message.includes('格式不正确') ||
|
||||
message.includes('仅支持') ||
|
||||
message.includes('必须提供')
|
||||
? 400
|
||||
: 500
|
||||
|
||||
return NextResponse.json({ success: false, error: message }, { status })
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authError = await assertAdminPermission()
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
const targets = await listExternalNotificationTargets()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: targets,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin/external-notification-targets] GET error:', error)
|
||||
return toErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authError = await assertAdminPermission()
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const target = await createExternalNotificationTarget({
|
||||
name: typeof body?.name === 'string' ? body.name : '',
|
||||
callbackUrl: typeof body?.callbackUrl === 'string' ? body.callbackUrl : '',
|
||||
authType: typeof body?.authType === 'string' ? body.authType : undefined,
|
||||
authConfig:
|
||||
body?.authConfig && typeof body.authConfig === 'object' && !Array.isArray(body.authConfig)
|
||||
? body.authConfig
|
||||
: undefined,
|
||||
eventTypes: Array.isArray(body?.eventTypes) ? body.eventTypes : undefined,
|
||||
isActive: typeof body?.isActive === 'boolean' ? body.isActive : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: target,
|
||||
message: '通知目标创建成功',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin/external-notification-targets] POST error:', error)
|
||||
return toErrorResponse(error)
|
||||
}
|
||||
}
|
||||
51
app/api/external/product-assets/route.ts
vendored
51
app/api/external/product-assets/route.ts
vendored
@ -16,6 +16,7 @@ import {
|
||||
} from '@/lib/external-api';
|
||||
import {
|
||||
validateTaskNo,
|
||||
getMaterialById,
|
||||
getMaterialByTaskNo,
|
||||
updateTaskStatus,
|
||||
softDeleteByTaskNo,
|
||||
@ -33,6 +34,14 @@ function getBaseUrl(): string {
|
||||
return getPublicAppOrigin();
|
||||
}
|
||||
|
||||
function isValidMaterialId(materialId: string | null): materialId is string {
|
||||
if (!materialId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(materialId)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - 查询素材详情
|
||||
*/
|
||||
@ -40,6 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
const taskNo = request.nextUrl.searchParams.get('taskNo');
|
||||
const materialId = request.nextUrl.searchParams.get('materialId')
|
||||
|
||||
// 验证 API Key
|
||||
const keyRecord = await validateApiKey(apiKey);
|
||||
@ -47,8 +57,7 @@ export async function GET(request: NextRequest) {
|
||||
return errorResponse(API_ERROR.INVALID_API_KEY);
|
||||
}
|
||||
|
||||
// 验证任务号格式
|
||||
if (!taskNo) {
|
||||
if (!taskNo && !materialId) {
|
||||
await logApiCall({
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
@ -58,10 +67,23 @@ export async function GET(request: NextRequest) {
|
||||
ipAddress: getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
});
|
||||
return errorResponse(API_ERROR.INVALID_TASK_NO);
|
||||
return errorResponse(API_ERROR.INVALID_QUERY);
|
||||
}
|
||||
|
||||
if (!validateTaskNo(taskNo)) {
|
||||
if (materialId && !isValidMaterialId(materialId)) {
|
||||
await logApiCall({
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
method: 'GET',
|
||||
statusCode: 400,
|
||||
duration: Date.now() - startTime,
|
||||
ipAddress: getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
});
|
||||
return errorResponse(API_ERROR.INVALID_MATERIAL_ID);
|
||||
}
|
||||
|
||||
if (!materialId && taskNo && !validateTaskNo(taskNo)) {
|
||||
await logApiCall({
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
@ -77,8 +99,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
// 查询素材
|
||||
console.log('[external/product-assets] Querying material for taskNo:', taskNo);
|
||||
const material: any = await getMaterialByTaskNo(taskNo);
|
||||
console.log('[external/product-assets] Querying material:', { taskNo, materialId });
|
||||
const material: any = materialId ? await getMaterialById(materialId) : await getMaterialByTaskNo(taskNo as string);
|
||||
console.log('[external/product-assets] Material found:', material ? {
|
||||
id: material.id,
|
||||
taskNo: material.taskNo,
|
||||
@ -93,13 +115,13 @@ export async function GET(request: NextRequest) {
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
method: 'GET',
|
||||
taskNo,
|
||||
taskNo: taskNo || undefined,
|
||||
statusCode: 404,
|
||||
duration: Date.now() - startTime,
|
||||
ipAddress: getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
});
|
||||
return errorResponse(API_ERROR.TASK_NOT_FOUND);
|
||||
return errorResponse(materialId ? API_ERROR.MATERIAL_NOT_FOUND : API_ERROR.TASK_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (material.deletedAt) {
|
||||
@ -107,7 +129,7 @@ export async function GET(request: NextRequest) {
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
method: 'GET',
|
||||
taskNo,
|
||||
taskNo: taskNo || undefined,
|
||||
statusCode: 410,
|
||||
duration: Date.now() - startTime,
|
||||
ipAddress: getClientIp(request),
|
||||
@ -182,7 +204,7 @@ export async function GET(request: NextRequest) {
|
||||
url: rewrittenUrl,
|
||||
fileName: asset.fileName || '',
|
||||
fileSize: Number(asset.fileSize || 0),
|
||||
...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder || 0 }),
|
||||
sortOrder: asset.sortOrder || 0,
|
||||
};
|
||||
} catch (formatError) {
|
||||
console.error('[external/product-assets] Error formatting asset:', {
|
||||
@ -203,6 +225,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
const responseData = {
|
||||
materialId: material.id,
|
||||
taskNo: material.taskNo,
|
||||
productModel: material.productModel,
|
||||
categoryName: material.categoryName,
|
||||
@ -230,7 +253,7 @@ export async function GET(request: NextRequest) {
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
method: 'GET',
|
||||
taskNo,
|
||||
taskNo: material.taskNo || taskNo || undefined,
|
||||
statusCode: 200,
|
||||
duration: Date.now() - startTime,
|
||||
ipAddress: getClientIp(request),
|
||||
@ -260,6 +283,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
materialId: material.id,
|
||||
taskNo: material.taskNo,
|
||||
productModel: material.productModel,
|
||||
categoryName: material.categoryName,
|
||||
@ -288,7 +312,7 @@ export async function GET(request: NextRequest) {
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
method: 'GET',
|
||||
taskNo,
|
||||
taskNo: material.taskNo || taskNo || undefined,
|
||||
statusCode: 200,
|
||||
duration: Date.now() - startTime,
|
||||
ipAddress: getClientIp(request),
|
||||
@ -304,6 +328,7 @@ export async function GET(request: NextRequest) {
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
console.error('[external/product-assets] GET error:', {
|
||||
taskNo,
|
||||
materialId,
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
apiKeyId: keyRecord?.id,
|
||||
@ -317,7 +342,7 @@ export async function GET(request: NextRequest) {
|
||||
apiKeyId: keyRecord.id,
|
||||
endpoint: '/api/external/product-assets',
|
||||
method: 'GET',
|
||||
taskNo,
|
||||
taskNo: taskNo || undefined,
|
||||
statusCode: 500,
|
||||
duration: Date.now() - startTime,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@ -23,6 +23,134 @@ interface ApiKey {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type NotificationAuthType = 'none' | 'bearer' | 'header'
|
||||
|
||||
interface ExternalNotificationTarget {
|
||||
id: string
|
||||
name: string
|
||||
callbackUrl: string
|
||||
isActive: boolean
|
||||
authType: NotificationAuthType
|
||||
authConfig: Record<string, string>
|
||||
eventTypes: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface ExternalNotificationDelivery {
|
||||
id: string
|
||||
targetId: string
|
||||
targetName: string
|
||||
eventType: string
|
||||
materialId: string
|
||||
materialType: string
|
||||
taskNo: string | null
|
||||
status: string
|
||||
attemptCount: number
|
||||
maxAttempts: number
|
||||
nextRetryAt: string | null
|
||||
lastStatusCode: number | null
|
||||
lastResponseBody: string | null
|
||||
lastError: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
interface NotificationTargetFormState {
|
||||
name: string
|
||||
callbackUrl: string
|
||||
authType: NotificationAuthType
|
||||
token: string
|
||||
headerName: string
|
||||
headerValue: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const EMPTY_NOTIFICATION_TARGET_FORM: NotificationTargetFormState = {
|
||||
name: '',
|
||||
callbackUrl: '',
|
||||
authType: 'none',
|
||||
token: '',
|
||||
headerName: '',
|
||||
headerValue: '',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const NOTIFICATION_DELIVERY_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'pending', label: '待发送' },
|
||||
{ value: 'processing', label: '发送中' },
|
||||
{ value: 'retrying', label: '重试中' },
|
||||
{ value: 'succeeded', label: '已成功' },
|
||||
{ value: 'exhausted', label: '待人工处理' },
|
||||
]
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '无'
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function maskSecret(value: string | undefined) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return '未配置'
|
||||
}
|
||||
|
||||
if (normalized.length <= 8) {
|
||||
return '已配置'
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 4)}****${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
function getNotificationTargetAuthSummary(target: ExternalNotificationTarget) {
|
||||
if (target.authType === 'bearer') {
|
||||
return `Bearer · ${maskSecret(target.authConfig.token)}`
|
||||
}
|
||||
|
||||
if (target.authType === 'header') {
|
||||
const headerName = target.authConfig.headerName || '自定义请求头'
|
||||
return `Header · ${headerName}`
|
||||
}
|
||||
|
||||
return '无鉴权'
|
||||
}
|
||||
|
||||
function getNotificationDeliveryStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '待发送'
|
||||
case 'processing':
|
||||
return '发送中'
|
||||
case 'retrying':
|
||||
return '重试中'
|
||||
case 'succeeded':
|
||||
return '已成功'
|
||||
case 'exhausted':
|
||||
return '待人工处理'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationDeliveryStatusClassName(status: string) {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
case 'retrying':
|
||||
case 'processing':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200'
|
||||
case 'exhausted':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
interface SuperAdminsTabProps {
|
||||
showToast: (message: string, type?: 'success' | 'error') => void
|
||||
}
|
||||
@ -42,6 +170,18 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null)
|
||||
const [notificationTargets, setNotificationTargets] = useState<ExternalNotificationTarget[]>([])
|
||||
const [notificationTargetsLoading, setNotificationTargetsLoading] = useState(true)
|
||||
const [notificationTargetForm, setNotificationTargetForm] = useState<NotificationTargetFormState>(
|
||||
EMPTY_NOTIFICATION_TARGET_FORM,
|
||||
)
|
||||
const [editingNotificationTargetId, setEditingNotificationTargetId] = useState<string | null>(null)
|
||||
const [savingNotificationTarget, setSavingNotificationTarget] = useState(false)
|
||||
const [togglingNotificationTargetId, setTogglingNotificationTargetId] = useState<string | null>(null)
|
||||
const [notificationDeliveries, setNotificationDeliveries] = useState<ExternalNotificationDelivery[]>([])
|
||||
const [notificationDeliveriesLoading, setNotificationDeliveriesLoading] = useState(true)
|
||||
const [notificationDeliveryStatusFilter, setNotificationDeliveryStatusFilter] = useState('all')
|
||||
const [retryingNotificationDeliveryId, setRetryingNotificationDeliveryId] = useState<string | null>(null)
|
||||
|
||||
// 加载超级管理员列表
|
||||
const loadSuperAdmins = async () => {
|
||||
@ -66,8 +206,13 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||||
useEffect(() => {
|
||||
loadSuperAdmins()
|
||||
fetchApiKeys()
|
||||
fetchNotificationTargets()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationDeliveries(notificationDeliveryStatusFilter)
|
||||
}, [notificationDeliveryStatusFilter])
|
||||
|
||||
// 添加超级管理员
|
||||
const handleAddSuperAdmin = async () => {
|
||||
if (!addingUserId.trim()) {
|
||||
@ -147,6 +292,196 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const resetNotificationTargetForm = () => {
|
||||
setEditingNotificationTargetId(null)
|
||||
setNotificationTargetForm(EMPTY_NOTIFICATION_TARGET_FORM)
|
||||
}
|
||||
|
||||
const fetchNotificationTargets = async () => {
|
||||
try {
|
||||
setNotificationTargetsLoading(true)
|
||||
const response = await fetch('/api/admin/external-notification-targets')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setNotificationTargets(data.data || [])
|
||||
} else {
|
||||
const errorData = await parseFetchError(response)
|
||||
showToast(errorData.message, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载自动上架通知目标失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '加载失败'
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setNotificationTargetsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNotificationDeliveries = async (status: string) => {
|
||||
try {
|
||||
setNotificationDeliveriesLoading(true)
|
||||
const searchParams = new URLSearchParams()
|
||||
if (status && status !== 'all') {
|
||||
searchParams.set('status', status)
|
||||
}
|
||||
searchParams.set('limit', '50')
|
||||
|
||||
const query = searchParams.toString()
|
||||
const response = await fetch(
|
||||
`/api/admin/external-notification-deliveries${query ? `?${query}` : ''}`,
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setNotificationDeliveries(data.data || [])
|
||||
} else {
|
||||
const errorData = await parseFetchError(response)
|
||||
showToast(errorData.message, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载自动上架投递记录失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '加载失败'
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setNotificationDeliveriesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveNotificationTarget = async () => {
|
||||
if (!notificationTargetForm.name.trim()) {
|
||||
showToast('请填写目标系统名称', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!notificationTargetForm.callbackUrl.trim()) {
|
||||
showToast('请填写回调地址', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationTargetForm.authType === 'bearer' && !notificationTargetForm.token.trim()) {
|
||||
showToast('请选择 Bearer 鉴权时必须填写 token', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
notificationTargetForm.authType === 'header' &&
|
||||
(!notificationTargetForm.headerName.trim() || !notificationTargetForm.headerValue.trim())
|
||||
) {
|
||||
showToast('自定义 Header 鉴权需要填写请求头名称和值', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setSavingNotificationTarget(true)
|
||||
try {
|
||||
const authConfig =
|
||||
notificationTargetForm.authType === 'bearer'
|
||||
? { token: notificationTargetForm.token.trim() }
|
||||
: notificationTargetForm.authType === 'header'
|
||||
? {
|
||||
headerName: notificationTargetForm.headerName.trim(),
|
||||
headerValue: notificationTargetForm.headerValue.trim(),
|
||||
}
|
||||
: {}
|
||||
|
||||
const payload = {
|
||||
name: notificationTargetForm.name.trim(),
|
||||
callbackUrl: notificationTargetForm.callbackUrl.trim(),
|
||||
isActive: notificationTargetForm.isActive,
|
||||
authType: notificationTargetForm.authType,
|
||||
authConfig,
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
editingNotificationTargetId
|
||||
? `/api/admin/external-notification-targets/${editingNotificationTargetId}`
|
||||
: '/api/admin/external-notification-targets',
|
||||
{
|
||||
method: editingNotificationTargetId ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
showToast(editingNotificationTargetId ? '通知目标已更新' : '通知目标已创建', 'success')
|
||||
resetNotificationTargetForm()
|
||||
await fetchNotificationTargets()
|
||||
} else {
|
||||
const errorData = await parseFetchError(response)
|
||||
showToast(errorData.message, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存自动上架通知目标失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '保存失败'
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setSavingNotificationTarget(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditNotificationTarget = (target: ExternalNotificationTarget) => {
|
||||
setEditingNotificationTargetId(target.id)
|
||||
setNotificationTargetForm({
|
||||
name: target.name,
|
||||
callbackUrl: target.callbackUrl,
|
||||
authType: target.authType,
|
||||
token: target.authConfig.token || '',
|
||||
headerName: target.authConfig.headerName || '',
|
||||
headerValue: target.authConfig.headerValue || '',
|
||||
isActive: target.isActive,
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleNotificationTargetActive = async (target: ExternalNotificationTarget) => {
|
||||
setTogglingNotificationTargetId(target.id)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/external-notification-targets/${target.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
isActive: !target.isActive,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
showToast(target.isActive ? '通知目标已停用' : '通知目标已启用', 'success')
|
||||
await fetchNotificationTargets()
|
||||
} else {
|
||||
const errorData = await parseFetchError(response)
|
||||
showToast(errorData.message, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换通知目标状态失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '更新失败'
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setTogglingNotificationTargetId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetryNotificationDelivery = async (deliveryId: string) => {
|
||||
setRetryingNotificationDeliveryId(deliveryId)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/external-notification-deliveries/${deliveryId}/retry`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
showToast('补发任务已重新入队', 'success')
|
||||
await fetchNotificationDeliveries(notificationDeliveryStatusFilter)
|
||||
} else {
|
||||
const errorData = await parseFetchError(response)
|
||||
showToast(errorData.message, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('手工补发失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '补发失败'
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setRetryingNotificationDeliveryId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 API Key
|
||||
const handleCreateApiKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
@ -422,6 +757,404 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
自动上架通知目标
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
这里配置素材库主动通知的目标系统。当前事件固定为“任务-详情全套入库完成”。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetNotificationTargetForm()
|
||||
fetchNotificationTargets()
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300 transition-colors"
|
||||
>
|
||||
刷新目标
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
已配置目标
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{notificationTargetsLoading ? (
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
) : notificationTargets.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
暂无通知目标
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notificationTargets.map((target) => (
|
||||
<div
|
||||
key={target.id}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{target.name}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
target.isActive
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{target.isActive ? '启用中' : '已停用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-mono break-all text-gray-500 dark:text-gray-400">
|
||||
{target.callbackUrl}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
鉴权:{getNotificationTargetAuthSummary(target)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
更新时间:{formatDateTime(target.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEditNotificationTarget(target)}
|
||||
className="px-3 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleNotificationTargetActive(target)}
|
||||
disabled={togglingNotificationTargetId === target.id}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{togglingNotificationTargetId === target.id
|
||||
? '处理中...'
|
||||
: target.isActive
|
||||
? '停用'
|
||||
: '启用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{editingNotificationTargetId ? '编辑通知目标' : '新增通知目标'}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
目标系统名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notificationTargetForm.name}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="例如:自动上架平台"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
回调地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notificationTargetForm.callbackUrl}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
callbackUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://example.com/callback"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
鉴权方式
|
||||
</label>
|
||||
<select
|
||||
value={notificationTargetForm.authType}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
authType: e.target.value as NotificationAuthType,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="none">无鉴权</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
<option value="header">自定义 Header</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{notificationTargetForm.authType === 'bearer' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Bearer Token
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notificationTargetForm.token}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
token: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入 token"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationTargetForm.authType === 'header' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Header 名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notificationTargetForm.headerName}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
headerName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="例如:X-Signature"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Header 值
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notificationTargetForm.headerValue}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
headerValue: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入请求头值"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationTargetForm.isActive}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
isActive: e.target.checked,
|
||||
}))
|
||||
}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
创建后立即启用
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 dark:border-blue-900/40 bg-blue-50 dark:bg-blue-900/10 px-3 py-2 text-xs text-blue-700 dark:text-blue-200">
|
||||
当前固定订阅事件:任务-详情全套入库完成(`task-detail.full-set.ready`)
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSaveNotificationTarget}
|
||||
disabled={savingNotificationTarget}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{savingNotificationTarget
|
||||
? '保存中...'
|
||||
: editingNotificationTargetId
|
||||
? '保存修改'
|
||||
: '创建目标'}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetNotificationTargetForm}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
|
||||
>
|
||||
{editingNotificationTargetId ? '取消编辑' : '清空'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
自动上架投递记录
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
查看最近 50 条通知投递情况;失败或超上限的记录可以在这里手工补发。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={notificationDeliveryStatusFilter}
|
||||
onChange={(e) => setNotificationDeliveryStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{NOTIFICATION_DELIVERY_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => fetchNotificationDeliveries(notificationDeliveryStatusFilter)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300 transition-colors"
|
||||
>
|
||||
刷新记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationDeliveriesLoading ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
加载中...
|
||||
</div>
|
||||
) : notificationDeliveries.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
当前筛选条件下暂无投递记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
目标系统
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
素材
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
尝试次数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
最近结果
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
下一次重试
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
更新时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{notificationDeliveries.map((delivery) => (
|
||||
<tr key={delivery.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td className="px-4 py-4 align-top">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{delivery.targetName || '未知目标'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{delivery.id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{delivery.materialType}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 font-mono break-all">
|
||||
materialId: {delivery.materialId}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
taskNo: {delivery.taskNo || '无'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${getNotificationDeliveryStatusClassName(delivery.status)}`}
|
||||
>
|
||||
{getNotificationDeliveryStatusLabel(delivery.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top text-sm text-gray-600 dark:text-gray-300">
|
||||
{delivery.attemptCount} / {delivery.maxAttempts}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{delivery.lastStatusCode ? `HTTP ${delivery.lastStatusCode}` : '暂无'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 max-w-xs break-words">
|
||||
{delivery.lastError || delivery.lastResponseBody || '无'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top text-sm text-gray-600 dark:text-gray-300">
|
||||
{formatDateTime(delivery.nextRetryAt)}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top text-sm text-gray-600 dark:text-gray-300">
|
||||
{formatDateTime(delivery.updatedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top text-right">
|
||||
<button
|
||||
onClick={() => handleRetryNotificationDelivery(delivery.id)}
|
||||
disabled={
|
||||
retryingNotificationDeliveryId === delivery.id ||
|
||||
delivery.status === 'succeeded'
|
||||
}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{retryingNotificationDeliveryId === delivery.id ? '补发中...' : '手工补发'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建 API Key 模态框 */}
|
||||
@ -492,4 +1225,3 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
148
docs/外部接口文档.md
148
docs/外部接口文档.md
@ -1,10 +1,9 @@
|
||||
# 素材平台 - 外部接口文档
|
||||
|
||||
> 本文档供外部任务系统对接使用
|
||||
> 本文档供外部任务系统、AI 生图平台、自动上架平台等对接方使用
|
||||
> 当前主域名:`https://sc.jiansun.vip`
|
||||
> 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名
|
||||
> 资源主域名:`https://s3.jiansun.vip`
|
||||
> 备用资源域名:`https://s3.yydd.ai`(备用 / 回切用,非默认对接地址)
|
||||
|
||||
## 概述
|
||||
|
||||
@ -87,12 +86,13 @@ X-API-Key: your-api-key-here
|
||||
|
||||
### 1. 查询素材详情
|
||||
|
||||
通过任务号查询素材详情,获取下载链接。
|
||||
通过任务号或素材 ID 查询素材详情,获取正式资源下载链接。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /api/external/product-assets?taskNo={任务号}
|
||||
GET /api/external/product-assets?materialId={素材ID}
|
||||
```
|
||||
|
||||
**请求头**
|
||||
@ -106,7 +106,14 @@ GET /api/external/product-assets?taskNo={任务号}
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| taskNo | string | 是 | 任务号,格式:TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号(如 TASK202601071437194808-01) |
|
||||
| taskNo | string | 条件必填 | 任务号,格式:`TASK` + 12-18 位数字,或 `TASK` + 12-18 位数字 + `-2 位子任务号` |
|
||||
| materialId | string | 条件必填 | 素材记录 ID,UUID 格式 |
|
||||
|
||||
说明:
|
||||
|
||||
- `taskNo` 和 `materialId` 二选一即可
|
||||
- 自动上架平台后续建议优先使用 `materialId`
|
||||
- 删除接口目前仍只支持 `taskNo`
|
||||
|
||||
**完整请求示例**
|
||||
|
||||
@ -117,6 +124,13 @@ curl -X GET \
|
||||
-H 'X-API-Key: your-api-key-here'
|
||||
```
|
||||
|
||||
```bash
|
||||
# 按 materialId 查询
|
||||
curl -X GET \
|
||||
'https://sc.jiansun.vip/api/external/product-assets?materialId=35d8644b-1e09-4ce8-9bb0-c529b39ba52e' \
|
||||
-H 'X-API-Key: your-api-key-here'
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/external/product-assets?taskNo=TASK202601030184 HTTP/1.1
|
||||
Host: sc.jiansun.vip
|
||||
@ -129,6 +143,7 @@ X-API-Key: your-api-key-here
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"materialId": "35d8644b-1e09-4ce8-9bb0-c529b39ba52e",
|
||||
"taskNo": "TASK202601030184",
|
||||
"productModel": "YYD-001",
|
||||
"categoryName": "天猫旗舰店",
|
||||
@ -144,7 +159,8 @@ X-API-Key: your-api-key-here
|
||||
"id": "asset-uuid-1",
|
||||
"url": "https://s3.jiansun.vip/...",
|
||||
"fileName": "主图1.jpg",
|
||||
"fileSize": 1024000
|
||||
"fileSize": 1024000,
|
||||
"sortOrder": 0
|
||||
}
|
||||
],
|
||||
"sku": [
|
||||
@ -152,7 +168,8 @@ X-API-Key: your-api-key-here
|
||||
"id": "asset-uuid-2",
|
||||
"url": "https://s3.jiansun.vip/...",
|
||||
"fileName": "SKU白色.jpg",
|
||||
"fileSize": 512000
|
||||
"fileSize": 512000,
|
||||
"sortOrder": 1
|
||||
}
|
||||
],
|
||||
"detail": [
|
||||
@ -187,6 +204,7 @@ X-API-Key: your-api-key-here
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"materialId": "material-uuid-1",
|
||||
"taskNo": "TASK202601030185",
|
||||
"productModel": "YYD-002",
|
||||
"categoryName": "天猫旗舰店",
|
||||
@ -216,6 +234,7 @@ X-API-Key: your-api-key-here
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| materialId | string | 素材记录唯一标识,后续推荐作为稳定主键 |
|
||||
| taskNo | string | 任务号 |
|
||||
| productModel | string | SPU型号 |
|
||||
| categoryName | string | 店铺名称 |
|
||||
@ -238,7 +257,7 @@ X-API-Key: your-api-key-here
|
||||
| url | string | 资源URL(S3地址或本地路径,仅用于预览) |
|
||||
| fileName | string | 文件名 |
|
||||
| fileSize | number | 文件大小(字节) |
|
||||
| sortOrder | number | 排序序号(仅详情图有此字段) |
|
||||
| sortOrder | number | 排序序号(主图 / SKU 图 / 详情图 / PSD 均会显式返回) |
|
||||
|
||||
**文件对象字段说明(其他素材类型)**
|
||||
|
||||
@ -401,7 +420,7 @@ GET /api/external/standard-model-white-background
|
||||
- 接口查询与下载 token 统一走 `https://sc.jiansun.vip`
|
||||
- `white-background` 分支的 `previewUrl` 默认走 `https://sc.jiansun.vip/uploads/...`
|
||||
- `task-detail` 分支里返回的预览图直链,当前默认资源域名是 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 先保留为备用资源域名说明,不作为当前默认示例地址
|
||||
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为对接口径
|
||||
|
||||
**请求头**
|
||||
|
||||
@ -483,7 +502,7 @@ GET /api/external/standard-model-white-background?resourceType=task-detail&sku={
|
||||
- 白底图已经采用“预览 / 下载双轨”:
|
||||
- `previewUrl` 给展示用
|
||||
- `downloadUrl` 给原图下载用
|
||||
- 当前默认资源直链口径是 `https://s3.jiansun.vip`,`https://s3.yydd.ai` 仅保留备用说明。
|
||||
- 当前默认资源直链口径统一为 `https://s3.jiansun.vip`。
|
||||
|
||||
**详细文档**
|
||||
|
||||
@ -491,6 +510,108 @@ AI 生图平台的详细字段说明、完整响应示例、错误码和 cURL
|
||||
|
||||
- `docs/AI生图白底图以及详情页面接口文档.md`
|
||||
|
||||
---
|
||||
|
||||
### 5. 自动上架平台成套入库通知
|
||||
|
||||
这组能力用于在素材库出现新的「任务-详情全套素材」后,主动通知自动上架平台。
|
||||
|
||||
#### 基本原则
|
||||
|
||||
- 通知由素材库服务端主动 `POST` 到自动上架平台提供的回调地址
|
||||
- 触发条件是:某条素材记录第一次达到「任务-详情全套入库完成」
|
||||
- 通知主识别键使用 `materialId`
|
||||
- `taskNo` 只作为附带信息,可为空
|
||||
- 通知中直接附带预览图 URL,供自动上架平台先做预览
|
||||
- 自动上架平台正式拉资源时,再调用:
|
||||
- `GET /api/external/product-assets?materialId={materialId}`
|
||||
|
||||
#### 通知事件
|
||||
|
||||
- `eventType`: `task-detail.full-set.ready`
|
||||
|
||||
#### 通知 payload 字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| eventId | 事件唯一 ID |
|
||||
| eventType | 固定为 `task-detail.full-set.ready` |
|
||||
| materialId | 素材记录 ID |
|
||||
| materialType | 当前固定为 `任务-详情` |
|
||||
| taskNo | 任务号,可为空 |
|
||||
| productModel | SPU |
|
||||
| categoryName | 店铺 |
|
||||
| shopName | 店铺,当前与 `categoryName` 相同 |
|
||||
| sceneName | 场景 |
|
||||
| isFullSet | 是否全套 |
|
||||
| occurredAt | 事件时间 |
|
||||
| pullUrl | 正式资源拉取地址 |
|
||||
| coverPreviewUrl | 素材封面预览图 |
|
||||
| previewAssets.main | 主图预览数组 |
|
||||
| previewAssets.sku | SKU 图预览数组 |
|
||||
| previewAssets.detail | 详情图预览数组 |
|
||||
|
||||
每张预览图都至少包含:
|
||||
|
||||
- `url`
|
||||
- `fileName`
|
||||
- `sortOrder`
|
||||
|
||||
#### 通知示例
|
||||
|
||||
```json
|
||||
{
|
||||
"eventId": "evt-20260325-0001",
|
||||
"eventType": "task-detail.full-set.ready",
|
||||
"materialId": "35d8644b-1e09-4ce8-9bb0-c529b39ba52e",
|
||||
"materialType": "任务-详情",
|
||||
"taskNo": null,
|
||||
"productModel": "XG025503",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"isFullSet": true,
|
||||
"occurredAt": "2026-03-25T08:00:00.000Z",
|
||||
"pullUrl": "https://sc.jiansun.vip/api/external/product-assets?materialId=35d8644b-1e09-4ce8-9bb0-c529b39ba52e",
|
||||
"coverPreviewUrl": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/cover.webp",
|
||||
"previewAssets": {
|
||||
"main": [
|
||||
{
|
||||
"url": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/main/1.webp",
|
||||
"fileName": "main-1.webp",
|
||||
"sortOrder": 0
|
||||
}
|
||||
],
|
||||
"sku": [
|
||||
{
|
||||
"url": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp",
|
||||
"fileName": "sku-1.webp",
|
||||
"sortOrder": 1
|
||||
}
|
||||
],
|
||||
"detail": [
|
||||
{
|
||||
"url": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/1.webp",
|
||||
"fileName": "detail-1.webp",
|
||||
"sortOrder": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 投递结果判定
|
||||
|
||||
- 自动上架平台回调返回 `2xx`,视为通知成功
|
||||
- 超时、网络错误、非 `2xx`,视为失败
|
||||
- 素材库会自动重试;超过上限后,后台可手工补发
|
||||
|
||||
#### 详细文档
|
||||
|
||||
自动上架平台的详细回调说明、字段解释和对接建议,请以单独文档为准:
|
||||
|
||||
- `docs/自动上架平台成套入库通知接口.md`
|
||||
|
||||
**本接口补充错误码**
|
||||
|
||||
| 状态码 | code | 说明 |
|
||||
@ -518,6 +639,9 @@ AI 生图平台的详细字段说明、完整响应示例、错误码和 cURL
|
||||
| TOKEN_EXPIRED | 401 | 下载令牌已过期(当前代码口径为长期有效,正常对接场景极少触发) |
|
||||
| INVALID_TOKEN | 401 | 下载令牌无效 |
|
||||
| INVALID_TASK_NO | 400 | 任务号格式错误 |
|
||||
| INVALID_QUERY | 400 | 没有传 `taskNo` 或 `materialId` |
|
||||
| INVALID_MATERIAL_ID | 400 | `materialId` 格式错误 |
|
||||
| MATERIAL_NOT_FOUND | 404 | 指定 `materialId` 的素材不存在 |
|
||||
| MISSING_PARAM | 400 | 没有传 `sku` 或 `spu` |
|
||||
| RESOURCE_TYPE_INVALID | 400 | `resourceType` 非法 |
|
||||
| SKU_NOT_FOUND | 404 | 未找到对应标准模型 SKU |
|
||||
@ -676,7 +800,7 @@ if result['success']:
|
||||
|
||||
---
|
||||
|
||||
*文档版本: 1.5*
|
||||
*更新日期: 2026-03-19*
|
||||
*更新内容: 按代码真实口径修正产品全案返回结构,并同步下载 token 长期有效说明*
|
||||
*文档版本: 1.6*
|
||||
*更新日期: 2026-03-25*
|
||||
*更新内容: 补充 `materialId` 查询口径、自动上架平台通知说明,以及相关错误码与排序字段说明*
|
||||
|
||||
|
||||
161
docs/自动上架平台成套入库通知接口.md
Normal file
161
docs/自动上架平台成套入库通知接口.md
Normal file
@ -0,0 +1,161 @@
|
||||
# 自动上架平台成套入库通知接口
|
||||
|
||||
更新时间:2026-03-25
|
||||
|
||||
## 目标
|
||||
|
||||
这组对接能力用于让素材库在出现新的「任务-详情全套素材」时,主动通知自动上架平台。
|
||||
|
||||
自动上架平台收到通知后:
|
||||
|
||||
1. 先用通知里自带的预览图做展示或人工确认
|
||||
2. 真正需要上架时,再按 `materialId` 调素材库资源接口拉正式资源
|
||||
|
||||
## 触发规则
|
||||
|
||||
- 当前只对 `任务-详情` 生效
|
||||
- 触发条件是:同一条素材记录第一次达到「全套入库完成」
|
||||
- 创建时就是全套,会触发 1 次
|
||||
- 后续补齐到全套,会触发 1 次
|
||||
- 已经全套后再次编辑,不会重复触发
|
||||
|
||||
## 主识别键
|
||||
|
||||
- 主键:`materialId`
|
||||
- 附带字段:`taskNo`
|
||||
|
||||
说明:
|
||||
|
||||
- `taskNo` 可能为空
|
||||
- 不能把 `taskNo` 当成这组通知能力的唯一主键
|
||||
|
||||
## 通知方式
|
||||
|
||||
- 方法:`POST`
|
||||
- 方向:素材库服务端主动调用自动上架平台提供的回调地址
|
||||
- `Content-Type`:`application/json`
|
||||
|
||||
回调地址、鉴权方式、是否启用,由素材库后台配置。
|
||||
|
||||
当前支持的回调鉴权方式:
|
||||
|
||||
- 无鉴权
|
||||
- Bearer Token
|
||||
- 自定义 Header
|
||||
|
||||
## 通知事件
|
||||
|
||||
- `eventType`: `task-detail.full-set.ready`
|
||||
|
||||
## 通知 payload
|
||||
|
||||
### 顶层字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `eventId` | 事件唯一 ID |
|
||||
| `eventType` | 固定为 `task-detail.full-set.ready` |
|
||||
| `materialId` | 素材记录 ID |
|
||||
| `materialType` | 当前固定为 `任务-详情` |
|
||||
| `taskNo` | 任务号,可为空 |
|
||||
| `productModel` | SPU |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `sceneName` | 场景 |
|
||||
| `isFullSet` | 是否全套 |
|
||||
| `occurredAt` | 事件发生时间 |
|
||||
| `pullUrl` | 正式资源拉取地址 |
|
||||
| `coverPreviewUrl` | 封面预览图地址 |
|
||||
| `previewAssets` | 分组预览图 |
|
||||
|
||||
### 预览图字段
|
||||
|
||||
`previewAssets.main`、`previewAssets.sku`、`previewAssets.detail` 中的每一张图,至少包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `url` | 预览图地址 |
|
||||
| `fileName` | 文件名 |
|
||||
| `sortOrder` | 顺序 |
|
||||
|
||||
## 通知示例
|
||||
|
||||
```json
|
||||
{
|
||||
"eventId": "evt-20260325-0001",
|
||||
"eventType": "task-detail.full-set.ready",
|
||||
"materialId": "35d8644b-1e09-4ce8-9bb0-c529b39ba52e",
|
||||
"materialType": "任务-详情",
|
||||
"taskNo": null,
|
||||
"productModel": "XG025503",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"isFullSet": true,
|
||||
"occurredAt": "2026-03-25T08:00:00.000Z",
|
||||
"pullUrl": "https://sc.jiansun.vip/api/external/product-assets?materialId=35d8644b-1e09-4ce8-9bb0-c529b39ba52e",
|
||||
"coverPreviewUrl": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/cover.webp",
|
||||
"previewAssets": {
|
||||
"main": [
|
||||
{
|
||||
"url": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/main/1.webp",
|
||||
"fileName": "main-1.webp",
|
||||
"sortOrder": 0
|
||||
}
|
||||
],
|
||||
"sku": [
|
||||
{
|
||||
"url": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp",
|
||||
"fileName": "sku-1.webp",
|
||||
"sortOrder": 1
|
||||
}
|
||||
],
|
||||
"detail": [
|
||||
{
|
||||
"url": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/1.webp",
|
||||
"fileName": "detail-1.webp",
|
||||
"sortOrder": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 正式资源拉取
|
||||
|
||||
自动上架平台后续拉正式资源时,统一调用:
|
||||
|
||||
```http
|
||||
GET /api/external/product-assets?materialId={materialId}
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
```http
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这组资源接口和 AI 生图平台共用同一套外部接口
|
||||
- 当前推荐自动上架平台统一按 `materialId` 查询
|
||||
- 原来的 `taskNo` 查询仍兼容,但不建议把它当这组能力的主键
|
||||
|
||||
## 成功判定
|
||||
|
||||
- 回调返回 `2xx`:视为成功
|
||||
- 回调超时、网络失败、非 `2xx`:视为失败
|
||||
|
||||
## 重试与补发
|
||||
|
||||
- 通知发送是异步执行,不阻塞素材入库成功
|
||||
- 失败后会自动重试
|
||||
- 超过重试上限后,会进入待人工处理
|
||||
- 素材库后台支持手工补发
|
||||
|
||||
## 对接建议
|
||||
|
||||
- 自动上架平台接到通知后,先按 `eventId + materialId` 做幂等去重
|
||||
- 预览图仅用于预览,不建议直接把通知里的预览图当正式上架源文件
|
||||
- 真正上架前,请再按 `materialId` 调正式资源接口获取完整资源
|
||||
- 如果回调接口会做验签或 Header 校验,请在素材库后台配置对应鉴权方式
|
||||
347
lib/__tests__/external-auto-listing.test.ts
Normal file
347
lib/__tests__/external-auto-listing.test.ts
Normal file
@ -0,0 +1,347 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
AUTO_LISTING_DELIVERY_STATUS,
|
||||
AUTO_LISTING_EVENT_TYPE,
|
||||
createExternalNotificationTarget,
|
||||
listExternalNotificationDeliveries,
|
||||
listExternalNotificationTargets,
|
||||
queueTaskDetailFullSetNotification,
|
||||
processExternalNotificationDelivery,
|
||||
retryExternalNotificationDelivery,
|
||||
updateExternalNotificationTarget,
|
||||
} from '@/lib/external-auto-listing'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$executeRawUnsafe: jest.fn(),
|
||||
$queryRawUnsafe: jest.fn(),
|
||||
$transaction: jest.fn(),
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-app-url', () => ({
|
||||
getPublicAppOrigin: jest.fn(() => 'https://sc.jiansun.vip'),
|
||||
}))
|
||||
|
||||
describe('external auto listing', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.useFakeTimers()
|
||||
;(prisma.$executeRawUnsafe as jest.Mock).mockResolvedValue(undefined)
|
||||
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
|
||||
callback({
|
||||
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
||||
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('应能创建并更新自动上架通知目标', async () => {
|
||||
;(prisma.$queryRawUnsafe as jest.Mock)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'target-1',
|
||||
name: '自动上架平台-新版',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:30:00.000Z'),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'target-1',
|
||||
name: '自动上架平台-新版',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:30:00.000Z'),
|
||||
},
|
||||
])
|
||||
|
||||
const created = await createExternalNotificationTarget({
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
})
|
||||
|
||||
expect(created).toEqual({
|
||||
id: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:00:00.000Z',
|
||||
})
|
||||
|
||||
const listed = await listExternalNotificationTargets()
|
||||
expect(listed).toHaveLength(1)
|
||||
|
||||
const updated = await updateExternalNotificationTarget('target-1', {
|
||||
name: '自动上架平台-新版',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: {
|
||||
headerName: 'X-Signature',
|
||||
headerValue: 'abc123',
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated).toEqual({
|
||||
id: 'target-1',
|
||||
name: '自动上架平台-新版',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:30:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
test('投递成功后应标记为 succeeded', async () => {
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
|
||||
callback({
|
||||
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
||||
$queryRawUnsafe: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'delivery-1',
|
||||
status: 'pending',
|
||||
payload: { eventId: 'evt-1' },
|
||||
attemptCount: 0,
|
||||
maxAttempts: 5,
|
||||
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
targetId: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
targetUpdatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
)
|
||||
global.fetch = jest.fn(async () => new Response('ok', { status: 200 })) as any
|
||||
|
||||
const result = await processExternalNotificationDelivery('delivery-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
status: AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
|
||||
})
|
||||
expect(prisma.$executeRawUnsafe).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`UPDATE "${'external_notification_deliveries'}"`),
|
||||
'delivery-1',
|
||||
AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
|
||||
200,
|
||||
'ok',
|
||||
)
|
||||
})
|
||||
|
||||
test('创建通知任务时应把预览图和拉取地址写入 payload', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
taskNo: null,
|
||||
productModel: 'XG025503',
|
||||
categoryName: '旗舰店',
|
||||
sceneName: '客厅',
|
||||
materialType: '任务-详情',
|
||||
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/cover.webp',
|
||||
isFullSet: true,
|
||||
createdAt: new Date('2026-03-25T08:00:00.000Z'),
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-main-1',
|
||||
assetType: 'main-preview',
|
||||
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/main/1.webp',
|
||||
fileName: 'main-1.webp',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 'asset-sku-1',
|
||||
assetType: 'sku-preview',
|
||||
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
|
||||
fileName: 'sku-1.webp',
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: 'asset-detail-1',
|
||||
assetType: 'detail-preview',
|
||||
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/1.webp',
|
||||
fileName: 'detail-1.webp',
|
||||
sortOrder: 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
;(prisma.$queryRawUnsafe as jest.Mock)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'none',
|
||||
authConfig: {},
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'delivery-1' }])
|
||||
|
||||
const createdIds = await queueTaskDetailFullSetNotification('material-1')
|
||||
const insertCall = (prisma.$queryRawUnsafe as jest.Mock).mock.calls[1]
|
||||
const payload = JSON.parse(insertCall[8])
|
||||
|
||||
expect(createdIds).toEqual(['delivery-1'])
|
||||
expect(payload.pullUrl).toBe(
|
||||
'https://sc.jiansun.vip/api/external/product-assets?materialId=material-1',
|
||||
)
|
||||
expect(payload.coverPreviewUrl).toBe(
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/cover.webp',
|
||||
)
|
||||
expect(payload.previewAssets).toEqual({
|
||||
main: [
|
||||
{
|
||||
id: 'asset-main-1',
|
||||
url: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/main/1.webp',
|
||||
fileName: 'main-1.webp',
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
sku: [
|
||||
{
|
||||
id: 'asset-sku-1',
|
||||
url: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
|
||||
fileName: 'sku-1.webp',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
detail: [
|
||||
{
|
||||
id: 'asset-detail-1',
|
||||
url: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/1.webp',
|
||||
fileName: 'detail-1.webp',
|
||||
sortOrder: 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('投递失败后应进入 retrying,并支持手工补发查询', async () => {
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
|
||||
callback({
|
||||
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
||||
$queryRawUnsafe: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'delivery-2',
|
||||
status: 'pending',
|
||||
payload: { eventId: 'evt-2' },
|
||||
attemptCount: 0,
|
||||
maxAttempts: 5,
|
||||
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
targetId: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'none',
|
||||
authConfig: {},
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
targetUpdatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
)
|
||||
;(prisma.$queryRawUnsafe as jest.Mock)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'delivery-2',
|
||||
targetId: 'target-1',
|
||||
targetName: '自动上架平台',
|
||||
eventType: AUTO_LISTING_EVENT_TYPE,
|
||||
materialId: 'material-1',
|
||||
materialType: '任务-详情',
|
||||
taskNo: null,
|
||||
status: AUTO_LISTING_DELIVERY_STATUS.RETRYING,
|
||||
attemptCount: 1,
|
||||
maxAttempts: 5,
|
||||
nextRetryAt: new Date('2026-03-25T10:01:00.000Z'),
|
||||
lastStatusCode: 500,
|
||||
lastResponseBody: 'server error',
|
||||
lastError: 'HTTP 500',
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:00:01.000Z'),
|
||||
completedAt: null,
|
||||
},
|
||||
])
|
||||
global.fetch = jest.fn(async () => new Response('server error', { status: 500 })) as any
|
||||
|
||||
const result = await processExternalNotificationDelivery('delivery-2')
|
||||
const deliveries = await listExternalNotificationDeliveries()
|
||||
await retryExternalNotificationDelivery('delivery-2')
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
status: AUTO_LISTING_DELIVERY_STATUS.RETRYING,
|
||||
})
|
||||
expect(deliveries).toHaveLength(1)
|
||||
expect(deliveries[0].status).toBe(AUTO_LISTING_DELIVERY_STATUS.RETRYING)
|
||||
expect(prisma.$executeRawUnsafe).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`UPDATE "${'external_notification_deliveries'}"`),
|
||||
'delivery-2',
|
||||
AUTO_LISTING_DELIVERY_STATUS.PENDING,
|
||||
)
|
||||
})
|
||||
})
|
||||
236
lib/__tests__/external-notification-admin-routes.test.ts
Normal file
236
lib/__tests__/external-notification-admin-routes.test.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { GET as getTargetsRoute, POST as createTargetRoute } from '@/app/api/admin/external-notification-targets/route'
|
||||
import { PUT as updateTargetRoute } from '@/app/api/admin/external-notification-targets/[id]/route'
|
||||
import { GET as getDeliveriesRoute } from '@/app/api/admin/external-notification-deliveries/route'
|
||||
import { POST as retryDeliveryRoute } from '@/app/api/admin/external-notification-deliveries/[id]/retry/route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import {
|
||||
createExternalNotificationTarget,
|
||||
listExternalNotificationDeliveries,
|
||||
listExternalNotificationTargets,
|
||||
retryExternalNotificationDelivery,
|
||||
updateExternalNotificationTarget,
|
||||
} from '@/lib/external-auto-listing'
|
||||
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/department-permission', () => ({
|
||||
isSuperAdmin: jest.fn(),
|
||||
hasSystemAdminPermission: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/external-auto-listing', () => ({
|
||||
AUTO_LISTING_EVENT_TYPE: 'task-detail.full-set.ready',
|
||||
createExternalNotificationTarget: jest.fn(),
|
||||
listExternalNotificationTargets: jest.fn(),
|
||||
updateExternalNotificationTarget: jest.fn(),
|
||||
listExternalNotificationDeliveries: jest.fn(),
|
||||
retryExternalNotificationDelivery: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('external notification admin routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
role: 'SYSTEM_ADMIN',
|
||||
},
|
||||
})
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
|
||||
})
|
||||
|
||||
test('GET /api/admin/external-notification-targets 应返回通知目标列表', async () => {
|
||||
;(listExternalNotificationTargets as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: ['task-detail.full-set.ready'],
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:00:00.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
const response = await getTargetsRoute(new Request('http://localhost/api/admin/external-notification-targets') as any)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('POST /api/admin/external-notification-targets 应创建通知目标', async () => {
|
||||
;(createExternalNotificationTarget as jest.Mock).mockResolvedValue({
|
||||
id: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: ['task-detail.full-set.ready'],
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:00:00.000Z',
|
||||
})
|
||||
|
||||
const request = {
|
||||
json: jest.fn().mockResolvedValue({
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
}),
|
||||
} as any
|
||||
|
||||
const response = await createTargetRoute(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(createExternalNotificationTarget).toHaveBeenCalledWith({
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: undefined,
|
||||
isActive: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/admin/external-notification-targets 参数不合法时应返回 400', async () => {
|
||||
;(createExternalNotificationTarget as jest.Mock).mockRejectedValue(
|
||||
new Error('Bearer 鉴权必须提供 token'),
|
||||
)
|
||||
|
||||
const request = {
|
||||
json: jest.fn().mockResolvedValue({
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
authType: 'bearer',
|
||||
authConfig: {},
|
||||
}),
|
||||
} as any
|
||||
|
||||
const response = await createTargetRoute(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.success).toBe(false)
|
||||
})
|
||||
|
||||
test('PUT /api/admin/external-notification-targets/[id] 应更新通知目标', async () => {
|
||||
;(updateExternalNotificationTarget as jest.Mock).mockResolvedValue({
|
||||
id: 'target-1',
|
||||
name: '自动上架平台-新版',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: ['task-detail.full-set.ready'],
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:30:00.000Z',
|
||||
})
|
||||
|
||||
const request = {
|
||||
json: jest.fn().mockResolvedValue({
|
||||
name: '自动上架平台-新版',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
}),
|
||||
} as any
|
||||
|
||||
const response = await updateTargetRoute(request, {
|
||||
params: { id: 'target-1' },
|
||||
} as any)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(updateExternalNotificationTarget).toHaveBeenCalledWith('target-1', {
|
||||
name: '自动上架平台-新版',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/admin/external-notification-deliveries 应按查询条件返回投递记录', async () => {
|
||||
;(listExternalNotificationDeliveries as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'delivery-1',
|
||||
targetId: 'target-1',
|
||||
targetName: '自动上架平台',
|
||||
eventType: 'task-detail.full-set.ready',
|
||||
materialId: 'material-1',
|
||||
materialType: '任务-详情',
|
||||
taskNo: null,
|
||||
status: 'retrying',
|
||||
attemptCount: 1,
|
||||
maxAttempts: 5,
|
||||
nextRetryAt: '2026-03-25T10:01:00.000Z',
|
||||
lastStatusCode: 500,
|
||||
lastResponseBody: 'server error',
|
||||
lastError: 'HTTP 500',
|
||||
payload: { eventId: 'evt-1' },
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:00:01.000Z',
|
||||
completedAt: null,
|
||||
},
|
||||
])
|
||||
|
||||
const response = await getDeliveriesRoute(
|
||||
new Request(
|
||||
'http://localhost/api/admin/external-notification-deliveries?status=retrying&targetId=target-1&limit=20',
|
||||
) as any,
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(listExternalNotificationDeliveries).toHaveBeenCalledWith({
|
||||
status: 'retrying',
|
||||
targetId: 'target-1',
|
||||
materialId: undefined,
|
||||
limit: 20,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/admin/external-notification-deliveries/[id]/retry 应触发手工补发', async () => {
|
||||
;(retryExternalNotificationDelivery as jest.Mock).mockResolvedValue(undefined)
|
||||
|
||||
const response = await retryDeliveryRoute(new Request('http://localhost') as any, {
|
||||
params: { id: 'delivery-1' },
|
||||
} as any)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(retryExternalNotificationDelivery).toHaveBeenCalledWith('delivery-1')
|
||||
})
|
||||
|
||||
test('未登录时应拒绝访问', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const response = await getTargetsRoute(new Request('http://localhost/api/admin/external-notification-targets') as any)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -2,7 +2,9 @@ import { prisma } from '@/lib/prisma'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
|
||||
import { deleteFromS3, uploadToS3 } from '@/lib/s3'
|
||||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||||
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
import { updateMaterialWithSharedRules } from '@/lib/material-update'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
@ -32,11 +34,20 @@ jest.mock('@/lib/image-processor', () => ({
|
||||
sanitizeFileName: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
buildMaterialPublicDisplayPath: jest.fn((fileName: string) => `materials/${fileName}`),
|
||||
uploadPublicDisplayToS3: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/department-permission', () => ({
|
||||
isSuperAdmin: jest.fn(),
|
||||
isSystemAdmin: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/external-auto-listing', () => ({
|
||||
queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
describe('material-update shared rules', () => {
|
||||
const baseMaterial = {
|
||||
id: 'material-1',
|
||||
@ -62,6 +73,7 @@ describe('material-update shared rules', () => {
|
||||
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
|
||||
;(sanitizeFileName as jest.Mock).mockImplementation((name: string) => name)
|
||||
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed'))
|
||||
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue('https://new-preview.example/cover.webp')
|
||||
;(uploadToS3 as jest.Mock).mockResolvedValue('https://new-preview.example/cover.webp')
|
||||
;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined)
|
||||
})
|
||||
@ -339,6 +351,46 @@ describe('material-update shared rules', () => {
|
||||
taskNo: 'TASK202601141713525624',
|
||||
}),
|
||||
})
|
||||
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
|
||||
})
|
||||
|
||||
test('已是全套的任务-详情再次编辑时不应重复触发自动上架通知', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
...baseMaterial,
|
||||
materialType: '任务-详情',
|
||||
isFullSet: true,
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
assets: [
|
||||
{ assetType: 'main-preview' },
|
||||
{ assetType: 'sku-preview' },
|
||||
{ assetType: 'detail-preview' },
|
||||
],
|
||||
})
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...baseMaterial,
|
||||
...data,
|
||||
isFullSet: true,
|
||||
}))
|
||||
|
||||
await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
platformName: '',
|
||||
productCategoryName: '',
|
||||
materialType: '任务-详情',
|
||||
renderer: '',
|
||||
authorName: '任务作者',
|
||||
userId: 'user-1',
|
||||
} as any,
|
||||
})
|
||||
|
||||
expect(queueTaskDetailFullSetNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('任务类素材不允许手工替换预览图', async () => {
|
||||
@ -380,4 +432,44 @@ describe('material-update shared rules', () => {
|
||||
})
|
||||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('可手工替换的预览图应写入 public-display materials 前缀', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...baseMaterial,
|
||||
...data,
|
||||
}))
|
||||
|
||||
const previewImage = {
|
||||
name: 'cover.png',
|
||||
size: 7,
|
||||
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
|
||||
} as any
|
||||
const result = await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '新店铺',
|
||||
sceneName: '新场景',
|
||||
platformName: 'Amazon',
|
||||
productCategoryName: '',
|
||||
materialType: '3D场景',
|
||||
renderer: 'CR',
|
||||
authorName: '新作者',
|
||||
userId: 'user-1',
|
||||
},
|
||||
previewImage,
|
||||
})
|
||||
|
||||
expect('error' in result).toBe(false)
|
||||
expect(uploadPublicDisplayToS3).toHaveBeenCalledWith(
|
||||
Buffer.from('compressed'),
|
||||
expect.stringMatching(/^materials\/preview-\d+-cover\.png$/),
|
||||
'image/webp',
|
||||
)
|
||||
expect(uploadToS3).not.toHaveBeenCalled()
|
||||
expect(deleteFromS3).toHaveBeenCalledWith('https://old-preview.example/cover.webp')
|
||||
})
|
||||
})
|
||||
|
||||
233
lib/__tests__/product-assets-route.test.ts
Normal file
233
lib/__tests__/product-assets-route.test.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { GET } from '@/app/api/external/product-assets/route'
|
||||
import { getMaterialById, getMaterialByTaskNo, TASK_STATUS, updateTaskStatus } from '@/lib/product-package'
|
||||
import { logApiCall, validateApiKey } from '@/lib/external-api'
|
||||
|
||||
jest.mock('@/lib/external-api', () => {
|
||||
const { NextResponse } = require('next/server')
|
||||
return {
|
||||
getApiKeyFromRequest: jest.fn((request: { headers: Headers }) => request.headers.get('X-API-Key')),
|
||||
getClientIp: jest.fn(() => '127.0.0.1'),
|
||||
logApiCall: jest.fn().mockResolvedValue(undefined),
|
||||
successResponse: jest.fn((data: unknown, message?: string) =>
|
||||
NextResponse.json({
|
||||
success: true,
|
||||
...(message ? { message } : {}),
|
||||
data,
|
||||
}),
|
||||
),
|
||||
errorResponse: jest.fn((error: { code: string; message: string; status: number }) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
},
|
||||
{ status: error.status },
|
||||
),
|
||||
),
|
||||
validateApiKey: jest.fn(),
|
||||
API_ERROR: {
|
||||
INVALID_API_KEY: { code: 'INVALID_API_KEY', message: 'API Key 无效或缺失', status: 401 },
|
||||
TASK_NOT_FOUND: { code: 'TASK_NOT_FOUND', message: '任务号不存在', status: 404 },
|
||||
MATERIAL_NOT_FOUND: { code: 'MATERIAL_NOT_FOUND', message: '素材不存在', status: 404 },
|
||||
MATERIAL_DELETED: { code: 'MATERIAL_DELETED', message: '素材已被删除', status: 410 },
|
||||
INVALID_TASK_NO: { code: 'INVALID_TASK_NO', message: '任务号格式错误', status: 400 },
|
||||
INVALID_MATERIAL_ID: { code: 'INVALID_MATERIAL_ID', message: '素材ID格式错误', status: 400 },
|
||||
INVALID_QUERY: { code: 'INVALID_QUERY', message: '请提供 taskNo 或 materialId', status: 400 },
|
||||
SERVER_ERROR: { code: 'SERVER_ERROR', message: '服务器内部错误', status: 500 },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/lib/product-package', () => ({
|
||||
validateTaskNo: jest.fn((taskNo?: string | null) => Boolean(taskNo && taskNo.startsWith('TASK'))),
|
||||
getMaterialByTaskNo: jest.fn(),
|
||||
getMaterialById: jest.fn(),
|
||||
updateTaskStatus: jest.fn().mockResolvedValue(undefined),
|
||||
softDeleteByTaskNo: jest.fn(),
|
||||
TASK_STATUS: {
|
||||
PENDING: 'pending',
|
||||
COMPLETED: 'completed',
|
||||
FETCHED: 'fetched',
|
||||
},
|
||||
ASSET_TYPE: {
|
||||
MAIN: 'main',
|
||||
SKU: 'sku',
|
||||
DETAIL: 'detail',
|
||||
PSD: 'psd',
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/download-token', () => ({
|
||||
generateDownloadUrl: jest.fn((_baseUrl: string, { materialId }: { materialId: string }) => `https://download.example/${materialId}`),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-app-url', () => ({
|
||||
getPublicAppOrigin: jest.fn(() => 'https://sc.jiansun.vip'),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/url-rewrite', () => ({
|
||||
rewriteStorageUrlForClient: jest.fn((url?: string | null) => (url ? `rewritten:${url}` : null)),
|
||||
}))
|
||||
|
||||
describe('external product-assets route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(validateApiKey as jest.Mock).mockResolvedValue({
|
||||
id: 'api-key-1',
|
||||
name: 'auto-listing',
|
||||
isActive: true,
|
||||
})
|
||||
})
|
||||
|
||||
function createRequest(url: string) {
|
||||
return {
|
||||
nextUrl: new URL(url),
|
||||
headers: new Headers({
|
||||
'X-API-Key': 'test-key',
|
||||
'user-agent': 'jest',
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
test('materialId 查询应返回任务详情正式资源,并为所有资源组补齐 sortOrder', async () => {
|
||||
;(getMaterialById as jest.Mock).mockResolvedValue({
|
||||
id: '35d8644b-1e09-4ce8-9bb0-c529b39ba52e',
|
||||
taskNo: null,
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '旗舰店',
|
||||
sceneName: '客厅',
|
||||
materialType: '任务-详情',
|
||||
taskStatus: TASK_STATUS.COMPLETED,
|
||||
isFullSet: true,
|
||||
deletedAt: null,
|
||||
createdAt: new Date('2026-03-25T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T09:10:00.000Z'),
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-main',
|
||||
assetType: 'main',
|
||||
fileUrl: 'https://s3.example/main.zip',
|
||||
fileName: '主图.zip',
|
||||
fileSize: BigInt(10),
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 'asset-sku',
|
||||
assetType: 'sku',
|
||||
fileUrl: 'https://s3.example/sku.zip',
|
||||
fileName: 'SKU.zip',
|
||||
fileSize: BigInt(20),
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: 'asset-detail',
|
||||
assetType: 'detail',
|
||||
fileUrl: 'https://s3.example/detail.zip',
|
||||
fileName: '详情.zip',
|
||||
fileSize: BigInt(30),
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
id: 'asset-psd',
|
||||
assetType: 'psd',
|
||||
fileUrl: 'https://s3.example/source.psd',
|
||||
fileName: '源文件.psd',
|
||||
fileSize: BigInt(40),
|
||||
sortOrder: 3,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://example.com/api/external/product-assets?materialId=35d8644b-1e09-4ce8-9bb0-c529b39ba52e',
|
||||
),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data).toEqual({
|
||||
materialId: '35d8644b-1e09-4ce8-9bb0-c529b39ba52e',
|
||||
taskNo: null,
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '旗舰店',
|
||||
shopName: '旗舰店',
|
||||
sceneName: '客厅',
|
||||
isFullSet: true,
|
||||
status: TASK_STATUS.FETCHED,
|
||||
createdAt: '2026-03-25T09:00:00.000Z',
|
||||
updatedAt: '2026-03-25T09:10:00.000Z',
|
||||
assets: {
|
||||
main: [
|
||||
{
|
||||
id: 'asset-main',
|
||||
url: 'rewritten:https://s3.example/main.zip',
|
||||
fileName: '主图.zip',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
sku: [
|
||||
{
|
||||
id: 'asset-sku',
|
||||
url: 'rewritten:https://s3.example/sku.zip',
|
||||
fileName: 'SKU.zip',
|
||||
fileSize: 20,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
detail: [
|
||||
{
|
||||
id: 'asset-detail',
|
||||
url: 'rewritten:https://s3.example/detail.zip',
|
||||
fileName: '详情.zip',
|
||||
fileSize: 30,
|
||||
sortOrder: 2,
|
||||
},
|
||||
],
|
||||
psd: [
|
||||
{
|
||||
id: 'asset-psd',
|
||||
url: 'rewritten:https://s3.example/source.psd',
|
||||
fileName: '源文件.psd',
|
||||
fileSize: 40,
|
||||
sortOrder: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
downloadUrl: 'https://download.example/35d8644b-1e09-4ce8-9bb0-c529b39ba52e',
|
||||
downloadUrls: {
|
||||
all: 'https://download.example/35d8644b-1e09-4ce8-9bb0-c529b39ba52e',
|
||||
},
|
||||
})
|
||||
expect(updateTaskStatus).toHaveBeenCalledWith(
|
||||
'35d8644b-1e09-4ce8-9bb0-c529b39ba52e',
|
||||
TASK_STATUS.FETCHED,
|
||||
expect.objectContaining({
|
||||
taskFetchedAt: expect.any(Date),
|
||||
}),
|
||||
)
|
||||
expect(getMaterialByTaskNo).not.toHaveBeenCalled()
|
||||
expect(logApiCall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('未提供 taskNo 和 materialId 时应立即失败', async () => {
|
||||
const response = await GET(createRequest('https://example.com/api/external/product-assets'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_QUERY',
|
||||
message: '请提供 taskNo 或 materialId',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
131
lib/__tests__/product-package-completion.test.ts
Normal file
131
lib/__tests__/product-package-completion.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { completeProductPackageUpload } from '@/lib/product-package-completion'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/product-package', () => ({
|
||||
assignTaskNoOnComplete: jest.fn().mockResolvedValue(null),
|
||||
validateTaskNo: jest.fn(() => true),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/task-material-processor', () => ({
|
||||
cleanupOnError: jest.fn().mockResolvedValue(undefined),
|
||||
cleanupTempFiles: jest.fn().mockResolvedValue(undefined),
|
||||
createZipFromFiles: jest.fn()
|
||||
.mockResolvedValueOnce({ filePath: 'zip-main', fileName: 'main.zip', fileSize: 11 })
|
||||
.mockResolvedValueOnce({ filePath: 'zip-sku', fileName: 'sku.zip', fileSize: 22 })
|
||||
.mockResolvedValueOnce({ filePath: 'zip-detail', fileName: 'detail.zip', fileSize: 33 }),
|
||||
createZipFromZips: jest.fn().mockResolvedValue({ filePath: 'zip-all', fileName: 'all.zip', fileSize: 66 }),
|
||||
saveProductPackageAssets: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/upload-sessions', () => ({
|
||||
getUploadedFile: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-log', () => ({
|
||||
createMaterialLog: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/task-detail-full-set', () => ({
|
||||
isTaskDetailFullSetFromUploadGroups: jest.fn(() => true),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
buildTaskDetailPublicDisplayPath: jest.fn((_materialId: string, group: string, fileName: string) => `task-detail/${group}/${fileName}`),
|
||||
uploadPublicDisplayToS3: jest.fn((_: Buffer, path: string) => Promise.resolve(`https://s3.example/${path}`)),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/external-auto-listing', () => ({
|
||||
queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/image-processor', () => ({
|
||||
compressImage: jest.fn(async (buffer: Buffer) => buffer),
|
||||
sliceImage: jest.fn(async (buffer: Buffer) => [buffer]),
|
||||
}))
|
||||
|
||||
jest.mock('sharp', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
metadata: jest.fn().mockResolvedValue({ width: 800, height: 800 }),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('product-package-completion', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '旗舰店',
|
||||
previewUrl: '',
|
||||
user: {
|
||||
realName: '张三',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
})
|
||||
;(prisma.material.update as jest.Mock).mockResolvedValue(undefined)
|
||||
global.fetch = jest.fn(async () =>
|
||||
new Response(Buffer.from('image-binary'), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
) as any
|
||||
})
|
||||
|
||||
test('全套任务详情完成后应触发自动上架通知排队', async () => {
|
||||
const result = await completeProductPackageUpload({
|
||||
materialId: 'material-1',
|
||||
taskNo: null,
|
||||
operatorUserId: 'user-1',
|
||||
assets: {
|
||||
main: [
|
||||
{
|
||||
fileUrl: 'https://img.example/main.png',
|
||||
fileName: 'main.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
sku: [
|
||||
{
|
||||
fileUrl: 'https://img.example/sku.png',
|
||||
fileName: 'sku.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
detail: [
|
||||
{
|
||||
fileUrl: 'https://img.example/detail.png',
|
||||
fileName: 'detail.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
materialId: 'material-1',
|
||||
taskNo: null,
|
||||
isFullSet: true,
|
||||
})
|
||||
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
|
||||
expect(createMaterialLog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -38,6 +38,21 @@ export const API_ERROR = {
|
||||
message: '任务号格式错误',
|
||||
status: 400,
|
||||
},
|
||||
INVALID_QUERY: {
|
||||
code: 'INVALID_QUERY',
|
||||
message: '请提供 taskNo 或 materialId',
|
||||
status: 400,
|
||||
},
|
||||
INVALID_MATERIAL_ID: {
|
||||
code: 'INVALID_MATERIAL_ID',
|
||||
message: '素材 ID 格式错误',
|
||||
status: 400,
|
||||
},
|
||||
MATERIAL_NOT_FOUND: {
|
||||
code: 'MATERIAL_NOT_FOUND',
|
||||
message: '素材不存在',
|
||||
status: 404,
|
||||
},
|
||||
SERVER_ERROR: {
|
||||
code: 'SERVER_ERROR',
|
||||
message: '服务器内部错误',
|
||||
|
||||
938
lib/external-auto-listing.ts
Normal file
938
lib/external-auto-listing.ts
Normal file
@ -0,0 +1,938 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||||
|
||||
export const AUTO_LISTING_EVENT_TYPE = 'task-detail.full-set.ready'
|
||||
|
||||
export const AUTO_LISTING_AUTH_TYPE = {
|
||||
NONE: 'none',
|
||||
BEARER: 'bearer',
|
||||
HEADER: 'header',
|
||||
} as const
|
||||
|
||||
export const AUTO_LISTING_DELIVERY_STATUS = {
|
||||
PENDING: 'pending',
|
||||
PROCESSING: 'processing',
|
||||
RETRYING: 'retrying',
|
||||
SUCCEEDED: 'succeeded',
|
||||
EXHAUSTED: 'exhausted',
|
||||
} as const
|
||||
|
||||
export interface ExternalNotificationTarget {
|
||||
id: string
|
||||
name: string
|
||||
callbackUrl: string
|
||||
isActive: boolean
|
||||
authType: string
|
||||
authConfig: Record<string, string>
|
||||
eventTypes: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ExternalNotificationDelivery {
|
||||
id: string
|
||||
targetId: string
|
||||
targetName: string
|
||||
eventType: string
|
||||
materialId: string
|
||||
materialType: string
|
||||
taskNo: string | null
|
||||
status: string
|
||||
attemptCount: number
|
||||
maxAttempts: number
|
||||
nextRetryAt: string | null
|
||||
lastStatusCode: number | null
|
||||
lastResponseBody: string | null
|
||||
lastError: string | null
|
||||
payload: Record<string, unknown> | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export interface ExternalNotificationTargetInput {
|
||||
name: string
|
||||
callbackUrl: string
|
||||
isActive?: boolean
|
||||
authType?: string
|
||||
authConfig?: Record<string, string>
|
||||
eventTypes?: string[]
|
||||
}
|
||||
|
||||
const TARGET_TABLE = 'external_notification_targets'
|
||||
const DELIVERY_TABLE = 'external_notification_deliveries'
|
||||
const DELIVERY_MAX_ATTEMPTS = 5
|
||||
const DELIVERY_RESPONSE_BODY_LIMIT = 2000
|
||||
const RECOVERY_INTERVAL_MS = 60_000
|
||||
const STALE_PROCESSING_MS = 10 * 60 * 1000
|
||||
const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
|
||||
|
||||
let isTableInitialized = false
|
||||
let isWorkerStarted = false
|
||||
let recoveryTimer: NodeJS.Timeout | null = null
|
||||
const deliveryTimers = new Map<string, NodeJS.Timeout>()
|
||||
|
||||
function asJson(value: unknown, fallback: string) {
|
||||
try {
|
||||
return JSON.stringify(value ?? JSON.parse(fallback))
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoString(value: unknown): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
|
||||
const date = new Date(String(value))
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
function normalizeAuthType(value: string | null | undefined) {
|
||||
if (value === AUTO_LISTING_AUTH_TYPE.BEARER || value === AUTO_LISTING_AUTH_TYPE.HEADER) {
|
||||
return value
|
||||
}
|
||||
|
||||
return AUTO_LISTING_AUTH_TYPE.NONE
|
||||
}
|
||||
|
||||
function normalizeAuthConfig(input: unknown): Record<string, string> {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.entries(input as Record<string, unknown>).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
const normalizedValue = typeof value === 'string' ? value.trim() : ''
|
||||
if (normalizedKey && normalizedValue) {
|
||||
acc[normalizedKey] = normalizedValue
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function normalizeEventTypes(eventTypes?: string[] | null): string[] {
|
||||
const normalized = Array.isArray(eventTypes)
|
||||
? eventTypes.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
return normalized.length > 0 ? Array.from(new Set(normalized)) : [AUTO_LISTING_EVENT_TYPE]
|
||||
}
|
||||
|
||||
function normalizeTargetRow(row: any): ExternalNotificationTarget {
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name || ''),
|
||||
callbackUrl: String(row.callbackUrl || ''),
|
||||
isActive: Boolean(row.isActive),
|
||||
authType: normalizeAuthType(row.authType),
|
||||
authConfig: normalizeAuthConfig(row.authConfig),
|
||||
eventTypes: normalizeEventTypes(Array.isArray(row.eventTypes) ? row.eventTypes : []),
|
||||
createdAt: toIsoString(row.createdAt) || new Date().toISOString(),
|
||||
updatedAt: toIsoString(row.updatedAt) || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeliveryRow(row: any): ExternalNotificationDelivery {
|
||||
return {
|
||||
id: String(row.id),
|
||||
targetId: String(row.targetId || ''),
|
||||
targetName: String(row.targetName || ''),
|
||||
eventType: String(row.eventType || ''),
|
||||
materialId: String(row.materialId || ''),
|
||||
materialType: String(row.materialType || ''),
|
||||
taskNo: row.taskNo ? String(row.taskNo) : null,
|
||||
status: String(row.status || AUTO_LISTING_DELIVERY_STATUS.PENDING),
|
||||
attemptCount: Number(row.attemptCount || 0),
|
||||
maxAttempts: Number(row.maxAttempts || DELIVERY_MAX_ATTEMPTS),
|
||||
nextRetryAt: toIsoString(row.nextRetryAt),
|
||||
lastStatusCode:
|
||||
typeof row.lastStatusCode === 'number'
|
||||
? row.lastStatusCode
|
||||
: row.lastStatusCode != null
|
||||
? Number(row.lastStatusCode)
|
||||
: null,
|
||||
lastResponseBody: row.lastResponseBody ? String(row.lastResponseBody) : null,
|
||||
lastError: row.lastError ? String(row.lastError) : null,
|
||||
payload:
|
||||
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
|
||||
? (row.payload as Record<string, unknown>)
|
||||
: null,
|
||||
createdAt: toIsoString(row.createdAt) || new Date().toISOString(),
|
||||
updatedAt: toIsoString(row.updatedAt) || new Date().toISOString(),
|
||||
completedAt: toIsoString(row.completedAt),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCallbackUrl(callbackUrl: string) {
|
||||
const normalized = String(callbackUrl || '').trim()
|
||||
if (!normalized) {
|
||||
throw new Error('回调地址不能为空')
|
||||
}
|
||||
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(normalized)
|
||||
} catch {
|
||||
throw new Error('回调地址格式不正确')
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
throw new Error('回调地址仅支持 HTTP 或 HTTPS')
|
||||
}
|
||||
|
||||
return parsedUrl.toString()
|
||||
}
|
||||
|
||||
function normalizeTargetInput(
|
||||
input: ExternalNotificationTargetInput,
|
||||
fallback?: ExternalNotificationTarget,
|
||||
) {
|
||||
const name = String(input.name ?? fallback?.name ?? '').trim()
|
||||
if (!name) {
|
||||
throw new Error('目标系统名称不能为空')
|
||||
}
|
||||
|
||||
const callbackUrl = normalizeCallbackUrl(input.callbackUrl ?? fallback?.callbackUrl ?? '')
|
||||
const authType = normalizeAuthType(input.authType ?? fallback?.authType)
|
||||
const authConfig = normalizeAuthConfig(input.authConfig ?? fallback?.authConfig ?? {})
|
||||
const eventTypes = normalizeEventTypes(input.eventTypes ?? fallback?.eventTypes)
|
||||
const isActive = typeof input.isActive === 'boolean' ? input.isActive : Boolean(fallback?.isActive ?? true)
|
||||
|
||||
if (authType === AUTO_LISTING_AUTH_TYPE.BEARER && !String(authConfig.token || '').trim()) {
|
||||
throw new Error('Bearer 鉴权必须提供 token')
|
||||
}
|
||||
|
||||
if (authType === AUTO_LISTING_AUTH_TYPE.HEADER) {
|
||||
const headerName = String(authConfig.headerName || '').trim()
|
||||
const headerValue = String(authConfig.headerValue || '').trim()
|
||||
if (!headerName || !headerValue) {
|
||||
throw new Error('Header 鉴权必须提供请求头名称和值')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
callbackUrl,
|
||||
isActive,
|
||||
authType,
|
||||
authConfig,
|
||||
eventTypes,
|
||||
}
|
||||
}
|
||||
|
||||
async function getExternalNotificationTargetById(targetId: string) {
|
||||
const rows = await queryRaw<any>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
callback_url AS "callbackUrl",
|
||||
is_active AS "isActive",
|
||||
auth_type AS "authType",
|
||||
auth_config AS "authConfig",
|
||||
event_types AS "eventTypes",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM "${TARGET_TABLE}"
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
targetId,
|
||||
)
|
||||
|
||||
return rows[0] ? normalizeTargetRow(rows[0]) : null
|
||||
}
|
||||
|
||||
export async function createExternalNotificationTarget(input: ExternalNotificationTargetInput) {
|
||||
await ensureExternalNotificationTables()
|
||||
ensureWorkerStarted()
|
||||
|
||||
const normalized = normalizeTargetInput(input)
|
||||
const targetId = randomUUID()
|
||||
|
||||
await executeRaw(
|
||||
`
|
||||
INSERT INTO "${TARGET_TABLE}" (
|
||||
id,
|
||||
name,
|
||||
callback_url,
|
||||
is_active,
|
||||
auth_type,
|
||||
auth_config,
|
||||
event_types,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, NOW(), NOW())
|
||||
`,
|
||||
targetId,
|
||||
normalized.name,
|
||||
normalized.callbackUrl,
|
||||
normalized.isActive,
|
||||
normalized.authType,
|
||||
asJson(normalized.authConfig, '{}'),
|
||||
asJson(normalized.eventTypes, '[]'),
|
||||
)
|
||||
|
||||
const created = await getExternalNotificationTargetById(targetId)
|
||||
if (!created) {
|
||||
throw new Error('创建通知目标失败')
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
export async function listExternalNotificationTargets() {
|
||||
await ensureExternalNotificationTables()
|
||||
|
||||
const rows = await queryRaw<any>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
callback_url AS "callbackUrl",
|
||||
is_active AS "isActive",
|
||||
auth_type AS "authType",
|
||||
auth_config AS "authConfig",
|
||||
event_types AS "eventTypes",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM "${TARGET_TABLE}"
|
||||
ORDER BY created_at ASC, name ASC
|
||||
`,
|
||||
)
|
||||
|
||||
return rows.map((row) => normalizeTargetRow(row))
|
||||
}
|
||||
|
||||
export async function updateExternalNotificationTarget(
|
||||
targetId: string,
|
||||
input: Partial<ExternalNotificationTargetInput>,
|
||||
) {
|
||||
await ensureExternalNotificationTables()
|
||||
ensureWorkerStarted()
|
||||
|
||||
const existing = await getExternalNotificationTargetById(targetId)
|
||||
if (!existing) {
|
||||
throw new Error('通知目标不存在')
|
||||
}
|
||||
|
||||
const normalized = normalizeTargetInput(
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
callbackUrl: input.callbackUrl ?? existing.callbackUrl,
|
||||
isActive: typeof input.isActive === 'boolean' ? input.isActive : existing.isActive,
|
||||
authType: input.authType ?? existing.authType,
|
||||
authConfig: input.authConfig ?? existing.authConfig,
|
||||
eventTypes: input.eventTypes ?? existing.eventTypes,
|
||||
},
|
||||
existing,
|
||||
)
|
||||
|
||||
await executeRaw(
|
||||
`
|
||||
UPDATE "${TARGET_TABLE}"
|
||||
SET
|
||||
name = $2,
|
||||
callback_url = $3,
|
||||
is_active = $4,
|
||||
auth_type = $5,
|
||||
auth_config = $6::jsonb,
|
||||
event_types = $7::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
targetId,
|
||||
normalized.name,
|
||||
normalized.callbackUrl,
|
||||
normalized.isActive,
|
||||
normalized.authType,
|
||||
asJson(normalized.authConfig, '{}'),
|
||||
asJson(normalized.eventTypes, '[]'),
|
||||
)
|
||||
|
||||
const updated = await getExternalNotificationTargetById(targetId)
|
||||
if (!updated) {
|
||||
throw new Error('更新通知目标失败')
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function listExternalNotificationDeliveries(options?: {
|
||||
status?: string
|
||||
targetId?: string
|
||||
materialId?: string
|
||||
limit?: number
|
||||
}) {
|
||||
await ensureExternalNotificationTables()
|
||||
|
||||
const conditions = ['1 = 1']
|
||||
const params: any[] = []
|
||||
|
||||
if (options?.status) {
|
||||
params.push(String(options.status).trim())
|
||||
conditions.push(`d.status = $${params.length}`)
|
||||
}
|
||||
|
||||
if (options?.targetId) {
|
||||
params.push(String(options.targetId).trim())
|
||||
conditions.push(`d.target_id = $${params.length}`)
|
||||
}
|
||||
|
||||
if (options?.materialId) {
|
||||
params.push(String(options.materialId).trim())
|
||||
conditions.push(`d.material_id = $${params.length}`)
|
||||
}
|
||||
|
||||
const limit = Number.isFinite(options?.limit) && Number(options?.limit) > 0
|
||||
? Math.min(Number(options?.limit), 200)
|
||||
: 100
|
||||
params.push(limit)
|
||||
|
||||
const rows = await queryRaw<any>(
|
||||
`
|
||||
SELECT
|
||||
d.id,
|
||||
d.target_id AS "targetId",
|
||||
t.name AS "targetName",
|
||||
d.event_type AS "eventType",
|
||||
d.material_id AS "materialId",
|
||||
d.material_type AS "materialType",
|
||||
d.task_no AS "taskNo",
|
||||
d.status,
|
||||
d.attempt_count AS "attemptCount",
|
||||
d.max_attempts AS "maxAttempts",
|
||||
d.next_retry_at AS "nextRetryAt",
|
||||
d.last_status_code AS "lastStatusCode",
|
||||
d.last_response_body AS "lastResponseBody",
|
||||
d.last_error AS "lastError",
|
||||
d.payload,
|
||||
d.created_at AS "createdAt",
|
||||
d.updated_at AS "updatedAt",
|
||||
d.completed_at AS "completedAt"
|
||||
FROM "${DELIVERY_TABLE}" d
|
||||
LEFT JOIN "${TARGET_TABLE}" t ON t.id = d.target_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
...params,
|
||||
)
|
||||
|
||||
return rows.map((row) => normalizeDeliveryRow(row))
|
||||
}
|
||||
|
||||
async function executeRaw(sql: string, ...params: any[]) {
|
||||
return prisma.$executeRawUnsafe(sql, ...params)
|
||||
}
|
||||
|
||||
async function queryRaw<T>(sql: string, ...params: any[]) {
|
||||
return prisma.$queryRawUnsafe(sql, ...params) as Promise<T[]>
|
||||
}
|
||||
|
||||
export async function ensureExternalNotificationTables() {
|
||||
if (isTableInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
await executeRaw(`
|
||||
CREATE TABLE IF NOT EXISTS "${TARGET_TABLE}" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"callback_url" TEXT NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
"auth_type" TEXT NOT NULL DEFAULT 'none',
|
||||
"auth_config" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"event_types" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
await executeRaw(`
|
||||
CREATE TABLE IF NOT EXISTS "${DELIVERY_TABLE}" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"target_id" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"material_id" TEXT NOT NULL,
|
||||
"material_type" TEXT NOT NULL,
|
||||
"task_no" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"attempt_count" INT NOT NULL DEFAULT 0,
|
||||
"max_attempts" INT NOT NULL DEFAULT ${DELIVERY_MAX_ATTEMPTS},
|
||||
"next_retry_at" TIMESTAMP(3),
|
||||
"last_status_code" INT,
|
||||
"last_response_body" TEXT,
|
||||
"last_error" TEXT,
|
||||
"dispatched_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
await executeRaw(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_notification_targets_name_key"
|
||||
ON "${TARGET_TABLE}" ("name")
|
||||
`)
|
||||
await executeRaw(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_notification_deliveries_target_event_material_key"
|
||||
ON "${DELIVERY_TABLE}" ("target_id", "event_type", "material_id")
|
||||
`)
|
||||
await executeRaw(`
|
||||
CREATE INDEX IF NOT EXISTS "external_notification_deliveries_status_next_retry_idx"
|
||||
ON "${DELIVERY_TABLE}" ("status", "next_retry_at")
|
||||
`)
|
||||
|
||||
isTableInitialized = true
|
||||
}
|
||||
|
||||
async function recoverDueDeliveries(limit = 20) {
|
||||
await ensureExternalNotificationTables()
|
||||
|
||||
const rows = await queryRaw<{ id: string }>(
|
||||
`
|
||||
SELECT d.id
|
||||
FROM "${DELIVERY_TABLE}" d
|
||||
INNER JOIN "${TARGET_TABLE}" t ON t.id = d.target_id
|
||||
WHERE t.is_active = TRUE
|
||||
AND (
|
||||
(d.status IN ('pending', 'retrying') AND (d.next_retry_at IS NULL OR d.next_retry_at <= NOW()))
|
||||
OR
|
||||
(d.status = 'processing' AND d.updated_at < NOW() - INTERVAL '10 minutes')
|
||||
)
|
||||
ORDER BY d.created_at ASC
|
||||
LIMIT $1
|
||||
`,
|
||||
limit,
|
||||
)
|
||||
|
||||
rows.forEach((row) => scheduleDeliveryProcessing(String(row.id), 0))
|
||||
}
|
||||
|
||||
function ensureWorkerStarted() {
|
||||
if (isWorkerStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
isWorkerStarted = true
|
||||
|
||||
const bootstrapTimer = setTimeout(() => {
|
||||
void recoverDueDeliveries()
|
||||
}, 1000)
|
||||
bootstrapTimer.unref?.()
|
||||
|
||||
recoveryTimer = setInterval(() => {
|
||||
void recoverDueDeliveries()
|
||||
}, RECOVERY_INTERVAL_MS)
|
||||
recoveryTimer.unref?.()
|
||||
}
|
||||
|
||||
function scheduleDeliveryProcessing(deliveryId: string, delayMs: number) {
|
||||
const existingTimer = deliveryTimers.get(deliveryId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
deliveryTimers.delete(deliveryId)
|
||||
void processExternalNotificationDelivery(deliveryId)
|
||||
}, Math.max(0, delayMs))
|
||||
timer.unref?.()
|
||||
deliveryTimers.set(deliveryId, timer)
|
||||
}
|
||||
|
||||
function buildNotificationHeaders(target: ExternalNotificationTarget) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (target.authType === AUTO_LISTING_AUTH_TYPE.BEARER) {
|
||||
const token = String(target.authConfig.token || '').trim()
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
if (target.authType === AUTO_LISTING_AUTH_TYPE.HEADER) {
|
||||
const headerName = String(target.authConfig.headerName || '').trim()
|
||||
const headerValue = String(target.authConfig.headerValue || '').trim()
|
||||
if (headerName && headerValue) {
|
||||
headers[headerName] = headerValue
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
function truncateResponseBody(body: string | null | undefined) {
|
||||
const normalized = String(body || '')
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalized.length > DELIVERY_RESPONSE_BODY_LIMIT
|
||||
? normalized.slice(0, DELIVERY_RESPONSE_BODY_LIMIT)
|
||||
: normalized
|
||||
}
|
||||
|
||||
async function buildTaskDetailNotificationPayload(materialId: string) {
|
||||
const material = await prisma.material.findFirst({
|
||||
where: {
|
||||
id: materialId,
|
||||
deletedAt: null,
|
||||
materialType: '任务-详情',
|
||||
isFullSet: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
taskNo: true,
|
||||
productModel: true,
|
||||
categoryName: true,
|
||||
sceneName: true,
|
||||
materialType: true,
|
||||
previewUrl: true,
|
||||
isFullSet: true,
|
||||
createdAt: true,
|
||||
assets: {
|
||||
where: {
|
||||
assetType: {
|
||||
in: ['main-preview', 'sku-preview', 'detail-preview'],
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
assetType: true,
|
||||
fileUrl: true,
|
||||
fileName: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mapPreviewAssets = (assetType: 'main-preview' | 'sku-preview' | 'detail-preview') =>
|
||||
material.assets
|
||||
.filter((asset: any) => asset.assetType === assetType)
|
||||
.sort((left: any, right: any) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0))
|
||||
.map((asset: any) => ({
|
||||
id: String(asset.id),
|
||||
url: String(asset.fileUrl || ''),
|
||||
fileName: String(asset.fileName || ''),
|
||||
sortOrder: Number(asset.sortOrder || 0),
|
||||
}))
|
||||
|
||||
return {
|
||||
eventId: randomUUID(),
|
||||
eventType: AUTO_LISTING_EVENT_TYPE,
|
||||
materialId: material.id,
|
||||
materialType: material.materialType,
|
||||
taskNo: material.taskNo,
|
||||
productModel: material.productModel,
|
||||
categoryName: material.categoryName,
|
||||
shopName: material.categoryName,
|
||||
sceneName: material.sceneName,
|
||||
isFullSet: Boolean(material.isFullSet),
|
||||
occurredAt: material.createdAt.toISOString(),
|
||||
pullUrl: `${getPublicAppOrigin()}/api/external/product-assets?materialId=${material.id}`,
|
||||
coverPreviewUrl: material.previewUrl || null,
|
||||
previewAssets: {
|
||||
main: mapPreviewAssets('main-preview'),
|
||||
sku: mapPreviewAssets('sku-preview'),
|
||||
detail: mapPreviewAssets('detail-preview'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function queueTaskDetailFullSetNotification(materialId: string) {
|
||||
await ensureExternalNotificationTables()
|
||||
ensureWorkerStarted()
|
||||
|
||||
const payload = await buildTaskDetailNotificationPayload(materialId)
|
||||
if (!payload) {
|
||||
return []
|
||||
}
|
||||
|
||||
const targets = await queryRaw<any>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
callback_url AS "callbackUrl",
|
||||
is_active AS "isActive",
|
||||
auth_type AS "authType",
|
||||
auth_config AS "authConfig",
|
||||
event_types AS "eventTypes",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM "${TARGET_TABLE}"
|
||||
WHERE is_active = TRUE
|
||||
AND event_types ? $1
|
||||
ORDER BY created_at ASC
|
||||
`,
|
||||
AUTO_LISTING_EVENT_TYPE,
|
||||
)
|
||||
|
||||
const createdIds: string[] = []
|
||||
|
||||
for (const row of targets) {
|
||||
const deliveryId = randomUUID()
|
||||
const insertedRows = await queryRaw<{ id: string }>(
|
||||
`
|
||||
INSERT INTO "${DELIVERY_TABLE}" (
|
||||
id,
|
||||
target_id,
|
||||
event_type,
|
||||
material_id,
|
||||
material_type,
|
||||
task_no,
|
||||
status,
|
||||
payload,
|
||||
attempt_count,
|
||||
max_attempts,
|
||||
next_retry_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 0, $9, NOW(), NOW(), NOW())
|
||||
ON CONFLICT ("target_id", "event_type", "material_id") DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
deliveryId,
|
||||
String(row.id),
|
||||
AUTO_LISTING_EVENT_TYPE,
|
||||
payload.materialId,
|
||||
payload.materialType,
|
||||
payload.taskNo,
|
||||
AUTO_LISTING_DELIVERY_STATUS.PENDING,
|
||||
asJson(payload, '{}'),
|
||||
DELIVERY_MAX_ATTEMPTS,
|
||||
)
|
||||
|
||||
const inserted = insertedRows[0]
|
||||
if (inserted?.id) {
|
||||
createdIds.push(String(inserted.id))
|
||||
scheduleDeliveryProcessing(String(inserted.id), 0)
|
||||
}
|
||||
}
|
||||
|
||||
return createdIds
|
||||
}
|
||||
|
||||
export async function retryExternalNotificationDelivery(deliveryId: string) {
|
||||
await ensureExternalNotificationTables()
|
||||
ensureWorkerStarted()
|
||||
|
||||
await executeRaw(
|
||||
`
|
||||
UPDATE "${DELIVERY_TABLE}"
|
||||
SET
|
||||
status = $2,
|
||||
next_retry_at = NOW(),
|
||||
completed_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
deliveryId,
|
||||
AUTO_LISTING_DELIVERY_STATUS.PENDING,
|
||||
)
|
||||
|
||||
scheduleDeliveryProcessing(deliveryId, 0)
|
||||
}
|
||||
|
||||
export async function processExternalNotificationDelivery(
|
||||
deliveryId: string,
|
||||
options?: { force?: boolean },
|
||||
) {
|
||||
await ensureExternalNotificationTables()
|
||||
ensureWorkerStarted()
|
||||
|
||||
const claimed = await prisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))', deliveryId)
|
||||
|
||||
const rows = await tx.$queryRawUnsafe(
|
||||
`
|
||||
SELECT
|
||||
d.id,
|
||||
d.status,
|
||||
d.payload,
|
||||
d.attempt_count AS "attemptCount",
|
||||
d.max_attempts AS "maxAttempts",
|
||||
d.updated_at AS "updatedAt",
|
||||
t.id AS "targetId",
|
||||
t.name,
|
||||
t.callback_url AS "callbackUrl",
|
||||
t.is_active AS "isActive",
|
||||
t.auth_type AS "authType",
|
||||
t.auth_config AS "authConfig",
|
||||
t.event_types AS "eventTypes",
|
||||
t.created_at AS "createdAt",
|
||||
t.updated_at AS "targetUpdatedAt"
|
||||
FROM "${DELIVERY_TABLE}" d
|
||||
INNER JOIN "${TARGET_TABLE}" t ON t.id = d.target_id
|
||||
WHERE d.id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
deliveryId,
|
||||
)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row || !Boolean(row.isActive)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedAt = new Date(String(row.updatedAt || ''))
|
||||
const isStaleProcessing =
|
||||
row.status === AUTO_LISTING_DELIVERY_STATUS.PROCESSING &&
|
||||
!Number.isNaN(updatedAt.getTime()) &&
|
||||
Date.now() - updatedAt.getTime() > STALE_PROCESSING_MS
|
||||
|
||||
if (
|
||||
!options?.force &&
|
||||
row.status === AUTO_LISTING_DELIVERY_STATUS.PROCESSING &&
|
||||
!isStaleProcessing
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!options?.force &&
|
||||
row.status !== AUTO_LISTING_DELIVERY_STATUS.PENDING &&
|
||||
row.status !== AUTO_LISTING_DELIVERY_STATUS.RETRYING &&
|
||||
!isStaleProcessing
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
await tx.$executeRawUnsafe(
|
||||
`
|
||||
UPDATE "${DELIVERY_TABLE}"
|
||||
SET
|
||||
status = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
deliveryId,
|
||||
AUTO_LISTING_DELIVERY_STATUS.PROCESSING,
|
||||
)
|
||||
|
||||
return {
|
||||
attemptCount: Number(row.attemptCount || 0),
|
||||
maxAttempts: Number(row.maxAttempts || DELIVERY_MAX_ATTEMPTS),
|
||||
payload: row.payload,
|
||||
target: normalizeTargetRow({
|
||||
id: row.targetId,
|
||||
name: row.name,
|
||||
callbackUrl: row.callbackUrl,
|
||||
isActive: row.isActive,
|
||||
authType: row.authType,
|
||||
authConfig: row.authConfig,
|
||||
eventTypes: row.eventTypes,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.targetUpdatedAt,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
if (!claimed) {
|
||||
return null
|
||||
}
|
||||
|
||||
let responseStatus: number | null = null
|
||||
let responseText: string | null = null
|
||||
let requestError: string | null = null
|
||||
|
||||
try {
|
||||
const response = await fetch(claimed.target.callbackUrl, {
|
||||
method: 'POST',
|
||||
headers: buildNotificationHeaders(claimed.target),
|
||||
body: JSON.stringify(claimed.payload),
|
||||
})
|
||||
|
||||
responseStatus = response.status
|
||||
responseText = truncateResponseBody(await response.text())
|
||||
|
||||
if (response.ok) {
|
||||
await executeRaw(
|
||||
`
|
||||
UPDATE "${DELIVERY_TABLE}"
|
||||
SET
|
||||
status = $2,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_status_code = $3,
|
||||
last_response_body = $4,
|
||||
last_error = NULL,
|
||||
next_retry_at = NULL,
|
||||
dispatched_at = COALESCE(dispatched_at, NOW()),
|
||||
completed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
deliveryId,
|
||||
AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
|
||||
responseStatus,
|
||||
responseText,
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
|
||||
}
|
||||
}
|
||||
|
||||
requestError = `HTTP ${response.status}`
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : '通知请求失败'
|
||||
}
|
||||
|
||||
const nextAttempt = claimed.attemptCount + 1
|
||||
const isExhausted = nextAttempt >= claimed.maxAttempts
|
||||
const retryDelayMs = RETRY_DELAYS_MS[Math.min(nextAttempt, RETRY_DELAYS_MS.length - 1)]
|
||||
const nextRetryAt = new Date(Date.now() + retryDelayMs).toISOString()
|
||||
|
||||
await executeRaw(
|
||||
`
|
||||
UPDATE "${DELIVERY_TABLE}"
|
||||
SET
|
||||
status = $2,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_status_code = $3,
|
||||
last_response_body = $4,
|
||||
last_error = $5,
|
||||
next_retry_at = $6,
|
||||
completed_at = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
deliveryId,
|
||||
isExhausted ? AUTO_LISTING_DELIVERY_STATUS.EXHAUSTED : AUTO_LISTING_DELIVERY_STATUS.RETRYING,
|
||||
responseStatus,
|
||||
responseText,
|
||||
requestError,
|
||||
isExhausted ? null : nextRetryAt,
|
||||
isExhausted ? new Date().toISOString() : null,
|
||||
)
|
||||
|
||||
if (!isExhausted) {
|
||||
scheduleDeliveryProcessing(deliveryId, retryDelayMs)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: isExhausted ? AUTO_LISTING_DELIVERY_STATUS.EXHAUSTED : AUTO_LISTING_DELIVERY_STATUS.RETRYING,
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { deleteFromS3, uploadToS3 } from '@/lib/s3'
|
||||
import { deleteFromS3 } from '@/lib/s3'
|
||||
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
|
||||
import {
|
||||
buildMaterialPublicDisplayPath,
|
||||
uploadPublicDisplayToS3,
|
||||
} from '@/lib/public-display'
|
||||
import {
|
||||
getDefaultRenderer,
|
||||
requiresCategoryAndScene,
|
||||
@ -16,6 +20,7 @@ import {
|
||||
} from '@/lib/material-type-options'
|
||||
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
|
||||
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
|
||||
export interface MaterialUpdateInput {
|
||||
productModel: string
|
||||
@ -135,7 +140,11 @@ async function resolvePreviewUrl(
|
||||
|
||||
const sanitizedName = sanitizeFileName(previewImage.name)
|
||||
const fileName = `preview-${Date.now()}-${sanitizedName}`
|
||||
const nextPreviewUrl = await uploadToS3(compressedBuffer, fileName, 'image/webp')
|
||||
const nextPreviewUrl = await uploadPublicDisplayToS3(
|
||||
compressedBuffer,
|
||||
buildMaterialPublicDisplayPath(fileName),
|
||||
'image/webp',
|
||||
)
|
||||
|
||||
if (currentPreviewUrl) {
|
||||
try {
|
||||
@ -267,6 +276,10 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
})
|
||||
}
|
||||
|
||||
if (!Boolean(material.isFullSet) && Boolean((updatedMaterial as any).isFullSet)) {
|
||||
void queueTaskDetailFullSetNotification(materialId)
|
||||
}
|
||||
|
||||
return {
|
||||
material: updatedMaterial,
|
||||
changes,
|
||||
|
||||
@ -10,6 +10,11 @@ import {
|
||||
import { getUploadedFile } from '@/lib/upload-sessions'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
|
||||
import {
|
||||
buildTaskDetailPublicDisplayPath,
|
||||
uploadPublicDisplayToS3,
|
||||
} from '@/lib/public-display'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
import path from 'path'
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
|
||||
@ -109,6 +114,7 @@ async function collectAssetFiles(
|
||||
}
|
||||
|
||||
async function generatePreviewsFromAssets(
|
||||
materialId: string,
|
||||
assets: ProductPackageImageInput[] | undefined,
|
||||
type: 'main' | 'sku' | 'detail',
|
||||
uploadedPreviewUrls: string[],
|
||||
@ -120,7 +126,6 @@ async function generatePreviewsFromAssets(
|
||||
const previews: string[] = []
|
||||
const { compressImage, sliceImage } = await import('@/lib/image-processor')
|
||||
const sharp = await import('sharp')
|
||||
const { uploadToS3 } = await import('@/lib/s3')
|
||||
|
||||
for (let index = 0; index < assets.length; index += 1) {
|
||||
const asset = assets[index]
|
||||
@ -160,8 +165,11 @@ async function generatePreviewsFromAssets(
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 9)
|
||||
const sliceSuffix = buffers.length > 1 ? `-slice${sliceIndex}` : ''
|
||||
const fileName = `${type}-preview-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
|
||||
const s3Path = `product-package/${fileName}`
|
||||
const previewUrl = await uploadToS3(buffers[sliceIndex], s3Path, 'image/webp')
|
||||
const previewUrl = await uploadPublicDisplayToS3(
|
||||
buffers[sliceIndex],
|
||||
buildTaskDetailPublicDisplayPath(materialId, type, fileName),
|
||||
'image/webp',
|
||||
)
|
||||
previews.push(previewUrl)
|
||||
uploadedPreviewUrls.push(previewUrl)
|
||||
}
|
||||
@ -281,9 +289,9 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
const detailFiles = await collectAssetFiles(assets.detail, 'detail', tempFiles, uploadIds)
|
||||
|
||||
const previewUrls = {
|
||||
main: await generatePreviewsFromAssets(assets.main, 'main', uploadedPreviewUrls),
|
||||
sku: await generatePreviewsFromAssets(assets.sku, 'sku', uploadedPreviewUrls),
|
||||
detail: await generatePreviewsFromAssets(assets.detail, 'detail', uploadedPreviewUrls),
|
||||
main: await generatePreviewsFromAssets(materialId, assets.main, 'main', uploadedPreviewUrls),
|
||||
sku: await generatePreviewsFromAssets(materialId, assets.sku, 'sku', uploadedPreviewUrls),
|
||||
detail: await generatePreviewsFromAssets(materialId, assets.detail, 'detail', uploadedPreviewUrls),
|
||||
}
|
||||
|
||||
const zipInfos: {
|
||||
@ -401,6 +409,10 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
},
|
||||
})
|
||||
|
||||
if (derivedIsFullSet) {
|
||||
void queueTaskDetailFullSetNotification(materialId)
|
||||
}
|
||||
|
||||
return {
|
||||
materialId,
|
||||
taskNo: normalizedTaskNo,
|
||||
|
||||
@ -239,6 +239,24 @@ export async function softDeleteByTaskNo(taskNo: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMaterialById(materialId: string) {
|
||||
return prisma.material.findFirst({
|
||||
where: { id: materialId, deletedAt: null },
|
||||
include: {
|
||||
assets: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
realName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 当任务号被复用时,清理此前已软删除的旧素材,避免残留孤儿文件
|
||||
*/
|
||||
|
||||
109
lib/public-display.ts
Normal file
109
lib/public-display.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { uploadToS3 } from '@/lib/s3'
|
||||
|
||||
export const PUBLIC_DISPLAY_PREFIX = 'public-display'
|
||||
|
||||
function normalizeRelativePath(relativePath: string): string {
|
||||
return String(relativePath || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
}
|
||||
|
||||
function normalizeS3PublicDomain(): string | null {
|
||||
const rawDomain = String(process.env.S3_PUBLIC_DOMAIN || '').trim()
|
||||
if (!rawDomain) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = rawDomain.match(/^https?:\/\//i) ? rawDomain : `https://${rawDomain}`
|
||||
return normalized.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function buildPublicDisplayObjectPath(relativePath: string): string {
|
||||
const normalized = normalizeRelativePath(relativePath)
|
||||
if (!normalized) {
|
||||
return PUBLIC_DISPLAY_PREFIX
|
||||
}
|
||||
|
||||
return `${PUBLIC_DISPLAY_PREFIX}/${normalized}`
|
||||
}
|
||||
|
||||
export function buildPublicDisplayUrl(relativePath: string): string | null {
|
||||
const publicDomain = normalizeS3PublicDomain()
|
||||
const objectPath = buildPublicDisplayObjectPath(relativePath)
|
||||
|
||||
if (!publicDomain || !objectPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${publicDomain}/${objectPath}`
|
||||
}
|
||||
|
||||
export async function uploadPublicDisplayToS3(
|
||||
file: Buffer,
|
||||
relativePath: string,
|
||||
contentType: string,
|
||||
): Promise<string> {
|
||||
return uploadToS3(file, buildPublicDisplayObjectPath(relativePath), contentType)
|
||||
}
|
||||
|
||||
export function buildMaterialPublicDisplayPath(fileName: string): string {
|
||||
return normalizeRelativePath(`materials/${fileName}`)
|
||||
}
|
||||
|
||||
export function buildTaskDetailPublicDisplayPath(
|
||||
materialId: string,
|
||||
group: 'main' | 'sku' | 'detail',
|
||||
fileName: string,
|
||||
): string {
|
||||
return normalizeRelativePath(`task-detail/${materialId}/${group}/${fileName}`)
|
||||
}
|
||||
|
||||
export function buildWhiteBackgroundPublicDisplayPath(
|
||||
materialId: string,
|
||||
skuId: string,
|
||||
fileName: string,
|
||||
): string {
|
||||
return normalizeRelativePath(`white-background/${materialId}/${skuId}/${fileName}`)
|
||||
}
|
||||
|
||||
export function buildWhiteBackgroundPublicDisplayPathFromAssetPath(
|
||||
fileUrl?: string | null,
|
||||
): string | null {
|
||||
const normalizedFileUrl = String(fileUrl || '').trim().replace(/\\/g, '/')
|
||||
if (!normalizedFileUrl || /^https?:\/\//i.test(normalizedFileUrl)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedRelativePath = normalizedFileUrl.replace(/^\.?\//, '')
|
||||
const segments = normalizedRelativePath.split('/').filter(Boolean)
|
||||
const whiteBackgroundIndex = segments.indexOf('white-background')
|
||||
|
||||
if (
|
||||
whiteBackgroundIndex < 0 ||
|
||||
whiteBackgroundIndex < 2 ||
|
||||
!segments[whiteBackgroundIndex - 1] ||
|
||||
!segments[whiteBackgroundIndex + 1]
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const materialId = segments[whiteBackgroundIndex - 1]
|
||||
const skuId = segments[whiteBackgroundIndex + 1]
|
||||
const fileSegments = segments.slice(whiteBackgroundIndex + 2)
|
||||
|
||||
if (fileSegments.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (fileSegments[0] === 'preview') {
|
||||
fileSegments.shift()
|
||||
}
|
||||
|
||||
if (fileSegments.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildWhiteBackgroundPublicDisplayPath(materialId, skuId, fileSegments.join('/'))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user