2026-05-07 16:37:22 +08:00

481 lines
17 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 { 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 });
}
}