Files
2026-05-13 14:20:41 +00:00

353 lines
11 KiB
JavaScript

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