Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
131 lines
5.0 KiB
TypeScript
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?.();
|
|
}
|
|
}
|