This commit is contained in:
F4ilji
2026-03-20 21:47:02 +05:00
parent ae7b42acbe
commit a3948c872e
1926 changed files with 2630 additions and 270 deletions
@@ -0,0 +1,179 @@
<?php
namespace App\Containers\Dashboard\Actions;
use App\Containers\Article\Models\Category;
use App\Containers\Article\Models\Post;
use App\Containers\Dashboard\Tasks\CallAiServiceForFileSelectionTask;
use App\Containers\Dashboard\Tasks\CallAiServiceTask;
use App\Containers\Dashboard\Tasks\CreatePostFromAiDataTask;
use App\Containers\Dashboard\Tasks\ExtractTextFromDocumentTask;
use App\Containers\Dashboard\Tasks\ExtractTextFragmentTask;
use App\Containers\Dashboard\Tasks\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,
) {}
/**
* Обрабатывает смешанные файлы и создает пост
*
* @param Collection<UploadedFile> $files Все загруженные файлы
* @return array Данные о созданном посте
*/
public function run(Collection $files): array
{
Log::info('[ProcessMixedFilesAction] Начало обработки файлов', [
'files_count' => $files->count(),
'files' => $files->map(fn($f) => $f->getClientOriginalName())->toArray(),
]);
// Находим основной файл с текстом новости
$mainFile = $this->findMainNewsFileTask->run($files);
if (!$mainFile) {
Log::error('[ProcessMixedFilesAction] Не найден файл для извлечения текста');
throw new \RuntimeException('Не найден DOC/DOCX файл для извлечения текста');
}
Log::info('[ProcessMixedFilesAction] Основной файл найден', [
'file' => $mainFile->getClientOriginalName(),
'extension' => $mainFile->getClientOriginalExtension(),
]);
// Извлекаем текст из основного файла (поддерживает и .doc, и .docx)
$extractedText = $this->extractTextFromDocumentTask->run($mainFile);
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
$newsData = $this->callAiServiceTask->run($extractedText, $categories);
if (!$newsData) {
throw new \RuntimeException('Не удалось распознать данные через AI сервис');
}
// Создаём пост
$post = $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles);
// Возвращаем данные для отображения
return $this->prepareResponse($post, $newsData);
}
/**
* Сохраняет все файлы
*/
private function saveFiles(Collection $files, UploadedFile $mainFile): array
{
// Сохраняем основной документ
$documentPath = $mainFile->store('documents', 'local');
// Сохраняем остальные файлы как медиа
$mediaPaths = $files
->filter(fn($file) => $file !== $mainFile)
->map(fn($file) => $file->store('media', 'public'))
->toArray();
return [
'documentPath' => $documentPath,
'mediaPaths' => $mediaPaths,
];
}
/**
* Обрабатывает прикреплённые файлы (для добавления в контент)
*/
private function processAttachedFiles(Collection $files, UploadedFile $mainFile): array
{
$attachedFiles = [];
$fileExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar'];
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,161 @@
<?php
namespace App\Containers\Dashboard\Actions;
use App\Containers\Article\Models\Category;
use App\Containers\Article\Models\Post;
use App\Containers\Dashboard\Tasks\CallAiServiceTask;
use App\Containers\Dashboard\Tasks\CreatePostFromAiDataTask;
use App\Containers\Dashboard\Tasks\ExtractTextFromDocumentTask;
use Illuminate\Http\UploadedFile;
class ProcessUploadedFilesAction
{
public function __construct(
private readonly ExtractTextFromDocumentTask $extractTextFromDocumentTask,
private readonly CallAiServiceTask $callAiServiceTask,
private readonly CreatePostFromAiDataTask $createPostFromAiDataTask,
) {}
/**
* Обрабатывает загруженные файлы и создаёт пост
*
* @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
{
return collect($mediaFiles)
->map(fn($file) => $file->store('media', 'public'))
->toArray();
}
/**
* Обрабатывает прикреплённые файлы (для добавления в контент)
*/
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];
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Containers\Dashboard\Actions;
use App\Containers\Article\Enums\PostStatus;
use App\Containers\Article\Models\Post;
use App\Services\Filament\Domain\Posts\PostDataProcessor;
use App\Services\Filament\Domain\Posts\PostNotificationService;
use App\Services\Filament\Domain\Posts\PostSliderService;
use App\Services\Filament\Domain\Posts\VkPostPublisher;
use App\Dto\MainSliderDTO;
use Illuminate\Support\Carbon;
class PublishPostAction
{
public function __construct(
private readonly PostDataProcessor $postDataProcessor,
) {}
/**
* Публикует черновик поста
*
* @param Post $post Пост для публикации
* @return Post Опубликованный пост
*/
public function run(Post $post): Post
{
// Обновляем данные поста
$updatedData = $this->postDataProcessor->processUpdate([
...$post->toArray(),
'status' => PostStatus::PUBLISHED,
'publish_setting' => [
'publish_after' => false,
'publish_at' => null,
],
]);
$post->update($updatedData);
// Отправляем уведомления
$this->sendNotifications($post);
// Публикуем в VK
$this->publishToVk($post);
// Обновляем слайд, если есть
$this->updateSlide($post);
return $post->fresh();
}
/**
* Отправляет уведомления о публикации
*/
private function sendNotifications(Post $post): void
{
(new PostNotificationService())->send($post);
}
/**
* Публикует пост в VK
*/
private function publishToVk(Post $post): void
{
// Публикуем в VK по умолчанию (аналогично Filament, где есть чекбокс)
(new VkPostPublisher())->publish(['vk' => true], $post);
}
/**
* Обновляет слайд для поста
*/
private function updateSlide(Post $post): void
{
// Если у поста уже есть слайд, обновляем его статус
if ($post->slide) {
$post->slide->update([
'is_active' => true,
'start_time' => $post->publish_at ?? Carbon::now(),
]);
}
// Если слайда нет, но есть превью - создаём новый
elseif ($post->preview) {
$slideData = [
'is_active' => true,
'start_time' => $post->publish_at ?? Carbon::now(),
'title' => $post->title,
'description' => $post->preview_text,
'image' => $post->preview,
];
$sliderDTO = MainSliderDTO::fromArray($slideData);
(new PostSliderService($sliderDTO, $post))->create();
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Containers\Dashboard\Loaders;
class AliasesLoader
{
/**
* @var array
*/
public array $aliases = [];
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Containers\Dashboard\Loaders;
class MiddlewareLoader
{
/**
* @var array
*/
public array $middleware = [];
/**
* @var array
*/
public array $middlewareGroups = [];
/**
* @var array
*/
public array $routeMiddleware = [];
/**
* @var array
*/
public array $middlewarePriority = [];
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Containers\Dashboard\Loaders;
class ProvidersLoader
{
/**
* @var array
*/
public array $providers = [];
}
@@ -0,0 +1,93 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CallAiServiceForFileSelectionTask
{
/**
* Отправляет фрагменты файлов в AI для определения основного файла
*
* @param array $fragments Массив фрагментов [['filename' => '...', 'text' => '...'], ...]
* @return array|null Ответ AI с названием основного файла
*/
public function run(array $fragments): ?array
{
Log::info('[CallAiServiceForFileSelectionTask] Начало LLM запроса', [
'fragments_count' => count($fragments),
]);
$prompt = $this->buildPrompt($fragments);
Log::info('[CallAiServiceForFileSelectionTask] Отправка запроса к AI');
try {
$response = Http::timeout(30) // Таймаут 30 секунд
->withHeaders([
'Authorization' => 'Bearer ' . env('QWEN_API_KEY'),
'Content-Type' => 'application/json',
])->post('https://routerai.ru/api/v1/chat/completions', [
'model' => 'qwen/qwen3.5-flash-02-23',
'messages' => [
['role' => 'user', 'content' => $prompt],
],
'response_format' => ['type' => 'json_object'],
'temperature' => 0,
'max_tokens' => 500,
]);
Log::info('[CallAiServiceForFileSelectionTask] Ответ от AI', [
'status' => $response->status(),
'successful' => $response->successful(),
]);
if ($response->successful()) {
$result = $response->json();
$llmResponse = json_decode($result['choices'][0]['message']['content'], true);
Log::info('[CallAiServiceForFileSelectionTask] Распознан ответ', [
'main_file' => $llmResponse['main_file'] ?? null,
]);
return $llmResponse;
} else {
Log::error('[CallAiServiceForFileSelectionTask] Ошибка AI запроса', [
'status' => $response->status(),
'body' => $response->body(),
]);
}
} catch (\Exception $e) {
Log::error('[CallAiServiceForFileSelectionTask] Исключение при запросе к AI', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
return null;
}
/**
* Формирует промпт для AI
*/
private function buildPrompt(array $fragments): string
{
$filesInfo = "";
foreach ($fragments as $index => $fragment) {
$filesInfo .= "Файл #{$index}: {$fragment['filename']}\n";
$filesInfo .= "Текст: {$fragment['text']}\n\n";
}
return "Ты — ассистент для определения основного файла с новостью. " .
"Тебе предоставлены фрагменты текста из нескольких DOCX файлов.\n\n" .
"СПИСОК ФАЙЛОВ:\n{$filesInfo}\n" .
"ЗАДАЧА: Определи, какой файл содержит основной текст новости (не приложение, не документ, а именно новость/статью).\n\n" .
"ВЕРНИ СТРОГО JSON:\n" .
"{\n" .
' "main_file": "точное название файла с расширением .docx",' . "\n" .
' "reason": "краткое объяснение выбора"' . "\n" .
"}\n\n" .
"Если не можешь определить — верни первый файл из списка.";
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class CallAiServiceTask
{
/**
* Отправляет текст в AI сервис для извлечения структурированных данных
*
* @param string $text Исходный текст новости
* @param Collection $categories Коллекция категорий для выбора
* @return array|null Распознанные данные (title, body, authors, tags, category_id)
*/
public function run(string $text, Collection $categories): ?array
{
$categoryList = collect($categories)
->map(fn($cat) => "{$cat->id}: {$cat->title}")
->implode("\n");
$systemPrompt = $this->buildSystemPrompt($categoryList);
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . env('QWEN_API_KEY'),
'Content-Type' => 'application/json',
])->post('https://routerai.ru/api/v1/chat/completions', [
'model' => 'qwen/qwen3.5-flash-02-23',
'reasoning' => ['enabled' => false],
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $text],
],
'response_format' => ['type' => 'json_object'],
'temperature' => 0,
'max_tokens' => 2000,
]);
if ($response->successful()) {
$result = $response->json();
return json_decode($result['choices'][0]['message']['content'], true);
}
return null;
}
/**
* Формирует системный промпт для AI
*/
private function buildSystemPrompt(string $categoryList): string
{
return "Ты — строгий агент по извлечению данных. Твоя задача — преобразовать входящий текст новости в JSON.\n\n"
. "СПИСОК КАТЕГОРИЙ (ID: Название):\n"
. $categoryList
. "\n\nПРАВИЛА:\n"
. "1. Поля \"title\" и \"body\" копируй ТОЧЬ-В-ТОЧЬ из источника. Запрещено исправлять ошибки, менять пунктуацию или добавлять Markdown-разметку. Только чистый текст.\n"
. "2. В конце новости извлеки имя автора(-ов) и их должности, это должен быть массив \"authors\". Если оно не указано явно — верни null.\n"
. "3. \"tags\" извлекай ТОЛЬКО слова, начинающиеся с символа решетки (#). Если таких слов в тексте нет — верни null. Не создавай свои теги.\n"
. "4. В поле \"category_id\" запиши идентификатор из предоставленного списка выше.\n"
. "5. Полностью игнорируй комментарии пользователей и рекламный мусор, не включая их в \"body\".\n\n"
. "ОТВЕТ ДОЛЖЕН БЫТЬ СТРОГО В JSON:\n"
. "{\n"
. ' "title": "string",' . "\n"
. ' "body": "string",' . "\n"
. ' "authors": ["ФИО и должность"] или null,' . "\n"
. ' "tags": ["#тег1"] или null,' . "\n"
. ' "category_id": number/string' . "\n"
. "}";
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
class ConvertDocToDocxTask
{
/**
* Конвертирует .doc файл в .docx с помощью LibreOffice
*
* @param UploadedFile $file Исходный .doc файл
* @return string|null Путь к конвертированному .docx файлу или null при ошибке
*/
public function run(UploadedFile $file): ?string
{
Log::info('[ConvertDocToDocxTask] Начало конвертации', [
'file' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'extension' => $file->getClientOriginalExtension(),
]);
// Проверяем расширение
if (strtolower($file->getClientOriginalExtension()) !== 'doc') {
Log::warning('[ConvertDocToDocxTask] Неверное расширение', [
'extension' => $file->getClientOriginalExtension(),
]);
return null;
}
// Сохраняем исходный файл во временную директорию
$originalPath = $file->storeAs('temp/conversion', 'original_' . time() . '_' . Str::random(10) . '.doc', 'local');
$absoluteOriginalPath = Storage::disk('local')->path($originalPath);
Log::info('[ConvertDocToDocxTask] Файл сохранен', [
'original_path' => $originalPath,
'absolute_path' => $absoluteOriginalPath,
'file_exists' => file_exists($absoluteOriginalPath),
]);
// Проверяем существование файла
if (!file_exists($absoluteOriginalPath)) {
Log::error('[ConvertDocToDocxTask] Файл не сохранен', [
'path' => $absoluteOriginalPath,
]);
return null;
}
// Директория для вывода
$outputDir = dirname($absoluteOriginalPath);
// Конвертируем через LibreOffice
$command = sprintf(
'libreoffice --headless --convert-to docx --outdir %s %s 2>&1',
escapeshellarg($outputDir),
escapeshellarg($absoluteOriginalPath)
);
Log::info('[ConvertDocToDocxTask] Выполнение команды', [
'command' => $command,
]);
$output = shell_exec($command);
Log::info('[ConvertDocToDocxTask] Результат конвертации', [
'output' => $output,
]);
// Ищем конвертированный файл
$docxPath = preg_replace('/\.doc$/i', '.docx', $absoluteOriginalPath);
Log::info('[ConvertDocToDocxTask] Проверка результата', [
'expected_path' => $docxPath,
'file_exists' => file_exists($docxPath),
]);
if (!file_exists($docxPath)) {
// Логируем ошибку
Log::error('[ConvertDocToDocxTask] Конвертация не удалась', [
'file' => $file->getClientOriginalName(),
'output' => $output,
'expected_path' => $docxPath,
]);
// Удаляем исходный файл
Storage::disk('local')->delete($originalPath);
return null;
}
// Удаляем исходный .doc файл
Storage::disk('local')->delete($originalPath);
// Возвращаем относительный путь к .docx файлу
$relativeDocxPath = preg_replace('/\.doc$/i', '.docx', $originalPath);
Log::info('[ConvertDocToDocxTask] Конвертация успешна', [
'result_path' => $relativeDocxPath,
]);
return $relativeDocxPath;
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use App\Containers\Article\Enums\PostStatus;
use App\Containers\Article\Models\Post;
use App\Services\Filament\Domain\Posts\PostDataProcessor;
use Illuminate\Support\Str;
class CreatePostFromAiDataTask
{
/**
* Создаёт пост из данных, полученных от AI
*
* @param array $newsData Данные от AI (title, body, authors, tags, category_id)
* @param string|null $documentPath Путь к основному документу
* @param array $mediaPaths Пути к медиафайлам
* @param array $attachedFiles Прикреплённые файлы (для блока files)
*/
public function run(array $newsData, ?string $documentPath, array $mediaPaths, array $attachedFiles): Post
{
// Формируем контент в формате редактора
$content = $this->buildContent($newsData, $attachedFiles);
// Обрабатываем изображения
['preview' => $previewPath, 'images' => $imagesPaths] = $this->processImages($mediaPaths);
// Подготавливаем данные для создания поста
$postData = $this->preparePostData($newsData, $content, $previewPath, $imagesPaths);
// Обрабатываем через PostDataProcessor (как в админке)
$processedData = (new PostDataProcessor())->processCreate($postData);
// Создаём пост
$post = Post::create($processedData);
// Сохраняем теги
$this->syncTags($post, $newsData);
return $post;
}
/**
* Формирует контент в формате редактора
*/
private function buildContent(array $newsData, array $attachedFiles): array
{
$content = [
[
'type' => 'paragraph',
'data' => [
'seo_active' => true,
'content' => $newsData['body'] ?? '',
],
],
];
if (!empty($attachedFiles)) {
$content[] = [
'type' => 'files',
'data' => [
'file' => $attachedFiles,
],
];
}
return $content;
}
/**
* Обрабатывает изображения: все изображения идут в gallery, первое — preview
*/
private function processImages(array $mediaPaths): array
{
$imagesPaths = [];
$imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'tiff'];
foreach ($mediaPaths as $path) {
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (in_array($extension, $imageExtensions)) {
$imagesPaths[] = $path;
}
}
return [
'preview' => !empty($imagesPaths) ? reset($imagesPaths) : null,
'images' => $imagesPaths,
];
}
/**
* Подготавливает данные для создания поста
*/
private function preparePostData(array $newsData, array $content, ?string $previewPath, array $imagesPaths): array
{
return [
'title' => $newsData['title'] ?? 'Без названия',
'slug' => Str::slug($newsData['title'] ?? '') . '-' . time(),
'content' => $content,
'authors' => $newsData['authors'] ?? [],
'category_id' => $newsData['category_id'] ?? null,
'status' => PostStatus::VERIFICATION,
'user_id' => auth()->id(),
'preview' => $previewPath,
'images' => !empty($imagesPaths) ? $imagesPaths : null,
'publish_setting' => [
'publish_after' => false,
'publish_at' => null,
],
'publication' => [],
];
}
/**
* Сохраняет теги поста
*/
private function syncTags(Post $post, array $newsData): void
{
if (!empty($newsData['tags'])) {
$tagNames = array_map(
fn($tag) => str_replace('#', '', $tag),
$newsData['tags']
);
$post->syncTags($tagNames);
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class ExtractTextFragmentTask
{
/**
* Извлекает фрагмент текста (первые N символов) из DOCX или DOC файла
*/
public function run(UploadedFile $file, int $maxLength = 300): string
{
Log::info('[ExtractTextFragmentTask] Начало извлечения фрагмента', [
'file' => $file->getClientOriginalName(),
'extension' => $file->getClientOriginalExtension(),
]);
$extension = strtolower($file->getClientOriginalExtension());
// Для DOCX — парсим напрямую
if ($extension === 'docx') {
return $this->extractFromDocx($file, $maxLength);
}
// Для DOC — конвертируем и парсим
if ($extension === 'doc') {
return $this->extractFromDoc($file, $maxLength);
}
Log::warning('[ExtractTextFragmentTask] Неизвестный формат', [
'extension' => $extension,
]);
return '(неподдерживаемый формат)';
}
/**
* Извлекает фрагмент из DOCX
*/
private function extractFromDocx(UploadedFile $file, int $maxLength): string
{
// Сохраняем во временную директорию
$tempPath = $file->storeAs('temp', 'temp_fragment_' . time() . '.docx', 'local');
$absoluteTempPath = Storage::disk('local')->path($tempPath);
// Парсим файл через ParseDocxTask
$fullText = app(ParseDocxTask::class)->run($absoluteTempPath);
// Удаляем временный файл
Storage::disk('local')->delete($tempPath);
return $this->truncateText($fullText, $maxLength);
}
/**
* Извлекает фрагмент из DOC (через конвертацию)
*/
private function extractFromDoc(UploadedFile $file, int $maxLength): string
{
// Используем ExtractTextFromDocumentTask для конвертации и извлечения
$extractor = app(ExtractTextFromDocumentTask::class);
$fullText = $extractor->run($file);
return $this->truncateText($fullText, $maxLength);
}
/**
* Обрезает текст до нужной длины
*/
private function truncateText(?string $text, int $maxLength): string
{
if (empty($text)) {
return '(пустой файл)';
}
if (strlen($text) > $maxLength) {
return substr($text, 0, $maxLength) . '...';
}
return $text;
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use App\Containers\Dashboard\Tasks\ParseDocxTask;
use App\Containers\Dashboard\Tasks\ConvertDocToDocxTask;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class ExtractTextFromDocumentTask
{
public function __construct(
private readonly ParseDocxTask $parseDocxTask,
private readonly ConvertDocToDocxTask $convertDocToDocxTask,
) {}
/**
* Извлекает текст из DOCX или DOC файла
* Автоматически определяет формат и конвертирует при необходимости
*/
public function run(UploadedFile $file): ?string
{
$extension = strtolower($file->getClientOriginalExtension());
Log::info('[ExtractTextFromDocumentTask] Начало извлечения текста', [
'file' => $file->getClientOriginalName(),
'extension' => $extension,
'size' => $file->getSize(),
]);
// Если DOCX — парсим напрямую
if ($extension === 'docx') {
Log::info('[ExtractTextFromDocumentTask] Обработка DOCX файла');
return $this->extractFromDocx($file);
}
// Если DOC — конвертируем в DOCX, затем парсим
if ($extension === 'doc') {
Log::info('[ExtractTextFromDocumentTask] Обработка DOC файла (требуется конвертация)');
return $this->extractFromDoc($file);
}
Log::warning('[ExtractTextFromDocumentTask] Неизвестный формат файла', [
'extension' => $extension,
]);
return null;
}
/**
* Извлекает текст из DOCX файла
*/
private function extractFromDocx(UploadedFile $file): ?string
{
Log::info('[ExtractTextFromDocumentTask:extractFromDocx] Начало');
// Сохраняем во временную директорию
$tempPath = $file->storeAs('temp', 'temp_doc_' . time() . '.docx', 'local');
$absoluteTempPath = Storage::disk('local')->path($tempPath);
Log::info('[ExtractTextFromDocumentTask:extractFromDocx] Файл сохранен', [
'path' => $tempPath,
'absolute_path' => $absoluteTempPath,
]);
// Парсим файл
$extractedText = $this->parseDocxTask->run($absoluteTempPath);
Log::info('[ExtractTextFromDocumentTask:extractFromDocx] Результат парсинга', [
'text_length' => strlen($extractedText ?? ''),
]);
// Удаляем временный файл
Storage::disk('local')->delete($tempPath);
return $extractedText;
}
/**
* Извлекает текст из DOC файла (через конвертацию)
*/
private function extractFromDoc(UploadedFile $file): ?string
{
Log::info('[ExtractTextFromDocumentTask:extractFromDoc] Начало конвертации');
// Конвертируем DOC → DOCX
$docxPath = $this->convertDocToDocxTask->run($file);
if (!$docxPath) {
Log::error('[ExtractTextFromDocumentTask:extractFromDoc] Не удалось конвертировать DOC файл', [
'file' => $file->getClientOriginalName(),
]);
return null;
}
Log::info('[ExtractTextFromDocumentTask:extractFromDoc] Конвертация успешна', [
'docx_path' => $docxPath,
]);
// Получаем абсолютный путь
$absoluteDocxPath = Storage::disk('local')->path($docxPath);
// Парсим конвертированный файл
$extractedText = $this->parseDocxTask->run($absoluteDocxPath);
Log::info('[ExtractTextFromDocumentTask:extractFromDoc] Результат парсинга', [
'text_length' => strlen($extractedText ?? ''),
]);
// Удаляем конвертированный файл
Storage::disk('local')->delete($docxPath);
return $extractedText;
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class FindMainNewsFileTask
{
/**
* Ключевые слова для определения файла с новостью
*/
private const NEWS_KEYWORDS = [
'новость', 'новости', 'заметка', 'заметки', 'статья', 'статьи',
'материал', 'материалы', 'текст', 'контент', 'публикация',
'news', 'article', 'post', 'material', 'text',
'релиз', 'пресс', 'отчет', 'отчёт', 'доклад'
];
/**
* Находит основной файл с текстом новости среди всех загруженных файлов
*
* @param Collection<UploadedFile> $files Все загруженные файлы
* @return UploadedFile|null Основной файл или null если не найден
*/
public function run(Collection $files): ?UploadedFile
{
Log::info('[FindMainNewsFileTask] Начало поиска основного файла', [
'files_count' => $files->count(),
'files' => $files->map(function($f) {
return $f->getClientOriginalName() . ' (' . $f->getClientOriginalExtension() . ')';
})->toArray(),
]);
// Фильтруем DOC и DOCX файлы (поддерживаем оба формата)
$docFiles = $files->filter(function($file) {
$ext = strtolower($file->getClientOriginalExtension());
return in_array($ext, ['doc', 'docx']);
});
Log::info('[FindMainNewsFileTask] Найдено DOC/DOCX файлов', [
'count' => $docFiles->count(),
'files' => $docFiles->map(fn($f) => $f->getClientOriginalName())->toArray(),
]);
if ($docFiles->isEmpty()) {
Log::warning('[FindMainNewsFileTask] Не найдено DOC/DOCX файлов');
return null;
}
// Если только один файл — используем его
if ($docFiles->count() === 1) {
Log::info('[FindMainNewsFileTask] Найден один файл, используем его', [
'file' => $docFiles->first()->getClientOriginalName(),
]);
return $docFiles->first();
}
// Ищем файл с ключевыми словами в названии
foreach ($docFiles as $file) {
$filename = mb_strtolower($file->getClientOriginalName());
foreach (self::NEWS_KEYWORDS as $keyword) {
if (str_contains($filename, $keyword)) {
Log::info('[FindMainNewsFileTask] Найден файл по ключевому слову', [
'file' => $file->getClientOriginalName(),
'keyword' => $keyword,
]);
return $file;
}
}
}
Log::info('[FindMainNewsFileTask] Не найдено файлов по ключевым словам, используем LLM');
// Если не нашли по ключевым словам — используем LLM
return $this->findViaLLM($docFiles);
}
/**
* Определяет основной файл через LLM
*/
private function findViaLLM(Collection $docFiles): ?UploadedFile
{
Log::info('[FindMainNewsFileTask:findViaLLM] Начало LLM определения', [
'files_count' => $docFiles->count(),
]);
if ($docFiles->isEmpty()) {
return null;
}
if ($docFiles->count() === 1) {
return $docFiles->first();
}
// Собираем фрагменты текста из каждого файла
$fragments = [];
foreach ($docFiles as $file) {
Log::info('[FindMainNewsFileTask:findViaLLM] Извлечение фрагмента', [
'file' => $file->getClientOriginalName(),
]);
$fragment = app(ExtractTextFragmentTask::class)->run($file, 300);
$fragments[] = [
'filename' => $file->getClientOriginalName(),
'text' => $fragment,
];
}
// Отправляем запрос к LLM
Log::info('[FindMainNewsFileTask:findViaLLM] Отправка запроса к LLM');
$llmResponse = app(CallAiServiceForFileSelectionTask::class)->run($fragments);
if ($llmResponse && isset($llmResponse['main_file'])) {
Log::info('[FindMainNewsFileTask:findViaLLM] LLM вернула результат', [
'main_file' => $llmResponse['main_file'],
]);
$mainFilename = $llmResponse['main_file'];
foreach ($docFiles as $file) {
if ($file->getClientOriginalName() === $mainFilename) {
return $file;
}
}
}
Log::warning('[FindMainNewsFileTask:findViaLLM] LLM не помогла, используем первый файл');
// Fallback — первый файл
return $docFiles->first();
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use App\Containers\Article\Models\Post;
use Illuminate\Database\Eloquent\Collection;
class GetDraftPostsTask
{
public function run(): Collection
{
return Post::query()
->with(['category', 'author'])
->whereNull('publish_at')
->orderBy('created_at', 'desc')
->get([
'id',
'title',
'slug',
'preview_text',
'content',
'status',
'authors',
'preview',
'images',
'reading_time',
'category_id',
'created_at',
'updated_at',
]);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Containers\Dashboard\Tasks;
use ZipArchive;
use Illuminate\Support\Facades\Log;
class ParseDocxTask
{
/**
* Извлекает текст из .docx файла с подробным логированием ошибок
*/
public function run(string $filePath): ?string
{
// 1. Проверяем, существует ли физически файл по этому пути
if (!file_exists($filePath)) {
Log::error("[ParseDocxTask] Файл не найден по пути: {$filePath}");
return "ОШИБКА ДЕБАГА: Файл не найден на сервере.";
}
$zip = new ZipArchive();
// 2. Пытаемся открыть архив
$res = $zip->open($filePath);
// Если не true, значит произошла ошибка
if ($res !== true) {
$errorMsg = $this->getZipError($res);
Log::error("[ParseDocxTask] Ошибка ZipArchive: {$errorMsg} | Путь: {$filePath}");
// Возвращаем текст ошибки прямо на фронт, чтобы сразу увидеть причину!
return "ОШИБКА ДЕБАГА ZipArchive: {$errorMsg}";
}
// 3. Пытаемся достать нужный XML файл
$xmlString = $zip->getFromName('word/document.xml');
// Обязательно закрываем архив, чтобы не было утечек памяти
$zip->close();
if ($xmlString === false) {
Log::error("[ParseDocxTask] Файл word/document.xml не найден внутри архива. Возможно, это не настоящий DOCX.");
return "ОШИБКА ДЕБАГА: Внутри архива нет word/document.xml";
}
// 4. Парсим XML
try {
$text = str_replace('</w:p>', " \n", $xmlString);
$text = strip_tags($text);
return trim($text);
} catch (\Exception $e) {
Log::error("[ParseDocxTask] Ошибка очистки тегов: " . $e->getMessage());
return "ОШИБКА ДЕБАГА при очистке текста.";
}
}
/**
* Превращает числовой код ошибки ZipArchive в текст
*/
private function getZipError(int $code): string
{
$errors = [
ZipArchive::ER_EXISTS => 'Файл уже существует.',
ZipArchive::ER_INCONS => 'Несовместимый ZIP-архив.',
ZipArchive::ER_INVAL => 'Недопустимый аргумент.',
ZipArchive::ER_MEMORY => 'Ошибка выделения памяти (Malloc).',
ZipArchive::ER_NOENT => 'Нет такого файла.',
ZipArchive::ER_NOZIP => 'Это НЕ ZIP-архив (Скорее всего файл переименован или битый).',
ZipArchive::ER_OPEN => 'Невозможно открыть файл (Проблема с правами).',
ZipArchive::ER_READ => 'Ошибка чтения.',
ZipArchive::ER_SEEK => 'Ошибка поиска (Seek error).',
];
return $errors[$code] ?? "Неизвестная ошибка с кодом: {$code}";
}
}
@@ -0,0 +1,281 @@
<?php
namespace App\Containers\Dashboard\Tests\Unit;
use App\Containers\Article\Enums\PostStatus;
use App\Containers\Article\Models\Post;
use App\Services\Filament\Domain\Posts\VkPostPublisher;
use Tests\TestCase;
/**
* Тест генерации текста поста для VK
*
* Запуск: docker exec ntspi-php php artisan test --filter=VkPostGenerationTest
*/
class VkPostGenerationTest extends TestCase
{
/**
* Тест: генерация текста для поста с файлами
*/
public function test_vk_post_generation_with_files(): void
{
// Создаём тестовый пост с вложениями
$post = Post::create([
'title' => 'Тестовая новость с вложениями',
'slug' => 'test-news-with-files-' . time(),
'preview_text' => 'Это тестовая новость для проверки генерации VK поста',
'content' => [
[
'type' => 'paragraph',
'data' => [
'seo_active' => true,
'content' => '<p>Это основной текст новости. Он содержит важную информацию о событии.</p>',
],
],
[
'type' => 'heading',
'data' => [
'content' => '<h2>Детали мероприятия</h2>',
],
],
[
'type' => 'paragraph',
'data' => [
'content' => '<p>Здесь находится подробное описание мероприятия.</p>',
],
],
[
'type' => 'files',
'data' => [
'file' => [
[
'title' => 'Презентация мероприятия.pdf',
'path' => 'media/attachments/presentation.pdf',
'expansion' => 'pdf',
'size' => '2.5 МиБ',
],
[
'title' => 'Список участников.xlsx',
'path' => 'media/attachments/participants.xlsx',
'expansion' => 'xlsx',
'size' => '156 КиБ',
],
],
],
],
],
'authors' => ['Иванов Иван'],
'status' => PostStatus::PUBLISHED,
'publish_at' => now(),
'images' => [
'media/image1.jpg',
'media/image2.png',
],
'preview' => 'media/image1.jpg',
'user_id' => 1,
'category_id' => null,
]);
// Генерируем текст для VK
$publisher = new VkPostPublisher();
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
// Выводим результат для визуальной проверки
$this->info("\n" . str_repeat('═', 60));
$this->info('📝 СГЕНЕРИРОВАННЫЙ ТЕКСТ ДЛЯ VK');
$this->info(str_repeat('═', 60));
$this->info($generatedText);
$this->info(str_repeat('═', 60) . "\n");
// Проверки
$this->assertStringContainsString('Тестовая новость с вложениями', $generatedText);
$this->assertStringContainsString('Иванов Иван', $generatedText);
// Проверяем наличие ссылок на файлы в формате VK [url|text]
$this->assertStringContainsString('📎 Презентация мероприятия.pdf', $generatedText);
$this->assertStringContainsString('📎 Список участников.xlsx', $generatedText);
// Проверяем формат ссылок VK
$this->assertMatchesRegularExpression('/\[https?:\/\/[^\|]+\|📎 [^\]]+\]/', $generatedText);
// Очищаем
$post->delete();
}
/**
* Тест: генерация текста без файлов
*/
public function test_vk_post_generation_without_files(): void
{
$post = Post::create([
'title' => 'Новость без вложений',
'slug' => 'news-without-files-' . time(),
'preview_text' => 'Новость без прикреплённых файлов',
'content' => [
[
'type' => 'paragraph',
'data' => [
'seo_active' => true,
'content' => '<p>Текст новости без файлов.</p>',
],
],
],
'authors' => ['Петров Пётр'],
'status' => PostStatus::PUBLISHED,
'publish_at' => now(),
'images' => [],
'preview' => null,
'user_id' => 1,
'category_id' => null,
]);
$publisher = new VkPostPublisher();
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
$this->info("\n" . str_repeat('─', 60));
$this->info('📝 ТЕКСТ БЕЗ ФАЙЛОВ');
$this->info(str_repeat('─', 60));
$this->info($generatedText);
$this->info(str_repeat('─', 60) . "\n");
$this->assertStringContainsString('Новость без вложений', $generatedText);
$this->assertStringNotContainsString('Прикреплённые файлы:', $generatedText);
$post->delete();
}
/**
* Тест: генерация текста с несколькими блоками файлов
*/
public function test_vk_post_generation_with_multiple_file_blocks(): void
{
$post = Post::create([
'title' => 'Новость с несколькими блоками файлов',
'slug' => 'news-multiple-files-' . time(),
'preview_text' => 'Тест с несколькими блоками файлов',
'content' => [
[
'type' => 'paragraph',
'data' => [
'content' => '<p>Основной текст.</p>',
],
],
[
'type' => 'files',
'data' => [
'file' => [
[
'title' => 'Документ 1.docx',
'path' => 'media/attachments/doc1.docx',
'expansion' => 'docx',
'size' => '500 КиБ',
],
],
],
],
[
'type' => 'paragraph',
'data' => [
'content' => '<p>Ещё текст.</p>',
],
],
[
'type' => 'files',
'data' => [
'file' => [
[
'title' => 'Документ 2.pdf',
'path' => 'media/attachments/doc2.pdf',
'expansion' => 'pdf',
'size' => '1.2 МиБ',
],
[
'title' => 'Документ 3.zip',
'path' => 'media/attachments/doc3.zip',
'expansion' => 'zip',
'size' => '5.6 МиБ',
],
],
],
],
],
'authors' => [],
'status' => PostStatus::PUBLISHED,
'publish_at' => now(),
'images' => [],
'preview' => null,
'user_id' => 1,
'category_id' => null,
]);
$publisher = new VkPostPublisher();
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
$this->info("\n" . str_repeat('─', 60));
$this->info('📝 ТЕКСТ С НЕСКОЛЬКИМИ БЛОКАМИ ФАЙЛОВ');
$this->info(str_repeat('─', 60));
$this->info($generatedText);
$this->info(str_repeat('─', 60) . "\n");
// Проверяем, что все файлы присутствуют
$this->assertStringContainsString('📎 Документ 1.docx', $generatedText);
$this->assertStringContainsString('📎 Документ 2.pdf', $generatedText);
$this->assertStringContainsString('📎 Документ 3.zip', $generatedText);
$post->delete();
}
/**
* Тест: проверка формата ссылок VK
*/
public function test_vk_link_format(): void
{
$post = Post::create([
'title' => 'Проверка формата ссылок',
'slug' => 'check-link-format-' . time(),
'content' => [
[
'type' => 'files',
'data' => [
'file' => [
[
'title' => 'Файл для проверки.pdf',
'path' => 'media/attachments/test.pdf',
'expansion' => 'pdf',
'size' => '100 КиБ',
],
],
],
],
],
'authors' => [],
'status' => PostStatus::PUBLISHED,
'publish_at' => now(),
'images' => [],
'preview' => null,
'user_id' => 1,
'category_id' => null,
]);
$publisher = new VkPostPublisher();
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
// Проверяем точный формат ссылки VK
$expectedPattern = '/\[https?:\/\/[^\/]+\/storage\/media\/attachments\/test\.pdf\|📎 Файл для проверки\.pdf\]/';
$this->assertMatchesRegularExpression($expectedPattern, $generatedText);
$post->delete();
}
/**
* Вызов приватного метода через рефлексию
*/
private function invokePrivateMethod(object $object, string $methodName, array $parameters = []): mixed
{
$reflection = new \ReflectionClass($object);
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invoke($object, ...$parameters);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Controllers;
use App\Containers\Dashboard\Tasks\GetDraftPostsTask;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class IndexDashboardController extends Controller
{
public function __construct(
private readonly GetDraftPostsTask $getDraftPostsTask,
) {}
public function __invoke(Request $request): \Inertia\Response
{
$draftPosts = $this->getDraftPostsTask->run();
return inertia()->render('Dashboard/Main', [
'draftPosts' => $draftPosts,
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Controllers;
use App\Containers\Dashboard\Actions\ProcessMixedFilesAction;
use App\Containers\Dashboard\UI\WEB\Requests\StoreFilesRequest;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Collection;
class ProcessMixedFilesController extends Controller
{
public function __construct(
private readonly ProcessMixedFilesAction $processMixedFilesAction,
) {}
/**
* Обработка смешанных файлов и создание новости
*/
public function __invoke(StoreFilesRequest $request): RedirectResponse
{
try {
// Собираем все файлы в одну коллекцию
$files = $this->collectAllFiles($request);
if ($files->isEmpty()) {
return back()->with(['error' => 'Не загружено ни одного файла']);
}
$result = $this->processMixedFilesAction->run($files);
return back()->with([
'success' => 'Файлы успешно загружены! Новость создана: ' . $result['post']->title,
'extracted_text' => $result['newsData'],
'created_post' => [
...$result['post']->toArray(),
'preview_url' => $result['preview_url'],
'images_urls' => $result['images_urls'],
],
]);
} catch (\Exception $e) {
return back()->with([
'error' => 'Ошибка при создании новости: ' . $e->getMessage(),
]);
}
}
/**
* Собирает все файлы из request
*/
private function collectAllFiles(StoreFilesRequest $request): Collection
{
$files = collect();
// Добавляем файлы из поля 'files' (множественная загрузка)
if ($request->hasFile('files')) {
foreach ($request->file('files') as $file) {
if ($file instanceof Collection) {
$files = $files->merge($file);
} else {
$files->push($file);
}
}
}
return $files;
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Controllers;
use App\Containers\Article\Models\Post;
use App\Containers\Dashboard\Actions\PublishPostAction;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PublishPostController extends Controller
{
public function __construct(
private readonly PublishPostAction $publishPostAction,
) {}
/**
* Публикация черновика поста
*/
public function __invoke(Request $request, Post $post): RedirectResponse
{
// Проверяем, что пост в статусе черновика (без publish_at)
if ($post->publish_at) {
return back()->with([
'error' => 'Пост уже опубликован',
]);
}
try {
$publishedPost = $this->publishPostAction->run($post);
return back()->with([
'success' => 'Новость успешно опубликована: ' . $publishedPost->title,
'published_post_id' => $publishedPost->id,
]);
} catch (\Exception $e) {
return back()->with([
'error' => 'Ошибка при публикации: ' . $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Controllers;
use App\Containers\Dashboard\Actions\ProcessUploadedFilesAction;
use App\Containers\Dashboard\UI\WEB\Requests\StoreFilesRequest;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
class StoreFilesController extends Controller
{
public function __construct(
private readonly ProcessUploadedFilesAction $processUploadedFilesAction,
) {}
/**
* Обработка загруженных файлов и создание новости
*/
public function __invoke(StoreFilesRequest $request): RedirectResponse
{
try {
$result = $this->processUploadedFilesAction->run(
$request->file('document'),
$request->file('media', [])
);
return back()->with([
'success' => 'Файлы успешно загружены! Новость создана: ' . $result['post']->title,
'extracted_text' => $result['newsData'],
'created_post' => [
...$result['post']->toArray(),
'preview_url' => $result['preview_url'],
'images_urls' => $result['images_urls'],
],
]);
} catch (\Exception $e) {
return back()->with([
'error' => 'Ошибка при создании новости: ' . $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFilesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// Поле для всех файлов (множественная загрузка)
'files' => [
'required',
'array',
'min:1',
'max:20' // Максимум 20 файлов за раз
],
// Проверка каждого файла ВНУТРИ массива files
'files.*' => [
'required',
'file',
'mimes:doc,docx,pdf,xls,xlsx,jpg,jpeg,png,webp,gif',
'max:20480' // макс 20МБ на КАЖДЫЙ файл
],
// Обязательно наличие хотя бы одного DOC или DOCX файла
'files' => [
'required',
'array',
function ($attribute, $value, $fail) {
$hasDocOrDocx = collect($value)->contains(function ($file) {
$ext = strtolower($file->getClientOriginalExtension());
return in_array($ext, ['doc', 'docx']);
});
if (!$hasDocOrDocx) {
$fail('Должен быть загружен хотя бы один DOC или DOCX файл для извлечения текста новости.');
}
},
],
];
}
public function messages(): array
{
return [
'files.required' => 'Загрузите хотя бы один файл',
'files.array' => 'Поле должно быть массивом файлов',
'files.min' => 'Загрузите хотя бы один файл',
'files.max' => 'Нельзя загрузить больше 20 файлов за один раз',
'files.*.mimes' => 'Файл имеет недопустимый формат. Разрешены: DOC, DOCX, PDF, XLS, XLSX, JPG, JPEG, PNG, WEBP, GIF',
'files.*.max' => 'Размер файла превышает 20 МБ.',
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
use App\Containers\Dashboard\UI\WEB\Controllers\IndexDashboardController;
use App\Containers\Dashboard\UI\WEB\Controllers\ProcessMixedFilesController;
use App\Containers\Dashboard\UI\WEB\Controllers\PublishPostController;
use App\Containers\Dashboard\UI\WEB\Controllers\StoreFilesController;
use Illuminate\Support\Facades\Route;
Route::middleware(['access-check', 'superadmin'])->group(function () {
Route::get('/dashboard', IndexDashboardController::class)->name('dashboard.index');
// Смешанная загрузка (все файлы в одном поле)
Route::post('/dashboard/files/store', ProcessMixedFilesController::class)->name('dashboard.files.store');
// Раздельная загрузка (document + media) - для будущего использования
Route::post('/dashboard/files/store-separate', StoreFilesController::class)->name('dashboard.files.store-separate');
// Публикация черновика поста
Route::post('/dashboard/posts/{post}/publish', PublishPostController::class)->name('dashboard.posts.publish');
});