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 }); } }