272 lines
7.2 KiB
TypeScript
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',
|
|
)
|
|
}
|
|
}
|