import path from 'path' import { randomUUID } from 'crypto' import { NextRequest, NextResponse } from 'next/server' import { getApiKeyFromRequest, getClientIp, logApiCall, successResponse, validateApiKey, } from '@/lib/external-api' import { generateDownloadUrl } from '@/lib/download-token' import { prisma } from '@/lib/prisma' import { countWhiteBackgroundAssets, countWhiteBackgroundFixedViews, countWhiteBackgroundOtherImages, getMissingWhiteBackgroundViewsFromAssetGroup, hasAnyWhiteBackgroundAssetGroup, getWhiteBackgroundAssetWhereForSku, getWhiteBackgroundZipFileName, groupWhiteBackgroundAssetsBySku, hasCompleteWhiteBackgroundAssetGroup, STANDARD_MODEL_WHITE_BG_VIEWS, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' import { getPublicAppOrigin } from '@/lib/public-app-url' import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' import { isTaskDetailFullSetFromAssets } from '@/lib/task-detail-full-set' import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview' function normalizeSkuName(value: string): string { return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim() } function getBaseUrl(): string { return getPublicAppOrigin() } function buildErrorResponse(status: number, code: string, message: string) { return NextResponse.json( { success: false, error: { code, message, }, }, { status }, ) } type ResourceType = 'white-background' | 'task-detail' type TraceContext = { traceId: string startTime: number resourceType: ResourceType normalizedSkuName: string normalizedSpu: string ipAddress?: string apiKeyName?: string } type LogBase = { apiKeyId: string endpoint: string method: string ipAddress?: string userAgent?: string } function formatPreviewUrl(url?: string | null): string | null { return rewriteStorageUrlForClient(url) } function buildWhiteBackgroundPreviewUrl(url?: string | null): string | null { const previewFileUrl = resolveWhiteBackgroundPreviewFileUrl(url) const rewrittenUrl = formatPreviewUrl(previewFileUrl) if (!rewrittenUrl) { return null } if (/^https?:\/\//i.test(rewrittenUrl)) { return rewrittenUrl } const normalizedPath = rewrittenUrl .trim() .replace(/\\/g, '/') .replace(/^\.?\//, '/') if (!normalizedPath) { return null } try { return new URL(normalizedPath, getBaseUrl()).toString() } catch { return `${getBaseUrl().replace(/\/+$/, '')}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}` } } function getElapsedMs(startTime: number) { return Math.max(0, Date.now() - startTime) } function finalizeResponse( response: NextResponse, trace: TraceContext, statusCode: number, outcome: string, ) { const duration = getElapsedMs(trace.startTime) response.headers.set('X-Trace-Id', trace.traceId) response.headers.set('X-Server-Duration-Ms', String(duration)) response.headers.set('Server-Timing', `app;dur=${duration}`) response.headers.set('X-Resource-Type', trace.resourceType) if (trace.normalizedSkuName) { response.headers.set('X-Request-Sku', trace.normalizedSkuName) } if (trace.normalizedSpu) { response.headers.set('X-Request-Spu', trace.normalizedSpu) } console.info( '[external/standard-model-white-background]', JSON.stringify({ traceId: trace.traceId, outcome, statusCode, duration, resourceType: trace.resourceType, sku: trace.normalizedSkuName || null, spu: trace.normalizedSpu || null, ipAddress: trace.ipAddress || null, apiKeyName: trace.apiKeyName || null, }), ) return response } function collectPreviewUrls( assets: Array<{ assetType: string; fileUrl: string; sortOrder: number }>, assetType: string, ) { return assets .filter((asset) => asset.assetType === assetType) .sort((left, right) => left.sortOrder - right.sortOrder) .map((asset) => formatPreviewUrl(asset.fileUrl)) .filter((url): url is string => Boolean(url)) } function formatTaskDetailCandidate(material: { id: string taskNo: string | null productModel: string categoryName: string sceneName: string createdAt: Date assets: Array<{ assetType: string; fileUrl: string; sortOrder: number }> }) { const main = collectPreviewUrls(material.assets, 'main-preview') const sku = collectPreviewUrls(material.assets, 'sku-preview') const detail = collectPreviewUrls(material.assets, 'detail-preview') const isFullSet = isTaskDetailFullSetFromAssets(material.assets) return { materialId: material.id, taskNo: material.taskNo, productModel: material.productModel, categoryName: material.categoryName, shopName: material.categoryName, sceneName: material.sceneName, uploadedAt: material.createdAt.toISOString(), isFullSet, downloadUrls: { main, sku, detail, all: [...main, ...sku, ...detail], }, } } function getTaskDetailSummaryFields( candidates: Array<{ categoryName: string; shopName: string }>, ) { const firstCandidate = candidates[0] return { categoryName: firstCandidate?.categoryName || '', shopName: firstCandidate?.shopName || '', } } export async function GET(request: NextRequest) { const startTime = Date.now() const traceId = randomUUID() const apiKey = getApiKeyFromRequest(request) const sku = request.nextUrl.searchParams.get('sku') || '' const spu = request.nextUrl.searchParams.get('spu') || '' const resourceType = (request.nextUrl.searchParams.get('resourceType') || 'white-background').trim() as ResourceType const normalizedSkuName = normalizeSkuName(sku) const normalizedSpu = normalizeSkuName(spu) const ipAddress = getClientIp(request) const trace: TraceContext = { traceId, startTime, resourceType: resourceType === 'task-detail' ? 'task-detail' : 'white-background', normalizedSkuName, normalizedSpu, ipAddress, } const keyRecord = await validateApiKey(apiKey) if (!keyRecord) { return finalizeResponse( buildErrorResponse(401, 'INVALID_API_KEY', 'API Key 无效或缺失'), trace, 401, 'INVALID_API_KEY', ) } trace.apiKeyName = keyRecord.name const logBase = { apiKeyId: keyRecord.id, endpoint: '/api/external/standard-model-white-background', method: 'GET' as const, ipAddress, userAgent: request.headers.get('user-agent') || undefined, } if (resourceType !== 'white-background' && resourceType !== 'task-detail') { const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 400, duration, }) return finalizeResponse( buildErrorResponse(400, 'RESOURCE_TYPE_INVALID', 'resourceType 仅支持 white-background 或 task-detail'), trace, 400, 'RESOURCE_TYPE_INVALID', ) } if (resourceType === 'task-detail') { trace.resourceType = 'task-detail' if (normalizedSpu) { return handleTaskDetailSpuQuery(normalizedSpu, logBase, trace) } if (!normalizedSkuName) { const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 400, duration, }) return finalizeResponse( buildErrorResponse(400, 'MISSING_PARAM', '请传入 sku 或 spu 参数'), trace, 400, 'MISSING_PARAM', ) } return handleTaskDetailSkuQuery(normalizedSkuName, logBase, trace) } // SPU 查询:返回该 SPU 下所有 SKU 的右前45°预览图清单 if (normalizedSpu) { return handleSpuQuery(normalizedSpu, logBase, trace) } if (!normalizedSkuName) { const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 400, duration, }) return finalizeResponse( buildErrorResponse(400, 'MISSING_PARAM', '请传入 sku 或 spu 参数'), trace, 400, 'MISSING_PARAM', ) } try { const matchedSkus = await prisma.materialSku.findMany({ where: { deletedAt: null, skuName: normalizedSkuName, material: { is: { materialType: '标准模型', deletedAt: null, }, }, }, select: { id: true, skuName: true, materialId: true, material: { select: { id: true, productModel: true, categoryName: true, deletedAt: true, }, }, }, take: 2, }) if (matchedSkus.length === 0) { const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 404, duration, }) return finalizeResponse( buildErrorResponse(404, 'SKU_NOT_FOUND', '未找到对应的标准模型 SKU'), trace, 404, 'SKU_NOT_FOUND', ) } if (matchedSkus.length > 1) { const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 409, duration, }) return finalizeResponse( buildErrorResponse(409, 'SKU_NOT_UNIQUE', '该 SKU 存在重复数据,暂无法对外提供白底图'), trace, 409, 'SKU_NOT_UNIQUE', ) } const matchedSku = matchedSkus[0] trace.normalizedSpu = matchedSku.material.productModel const whiteBackgroundAssets = await prisma.materialAsset.findMany({ where: { materialId: matchedSku.materialId, ...getWhiteBackgroundAssetWhereForSku(matchedSku.id), }, select: { id: true, assetType: true, fileUrl: true, fileName: true, fileSize: true, sortOrder: true, }, orderBy: { sortOrder: 'asc' }, }) const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets).get(matchedSku.id) if (!whiteBackgroundGroup || !hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) { const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 404, duration, }) return finalizeResponse( buildErrorResponse(404, 'WHITE_BACKGROUND_NOT_FOUND', '该 SKU 暂无白底图'), trace, 404, 'WHITE_BACKGROUND_NOT_FOUND', ) } const baseUrl = getBaseUrl() const fixed = STANDARD_MODEL_WHITE_BG_VIEWS .map((view) => { const asset = whiteBackgroundGroup.fixed[view.key] if (!asset) { return null } const fileName = asset.fileName || `${view.label}${path.extname(asset.fileUrl || '') || '.jpg'}` return { key: view.key, label: view.label, fileName, fileSize: Number(asset.fileSize || 0), previewUrl: buildWhiteBackgroundPreviewUrl(asset.fileUrl), downloadUrl: generateDownloadUrl(baseUrl, { materialId: matchedSku.materialId, downloadType: 'white-background-single', assetId: asset.id, }), } }) .filter((item): item is { key: StandardModelWhiteBgKey label: string fileName: string fileSize: number previewUrl: string | null downloadUrl: string } => Boolean(item)) const other = (whiteBackgroundGroup.other || []).map((asset, index) => ({ key: `other-${index + 1}`, label: `其他图片${index + 1}`, fileName: asset.fileName || path.basename(asset.fileUrl || 'other-image'), fileSize: Number(asset.fileSize || 0), previewUrl: buildWhiteBackgroundPreviewUrl(asset.fileUrl), downloadUrl: generateDownloadUrl(baseUrl, { materialId: matchedSku.materialId, downloadType: 'white-background-single', assetId: asset.id, }), })) const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 200, duration, }) return finalizeResponse( successResponse({ resourceType: 'white-background', sku: matchedSku.skuName, materialId: matchedSku.materialId, productModel: matchedSku.material.productModel, categoryName: matchedSku.material.categoryName || '', shopName: matchedSku.material.categoryName || '', whiteBackgroundUploaded: true, whiteBackgroundComplete: hasCompleteWhiteBackgroundAssetGroup(whiteBackgroundGroup), whiteBackgroundFixedCount: countWhiteBackgroundFixedViews(whiteBackgroundGroup), whiteBackgroundOtherCount: countWhiteBackgroundOtherImages(whiteBackgroundGroup), whiteBackgroundCount: countWhiteBackgroundAssets(whiteBackgroundGroup), missingViews: getMissingWhiteBackgroundViewsFromAssetGroup(whiteBackgroundGroup).map((view) => ({ key: view.key, label: view.label, })), whiteBackground: { fixed, other, }, downloadUrls: { zip: generateDownloadUrl(baseUrl, { materialId: matchedSku.materialId, downloadType: 'white-background-zip', skuId: matchedSku.id, }), }, }), trace, 200, 'WHITE_BACKGROUND_SKU_OK', ) } catch (error) { console.error('[external/standard-model-white-background] error:', error) const duration = getElapsedMs(startTime) await logApiCall({ ...logBase, statusCode: 500, duration, }) return finalizeResponse( buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), trace, 500, 'SERVER_ERROR', ) } } /** * SPU 查询:返回该 SPU 下所有 SKU 的右前45°预览图清单 * AI 生图平台用 SPU 调用,展示所有 SKU 供用户选择,之后再用 SKU 查询获取完整白底图 */ async function handleSpuQuery( normalizedSpu: string, logBase: LogBase, trace: TraceContext, ) { try { // 通过 productModel(SPU)查找标准模型素材 const material = await prisma.material.findFirst({ where: { productModel: normalizedSpu, materialType: '标准模型', deletedAt: null, }, select: { id: true, productModel: true, categoryName: true, skus: { where: { deletedAt: null }, select: { id: true, skuName: true, previewUrl: true, }, orderBy: { sortOrder: 'asc' }, }, }, }) if (!material) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 404, duration }) return finalizeResponse( buildErrorResponse(404, 'SPU_NOT_FOUND', '未找到对应的标准模型 SPU'), trace, 404, 'SPU_NOT_FOUND', ) } if (material.skus.length === 0) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 404, duration }) return finalizeResponse( buildErrorResponse(404, 'NO_SKUS', '该 SPU 下没有 SKU 数据'), trace, 404, 'NO_SKUS', ) } // 批量查询所有 SKU 的白底图资产(一次性查出,避免 N+1) const allSkuIds = material.skus.map((s) => s.id) const allWhiteBackgroundAssets = await prisma.materialAsset.findMany({ where: { materialId: material.id, OR: allSkuIds.flatMap((skuId) => [ { assetType: { startsWith: `standard-model-white-bg-view:${skuId}:` } }, { assetType: { startsWith: `standard-model-white-bg-other:${skuId}:` } }, { assetType: { startsWith: `standard-model-white-bg-zip-v2:${skuId}` } }, { assetType: { startsWith: `standard-model-white-bg-zip:${skuId}` } }, { assetType: 'standard-model-white-bg-zip' }, ]), }, select: { id: true, assetType: true, fileUrl: true, fileName: true, fileSize: true, sortOrder: true, }, }) // 按 SKU 分组 const groupedBySku = groupWhiteBackgroundAssetsBySku(allWhiteBackgroundAssets) const baseUrl = getBaseUrl() // 构建每个 SKU 的预览信息 const skuList = material.skus.map((sku) => { const group = groupedBySku.get(sku.id) const rightFront45Asset = group?.fixed?.rightFront45 const hasWhiteBackground = group ? hasAnyWhiteBackgroundAssetGroup(group) : false let previewImage: { downloadUrl: string previewUrl: string | null fileName: string fileSize: number } | null = null if (rightFront45Asset) { const fileName = rightFront45Asset.fileName || `右前45°${path.extname(rightFront45Asset.fileUrl || '') || '.jpg'}` previewImage = { downloadUrl: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'white-background-single', assetId: rightFront45Asset.id, }), previewUrl: buildWhiteBackgroundPreviewUrl(rightFront45Asset.fileUrl), fileName, fileSize: Number(rightFront45Asset.fileSize || 0), } } return { skuName: sku.skuName, skuId: sku.id, hasWhiteBackground, whiteBackgroundCount: group ? countWhiteBackgroundFixedViews(group) : 0, previewImage, } }) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 200, duration }) return finalizeResponse( successResponse({ resourceType: 'white-background', spu: material.productModel, materialId: material.id, categoryName: material.categoryName || '', shopName: material.categoryName || '', skuCount: material.skus.length, skuList, }), trace, 200, 'WHITE_BACKGROUND_SPU_OK', ) } catch (error) { console.error('[external/standard-model-white-background] spu query error:', error) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 500, duration }) return finalizeResponse( buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), trace, 500, 'SERVER_ERROR', ) } } async function getTaskDetailMaterialsBySpu(normalizedSpu: string) { return prisma.material.findMany({ where: { productModel: normalizedSpu, materialType: '任务-详情', deletedAt: null, }, select: { id: true, taskNo: true, productModel: true, categoryName: true, sceneName: true, createdAt: true, assets: { where: { assetType: { in: ['main-preview', 'main', 'sku-preview', 'sku', 'detail-preview', 'detail'], }, }, select: { assetType: true, fileUrl: true, sortOrder: true, }, orderBy: { sortOrder: 'asc' }, }, }, orderBy: { createdAt: 'desc' }, }) } async function handleTaskDetailSpuQuery( normalizedSpu: string, logBase: LogBase, trace: TraceContext, ) { try { const taskDetails = await getTaskDetailMaterialsBySpu(normalizedSpu) if (taskDetails.length === 0) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 404, duration }) return finalizeResponse( buildErrorResponse(404, 'TASK_DETAIL_NOT_FOUND', '该 SPU 下暂无任务-详情素材'), trace, 404, 'TASK_DETAIL_NOT_FOUND', ) } const fullSetCandidates = taskDetails.filter((material) => isTaskDetailFullSetFromAssets(material.assets)) if (fullSetCandidates.length === 0) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 404, duration }) return finalizeResponse( buildErrorResponse(404, 'FULL_SET_NOT_FOUND', '该 SPU 下暂无主图、SKU 图、详情图齐全的任务-详情素材'), trace, 404, 'FULL_SET_NOT_FOUND', ) } const candidates = fullSetCandidates.map((material) => formatTaskDetailCandidate(material)) const summaryFields = getTaskDetailSummaryFields(candidates) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 200, duration }) return finalizeResponse( successResponse({ resourceType: 'task-detail', spu: normalizedSpu, sku: null, categoryName: summaryFields.categoryName, shopName: summaryFields.shopName, candidateCount: candidates.length, candidates, }), trace, 200, 'TASK_DETAIL_SPU_OK', ) } catch (error) { console.error('[external/standard-model-white-background] task-detail spu query error:', error) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 500, duration }) return finalizeResponse( buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), trace, 500, 'SERVER_ERROR', ) } } async function handleTaskDetailSkuQuery( normalizedSkuName: string, logBase: LogBase, trace: TraceContext, ) { try { const matchedSkus = await prisma.materialSku.findMany({ where: { deletedAt: null, skuName: normalizedSkuName, material: { is: { materialType: '标准模型', deletedAt: null, }, }, }, select: { id: true, skuName: true, material: { select: { productModel: true, categoryName: true, }, }, }, take: 2, }) if (matchedSkus.length === 0) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 404, duration }) return finalizeResponse( buildErrorResponse(404, 'SKU_NOT_FOUND', '未找到对应的标准模型 SKU'), trace, 404, 'SKU_NOT_FOUND', ) } if (matchedSkus.length > 1) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 409, duration }) return finalizeResponse( buildErrorResponse(409, 'SKU_NOT_UNIQUE', '该 SKU 存在重复数据,暂无法对外提供任务-详情整套素材'), trace, 409, 'SKU_NOT_UNIQUE', ) } const matchedSku = matchedSkus[0] trace.normalizedSpu = matchedSku.material.productModel const taskDetails = await getTaskDetailMaterialsBySpu(matchedSku.material.productModel) if (taskDetails.length === 0) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 404, duration }) return finalizeResponse( buildErrorResponse(404, 'TASK_DETAIL_NOT_FOUND', '该 SKU 所属 SPU 暂无任务-详情素材'), trace, 404, 'TASK_DETAIL_NOT_FOUND', ) } const fullSetCandidates = taskDetails.filter((material) => isTaskDetailFullSetFromAssets(material.assets)) if (fullSetCandidates.length === 0) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 404, duration }) return finalizeResponse( buildErrorResponse(404, 'FULL_SET_NOT_FOUND', '该 SKU 所属 SPU 暂无主图、SKU 图、详情图齐全的任务-详情素材'), trace, 404, 'FULL_SET_NOT_FOUND', ) } const candidates = fullSetCandidates.map((material) => formatTaskDetailCandidate(material)) const summaryFields = getTaskDetailSummaryFields(candidates) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 200, duration }) return finalizeResponse( successResponse({ resourceType: 'task-detail', spu: matchedSku.material.productModel, sku: matchedSku.skuName, categoryName: summaryFields.categoryName, shopName: summaryFields.shopName, candidateCount: candidates.length, candidates, }), trace, 200, 'TASK_DETAIL_SKU_OK', ) } catch (error) { console.error('[external/standard-model-white-background] task-detail sku query error:', error) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, statusCode: 500, duration }) return finalizeResponse( buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), trace, 500, 'SERVER_ERROR', ) } }