import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { isSystemAdmin } from '@/lib/department-permission'; import { createMaterialLog } from '@/lib/material-log'; import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; import { rewriteMediaUrlForClient } from '@/lib/url-rewrite'; import { detectIsIntranet } from '@/lib/network-detection'; import { buildMaterialWhereFromSearchParams, MaterialQueryError, } from '@/lib/material-query'; import { getMaterialCoverUrl } from '@/lib/product-package'; import { isProductPackageType } from '@/lib/material-type-mapper'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; import { applyVideoPreviewPolicy, userHasVideoEarlyAccess } from '@/lib/video-download-policy'; // 🔒 R45修复:数据库查询限制常量(防止OOM) const MAX_PAGE_SIZE = 100; // 单页最大100条记录 const MAX_QUERY_LIMIT = 5000; // comprehensive排序时最大查询5000条 // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { if (!displayImages) return []; if (Array.isArray(displayImages)) { return displayImages.filter(img => 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 => typeof img === 'string' && img.trim().length > 0); } } catch (e) { console.warn('[materials] Failed to parse displayImages JSON, fallback to single value:', e); } return displayImages.trim() ? [displayImages] : []; } return []; } function formatTaskDetailAssetSummary( asset: any, uploaderName: string, rewriteFileUrl?: (url: string) => string | null, ) { const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl; const fileKind = getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl) const baseAsset = { ...asset, fileUrl: nextFileUrl || asset.fileUrl || '', fileKind, fileSize: typeof asset.fileSize === 'bigint' ? asset.fileSize.toString() : asset.fileSize != null ? String(asset.fileSize) : '0', }; if (asset.assetType !== 'sku-preview') { return baseAsset; } return { ...baseAsset, 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, }; } // GET - 获取素材列表 export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions); const { searchParams } = new URL(request.url); if (!session?.user || !(session.user as any).id) { return NextResponse.json( { error: '未登录', code: 'UNAUTHORIZED' }, { status: 401 }, ); } // 单个素材查询(通过uploadId) const uploadId = searchParams.get('uploadId'); if (uploadId) { const material: any = await prisma.material.findFirst({ where: { uploadId, deletedAt: null, } as any, select: { id: true, userId: true, previewUrl: 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, zipFileName: true, zipFileSize: true, downloadCount: true, likeCount: true, createdAt: true, videoSourceUrl: true, displayImages: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, videoProcessingStatus: true, user: { select: { username: true, realName: true, avatar: 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, }); if (!material) { return NextResponse.json({ material: null }); } // 权限检查:分享页使用 /api/materials/share?id=...,普通素材接口不接受 share 参数绕过权限。 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, canUserViewMaterial } = await import('@/lib/department-permission'); if (!(await isSuperAdmin(userId))) { const canView = await canUserViewMaterial( userDepartment, userRole, managedDepartment, material.materialType, userId ); if (!canView) { return NextResponse.json( { error: '无权限查看该素材' }, { status: 403 } ); } } // 检查点赞和收藏状态 let isLiked = false; let isFavorited = false; if (session?.user) { const userId = (session.user as any).id; const materialId = material.id; // 检查点赞 const like = await prisma.materialLike.findFirst({ where: { userId, materialId }, }); isLiked = !!like; // 检查收藏 try { const favorite = await prisma.materialFavorite.findFirst({ where: { materialId, userId, }, }); isFavorited = !!favorite; } catch (e: any) { if (!e?.meta?.table?.includes('material_favorites')) { console.error('[Materials API] Error fetching favorite:', e); } } } // 处理previewUrl的fallback逻辑 let previewUrl = material.previewUrl || material.videoCoverUrl || ''; // 任务-详情类型:优先使用 assets 中的预览图 if (!previewUrl && isProductPackageType(material.materialType) && material.assets && material.assets.length > 0) { const previewAsset = material.assets.find((a: any) => a.assetType === 'main-preview'); if (previewAsset) { previewUrl = previewAsset.fileUrl; console.log('[Materials API] Using preview from assets:', previewUrl); } } // 任务-页面类型:完成上传后预览资源的 assetType 为 preview。 if (!previewUrl && material.materialType === '任务-页面' && material.assets && material.assets.length > 0) { const previewAsset = material.assets.find((a: any) => a.assetType === 'preview'); if (previewAsset) { previewUrl = previewAsset.fileUrl; } } // 其他素材类型:使用 displayImages if (!previewUrl && material.displayImages) { const displayImagesArray = parseDisplayImages(material.displayImages); if (displayImagesArray.length > 0) { previewUrl = displayImagesArray[0]; } } // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName const displayAuthorName = material.user?.realName || material.user?.username || material.authorName; const formattedAssets = (material.assets || []).map((asset: any) => formatTaskDetailAssetSummary( asset, displayAuthorName, (fileUrl) => rewriteMediaUrlForClient(fileUrl, request, 'image'), ), ); const normalizedMaterial = normalizeMaterial({ ...material, authorName: displayAuthorName, // 使用处理后的作者名 previewUrl, // 使用处理后的previewUrl zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), isLiked, isFavorited, user: material.user ? { ...material.user, avatar: normalizeUrl(material.user.avatar), } : null, assets: formattedAssets, }); return NextResponse.json({ material: normalizedMaterial }); } // 列表查询 const sortBy = searchParams.get('sortBy') || 'comprehensive'; const sortOrder = searchParams.get('sortOrder') || 'desc'; const page = parseInt(searchParams.get('page') || '1'); // 🔒 R45修复:限制 pageSize 上限,防止查询过多数据 const rawPageSize = parseInt(searchParams.get('pageSize') || '20'); const pageSize = Math.min(rawPageSize, MAX_PAGE_SIZE); // 如果用户请求的 pageSize 超过限制,记录警告 if (rawPageSize > MAX_PAGE_SIZE) { console.warn(`[Materials API] pageSize 超过限制:${rawPageSize} > ${MAX_PAGE_SIZE},已自动限制为 ${MAX_PAGE_SIZE}`); } const { where, empty } = await buildMaterialWhereFromSearchParams( searchParams, session.user as any, ); if (empty) { return NextResponse.json({ materials: [], hasMore: false, totalPages: 0, total: 0, totalCount: 0, currentPage: page, }); } // 创意资产库首页用 deferTotal=1:先返回当前页,不阻塞全库 count。 // 后台管理等仍走原来的 total/totalPages。 const deferTotal = searchParams.get('deferTotal') === '1'; let totalCount: number | null = null; let totalPages: number | null = null; if (!deferTotal) { try { totalCount = await prisma.material.count({ where }); } catch (e: any) { if (e?.meta?.column?.includes('deletedAt')) { console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); const whereWithoutDeleted = { ...where }; delete whereWithoutDeleted.deletedAt; totalCount = await prisma.material.count({ where: whereWithoutDeleted }); } else { throw e; } } totalPages = Math.ceil(totalCount / pageSize); } // 排序 - 使用 id 作为次要排序键,确保分页排序稳定(避免重复数据) let orderBy: any = [{ createdAt: sortOrder }, { id: 'asc' }]; if (sortBy === 'downloadCount') { orderBy = [{ downloadCount: sortOrder }, { id: 'asc' }]; } // 查询素材列表 let materials: any[]; const queryWhere = where; try { const query: any = { where: queryWhere, select: { id: true, userId: true, previewUrl: 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, zipFileName: true, zipFileSize: true, downloadCount: true, likeCount: true, createdAt: true, videoSourceUrl: true, displayImages: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, videoProcessingStatus: true, transcodeLastError: true, transcodeAttempts: true, // 包含用户信息(用于显示真实姓名) user: { select: { username: true, realName: true, avatar: true, }, }, }, }; if (sortBy !== 'comprehensive') { query.orderBy = orderBy; query.skip = (page - 1) * pageSize; query.take = pageSize + (deferTotal ? 1 : 0); } else { // comprehensive排序:获取更多数据用于内存排序,但有上限 // 只需要前 (page * pageSize) 条数据,最多 MAX_QUERY_LIMIT 条 const offset = (page - 1) * pageSize; const limit = Math.min(offset + pageSize, MAX_QUERY_LIMIT); query.skip = 0; query.take = limit; } materials = await prisma.material.findMany(query); // comprehensive排序:按下载量和创建时间综合排序 if (sortBy === 'comprehensive') { materials.sort((a, b) => { // 综合排序:下载量权重70%,创建时间权重30% const downloadScoreA = (a.downloadCount || 0) * 0.7; const downloadScoreB = (b.downloadCount || 0) * 0.7; const timeScoreA = new Date(a.createdAt).getTime() * 0.3; const timeScoreB = new Date(b.createdAt).getTime() * 0.3; const scoreA = downloadScoreA + timeScoreA; const scoreB = downloadScoreB + timeScoreB; return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; }); // 分页 const startIndex = (page - 1) * pageSize; materials = materials.slice(startIndex, startIndex + pageSize); } } catch (e: any) { if (e?.meta?.column?.includes('deletedAt')) { console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); const whereWithoutDeleted = { ...queryWhere }; delete whereWithoutDeleted.deletedAt; try { materials = await prisma.material.findMany({ where: whereWithoutDeleted, select: { id: true, userId: true, previewUrl: true, productModel: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, taskDetailSourceType: true, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, authorName: true, zipFileName: true, zipFileSize: true, downloadCount: true, likeCount: true, createdAt: true, videoSourceUrl: true, displayImages: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, videoProcessingStatus: true, transcodeLastError: true, transcodeAttempts: true, user: { select: { username: true, realName: true, avatar: true, }, }, } as any, ...(sortBy !== 'comprehensive' ? { orderBy, skip: (page - 1) * pageSize, take: pageSize, } : {}), }); // comprehensive排序处理 if (sortBy === 'comprehensive') { materials.sort((a, b) => { const downloadScoreA = (a.downloadCount || 0) * 0.7; const downloadScoreB = (b.downloadCount || 0) * 0.7; const timeScoreA = new Date(a.createdAt).getTime() * 0.3; const timeScoreB = new Date(b.createdAt).getTime() * 0.3; const scoreA = downloadScoreA + timeScoreA; const scoreB = downloadScoreB + timeScoreB; return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; }); const startIndex = (page - 1) * pageSize; materials = materials.slice(startIndex, startIndex + pageSize); } } catch (e2: any) { if (e2?.meta?.column?.includes('transcode')) { console.warn('[Materials API] Transcode fields not found, using fallback query'); materials = (await prisma.material.findMany({ where: whereWithoutDeleted, select: { id: true, userId: true, previewUrl: true, productModel: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, taskDetailSourceType: true, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, authorName: true, zipFileName: true, zipFileSize: true, downloadCount: true, likeCount: true, createdAt: true, videoSourceUrl: true, displayImages: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, videoProcessingStatus: true, user: { select: { username: true, realName: true, }, }, } as any, ...(sortBy !== 'comprehensive' ? { orderBy, skip: (page - 1) * pageSize, take: pageSize, } : {}), })); // comprehensive排序处理 if (sortBy === 'comprehensive') { materials.sort((a, b) => { const downloadScoreA = (a.downloadCount || 0) * 0.7; const downloadScoreB = (b.downloadCount || 0) * 0.7; const timeScoreA = new Date(a.createdAt).getTime() * 0.3; const timeScoreB = new Date(b.createdAt).getTime() * 0.3; const scoreA = downloadScoreA + timeScoreA; const scoreB = downloadScoreB + timeScoreB; return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; }); const startIndex = (page - 1) * pageSize; materials = materials.slice(startIndex, startIndex + pageSize); } materials = materials.map(m => ({ ...m, transcodeLastError: null, transcodeAttempts: 0, })); } else { throw e2; } } } else if (e?.meta?.column?.includes('transcode')) { console.warn('[Materials API] Transcode fields not found, using fallback query'); materials = (await prisma.material.findMany({ where: queryWhere, select: { id: true, userId: true, previewUrl: true, productModel: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, taskDetailSourceType: true, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, authorName: true, zipFileName: true, zipFileSize: true, downloadCount: true, likeCount: true, createdAt: true, videoSourceUrl: true, displayImages: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, videoProcessingStatus: true, user: { select: { username: true, realName: true, }, }, } as any, ...(sortBy !== 'comprehensive' ? { orderBy, skip: (page - 1) * pageSize, take: pageSize, } : {}), })); // comprehensive排序处理 if (sortBy === 'comprehensive') { materials.sort((a, b) => { const downloadScoreA = (a.downloadCount || 0) * 0.7; const downloadScoreB = (b.downloadCount || 0) * 0.7; const timeScoreA = new Date(a.createdAt).getTime() * 0.3; const timeScoreB = new Date(b.createdAt).getTime() * 0.3; const scoreA = downloadScoreA + timeScoreA; const scoreB = downloadScoreB + timeScoreB; return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; }); const startIndex = (page - 1) * pageSize; materials = materials.slice(startIndex, startIndex + pageSize); } materials = materials.map(m => ({ ...m, transcodeLastError: null, transcodeAttempts: 0, })); } else { throw e; } } let hasMore = false; if (deferTotal && sortBy !== 'comprehensive') { hasMore = materials.length > pageSize; if (hasMore) { materials = materials.slice(0, pageSize); } } else if (totalPages != null) { hasMore = page < totalPages; } else { hasMore = materials.length === pageSize; } // 批量查询点赞和收藏状态 const likedSet = new Set(); const favoritedSet = new Set(); if (session?.user) { const userId = (session.user as any).id; const materialIds = materials.map(m => m.id); // 批量查询点赞 const likes = await prisma.materialLike.findMany({ where: { userId, materialId: { in: materialIds }, }, select: { materialId: true }, }); likes.forEach(like => likedSet.add(like.materialId)); // 批量查询收藏 try { const favorites = await prisma.materialFavorite.findMany({ where: { userId, materialId: { in: materialIds }, }, select: { materialId: true }, }); favorites.forEach(fav => favoritedSet.add(fav.materialId)); } catch (e: any) { if (!e?.meta?.table?.includes('material_favorites')) { console.error('[Materials API] Error fetching favorites:', e); } } } // 格式化返回数据 // 任务-详情 / 缺封面的任务-页面:单独批量取封面,避免列表主查询联资产表 const productPackageMaterialIds = materials .filter(m => isProductPackageType(m.materialType)) .map(m => m.id); const taskPageMissingPreviewIds = materials .filter(m => m.materialType === '任务-页面' && !m.previewUrl && !m.videoCoverUrl) .map(m => m.id); const coverMaterialIds = Array.from(new Set(productPackageMaterialIds.concat(taskPageMissingPreviewIds))); const coverUrlMap = new Map(); if (coverMaterialIds.length > 0) { try { const assets = await prisma.materialAsset.findMany({ where: { materialId: { in: coverMaterialIds }, assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview', 'preview'] }, }, select: { materialId: true, assetType: true, fileUrl: true, sortOrder: true, }, orderBy: [ { materialId: 'asc' }, { assetType: 'asc' }, { sortOrder: 'asc' }, ], }); const assetsByMaterial = new Map(); for (const asset of assets) { if (!assetsByMaterial.has(asset.materialId)) { assetsByMaterial.set(asset.materialId, {}); } const materialAssets = assetsByMaterial.get(asset.materialId)!; if (asset.assetType === 'main-preview') { materialAssets.mainPreview = asset.fileUrl; } else if (asset.assetType === 'sku-preview') { materialAssets.skuPreview = asset.fileUrl; } else if (asset.assetType === 'main') { materialAssets.main = asset.fileUrl; } else if (asset.assetType === 'sku') { materialAssets.sku = asset.fileUrl; } else if (asset.assetType === 'preview' && !materialAssets.preview) { materialAssets.preview = asset.fileUrl; } } assetsByMaterial.forEach((materialAssets, materialId) => { const coverUrl = materialAssets.mainPreview || materialAssets.skuPreview || materialAssets.preview || materialAssets.main || materialAssets.sku || null; if (coverUrl) { coverUrlMap.set(materialId, coverUrl); } }); } catch (error) { console.error('[Materials API] Failed to batch get cover URLs:', error); await Promise.all( productPackageMaterialIds.map(async (materialId) => { try { const coverUrl = await getMaterialCoverUrl(materialId); if (coverUrl) { coverUrlMap.set(materialId, coverUrl); } } catch (err) { console.error(`[Materials API] Failed to get cover URL for material ${materialId}:`, err); } }) ); } } const formattedMaterials = materials.map(material => { // 处理previewUrl的fallback逻辑 let previewUrl = material.previewUrl || material.videoCoverUrl || ''; if (!previewUrl && material.materialType === '任务-页面') { const coverUrl = coverUrlMap.get(material.id); if (coverUrl) { previewUrl = coverUrl; } } // 任务-详情类型:优先使用从 assets 获取的封面图 if (isProductPackageType(material.materialType)) { const coverUrl = coverUrlMap.get(material.id); if (coverUrl) { previewUrl = coverUrl; } else if (!previewUrl) { // 如果 assets 中没有,再尝试其他 fallback if (material.displayImages) { const displayImagesArray = parseDisplayImages(material.displayImages); if (displayImagesArray.length > 0) { previewUrl = displayImagesArray[0]; } } } } else { // 其他类型:使用原有的 fallback 逻辑 if (!previewUrl && material.displayImages) { const displayImagesArray = parseDisplayImages(material.displayImages); if (displayImagesArray.length > 0) { previewUrl = displayImagesArray[0]; } } } // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName const displayAuthorName = material.user?.realName || material.user?.username || material.authorName; const formattedAssets = (material.assets || []).map((asset: any) => formatTaskDetailAssetSummary( asset, displayAuthorName, (fileUrl) => rewriteMediaUrlForClient(fileUrl, request, 'image'), ), ); const formatted = { ...material, authorName: displayAuthorName, // 使用处理后的作者名 previewUrl, // 使用处理后的previewUrl zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), isLiked: likedSet.has(material.id), isFavorited: favoritedSet.has(material.id), user: material.user ? { ...material.user, avatar: normalizeUrl(material.user.avatar), } : null, assets: formattedAssets, }; // 应用统一规则:内网用户直接访问,外网用户根据文件大小智能选择 // 注意:不要先调用 normalizeMaterial,因为它会将URL转换为代理URL,导致内网用户无法重写为内网地址 // 应该直接使用 rewriteMediaUrlForClient,它会根据用户网络位置智能决定是否重写为内网地址 const isIntranet = detectIsIntranet(request); // 处理音频文件的zipFileName(特殊处理) let processedZipFileName = formatted.zipFileName; if (formatted.zipFileName && formatted.materialType === '音乐') { const zipFileName = formatted.zipFileName; if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) { // 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理) processedZipFileName = rewriteMediaUrlForClient(zipFileName, request, 'audio'); } else { // 新素材:本地文件 if (isIntranet) { // 内网用户:直接访问 processedZipFileName = `/uploads/${zipFileName}`; } else { // 外网用户:通过API访问 processedZipFileName = `/api/uploads/${zipFileName}`; } } } const rewrittenMaterial = { ...formatted, // 图片:使用统一规则(内网用户直接访问内网MinIO,外网用户根据文件大小决定是否走代理) previewUrl: rewriteMediaUrlForClient(formatted.previewUrl, request, 'image'), videoCoverUrl: rewriteMediaUrlForClient(formatted.videoCoverUrl, request, 'image'), displayImages: formatted.displayImages?.map((img: string) => rewriteMediaUrlForClient(img, request, 'image') ), // 视频:使用统一规则(内网用户直接访问内网MinIO,外网用户通常>1MB会走代理) videoPreview540Url: rewriteMediaUrlForClient(formatted.videoPreview540Url, request, 'video'), videoPreviewClipUrl: rewriteMediaUrlForClient(formatted.videoPreviewClipUrl, request, 'video'), videoSourceUrl: rewriteMediaUrlForClient(formatted.videoSourceUrl, request, 'video'), // 音频文件:特殊处理 zipFileName: processedZipFileName, assets: formattedAssets, }; return applyVideoPreviewPolicy(rewrittenMaterial, new Date(), { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) }); }); return NextResponse.json({ materials: formattedMaterials, hasMore, currentPage: page, ...(totalCount != null ? { total: totalCount, totalCount, totalPages, } : {}), }); } catch (error) { if (error instanceof MaterialQueryError) { return NextResponse.json( { error: error.message, code: error.code }, { status: error.status }, ); } console.error('Error fetching materials:', error); return NextResponse.json( { error: '获取素材列表失败' }, { status: 500 } ); } } // DELETE - 删除素材 export async function DELETE(request: NextRequest) { try { const session = await getServerSession(authOptions); // 权限检查 if (!session?.user || !(session.user as any).id) { return NextResponse.json( { error: '未登录' }, { status: 401 } ); } const userRole = (session.user as any).role; if (!isSystemAdmin(userRole)) { return NextResponse.json( { error: '无权限' }, { status: 403 } ); } const { searchParams } = new URL(request.url); const id = searchParams.get('id'); const permanent = searchParams.get('permanent') === 'true'; if (!id) { return NextResponse.json( { error: '缺少素材ID' }, { status: 400 } ); } // 查询素材 const material = await prisma.material.findUnique({ where: { id }, }); if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } ); } const userId = (session.user as any).id; // 软删除 if (!permanent) { const deletedAt = new Date(); // 注意:软删除时,文件仍然保留,以便恢复时可以重新使用 // 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时) await prisma.$transaction([ prisma.material.update({ where: { id }, data: { deletedAt }, }), prisma.materialSku.updateMany({ where: { materialId: id, deletedAt: null, }, data: { deletedAt }, }), ]); // 记录日志 await createMaterialLog({ materialId: id, userId, action: 'DELETE', changes: { materialSnapshot: { oldValue: null, newValue: { id: material.id, productModel: material.productModel, materialType: material.materialType, }, }, deletedAt: { oldValue: material.deletedAt ? material.deletedAt.toISOString() : null, newValue: deletedAt.toISOString(), }, deleteMode: { oldValue: material.deletedAt ? 'deleted' : 'active', newValue: 'soft', }, }, }); return NextResponse.json({ message: '删除成功(可恢复),文件已保留' }); } // 永久删除 { return NextResponse.json({ error: '永久删除已暂停:请先使用可恢复删除;待持久化清理任务上线后再开放物理删除', code: 'PERMANENT_DELETE_DISABLED', }, { status: 409 }); } } catch (error: any) { // 缺少软删除列时必须失败关闭;绝不能悄悄退化成不可恢复的物理删除。 if (error?.meta?.column?.includes('deletedAt')) { console.error('[Materials API] deletedAt field is unavailable; refusing destructive fallback'); return NextResponse.json({ error: '当前数据库未完成软删除结构升级,已拒绝不可恢复删除', code: 'SOFT_DELETE_SCHEMA_REQUIRED', }, { status: 503 }); } console.error('Error deleting material:', error); return NextResponse.json( { error: '删除失败' }, { status: 500 } ); } }