860 lines
27 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 { deleteFromS3, isS3Url } from '@/lib/s3';
import { createMaterialLog } from '@/lib/material-log';
import { normalizeUrl, normalizeMaterial } from '@/lib/utils';
import {
getUserViewableMaterialTypes,
isShareLinkAccess,
} from '@/lib/department-permission';
// 解析 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[]> = {
'3D场景': ['3D场景', '3DMAX'],
'3DMAX': ['3D场景', '3DMAX'],
'详情页': ['详情页', 'PSD'],
'PSD': ['详情页', 'PSD'],
'矢量文件': ['矢量文件', '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,
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) {
const userId = session.user.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];
}
}
const normalizedMaterial = normalizeMaterial({
...material,
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,
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,
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,
},
},
} 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,
},
},
} 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) {
const userId = session.user.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 formattedMaterials = materials.map(material => {
// 处理previewUrl的fallback逻辑(与反编译文件一致)
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
if (!previewUrl && material.displayImages) {
const displayImagesArray = parseDisplayImages(material.displayImages);
if (displayImagesArray.length > 0) {
previewUrl = displayImagesArray[0];
}
}
const formatted = {
...material,
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,
};
return normalizeMaterial(formatted);
});
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 || session.user.role !== 'ADMIN') {
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.id;
// 软删除
if (!permanent) {
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 filesToDelete: string[] = [];
if ((material as any).zipFilePath) {
filesToDelete.push((material as any).zipFilePath);
}
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== (material as any).zipFilePath) {
filesToDelete.push((material as any).videoSourceUrl);
}
if ((material as any).previewUrl) {
filesToDelete.push((material as any).previewUrl);
}
if ((material as any).videoPreview540Url) {
filesToDelete.push((material as any).videoPreview540Url);
}
if ((material as any).videoPreviewClipUrl) {
filesToDelete.push((material as any).videoPreviewClipUrl);
}
if ((material as any).videoCoverUrl) {
filesToDelete.push((material as any).videoCoverUrl);
}
if (Array.isArray((material as any).displayImages) && (material as any).displayImages.length > 0) {
filesToDelete.push(...(material as any).displayImages.filter((img: string) => img && img.trim()));
}
// 去重
const uniqueFiles = Array.from(new Set(filesToDelete.filter(f => f && f.trim())));
// 删除文件
let deletedCount = 0;
let failedCount = 0;
for (const filePath of uniqueFiles) {
try {
if (isS3Url(filePath)) {
await deleteFromS3(filePath);
deletedCount++;
console.log(`[Delete] Deleted S3 file: ${filePath}`);
} else {
// 本地文件
const { unlink } = await import('fs/promises');
try {
await unlink(filePath);
deletedCount++;
console.log(`[Delete] Deleted local file: ${filePath}`);
} catch (err: any) {
if (err.code !== 'ENOENT') {
console.error(`[Delete] Failed to delete local file: ${filePath}`, err);
failedCount++;
}
}
}
} catch (err) {
console.error(`[Delete] Failed to delete file: ${filePath}`, err);
failedCount++;
}
}
console.log(`[Delete] Material ${id} files cleanup: ${deletedCount} deleted, ${failedCount} failed`);
// 从数据库删除
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 }
);
}
}