367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { authOptions } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
import {
|
||
canUserViewMaterial,
|
||
isShareLinkAccess,
|
||
} from '@/lib/department-permission';
|
||
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation';
|
||
|
||
// 解析 displayImages 字段
|
||
function parseDisplayImages(displayImages: any): string[] {
|
||
if (!displayImages) return [];
|
||
|
||
if (Array.isArray(displayImages)) {
|
||
return displayImages.filter((img: any) => 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: any) => typeof img === 'string' && img.trim().length > 0);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[materials/[id]] Failed to parse displayImages JSON, fallback to single value:', e);
|
||
}
|
||
return displayImages.trim() ? [displayImages] : [];
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
// GET /api/materials/[id] - 获取单个素材详情
|
||
export async function GET(
|
||
request: NextRequest,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
try {
|
||
let material;
|
||
|
||
try {
|
||
material = await prisma.material.findFirst({
|
||
where: {
|
||
id: params.id,
|
||
deletedAt: null,
|
||
},
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
platformName: true,
|
||
productCategoryName: true,
|
||
materialType: true,
|
||
renderer: true,
|
||
authorName: true,
|
||
previewUrl: true,
|
||
zipFileName: true,
|
||
zipFileSize: true,
|
||
downloadCount: true,
|
||
likeCount: true,
|
||
displayImages: true,
|
||
createdAt: true,
|
||
videoSourceUrl: true,
|
||
videoPreview540Url: true,
|
||
videoPreviewClipUrl: true,
|
||
videoCoverUrl: true,
|
||
videoDuration: true,
|
||
videoProcessingStatus: true,
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
},
|
||
},
|
||
} as any,
|
||
});
|
||
} catch (error: any) {
|
||
// 兼容旧版本数据库(没有 deletedAt 字段)
|
||
if (error?.meta?.column?.includes('deletedAt')) {
|
||
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
|
||
material = await prisma.material.findFirst({
|
||
where: {
|
||
id: params.id,
|
||
},
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
platformName: true,
|
||
productCategoryName: true,
|
||
materialType: true,
|
||
renderer: true,
|
||
authorName: true,
|
||
previewUrl: true,
|
||
zipFileName: true,
|
||
zipFileSize: true,
|
||
downloadCount: true,
|
||
likeCount: true,
|
||
displayImages: true,
|
||
createdAt: true,
|
||
videoSourceUrl: true,
|
||
videoPreview540Url: true,
|
||
videoPreviewClipUrl: true,
|
||
videoCoverUrl: true,
|
||
videoDuration: true,
|
||
videoProcessingStatus: true,
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
},
|
||
},
|
||
} as any,
|
||
});
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (!material) {
|
||
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
|
||
}
|
||
|
||
// 权限检查:如果不是分享链接访问,检查查看权限
|
||
const isShare = isShareLinkAccess(request);
|
||
if (!isShare) {
|
||
const session = await getServerSession(authOptions);
|
||
if (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 canView = await canUserViewMaterial(
|
||
userDepartment,
|
||
userRole,
|
||
managedDepartment,
|
||
(material as any).materialType,
|
||
userId
|
||
);
|
||
|
||
if (!canView) {
|
||
return NextResponse.json(
|
||
{ error: '无权限查看该素材' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
// 未登录用户且不是分享链接,不允许查看
|
||
return NextResponse.json(
|
||
{ error: '请先登录' },
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 类型断言,确保 user 字段存在
|
||
const materialWithUser = material as any;
|
||
|
||
// 优先使用用户的真实姓名或用户名,如果没有才使用 authorName
|
||
const displayAuthorName = materialWithUser.user?.realName || materialWithUser.user?.username || materialWithUser.authorName;
|
||
|
||
const response = {
|
||
id: materialWithUser.id,
|
||
userId: materialWithUser.userId,
|
||
productModel: materialWithUser.productModel,
|
||
categoryName: materialWithUser.categoryName,
|
||
sceneName: materialWithUser.sceneName,
|
||
platformName: materialWithUser.platformName || '',
|
||
productCategoryName: materialWithUser.productCategoryName || '',
|
||
materialType: materialWithUser.materialType,
|
||
renderer: materialWithUser.renderer,
|
||
authorName: displayAuthorName, // 使用处理后的作者名
|
||
previewUrl: materialWithUser.previewUrl,
|
||
zipFileName: materialWithUser.zipFileName,
|
||
zipFileSize: materialWithUser.zipFileSize.toString(),
|
||
downloadCount: materialWithUser.downloadCount,
|
||
likeCount: materialWithUser.likeCount,
|
||
displayImages: parseDisplayImages(materialWithUser.displayImages || null),
|
||
createdAt: materialWithUser.createdAt,
|
||
videoSourceUrl: materialWithUser.videoSourceUrl || null,
|
||
videoPreview540Url: materialWithUser.videoPreview540Url || null,
|
||
videoPreviewClipUrl: materialWithUser.videoPreviewClipUrl || null,
|
||
videoCoverUrl: materialWithUser.videoCoverUrl || null,
|
||
videoDuration: materialWithUser.videoDuration || null,
|
||
videoProcessingStatus: materialWithUser.videoProcessingStatus || null,
|
||
user: {
|
||
username: materialWithUser.user?.username || '',
|
||
},
|
||
};
|
||
|
||
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
|
||
const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite');
|
||
const { detectIsIntranet } = await import('@/lib/network-detection');
|
||
|
||
const isIntranet = detectIsIntranet(request);
|
||
|
||
// 处理音频文件的zipFileName(特殊处理)
|
||
let processedZipFileName = response.zipFileName;
|
||
if (response.zipFileName && response.materialType === '音乐') {
|
||
const zipFileName = response.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 rewrittenResponse = {
|
||
...response,
|
||
// 图片:使用统一规则(小文件直接访问,大文件走代理)
|
||
previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'),
|
||
videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'),
|
||
displayImages: response.displayImages?.map((img: string) =>
|
||
rewriteMediaUrlForClient(img, request, 'image')
|
||
),
|
||
// 视频:使用统一规则(通常>1MB,会走代理)
|
||
videoPreview540Url: rewriteMediaUrlForClient(response.videoPreview540Url, request, 'video'),
|
||
videoPreviewClipUrl: rewriteMediaUrlForClient(response.videoPreviewClipUrl, request, 'video'),
|
||
videoSourceUrl: rewriteMediaUrlForClient(response.videoSourceUrl, request, 'video'),
|
||
// 音频文件:特殊处理
|
||
zipFileName: processedZipFileName,
|
||
};
|
||
|
||
return NextResponse.json(rewrittenResponse);
|
||
} catch (error) {
|
||
console.error('获取素材失败:', error);
|
||
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
// PUT /api/materials/[id] - 更新素材信息
|
||
export async function PUT(
|
||
request: NextRequest,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
try {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session?.user) {
|
||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||
}
|
||
|
||
const {
|
||
productModel,
|
||
categoryName,
|
||
sceneName,
|
||
platformName,
|
||
productCategoryName,
|
||
materialType,
|
||
renderer,
|
||
authorName
|
||
} = await request.json();
|
||
|
||
// 使用统一的验证逻辑
|
||
// 基础必填字段验证
|
||
if (!productModel || !materialType || !authorName) {
|
||
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 });
|
||
}
|
||
|
||
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
|
||
return NextResponse.json({ error: '请填写所有必填字段(店铺、场景)' }, { status: 400 });
|
||
}
|
||
|
||
// 标准模型必须选择品类
|
||
if (requiresProductCategoryName(materialType) && !productCategoryName) {
|
||
return NextResponse.json({ error: '标准模型类型必须选择品类' }, { status: 400 });
|
||
}
|
||
|
||
// renderer 只在3D场景类型时必填
|
||
if (requiresRenderer(materialType) && !renderer) {
|
||
return NextResponse.json({ error: '3D场景类型必须指定渲染器' }, { status: 400 });
|
||
}
|
||
|
||
// 检查素材是否存在(排除已删除的素材)
|
||
const material = await prisma.material.findFirst({
|
||
where: { id: params.id, deletedAt: null },
|
||
});
|
||
|
||
if (!material) {
|
||
return NextResponse.json({ error: '素材不存在或已被删除' }, { status: 404 });
|
||
}
|
||
|
||
// 检查权限:素材所有者、普通管理员或超级管理员都可以编辑
|
||
const user = session.user as any;
|
||
const userId = user.id;
|
||
const userRole = user.role;
|
||
|
||
// 检查是否是超级管理员
|
||
const { isSuperAdmin } = await import('@/lib/department-permission');
|
||
const isUserSuperAdmin = await isSuperAdmin(userId);
|
||
|
||
if (material.userId !== userId && userRole !== 'ADMIN' && !isUserSuperAdmin) {
|
||
return NextResponse.json({ error: '您没有权限编辑此素材' }, { status: 403 });
|
||
}
|
||
|
||
// 更新素材(包含所有字段,确保数据不丢失)
|
||
const updateData: any = {
|
||
productModel,
|
||
// 使用统一的验证逻辑判断字段是否需要更新
|
||
categoryName: requiresCategoryAndScene(materialType) ? categoryName : (categoryName !== undefined ? categoryName : material.categoryName),
|
||
sceneName: requiresCategoryAndScene(materialType) ? sceneName : (sceneName !== undefined ? sceneName : material.sceneName),
|
||
platformName: platformName !== undefined && platformName !== null ? platformName : (material.platformName || ''),
|
||
materialType,
|
||
authorName,
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
// 标准模型必须更新 productCategoryName
|
||
if (requiresProductCategoryName(materialType)) {
|
||
updateData.productCategoryName = productCategoryName || material.productCategoryName;
|
||
} else {
|
||
updateData.productCategoryName = productCategoryName !== undefined ? productCategoryName : material.productCategoryName;
|
||
}
|
||
|
||
// 使用统一的验证逻辑更新renderer
|
||
if (requiresRenderer(materialType)) {
|
||
// 3D场景类型:必须提供renderer,如果没有提供则使用默认值或保留原值
|
||
updateData.renderer = renderer || material.renderer || getDefaultRenderer(materialType);
|
||
} else {
|
||
// 非3D场景类型,保持原renderer值(如果有)
|
||
updateData.renderer = material.renderer;
|
||
}
|
||
|
||
const updatedMaterial = await prisma.material.update({
|
||
where: { id: params.id },
|
||
data: updateData,
|
||
});
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: '素材信息更新成功',
|
||
material: {
|
||
id: updatedMaterial.id,
|
||
productModel: updatedMaterial.productModel,
|
||
categoryName: updatedMaterial.categoryName,
|
||
sceneName: updatedMaterial.sceneName,
|
||
platformName: updatedMaterial.platformName,
|
||
productCategoryName: updatedMaterial.productCategoryName,
|
||
materialType: updatedMaterial.materialType,
|
||
renderer: updatedMaterial.renderer,
|
||
authorName: updatedMaterial.authorName,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('更新素材失败:', error);
|
||
return NextResponse.json({ error: '更新素材失败' }, { status: 500 });
|
||
}
|
||
}
|