fix: 转码按正式目录找源文件,失败只给用户可重试提示

把 /uploads 网址路径落到 UPLOAD_DIR,避免误判文件不存在。
页面和接口不再回传路径或堆栈,只提示稍后重试。
This commit is contained in:
chenpei 2026-08-17 15:24:13 +08:00
parent 39954b1424
commit 5ef70bce4e
11 changed files with 69 additions and 53 deletions

View File

@ -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,
})

View File

@ -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({

View File

@ -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: {

View File

@ -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)');

View File

@ -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')
})

View File

@ -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) {

View File

@ -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: '预览还没做好,请稍后重试',
},
})
})

View File

@ -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'),
)
})
})

View File

@ -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')

View File

@ -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))

View File

@ -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<boolean> {
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}`)