984 lines
34 KiB
TypeScript
984 lines
34 KiB
TypeScript
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 { 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';
|
||
|
||
// 解析 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 [];
|
||
}
|
||
|
||
// 素材类型映射(兼容旧数据)
|
||
// 用于查询时兼容老类型名称,确保用户可以查询到迁移前的素材
|
||
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,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
platformName: true,
|
||
productCategoryName: true,
|
||
materialType: 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,
|
||
},
|
||
},
|
||
} 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 || '';
|
||
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 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,
|
||
});
|
||
|
||
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');
|
||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||
|
||
// 构建查询条件
|
||
const where: any = {};
|
||
|
||
// 软删除过滤
|
||
if (searchParams.get('showDeleted') === 'true' || searchParams.get('deleted') === 'true') {
|
||
where.deletedAt = { not: null };
|
||
} else {
|
||
where.deletedAt = null;
|
||
}
|
||
|
||
// 搜索条件
|
||
if (search) {
|
||
where.OR = [
|
||
{ productModel: { contains: search, mode: 'insensitive' } },
|
||
{ categoryName: { contains: search, mode: 'insensitive' } },
|
||
{ sceneName: { contains: search, mode: 'insensitive' } },
|
||
{ authorName: { contains: search, mode: 'insensitive' } },
|
||
];
|
||
}
|
||
|
||
// 分类筛选
|
||
if (category) {
|
||
where.categoryName = category;
|
||
}
|
||
|
||
if (scene) {
|
||
where.sceneName = scene;
|
||
}
|
||
|
||
// 素材类型筛选(支持映射)
|
||
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;
|
||
}
|
||
|
||
// 月份筛选
|
||
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 { isSuperAdmin } = await import('@/lib/department-permission');
|
||
if (!(await isSuperAdmin(userId))) {
|
||
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);
|
||
|
||
// 排序
|
||
let orderBy: any = { createdAt: sortOrder };
|
||
if (sortBy === 'downloadCount') {
|
||
orderBy = { downloadCount: sortOrder };
|
||
}
|
||
|
||
// 查询素材列表
|
||
let materials: any[];
|
||
const queryWhere = where;
|
||
|
||
try {
|
||
const query: any = {
|
||
where: queryWhere,
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
previewUrl: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
platformName: true,
|
||
productCategoryName: true,
|
||
materialType: 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,
|
||
};
|
||
|
||
// comprehensive排序时,不设置orderBy、skip、take,获取所有数据后在内存中排序
|
||
if (sortBy !== 'comprehensive') {
|
||
query.orderBy = orderBy;
|
||
query.skip = (page - 1) * pageSize;
|
||
query.take = pageSize;
|
||
}
|
||
|
||
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,
|
||
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,
|
||
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,
|
||
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'] },
|
||
},
|
||
select: {
|
||
materialId: true,
|
||
assetType: true,
|
||
fileUrl: true,
|
||
sortOrder: true,
|
||
},
|
||
orderBy: [
|
||
{ materialId: 'asc' },
|
||
{ assetType: 'asc' },
|
||
{ sortOrder: 'asc' },
|
||
],
|
||
});
|
||
|
||
// 按 materialId 分组,优先使用主图,没有主图则使用SKU图
|
||
const assetsByMaterial = new Map<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' && !materialAssets.main) {
|
||
materialAssets.main = asset.fileUrl;
|
||
} else if (asset.assetType === 'sku' && !materialAssets.sku && !materialAssets.main) {
|
||
materialAssets.sku = asset.fileUrl;
|
||
}
|
||
}
|
||
|
||
// 构建封面图映射
|
||
assetsByMaterial.forEach((materialAssets, materialId) => {
|
||
const coverUrl = 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 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,
|
||
};
|
||
// 应用统一规则:内网用户直接访问,外网用户根据文件大小智能选择
|
||
// 注意:不要先调用 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,
|
||
};
|
||
|
||
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) {
|
||
// 注意:软删除时,文件仍然保留,以便恢复时可以重新使用
|
||
// 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时)
|
||
await prisma.material.update({
|
||
where: { id },
|
||
data: { deletedAt: new Date() },
|
||
});
|
||
|
||
// 记录日志
|
||
await createMaterialLog({
|
||
materialId: id,
|
||
userId,
|
||
action: 'DELETE',
|
||
changes: {
|
||
permanent: {
|
||
oldValue: false,
|
||
newValue: false,
|
||
},
|
||
},
|
||
});
|
||
|
||
return NextResponse.json({ message: '删除成功(可恢复),文件已保留' });
|
||
}
|
||
|
||
// 永久删除
|
||
{
|
||
// 记录日志
|
||
await createMaterialLog({
|
||
materialId: id,
|
||
userId,
|
||
action: 'DELETE',
|
||
changes: {
|
||
permanent: {
|
||
oldValue: false,
|
||
newValue: true,
|
||
},
|
||
},
|
||
});
|
||
|
||
// 使用统一的素材文件收集函数(根据素材类型自动收集所有文件)
|
||
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 }
|
||
);
|
||
}
|
||
}
|