chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

104 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { uploadToS3 } from '@/lib/s3';
import {
assertBoundedMultipartHeaders,
assertMultipartFiles,
} from '@/lib/multipart-request-guard';
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
MULTIPART_ROUTE_LIMITS,
} from '@/lib/multipart-route-limits';
/**
* POST - 上传原图到 S3(不压缩)
* 专门用于上传需要保留原始质量的图片(如任务原图)
*/
export async function POST(request: NextRequest) {
let releaseRequestSlot: (() => void) | null = null;
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
assertBoundedMultipartHeaders(request.headers, MULTIPART_ROUTE_LIMITS.legacyS3RequestBytes);
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire();
const formData = await request.formData();
assertMultipartFiles(formData, {
fields: {
file: {
maxCount: 1,
maxFileBytes: MULTIPART_ROUTE_LIMITS.legacyS3FileBytes,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/tiff', 'image/avif'],
},
},
maxFiles: 1,
maxTotalBytes: MULTIPART_ROUTE_LIMITS.legacyS3FileBytes,
});
const file = formData.get('file') as File;
const type = formData.get('type') as string; // original-main/original-preview等
if (!file) {
return NextResponse.json({ error: '缺少文件' }, { status: 400 });
}
// 文件大小检查(原图最大100MB)
if (file.size > MULTIPART_ROUTE_LIMITS.legacyS3FileBytes) {
return NextResponse.json(
{ error: `文件过大,请使用小于100MB的图片` },
{ status: 400 }
);
}
console.log(`[S3 Original Upload] ========== Processing original file ==========`);
console.log(`[S3 Original Upload] Type: ${type}`);
console.log(`[S3 Original Upload] Original fileName: ${file.name}`);
console.log(`[S3 Original Upload] File size: ${file.size} bytes (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
console.log(`[S3 Original Upload] ⚠️ NO COMPRESSION - Uploading as-is`);
// 读取文件buffer(不压缩)
const buffer = Buffer.from(await file.arrayBuffer());
// 生成文件路径(保持原文件扩展名)
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const extension = file.name.split('.').pop() || 'jpg';
const fileName = `${type}-${timestamp}-${randomSuffix}.${extension}`;
const s3Path = `product-package/${fileName}`;
console.log(`[S3 Original Upload] Generated fileName: ${fileName}`);
console.log(`[S3 Original Upload] S3 path: ${s3Path}`);
// 直接上传原图到 S3(不压缩)
const url = await uploadToS3(buffer, s3Path, file.type || 'image/jpeg');
console.log(`[S3 Original Upload] Uploaded successfully: url=${url}`);
console.log(`[S3 Original Upload] ==========================================`);
return NextResponse.json({ url });
} catch (error) {
console.error('[S3 Original Upload] Error:', error);
const boundedFailure = getBoundedMultipartFailure(error);
if (boundedFailure) {
return NextResponse.json(
{ error: boundedFailure.message, code: boundedFailure.code },
{
status: boundedFailure.status,
...(boundedFailure.retryAfterSeconds
? { headers: { 'Retry-After': String(boundedFailure.retryAfterSeconds) } }
: {}),
},
);
}
return NextResponse.json(
{ error: '上传失败: ' + (error instanceof Error ? error.message : '未知错误') },
{ status: 500 }
);
} finally {
releaseRequestSlot?.();
}
}