diff --git a/a.md b/a.md
new file mode 100644
index 0000000..43a4e71
--- /dev/null
+++ b/a.md
@@ -0,0 +1,309 @@
+# Извлечение информации о пользователе из JWT токена
+
+## Обзор
+
+Данная документация описывает, как извлечь информацию о пользователе из JWT токена в микросервисе на Spring Boot.
+
+## Структура JWT токена
+
+JWT токен содержит следующую информацию:
+
+- **Subject (sub)**: Email пользователя
+- **Custom Claims**:
+ - `uid`: ID пользователя (Long)
+ - `roles`: Роли пользователя (String, разделённые запятыми, например: "ROLE_USER,ROLE_ADMIN")
+- **Стандартные поля**: `iat` (issued at), `exp` (expiration)
+
+## Зависимости
+
+Убедитесь, что в `pom.xml` добавлена зависимость:
+
+```xml
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.3
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.3
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.3
+ runtime
+
+```
+
+## Конфигурация
+
+В `application.properties` или `application.yml`:
+
+```properties
+security.jwt.secret-base64=
+security.jwt.access-ttl-seconds=3600
+```
+
+**Важно**: Используйте тот же `secret-base64`, что и в сервисе, выдающем токены.
+
+## Создание JwtService
+
+```java
+package com.example.service;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.security.Key;
+
+@Service
+public class JwtService {
+
+ private final Key signingKey;
+
+ public JwtService(
+ @Value("${security.jwt.secret-base64}") String base64Secret) {
+ this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret));
+ }
+
+ public Claims parseAndValidate(String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(signingKey)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+}
+```
+
+## Извлечение информации о пользователе
+
+### Вариант 1: Из заголовка Authorization
+
+```java
+import io.jsonwebtoken.Claims;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api")
+public class UserController {
+
+ private final JwtService jwtService;
+
+ public UserController(JwtService jwtService) {
+ this.jwtService = jwtService;
+ }
+
+ @GetMapping("/user-info")
+ public ResponseEntity getUserInfo(
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+
+ // Извлекаем токен из заголовка "Bearer "
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ return ResponseEntity.status(401).build();
+ }
+
+ String token = authHeader.substring(7);
+
+ try {
+ Claims claims = jwtService.parseAndValidate(token);
+
+ // Извлекаем информацию
+ String email = claims.getSubject();
+ Long userId = claims.get("uid", Long.class);
+ String rolesString = claims.get("roles", String.class);
+
+ // Парсим роли
+ List roles = rolesString == null || rolesString.isBlank()
+ ? List.of()
+ : Arrays.stream(rolesString.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toList());
+
+ UserInfo userInfo = new UserInfo(userId, email, roles);
+ return ResponseEntity.ok(userInfo);
+
+ } catch (Exception e) {
+ // Токен невалиден или истёк
+ return ResponseEntity.status(401).build();
+ }
+ }
+}
+```
+
+### Вариант 2: Использование Spring Security (рекомендуется)
+
+Если в вашем микросервисе настроен Spring Security с JWT фильтром, используйте `Principal`:
+
+```java
+import java.security.Principal;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+@RestController
+@RequestMapping("/api")
+public class UserController {
+
+ private final JwtService jwtService;
+
+ public UserController(JwtService jwtService) {
+ this.jwtService = jwtService;
+ }
+
+ @GetMapping("/me")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity getCurrentUser(Principal principal) {
+ // Principal.getName() возвращает subject (email) из JWT
+ String email = principal.getName();
+
+ // Если нужны дополнительные данные (uid, roles),
+ // можно извлечь их из SecurityContext или извлечь токен из запроса
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+
+ // Или получить токен из запроса и распарсить
+ // (см. Вариант 1 для полного извлечения всех claims)
+
+ return ResponseEntity.ok(new UserInfo(null, email, List.of()));
+ }
+}
+```
+
+### Вариант 3: Полное извлечение через HttpServletRequest
+
+```java
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api")
+public class UserController {
+
+ private final JwtService jwtService;
+
+ @GetMapping("/profile")
+ public ResponseEntity getProfile(HttpServletRequest request) {
+ String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
+
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ return ResponseEntity.status(401).build();
+ }
+
+ String token = authHeader.substring(7);
+ Claims claims = jwtService.parseAndValidate(token);
+
+ String email = claims.getSubject();
+ Long userId = claims.get("uid", Long.class);
+ String rolesString = claims.get("roles", String.class);
+ List roles = parseRoles(rolesString);
+
+ return ResponseEntity.ok(new UserInfo(userId, email, roles));
+ }
+
+ private List parseRoles(String rolesString) {
+ if (rolesString == null || rolesString.isBlank()) {
+ return List.of();
+ }
+ return Arrays.stream(rolesString.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toList());
+ }
+}
+```
+
+## DTO для пользователя
+
+```java
+public record UserInfo(
+ Long userId,
+ String email,
+ List roles
+) {}
+```
+
+## Обработка ошибок
+
+```java
+@ControllerAdvice
+public class JwtExceptionHandler {
+
+ @ExceptionHandler(JwtException.class)
+ public ResponseEntity handleJwtException(JwtException e) {
+ return ResponseEntity.status(401)
+ .body(new ErrorResponse("Invalid or expired token", 401));
+ }
+}
+```
+
+## Пример использования в сервисном слое
+
+```java
+@Service
+public class BusinessService {
+
+ private final JwtService jwtService;
+
+ public BusinessService(JwtService jwtService) {
+ this.jwtService = jwtService;
+ }
+
+ public void processRequest(String token) {
+ Claims claims = jwtService.parseAndValidate(token);
+ Long userId = claims.get("uid", Long.class);
+ String email = claims.getSubject();
+
+ // Используйте userId и email для бизнес-логики
+ // ...
+ }
+}
+```
+
+## Важные замечания
+
+1. **Валидация токена**: Метод `parseAndValidate` автоматически проверяет:
+
+ - Подпись токена
+ - Срок действия (expiration)
+ - Формат токена
+
+2. **Безопасность**: Никогда не логируйте полный JWT токен или секретный ключ.
+
+3. **Секретный ключ**: Должен совпадать с ключом в сервисе, выдающем токены.
+
+4. **Обработка исключений**: `JwtException` и его подклассы (`ExpiredJwtException`, `MalformedJwtException`, и т.д.) должны обрабатываться корректно.
+
+## Примеры исключений
+
+- `ExpiredJwtException`: Токен истёк
+- `MalformedJwtException`: Неверный формат токена
+- `SignatureException`: Неверная подпись
+- `UnsupportedJwtException`: Неподдерживаемый тип токена
+
+## Тестирование
+
+```java
+@SpringBootTest
+class JwtServiceTest {
+
+ @Autowired
+ private JwtService jwtService;
+
+ @Test
+ void testParseToken() {
+ String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";
+ Claims claims = jwtService.parseAndValidate(token);
+
+ assertEquals("user@example.com", claims.getSubject());
+ assertEquals(123L, claims.get("uid", Long.class));
+ assertEquals("ROLE_USER", claims.get("roles", String.class));
+ }
+}
+```
diff --git a/marketing-api-with-jwt-frontend.md b/marketing-api-with-jwt-frontend.md
new file mode 100644
index 0000000..10ce258
--- /dev/null
+++ b/marketing-api-with-jwt-frontend.md
@@ -0,0 +1,987 @@
+# API Документация: Маркетинговый анализ и стратегии (Frontend/AI Agent)
+
+## Базовый URL
+
+```
+https://api.konturai.kz
+```
+
+## Обзор
+
+API для генерации маркетингового анализа и стратегий на основе данных о бизнесе. Все эндпоинты требуют JWT аутентификации для связи данных с пользователем.
+
+**Важные изменения:**
+
+- ✅ Все эндпоинты теперь требуют JWT токен в заголовке `Authorization`
+- ✅ Пользователи могут видеть только свои анализы и стратегии
+- ✅ Добавлена детальная история статусов для каждого анализа и стратегии
+- ✅ Новые эндпоинты для получения списка всех анализов/стратегий пользователя
+
+---
+
+## Аутентификация
+
+Все запросы должны включать JWT токен в заголовке `Authorization`:
+
+```
+Authorization: Bearer
+```
+
+### Структура JWT токена
+
+JWT токен содержит следующую информацию:
+
+- **sub**: Email пользователя
+- **uid**: ID пользователя (Long) - используется для связи данных
+- **roles**: Роли пользователя (String, разделённые запятыми)
+
+### Ошибки аутентификации
+
+Если токен отсутствует или невалиден, API вернет:
+
+```json
+{
+ "success": false,
+ "message": "Не авторизован",
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "Требуется аутентификация. Пожалуйста, предоставьте валидный JWT токен."
+ }
+}
+```
+
+**HTTP статус**: `401 Unauthorized`
+
+---
+
+## Эндпоинты
+
+### 1. Запуск маркетингового анализа
+
+**POST** `/api/marketing/analysis/start`
+
+Создает новую задачу на генерацию маркетингового анализа и запускает асинхронную обработку. Анализ автоматически связывается с пользователем из JWT токена.
+
+#### Заголовки запроса
+
+```
+Content-Type: application/json
+Authorization: Bearer
+```
+
+#### Тело запроса (JSON)
+
+| Поле | Тип | Обязательный | Описание | Пример значения |
+| ---------------- | ------ | ------------ | ------------------------------ | -------------------------------- |
+| `product` | string | ✅ | Название продукта или услуги | "Веб-разработка" |
+| `location` | string | ✅ | Географическая локация работы | "Алматы, Казахстан" |
+| `client` | string | ✅ | Тип целевой аудитории | "B2B клиенты" |
+| `differentiator` | string | ✅ | Уникальные особенности бизнеса | "Быстрая разработка за 2 недели" |
+
+#### Валидация полей
+
+**`product`** (string, обязательное)
+
+- Минимальная длина: 3 символа
+- Максимальная длина: 200 символов
+
+**`location`** (string, обязательное)
+
+- Минимальная длина: 2 символа
+- Максимальная длина: 150 символов
+
+**`client`** (string, обязательное)
+
+- Допустимые значения:
+ - `"B2B клиенты"`
+ - `"B2C клиенты"`
+ - `"Частные лица"`
+ - `"Корпорации"`
+ - `"Малый бизнес"`
+
+**`differentiator`** (string, обязательное)
+
+- Минимальная длина: 10 символов
+- Максимальная длина: 500 символов
+
+#### Пример запроса
+
+```javascript
+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 недели',
+ }),
+ }
+);
+```
+
+#### Пример успешного ответа (200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Анализ запущен успешно",
+ "data": {
+ "analysisId": "507f1f77bcf86cd799439011",
+ "status": "processing",
+ "estimatedCompletionTime": "2025-01-20T15:38:00",
+ "message": "Анализ запущен успешно. Результаты будут готовы в течение 5-10 минут."
+ }
+}
+```
+
+---
+
+### 2. Получение результата анализа
+
+**GET** `/api/marketing/analysis/{analysisId}`
+
+Возвращает статус и результаты анализа по идентификатору. Пользователь может получить только свои анализы.
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Параметры пути
+
+| Параметр | Тип | Описание |
+| ------------ | ------ | --------------------- |
+| `analysisId` | string | Идентификатор анализа |
+
+#### Пример запроса
+
+```javascript
+const response = await fetch(
+ `https://api.konturai.kz/api/marketing/analysis/${analysisId}`,
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ }
+);
+```
+
+#### Пример ответа (когда анализ завершен - 200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Операция выполнена успешно",
+ "data": {
+ "analysisId": "507f1f77bcf86cd799439011",
+ "status": "completed",
+ "createdAt": "2025-01-20T15:30:00",
+ "completedAt": "2025-01-20T15:38:00",
+ "report": {
+ "summary": "Краткое резюме анализа...",
+ "targetAudience": {
+ "description": "Описание целевой аудитории...",
+ "channels": ["Instagram", "LinkedIn", "Telegram"]
+ },
+ "recommendations": ["Рекомендация 1", "Рекомендация 2"],
+ "strategy": {
+ "duration": "2 недели",
+ "channels": ["Instagram", "Telegram", "21MC"],
+ "contentTypes": ["посты", "сторис", "баннеры"]
+ },
+ "pdfUrl": "/api/marketing/analysis/507f1f77bcf86cd799439011/download"
+ }
+ }
+}
+```
+
+#### Статусы анализа
+
+| Статус | Описание |
+| ------------ | ----------------------------- |
+| `queued` | Запрос в очереди на обработку |
+| `processing` | Анализ выполняется |
+| `completed` | Анализ завершен успешно |
+| `failed` | Анализ завершился с ошибкой |
+
+#### Ошибки доступа
+
+Если пользователь пытается получить доступ к анализу другого пользователя:
+
+```json
+{
+ "success": false,
+ "message": "Доступ запрещен",
+ "error": {
+ "code": "FORBIDDEN",
+ "message": "У вас нет доступа к этому анализу"
+ }
+}
+```
+
+**HTTP статус**: `403 Forbidden`
+
+---
+
+### 3. Получение списка всех анализов пользователя
+
+**GET** `/api/marketing/analysis/my`
+
+Возвращает список всех анализов текущего пользователя, отсортированных по дате создания (новые первыми).
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Пример запроса
+
+```javascript
+const response = await fetch(
+ 'https://api.konturai.kz/api/marketing/analysis/my',
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ }
+);
+```
+
+#### Пример успешного ответа (200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Операция выполнена успешно",
+ "data": [
+ {
+ "analysisId": "507f1f77bcf86cd799439011",
+ "product": "Разработка мобильных приложений",
+ "location": "Нур-Султан, Казахстан",
+ "clientType": "B2B клиенты",
+ "differentiator": "Быстрая разработка MVP за 4 недели",
+ "status": "completed",
+ "userId": "12345",
+ "createdAt": "2025-01-20T15:30:00",
+ "completedAt": "2025-01-20T15:38:00",
+ "statusHistory": [
+ {
+ "status": "queued",
+ "timestamp": "2025-01-20T15:30:00",
+ "message": "Анализ создан и добавлен в очередь"
+ },
+ {
+ "status": "processing",
+ "timestamp": "2025-01-20T15:30:05",
+ "message": "Начата обработка анализа"
+ },
+ {
+ "status": "completed",
+ "timestamp": "2025-01-20T15:38:00",
+ "message": "Анализ успешно завершен"
+ }
+ ]
+ },
+ {
+ "analysisId": "507f1f77bcf86cd799439012",
+ "product": "Веб-разработка",
+ "location": "Алматы, Казахстан",
+ "clientType": "B2C клиенты",
+ "differentiator": "Современные технологии",
+ "status": "processing",
+ "userId": "12345",
+ "createdAt": "2025-01-20T14:00:00",
+ "completedAt": null,
+ "statusHistory": [
+ {
+ "status": "queued",
+ "timestamp": "2025-01-20T14:00:00",
+ "message": "Анализ создан и добавлен в очередь"
+ },
+ {
+ "status": "processing",
+ "timestamp": "2025-01-20T14:00:05",
+ "message": "Начата обработка анализа"
+ }
+ ]
+ }
+ ]
+}
+```
+
+#### Структура ответа
+
+| Поле | Тип | Описание |
+| ---------------------------------- | ------ | ------------------------------------------------------ |
+| `data[].analysisId` | string | Уникальный идентификатор анализа |
+| `data[].product` | string | Название продукта или услуги |
+| `data[].location` | string | Географическая локация |
+| `data[].clientType` | string | Тип целевой аудитории |
+| `data[].differentiator` | string | Уникальные особенности бизнеса |
+| `data[].status` | string | Текущий статус анализа |
+| `data[].userId` | string | ID пользователя (из JWT) |
+| `data[].createdAt` | string | ISO 8601 дата/время создания |
+| `data[].completedAt` | string | ISO 8601 дата/время завершения (null если не завершен) |
+| `data[].statusHistory` | array | Детальная история изменений статуса |
+| `data[].statusHistory[].status` | string | Статус на момент изменения |
+| `data[].statusHistory[].timestamp` | string | ISO 8601 дата/время изменения статуса |
+| `data[].statusHistory[].message` | string | Описание изменения статуса |
+
+---
+
+### 4. Получение детальной истории анализа
+
+**GET** `/api/marketing/analysis/{analysisId}/history`
+
+Возвращает детальную информацию об анализе, включая полную историю изменений статуса.
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Параметры пути
+
+| Параметр | Тип | Описание |
+| ------------ | ------ | --------------------- |
+| `analysisId` | string | Идентификатор анализа |
+
+#### Пример запроса
+
+```javascript
+const response = await fetch(
+ `https://api.konturai.kz/api/marketing/analysis/${analysisId}/history`,
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ }
+);
+```
+
+#### Пример успешного ответа (200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Операция выполнена успешно",
+ "data": {
+ "analysisId": "507f1f77bcf86cd799439011",
+ "product": "Разработка мобильных приложений",
+ "location": "Нур-Султан, Казахстан",
+ "clientType": "B2B клиенты",
+ "differentiator": "Быстрая разработка MVP за 4 недели",
+ "status": "completed",
+ "userId": "12345",
+ "createdAt": "2025-01-20T15:30:00",
+ "completedAt": "2025-01-20T15:38:00",
+ "statusHistory": [
+ {
+ "status": "queued",
+ "timestamp": "2025-01-20T15:30:00",
+ "message": "Анализ создан и добавлен в очередь"
+ },
+ {
+ "status": "processing",
+ "timestamp": "2025-01-20T15:30:05",
+ "message": "Начата обработка анализа"
+ },
+ {
+ "status": "completed",
+ "timestamp": "2025-01-20T15:38:00",
+ "message": "Анализ успешно завершен"
+ }
+ ]
+ }
+}
+```
+
+---
+
+### 5. Скачивание PDF отчета
+
+**GET** `/api/marketing/analysis/{analysisId}/download`
+
+Возвращает PDF файл с полным маркетинговым отчетом. Пользователь может скачать только свои отчеты.
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Параметры пути
+
+| Параметр | Тип | Описание |
+| ------------ | ------ | --------------------- |
+| `analysisId` | string | Идентификатор анализа |
+
+#### Пример запроса
+
+```javascript
+const response = await fetch(
+ `https://api.konturai.kz/api/marketing/analysis/${analysisId}/download`,
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ }
+);
+
+if (response.ok) {
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `marketing_analysis_${analysisId}.pdf`;
+ a.click();
+}
+```
+
+#### Успешный ответ (200 OK)
+
+- **Content-Type**: `application/pdf`
+- **Content-Disposition**: `attachment; filename="marketing_analysis_{analysisId}_{timestamp}.pdf"`
+- **Body**: Бинарные данные PDF файла
+
+---
+
+### 6. Генерация маркетинговой стратегии
+
+**POST** `/api/marketing/analysis/strategy/generate`
+
+Создает маркетинговую стратегию на основе завершенного анализа. Стратегия автоматически связывается с пользователем из JWT токена.
+
+#### Заголовки запроса
+
+```
+Content-Type: application/json
+Authorization: Bearer
+```
+
+#### Параметры запроса
+
+| Параметр | Тип | Обязательный | Описание |
+| ------------------- | ------- | ------------ | --------------------------------------------- |
+| `analysisId` | string | ✅ | ID завершенного анализа (query parameter) |
+| `durationWeeks` | integer | ❌ | Длительность стратегии в неделях (default: 4) |
+| `priorityPlatforms` | array | ❌ | Приоритетные платформы (массив строк) |
+
+#### Тело запроса (JSON, опционально)
+
+```json
+{
+ "durationWeeks": 4,
+ "priorityPlatforms": ["Instagram", "LinkedIn", "Telegram"]
+}
+```
+
+#### Пример запроса
+
+```javascript
+const response = await fetch(
+ `https://api.konturai.kz/api/marketing/analysis/strategy/generate?analysisId=${analysisId}`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ body: JSON.stringify({
+ durationWeeks: 4,
+ priorityPlatforms: ['Instagram', 'LinkedIn', 'Telegram'],
+ }),
+ }
+);
+```
+
+#### Пример успешного ответа (200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Генерация стратегии запущена успешно. Результаты будут готовы в течение 3-5 минут.",
+ "data": {
+ "strategyId": "507f1f77bcf86cd799439020",
+ "analysisId": "507f1f77bcf86cd799439011",
+ "status": "queued",
+ "createdAt": "2025-01-20T15:40:00",
+ "completedAt": null,
+ "durationWeeks": 4,
+ "priorityPlatforms": ["Instagram", "LinkedIn", "Telegram"]
+ }
+}
+```
+
+---
+
+### 7. Получение результата стратегии
+
+**GET** `/api/marketing/analysis/strategy/{strategyId}`
+
+Возвращает статус и результаты стратегии. Пользователь может получить только свои стратегии.
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Параметры пути
+
+| Параметр | Тип | Описание |
+| ------------ | ------ | ----------------------- |
+| `strategyId` | string | Идентификатор стратегии |
+
+#### Пример ответа (когда стратегия завершена - 200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Операция выполнена успешно",
+ "data": {
+ "strategyId": "507f1f77bcf86cd799439020",
+ "analysisId": "507f1f77bcf86cd799439011",
+ "status": "completed",
+ "createdAt": "2025-01-20T15:40:00",
+ "completedAt": "2025-01-20T15:43:00",
+ "durationWeeks": 4,
+ "priorityPlatforms": ["Instagram", "LinkedIn", "Telegram"],
+ "strategy": {
+ "weeklyPlans": [
+ {
+ "weekNumber": 1,
+ "mainThemes": ["Презентация продукта", "Преимущества"],
+ "contentRecommendations": "Создавайте контент, который демонстрирует ценность продукта",
+ "priorityPlatforms": ["Instagram", "LinkedIn"]
+ }
+ ],
+ "postCalendar": [
+ {
+ "publishDate": "2025-01-21T10:00:00",
+ "platform": "Instagram",
+ "contentType": "пост",
+ "theme": "Презентация продукта",
+ "postText": "Полный текст поста для публикации...",
+ "hashtags": ["#маркетинг", "#бизнес"],
+ "publishTime": "10:00"
+ }
+ ]
+ }
+ }
+}
+```
+
+---
+
+### 8. Получение стратегии по ID анализа
+
+**GET** `/api/marketing/analysis/{analysisId}/strategy`
+
+Возвращает стратегию, связанную с указанным анализом. Пользователь может получить только стратегии для своих анализов.
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Параметры пути
+
+| Параметр | Тип | Описание |
+| ------------ | ------ | --------------------- |
+| `analysisId` | string | Идентификатор анализа |
+
+---
+
+### 9. Получение списка всех стратегий пользователя
+
+**GET** `/api/marketing/analysis/strategy/my`
+
+Возвращает список всех стратегий текущего пользователя, отсортированных по дате создания (новые первыми).
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Пример успешного ответа (200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Операция выполнена успешно",
+ "data": [
+ {
+ "strategyId": "507f1f77bcf86cd799439020",
+ "analysisId": "507f1f77bcf86cd799439011",
+ "status": "completed",
+ "userId": "12345",
+ "durationWeeks": 4,
+ "priorityPlatforms": ["Instagram", "LinkedIn", "Telegram"],
+ "createdAt": "2025-01-20T15:40:00",
+ "completedAt": "2025-01-20T15:43:00",
+ "statusHistory": [
+ {
+ "status": "queued",
+ "timestamp": "2025-01-20T15:40:00",
+ "message": "Стратегия создана и добавлена в очередь"
+ },
+ {
+ "status": "processing",
+ "timestamp": "2025-01-20T15:40:05",
+ "message": "Начата генерация стратегии"
+ },
+ {
+ "status": "completed",
+ "timestamp": "2025-01-20T15:43:00",
+ "message": "Стратегия успешно сгенерирована"
+ }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+### 10. Получение детальной истории стратегии
+
+**GET** `/api/marketing/analysis/strategy/{strategyId}/history`
+
+Возвращает детальную информацию о стратегии, включая полную историю изменений статуса.
+
+#### Заголовки запроса
+
+```
+Authorization: Bearer
+```
+
+#### Параметры пути
+
+| Параметр | Тип | Описание |
+| ------------ | ------ | ----------------------- |
+| `strategyId` | string | Идентификатор стратегии |
+
+#### Пример успешного ответа (200 OK)
+
+```json
+{
+ "success": true,
+ "message": "Операция выполнена успешно",
+ "data": {
+ "strategyId": "507f1f77bcf86cd799439020",
+ "analysisId": "507f1f77bcf86cd799439011",
+ "status": "completed",
+ "userId": "12345",
+ "durationWeeks": 4,
+ "priorityPlatforms": ["Instagram", "LinkedIn", "Telegram"],
+ "createdAt": "2025-01-20T15:40:00",
+ "completedAt": "2025-01-20T15:43:00",
+ "statusHistory": [
+ {
+ "status": "queued",
+ "timestamp": "2025-01-20T15:40:00",
+ "message": "Стратегия создана и добавлена в очередь"
+ },
+ {
+ "status": "processing",
+ "timestamp": "2025-01-20T15:40:05",
+ "message": "Начата генерация стратегии"
+ },
+ {
+ "status": "completed",
+ "timestamp": "2025-01-20T15:43:00",
+ "message": "Стратегия успешно сгенерирована"
+ }
+ ]
+ }
+}
+```
+
+---
+
+## Обработка ошибок
+
+### Коды ошибок
+
+| Код | HTTP статус | Описание |
+| ------------------------ | ----------- | ------------------------------- |
+| `UNAUTHORIZED` | 401 | Требуется аутентификация |
+| `FORBIDDEN` | 403 | Нет доступа к ресурсу |
+| `VALIDATION_ERROR` | 400 | Ошибка валидации входных данных |
+| `NOT_FOUND` | 404 | Ресурс не найден |
+| `INVALID_ANALYSIS` | 404 | Анализ не найден |
+| `ANALYSIS_NOT_COMPLETED` | 400 | Анализ еще не завершен |
+| `INTERNAL_SERVER_ERROR` | 500 | Внутренняя ошибка сервера |
+
+### Формат ошибки
+
+```json
+{
+ "success": false,
+ "message": "Описание ошибки",
+ "error": {
+ "code": "ERROR_CODE",
+ "message": "Детальное сообщение об ошибке",
+ "details": {
+ "field1": "Сообщение об ошибке для поля 1"
+ }
+ }
+}
+```
+
+---
+
+## Примеры использования
+
+### JavaScript/TypeScript (Fetch API)
+
+#### Получение JWT токена
+
+```javascript
+// Предполагается, что токен получен при логине
+const jwtToken = localStorage.getItem('jwtToken');
+```
+
+#### Запуск анализа с JWT
+
+```javascript
+async function startMarketingAnalysis(data, jwtToken) {
+ 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(data),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ throw new Error('Требуется аутентификация');
+ }
+ const error = await response.json();
+ throw new Error(error.error?.message || 'Ошибка при запуске анализа');
+ }
+
+ const result = await response.json();
+ return result.data.analysisId;
+}
+```
+
+#### Получение списка всех анализов пользователя
+
+```javascript
+async function getMyAnalyses(jwtToken) {
+ const response = await fetch(
+ 'https://api.konturai.kz/api/marketing/analysis/my',
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Ошибка при получении списка анализов');
+ }
+
+ const result = await response.json();
+ return result.data; // Массив анализов
+}
+```
+
+#### Получение детальной истории анализа
+
+```javascript
+async function getAnalysisHistory(analysisId, jwtToken) {
+ const response = await fetch(
+ `https://api.konturai.kz/api/marketing/analysis/${analysisId}/history`,
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 403) {
+ throw new Error('Нет доступа к этому анализу');
+ }
+ throw new Error('Ошибка при получении истории');
+ }
+
+ const result = await response.json();
+ return result.data;
+}
+```
+
+#### Полный пример: создание анализа и отслеживание статуса
+
+```javascript
+async function createAndTrackAnalysis(
+ product,
+ location,
+ client,
+ differentiator,
+ jwtToken
+) {
+ try {
+ // 1. Запускаем анализ
+ const analysisId = await startMarketingAnalysis(
+ {
+ product,
+ location,
+ client,
+ differentiator,
+ },
+ jwtToken
+ );
+
+ console.log(`Анализ запущен: ${analysisId}`);
+
+ // 2. Получаем историю для отслеживания статуса
+ const checkStatus = async () => {
+ const history = await getAnalysisHistory(analysisId, jwtToken);
+
+ // Показываем последний статус
+ const lastStatus =
+ history.statusHistory[history.statusHistory.length - 1];
+ console.log(`Статус: ${lastStatus.status} - ${lastStatus.message}`);
+
+ return history.status;
+ };
+
+ // 3. Polling: проверяем статус каждые 10 секунд
+ const pollInterval = setInterval(async () => {
+ const status = await checkStatus();
+
+ if (status === 'completed') {
+ clearInterval(pollInterval);
+ console.log('Анализ завершен!');
+ // Получаем полный результат
+ const fullResult = await getAnalysisResult(analysisId, jwtToken);
+ return fullResult;
+ } else if (status === 'failed') {
+ clearInterval(pollInterval);
+ throw new Error('Анализ завершился с ошибкой');
+ }
+ }, 10000);
+
+ // Останавливаем polling через 15 минут
+ setTimeout(() => {
+ clearInterval(pollInterval);
+ console.log('Превышено время ожидания');
+ }, 15 * 60 * 1000);
+ } catch (error) {
+ console.error('Ошибка:', error);
+ throw error;
+ }
+}
+```
+
+#### Получение списка стратегий пользователя
+
+```javascript
+async function getMyStrategies(jwtToken) {
+ const response = await fetch(
+ 'https://api.konturai.kz/api/marketing/analysis/strategy/my',
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Ошибка при получении списка стратегий');
+ }
+
+ const result = await response.json();
+ return result.data; // Массив стратегий
+}
+```
+
+---
+
+## Рекомендации по интеграции
+
+### 1. Обработка JWT токена
+
+- Сохраняйте токен в безопасном месте (например, `localStorage` или `sessionStorage`)
+- Проверяйте срок действия токена перед запросами
+- Реализуйте механизм обновления токена при истечении
+
+### 2. Обработка ошибок аутентификации
+
+При получении `401 Unauthorized`:
+
+- Перенаправляйте пользователя на страницу входа
+- Очищайте сохраненный токен
+- Показывайте понятное сообщение пользователю
+
+### 3. Обработка ошибок доступа
+
+При получении `403 Forbidden`:
+
+- Показывайте сообщение о том, что ресурс недоступен
+- Не пытайтесь повторять запрос с теми же параметрами
+
+### 4. Polling стратегия
+
+Для отслеживания статуса анализа/стратегии:
+
+- Используйте интервал 10-15 секунд
+- Максимальное время ожидания: 15 минут
+- Показывайте прогресс пользователю на основе `statusHistory`
+
+### 5. Отображение истории статусов
+
+Используйте `statusHistory` для:
+
+- Показывать временную шкалу изменений статуса
+- Отображать детальную информацию о каждом этапе
+- Информировать пользователя о прогрессе
+
+### 6. Кэширование
+
+- Кэшируйте список анализов/стратегий пользователя
+- Обновляйте кэш при создании новых записей
+- Используйте `analysisId`/`strategyId` как ключи кэша
+
+---
+
+## Примечания
+
+1. **Формат даты**: Все даты возвращаются в формате ISO 8601 без timezone (LocalDateTime)
+2. **Идентификаторы**: Используются MongoDB ObjectId (24 символа hex)
+3. **Асинхронность**: Анализ и стратегия выполняются асинхронно
+4. **Безопасность**: Все эндпоинты требуют валидный JWT токен
+5. **Изоляция данных**: Пользователи видят только свои данные
+6. **История статусов**: Детальная история доступна для всех анализов и стратегий
+
+---
+
+## Поддержка
+
+При возникновении проблем с API обращайтесь в техническую поддержку с указанием:
+
+- `analysisId` или `strategyId` (если есть)
+- Время запроса
+- Описание проблемы
+- Код ошибки (если есть)
+- JWT токен (только для отладки, не в продакшене!)
diff --git a/pom.xml b/pom.xml
index 411a3f9..b989bd1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -126,6 +126,25 @@
batik-transcoder
1.17
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.3
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.3
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.3
+ runtime
+
diff --git a/src/main/java/kz/konturai/parser/controller/MarketingController.java b/src/main/java/kz/konturai/parser/controller/MarketingController.java
index 4ddce17..f78d600 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.service.JwtService;
import kz.konturai.parser.service.MarketingAnalysisService;
import kz.konturai.parser.service.MarketingStrategyService;
import kz.konturai.parser.service.MinIOService;
import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -16,8 +18,10 @@ import jakarta.validation.Valid;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/marketing/analysis")
@@ -26,22 +30,46 @@ public class MarketingController {
private final MarketingAnalysisService marketingAnalysisService;
private final MarketingStrategyService marketingStrategyService;
private final MinIOService minIOService;
+ private final JwtService jwtService;
public MarketingController(
MarketingAnalysisService marketingAnalysisService,
MarketingStrategyService marketingStrategyService,
- MinIOService minIOService) {
+ MinIOService minIOService,
+ JwtService jwtService) {
this.marketingAnalysisService = marketingAnalysisService;
this.marketingStrategyService = marketingStrategyService;
this.minIOService = minIOService;
+ 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));
}
@PostMapping("/start")
- public ResponseEntity> startAnalysis(
+ public ResponseEntity> startAnalysis(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
@Valid @RequestBody MarketingAnalysisRequest request) {
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
// Create analysis record
- MarketingAnalysis analysis = marketingAnalysisService.startAnalysis(request);
+ MarketingAnalysis analysis = marketingAnalysisService.startAnalysis(request, userId);
// Start async processing
marketingAnalysisService.processAnalysis(analysis.getId(), request);
@@ -60,11 +88,16 @@ public class MarketingController {
@GetMapping("/{analysisId}")
public ResponseEntity> getAnalysis(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId) {
- MarketingAnalysisResult result = marketingAnalysisService.getAnalysisResult(analysisId);
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
- if (result == null) {
+ Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
+ if (optAnalysis.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Анализ с указанным ID не найден");
@@ -72,31 +105,55 @@ public class MarketingController {
.body(ApiResponse.error("Анализ не найден", error));
}
+ MarketingAnalysis analysis = optAnalysis.get();
+ if (!userId.equals(analysis.getUserId())) {
+ ErrorResponse error = new ErrorResponse(
+ "FORBIDDEN",
+ "У вас нет доступа к этому анализу");
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiResponse.error("Доступ запрещен", error));
+ }
+
+ MarketingAnalysisResult result = marketingAnalysisService.getAnalysisResult(analysisId);
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/{analysisId}/download")
- public ResponseEntity downloadPdf(@PathVariable String analysisId) {
- MarketingAnalysisResult result = marketingAnalysisService.getAnalysisResult(analysisId);
+ public ResponseEntity> downloadPdf(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
+ @PathVariable String analysisId) {
- if (result == null || result.getReport() == null || result.getReport().getPdfUrl() == null) {
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
+ Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
+ if (optAnalysis.isEmpty()) {
return ResponseEntity.notFound().build();
}
- // Get the analysis to find PDF filename
- Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
- if (optAnalysis.isEmpty() || optAnalysis.get().getPdfFilePath() == null) {
+ MarketingAnalysis analysis = optAnalysis.get();
+ if (!userId.equals(analysis.getUserId())) {
+ ErrorResponse error = new ErrorResponse(
+ "FORBIDDEN",
+ "У вас нет доступа к этому анализу");
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiResponse.error("Доступ запрещен", error));
+ }
+
+ if (analysis.getPdfFilePath() == null) {
return ResponseEntity.notFound().build();
}
try {
- InputStream inputStream = minIOService.downloadFile(optAnalysis.get().getPdfFilePath());
+ InputStream inputStream = minIOService.downloadFile(analysis.getPdfFilePath());
byte[] bytes = inputStream.readAllBytes();
inputStream.close();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
- "attachment; filename=\"" + optAnalysis.get().getPdfFilename() + "\"")
+ "attachment; filename=\"" + analysis.getPdfFilename() + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(bytes);
} catch (Exception e) {
@@ -106,15 +163,38 @@ public class MarketingController {
@PostMapping("/strategy/generate")
public ResponseEntity> generateStrategy(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestParam String analysisId,
@Valid @RequestBody(required = false) MarketingStrategyRequest request) {
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
+ // Check if user owns the analysis
+ Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
+ if (optAnalysis.isEmpty()) {
+ ErrorResponse error = new ErrorResponse("INVALID_ANALYSIS", "Анализ не найден");
+ return ResponseEntity.status(404)
+ .body(ApiResponse.error("Анализ не найден", error));
+ }
+
+ MarketingAnalysis analysis = optAnalysis.get();
+ if (!userId.equals(analysis.getUserId())) {
+ ErrorResponse error = new ErrorResponse(
+ "FORBIDDEN",
+ "У вас нет доступа к этому анализу");
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiResponse.error("Доступ запрещен", error));
+ }
+
if (request == null) {
request = new MarketingStrategyRequest();
}
try {
- MarketingStrategy strategy = marketingStrategyService.generateStrategy(analysisId, request);
+ MarketingStrategy strategy = marketingStrategyService.generateStrategy(analysisId, request, userId);
MarketingStrategyResponse response = new MarketingStrategyResponse();
response.setStrategyId(strategy.getId());
@@ -145,10 +225,17 @@ public class MarketingController {
}
@GetMapping("/strategy/{strategyId}")
- public ResponseEntity> getStrategy(@PathVariable String strategyId) {
- MarketingStrategyResponse result = marketingStrategyService.getStrategyResult(strategyId);
+ public ResponseEntity> getStrategy(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
+ @PathVariable String strategyId) {
- if (result == null) {
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
+ Optional optStrategy = marketingStrategyService.getStrategyById(strategyId);
+ if (optStrategy.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия с указанным ID не найдена");
@@ -156,11 +243,48 @@ public class MarketingController {
.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));
+ }
+
+ MarketingStrategyResponse result = marketingStrategyService.getStrategyResult(strategyId);
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/{analysisId}/strategy")
- public ResponseEntity> getStrategyByAnalysis(@PathVariable String analysisId) {
+ public ResponseEntity> getStrategyByAnalysis(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
+ @PathVariable String analysisId) {
+
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
+ // Check if user owns the analysis
+ Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
+ if (optAnalysis.isEmpty()) {
+ ErrorResponse error = new ErrorResponse(
+ "NOT_FOUND",
+ "Анализ с указанным ID не найден");
+ return ResponseEntity.status(404)
+ .body(ApiResponse.error("Анализ не найден", error));
+ }
+
+ MarketingAnalysis analysis = optAnalysis.get();
+ if (!userId.equals(analysis.getUserId())) {
+ ErrorResponse error = new ErrorResponse(
+ "FORBIDDEN",
+ "У вас нет доступа к этому анализу");
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiResponse.error("Доступ запрещен", error));
+ }
+
MarketingStrategyResponse result = marketingStrategyService.getStrategyByAnalysisId(analysisId);
if (result == null) {
@@ -174,6 +298,133 @@ public class MarketingController {
return ResponseEntity.ok(ApiResponse.success(result));
}
+ @GetMapping("/my")
+ public ResponseEntity> getMyAnalyses(
+ @RequestHeader(value = "Authorization", required = false) String authHeader) {
+
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
+ List analyses = marketingAnalysisService.getUserAnalyses(userId);
+ List responseList = analyses.stream()
+ .map(this::convertToHistoryResponse)
+ .collect(Collectors.toList());
+
+ return ResponseEntity.ok(ApiResponse.success(responseList));
+ }
+
+ @GetMapping("/strategy/my")
+ public ResponseEntity> getMyStrategies(
+ @RequestHeader(value = "Authorization", required = false) String authHeader) {
+
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
+ List strategies = marketingStrategyService.getUserStrategies(userId);
+ List responseList = strategies.stream()
+ .map(this::convertToStrategyHistoryResponse)
+ .collect(Collectors.toList());
+
+ return ResponseEntity.ok(ApiResponse.success(responseList));
+ }
+
+ @GetMapping("/{analysisId}/history")
+ public ResponseEntity> getAnalysisHistory(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
+ @PathVariable String analysisId) {
+
+ String userId = extractUserIdFromHeader(authHeader);
+ if (userId == null) {
+ return unauthorizedResponse();
+ }
+
+ Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
+ if (optAnalysis.isEmpty()) {
+ ErrorResponse error = new ErrorResponse(
+ "NOT_FOUND",
+ "Анализ с указанным ID не найден");
+ return ResponseEntity.status(404)
+ .body(ApiResponse.error("Анализ не найден", error));
+ }
+
+ MarketingAnalysis analysis = optAnalysis.get();
+ if (!userId.equals(analysis.getUserId())) {
+ ErrorResponse error = new ErrorResponse(
+ "FORBIDDEN",
+ "У вас нет доступа к этому анализу");
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiResponse.error("Доступ запрещен", error));
+ }
+
+ AnalysisHistoryResponse response = convertToHistoryResponse(analysis);
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+
+ @GetMapping("/strategy/{strategyId}/history")
+ public ResponseEntity> getStrategyHistory(
+ @RequestHeader(value = "Authorization", required = false) String authHeader,
+ @PathVariable String strategyId) {
+
+ 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));
+ }
+
+ StrategyHistoryResponse response = convertToStrategyHistoryResponse(strategy);
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+
+ private AnalysisHistoryResponse convertToHistoryResponse(MarketingAnalysis analysis) {
+ AnalysisHistoryResponse response = new AnalysisHistoryResponse();
+ response.setAnalysisId(analysis.getId());
+ response.setProduct(analysis.getProduct());
+ response.setLocation(analysis.getLocation());
+ response.setClientType(analysis.getClientType());
+ response.setDifferentiator(analysis.getDifferentiator());
+ response.setStatus(analysis.getStatus());
+ response.setUserId(analysis.getUserId());
+ response.setCreatedAt(analysis.getCreatedAt());
+ response.setCompletedAt(analysis.getCompletedAt());
+ response.setStatusHistory(analysis.getStatusHistory());
+ return response;
+ }
+
+ private StrategyHistoryResponse convertToStrategyHistoryResponse(MarketingStrategy strategy) {
+ StrategyHistoryResponse response = new StrategyHistoryResponse();
+ response.setStrategyId(strategy.getId());
+ response.setAnalysisId(strategy.getAnalysisId());
+ response.setStatus(strategy.getStatus());
+ response.setUserId(strategy.getUserId());
+ response.setDurationWeeks(strategy.getDurationWeeks());
+ response.setPriorityPlatforms(strategy.getPriorityPlatforms());
+ response.setCreatedAt(strategy.getCreatedAt());
+ response.setCompletedAt(strategy.getCompletedAt());
+ response.setStatusHistory(strategy.getStatusHistory());
+ return response;
+ }
+
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity> handleValidationException(
MethodArgumentNotValidException ex) {
diff --git a/src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java b/src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java
new file mode 100644
index 0000000..7f2763b
--- /dev/null
+++ b/src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java
@@ -0,0 +1,101 @@
+package kz.konturai.parser.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public class AnalysisHistoryResponse {
+ private String analysisId;
+ private String product;
+ private String location;
+ private String clientType;
+ private String differentiator;
+ private String status;
+ private String userId;
+ private LocalDateTime createdAt;
+ private LocalDateTime completedAt;
+ private List statusHistory;
+
+ public AnalysisHistoryResponse() {
+ }
+
+ public String getAnalysisId() {
+ return analysisId;
+ }
+
+ public void setAnalysisId(String analysisId) {
+ this.analysisId = analysisId;
+ }
+
+ public String getProduct() {
+ return product;
+ }
+
+ public void setProduct(String product) {
+ this.product = product;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public void setLocation(String location) {
+ this.location = location;
+ }
+
+ public String getClientType() {
+ return clientType;
+ }
+
+ public void setClientType(String clientType) {
+ this.clientType = clientType;
+ }
+
+ public String getDifferentiator() {
+ return differentiator;
+ }
+
+ public void setDifferentiator(String differentiator) {
+ this.differentiator = differentiator;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getCompletedAt() {
+ return completedAt;
+ }
+
+ public void setCompletedAt(LocalDateTime completedAt) {
+ this.completedAt = completedAt;
+ }
+
+ public List getStatusHistory() {
+ return statusHistory;
+ }
+
+ public void setStatusHistory(List statusHistory) {
+ this.statusHistory = statusHistory;
+ }
+}
+
diff --git a/src/main/java/kz/konturai/parser/dto/StatusHistoryEntry.java b/src/main/java/kz/konturai/parser/dto/StatusHistoryEntry.java
new file mode 100644
index 0000000..1cc47b1
--- /dev/null
+++ b/src/main/java/kz/konturai/parser/dto/StatusHistoryEntry.java
@@ -0,0 +1,48 @@
+package kz.konturai.parser.dto;
+
+import java.time.LocalDateTime;
+
+public class StatusHistoryEntry {
+ private String status;
+ private LocalDateTime timestamp;
+ private String message;
+
+ public StatusHistoryEntry() {
+ }
+
+ public StatusHistoryEntry(String status, LocalDateTime timestamp) {
+ this.status = status;
+ this.timestamp = timestamp;
+ }
+
+ public StatusHistoryEntry(String status, LocalDateTime timestamp, String message) {
+ this.status = status;
+ this.timestamp = timestamp;
+ this.message = message;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public LocalDateTime getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(LocalDateTime timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+}
+
diff --git a/src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java b/src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java
new file mode 100644
index 0000000..e94c34b
--- /dev/null
+++ b/src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java
@@ -0,0 +1,92 @@
+package kz.konturai.parser.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public class StrategyHistoryResponse {
+ private String strategyId;
+ private String analysisId;
+ private String status;
+ private String userId;
+ private Integer durationWeeks;
+ private List priorityPlatforms;
+ private LocalDateTime createdAt;
+ private LocalDateTime completedAt;
+ private List statusHistory;
+
+ public StrategyHistoryResponse() {
+ }
+
+ public String getStrategyId() {
+ return strategyId;
+ }
+
+ public void setStrategyId(String strategyId) {
+ this.strategyId = strategyId;
+ }
+
+ public String getAnalysisId() {
+ return analysisId;
+ }
+
+ public void setAnalysisId(String analysisId) {
+ this.analysisId = analysisId;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ public Integer getDurationWeeks() {
+ return durationWeeks;
+ }
+
+ public void setDurationWeeks(Integer durationWeeks) {
+ this.durationWeeks = durationWeeks;
+ }
+
+ public List getPriorityPlatforms() {
+ return priorityPlatforms;
+ }
+
+ public void setPriorityPlatforms(List priorityPlatforms) {
+ this.priorityPlatforms = priorityPlatforms;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getCompletedAt() {
+ return completedAt;
+ }
+
+ public void setCompletedAt(LocalDateTime completedAt) {
+ this.completedAt = completedAt;
+ }
+
+ public List getStatusHistory() {
+ return statusHistory;
+ }
+
+ public void setStatusHistory(List statusHistory) {
+ this.statusHistory = statusHistory;
+ }
+}
+
diff --git a/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java b/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java
index 8e82928..4f84c99 100644
--- a/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java
+++ b/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java
@@ -1,10 +1,13 @@
package kz.konturai.parser.model;
+import kz.konturai.parser.dto.StatusHistoryEntry;
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;
import java.util.Map;
@Document(collection = "marketing_analysis")
@@ -46,9 +49,13 @@ public class MarketingAnalysis {
@Field("user_id")
private String userId; // Optional, for future authentication
+ @Field("status_history")
+ private List statusHistory;
+
public MarketingAnalysis() {
this.createdAt = LocalDateTime.now();
this.status = "queued";
+ this.statusHistory = new ArrayList<>();
}
public MarketingAnalysis(String product, String location, String clientType, String differentiator) {
@@ -154,4 +161,12 @@ public class MarketingAnalysis {
public void setUserId(String userId) {
this.userId = userId;
}
+
+ public List getStatusHistory() {
+ return statusHistory;
+ }
+
+ public void setStatusHistory(List statusHistory) {
+ this.statusHistory = statusHistory;
+ }
}
diff --git a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java
index b8809ec..071f76e 100644
--- a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java
+++ b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java
@@ -1,10 +1,12 @@
package kz.konturai.parser.model;
+import kz.konturai.parser.dto.StatusHistoryEntry;
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;
import java.util.Map;
@@ -41,9 +43,16 @@ public class MarketingStrategy {
@Field("strategy_data")
private Map strategyData; // JSON data with full strategy content
+ @Field("user_id")
+ private String userId;
+
+ @Field("status_history")
+ private List statusHistory;
+
public MarketingStrategy() {
this.createdAt = LocalDateTime.now();
this.status = "queued";
+ this.statusHistory = new ArrayList<>();
}
public MarketingStrategy(String analysisId) {
@@ -131,6 +140,22 @@ public class MarketingStrategy {
this.strategyData = strategyData;
}
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ public List getStatusHistory() {
+ return statusHistory;
+ }
+
+ public void setStatusHistory(List statusHistory) {
+ this.statusHistory = statusHistory;
+ }
+
public static class WeeklyPlan {
@Field("week_number")
private Integer weekNumber;
diff --git a/src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java b/src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java
index a49ed49..6d5f84a 100644
--- a/src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java
+++ b/src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java
@@ -4,9 +4,11 @@ import kz.konturai.parser.model.MarketingAnalysis;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
+import java.util.List;
import java.util.Optional;
@Repository
public interface MarketingAnalysisRepository extends MongoRepository {
Optional findById(String id);
+ List findByUserIdOrderByCreatedAtDesc(String userId);
}
diff --git a/src/main/java/kz/konturai/parser/repository/MarketingStrategyRepository.java b/src/main/java/kz/konturai/parser/repository/MarketingStrategyRepository.java
index ab7bcb3..c3d83e0 100644
--- a/src/main/java/kz/konturai/parser/repository/MarketingStrategyRepository.java
+++ b/src/main/java/kz/konturai/parser/repository/MarketingStrategyRepository.java
@@ -4,11 +4,14 @@ import kz.konturai.parser.model.MarketingStrategy;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
+import java.util.List;
import java.util.Optional;
@Repository
public interface MarketingStrategyRepository extends MongoRepository {
Optional findById(String id);
Optional findByAnalysisId(String analysisId);
+ List findByUserIdOrderByCreatedAtDesc(String userId);
+ List findByUserIdAndAnalysisId(String userId, String analysisId);
}
diff --git a/src/main/java/kz/konturai/parser/service/JwtService.java b/src/main/java/kz/konturai/parser/service/JwtService.java
new file mode 100644
index 0000000..ee1d82e
--- /dev/null
+++ b/src/main/java/kz/konturai/parser/service/JwtService.java
@@ -0,0 +1,200 @@
+package kz.konturai.parser.service;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.crypto.SecretKey;
+
+@Service
+public class JwtService {
+
+ private static final Logger logger = LoggerFactory.getLogger(JwtService.class);
+ private final SecretKey signingKey;
+
+ public JwtService(
+ @Value("${security.jwt.secret-base64:}") String base64Secret) {
+ if (StringUtils.hasText(base64Secret)) {
+ this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret));
+ } else {
+ // Если секрет не настроен, создаем временный ключ (для разработки)
+ // В продакшене это должно быть обязательно настроено
+ logger.warn("JWT secret not configured, using default key. This should be configured in production!");
+ String defaultSecret = "dGVzdC1zZWNyZXQta2V5LWZvci1kZXZlbG9wbWVudC1vbmx5LWRvLW5vdC11c2UtaW4tcHJvZHVjdGlvbg==";
+ this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(defaultSecret));
+ }
+ }
+
+ /**
+ * Парсит и валидирует JWT токен
+ *
+ * @param token JWT токен
+ * @return Claims из токена
+ * @throws JwtException если токен невалиден или истёк
+ */
+ public Claims parseAndValidate(String token) throws JwtException {
+ return Jwts.parser()
+ .verifyWith(signingKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+
+ /**
+ * Извлекает JWT токен из заголовка Authorization
+ *
+ * @param authHeader значение заголовка Authorization (например, "Bearer
+ * ")
+ * @return JWT токен или null если не найден
+ */
+ public String extractTokenFromHeader(String authHeader) {
+ if (!StringUtils.hasText(authHeader)) {
+ return null;
+ }
+
+ if (authHeader.startsWith("Bearer ")) {
+ return authHeader.substring(7);
+ }
+
+ return authHeader;
+ }
+
+ /**
+ * Извлекает userId (uid) из JWT токена
+ * Согласно документации, userId находится в поле "uid" как Long
+ *
+ * Структура JWT токена:
+ * - sub: Email пользователя
+ * - uid: ID пользователя (Long)
+ * - roles: Роли пользователя (String, разделённые запятыми)
+ *
+ * @param token JWT токен
+ * @return userId как String или null если не найден
+ */
+ public String extractUserIdFromToken(String token) {
+ if (!StringUtils.hasText(token)) {
+ return null;
+ }
+
+ try {
+ Claims claims = parseAndValidate(token);
+
+ // Основной способ: извлекаем uid (Long) из claims согласно документации
+ Long uid = claims.get("uid", Long.class);
+ if (uid != null) {
+ return String.valueOf(uid);
+ }
+
+ // Fallback: пробуем другие поля, если uid не найден (для совместимости)
+ if (claims.get("userId") != null) {
+ logger.debug("Using 'userId' field as fallback");
+ return String.valueOf(claims.get("userId"));
+ }
+ if (claims.get("id") != null) {
+ logger.debug("Using 'id' field as fallback");
+ return String.valueOf(claims.get("id"));
+ }
+ if (claims.get("user_id") != null) {
+ logger.debug("Using 'user_id' field as fallback");
+ return String.valueOf(claims.get("user_id"));
+ }
+
+ // Если ничего не найдено, пробуем subject (email) как последний вариант
+ // (не рекомендуется, но может быть полезно для обратной совместимости)
+ String subject = claims.getSubject();
+ if (subject != null && !subject.isEmpty()) {
+ logger.warn("Using subject (email) as userId fallback. Token should contain 'uid' field: {}", subject);
+ return subject;
+ }
+
+ logger.warn("No userId field (uid) found in JWT token");
+ return null;
+
+ } catch (io.jsonwebtoken.ExpiredJwtException e) {
+ logger.error("JWT token has expired: {}", e.getMessage());
+ return null;
+ } catch (io.jsonwebtoken.MalformedJwtException e) {
+ logger.error("Malformed JWT token: {}", e.getMessage());
+ return null;
+ } catch (io.jsonwebtoken.security.SignatureException e) {
+ logger.error("Invalid JWT signature: {}", e.getMessage());
+ return null;
+ } catch (JwtException e) {
+ logger.error("Error parsing/validating JWT token: {}", e.getMessage());
+ return null;
+ } catch (Exception e) {
+ logger.error("Unexpected error parsing JWT token: {}", e.getMessage(), e);
+ return null;
+ }
+ }
+
+ /**
+ * Извлекает userId из заголовка Authorization
+ *
+ * @param authHeader значение заголовка Authorization
+ * @return userId или null если не найден
+ */
+ public String extractUserIdFromHeader(String authHeader) {
+ String token = extractTokenFromHeader(authHeader);
+ if (token == null) {
+ return null;
+ }
+ return extractUserIdFromToken(token);
+ }
+
+ /**
+ * Извлекает email (subject) из JWT токена
+ *
+ * @param token JWT токен
+ * @return email или null если не найден
+ */
+ public String extractEmailFromToken(String token) {
+ if (!StringUtils.hasText(token)) {
+ return null;
+ }
+
+ try {
+ Claims claims = parseAndValidate(token);
+ return claims.getSubject();
+ } catch (JwtException e) {
+ logger.error("Error parsing/validating JWT token: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Извлекает роли из JWT токена
+ *
+ * @param token JWT токен
+ * @return список ролей или пустой список
+ */
+ public java.util.List extractRolesFromToken(String token) {
+ if (!StringUtils.hasText(token)) {
+ return java.util.List.of();
+ }
+
+ try {
+ Claims claims = parseAndValidate(token);
+ String rolesString = claims.get("roles", String.class);
+
+ if (rolesString == null || rolesString.isBlank()) {
+ return java.util.List.of();
+ }
+
+ return java.util.Arrays.stream(rolesString.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(java.util.stream.Collectors.toList());
+ } catch (JwtException e) {
+ logger.error("Error parsing/validating JWT token: {}", e.getMessage());
+ return java.util.List.of();
+ }
+ }
+}
diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java
index e95f944..359965b 100644
--- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java
+++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java
@@ -2,6 +2,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.MarketingAnalysis;
import kz.konturai.parser.repository.MarketingAnalysisRepository;
import org.slf4j.Logger;
@@ -34,18 +35,28 @@ public class MarketingAnalysisService {
this.researchPdfService = researchPdfService;
}
- public MarketingAnalysis startAnalysis(MarketingAnalysisRequest request) {
+ public MarketingAnalysis startAnalysis(MarketingAnalysisRequest request, String userId) {
MarketingAnalysis analysis = new MarketingAnalysis(
request.getProduct(),
request.getLocation(),
request.getClient(),
request.getDifferentiator());
+ analysis.setUserId(userId);
analysis.setStatus("queued");
+ addStatusHistoryEntry(analysis, "queued", "Анализ создан и добавлен в очередь");
analysis = repository.save(analysis);
- logger.info("Marketing analysis created with ID: {}", analysis.getId());
+ logger.info("Marketing analysis created with ID: {} for user: {}", analysis.getId(), userId);
return analysis;
}
+ private void addStatusHistoryEntry(MarketingAnalysis analysis, String status, String message) {
+ if (analysis.getStatusHistory() == null) {
+ analysis.setStatusHistory(new ArrayList<>());
+ }
+ StatusHistoryEntry entry = new StatusHistoryEntry(status, LocalDateTime.now(), message);
+ analysis.getStatusHistory().add(entry);
+ }
+
@Async("reportGenerationExecutor")
public void processAnalysis(String analysisId, MarketingAnalysisRequest request) {
try {
@@ -57,6 +68,7 @@ public class MarketingAnalysisService {
MarketingAnalysis analysis = optAnalysis.get();
analysis.setStatus("processing");
+ addStatusHistoryEntry(analysis, "processing", "Начата обработка анализа");
repository.save(analysis);
logger.info("Starting marketing analysis processing for ID: {}", analysisId);
@@ -83,6 +95,7 @@ public class MarketingAnalysisService {
analysis.setReportData(reportData);
analysis.setPdfFilename(filename);
analysis.setPdfFilePath(filename);
+ addStatusHistoryEntry(analysis, "completed", "Анализ успешно завершен");
repository.save(analysis);
logger.info("Marketing analysis completed successfully for ID: {}", analysisId);
@@ -94,6 +107,7 @@ public class MarketingAnalysisService {
if (optAnalysis.isPresent()) {
MarketingAnalysis analysis = optAnalysis.get();
analysis.setStatus("failed");
+ addStatusHistoryEntry(analysis, "failed", "Ошибка при обработке: " + e.getMessage());
repository.save(analysis);
}
} catch (Exception saveError) {
@@ -350,6 +364,10 @@ public class MarketingAnalysisService {
return repository.findById(analysisId);
}
+ public List getUserAnalyses(String userId) {
+ return repository.findByUserIdOrderByCreatedAtDesc(userId);
+ }
+
private MarketingAnalysisResult.MarketingReport buildReportFromData(Map reportData,
MarketingAnalysis analysis) {
MarketingAnalysisResult.MarketingReport report = new MarketingAnalysisResult.MarketingReport();
diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java
index bfa89ba..62ff2bb 100644
--- a/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java
+++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import kz.konturai.parser.dto.MarketingAnalysisResult;
import kz.konturai.parser.dto.MarketingStrategyRequest;
import kz.konturai.parser.dto.MarketingStrategyResponse;
+import kz.konturai.parser.dto.StatusHistoryEntry;
import kz.konturai.parser.model.MarketingStrategy;
import kz.konturai.parser.repository.MarketingStrategyRepository;
import org.slf4j.Logger;
@@ -36,7 +37,7 @@ public class MarketingStrategyService {
this.openAIAnalyticsService = openAIAnalyticsService;
}
- public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request) {
+ public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, String userId) {
// Check if analysis exists and is completed
MarketingAnalysisResult analysisResult = marketingAnalysisService.getAnalysisResult(analysisId);
if (analysisResult == null) {
@@ -55,12 +56,14 @@ public class MarketingStrategyService {
// Create new strategy
MarketingStrategy strategy = new MarketingStrategy(analysisId);
+ strategy.setUserId(userId);
strategy.setDurationWeeks(request.getDurationWeeks() != null ? request.getDurationWeeks() : 4);
strategy.setPriorityPlatforms(request.getPriorityPlatforms());
strategy.setStatus("queued");
+ addStatusHistoryEntry(strategy, "queued", "Стратегия создана и добавлена в очередь");
strategy = repository.save(strategy);
- logger.info("Marketing strategy created with ID: {}", strategy.getId());
+ logger.info("Marketing strategy created with ID: {} for user: {}", strategy.getId(), userId);
// Start async processing
processStrategyGeneration(strategy.getId(), analysisId, analysisResult);
@@ -68,6 +71,14 @@ public class MarketingStrategyService {
return strategy;
}
+ private void addStatusHistoryEntry(MarketingStrategy strategy, String status, String message) {
+ if (strategy.getStatusHistory() == null) {
+ strategy.setStatusHistory(new ArrayList<>());
+ }
+ StatusHistoryEntry entry = new StatusHistoryEntry(status, LocalDateTime.now(), message);
+ strategy.getStatusHistory().add(entry);
+ }
+
@Async("reportGenerationExecutor")
public void processStrategyGeneration(String strategyId, String analysisId, MarketingAnalysisResult analysisResult) {
try {
@@ -79,6 +90,7 @@ public class MarketingStrategyService {
MarketingStrategy strategy = optStrategy.get();
strategy.setStatus("processing");
+ addStatusHistoryEntry(strategy, "processing", "Начата генерация стратегии");
repository.save(strategy);
logger.info("Starting marketing strategy generation for ID: {}", strategyId);
@@ -106,6 +118,7 @@ public class MarketingStrategyService {
strategyData.put("postCalendar", postCalendar);
strategy.setStrategyData(strategyData);
+ addStatusHistoryEntry(strategy, "completed", "Стратегия успешно сгенерирована");
repository.save(strategy);
logger.info("Marketing strategy generation completed successfully for ID: {}", strategyId);
@@ -117,6 +130,7 @@ public class MarketingStrategyService {
if (optStrategy.isPresent()) {
MarketingStrategy strategy = optStrategy.get();
strategy.setStatus("failed");
+ addStatusHistoryEntry(strategy, "failed", "Ошибка при генерации стратегии: " + e.getMessage());
repository.save(strategy);
}
} catch (Exception saveError) {
@@ -497,5 +511,13 @@ public class MarketingStrategyService {
return getStrategyResult(optStrategy.get().getId());
}
+
+ public List getUserStrategies(String userId) {
+ return repository.findByUserIdOrderByCreatedAtDesc(userId);
+ }
+
+ public Optional getStrategyById(String strategyId) {
+ return repository.findById(strategyId);
+ }
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 8750519..a94fd29 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -77,3 +77,8 @@ spring.mail.properties.mail.smtp.starttls.required=true
# Deep Research API Configuration
deep-research.api.url=http://185.35.223.45:3051
deep-research.api.timeout=1800000
+
+# JWT Configuration
+# Используйте тот же secret-base64, что и в сервисе, выдающем токены
+# Формат: base64-encoded secret key
+security.jwt.secret-base64=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl