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

537 lines
14 KiB
TypeScript

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<WhiteBackgroundAssetRecord> | 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<WhiteBackgroundAssetRecord> | null
uploaderMap: Map<string, { id: string; username: string; realName: string | null }>
}) {
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',
)
}
}