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

131 lines
5.0 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update';
import { isWebFileLike } from '@/lib/web-file';
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard';
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
MULTIPART_ROUTE_LIMITS,
} from '@/lib/multipart-route-limits';
// PUT /api/materials/update - 更新素材(包括预览图)
export async function PUT(request: NextRequest) {
let releaseRequestSlot: (() => void) | null = null;
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const contentType = request.headers.get('content-type') || '';
let id = '';
let input: MaterialUpdateInput;
let previewImage: File | null = null;
if (contentType.includes('multipart/form-data')) {
assertBoundedMultipartHeaders(request.headers, MULTIPART_ROUTE_LIMITS.materialUpdateRequestBytes);
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire();
const formData = await request.formData();
assertMultipartFiles(formData, {
fields: {
previewImage: {
maxCount: 1,
maxFileBytes: MULTIPART_ROUTE_LIMITS.materialUpdatePreviewBytes,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'],
},
},
maxFiles: 1,
maxTotalBytes: MULTIPART_ROUTE_LIMITS.materialUpdatePreviewBytes,
});
id = String(formData.get('id') || '');
input = {
productModel: String(formData.get('productModel') || ''),
...(formData.has('reportTitle')
? { reportTitle: formData.get('reportTitle') as string | null }
: {}),
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,
platformName: formData.get('platformName') as string | null,
productCategoryName: formData.get('productCategoryName') as string | null,
materialType: String(formData.get('materialType') || ''),
renderer: formData.get('renderer') as string | null,
authorName: String(formData.get('authorName') || ''),
...(formData.has('userId')
? { userId: formData.get('userId') as string | null }
: {}),
...(formData.has('taskNo')
? { taskNo: formData.get('taskNo') as string | null }
: {}),
...(formData.has('dingtalkApprovalNo')
? { dingtalkApprovalNo: formData.get('dingtalkApprovalNo') as string | null }
: {}),
...(formData.has('taskDetailSourceType')
? { taskDetailSourceType: formData.get('taskDetailSourceType') as string | null }
: {}),
...(formData.has('taskDetailRequesterUserId')
? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null }
: {}),
...(formData.has('isFullSet')
? { isFullSet: formData.get('isFullSet') as string | null }
: {}),
};
const formPreview = formData.get('previewImage');
previewImage = isWebFileLike(formPreview) ? formPreview : null;
} else {
const json = await request.json();
id = String(json.id || '');
input = json;
}
if (!id) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
const user = session.user as any;
const result = await updateMaterialWithSharedRules({
materialId: id,
operatorUserId: user.id,
operatorRole: user.role,
input,
previewImage,
});
if ('error' in result) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
const response = {
...result.material,
zipFileSize: result.material.zipFileSize.toString(),
isFullSet: Boolean((result.material as any).isFullSet),
taskDetailSourceType: (result.material as any).taskDetailSourceType || null,
taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null,
taskDetailRequesterName: (result.material as any).taskDetailRequesterName || null,
};
return NextResponse.json(
{ message: '更新成功', material: response },
{ status: 200 }
);
} catch (error) {
console.error('Update 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: '更新失败,请稍后再试' }, { status: 500 });
} finally {
releaseRequestSlot?.();
}
}