537 lines
14 KiB
TypeScript
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',
|
|
)
|
|
}
|
|
}
|