353 lines
11 KiB
JavaScript
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,
|
|
}));
|
|
},
|
|
};
|