- 新建 GET /api/external/video-assets,支持 SPU/SKU/条码 三层回退查询 - 脉象 maixiang-spu-assets 接口 formatVideoItem 加入 15 天保护期控制 - 更新外部接口文档(v1.9)和脉象接口文档,新增视频接口文档
373 lines
10 KiB
TypeScript
373 lines
10 KiB
TypeScript
import { randomUUID } from 'crypto'
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { generateDownloadUrl } from '@/lib/download-token'
|
||
import {
|
||
getApiKeyFromRequest,
|
||
getClientIp,
|
||
logApiCall,
|
||
successResponse,
|
||
validateApiKey,
|
||
} from '@/lib/external-api'
|
||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||
import { buildMaterialSearchOrConditions } from '@/lib/material-query'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'
|
||
import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from '@/lib/video-material-types'
|
||
import { getVideoDownloadAvailability, getVideoDownloadUnavailableMessage } from '@/lib/video-download-policy'
|
||
|
||
const ENDPOINT = '/api/external/maixiang-spu-assets'
|
||
const BUYER_SHOW_MATERIAL_TYPE = '买家秀图片'
|
||
const VIDEO_MATERIAL_TYPES = Array.from(LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES)
|
||
|
||
type TraceContext = {
|
||
traceId: string
|
||
startTime: number
|
||
normalizedSpu: string
|
||
ipAddress?: string
|
||
apiKeyName?: string
|
||
}
|
||
|
||
type LogBase = {
|
||
apiKeyId: string
|
||
endpoint: string
|
||
method: string
|
||
queryType: string
|
||
queryValue?: string
|
||
ipAddress?: string
|
||
userAgent?: string
|
||
}
|
||
|
||
type MaixiangMaterial = {
|
||
id: string
|
||
productModel: string
|
||
materialType: string
|
||
categoryName: string
|
||
sceneName: string
|
||
zipFileName: string
|
||
zipFilePath: string
|
||
zipFileSize: bigint | number | string | null
|
||
previewUrl: string
|
||
displayImages: string[]
|
||
videoPreview540Url: string | null
|
||
videoPreviewClipUrl: string | null
|
||
videoCoverUrl: string | null
|
||
videoDuration: number | null
|
||
createdAt: Date
|
||
}
|
||
|
||
function normalizeSpu(value: string | null): string {
|
||
return String(value || '')
|
||
.trim()
|
||
.toUpperCase()
|
||
.replace(/+/g, '+')
|
||
.replace(/\s*\+\s*/g, '+')
|
||
}
|
||
|
||
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/maixiang-spu-assets]',
|
||
JSON.stringify({
|
||
traceId: trace.traceId,
|
||
outcome,
|
||
statusCode,
|
||
duration,
|
||
spu: trace.normalizedSpu || null,
|
||
ipAddress: trace.ipAddress || null,
|
||
apiKeyName: trace.apiKeyName || null,
|
||
}),
|
||
)
|
||
|
||
return response
|
||
}
|
||
|
||
function toNumber(value: bigint | number | string | null | undefined): number {
|
||
if (typeof value === 'bigint') {
|
||
return Number(value)
|
||
}
|
||
|
||
if (typeof value === 'number') {
|
||
return value
|
||
}
|
||
|
||
const parsed = Number(value || 0)
|
||
return Number.isFinite(parsed) ? parsed : 0
|
||
}
|
||
|
||
function buildAbsoluteClientUrl(url: string | null | undefined, request: NextRequest): string | null {
|
||
const rewritten = rewriteStorageUrlForClient(url, request)
|
||
if (!rewritten) {
|
||
return null
|
||
}
|
||
|
||
if (/^https?:\/\//i.test(rewritten)) {
|
||
return rewritten
|
||
}
|
||
|
||
const normalizedPath = rewritten
|
||
.trim()
|
||
.replace(/\\/g, '/')
|
||
.replace(/^\.?\//, '/')
|
||
|
||
if (!normalizedPath) {
|
||
return null
|
||
}
|
||
|
||
const baseUrl = getPublicAppOrigin().replace(/\/+$/, '')
|
||
return `${baseUrl}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}`
|
||
}
|
||
|
||
function uniqueUrls(urls: Array<string | null>) {
|
||
return Array.from(new Set(urls.filter((url): url is string => Boolean(url))))
|
||
}
|
||
|
||
function formatBuyerShowItem(material: MaixiangMaterial, request: NextRequest, baseUrl: string) {
|
||
const coverUrl = buildAbsoluteClientUrl(material.previewUrl, request)
|
||
const previewUrls = uniqueUrls([
|
||
coverUrl,
|
||
...((material.displayImages || []).map((url) => buildAbsoluteClientUrl(url, request))),
|
||
])
|
||
|
||
return {
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
materialType: material.materialType,
|
||
categoryName: material.categoryName || '',
|
||
shopName: material.categoryName || '',
|
||
sceneName: material.sceneName || '',
|
||
fileName: material.zipFileName || '',
|
||
fileSize: toNumber(material.zipFileSize),
|
||
uploadedAt: material.createdAt.toISOString(),
|
||
coverUrl,
|
||
previewUrls,
|
||
downloadUrl: generateDownloadUrl(baseUrl, {
|
||
materialId: material.id,
|
||
downloadType: 'all',
|
||
}),
|
||
}
|
||
}
|
||
|
||
function formatVideoItem(material: MaixiangMaterial, request: NextRequest, baseUrl: string) {
|
||
const availability = getVideoDownloadAvailability(material.materialType, material.createdAt)
|
||
|
||
if (availability.allowed) {
|
||
return {
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
materialType: material.materialType,
|
||
categoryName: material.categoryName || '',
|
||
shopName: material.categoryName || '',
|
||
sceneName: material.sceneName || '',
|
||
fileName: material.zipFileName || '',
|
||
fileSize: toNumber(material.zipFileSize),
|
||
duration: material.videoDuration || null,
|
||
uploadedAt: material.createdAt.toISOString(),
|
||
coverUrl: buildAbsoluteClientUrl(material.videoCoverUrl || material.previewUrl, request),
|
||
previewUrl: buildAbsoluteClientUrl(material.videoPreview540Url, request),
|
||
previewClipUrl: buildAbsoluteClientUrl(material.videoPreviewClipUrl, request),
|
||
downloadUrl: generateDownloadUrl(baseUrl, {
|
||
materialId: material.id,
|
||
downloadType: 'all',
|
||
}),
|
||
}
|
||
}
|
||
|
||
return {
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
materialType: material.materialType,
|
||
categoryName: material.categoryName || '',
|
||
shopName: material.categoryName || '',
|
||
sceneName: material.sceneName || '',
|
||
fileName: material.zipFileName || '',
|
||
fileSize: toNumber(material.zipFileSize),
|
||
duration: material.videoDuration || null,
|
||
uploadedAt: material.createdAt.toISOString(),
|
||
coverUrl: buildAbsoluteClientUrl(material.videoCoverUrl || material.previewUrl, request),
|
||
previewUrl: null,
|
||
previewClipUrl: null,
|
||
videoBlocked: true,
|
||
videoBlockReason: 'VIDEO_WAIT_PERIOD',
|
||
videoRemainingDays: availability.remainingDays || 15,
|
||
videoBlockMessage: getVideoDownloadUnavailableMessage(availability),
|
||
videoAvailableAt: availability.availableAt?.toISOString() || null,
|
||
downloadUrl: null,
|
||
}
|
||
}
|
||
|
||
export async function GET(request: NextRequest) {
|
||
const trace: TraceContext = {
|
||
traceId: randomUUID(),
|
||
startTime: Date.now(),
|
||
normalizedSpu: normalizeSpu(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: ENDPOINT,
|
||
method: 'GET',
|
||
queryType: 'spu',
|
||
queryValue: trace.normalizedSpu || undefined,
|
||
ipAddress: trace.ipAddress,
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
}
|
||
|
||
if (!trace.normalizedSpu) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
queryValue: '',
|
||
statusCode: 400,
|
||
duration,
|
||
})
|
||
|
||
return finalizeResponse(
|
||
buildErrorResponse(400, 'MISSING_SPU', 'SPU 为必填项'),
|
||
trace,
|
||
400,
|
||
'MISSING_SPU',
|
||
)
|
||
}
|
||
|
||
try {
|
||
const searchOrConditions = await buildMaterialSearchOrConditions(trace.normalizedSpu)
|
||
const materials = await prisma.material.findMany({
|
||
where: {
|
||
deletedAt: null,
|
||
...(searchOrConditions ? { OR: searchOrConditions } : {}),
|
||
materialType: {
|
||
in: [BUYER_SHOW_MATERIAL_TYPE, ...VIDEO_MATERIAL_TYPES],
|
||
},
|
||
},
|
||
select: {
|
||
id: true,
|
||
productModel: true,
|
||
materialType: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
zipFileName: true,
|
||
zipFilePath: true,
|
||
zipFileSize: true,
|
||
previewUrl: true,
|
||
displayImages: true,
|
||
videoPreview540Url: true,
|
||
videoPreviewClipUrl: true,
|
||
videoCoverUrl: true,
|
||
videoDuration: true,
|
||
createdAt: true,
|
||
},
|
||
orderBy: [
|
||
{ createdAt: 'desc' },
|
||
{ id: 'asc' },
|
||
],
|
||
})
|
||
|
||
const buyerShowMaterials = materials.filter(
|
||
(material) => material.materialType === BUYER_SHOW_MATERIAL_TYPE,
|
||
) as MaixiangMaterial[]
|
||
const videoMaterials = materials.filter(
|
||
(material) => VIDEO_MATERIAL_TYPES.includes(material.materialType),
|
||
) as MaixiangMaterial[]
|
||
|
||
if (buyerShowMaterials.length === 0 && videoMaterials.length === 0) {
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 404,
|
||
duration,
|
||
})
|
||
|
||
return finalizeResponse(
|
||
buildErrorResponse(404, 'SPU_ASSETS_NOT_FOUND', '该 SPU 下暂无买家秀图片或视频素材'),
|
||
trace,
|
||
404,
|
||
'SPU_ASSETS_NOT_FOUND',
|
||
)
|
||
}
|
||
|
||
const baseUrl = getPublicAppOrigin()
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 200,
|
||
duration,
|
||
})
|
||
|
||
return finalizeResponse(
|
||
successResponse({
|
||
spu: trace.normalizedSpu,
|
||
buyerShow: {
|
||
total: buyerShowMaterials.length,
|
||
items: buyerShowMaterials.map((material) => formatBuyerShowItem(material, request, baseUrl)),
|
||
},
|
||
videos: {
|
||
total: videoMaterials.length,
|
||
items: videoMaterials.map((material) => formatVideoItem(material, request, baseUrl)),
|
||
},
|
||
}),
|
||
trace,
|
||
200,
|
||
'OK',
|
||
)
|
||
} catch (error) {
|
||
console.error('[external/maixiang-spu-assets] error:', error)
|
||
const duration = getElapsedMs(trace.startTime)
|
||
await logApiCall({
|
||
...logBase,
|
||
statusCode: 500,
|
||
duration,
|
||
})
|
||
|
||
return finalizeResponse(
|
||
buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'),
|
||
trace,
|
||
500,
|
||
'SERVER_ERROR',
|
||
)
|
||
}
|
||
}
|