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

304 lines
11 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 { rewriteMaterialStorageUrlsForClient, rewriteMediaUrlForClient } from '@/lib/url-rewrite';
import { detectIsIntranet } from '@/lib/network-detection';
import { canUserDownloadSource, isSuperAdmin } from '@/lib/department-permission';
import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
// 解析 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/share] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
function buildSourceFileUrl(material: any, isIntranet: boolean): string | null {
const zipFilePath = String(material?.zipFilePath || '').trim()
const zipFileName = String(material?.zipFileName || '').trim()
const rawSource = zipFilePath || zipFileName
if (!rawSource) {
return null
}
if (rawSource.startsWith('http://') || rawSource.startsWith('https://')) {
return rawSource
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
const normalizedUploadDir = uploadDir.replace(/\\/g, '/').replace(/\/+$/, '')
let sourceRelativePath = rawSource.replace(/\\/g, '/').replace(/^\/+/, '')
const prefixCandidates = Array.from(
new Set([
normalizedUploadDir,
normalizedUploadDir.replace(/^\.\//, ''),
normalizedUploadDir.replace(/^\//, ''),
'uploads',
].filter(Boolean)),
)
for (const prefix of prefixCandidates) {
const normalizedPrefix = prefix.replace(/\/+$/, '')
if (sourceRelativePath.startsWith(`${normalizedPrefix}/`)) {
sourceRelativePath = sourceRelativePath.slice(normalizedPrefix.length + 1)
break
}
}
return isIntranet ? `/uploads/${sourceRelativePath}` : `/api/uploads/${sourceRelativePath}`
}
// GET /api/materials/share - 获取分享素材信息(无需登录,但登录用户会返回点赞和收藏状态)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
// 获取当前登录用户(如果有)
const session = await getServerSession(authOptions);
const userId = session?.user ? (session.user as any).id : undefined;
const material = await prisma.material.findUnique({
where: { id },
select: {
id: true,
previewUrl: true,
productModel: true,
reportTitle: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
authorName: true,
zipFileName: true,
zipFilePath: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
userId: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskNo: true,
taskStatus: true,
taskFetchedAt: true,
isFullSet: true,
} as any,
});
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 如果是任务-详情或任务-3D原图等类型,查询资产列表
let assets: any[] = [];
let rawAssetsForFullSet: Array<{ assetType: string | null }> | undefined;
if (needsAssetsList(material.materialType as unknown as string)) {
const rawAssets = await prisma.materialAsset.findMany({
where: {
materialId: id,
},
orderBy: { sortOrder: 'asc' },
select: {
id: true,
assetType: true,
fileUrl: true,
fileName: true,
fileSize: true,
sortOrder: true,
},
});
rawAssetsForFullSet = rawAssets;
// 添加详细日志
console.log(`[Share API] ========== 查询资产 ==========`);
console.log(`[Share API] MaterialId: ${id}`);
console.log(`[Share API] Total assets: ${rawAssets.length}`);
rawAssets.forEach((asset: any, index: number) => {
console.log(`[Share API] Asset ${index}: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
});
// 检查是否有重复的fileUrl
const urlCounts: { [key: string]: { count: number; types: string[] } } = {};
rawAssets.forEach((asset: any) => {
if (asset.fileUrl) {
if (!urlCounts[asset.fileUrl]) {
urlCounts[asset.fileUrl] = { count: 0, types: [] };
}
urlCounts[asset.fileUrl].count++;
urlCounts[asset.fileUrl].types.push(asset.assetType);
}
});
const duplicateUrls = Object.entries(urlCounts).filter(([url, info]) => info.count > 1);
if (duplicateUrls.length > 0) {
console.error(`[Share API] ⚠️ WARNING: Found duplicate fileUrls!`);
duplicateUrls.forEach(([url, info]) => {
console.error(` URL: ${url}`);
console.error(` Used by ${info.count} assets with types: ${info.types.join(', ')}`);
});
}
console.log(`[Share API] ====================================`);
// 将 BigInt 转换为字符串
assets = rawAssets.map(asset => ({
...asset,
fileKind: getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl),
fileSize: asset.fileSize.toString(),
}));
}
// 如果用户已登录,检查点赞和收藏状态
let isLiked = false;
let isFavorited = false;
if (userId) {
const [like, favorite] = await Promise.all([
prisma.materialLike.findFirst({
where: {
materialId: id,
userId,
},
}),
prisma.materialFavorite.findFirst({
where: {
materialId: id,
userId,
},
}),
]);
isLiked = !!like;
isFavorited = !!favorite;
}
// 任务-详情:检查 PSD 下载权限(参考"详情模板"类型的权限)
// 任务-3D原图等:检查 ZIP 下载权限(参考"详情模板"类型的权限)
let canDownloadSource = false;
if ((isProductPackageType(material.materialType as unknown as string) || is3DOriginalType(material.materialType as unknown as string)) && userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
department: true,
managedDepartment: true,
},
});
if (user) {
// 任务-详情的 PSD 下载权限和任务-3D原图等的 ZIP 下载权限都参考"详情模板"类型的 canDownloadSource 权限
canDownloadSource = await canUserDownloadSource(
user.department,
user.role,
user.managedDepartment,
'详情模板', // 使用"详情模板"类型检查权限
userId
);
}
}
const response: any = {
...material,
reportTitle: (material as any).reportTitle || null,
isAiGenerated: Boolean((material as any).isAiGenerated),
taskDetailSourceType: (material as any).taskDetailSourceType || null,
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages || null),
isLiked,
isFavorited,
assets: needsAssetsList(material.materialType as unknown as string) ? assets : undefined,
canDownloadSource: needsAssetsList(material.materialType as unknown as string) ? canDownloadSource : undefined,
isFullSet: deriveTaskDetailIsFullSet(
material.materialType as unknown as string,
rawAssetsForFullSet,
Boolean((material as any).isFullSet),
),
};
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
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,
sourceFileUrl: buildSourceFileUrl(response, isIntranet),
// 图片:使用统一规则(小文件直接访问,大文件走代理)
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,
// 任务-详情和任务-3D原图等:重写 assets 中的 fileUrl
assets: response.assets?.map((asset: any) => ({
...asset,
fileUrl: rewriteMediaUrlForClient(
asset.fileUrl,
request,
asset.fileKind === 'image' ? 'image' : 'source',
),
})),
};
return NextResponse.json(rewrittenResponse);
} catch (error) {
console.error('Error fetching material for share:', error);
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
}
}