366 lines
12 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.

/**
* 简单的下载路由 - 直接返回已打包好的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);
// 不抛出错误,避免影响下载
}
}