fix(IMAP): оптимизация обработки email для предотвращения memory exhaustion
Корневая проблема: библиотека webklex/php-imap загружала все тела писем по умолчанию, что приводило к исчерпанию 256MB памяти при 50+ письмах. Решение (3 уровня): Уровень 1 - Конфигурация: - config/imap.php: добавлены fetch_body=false, fetch_flags=false, fetch_limit=20 - php.ini: увеличен memory_limit с 256M до 1G, добавлены OPcache настройки Уровень 2 - Оптимизация кода: - FetchUnreadEmailsTask: setFetchBody(false), лимиты, MemoryAwareTrait - FetchEmailNewsAction: batch-обработка (10 писем), gc_collect_cycles() - DownloadAttachmentsTask: lazy load вложений, extension whitelist (security) - ConnectToImapTask: MemoryAwareTrait для мониторинга Уровень 3 - Инфраструктура: - ProcessEmailNewsJob: queue job для асинхронной обработки (3 попытки, 300s timeout) - FetchEmailNewsCommand: поддержка --async режима - MemoryAwareTrait: reusable трейт для мониторинга памяти Security fixes: - Extension whitelist для предотвращения path traversal - Убраны реальные email из config/imap.php - Добавлена проверка на null message объект Docs: - README_EMAIL_OPTIMIZATION.md: полная документация по оптимизации Fixes: Allowed memory size of 268435456 bytes exhausted Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions\EmailNews;
|
||||
|
||||
use App\Containers\Dashboard\Data\EmailAttachmentData;
|
||||
use App\Containers\Dashboard\Exceptions\EmailFetchException;
|
||||
use App\Containers\Dashboard\Tasks\Email\ConnectToImapTask;
|
||||
use App\Containers\Dashboard\Tasks\Email\DownloadAttachmentsTask;
|
||||
use App\Containers\Dashboard\Tasks\Email\FetchUnreadEmailsTask;
|
||||
use App\Containers\Dashboard\Tasks\Email\FilterBySenderTask;
|
||||
use App\Containers\Dashboard\Tasks\Email\MarkEmailAsReadTask;
|
||||
use App\Containers\Dashboard\Actions\EmailNews\ProcessMixedFilesAction;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
use Webklex\PHPIMAP\Folder;
|
||||
|
||||
/**
|
||||
* Оркестрация процесса получения новостей из Email
|
||||
*
|
||||
* OPTIMIZATION: Реализует batch-обработку писем для предотвращения переполнения памяти.
|
||||
* Каждое письмо обрабатывается отдельно с освобождением памяти после обработки.
|
||||
*/
|
||||
class FetchEmailNewsAction
|
||||
{
|
||||
/**
|
||||
* Максимальное использование памяти (в байтах) перед принудительной сборкой мусора
|
||||
*/
|
||||
private const MEMORY_THRESHOLD = 400 * 1024 * 1024; // 400MB
|
||||
|
||||
/**
|
||||
* Размер batch-обработки (писем за один цикл)
|
||||
*/
|
||||
private const BATCH_SIZE = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectToImapTask $connectToImapTask,
|
||||
private readonly FetchUnreadEmailsTask $fetchUnreadEmailsTask,
|
||||
private readonly FilterBySenderTask $filterBySenderTask,
|
||||
private readonly DownloadAttachmentsTask $downloadAttachmentsTask,
|
||||
private readonly MarkEmailAsReadTask $markEmailAsReadTask,
|
||||
private readonly ProcessMixedFilesAction $processMixedFilesAction,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Выполнить получение и обработку новостей из Email
|
||||
*
|
||||
* @return array Результат обработки
|
||||
* @throws EmailFetchException
|
||||
*/
|
||||
public function run(): array
|
||||
{
|
||||
// Проверяем, включена ли функция
|
||||
if (!config('email-news.enabled', true)) {
|
||||
Log::warning('[FetchEmailNewsAction] Функция отключена в конфиге');
|
||||
throw EmailFetchException::featureDisabled();
|
||||
}
|
||||
|
||||
Log::info('[FetchEmailNewsAction] Начало получения новостей из Email');
|
||||
|
||||
$result = [
|
||||
'processed_emails' => 0,
|
||||
'skipped_emails' => 0,
|
||||
'created_posts' => 0,
|
||||
'errors' => [],
|
||||
'posts' => [],
|
||||
'batches_processed' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
// Подключаемся к IMAP
|
||||
$client = $this->connectToImapTask->run();
|
||||
|
||||
// Получаем папку
|
||||
$folder = $this->connectToImapTask->getFolder(
|
||||
$client,
|
||||
config('email-news.folder', 'INBOX')
|
||||
);
|
||||
|
||||
// OPTIMIZATION: Batch-обработка с контролем памяти
|
||||
$hasMoreEmails = true;
|
||||
|
||||
while ($hasMoreEmails) {
|
||||
Log::info('[FetchEmailNewsAction] Обработка batch', [
|
||||
'batch' => $result['batches_processed'] + 1,
|
||||
'batch_size' => self::BATCH_SIZE,
|
||||
'memory_usage' => round(memory_get_usage(true) / 1024 / 1024, 2) . 'MB',
|
||||
]);
|
||||
|
||||
// Получаем batch писем (без offset, т.к. письма помечаются как прочитанные)
|
||||
$emails = $this->fetchUnreadEmailsTask->run(
|
||||
$folder,
|
||||
self::BATCH_SIZE
|
||||
);
|
||||
|
||||
if (empty($emails)) {
|
||||
$hasMoreEmails = false;
|
||||
Log::info('[FetchEmailNewsAction] Нет больше писем для обработки');
|
||||
break;
|
||||
}
|
||||
|
||||
// Фильтруем по отправителю
|
||||
$filteredEmails = $this->filterBySenderTask->run($emails);
|
||||
$result['skipped_emails'] += count($emails) - count($filteredEmails);
|
||||
|
||||
// Обрабатываем каждое письмо в batch
|
||||
foreach ($filteredEmails as $email) {
|
||||
$emailResult = $this->processEmail($email, $folder);
|
||||
|
||||
if ($emailResult['success']) {
|
||||
$result['created_posts']++;
|
||||
$result['posts'][] = $emailResult['post'];
|
||||
} else {
|
||||
$result['errors'][] = [
|
||||
'email_subject' => $email['subject'] ?? 'unknown',
|
||||
'error' => $emailResult['error'],
|
||||
];
|
||||
}
|
||||
|
||||
$result['processed_emails']++;
|
||||
}
|
||||
|
||||
$result['batches_processed']++;
|
||||
|
||||
// OPTIMIZATION: Принудительная сборка мусора после каждого batch
|
||||
$this->collectGarbageIfNeeded();
|
||||
}
|
||||
|
||||
Log::info('[FetchEmailNewsAction] Завершено', [
|
||||
'processed' => $result['processed_emails'],
|
||||
'created_posts' => $result['created_posts'],
|
||||
'skipped' => $result['skipped_emails'],
|
||||
'errors_count' => count($result['errors']),
|
||||
'batches' => $result['batches_processed'],
|
||||
'peak_memory' => round(memory_get_peak_usage(true) / 1024 / 1024, 2) . 'MB',
|
||||
]);
|
||||
|
||||
// Добавляем peak_memory в результат для отображения в команде
|
||||
$result['peak_memory'] = round(memory_get_peak_usage(true) / 1024 / 1024, 2) . 'MB';
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[FetchEmailNewsAction] Критическая ошибка', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать одно письмо
|
||||
*
|
||||
* @param array $email Данные письма
|
||||
* @param Folder $folder IMAP папка
|
||||
* @return array Результат обработки
|
||||
*/
|
||||
private function processEmail(array $email, Folder $folder): array
|
||||
{
|
||||
Log::info('[FetchEmailNewsAction:processEmail] Обработка письма', [
|
||||
'subject' => $email['subject'] ?? 'unknown',
|
||||
'from' => $email['from_email'] ?? 'unknown',
|
||||
'uid' => $email['uid'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
try {
|
||||
// SAFETY: Проверка наличия валидного IMAP message объекта
|
||||
if (!isset($email['message']) || !is_object($email['message'])) {
|
||||
Log::warning('[FetchEmailNewsAction:processEmail] Отсутствует IMAP message объект', [
|
||||
'subject' => $email['subject'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid IMAP message object',
|
||||
];
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Загружаем вложения (тело загружается лениво)
|
||||
$attachments = $this->downloadAttachmentsTask->run($email['message']);
|
||||
|
||||
// Проверяем, есть ли DOC/DOCX файл
|
||||
$hasDocument = collect($attachments)->contains(fn($att) => $att->isDocument());
|
||||
|
||||
if (!$hasDocument) {
|
||||
Log::warning('[FetchEmailNewsAction:processEmail] Нет DOC/DOCX файла во вложениях', [
|
||||
'subject' => $email['subject'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Нет DOC/DOCX файла для извлечения текста',
|
||||
];
|
||||
}
|
||||
|
||||
// Конвертируем вложения в UploadedFile
|
||||
$uploadedFiles = $this->convertToUploadedFiles($attachments);
|
||||
|
||||
// Обрабатываем через существующий ProcessMixedFilesAction
|
||||
$postResult = $this->processMixedFilesAction->run($uploadedFiles);
|
||||
|
||||
// Помечаем письмо как прочитанное
|
||||
$this->markEmail($email['message'], $folder);
|
||||
|
||||
Log::info('[FetchEmailNewsAction:processEmail] Письмо успешно обработано', [
|
||||
'subject' => $email['subject'] ?? 'unknown',
|
||||
'post_id' => $postResult['post']->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'post' => $postResult['post'],
|
||||
'attachments_count' => count($attachments),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[FetchEmailNewsAction:processEmail] Ошибка обработки письма', [
|
||||
'subject' => $email['subject'] ?? 'unknown',
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертировать EmailAttachmentData в UploadedFile
|
||||
*
|
||||
* @param array<EmailAttachmentData> $attachments
|
||||
* @return \Illuminate\Support\Collection<UploadedFile>
|
||||
*/
|
||||
private function convertToUploadedFiles(array $attachments): \Illuminate\Support\Collection
|
||||
{
|
||||
$uploadedFiles = [];
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$fullPath = storage_path('app/' . $attachment->path);
|
||||
|
||||
if (!file_exists($fullPath)) {
|
||||
Log::warning('[FetchEmailNewsAction:convertToUploadedFiles] Файл не найден', [
|
||||
'path' => $attachment->path,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Создаём UploadedFile из сохранённого файла
|
||||
$uploadedFile = new UploadedFile(
|
||||
$fullPath,
|
||||
$attachment->filename,
|
||||
$attachment->mimeType,
|
||||
null,
|
||||
true // test = false (файл валиден)
|
||||
);
|
||||
|
||||
$uploadedFiles[] = $uploadedFile;
|
||||
}
|
||||
|
||||
Log::info('[FetchEmailNewsAction:convertToUploadedFiles] Конвертировано файлов', [
|
||||
'count' => count($uploadedFiles),
|
||||
]);
|
||||
|
||||
return collect($uploadedFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пометить письмо как прочитанное (и возможно переместить)
|
||||
*
|
||||
* @param object $message IMAP сообщение
|
||||
* @param Folder $folder Текущая папка
|
||||
*/
|
||||
private function markEmail(object $message, Folder $folder): void
|
||||
{
|
||||
$moveToFolder = config('email-news.move_to_folder');
|
||||
|
||||
if ($moveToFolder) {
|
||||
$this->markEmailAsReadTask->markAndMove($message, $moveToFolder);
|
||||
} elseif (config('email-news.mark_as_read', true)) {
|
||||
$this->markEmailAsReadTask->run($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIMIZATION: Проверка использования памяти и принудительная сборка мусора
|
||||
*
|
||||
* Предотвращает переполнение памяти при обработке больших писем.
|
||||
*/
|
||||
private function collectGarbageIfNeeded(): void
|
||||
{
|
||||
$currentMemory = memory_get_usage(true);
|
||||
|
||||
if ($currentMemory > self::MEMORY_THRESHOLD) {
|
||||
$memoryBefore = round($currentMemory / 1024 / 1024, 2);
|
||||
|
||||
// Принудительная сборка мусора
|
||||
gc_collect_cycles();
|
||||
|
||||
$memoryAfter = round(memory_get_usage(true) / 1024 / 1024, 2);
|
||||
$freed = round(($memoryBefore - $memoryAfter), 2);
|
||||
|
||||
Log::info('[FetchEmailNewsAction] Сборка мусора', [
|
||||
'memory_before' => $memoryBefore . 'MB',
|
||||
'memory_after' => $memoryAfter . 'MB',
|
||||
'freed' => $freed . 'MB',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions\EmailNews;
|
||||
|
||||
use App\Containers\Article\Models\Category;
|
||||
use App\Containers\Article\Models\Post;
|
||||
use App\Containers\Dashboard\Tasks\AI\CallAiServiceTask;
|
||||
use App\Containers\Dashboard\Tasks\Files\CompressImageTask;
|
||||
use App\Containers\Dashboard\Tasks\Posts\CreatePostFromAiDataTask;
|
||||
use App\Containers\Dashboard\Tasks\AI\ExtractTextFromDocumentTask;
|
||||
use App\Containers\Dashboard\Tasks\AI\FindMainNewsFileTask;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessMixedFilesAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindMainNewsFileTask $findMainNewsFileTask,
|
||||
private readonly ExtractTextFromDocumentTask $extractTextFromDocumentTask,
|
||||
private readonly CallAiServiceTask $callAiServiceTask,
|
||||
private readonly CreatePostFromAiDataTask $createPostFromAiDataTask,
|
||||
private readonly CompressImageTask $compressImageTask,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Обрабатывает смешанные файлы и создает пост
|
||||
*
|
||||
* @param Collection<UploadedFile> $files Все загруженные файлы
|
||||
* @return array Данные о созданном посте
|
||||
*/
|
||||
public function run(Collection $files): array
|
||||
{
|
||||
// Принудительная очистка памяти перед обработкой
|
||||
gc_collect_cycles();
|
||||
|
||||
Log::info('[ProcessMixedFilesAction] Начало обработки файлов', [
|
||||
'files_count' => $files->count(),
|
||||
'files' => $files->map(fn($f) => $f->getClientOriginalName())->toArray(),
|
||||
'memory_usage' => round(memory_get_usage(true) / 1024 / 1024, 2) . ' MB',
|
||||
'memory_peak' => round(memory_get_peak_usage(true) / 1024 / 1024, 2) . ' MB',
|
||||
]);
|
||||
|
||||
// Находим основной файл с текстом новости
|
||||
$mainFile = $this->findMainNewsFileTask->run($files);
|
||||
|
||||
if (!$mainFile) {
|
||||
Log::error('[ProcessMixedFilesAction] Не найден файл для извлечения текста');
|
||||
throw new \RuntimeException('Не найден DOC/DOCX файл для извлечения текста');
|
||||
}
|
||||
|
||||
Log::info('[ProcessMixedFilesAction] Основной файл найден', [
|
||||
'file' => $mainFile->getClientOriginalName(),
|
||||
'extension' => $mainFile->getClientOriginalExtension(),
|
||||
'real_path' => $mainFile->getRealPath(),
|
||||
'exists' => file_exists($mainFile->getRealPath()),
|
||||
]);
|
||||
|
||||
// Извлекаем текст из основного файла (поддерживает и .doc, и .docx)
|
||||
Log::info('[ProcessMixedFilesAction] Начало извлечения текста из документа');
|
||||
try {
|
||||
$extractedText = $this->extractTextFromDocumentTask->run($mainFile);
|
||||
Log::info('[ProcessMixedFilesAction] Текст извлечен', [
|
||||
'text_length' => strlen($extractedText ?? ''),
|
||||
'text_preview' => substr($extractedText ?? '', 0, 100),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[ProcessMixedFilesAction] Ошибка при извлечении текста', [
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $mainFile->getClientOriginalName(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Log::info('[ProcessMixedFilesAction] Результат извлечения текста', [
|
||||
'text_length' => strlen($extractedText ?? ''),
|
||||
'has_text' => !empty($extractedText),
|
||||
]);
|
||||
|
||||
if (empty($extractedText)) {
|
||||
Log::warning('[ProcessMixedFilesAction] Пустой текст после извлечения', [
|
||||
'file' => $mainFile->getClientOriginalName(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Сохраняем все файлы
|
||||
['documentPath' => $documentPath, 'mediaPaths' => $mediaPaths] = $this->saveFiles($files, $mainFile);
|
||||
|
||||
// Обрабатываем прикреплённые файлы из media
|
||||
$attachedFiles = $this->processAttachedFiles($files, $mainFile);
|
||||
|
||||
// Получаем категории
|
||||
$categories = Category::all();
|
||||
|
||||
// Отправляем текст в AI
|
||||
Log::info('[ProcessMixedFilesAction] Отправка текста в AI сервис', [
|
||||
'text_length' => strlen($extractedText ?? ''),
|
||||
'categories_count' => $categories->count(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$newsData = $this->callAiServiceTask->run($extractedText, $categories);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[ProcessMixedFilesAction] Ошибка вызова AI сервиса', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw new \RuntimeException('Ошибка при обработке данных AI: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if (!$newsData) {
|
||||
Log::error('[ProcessMixedFilesAction] AI сервис вернул пустой ответ');
|
||||
throw new \RuntimeException('Не удалось распознать данные через AI сервис. Проверьте логи AI запроса.');
|
||||
}
|
||||
|
||||
Log::info('[ProcessMixedFilesAction] AI данные успешно получены', [
|
||||
'title' => $newsData['title'] ?? 'N/A',
|
||||
'category_id' => $newsData['category_id'] ?? 'N/A',
|
||||
]);
|
||||
|
||||
// Создаём пост
|
||||
$post = $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles);
|
||||
|
||||
// Возвращаем данные для отображения
|
||||
return $this->prepareResponse($post, $newsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет все файлы
|
||||
*/
|
||||
private function saveFiles(Collection $files, UploadedFile $mainFile): array
|
||||
{
|
||||
Log::info('[ProcessMixedFilesAction:saveFiles] Начало сохранения файлов', [
|
||||
'total_files' => $files->count(),
|
||||
]);
|
||||
|
||||
// Сохраняем основной документ
|
||||
$documentPath = $mainFile->store('documents', 'local');
|
||||
|
||||
// Сжимаем и сохраняем остальные файлы как медиа
|
||||
$mediaPaths = [];
|
||||
$compressionStats = ['total' => 0, 'compressed' => 0, 'saved_bytes' => 0];
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Пропускаем основной файл
|
||||
if ($file === $mainFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$compressionStats['total']++;
|
||||
|
||||
// Если это изображение - сжимаем
|
||||
if ($this->compressImageTask->isImage($file)) {
|
||||
Log::info('[ProcessMixedFilesAction:saveFiles] Обработка изображения', [
|
||||
'file' => $file->getClientOriginalName(),
|
||||
]);
|
||||
|
||||
$result = $this->compressImageTask->run($file);
|
||||
|
||||
if ($result['compressed']) {
|
||||
$compressionStats['compressed']++;
|
||||
$compressionStats['saved_bytes'] += $result['original_size'] - $result['size'];
|
||||
|
||||
Log::info('[ProcessMixedFilesAction:saveFiles] Изображение сжато', [
|
||||
'file' => $file->getClientOriginalName(),
|
||||
'original_size' => $this->formatFileSize($result['original_size']),
|
||||
'compressed_size' => $this->formatFileSize($result['size']),
|
||||
'ratio' => $result['compression_ratio'] . '%',
|
||||
]);
|
||||
}
|
||||
|
||||
// Сохраняем сжатый файл
|
||||
$path = $result['file']->store('media', 'public');
|
||||
|
||||
// Очищаем временный файл
|
||||
if (file_exists($result['file']->getRealPath())) {
|
||||
unlink($result['file']->getRealPath());
|
||||
}
|
||||
|
||||
$mediaPaths[] = $path;
|
||||
} else {
|
||||
// Не изображения сохраняем как есть
|
||||
$path = $file->store('media', 'public');
|
||||
$mediaPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('[ProcessMixedFilesAction:saveFiles] Статистика сжатия', [
|
||||
'total_images' => $compressionStats['total'],
|
||||
'compressed' => $compressionStats['compressed'],
|
||||
'saved' => $this->formatFileSize($compressionStats['saved_bytes']),
|
||||
'saved_bytes' => $compressionStats['saved_bytes'],
|
||||
'memory_usage' => round(memory_get_usage(true) / 1024 / 1024, 2) . ' MB',
|
||||
]);
|
||||
|
||||
// Очищаем память после сжатия
|
||||
unset($result, $file);
|
||||
gc_collect_cycles();
|
||||
|
||||
return [
|
||||
'documentPath' => $documentPath,
|
||||
'mediaPaths' => $mediaPaths,
|
||||
'compressionStats' => $compressionStats,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает прикреплённые файлы (для добавления в контент)
|
||||
*/
|
||||
private function processAttachedFiles(Collection $files, UploadedFile $mainFile): array
|
||||
{
|
||||
$attachedFiles = [];
|
||||
$fileExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx'];
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Пропускаем основной файл
|
||||
if ($file === $mainFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
// Пропускаем изображения
|
||||
if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обрабатываем только файлы-вложения
|
||||
if (!in_array($extension, $fileExtensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$savedPath = $file->store('media/attachments', 'public');
|
||||
|
||||
$attachedFiles[] = [
|
||||
'expansion' => $extension,
|
||||
'size' => $this->formatFileSize($file->getSize()),
|
||||
'time_added' => time(),
|
||||
'title' => $file->getClientOriginalName(),
|
||||
'path' => $savedPath,
|
||||
];
|
||||
}
|
||||
|
||||
return $attachedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает ответ для возврата
|
||||
*/
|
||||
private function prepareResponse(Post $post, array $newsData): array
|
||||
{
|
||||
$post->load('category');
|
||||
|
||||
return [
|
||||
'post' => $post,
|
||||
'newsData' => $newsData,
|
||||
'preview_url' => $post->preview ? asset('storage/' . $post->preview) : null,
|
||||
'images_urls' => $post->images
|
||||
? array_map(fn($img) => asset('storage/' . $img), $post->images)
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует размер файла
|
||||
*/
|
||||
private function formatFileSize(int $bytes): string
|
||||
{
|
||||
$units = ['б', 'КиБ', 'МиБ', 'ГиБ'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions\EmailNews;
|
||||
|
||||
use App\Containers\Article\Models\Category;
|
||||
use App\Containers\Article\Models\Post;
|
||||
use App\Containers\Dashboard\Tasks\AI\CallAiServiceTask;
|
||||
use App\Containers\Dashboard\Tasks\Files\CompressImageTask;
|
||||
use App\Containers\Dashboard\Tasks\Posts\CreatePostFromAiDataTask;
|
||||
use App\Containers\Dashboard\Tasks\AI\ExtractTextFromDocumentTask;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessUploadedFilesAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExtractTextFromDocumentTask $extractTextFromDocumentTask,
|
||||
private readonly CallAiServiceTask $callAiServiceTask,
|
||||
private readonly CreatePostFromAiDataTask $createPostFromAiDataTask,
|
||||
private readonly CompressImageTask $compressImageTask,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Обрабатывает загруженные файлы и создаёт пост
|
||||
*
|
||||
* @param UploadedFile $document Основной документ
|
||||
* @param array $mediaFiles Массив медиафайлов
|
||||
* @return array Данные о созданном посте
|
||||
*/
|
||||
public function run(UploadedFile $document, array $mediaFiles = []): array
|
||||
{
|
||||
// Извлекаем текст из DOCX
|
||||
$extractedText = $this->extractText($document);
|
||||
|
||||
// Сохраняем документ
|
||||
$documentPath = $document->store('documents', 'local');
|
||||
|
||||
// Сохраняем медиафайлы
|
||||
$mediaPaths = $this->saveMediaFiles($mediaFiles);
|
||||
|
||||
// Обрабатываем прикреплённые файлы из media (DOCX, PDF и т.д.)
|
||||
$attachedFiles = $this->processAttachedFiles($mediaFiles);
|
||||
|
||||
// Получаем категории
|
||||
$categories = Category::all();
|
||||
|
||||
// Отправляем текст в AI
|
||||
$newsData = $this->callAiService($extractedText, $categories);
|
||||
|
||||
if (!$newsData) {
|
||||
throw new \RuntimeException('Не удалось распознать данные через AI сервис');
|
||||
}
|
||||
|
||||
// Создаём пост
|
||||
$post = $this->createPost($newsData, $documentPath, $mediaPaths, $attachedFiles);
|
||||
|
||||
// Возвращаем данные для отображения
|
||||
return $this->prepareResponse($post, $newsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает текст из документа
|
||||
*/
|
||||
private function extractText(UploadedFile $document): ?string
|
||||
{
|
||||
return $this->extractTextFromDocumentTask->run($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет медиафайлы со сжатием
|
||||
*/
|
||||
private function saveMediaFiles(array $mediaFiles): array
|
||||
{
|
||||
$paths = [];
|
||||
$compressionStats = ['total' => 0, 'compressed' => 0, 'saved_bytes' => 0];
|
||||
|
||||
foreach ($mediaFiles as $file) {
|
||||
$compressionStats['total']++;
|
||||
|
||||
// Если это изображение - сжимаем
|
||||
if ($this->compressImageTask->isImage($file)) {
|
||||
$result = $this->compressImageTask->run($file);
|
||||
|
||||
if ($result['compressed']) {
|
||||
$compressionStats['compressed']++;
|
||||
$compressionStats['saved_bytes'] += $result['original_size'] - $result['size'];
|
||||
}
|
||||
|
||||
// Сохраняем сжатый файл
|
||||
$path = $result['file']->store('media', 'public');
|
||||
|
||||
// Очищаем временный файл
|
||||
if (file_exists($result['file']->getRealPath())) {
|
||||
unlink($result['file']->getRealPath());
|
||||
}
|
||||
|
||||
$paths[] = $path;
|
||||
} else {
|
||||
// Не изображения сохраняем как есть
|
||||
$path = $file->store('media', 'public');
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('[ProcessUploadedFilesAction] Статистика сжатия', [
|
||||
'total_images' => $compressionStats['total'],
|
||||
'compressed' => $compressionStats['compressed'],
|
||||
'saved' => $this->formatFileSize($compressionStats['saved_bytes']),
|
||||
]);
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает прикреплённые файлы (для добавления в контент)
|
||||
*/
|
||||
private function processAttachedFiles(array $mediaFiles): array
|
||||
{
|
||||
$attachedFiles = [];
|
||||
$fileExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar'];
|
||||
|
||||
foreach ($mediaFiles as $file) {
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
// Пропускаем изображения
|
||||
if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обрабатываем только файлы-вложения
|
||||
if (!in_array($extension, $fileExtensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$savedPath = $file->store('media/attachments', 'public');
|
||||
|
||||
$attachedFiles[] = [
|
||||
'expansion' => $extension,
|
||||
'size' => $this->formatFileSize($file->getSize()),
|
||||
'time_added' => time(),
|
||||
'title' => $file->getClientOriginalName(),
|
||||
'path' => $savedPath,
|
||||
];
|
||||
}
|
||||
|
||||
return $attachedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызывает AI сервис для распознавания данных
|
||||
*/
|
||||
private function callAiService(?string $text, $categories): ?array
|
||||
{
|
||||
if (!$text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->callAiServiceTask->run($text, $categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт пост из данных
|
||||
*/
|
||||
private function createPost(array $newsData, ?string $documentPath, array $mediaPaths, array $attachedFiles): Post
|
||||
{
|
||||
return $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает ответ для возврата
|
||||
*/
|
||||
private function prepareResponse(Post $post, array $newsData): array
|
||||
{
|
||||
$post->load('category');
|
||||
|
||||
return [
|
||||
'post' => $post,
|
||||
'newsData' => $newsData,
|
||||
'preview_url' => $post->preview ? asset('storage/' . $post->preview) : null,
|
||||
'images_urls' => $post->images
|
||||
? array_map(fn($img) => asset('storage/' . $img), $post->images)
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует размер файла
|
||||
*/
|
||||
private function formatFileSize(int $bytes): string
|
||||
{
|
||||
$units = ['б', 'КиБ', 'МиБ', 'ГиБ'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user