387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
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 });
|
||
}
|
||
}
|