2026-05-07 16:37:22 +08:00

1222 lines
43 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 { 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<string, string[]> = {
// 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<string>();
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<string>();
const favoritedSet = new Set<string>();
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<string, string | null>();
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<string, { mainPreview?: string; skuPreview?: string; main?: string; sku?: string }>();
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 }
);
}
}