481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { authOptions } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
import {
|
||
canUserViewMaterial,
|
||
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';
|
||
|
||
// 解析 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 buildSourceFileUrl(material: any, isIntranet: boolean): string | null {
|
||
const zipFilePath = String(material?.zipFilePath || '').trim()
|
||
const zipFileName = String(material?.zipFileName || '').trim()
|
||
const rawSource = zipFilePath || zipFileName
|
||
if (!rawSource) {
|
||
return null
|
||
}
|
||
|
||
if (rawSource.startsWith('http://') || rawSource.startsWith('https://')) {
|
||
return rawSource
|
||
}
|
||
|
||
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 isIntranet ? `/uploads/${sourceRelativePath}` : `/api/uploads/${sourceRelativePath}`
|
||
}
|
||
|
||
// 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);
|
||
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');
|
||
if (!(await isSuperAdmin(userId))) {
|
||
const canView = await canUserViewMaterial(
|
||
userDepartment,
|
||
userRole,
|
||
managedDepartment,
|
||
(material as any).materialType,
|
||
userId
|
||
);
|
||
|
||
if (!canView) {
|
||
return NextResponse.json(
|
||
{ error: '无权限查看该素材' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
} 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: materialWithUser.zipFileName,
|
||
zipFilePath: materialWithUser.zipFilePath,
|
||
zipFileSize: materialWithUser.zipFileSize.toString(),
|
||
downloadCount: materialWithUser.downloadCount,
|
||
likeCount: materialWithUser.likeCount,
|
||
displayImages: parseDisplayImages(materialWithUser.displayImages || null),
|
||
createdAt: materialWithUser.createdAt,
|
||
videoSourceUrl: materialWithUser.videoSourceUrl || 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,
|
||
user: {
|
||
username: materialWithUser.user?.username || '',
|
||
},
|
||
};
|
||
|
||
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
|
||
const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite');
|
||
const { detectIsIntranet } = await import('@/lib/network-detection');
|
||
|
||
const isIntranet = detectIsIntranet(request);
|
||
|
||
// 处理音频文件的zipFileName(特殊处理)
|
||
let processedZipFileName = response.zipFileName;
|
||
if (response.zipFileName && response.materialType === '音乐') {
|
||
const zipFileName = response.zipFileName;
|
||
if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) {
|
||
// 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理)
|
||
processedZipFileName = rewriteMediaUrlForClient(zipFileName, request, 'audio');
|
||
} else {
|
||
// 新素材:本地文件
|
||
if (isIntranet) {
|
||
// 内网用户:直接访问
|
||
processedZipFileName = `/uploads/${zipFileName}`;
|
||
} else {
|
||
// 外网用户:通过API访问
|
||
processedZipFileName = `/api/uploads/${zipFileName}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
const rewrittenResponse = {
|
||
...response,
|
||
sourceFileUrl: buildSourceFileUrl(response, isIntranet),
|
||
// 图片:使用统一规则(小文件直接访问,大文件走代理)
|
||
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: rewriteMediaUrlForClient(response.videoSourceUrl, request, 'video'),
|
||
// 音频文件:特殊处理
|
||
zipFileName: processedZipFileName,
|
||
assets: formattedAssets.map((asset: any) => ({
|
||
...asset,
|
||
fileUrl: rewriteMediaUrlForClient(
|
||
asset.fileUrl,
|
||
request,
|
||
asset.fileKind === 'image' ? 'image' : 'source',
|
||
),
|
||
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 });
|
||
}
|
||
}
|