fix: 转码按正式目录找源文件,失败只给用户可重试提示
把 /uploads 网址路径落到 UPLOAD_DIR,避免误判文件不存在。 页面和接口不再回传路径或堆栈,只提示稍后重试。
This commit is contained in:
parent
39954b1424
commit
5ef70bce4e
@ -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,
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)');
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: '预览还没做好,请稍后重试',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
26
lib/__tests__/video-local-path.test.ts
Normal file
26
lib/__tests__/video-local-path.test.ts
Normal 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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}`)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user