561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
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,
|
||
} 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 {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session?.user) {
|
||
return NextResponse.json(
|
||
{ error: '请先登录' },
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
|
||
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,
|
||
taskDetailRequesterUserId: true,
|
||
taskDetailRequesterName: 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,
|
||
taskDetailRequesterUserId: true,
|
||
taskDetailRequesterName: 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 });
|
||
}
|
||
|
||
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 },
|
||
);
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 类型断言,确保 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,
|
||
taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null,
|
||
taskDetailRequesterName: materialWithUser.taskDetailRequesterName || 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,
|
||
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') 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,
|
||
taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null,
|
||
taskDetailRequesterName: (result.material as any).taskDetailRequesterName || 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 });
|
||
}
|
||
}
|