diff --git a/app/api/admin/external-notification-deliveries/[id]/retry/route.ts b/app/api/admin/external-notification-deliveries/[id]/retry/route.ts new file mode 100644 index 0000000..5be7282 --- /dev/null +++ b/app/api/admin/external-notification-deliveries/[id]/retry/route.ts @@ -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 }) + } +} diff --git a/app/api/admin/external-notification-deliveries/route.ts b/app/api/admin/external-notification-deliveries/route.ts new file mode 100644 index 0000000..c56e433 --- /dev/null +++ b/app/api/admin/external-notification-deliveries/route.ts @@ -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 }) + } +} diff --git a/app/api/admin/external-notification-targets/[id]/route.ts b/app/api/admin/external-notification-targets/[id]/route.ts new file mode 100644 index 0000000..5e054e9 --- /dev/null +++ b/app/api/admin/external-notification-targets/[id]/route.ts @@ -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) + } +} diff --git a/app/api/admin/external-notification-targets/route.ts b/app/api/admin/external-notification-targets/route.ts new file mode 100644 index 0000000..19dbc65 --- /dev/null +++ b/app/api/admin/external-notification-targets/route.ts @@ -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) + } +} diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index 04f1c9c..8405cbe 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -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), diff --git a/components/SuperAdminsTab.tsx b/components/SuperAdminsTab.tsx index 8fae4d1..d05a0a3 100644 --- a/components/SuperAdminsTab.tsx +++ b/components/SuperAdminsTab.tsx @@ -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 + 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(null) + const [notificationTargets, setNotificationTargets] = useState([]) + const [notificationTargetsLoading, setNotificationTargetsLoading] = useState(true) + const [notificationTargetForm, setNotificationTargetForm] = useState( + EMPTY_NOTIFICATION_TARGET_FORM, + ) + const [editingNotificationTargetId, setEditingNotificationTargetId] = useState(null) + const [savingNotificationTarget, setSavingNotificationTarget] = useState(false) + const [togglingNotificationTargetId, setTogglingNotificationTargetId] = useState(null) + const [notificationDeliveries, setNotificationDeliveries] = useState([]) + const [notificationDeliveriesLoading, setNotificationDeliveriesLoading] = useState(true) + const [notificationDeliveryStatusFilter, setNotificationDeliveryStatusFilter] = useState('all') + const [retryingNotificationDeliveryId, setRetryingNotificationDeliveryId] = useState(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) { )} + +
+
+
+

+ 自动上架通知目标 +

+

+ 这里配置素材库主动通知的目标系统。当前事件固定为“任务-详情全套入库完成”。 +

+
+ +
+ +
+
+
+

+ 已配置目标 +

+
+
+ {notificationTargetsLoading ? ( +
+ 加载中... +
+ ) : notificationTargets.length === 0 ? ( +
+ 暂无通知目标 +
+ ) : ( +
+ {notificationTargets.map((target) => ( +
+
+
+
+ + {target.name} + + + {target.isActive ? '启用中' : '已停用'} + +
+
+ {target.callbackUrl} +
+
+ 鉴权:{getNotificationTargetAuthSummary(target)} +
+
+ 更新时间:{formatDateTime(target.updatedAt)} +
+
+
+ + +
+
+
+ ))} +
+ )} +
+
+ +
+
+

+ {editingNotificationTargetId ? '编辑通知目标' : '新增通知目标'} +

+
+
+
+ + + 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" + /> +
+ +
+ + + 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" + /> +
+ +
+ + +
+ + {notificationTargetForm.authType === 'bearer' && ( +
+ + + 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" + /> +
+ )} + + {notificationTargetForm.authType === 'header' && ( +
+
+ + + 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" + /> +
+
+ + + 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" + /> +
+
+ )} + + + +
+ 当前固定订阅事件:任务-详情全套入库完成(`task-detail.full-set.ready`) +
+ +
+ + +
+
+
+
+
+ +
+
+
+

+ 自动上架投递记录 +

+

+ 查看最近 50 条通知投递情况;失败或超上限的记录可以在这里手工补发。 +

