387 lines
14 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 path from 'path';
import { rewriteMediaUrlForClient } from '@/lib/url-rewrite';
import {
canUserDownloadMaterial,
canUserDownloadSource,
hasSystemAdminPermission,
isSystemAdmin,
} 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';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy';
// 解析 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 getSourceDisplayName(value: unknown): string {
const rawValue = String(value || '').trim()
if (!rawValue) {
return ''
}
try {
if (/^https?:\/\//i.test(rawValue)) {
return decodeURIComponent(path.posix.basename(new URL(rawValue).pathname))
}
} catch {
return ''
}
return path.basename(rawValue.replace(/\\/g, '/'))
}
function buildProtectedUploadUrl(rawSourceValue: unknown): string | null {
const rawSource = String(rawSourceValue || '').trim()
if (!rawSource) {
return null
}
if (/^https?:\/\//i.test(rawSource)) {
return null
}
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 sourceRelativePath ? `/api/uploads/${sourceRelativePath}` : null
}
function buildSourceFileUrl(materialId: string): string {
return `/api/materials/download?id=${encodeURIComponent(materialId)}`
}
function isPreviewAssetType(assetType: unknown): boolean {
const normalized = String(assetType || '').trim()
return (
['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image'].includes(normalized) ||
normalized.startsWith('standard-model-white-bg-view:') ||
normalized.startsWith('standard-model-white-bg-other:')
)
}
// 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.findFirst({
where: { id, deletedAt: null },
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,
dingtalkApprovalNo: true,
taskStatus: true,
taskFetchedAt: true,
isFullSet: true,
} as any,
});
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
const videoPreviewPolicy = getVideoPreviewPolicyFields(
(material as any).materialType,
(material as any).createdAt,
);
if (videoPreviewPolicy.videoPreviewBlocked) {
return NextResponse.json(
{
error: '视频类素材上传后满 15 天才能查看详情',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: videoPreviewPolicy.videoPreviewUnavailableMessage,
availableAt: videoPreviewPolicy.videoPreviewAvailableAt,
remainingDays: videoPreviewPolicy.videoPreviewRemainingDays,
},
{ status: 403 },
);
}
// 如果是任务-详情或任务-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;
let currentUser: {
role: string;
department: string | null;
managedDepartment: string | null;
canDownload: boolean;
isSuperAdmin: boolean;
} | null = null;
if (userId) {
const [like, favorite, user] = await Promise.all([
prisma.materialLike.findFirst({
where: {
materialId: id,
userId,
},
}),
prisma.materialFavorite.findFirst({
where: {
materialId: id,
userId,
},
}),
prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
department: true,
managedDepartment: true,
canDownload: true,
isSuperAdmin: true,
},
}),
]);
isLiked = !!like;
isFavorited = !!favorite;
currentUser = user;
}
// 任务-详情:检查 PSD 下载权限(参考"详情模板"类型的权限)
// 任务-3D原图等:检查 ZIP 下载权限(参考"详情模板"类型的权限)
let canAccessSource = false;
if (userId && currentUser) {
if (hasSystemAdminPermission(currentUser.role, Boolean(currentUser.isSuperAdmin))) {
canAccessSource = true;
} else if (currentUser.canDownload) {
const canDownloadMaterial = await canUserDownloadMaterial(
currentUser.department,
currentUser.role,
currentUser.managedDepartment,
material.materialType as unknown as string,
userId
);
const requiresSourcePermission =
isProductPackageType(material.materialType as unknown as string) ||
is3DOriginalType(material.materialType as unknown as string);
const hasSourcePermission = requiresSourcePermission
? await canUserDownloadSource(
currentUser.department,
currentUser.role,
currentUser.managedDepartment,
'详情模板',
userId
)
: true;
canAccessSource = canDownloadMaterial && hasSourcePermission;
}
}
const capabilities = {
canFavorite: Boolean(userId && currentUser),
canEdit: Boolean(
userId &&
currentUser &&
(
material.userId === userId ||
hasSystemAdminPermission(currentUser.role, Boolean(currentUser.isSuperAdmin))
)
),
// 与 DELETE /api/materials 当前契约保持一致:仅系统管理员角色可删除。
canDelete: Boolean(currentUser && isSystemAdmin(currentUser.role)),
};
const response: any = {
...material,
zipFileName: getSourceDisplayName(material.zipFileName || material.zipFilePath),
zipFilePath: '',
videoSourceUrl: canAccessSource ? material.videoSourceUrl : null,
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,
capabilities,
assets: needsAssetsList(material.materialType as unknown as string) ? assets : undefined,
canDownloadSource: needsAssetsList(material.materialType as unknown as string) ? canAccessSource : undefined,
isFullSet: deriveTaskDetailIsFullSet(
material.materialType as unknown as string,
rawAssetsForFullSet,
Boolean((material as any).isFullSet),
),
};
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
// 处理音频文件的zipFileName(特殊处理)
let processedZipFileName = response.zipFileName;
if (response.zipFileName && response.materialType === '音乐' && canAccessSource) {
processedZipFileName = buildSourceFileUrl(response.id);
}
const rewrittenResponse = {
...response,
sourceFileUrl: canAccessSource ? buildSourceFileUrl(response.id) : null,
// 图片:使用统一规则(小文件直接访问,大文件走代理)
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: canAccessSource ? buildProtectedUploadUrl(response.videoSourceUrl) : null,
// 音频文件:特殊处理
zipFileName: processedZipFileName,
// 任务-详情和任务-3D原图等:重写 assets 中的 fileUrl
assets: response.assets?.map((asset: any) => {
const isPreview = isPreviewAssetType(asset.assetType)
return {
...asset,
fileUrl: isPreview
? rewriteMediaUrlForClient(asset.fileUrl, request, 'image')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
}
}),
};
return NextResponse.json(rewrittenResponse);
} catch (error) {
console.error('Error fetching material for share:', error);
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
}
}