1005 lines
35 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 { 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 { isSystemAdmin } from '@/lib/department-permission';
import { createMaterialLog } from '@/lib/material-log';
import { normalizeUrl, normalizeMaterial } from '@/lib/utils';
import { rewriteMediaUrlForClient } from '@/lib/url-rewrite';
import { detectIsIntranet } from '@/lib/network-detection';
import {
buildMaterialWhereFromSearchParams,
MaterialQueryError,
} from '@/lib/material-query';
import { getMaterialCoverUrl } from '@/lib/product-package';
import { isProductPackageType } from '@/lib/material-type-mapper';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { applyVideoPreviewPolicy, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
// 🔒 R45修复:数据库查询限制常量(防止OOM)
const MAX_PAGE_SIZE = 100; // 单页最大100条记录
const MAX_QUERY_LIMIT = 5000; // comprehensive排序时最大查询5000条
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
if (!displayImages) return [];
if (Array.isArray(displayImages)) {
return displayImages.filter(img => 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 => typeof img === 'string' && img.trim().length > 0);
}
} catch (e) {
console.warn('[materials] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
function formatTaskDetailAssetSummary(
asset: any,
uploaderName: string,
rewriteFileUrl?: (url: string) => string | null,
) {
const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl;
const fileKind = getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl)
const baseAsset = {
...asset,
fileUrl: nextFileUrl || asset.fileUrl || '',
fileKind,
fileSize:
typeof asset.fileSize === 'bigint'
? asset.fileSize.toString()
: asset.fileSize != null
? String(asset.fileSize)
: '0',
};
if (asset.assetType !== 'sku-preview') {
return baseAsset;
}
return {
...baseAsset,
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,
};
}
// GET - 获取素材列表
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const { searchParams } = new URL(request.url);
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录', code: 'UNAUTHORIZED' },
{ status: 401 },
);
}
// 单个素材查询(通过uploadId)
const uploadId = searchParams.get('uploadId');
if (uploadId) {
const material: any = await prisma.material.findFirst({
where: {
uploadId,
deletedAt: null,
} as any,
select: {
id: true,
userId: 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,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
createdAt: true,
videoSourceUrl: true,
displayImages: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
user: {
select: {
username: true,
realName: true,
avatar: 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,
});
if (!material) {
return NextResponse.json({ material: null });
}
if (!(await canAccessMaterialPrivacy(material, (session.user as any).id))) {
return NextResponse.json({ error: '无权限查看该私密素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } });
}
// 权限检查:分享页使用 /api/materials/share?id=...,普通素材接口不接受 share 参数绕过权限。
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, canUserViewMaterial } = await import('@/lib/department-permission');
if (!isPrivateTaskDetail(material) && !(await isSuperAdmin(userId))) {
const canView = await canUserViewMaterial(
userDepartment,
userRole,
managedDepartment,
material.materialType,
userId
);
if (!canView) {
return NextResponse.json(
{ error: '无权限查看该素材' },
{ status: 403 }
);
}
}
// 检查点赞和收藏状态
let isLiked = false;
let isFavorited = false;
if (session?.user) {
const userId = (session.user as any).id;
const materialId = material.id;
// 检查点赞
const like = await prisma.materialLike.findFirst({
where: { userId, materialId },
});
isLiked = !!like;
// 检查收藏
try {
const favorite = await prisma.materialFavorite.findFirst({
where: {
materialId,
userId,
},
});
isFavorited = !!favorite;
} catch (e: any) {
if (!e?.meta?.table?.includes('material_favorites')) {
console.error('[Materials API] Error fetching favorite:', e);
}
}
}
// 处理previewUrl的fallback逻辑
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
// 任务-详情类型:优先使用 assets 中的预览图
if (!previewUrl && isProductPackageType(material.materialType) && material.assets && material.assets.length > 0) {
const previewAsset = material.assets.find((a: any) => a.assetType === 'main-preview');
if (previewAsset) {
previewUrl = previewAsset.fileUrl;
console.log('[Materials API] Using preview from assets:', previewUrl);
}
}
// 任务-页面类型:完成上传后预览资源的 assetType 为 preview。
if (!previewUrl && material.materialType === '任务-页面' && material.assets && material.assets.length > 0) {
const previewAsset = material.assets.find((a: any) => a.assetType === 'preview');
if (previewAsset) {
previewUrl = previewAsset.fileUrl;
}
}
// 其他素材类型:使用 displayImages
if (!previewUrl && material.displayImages) {
const displayImagesArray = parseDisplayImages(material.displayImages);
if (displayImagesArray.length > 0) {
previewUrl = displayImagesArray[0];
}
}
// 优先使用用户的真实姓名或用户名,如果没有才使用 authorName
const displayAuthorName = material.user?.realName || material.user?.username || material.authorName;
const formattedAssets = (material.assets || []).map((asset: any) =>
formatTaskDetailAssetSummary(
asset,
displayAuthorName,
(fileUrl) => rewriteMediaUrlForClient(fileUrl, request, 'image'),
),
);
const normalizedMaterial = normalizeMaterial({
...material,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl, // 使用处理后的previewUrl
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages || null),
isLiked,
isFavorited,
user: material.user ? {
...material.user,
avatar: normalizeUrl(material.user.avatar),
} : null,
assets: formattedAssets,
});
return NextResponse.json({ material: normalizedMaterial }, { headers: { 'Cache-Control': 'private, no-store' } });
}
// 列表查询
const sortBy = searchParams.get('sortBy') || 'comprehensive';
const sortOrder = searchParams.get('sortOrder') || 'desc';
const page = parseInt(searchParams.get('page') || '1');
// 🔒 R45修复:限制 pageSize 上限,防止查询过多数据
const rawPageSize = parseInt(searchParams.get('pageSize') || '20');
const pageSize = Math.min(rawPageSize, MAX_PAGE_SIZE);
// 如果用户请求的 pageSize 超过限制,记录警告
if (rawPageSize > MAX_PAGE_SIZE) {
console.warn(`[Materials API] pageSize 超过限制:${rawPageSize} > ${MAX_PAGE_SIZE},已自动限制为 ${MAX_PAGE_SIZE}`);
}
const { where, empty } = await buildMaterialWhereFromSearchParams(
searchParams,
session.user as any,
);
if (empty) {
return NextResponse.json({
materials: [],
hasMore: false,
totalPages: 0,
total: 0,
totalCount: 0,
currentPage: page,
});
}
// 创意资产库首页用 deferTotal=1:先返回当前页,不阻塞全库 count。
// 后台管理等仍走原来的 total/totalPages。
const deferTotal = searchParams.get('deferTotal') === '1';
let totalCount: number | null = null;
let totalPages: number | null = null;
if (!deferTotal) {
try {
totalCount = await prisma.material.count({ where });
} catch (e: any) {
if (e?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
const whereWithoutDeleted = { ...where };
delete whereWithoutDeleted.deletedAt;
totalCount = await prisma.material.count({ where: whereWithoutDeleted });
} else {
throw e;
}
}
totalPages = Math.ceil(totalCount / pageSize);
}
// 排序 - 使用 id 作为次要排序键,确保分页排序稳定(避免重复数据)
let orderBy: any = [{ createdAt: sortOrder }, { id: 'asc' }];
if (sortBy === 'downloadCount') {
orderBy = [{ downloadCount: sortOrder }, { id: 'asc' }];
}
// 查询素材列表
let materials: any[];
const queryWhere = where;
try {
const query: any = {
where: queryWhere,
select: {
id: true,
userId: 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,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
createdAt: true,
videoSourceUrl: true,
displayImages: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
transcodeLastError: true,
transcodeAttempts: true,
// 包含用户信息(用于显示真实姓名)
user: {
select: {
username: true,
realName: true,
avatar: true,
},
},
},
};
if (sortBy !== 'comprehensive') {
query.orderBy = orderBy;
query.skip = (page - 1) * pageSize;
query.take = pageSize + (deferTotal ? 1 : 0);
} else {
// comprehensive排序:获取更多数据用于内存排序,但有上限
// 只需要前 (page * pageSize) 条数据,最多 MAX_QUERY_LIMIT 条
const offset = (page - 1) * pageSize;
const limit = Math.min(offset + pageSize, MAX_QUERY_LIMIT);
query.skip = 0;
query.take = limit;
}
materials = await prisma.material.findMany(query);
// comprehensive排序:按下载量和创建时间综合排序
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
// 综合排序:下载量权重70%,创建时间权重30%
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
// 分页
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
} catch (e: any) {
if (e?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
const whereWithoutDeleted = { ...queryWhere };
delete whereWithoutDeleted.deletedAt;
try {
materials = await prisma.material.findMany({
where: whereWithoutDeleted,
select: {
id: true,
userId: true,
previewUrl: true,
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
...TASK_DETAIL_PRIVACY_SELECT,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
createdAt: true,
videoSourceUrl: true,
displayImages: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
transcodeLastError: true,
transcodeAttempts: true,
user: {
select: {
username: true,
realName: true,
avatar: true,
},
},
} as any,
...(sortBy !== 'comprehensive' ? {
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} : {}),
});
// comprehensive排序处理
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
} catch (e2: any) {
if (e2?.meta?.column?.includes('transcode')) {
console.warn('[Materials API] Transcode fields not found, using fallback query');
materials = (await prisma.material.findMany({
where: whereWithoutDeleted,
select: {
id: true,
userId: true,
previewUrl: true,
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
...TASK_DETAIL_PRIVACY_SELECT,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
createdAt: true,
videoSourceUrl: true,
displayImages: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
user: {
select: {
username: true,
realName: true,
},
},
} as any,
...(sortBy !== 'comprehensive' ? {
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} : {}),
}));
// comprehensive排序处理
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
materials = materials.map(m => ({
...m,
transcodeLastError: null,
transcodeAttempts: 0,
}));
} else {
throw e2;
}
}
} else if (e?.meta?.column?.includes('transcode')) {
console.warn('[Materials API] Transcode fields not found, using fallback query');
materials = (await prisma.material.findMany({
where: queryWhere,
select: {
id: true,
userId: true,
previewUrl: true,
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
isAiGenerated: true,
taskDetailSourceType: true,
...TASK_DETAIL_PRIVACY_SELECT,
taskDetailRequesterUserId: true,
taskDetailRequesterName: true,
renderer: true,
authorName: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
createdAt: true,
videoSourceUrl: true,
displayImages: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
user: {
select: {
username: true,
realName: true,
},
},
} as any,
...(sortBy !== 'comprehensive' ? {
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} : {}),
}));
// comprehensive排序处理
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
materials = materials.map(m => ({
...m,
transcodeLastError: null,
transcodeAttempts: 0,
}));
} else {
throw e;
}
}
let hasMore = false;
if (deferTotal && sortBy !== 'comprehensive') {
hasMore = materials.length > pageSize;
if (hasMore) {
materials = materials.slice(0, pageSize);
}
} else if (totalPages != null) {
hasMore = page < totalPages;
} else {
hasMore = materials.length === pageSize;
}
// 批量查询点赞和收藏状态
const likedSet = new Set<string>();
const favoritedSet = new Set<string>();
if (session?.user) {
const userId = (session.user as any).id;
const materialIds = materials.map(m => m.id);
// 批量查询点赞
const likes = await prisma.materialLike.findMany({
where: {
userId,
materialId: { in: materialIds },
},
select: { materialId: true },
});
likes.forEach(like => likedSet.add(like.materialId));
// 批量查询收藏
try {
const favorites = await prisma.materialFavorite.findMany({
where: {
userId,
materialId: { in: materialIds },
},
select: { materialId: true },
});
favorites.forEach(fav => favoritedSet.add(fav.materialId));
} catch (e: any) {
if (!e?.meta?.table?.includes('material_favorites')) {
console.error('[Materials API] Error fetching favorites:', e);
}
}
}
// 格式化返回数据
// 任务-详情 / 缺封面的任务-页面:单独批量取封面,避免列表主查询联资产表
const productPackageMaterialIds = materials
.filter(m => isProductPackageType(m.materialType))
.map(m => m.id);
const taskPageMissingPreviewIds = materials
.filter(m => m.materialType === '任务-页面' && !m.previewUrl && !m.videoCoverUrl)
.map(m => m.id);
const coverMaterialIds = Array.from(new Set(productPackageMaterialIds.concat(taskPageMissingPreviewIds)));
const coverUrlMap = new Map<string, string | null>();
if (coverMaterialIds.length > 0) {
try {
const assets = await prisma.materialAsset.findMany({
where: {
materialId: { in: coverMaterialIds },
assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview', 'preview'] },
},
select: {
materialId: true,
assetType: true,
fileUrl: true,
sortOrder: true,
},
orderBy: [
{ materialId: 'asc' },
{ assetType: 'asc' },
{ sortOrder: 'asc' },
],
});
const assetsByMaterial = new Map<string, { mainPreview?: string; skuPreview?: string; main?: string; sku?: string; preview?: string }>();
for (const asset of assets) {
if (!assetsByMaterial.has(asset.materialId)) {
assetsByMaterial.set(asset.materialId, {});
}
const materialAssets = assetsByMaterial.get(asset.materialId)!;
if (asset.assetType === 'main-preview') {
materialAssets.mainPreview = asset.fileUrl;
} else if (asset.assetType === 'sku-preview') {
materialAssets.skuPreview = asset.fileUrl;
} else if (asset.assetType === 'main') {
materialAssets.main = asset.fileUrl;
} else if (asset.assetType === 'sku') {
materialAssets.sku = asset.fileUrl;
} else if (asset.assetType === 'preview' && !materialAssets.preview) {
materialAssets.preview = asset.fileUrl;
}
}
assetsByMaterial.forEach((materialAssets, materialId) => {
const coverUrl = materialAssets.mainPreview || materialAssets.skuPreview || materialAssets.preview || materialAssets.main || materialAssets.sku || null;
if (coverUrl) {
coverUrlMap.set(materialId, coverUrl);
}
});
} catch (error) {
console.error('[Materials API] Failed to batch get cover URLs:', error);
await Promise.all(
productPackageMaterialIds.map(async (materialId) => {
try {
const coverUrl = await getMaterialCoverUrl(materialId);
if (coverUrl) {
coverUrlMap.set(materialId, coverUrl);
}
} catch (err) {
console.error(`[Materials API] Failed to get cover URL for material ${materialId}:`, err);
}
})
);
}
}
const formattedMaterials = materials.map(material => {
// 处理previewUrl的fallback逻辑
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
if (!previewUrl && material.materialType === '任务-页面') {
const coverUrl = coverUrlMap.get(material.id);
if (coverUrl) {
previewUrl = coverUrl;
}
}
// 任务-详情类型:优先使用从 assets 获取的封面图
if (isProductPackageType(material.materialType)) {
const coverUrl = coverUrlMap.get(material.id);
if (coverUrl) {
previewUrl = coverUrl;
} else if (!previewUrl) {
// 如果 assets 中没有,再尝试其他 fallback
if (material.displayImages) {
const displayImagesArray = parseDisplayImages(material.displayImages);
if (displayImagesArray.length > 0) {
previewUrl = displayImagesArray[0];
}
}
}
} else {
// 其他类型:使用原有的 fallback 逻辑
if (!previewUrl && material.displayImages) {
const displayImagesArray = parseDisplayImages(material.displayImages);
if (displayImagesArray.length > 0) {
previewUrl = displayImagesArray[0];
}
}
}
// 优先使用用户的真实姓名或用户名,如果没有才使用 authorName
const displayAuthorName = material.user?.realName || material.user?.username || material.authorName;
const formattedAssets = (material.assets || []).map((asset: any) =>
formatTaskDetailAssetSummary(
asset,
displayAuthorName,
(fileUrl) => rewriteMediaUrlForClient(fileUrl, request, 'image'),
),
);
const formatted = {
...material,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl, // 使用处理后的previewUrl
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages || null),
isLiked: likedSet.has(material.id),
isFavorited: favoritedSet.has(material.id),
user: material.user ? {
...material.user,
avatar: normalizeUrl(material.user.avatar),
} : null,
assets: formattedAssets,
};
// 应用统一规则:内网用户直接访问,外网用户根据文件大小智能选择
// 注意:不要先调用 normalizeMaterial,因为它会将URL转换为代理URL,导致内网用户无法重写为内网地址
// 应该直接使用 rewriteMediaUrlForClient,它会根据用户网络位置智能决定是否重写为内网地址
const isIntranet = detectIsIntranet(request);
// 处理音频文件的zipFileName(特殊处理)
let processedZipFileName = formatted.zipFileName;
if (formatted.zipFileName && formatted.materialType === '音乐') {
const zipFileName = formatted.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 rewrittenMaterial = {
...formatted,
// 图片:使用统一规则(内网用户直接访问内网MinIO,外网用户根据文件大小决定是否走代理)
previewUrl: rewriteMediaUrlForClient(formatted.previewUrl, request, 'image'),
videoCoverUrl: rewriteMediaUrlForClient(formatted.videoCoverUrl, request, 'image'),
displayImages: formatted.displayImages?.map((img: string) =>
rewriteMediaUrlForClient(img, request, 'image')
),
// 视频:使用统一规则(内网用户直接访问内网MinIO,外网用户通常>1MB会走代理)
videoPreview540Url: rewriteMediaUrlForClient(formatted.videoPreview540Url, request, 'video'),
videoPreviewClipUrl: rewriteMediaUrlForClient(formatted.videoPreviewClipUrl, request, 'video'),
videoSourceUrl: rewriteMediaUrlForClient(formatted.videoSourceUrl, request, 'video'),
// 音频文件:特殊处理
zipFileName: processedZipFileName,
assets: formattedAssets,
};
return applyVideoPreviewPolicy(rewrittenMaterial, new Date(), { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) });
});
return NextResponse.json({
materials: formattedMaterials,
hasMore,
currentPage: page,
...(totalCount != null
? {
total: totalCount,
totalCount,
totalPages,
}
: {}),
}, { headers: { 'Cache-Control': 'private, no-store' } });
} catch (error) {
if (error instanceof MaterialQueryError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.status },
);
}
console.error('Error fetching materials:', error);
return NextResponse.json(
{ error: '获取素材列表失败' },
{ status: 500 }
);
}
}
// DELETE - 删除素材
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
// 权限检查
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
);
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const permanent = searchParams.get('permanent') === 'true';
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
);
}
// 查询素材
const material = await prisma.material.findUnique({
where: { id },
});
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
);
}
const userId = (session.user as any).id;
// 软删除
if (!permanent) {
const deletedAt = new Date();
// 注意:软删除时,文件仍然保留,以便恢复时可以重新使用
// 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时)
await prisma.$transaction([
prisma.material.update({
where: { id },
data: { deletedAt },
}),
prisma.materialSku.updateMany({
where: {
materialId: id,
deletedAt: null,
},
data: { deletedAt },
}),
]);
// 记录日志
await createMaterialLog({
materialId: id,
userId,
action: 'DELETE',
changes: {
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: material.materialType,
},
},
deletedAt: {
oldValue: material.deletedAt ? material.deletedAt.toISOString() : null,
newValue: deletedAt.toISOString(),
},
deleteMode: {
oldValue: material.deletedAt ? 'deleted' : 'active',
newValue: 'soft',
},
},
});
return NextResponse.json({ message: '删除成功(可恢复),文件已保留' });
}
// 永久删除
{
return NextResponse.json({
error: '永久删除已暂停:请先使用可恢复删除;待持久化清理任务上线后再开放物理删除',
code: 'PERMANENT_DELETE_DISABLED',
}, { status: 409 });
}
} catch (error: any) {
// 缺少软删除列时必须失败关闭;绝不能悄悄退化成不可恢复的物理删除。
if (error?.meta?.column?.includes('deletedAt')) {
console.error('[Materials API] deletedAt field is unavailable; refusing destructive fallback');
return NextResponse.json({
error: '当前数据库未完成软删除结构升级,已拒绝不可恢复删除',
code: 'SOFT_DELETE_SCHEMA_REQUIRED',
}, { status: 503 });
}
console.error('Error deleting material:', error);
return NextResponse.json(
{ error: '删除失败' },
{ status: 500 }
);
}
}