416 lines
15 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 { canAccessMaterialPrivacy, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy';
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 { canManageMaterial } from '@/lib/material-authorization';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } 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,
...TASK_DETAIL_PRIVACY_SELECT,
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 });
}
if (!(await canAccessMaterialPrivacy(material as any, userId))) {
return NextResponse.json({ error: '请登录有权限的账号查看该私密素材' }, { status: userId ? 403 : 401, headers: { 'Cache-Control': 'private, no-store' } });
}
const videoPreviewPolicy = getVideoPreviewPolicyFields(
(material as any).materialType,
(material as any).createdAt,
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) },
);
if (videoPreviewPolicy.videoPreviewBlocked) {
return NextResponse.json(
{
error: '视频类素材上传后满 15 天才能查看详情',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: videoPreviewPolicy.videoPreviewUnavailableMessage,
availableAt: videoPreviewPolicy.videoPreviewAvailableAt,
remainingDays: videoPreviewPolicy.videoPreviewRemainingDays,
},
{
status: 403,
headers: {
'Cache-Control': 'private, no-store, max-age=0',
},
},
);
}
// 如果是任务-详情或任务-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 和任务原图 ZIP 按各自素材类型检查源文件权限。
let canAccessSource = false;
if (userId && currentUser) {
if (hasSystemAdminPermission(currentUser.role, Boolean(currentUser.isSuperAdmin))) {
canAccessSource = true;
} else if (currentUser.canDownload) {
const canDownloadMaterial = isPrivateTaskDetail(material as any) || 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,
material.materialType as unknown as string,
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)),
};
// 任务-详情单图编辑(替换/改名/删除)与元数据编辑权限口径不同:
// 走 canManageMaterial,托管部门的成员也能维护本部门素材。
let canManageTaskDetailAssets = false;
if (userId && currentUser && isProductPackageType(material.materialType as unknown as string)) {
canManageTaskDetailAssets = await canManageMaterial(
{ id: userId, role: currentUser.role, isSuperAdmin: currentUser.isSuperAdmin },
{ id: material.id, userId: material.userId } as any,
);
// 权限组授权线:组对「任务-详情」勾选了编辑图片的成员同样放行
if (!canManageTaskDetailAssets) {
const { canUserEditTaskDetailAssets } = await import('@/lib/user-permission-inherit');
canManageTaskDetailAssets = await canUserEditTaskDetailAssets(userId);
}
}
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,
taskDetailDemandType: (material as any).taskDetailDemandType || null,
taskDetailPrivate: isPrivateTaskDetail(material as any),
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages || null),
isLiked,
isFavorited,
capabilities,
canManageTaskDetailAssets,
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, { headers: { 'Cache-Control': 'private, no-store' } });
} catch (error) {
console.error('Error fetching material for share:', error);
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
}
}