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:
F4ilji
2026-04-06 22:08:03 +05:00
co-authored by Qwen-Coder
parent 4b071d9ef2
commit c115a5c7a7
14 changed files with 2143 additions and 47 deletions
@@ -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];
}
}