366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
/**
|
||
* 简单的下载路由 - 直接返回已打包好的zip文件
|
||
*/
|
||
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { authOptions } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
import { access, stat } from 'fs/promises';
|
||
import { createReadStream } from 'fs';
|
||
import path from 'path';
|
||
import { lookup } from 'mime-types';
|
||
import {
|
||
canUserDownloadMaterial,
|
||
canUserDownloadSource,
|
||
} from '@/lib/department-permission';
|
||
import {
|
||
DownloadErrorCode,
|
||
createDownloadError,
|
||
} from '@/lib/download-errors';
|
||
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
|
||
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log';
|
||
import { resolveStoredUploadPath } from '@/lib/upload-paths';
|
||
import {
|
||
getVideoDownloadAvailability,
|
||
getVideoDownloadUnavailableMessage,
|
||
userHasVideoEarlyAccess,
|
||
} from '@/lib/video-download-policy';
|
||
import { nodeReadableToWebStream } from '@/lib/node-readable-stream';
|
||
import { canAccessMaterialPrivacy, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy';
|
||
|
||
function normalizeStoredLocalPath(filePath: string) {
|
||
return String(filePath || '').replace(/\\/g, '/');
|
||
}
|
||
|
||
// GET - 下载素材
|
||
export async function GET(request: NextRequest) {
|
||
try {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session?.user) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.UNAUTHORIZED),
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
|
||
const userId = (session.user as any).id;
|
||
const userRole = (session.user as any).role;
|
||
|
||
// 检查用户级别的下载权限
|
||
if (userRole !== 'ADMIN') {
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: { canDownload: true },
|
||
});
|
||
|
||
if (!user || !user.canDownload) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FORBIDDEN),
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
|
||
const { searchParams } = new URL(request.url);
|
||
const materialId = searchParams.get('id');
|
||
const downloadType = searchParams.get('type'); // all/main/sku/detail/psd
|
||
const assetId = searchParams.get('assetId'); // 单个资产ID(用于单个PSD文件下载)
|
||
|
||
const validateOnly = searchParams.get('validate') === '1';
|
||
|
||
if (!materialId) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE, '缺少素材ID'),
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 查询素材
|
||
const material = await prisma.material.findFirst({
|
||
where: { id: materialId, deletedAt: null },
|
||
select: {
|
||
id: true,
|
||
zipFilePath: true,
|
||
zipFileName: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
productCategoryName: true,
|
||
materialType: true,
|
||
...TASK_DETAIL_PRIVACY_SELECT,
|
||
downloadCount: true,
|
||
createdAt: true,
|
||
},
|
||
});
|
||
|
||
if (!material) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.MATERIAL_NOT_FOUND),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
if (!(await canAccessMaterialPrivacy(material, userId))) {
|
||
return NextResponse.json(createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载该私密素材'), { status: 403, headers: { 'Cache-Control': 'private, no-store' } });
|
||
}
|
||
|
||
const videoDownloadAvailability = getVideoDownloadAvailability(
|
||
material.materialType,
|
||
material.createdAt,
|
||
new Date(),
|
||
{ bypassWaitPeriod: userHasVideoEarlyAccess(session.user) },
|
||
);
|
||
if (!videoDownloadAvailability.allowed) {
|
||
return NextResponse.json(
|
||
createDownloadError(
|
||
DownloadErrorCode.VIDEO_WAIT_PERIOD,
|
||
getVideoDownloadUnavailableMessage(videoDownloadAvailability),
|
||
),
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// 所有下载请求都必须检查部门和源文件权限,查询参数不能绕过。
|
||
const userDepartment = (session.user as any).department;
|
||
const managedDepartment = (session.user as any).managedDepartment;
|
||
|
||
// 超级管理员不受限制
|
||
const { isSuperAdmin } = await import('@/lib/department-permission');
|
||
if (!(await isSuperAdmin(userId))) {
|
||
const canDownload = isPrivateTaskDetail(material) || await canUserDownloadMaterial(
|
||
userDepartment,
|
||
userRole,
|
||
managedDepartment,
|
||
material.materialType,
|
||
userId
|
||
);
|
||
|
||
if (!canDownload) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_PERMISSION),
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// 任务-详情 PSD 和任务原图源文件按各自素材类型检查 canDownloadSource。
|
||
const requiresSourcePermission =
|
||
is3DOriginalType(material.materialType) ||
|
||
(isProductPackageType(material.materialType) && downloadType === 'psd');
|
||
if (requiresSourcePermission) {
|
||
const canDownloadSourceFile = await canUserDownloadSource(
|
||
userDepartment,
|
||
userRole,
|
||
managedDepartment,
|
||
material.materialType,
|
||
userId
|
||
);
|
||
|
||
if (!canDownloadSourceFile) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载该源文件'),
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 任务-详情:处理分类下载
|
||
if (isProductPackageType(material.materialType) && downloadType) {
|
||
return handleProductPackageDownload(material, downloadType, assetId, validateOnly);
|
||
}
|
||
|
||
// 其他类型:返回zipFilePath对应的文件
|
||
if (!material.zipFilePath || material.zipFilePath.trim() === '') {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
let filePath = normalizeStoredLocalPath(material.zipFilePath);
|
||
filePath = path.isAbsolute(filePath) ? filePath : resolveStoredUploadPath(filePath);
|
||
|
||
return serveFile(filePath, material, downloadType || '源文件', validateOnly);
|
||
} catch (error) {
|
||
console.error('[Download] Error:', error);
|
||
return NextResponse.json(
|
||
{ error: '下载失败' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 处理任务-详情分类下载
|
||
async function handleProductPackageDownload(
|
||
material: any,
|
||
downloadType: string,
|
||
assetId?: string | null,
|
||
validateOnly: boolean = false
|
||
) {
|
||
console.log(`[Download] ========== 任务-详情下载 ==========`);
|
||
console.log(`[Download] materialId=${material.id}, downloadType=${downloadType}, assetId=${assetId}`);
|
||
|
||
// 查询资产
|
||
let asset: any = null;
|
||
|
||
if (assetId && downloadType === 'psd') {
|
||
// 单个PSD文件下载
|
||
asset = await prisma.materialAsset.findFirst({
|
||
where: {
|
||
materialId: material.id,
|
||
id: assetId,
|
||
assetType: 'psd',
|
||
},
|
||
});
|
||
} else {
|
||
// 按类型查找资产(直接使用已打包好的zip文件)
|
||
// 优先查找本地 zip 文件(fileUrl 以 uploads/ 开头)
|
||
const assetType = downloadType === 'all' ? 'all' : downloadType;
|
||
asset = await prisma.materialAsset.findFirst({
|
||
where: {
|
||
materialId: material.id,
|
||
assetType,
|
||
OR: [
|
||
{ fileUrl: { startsWith: 'uploads/' } },
|
||
{ fileUrl: { startsWith: 'uploads\\' } },
|
||
],
|
||
},
|
||
});
|
||
}
|
||
|
||
if (!asset) {
|
||
console.error(`[Download] 未找到下载资源: downloadType=${downloadType}, assetId=${assetId}`);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
const filePath = normalizeStoredLocalPath(asset.fileUrl);
|
||
const absolutePath = path.isAbsolute(filePath)
|
||
? filePath
|
||
: resolveStoredUploadPath(filePath);
|
||
|
||
console.log(`[Download] 文件路径: ${absolutePath}`);
|
||
console.log(`[Download] 文件名: ${asset.fileName}`);
|
||
|
||
return serveFile(absolutePath, material, downloadType === 'all' ? '全部' : downloadType, validateOnly);
|
||
}
|
||
|
||
// 通用文件服务函数
|
||
async function serveFile(filePath: string, material: any, fileNameSuffix: string, validateOnly: boolean = false) {
|
||
// R29修复:路径遍历保护
|
||
const { isPathSafe, isPathInAllowedDirs, getAllowedFileDirectories } = await import('@/lib/path-security');
|
||
|
||
if (!isPathSafe(filePath)) {
|
||
console.error('[Download] 检测到危险路径:', filePath);
|
||
return NextResponse.json(
|
||
{ error: '文件路径无效' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const allowedDirs = getAllowedFileDirectories();
|
||
if (!isPathInAllowedDirs(filePath, allowedDirs)) {
|
||
console.error('[Download] 路径越界:', filePath, '允许的目录:', allowedDirs);
|
||
return NextResponse.json(
|
||
{ error: '无权访问该文件' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
try {
|
||
await access(filePath);
|
||
const fileStats = await stat(filePath);
|
||
|
||
const ext = path.extname(filePath);
|
||
const mimeType = lookup(ext) || 'application/octet-stream';
|
||
|
||
// 生成下载文件名
|
||
const filenameParts = [
|
||
material.productModel || 'unknown',
|
||
material.categoryName || '',
|
||
material.sceneName || '',
|
||
fileNameSuffix,
|
||
].filter(Boolean);
|
||
const filename = `${filenameParts.join('_')}${ext}`;
|
||
|
||
if (validateOnly) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
fileName: filename,
|
||
fileSize: fileStats.size,
|
||
contentType: mimeType,
|
||
});
|
||
}
|
||
|
||
const stream = nodeReadableToWebStream(createReadStream(filePath));
|
||
|
||
console.log(`[Download] 开始下载: ${filename}, size=${fileStats.size} bytes`);
|
||
|
||
// 修复:记录下载日志并更新下载次数(在后台异步执行)
|
||
recordDownloadLog(material.id).catch(err => {
|
||
console.error('[Download] 记录下载日志失败:', err);
|
||
});
|
||
|
||
return new NextResponse(stream, {
|
||
headers: {
|
||
'Content-Type': mimeType,
|
||
'Content-Length': fileStats.size.toString(),
|
||
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
|
||
// 鉴权下载不允许浏览器或前置代理共享缓存。
|
||
'Cache-Control': 'private, no-store, max-age=0',
|
||
'Accept-Ranges': 'bytes',
|
||
'Vary': 'Accept-Encoding',
|
||
'ETag': `"${Buffer.from(`${filePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[Download] 文件不存在或无法访问:', filePath, error);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 记录下载日志(异步执行,不阻塞下载)
|
||
async function recordDownloadLog(materialId: string) {
|
||
try {
|
||
await ensureMaterialDownloadLogsTable();
|
||
|
||
const session = await getServerSession(authOptions);
|
||
if (!session?.user) {
|
||
return; // 分享链接访问不记录日志
|
||
}
|
||
|
||
const userId = (session.user as any).id;
|
||
|
||
// 并行执行:写入下载日志表 + 更新素材下载次数
|
||
await Promise.all([
|
||
// 写入下载日志
|
||
prisma.materialDownloadLog.create({
|
||
data: {
|
||
materialId,
|
||
userId,
|
||
downloadedAt: new Date(),
|
||
},
|
||
}),
|
||
// 更新素材下载次数
|
||
prisma.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
downloadCount: {
|
||
increment: 1,
|
||
},
|
||
},
|
||
}),
|
||
]);
|
||
|
||
console.log(`[Download] 记录下载日志成功: materialId=${materialId}, userId=${userId}`);
|
||
} catch (error) {
|
||
console.error('[Download] 记录下载日志失败:', error);
|
||
// 不抛出错误,避免影响下载
|
||
}
|
||
}
|