This commit is contained in:
root
2025-11-29 21:48:10 +05:00
parent 82415ad5ff
commit cd233bf4c0
24 changed files with 1747 additions and 16 deletions
+128
View File
@@ -16,6 +16,7 @@ API для генерации маркетингового анализа и с
- ✅ Пользователи могут видеть только свои анализы и стратегии
- ✅ Добавлена детальная история статусов для каждого анализа и стратегии
- ✅ Новые эндпоинты для получения списка всех анализов/стратегий пользователя
-**НОВОЕ**: Добавлена поддержка типов анализов (РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT)
---
@@ -77,6 +78,7 @@ Authorization: Bearer <your_jwt_token>
| `location` | string | ✅ | Географическая локация работы | "Алматы, Казахстан" |
| `client` | string | ✅ | Тип целевой аудитории | "B2B клиенты" |
| `differentiator` | string | ✅ | Уникальные особенности бизнеса | "Быстрая разработка за 2 недели" |
| `analysisType` | string | ✅ | Тип анализа для генерации | "РЫНОК" |
#### Валидация полей
@@ -104,6 +106,15 @@ Authorization: Bearer <your_jwt_token>
- Минимальная длина: 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 обращайтесь в техническую поддержку с указанием:
@@ -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<MarketingStrategy> 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<PostingTask> tasks = postingTaskService.createTasksFromStrategy(strategyId);
// Получаем список платформ
List<String> 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<ApiResponse<ErrorResponse>> handleValidationException(
MethodArgumentNotValidException ex) {
@@ -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<SocialMediaCredentials> credentialsList = credentialsService.getUserCredentials(userId);
List<SocialMediaCredentialsResponse> 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<ApiResponse<ErrorResponse>> handleValidationException(
MethodArgumentNotValidException ex) {
Map<String, String> 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<ApiResponse<ErrorResponse>> handleGenericException(Exception e) {
ErrorResponse errorResponse = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"Произошла внутренняя ошибка сервера. Попробуйте позже.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Внутренняя ошибка сервера", errorResponse));
}
}
@@ -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<StatusHistoryEntry> statusHistory) {
this.statusHistory = statusHistory;
}
}
public String getAnalysisType() {
return analysisType;
}
public void setAnalysisType(String analysisType) {
this.analysisType = analysisType;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,11 @@
package kz.konturai.parser.dto;
public class StartStrategyRequest {
// Пока что пустой класс, можно расширить в будущем
// Например, добавить опции для фильтрации задач или настройки выполнения
public StartStrategyRequest() {
}
}
@@ -0,0 +1,54 @@
package kz.konturai.parser.dto;
import java.util.List;
public class StartStrategyResponse {
private String strategyId;
private int tasksCreated;
private List<String> platforms;
private String message;
public StartStrategyResponse() {
}
public StartStrategyResponse(String strategyId, int tasksCreated, List<String> 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<String> getPlatforms() {
return platforms;
}
public void setPlatforms(List<String> platforms) {
this.platforms = platforms;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
@@ -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;
}
}
}
@@ -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<StatusHistoryEntry> statusHistory) {
this.statusHistory = statusHistory;
}
public String getAnalysisType() {
return analysisType;
}
public void setAnalysisType(String analysisType) {
this.analysisType = analysisType;
}
}
@@ -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<String> 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<String> 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<String> getHashtags() {
return hashtags;
}
public void setHashtags(List<String> 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;
}
}
@@ -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;
}
}
@@ -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<PostingTask, String> {
List<PostingTask> findByStatusAndPublishDateLessThanEqual(String status, LocalDateTime date);
List<PostingTask> findByStrategyId(String strategyId);
List<PostingTask> findByUserId(String userId);
Optional<PostingTask> findById(String id);
}
@@ -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<SocialMediaCredentials, String> {
Optional<SocialMediaCredentials> findByUserIdAndPlatform(String userId, String platform);
List<SocialMediaCredentials> findByUserId(String userId);
void deleteByUserIdAndPlatform(String userId, String platform);
}
@@ -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);
}
}
}
@@ -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<String> 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<String> 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();
}
}
@@ -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<String, Object> generateMarketingReport(MarketingAnalysisRequest request) {
Map<String, Object> 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<String> 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")
@@ -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<PostingTask> 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);
}
}
}
@@ -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<PostingTask> createTasksFromStrategy(String strategyId) {
Optional<MarketingStrategy> 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<String> 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<PostingTask> existingTasks = taskRepository.findByStrategyId(strategyId);
if (!existingTasks.isEmpty()) {
logger.info("Tasks already exist for strategy {}. Returning existing tasks.", strategyId);
return existingTasks;
}
// Создаем задачи из календаря постов
List<PostingTask> 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<PostingTask> 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<PostingTask> getPendingTasks(LocalDateTime beforeDate) {
return taskRepository.findByStatusAndPublishDateLessThanEqual("pending", beforeDate);
}
/**
* Выполняет задачу публикации
*
* @param taskId ID задачи
*/
public void executeTask(String taskId) {
Optional<PostingTask> 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<PostingTask> getUserTasks(String userId) {
return taskRepository.findByUserId(userId);
}
/**
* Получает задачи стратегии
*
* @param strategyId ID стратегии
* @return Список задач
*/
public List<PostingTask> getStrategyTasks(String strategyId) {
return taskRepository.findByStrategyId(strategyId);
}
}
@@ -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<SocialMediaCredentials> 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<SocialMediaCredentials> 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<String> 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<SocialMediaCredentials> getUserCredentials(String userId) {
if (userId == null) {
return List.of();
}
return repository.findByUserId(userId);
}
}
@@ -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<ValidAnalysisType, String> {
@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;
}
}
@@ -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<? extends Payload>[] payload() default {};
}
+12
View File
@@ -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