From 5ef70bce4e9cab66efa8e48d3b9e4a48f718d9a9 Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 17 Aug 2026 15:24:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=BD=AC=E7=A0=81=E6=8C=89=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E7=9B=AE=E5=BD=95=E6=89=BE=E6=BA=90=E6=96=87=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E5=A4=B1=E8=B4=A5=E5=8F=AA=E7=BB=99=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8F=AF=E9=87=8D=E8=AF=95=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 /uploads 网址路径落到 UPLOAD_DIR,避免误判文件不存在。 页面和接口不再回传路径或堆栈,只提示稍后重试。 --- app/api/admin/video-transcode-stats/route.ts | 3 +- .../materials/[id]/retry-transcode/route.ts | 11 +++-- app/api/materials/transcode/route.ts | 6 ++- app/api/materials/upload/route.ts | 13 ++---- components/home/MaterialCard.test.tsx | 2 +- components/home/MaterialCard.tsx | 4 +- lib/__tests__/retry-transcode-route.test.ts | 6 ++- lib/__tests__/video-local-path.test.ts | 26 +++++++++++ .../video-source-preservation.test.ts | 3 +- lib/video-processor.ts | 2 +- lib/video-transcode-task.ts | 46 +++++++------------ 11 files changed, 69 insertions(+), 53 deletions(-) create mode 100644 lib/__tests__/video-local-path.test.ts diff --git a/app/api/admin/video-transcode-stats/route.ts b/app/api/admin/video-transcode-stats/route.ts index 255c161..41bbae9 100644 --- a/app/api/admin/video-transcode-stats/route.ts +++ b/app/api/admin/video-transcode-stats/route.ts @@ -12,6 +12,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from '@/lib/video-material-types' +import { USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task' export async function GET(req: NextRequest) { try { @@ -89,7 +90,7 @@ export async function GET(req: NextRequest) { failedTasks.push({ id: material.id, materialType: material.materialType, - error: material.transcodeLastError, + error: USER_TRANSCODE_FAILURE_MESSAGE, attempts: material.transcodeAttempts || 0, createdAt: material.createdAt, }) diff --git a/app/api/materials/[id]/retry-transcode/route.ts b/app/api/materials/[id]/retry-transcode/route.ts index 974ce88..e2264f8 100644 --- a/app/api/materials/[id]/retry-transcode/route.ts +++ b/app/api/materials/[id]/retry-transcode/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task' +import { enqueueVideoTranscodeTask, USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task' // 重试转码API export async function POST( @@ -66,15 +66,18 @@ export async function POST( !material.videoCoverUrl ) } catch (enqueueError) { - const message = enqueueError instanceof Error ? enqueueError.message : String(enqueueError) + console.error('[RetryTranscode] Enqueue failed:', { + materialId, + error: enqueueError, + }) await prisma.material.update({ where: { id: materialId }, data: { videoProcessingStatus: 'failed', - transcodeLastError: message.slice(0, 500), + transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE, }, }) - return NextResponse.json({ error: `转码入队失败: ${message}` }, { status: 500 }) + return NextResponse.json({ error: USER_TRANSCODE_FAILURE_MESSAGE }, { status: 500 }) } return NextResponse.json({ diff --git a/app/api/materials/transcode/route.ts b/app/api/materials/transcode/route.ts index f6698ed..0a560a0 100644 --- a/app/api/materials/transcode/route.ts +++ b/app/api/materials/transcode/route.ts @@ -3,7 +3,7 @@ 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 } from '@/lib/video-transcode-task'; +import { executeTranscodeTask, USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task'; import { authOptions } from '@/lib/auth'; import { canUserViewMaterial, hasSystemAdminPermission } from '@/lib/department-permission'; @@ -115,7 +115,9 @@ export async function GET(request: NextRequest) { hasCover: !!material.videoCoverUrl, duration: material.videoDuration, attempts: material.transcodeAttempts || 0, - lastError: isSystemAdministrator ? material.transcodeLastError : null, + lastError: isSystemAdministrator && material.videoProcessingStatus === 'failed' + ? USER_TRANSCODE_FAILURE_MESSAGE + : null, progress: isProcessing ? queueStatus.progress.get(materialId) || null : null, stage: isProcessing ? queueStatus.stages.get(materialId) || null : null, queue: { diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index fc48488..743a925 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -8,7 +8,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { randomBytes, randomUUID } from 'crypto'; import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'; -import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task'; +import { enqueueVideoTranscodeTask, USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task'; import { AUTO_GENERAL_TYPES, requiresCategoryAndScene, requiresRenderer, getDefaultRenderer } from '@/lib/material-validation'; import { compressImage, sanitizeFileName } from '@/lib/image-processor'; import { processPdfUpload } from '@/lib/pdf-upload-handler'; @@ -1366,15 +1366,10 @@ export async function POST(request: NextRequest) { .catch(async (e: any) => { // 入队失败时保持 pending 可恢复状态。canonical 源视频必须保留, // 60 秒周期扫描会重新装载该任务,错误信息仅用于诊断。 - const errorMsg = e?.message || String(e) || '未知错误'; - const errorStack = e?.stack ? `\n堆栈: ${e.stack.substring(0, 1000)}` : ''; - const fullErrorMsg = `入队失败: ${errorMsg}${errorStack}`; - - console.error('[Upload] ❌ Failed to enqueue video transcode task:', { + console.error('[Upload] Failed to enqueue video transcode task:', { materialId: material.id, videoSourceUrl, - error: errorMsg, - stack: e?.stack, + error: e, }); try { @@ -1383,7 +1378,7 @@ export async function POST(request: NextRequest) { where: { id: material.id }, data: { videoProcessingStatus: 'pending', - transcodeLastError: fullErrorMsg.substring(0, 1000), + transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE, } as any, }); console.log('[Upload] ⚠️ Kept material recoverable after enqueue error (status: pending)'); diff --git a/components/home/MaterialCard.test.tsx b/components/home/MaterialCard.test.tsx index a913044..c1f5024 100644 --- a/components/home/MaterialCard.test.tsx +++ b/components/home/MaterialCard.test.tsx @@ -194,7 +194,7 @@ describe('MaterialCard', () => { />, ) - expect(screen.getByRole('status')).toHaveTextContent('转码失败:源文件暂时不可读') + expect(screen.getByRole('status')).toHaveTextContent('转码失败,请稍后重试') fireEvent.click(screen.getByRole('button', { name: '重试转码' })) expect(defaultProps.onRetryTranscode).toHaveBeenCalledWith('material-1') }) diff --git a/components/home/MaterialCard.tsx b/components/home/MaterialCard.tsx index 45ef041..722168e 100644 --- a/components/home/MaterialCard.tsx +++ b/components/home/MaterialCard.tsx @@ -291,9 +291,7 @@ export default function MaterialCard({ let bgColor = 'bg-gray-700' if (status === 'failed') { - const error = material.transcodeLastError || '' - const errorMsg = error.replace(/^转码失败[::]\s*/i, '').trim() - label = errorMsg ? `转码失败:${errorMsg}` : '转码失败' + label = '转码失败,请稍后重试' progressValue = 0 bgColor = 'bg-red-600' } else if (isProcessing) { diff --git a/lib/__tests__/retry-transcode-route.test.ts b/lib/__tests__/retry-transcode-route.test.ts index 75668a8..6e2214f 100644 --- a/lib/__tests__/retry-transcode-route.test.ts +++ b/lib/__tests__/retry-transcode-route.test.ts @@ -28,6 +28,7 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/video-transcode-task', () => ({ enqueueVideoTranscodeTask: jest.fn(), + USER_TRANSCODE_FAILURE_MESSAGE: '预览还没做好,请稍后重试', })) describe('retry transcode route', () => { @@ -72,12 +73,13 @@ describe('retry transcode route', () => { const body = await response.json() expect(response.status).toBe(500) - expect(body.error).toContain('queue unavailable') + expect(body.error).toBe('预览还没做好,请稍后重试') + expect(body.error).not.toContain('queue unavailable') expect(prisma.material.update).toHaveBeenLastCalledWith({ where: { id: 'material-1' }, data: { videoProcessingStatus: 'failed', - transcodeLastError: 'queue unavailable', + transcodeLastError: '预览还没做好,请稍后重试', }, }) }) diff --git a/lib/__tests__/video-local-path.test.ts b/lib/__tests__/video-local-path.test.ts new file mode 100644 index 0000000..03809be --- /dev/null +++ b/lib/__tests__/video-local-path.test.ts @@ -0,0 +1,26 @@ +import path from 'path' + +import { resolveLocalVideoPath } from '../video-processor' + +describe('resolveLocalVideoPath', () => { + const previousUploadDir = process.env.UPLOAD_DIR + + afterEach(() => { + if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR + else process.env.UPLOAD_DIR = previousUploadDir + }) + + test('把 /uploads/ 网址路径落到 UPLOAD_DIR,而不是容器根目录的 /uploads', () => { + process.env.UPLOAD_DIR = '/app/uploads' + expect(resolveLocalVideoPath('/uploads/video-1786944507495-5131e070eb5b3827.mp4')).toBe( + path.resolve('/app/uploads/video-1786944507495-5131e070eb5b3827.mp4'), + ) + }) + + test('已经是正式目录里的绝对路径时保持不变', () => { + process.env.UPLOAD_DIR = '/app/uploads' + expect(resolveLocalVideoPath('/app/uploads/video-source.mp4')).toBe( + path.resolve('/app/uploads/video-source.mp4'), + ) + }) +}) diff --git a/lib/__tests__/video-source-preservation.test.ts b/lib/__tests__/video-source-preservation.test.ts index d9b7df6..5fbbeef 100644 --- a/lib/__tests__/video-source-preservation.test.ts +++ b/lib/__tests__/video-source-preservation.test.ts @@ -189,7 +189,8 @@ describe('canonical video source preservation', () => { expect(enqueueFailureBlock).toContain("videoProcessingStatus: 'pending'") expect(enqueueFailureBlock).toContain('60 秒周期扫描会重新装载该任务') - expect(enqueueFailureBlock).toContain('transcodeLastError: fullErrorMsg.substring(0, 1000)') + expect(enqueueFailureBlock).toContain('transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE') + expect(enqueueFailureBlock).not.toContain('fullErrorMsg') expect(enqueueFailureBlock).not.toContain("videoProcessingStatus: 'failed'") expect(enqueueFailureBlock).toContain('canonical 源视频必须保留') expect(enqueueFailureBlock).not.toContain('deleteLocalFile') diff --git a/lib/video-processor.ts b/lib/video-processor.ts index c8b3187..4110ccf 100644 --- a/lib/video-processor.ts +++ b/lib/video-processor.ts @@ -548,7 +548,7 @@ export function resolveTranscodeOutputRoot(outputDir: string): string { return candidate } -function resolveLocalVideoPath(inputPath: string): string { +export function resolveLocalVideoPath(inputPath: string): string { const uploadRoot = configuredUploadRoot() if (inputPath.startsWith('/uploads/')) { const candidate = path.resolve(uploadRoot, inputPath.slice('/uploads/'.length)) diff --git a/lib/video-transcode-task.ts b/lib/video-transcode-task.ts index fb64f89..e092aae 100644 --- a/lib/video-transcode-task.ts +++ b/lib/video-transcode-task.ts @@ -1,7 +1,10 @@ // 视频转码任务封装,供上传接口和转码API复用 import { prisma } from '@/lib/prisma' import { transcodeQueue } from '@/lib/video-transcode-queue' -import { processVideoTranscode } from '@/lib/video-processor' +import { processVideoTranscode, resolveLocalVideoPath } from '@/lib/video-processor' +import { access } from 'fs/promises' + +export const USER_TRANSCODE_FAILURE_MESSAGE = '预览还没做好,请稍后重试' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' const MAX_TRANSCODE_RETRIES = 3 @@ -114,9 +117,7 @@ function isNonRetryableTranscodeError(error: unknown): boolean { } const message = extractErrorMessage(error) - return message.includes('视频文件不存在') - || message.includes('ENOENT') - || message.includes('FFmpeg not available') + return message.includes('FFmpeg not available') } function generatedOutputUrls(result: TranscodeResult): string[] { @@ -327,7 +328,7 @@ export async function executeTranscodeTask( const videoSourceUrl = latestMaterial?.videoSourceUrl || fallbackVideoSourceUrl if (!videoSourceUrl) { - throw new NonRetryableTranscodeError('视频文件不存在') + throw new NonRetryableTranscodeError(USER_TRANSCODE_FAILURE_MESSAGE) } // 入队时的 needsCoverImage 只是快照;真正开始处理时以数据库最新值为准, @@ -339,7 +340,7 @@ export async function executeTranscodeTask( const fileExists = await validateVideoFile(videoSourceUrl) if (!fileExists) { - throw new NonRetryableTranscodeError(`视频文件不存在: ${videoSourceUrl}`) + throw new Error(USER_TRANSCODE_FAILURE_MESSAGE) } const started = await withCurrentDatabaseLease<{ count: number }>(materialId, leaseToken, (tx) => tx.material.updateMany({ @@ -415,7 +416,7 @@ export async function executeTranscodeTask( where: { id: materialId, transcodeLeaseToken: leaseToken }, data: { videoProcessingStatus: 'failed', - transcodeLastError: errorMsg.substring(0, 500), + transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE, transcodeAttempts: attempt, }, })) @@ -429,7 +430,7 @@ export async function executeTranscodeTask( const retryRecorded = await withCurrentDatabaseLease<{ count: number }>(materialId, leaseToken, (tx) => tx.material.updateMany({ where: { id: materialId, transcodeLeaseToken: leaseToken }, data: { - transcodeLastError: errorMsg.substring(0, 500), + transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE, transcodeAttempts: attempt, }, })) @@ -454,32 +455,19 @@ export class StaleTranscodeLeaseError extends Error { */ async function validateVideoFile(videoSourceUrl: string): Promise { try { - // 如果是 HTTP URL,跳过本地文件检查 if (videoSourceUrl.startsWith('http://') || videoSourceUrl.startsWith('https://')) { return true } - const path = await import('path') - const { access, constants } = await import('fs/promises') - - let localFilePath = videoSourceUrl - - // 如果是相对路径(以 /uploads/ 开头),转换为绝对路径 - if (videoSourceUrl.startsWith('/uploads/')) { - const uploadDir = process.env.UPLOAD_DIR || './uploads' - const absoluteUploadDir = path.isAbsolute(uploadDir) ? uploadDir : path.join(process.cwd(), uploadDir) - localFilePath = path.join(absoluteUploadDir, videoSourceUrl.replace('/uploads/', '')) - } else if (!path.isAbsolute(videoSourceUrl)) { - // 相对路径:基于当前工作目录 - localFilePath = path.join(process.cwd(), videoSourceUrl) - } - - // 检查文件是否存在 - await access(localFilePath, constants.F_OK) - console.log(`[TranscodeTask] ✅ Video file exists: ${localFilePath}`) + const localFilePath = resolveLocalVideoPath(videoSourceUrl) + await access(localFilePath) + console.log(`[TranscodeTask] Video source is ready: ${localFilePath}`) return true } catch (error) { - console.error(`[TranscodeTask] ❌ Video file not found: ${videoSourceUrl}`, error) + console.error('[TranscodeTask] Video source is not readable', { + videoSourceUrl, + error, + }) return false } } @@ -495,7 +483,7 @@ export async function enqueueVideoTranscodeTask( // 验证视频文件是否存在 const fileExists = await validateVideoFile(videoSourceUrl) if (!fileExists) { - throw new Error(`视频文件不存在: ${videoSourceUrl}`) + throw new Error(USER_TRANSCODE_FAILURE_MESSAGE) } console.log(`[TranscodeTask] 📝 Enqueuing transcode task: materialId=${materialId}, videoSourceUrl=${videoSourceUrl}, needsCoverImage=${needsCoverImage}`)