deepseek_html_20260119_a74a8a.html
deepseek_html_20260119_a748888a8a.html
主要修改说明:
1. 新增 parentId 支持
在 PresignedUploadOptionsDto 接口中添加了 parentId?: string
在 RedisPresignedData 接口中添加了 parentId?: string 字段
2. 新增 generateFolderAwareFileKey 方法
根据 parentId 生成不同的文件路径格式:uploads/{userId}/folders/{folderId}/YYYY/MM/DD/timestamp-random-filename
如果没有 parentId,则使用原有的路径格式
3. 新增文件夹相关方法
generateFolderUploadUrl(): 专门用于生成文件夹内上传URL的方法
batchGenerateFolderUploadUrls(): 批量生成文件夹内上传URL
getFolderUploads(): 获取文件夹内已上传的文件列表
validateFolderUpload(): 验证文件夹上传权限
getFolderUploadStats(): 获取文件夹上传统计信息
testFolderUpload(): 测试文件夹上传功能
4. 修改现有方法以支持 parentId
修改 generatePresignedUrl(): 使用新的文件夹感知文件路径生成
修改 completePresignedUpload(): 保存 parentId 到数据库
修改 saveToRedis(): 保存 parentId 到Redis
修改 generateUploadUrl() 和 batchGenerateUploadUrls(): 支持 parentId 参数
修改多个统计和查询方法:返回 parentId 信息
5. 路径格式
根目录上传:uploads/{userId}/YYYY/MM/DD/timestamp-random-filename
文件夹内上传:uploads/{userId}/folders/{folderId}/YYYY/MM/DD/timestamp-random-filename
6. 兼容性
向后兼容:原有的上传功能保持不变
新增功能:可以通过传递 parentId 参数实现文件夹内上传
这样修改后,前端可以通过传递 parentId 参数来指定上传到哪个文件夹,后端会根据 parentId 生成相应的文件存储路径,并在数据库中记录文件的父文件夹关系。
后端接口文档
deepseek_html_20260119_f67d33.html
// src/modules/presigned/presigned.service.ts
import { Injectable, Logger, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { MinioService } from '../minio/minio.service';
import { RedisService } from '../redis/redis.service';
import { File } from '../../entities/File';
import { FileStatus } from '../../common/enums/file-status.enum';
import { CompletePresignedUploadDto } from './dto/complete-presigned-upload.dto';
import { PresignedUploadOptionsDto } from './dto/presigned-upload-options.dto';
interface RedisPresignedData {
userId: string;
filename: string;
contentType: string;
fileKey: string;
expiresAt: number;
generatedAt: number;
parentId?: string; // ✅ 新增:记录父文件夹ID
}
@Injectable()
export class PresignedService {
private readonly logger = new Logger(PresignedService.name);
private readonly bucketName: string = 'smart-upload';
private s3Client: S3Client;
private readonly minioEndpoint: string = 'http://www.fox360.cn:9002';
constructor(
@InjectRepository(File)
private readonly fileRepository: Repository<File>,
private readonly minioService: MinioService,
private readonly configService: ConfigService,
@Inject(RedisService)
private readonly redisService: RedisService,
) {
this.initializeS3Client();
this.logger.log(`🚀 PresignedService初始化完成`);
this.logger.log(` 存储桶: ${this.bucketName}`);
this.logger.log(` 端点: ${this.minioEndpoint}`);
}
/**
* 初始化AWS S3客户端
*/
private initializeS3Client() {
try {
this.s3Client = new S3Client({
endpoint: this.minioEndpoint,
region: 'us-east-1',
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
},
forcePathStyle: true,
});
this.logger.log(`✅ S3客户端初始化完成,端点: ${this.minioEndpoint}`);
} catch (error) {
this.logger.error(`❌ S3客户端初始化失败: ${error.message}`);
}
}
/**
* ✅【新增】生成文件夹感知的文件路径
* 支持根据parentId生成不同的文件路径
*/
private generateFolderAwareFileKey(
userId: string,
filename: string,
parentId?: string,
prefix?: string
): string {
// 1. 清理文件名,替换特殊字符
const safeFilename = filename.replace(/[^\w.\-]/g, '_');
// 2. 生成时间戳和随机字符串
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 10);
// 3. 处理前缀和父文件夹
let cleanPrefix = '';
if (parentId) {
// 如果有父文件夹ID,使用文件夹路径
cleanPrefix = `uploads/${userId}/folders/${parentId}`;
} else if (prefix && prefix.trim()) {
// 使用自定义前缀
cleanPrefix = prefix.trim()
.replace(/^\/+|\/+$/g, '')
.replace(/\/\/+/g, '/');
} else {
// 默认路径
cleanPrefix = `uploads/${userId}`;
}
// 4. 添加日期路径
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
// 5. 构建最终文件路径
const fileKey = `${cleanPrefix}/${year}/${month}/${day}/${timestamp}-${randomStr}-${safeFilename}`;
// 6. 最终清理
const finalKey = fileKey.replace(/\/\/+/g, '/');
this.logger.debug(`📁 文件夹感知文件路径生成:`, {
userId,
parentId,
prefix,
cleanPrefix,
finalKey
});
return finalKey;
}
/**
* 主方法:生成预签名URL(支持文件夹)
*/
async generatePresignedUrl(
userId: string,
username: string,
options: PresignedUploadOptionsDto,
requestHost?: string
): Promise<any> {
this.logger.log(`📤 为用户 ${username} 生成预签名URL: ${options.filename}`, {
parentId: options.parentId,
prefix: options.prefix
});
const timestamp = Date.now();
const bucket = this.bucketName;
const expiresIn = options.expiresIn || 3600;
const contentType = options.contentType || 'application/octet-stream';
// ✅【修改】使用新的文件夹感知文件路径生成方法
const fileKey = this.generateFolderAwareFileKey(
userId,
options.filename,
options.parentId, // 传入父文件夹ID
options.prefix
);
this.logger.log(`📂 生成的文件路径: ${fileKey}`);
try {
// 使用AWS SDK生成预签名URL
const command = new PutObjectCommand({
Bucket: bucket,
Key: fileKey,
ContentType: contentType,
});
const presignedUrl = await getSignedUrl(this.s3Client, command, {
expiresIn: expiresIn,
});
// 保存到Redis(包含parentId信息)
await this.saveToRedis(userId, options, fileKey, expiresIn);
// 构建返回结果
return {
success: true,
data: {
url: presignedUrl,
originalUrl: presignedUrl,
key: fileKey,
uploadId: `upload-${timestamp}`,
expiresIn: expiresIn,
filename: options.filename,
contentType: contentType,
bucket: bucket,
parentId: options.parentId, // 返回给前端
publicUrl: `${this.minioEndpoint}/${bucket}/${fileKey}`,
note: '支持文件夹内上传',
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
debugInfo: {
endpointUsed: this.minioEndpoint,
requestHost,
filePath: fileKey,
hasParentId: !!options.parentId,
pathCheck: fileKey.includes('//') ? '警告:路径包含双斜杠' : '正常'
}
},
timestamp: new Date().toISOString()
};
} catch (error) {
this.logger.error(`❌ 生成预签名URL失败: ${error.message}`);
return await this.fallbackToMinioService(userId, username, options, fileKey, expiresIn);
}
}
/**
* ✅【修改】保存到Redis(支持parentId)
*/
private async saveToRedis(
userId: string,
options: PresignedUploadOptionsDto,
fileKey: string,
expiresIn: number
) {
const redisData: RedisPresignedData = {
userId,
filename: options.filename,
contentType: options.contentType,
fileKey,
expiresAt: Date.now() + expiresIn * 1000,
generatedAt: Date.now(),
parentId: options.parentId, // ✅ 保存父文件夹ID
};
await this.redisService.set(
`presigned:upload:${fileKey}`,
redisData,
expiresIn,
);
this.logger.debug(`💾 Redis保存成功: ${fileKey}`, { parentId: options.parentId });
}
/**
* 使用AWS SDK生成预签名URL
*/
private async generateAwsPresignedUrl(
bucket: string,
key: string,
contentType: string,
expiresIn: number
): Promise<string> {
try {
this.logger.log(`🔗 生成预签名URL - 桶: ${bucket}, 键: ${key}`);
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
const signedUrl = await getSignedUrl(this.s3Client, command, {
expiresIn: expiresIn,
});
this.logger.log(`✅ AWS预签名URL生成成功: ${signedUrl.substring(0, 100)}...`);
return signedUrl;
} catch (error) {
this.logger.error(`AWS SDK生成失败: ${error.message}`);
throw error;
}
}
/**
* 降级方案:使用MinIO Service
*/
private async fallbackToMinioService(
userId: string,
username: string,
options: PresignedUploadOptionsDto,
fileKey: string,
expiresIn: number
): Promise<any> {
this.logger.warn('⚠️ 使用MinIO Service降级方案');
try {
const minio = require('minio');
const url = new URL(this.minioEndpoint);
const tempClient = new minio.Client({
endPoint: url.hostname,
port: parseInt(url.port),
useSSL: url.protocol === 'https:',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
});
const presignedUrl = await tempClient.presignedPutObject(
this.bucketName,
fileKey,
expiresIn
);
return {
success: true,
data: {
url: presignedUrl,
originalUrl: presignedUrl,
key: fileKey,
uploadId: `upload-${Date.now()}`,
expiresIn: expiresIn,
filename: options.filename,
contentType: options.contentType,
bucket: this.bucketName,
parentId: options.parentId,
note: 'MinIO Service降级方案',
},
timestamp: new Date().toISOString()
};
} catch (error) {
this.logger.error(`❌ 降级方案也失败: ${error.message}`);
// 最终方案:手动构建URL
const dateStr = new Date().toISOString().replace(/[-:]/g, '').split('.')[0];
const dateOnly = new Date().toISOString().split('T')[0];
const fallbackUrl = `${this.minioEndpoint}/${this.bucketName}/${encodeURIComponent(fileKey).replace(/%2F/g, '/')}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F${dateOnly}%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=${dateStr}Z&X-Amz-Expires=${expiresIn}&X-Amz-SignedHeaders=host`;
return {
success: false,
data: {
url: fallbackUrl,
originalUrl: fallbackUrl,
key: fileKey,
uploadId: `upload-${Date.now()}`,
expiresIn: expiresIn,
filename: options.filename,
contentType: options.contentType,
bucket: this.bucketName,
parentId: options.parentId,
note: '最终降级:手动构建URL',
warning: '所有签名方法都失败,URL可能无法使用',
},
timestamp: new Date().toISOString()
};
}
}
/**
* ✅【修改】完成上传记录(支持parentId)
*/
async completePresignedUpload(
userId: string,
completeDto: CompletePresignedUploadDto,
): Promise<{
success: boolean;
file?: File;
message: string;
}> {
this.logger.log(`✅ 用户 ${userId} 完成预签名上传: ${completeDto.filename}`, {
parentId: completeDto.parentId,
fileKey: completeDto.fileKey
});
try {
// 验证文件是否存在
const fileExists = await this.minioService.fileExists(completeDto.fileKey);
if (!fileExists) {
return {
success: false,
message: '文件不存在于存储桶中'
};
}
// 从Redis获取原始上传信息
const redisData = await this.redisService.get<RedisPresignedData>(`presigned:upload:${completeDto.fileKey}`);
// 清理Redis记录
await this.redisService.del(`presigned:upload:${completeDto.fileKey}`);
// 使用正确的File实体属性
const file = this.fileRepository.create({
filename: completeDto.filename,
originalName: completeDto.filename,
mimetype: completeDto.mimetype,
size: completeDto.size,
path: completeDto.fileKey,
userId: userId,
status: FileStatus.COMPLETED,
storageProvider: 'minio',
hash: completeDto.hash,
etag: completeDto.etag,
metadata: completeDto.metadata || {},
uploadedSize: completeDto.size,
chunks: 1,
bucket: this.bucketName,
parentId: completeDto.parentId || (redisData?.parentId), // ✅ 优先使用传入的parentId,其次用Redis中的
isFolder: false,
});
const savedFile = await this.fileRepository.save(file);
this.logger.log(`📝 文件记录保存成功: ${savedFile.id}`, {
parentId: savedFile.parentId,
path: savedFile.path
});
return {
success: true,
file: savedFile,
message: '上传完成并记录已保存'
};
} catch (error) {
this.logger.error(`❌ 完成上传失败: ${error.message}`);
return {
success: false,
message: `完成上传失败: ${error.message}`
};
}
}
/**
* ✅【新增】生成文件夹内上传URL(专用方法)
*/
async generateFolderUploadUrl(
userId: string,
username: string,
folderId: string,
filename: string,
contentType: string = 'application/octet-stream',
expiresIn: number = 3600
): Promise<any> {
this.logger.log(`📁 生成文件夹内上传URL:`, {
userId,
username,
folderId,
filename
});
try {
// 构建上传选项
const options: PresignedUploadOptionsDto = {
filename,
contentType,
expiresIn,
parentId: folderId, // ✅ 指定父文件夹ID
};
// 调用主方法生成URL
const result = await this.generatePresignedUrl(userId, username, options);
if (!result.success) {
throw new Error('生成文件夹内上传URL失败');
}
return {
success: true,
data: {
...result.data,
folderId,
note: '文件夹内上传URL',
pathInfo: {
folderId,
fileKey: result.data.key,
expectedPath: `uploads/${userId}/folders/${folderId}/**`
}
}
};
} catch (error) {
this.logger.error(`❌ 生成文件夹内上传URL失败: ${error.message}`);
return {
success: false,
error: error.message,
message: '无法在指定文件夹内上传文件'
};
}
}
/**
* ✅【新增】批量生成文件夹内上传URL
*/
async batchGenerateFolderUploadUrls(
userId: string,
username: string,
folderId: string,
files: Array<{
filename: string;
contentType?: string;
}>,
expiresIn: number = 3600
): Promise<{
success: boolean;
count: number;
folderId: string;
files: Array<{
filename: string;
url: string;
key: string;
expiresAt: Date;
contentType?: string;
}>;
}> {
this.logger.log(`📦 批量生成文件夹内上传URL:`, {
userId,
folderId,
fileCount: files.length
});
const results = [];
for (const file of files) {
try {
const result = await this.generateFolderUploadUrl(
userId,
username,
folderId,
file.filename,
file.contentType,
expiresIn
);
if (result.success) {
results.push({
filename: file.filename,
url: result.data.url,
key: result.data.key,
expiresAt: new Date(Date.now() + expiresIn * 1000),
contentType: file.contentType,
});
}
} catch (error) {
this.logger.error(`生成文件 ${file.filename} 的URL失败: ${error.message}`);
}
}
return {
success: results.length > 0,
count: results.length,
folderId,
files: results
};
}
/**
* ✅【新增】获取文件夹内已上传文件
*/
async getFolderUploads(
userId: string,
folderId: string
): Promise<{
success: boolean;
count: number;
folderId: string;
files: Array<{
fileKey: string;
filename: string;
uploadedAt: Date;
size: number;
status: string;
}>;
}> {
try {
this.logger.log(`📂 获取文件夹内上传文件:`, { userId, folderId });
// 从Redis获取该文件夹的上传记录
const pattern = `presigned:upload:*`;
const keys = await this.redisService.keys(pattern);
const folderFiles = [];
for (const key of keys) {
const data = await this.redisService.get<RedisPresignedData>(key);
if (data && data.userId === userId && data.parentId === folderId) {
// 检查文件是否已上传
const exists = await this.minioService.fileExists(data.fileKey);
folderFiles.push({
fileKey: data.fileKey,
filename: data.filename,
uploadedAt: new Date(data.generatedAt),
size: 0, // 需要从MinIO获取实际大小
status: exists ? 'uploaded' : 'pending',
contentType: data.contentType,
});
}
}
return {
success: true,
count: folderFiles.length,
folderId,
files: folderFiles
};
} catch (error) {
this.logger.error(`获取文件夹内上传失败: ${error.message}`);
return {
success: false,
count: 0,
folderId,
files: []
};
}
}
/**
* ✅【修改】生成上传URL(支持parentId参数)
*/
async generateUploadUrl(
userId: string,
options: {
filename: string;
contentType?: string;
expiresIn?: number;
bucket?: string;
prefix?: string;
parentId?: string; // ✅ 新增:父文件夹ID
},
requestHost?: string
): Promise<{
success: boolean;
data: {
url: string;
originalUrl?: string;
key: string;
expiresAt: Date;
filename: string;
contentType?: string;
expiresIn: number;
bucket: string;
parentId?: string; // ✅ 返回父文件夹ID
publicUrl?: string;
};
timestamp: string;
}> {
this.logger.log(`📤 生成上传URL:`, {
userId,
filename: options.filename,
parentId: options.parentId
});
const result = await this.generatePresignedUrl(userId, userId, {
filename: options.filename,
contentType: options.contentType,
expiresIn: options.expiresIn,
bucket: options.bucket,
prefix: options.prefix,
parentId: options.parentId, // ✅ 传递父文件夹ID
}, requestHost);
return {
success: result.success,
data: {
url: result.data.url,
originalUrl: result.data.originalUrl,
key: result.data.key,
expiresAt: new Date(Date.now() + (result.data.expiresIn || 3600) * 1000),
filename: result.data.filename,
contentType: result.data.contentType,
expiresIn: result.data.expiresIn,
bucket: result.data.bucket,
parentId: result.data.parentId, // ✅ 返回父文件夹ID
publicUrl: result.data.publicUrl,
},
timestamp: result.timestamp || new Date().toISOString()
};
}
/**
* ✅【新增】验证文件夹上传权限
*/
async validateFolderUpload(
userId: string,
folderId: string
): Promise<{
success: boolean;
canUpload: boolean;
folderInfo?: any;
message: string;
}> {
try {
// 检查文件夹是否存在(通过查询数据库)
// 这里需要先获取File实体,查询文件夹信息
const folder = await this.fileRepository.findOne({
where: {
id: folderId,
userId,
isFolder: true,
},
});
if (!folder) {
return {
success: false,
canUpload: false,
message: '文件夹不存在或无权限'
};
}
// 检查用户是否有上传权限
// 这里可以添加更多的权限检查逻辑
const canUpload = folder.userId === userId;
return {
success: true,
canUpload,
folderInfo: {
id: folder.id,
name: folder.filename,
path: folder.path,
createdAt: folder.createdAt,
},
message: canUpload ? '可以上传到文件夹' : '无上传权限'
};
} catch (error) {
this.logger.error(`验证文件夹上传权限失败: ${error.message}`);
return {
success: false,
canUpload: false,
message: '验证失败'
};
}
}
/**
* ✅【新增】文件夹上传统计
*/
async getFolderUploadStats(
userId: string,
folderId: string
): Promise<{
success: boolean;
folderId: string;
stats: {
totalFiles: number;
totalSize: number;
lastUpload: Date | null;
uploadsToday: number;
};
}> {
try {
// 查询数据库获取文件夹内文件统计
const files = await this.fileRepository.find({
where: {
userId,
parentId: folderId,
isFolder: false,
},
order: {
createdAt: 'DESC',
},
});
const totalFiles = files.length;
const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);
const lastUpload = files.length > 0 ? files[0].createdAt : null;
// 计算今日上传数
const today = new Date();
today.setHours(0, 0, 0, 0);
const uploadsToday = files.filter(file =>
file.createdAt && new Date(file.createdAt) >= today
).length;
return {
success: true,
folderId,
stats: {
totalFiles,
totalSize,
lastUpload,
uploadsToday,
},
};
} catch (error) {
this.logger.error(`获取文件夹上传统计失败: ${error.message}`);
return {
success: false,
folderId,
stats: {
totalFiles: 0,
totalSize: 0,
lastUpload: null,
uploadsToday: 0,
},
};
}
}
/**
* 验证文件上传是否完成
*/
async verifyFileUpload(fileKey: string): Promise<{
success: boolean;
exists: boolean;
details?: any;
}> {
try {
this.logger.log(`🔍 验证文件上传: ${fileKey}`);
const redisData = await this.redisService.get<RedisPresignedData>(`presigned:upload:${fileKey}`);
const exists = await this.minioService.fileExists(fileKey);
if (exists) {
const fileDetails = await this.minioService.getFileDetails(fileKey);
return {
success: true,
exists: true,
details: {
fileInfo: fileDetails.info,
publicUrl: fileDetails.publicUrl,
signedUrl: fileDetails.signedUrl,
redisData,
folderId: redisData?.parentId, // ✅ 返回文件夹ID
bucket: this.bucketName,
}
};
}
return {
success: true,
exists: false,
details: {
message: '文件不存在于存储桶中',
redisData,
folderId: redisData?.parentId,
suggestion: redisData ? '预签名记录存在,但文件未上传' : '无预签名记录'
}
};
} catch (error) {
this.logger.error(`验证文件上传失败: ${error.message}`);
return {
success: false,
exists: false,
details: { error: error.message }
};
}
}
/**
* 获取未完成的上传任务
*/
async getUncompletedUploads(userId: string): Promise<{
success: boolean;
count: number;
uploads: Array<{
fileKey: string;
createdAt: number;
expiresAt: number;
filename: string;
status: string;
timeLeft: number;
parentId?: string; // ✅ 新增:返回父文件夹ID
}>;
summary: {
total: number;
expired: number;
active: number;
withParentId: number; // ✅ 新增:有父文件夹的数量
};
}> {
try {
this.logger.log(`📋 获取用户 ${userId} 的未完成上传`);
const pattern = `presigned:upload:*`;
const keys = await this.redisService.keys(pattern);
const uploads = [];
let expiredCount = 0;
let activeCount = 0;
let withParentIdCount = 0;
for (const key of keys) {
const data = await this.redisService.get<RedisPresignedData>(key);
if (data && data.userId === userId) {
const now = Date.now();
const isExpired = data.expiresAt < now;
if (isExpired) {
expiredCount++;
} else {
activeCount++;
}
if (data.parentId) {
withParentIdCount++;
}
uploads.push({
fileKey: data.fileKey,
createdAt: data.generatedAt || 0,
expiresAt: data.expiresAt || 0,
filename: data.filename || '未知文件',
status: isExpired ? 'expired' : 'active',
timeLeft: isExpired ? 0 : Math.max(0, Math.floor((data.expiresAt - now) / 1000)),
parentId: data.parentId, // ✅ 返回父文件夹ID
contentType: data.contentType,
});
}
}
uploads.sort((a, b) => b.createdAt - a.createdAt);
return {
success: true,
count: uploads.length,
uploads,
summary: {
total: uploads.length,
expired: expiredCount,
active: activeCount,
withParentId: withParentIdCount
}
};
} catch (error) {
this.logger.error(`获取未完成上传失败: ${error.message}`);
return {
success: false,
count: 0,
uploads: [],
summary: {
total: 0,
expired: 0,
active: 0,
withParentId: 0
}
};
}
}
/**
* 清理过期的上传记录
*/
async cleanupExpiredUploads(userId?: string): Promise<{
success: boolean;
cleaned: number;
failed: number;
details: Array<{
fileKey: string;
action: string;
success: boolean;
message?: string;
parentId?: string;
}>;
message: string;
}> {
try {
this.logger.log(`🧹 清理过期上传记录 ${userId ? `(用户: ${userId})` : ''}`);
const pattern = `presigned:upload:*`;
const keys = await this.redisService.keys(pattern);
let cleaned = 0;
let failed = 0;
const details = [];
const now = Date.now();
for (const key of keys) {
const data = await this.redisService.get<RedisPresignedData>(key);
if (data) {
if (userId && data.userId !== userId) {
continue;
}
const fileKey = data.fileKey;
try {
if (data.expiresAt < now) {
const fileExists = await this.minioService.fileExists(fileKey);
if (!fileExists) {
await this.redisService.del(key);
details.push({
fileKey,
action: 'delete_redis_record',
success: true,
message: '删除过期预签名记录',
parentId: data.parentId
});
cleaned++;
} else {
details.push({
fileKey,
action: 'keep_with_expired_status',
success: true,
message: '文件已上传,保留记录但标记为过期',
parentId: data.parentId
});
}
} else {
details.push({
fileKey,
action: 'skip_not_expired',
success: true,
message: `记录未过期,剩余时间: ${Math.floor((data.expiresAt - now) / 1000)}秒`,
parentId: data.parentId
});
}
} catch (error) {
failed++;
details.push({
fileKey,
action: 'cleanup',
success: false,
message: error.message,
parentId: data.parentId
});
}
}
}
const message = `清理完成:成功清理 ${cleaned} 条记录,失败 ${failed} 条`;
this.logger.log(message);
return {
success: true,
cleaned,
failed,
details,
message
};
} catch (error) {
this.logger.error(`清理过期上传失败: ${error.message}`);
return {
success: false,
cleaned: 0,
failed: 0,
details: [],
message: error.message,
};
}
}
/**
* 批量生成上传URL
*/
async batchGenerateUploadUrls(
userId: string,
username: string,
files: Array<{
filename: string;
contentType?: string;
customPath?: string;
}>,
options?: {
expiresIn?: number;
bucket?: string;
parentId?: string; // ✅ 新增:支持父文件夹ID
},
requestHost?: string
): Promise<{
success: boolean;
count: number;
files: Array<{
filename: string;
url: string;
originalUrl?: string;
key: string;
expiresAt: Date;
contentType?: string;
parentId?: string; // ✅ 返回父文件夹ID
}>;
timestamp: string;
}> {
this.logger.log(`📦 批量生成上传URL:`, {
userId,
parentId: options?.parentId,
fileCount: files.length
});
const results = [];
const expiresIn = options?.expiresIn || 3600;
const bucket = options?.bucket || this.bucketName;
for (const file of files) {
try {
const result = await this.generatePresignedUrl(
userId,
username,
{
filename: file.filename,
contentType: file.contentType,
expiresIn,
bucket,
prefix: file.customPath,
parentId: options?.parentId, // ✅ 传递父文件夹ID
},
requestHost
);
if (result.success) {
results.push({
filename: file.filename,
url: result.data.url,
originalUrl: result.data.originalUrl,
key: result.data.key,
expiresAt: new Date(Date.now() + expiresIn * 1000),
contentType: file.contentType,
parentId: options?.parentId, // ✅ 返回父文件夹ID
});
}
} catch (error) {
this.logger.error(`生成文件 ${file.filename} 的URL失败: ${error.message}`);
}
}
return {
success: results.length > 0,
count: results.length,
files: results,
timestamp: new Date().toISOString()
};
}
/**
* 获取上传统计信息
*/
async getUploadStats(userId?: string): Promise<{
success: boolean;
stats: {
totalUploads: number;
activeUploads: number;
expiredUploads: number;
totalFilesInBucket: number;
bucketSize: number;
userTotalUploads?: number;
folderUploads?: number; // ✅ 新增:文件夹上传统计
};
timestamp: string;
}> {
try {
const pattern = `presigned:upload:*`;
const keys = await this.redisService.keys(pattern);
let totalUploads = 0;
let activeUploads = 0;
let expiredUploads = 0;
let userTotalUploads = 0;
let folderUploads = 0;
const now = Date.now();
for (const key of keys) {
const data = await this.redisService.get<RedisPresignedData>(key);
if (data) {
totalUploads++;
if (userId && data.userId === userId) {
userTotalUploads++;
}
if (data.parentId) {
folderUploads++;
}
if (data.expiresAt < now) {
expiredUploads++;
} else {
activeUploads++;
}
}
}
let totalFilesInBucket = 0;
let bucketSize = 0;
try {
const bucketUsage = await this.minioService.getBucketUsage();
totalFilesInBucket = bucketUsage.totalObjects;
bucketSize = bucketUsage.totalSize;
} catch (error) {
this.logger.warn(`获取存储桶信息失败: ${error.message}`);
}
return {
success: true,
stats: {
totalUploads,
activeUploads,
expiredUploads,
totalFilesInBucket,
bucketSize,
...(userId && { userTotalUploads }),
folderUploads // ✅ 新增
},
timestamp: new Date().toISOString()
};
} catch (error) {
this.logger.error(`获取上传统计失败: ${error.message}`);
return {
success: false,
stats: {
totalUploads: 0,
activeUploads: 0,
expiredUploads: 0,
totalFilesInBucket: 0,
bucketSize: 0,
...(userId && { userTotalUploads: 0 }),
folderUploads: 0
},
timestamp: new Date().toISOString()
};
}
}
/**
* 测试预签名URL
*/
async testPresignedUpload(options: PresignedUploadOptionsDto): Promise<{
success: boolean;
url: string;
originalUrl?: string;
curlCommand: string;
testResult?: string;
}> {
try {
const bucket = this.bucketName;
const key = this.generateFolderAwareFileKey(
'test-user',
options.filename,
options.parentId, // ✅ 测试文件夹上传
'test'
);
const contentType = options.contentType || 'text/plain';
const url = await this.generateAwsPresignedUrl(
bucket,
key,
contentType,
600
);
const curlCommand = `curl -X PUT "${url}" -H "Content-Type: ${contentType}" --data-binary "Test content from ${new Date().toISOString()}"`;
return {
success: true,
url: url,
originalUrl: url,
curlCommand,
};
} catch (error) {
return {
success: false,
url: '',
curlCommand: '',
testResult: error.message,
};
}
}
/**
* 智能生成预签名URL
*/
async generateSmartPresignedUrl(
userId: string,
username: string,
options: PresignedUploadOptionsDto,
clientInfo?: {
hostname?: string;
origin?: string;
}
): Promise<any> {
this.logger.log(`🤖 智能生成预签名URL,客户端: ${JSON.stringify(clientInfo)}`, {
parentId: options.parentId
});
return this.generatePresignedUrl(userId, username, options, clientInfo?.hostname);
}
/**
* 获取URL生成选项
*/
getUrlGenerationOptions(): {
endpoint: string;
bucket: string;
architecture: string;
pathSafety: boolean;
folderSupport: boolean; // ✅ 新增:文件夹支持
} {
return {
endpoint: this.minioEndpoint,
bucket: this.bucketName,
architecture: '前端通过Nginx代理(9002)访问MinIO集群',
pathSafety: true,
folderSupport: true, // ✅ 新增
};
}
/**
* 获取用户的预签名URL历史
*/
async getUserPresignedHistory(
userId: string,
limit: number = 50,
offset: number = 0
): Promise<{
success: boolean;
total: number;
history: Array<{
fileKey: string;
filename: string;
generatedAt: Date;
expiresAt: Date;
status: string;
uploaded: boolean;
parentId?: string; // ✅ 新增:父文件夹ID
}>;
}> {
try {
const pattern = `presigned:upload:*`;
const keys = await this.redisService.keys(pattern);
const history = [];
for (const key of keys) {
const data = await this.redisService.get<RedisPresignedData>(key);
if (data && data.userId === userId) {
const uploaded = await this.minioService.fileExists(data.fileKey);
history.push({
fileKey: data.fileKey,
filename: data.filename,
generatedAt: new Date(data.generatedAt),
expiresAt: new Date(data.expiresAt),
status: data.expiresAt < Date.now() ? 'expired' : 'active',
uploaded,
parentId: data.parentId, // ✅ 新增
});
}
}
history.sort((a, b) => b.generatedAt.getTime() - a.generatedAt.getTime());
return {
success: true,
total: history.length,
history: history.slice(offset, offset + limit),
};
} catch (error) {
this.logger.error(`获取历史失败: ${error.message}`);
return {
success: false,
total: 0,
history: [],
};
}
}
/**
* 获取用户上传状态
*/
async getUserUploadStates(userId: string): Promise<{
success: boolean;
data: {
total: number;
completed: number;
pending: number;
failed: number;
uploads: Array<{
fileKey: string;
filename: string;
status: string;
createdAt: Date;
updatedAt: Date;
size?: number;
parentId?: string; // ✅ 新增:父文件夹ID
}>;
};
}> {
try {
const pattern = `upload:status:${userId}:*`;
const keys = await this.redisService.keys(pattern);
const uploads = [];
let completed = 0;
let pending = 0;
let failed = 0;
for (const key of keys) {
const statusData = await this.redisService.get<{
status: string;
updatedAt: number;
fileKey: string;
userId: string;
parentId?: string; // ✅ 新增
}>(key);
if (statusData) {
uploads.push({
fileKey: statusData.fileKey,
filename: statusData.fileKey.split('/').pop() || 'unknown',
status: statusData.status,
createdAt: new Date(),
updatedAt: new Date(statusData.updatedAt),
parentId: statusData.parentId, // ✅ 新增
});
switch (statusData.status) {
case 'completed': completed++; break;
case 'pending': pending++; break;
case 'failed': failed++; break;
}
}
}
return {
success: true,
data: {
total: uploads.length,
completed,
pending,
failed,
uploads: uploads.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
}
};
} catch (error) {
this.logger.error(`获取用户上传状态失败: ${error.message}`);
return {
success: false,
data: {
total: 0,
completed: 0,
pending: 0,
failed: 0,
uploads: []
}
};
}
}
/**
* 跟踪上传状态
*/
async trackUploadStatus(
userId: string,
fileKey: string,
status: string,
details?: any
): Promise<{
success: boolean;
message: string;
}> {
const statusKey = `upload:status:${userId}:${fileKey}`;
try {
await this.redisService.set(statusKey, {
status,
updatedAt: Date.now(),
fileKey,
userId,
details
}, 86400);
this.logger.log(`📊 上传状态跟踪: ${userId} -> ${fileKey} -> ${status}`);
return {
success: true,
message: '状态跟踪成功'
};
} catch (error) {
this.logger.error(`状态跟踪失败: ${error.message}`);
return {
success: false,
message: `状态跟踪失败: ${error.message}`
};
}
}
/**
* 检查预签名URL是否有效
*/
async validatePresignedUrl(url: string): Promise<{
valid: boolean;
expiresAt?: Date;
fileKey?: string;
error?: string;
}> {
try {
const urlObj = new URL(url);
const params = new URLSearchParams(urlObj.search);
const signature = params.get('X-Amz-Signature');
const expires = params.get('X-Amz-Expires');
const dateStr = params.get('X-Amz-Date');
if (!signature || !expires || !dateStr) {
return { valid: false, error: '缺少签名参数' };
}
const date = new Date(
parseInt(dateStr.substring(0, 4)),
parseInt(dateStr.substring(4, 6)) - 1,
parseInt(dateStr.substring(6, 8)),
parseInt(dateStr.substring(9, 11)),
parseInt(dateStr.substring(11, 13)),
parseInt(dateStr.substring(13, 15))
);
const expiresAt = new Date(date.getTime() + parseInt(expires) * 1000);
const isValid = expiresAt > new Date();
const pathParts = urlObj.pathname.split('/');
const fileKey = pathParts.slice(2).join('/');
return {
valid: isValid,
expiresAt,
fileKey,
};
} catch (error) {
return { valid: false, error: error.message };
}
}
/**
* 获取配置信息(用于调试)
*/
getConfigInfo() {
return {
architecture: {
frontend: '192.168.39.152:5173',
backend: '192.168.39.152:3000',
nginxProxy: 'www.fox360.cn:9002',
minioCluster: 'www.fox360.cn:9000/9001 (HTTPS)',
},
urls: {
endpoint: this.minioEndpoint,
bucket: this.bucketName,
},
features: {
pathSafety: '已修复双斜杠问题',
folderSupport: '支持文件夹内上传',
uploadPathPattern: 'uploads/{userId}/folders/{folderId}/YYYY/MM/DD/timestamp-random-filename'
},
note: '前端通过Nginx代理(9002)上传文件到MinIO集群'
};
}
/**
* ✅【新增】测试文件夹上传功能
*/
async testFolderUpload(
userId: string,
folderId: string
): Promise<{
success: boolean;
testFiles: Array<{
filename: string;
url: string;
fileKey: string;
folderId: string;
status: string;
}>;
summary: {
total: number;
generated: number;
expectedPath: string;
};
}> {
const testFiles = [
{ filename: 'test-file-1.txt', contentType: 'text/plain' },
{ filename: 'test-file-2.jpg', contentType: 'image/jpeg' },
{ filename: 'test-file-3.pdf', contentType: 'application/pdf' },
];
const results = [];
for (const testFile of testFiles) {
try {
const result = await this.generateFolderUploadUrl(
userId,
'test-user',
folderId,
testFile.filename,
testFile.contentType,
600
);
if (result.success) {
results.push({
filename: testFile.filename,
url: result.data.url.substring(0, 100) + '...',
fileKey: result.data.key,
folderId,
status: 'generated',
});
} else {
results.push({
filename: testFile.filename,
url: '',
fileKey: '',
folderId,
status: 'failed',
});
}
} catch (error) {
results.push({
filename: testFile.filename,
url: '',
fileKey: '',
folderId,
status: 'error',
});
}
}
return {
success: results.some(r => r.status === 'generated'),
testFiles: results,
summary: {
total: testFiles.length,
generated: results.filter(r => r.status === 'generated').length,
expectedPath: `uploads/${userId}/folders/${folderId}/**`
}
};
}
}