843 lines
24 KiB
TypeScript
843 lines
24 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,
|
||
hasAnyWhiteBackgroundAssetGroup,
|
||
getWhiteBackgroundAssetWhereForSku,
|
||
getWhiteBackgroundZipFileName,
|
||
groupWhiteBackgroundAssetsBySku,
|
||
hasCompleteWhiteBackgroundAssetGroup,
|
||
STANDARD_MODEL_WHITE_BG_VIEWS,
|
||
type StandardModelWhiteBgKey,
|
||
} from '@/lib/standard-model-white-background'
|
||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'
|
||
import { isTaskDetailFullSetFromAssets } from '@/lib/task-detail-full-set'
|
||
import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview'
|
||
|
||
function normalizeSkuName(value: string): string {
|
||
return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim()
|
||
}
|
||
|
||
function getBaseUrl(): string {
|
||
return getPublicAppOrigin()
|
||
}
|
||
|
||
function buildErrorResponse(status: number, code: string, message: string) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code,
|
||
message,
|
||
},
|
||
},
|
||
{ status },
|
||
)
|
||
}
|
||
|
||
type ResourceType = 'white-background' | 'task-detail'
|
||
|
||
type TraceContext = {
|
||
traceId: string
|
||
startTime: number
|
||
resourceType: ResourceType
|
||
normalizedSkuName: string
|
||
normalizedSpu: string
|
||
ipAddress?: string
|
||
apiKeyName?: string
|
||
}
|
||
|
||
type LogBase = {
|
||
apiKeyId: string
|
||
endpoint: string
|
||
method: string
|
||
ipAddress?: string
|
||
userAgent?: string
|
||
}
|
||
|
||
function formatPreviewUrl(url?: string | null): string | null {
|
||
return rewriteStorageUrlForClient(url)
|
||
}
|
||
|
||
function buildWhiteBackgroundPreviewUrl(url?: string | null): string | null {
|
||
const previewFileUrl = resolveWhiteBackgroundPreviewFileUrl(url)
|
||
const rewrittenUrl = formatPreviewUrl(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 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}`)
|
||
response.headers.set('X-Resource-Type', trace.resourceType)
|
||
|
||
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-white-background]',
|
||
JSON.stringify({
|
||
traceId: trace.traceId,
|
||
outcome,
|
||
statusCode,
|
||
duration,
|
||
resourceType: trace.resourceType,
|
||
sku: trace.normalizedSkuName || null,
|
||
spu: trace.normalizedSpu || null,
|
||
ipAddress: trace.ipAddress || null,
|
||
apiKeyName: trace.apiKeyName || null,
|
||
}),
|
||
)
|
||
|
||
return response
|
||
}
|
||
|
||
function collectPreviewUrls(
|
||
assets: Array<{ assetType: string; fileUrl: string; sortOrder: number }>,
|
||
assetType: string,
|
||
) {
|
||
return assets
|
||
.filter((asset) => asset.assetType === assetType)
|
||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||
.map((asset) => formatPreviewUrl(asset.fileUrl))
|
||
.filter((url): url is string => Boolean(url))
|
||
}
|
||
|
||
function formatTaskDetailCandidate(material: {
|
||
id: string
|
||
taskNo: string | null
|
||
productModel: string
|
||
categoryName: string
|
||
sceneName: string
|
||
createdAt: Date
|
||
assets: Array<{ assetType: string; fileUrl: string; sortOrder: number }>
|
||
}) {
|
||
const main = collectPreviewUrls(material.assets, 'main-preview')
|
||
const sku = collectPreviewUrls(material.assets, 'sku-preview')
|
||
const detail = collectPreviewUrls(material.assets, 'detail-preview')
|
||
const isFullSet = isTaskDetailFullSetFromAssets(material.assets)
|
||
|
||
return {
|
||
materialId: material.id,
|
||
taskNo: material.taskNo,
|
||
productModel: material.productModel,
|
||
categoryName: material.categoryName,
|
||
shopName: material.categoryName,
|
||
sceneName: material.sceneName,
|
||
uploadedAt: material.createdAt.toISOString(),
|
||
isFullSet,
|
||
downloadUrls: {
|
||
main,
|
||
sku,
|
||
detail,
|
||
all: [...main, ...sku, ...detail],
|
||
},
|
||
}
|
||
}
|
||
|
||
function getTaskDetailSummaryFields(
|
||
candidates: Array<{ categoryName: string; shopName: string }>,
|
||
) {
|
||
const firstCandidate = candidates[0]
|
||
|
||
return {
|
||
categoryName: firstCandidate?.categoryName || '',
|
||
shopName: firstCandidate?.shopName || '',
|
||
}
|
||
}
|
||
|
||
export async function GET(request: NextRequest) {
|
||
const startTime = Date.now()
|
||
const traceId = randomUUID()
|
||
const apiKey = getApiKeyFromRequest(request)
|
||
const sku = request.nextUrl.searchParams.get('sku') || ''
|
||
const spu = request.nextUrl.searchParams.get('spu') || ''
|
||
const resourceType = (request.nextUrl.searchParams.get('resourceType') || 'white-background').trim() as ResourceType
|
||
const normalizedSkuName = normalizeSkuName(sku)
|
||
const normalizedSpu = normalizeSkuName(spu)
|
||
const ipAddress = getClientIp(request)
|
||
const trace: TraceContext = {
|
||
traceId,
|
||
startTime,
|
||
resourceType: resourceType === 'task-detail' ? 'task-detail' : 'white-background',
|
||
normalizedSkuName,
|
||
normalizedSpu,
|
||
ipAddress,
|
||
}
|
||
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 = {
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/standard-model-white-background',
|
||
method: 'GET' as const,
|
||
ipAddress,
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
}
|
||
|
||
if (resourceType !== 'white-background' && resourceType !== 'task-detail') {
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 400,
|
||
duration,
|
||
})
|
||
return finalizeResponse(
|
||
buildErrorResponse(400, 'RESOURCE_TYPE_INVALID', 'resourceType 仅支持 white-background 或 task-detail'),
|
||
trace,
|
||
400,
|
||
'RESOURCE_TYPE_INVALID',
|
||
)
|
||
}
|
||
|
||
if (resourceType === 'task-detail') {
|
||
trace.resourceType = 'task-detail'
|
||
|
||
if (normalizedSpu) {
|
||
return handleTaskDetailSpuQuery(normalizedSpu, logBase, trace)
|
||
}
|
||
|
||
if (!normalizedSkuName) {
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 400,
|
||
duration,
|
||
})
|
||
return finalizeResponse(
|
||
buildErrorResponse(400, 'MISSING_PARAM', '请传入 sku 或 spu 参数'),
|
||
trace,
|
||
400,
|
||
'MISSING_PARAM',
|
||
)
|
||
}
|
||
|
||
return handleTaskDetailSkuQuery(normalizedSkuName, logBase, trace)
|
||
}
|
||
|
||
// SPU 查询:返回该 SPU 下所有 SKU 的右前45°预览图清单
|
||
if (normalizedSpu) {
|
||
return handleSpuQuery(normalizedSpu, logBase, trace)
|
||
}
|
||
|
||
if (!normalizedSkuName) {
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 400,
|
||
duration,
|
||
})
|
||
return finalizeResponse(
|
||
buildErrorResponse(400, 'MISSING_PARAM', '请传入 sku 或 spu 参数'),
|
||
trace,
|
||
400,
|
||
'MISSING_PARAM',
|
||
)
|
||
}
|
||
|
||
try {
|
||
const matchedSkus = await prisma.materialSku.findMany({
|
||
where: {
|
||
deletedAt: null,
|
||
skuName: normalizedSkuName,
|
||
material: {
|
||
is: {
|
||
materialType: '标准模型',
|
||
deletedAt: null,
|
||
},
|
||
},
|
||
},
|
||
select: {
|
||
id: true,
|
||
skuName: true,
|
||
materialId: true,
|
||
material: {
|
||
select: {
|
||
id: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
deletedAt: true,
|
||
},
|
||
},
|
||
},
|
||
take: 2,
|
||
})
|
||
|
||
if (matchedSkus.length === 0) {
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 404,
|
||
duration,
|
||
})
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'SKU_NOT_FOUND', '未找到对应的标准模型 SKU'),
|
||
trace,
|
||
404,
|
||
'SKU_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
if (matchedSkus.length > 1) {
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
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 = await 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' },
|
||
})
|
||
|
||
const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets).get(matchedSku.id)
|
||
if (!whiteBackgroundGroup || !hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) {
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 404,
|
||
duration,
|
||
})
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'WHITE_BACKGROUND_NOT_FOUND', '该 SKU 暂无白底图'),
|
||
trace,
|
||
404,
|
||
'WHITE_BACKGROUND_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
const baseUrl = getBaseUrl()
|
||
const fixed = STANDARD_MODEL_WHITE_BG_VIEWS
|
||
.map((view) => {
|
||
const asset = whiteBackgroundGroup.fixed[view.key]
|
||
if (!asset) {
|
||
return null
|
||
}
|
||
const fileName = asset.fileName || `${view.label}${path.extname(asset.fileUrl || '') || '.jpg'}`
|
||
|
||
return {
|
||
key: view.key,
|
||
label: view.label,
|
||
fileName,
|
||
fileSize: Number(asset.fileSize || 0),
|
||
previewUrl: buildWhiteBackgroundPreviewUrl(asset.fileUrl),
|
||
downloadUrl: generateDownloadUrl(baseUrl, {
|
||
materialId: matchedSku.materialId,
|
||
downloadType: 'white-background-single',
|
||
assetId: asset.id,
|
||
}),
|
||
}
|
||
})
|
||
.filter((item): item is {
|
||
key: StandardModelWhiteBgKey
|
||
label: string
|
||
fileName: string
|
||
fileSize: number
|
||
previewUrl: string | null
|
||
downloadUrl: string
|
||
} => Boolean(item))
|
||
|
||
const other = (whiteBackgroundGroup.other || []).map((asset, index) => ({
|
||
key: `other-${index + 1}`,
|
||
label: `其他图片${index + 1}`,
|
||
fileName: asset.fileName || path.basename(asset.fileUrl || 'other-image'),
|
||
fileSize: Number(asset.fileSize || 0),
|
||
previewUrl: buildWhiteBackgroundPreviewUrl(asset.fileUrl),
|
||
downloadUrl: generateDownloadUrl(baseUrl, {
|
||
materialId: matchedSku.materialId,
|
||
downloadType: 'white-background-single',
|
||
assetId: asset.id,
|
||
}),
|
||
}))
|
||
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 200,
|
||
duration,
|
||
})
|
||
|
||
return finalizeResponse(
|
||
successResponse({
|
||
resourceType: 'white-background',
|
||
sku: matchedSku.skuName,
|
||
materialId: matchedSku.materialId,
|
||
productModel: matchedSku.material.productModel,
|
||
categoryName: matchedSku.material.categoryName || '',
|
||
shopName: matchedSku.material.categoryName || '',
|
||
whiteBackgroundUploaded: true,
|
||
whiteBackgroundComplete: hasCompleteWhiteBackgroundAssetGroup(whiteBackgroundGroup),
|
||
whiteBackgroundFixedCount: countWhiteBackgroundFixedViews(whiteBackgroundGroup),
|
||
whiteBackgroundOtherCount: countWhiteBackgroundOtherImages(whiteBackgroundGroup),
|
||
whiteBackgroundCount: countWhiteBackgroundAssets(whiteBackgroundGroup),
|
||
missingViews: getMissingWhiteBackgroundViewsFromAssetGroup(whiteBackgroundGroup).map((view) => ({
|
||
key: view.key,
|
||
label: view.label,
|
||
})),
|
||
whiteBackground: {
|
||
fixed,
|
||
other,
|
||
},
|
||
downloadUrls: {
|
||
zip: generateDownloadUrl(baseUrl, {
|
||
materialId: matchedSku.materialId,
|
||
downloadType: 'white-background-zip',
|
||
skuId: matchedSku.id,
|
||
}),
|
||
},
|
||
}),
|
||
trace,
|
||
200,
|
||
'WHITE_BACKGROUND_SKU_OK',
|
||
)
|
||
} catch (error) {
|
||
console.error('[external/standard-model-white-background] error:', error)
|
||
const duration = getElapsedMs(startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 500,
|
||
duration,
|
||
})
|
||
return finalizeResponse(
|
||
buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'),
|
||
trace,
|
||
500,
|
||
'SERVER_ERROR',
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* SPU 查询:返回该 SPU 下所有 SKU 的右前45°预览图清单
|
||
* AI 生图平台用 SPU 调用,展示所有 SKU 供用户选择,之后再用 SKU 查询获取完整白底图
|
||
*/
|
||
async function handleSpuQuery(
|
||
normalizedSpu: string,
|
||
logBase: LogBase,
|
||
trace: TraceContext,
|
||
) {
|
||
try {
|
||
// 通过 productModel(SPU)查找标准模型素材
|
||
const material = await prisma.material.findFirst({
|
||
where: {
|
||
productModel: normalizedSpu,
|
||
materialType: '标准模型',
|
||
deletedAt: null,
|
||
},
|
||
select: {
|
||
id: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
skus: {
|
||
where: { deletedAt: null },
|
||
select: {
|
||
id: true,
|
||
skuName: true,
|
||
previewUrl: true,
|
||
},
|
||
orderBy: { sortOrder: 'asc' },
|
||
},
|
||
},
|
||
})
|
||
|
||
if (!material) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 404, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'SPU_NOT_FOUND', '未找到对应的标准模型 SPU'),
|
||
trace,
|
||
404,
|
||
'SPU_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
if (material.skus.length === 0) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 404, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'NO_SKUS', '该 SPU 下没有 SKU 数据'),
|
||
trace,
|
||
404,
|
||
'NO_SKUS',
|
||
)
|
||
}
|
||
|
||
// 批量查询所有 SKU 的白底图资产(一次性查出,避免 N+1)
|
||
const allSkuIds = material.skus.map((s) => s.id)
|
||
const allWhiteBackgroundAssets = await prisma.materialAsset.findMany({
|
||
where: {
|
||
materialId: material.id,
|
||
OR: allSkuIds.flatMap((skuId) => [
|
||
{ assetType: { startsWith: `standard-model-white-bg-view:${skuId}:` } },
|
||
{ assetType: { startsWith: `standard-model-white-bg-other:${skuId}:` } },
|
||
{ assetType: { startsWith: `standard-model-white-bg-zip-v2:${skuId}` } },
|
||
{ assetType: { startsWith: `standard-model-white-bg-zip:${skuId}` } },
|
||
{ assetType: 'standard-model-white-bg-zip' },
|
||
]),
|
||
},
|
||
select: {
|
||
id: true,
|
||
assetType: true,
|
||
fileUrl: true,
|
||
fileName: true,
|
||
fileSize: true,
|
||
sortOrder: true,
|
||
},
|
||
})
|
||
|
||
// 按 SKU 分组
|
||
const groupedBySku = groupWhiteBackgroundAssetsBySku(allWhiteBackgroundAssets)
|
||
const baseUrl = getBaseUrl()
|
||
|
||
// 构建每个 SKU 的预览信息
|
||
const skuList = material.skus.map((sku) => {
|
||
const group = groupedBySku.get(sku.id)
|
||
const rightFront45Asset = group?.fixed?.rightFront45
|
||
const hasWhiteBackground = group ? hasAnyWhiteBackgroundAssetGroup(group) : false
|
||
|
||
let previewImage: {
|
||
downloadUrl: string
|
||
previewUrl: string | null
|
||
fileName: string
|
||
fileSize: number
|
||
} | null = null
|
||
|
||
if (rightFront45Asset) {
|
||
const fileName = rightFront45Asset.fileName ||
|
||
`右前45°${path.extname(rightFront45Asset.fileUrl || '') || '.jpg'}`
|
||
previewImage = {
|
||
downloadUrl: generateDownloadUrl(baseUrl, {
|
||
materialId: material.id,
|
||
downloadType: 'white-background-single',
|
||
assetId: rightFront45Asset.id,
|
||
}),
|
||
previewUrl: buildWhiteBackgroundPreviewUrl(rightFront45Asset.fileUrl),
|
||
fileName,
|
||
fileSize: Number(rightFront45Asset.fileSize || 0),
|
||
}
|
||
}
|
||
|
||
return {
|
||
skuName: sku.skuName,
|
||
skuId: sku.id,
|
||
hasWhiteBackground,
|
||
whiteBackgroundCount: group ? countWhiteBackgroundFixedViews(group) : 0,
|
||
previewImage,
|
||
}
|
||
})
|
||
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 200, duration })
|
||
|
||
return finalizeResponse(
|
||
successResponse({
|
||
resourceType: 'white-background',
|
||
spu: material.productModel,
|
||
materialId: material.id,
|
||
categoryName: material.categoryName || '',
|
||
shopName: material.categoryName || '',
|
||
skuCount: material.skus.length,
|
||
skuList,
|
||
}),
|
||
trace,
|
||
200,
|
||
'WHITE_BACKGROUND_SPU_OK',
|
||
)
|
||
} catch (error) {
|
||
console.error('[external/standard-model-white-background] spu query error:', error)
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 500, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'),
|
||
trace,
|
||
500,
|
||
'SERVER_ERROR',
|
||
)
|
||
}
|
||
}
|
||
|
||
async function getTaskDetailMaterialsBySpu(normalizedSpu: string) {
|
||
return prisma.material.findMany({
|
||
where: {
|
||
productModel: normalizedSpu,
|
||
materialType: '任务-详情',
|
||
deletedAt: null,
|
||
},
|
||
select: {
|
||
id: true,
|
||
taskNo: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
createdAt: true,
|
||
assets: {
|
||
where: {
|
||
assetType: {
|
||
in: ['main-preview', 'main', 'sku-preview', 'sku', 'detail-preview', 'detail'],
|
||
},
|
||
},
|
||
select: {
|
||
assetType: true,
|
||
fileUrl: true,
|
||
sortOrder: true,
|
||
},
|
||
orderBy: { sortOrder: 'asc' },
|
||
},
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
})
|
||
}
|
||
|
||
async function handleTaskDetailSpuQuery(
|
||
normalizedSpu: string,
|
||
logBase: LogBase,
|
||
trace: TraceContext,
|
||
) {
|
||
try {
|
||
const taskDetails = await getTaskDetailMaterialsBySpu(normalizedSpu)
|
||
if (taskDetails.length === 0) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 404, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'TASK_DETAIL_NOT_FOUND', '该 SPU 下暂无任务-详情素材'),
|
||
trace,
|
||
404,
|
||
'TASK_DETAIL_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
const fullSetCandidates = taskDetails.filter((material) => isTaskDetailFullSetFromAssets(material.assets))
|
||
if (fullSetCandidates.length === 0) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 404, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'FULL_SET_NOT_FOUND', '该 SPU 下暂无主图、SKU 图、详情图齐全的任务-详情素材'),
|
||
trace,
|
||
404,
|
||
'FULL_SET_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
const candidates = fullSetCandidates.map((material) => formatTaskDetailCandidate(material))
|
||
const summaryFields = getTaskDetailSummaryFields(candidates)
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 200, duration })
|
||
|
||
return finalizeResponse(
|
||
successResponse({
|
||
resourceType: 'task-detail',
|
||
spu: normalizedSpu,
|
||
sku: null,
|
||
categoryName: summaryFields.categoryName,
|
||
shopName: summaryFields.shopName,
|
||
candidateCount: candidates.length,
|
||
candidates,
|
||
}),
|
||
trace,
|
||
200,
|
||
'TASK_DETAIL_SPU_OK',
|
||
)
|
||
} catch (error) {
|
||
console.error('[external/standard-model-white-background] task-detail spu query error:', error)
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 500, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'),
|
||
trace,
|
||
500,
|
||
'SERVER_ERROR',
|
||
)
|
||
}
|
||
}
|
||
|
||
async function handleTaskDetailSkuQuery(
|
||
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,
|
||
material: {
|
||
select: {
|
||
productModel: true,
|
||
categoryName: true,
|
||
},
|
||
},
|
||
},
|
||
take: 2,
|
||
})
|
||
|
||
if (matchedSkus.length === 0) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, 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, 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 taskDetails = await getTaskDetailMaterialsBySpu(matchedSku.material.productModel)
|
||
if (taskDetails.length === 0) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 404, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'TASK_DETAIL_NOT_FOUND', '该 SKU 所属 SPU 暂无任务-详情素材'),
|
||
trace,
|
||
404,
|
||
'TASK_DETAIL_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
const fullSetCandidates = taskDetails.filter((material) => isTaskDetailFullSetFromAssets(material.assets))
|
||
if (fullSetCandidates.length === 0) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 404, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'FULL_SET_NOT_FOUND', '该 SKU 所属 SPU 暂无主图、SKU 图、详情图齐全的任务-详情素材'),
|
||
trace,
|
||
404,
|
||
'FULL_SET_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
const candidates = fullSetCandidates.map((material) => formatTaskDetailCandidate(material))
|
||
const summaryFields = getTaskDetailSummaryFields(candidates)
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 200, duration })
|
||
|
||
return finalizeResponse(
|
||
successResponse({
|
||
resourceType: 'task-detail',
|
||
spu: matchedSku.material.productModel,
|
||
sku: matchedSku.skuName,
|
||
categoryName: summaryFields.categoryName,
|
||
shopName: summaryFields.shopName,
|
||
candidateCount: candidates.length,
|
||
candidates,
|
||
}),
|
||
trace,
|
||
200,
|
||
'TASK_DETAIL_SKU_OK',
|
||
)
|
||
} catch (error) {
|
||
console.error('[external/standard-model-white-background] task-detail sku query error:', error)
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({ ...logBase, statusCode: 500, duration })
|
||
return finalizeResponse(
|
||
buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'),
|
||
trace,
|
||
500,
|
||
'SERVER_ERROR',
|
||
)
|
||
}
|
||
}
|