From 82415ad5ffdc67bdf7f0d05eeca86f0b3912e445 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 29 Nov 2025 17:19:44 +0500 Subject: [PATCH] new endpoints for analys history --- a.md | 309 ++++++ marketing-api-with-jwt-frontend.md | 987 ++++++++++++++++++ pom.xml | 19 + .../controller/MarketingController.java | 287 ++++- .../parser/dto/AnalysisHistoryResponse.java | 101 ++ .../parser/dto/StatusHistoryEntry.java | 48 + .../parser/dto/StrategyHistoryResponse.java | 92 ++ .../parser/model/MarketingAnalysis.java | 15 + .../parser/model/MarketingStrategy.java | 25 + .../MarketingAnalysisRepository.java | 2 + .../MarketingStrategyRepository.java | 3 + .../konturai/parser/service/JwtService.java | 200 ++++ .../service/MarketingAnalysisService.java | 22 +- .../service/MarketingStrategyService.java | 26 +- src/main/resources/application.properties | 5 + 15 files changed, 2119 insertions(+), 22 deletions(-) create mode 100644 a.md create mode 100644 marketing-api-with-jwt-frontend.md create mode 100644 src/main/java/kz/konturai/parser/dto/AnalysisHistoryResponse.java create mode 100644 src/main/java/kz/konturai/parser/dto/StatusHistoryEntry.java create mode 100644 src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java create mode 100644 src/main/java/kz/konturai/parser/service/JwtService.java 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