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