chenpei 4712175cb0 feat(素材链路): 发布白底图双轨与AI生图回传能力
- 完成白底图 WebP 双轨接口、上传链路与预览缓存改造
- 新增 AI 生图平台成品回传接口、任务状态与表单规则
- 补齐 AI 生成标签、任务详情补全、相关测试与对接文档
2026-03-25 10:28:02 +08:00

843 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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