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, getWhiteBackgroundAssetWhereForSku, getWhiteBackgroundZipFileName, groupWhiteBackgroundAssetsBySku, hasAnyWhiteBackgroundAssetGroup, hasCompleteWhiteBackgroundAssetGroup, STANDARD_MODEL_WHITE_BG_VIEWS, type GroupedWhiteBackgroundAssets, type WhiteBackgroundAssetLike, } from '@/lib/standard-model-white-background' import { getPublicAppOrigin } from '@/lib/public-app-url' import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview' type WhiteBackgroundAssetRecord = WhiteBackgroundAssetLike & { id: string fileUrl: string fileName: string | null fileSize: bigint | number | string | null sortOrder: number } type TraceContext = { traceId: string startTime: number normalizedSkuName: string normalizedSpu: string ipAddress?: string apiKeyName?: string } type LogBase = { apiKeyId: string endpoint: string method: string ipAddress?: string userAgent?: string } function normalizeSkuName(value: string): string { return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim() } function buildErrorResponse(status: number, code: string, message: string) { return NextResponse.json( { success: false, error: { code, message, }, }, { status }, ) } 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}`) 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-sku-white-background]', JSON.stringify({ traceId: trace.traceId, outcome, statusCode, duration, sku: trace.normalizedSkuName || null, spu: trace.normalizedSpu || null, ipAddress: trace.ipAddress || null, apiKeyName: trace.apiKeyName || null, }), ) return response } function getBaseUrl() { return getPublicAppOrigin() } function formatFileSize(value: bigint | number | string | null | undefined): number { if (typeof value === 'bigint') { return Number(value) } const parsed = Number(value || 0) return Number.isFinite(parsed) ? parsed : 0 } function buildPreviewUrl(url?: string | null): string | null { const previewFileUrl = resolveWhiteBackgroundPreviewFileUrl(url) const rewrittenUrl = rewriteStorageUrlForClient(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 buildUploaderMap(users: Array<{ id: string; username: string; realName: string | null }>) { return new Map(users.map((user) => [user.id, user])) } function pickRepresentativePreview( group?: GroupedWhiteBackgroundAssets | null, ) { return Object.values(group?.fixed || {}).find(Boolean) || group?.other?.[0] || null } function buildSkuSummary(params: { materialId: string spu: string sku: { id: string skuName: string whiteBackgroundSourceType: string | null whiteBackgroundUploadedBy: string | null whiteBackgroundUploadedAt: Date | null } group?: GroupedWhiteBackgroundAssets | null uploaderMap: Map }) { const { materialId, spu, sku, group, uploaderMap } = params const hasWhiteBackground = hasAnyWhiteBackgroundAssetGroup(group) const fixedCount = countWhiteBackgroundFixedViews(group) const otherCount = countWhiteBackgroundOtherImages(group) const whiteBackgroundCount = countWhiteBackgroundAssets(group) const previewAsset = pickRepresentativePreview(group) const uploader = sku.whiteBackgroundUploadedBy ? uploaderMap.get(sku.whiteBackgroundUploadedBy) || null : null const baseUrl = getBaseUrl() return { spu, sku: sku.skuName, hasWhiteBackground, isComplete: hasCompleteWhiteBackgroundAssetGroup(group), whiteBackgroundCount, fixedCount, otherCount, sourceType: sku.whiteBackgroundSourceType, uploader: uploader ? { id: uploader.id, username: uploader.username, realName: uploader.realName, } : null, uploadedAt: sku.whiteBackgroundUploadedAt ? sku.whiteBackgroundUploadedAt.toISOString() : null, download: hasWhiteBackground ? { zipFileName: getWhiteBackgroundZipFileName(sku.skuName), downloadUrl: generateDownloadUrl(baseUrl, { materialId, downloadType: 'white-background-zip', skuId: sku.id, }), } : null, preview: previewAsset ? { fileName: previewAsset.fileName || path.basename(previewAsset.fileUrl || `${sku.skuName}-white-background.webp`), previewUrl: buildPreviewUrl(previewAsset.fileUrl), } : null, missingViews: hasWhiteBackground ? getMissingWhiteBackgroundViewsFromAssetGroup(group).map((view) => ({ key: view.key, label: view.label, })) : STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => ({ key: view.key, label: view.label, })), } } export async function GET(request: NextRequest) { const trace: TraceContext = { traceId: randomUUID(), startTime: Date.now(), normalizedSkuName: normalizeSkuName(request.nextUrl.searchParams.get('sku') || ''), normalizedSpu: normalizeSkuName(request.nextUrl.searchParams.get('spu') || ''), ipAddress: getClientIp(request), } const apiKey = getApiKeyFromRequest(request) 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: LogBase = { apiKeyId: keyRecord.id, endpoint: '/api/external/standard-model-sku-white-background', method: 'GET', ipAddress: trace.ipAddress, userAgent: request.headers.get('user-agent') || undefined, } if (!trace.normalizedSkuName && !trace.normalizedSpu) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'empty', queryValue: '', statusCode: 400, duration, }) return finalizeResponse( buildErrorResponse(400, 'MISSING_PARAM', '请传入 sku 或 spu 参数'), trace, 400, 'MISSING_PARAM', ) } if (trace.normalizedSkuName) { return handleSkuQuery(trace.normalizedSkuName, logBase, trace) } return handleSpuQuery(trace.normalizedSpu, logBase, trace) } async function handleSkuQuery(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, materialId: true, whiteBackgroundSourceType: true, whiteBackgroundUploadedBy: true, whiteBackgroundUploadedAt: true, material: { select: { productModel: true, }, }, }, take: 2, }) if (matchedSkus.length === 0) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'sku', queryValue: normalizedSkuName, 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, queryType: 'sku', queryValue: normalizedSkuName, 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, uploaders] = await Promise.all([ 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' }, }), matchedSku.whiteBackgroundUploadedBy ? prisma.user.findMany({ where: { id: { in: [matchedSku.whiteBackgroundUploadedBy] } }, select: { id: true, username: true, realName: true }, }) : Promise.resolve([]), ]) const group = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets).get(matchedSku.id) const data = buildSkuSummary({ materialId: matchedSku.materialId, spu: matchedSku.material.productModel, sku: matchedSku, group, uploaderMap: buildUploaderMap(uploaders), }) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'sku', queryValue: normalizedSkuName, statusCode: 200, duration, }) return finalizeResponse(successResponse(data), trace, 200, 'SKU_OK') } catch (error) { console.error('[external/standard-model-sku-white-background] sku query error:', error) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'sku', queryValue: normalizedSkuName, statusCode: 500, duration, }) return finalizeResponse( buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), trace, 500, 'SERVER_ERROR', ) } } async function handleSpuQuery(normalizedSpu: string, logBase: LogBase, trace: TraceContext) { try { const material = await prisma.material.findFirst({ where: { productModel: normalizedSpu, materialType: '标准模型', deletedAt: null, }, select: { id: true, productModel: true, skus: { where: { deletedAt: null }, select: { id: true, skuName: true, whiteBackgroundSourceType: true, whiteBackgroundUploadedBy: true, whiteBackgroundUploadedAt: true, }, orderBy: { sortOrder: 'asc' }, }, }, }) if (!material) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'spu', queryValue: normalizedSpu, statusCode: 404, duration, }) return finalizeResponse( buildErrorResponse(404, 'SPU_NOT_FOUND', '未找到对应的标准模型 SPU'), trace, 404, 'SPU_NOT_FOUND', ) } const skuIds = material.skus.map((sku) => sku.id) const uploaderIds = Array.from( new Set(material.skus.map((sku) => sku.whiteBackgroundUploadedBy).filter((value): value is string => Boolean(value))), ) const [whiteBackgroundAssets, uploaders] = await Promise.all([ skuIds.length > 0 ? prisma.materialAsset.findMany({ where: { materialId: material.id, OR: skuIds.flatMap((skuId) => getWhiteBackgroundAssetWhereForSku(skuId).OR), }, select: { id: true, assetType: true, fileUrl: true, fileName: true, fileSize: true, sortOrder: true, }, orderBy: { sortOrder: 'asc' }, }) : Promise.resolve([]), uploaderIds.length > 0 ? prisma.user.findMany({ where: { id: { in: uploaderIds } }, select: { id: true, username: true, realName: true }, }) : Promise.resolve([]), ]) const grouped = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets) const uploaderMap = buildUploaderMap(uploaders) const items = material.skus.map((sku) => buildSkuSummary({ materialId: material.id, spu: material.productModel, sku, group: grouped.get(sku.id), uploaderMap, }), ) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'spu', queryValue: normalizedSpu, statusCode: 200, duration, }) return finalizeResponse( successResponse({ spu: material.productModel, skuCount: items.length, items, }), trace, 200, 'SPU_OK', ) } catch (error) { console.error('[external/standard-model-sku-white-background] spu query error:', error) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'spu', queryValue: normalizedSpu, statusCode: 500, duration, }) return finalizeResponse( buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), trace, 500, 'SERVER_ERROR', ) } }