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, })); }, };