initial commit
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
import { userFileRepository } from '../repositories/user-file.repository.js';
|
||||
import {
|
||||
uploadFile as s3UploadFile,
|
||||
getPresignedUrl as s3GetPresignedUrl,
|
||||
deleteFile as s3DeleteFile,
|
||||
getImageInputKey,
|
||||
getVideoInputKey,
|
||||
createMultipartUpload as s3CreateMultipartUpload,
|
||||
getPresignedPartUrl as s3GetPresignedPartUrl,
|
||||
completeMultipartUpload as s3CompleteMultipartUpload,
|
||||
abortMultipartUpload as s3AbortMultipartUpload,
|
||||
} from './s3.service.js';
|
||||
|
||||
/**
|
||||
* Сервис для управления файлами пользователей
|
||||
*/
|
||||
export const fileService = {
|
||||
/**
|
||||
* Загрузить изображение в S3
|
||||
*/
|
||||
async uploadImage({ userId, file, folder = 'images_input', generationUuid = null, generationStepId = null }) {
|
||||
console.log('[fileService.uploadImage] userId:', userId, 'file:', file?.originalname, 'folder:', folder);
|
||||
|
||||
const s3Key = getImageInputKey(file.originalname, userId, folder);
|
||||
await s3UploadFile(s3Key, file.buffer, file.mimetype);
|
||||
|
||||
const fileRecord = await userFileRepository.create({
|
||||
userId,
|
||||
s3Key,
|
||||
originalFilename: file.originalname,
|
||||
fileSize: file.size,
|
||||
contentType: file.mimetype,
|
||||
fileType: 'image',
|
||||
folder,
|
||||
generationUuid,
|
||||
generationStepId,
|
||||
});
|
||||
|
||||
return fileRecord;
|
||||
},
|
||||
|
||||
/**
|
||||
* Загрузить видео в S3
|
||||
*/
|
||||
async uploadVideo({ userId, file, folder = 'videos_input', generationUuid = null, generationStepId = null }) {
|
||||
console.log('[fileService.uploadVideo] userId:', userId, 'file:', file?.originalname, 'folder:', folder);
|
||||
|
||||
const s3Key = getVideoInputKey(file.originalname, userId, folder);
|
||||
await s3UploadFile(s3Key, file.buffer, file.mimetype);
|
||||
|
||||
const fileRecord = await userFileRepository.create({
|
||||
userId,
|
||||
s3Key,
|
||||
originalFilename: file.originalname,
|
||||
fileSize: file.size,
|
||||
contentType: file.mimetype,
|
||||
fileType: 'video',
|
||||
folder,
|
||||
generationUuid,
|
||||
generationStepId,
|
||||
});
|
||||
|
||||
return fileRecord;
|
||||
},
|
||||
|
||||
/**
|
||||
* Инициировать multipart upload для прямой загрузки в S3
|
||||
*/
|
||||
async initMultipartUpload({ userId, filename, contentType, fileSize, folder = 'videos_input', generationUuid = null, generationStepId = null }) {
|
||||
console.log('[fileService.initMultipartUpload] userId:', userId, 'filename:', filename, 'size:', fileSize);
|
||||
|
||||
const s3Key = getVideoInputKey(filename, userId, folder);
|
||||
|
||||
// Инициируем multipart upload в S3
|
||||
const { uploadId } = await s3CreateMultipartUpload(s3Key, contentType);
|
||||
|
||||
// Создаём запись в БД со статусом "uploading" и uploadId
|
||||
const fileRecord = await userFileRepository.create({
|
||||
userId,
|
||||
s3Key,
|
||||
originalFilename: filename,
|
||||
fileSize,
|
||||
contentType,
|
||||
fileType: 'video',
|
||||
folder,
|
||||
generationUuid,
|
||||
generationStepId,
|
||||
status: 'uploading',
|
||||
uploadId, // Сохраняем uploadId в БД
|
||||
});
|
||||
|
||||
// Вычисляем количество частей (10MB на часть - оптимизировано для скорости)
|
||||
const PART_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const partCount = Math.ceil(fileSize / PART_SIZE);
|
||||
|
||||
// Создаём presigned URLs для всех частей
|
||||
const parts = [];
|
||||
for (let i = 1; i <= partCount; i++) {
|
||||
const presignedUrl = await s3GetPresignedPartUrl(s3Key, uploadId, i, 3600); // 1 час на загрузку части
|
||||
parts.push({
|
||||
partNumber: i,
|
||||
presignedUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
fileId: fileRecord.id,
|
||||
s3Key,
|
||||
uploadId,
|
||||
parts,
|
||||
partCount,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Завершить multipart upload
|
||||
*/
|
||||
async completeMultipartUpload({ fileId, userId, uploadId, parts }) {
|
||||
console.log('[fileService.completeMultipartUpload] fileId:', fileId, 'uploadId:', uploadId);
|
||||
|
||||
// Проверяем владение файлом
|
||||
const fileRecord = await userFileRepository.findByIdAndOwner(fileId, userId);
|
||||
if (!fileRecord) {
|
||||
const error = new Error('Файл не найден');
|
||||
error.code = 'FILE_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Завершаем multipart upload в S3
|
||||
const result = await s3CompleteMultipartUpload(fileRecord.s3_key, uploadId, parts);
|
||||
|
||||
// Обновляем статус файла в БД
|
||||
await userFileRepository.update(fileId, {
|
||||
status: 'uploaded',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
fileId: fileRecord.id,
|
||||
s3Key: fileRecord.s3_key,
|
||||
location: result.location,
|
||||
etag: result.etag,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Отменить multipart upload
|
||||
*/
|
||||
async abortMultipartUpload({ fileId, userId, uploadId }) {
|
||||
console.log('[fileService.abortMultipartUpload] fileId:', fileId);
|
||||
|
||||
const fileRecord = await userFileRepository.findByIdAndOwner(fileId, userId);
|
||||
if (!fileRecord) {
|
||||
const error = new Error('Файл не найден');
|
||||
error.code = 'FILE_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Отменяем multipart upload в S3
|
||||
await s3AbortMultipartUpload(fileRecord.s3_key, uploadId);
|
||||
|
||||
// Удаляем запись из БД
|
||||
await userFileRepository.delete(fileId);
|
||||
|
||||
return { fileId, aborted: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить URL для файла с проверкой владения
|
||||
* Если файл в публичной папке — возвращается прямой URL, иначе presigned
|
||||
*/
|
||||
async getPresignedUrl({ userId, fileId, s3Key, expiresIn }) {
|
||||
let fileRecord;
|
||||
|
||||
// Поиск файла по ID или по ключу
|
||||
if (fileId) {
|
||||
fileRecord = await userFileRepository.findByIdAndOwner(fileId, userId);
|
||||
} else if (s3Key) {
|
||||
fileRecord = await userFileRepository.findByKeyAndOwner(s3Key, userId);
|
||||
}
|
||||
|
||||
if (!fileRecord) {
|
||||
const error = new Error('Файл не найден или у вас нет доступа к нему');
|
||||
error.code = 'FILE_NOT_FOUND';
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Для публичных папок возвращаем прямой URL через nginx
|
||||
const publicFolders = ['images_input', 'videos_input', 'videos_output'];
|
||||
const isPublic = publicFolders.includes(fileRecord.folder);
|
||||
|
||||
let url;
|
||||
if (isPublic) {
|
||||
// Прямой URL через nginx прокси
|
||||
url = `/files/${fileRecord.s3_key}`;
|
||||
} else {
|
||||
// Presigned URL для приватных файлов
|
||||
url = await s3GetPresignedUrl(fileRecord.s3_key, expiresIn);
|
||||
}
|
||||
|
||||
return {
|
||||
id: fileRecord.id,
|
||||
s3Key: fileRecord.s3_key,
|
||||
originalFilename: fileRecord.original_filename,
|
||||
contentType: fileRecord.content_type,
|
||||
fileSize: fileRecord.file_size,
|
||||
url,
|
||||
isPublic,
|
||||
expiresIn: isPublic ? null : (expiresIn || 3600),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить список файлов пользователя
|
||||
*/
|
||||
async listUserFiles(userId, options = {}) {
|
||||
const defaultOptions = {
|
||||
fileType: 'image',
|
||||
folder: 'images_input',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
const [files, total] = await Promise.all([
|
||||
userFileRepository.findByUser(userId, mergedOptions),
|
||||
userFileRepository.countByUser(userId, mergedOptions),
|
||||
]);
|
||||
|
||||
return {
|
||||
files: files.map((f) => ({
|
||||
id: f.id,
|
||||
s3Key: f.s3_key,
|
||||
originalFilename: f.original_filename,
|
||||
contentType: f.content_type,
|
||||
fileSize: f.file_size,
|
||||
fileType: f.file_type,
|
||||
folder: f.folder,
|
||||
createdAt: f.created_at,
|
||||
})),
|
||||
total,
|
||||
limit: mergedOptions.limit,
|
||||
offset: mergedOptions.offset,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Удалить файл пользователя (из S3 и БД)
|
||||
*/
|
||||
async deleteFile({ userId, fileId, s3Key }) {
|
||||
let fileRecord;
|
||||
|
||||
// Поиск файла с проверкой владения
|
||||
if (fileId) {
|
||||
fileRecord = await userFileRepository.deleteByIdAndOwner(fileId, userId);
|
||||
} else if (s3Key) {
|
||||
fileRecord = await userFileRepository.deleteByKeyAndOwner(s3Key, userId);
|
||||
}
|
||||
|
||||
if (!fileRecord) {
|
||||
const error = new Error('Файл не найден или у вас нет доступа к нему');
|
||||
error.code = 'FILE_NOT_FOUND';
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Удаление из S3
|
||||
try {
|
||||
await s3DeleteFile(fileRecord.s3_key);
|
||||
} catch (err) {
|
||||
// Логируем ошибку, но не прерываем процесс
|
||||
// Файл уже удалён из БД
|
||||
console.error(`Ошибка удаления файла из S3: ${fileRecord.s3_key}`, err);
|
||||
}
|
||||
|
||||
return {
|
||||
id: fileRecord.id,
|
||||
s3Key: fileRecord.s3_key,
|
||||
deleted: true,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить статистику по файлам пользователя
|
||||
*/
|
||||
async getFileStats(userId) {
|
||||
const { pool } = await import('../db.js');
|
||||
|
||||
const [imageCount, videoCount, totalSize] = await Promise.all([
|
||||
userFileRepository.countByUser(userId, { fileType: 'image' }),
|
||||
userFileRepository.countByUser(userId, { fileType: 'video' }),
|
||||
(async () => {
|
||||
const query = `
|
||||
SELECT COALESCE(SUM(file_size), 0) as total
|
||||
FROM uno_bff.user_files
|
||||
WHERE user_id = $1
|
||||
`;
|
||||
const result = await pool.query(query, [userId]);
|
||||
return parseInt(result.rows[0].total, 10);
|
||||
})(),
|
||||
]);
|
||||
|
||||
return {
|
||||
imageCount,
|
||||
videoCount,
|
||||
totalCount: imageCount + videoCount,
|
||||
totalSizeBytes: totalSize,
|
||||
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить файлы генерации
|
||||
*/
|
||||
async getGenerationFiles(generationUuid, userId) {
|
||||
const files = await userFileRepository.findByGeneration(generationUuid, userId);
|
||||
|
||||
return files.map((f) => ({
|
||||
id: f.id,
|
||||
s3Key: f.s3_key,
|
||||
originalFilename: f.original_filename,
|
||||
contentType: f.content_type,
|
||||
fileSize: f.file_size,
|
||||
fileType: f.file_type,
|
||||
folder: f.folder,
|
||||
generationUuid: f.generation_uuid,
|
||||
generationStepId: f.generation_step_id,
|
||||
createdAt: f.created_at,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить файлы шага генерации
|
||||
*/
|
||||
async getGenerationStepFiles(generationUuid, stepId, userId) {
|
||||
const files = await userFileRepository.findByGenerationStep(generationUuid, stepId, userId);
|
||||
|
||||
return files.map((f) => ({
|
||||
id: f.id,
|
||||
s3Key: f.s3_key,
|
||||
originalFilename: f.original_filename,
|
||||
contentType: f.content_type,
|
||||
fileSize: f.file_size,
|
||||
fileType: f.file_type,
|
||||
folder: f.folder,
|
||||
generationUuid: f.generation_uuid,
|
||||
generationStepId: f.generation_step_id,
|
||||
createdAt: f.created_at,
|
||||
}));
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user