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

105 lines
4.0 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 path from 'path';
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; // main/sku/detail
if (!file) {
return NextResponse.json({ error: '缺少文件' }, { status: 400 });
}
// 文件大小检查(任务-详情允许较大文件,最大100MB)
if (file.size > MULTIPART_ROUTE_LIMITS.legacyS3FileBytes) {
return NextResponse.json(
{ error: `图片过大,请使用小于100MB的图片` },
{ status: 400 }
);
}
// 读取文件buffer
const buffer = Buffer.from(await file.arrayBuffer());
// 获取原始文件扩展名
const originalExt = path.extname(file.name).toLowerCase() || '.jpg';
// 保持原始格式,不转换为webp
const mimeType = file.type || 'image/jpeg';
console.log(`[S3 Upload] ========== Processing file (NO COMPRESSION) ==========`);
console.log(`[S3 Upload] Type: ${type}`);
console.log(`[S3 Upload] Original fileName: ${file.name}`);
console.log(`[S3 Upload] Original file size: ${buffer.length} bytes (${(buffer.length / 1024 / 1024).toFixed(2)} MB)`);
console.log(`[S3 Upload] MIME type: ${mimeType}`);
console.log(`[S3 Upload] 任务-详情类型:保持原图质量,不做压缩处理`);
// 生成文件路径(保持原始格式)
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9); // 7位随机字符串
const fileName = `${type}-${timestamp}-${randomSuffix}${originalExt}`;
const s3Path = `product-package/${fileName}`;
console.log(`[S3 Upload] Generated fileName: ${fileName}`);
console.log(`[S3 Upload] S3 path: ${s3Path}`);
// 直接上传原图到 S3(不压缩)
const url = await uploadToS3(buffer, s3Path, mimeType);
console.log(`[S3 Upload] Uploaded successfully: url=${url}`);
console.log(`[S3 Upload] ====================================`);
return NextResponse.json({ url });
} catch (error) {
console.error('[S3 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?.();
}
}