2026-05-07 16:37:22 +08:00

272 lines
7.2 KiB
TypeScript

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',
)
}
}