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

439 lines
13 KiB
TypeScript

import { randomUUID } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
import { generateDownloadUrl } from '@/lib/download-token'
import {
API_ERROR,
errorResponse,
getApiKeyFromRequest,
getClientIp,
logApiCall,
successResponse,
validateApiKey,
} from '@/lib/external-api'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { prisma } from '@/lib/prisma'
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'
import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
} from '@/lib/video-download-policy'
import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from '@/lib/video-material-types'
const ENDPOINT = '/api/external/video-assets'
const VIDEO_MATERIAL_TYPES = Array.from(LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES)
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type TraceContext = {
traceId: string
startTime: number
normalizedKeyword: string
ipAddress?: string
apiKeyName?: string
}
type LogBase = {
apiKeyId: string
endpoint: string
method: string
queryType: string
queryValue?: string
ipAddress?: string
userAgent?: string
}
/** Prisma select shape used across all three query layers. */
const MATERIAL_SELECT = {
id: true,
productModel: true,
materialType: true,
categoryName: true,
sceneName: true,
zipFileName: true,
zipFileSize: true,
previewUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
createdAt: true,
} as const
type MaterialRow = Awaited<
ReturnType<typeof prisma.material.findFirstOrThrow<{ select: typeof MATERIAL_SELECT }>>
>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function normalizeKeyword(value: string | null): string {
return String(value || '').trim().toUpperCase()
}
function getElapsedMs(startTime: number): number {
return Math.max(0, Date.now() - startTime)
}
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 buildErrorResponse(status: number, code: string, message: string): NextResponse {
return NextResponse.json(
{
success: false,
error: { code, message },
},
{ status },
)
}
function finalizeResponse(
response: NextResponse,
trace: TraceContext,
statusCode: number,
outcome: string,
): NextResponse {
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}`)
console.info(
'[external/video-assets]',
JSON.stringify({
traceId: trace.traceId,
outcome,
statusCode,
duration,
keyword: trace.normalizedKeyword || null,
ipAddress: trace.ipAddress || null,
apiKeyName: trace.apiKeyName || null,
}),
)
return response
}
/**
* Rewrite a storage URL and convert it to an absolute, client-accessible URL.
*
* - If `rewriteStorageUrlForClient` already returns an absolute URL
* (http / https), it is returned as-is.
* - Otherwise the path is normalised and prefixed with the public app origin.
*/
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}`}`
}
/**
* Format a single Material database row into the API response item shape,
* applying video-download policy (15-day protection).
*/
function formatVideoItem(
material: MaterialRow,
request: NextRequest,
baseUrl: string,
): Record<string, unknown> {
const availability = getVideoDownloadAvailability(
material.materialType,
material.createdAt,
)
const coverUrl = buildAbsoluteClientUrl(
material.videoCoverUrl || material.previewUrl,
request,
)
if (availability.allowed) {
return {
materialId: material.id,
productModel: material.productModel,
materialType: material.materialType,
categoryName: material.categoryName || '',
sceneName: material.sceneName || '',
fileName: material.zipFileName || '',
fileSize: toNumber(material.zipFileSize),
duration: material.videoDuration || null,
uploadedAt: material.createdAt.toISOString(),
coverUrl,
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 || '',
sceneName: material.sceneName || '',
fileName: material.zipFileName || '',
fileSize: toNumber(material.zipFileSize),
duration: material.videoDuration || null,
uploadedAt: material.createdAt.toISOString(),
coverUrl,
previewUrl: null,
previewClipUrl: null,
downloadUrl: null,
videoBlocked: true as const,
videoBlockReason: 'VIDEO_WAIT_PERIOD',
videoRemainingDays: availability.remainingDays || 15,
videoBlockMessage: getVideoDownloadUnavailableMessage(availability),
videoAvailableAt: availability.availableAt?.toISOString() || null,
}
}
// ---------------------------------------------------------------------------
// Query helpers (three-layer fallback)
// ---------------------------------------------------------------------------
/**
* Query materials by keyword across three layers:
* 1. Barcode exact match → MaterialSkuBarcode → MaterialSku → Material
* 2. SKU-name contains match → MaterialSku → Material
* 3. SPU (productModel) contains match → Material directly
*
* Results are deduplicated by materialId.
*/
async function findVideoMaterials(keyword: string): Promise<MaterialRow[]> {
// ── Layer 1: Barcode exact match ──────────────────────────────────────
const barcodeRecords = await prisma.materialSkuBarcode.findMany({
where: {
barcode: { equals: keyword, mode: 'insensitive' },
deletedAt: null,
sku: { deletedAt: null },
},
select: { sku: { select: { materialId: true } } },
})
if (barcodeRecords.length > 0) {
const materialIds = Array.from(
new Set(barcodeRecords.map((r) => r.sku.materialId)),
)
const materials = await prisma.material.findMany({
where: {
id: { in: materialIds },
deletedAt: null,
materialType: { in: VIDEO_MATERIAL_TYPES },
},
select: MATERIAL_SELECT,
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
})
if (materials.length > 0) {
return materials as unknown as MaterialRow[]
}
}
// ── Layer 2: SKU-name contains match ──────────────────────────────────
const skuRecords = await prisma.materialSku.findMany({
where: {
skuName: { contains: keyword, mode: 'insensitive' },
deletedAt: null,
},
select: { materialId: true },
})
if (skuRecords.length > 0) {
const materialIds = Array.from(new Set(skuRecords.map((r) => r.materialId)))
const materials = await prisma.material.findMany({
where: {
id: { in: materialIds },
deletedAt: null,
materialType: { in: VIDEO_MATERIAL_TYPES },
},
select: MATERIAL_SELECT,
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
})
if (materials.length > 0) {
return materials as unknown as MaterialRow[]
}
}
// ── Layer 3: SPU (productModel) contains match ────────────────────────
const materials = await prisma.material.findMany({
where: {
productModel: { contains: keyword, mode: 'insensitive' },
deletedAt: null,
materialType: { in: VIDEO_MATERIAL_TYPES },
},
select: MATERIAL_SELECT,
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
})
return materials as unknown as MaterialRow[]
}
// ---------------------------------------------------------------------------
// Route handler
// ---------------------------------------------------------------------------
export async function GET(request: NextRequest): Promise<NextResponse> {
const trace: TraceContext = {
traceId: randomUUID(),
startTime: Date.now(),
normalizedKeyword: normalizeKeyword(
request.nextUrl.searchParams.get('keyword'),
),
ipAddress: getClientIp(request),
}
// ── Entire handler wrapped so validateApiKey and DB queries all route
// through the same catch → 500 path ──────────────────────────────────
try {
// ── Auth ────────────────────────────────────────────────────────────
const apiKey = getApiKeyFromRequest(request)
const keyRecord = await validateApiKey(apiKey)
if (!keyRecord) {
return finalizeResponse(
errorResponse(API_ERROR.INVALID_API_KEY),
trace,
401,
'INVALID_API_KEY',
)
}
trace.apiKeyName = keyRecord.name
const logBase: LogBase = {
apiKeyId: keyRecord.id,
endpoint: ENDPOINT,
method: 'GET',
queryType: 'keyword',
queryValue: trace.normalizedKeyword || undefined,
ipAddress: trace.ipAddress,
userAgent: request.headers.get('user-agent') || undefined,
}
// ── Validation ──────────────────────────────────────────────────────
if (!trace.normalizedKeyword) {
const duration = getElapsedMs(trace.startTime)
await logApiCall({
...logBase,
queryValue: '',
statusCode: 400,
duration,
})
return finalizeResponse(
buildErrorResponse(400, 'MISSING_KEYWORD', 'keyword 为必填项'),
trace,
400,
'MISSING_KEYWORD',
)
}
// ── Query ───────────────────────────────────────────────────────────
const materials = await findVideoMaterials(trace.normalizedKeyword)
if (materials.length === 0) {
const duration = getElapsedMs(trace.startTime)
await logApiCall({
...logBase,
statusCode: 404,
duration,
})
return finalizeResponse(
buildErrorResponse(404, 'VIDEO_NOT_FOUND', '未找到匹配的视频素材'),
trace,
404,
'VIDEO_NOT_FOUND',
)
}
// ── Format response ─────────────────────────────────────────────────
const baseUrl = getPublicAppOrigin()
const items = materials.map((material) =>
formatVideoItem(material, request, baseUrl),
)
const duration = getElapsedMs(trace.startTime)
await logApiCall({
...logBase,
statusCode: 200,
duration,
})
return finalizeResponse(
successResponse({
keyword: trace.normalizedKeyword,
total: items.length,
items,
}),
trace,
200,
'OK',
)
} catch (error) {
console.error('[external/video-assets] error:', error)
const duration = getElapsedMs(trace.startTime)
// Best-effort logging — logBase may not exist if auth failed,
// so we construct a minimal log entry inline.
try {
await logApiCall({
apiKeyId: trace.apiKeyName ? '(lookup-failed)' : '(auth-failed)',
endpoint: ENDPOINT,
method: 'GET',
queryType: 'keyword',
queryValue: trace.normalizedKeyword || undefined,
ipAddress: trace.ipAddress,
userAgent: request.headers.get('user-agent') || undefined,
statusCode: 500,
duration,
})
} catch {
// ignore — already in error path
}
return finalizeResponse(
buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'),
trace,
500,
'SERVER_ERROR',
)
}
}