// 视频转码API - 完整版本(支持自动恢复) import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { prisma } from '@/lib/prisma'; import { transcodeQueue, VIDEO_MATERIAL_TYPES } from '@/lib/video-transcode-queue'; import { executeTranscodeTask, USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task'; import { authOptions } from '@/lib/auth'; import { canUserViewMaterial, hasSystemAdminPermission } from '@/lib/department-permission'; export const dynamic = 'force-dynamic'; export const maxDuration = 3600; // 1小时 const MATERIAL_ID_PATTERN = /^[A-Za-z0-9_-]{1,100}$/; const RATE_LIMIT_WINDOW_MS = 60 * 1000; const STATUS_RATE_LIMIT = 120; const START_RATE_LIMIT = 5; const requestCounts = new Map(); function consumeRateLimit(key: string, maxRequests: number): number | null { const now = Date.now(); const current = requestCounts.get(key); if (!current || current.resetAt <= now) { requestCounts.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); return null; } if (current.count >= maxRequests) { return Math.max(1, Math.ceil((current.resetAt - now) / 1000)); } current.count += 1; return null; } function rateLimitResponse(retryAfter: number) { return NextResponse.json( { error: '请求过于频繁,请稍后再试' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }, ); } // GET - 查询转码状态 export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json({ error: '请先登录' }, { status: 401 }); } const sessionUser = session.user as any; const isSystemAdministrator = hasSystemAdminPermission( sessionUser.role, Boolean(sessionUser.isSuperAdmin), ); const retryAfter = consumeRateLimit(`status:${String(sessionUser.id || 'unknown')}`, STATUS_RATE_LIMIT); if (retryAfter) { return rateLimitResponse(retryAfter); } const { searchParams } = new URL(request.url); const materialId = String(searchParams.get('materialId') || '').trim(); if (!MATERIAL_ID_PATTERN.test(materialId)) { return NextResponse.json( { error: '素材ID格式无效' }, { status: 400 } ); } const material = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, userId: true, materialType: true, videoProcessingStatus: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, transcodeLastError: true, transcodeAttempts: true, } as any, }); if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } ); } if (!isSystemAdministrator && (material as any).userId !== sessionUser.id) { const canView = await canUserViewMaterial( sessionUser.department, sessionUser.role, sessionUser.managedDepartment, (material as any).materialType, sessionUser.id, ); if (!canView) { return NextResponse.json({ error: '无权限查看该素材转码状态' }, { status: 403 }); } } // 获取转码队列状态 const queueStatus = transcodeQueue.getStatus(); const isProcessing = queueStatus.processingIds.includes(materialId); const waitingIndex = queueStatus.waiting.findIndex(id => id === materialId); const waitingTotal = queueStatus.waiting.length; return NextResponse.json({ materialId: material.id, status: material.videoProcessingStatus || 'pending', hasPreview540: !!material.videoPreview540Url, hasClip: !!material.videoPreviewClipUrl, hasCover: !!material.videoCoverUrl, duration: material.videoDuration, attempts: material.transcodeAttempts || 0, lastError: isSystemAdministrator && (material as any).videoProcessingStatus === 'failed' ? USER_TRANSCODE_FAILURE_MESSAGE : null, progress: isProcessing ? queueStatus.progress.get(materialId) || null : null, stage: isProcessing ? queueStatus.stages.get(materialId) || null : null, queue: { waitingIndex: waitingIndex >= 0 ? waitingIndex + 1 : null, waitingTotal: isSystemAdministrator ? waitingTotal : waitingIndex >= 0 ? 1 : 0, processingIds: isProcessing ? [materialId] : [], maxConcurrent: isSystemAdministrator ? queueStatus.maxConcurrent : null, }, }); } catch (error) { console.error('[Transcode] GET error:', error); return NextResponse.json( { error: '查询转码状态失败' }, { status: 500 } ); } } // POST - 启动转码任务 export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json({ error: '请先登录' }, { status: 401 }); } const sessionUser = session.user as any; const isSystemAdministrator = hasSystemAdminPermission( sessionUser.role, Boolean(sessionUser.isSuperAdmin), ); const retryAfter = consumeRateLimit(`start:${String(sessionUser.id || 'unknown')}`, START_RATE_LIMIT); if (retryAfter) { return rateLimitResponse(retryAfter); } const contentLength = Number(request.headers.get('content-length') || '0'); if (Number.isFinite(contentLength) && contentLength > 2048) { return NextResponse.json({ error: '请求参数过大' }, { status: 413 }); } const { materialId, forceRetry } = await request.json(); if (!MATERIAL_ID_PATTERN.test(String(materialId || '').trim())) { return NextResponse.json( { error: '素材ID格式无效' }, { status: 400 } ); } if (forceRetry !== undefined && typeof forceRetry !== 'boolean') { return NextResponse.json({ error: 'forceRetry 参数必须是布尔值' }, { status: 400 }); } const normalizedMaterialId = String(materialId).trim(); const material = await prisma.material.findFirst({ where: { id: normalizedMaterialId, deletedAt: null }, select: { id: true, userId: true, videoSourceUrl: true, videoCoverUrl: true, videoProcessingStatus: true, materialType: true, transcodeLeaseToken: true, transcodeLeaseExpiresAt: true, } as any, }); if (!material) { return NextResponse.json( { error: '素材不存在或已被删除' }, { status: 404 } ); } if (!isSystemAdministrator && (material as any).userId !== String(sessionUser.id || '')) { return NextResponse.json({ error: '只能启动自己素材的转码任务' }, { status: 403 }); } // 检查是否为视频类型 if (!VIDEO_MATERIAL_TYPES.includes((material as any).materialType)) { return NextResponse.json( { error: '此素材不是视频类型' }, { status: 400 } ); } // 检查是否有源视频 if (!(material as any).videoSourceUrl) { return NextResponse.json( { error: '未找到源视频文件' }, { status: 400 } ); } // 检查转码状态 if ((material as any).videoProcessingStatus === 'processing') { const hasActiveLease = Boolean( (material as any).transcodeLeaseToken && (material as any).transcodeLeaseExpiresAt && new Date((material as any).transcodeLeaseExpiresAt).getTime() > Date.now(), ) return NextResponse.json( { error: hasActiveLease ? '转码任务正在由后台安全处理,请稍后再试' : '转码任务仍处于处理中,后台会在确认任务失效后自动恢复', materialId: normalizedMaterialId, }, { status: 409 }, ) } if ((material as any).videoProcessingStatus === 'done' && !forceRetry) { return NextResponse.json( { message: '转码已完成', materialId: normalizedMaterialId }, { status: 200 } ); } // 判断是否需要生成封面图(用户未上传时才生成) const needsCoverImage = !(material as any).videoCoverUrl; // 加入转码队列 await transcodeQueue.add( normalizedMaterialId, (material as any).videoSourceUrl, needsCoverImage, executeTranscodeTask ); console.log(`[Transcode] Task queued for material ${normalizedMaterialId}`); const queueStatus = transcodeQueue.getStatus(); return NextResponse.json({ message: '转码任务已进入后台队列', materialId: normalizedMaterialId, status: queueStatus.processingIds.includes(normalizedMaterialId) ? 'processing' : 'queued', queueStatus: isSystemAdministrator ? queueStatus : { waitingIndex: queueStatus.waiting.findIndex((id) => id === normalizedMaterialId) + 1 || null, processing: queueStatus.processingIds.includes(normalizedMaterialId), }, }); } catch (error) { console.error('[Transcode] POST error:', error); return NextResponse.json( { error: '转码任务启动失败' }, { status: 500 } ); } }