562 lines
20 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 path from 'path';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import {
canUserDownloadMaterial,
canUserDownloadSource,
canUserViewMaterial,
isSystemAdmin,
isShareLinkAccess,
} from '@/lib/department-permission';
import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { isWebFileLike } from '@/lib/web-file';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy';
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
// 解析 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/[id]] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
function formatTaskDetailAsset(asset: any, uploaderName: string, rewriteFileUrl?: (url: string) => string) {
const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl
const fileKind = getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl)
if (asset.assetType !== 'sku-preview') {
return {
...asset,
fileUrl: nextFileUrl,
fileKind,
}
}
return {
...asset,
fileUrl: nextFileUrl,
fileKind,
skuCode: asset.skuCode || null,
barcodes: Array.isArray(asset.barcodes)
? asset.barcodes
.map((item: any) => String(item?.barcode || '').trim())
.filter(Boolean)
: [],
uploadedAt: asset.createdAt || null,
updatedAt: asset.updatedAt || null,
uploaderName,
}
}
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/[id] - 获取单个素材详情
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
let material;
try {
material = await prisma.material.findFirst({
where: {
id: params.id,
deletedAt: null,
},
select: {
id: true,
userId: true,
productModel: true,
reportTitle: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
renderer: true,
authorName: true,
previewUrl: true,
zipFileName: true,
zipFilePath: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskNo: true,
taskStatus: true,
taskFetchedAt: true,
isFullSet: true,
user: {
select: {
id: true,
username: true,
realName: true,
},
},
// 包含 assets 数据(任务-详情需要)
assets: {
select: {
id: true,
assetType: true,
fileUrl: true,
fileName: true,
fileSize: true,
sortOrder: true,
createdAt: true,
updatedAt: true,
skuCode: true,
barcodes: {
select: {
barcode: true,
},
},
},
orderBy: { sortOrder: 'asc' },
},
} as any,
});
} catch (error: any) {
// 兼容旧版本数据库(没有 deletedAt 字段)
if (error?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
material = await prisma.material.findFirst({
where: {
id: params.id,
},
select: {
id: true,
userId: true,
productModel: true,
reportTitle: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
renderer: true,
authorName: true,
previewUrl: true,
zipFileName: true,
zipFilePath: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskNo: true,
taskStatus: true,
taskFetchedAt: true,
isFullSet: true,
user: {
select: {
id: true,
username: true,
realName: true,
},
},
assets: {
select: {
id: true,
assetType: true,
fileUrl: true,
fileName: true,
fileSize: true,
sortOrder: true,
createdAt: true,
updatedAt: true,
skuCode: true,
barcodes: {
select: {
barcode: true,
},
},
},
orderBy: { sortOrder: 'asc' },
},
} as any,
});
} else {
throw error;
}
}
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 权限检查:如果不是分享链接访问,检查查看权限
const isShare = isShareLinkAccess(request);
let canAccessSource = false;
const videoPreviewPolicy = getVideoPreviewPolicyFields(
(material as any).materialType,
(material as any).createdAt,
);
const buildVideoPreviewBlockedResponse = () =>
NextResponse.json(
{
error: '视频类素材上传后满 15 天才能查看详情',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: videoPreviewPolicy.videoPreviewUnavailableMessage,
availableAt: videoPreviewPolicy.videoPreviewAvailableAt,
remainingDays: videoPreviewPolicy.videoPreviewRemainingDays,
},
{ status: 403 },
);
if (isShare && videoPreviewPolicy.videoPreviewBlocked) {
return buildVideoPreviewBlockedResponse();
}
if (!isShare) {
const session = await getServerSession(authOptions);
if (session?.user) {
const userRole = (session.user as any).role;
const userDepartment = (session.user as any).department;
const managedDepartment = (session.user as any).managedDepartment;
const userId = (session.user as any).id;
// 超级管理员不受限制
const { isSuperAdmin } = await import('@/lib/department-permission');
const userIsSuperAdmin = await isSuperAdmin(userId);
if (!userIsSuperAdmin) {
const canView = await canUserViewMaterial(
userDepartment,
userRole,
managedDepartment,
(material as any).materialType,
userId
);
if (!canView) {
return NextResponse.json(
{ error: '无权限查看该素材' },
{ status: 403 }
);
}
}
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) {
return buildVideoPreviewBlockedResponse();
}
if (isSystemAdmin(userRole) || userIsSuperAdmin) {
canAccessSource = true;
} else {
const userDownloadSetting = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
});
if (userDownloadSetting?.canDownload) {
const canDownloadMaterial = await canUserDownloadMaterial(
userDepartment,
userRole,
managedDepartment,
(material as any).materialType,
userId
);
const requiresSourcePermission =
isProductPackageType((material as any).materialType) ||
is3DOriginalType((material as any).materialType);
const hasSourcePermission = requiresSourcePermission
? await canUserDownloadSource(
userDepartment,
userRole,
managedDepartment,
'详情模板',
userId
)
: true;
canAccessSource = canDownloadMaterial && hasSourcePermission;
}
}
} else {
// 未登录用户且不是分享链接,不允许查看
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
}
// 类型断言,确保 user 字段存在
const materialWithUser = material as any;
// 优先使用用户的真实姓名或用户名,如果没有才使用 authorName
const displayAuthorName = materialWithUser.user?.realName || materialWithUser.user?.username || materialWithUser.authorName;
const formattedAssets = (materialWithUser.assets || []).map((asset: any) =>
formatTaskDetailAsset(asset, displayAuthorName),
);
const response = {
id: materialWithUser.id,
userId: materialWithUser.userId,
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
platformName: materialWithUser.platformName || '',
productCategoryName: materialWithUser.productCategoryName || '',
materialType: materialWithUser.materialType,
isAiGenerated: Boolean(materialWithUser.isAiGenerated),
taskDetailSourceType: materialWithUser.taskDetailSourceType || null,
renderer: materialWithUser.renderer,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl: materialWithUser.previewUrl,
zipFileName: getSourceDisplayName(materialWithUser.zipFileName || materialWithUser.zipFilePath),
zipFilePath: '',
zipFileSize: materialWithUser.zipFileSize.toString(),
downloadCount: materialWithUser.downloadCount,
likeCount: materialWithUser.likeCount,
displayImages: parseDisplayImages(materialWithUser.displayImages || null),
createdAt: materialWithUser.createdAt,
videoSourceUrl: canAccessSource ? materialWithUser.videoSourceUrl || null : null,
videoPreview540Url: materialWithUser.videoPreview540Url || null,
videoPreviewClipUrl: materialWithUser.videoPreviewClipUrl || null,
videoCoverUrl: materialWithUser.videoCoverUrl || null,
videoDuration: materialWithUser.videoDuration || null,
videoProcessingStatus: materialWithUser.videoProcessingStatus || null,
taskNo: materialWithUser.taskNo || null,
taskStatus: materialWithUser.taskStatus || null,
taskFetchedAt: materialWithUser.taskFetchedAt || null,
isFullSet: deriveTaskDetailIsFullSet(
materialWithUser.materialType,
materialWithUser.assets,
Boolean(materialWithUser.isFullSet),
),
assets: formattedAssets,
canDownloadSource: canAccessSource,
user: {
username: materialWithUser.user?.username || '',
},
};
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite');
// 处理音频文件的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,
assets: formattedAssets.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) || ''
: '',
fileSize:
typeof asset.fileSize === 'bigint'
? asset.fileSize.toString()
: asset.fileSize != null
? String(asset.fileSize)
: '0',
}
}),
};
return NextResponse.json(rewrittenResponse);
} catch (error) {
console.error('获取素材失败:', error);
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
}
}
// PUT /api/materials/[id] - 更新素材信息
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const contentType = request.headers.get('content-type') || '';
let input: MaterialUpdateInput;
let previewImage: File | null = null;
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData();
input = {
productModel: String(formData.get('productModel') || ''),
reportTitle: formData.get('reportTitle') as string | null,
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,
platformName: formData.get('platformName') as string | null,
productCategoryName: formData.get('productCategoryName') as string | null,
materialType: String(formData.get('materialType') || ''),
renderer: formData.get('renderer') as string | null,
authorName: String(formData.get('authorName') || ''),
userId: formData.get('userId') as string | null,
taskNo: formData.get('taskNo') as string | null,
taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
isFullSet: formData.get('isFullSet') as string | null,
};
const formPreview = formData.get('previewImage');
previewImage = isWebFileLike(formPreview) ? formPreview : null;
} else {
const json = await request.json();
input = json;
}
const user = session.user as any;
const result = await updateMaterialWithSharedRules({
materialId: params.id,
operatorUserId: user.id,
operatorRole: user.role,
input,
previewImage,
});
if ('error' in result) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({
success: true,
message: '素材信息更新成功',
material: {
id: result.material.id,
productModel: result.material.productModel,
categoryName: result.material.categoryName,
sceneName: result.material.sceneName,
platformName: result.material.platformName,
productCategoryName: result.material.productCategoryName,
materialType: result.material.materialType,
renderer: result.material.renderer,
authorName: result.material.authorName,
taskNo: result.material.taskNo,
taskDetailSourceType: (result.material as any).taskDetailSourceType || null,
taskStatus: result.material.taskStatus,
taskFetchedAt: result.material.taskFetchedAt,
isFullSet: Boolean((result.material as any).isFullSet),
},
});
} catch (error) {
console.error('更新素材失败:', error);
return NextResponse.json({ error: '更新素材失败' }, { status: 500 });
}
}