diff --git a/POSTING_TASKS_API_FRONTEND.md b/POSTING_TASKS_API_FRONTEND.md new file mode 100644 index 0000000..7fc8dd1 --- /dev/null +++ b/POSTING_TASKS_API_FRONTEND.md @@ -0,0 +1,286 @@ +# API Документация: Управление задачами публикации (Frontend/AI Agent) + +## Базовый URL + +``` +https://api.konturai.kz +``` + +## Обзор + +API для управления задачами публикации постов в социальных сетях. Позволяет запускать задачи публикации вручную, не дожидаясь времени выполнения через шедулер. + +**Важно**: + +- Для выполнения задачи требуются настроенные credentials для соответствующей платформы +- Задачи создаются автоматически при запуске стратегии через `/api/marketing/analysis/strategy/{strategyId}/start` +- Каждая задача связана с элементом календаря постов в стратегии + +--- + +## Изменения в существующих эндпоинтах + +### Обновление: Получение стратегии по ID анализа + +**GET** `/api/marketing/analysis/{analysisId}/strategy` + +Теперь каждый элемент в `postCalendar` содержит поле `taskId`, если задача была создана для этого элемента. + +#### Пример ответа (обновленный формат) + +```json +{ + "success": true, + "data": { + "strategyId": "507f1f77bcf86cd799439012", + "analysisId": "507f1f77bcf86cd799439011", + "status": "completed", + "strategy": { + "postCalendar": [ + { + "publishDate": "2025-01-25T10:00:00", + "platform": "Facebook", + "contentType": "пост", + "theme": "Презентация продукта", + "postText": "Добро пожаловать в наш новый продукт!", + "hashtags": ["#маркетинг", "#бизнес"], + "publishTime": "10:00", + "imageUrl": "post_image_1234567890.png", + "imageFilename": "post_image_1234567890.png", + "taskId": "507f1f77bcf86cd799439013" + } + ] + } + } +} +``` + +**Новое поле:** + +- `taskId` (string, опциональное) - ID задачи публикации, если задача была создана. Может быть `null`, если стратегия еще не была запущена. + +--- + +## Новые эндпоинты + +### 1. Ручной запуск задачи публикации + +**POST** `/api/marketing/analysis/tasks/{taskId}/execute` + +Запускает задачу публикации немедленно, не дожидаясь времени публикации через шедулер. Позволяет выполнить задачу вручную или повторить выполнение неудачной задачи. + +#### Параметры запроса + +| Параметр | Тип | Расположение | Обязательный | Описание | +| -------- | ------ | ------------ | ------------ | -------------------- | +| `taskId` | string | Path | ✅ | ID задачи публикации | + +#### Заголовки запроса + +``` +Authorization: Bearer +Content-Type: application/json +``` + +#### Пример запроса + +```http +POST /api/marketing/analysis/tasks/507f1f77bcf86cd799439013/execute +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json +``` + +#### Пример успешного ответа (200 OK) + +```json +{ + "success": true, + "message": "Задача успешно запущена", + "data": { + "taskId": "507f1f77bcf86cd799439013", + "status": "processing", + "platform": "Facebook", + "publishDate": "2025-01-25T10:00:00" + } +} +``` + +#### Описание полей ответа + +| Поле | Тип | Описание | +| ------------- | ------ | ----------------------------------------------------- | +| `taskId` | string | ID задачи публикации | +| `status` | string | Статус задачи: `processing`, `completed` или `failed` | +| `platform` | string | Платформа для публикации (например, `Facebook`) | +| `publishDate` | string | Дата и время публикации в формате ISO 8601 | + +#### Ошибки + +**401 Unauthorized** - Требуется аутентификация + +```json +{ + "success": false, + "message": "Не авторизован", + "error": { + "code": "UNAUTHORIZED", + "message": "Требуется аутентификация. Пожалуйста, предоставьте валидный JWT токен." + } +} +``` + +**403 Forbidden** - Пользователь не является владельцем задачи + +```json +{ + "success": false, + "message": "Доступ запрещен", + "error": { + "code": "FORBIDDEN", + "message": "У вас нет доступа к этой задаче" + } +} +``` + +**404 Not Found** - Задача не найдена + +```json +{ + "success": false, + "message": "Задача не найдена", + "error": { + "code": "NOT_FOUND", + "message": "Задача с указанным ID не найдена" + } +} +``` + +**400 Bad Request** - Задача не может быть запущена + +```json +{ + "success": false, + "message": "Задача не может быть запущена", + "error": { + "code": "INVALID_STATUS", + "message": "Task cannot be executed manually. Current status: completed. Only tasks with status 'pending' or 'failed' can be executed manually." + } +} +``` + +#### Правила выполнения + +1. **Статусы задач:** + + - `pending` - задача ожидает выполнения (может быть запущена вручную) + - `failed` - задача завершилась с ошибкой (может быть запущена повторно вручную) + - `processing` - задача выполняется (не может быть запущена повторно) + - `completed` - задача успешно выполнена (не может быть запущена повторно) + +2. **Повторное выполнение:** + + - Задачи со статусом `failed` автоматически сбрасываются на `pending` перед повторным выполнением + - Ошибка из предыдущего выполнения очищается + +3. **Асинхронное выполнение:** + - Задача запускается асинхронно + - Ответ возвращается сразу после начала выполнения + - Для проверки статуса задачи используйте соответствующие эндпоинты (если доступны) + +#### Примеры использования + +**Пример 1: Запуск задачи, которая еще не была выполнена** + +```javascript +// Получаем стратегию +const strategyResponse = await fetch( + `/api/marketing/analysis/${analysisId}/strategy`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } +); + +const strategy = await strategyResponse.json(); +const taskId = strategy.data.strategy.postCalendar[0].taskId; + +// Запускаем задачу вручную +const executeResponse = await fetch( + `/api/marketing/analysis/tasks/${taskId}/execute`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } +); + +const result = await executeResponse.json(); +console.log('Задача запущена:', result.data); +``` + +**Пример 2: Повторное выполнение неудачной задачи** + +```javascript +// Если задача завершилась с ошибкой (status: "failed") +// можно повторить её выполнение +const retryResponse = await fetch( + `/api/marketing/analysis/tasks/${failedTaskId}/execute`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } +); + +const retryResult = await retryResponse.json(); +if (retryResult.success) { + console.log('Повторная попытка запущена:', retryResult.data); +} +``` + +--- + +## Интеграция с существующим workflow + +### Типичный сценарий использования + +1. **Создание анализа** → `POST /api/marketing/analysis/start` +2. **Генерация стратегии** → `POST /api/marketing/analysis/strategy/generate` +3. **Получение стратегии** → `GET /api/marketing/analysis/{analysisId}/strategy` + - Теперь содержит `taskId` для каждого элемента календаря +4. **Запуск стратегии** → `POST /api/marketing/analysis/strategy/{strategyId}/start` + - Создает задачи публикации для всех элементов календаря +5. **Ручной запуск задачи** (опционально) → `POST /api/marketing/analysis/tasks/{taskId}/execute` + - Запускает задачу немедленно, не дожидаясь времени публикации + +### Когда использовать ручной запуск + +- **Тестирование**: Проверить публикацию поста перед запланированным временем +- **Повторная попытка**: Повторить выполнение задачи, которая завершилась с ошибкой +- **Срочная публикация**: Опубликовать пост раньше запланированного времени +- **Отладка**: Проверить работу системы публикации + +--- + +## Примечания + +1. **Аутентификация**: Все эндпоинты требуют валидный JWT токен в заголовке `Authorization` +2. **Права доступа**: Пользователь может запускать только свои собственные задачи +3. **Статусы задач**: Проверяйте статус задачи перед попыткой ручного запуска +4. **Асинхронность**: Выполнение задачи происходит асинхронно, ответ возвращается сразу +5. **Ошибки выполнения**: Если задача завершится с ошибкой, её можно запустить повторно + +--- + +## Версия API + +- **Версия документа**: 1.0 +- **Дата обновления**: 2025-01-20 +- **Изменения**: + - Добавлен эндпоинт для ручного запуска задач публикации + - Добавлено поле `taskId` в элементы календаря постов при получении стратегии diff --git a/src/main/java/kz/konturai/parser/controller/MarketingController.java b/src/main/java/kz/konturai/parser/controller/MarketingController.java index 8bcc3db..2d33fd4 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingController.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingController.java @@ -659,6 +659,85 @@ public class MarketingController { } } + @PostMapping("/tasks/{taskId}/execute") + public ResponseEntity executeTaskManually( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String taskId) { + + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) { + return unauthorizedResponse(); + } + + // Проверяем существование задачи и права доступа + Optional optTask = postingTaskService.getTaskById(taskId); + if (optTask.isEmpty()) { + ErrorResponse error = new ErrorResponse( + "NOT_FOUND", + "Задача с указанным ID не найдена"); + return ResponseEntity.status(404) + .body(ApiResponse.error("Задача не найдена", error)); + } + + PostingTask task = optTask.get(); + if (!userId.equals(task.getUserId())) { + ErrorResponse error = new ErrorResponse( + "FORBIDDEN", + "У вас нет доступа к этой задаче"); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("Доступ запрещен", error)); + } + + try { + // Запускаем задачу вручную + postingTaskService.executeTaskManually(taskId); + + // Получаем обновленную задачу для ответа + Optional updatedTask = postingTaskService.getTaskById(taskId); + if (updatedTask.isPresent()) { + PostingTask taskData = updatedTask.get(); + Map responseData = new HashMap<>(); + responseData.put("taskId", taskData.getId()); + responseData.put("status", taskData.getStatus()); + responseData.put("platform", taskData.getPlatform()); + responseData.put("publishDate", taskData.getPublishDate()); + + return ResponseEntity.ok(ApiResponse.success( + "Задача успешно запущена", + responseData)); + } else { + // Если задача не найдена после выполнения (маловероятно) + Map responseData = new HashMap<>(); + responseData.put("taskId", taskId); + responseData.put("status", "processing"); + + return ResponseEntity.ok(ApiResponse.success( + "Задача успешно запущена", + responseData)); + } + + } catch (IllegalArgumentException e) { + ErrorResponse error = new ErrorResponse( + "NOT_FOUND", + e.getMessage()); + return ResponseEntity.status(404) + .body(ApiResponse.error("Задача не найдена", error)); + } catch (IllegalStateException e) { + ErrorResponse error = new ErrorResponse( + "INVALID_STATUS", + e.getMessage()); + return ResponseEntity.status(400) + .body(ApiResponse.error("Задача не может быть запущена", error)); + } catch (Exception e) { + logger.error("Error executing task {} manually: {}", taskId, e.getMessage(), e); + ErrorResponse error = new ErrorResponse( + "INTERNAL_SERVER_ERROR", + "Произошла ошибка при запуске задачи"); + return ResponseEntity.status(500) + .body(ApiResponse.error("Внутренняя ошибка сервера", error)); + } + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationException( MethodArgumentNotValidException ex) { diff --git a/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java b/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java index f6f4d0b..8d78044 100644 --- a/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java +++ b/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java @@ -16,7 +16,9 @@ public class MarketingStrategyResponse { public MarketingStrategyResponse() { } - public MarketingStrategyResponse(String strategyId, String analysisId, String status, LocalDateTime createdAt, LocalDateTime completedAt, Integer durationWeeks, List priorityPlatforms, StrategyContent strategy) { + public MarketingStrategyResponse(String strategyId, String analysisId, String status, LocalDateTime createdAt, + LocalDateTime completedAt, Integer durationWeeks, List priorityPlatforms, + StrategyContent strategy) { this.strategyId = strategyId; this.analysisId = analysisId; this.status = status; @@ -129,7 +131,8 @@ public class MarketingStrategyResponse { public WeeklyPlan() { } - public WeeklyPlan(Integer weekNumber, List mainThemes, String contentRecommendations, List priorityPlatforms) { + public WeeklyPlan(Integer weekNumber, List mainThemes, String contentRecommendations, + List priorityPlatforms) { this.weekNumber = weekNumber; this.mainThemes = mainThemes; this.contentRecommendations = contentRecommendations; @@ -179,11 +182,13 @@ public class MarketingStrategyResponse { private String publishTime; private String imageUrl; private String imageFilename; + private String taskId; public PostCalendarItem() { } - public PostCalendarItem(LocalDateTime publishDate, String platform, String contentType, String theme, String postText, List hashtags, String publishTime) { + public PostCalendarItem(LocalDateTime publishDate, String platform, String contentType, String theme, + String postText, List hashtags, String publishTime) { this.publishDate = publishDate; this.platform = platform; this.contentType = contentType; @@ -264,6 +269,13 @@ public class MarketingStrategyResponse { public void setImageFilename(String imageFilename) { this.imageFilename = imageFilename; } + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } } } - diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java index 5a8365c..5938c10 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java @@ -8,6 +8,7 @@ import kz.konturai.parser.dto.MarketingStrategyResponse; import kz.konturai.parser.dto.StatusHistoryEntry; import kz.konturai.parser.model.MarketingAnalysis; import kz.konturai.parser.model.MarketingStrategy; +import kz.konturai.parser.model.PostingTask; import kz.konturai.parser.repository.MarketingAnalysisRepository; import kz.konturai.parser.repository.MarketingStrategyRepository; import org.slf4j.Logger; @@ -32,6 +33,7 @@ public class MarketingStrategyService { private final OpenAIAnalyticsService openAIAnalyticsService; private final OpenAIImageGenerationService imageGenerationService; private final MinIOService minIOService; + private final PostingTaskService postingTaskService; private final ObjectMapper objectMapper = new ObjectMapper(); public MarketingStrategyService( @@ -40,13 +42,15 @@ public class MarketingStrategyService { MarketingAnalysisRepository analysisRepository, OpenAIAnalyticsService openAIAnalyticsService, OpenAIImageGenerationService imageGenerationService, - MinIOService minIOService) { + MinIOService minIOService, + PostingTaskService postingTaskService) { this.repository = repository; this.marketingAnalysisService = marketingAnalysisService; this.analysisRepository = analysisRepository; this.openAIAnalyticsService = openAIAnalyticsService; this.imageGenerationService = imageGenerationService; this.minIOService = minIOService; + this.postingTaskService = postingTaskService; } public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, String userId) { @@ -615,8 +619,11 @@ public class MarketingStrategyService { } strategyContent.setWeeklyPlans(weeklyPlans); - // Convert PostCalendarItems + // Convert PostCalendarItems and add taskId if tasks exist List postCalendar = new ArrayList<>(); + // Получаем все задачи для этой стратегии + List tasks = postingTaskService.getStrategyTasks(strategyId); + for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { MarketingStrategyResponse.PostCalendarItem dtoItem = new MarketingStrategyResponse.PostCalendarItem(); dtoItem.setPublishDate(item.getPublishDate()); @@ -628,6 +635,21 @@ public class MarketingStrategyService { dtoItem.setPublishTime(item.getPublishTime()); dtoItem.setImageUrl(item.getImageUrl()); dtoItem.setImageFilename(item.getImageFilename()); + + // Находим соответствующую задачу по дате публикации, платформе и тексту поста + Optional matchingTask = tasks.stream() + .filter(task -> task.getPublishDate() != null && item.getPublishDate() != null + && task.getPublishDate().equals(item.getPublishDate()) + && task.getPlatform() != null && item.getPlatform() != null + && task.getPlatform().equalsIgnoreCase(item.getPlatform()) + && task.getPostText() != null && item.getPostText() != null + && task.getPostText().equals(item.getPostText())) + .findFirst(); + + if (matchingTask.isPresent()) { + dtoItem.setTaskId(matchingTask.get().getId()); + } + postCalendar.add(dtoItem); } strategyContent.setPostCalendar(postCalendar); diff --git a/src/main/java/kz/konturai/parser/service/PostingTaskService.java b/src/main/java/kz/konturai/parser/service/PostingTaskService.java index 42f4237..535198c 100644 --- a/src/main/java/kz/konturai/parser/service/PostingTaskService.java +++ b/src/main/java/kz/konturai/parser/service/PostingTaskService.java @@ -225,6 +225,44 @@ public class PostingTaskService { executeTask(taskId); } + /** + * Ручное выполнение задачи публикации (независимо от времени публикации) + * Разрешает выполнение только для задач со статусом "pending" или "failed" + * + * @param taskId ID задачи + * @throws IllegalArgumentException если задача не найдена + * @throws IllegalStateException если задача уже выполнена или имеет + * недопустимый статус + */ + public void executeTaskManually(String taskId) { + Optional optTask = taskRepository.findById(taskId); + if (optTask.isEmpty()) { + throw new IllegalArgumentException("Task not found: " + taskId); + } + + PostingTask task = optTask.get(); + + // Проверяем статус - разрешаем только pending или failed + String status = task.getStatus(); + if (!"pending".equals(status) && !"failed".equals(status)) { + throw new IllegalStateException( + "Task cannot be executed manually. Current status: " + status + + ". Only tasks with status 'pending' or 'failed' can be executed manually."); + } + + // Если задача в статусе failed, сбрасываем статус на pending для повторной + // попытки + if ("failed".equals(status)) { + task.setStatus("pending"); + task.setErrorMessage(null); + taskRepository.save(task); + logger.info("Task {} status reset from 'failed' to 'pending' for manual execution", taskId); + } + + // Выполняем задачу (игнорируя время публикации) + executeTask(taskId); + } + /** * Получает задачи пользователя * @@ -244,4 +282,14 @@ public class PostingTaskService { public List getStrategyTasks(String strategyId) { return taskRepository.findByStrategyId(strategyId); } + + /** + * Получает задачу по ID + * + * @param taskId ID задачи + * @return Optional с задачей, если найдена + */ + public Optional getTaskById(String taskId) { + return taskRepository.findById(taskId); + } }