This commit is contained in:
root
2025-12-03 09:02:21 +05:00
parent 6678a67d73
commit 8b1a96efe5
16 changed files with 2629 additions and 587 deletions
+340
View File
@@ -0,0 +1,340 @@
# Учетные данные Facebook для запуска рекламы
## Обзор
Для запуска рекламных кампаний в Facebook через Marketing API (ранее Ads API) требуются специальные учетные данные, которые отличаются от простого Access Token для публикации постов.
---
## Необходимые учетные данные
### 1. **Access Token (обязательно)**
Access Token с расширенными разрешениями для управления рекламой.
**Требуемые разрешения (Permissions):**
- `ads_management` - Управление рекламными кампаниями
- `ads_read` - Чтение данных о рекламе
- `business_management` - Управление бизнес-аккаунтом
- `pages_read_engagement` - Чтение данных страниц (опционально)
**Типы токенов:**
- **User Access Token** - краткосрочный (1-2 часа)
- **Long-Lived User Access Token** - долгосрочный (60 дней)
- **Page Access Token** - для управления страницами
- **System User Access Token** - для серверных приложений (рекомендуется для продакшена)
---
### 2. **Ad Account ID (обязательно)**
ID рекламного аккаунта Facebook, в котором будут создаваться кампании.
**Формат:** `act_XXXXXXXXX` (например: `act_123456789`)
**Где найти:**
1. Перейдите в [Facebook Ads Manager](https://business.facebook.com/adsmanager)
2. В настройках аккаунта найдите "Account ID"
3. Или используйте API: `GET /me/adaccounts`
---
### 3. **App ID и App Secret (обязательно для серверных приложений)**
Учетные данные Facebook приложения.
**Где найти:**
1. Перейдите на [Facebook Developers](https://developers.facebook.com/)
2. Выберите ваше приложение
3. В разделе "Settings" → "Basic" найдите:
- **App ID**
- **App Secret** (нажмите "Show" для отображения)
**Важно:** App Secret должен храниться в безопасности и никогда не передаваться на клиент.
---
### 4. **Page ID (опционально, но рекомендуется)**
ID страницы Facebook, связанной с рекламным аккаунтом.
**Где найти:**
1. Перейдите на вашу страницу Facebook
2. В настройках страницы найдите "Page ID"
3. Или используйте API: `GET /me/accounts`
---
## Пошаговая инструкция получения учетных данных
### Шаг 1: Создание Facebook приложения
1. Перейдите на [Facebook Developers](https://developers.facebook.com/)
2. Нажмите "My Apps" → "Create App"
3. Выберите тип приложения: **"Business"** или **"Other"**
4. Заполните название и контактный email
5. Нажмите "Create App"
### Шаг 2: Добавление продукта "Marketing API"
1. В панели управления приложением найдите раздел "Add Products"
2. Найдите "Marketing API" и нажмите "Set Up"
3. Следуйте инструкциям для настройки
### Шаг 3: Получение App ID и App Secret
1. В левом меню выберите "Settings" → "Basic"
2. Скопируйте **App ID**
3. Нажмите "Show" рядом с **App Secret** и скопируйте его
4. **Сохраните эти данные в безопасном месте**
### Шаг 4: Настройка разрешений (Permissions)
1. В левом меню выберите "Settings" → "Advanced"
2. Добавьте в "Valid OAuth Redirect URIs" ваш callback URL
3. В разделе "Permissions and Features" запросите:
- `ads_management`
- `ads_read`
- `business_management`
- `pages_read_engagement`
### Шаг 5: Получение Access Token
#### Вариант A: User Access Token (для тестирования)
1. Перейдите в [Graph API Explorer](https://developers.facebook.com/tools/explorer/)
2. Выберите ваше приложение
3. Нажмите "Generate Access Token"
4. Выберите необходимые разрешения
5. Скопируйте полученный токен
#### Вариант B: Long-Lived Token (для разработки)
```bash
# Обмен краткосрочного токена на долгосрочный
curl -X GET "https://graph.facebook.com/v18.0/oauth/access_token?grant_type=fb_exchange_token&client_id={app-id}&client_secret={app-secret}&fb_exchange_token={short-lived-token}"
```
#### Вариант C: System User Token (для продакшена - рекомендуется)
1. В панели управления приложением перейдите в "Business Settings"
2. Создайте System User
3. Назначьте ему доступ к рекламному аккаунту
4. Сгенерируйте токен для System User
### Шаг 6: Получение Ad Account ID
**Через Ads Manager:**
1. Перейдите в [Facebook Ads Manager](https://business.facebook.com/adsmanager)
2. В настройках аккаунта найдите "Account ID"
**Через API:**
```bash
curl -X GET "https://graph.facebook.com/v18.0/me/adaccounts?access_token={access-token}"
```
Ответ будет содержать массив с `id` в формате `act_XXXXXXXXX`.
---
## Структура учетных данных для вашего API
Для интеграции с вашей системой, учетные данные Facebook для рекламы должны быть сохранены в следующем формате:
### Формат JSON для сохранения credentials
```json
{
"platform": "facebook_ads",
"credentials": {
"accessToken": "EAABwzLix...",
"adAccountId": "act_123456789",
"appId": "1234567890123456",
"appSecret": "your-app-secret-here",
"pageId": "1234567890123456",
"tokenType": "LONG_LIVED",
"expiresAt": "2024-12-31T23:59:59Z"
}
}
```
### Пример сохранения через API
```javascript
const facebookAdsCredentials = {
platform: 'facebook_ads',
credentials: JSON.stringify({
accessToken: 'EAABwzLix...',
adAccountId: 'act_123456789',
appId: '1234567890123456',
appSecret: 'your-app-secret-here',
pageId: '1234567890123456',
tokenType: 'LONG_LIVED',
expiresAt: '2024-12-31T23:59:59Z',
}),
};
// Сохранение через ваш API
const response = await fetch('/api/social-media/credentials', {
method: 'POST',
headers: {
Authorization: `Bearer ${jwtToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(facebookAdsCredentials),
});
```
---
## Требования и ограничения
### Ограничения Facebook Marketing API
1. **Rate Limits:**
- 200 вызовов в час на пользователя
- 4800 вызовов в час на приложение
2. **Минимальные требования:**
- Рекламный аккаунт должен быть активен
- У пользователя должны быть права администратора на аккаунте
- Приложение должно пройти ревью Facebook (для продакшена)
3. **Версия API:**
- Текущая версия: v18.0
- Facebook регулярно обновляет API, следите за изменениями
### Безопасность
1. **Никогда не храните App Secret в открытом виде**
2. **Используйте шифрование для хранения credentials** (ваша система уже использует шифрование)
3. **Регулярно обновляйте токены** (Long-Lived токены истекают через 60 дней)
4. **Используйте System User Token для продакшена** вместо User Token
---
## Проверка учетных данных
### Проверка Access Token
```bash
curl -X GET "https://graph.facebook.com/v18.0/me?access_token={access-token}"
```
Если токен валиден, вы получите информацию о пользователе.
### Проверка доступа к Ad Account
```bash
curl -X GET "https://graph.facebook.com/v18.0/{ad-account-id}?access_token={access-token}&fields=id,name,account_id"
```
Если доступ есть, вы получите информацию об аккаунте.
### Проверка разрешений
```bash
curl -X GET "https://graph.facebook.com/v18.0/me/permissions?access_token={access-token}"
```
Проверьте, что в ответе есть:
- `ads_management` со статусом `granted`
- `ads_read` со статусом `granted`
- `business_management` со статусом `granted`
---
## Примеры использования для создания рекламной кампании
### Создание кампании
```bash
curl -X POST "https://graph.facebook.com/v18.0/{ad-account-id}/campaigns" \
-d "name=Test Campaign" \
-d "objective=OUTCOME_TRAFFIC" \
-d "status=PAUSED" \
-d "access_token={access-token}"
```
### Создание Ad Set
```bash
curl -X POST "https://graph.facebook.com/v18.0/{ad-account-id}/adsets" \
-d "name=Test Ad Set" \
-d "campaign_id={campaign-id}" \
-d "billing_event=IMPRESSIONS" \
-d "optimization_goal=REACH" \
-d "bid_amount=100" \
-d "daily_budget=1000" \
-d "targeting={'geo_locations':{'countries':['KZ']}}" \
-d "access_token={access-token}"
```
---
## Обновление токенов
Access Token имеет срок действия. Для автоматического обновления:
1. **Отслеживайте срок действия токена** (`expiresAt`)
2. **Используйте refresh token** (если доступен)
3. **Или запросите новый токен** перед истечением старого
### Обмен краткосрочного токена на долгосрочный
```javascript
async function exchangeToken(shortLivedToken, appId, appSecret) {
const response = await fetch(
`https://graph.facebook.com/v18.0/oauth/access_token?grant_type=fb_exchange_token&client_id=${appId}&client_secret=${appSecret}&fb_exchange_token=${shortLivedToken}`
);
const data = await response.json();
return {
accessToken: data.access_token,
expiresIn: data.expires_in, // в секундах
expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
};
}
```
---
## Рекомендации
1. **Для разработки:** Используйте Long-Lived User Access Token
2. **Для продакшена:** Используйте System User Access Token
3. **Храните credentials в зашифрованном виде** (ваша система уже это делает)
4. **Реализуйте автоматическое обновление токенов**
5. **Логируйте все операции с рекламой** для отладки
6. **Обрабатывайте ошибки API** (rate limits, invalid tokens, etc.)
---
## Полезные ссылки
- [Facebook Marketing API Documentation](https://developers.facebook.com/docs/marketing-apis)
- [Facebook Graph API Explorer](https://developers.facebook.com/tools/explorer/)
- [Facebook Business Settings](https://business.facebook.com/settings)
- [Facebook Ads Manager](https://business.facebook.com/adsmanager)
- [Access Token Debugger](https://developers.facebook.com/tools/debug/accesstoken/)
---
## Поддержка
При возникновении проблем с получением или использованием учетных данных:
1. Проверьте документацию Facebook Marketing API
2. Используйте [Access Token Debugger](https://developers.facebook.com/tools/debug/accesstoken/) для проверки токена
3. Убедитесь, что все разрешения запрошены и одобрены
4. Проверьте, что рекламный аккаунт активен и имеет необходимые права
+11
View File
@@ -586,6 +586,17 @@ try {
- Для долгосрочного использования рекомендуется использовать Long-Lived Token
- Токен должен иметь разрешения `pages_manage_posts` для публикации
**Для запуска рекламных кампаний в Facebook требуется дополнительная настройка:**
📖 **Подробная инструкция:** См. [FACEBOOK_ADS_CREDENTIALS.md](./FACEBOOK_ADS_CREDENTIALS.md)
Для рекламы нужны:
- Access Token с разрешениями `ads_management`, `ads_read`, `business_management`
- Ad Account ID (формат: `act_XXXXXXXXX`)
- App ID и App Secret
- Page ID (опционально)
---
## Примеры React компонентов
+700
View File
@@ -0,0 +1,700 @@
# API Документация: Обновленные поля для генерации бизнес-анализа (Frontend/AI Agent)
## Обзор изменений
API для генерации маркетингового анализа был обновлен с новыми полями, которые более точно отражают требования бизнес-анализа. Все старые поля были заменены новыми для улучшения качества анализа.
---
## Изменения в структуре запроса
### Удаленные поля (больше не используются)
Следующие поля были **удалены** из API и больше не принимаются:
-`location` - заменено на `region`
-`client` - заменено на `targetAudience`
-`differentiator` - заменено на комбинацию `businessNiche`, `strongSide`, `weakSide`
### Новые обязательные поля
| Поле | Тип | Обязательный | Описание | Пример значения |
| ---------------- | ------ | ------------ | -------------------------------------- | ------------------------------------------------------ |
| `businessNiche` | string | ✅ | Ниша бизнеса | "E-commerce платформы" |
| `product` | string | ✅ | Продукт или услуга | "Разработка мобильных приложений" |
| `targetAudience` | string | ✅ | Целевая аудитория (детальное описание) | "Малый и средний бизнес, владельцы интернет-магазинов" |
| `region` | string | ✅ | Регион (город Казахстана) | "Алматы" |
| `goal` | string | ✅ | Цель на 6-12 месяцев | "Увеличить количество клиентов на 50%" |
| `detailLevel` | string | ✅ | Уровень детализации анализа | "СТАНДАРТНО" |
| `analysisType` | string | ✅ | Тип анализа | "РЫНОК" |
### Новые опциональные поля
| Поле | Тип | Обязательный | Описание | Пример значения |
| ------------ | ------ | ------------ | ----------------------- | ------------------------------------------------- |
| `strongSide` | string | ❌ | Сильная сторона бизнеса | "Опытная команда разработчиков, быстрая доставка" |
| `weakSide` | string | ❌ | Слабая сторона бизнеса | "Ограниченный маркетинговый бюджет" |
---
## Детальное описание полей
### 1. `businessNiche` (обязательное)
**Описание**: Ниша бизнеса, в которой работает компания.
**Валидация**:
- Минимальная длина: 3 символа
- Максимальная длина: 200 символов
- Разрешены: буквы, цифры, пробелы, дефисы, запятые
- Паттерн: `^[\p{L}\p{N}\s\-,]+$`
**Примеры**:
```json
"businessNiche": "E-commerce платформы"
"businessNiche": "Образовательные технологии"
"businessNiche": "Финансовые услуги для малого бизнеса"
```
---
### 2. `product` (обязательное)
**Описание**: Конкретный продукт или услуга, которую предоставляет компания.
**Валидация**:
- Минимальная длина: 3 символа
- Максимальная длина: 200 символов
- Разрешены: буквы, цифры, пробелы, дефисы, запятые
- Паттерн: `^[\p{L}\p{N}\s\-,]+$`
**Примеры**:
```json
"product": "Разработка мобильных приложений"
"product": "Консультации по маркетингу"
"product": "Веб-разработка и дизайн"
```
---
### 3. `targetAudience` (обязательное)
**Описание**: Детальное описание целевой аудитории. Это поле заменяет старое поле `client` и должно содержать более подробную информацию.
**Валидация**:
- Минимальная длина: 3 символа
- Максимальная длина: 300 символов
- Разрешены любые символы
**Примеры**:
```json
"targetAudience": "Малый и средний бизнес, владельцы интернет-магазинов в возрасте 30-50 лет"
"targetAudience": "Стартапы и технологические компании, нуждающиеся в быстрой разработке MVP"
"targetAudience": "Частные лица, желающие создать личный бренд в социальных сетях"
```
**Примечание**: В отличие от старого поля `client`, которое принимало только фиксированные значения, `targetAudience` принимает свободный текст для более гибкого описания.
---
### 4. `region` (обязательное)
**Описание**: Регион (город Казахстана), в котором работает бизнес. Это поле заменяет старое поле `location`.
**Валидация**:
- Должно быть одним из допустимых городов Казахстана
- Проверка выполняется через валидатор `@ValidRegion`
**Допустимые значения** (точное совпадение):
- `"Алматы"`
- `"Астана"`
- `"Шымкент"`
- `"Караганда"`
- `"Актобе"`
- `"Тараз"`
- `"Павлодар"`
- `"Усть-Каменогорск"`
- `"Семей"`
- `"Костанай"`
- `"Кызылорда"`
- `"Уральск"`
- `"Петропавловск"`
- `"Атырау"`
- `"Актау"`
- `"Туркестан"`
- `"Кокшетау"`
- `"Талдыкорган"`
- `"Экибастуз"`
- `"Рудный"`
**Примеры**:
```json
"region": "Алматы"
"region": "Астана"
"region": "Шымкент"
```
**Важно**: Значение должно точно совпадать с одним из допустимых городов (регистр важен).
---
### 5. `goal` (обязательное)
**Описание**: Цель бизнеса на период 6-12 месяцев. Это новое поле, которое помогает AI лучше понять приоритеты бизнеса.
**Валидация**:
- Минимальная длина: 10 символов
- Максимальная длина: 500 символов
- Разрешены любые символы
**Примеры**:
```json
"goal": "Увеличить количество клиентов на 50% за следующие 6 месяцев"
"goal": "Выйти на рынок соседних регионов и открыть 3 новых филиала"
"goal": "Повысить узнаваемость бренда и увеличить продажи через онлайн-каналы на 30%"
```
---
### 6. `detailLevel` (обязательное)
**Описание**: Уровень детализации анализа. Влияет на объем и глубину генерируемого анализа.
**Валидация**:
- Должно быть одним из допустимых значений
- Проверка выполняется через валидатор `@ValidDetailLevel`
**Допустимые значения** (точное совпадение, регистр важен):
- `"КРАТКО"` - Краткий анализ (1-2 абзаца)
- `"СТАНДАРТНО"` - Стандартный анализ (3-5 абзацев) - **рекомендуется по умолчанию**
- `"ПОДРОБНО"` - Подробный анализ (5-8 абзацев)
**Влияние на анализ**:
| Уровень | Длина ответов | Количество рекомендаций | Детализация стратегии |
| ------------ | ------------- | ----------------------- | ------------------------ |
| `КРАТКО` | 1-2 абзаца | 3-4 рекомендации | Краткая |
| `СТАНДАРТНО` | 3-5 абзацев | 4-6 рекомендаций | Стандартная |
| `ПОДРОБНО` | 5-8 абзацев | 6-8 рекомендаций | Детальная с обоснованием |
**Примеры**:
```json
"detailLevel": "КРАТКО"
"detailLevel": "СТАНДАРТНО"
"detailLevel": "ПОДРОБНО"
```
---
### 7. `strongSide` (опциональное)
**Описание**: Сильная сторона бизнеса. Помогает AI лучше понять конкурентные преимущества.
**Валидация**:
- Максимальная длина: 500 символов
- Разрешены любые символы
- Может быть пустым или отсутствовать
**Примеры**:
```json
"strongSide": "Опытная команда разработчиков с 10+ летним опытом, быстрая доставка проектов"
"strongSide": "Уникальная технология, низкие цены, отличная поддержка клиентов"
```
**Примечание**: Если поле не указано, AI будет анализировать сильные стороны на основе других данных.
---
### 8. `weakSide` (опциональное)
**Описание**: Слабая сторона бизнеса. Помогает AI лучше понять области для улучшения.
**Валидация**:
- Максимальная длина: 500 символов
- Разрешены любые символы
- Может быть пустым или отсутствовать
**Примеры**:
```json
"weakSide": "Ограниченный маркетинговый бюджет, недостаточная узнаваемость бренда"
"weakSide": "Небольшая команда, ограниченные ресурсы для масштабирования"
```
**Примечание**: Если поле не указано, AI будет анализировать слабые стороны на основе других данных.
---
### 9. `analysisType` (обязательное)
**Описание**: Тип анализа, который необходимо провести. Поле осталось без изменений.
**Допустимые значения**:
- `"РЫНОК"` - Анализ рынка
- `"КОНКУРЕНТЫ"` - Анализ конкурентов
- `"ЦА"` - Анализ целевой аудитории
- `"КАНАЛЫ"` - Анализ маркетинговых каналов
- `"SWOT"` - SWOT-анализ
---
## Полный пример запроса
### POST `/api/marketing/analysis/start`
```json
{
"businessNiche": "E-commerce платформы",
"product": "Разработка мобильных приложений для интернет-магазинов",
"targetAudience": "Малый и средний бизнес, владельцы интернет-магазинов в возрасте 30-50 лет, нуждающиеся в мобильных решениях",
"region": "Алматы",
"goal": "Увеличить количество клиентов на 50% за следующие 6 месяцев и выйти на рынок соседних регионов",
"detailLevel": "СТАНДАРТНО",
"strongSide": "Опытная команда разработчиков с 10+ летним опытом, быстрая доставка проектов, использование современных технологий",
"weakSide": "Ограниченный маркетинговый бюджет, недостаточная узнаваемость бренда в новых регионах",
"analysisType": "РЫНОК"
}
```
---
## Примеры использования на фронтенде
### JavaScript/TypeScript
```typescript
interface MarketingAnalysisRequest {
businessNiche: string;
product: string;
targetAudience: string;
region: string;
goal: string;
detailLevel: 'КРАТКО' | 'СТАНДАРТНО' | 'ПОДРОБНО';
strongSide?: string;
weakSide?: string;
analysisType: 'РЫНОК' | 'КОНКУРЕНТЫ' | 'ЦА' | 'КАНАЛЫ' | 'SWOT';
}
// Список допустимых регионов
const VALID_REGIONS = [
'Алматы',
'Астана',
'Шымкент',
'Караганда',
'Актобе',
'Тараз',
'Павлодар',
'Усть-Каменогорск',
'Семей',
'Костанай',
'Кызылорда',
'Уральск',
'Петропавловск',
'Атырау',
'Актау',
'Туркестан',
'Кокшетау',
'Талдыкорган',
'Экибастуз',
'Рудный',
];
// Список уровней детализации
const DETAIL_LEVELS = ['КРАТКО', 'СТАНДАРТНО', 'ПОДРОБНО'] as const;
// Функция для отправки запроса
async function startMarketingAnalysis(
data: MarketingAnalysisRequest
): Promise<string> {
const response = await fetch(
'https://api.konturai.kz/api/marketing/analysis/start',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_JWT_TOKEN', // Если требуется
},
body: JSON.stringify(data),
}
);
const result = await response.json();
if (!result.success) {
throw new Error(result.error?.message || 'Failed to start analysis');
}
return result.data.analysisId;
}
// Пример использования
const analysisId = await startMarketingAnalysis({
businessNiche: 'E-commerce платформы',
product: 'Разработка мобильных приложений',
targetAudience: 'Малый и средний бизнес, владельцы интернет-магазинов',
region: 'Алматы',
goal: 'Увеличить количество клиентов на 50% за следующие 6 месяцев',
detailLevel: 'СТАНДАРТНО',
strongSide: 'Опытная команда, быстрая доставка',
weakSide: 'Ограниченный маркетинговый бюджет',
analysisType: 'РЫНОК',
});
```
### React компонент с формой
```tsx
import React, { useState } from 'react';
const MarketingAnalysisForm: React.FC = () => {
const [formData, setFormData] = useState<MarketingAnalysisRequest>({
businessNiche: '',
product: '',
targetAudience: '',
region: '',
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: 'РЫНОК',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const analysisId = await startMarketingAnalysis(formData);
console.log('Analysis started:', analysisId);
// Перенаправление на страницу с результатами
} catch (error) {
console.error('Error:', error);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Ниша бизнеса *</label>
<input
type='text'
value={formData.businessNiche}
onChange={(e) =>
setFormData({ ...formData, businessNiche: e.target.value })
}
required
minLength={3}
maxLength={200}
/>
</div>
<div>
<label>Продукт / услуга *</label>
<input
type='text'
value={formData.product}
onChange={(e) =>
setFormData({ ...formData, product: e.target.value })
}
required
minLength={3}
maxLength={200}
/>
</div>
<div>
<label>Целевая аудитория *</label>
<textarea
value={formData.targetAudience}
onChange={(e) =>
setFormData({ ...formData, targetAudience: e.target.value })
}
required
minLength={3}
maxLength={300}
/>
</div>
<div>
<label>Регион *</label>
<select
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
required
>
<option value=''>Выберите регион</option>
{VALID_REGIONS.map((region) => (
<option key={region} value={region}>
{region}
</option>
))}
</select>
</div>
<div>
<label>Цель на 6-12 месяцев *</label>
<textarea
value={formData.goal}
onChange={(e) => setFormData({ ...formData, goal: e.target.value })}
required
minLength={10}
maxLength={500}
/>
</div>
<div>
<label>Уровень детализации анализа *</label>
<div>
{DETAIL_LEVELS.map((level) => (
<label key={level}>
<input
type='radio'
name='detailLevel'
value={level}
checked={formData.detailLevel === level}
onChange={(e) =>
setFormData({
...formData,
detailLevel: e.target.value as any,
})
}
/>
{level === 'КРАТКО' && ' Кратко'}
{level === 'СТАНДАРТНО' && ' Стандартно'}
{level === 'ПОДРОБНО' && ' Подробно'}
</label>
))}
</div>
</div>
<div>
<label>Сильная сторона</label>
<textarea
value={formData.strongSide}
onChange={(e) =>
setFormData({ ...formData, strongSide: e.target.value })
}
maxLength={500}
/>
</div>
<div>
<label>Слабая сторона</label>
<textarea
value={formData.weakSide}
onChange={(e) =>
setFormData({ ...formData, weakSide: e.target.value })
}
maxLength={500}
/>
</div>
<div>
<label>Тип анализа *</label>
<select
value={formData.analysisType}
onChange={(e) =>
setFormData({ ...formData, analysisType: e.target.value as any })
}
required
>
<option value='РЫНОК'>Рынок</option>
<option value='КОНКУРЕНТЫ'>Конкуренты</option>
<option value='ЦА'>Целевая аудитория</option>
<option value='КАНАЛЫ'>Каналы</option>
<option value='SWOT'>SWOT</option>
</select>
</div>
<button type='submit'>Сгенерировать анализ бизнеса</button>
</form>
);
};
```
---
## Валидация на клиенте
Рекомендуется выполнять валидацию на клиенте перед отправкой запроса:
```typescript
function validateMarketingAnalysisRequest(data: MarketingAnalysisRequest): {
valid: boolean;
errors: Record<string, string>;
} {
const errors: Record<string, string> = {};
// Валидация businessNiche
if (!data.businessNiche || data.businessNiche.trim().length < 3) {
errors.businessNiche = 'Ниша бизнеса должна содержать минимум 3 символа';
}
if (data.businessNiche && data.businessNiche.length > 200) {
errors.businessNiche = 'Ниша бизнеса не должна превышать 200 символов';
}
// Валидация product
if (!data.product || data.product.trim().length < 3) {
errors.product = 'Продукт должен содержать минимум 3 символа';
}
if (data.product && data.product.length > 200) {
errors.product = 'Продукт не должен превышать 200 символов';
}
// Валидация targetAudience
if (!data.targetAudience || data.targetAudience.trim().length < 3) {
errors.targetAudience =
'Целевая аудитория должна содержать минимум 3 символа';
}
if (data.targetAudience && data.targetAudience.length > 300) {
errors.targetAudience =
'Целевая аудитория не должна превышать 300 символов';
}
// Валидация region
if (!data.region || !VALID_REGIONS.includes(data.region)) {
errors.region = 'Выберите допустимый регион из списка';
}
// Валидация goal
if (!data.goal || data.goal.trim().length < 10) {
errors.goal = 'Цель должна содержать минимум 10 символов';
}
if (data.goal && data.goal.length > 500) {
errors.goal = 'Цель не должна превышать 500 символов';
}
// Валидация detailLevel
if (!data.detailLevel || !DETAIL_LEVELS.includes(data.detailLevel)) {
errors.detailLevel = 'Выберите допустимый уровень детализации';
}
// Валидация strongSide (опциональное)
if (data.strongSide && data.strongSide.length > 500) {
errors.strongSide = 'Сильная сторона не должна превышать 500 символов';
}
// Валидация weakSide (опциональное)
if (data.weakSide && data.weakSide.length > 500) {
errors.weakSide = 'Слабая сторона не должна превышать 500 символов';
}
// Валидация analysisType
const validAnalysisTypes = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
if (!data.analysisType || !validAnalysisTypes.includes(data.analysisType)) {
errors.analysisType = 'Выберите допустимый тип анализа';
}
return {
valid: Object.keys(errors).length === 0,
errors,
};
}
```
---
## Миграция со старого API
Если у вас есть код, использующий старые поля, необходимо обновить его следующим образом:
### Старый формат (больше не работает):
```json
{
"product": "Разработка мобильных приложений",
"location": "Алматы, Казахстан",
"client": "B2B клиенты",
"differentiator": "Быстрая разработка за 2 недели",
"analysisType": "РЫНОК"
}
```
### Новый формат:
```json
{
"businessNiche": "Разработка программного обеспечения",
"product": "Разработка мобильных приложений",
"targetAudience": "B2B клиенты, технологические компании, стартапы",
"region": "Алматы",
"goal": "Увеличить количество клиентов на 50% за следующие 6 месяцев",
"detailLevel": "СТАНДАРТНО",
"strongSide": "Быстрая разработка за 2 недели, опытная команда",
"weakSide": "",
"analysisType": "РЫНОК"
}
```
### Маппинг старых полей на новые:
| Старое поле | Новое поле(я) | Примечание |
| ---------------- | ----------------------------------------- | ------------------------------------------------ |
| `location` | `region` | Только название города из списка |
| `client` | `targetAudience` | Более детальное описание (свободный текст) |
| `differentiator` | `businessNiche`, `strongSide`, `weakSide` | Разделено на несколько полей для лучшего анализа |
---
## Обработка ошибок валидации
При ошибках валидации API возвращает следующий формат:
```json
{
"success": false,
"message": "Ошибка валидации",
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации входных данных",
"details": {
"businessNiche": "Поле 'businessNiche' должно содержать от 3 до 200 символов",
"region": "Поле 'region' должно быть одним из допустимых городов Казахстана",
"detailLevel": "Поле 'detailLevel' должно быть одним из: КРАТКО, СТАНДАРТНО, ПОДРОБНО"
}
}
}
```
---
## Важные замечания
1. **Регистр важен**: Значения `region`, `detailLevel` и `analysisType` чувствительны к регистру. Используйте точные значения из списка допустимых.
2. **Все новые поля передаются в OpenAI**: Все указанные поля включаются в контекст для генерации анализа, что улучшает качество и релевантность результатов.
3. **Уровень детализации влияет на результат**: Выбор `detailLevel` напрямую влияет на объем и глубину генерируемого анализа.
4. **Опциональные поля улучшают анализ**: Хотя `strongSide` и `weakSide` опциональны, их указание помогает AI лучше понять бизнес и дать более точные рекомендации.
5. **Обратная совместимость**: Старые поля больше не поддерживаются. Необходимо обновить все клиентские приложения.
---
## Поддержка
При возникновении проблем с API обращайтесь в техническую поддержку с указанием:
- `analysisId` (если есть)
- Время запроса
- Описание проблемы
- Код ошибки (если есть)
- Пример запроса (без чувствительных данных)
@@ -29,512 +29,517 @@ import java.util.stream.Collectors;
@RequestMapping("/api/marketing/analysis")
public class MarketingController {
private final MarketingAnalysisService marketingAnalysisService;
private final MarketingStrategyService marketingStrategyService;
private final MinIOService minIOService;
private final JwtService jwtService;
private final PostingTaskService postingTaskService;
private final MarketingAnalysisService marketingAnalysisService;
private final MarketingStrategyService marketingStrategyService;
private final MinIOService minIOService;
private final JwtService jwtService;
private final PostingTaskService postingTaskService;
public MarketingController(
MarketingAnalysisService marketingAnalysisService,
MarketingStrategyService marketingStrategyService,
MinIOService minIOService,
JwtService jwtService,
PostingTaskService postingTaskService) {
this.marketingAnalysisService = marketingAnalysisService;
this.marketingStrategyService = marketingStrategyService;
this.minIOService = minIOService;
this.jwtService = jwtService;
this.postingTaskService = postingTaskService;
}
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(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@Valid @RequestBody MarketingAnalysisRequest request) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
public MarketingController(
MarketingAnalysisService marketingAnalysisService,
MarketingStrategyService marketingStrategyService,
MinIOService minIOService,
JwtService jwtService,
PostingTaskService postingTaskService) {
this.marketingAnalysisService = marketingAnalysisService;
this.marketingStrategyService = marketingStrategyService;
this.minIOService = minIOService;
this.jwtService = jwtService;
this.postingTaskService = postingTaskService;
}
// Create analysis record
MarketingAnalysis analysis = marketingAnalysisService.startAnalysis(request, userId);
// Start async processing
marketingAnalysisService.processAnalysis(analysis.getId(), request);
// Calculate estimated completion time (5-10 minutes)
LocalDateTime estimatedCompletion = LocalDateTime.now().plusMinutes(8);
MarketingAnalysisResponse response = new MarketingAnalysisResponse(
analysis.getId(),
"processing",
estimatedCompletion,
"Анализ запущен успешно. Результаты будут готовы в течение 5-10 минут.");
return ResponseEntity.ok(ApiResponse.success("Анализ запущен успешно", response));
}
@GetMapping("/{analysisId}")
public ResponseEntity<?> getAnalysis(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
private String extractUserIdFromHeader(String authHeader) {
if (authHeader == null || authHeader.isEmpty()) {
return null;
}
return jwtService.extractUserIdFromHeader(authHeader);
}
Optional<MarketingAnalysis> optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
if (optAnalysis.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Анализ с указанным ID не найден");
return ResponseEntity.status(404)
.body(ApiResponse.error("Анализ не найден", error));
private ResponseEntity<?> unauthorizedResponse() {
ErrorResponse error = new ErrorResponse(
"UNAUTHORIZED",
"Требуется аутентификация. Пожалуйста, предоставьте валидный JWT токен.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.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));
@PostMapping("/start")
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, userId);
// Start async processing
marketingAnalysisService.processAnalysis(analysis.getId(), request);
// Calculate estimated completion time (5-10 minutes)
LocalDateTime estimatedCompletion = LocalDateTime.now().plusMinutes(8);
MarketingAnalysisResponse response = new MarketingAnalysisResponse(
analysis.getId(),
"processing",
estimatedCompletion,
"Анализ запущен успешно. Результаты будут готовы в течение 5-10 минут.");
return ResponseEntity.ok(ApiResponse.success("Анализ запущен успешно", response));
}
MarketingAnalysisResult result = marketingAnalysisService.getAnalysisResult(analysisId);
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/{analysisId}")
public ResponseEntity<?> getAnalysis(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId) {
@GetMapping("/{analysisId}/download")
public ResponseEntity<?> downloadPdf(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
Optional<MarketingAnalysis> 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));
}
MarketingAnalysisResult result = marketingAnalysisService.getAnalysisResult(analysisId);
return ResponseEntity.ok(ApiResponse.success(result));
}
Optional<MarketingAnalysis> optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
if (optAnalysis.isEmpty()) {
return ResponseEntity.notFound().build();
@GetMapping("/{analysisId}/download")
public ResponseEntity<?> downloadPdf(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
Optional<MarketingAnalysis> optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
if (optAnalysis.isEmpty()) {
return ResponseEntity.notFound().build();
}
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(analysis.getPdfFilePath());
byte[] bytes = inputStream.readAllBytes();
inputStream.close();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + analysis.getPdfFilename() + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(bytes);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
MarketingAnalysis analysis = optAnalysis.get();
if (!userId.equals(analysis.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этому анализу");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
@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<MarketingAnalysis> 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,
userId);
MarketingStrategyResponse response = new MarketingStrategyResponse();
response.setStrategyId(strategy.getId());
response.setAnalysisId(strategy.getAnalysisId());
response.setStatus(strategy.getStatus());
response.setCreatedAt(strategy.getCreatedAt());
response.setDurationWeeks(strategy.getDurationWeeks());
response.setPriorityPlatforms(strategy.getPriorityPlatforms());
return ResponseEntity.ok(ApiResponse.success(
"Генерация стратегии запущена успешно. Результаты будут готовы в течение 3-5 минут.",
response));
} catch (IllegalArgumentException e) {
ErrorResponse error = new ErrorResponse("INVALID_ANALYSIS", e.getMessage());
return ResponseEntity.status(404)
.body(ApiResponse.error("Анализ не найден", error));
} catch (IllegalStateException e) {
ErrorResponse error = new ErrorResponse("ANALYSIS_NOT_COMPLETED", e.getMessage());
return ResponseEntity.status(400)
.body(ApiResponse.error("Анализ еще не завершен", error));
} catch (Exception e) {
ErrorResponse error = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"Произошла ошибка при запуске генерации стратегии");
return ResponseEntity.status(500)
.body(ApiResponse.error("Внутренняя ошибка сервера", error));
}
}
if (analysis.getPdfFilePath() == null) {
return ResponseEntity.notFound().build();
@GetMapping("/strategy/{strategyId}")
public ResponseEntity<?> getStrategy(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String strategyId) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
Optional<MarketingStrategy> optStrategy = marketingStrategyService.getStrategyById(strategyId);
if (optStrategy.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия с указанным ID не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
}
MarketingStrategy strategy = optStrategy.get();
if (!userId.equals(strategy.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этой стратегии");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
}
MarketingStrategyResponse result = marketingStrategyService.getStrategyResult(strategyId);
return ResponseEntity.ok(ApiResponse.success(result));
}
try {
InputStream inputStream = minIOService.downloadFile(analysis.getPdfFilePath());
byte[] bytes = inputStream.readAllBytes();
inputStream.close();
@GetMapping("/{analysisId}/strategy")
public ResponseEntity<?> getStrategyByAnalysis(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId) {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + analysis.getPdfFilename() + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(bytes);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
@PostMapping("/strategy/generate")
public ResponseEntity<?> generateStrategy(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestParam String analysisId,
@Valid @RequestBody(required = false) MarketingStrategyRequest request) {
// Check if user owns the analysis
Optional<MarketingAnalysis> optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
if (optAnalysis.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Анализ с указанным ID не найден");
return ResponseEntity.status(404)
.body(ApiResponse.error("Анализ не найден", error));
}
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
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) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия для указанного анализа не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
// Check if user owns the analysis
Optional<MarketingAnalysis> optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
if (optAnalysis.isEmpty()) {
ErrorResponse error = new ErrorResponse("INVALID_ANALYSIS", "Анализ не найден");
return ResponseEntity.status(404)
.body(ApiResponse.error("Анализ не найден", error));
@GetMapping("/my")
public ResponseEntity<?> getMyAnalyses(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
List<MarketingAnalysis> analyses = marketingAnalysisService.getUserAnalyses(userId);
List<AnalysisHistoryResponse> responseList = analyses.stream()
.map(this::convertToHistoryResponse)
.collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.success(responseList));
}
MarketingAnalysis analysis = optAnalysis.get();
if (!userId.equals(analysis.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этому анализу");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
@GetMapping("/strategy/my")
public ResponseEntity<?> getMyStrategies(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
List<MarketingStrategy> strategies = marketingStrategyService.getUserStrategies(userId);
List<StrategyHistoryResponse> responseList = strategies.stream()
.map(this::convertToStrategyHistoryResponse)
.collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.success(responseList));
}
if (request == null) {
request = new MarketingStrategyRequest();
@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<MarketingAnalysis> 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));
}
try {
MarketingStrategy strategy = marketingStrategyService.generateStrategy(analysisId, request, userId);
@GetMapping("/strategy/{strategyId}/history")
public ResponseEntity<?> getStrategyHistory(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String strategyId) {
MarketingStrategyResponse response = new MarketingStrategyResponse();
response.setStrategyId(strategy.getId());
response.setAnalysisId(strategy.getAnalysisId());
response.setStatus(strategy.getStatus());
response.setCreatedAt(strategy.getCreatedAt());
response.setDurationWeeks(strategy.getDurationWeeks());
response.setPriorityPlatforms(strategy.getPriorityPlatforms());
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
return ResponseEntity.ok(ApiResponse.success(
"Генерация стратегии запущена успешно. Результаты будут готовы в течение 3-5 минут.",
response));
} catch (IllegalArgumentException e) {
ErrorResponse error = new ErrorResponse("INVALID_ANALYSIS", e.getMessage());
return ResponseEntity.status(404)
.body(ApiResponse.error("Анализ не найден", error));
} catch (IllegalStateException e) {
ErrorResponse error = new ErrorResponse("ANALYSIS_NOT_COMPLETED", e.getMessage());
return ResponseEntity.status(400)
.body(ApiResponse.error("Анализ еще не завершен", error));
} catch (Exception e) {
ErrorResponse error = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"Произошла ошибка при запуске генерации стратегии");
return ResponseEntity.status(500)
.body(ApiResponse.error("Внутренняя ошибка сервера", error));
}
}
Optional<MarketingStrategy> optStrategy = marketingStrategyService.getStrategyById(strategyId);
if (optStrategy.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия с указанным ID не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
}
@GetMapping("/strategy/{strategyId}")
public ResponseEntity<?> getStrategy(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String strategyId) {
MarketingStrategy strategy = optStrategy.get();
if (!userId.equals(strategy.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этой стратегии");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
}
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
StrategyHistoryResponse response = convertToStrategyHistoryResponse(strategy);
return ResponseEntity.ok(ApiResponse.success(response));
}
Optional<MarketingStrategy> optStrategy = marketingStrategyService.getStrategyById(strategyId);
if (optStrategy.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия с указанным ID не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
private AnalysisHistoryResponse convertToHistoryResponse(MarketingAnalysis analysis) {
AnalysisHistoryResponse response = new AnalysisHistoryResponse();
response.setAnalysisId(analysis.getId());
response.setBusinessNiche(analysis.getBusinessNiche());
response.setProduct(analysis.getProduct());
response.setTargetAudience(analysis.getTargetAudience());
response.setRegion(analysis.getRegion());
response.setGoal(analysis.getGoal());
response.setDetailLevel(analysis.getDetailLevel());
response.setStrongSide(analysis.getStrongSide());
response.setWeakSide(analysis.getWeakSide());
response.setAnalysisType(analysis.getAnalysisType());
response.setStatus(analysis.getStatus());
response.setUserId(analysis.getUserId());
response.setCreatedAt(analysis.getCreatedAt());
response.setCompletedAt(analysis.getCompletedAt());
response.setStatusHistory(analysis.getStatusHistory());
return response;
}
MarketingStrategy strategy = optStrategy.get();
if (!userId.equals(strategy.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этой стратегии");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
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;
}
MarketingStrategyResponse result = marketingStrategyService.getStrategyResult(strategyId);
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/strategy/{strategyId}/start")
public ResponseEntity<?> startStrategy(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String strategyId,
@RequestBody(required = false) StartStrategyRequest request) {
@GetMapping("/{analysisId}/strategy")
public ResponseEntity<?> getStrategyByAnalysis(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
// Проверяем существование стратегии и права доступа
Optional<MarketingStrategy> optStrategy = marketingStrategyService.getStrategyById(strategyId);
if (optStrategy.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия с указанным ID не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
}
MarketingStrategy strategy = optStrategy.get();
if (!userId.equals(strategy.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этой стратегии");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
}
if (!"completed".equals(strategy.getStatus())) {
ErrorResponse error = new ErrorResponse(
"INVALID_STATUS",
"Стратегия еще не завершена. Статус: " + strategy.getStatus());
return ResponseEntity.status(400)
.body(ApiResponse.error("Стратегия не готова к запуску", error));
}
try {
// Создаем задачи из стратегии
List<PostingTask> tasks = postingTaskService.createTasksFromStrategy(strategyId);
// Получаем список платформ
List<String> platforms = tasks.stream()
.map(PostingTask::getPlatform)
.distinct()
.collect(Collectors.toList());
StartStrategyResponse response = new StartStrategyResponse(
strategyId,
tasks.size(),
platforms,
"Стратегия успешно запущена. Создано задач: " + tasks.size());
return ResponseEntity.ok(ApiResponse.success(
"Стратегия успешно запущена",
response));
} catch (IllegalStateException e) {
ErrorResponse error = new ErrorResponse(
"MISSING_CREDENTIALS",
e.getMessage());
return ResponseEntity.status(400)
.body(ApiResponse.error("Не удалось запустить стратегию", error));
} catch (IllegalArgumentException e) {
ErrorResponse error = new ErrorResponse(
"INVALID_STRATEGY",
e.getMessage());
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
} catch (Exception e) {
ErrorResponse error = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"Произошла ошибка при запуске стратегии");
return ResponseEntity.status(500)
.body(ApiResponse.error("Внутренняя ошибка сервера", error));
}
}
// Check if user owns the analysis
Optional<MarketingAnalysis> optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
if (optAnalysis.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Анализ с указанным ID не найден");
return ResponseEntity.status(404)
.body(ApiResponse.error("Анализ не найден", error));
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleValidationException(
MethodArgumentNotValidException ex) {
Map<String, String> details = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error -> {
details.put(error.getField(), error.getDefaultMessage());
});
ErrorResponse errorResponse = new ErrorResponse(
"VALIDATION_ERROR",
"Ошибка валидации входных данных",
details);
return ResponseEntity.badRequest()
.body(ApiResponse.error("Ошибка валидации", errorResponse));
}
MarketingAnalysis analysis = optAnalysis.get();
if (!userId.equals(analysis.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этому анализу");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleGenericException(Exception e) {
ErrorResponse errorResponse = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"Произошла внутренняя ошибка сервера. Попробуйте позже.");
return ResponseEntity.status(500)
.body(ApiResponse.error("Внутренняя ошибка сервера", errorResponse));
}
MarketingStrategyResponse result = marketingStrategyService.getStrategyByAnalysisId(analysisId);
if (result == null) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия для указанного анализа не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
}
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<MarketingAnalysis> analyses = marketingAnalysisService.getUserAnalyses(userId);
List<AnalysisHistoryResponse> 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<MarketingStrategy> strategies = marketingStrategyService.getUserStrategies(userId);
List<StrategyHistoryResponse> 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<MarketingAnalysis> 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<MarketingStrategy> optStrategy = marketingStrategyService.getStrategyById(strategyId);
if (optStrategy.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия с указанным ID не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
}
MarketingStrategy strategy = optStrategy.get();
if (!userId.equals(strategy.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этой стратегии");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
}
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.setAnalysisType(analysis.getAnalysisType());
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;
}
@PostMapping("/strategy/{strategyId}/start")
public ResponseEntity<?> startStrategy(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String strategyId,
@RequestBody(required = false) StartStrategyRequest request) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
// Проверяем существование стратегии и права доступа
Optional<MarketingStrategy> optStrategy = marketingStrategyService.getStrategyById(strategyId);
if (optStrategy.isEmpty()) {
ErrorResponse error = new ErrorResponse(
"NOT_FOUND",
"Стратегия с указанным ID не найдена");
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
}
MarketingStrategy strategy = optStrategy.get();
if (!userId.equals(strategy.getUserId())) {
ErrorResponse error = new ErrorResponse(
"FORBIDDEN",
"У вас нет доступа к этой стратегии");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Доступ запрещен", error));
}
if (!"completed".equals(strategy.getStatus())) {
ErrorResponse error = new ErrorResponse(
"INVALID_STATUS",
"Стратегия еще не завершена. Статус: " + strategy.getStatus());
return ResponseEntity.status(400)
.body(ApiResponse.error("Стратегия не готова к запуску", error));
}
try {
// Создаем задачи из стратегии
List<PostingTask> tasks = postingTaskService.createTasksFromStrategy(strategyId);
// Получаем список платформ
List<String> platforms = tasks.stream()
.map(PostingTask::getPlatform)
.distinct()
.collect(Collectors.toList());
StartStrategyResponse response = new StartStrategyResponse(
strategyId,
tasks.size(),
platforms,
"Стратегия успешно запущена. Создано задач: " + tasks.size());
return ResponseEntity.ok(ApiResponse.success(
"Стратегия успешно запущена",
response));
} catch (IllegalStateException e) {
ErrorResponse error = new ErrorResponse(
"MISSING_CREDENTIALS",
e.getMessage());
return ResponseEntity.status(400)
.body(ApiResponse.error("Не удалось запустить стратегию", error));
} catch (IllegalArgumentException e) {
ErrorResponse error = new ErrorResponse(
"INVALID_STRATEGY",
e.getMessage());
return ResponseEntity.status(404)
.body(ApiResponse.error("Стратегия не найдена", error));
} catch (Exception e) {
ErrorResponse error = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"Произошла ошибка при запуске стратегии");
return ResponseEntity.status(500)
.body(ApiResponse.error("Внутренняя ошибка сервера", error));
}
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleValidationException(
MethodArgumentNotValidException ex) {
Map<String, String> details = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error -> {
details.put(error.getField(), error.getDefaultMessage());
});
ErrorResponse errorResponse = new ErrorResponse(
"VALIDATION_ERROR",
"Ошибка валидации входных данных",
details);
return ResponseEntity.badRequest()
.body(ApiResponse.error("Ошибка валидации", errorResponse));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleGenericException(Exception e) {
ErrorResponse errorResponse = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"Произошла внутренняя ошибка сервера. Попробуйте позже.");
return ResponseEntity.status(500)
.body(ApiResponse.error("Внутренняя ошибка сервера", errorResponse));
}
}
@@ -5,10 +5,14 @@ import java.util.List;
public class AnalysisHistoryResponse {
private String analysisId;
private String businessNiche;
private String product;
private String location;
private String clientType;
private String differentiator;
private String targetAudience;
private String region;
private String goal;
private String detailLevel;
private String strongSide;
private String weakSide;
private String analysisType;
private String status;
private String userId;
@@ -27,6 +31,14 @@ public class AnalysisHistoryResponse {
this.analysisId = analysisId;
}
public String getBusinessNiche() {
return businessNiche;
}
public void setBusinessNiche(String businessNiche) {
this.businessNiche = businessNiche;
}
public String getProduct() {
return product;
}
@@ -35,28 +47,52 @@ public class AnalysisHistoryResponse {
this.product = product;
}
public String getLocation() {
return location;
public String getTargetAudience() {
return targetAudience;
}
public void setLocation(String location) {
this.location = location;
public void setTargetAudience(String targetAudience) {
this.targetAudience = targetAudience;
}
public String getClientType() {
return clientType;
public String getRegion() {
return region;
}
public void setClientType(String clientType) {
this.clientType = clientType;
public void setRegion(String region) {
this.region = region;
}
public String getDifferentiator() {
return differentiator;
public String getGoal() {
return goal;
}
public void setDifferentiator(String differentiator) {
this.differentiator = differentiator;
public void setGoal(String goal) {
this.goal = goal;
}
public String getDetailLevel() {
return detailLevel;
}
public void setDetailLevel(String detailLevel) {
this.detailLevel = detailLevel;
}
public String getStrongSide() {
return strongSide;
}
public void setStrongSide(String strongSide) {
this.strongSide = strongSide;
}
public String getWeakSide() {
return weakSide;
}
public void setWeakSide(String weakSide) {
this.weakSide = weakSide;
}
public String getStatus() {
@@ -4,28 +4,44 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import kz.konturai.parser.model.AnalysisType;
import kz.konturai.parser.model.DetailLevel;
import kz.konturai.parser.validator.ValidAnalysisType;
import kz.konturai.parser.validator.ValidClientType;
import kz.konturai.parser.validator.ValidDetailLevel;
import kz.konturai.parser.validator.ValidRegion;
public class MarketingAnalysisRequest {
@NotBlank(message = "Поле 'businessNiche' обязательно для заполнения")
@Size(min = 3, max = 200, message = "Поле 'businessNiche' должно содержать от 3 до 200 символов")
@Pattern(regexp = "^[\\p{L}\\p{N}\\s\\-,]+$", message = "Поле 'businessNiche' содержит недопустимые символы")
private String businessNiche;
@NotBlank(message = "Поле 'product' обязательно для заполнения")
@Size(min = 3, max = 200, message = "Поле 'product' должно содержать от 3 до 200 символов")
@Pattern(regexp = "^[\\p{L}\\p{N}\\s\\-,]+$", message = "Поле 'product' содержит недопустимые символы")
private String product;
@NotBlank(message = "Поле 'location' обязательно для заполнения")
@Size(min = 2, max = 150, message = "Поле 'location' должно содержать от 2 до 150 символов")
@Pattern(regexp = "^[\\p{L}\\p{N}\\s\\-,]+$", message = "Поле 'location' содержит недопустимые символы")
private String location;
@NotBlank(message = "Поле 'targetAudience' обязательно для заполнения")
@Size(min = 3, max = 300, message = "Поле 'targetAudience' должно содержать от 3 до 300 символов")
private String targetAudience;
@NotBlank(message = "Поле 'client' обязательно для заполнения")
@ValidClientType(message = "Поле 'client' должно быть одним из: B2B клиенты, B2C клиенты, Частные лица, Корпорации, Малый бизнес")
private String client;
@NotBlank(message = "Поле 'region' обязательно для заполнения")
@ValidRegion(message = "Поле 'region' должно быть одним из допустимых городов Казахстана")
private String region;
@NotBlank(message = "Поле 'differentiator' обязательно для заполнения")
@Size(min = 10, max = 500, message = "Поле 'differentiator' должно содержать от 10 до 500 символов")
private String differentiator;
@NotBlank(message = "Поле 'goal' обязательно для заполнения")
@Size(min = 10, max = 500, message = "Поле 'goal' должно содержать от 10 до 500 символов")
private String goal;
@NotBlank(message = "Поле 'detailLevel' обязательно для заполнения")
@ValidDetailLevel(message = "Поле 'detailLevel' должно быть одним из: КРАТКО, СТАНДАРТНО, ПОДРОБНО")
private String detailLevel;
@Size(max = 500, message = "Поле 'strongSide' должно содержать не более 500 символов")
private String strongSide;
@Size(max = 500, message = "Поле 'weakSide' должно содержать не более 500 символов")
private String weakSide;
@NotBlank(message = "Поле 'analysisType' обязательно для заполнения")
@ValidAnalysisType(message = "Поле 'analysisType' должно быть одним из: РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT")
@@ -34,15 +50,27 @@ public class MarketingAnalysisRequest {
public MarketingAnalysisRequest() {
}
public MarketingAnalysisRequest(String product, String location, String client, String differentiator,
String analysisType) {
public MarketingAnalysisRequest(String businessNiche, String product, String targetAudience, String region,
String goal, String detailLevel, String strongSide, String weakSide, String analysisType) {
this.businessNiche = businessNiche;
this.product = product;
this.location = location;
this.client = client;
this.differentiator = differentiator;
this.targetAudience = targetAudience;
this.region = region;
this.goal = goal;
this.detailLevel = detailLevel;
this.strongSide = strongSide;
this.weakSide = weakSide;
this.analysisType = analysisType;
}
public String getBusinessNiche() {
return businessNiche;
}
public void setBusinessNiche(String businessNiche) {
this.businessNiche = businessNiche;
}
public String getProduct() {
return product;
}
@@ -51,28 +79,56 @@ public class MarketingAnalysisRequest {
this.product = product;
}
public String getLocation() {
return location;
public String getTargetAudience() {
return targetAudience;
}
public void setLocation(String location) {
this.location = location;
public void setTargetAudience(String targetAudience) {
this.targetAudience = targetAudience;
}
public String getClient() {
return client;
public String getRegion() {
return region;
}
public void setClient(String client) {
this.client = client;
public void setRegion(String region) {
this.region = region;
}
public String getDifferentiator() {
return differentiator;
public String getGoal() {
return goal;
}
public void setDifferentiator(String differentiator) {
this.differentiator = differentiator;
public void setGoal(String goal) {
this.goal = goal;
}
public String getDetailLevel() {
return detailLevel;
}
public void setDetailLevel(String detailLevel) {
this.detailLevel = detailLevel;
}
public DetailLevel getDetailLevelEnum() {
return DetailLevel.fromString(detailLevel);
}
public String getStrongSide() {
return strongSide;
}
public void setStrongSide(String strongSide) {
this.strongSide = strongSide;
}
public String getWeakSide() {
return weakSide;
}
public void setWeakSide(String weakSide) {
this.weakSide = weakSide;
}
public String getAnalysisType() {
@@ -0,0 +1,36 @@
package kz.konturai.parser.model;
public enum DetailLevel {
КРАТКО("Кратко"),
СТАНДАРТНО("Стандартно"),
ПОДРОБНО("Подробно");
private final String displayName;
DetailLevel(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
public static DetailLevel fromString(String value) {
if (value == null) {
return null;
}
try {
return DetailLevel.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
// Try to match by display name
for (DetailLevel level : DetailLevel.values()) {
if (level.getDisplayName().equalsIgnoreCase(value) ||
level.name().equalsIgnoreCase(value)) {
return level;
}
}
return null;
}
}
}
@@ -16,17 +16,29 @@ public class MarketingAnalysis {
@Id
private String id;
@Field("business_niche")
private String businessNiche;
@Field("product")
private String product;
@Field("location")
private String location;
@Field("target_audience")
private String targetAudience;
@Field("client_type")
private String clientType;
@Field("region")
private String region;
@Field("differentiator")
private String differentiator;
@Field("goal")
private String goal;
@Field("detail_level")
private String detailLevel; // КРАТКО, СТАНДАРТНО, ПОДРОБНО
@Field("strong_side")
private String strongSide;
@Field("weak_side")
private String weakSide;
@Field("analysis_type")
private String analysisType; // РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT
@@ -61,21 +73,17 @@ public class MarketingAnalysis {
this.statusHistory = new ArrayList<>();
}
public MarketingAnalysis(String product, String location, String clientType, String differentiator) {
public MarketingAnalysis(String businessNiche, String product, String targetAudience, String region,
String goal, String detailLevel, String strongSide, String weakSide, String analysisType) {
this();
this.businessNiche = businessNiche;
this.product = product;
this.location = location;
this.clientType = clientType;
this.differentiator = differentiator;
}
public MarketingAnalysis(String product, String location, String clientType, String differentiator,
String analysisType) {
this();
this.product = product;
this.location = location;
this.clientType = clientType;
this.differentiator = differentiator;
this.targetAudience = targetAudience;
this.region = region;
this.goal = goal;
this.detailLevel = detailLevel;
this.strongSide = strongSide;
this.weakSide = weakSide;
this.analysisType = analysisType;
}
@@ -95,28 +103,60 @@ public class MarketingAnalysis {
this.product = product;
}
public String getLocation() {
return location;
public String getBusinessNiche() {
return businessNiche;
}
public void setLocation(String location) {
this.location = location;
public void setBusinessNiche(String businessNiche) {
this.businessNiche = businessNiche;
}
public String getClientType() {
return clientType;
public String getTargetAudience() {
return targetAudience;
}
public void setClientType(String clientType) {
this.clientType = clientType;
public void setTargetAudience(String targetAudience) {
this.targetAudience = targetAudience;
}
public String getDifferentiator() {
return differentiator;
public String getRegion() {
return region;
}
public void setDifferentiator(String differentiator) {
this.differentiator = differentiator;
public void setRegion(String region) {
this.region = region;
}
public String getGoal() {
return goal;
}
public void setGoal(String goal) {
this.goal = goal;
}
public String getDetailLevel() {
return detailLevel;
}
public void setDetailLevel(String detailLevel) {
this.detailLevel = detailLevel;
}
public String getStrongSide() {
return strongSide;
}
public void setStrongSide(String strongSide) {
this.strongSide = strongSide;
}
public String getWeakSide() {
return weakSide;
}
public void setWeakSide(String weakSide) {
this.weakSide = weakSide;
}
public String getStatus() {
@@ -4,6 +4,7 @@ import kz.konturai.parser.dto.MarketingAnalysisRequest;
import kz.konturai.parser.dto.MarketingAnalysisResult;
import kz.konturai.parser.dto.StatusHistoryEntry;
import kz.konturai.parser.model.AnalysisType;
import kz.konturai.parser.model.DetailLevel;
import kz.konturai.parser.model.MarketingAnalysis;
import kz.konturai.parser.repository.MarketingAnalysisRepository;
import org.slf4j.Logger;
@@ -38,10 +39,14 @@ public class MarketingAnalysisService {
public MarketingAnalysis startAnalysis(MarketingAnalysisRequest request, String userId) {
MarketingAnalysis analysis = new MarketingAnalysis(
request.getBusinessNiche(),
request.getProduct(),
request.getLocation(),
request.getClient(),
request.getDifferentiator(),
request.getTargetAudience(),
request.getRegion(),
request.getGoal(),
request.getDetailLevel(),
request.getStrongSide(),
request.getWeakSide(),
request.getAnalysisType());
analysis.setUserId(userId);
analysis.setStatus("queued");
@@ -122,32 +127,52 @@ public class MarketingAnalysisService {
private Map<String, Object> generateMarketingReport(MarketingAnalysisRequest request) {
Map<String, Object> report = new HashMap<>();
// Get analysis type
// Get analysis type and detail level
AnalysisType analysisType = request.getAnalysisTypeEnum();
String analysisTypeName = analysisType != null ? analysisType.getDisplayName() : "Общий";
DetailLevel detailLevel = request.getDetailLevelEnum();
String detailLevelName = detailLevel != null ? detailLevel.getDisplayName() : "Стандартно";
// Build context for AI
String context = String.format(
"Продукт/услуга: %s\n" +
"Локация: %s\n" +
"Тип клиентов: %s\n" +
"Уникальные особенности: %s\n" +
"Тип анализа: %s\n",
request.getProduct(),
request.getLocation(),
request.getClient(),
request.getDifferentiator(),
analysisTypeName);
// Build context for AI with all new fields
StringBuilder contextBuilder = new StringBuilder();
contextBuilder.append("Ниша бизнеса: ").append(request.getBusinessNiche()).append("\n");
contextBuilder.append("Продукт/услуга: ").append(request.getProduct()).append("\n");
contextBuilder.append("Целевая аудитория: ").append(request.getTargetAudience()).append("\n");
contextBuilder.append("Регион: ").append(request.getRegion()).append("\n");
contextBuilder.append("Цель на 6-12 месяцев: ").append(request.getGoal()).append("\n");
contextBuilder.append("Уровень детализации: ").append(detailLevelName).append("\n");
if (request.getStrongSide() != null && !request.getStrongSide().trim().isEmpty()) {
contextBuilder.append("Сильная сторона: ").append(request.getStrongSide()).append("\n");
}
if (request.getWeakSide() != null && !request.getWeakSide().trim().isEmpty()) {
contextBuilder.append("Слабая сторона: ").append(request.getWeakSide()).append("\n");
}
contextBuilder.append("Тип анализа: ").append(analysisTypeName).append("\n");
String context = contextBuilder.toString();
// Generate analysis based on type
String analysisContent = generateAnalysisByType(context, analysisType);
// Generate analysis based on type and detail level
String analysisContent = generateAnalysisByType(context, analysisType, detailLevel);
report.put("analysisType", analysisTypeName);
report.put("detailLevel", detailLevelName);
report.put("summary", analysisContent != null ? analysisContent : "Анализ не удалось сгенерировать.");
// Generate target audience description
String audiencePrompt = "На основе информации о бизнесе опиши целевую аудиторию (1-2 абзаца). " +
"Укажи, какие каналы коммуникации наиболее подходят для этой аудитории. " +
"Ответ должен быть на русском языке.\n\n" + context;
// Generate target audience description (adjusted by detail level)
String audiencePrompt;
if (detailLevel == DetailLevel.КРАТКО) {
audiencePrompt = "На основе информации о бизнесе опиши целевую аудиторию кратко (1 абзац). " +
"Укажи основные каналы коммуникации. Ответ должен быть на русском языке.\n\n" + context;
} else if (detailLevel == DetailLevel.ПОДРОБНО) {
audiencePrompt = "На основе информации о бизнесе проведи детальный анализ целевой аудитории (3-4 абзаца). "
+
"Включи демографические характеристики, психографический профиль, потребности, поведенческие паттерны. "
+
"Укажи, какие каналы коммуникации наиболее подходят для этой аудитории и почему. " +
"Ответ должен быть на русском языке.\n\n" + context;
} else {
audiencePrompt = "На основе информации о бизнесе опиши целевую аудиторию (1-2 абзаца). " +
"Укажи, какие каналы коммуникации наиболее подходят для этой аудитории. " +
"Ответ должен быть на русском языке.\n\n" + context;
}
String audienceDescription = openAIAnalyticsService.generateWithInstruction(context, audiencePrompt, "ru");
// Extract channels from description or generate separately
@@ -164,22 +189,50 @@ public class MarketingAnalysisService {
channels.isEmpty() ? Arrays.asList("Instagram", "LinkedIn", "Telegram") : channels);
report.put("targetAudience", targetAudience);
// Generate recommendations
String recommendationsPrompt = "На основе информации о бизнесе сформулируй 4-6 практических рекомендаций для маркетинговой стратегии. "
+
"Каждая рекомендация должна быть конкретной и применимой. " +
"Ответ должен быть списком рекомендаций, каждая с новой строки, без нумерации.\n\n" + context;
// Generate recommendations (adjusted by detail level)
String recommendationsPrompt;
if (detailLevel == DetailLevel.КРАТКО) {
recommendationsPrompt = "На основе информации о бизнесе сформулируй 3-4 краткие практические рекомендации для маркетинговой стратегии. "
+
"Каждая рекомендация должна быть конкретной. " +
"Ответ должен быть списком рекомендаций, каждая с новой строки, без нумерации.\n\n" + context;
} else if (detailLevel == DetailLevel.ПОДРОБНО) {
recommendationsPrompt = "На основе информации о бизнесе сформулируй 6-8 детальных практических рекомендаций для маркетинговой стратегии. "
+
"Каждая рекомендация должна быть конкретной, применимой и содержать обоснование. " +
"Ответ должен быть списком рекомендаций, каждая с новой строки, без нумерации.\n\n" + context;
} else {
recommendationsPrompt = "На основе информации о бизнесе сформулируй 4-6 практических рекомендаций для маркетинговой стратегии. "
+
"Каждая рекомендация должна быть конкретной и применимой. " +
"Ответ должен быть списком рекомендаций, каждая с новой строки, без нумерации.\n\n" + context;
}
String recommendationsStr = openAIAnalyticsService.generateWithInstruction(context, recommendationsPrompt,
"ru");
List<String> recommendations = parseRecommendations(recommendationsStr);
report.put("recommendations",
recommendations.isEmpty() ? Arrays.asList("Рекомендации не удалось сгенерировать.") : recommendations);
// Generate strategy
String strategyPrompt = "На основе информации о бизнесе создай краткую маркетинговую стратегию. " +
"Укажи рекомендуемую длительность кампании (например, '2 недели', '1 месяц'), " +
"3-5 каналов коммуникации и типы контента (например, 'посты', 'сторис', 'баннеры'). " +
"Ответ должен быть структурированным текстом на русском языке.\n\n" + context;
// Generate strategy (adjusted by detail level)
String strategyPrompt;
if (detailLevel == DetailLevel.КРАТКО) {
strategyPrompt = "На основе информации о бизнесе создай краткую маркетинговую стратегию. " +
"Укажи рекомендуемую длительность кампании (например, '2 недели', '1 месяц'), " +
"3-4 канала коммуникации и типы контента (например, 'посты', 'сторис', 'баннеры'). " +
"Ответ должен быть кратким структурированным текстом на русском языке.\n\n" + context;
} else if (detailLevel == DetailLevel.ПОДРОБНО) {
strategyPrompt = "На основе информации о бизнесе создай детальную маркетинговую стратегию. " +
"Укажи рекомендуемую длительность кампании с обоснованием (например, '2 недели', '1 месяц'), " +
"5-7 каналов коммуникации с описанием их преимуществ, типы контента (например, 'посты', 'сторис', 'баннеры') "
+
"и примерный план действий. Ответ должен быть подробным структурированным текстом на русском языке.\n\n"
+ context;
} else {
strategyPrompt = "На основе информации о бизнесе создай маркетинговую стратегию. " +
"Укажи рекомендуемую длительность кампании (например, '2 недели', '1 месяц'), " +
"3-5 каналов коммуникации и типы контента (например, 'посты', 'сторис', 'баннеры'). " +
"Ответ должен быть структурированным текстом на русском языке.\n\n" + context;
}
String strategyText = openAIAnalyticsService.generateWithInstruction(context, strategyPrompt, "ru");
Map<String, Object> strategy = parseStrategy(strategyText, channels);
@@ -188,49 +241,60 @@ public class MarketingAnalysisService {
return report;
}
private String generateAnalysisByType(String context, AnalysisType analysisType) {
private String generateAnalysisByType(String context, AnalysisType analysisType, DetailLevel detailLevel) {
if (analysisType == null) {
analysisType = AnalysisType.РЫНОК; // Default
}
if (detailLevel == null) {
detailLevel = DetailLevel.СТАНДАРТНО; // Default
}
String prompt;
String lengthInstruction;
// Adjust length based on detail level
if (detailLevel == DetailLevel.КРАТКО) {
lengthInstruction = "Ответ должен быть кратким структурированным текстом на русском языке (1-2 абзаца).";
} else if (detailLevel == DetailLevel.ПОДРОБНО) {
lengthInstruction = "Ответ должен быть подробным структурированным текстом на русском языке (5-8 абзацев) с глубоким анализом.";
} else {
lengthInstruction = "Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).";
}
switch (analysisType) {
case РЫНОК:
prompt = "Проведи детальный анализ рынка на основе предоставленной информации о бизнесе. " +
prompt = "Проведи анализ рынка на основе предоставленной информации о бизнесе. " +
"Включи: размер рынка, динамику роста, основные сегменты, тренды и перспективы развития. " +
"Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n" + context;
lengthInstruction + "\n\n" + context;
break;
case КОНКУРЕНТЫ:
prompt = "Проведи анализ конкурентов на основе предоставленной информации о бизнесе. " +
"Включи: основных конкурентов, их сильные и слабые стороны, позиционирование, " +
"ценовую политику и маркетинговые стратегии. Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n"
+ context;
"ценовую политику и маркетинговые стратегии. " + lengthInstruction + "\n\n" + context;
break;
case ЦА:
prompt = "Проведи детальный анализ целевой аудитории на основе предоставленной информации о бизнесе. " +
prompt = "Проведи анализ целевой аудитории на основе предоставленной информации о бизнесе. " +
"Включи: демографические характеристики, психографический профиль, потребности и боли, " +
"поведенческие паттерны и предпочтения. Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n"
+ context;
"поведенческие паттерны и предпочтения. " + lengthInstruction + "\n\n" + context;
break;
case КАНАЛЫ:
prompt = "Проведи анализ маркетинговых каналов на основе предоставленной информации о бизнесе. " +
"Включи: оценку эффективности различных каналов коммуникации, рекомендации по выбору каналов, "
+
"особенности использования каждого канала и бюджетные рекомендации. Ответ должен быть структурированным текстом на русском языке (3-5 абзацев).\n\n"
+ context;
"особенности использования каждого канала и бюджетные рекомендации. " + lengthInstruction
+ "\n\n" + context;
break;
case SWOT:
prompt = "Проведи SWOT-анализ на основе предоставленной информации о бизнесе. " +
"Включи детальный анализ: Сильных сторон (Strengths), Слабых сторон (Weaknesses), " +
"Возможностей (Opportunities) и Угроз (Threats). Ответ должен быть структурированным текстом на русском языке, "
+
"с четким разделением по каждому разделу SWOT (3-5 абзацев).\n\n" + context;
"Включи анализ: Сильных сторон (Strengths), Слабых сторон (Weaknesses), " +
"Возможностей (Opportunities) и Угроз (Threats). " +
"Ответ должен быть структурированным текстом на русском языке, " +
"с четким разделением по каждому разделу SWOT. " + lengthInstruction + "\n\n" + context;
break;
default:
prompt = "На основе следующей информации о бизнесе создай краткое резюме маркетингового анализа (2-3 абзаца). "
+
prompt = "На основе следующей информации о бизнесе создай резюме маркетингового анализа. " +
"Выдели ключевые возможности и особенности бизнеса. " +
"Ответ должен быть на русском языке, деловым стилем.\n\n" + context;
lengthInstruction + "\n\n" + context;
}
return openAIAnalyticsService.generateWithInstruction(context, prompt, "ru");
@@ -329,10 +393,23 @@ public class MarketingAnalysisService {
markdown.append("# Маркетинговый анализ\n\n");
markdown.append("## Информация о бизнесе\n\n");
markdown.append("- **Ниша бизнеса:** ").append(request.getBusinessNiche()).append("\n");
markdown.append("- **Продукт/услуга:** ").append(request.getProduct()).append("\n");
markdown.append("- **Локация:** ").append(request.getLocation()).append("\n");
markdown.append("- **Тип клиентов:** ").append(request.getClient()).append("\n");
markdown.append("- **Уникальные особенности:** ").append(request.getDifferentiator()).append("\n");
markdown.append("- **Целевая аудитория:** ").append(request.getTargetAudience()).append("\n");
markdown.append("- **Регион:** ").append(request.getRegion()).append("\n");
markdown.append("- **Цель на 6-12 месяцев:** ").append(request.getGoal()).append("\n");
String detailLevelName = (String) reportData.get("detailLevel");
if (detailLevelName != null) {
markdown.append("- **Уровень детализации:** ").append(detailLevelName).append("\n");
}
if (request.getStrongSide() != null && !request.getStrongSide().trim().isEmpty()) {
markdown.append("- **Сильная сторона:** ").append(request.getStrongSide()).append("\n");
}
if (request.getWeakSide() != null && !request.getWeakSide().trim().isEmpty()) {
markdown.append("- **Слабая сторона:** ").append(request.getWeakSide()).append("\n");
}
String analysisTypeName = (String) reportData.get("analysisType");
if (analysisTypeName != null) {
@@ -0,0 +1,21 @@
package kz.konturai.parser.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import kz.konturai.parser.model.DetailLevel;
public class DetailLevelValidator implements ConstraintValidator<ValidDetailLevel, String> {
@Override
public void initialize(ValidDetailLevel constraintAnnotation) {
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.trim().isEmpty()) {
return true; // @NotBlank will handle null/empty
}
return DetailLevel.fromString(value) != null;
}
}
@@ -0,0 +1,44 @@
package kz.konturai.parser.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.Arrays;
import java.util.List;
public class RegionValidator implements ConstraintValidator<ValidRegion, String> {
private static final List<String> VALID_REGIONS = Arrays.asList(
"Алматы",
"Астана",
"Шымкент",
"Караганда",
"Актобе",
"Тараз",
"Павлодар",
"Усть-Каменогорск",
"Семей",
"Костанай",
"Кызылорда",
"Уральск",
"Петропавловск",
"Атырау",
"Актау",
"Туркестан",
"Кокшетау",
"Талдыкорган",
"Экибастуз",
"Рудный");
@Override
public void initialize(ValidRegion constraintAnnotation) {
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.trim().isEmpty()) {
return true; // @NotBlank will handle null/empty
}
return VALID_REGIONS.contains(value);
}
}
@@ -0,0 +1,22 @@
package kz.konturai.parser.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = DetailLevelValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidDetailLevel {
String message() default "Поле 'detailLevel' должно быть одним из: КРАТКО, СТАНДАРТНО, ПОДРОБНО";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@@ -0,0 +1,22 @@
package kz.konturai.parser.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = RegionValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidRegion {
String message() default "Поле 'region' должно быть одним из допустимых городов Казахстана";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
+232
View File
@@ -0,0 +1,232 @@
ТЕХНИЧЕСКОЕ ЗАДАНИЕ v0.2
Подсистема: модуль «Маркетинг» (Анализ + Продвижение + Результаты) платформы NAK.AI
1. Общие положения
Цель подсистемы:Реализовать единый модуль «Маркетинг», который для предпринимателя МСБ выполняет полный цикл:
Собирает минимальные данные о бизнесе.
Проводит автоматический маркетинговый анализ (рынок, конкуренты, ЦА, каналы, SWOT).
Формирует понятный аналитический отчёт с рекомендациями простым языком.
Позволяет на основе анализа создать и запустить продвижение (кампанию).
Отображает результаты продвижения в виде метрик и простых выводов.
Роль пользователя: владелец или представитель МСБ (без штатного маркетолога).Интерфейс: полностью на русском языке, без перегруза терминами.
2. Целевая аудитория и сценарий использования
Малый и средний бизнес в Казахстане.
Пользователь не обязан разбираться в маркетинге.
Ожидание: “я отвечу на несколько вопросов → система сама всё посчитает → покажет выводы → предложит готовое продвижение”.
Важно:Нельзя заставлять пользователя:
выбирать типы рекламы (таргет/контекст и т.д.),
выбирать форматы продвижения “по-умному”,
считать бюджеты и вручную настраивать каналы.
Система сама принимает сложные решения и даёт гибкий, но простой интерфейс.
3. Общая структура модуля «Маркетинг»
Модуль «Маркетинг» включает 3 логических блока:
Блок А. Маркетинговый анализ
Блок B. Автоматическое продвижение
Блок C. Результаты кампаний
В UI это один раздел “Маркетинг” с навигацией по шагам.
4. Блок А. Маркетинговый анализ
4.1. Назначение блока
Собрать минимальный набор входных данных и выполнить автоматический анализ, чтобы:
объяснить предпринимателю, на каком рынке он работает,
кто его конкуренты,
кто его клиент,
какие каналы и форматы для него перспективны,
в чём его сильные и слабые стороны.
4.2. Пользовательский поток (step-by-step)
Шаг А1. Экран «Начать анализ»
Поля ввода (всё максимально простым языком):
Чем занимается ваш бизнес?Тип: строка, max 255Примеры подсказок:
«Студия маникюра»
«Магазин детской одежды»
«СТО по ремонту авто»
Где вы работаете?Тип: строкаПримеры:
«Алматы»
«Астана»
«Онлайн по всему Казахстану»
Что вы продаёте?Тип: строкаПримеры:
«Наращивание ресниц»
«Пальто зимние»
«Установка дверей»
Кто ваш клиент? (опционально)Тип: строка + быстрые кнопки выбора:
Женщины 2040
Мужчины 2545
Семьи
Молодёжь
Все подряд
Кнопка: «Начать анализ»
После нажатия создаётся сущность MarketingAnalysis со статусом PENDING.
Шаг А2. Обработка анализа
Бэкенд:
Меняет статус PENDING → IN_PROGRESS.
Запускает внутренние алгоритмы анализа (в рамках MVP можно сделать заглушки/правила).
Подзадачи анализа:
Анализ рынка (MarketInsights)
Анализ конкурентов (CompetitorInsights)
Анализ ЦА (AudienceProfile)
Анализ каналов (ChannelInsights)
SWOT (SwotAnalysis)
Формирование рекомендаций (Recommendation)
Шаг А3. Экран «Анализ выполняется»
Пользователь видит:
Прогресс-бар (этапы: рынок → конкуренты → ЦА → SWOT → отчёт)
Сообщение:
«Система анализирует ваш рынок, конкурентов и целевую аудиторию. Это может занять немного времени.»
Опрашивается GET /api/marketing/analyses/{id}/status до состояния COMPLETED или FAILED.
Шаг А4. Экран «Готовый отчёт»
После завершения анализа:
UI запрашивает GET /api/marketing/analyses/{id} и отображает блоки:
Обзор бизнеса (кратко, на основе BusinessProfile + входных)
Рынок (MarketInsights)
Конкуренты (CompetitorInsights)
Целевая аудитория (AudienceProfile)
Каналы (ChannelInsights)
SWOT (SwotAnalysis)
Рекомендации (список Recommendation)
Кнопки:
«Сохранить отчёт» (на будущее – выгрузка PDF/Word)
«Перейти к продвижению» → открывает блок B.
5. Блок B. Автоматическое продвижение
5.1. Назначение блока
На основе данных анализа автоматически:
сформировать цель кампании,
предложить стратегию,
создать структуру кампании и контент-пакет,— чтобы предпринимателю не пришлось самому разбираться в каналах и форматах.
5.2. Пользовательский поток (step-by-step)
Шаг B1. Экран «Выбор цели продвижения»
Появляется после клика «Перейти к продвижению» или при заходе в маркетинг → вкладка «Продвижение».
Пользователь видит вопрос:
«Какой результат вы хотите получить?»
Карточки-цели (CampaignGoal):
Увеличить продажи (increase_sales)
Получить больше заявок/звонков (get_leads)
Повысить узнаваемость бренда (awareness)
Продвигать акцию или спецпредложение (promo)
Кнопка: «Продолжить»
После выбора вызывается POST /api/promotion/campaigns с:
{
"business_id": "<id>",
"analysis_id": "<last_or_selected>",
"goal_id": "increase_sales"
}
Создаётся Campaign со статусом DRAFT.
Шаг B2. Автоматическое формирование стратегии
Бэкенд:
Читает MarketingAnalysis по analysis_id.
Использует данные:
AudienceProfile
ChannelInsights
MarketInsights
Recommendation
Формирует сущности:
Strategy: общее описание, длительность (например, 7–14 дней).
CampaignChannel: какие каналы будут использоваться.
ContentItem: список контентных единиц (черновики).
Пример:
Каналы: Instagram, Telegram, 2GIS
План:
3 поста в Instagram
7 сторис
1 подборка на маркетплейсе/каталоге
1 простая рассылка по клиентам (если есть база – позже)
Шаг B3. Экран «Стратегия продвижения»
UI запрашивает GET /api/promotion/campaigns/{campaign_id} и отображает:
Цель кампании (человекочитаемый текст)
Каналы, которые выбрала система
Краткое текстовое описание стратегии:
«На основе вашего анализа система рекомендует 2 недели активного продвижения в Instagram и Telegram с упором на отзывы и примеры работ.»
Пользователь может:
«Принять стратегию» → переход к контенту
«Редактировать детали» (в MVP можно просто подсветить, что это появится позже или минимально корректировать текст/названия)
Шаг B4. Экран «Материалы кампании» (контент-пакет)
Отображаются ContentItem:
Список:
Тип: пост / сторис / баннер / email
Канал
Черновой текст (который может быть сгенерирован системой)
Подсказка по визуалу (например: «фото до/после», «фото товара на человеке»)
Пользователь может:
Просмотреть
При необходимости подправить текст (на фронте)
Нажать «Готово, перейти к запуску»
Шаг B5. Экран «Запуск кампании»
Простой экран подтверждения:
«Кампания готова к запуску.Вы можете использовать эти материалы для публикации на своих площадках.В следующих версиях система сможет автоматически публиковать материалы через интеграции.»
Кнопка:
«Зафиксировать запуск кампании» → POST /api/promotion/campaigns/{id}/activate
Статус Campaign меняется на ACTIVE.
6. Блок C. Результаты кампаний
6.1. Назначение
Показать предпринимателю понятную картину, как сработало продвижение:
без сложных метрик,
с упором на результат: охват, вовлечённость, заявки, наиболее эффективные каналы.
6.2. Пользовательский поток
Шаг C1. Список кампаний
Экран:
Таблица или карточки кампаний:
Название кампании
Цель
Статус: DRAFT, ACTIVE, FINISHED
Краткая метрика: например, «Охват: 13 200, Заявок: 31»
Данные: GET /api/promotion/campaigns?business_id=...
Шаг C2. Экран «Результаты продвижения» (детали кампании)
Показывает:
Основные показатели (CampaignMetrics):
Охват (суммарно)
Вовлечённость (лайки/клики/сообщения)
Заявки/обращения (если будут интеграции/ручной ввод)
График:
Ось X: дни
Ось Y: охват/клики или композитный KPI
По каналам:
Instagram: охват, вовлечённость
Telegram: охват, клики
Прочие
Итоговый вывод системы (короткий текст):
«Лучше всего сработал Instagram, основная активность пришлась на 3–5 день кампании. Рекомендуется повторить подобный формат постов и усилить работу с отзывами.»
7. Сущности данных (укрупнённо)
(Ты их уже видел, но теперь в рамках одного модуля.)
7.1. Общие
BusinessProfile
MarketingAnalysis
MarketInsights
CompetitorInsights
AudienceProfile
ChannelInsights
SwotAnalysis
Recommendation
7.2. Продвижение
Campaign
CampaignGoal
Strategy
Channel
CampaignChannel
ContentItem
CampaignMetrics
Связь главная:Campaign.analysis_id → MarketingAnalysis.id
8. Важные бизнес-правила и нюансы
Пользователь не выбирает тип продвижения (“таргет” / “контекст”) — только цель, всё остальное решает система.
Не задаём вопрос про бюджет на этом этапе (подписная модель).
Не перегружаем терминами: CTA, CTR, CPC и т.д. — только человекочитаемые объяснения.
В модуле «Маркетинг» нет технических слов “AI”, “нейросеть”, вместо этого — “система”, “анализ”.
Переход от блока Анализа к Продвижению — мягкий, логичный, без ощущения “другого продукта”.
9. Нефункциональные требования (кратко)
Все тексты для пользователя — на русском.
Логирование действий пользователя и ключевых событий.
API версионируемый: /api/v1/...
Архитектура должна позволять вынести анализ и кампании в отдельные сервисы/очереди при росте нагрузки.
@@ -0,0 +1,400 @@
# Отличия между ТЗ и текущей реализацией
## Обзор
Документ описывает расхождения между техническим заданием (ТЗ v0.2) и текущей реализацией модуля «Маркетинг».
---
## Блок А. Маркетинговый анализ
### 1. Поля ввода формы
#### ТЗ (Шаг А1):
- **Чем занимается ваш бизнес?** (строка, max 255)
- **Где вы работаете?** (строка) - примеры: "Алматы", "Астана", "Онлайн по всему Казахстану"
- **Что вы продаёте?** (строка)
- **Кто ваш клиент?** (опционально, строка + быстрые кнопки выбора)
#### Реализация:
- ✅ **businessNiche** (ниша бизнеса) - соответствует "Чем занимается ваш бизнес?"
- ✅ **product** (продукт/услуга) - соответствует "Что вы продаёте?"
- ✅ **targetAudience** (целевая аудитория) - соответствует "Кто ваш клиент?", но **обязательное поле** (в ТЗ опциональное)
- ✅ **region** (регион) - соответствует "Где вы работаете?", но **только города Казахстана** (в ТЗ допускается "Онлайн по всему Казахстану")
- ❌ **goal** (цель на 6-12 месяцев) - **новое поле, отсутствует в ТЗ**
- ❌ **detailLevel** (уровень детализации) - **новое поле, отсутствует в ТЗ**
- ❌ **strongSide** (сильная сторона) - **новое поле, отсутствует в ТЗ**
- ❌ **weakSide** (слабая сторона) - **новое поле, отсутствует в ТЗ**
**Вывод**: Реализация расширена дополнительными полями, которые улучшают качество анализа, но не соответствуют минималистичному подходу из ТЗ.
---
### 2. Статусы анализа
#### ТЗ:
- `PENDING``IN_PROGRESS``COMPLETED` / `FAILED`
#### Реализация:
- `queued``processing``completed` / `failed`
**Вывод**: Разные названия статусов, но логика идентична.
---
### 3. Эндпоинты API
#### ТЗ:
- `POST /api/marketing/analyses` - создание анализа
- `GET /api/marketing/analyses/{id}/status` - проверка статуса
- `GET /api/marketing/analyses/{id}` - получение результата
#### Реализация:
- ✅ `POST /api/marketing/analysis/start` - создание анализа (путь отличается: `analysis` вместо `analyses`)
- ❌ `GET /api/marketing/analysis/{id}/status` - **отсутствует отдельный эндпоинт для статуса**
- ✅ `GET /api/marketing/analysis/{id}` - получение результата (путь отличается)
**Вывод**: Пути API отличаются (единственное число vs множественное), отсутствует отдельный эндпоинт для проверки статуса (статус возвращается вместе с результатом).
---
### 4. Структура ответа анализа
#### ТЗ (Шаг А4):
Отчёт должен содержать блоки:
- Обзор бизнеса
- Рынок (MarketInsights)
- Конкуренты (CompetitorInsights)
- Целевая аудитория (AudienceProfile)
- Каналы (ChannelInsights)
- SWOT (SwotAnalysis)
- Рекомендации (список Recommendation)
#### Реализация:
Ответ содержит:
- ✅ `summary` - резюме анализа (соответствует обзору)
- ✅ `targetAudience` - целевая аудитория с описанием и каналами
- ✅ `recommendations` - список рекомендаций
- ✅ `strategy` - маркетинговая стратегия с каналами и типами контента
- ❌ **Нет отдельных блоков** для Рынка, Конкурентов, SWOT, Каналов - всё объединено в `summary` на основе `analysisType`
**Вывод**: В ТЗ предполагается комплексный анализ со всеми блоками, в реализации анализ зависит от выбранного `analysisType` (один тип за раз).
---
### 5. Тип анализа
#### ТЗ:
Анализ включает **все типы одновременно**:
- Анализ рынка (MarketInsights)
- Анализ конкурентов (CompetitorInsights)
- Анализ ЦА (AudienceProfile)
- Анализ каналов (ChannelInsights)
- SWOT (SwotAnalysis)
#### Реализация:
Пользователь **выбирает один тип анализа**:
- `РЫНОК` - только анализ рынка
- `КОНКУРЕНТЫ` - только анализ конкурентов
- `ЦА` - только анализ целевой аудитории
- `КАНАЛЫ` - только анализ каналов
- `SWOT` - только SWOT-анализ
**Вывод**: Критическое отличие - в ТЗ анализ комплексный, в реализации пользователь выбирает один тип.
---
## Блок B. Автоматическое продвижение
### 1. Название сущности
#### ТЗ:
- `Campaign` (кампания)
- `CampaignGoal` (цель кампании)
- `Strategy` (стратегия)
- `CampaignChannel` (каналы кампании)
- `ContentItem` (контент-единицы)
#### Реализация:
- `MarketingStrategy` (маркетинговая стратегия) - **другое название**
- Нет сущности `Campaign` - вместо неё используется `MarketingStrategy`
- Нет сущности `CampaignGoal` - цели не реализованы
- ✅ `MarketingStrategy.WeeklyPlan` - недельные планы
- ✅ `MarketingStrategy.PostCalendarItem` - календарь постов (аналог ContentItem)
**Вывод**: Концептуальное отличие - в ТЗ есть отдельные сущности Campaign и Strategy, в реализации всё объединено в MarketingStrategy.
---
### 2. Эндпоинты продвижения
#### ТЗ:
- `POST /api/promotion/campaigns` - создание кампании с `business_id`, `analysis_id`, `goal_id`
- `GET /api/promotion/campaigns/{campaign_id}` - получение стратегии кампании
- `POST /api/promotion/campaigns/{id}/activate` - активация кампании
#### Реализация:
- ✅ `POST /api/marketing/analysis/strategy/generate` - генерация стратегии (путь отличается, нет `goal_id`)
- ✅ `GET /api/marketing/analysis/strategy/{strategyId}` - получение стратегии
- ✅ `POST /api/marketing/analysis/strategy/{strategyId}/start` - запуск стратегии (аналог активации)
**Вывод**: Пути API отличаются (`/api/promotion/campaigns` vs `/api/marketing/analysis/strategy`), отсутствует выбор цели кампании (`goal_id`).
---
### 3. Выбор цели продвижения
#### ТЗ (Шаг B1):
Пользователь выбирает цель из карточек:
- Увеличить продажи (`increase_sales`)
- Получить больше заявок/звонков (`get_leads`)
- Повысить узнаваемость бренда (`awareness`)
- Продвигать акцию или спецпредложение (`promo`)
#### Реализация:
- ❌ **Выбор цели отсутствует** - стратегия генерируется автоматически без выбора цели пользователем
**Вывод**: Критическое отличие - в ТЗ пользователь выбирает цель, в реализации цель определяется автоматически системой.
---
### 4. Формирование стратегии
#### ТЗ (Шаг B2):
Стратегия формируется на основе:
- `AudienceProfile`
- `ChannelInsights`
- `MarketInsights`
- `Recommendation`
Создаются:
- `Strategy` - описание, длительность (7-14 дней)
- `CampaignChannel` - каналы
- `ContentItem` - контент-единицы (черновики)
#### Реализация:
Стратегия формируется на основе:
- ✅ Данных из `MarketingAnalysis`
- ✅ `targetAudience` из анализа
- ✅ `recommendations` из анализа
Создаются:
- ✅ `MarketingStrategy` - описание, длительность в неделях
- ✅ `priorityPlatforms` - приоритетные платформы
- ✅ `WeeklyPlan` - недельные планы с темами
- ✅ `PostCalendarItem` - календарь постов с текстами и хештегами
**Вывод**: Логика похожа, но структура данных отличается (недельные планы и календарь постов вместо простых ContentItem).
---
### 5. Экран "Материалы кампании"
#### ТЗ (Шаг B4):
Отображаются `ContentItem`:
- Тип: пост / сторис / баннер / email
- Канал
- Черновой текст
- Подсказка по визуалу
#### Реализация:
Отображаются `PostCalendarItem`:
- ✅ `platform` - канал
- ✅ `contentType` - тип контента (пост, сторис, видео, баннер)
- ✅ `postText` - текст поста
- ✅ `hashtags` - хештеги
- ✅ `publishDate` - дата публикации
- ✅ `publishTime` - время публикации
- ❌ **Нет подсказок по визуалу**
**Вывод**: Реализация более детальная (даты, время, хештеги), но отсутствуют подсказки по визуалу.
---
## Блок C. Результаты кампаний
### 1. Эндпоинты результатов
#### ТЗ:
- `GET /api/promotion/campaigns?business_id=...` - список кампаний
- Детали кампании через тот же эндпоинт
#### Реализация:
- ❌ **Эндпоинты для результатов кампаний отсутствуют**
- ✅ Есть `GET /api/marketing/analysis/strategy/my` - список стратегий пользователя
- ✅ Есть `PostingTask` - задачи на публикацию, но нет метрик
**Вывод**: Блок C (Результаты кампаний) **не реализован**. Нет метрик, графиков, итоговых выводов.
---
### 2. Метрики кампаний
#### ТЗ (Шаг C2):
Должны отображаться:
- Охват (суммарно)
- Вовлечённость (лайки/клики/сообщения)
- Заявки/обращения
- График по дням
- Метрики по каналам (Instagram, Telegram и т.д.)
- Итоговый вывод системы
#### Реализация:
- ❌ **Метрики отсутствуют**
- ❌ **Графики отсутствуют**
- ❌ **Итоговые выводы отсутствуют**
- ✅ Есть `PostingTask` - задачи на публикацию, но без метрик выполнения
**Вывод**: Функционал отслеживания результатов кампаний полностью отсутствует.
---
## Общие отличия
### 1. API версионирование
#### ТЗ:
- API должен быть версионируемым: `/api/v1/...`
#### Реализация:
- ❌ **Версионирование отсутствует** - используется `/api/marketing/...`
**Вывод**: Не соответствует требованию версионирования API.
---
### 2. Структура данных
#### ТЗ:
Сущности:
- `BusinessProfile`
- `MarketingAnalysis`
- `MarketInsights`
- `CompetitorInsights`
- `AudienceProfile`
- `ChannelInsights`
- `SwotAnalysis`
- `Recommendation`
- `Campaign`
- `CampaignGoal`
- `Strategy`
- `CampaignChannel`
- `ContentItem`
- `CampaignMetrics`
#### Реализация:
Сущности:
- ✅ `MarketingAnalysis`
- ✅ `MarketingStrategy`
- ✅ `PostingTask`
- ❌ **Нет отдельных сущностей** для `MarketInsights`, `CompetitorInsights`, `AudienceProfile`, `ChannelInsights`, `SwotAnalysis` - данные хранятся в `reportData` как Map
- ❌ **Нет `Campaign`** - используется `MarketingStrategy`
- ❌ **Нет `CampaignGoal`**
- ❌ **Нет `CampaignMetrics`**
**Вывод**: Структура данных упрощена - многие сущности объединены или отсутствуют.
---
### 3. Минималистичный подход
#### ТЗ:
> "Собирает минимальные данные о бизнесе"
> "Нельзя заставлять пользователя выбирать типы рекламы, форматы продвижения"
#### Реализация:
- ❌ Пользователь должен выбрать `analysisType` (тип анализа)
- ❌ Пользователь должен выбрать `detailLevel` (уровень детализации)
- ❌ Добавлены дополнительные поля (`goal`, `strongSide`, `weakSide`)
**Вывод**: Реализация требует больше данных от пользователя, чем предполагалось в ТЗ.
---
## Резюме критических отличий
### 🔴 Критические расхождения:
1. **Тип анализа**: В ТЗ анализ комплексный (все типы сразу), в реализации - выбор одного типа
2. **Блок C (Результаты)**: Полностью не реализован - нет метрик, графиков, выводов
3. **Выбор цели кампании**: Отсутствует в реализации
4. **Структура данных**: Упрощена, многие сущности объединены или отсутствуют
5. **API версионирование**: Отсутствует
### 🟡 Значительные отличия:
1. **Поля формы**: Добавлены дополнительные поля, не указанные в ТЗ
2. **Пути API**: Отличаются от указанных в ТЗ
3. **Названия сущностей**: `Campaign``MarketingStrategy`
4. **Статусы**: Разные названия (`PENDING` vs `queued`)
### 🟢 Незначительные отличия:
1. **Детализация стратегии**: Реализация более детальная (недельные планы, календарь)
2. **Структура ответа**: Данные организованы по-другому, но информация присутствует
---
## Рекомендации по приведению к ТЗ
### Приоритет 1 (Критично):
1. **Реализовать комплексный анализ** - генерировать все типы анализа одновременно, а не по выбору
2. **Реализовать Блок C** - добавить метрики, графики, итоговые выводы
3. **Добавить выбор цели кампании** - перед генерацией стратегии
### Приоритет 2 (Важно):
1. **Упростить форму** - убрать лишние поля или сделать их опциональными
2. **Привести пути API** к указанным в ТЗ или обновить ТЗ
3. **Добавить версионирование API** - `/api/v1/...`
### Приоритет 3 (Желательно):
1. **Переименовать сущности** или обновить ТЗ под текущую реализацию
2. **Добавить подсказки по визуалу** в материалы кампании
3. **Привести статусы** к единому виду
Binary file not shown.