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 { deleteFromS3, isS3Url } from '@/lib/s3'; import { deleteFiles } from '@/lib/file-cleanup'; import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; import { createMaterialLog } from '@/lib/material-log'; import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'; import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; import { rewriteMaterialStorageUrlsForClient, rewriteMediaUrlForClient } from '@/lib/url-rewrite'; import { detectIsIntranet } from '@/lib/network-detection'; import { getUserViewableMaterialTypes, isShareLinkAccess, } from '@/lib/department-permission'; import { getMaterialCoverUrl } from '@/lib/product-package'; import { isProductPackageType } from '@/lib/material-type-mapper'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; // 🔒 R45修复:数据库查询限制常量(防止OOM) const MAX_PAGE_SIZE = 100; // 单页最大100条记录 const MAX_SEARCH_RESULTS = 1000; // 搜索辅助表最大返回1000条 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, }; } // 素材类型映射(兼容旧数据) const MATERIAL_TYPE_MAP: Record = { // 3DMAX → 3D场景 '3D场景': ['3D场景', '3DMAX'], '3DMAX': ['3D场景', '3DMAX'], // 产品全案 → 任务-详情 '任务-详情': ['任务-详情', '产品全案'], '产品全案': ['任务-详情', '产品全案'], // 详情页 → 详情模板(已迁移) '详情模板': ['详情模板', '详情页'], '详情页': ['详情模板', '详情页'], // 其他类型映射 '矢量文件': ['矢量文件', 'AI'], 'AI': ['矢量文件', 'AI'], 'C4D模型': ['C4D模型', 'C4D'], 'C4D': ['C4D模型', 'C4D'], '淘系-主图视频': ['淘系-主图视频', '主图视频'], '主图视频': ['淘系-主图视频', '主图视频'], '淘系-逛逛视频': ['淘系-逛逛视频', '种草视频'], '种草视频': ['淘系-逛逛视频', '种草视频'], }; // GET - 获取素材列表 export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions); const { searchParams } = new URL(request.url); // 单个素材查询(通过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, 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 }); } // 权限检查:如果不是分享链接访问,检查查看权限 const isShare = isShareLinkAccess(request); if (!isShare && session?.user) { 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); } } // 其他素材类型:使用 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 search = searchParams.get('search') || ''; const category = searchParams.get('category') || ''; const scene = searchParams.get('scene') || ''; const materialType = searchParams.get('materialType') || ''; const platform = searchParams.get('platform') || ''; const productCategory = searchParams.get('productCategory') || ''; const month = searchParams.get('month') || ''; 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: any = {}; // 软删除过滤 if (searchParams.get('showDeleted') === 'true' || searchParams.get('deleted') === 'true') { where.deletedAt = { not: null }; } else { where.deletedAt = null; } // 搜索条件(支持SPU型号、任务号、SKU、店铺、场景、作者) // 注意:SKU存储在关联表中,需要特殊处理 if (search) { // 先搜索SKU表,获取匹配的materialId列表 // 🔒 R45修复:限制搜索返回数量,防止OOM const skuMatches = await prisma.materialSku.findMany({ where: { skuName: { contains: search, mode: 'insensitive' }, deletedAt: null, }, select: { materialId: true }, take: MAX_SEARCH_RESULTS, // 最多返回1000条 }); const skuMaterialIds = skuMatches.map(s => s.materialId); // 搜索用户表(realName和username),获取匹配的userId列表 // 🔒 R45修复:限制搜索返回数量,防止OOM const userMatches = await prisma.user.findMany({ where: { OR: [ { realName: { contains: search, mode: 'insensitive' } }, { username: { contains: search, mode: 'insensitive' } }, ], }, select: { id: true }, take: MAX_SEARCH_RESULTS, // 最多返回1000条 }); const userIds = userMatches.map(u => u.id); // 构建OR条件:包含SKU匹配的素材ID和用户匹配的userId where.OR = [ { productModel: { contains: search, mode: 'insensitive' } }, { reportTitle: { contains: search, mode: 'insensitive' } }, { taskNo: { contains: search, mode: 'insensitive' } }, { categoryName: { contains: search, mode: 'insensitive' } }, { sceneName: { contains: search, mode: 'insensitive' } }, { authorName: { contains: search, mode: 'insensitive' } }, ]; // 如果有SKU匹配,添加到OR条件中 if (skuMaterialIds.length > 0) { where.OR.push({ id: { in: skuMaterialIds } }); } // 如果有用户匹配,添加到OR条件中 if (userIds.length > 0) { where.OR.push({ userId: { in: userIds } }); } } // 分类筛选(AND条件) if (category) { where.categoryName = category; } if (scene) { where.sceneName = scene; } // 素材类型筛选(支持映射)(AND条件) if (materialType) { const types = MATERIAL_TYPE_MAP[materialType] ?? [materialType]; if (types.length === 1) { where.materialType = types[0]; } else { where.materialType = { in: types }; } } if (platform) { where.platformName = platform; } if (productCategory) { where.productCategoryName = productCategory; } // 月份筛选(AND条件) if (month) { const [year, monthNum] = month.split('-'); const startDate = new Date(parseInt(year), parseInt(monthNum) - 1, 1); const endDate = new Date(parseInt(year), parseInt(monthNum), 1); where.createdAt = { gte: startDate, lt: endDate, }; } // 权限过滤:如果不是分享链接访问,且用户不是超级管理员,则过滤素材类型 const isShare = isShareLinkAccess(request); if (!isShare && session?.user) { 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 sessionIsSuperAdmin = (session.user as any).isSuperAdmin; // 优先使用 session 中的 isSuperAdmin,如果为 true 则直接跳过权限过滤 // 如果 session 中没有,则从数据库查询 const { isSuperAdmin } = await import('@/lib/department-permission'); const isUserSuperAdmin = sessionIsSuperAdmin === true || (userId && await isSuperAdmin(userId)); if (!isUserSuperAdmin) { const viewableTypes = await getUserViewableMaterialTypes( userDepartment, userRole, managedDepartment, userId ); // 如果用户没有查看权限的素材类型,添加过滤条件 if (viewableTypes.length > 0) { // 如果已经指定了materialType筛选,需要检查是否有权限 if (materialType) { const types = MATERIAL_TYPE_MAP[materialType] ?? [materialType]; const hasPermission = types.some((t) => viewableTypes.includes(t)); if (!hasPermission) { // 没有权限查看该类型,返回空结果 return NextResponse.json({ materials: [], totalPages: 0, totalCount: 0, currentPage: page, }); } } else { // 没有指定materialType,过滤掉没有权限的类型 // 需要处理类型映射 const allViewableTypes = new Set(); viewableTypes.forEach((type) => { allViewableTypes.add(type); // 添加映射的类型 if (MATERIAL_TYPE_MAP[type]) { MATERIAL_TYPE_MAP[type].forEach((mappedType) => { allViewableTypes.add(mappedType); }); } }); where.materialType = { in: Array.from(allViewableTypes) }; } } else { // 用户没有任何查看权限,返回空结果 return NextResponse.json({ materials: [], totalPages: 0, totalCount: 0, currentPage: page, }); } } } // 获取总数 let totalCount: number; 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; } } const 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, 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, }, }, // 包含 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, }, }, }, where: { assetType: { in: ['main-preview', 'sku-preview', 'detail-preview'] }, }, orderBy: { sortOrder: 'asc' }, }, }, }; // ⚠️ 安全修复:设置最大查询数量限制,防止OOM const MAX_QUERY_LIMIT = 5000; // 即使内存排序,最多查询5000条 if (sortBy !== 'comprehensive') { query.orderBy = orderBy; query.skip = (page - 1) * pageSize; query.take = pageSize; } 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, 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, 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, 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; } } // 批量查询点赞和收藏状态 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); } } } // 格式化返回数据 // 对于任务-详情类型,需要从 assets 中获取正确的预览图 const productPackageMaterialIds = materials .filter(m => isProductPackageType(m.materialType)) .map(m => m.id); // 批量获取 Product Package 素材的封面图(优化:使用单次查询) const coverUrlMap = new Map(); if (productPackageMaterialIds.length > 0) { try { // 批量查询所有 Product Package 素材的主图和SKU图 const assets = await prisma.materialAsset.findMany({ where: { materialId: { in: productPackageMaterialIds }, assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview'] }, }, select: { materialId: true, assetType: true, fileUrl: true, sortOrder: true, }, orderBy: [ { materialId: 'asc' }, { assetType: 'asc' }, { sortOrder: 'asc' }, ], }); // 按 materialId 分组,优先使用预览图(main-preview/sku-preview),没有预览图则使用原图(main/sku) 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; } } // 构建封面图映射:优先使用预览图,没有预览图才使用原图 assetsByMaterial.forEach((materialAssets, materialId) => { const coverUrl = materialAssets.mainPreview || materialAssets.skuPreview || 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 || ''; // 任务-详情类型:优先使用从 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 rewrittenMaterial; }); return NextResponse.json({ materials: formattedMaterials, totalPages, total: totalCount, // 添加total字段以兼容前端 totalCount, currentPage: page, }); } catch (error) { 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: '删除成功(可恢复),文件已保留' }); } // 永久删除 { // 确保下载日志表已应用“删除素材后保留日志”策略 await ensureMaterialDownloadLogsTable(); // 记录日志 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: 'PERMANENTLY_DELETED', }, deleteMode: { oldValue: material.deletedAt ? 'soft' : 'active', newValue: 'permanent', }, }, }); // 使用统一的素材文件收集函数(根据素材类型自动收集所有文件) const fileInfo = await collectMaterialFiles(id, material); const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]; console.log(`[Delete] Material ${id} (${(material as any).materialType}) file collection:`, { fileUrls: fileInfo.fileUrls.length, directories: fileInfo.directories.length, relatedRecords: fileInfo.relatedRecords, }); // 使用统一的文件删除工具批量删除 const result = await deleteFiles(filesToDelete); const deletedCount = result.deletedCount; const failedCount = result.failedCount; if (result.errors.length > 0) { console.error(`[Delete] Material ${id} files cleanup errors:`, result.errors); } console.log(`[Delete] Material ${id} files cleanup: ${deletedCount} deleted, ${failedCount} failed`); // 删除关联记录(MaterialAsset, MaterialSku 等) const relatedResult = await deleteMaterialRelatedRecords(id, (material as any).materialType); if (relatedResult.deletedAssets !== undefined || relatedResult.deletedSkus !== undefined) { console.log(`[Delete] Material ${id} related records deleted:`, relatedResult); } // 从数据库删除 await prisma.material.delete({ where: { id }, }); return NextResponse.json({ message: '永久删除成功', deletedFiles: deletedCount, failedFiles: failedCount, }); } } catch (error: any) { // 兼容旧版本数据库(没有deletedAt字段) if (error?.meta?.column?.includes('deletedAt')) { console.warn('[Materials API] deletedAt field not found, using hard delete'); const { searchParams } = new URL(request.url); const id = searchParams.get('id'); if (id) { try { await prisma.material.delete({ where: { id }, }); return NextResponse.json({ message: '删除成功' }); } catch (e) { console.error('Error deleting material:', e); return NextResponse.json( { error: '删除失败' }, { status: 500 } ); } } } console.error('Error deleting material:', error); return NextResponse.json( { error: '删除失败' }, { status: 500 } ); } }