- 新建 GET /api/external/video-assets,支持 SPU/SKU/条码 三层回退查询 - 脉象 maixiang-spu-assets 接口 formatVideoItem 加入 15 天保护期控制 - 更新外部接口文档(v1.9)和脉象接口文档,新增视频接口文档
439 lines
13 KiB
TypeScript
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',
|
|
)
|
|
}
|
|
}
|