+
+
+ + +
+
+ + {notificationDeliveriesLoading ? ( +
+ 加载中... +
+ ) : notificationDeliveries.length === 0 ? ( +
+ 当前筛选条件下暂无投递记录 +
+ ) : ( +
+ + + + + + + + + + + + + + + {notificationDeliveries.map((delivery) => ( + + + + + + + + + + + ))} + +
+ 目标系统 + + 素材 + + 状态 + + 尝试次数 + + 最近结果 + + 下一次重试 + + 更新时间 + + 操作 +
+
+ {delivery.targetName || '未知目标'} +
+
+ {delivery.id} +
+
+
+ {delivery.materialType} +
+
+ materialId: {delivery.materialId} +
+
+ taskNo: {delivery.taskNo || '无'} +
+
+ + {getNotificationDeliveryStatusLabel(delivery.status)} + + + {delivery.attemptCount} / {delivery.maxAttempts} + +
+ {delivery.lastStatusCode ? `HTTP ${delivery.lastStatusCode}` : '暂无'} +
+
+ {delivery.lastError || delivery.lastResponseBody || '无'} +
+
+ {formatDateTime(delivery.nextRetryAt)} + + {formatDateTime(delivery.updatedAt)} + + +
+
+ )} +
{/* 创建 API Key 模态框 */} @@ -492,4 +1225,3 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) { ) } - diff --git a/docs/外部接口文档.md b/docs/外部接口文档.md index cb5bbcd..264e02c 100644 --- a/docs/外部接口文档.md +++ b/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` 查询口径、自动上架平台通知说明,以及相关错误码与排序字段说明* diff --git a/docs/自动上架平台成套入库通知接口.md b/docs/自动上架平台成套入库通知接口.md new file mode 100644 index 0000000..9df6669 --- /dev/null +++ b/docs/自动上架平台成套入库通知接口.md @@ -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 校验,请在素材库后台配置对应鉴权方式 diff --git a/lib/__tests__/external-auto-listing.test.ts b/lib/__tests__/external-auto-listing.test.ts new file mode 100644 index 0000000..6781b1e --- /dev/null +++ b/lib/__tests__/external-auto-listing.test.ts @@ -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, + ) + }) +}) diff --git a/lib/__tests__/external-notification-admin-routes.test.ts b/lib/__tests__/external-notification-admin-routes.test.ts new file mode 100644 index 0000000..f02a61e --- /dev/null +++ b/lib/__tests__/external-notification-admin-routes.test.ts @@ -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) + }) +}) diff --git a/lib/__tests__/material-update.test.ts b/lib/__tests__/material-update.test.ts index c871553..0797427 100644 --- a/lib/__tests__/material-update.test.ts +++ b/lib/__tests__/material-update.test.ts @@ -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') + }) }) diff --git a/lib/__tests__/product-assets-route.test.ts b/lib/__tests__/product-assets-route.test.ts new file mode 100644 index 0000000..0329da6 --- /dev/null +++ b/lib/__tests__/product-assets-route.test.ts @@ -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', + }, + }) + }) +}) diff --git a/lib/__tests__/product-package-completion.test.ts b/lib/__tests__/product-package-completion.test.ts new file mode 100644 index 0000000..b21cc99 --- /dev/null +++ b/lib/__tests__/product-package-completion.test.ts @@ -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() + }) +}) diff --git a/lib/external-api.ts b/lib/external-api.ts index 8e07982..5fbf32b 100644 --- a/lib/external-api.ts +++ b/lib/external-api.ts @@ -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: '服务器内部错误', diff --git a/lib/external-auto-listing.ts b/lib/external-auto-listing.ts new file mode 100644 index 0000000..7ed2a7b --- /dev/null +++ b/lib/external-auto-listing.ts @@ -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 + 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 | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +export interface ExternalNotificationTargetInput { + name: string + callbackUrl: string + isActive?: boolean + authType?: string + authConfig?: Record + 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() + +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 { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return {} + } + + return Object.entries(input as Record).reduce>((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) + : 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( + ` + 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( + ` + 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, +) { + 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( + ` + 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(sql: string, ...params: any[]) { + return prisma.$queryRawUnsafe(sql, ...params) as Promise +} + +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 = { + '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( + ` + 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, + } +} diff --git a/lib/material-update.ts b/lib/material-update.ts index e274ce0..418b0b7 100644 --- a/lib/material-update.ts +++ b/lib/material-update.ts @@ -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, diff --git a/lib/product-package-completion.ts b/lib/product-package-completion.ts index c8c7593..86ba39a 100644 --- a/lib/product-package-completion.ts +++ b/lib/product-package-completion.ts @@ -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, diff --git a/lib/product-package.ts b/lib/product-package.ts index c77f79f..3952654 100644 --- a/lib/product-package.ts +++ b/lib/product-package.ts @@ -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, + }, + }, + }, + }) +} + /** * 当任务号被复用时,清理此前已软删除的旧素材,避免残留孤儿文件 */ diff --git a/lib/public-display.ts b/lib/public-display.ts new file mode 100644 index 0000000..939a36a --- /dev/null +++ b/lib/public-display.ts @@ -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 { + 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('/')) +}