chenpei 8288e2246f feat: 新增视频素材查询接口 + 脉象接口加上视频15天保护期
- 新建 GET /api/external/video-assets,支持 SPU/SKU/条码 三层回退查询

- 脉象 maixiang-spu-assets 接口 formatVideoItem 加入 15 天保护期控制

- 更新外部接口文档(v1.9)和脉象接口文档,新增视频接口文档
2026-07-01 19:06:32 +08:00

373 lines
10 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 { 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',
)
}
}