From cd233bf4c09193d0f2e4cd35f878fa192ae402b9 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 29 Nov 2025 21:48:10 +0500 Subject: [PATCH] . --- marketing-api-with-jwt-frontend.md | 128 ++++++++++ .../controller/MarketingController.java | 87 ++++++- .../SocialMediaCredentialsController.java | 211 +++++++++++++++++ .../parser/dto/AnalysisHistoryResponse.java | 10 +- .../parser/dto/MarketingAnalysisRequest.java | 22 +- .../dto/SocialMediaCredentialsRequest.java | 37 +++ .../dto/SocialMediaCredentialsResponse.java | 60 +++++ .../parser/dto/StartStrategyRequest.java | 11 + .../parser/dto/StartStrategyResponse.java | 54 +++++ .../konturai/parser/model/AnalysisType.java | 37 +++ .../parser/model/MarketingAnalysis.java | 21 ++ .../kz/konturai/parser/model/PostingTask.java | 152 ++++++++++++ .../parser/model/SocialMediaCredentials.java | 91 ++++++++ .../repository/PostingTaskRepository.java | 18 ++ .../SocialMediaCredentialsRepository.java | 16 ++ .../parser/service/EncryptionService.java | 85 +++++++ .../service/FacebookPostingService.java | 144 ++++++++++++ .../service/MarketingAnalysisService.java | 87 +++++-- .../service/PostingSchedulerService.java | 68 ++++++ .../parser/service/PostingTaskService.java | 221 ++++++++++++++++++ .../SocialMediaCredentialsService.java | 150 ++++++++++++ .../validator/AnalysisTypeValidator.java | 20 ++ .../parser/validator/ValidAnalysisType.java | 21 ++ src/main/resources/application.properties | 12 + 24 files changed, 1747 insertions(+), 16 deletions(-) create mode 100644 src/main/java/kz/konturai/parser/controller/SocialMediaCredentialsController.java create mode 100644 src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsRequest.java create mode 100644 src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsResponse.java create mode 100644 src/main/java/kz/konturai/parser/dto/StartStrategyRequest.java create mode 100644 src/main/java/kz/konturai/parser/dto/StartStrategyResponse.java create mode 100644 src/main/java/kz/konturai/parser/model/AnalysisType.java create mode 100644 src/main/java/kz/konturai/parser/model/PostingTask.java create mode 100644 src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java create mode 100644 src/main/java/kz/konturai/parser/repository/PostingTaskRepository.java create mode 100644 src/main/java/kz/konturai/parser/repository/SocialMediaCredentialsRepository.java create mode 100644 src/main/java/kz/konturai/parser/service/EncryptionService.java create mode 100644 src/main/java/kz/konturai/parser/service/FacebookPostingService.java create mode 100644 src/main/java/kz/konturai/parser/service/PostingSchedulerService.java create mode 100644 src/main/java/kz/konturai/parser/service/PostingTaskService.java create mode 100644 src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java create mode 100644 src/main/java/kz/konturai/parser/validator/AnalysisTypeValidator.java create mode 100644 src/main/java/kz/konturai/parser/validator/ValidAnalysisType.java diff --git a/marketing-api-with-jwt-frontend.md b/marketing-api-with-jwt-frontend.md index 10ce258..215b86f 100644 --- a/marketing-api-with-jwt-frontend.md +++ b/marketing-api-with-jwt-frontend.md @@ -16,6 +16,7 @@ API для генерации маркетингового анализа и с - ✅ Пользователи могут видеть только свои анализы и стратегии - ✅ Добавлена детальная история статусов для каждого анализа и стратегии - ✅ Новые эндпоинты для получения списка всех анализов/стратегий пользователя +- ✅ **НОВОЕ**: Добавлена поддержка типов анализов (РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT) --- @@ -77,6 +78,7 @@ Authorization: Bearer | `location` | string | ✅ | Географическая локация работы | "Алматы, Казахстан" | | `client` | string | ✅ | Тип целевой аудитории | "B2B клиенты" | | `differentiator` | string | ✅ | Уникальные особенности бизнеса | "Быстрая разработка за 2 недели" | +| `analysisType` | string | ✅ | Тип анализа для генерации | "РЫНОК" | #### Валидация полей @@ -104,6 +106,15 @@ Authorization: Bearer - Минимальная длина: 10 символов - Максимальная длина: 500 символов +**`analysisType`** (string, обязательное) + +- Допустимые значения (регистр не важен, но рекомендуется использовать заглавные буквы): + - `"РЫНОК"` - Анализ рынка (размер рынка, динамика роста, сегменты, тренды) + - `"КОНКУРЕНТЫ"` - Анализ конкурентов (основные конкуренты, их сильные/слабые стороны, позиционирование) + - `"ЦА"` - Анализ целевой аудитории (демография, психография, потребности, поведение) + - `"КАНАЛЫ"` - Анализ маркетинговых каналов (эффективность каналов, рекомендации по выбору) + - `"SWOT"` - SWOT-анализ (сильные стороны, слабые стороны, возможности, угрозы) + #### Пример запроса ```javascript @@ -120,6 +131,7 @@ const response = await fetch( location: 'Нур-Султан, Казахстан', client: 'B2B клиенты', differentiator: 'Специализируемся на быстрой разработке MVP за 4 недели', + analysisType: 'РЫНОК', }), } ); @@ -976,6 +988,122 @@ async function getMyStrategies(jwtToken) { --- +## Изменения: Поддержка типов анализов + +### Обзор изменений + +В API добавлена поддержка различных типов маркетинговых анализов. Теперь при создании анализа можно указать тип анализа, который определяет фокус и содержание генерируемого отчета. + +### Новое поле в запросе + +**Поле**: `analysisType` (обязательное) + +**Тип**: `string` + +**Допустимые значения**: + +- `"РЫНОК"` - Анализ рынка +- `"КОНКУРЕНТЫ"` - Анализ конкурентов +- `"ЦА"` - Анализ целевой аудитории +- `"КАНАЛЫ"` - Анализ маркетинговых каналов +- `"SWOT"` - SWOT-анализ + +### Описание типов анализов + +#### 1. РЫНОК + +Генерирует анализ рынка, включающий: + +- Размер рынка +- Динамику роста +- Основные сегменты +- Тренды и перспективы развития + +#### 2. КОНКУРЕНТЫ + +Генерирует анализ конкурентов, включающий: + +- Основных конкурентов +- Их сильные и слабые стороны +- Позиционирование +- Ценовую политику +- Маркетинговые стратегии + +#### 3. ЦА (Целевая аудитория) + +Генерирует анализ целевой аудитории, включающий: + +- Демографические характеристики +- Психографический профиль +- Потребности и боли +- Поведенческие паттерны +- Предпочтения + +#### 4. КАНАЛЫ + +Генерирует анализ маркетинговых каналов, включающий: + +- Оценку эффективности различных каналов коммуникации +- Рекомендации по выбору каналов +- Особенности использования каждого канала +- Бюджетные рекомендации + +#### 5. SWOT + +Генерирует SWOT-анализ, включающий: + +- Сильные стороны (Strengths) +- Слабые стороны (Weaknesses) +- Возможности (Opportunities) +- Угрозы (Threats) + +### Изменения в ответе + +В ответе анализа теперь присутствует поле `analysisType` в объекте `reportData`, которое содержит название типа анализа. + +### Пример использования + +```javascript +// Запрос на создание SWOT-анализа +const response = await fetch( + 'https://api.konturai.kz/api/marketing/analysis/start', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + }, + body: JSON.stringify({ + product: 'Разработка мобильных приложений', + location: 'Нур-Султан, Казахстан', + client: 'B2B клиенты', + differentiator: 'Специализируемся на быстрой разработке MVP за 4 недели', + analysisType: 'SWOT', // Указываем тип анализа + }), + } +); +``` + +### Рекомендации для фронтенда + +1. **UI для выбора типа анализа**: Добавьте выпадающий список или радиокнопки для выбора типа анализа перед отправкой запроса. + +2. **Валидация на фронтенде**: Убедитесь, что отправляется только один из допустимых значений. + +3. **Отображение типа анализа**: При показе результатов анализа отображайте тип анализа для контекста. + +4. **Обратная совместимость**: Если поле `analysisType` не указано, API вернет ошибку валидации. Это обязательное поле. + +### Миграция существующих интеграций + +Если у вас есть существующие интеграции, которые не используют поле `analysisType`, необходимо: + +1. Добавить поле `analysisType` в запрос +2. Выбрать подходящий тип анализа по умолчанию или позволить пользователю выбрать +3. Обновить UI для отображения выбора типа анализа + +--- + ## Поддержка При возникновении проблем с API обращайтесь в техническую поддержку с указанием: diff --git a/src/main/java/kz/konturai/parser/controller/MarketingController.java b/src/main/java/kz/konturai/parser/controller/MarketingController.java index f78d600..5789a19 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingController.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingController.java @@ -3,10 +3,12 @@ package kz.konturai.parser.controller; import kz.konturai.parser.dto.*; import kz.konturai.parser.model.MarketingAnalysis; import kz.konturai.parser.model.MarketingStrategy; +import kz.konturai.parser.model.PostingTask; import kz.konturai.parser.service.JwtService; import kz.konturai.parser.service.MarketingAnalysisService; import kz.konturai.parser.service.MarketingStrategyService; import kz.konturai.parser.service.MinIOService; +import kz.konturai.parser.service.PostingTaskService; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -31,16 +33,19 @@ public class MarketingController { private final MarketingStrategyService marketingStrategyService; private final MinIOService minIOService; private final JwtService jwtService; + private final PostingTaskService postingTaskService; public MarketingController( MarketingAnalysisService marketingAnalysisService, MarketingStrategyService marketingStrategyService, MinIOService minIOService, - JwtService jwtService) { + JwtService jwtService, + PostingTaskService postingTaskService) { this.marketingAnalysisService = marketingAnalysisService; this.marketingStrategyService = marketingStrategyService; this.minIOService = minIOService; this.jwtService = jwtService; + this.postingTaskService = postingTaskService; } private String extractUserIdFromHeader(String authHeader) { @@ -403,6 +408,7 @@ public class MarketingController { response.setLocation(analysis.getLocation()); response.setClientType(analysis.getClientType()); response.setDifferentiator(analysis.getDifferentiator()); + response.setAnalysisType(analysis.getAnalysisType()); response.setStatus(analysis.getStatus()); response.setUserId(analysis.getUserId()); response.setCreatedAt(analysis.getCreatedAt()); @@ -425,6 +431,85 @@ public class MarketingController { return response; } + @PostMapping("/strategy/{strategyId}/start") + public ResponseEntity startStrategy( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String strategyId, + @RequestBody(required = false) StartStrategyRequest request) { + + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) { + return unauthorizedResponse(); + } + + // Проверяем существование стратегии и права доступа + Optional optStrategy = marketingStrategyService.getStrategyById(strategyId); + if (optStrategy.isEmpty()) { + ErrorResponse error = new ErrorResponse( + "NOT_FOUND", + "Стратегия с указанным ID не найдена"); + return ResponseEntity.status(404) + .body(ApiResponse.error("Стратегия не найдена", error)); + } + + MarketingStrategy strategy = optStrategy.get(); + if (!userId.equals(strategy.getUserId())) { + ErrorResponse error = new ErrorResponse( + "FORBIDDEN", + "У вас нет доступа к этой стратегии"); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("Доступ запрещен", error)); + } + + if (!"completed".equals(strategy.getStatus())) { + ErrorResponse error = new ErrorResponse( + "INVALID_STATUS", + "Стратегия еще не завершена. Статус: " + strategy.getStatus()); + return ResponseEntity.status(400) + .body(ApiResponse.error("Стратегия не готова к запуску", error)); + } + + try { + // Создаем задачи из стратегии + List tasks = postingTaskService.createTasksFromStrategy(strategyId); + + // Получаем список платформ + List platforms = tasks.stream() + .map(PostingTask::getPlatform) + .distinct() + .collect(Collectors.toList()); + + StartStrategyResponse response = new StartStrategyResponse( + strategyId, + tasks.size(), + platforms, + "Стратегия успешно запущена. Создано задач: " + tasks.size()); + + return ResponseEntity.ok(ApiResponse.success( + "Стратегия успешно запущена", + response)); + + } catch (IllegalStateException e) { + ErrorResponse error = new ErrorResponse( + "MISSING_CREDENTIALS", + e.getMessage()); + return ResponseEntity.status(400) + .body(ApiResponse.error("Не удалось запустить стратегию", error)); + } catch (IllegalArgumentException e) { + ErrorResponse error = new ErrorResponse( + "INVALID_STRATEGY", + e.getMessage()); + return ResponseEntity.status(404) + .body(ApiResponse.error("Стратегия не найдена", error)); + } catch (Exception 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/controller/SocialMediaCredentialsController.java b/src/main/java/kz/konturai/parser/controller/SocialMediaCredentialsController.java new file mode 100644 index 0000000..d8d464c --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/SocialMediaCredentialsController.java @@ -0,0 +1,211 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.*; +import kz.konturai.parser.model.SocialMediaCredentials; +import kz.konturai.parser.service.JwtService; +import kz.konturai.parser.service.SocialMediaCredentialsService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/social-media/credentials") +public class SocialMediaCredentialsController { + + private final SocialMediaCredentialsService credentialsService; + private final JwtService jwtService; + + public SocialMediaCredentialsController( + SocialMediaCredentialsService credentialsService, + JwtService jwtService) { + this.credentialsService = credentialsService; + this.jwtService = jwtService; + } + + private String extractUserIdFromHeader(String authHeader) { + if (authHeader == null || authHeader.isEmpty()) { + return null; + } + return jwtService.extractUserIdFromHeader(authHeader); + } + + private ResponseEntity unauthorizedResponse() { + ErrorResponse error = new ErrorResponse( + "UNAUTHORIZED", + "Требуется аутентификация. Пожалуйста, предоставьте валидный JWT токен."); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("Не авторизован", error)); + } + + /** + * Сохранение или обновление credentials для платформы + * POST /api/social-media/credentials + */ + @PostMapping + public ResponseEntity saveCredentials( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @Valid @RequestBody SocialMediaCredentialsRequest request) { + + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) { + return unauthorizedResponse(); + } + + try { + SocialMediaCredentials credentials = credentialsService.saveCredentials( + userId, + request.getPlatform(), + request.getCredentials() + ); + + SocialMediaCredentialsResponse response = new SocialMediaCredentialsResponse( + credentials.getPlatform(), + true, + credentials.getCreatedAt(), + credentials.getUpdatedAt() + ); + + return ResponseEntity.ok(ApiResponse.success( + "Credentials успешно сохранены для платформы " + request.getPlatform(), + response)); + + } catch (IllegalArgumentException e) { + ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", e.getMessage()); + return ResponseEntity.badRequest() + .body(ApiResponse.error("Ошибка валидации", error)); + } catch (Exception e) { + ErrorResponse error = new ErrorResponse( + "INTERNAL_SERVER_ERROR", + "Произошла ошибка при сохранении credentials"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("Внутренняя ошибка сервера", error)); + } + } + + /** + * Получение информации о credentials для платформы + * GET /api/social-media/credentials/{platform} + */ + @GetMapping("/{platform}") + public ResponseEntity getCredentials( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String platform) { + + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) { + return unauthorizedResponse(); + } + + boolean hasCredentials = credentialsService.hasCredentials(userId, platform); + SocialMediaCredentialsResponse response = new SocialMediaCredentialsResponse( + platform, + hasCredentials + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * Получение списка всех платформ с credentials + * GET /api/social-media/credentials + */ + @GetMapping + public ResponseEntity getAllCredentials( + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) { + return unauthorizedResponse(); + } + + List credentialsList = credentialsService.getUserCredentials(userId); + List responseList = credentialsList.stream() + .map(cred -> new SocialMediaCredentialsResponse( + cred.getPlatform(), + true, + cred.getCreatedAt(), + cred.getUpdatedAt() + )) + .collect(Collectors.toList()); + + return ResponseEntity.ok(ApiResponse.success(responseList)); + } + + /** + * Удаление credentials для платформы + * DELETE /api/social-media/credentials/{platform} + */ + @DeleteMapping("/{platform}") + public ResponseEntity deleteCredentials( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String platform) { + + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) { + return unauthorizedResponse(); + } + + try { + if (!credentialsService.hasCredentials(userId, platform)) { + ErrorResponse error = new ErrorResponse( + "NOT_FOUND", + "Credentials для платформы " + platform + " не найдены"); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error("Credentials не найдены", error)); + } + + credentialsService.deleteCredentials(userId, platform); + + return ResponseEntity.ok(ApiResponse.success( + "Credentials для платформы " + platform + " успешно удалены", + null)); + + } catch (IllegalArgumentException e) { + ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", e.getMessage()); + return ResponseEntity.badRequest() + .body(ApiResponse.error("Ошибка валидации", error)); + } catch (Exception e) { + ErrorResponse error = new ErrorResponse( + "INTERNAL_SERVER_ERROR", + "Произошла ошибка при удалении credentials"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("Внутренняя ошибка сервера", error)); + } + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException( + MethodArgumentNotValidException ex) { + + Map details = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> { + details.put(error.getField(), error.getDefaultMessage()); + }); + + ErrorResponse errorResponse = new ErrorResponse( + "VALIDATION_ERROR", + "Ошибка валидации входных данных", + details); + + return ResponseEntity.badRequest() + .body(ApiResponse.error("Ошибка валидации", errorResponse)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception e) { + ErrorResponse errorResponse = new ErrorResponse( + "INTERNAL_SERVER_ERROR", + "Произошла внутренняя ошибка сервера. Попробуйте позже."); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("Внутренняя ошибка сервера", errorResponse)); + } +} + diff --git a/src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java b/src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java index 7f2763b..a8fc42f 100644 --- a/src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java +++ b/src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java @@ -9,6 +9,7 @@ public class AnalysisHistoryResponse { private String location; private String clientType; private String differentiator; + private String analysisType; private String status; private String userId; private LocalDateTime createdAt; @@ -97,5 +98,12 @@ public class AnalysisHistoryResponse { public void setStatusHistory(List statusHistory) { this.statusHistory = statusHistory; } -} + public String getAnalysisType() { + return analysisType; + } + + public void setAnalysisType(String analysisType) { + this.analysisType = analysisType; + } +} diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java index df7b251..8b63cc2 100644 --- a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java @@ -3,6 +3,8 @@ package kz.konturai.parser.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import kz.konturai.parser.model.AnalysisType; +import kz.konturai.parser.validator.ValidAnalysisType; import kz.konturai.parser.validator.ValidClientType; public class MarketingAnalysisRequest { @@ -25,14 +27,20 @@ public class MarketingAnalysisRequest { @Size(min = 10, max = 500, message = "Поле 'differentiator' должно содержать от 10 до 500 символов") private String differentiator; + @NotBlank(message = "Поле 'analysisType' обязательно для заполнения") + @ValidAnalysisType(message = "Поле 'analysisType' должно быть одним из: РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT") + private String analysisType; + public MarketingAnalysisRequest() { } - public MarketingAnalysisRequest(String product, String location, String client, String differentiator) { + public MarketingAnalysisRequest(String product, String location, String client, String differentiator, + String analysisType) { this.product = product; this.location = location; this.client = client; this.differentiator = differentiator; + this.analysisType = analysisType; } public String getProduct() { @@ -66,4 +74,16 @@ public class MarketingAnalysisRequest { public void setDifferentiator(String differentiator) { this.differentiator = differentiator; } + + public String getAnalysisType() { + return analysisType; + } + + public void setAnalysisType(String analysisType) { + this.analysisType = analysisType; + } + + public AnalysisType getAnalysisTypeEnum() { + return AnalysisType.fromString(analysisType); + } } diff --git a/src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsRequest.java b/src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsRequest.java new file mode 100644 index 0000000..2b1ffd1 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsRequest.java @@ -0,0 +1,37 @@ +package kz.konturai.parser.dto; + +import jakarta.validation.constraints.NotBlank; + +public class SocialMediaCredentialsRequest { + + @NotBlank(message = "Platform is required") + private String platform; + + @NotBlank(message = "Credentials are required") + private String credentials; + + public SocialMediaCredentialsRequest() { + } + + public SocialMediaCredentialsRequest(String platform, String credentials) { + this.platform = platform; + this.credentials = credentials; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getCredentials() { + return credentials; + } + + public void setCredentials(String credentials) { + this.credentials = credentials; + } +} + diff --git a/src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsResponse.java b/src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsResponse.java new file mode 100644 index 0000000..7d292d9 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/SocialMediaCredentialsResponse.java @@ -0,0 +1,60 @@ +package kz.konturai.parser.dto; + +import java.time.LocalDateTime; + +public class SocialMediaCredentialsResponse { + + private String platform; + private boolean hasCredentials; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public SocialMediaCredentialsResponse() { + } + + public SocialMediaCredentialsResponse(String platform, boolean hasCredentials) { + this.platform = platform; + this.hasCredentials = hasCredentials; + } + + public SocialMediaCredentialsResponse(String platform, boolean hasCredentials, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.platform = platform; + this.hasCredentials = hasCredentials; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public boolean isHasCredentials() { + return hasCredentials; + } + + public void setHasCredentials(boolean hasCredentials) { + this.hasCredentials = hasCredentials; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} + diff --git a/src/main/java/kz/konturai/parser/dto/StartStrategyRequest.java b/src/main/java/kz/konturai/parser/dto/StartStrategyRequest.java new file mode 100644 index 0000000..f8d46bf --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/StartStrategyRequest.java @@ -0,0 +1,11 @@ +package kz.konturai.parser.dto; + +public class StartStrategyRequest { + + // Пока что пустой класс, можно расширить в будущем + // Например, добавить опции для фильтрации задач или настройки выполнения + + public StartStrategyRequest() { + } +} + diff --git a/src/main/java/kz/konturai/parser/dto/StartStrategyResponse.java b/src/main/java/kz/konturai/parser/dto/StartStrategyResponse.java new file mode 100644 index 0000000..77a2b1b --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/StartStrategyResponse.java @@ -0,0 +1,54 @@ +package kz.konturai.parser.dto; + +import java.util.List; + +public class StartStrategyResponse { + + private String strategyId; + private int tasksCreated; + private List platforms; + private String message; + + public StartStrategyResponse() { + } + + public StartStrategyResponse(String strategyId, int tasksCreated, List platforms, String message) { + this.strategyId = strategyId; + this.tasksCreated = tasksCreated; + this.platforms = platforms; + this.message = message; + } + + public String getStrategyId() { + return strategyId; + } + + public void setStrategyId(String strategyId) { + this.strategyId = strategyId; + } + + public int getTasksCreated() { + return tasksCreated; + } + + public void setTasksCreated(int tasksCreated) { + this.tasksCreated = tasksCreated; + } + + public List getPlatforms() { + return platforms; + } + + public void setPlatforms(List platforms) { + this.platforms = platforms; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} + diff --git a/src/main/java/kz/konturai/parser/model/AnalysisType.java b/src/main/java/kz/konturai/parser/model/AnalysisType.java new file mode 100644 index 0000000..30427bf --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/AnalysisType.java @@ -0,0 +1,37 @@ +package kz.konturai.parser.model; + +public enum AnalysisType { + РЫНОК("Рынок"), + КОНКУРЕНТЫ("Конкуренты"), + ЦА("Целевая аудитория"), + КАНАЛЫ("Каналы"), + SWOT("SWOT"); + + private final String displayName; + + AnalysisType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public static AnalysisType fromString(String value) { + if (value == null) { + return null; + } + try { + return AnalysisType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + // Try to match by display name + for (AnalysisType type : AnalysisType.values()) { + if (type.getDisplayName().equalsIgnoreCase(value) || + type.name().equalsIgnoreCase(value)) { + return type; + } + } + return null; + } + } +} diff --git a/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java b/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java index 4f84c99..40c94c0 100644 --- a/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java +++ b/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java @@ -28,6 +28,9 @@ public class MarketingAnalysis { @Field("differentiator") private String differentiator; + @Field("analysis_type") + private String analysisType; // РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT + @Field("status") private String status; // queued, processing, completed, failed @@ -66,6 +69,16 @@ public class MarketingAnalysis { this.differentiator = differentiator; } + public MarketingAnalysis(String product, String location, String clientType, String differentiator, + String analysisType) { + this(); + this.product = product; + this.location = location; + this.clientType = clientType; + this.differentiator = differentiator; + this.analysisType = analysisType; + } + public String getId() { return id; } @@ -169,4 +182,12 @@ public class MarketingAnalysis { public void setStatusHistory(List statusHistory) { this.statusHistory = statusHistory; } + + public String getAnalysisType() { + return analysisType; + } + + public void setAnalysisType(String analysisType) { + this.analysisType = analysisType; + } } diff --git a/src/main/java/kz/konturai/parser/model/PostingTask.java b/src/main/java/kz/konturai/parser/model/PostingTask.java new file mode 100644 index 0000000..7e1620e --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/PostingTask.java @@ -0,0 +1,152 @@ +package kz.konturai.parser.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Document(collection = "posting_tasks") +public class PostingTask { + + @Id + private String id; + + @Field("strategy_id") + private String strategyId; + + @Field("user_id") + private String userId; + + @Field("platform") + private String platform; + + @Field("post_text") + private String postText; + + @Field("hashtags") + private List hashtags; + + @Field("publish_date") + private LocalDateTime publishDate; + + @Field("status") + private String status; // "pending", "processing", "completed", "failed" + + @Field("created_at") + private LocalDateTime createdAt; + + @Field("executed_at") + private LocalDateTime executedAt; + + @Field("error_message") + private String errorMessage; + + public PostingTask() { + this.createdAt = LocalDateTime.now(); + this.status = "pending"; + this.hashtags = new ArrayList<>(); + } + + public PostingTask(String strategyId, String userId, String platform, String postText, + List hashtags, LocalDateTime publishDate) { + this(); + this.strategyId = strategyId; + this.userId = userId; + this.platform = platform; + this.postText = postText; + this.hashtags = hashtags != null ? hashtags : new ArrayList<>(); + this.publishDate = publishDate; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getStrategyId() { + return strategyId; + } + + public void setStrategyId(String strategyId) { + this.strategyId = strategyId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getPostText() { + return postText; + } + + public void setPostText(String postText) { + this.postText = postText; + } + + public List getHashtags() { + return hashtags; + } + + public void setHashtags(List hashtags) { + this.hashtags = hashtags != null ? hashtags : new ArrayList<>(); + } + + public LocalDateTime getPublishDate() { + return publishDate; + } + + public void setPublishDate(LocalDateTime publishDate) { + this.publishDate = publishDate; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getExecutedAt() { + return executedAt; + } + + public void setExecutedAt(LocalDateTime executedAt) { + this.executedAt = executedAt; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} + diff --git a/src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java b/src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java new file mode 100644 index 0000000..4fe33ac --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java @@ -0,0 +1,91 @@ +package kz.konturai.parser.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +@Document(collection = "social_media_credentials") +public class SocialMediaCredentials { + + @Id + private String id; + + @Field("user_id") + private String userId; + + @Field("platform") + private String platform; // "facebook", "instagram", etc. + + @Field("encrypted_credentials") + private String encryptedCredentials; + + @Field("created_at") + private LocalDateTime createdAt; + + @Field("updated_at") + private LocalDateTime updatedAt; + + public SocialMediaCredentials() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public SocialMediaCredentials(String userId, String platform, String encryptedCredentials) { + this(); + this.userId = userId; + this.platform = platform; + this.encryptedCredentials = encryptedCredentials; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getEncryptedCredentials() { + return encryptedCredentials; + } + + public void setEncryptedCredentials(String encryptedCredentials) { + this.encryptedCredentials = encryptedCredentials; + this.updatedAt = LocalDateTime.now(); + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} + diff --git a/src/main/java/kz/konturai/parser/repository/PostingTaskRepository.java b/src/main/java/kz/konturai/parser/repository/PostingTaskRepository.java new file mode 100644 index 0000000..955bb85 --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/PostingTaskRepository.java @@ -0,0 +1,18 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.PostingTask; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PostingTaskRepository extends MongoRepository { + List findByStatusAndPublishDateLessThanEqual(String status, LocalDateTime date); + List findByStrategyId(String strategyId); + List findByUserId(String userId); + Optional findById(String id); +} + diff --git a/src/main/java/kz/konturai/parser/repository/SocialMediaCredentialsRepository.java b/src/main/java/kz/konturai/parser/repository/SocialMediaCredentialsRepository.java new file mode 100644 index 0000000..2ba384f --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/SocialMediaCredentialsRepository.java @@ -0,0 +1,16 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.SocialMediaCredentials; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SocialMediaCredentialsRepository extends MongoRepository { + Optional findByUserIdAndPlatform(String userId, String platform); + List findByUserId(String userId); + void deleteByUserIdAndPlatform(String userId, String platform); +} + diff --git a/src/main/java/kz/konturai/parser/service/EncryptionService.java b/src/main/java/kz/konturai/parser/service/EncryptionService.java new file mode 100644 index 0000000..55623cc --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/EncryptionService.java @@ -0,0 +1,85 @@ +package kz.konturai.parser.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +@Service +public class EncryptionService { + + private static final Logger logger = LoggerFactory.getLogger(EncryptionService.class); + private static final String ALGORITHM = "AES"; + private static final int KEY_SIZE = 256; + + private final SecretKey secretKey; + + public EncryptionService(@Value("${encryption.secret-key:}") String secretKeyBase64) { + SecretKey key; + if (secretKeyBase64 != null && !secretKeyBase64.isEmpty()) { + try { + byte[] keyBytes = Base64.getDecoder().decode(secretKeyBase64); + key = new SecretKeySpec(keyBytes, ALGORITHM); + } catch (Exception e) { + logger.warn("Failed to decode encryption key from config, generating new one", e); + key = generateKey(); + } + } else { + logger.warn("Encryption secret key not configured, generating temporary key. This should be configured in production!"); + key = generateKey(); + } + this.secretKey = key; + } + + private SecretKey generateKey() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM); + keyGenerator.init(KEY_SIZE, new SecureRandom()); + return keyGenerator.generateKey(); + } catch (Exception e) { + logger.error("Failed to generate encryption key", e); + throw new RuntimeException("Failed to initialize encryption service", e); + } + } + + public String encrypt(String plainText) { + if (plainText == null || plainText.isEmpty()) { + return plainText; + } + + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encryptedBytes); + } catch (Exception e) { + logger.error("Failed to encrypt data", e); + throw new RuntimeException("Encryption failed", e); + } + } + + public String decrypt(String encryptedText) { + if (encryptedText == null || encryptedText.isEmpty()) { + return encryptedText; + } + + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText)); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + logger.error("Failed to decrypt data", e); + throw new RuntimeException("Decryption failed", e); + } + } +} + diff --git a/src/main/java/kz/konturai/parser/service/FacebookPostingService.java b/src/main/java/kz/konturai/parser/service/FacebookPostingService.java new file mode 100644 index 0000000..92ed7d3 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/FacebookPostingService.java @@ -0,0 +1,144 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class FacebookPostingService { + + private static final Logger logger = LoggerFactory.getLogger(FacebookPostingService.class); + private static final String FACEBOOK_GRAPH_API_BASE = "https://graph.facebook.com/v18.0"; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + + @Value("${facebook.api.timeout:30000}") + private int timeoutMs; + + public FacebookPostingService() { + HttpClient httpClient = HttpClient.create() + .responseTimeout(Duration.ofMillis(30000)); + + this.webClient = WebClient.builder() + .baseUrl(FACEBOOK_GRAPH_API_BASE) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + + this.objectMapper = new ObjectMapper(); + } + + /** + * Публикует пост в Facebook через Graph API + * + * @param accessToken Access Token пользователя + * @param postText Текст поста + * @param hashtags Список хештегов + * @return ID опубликованного поста + * @throws RuntimeException если публикация не удалась + */ + public String postToFacebook(String accessToken, String postText, List hashtags) { + try { + // Формируем полный текст поста с хештегами + String fullPostText = buildPostText(postText, hashtags); + + // Получаем ID страницы пользователя (me) + String pageId = getPageId(accessToken); + + // Публикуем пост на странице + String postId = publishPost(accessToken, pageId, fullPostText); + + logger.info("Successfully posted to Facebook. Post ID: {}", postId); + return postId; + + } catch (WebClientResponseException e) { + logger.error("Facebook API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new RuntimeException("Failed to post to Facebook: " + e.getMessage(), e); + } catch (Exception e) { + logger.error("Unexpected error posting to Facebook", e); + throw new RuntimeException("Failed to post to Facebook", e); + } + } + + /** + * Получает ID страницы пользователя + */ + private String getPageId(String accessToken) { + try { + String response = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/me") + .queryParam("access_token", accessToken) + .queryParam("fields", "id") + .build()) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofMillis(timeoutMs)); + + JsonNode jsonNode = objectMapper.readTree(response); + return jsonNode.get("id").asText(); + } catch (JsonProcessingException e) { + logger.error("Failed to parse Facebook page ID response", e); + // Если не удалось получить ID страницы, используем "me" для публикации на стене пользователя + return "me"; + } catch (Exception e) { + logger.error("Failed to get Facebook page ID", e); + // Если не удалось получить ID страницы, используем "me" для публикации на стене пользователя + return "me"; + } + } + + /** + * Публикует пост на странице Facebook + */ + private String publishPost(String accessToken, String pageId, String message) + throws JsonProcessingException { + String response = webClient.post() + .uri(uriBuilder -> uriBuilder + .path("/{pageId}/feed") + .queryParam("access_token", accessToken) + .queryParam("message", message) + .build(pageId)) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofMillis(timeoutMs)); + + JsonNode jsonNode = objectMapper.readTree(response); + return jsonNode.get("id").asText(); + } + + /** + * Формирует полный текст поста с хештегами + */ + private String buildPostText(String postText, List hashtags) { + StringBuilder fullText = new StringBuilder(postText != null ? postText : ""); + + if (hashtags != null && !hashtags.isEmpty()) { + if (fullText.length() > 0) { + fullText.append("\n\n"); + } + + // Добавляем хештеги, убеждаясь что они начинаются с # + String hashtagsText = hashtags.stream() + .map(tag -> tag.startsWith("#") ? tag : "#" + tag) + .collect(Collectors.joining(" ")); + + fullText.append(hashtagsText); + } + + return fullText.toString(); + } +} + diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index 359965b..b878f9c 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -3,6 +3,7 @@ package kz.konturai.parser.service; import kz.konturai.parser.dto.MarketingAnalysisRequest; import kz.konturai.parser.dto.MarketingAnalysisResult; import kz.konturai.parser.dto.StatusHistoryEntry; +import kz.konturai.parser.model.AnalysisType; import kz.konturai.parser.model.MarketingAnalysis; import kz.konturai.parser.repository.MarketingAnalysisRepository; import org.slf4j.Logger; @@ -40,12 +41,14 @@ public class MarketingAnalysisService { request.getProduct(), request.getLocation(), request.getClient(), - request.getDifferentiator()); + request.getDifferentiator(), + request.getAnalysisType()); analysis.setUserId(userId); analysis.setStatus("queued"); addStatusHistoryEntry(analysis, "queued", "Анализ создан и добавлен в очередь"); analysis = repository.save(analysis); - logger.info("Marketing analysis created with ID: {} for user: {}", analysis.getId(), userId); + logger.info("Marketing analysis created with ID: {} for user: {} with type: {}", + analysis.getId(), userId, request.getAnalysisType()); return analysis; } @@ -119,24 +122,27 @@ public class MarketingAnalysisService { private Map generateMarketingReport(MarketingAnalysisRequest request) { Map report = new HashMap<>(); + // Get analysis type + AnalysisType analysisType = request.getAnalysisTypeEnum(); + String analysisTypeName = analysisType != null ? analysisType.getDisplayName() : "Общий"; + // Build context for AI String context = String.format( "Продукт/услуга: %s\n" + "Локация: %s\n" + "Тип клиентов: %s\n" + - "Уникальные особенности: %s\n", + "Уникальные особенности: %s\n" + + "Тип анализа: %s\n", request.getProduct(), request.getLocation(), request.getClient(), - request.getDifferentiator()); + request.getDifferentiator(), + analysisTypeName); - // Generate summary - String summaryPrompt = "На основе следующей информации о бизнесе создай краткое резюме маркетингового анализа (2-3 абзаца). " - + - "Выдели ключевые возможности и особенности бизнеса. " + - "Ответ должен быть на русском языке, деловым стилем.\n\n" + context; - String summary = openAIAnalyticsService.generateWithInstruction(context, summaryPrompt, "ru"); - report.put("summary", summary != null ? summary : "Резюме не удалось сгенерировать."); + // Generate analysis based on type + String analysisContent = generateAnalysisByType(context, analysisType); + report.put("analysisType", analysisTypeName); + report.put("summary", analysisContent != null ? analysisContent : "Анализ не удалось сгенерировать."); // Generate target audience description String audiencePrompt = "На основе информации о бизнесе опиши целевую аудиторию (1-2 абзаца). " + @@ -182,6 +188,54 @@ public class MarketingAnalysisService { return report; } + private String generateAnalysisByType(String context, AnalysisType analysisType) { + if (analysisType == null) { + analysisType = AnalysisType.РЫНОК; // Default + } + + String prompt; + switch (analysisType) { + case РЫНОК: + prompt = "Проведи детальный анализ рынка на основе предоставленной информации о бизнесе. " + + "Включи: размер рынка, динамику роста, основные сегменты, тренды и перспективы развития. " + + "Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n" + context; + break; + case КОНКУРЕНТЫ: + prompt = "Проведи анализ конкурентов на основе предоставленной информации о бизнесе. " + + "Включи: основных конкурентов, их сильные и слабые стороны, позиционирование, " + + "ценовую политику и маркетинговые стратегии. Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n" + + context; + break; + case ЦА: + prompt = "Проведи детальный анализ целевой аудитории на основе предоставленной информации о бизнесе. " + + "Включи: демографические характеристики, психографический профиль, потребности и боли, " + + "поведенческие паттерны и предпочтения. Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n" + + context; + break; + case КАНАЛЫ: + prompt = "Проведи анализ маркетинговых каналов на основе предоставленной информации о бизнесе. " + + "Включи: оценку эффективности различных каналов коммуникации, рекомендации по выбору каналов, " + + + "особенности использования каждого канала и бюджетные рекомендации. Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n" + + context; + break; + case SWOT: + prompt = "Проведи SWOT-анализ на основе предоставленной информации о бизнесе. " + + "Включи детальный анализ: Сильных сторон (Strengths), Слабых сторон (Weaknesses), " + + "Возможностей (Opportunities) и Угроз (Threats). Ответ должен быть структурированным текстом на русском языке, " + + + "с четким разделением по каждому разделу SWOT (3-5 абзацев).\n\n" + context; + break; + default: + prompt = "На основе следующей информации о бизнесе создай краткое резюме маркетингового анализа (2-3 абзаца). " + + + "Выдели ключевые возможности и особенности бизнеса. " + + "Ответ должен быть на русском языке, деловым стилем.\n\n" + context; + } + + return openAIAnalyticsService.generateWithInstruction(context, prompt, "ru"); + } + private List parseChannels(String channelsStr) { if (channelsStr == null || channelsStr.trim().isEmpty()) { return new ArrayList<>(); @@ -278,9 +332,16 @@ public class MarketingAnalysisService { markdown.append("- **Продукт/услуга:** ").append(request.getProduct()).append("\n"); markdown.append("- **Локация:** ").append(request.getLocation()).append("\n"); markdown.append("- **Тип клиентов:** ").append(request.getClient()).append("\n"); - markdown.append("- **Уникальные особенности:** ").append(request.getDifferentiator()).append("\n\n"); + markdown.append("- **Уникальные особенности:** ").append(request.getDifferentiator()).append("\n"); - markdown.append("## Резюме анализа\n\n"); + String analysisTypeName = (String) reportData.get("analysisType"); + if (analysisTypeName != null) { + markdown.append("- **Тип анализа:** ").append(analysisTypeName).append("\n"); + } + markdown.append("\n"); + + String analysisTitle = analysisTypeName != null ? analysisTypeName : "Резюме анализа"; + markdown.append("## ").append(analysisTitle).append("\n\n"); markdown.append(reportData.get("summary")).append("\n\n"); @SuppressWarnings("unchecked") diff --git a/src/main/java/kz/konturai/parser/service/PostingSchedulerService.java b/src/main/java/kz/konturai/parser/service/PostingSchedulerService.java new file mode 100644 index 0000000..3cbe898 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/PostingSchedulerService.java @@ -0,0 +1,68 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.PostingTask; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class PostingSchedulerService { + + private static final Logger logger = LoggerFactory.getLogger(PostingSchedulerService.class); + + private final PostingTaskService postingTaskService; + + @Value("${posting.scheduler.enabled:true}") + private boolean schedulerEnabled; + + public PostingSchedulerService(PostingTaskService postingTaskService) { + this.postingTaskService = postingTaskService; + } + + /** + * Планировщик, который запускается каждую минуту + * Проверяет очередь задач и выполняет задачи, у которых наступило время публикации + */ + @Scheduled(cron = "0 * * * * ?") + public void checkAndExecutePendingTasks() { + if (!schedulerEnabled) { + logger.debug("Posting scheduler is disabled. Skipping task check."); + return; + } + + try { + LocalDateTime now = LocalDateTime.now(); + logger.debug("Checking for pending posting tasks at {}", now); + + // Получаем задачи со статусом pending, у которых publishDate <= текущее время + List pendingTasks = postingTaskService.getPendingTasks(now); + + if (pendingTasks.isEmpty()) { + logger.debug("No pending tasks found for execution"); + return; + } + + logger.info("Found {} pending task(s) to execute", pendingTasks.size()); + + // Выполняем каждую задачу + for (PostingTask task : pendingTasks) { + try { + logger.info("Executing task {} for platform {} (publish date: {})", + task.getId(), task.getPlatform(), task.getPublishDate()); + postingTaskService.executeTaskAsync(task.getId()); + } catch (Exception e) { + logger.error("Error executing task {}: {}", task.getId(), e.getMessage(), e); + } + } + + } catch (Exception e) { + logger.error("Error in posting scheduler", e); + } + } +} + diff --git a/src/main/java/kz/konturai/parser/service/PostingTaskService.java b/src/main/java/kz/konturai/parser/service/PostingTaskService.java new file mode 100644 index 0000000..7e566e5 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/PostingTaskService.java @@ -0,0 +1,221 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketingStrategy; +import kz.konturai.parser.model.PostingTask; +import kz.konturai.parser.repository.MarketingStrategyRepository; +import kz.konturai.parser.repository.PostingTaskRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +public class PostingTaskService { + + private static final Logger logger = LoggerFactory.getLogger(PostingTaskService.class); + + private final PostingTaskRepository taskRepository; + private final MarketingStrategyRepository strategyRepository; + private final SocialMediaCredentialsService credentialsService; + private final FacebookPostingService facebookPostingService; + + public PostingTaskService( + PostingTaskRepository taskRepository, + MarketingStrategyRepository strategyRepository, + SocialMediaCredentialsService credentialsService, + FacebookPostingService facebookPostingService) { + this.taskRepository = taskRepository; + this.strategyRepository = strategyRepository; + this.credentialsService = credentialsService; + this.facebookPostingService = facebookPostingService; + } + + /** + * Создает задачи публикации из стратегии + * Конвертирует PostCalendarItem в PostingTask + * + * @param strategyId ID стратегии + * @return Список созданных задач + */ + public List createTasksFromStrategy(String strategyId) { + Optional optStrategy = strategyRepository.findById(strategyId); + if (optStrategy.isEmpty()) { + throw new IllegalArgumentException("Strategy not found: " + strategyId); + } + + MarketingStrategy strategy = optStrategy.get(); + + if (!"completed".equals(strategy.getStatus())) { + throw new IllegalStateException("Strategy is not completed yet. Status: " + strategy.getStatus()); + } + + if (strategy.getPostCalendar() == null || strategy.getPostCalendar().isEmpty()) { + logger.warn("Strategy {} has no post calendar items", strategyId); + return new ArrayList<>(); + } + + // Проверяем наличие credentials для платформ + List platforms = strategy.getPostCalendar().stream() + .map(MarketingStrategy.PostCalendarItem::getPlatform) + .distinct() + .toList(); + + for (String platform : platforms) { + if (!credentialsService.hasCredentials(strategy.getUserId(), platform)) { + throw new IllegalStateException( + "Credentials not found for platform: " + platform + ". Please configure credentials first."); + } + } + + // Проверяем, не созданы ли уже задачи для этой стратегии + List existingTasks = taskRepository.findByStrategyId(strategyId); + if (!existingTasks.isEmpty()) { + logger.info("Tasks already exist for strategy {}. Returning existing tasks.", strategyId); + return existingTasks; + } + + // Создаем задачи из календаря постов + List tasks = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + + for (MarketingStrategy.PostCalendarItem calendarItem : strategy.getPostCalendar()) { + PostingTask task = new PostingTask( + strategyId, + strategy.getUserId(), + calendarItem.getPlatform(), + calendarItem.getPostText(), + calendarItem.getHashtags(), + calendarItem.getPublishDate() + ); + + // Если дата публикации в прошлом, выполняем сразу + if (task.getPublishDate().isBefore(now) || task.getPublishDate().isEqual(now)) { + task.setStatus("pending"); + logger.info("Task created with past date, will be executed immediately: {}", task.getPublishDate()); + } + + tasks.add(task); + } + + // Сохраняем все задачи + List savedTasks = taskRepository.saveAll(tasks); + logger.info("Created {} posting tasks from strategy {}", savedTasks.size(), strategyId); + + // Выполняем задачи с прошедшей датой сразу + for (PostingTask task : savedTasks) { + if (task.getPublishDate().isBefore(now) || task.getPublishDate().isEqual(now)) { + if ("pending".equals(task.getStatus())) { + executeTaskAsync(task.getId()); + } + } + } + + return savedTasks; + } + + /** + * Получает задачи для выполнения (pending с датой <= указанной) + * + * @param beforeDate Максимальная дата публикации + * @return Список задач для выполнения + */ + public List getPendingTasks(LocalDateTime beforeDate) { + return taskRepository.findByStatusAndPublishDateLessThanEqual("pending", beforeDate); + } + + /** + * Выполняет задачу публикации + * + * @param taskId ID задачи + */ + public void executeTask(String taskId) { + Optional optTask = taskRepository.findById(taskId); + if (optTask.isEmpty()) { + logger.error("Task not found: {}", taskId); + return; + } + + PostingTask task = optTask.get(); + + // Проверяем статус + if (!"pending".equals(task.getStatus())) { + logger.warn("Task {} is not in pending status. Current status: {}", taskId, task.getStatus()); + return; + } + + // Обновляем статус на processing + task.setStatus("processing"); + taskRepository.save(task); + + try { + // Получаем credentials + String credentials = credentialsService.getCredentials(task.getUserId(), task.getPlatform()); + if (credentials == null || credentials.isEmpty()) { + throw new IllegalStateException("Credentials not found for platform: " + task.getPlatform()); + } + + // Выполняем публикацию в зависимости от платформы + String postId = null; + if ("facebook".equalsIgnoreCase(task.getPlatform())) { + postId = facebookPostingService.postToFacebook( + credentials, + task.getPostText(), + task.getHashtags() + ); + } else { + throw new UnsupportedOperationException("Platform not supported: " + task.getPlatform()); + } + + // Обновляем статус на completed + task.setStatus("completed"); + task.setExecutedAt(LocalDateTime.now()); + task.setErrorMessage(null); + taskRepository.save(task); + + logger.info("Task {} executed successfully. Post ID: {}", taskId, postId); + + } catch (Exception e) { + logger.error("Failed to execute task {}", taskId, e); + + // Обновляем статус на failed + task.setStatus("failed"); + task.setExecutedAt(LocalDateTime.now()); + task.setErrorMessage(e.getMessage()); + taskRepository.save(task); + } + } + + /** + * Асинхронное выполнение задачи + */ + @Async("reportGenerationExecutor") + public void executeTaskAsync(String taskId) { + executeTask(taskId); + } + + /** + * Получает задачи пользователя + * + * @param userId ID пользователя + * @return Список задач + */ + public List getUserTasks(String userId) { + return taskRepository.findByUserId(userId); + } + + /** + * Получает задачи стратегии + * + * @param strategyId ID стратегии + * @return Список задач + */ + public List getStrategyTasks(String strategyId) { + return taskRepository.findByStrategyId(strategyId); + } +} + diff --git a/src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java b/src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java new file mode 100644 index 0000000..3915075 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java @@ -0,0 +1,150 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.SocialMediaCredentials; +import kz.konturai.parser.repository.SocialMediaCredentialsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class SocialMediaCredentialsService { + + private static final Logger logger = LoggerFactory.getLogger(SocialMediaCredentialsService.class); + + private final SocialMediaCredentialsRepository repository; + private final EncryptionService encryptionService; + + public SocialMediaCredentialsService( + SocialMediaCredentialsRepository repository, + EncryptionService encryptionService) { + this.repository = repository; + this.encryptionService = encryptionService; + } + + /** + * Сохраняет или обновляет credentials пользователя для платформы + * + * @param userId ID пользователя + * @param platform Платформа (facebook, instagram, etc.) + * @param credentials Открытые credentials (будут зашифрованы) + * @return Сохраненные credentials + */ + public SocialMediaCredentials saveCredentials(String userId, String platform, String credentials) { + if (userId == null || platform == null || credentials == null || credentials.isEmpty()) { + throw new IllegalArgumentException("UserId, platform and credentials are required"); + } + + // Проверяем существующие credentials + Optional existing = repository.findByUserIdAndPlatform(userId, platform); + + SocialMediaCredentials credentialsEntity; + if (existing.isPresent()) { + credentialsEntity = existing.get(); + logger.info("Updating existing credentials for user {} and platform {}", userId, platform); + } else { + credentialsEntity = new SocialMediaCredentials(); + credentialsEntity.setUserId(userId); + credentialsEntity.setPlatform(platform.toLowerCase()); + logger.info("Creating new credentials for user {} and platform {}", userId, platform); + } + + // Шифруем credentials перед сохранением + String encryptedCredentials = encryptionService.encrypt(credentials); + credentialsEntity.setEncryptedCredentials(encryptedCredentials); + + return repository.save(credentialsEntity); + } + + /** + * Получает и расшифровывает credentials пользователя для платформы + * + * @param userId ID пользователя + * @param platform Платформа + * @return Расшифрованные credentials или null если не найдены + */ + public String getCredentials(String userId, String platform) { + if (userId == null || platform == null) { + return null; + } + + Optional optCredentials = repository.findByUserIdAndPlatform( + userId, platform.toLowerCase()); + + if (optCredentials.isEmpty()) { + logger.debug("Credentials not found for user {} and platform {}", userId, platform); + return null; + } + + try { + SocialMediaCredentials credentialsEntity = optCredentials.get(); + return encryptionService.decrypt(credentialsEntity.getEncryptedCredentials()); + } catch (Exception e) { + logger.error("Failed to decrypt credentials for user {} and platform {}", userId, platform, e); + throw new RuntimeException("Failed to decrypt credentials", e); + } + } + + /** + * Проверяет наличие credentials для пользователя и платформы + * + * @param userId ID пользователя + * @param platform Платформа + * @return true если credentials существуют + */ + public boolean hasCredentials(String userId, String platform) { + if (userId == null || platform == null) { + return false; + } + + return repository.findByUserIdAndPlatform(userId, platform.toLowerCase()).isPresent(); + } + + /** + * Удаляет credentials пользователя для платформы + * + * @param userId ID пользователя + * @param platform Платформа + */ + public void deleteCredentials(String userId, String platform) { + if (userId == null || platform == null) { + throw new IllegalArgumentException("UserId and platform are required"); + } + + repository.deleteByUserIdAndPlatform(userId, platform.toLowerCase()); + logger.info("Deleted credentials for user {} and platform {}", userId, platform); + } + + /** + * Получает список всех платформ, для которых у пользователя есть credentials + * + * @param userId ID пользователя + * @return Список платформ + */ + public List getUserPlatforms(String userId) { + if (userId == null) { + return List.of(); + } + + return repository.findByUserId(userId).stream() + .map(SocialMediaCredentials::getPlatform) + .toList(); + } + + /** + * Получает все credentials пользователя (без расшифровки) + * + * @param userId ID пользователя + * @return Список credentials entities + */ + public List getUserCredentials(String userId) { + if (userId == null) { + return List.of(); + } + + return repository.findByUserId(userId); + } +} + diff --git a/src/main/java/kz/konturai/parser/validator/AnalysisTypeValidator.java b/src/main/java/kz/konturai/parser/validator/AnalysisTypeValidator.java new file mode 100644 index 0000000..c25ec09 --- /dev/null +++ b/src/main/java/kz/konturai/parser/validator/AnalysisTypeValidator.java @@ -0,0 +1,20 @@ +package kz.konturai.parser.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import kz.konturai.parser.model.AnalysisType; + +public class AnalysisTypeValidator implements ConstraintValidator { + + @Override + public void initialize(ValidAnalysisType constraintAnnotation) { + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.trim().isEmpty()) { + return true; // @NotBlank will handle null/empty + } + return AnalysisType.fromString(value) != null; + } +} diff --git a/src/main/java/kz/konturai/parser/validator/ValidAnalysisType.java b/src/main/java/kz/konturai/parser/validator/ValidAnalysisType.java new file mode 100644 index 0000000..699fbe6 --- /dev/null +++ b/src/main/java/kz/konturai/parser/validator/ValidAnalysisType.java @@ -0,0 +1,21 @@ +package kz.konturai.parser.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = AnalysisTypeValidator.class) +@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidAnalysisType { + String message() default "Поле 'analysisType' должно быть одним из: РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a94fd29..992c688 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -82,3 +82,15 @@ deep-research.api.timeout=1800000 # Используйте тот же secret-base64, что и в сервисе, выдающем токены # Формат: base64-encoded secret key security.jwt.secret-base64=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl + +# Encryption Configuration for Social Media Credentials +# Base64-encoded AES-256 key (32 bytes = 256 bits) +# Generate a key: openssl rand -base64 32 +# IMPORTANT: Change this in production! +encryption.secret-key=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl + +# Posting Scheduler Configuration +posting.scheduler.enabled=true + +# Facebook API Configuration +facebook.api.timeout=30000