import { randomUUID } from 'crypto' import { NextRequest, NextResponse } from 'next/server' import { getApiKeyFromRequest, getClientIp, logApiCall, successResponse, validateApiKey, } from '@/lib/external-api' import { prisma } from '@/lib/prisma' import { getPublicAppOrigin } from '@/lib/public-app-url' import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' type TraceContext = { traceId: string startTime: number normalizedSpu: string ipAddress?: string apiKeyName?: string } type LogBase = { apiKeyId: string endpoint: string method: string ipAddress?: string userAgent?: string } function normalizeValue(value: string | null): string { return String(value || '').trim() } function normalizeSpu(value: string | null): string { return normalizeValue(value).replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase() } function getElapsedMs(startTime: number) { return Math.max(0, Date.now() - startTime) } function buildErrorResponse(status: number, code: string, message: string) { return NextResponse.json( { success: false, error: { code, message, }, }, { status }, ) } 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.normalizedSpu) { response.headers.set('X-Request-Spu', trace.normalizedSpu) } console.info( '[external/dingtalk-material-match]', JSON.stringify({ traceId: trace.traceId, outcome, statusCode, duration, spu: trace.normalizedSpu || null, ipAddress: trace.ipAddress || null, apiKeyName: trace.apiKeyName || null, }), ) return response } function buildAbsoluteUrl(url?: string | null): string | null { const rewritten = rewriteStorageUrlForClient(url) if (!rewritten) { return null } if (/^https?:\/\//i.test(rewritten)) { return rewritten } const baseUrl = getPublicAppOrigin().replace(/\/+$/, '') const normalizedPath = rewritten.replace(/\\/g, '/').replace(/^\.?\//, '/') return `${baseUrl}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}` } function pickAnyAddress(material: { previewUrl: string videoPreview540Url: string | null videoCoverUrl: string | null zipFilePath: string }) { return ( buildAbsoluteUrl(material.previewUrl) || buildAbsoluteUrl(material.videoPreview540Url) || buildAbsoluteUrl(material.videoCoverUrl) || buildAbsoluteUrl(material.zipFilePath) || null ) } export async function GET(request: NextRequest) { const trace: TraceContext = { traceId: randomUUID(), startTime: Date.now(), normalizedSpu: normalizeSpu( request.nextUrl.searchParams.get('spu') || request.nextUrl.searchParams.get('SPU'), ), ipAddress: getClientIp(request), } const apiKey = getApiKeyFromRequest(request, { allowQuery: true }) 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/dingtalk-material-match', method: 'GET', ipAddress: trace.ipAddress, userAgent: request.headers.get('user-agent') || undefined, } if (!trace.normalizedSpu) { const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'spu', queryValue: '', statusCode: 400, duration, }) return finalizeResponse( buildErrorResponse(400, 'MISSING_SPU', 'SPU 为必填项'), trace, 400, 'MISSING_SPU', ) } const materialType = normalizeValue(request.nextUrl.searchParams.get('requirementType')) || normalizeValue(request.nextUrl.searchParams.get('materialType')) || normalizeValue(request.nextUrl.searchParams.get('需求类型')) const brand = normalizeValue(request.nextUrl.searchParams.get('brand')) || normalizeValue(request.nextUrl.searchParams.get('品牌')) const sceneName = normalizeValue(request.nextUrl.searchParams.get('sceneCategory')) || normalizeValue(request.nextUrl.searchParams.get('sceneName')) || normalizeValue(request.nextUrl.searchParams.get('场景类别')) const productCategoryName = normalizeValue(request.nextUrl.searchParams.get('productCategory')) || normalizeValue(request.nextUrl.searchParams.get('productCategoryName')) || normalizeValue(request.nextUrl.searchParams.get('产品类别')) try { const matchedMaterial = await prisma.material.findFirst({ where: { deletedAt: null, productModel: trace.normalizedSpu, ...(materialType ? { materialType } : {}), ...(brand ? { categoryName: brand } : {}), ...(sceneName ? { sceneName } : {}), ...(productCategoryName ? { productCategoryName } : {}), }, select: { id: true, productModel: true, materialType: true, categoryName: true, sceneName: true, productCategoryName: true, previewUrl: true, videoPreview540Url: true, videoCoverUrl: true, zipFilePath: true, updatedAt: true, }, orderBy: [ { updatedAt: 'desc' }, { createdAt: 'desc' }, ], }) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'spu', queryValue: trace.normalizedSpu, statusCode: 200, duration, }) if (!matchedMaterial) { return finalizeResponse( successResponse({ matched: false, message: '该SPU下暂无素材', spu: trace.normalizedSpu, requirementType: materialType || null, brand: brand || null, sceneCategory: sceneName || null, productCategory: productCategoryName || null, address: null, }), trace, 200, 'NO_MATCH', ) } return finalizeResponse( successResponse({ matched: true, message: '查询成功', spu: matchedMaterial.productModel, materialId: matchedMaterial.id, requirementType: matchedMaterial.materialType, brand: matchedMaterial.categoryName, sceneCategory: matchedMaterial.sceneName, productCategory: matchedMaterial.productCategoryName || '', address: pickAnyAddress(matchedMaterial), updatedAt: matchedMaterial.updatedAt.toISOString(), }), trace, 200, 'MATCHED', ) } catch (error) { console.error('[external/dingtalk-material-match] error:', error) const duration = getElapsedMs(trace.startTime) await logApiCall({ ...logBase, queryType: 'spu', queryValue: trace.normalizedSpu, statusCode: 500, duration, }) return finalizeResponse( buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), trace, 500, 'SERVER_ERROR', ) } }