367 lines
13 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 {
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 });
}
}