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

663 lines
23 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 path from 'path';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import {
canUserDownloadMaterial,
canUserDownloadSource,
canUserViewMaterial,
isSystemAdmin,
} from '@/lib/department-permission';
import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { isWebFileLike } from '@/lib/web-file';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy';
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard';
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
MULTIPART_ROUTE_LIMITS,
} from '@/lib/multipart-route-limits';
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
if (!displayImages) return [];
if (Array.isArray(displayImages)) {
return displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0);
}
if (typeof displayImages === 'string') {
try {
const parsed = JSON.parse(displayImages);
if (Array.isArray(parsed)) {
return parsed.filter((img: any) => typeof img === 'string' && img.trim().length > 0);
}
} catch (e) {
console.warn('[materials/[id]] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
function formatTaskDetailAsset(asset: any, uploaderName: string, rewriteFileUrl?: (url: string) => string) {
const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl
const fileKind = getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl)
if (asset.assetType !== 'sku-preview') {
return {
...asset,
fileUrl: nextFileUrl,
fileKind,
}
}
return {
...asset,
fileUrl: nextFileUrl,
fileKind,
skuCode: asset.skuCode || null,
barcodes: Array.isArray(asset.barcodes)
? asset.barcodes
.map((item: any) => String(item?.barcode || '').trim())
.filter(Boolean)
: [],
uploadedAt: asset.createdAt || null,
updatedAt: asset.updatedAt || null,
uploaderName,
}
}
function getSourceDisplayName(value: unknown): string {
const rawValue = String(value || '').trim()
if (!rawValue) {
return ''
}
try {
if (/^https?:\/\//i.test(rawValue)) {
return decodeURIComponent(path.posix.basename(new URL(rawValue).pathname))
}
} catch {
return ''
}
return path.basename(rawValue.replace(/\\/g, '/'))
}
function buildProtectedUploadUrl(rawSourceValue: unknown): string | null {
const rawSource = String(rawSourceValue || '').trim()
if (!rawSource) {
return null
}
if (/^https?:\/\//i.test(rawSource)) {
return null
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
const normalizedUploadDir = uploadDir.replace(/\\/g, '/').replace(/\/+$/, '')
let sourceRelativePath = rawSource.replace(/\\/g, '/').replace(/^\/+/, '')
const prefixCandidates = Array.from(
new Set([
normalizedUploadDir,
normalizedUploadDir.replace(/^\.\//, ''),
normalizedUploadDir.replace(/^\//, ''),
'uploads',
].filter(Boolean)),
)
for (const prefix of prefixCandidates) {
const normalizedPrefix = prefix.replace(/\/+$/, '')
if (sourceRelativePath.startsWith(`${normalizedPrefix}/`)) {
sourceRelativePath = sourceRelativePath.slice(normalizedPrefix.length + 1)
break
}
}
return sourceRelativePath ? `/api/uploads/${sourceRelativePath}` : null
}
function buildSourceFileUrl(materialId: string): string {
return `/api/materials/download?id=${encodeURIComponent(materialId)}`
}
function isPreviewAssetType(assetType: unknown): boolean {
const normalized = String(assetType || '').trim()
return (
['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image'].includes(normalized) ||
normalized.startsWith('standard-model-white-bg-view:') ||
normalized.startsWith('standard-model-white-bg-other:')
)
}
// GET /api/materials/[id] - 获取单个素材详情
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
const isEditRequest = request.nextUrl.searchParams.get('intent') === 'edit';
let material;
try {
material = await prisma.material.findFirst({
where: {
id: params.id,
deletedAt: null,
},
select: {
id: true,
userId: true,
productModel: true,
reportTitle: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
previewUrl: true,
zipFileName: true,
zipFilePath: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskNo: true,
dingtalkApprovalNo: true,
taskStatus: true,
taskFetchedAt: true,
isFullSet: true,
user: {
select: {
id: true,
username: true,
realName: true,
},
},
// 包含 assets 数据(任务-详情需要)
assets: {
select: {
id: true,
assetType: true,
fileUrl: true,
fileName: true,
fileSize: true,
sortOrder: true,
createdAt: true,
updatedAt: true,
skuCode: true,
barcodes: {
select: {
barcode: true,
},
},
},
orderBy: { sortOrder: 'asc' },
},
} as any,
});
} catch (error: any) {
// 兼容旧版本数据库(没有 deletedAt 字段)
if (error?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
material = await prisma.material.findFirst({
where: {
id: params.id,
},
select: {
id: true,
userId: true,
productModel: true,
reportTitle: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
previewUrl: true,
zipFileName: true,
zipFilePath: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskNo: true,
dingtalkApprovalNo: true,
taskStatus: true,
taskFetchedAt: true,
isFullSet: true,
user: {
select: {
id: true,
username: true,
realName: true,
},
},
assets: {
select: {
id: true,
assetType: true,
fileUrl: true,
fileName: true,
fileSize: true,
sortOrder: true,
createdAt: true,
updatedAt: true,
skuCode: true,
barcodes: {
select: {
barcode: true,
},
},
},
orderBy: { sortOrder: 'asc' },
},
} as any,
});
} else {
throw error;
}
}
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
let canAccessSource = false;
const videoPreviewPolicy = getVideoPreviewPolicyFields(
(material as any).materialType,
(material as any).createdAt,
);
const buildVideoPreviewBlockedResponse = () =>
NextResponse.json(
{
error: '视频类素材上传后满 15 天才能查看详情',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: videoPreviewPolicy.videoPreviewUnavailableMessage,
availableAt: videoPreviewPolicy.videoPreviewAvailableAt,
remainingDays: videoPreviewPolicy.videoPreviewRemainingDays,
},
{ status: 403 },
);
const userRole = (session.user as any).role;
const userDepartment = (session.user as any).department;
const managedDepartment = (session.user as any).managedDepartment;
const userId = (session.user as any).id;
// 超级管理员不受限制
const { isSuperAdmin } = await import('@/lib/department-permission');
const userIsSuperAdmin = await isSuperAdmin(userId);
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
const canEdit =
(material as any).userId === userId ||
canBypassVideoPreviewGate;
const materialWithUser = material as any;
const displayAuthorName =
materialWithUser.user?.realName ||
materialWithUser.user?.username ||
materialWithUser.authorName;
if (isEditRequest) {
if (!canEdit) {
return NextResponse.json(
{ error: '您没有权限编辑此素材' },
{ status: 403 },
);
}
const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite');
const canShowPreview =
!videoPreviewPolicy.videoPreviewBlocked ||
canBypassVideoPreviewGate;
return NextResponse.json({
id: materialWithUser.id,
userId: materialWithUser.userId,
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
platformName: materialWithUser.platformName || '',
productCategoryName: materialWithUser.productCategoryName || '',
materialType: materialWithUser.materialType,
renderer: materialWithUser.renderer,
authorName: displayAuthorName,
previewUrl: canShowPreview
? rewriteMediaUrlForClient(materialWithUser.previewUrl, request, 'image')
: null,
taskNo: materialWithUser.taskNo || null,
dingtalkApprovalNo: materialWithUser.dingtalkApprovalNo || null,
taskStatus: materialWithUser.taskStatus || null,
taskFetchedAt: materialWithUser.taskFetchedAt || null,
isFullSet: deriveTaskDetailIsFullSet(
materialWithUser.materialType,
materialWithUser.assets,
Boolean(materialWithUser.isFullSet),
),
taskDetailSourceType: materialWithUser.taskDetailSourceType || null,
taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null,
taskDetailRequesterName: materialWithUser.taskDetailRequesterName || null,
canEdit: true,
});
}
if (!userIsSuperAdmin) {
const canView = await canUserViewMaterial(
userDepartment,
userRole,
managedDepartment,
(material as any).materialType,
userId
);
if (!canView) {
return NextResponse.json(
{ error: '无权限查看该素材' },
{ status: 403 }
);
}
}
if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) {
return buildVideoPreviewBlockedResponse();
}
if (isSystemAdmin(userRole) || userIsSuperAdmin) {
canAccessSource = true;
} else {
const userDownloadSetting = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
});
if (userDownloadSetting?.canDownload) {
const canDownloadMaterial = await canUserDownloadMaterial(
userDepartment,
userRole,
managedDepartment,
(material as any).materialType,
userId
);
const requiresSourcePermission =
isProductPackageType((material as any).materialType) ||
is3DOriginalType((material as any).materialType);
const hasSourcePermission = requiresSourcePermission
? await canUserDownloadSource(
userDepartment,
userRole,
managedDepartment,
'详情模板',
userId
)
: true;
canAccessSource = canDownloadMaterial && hasSourcePermission;
}
}
const formattedAssets = (materialWithUser.assets || []).map((asset: any) =>
formatTaskDetailAsset(asset, displayAuthorName),
);
const response = {
id: materialWithUser.id,
userId: materialWithUser.userId,
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
platformName: materialWithUser.platformName || '',
productCategoryName: materialWithUser.productCategoryName || '',
materialType: materialWithUser.materialType,
isAiGenerated: Boolean(materialWithUser.isAiGenerated),
taskDetailSourceType: materialWithUser.taskDetailSourceType || null,
taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null,
taskDetailRequesterName: materialWithUser.taskDetailRequesterName || null,
renderer: materialWithUser.renderer,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl: materialWithUser.previewUrl,
zipFileName: getSourceDisplayName(materialWithUser.zipFileName || materialWithUser.zipFilePath),
zipFilePath: '',
zipFileSize: materialWithUser.zipFileSize.toString(),
downloadCount: materialWithUser.downloadCount,
likeCount: materialWithUser.likeCount,
displayImages: parseDisplayImages(materialWithUser.displayImages || null),
createdAt: materialWithUser.createdAt,
videoSourceUrl: canAccessSource ? materialWithUser.videoSourceUrl || null : null,
videoPreview540Url: materialWithUser.videoPreview540Url || null,
videoPreviewClipUrl: materialWithUser.videoPreviewClipUrl || null,
videoCoverUrl: materialWithUser.videoCoverUrl || null,
videoDuration: materialWithUser.videoDuration || null,
videoProcessingStatus: materialWithUser.videoProcessingStatus || null,
taskNo: materialWithUser.taskNo || null,
dingtalkApprovalNo: materialWithUser.dingtalkApprovalNo || null,
taskStatus: materialWithUser.taskStatus || null,
taskFetchedAt: materialWithUser.taskFetchedAt || null,
isFullSet: deriveTaskDetailIsFullSet(
materialWithUser.materialType,
materialWithUser.assets,
Boolean(materialWithUser.isFullSet),
),
assets: formattedAssets,
canDownloadSource: canAccessSource,
canEdit,
user: {
username: materialWithUser.user?.username || '',
},
};
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite');
// 处理音频文件的zipFileName(特殊处理)
let processedZipFileName = response.zipFileName;
if (response.zipFileName && response.materialType === '音乐' && canAccessSource) {
processedZipFileName = buildSourceFileUrl(response.id);
}
const rewrittenResponse = {
...response,
sourceFileUrl: canAccessSource ? buildSourceFileUrl(response.id) : null,
// 图片:使用统一规则(小文件直接访问,大文件走代理)
previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'),
videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'),
displayImages: response.displayImages?.map((img: string) =>
rewriteMediaUrlForClient(img, request, 'image')
),
// 视频:使用统一规则(通常>1MB,会走代理)
videoPreview540Url: rewriteMediaUrlForClient(response.videoPreview540Url, request, 'video'),
videoPreviewClipUrl: rewriteMediaUrlForClient(response.videoPreviewClipUrl, request, 'video'),
videoSourceUrl: canAccessSource ? buildProtectedUploadUrl(response.videoSourceUrl) : null,
// 音频文件:特殊处理
zipFileName: processedZipFileName,
assets: formattedAssets.map((asset: any) => {
const isPreview = isPreviewAssetType(asset.assetType)
return {
...asset,
fileUrl: isPreview
? rewriteMediaUrlForClient(asset.fileUrl, request, 'image')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
fileSize:
typeof asset.fileSize === 'bigint'
? asset.fileSize.toString()
: asset.fileSize != null
? String(asset.fileSize)
: '0',
}
}),
};
return NextResponse.json(rewrittenResponse);
} catch (error) {
console.error('获取素材失败:', error);
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
}
}
// PUT /api/materials/[id] - 更新素材信息
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
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 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,
});
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();
input = json;
}
const user = session.user as any;
const result = await updateMaterialWithSharedRules({
materialId: params.id,
operatorUserId: user.id,
operatorRole: user.role,
input,
previewImage,
});
if ('error' in result) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({
success: true,
message: '素材信息更新成功',
material: {
id: result.material.id,
productModel: result.material.productModel,
categoryName: result.material.categoryName,
sceneName: result.material.sceneName,
platformName: result.material.platformName,
productCategoryName: result.material.productCategoryName,
materialType: result.material.materialType,
renderer: result.material.renderer,
authorName: result.material.authorName,
taskNo: result.material.taskNo,
dingtalkApprovalNo: (result.material as any).dingtalkApprovalNo || null,
taskDetailSourceType: (result.material as any).taskDetailSourceType || null,
taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null,
taskDetailRequesterName: (result.material as any).taskDetailRequesterName || null,
taskStatus: result.material.taskStatus,
taskFetchedAt: result.material.taskFetchedAt,
isFullSet: Boolean((result.material as any).isFullSet),
},
});
} catch (error) {
console.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?.();
}
}