feat: Telegram Mini App integration and UX improvements
- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK) - Отдельный компактный дизайн для Telegram Mini App - Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации) - Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию - Telegram Mini App: кнопка "Выход" просто закрывает приложение - Telegram Mini App: заявки "В работе" скрыты из списка - Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot) - Telegram Mini App: кнопки действий в черновиках расположены вертикально - Веб-версия: убрано отображение номера телефона в приветствии - Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации) - Заблокировано удаление и редактирование заявок со статусом "В работе" - Добавлена документация по Telegram Mini App интеграции
This commit is contained in:
137
CURRENT_SETUP.md
Normal file
137
CURRENT_SETUP.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 📍 Текущая структура запущенных окружений
|
||||
|
||||
**Дата проверки:** 2 января 2025
|
||||
|
||||
---
|
||||
|
||||
## 🟢 DEV окружение (запущено)
|
||||
|
||||
**Рабочая папка:**
|
||||
```
|
||||
/var/www/fastuser/data/www/crm.clientright.ru/aiform_dev/
|
||||
```
|
||||
|
||||
**Контейнеры:**
|
||||
- `aiform_frontend_dev` → порт **5177** → http://147.45.146.17:5177/
|
||||
- `aiform_backend_dev` → порт **8201**
|
||||
|
||||
**Docker Compose:**
|
||||
- Файл: `aiform_dev/docker-compose.dev.yml`
|
||||
- Запуск: `cd aiform_dev && docker-compose -f docker-compose.dev.yml up -d`
|
||||
|
||||
**Монтированные папки:**
|
||||
- Frontend: `aiform_dev/frontend/src` → `/app/src` (read-only, для live reload)
|
||||
- Backend: использует `aiform_dev/backend/.env`
|
||||
|
||||
**Git репозиторий:**
|
||||
- Remote: `aiform_dev` → http://147.45.146.17:3002/negodiy/aiform_dev.git
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PROD окружение (запущено)
|
||||
|
||||
**Рабочая папка:**
|
||||
```
|
||||
/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/
|
||||
```
|
||||
|
||||
**Контейнеры:**
|
||||
- `ticket_form_frontend_prod` → порт **5176** → https://aiform.clientright.ru/
|
||||
- `ticket_form_backend` → порт **8200** (network_mode: host)
|
||||
|
||||
**Docker Compose:**
|
||||
- Файл: `ticket_form/docker-compose.prod.yml` (новый) или старый `docker-compose.yml`
|
||||
- Запуск: `cd ticket_form && docker-compose -f docker-compose.prod.yml up -d`
|
||||
|
||||
**Git репозиторий:**
|
||||
- Remote: `aiform_prod` → http://147.45.146.17:3002/negodiy/aiform_prod.git
|
||||
- Remote: `origin` → http://147.45.146.17:3002/negodiy/erv-platform.git
|
||||
|
||||
---
|
||||
|
||||
## 📊 Сравнение
|
||||
|
||||
| | DEV | PROD |
|
||||
|---|---|---|
|
||||
| **Папка** | `/aiform_dev/` | `/ticket_form/` |
|
||||
| **Frontend порт** | 5177 | 5176 |
|
||||
| **Backend порт** | 8201 | 8200 |
|
||||
| **URL** | http://147.45.146.17:5177/ | https://aiform.clientright.ru/ |
|
||||
| **Docker Compose** | `aiform_dev/docker-compose.dev.yml` | `ticket_form/docker-compose.prod.yml` |
|
||||
| **Git** | `aiform_dev` | `aiform_prod` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Как переносить изменения
|
||||
|
||||
### Из DEV в PROD:
|
||||
|
||||
```bash
|
||||
# 1. Работаете в DEV папке
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
# Вносите изменения, тестируете
|
||||
|
||||
# 2. Копируете изменения в PROD папку (или через git)
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
git pull aiform_prod main # или копируете файлы вручную
|
||||
|
||||
# 3. Перезапускаете PROD
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### Или через git (рекомендуется):
|
||||
|
||||
```bash
|
||||
# В DEV папке
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
git add .
|
||||
git commit -m "feat: Описание"
|
||||
git push aiform_dev main
|
||||
|
||||
# В PROD папке
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
git pull aiform_prod main
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важно
|
||||
|
||||
1. **DEV и PROD — это разные папки:**
|
||||
- DEV: `/aiform_dev/`
|
||||
- PROD: `/ticket_form/`
|
||||
|
||||
2. **Изменения в DEV не попадают в PROD автоматически** — нужно копировать/пушить через git
|
||||
|
||||
3. **У каждого окружения свой `.env` файл:**
|
||||
- DEV: `aiform_dev/backend/.env`
|
||||
- PROD: `ticket_form/.env`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Полезные команды
|
||||
|
||||
```bash
|
||||
# Проверить статус DEV
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
|
||||
# Проверить статус PROD
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Логи DEV
|
||||
docker logs aiform_frontend_dev -f
|
||||
docker logs aiform_backend_dev -f
|
||||
|
||||
# Логи PROD
|
||||
docker logs ticket_form_frontend_prod -f
|
||||
docker logs ticket_form_backend -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant + Фёдор
|
||||
**Дата:** 2 января 2025
|
||||
|
||||
203
DEPLOYMENT.md
Normal file
203
DEPLOYMENT.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# 🚀 Руководство по деплою: DEV → PROD
|
||||
|
||||
## 📍 Текущая структура
|
||||
|
||||
- **DEV:** http://147.45.146.17:5177/ (папка `aiform_dev/`)
|
||||
- **PROD:** https://aiform.clientright.ru/ (домен продакшна)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Быстрый перенос изменений (1 команда)
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
./deploy-to-prod.sh
|
||||
```
|
||||
|
||||
Этот скрипт:
|
||||
1. ✅ Проверит незакоммиченные изменения
|
||||
2. ✅ Отправит код в git репозитории (dev и prod)
|
||||
3. ✅ Пересоберёт PROD контейнеры
|
||||
4. ✅ Перезапустит PROD окружение
|
||||
|
||||
---
|
||||
|
||||
## 📝 Пошаговый процесс (вручную)
|
||||
|
||||
### Шаг 1: Сохранить изменения в git
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Проверить что изменилось
|
||||
git status
|
||||
|
||||
# Добавить изменения
|
||||
git add .
|
||||
|
||||
# Закоммитить
|
||||
git commit -m "feat: Описание изменений"
|
||||
|
||||
# Отправить в dev репозиторий
|
||||
git push aiform_dev main # или master
|
||||
```
|
||||
|
||||
### Шаг 2: Отправить в prod репозиторий
|
||||
|
||||
```bash
|
||||
# Отправить в prod
|
||||
git push aiform_prod main # или master
|
||||
```
|
||||
|
||||
### Шаг 3: Обновить PROD контейнеры
|
||||
|
||||
```bash
|
||||
# Пересобрать
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Перезапустить
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Про .env файл
|
||||
|
||||
### Почему один .env, а не два?
|
||||
|
||||
**✅ Преимущества одного .env:**
|
||||
- Проще поддерживать (один файл вместо двух)
|
||||
- Меньше путаницы
|
||||
- Режим переключается через переменную `APP_ENV` в docker-compose
|
||||
|
||||
**Как это работает:**
|
||||
|
||||
В `docker-compose.dev.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- APP_ENV=development # Переопределяет значение из .env
|
||||
- DEBUG=true
|
||||
```
|
||||
|
||||
В `docker-compose.prod.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- APP_ENV=production # Переопределяет значение из .env
|
||||
- DEBUG=false
|
||||
```
|
||||
|
||||
**Ваш `.env` файл остаётся один**, но docker-compose переопределяет нужные переменные для каждого окружения.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Структура репозиториев
|
||||
|
||||
```
|
||||
Gitea (http://147.45.146.17:3002/negodiy):
|
||||
├─ aiform_dev → DEV версия (http://147.45.146.17:5177/)
|
||||
├─ aiform_prod → PROD версия (https://aiform.clientright.ru/)
|
||||
└─ erv-platform → Основной репозиторий
|
||||
```
|
||||
|
||||
**Локальные папки:**
|
||||
- `/var/www/.../aiform_dev/` → DEV окружение
|
||||
- `/var/www/.../ticket_form/` → Основной проект (может быть и DEV и PROD)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Типичный workflow
|
||||
|
||||
### 1. Разработка в DEV
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
# или
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Вносите изменения
|
||||
# Тестируете на http://147.45.146.17:5177/
|
||||
```
|
||||
|
||||
### 2. Когда готово → деплой в PROD
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Вариант 1: Автоматический (рекомендуется)
|
||||
./deploy-to-prod.sh
|
||||
|
||||
# Вариант 2: Вручную
|
||||
git add .
|
||||
git commit -m "feat: Описание"
|
||||
git push aiform_prod main
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### 3. Проверка PROD
|
||||
|
||||
```bash
|
||||
# Проверить статус
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Проверить логи
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Открыть в браузере
|
||||
# https://aiform.clientright.ru/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важные моменты
|
||||
|
||||
1. **Всегда тестируйте в DEV перед деплоем в PROD**
|
||||
2. **Проверяйте `.env` файл** — убедитесь что там правильные настройки
|
||||
3. **В PROD `APP_ENV=production` и `DEBUG=false`** (устанавливается через docker-compose)
|
||||
4. **Не коммитьте `.env`** — он в `.gitignore`
|
||||
5. **После деплоя проверяйте логи** — `docker-compose -f docker-compose.prod.yml logs`
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Откат изменений (если что-то пошло не так)
|
||||
|
||||
```bash
|
||||
# Откатить к предыдущему коммиту
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
git log --oneline -5 # Найти нужный коммит
|
||||
git checkout <commit-hash>
|
||||
git push aiform_prod main --force
|
||||
|
||||
# Пересобрать
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Полезные команды
|
||||
|
||||
```bash
|
||||
# Статус контейнеров
|
||||
docker ps | grep aiform
|
||||
|
||||
# Логи DEV
|
||||
docker logs aiform_frontend_dev -f
|
||||
docker logs aiform_backend_dev -f
|
||||
|
||||
# Логи PROD
|
||||
docker logs ticket_form_frontend_prod -f
|
||||
docker logs ticket_form_backend_prod -f
|
||||
|
||||
# Перезапуск PROD
|
||||
docker-compose -f docker-compose.prod.yml restart
|
||||
|
||||
# Полная пересборка PROD
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant + Фёдор
|
||||
**Дата:** 2 января 2025
|
||||
|
||||
264
ENVIRONMENTS.md
Normal file
264
ENVIRONMENTS.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 🚀 Руководство по DEV и PROD окружениям
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Проект поддерживает два отдельных окружения:
|
||||
- **DEV** (Development) — для разработки и тестирования
|
||||
- **PROD** (Production) — для продакшна
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Структура файлов
|
||||
|
||||
```
|
||||
ticket_form/
|
||||
├─ docker-compose.dev.yml ← Конфигурация для разработки
|
||||
├─ docker-compose.prod.yml ← Конфигурация для продакшна
|
||||
├─ .env.dev ← Переменные окружения для DEV
|
||||
├─ .env.prod ← Переменные окружения для PROD
|
||||
├─ .env.example ← Шаблон переменных окружения
|
||||
├─ start-dev.sh ← Скрипт запуска DEV
|
||||
├─ start-prod.sh ← Скрипт запуска PROD
|
||||
└─ ENVIRONMENTS.md ← Эта документация
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Быстрый старт
|
||||
|
||||
### 1. Первоначальная настройка
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Создаём .env файлы из шаблона
|
||||
cp .env.example .env.dev
|
||||
cp .env.example .env.prod
|
||||
|
||||
# Редактируем .env.dev (для разработки)
|
||||
nano .env.dev
|
||||
# Установите: APP_ENV=development, DEBUG=true
|
||||
|
||||
# Редактируем .env.prod (для продакшна)
|
||||
nano .env.prod
|
||||
# Установите: APP_ENV=production, DEBUG=false
|
||||
# Проверьте все URL и API ключи
|
||||
```
|
||||
|
||||
### 2. Запуск DEV окружения
|
||||
|
||||
```bash
|
||||
# Вариант 1: Используя скрипт (рекомендуется)
|
||||
./start-dev.sh
|
||||
|
||||
# Вариант 2: Вручную
|
||||
docker-compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
**Доступ:**
|
||||
- Frontend: http://localhost:5175
|
||||
- Backend: http://localhost:8200
|
||||
- API Docs: http://localhost:8200/docs
|
||||
|
||||
### 3. Запуск PROD окружения
|
||||
|
||||
```bash
|
||||
# Вариант 1: Используя скрипт (рекомендуется)
|
||||
./start-prod.sh
|
||||
|
||||
# Вариант 2: Вручную
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
**Доступ:**
|
||||
- Frontend: http://localhost:5176
|
||||
- Backend: http://localhost:8200
|
||||
- API Docs: http://localhost:8200/docs
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Различия между DEV и PROD
|
||||
|
||||
| Параметр | DEV | PROD |
|
||||
|----------|-----|------|
|
||||
| **Порты** | 5175 (frontend), 8200 (backend) | 5176 (frontend), 8200 (backend) |
|
||||
| **Контейнеры** | `*_dev` | `*_prod` |
|
||||
| **PostgreSQL** | Локальный контейнер (порт 5433) | Внешний (147.45.189.234:5432) |
|
||||
| **Redis** | Локальный контейнер (порт 6380) | Системный (localhost:6379) |
|
||||
| **Debug** | ✅ Включен | ❌ Выключен |
|
||||
| **Логи** | DEBUG уровень | INFO уровень |
|
||||
| **Hot Reload** | ✅ Включен | ❌ Выключен |
|
||||
| **Build** | Dev режим | Production оптимизация |
|
||||
| **Healthcheck** | ❌ Нет | ✅ Есть |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Управление окружениями
|
||||
|
||||
### Остановка
|
||||
|
||||
```bash
|
||||
# Остановить DEV
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
|
||||
# Остановить PROD
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
```
|
||||
|
||||
### Просмотр логов
|
||||
|
||||
```bash
|
||||
# Логи DEV
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Логи PROD
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Логи конкретного сервиса
|
||||
docker-compose -f docker-compose.dev.yml logs -f ticket_form_backend_dev
|
||||
```
|
||||
|
||||
### Перезапуск
|
||||
|
||||
```bash
|
||||
# Перезапуск DEV
|
||||
docker-compose -f docker-compose.dev.yml restart
|
||||
|
||||
# Перезапуск PROD
|
||||
docker-compose -f docker-compose.prod.yml restart
|
||||
```
|
||||
|
||||
### Пересборка
|
||||
|
||||
```bash
|
||||
# Пересборка DEV
|
||||
docker-compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# Пересборка PROD
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Переменные окружения
|
||||
|
||||
### Основные переменные
|
||||
|
||||
| Переменная | DEV значение | PROD значение |
|
||||
|------------|--------------|---------------|
|
||||
| `APP_ENV` | `development` | `production` |
|
||||
| `DEBUG` | `true` | `false` |
|
||||
| `LOG_LEVEL` | `DEBUG` | `INFO` |
|
||||
| `VITE_API_URL` | `http://localhost:8200` | `https://aiform.clientright.ru/api` |
|
||||
| `NODE_ENV` | `development` | `production` |
|
||||
|
||||
### Базы данных
|
||||
|
||||
**DEV:**
|
||||
- PostgreSQL: `ticket_form_postgres_dev` (контейнер, порт 5433)
|
||||
- Redis: `ticket_form_redis_dev` (контейнер, порт 6380)
|
||||
|
||||
**PROD:**
|
||||
- PostgreSQL: `147.45.189.234:5432` (внешний)
|
||||
- Redis: `localhost:6379` (системный)
|
||||
- MySQL: `localhost:3306` (системный)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Отладка
|
||||
|
||||
### Проверка статуса
|
||||
|
||||
```bash
|
||||
# Статус DEV контейнеров
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
|
||||
# Статус PROD контейнеров
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Все контейнеры проекта
|
||||
docker ps | grep ticket_form
|
||||
```
|
||||
|
||||
### Проверка подключений
|
||||
|
||||
```bash
|
||||
# Проверка backend health
|
||||
curl http://localhost:8200/health
|
||||
|
||||
# Проверка frontend
|
||||
curl http://localhost:5175
|
||||
|
||||
# Проверка PostgreSQL (DEV)
|
||||
docker exec -it ticket_form_postgres_dev psql -U erv_user -d erv_db_dev
|
||||
|
||||
# Проверка Redis (DEV)
|
||||
docker exec -it ticket_form_redis_dev redis-cli -a redis_dev_pass ping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Git репозитории
|
||||
|
||||
### Структура репозиториев
|
||||
|
||||
- **`erv-platform`** (origin) — основной репозиторий
|
||||
- **`aiform_prod`** — production версия
|
||||
- **`aiform_dev`** — development версия (в папке `aiform_dev/`)
|
||||
|
||||
### Работа с Git
|
||||
|
||||
```bash
|
||||
# Push в основной репозиторий
|
||||
git push origin main
|
||||
|
||||
# Push в prod репозиторий
|
||||
git push aiform_prod main
|
||||
|
||||
# Push в оба
|
||||
git push origin main && git push aiform_prod main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важные замечания
|
||||
|
||||
1. **Никогда не коммитьте `.env.dev` и `.env.prod`** — они в `.gitignore`
|
||||
2. **Всегда проверяйте `.env.prod`** перед деплоем в продакшн
|
||||
3. **DEV и PROD могут работать одновременно** на разных портах
|
||||
4. **В PROD используйте внешние БД** — не создавайте локальные контейнеры
|
||||
5. **Healthcheck в PROD** — проверяйте статус регулярно
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Миграция с текущей структуры
|
||||
|
||||
Если у вас уже запущены контейнеры со старыми именами:
|
||||
|
||||
```bash
|
||||
# Остановите старые контейнеры
|
||||
docker stop ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
|
||||
|
||||
# Удалите старые контейнеры (опционально)
|
||||
docker rm ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
|
||||
|
||||
# Запустите новые через скрипты
|
||||
./start-dev.sh
|
||||
./start-prod.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
При проблемах:
|
||||
1. Проверьте логи: `docker-compose -f docker-compose.*.yml logs`
|
||||
2. Проверьте статус: `docker-compose -f docker-compose.*.yml ps`
|
||||
3. Проверьте `.env` файлы на корректность
|
||||
4. Убедитесь, что порты не заняты: `netstat -tulpn | grep -E "5175|5176|8200"`
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant + Фёдор
|
||||
**Дата:** 2 января 2025
|
||||
|
||||
94
README_ENVIRONMENTS.md
Normal file
94
README_ENVIRONMENTS.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 🚀 Быстрый старт: DEV и PROD окружения
|
||||
|
||||
## 📦 Что создано
|
||||
|
||||
✅ `docker-compose.dev.yml` - конфигурация для разработки
|
||||
✅ `docker-compose.prod.yml` - конфигурация для продакшна
|
||||
✅ `start-dev.sh` - скрипт запуска DEV
|
||||
✅ `start-prod.sh` - скрипт запуска PROD
|
||||
✅ `.env.example` - шаблон переменных окружения
|
||||
✅ `ENVIRONMENTS.md` - полная документация
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Быстрый старт (3 шага)
|
||||
|
||||
### Шаг 1: Создайте .env файлы
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Создаём из шаблона
|
||||
cp .env.example .env.dev
|
||||
cp .env.example .env.prod
|
||||
|
||||
# Редактируем DEV
|
||||
nano .env.dev
|
||||
# Установите: APP_ENV=development, DEBUG=true
|
||||
|
||||
# Редактируем PROD
|
||||
nano .env.prod
|
||||
# Установите: APP_ENV=production, DEBUG=false
|
||||
# Проверьте все URL и ключи!
|
||||
```
|
||||
|
||||
### Шаг 2: Запустите DEV
|
||||
|
||||
```bash
|
||||
./start-dev.sh
|
||||
```
|
||||
|
||||
**Доступ:** http://localhost:5175
|
||||
|
||||
### Шаг 3: Запустите PROD (когда готово)
|
||||
|
||||
```bash
|
||||
./start-prod.sh
|
||||
```
|
||||
|
||||
**Доступ:** http://localhost:5176
|
||||
|
||||
---
|
||||
|
||||
## 📊 Основные команды
|
||||
|
||||
```bash
|
||||
# Остановить DEV
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
|
||||
# Остановить PROD
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Логи DEV
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Логи PROD
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Статус
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Различия
|
||||
|
||||
| | DEV | PROD |
|
||||
|---|---|---|
|
||||
| **Порты** | 5175, 8200 | 5176, 8200 |
|
||||
| **PostgreSQL** | Локальный контейнер | Внешний (147.45.189.234) |
|
||||
| **Redis** | Локальный контейнер | Системный (localhost) |
|
||||
| **Debug** | ✅ Включен | ❌ Выключен |
|
||||
| **Hot Reload** | ✅ Да | ❌ Нет |
|
||||
|
||||
---
|
||||
|
||||
## 📖 Полная документация
|
||||
|
||||
Смотрите `ENVIRONMENTS.md` для детальной информации.
|
||||
|
||||
---
|
||||
|
||||
**Всё готово к работе!** 🎉
|
||||
|
||||
60
backend/app/api/banks.py
Normal file
60
backend/app/api/banks.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Banks API - получение списка банков СБП
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import httpx
|
||||
import logging
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
|
||||
|
||||
|
||||
@router.get("/nspk")
|
||||
async def get_nspk_banks():
|
||||
"""
|
||||
Получить список банков СБП из внешнего API
|
||||
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
|
||||
"""
|
||||
try:
|
||||
# URL внешнего API
|
||||
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(external_api_url)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to fetch banks: HTTP {response.status_code}")
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Failed to fetch banks list: {response.status_code}"
|
||||
)
|
||||
|
||||
banks_data = response.json()
|
||||
logger.info(f"✅ Loaded {len(banks_data)} banks from external API")
|
||||
|
||||
return banks_data
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while fetching banks")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Timeout while fetching banks list"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error while fetching banks: {e}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Failed to connect to banks API: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error while fetching banks: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ import uuid
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from ..services.redis_service import redis_service
|
||||
from ..services.database import db
|
||||
from ..services.crm_mysql_service import crm_mysql_service
|
||||
from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL
|
||||
# Убрали импорты из n8n_service - больше не нужны для webhook подхода
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||
@@ -241,7 +242,7 @@ async def list_drafts(
|
||||
OR c.payload->>'phone' = $2
|
||||
OR c.payload->>'phone' = $3
|
||||
)
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
@@ -268,7 +269,7 @@ async def list_drafts(
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.session_token = $1
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
@@ -392,10 +393,11 @@ async def list_drafts(
|
||||
# Формируем список документов со статусами
|
||||
documents_list = []
|
||||
for doc_req in documents_required:
|
||||
doc_name = doc_req.get('name', 'Документ')
|
||||
# Пробуем разные поля для названия документа (field_label приоритетнее)
|
||||
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
|
||||
doc_id = doc_req.get('id', '')
|
||||
is_required = doc_req.get('required', False)
|
||||
# Проверяем загружен ли (по name или id)
|
||||
# Проверяем загружен ли (по field_label или name)
|
||||
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
|
||||
documents_list.append({
|
||||
"name": doc_name,
|
||||
@@ -498,10 +500,40 @@ async def get_draft(claim_id: str):
|
||||
|
||||
# 🔍 ОТЛАДКА: Логируем наличие documents_required
|
||||
documents_required = payload.get('documents_required', []) if isinstance(payload, dict) else []
|
||||
documents_meta = payload.get('documents_meta', []) if isinstance(payload, dict) else []
|
||||
logger.info(f"🔍 Черновик {final_claim_id}: status_code={row.get('status_code')}, documents_required count={len(documents_required) if isinstance(documents_required, list) else 0}")
|
||||
if documents_required:
|
||||
logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера
|
||||
|
||||
# Подсчет документов (как в списке черновиков)
|
||||
documents_required_list = documents_required if isinstance(documents_required, list) else []
|
||||
documents_meta_list = documents_meta if isinstance(documents_meta, list) else []
|
||||
|
||||
# Считаем загруженные (уникальные по field_label)
|
||||
uploaded_labels = set()
|
||||
for doc in documents_meta_list:
|
||||
label = doc.get('field_label') or doc.get('field_name')
|
||||
if label:
|
||||
uploaded_labels.add(label)
|
||||
|
||||
documents_uploaded = len(uploaded_labels)
|
||||
documents_total = len(documents_required_list) if documents_required_list else 0
|
||||
|
||||
# Формируем список документов со статусами
|
||||
documents_list = []
|
||||
for doc_req in documents_required_list:
|
||||
# Пробуем разные поля для названия документа (field_label приоритетнее)
|
||||
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
|
||||
doc_id = doc_req.get('id', '')
|
||||
is_required = doc_req.get('required', False)
|
||||
# Проверяем загружен ли (по field_label или name)
|
||||
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
|
||||
documents_list.append({
|
||||
"name": doc_name,
|
||||
"required": is_required,
|
||||
"uploaded": is_uploaded,
|
||||
})
|
||||
|
||||
# ✅ Проверяем флаг подтверждения данных контакта из CRM (поле cf_2624)
|
||||
# Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*)
|
||||
# ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL),
|
||||
@@ -604,7 +636,11 @@ async def get_draft(claim_id: str):
|
||||
"channel": row.get('channel'),
|
||||
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
|
||||
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
|
||||
"payload": payload
|
||||
"payload": payload,
|
||||
# Информация о документах
|
||||
"documents_total": documents_total,
|
||||
"documents_uploaded": documents_uploaded,
|
||||
"documents_list": documents_list,
|
||||
},
|
||||
# ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624)
|
||||
"contact_data_confirmed": contact_data_confirmed,
|
||||
@@ -908,48 +944,95 @@ async def load_wizard_data(claim_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
||||
|
||||
|
||||
async def _check_and_restart_workflow_if_needed(channel: str):
|
||||
async def _send_buffered_messages_to_webhook():
|
||||
"""
|
||||
Проверяет и перезапускает workflow если нужно (в фоне)
|
||||
Защита от частых перезапусков через Redis lock
|
||||
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
|
||||
"""
|
||||
try:
|
||||
# Проверяем lock - если недавно перезапускали, пропускаем
|
||||
lock_key = f"workflow_restart_lock:{channel}"
|
||||
lock_value = await redis_service.get(lock_key)
|
||||
|
||||
if lock_value:
|
||||
logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)")
|
||||
if not settings.n8n_description_webhook:
|
||||
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
|
||||
return
|
||||
|
||||
# Проверяем статус workflow
|
||||
workflow_data = await check_workflow_status()
|
||||
buffer_key = "description"
|
||||
messages = await redis_service.buffer_get_all(buffer_key)
|
||||
|
||||
if workflow_data:
|
||||
is_active = workflow_data.get("active", False)
|
||||
if not is_active:
|
||||
logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
|
||||
# Workflow выключен — нужно его ВКЛЮЧИТЬ
|
||||
else:
|
||||
logger.info(
|
||||
f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
|
||||
if not messages:
|
||||
logger.info("📭 Буфер пуст, нечего отправлять")
|
||||
return
|
||||
|
||||
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера в n8n webhook...")
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for buffered_message in messages:
|
||||
try:
|
||||
# Восстанавливаем формат для n8n: массив с channel и message
|
||||
channel = buffered_message.get("channel", f"{settings.redis_prefix}description")
|
||||
message_data = buffered_message.get("message", buffered_message.get("event", buffered_message))
|
||||
|
||||
webhook_payload = [
|
||||
{
|
||||
"channel": channel,
|
||||
"message": message_data
|
||||
}
|
||||
]
|
||||
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
# Устанавливаем lock на MIN_RESTART_INTERVAL секунд
|
||||
await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
|
||||
|
||||
# Перезапускаем
|
||||
success = await restart_workflow()
|
||||
|
||||
if success:
|
||||
logger.info("✅ Workflow успешно перезапущен")
|
||||
if response.status_code == 200:
|
||||
sent_count += 1
|
||||
logger.info(
|
||||
f"✅ Буферированное сообщение отправлено: "
|
||||
f"session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
# НЕ возвращаем в буфер - успешно отправили
|
||||
else:
|
||||
logger.error("❌ Не удалось перезапустить workflow")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
|
||||
# HTTP ошибка - возвращаем в буфер
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
f"⚠️ n8n вернул ошибку {response.status_code}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
f"⏱️ Таймаут при отправке из буфера, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
f"🔌 Ошибка подключения к n8n: {e}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}")
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
f"❌ Неожиданная ошибка при отправке из буфера: {e}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}",
|
||||
exc_info=True
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
logger.info(
|
||||
f"📊 Результат отправки буфера: {sent_count} отправлено, "
|
||||
f"{failed_count} возвращено в буфер"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ Ошибка при отправке буфера: {e}")
|
||||
|
||||
|
||||
@router.post("/description")
|
||||
@@ -958,12 +1041,18 @@ async def publish_ticket_form_description(
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Публикует свободное описание проблемы в Redis канал ticket_form:description
|
||||
(слушается воркфлоу в n8n)
|
||||
Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub)
|
||||
"""
|
||||
try:
|
||||
if not settings.n8n_description_webhook:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="N8N description webhook не настроен"
|
||||
)
|
||||
|
||||
# Формируем данные в формате, который ожидает n8n workflow
|
||||
channel = payload.channel or f"{settings.redis_prefix}description"
|
||||
event = {
|
||||
message = {
|
||||
"type": "ticket_form_description",
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id, # Опционально - может быть None
|
||||
@@ -976,7 +1065,13 @@ async def publish_ticket_form_description(
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
event_json = json.dumps(event, ensure_ascii=False)
|
||||
# n8n workflow ожидает массив с объектом, содержащим channel и message
|
||||
webhook_payload = [
|
||||
{
|
||||
"channel": channel,
|
||||
"message": message
|
||||
}
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"📝 TicketForm description received",
|
||||
@@ -991,81 +1086,111 @@ async def publish_ticket_form_description(
|
||||
},
|
||||
)
|
||||
|
||||
# Retry-логика: пытаемся отправить в n8n webhook
|
||||
max_attempts = 3
|
||||
initial_delay = 1 # секунды
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
logger.info(
|
||||
"📡 Publishing to Redis channel",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event_type": event["type"],
|
||||
"event_keys": list(event.keys()),
|
||||
"json_length": len(event_json),
|
||||
},
|
||||
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
|
||||
subscribers_count = await redis_service.publish(channel, event_json)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ TicketForm description published to Redis",
|
||||
f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"session_id": payload.session_id,
|
||||
"subscribers_count": subscribers_count,
|
||||
"event_json_preview": event_json[:500],
|
||||
},
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
)
|
||||
|
||||
if subscribers_count == 0:
|
||||
# Успешно отправили - возвращаем успех
|
||||
return {
|
||||
"success": True,
|
||||
"event": message,
|
||||
"attempt": attempt,
|
||||
}
|
||||
else:
|
||||
# HTTP ошибка (не 200)
|
||||
logger.warning(
|
||||
f"⚠️ WARNING: No subscribers on channel {channel}! "
|
||||
f"n8n workflow is not listening to this channel. "
|
||||
f"Saving message to buffer and restarting workflow..."
|
||||
f"⚠️ Попытка {attempt}: n8n вернул статус {response.status_code}",
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"status_code": response.status_code,
|
||||
"response_preview": response.text[:200],
|
||||
}
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(
|
||||
f"⏱️ Попытка {attempt}: таймаут при отправке в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
f"🔌 Попытка {attempt}: ошибка подключения к n8n: {e}",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"❌ Попытка {attempt}: неожиданная ошибка: {e}",
|
||||
extra={"session_id": payload.session_id},
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Если это не последняя попытка - ждём перед следующей
|
||||
if attempt < max_attempts:
|
||||
wait_time = initial_delay * (2 ** (attempt - 1)) # Экспоненциальный backoff
|
||||
logger.info(f"⏳ Жду {wait_time} секунд перед следующей попыткой...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# Все попытки исчерпаны - сохраняем в буфер
|
||||
logger.error(
|
||||
f"❌ Все {max_attempts} попытки исчерпаны, сохраняю в буфер",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
|
||||
# Сохраняем сообщение в буфер для последующей отправки
|
||||
buffer_message = {
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id,
|
||||
"event": event,
|
||||
"channel": channel,
|
||||
"message": message, # Сохраняем message для последующей отправки
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await redis_service.buffer_push("description", buffer_message)
|
||||
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
|
||||
|
||||
# Запускаем проверку и перезапуск workflow в фоне
|
||||
background_tasks.add_task(_check_and_restart_workflow_if_needed, channel)
|
||||
# Запускаем фоновую задачу для отправки из буфера
|
||||
background_tasks.add_task(_send_buffered_messages_to_webhook)
|
||||
|
||||
# Дополнительная проверка: логируем полный event для отладки
|
||||
logger.debug(
|
||||
"🔍 Full event data published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event": event,
|
||||
},
|
||||
)
|
||||
# Формируем ответ с информацией о подписчиках
|
||||
response_data = {
|
||||
"success": True,
|
||||
"channel": channel,
|
||||
"subscribers_count": subscribers_count,
|
||||
"event": event,
|
||||
}
|
||||
|
||||
# Если подписчиков нет - сообщаем что обработка займёт больше времени
|
||||
if subscribers_count == 0:
|
||||
buffer_size = await redis_service.buffer_size("description")
|
||||
response_data["warning"] = (
|
||||
return {
|
||||
"success": True,
|
||||
"event": message,
|
||||
"buffered": True,
|
||||
"warning": (
|
||||
"Обработка вашего обращения займёт немного больше времени. "
|
||||
"Идёт автоматическое восстановление системы. "
|
||||
"Ваше сообщение сохранено и будет обработано в ближайшее время."
|
||||
)
|
||||
response_data["workflow_recovering"] = True
|
||||
response_data["message_buffered"] = True
|
||||
response_data["buffer_size"] = buffer_size
|
||||
),
|
||||
"buffer_size": buffer_size,
|
||||
}
|
||||
|
||||
return response_data
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("❌ Failed to publish ticket form description")
|
||||
logger.exception("❌ Failed to send ticket form description to n8n")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Не удалось опубликовать описание: {e}"
|
||||
detail=f"Не удалось отправить описание: {e}"
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ N8N_POLICY_CHECK_WEBHOOK = settings.n8n_policy_check_webhook or None
|
||||
N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
|
||||
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
|
||||
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
|
||||
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
|
||||
|
||||
|
||||
@router.post("/policy/check")
|
||||
@@ -219,6 +220,72 @@ async def proxy_file_upload(
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка загрузки файла: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tg/auth")
|
||||
async def proxy_telegram_auth(request: Request):
|
||||
"""
|
||||
Проксирует авторизацию Telegram WebApp (Mini App) в n8n webhook.
|
||||
|
||||
Используется backend-эндпоинтом /api/v1/tg/auth:
|
||||
- backend валидирует initData
|
||||
- затем вызывает этот роут для маппинга telegram_user_id → unified_id в n8n
|
||||
"""
|
||||
if not N8N_TG_AUTH_WEBHOOK:
|
||||
logger.error("[TG] N8N_TG_AUTH_WEBHOOK не задан в .env — webhook не вызывается")
|
||||
raise HTTPException(status_code=500, detail="N8N Telegram auth webhook не настроен")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
logger.info(
|
||||
"[TG] Proxy → n8n webhook %s: telegram_user_id=%s, session_token=%s",
|
||||
N8N_TG_AUTH_WEBHOOK[:50] + "...",
|
||||
body.get("telegram_user_id", "unknown"),
|
||||
body.get("session_token", "unknown"),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TG_AUTH_WEBHOOK,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
logger.info("[TG] n8n webhook ответ: status=%s, body длина=%s", response.status_code, len(response_text))
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"[TG] n8n webhook success. Response: %s",
|
||||
response_text[:500],
|
||||
)
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"❌ Failed to parse Telegram auth JSON: %s. Response: %s",
|
||||
e,
|
||||
response_text[:500],
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||||
|
||||
logger.error(
|
||||
"[TG] n8n webhook вернул ошибку %s: %s",
|
||||
response.status_code,
|
||||
response_text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"N8N Telegram auth error: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[TG] Таймаут при вызове n8n Telegram auth webhook")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (Telegram auth)")
|
||||
except Exception as e:
|
||||
logger.exception("[TG] Ошибка при вызове n8n Telegram auth: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/claim/create")
|
||||
async def proxy_create_claim(request: Request):
|
||||
"""
|
||||
|
||||
@@ -15,14 +15,19 @@ async def send_sms_code(request: SMSSendRequest):
|
||||
|
||||
- **phone**: Номер телефона в формате +79001234567
|
||||
"""
|
||||
from ..config import settings
|
||||
|
||||
code = await sms_service.send_verification_code(request.phone)
|
||||
|
||||
if code:
|
||||
return {
|
||||
response = {
|
||||
"success": True,
|
||||
"message": "Код отправлен на указанный номер",
|
||||
"debug_code": code # Всегда возвращаем код для dev модалки
|
||||
"message": "Код отправлен на указанный номер"
|
||||
}
|
||||
# 🔧 DEV MODE: Возвращаем debug_code только в development
|
||||
if settings.debug or settings.app_env == "development":
|
||||
response["debug_code"] = code
|
||||
return response
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
|
||||
154
backend/app/api/telegram_auth.py
Normal file
154
backend/app/api/telegram_auth.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Telegram Mini App (WebApp) auth endpoint.
|
||||
|
||||
/api/v1/tg/auth:
|
||||
- Принимает init_data от Telegram WebApp и (опционально) session_token
|
||||
- Валидирует init_data и извлекает данные пользователя Telegram
|
||||
- Проксирует telegram_user_id в n8n для получения unified_id/контакта
|
||||
- Создаёт сессию в Redis через существующий /api/v1/session/create
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tg", tags=["Telegram"])
|
||||
|
||||
|
||||
class TelegramAuthRequest(BaseModel):
|
||||
init_data: str
|
||||
session_token: Optional[str] = None
|
||||
|
||||
|
||||
class TelegramAuthResponse(BaseModel):
|
||||
success: bool
|
||||
session_token: str
|
||||
unified_id: str
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
"""Генерирует новый session_token в формате, похожем на текущий веб-флоу."""
|
||||
import uuid
|
||||
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/auth", response_model=TelegramAuthResponse)
|
||||
async def telegram_auth(request: TelegramAuthRequest):
|
||||
"""
|
||||
Авторизация пользователя через Telegram WebApp.
|
||||
|
||||
Ничего не ломает в текущем SMS-флоу: это параллельный способ входа.
|
||||
"""
|
||||
# Логирование: что пришло на бэкенд
|
||||
init_data = request.init_data or ""
|
||||
logger.info(
|
||||
"[TG] POST /api/v1/tg/auth вызван: init_data длина=%s, session_token передан=%s",
|
||||
len(init_data),
|
||||
bool(request.session_token),
|
||||
)
|
||||
if not init_data:
|
||||
logger.warning("[TG] init_data пустой — запрос отклонён")
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
bot_token_configured = bool((getattr(settings, "telegram_bot_token", None) or "").strip())
|
||||
n8n_webhook_configured = bool((getattr(settings, "n8n_tg_auth_webhook", None) or "").strip())
|
||||
logger.info("[TG] Конфиг: TELEGRAM_BOT_TOKEN задан=%s, N8N_TG_AUTH_WEBHOOK задан=%s", bot_token_configured, n8n_webhook_configured)
|
||||
|
||||
# 1. Валидация и разбор init_data
|
||||
try:
|
||||
tg_user = extract_telegram_user(request.init_data)
|
||||
except TelegramAuthError as e:
|
||||
logger.warning("[TG] Ошибка валидации initData: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
telegram_user_id = tg_user["telegram_user_id"]
|
||||
logger.info("[TG] Telegram user валиден: id=%s, username=%s", telegram_user_id, tg_user.get("username"))
|
||||
|
||||
# 2. Определяем session_token
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
|
||||
# 3. Вызываем n8n через прокси для маппинга telegram_user_id → unified_id
|
||||
n8n_payload = {
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"username": tg_user.get("username"),
|
||||
"first_name": tg_user.get("first_name"),
|
||||
"last_name": tg_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": "ticket_form",
|
||||
"init_data": request.init_data, # сырая строка из Telegram (подпись уже проверена)
|
||||
}
|
||||
logger.info("[TG] Вызов n8n webhook, payload keys=%s", list(n8n_payload.keys()))
|
||||
|
||||
# Используем уже существующий n8n_proxy роут (внутренний вызов)
|
||||
try:
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
# Объект с async .json() для proxy_telegram_auth(request), без Pydantic __root__
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: dict):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
dummy_request = _DummyRequest(n8n_payload)
|
||||
n8n_response = await n8n_proxy.proxy_telegram_auth(dummy_request) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
logger.info("[TG] n8n ответ получен: keys=%s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
except HTTPException:
|
||||
# Пробрасываем HTTPException наверх
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
# Ожидаем от n8n как минимум unified_id
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data)
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя")
|
||||
|
||||
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
|
||||
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
|
||||
session_request = session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
)
|
||||
|
||||
try:
|
||||
await session_api.create_session(session_request)
|
||||
except HTTPException:
|
||||
# Если ошибка уже обёрнута в HTTPException — пробрасываем как есть
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error creating Redis session for Telegram user")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
|
||||
|
||||
return TelegramAuthResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"""
|
||||
Конфигурация приложения
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
|
||||
# Список CORS, обновляется при изменении .env (чтобы не перезапускать бэкенд)
|
||||
_cors_origins_live: List[str] = []
|
||||
_settings_cache: Optional["Settings"] = None
|
||||
_env_mtime_cache: float = 0
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ============================================
|
||||
@@ -179,6 +184,13 @@ class Settings(BaseSettings):
|
||||
n8n_file_upload_webhook: str = ""
|
||||
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
|
||||
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
|
||||
n8n_description_webhook: str = "https://n8n.clientright.pro/webhook/aiform_description" # Webhook для обработки описания проблемы
|
||||
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
|
||||
|
||||
# ============================================
|
||||
# TELEGRAM BOT
|
||||
# ============================================
|
||||
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
|
||||
|
||||
# ============================================
|
||||
# LOGGING
|
||||
@@ -192,9 +204,25 @@ class Settings(BaseSettings):
|
||||
extra = "ignore" # Игнорируем лишние поля из .env
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
"""Текущие настройки. При изменении .env подхватываются без перезапуска."""
|
||||
global _settings_cache, _env_mtime_cache, _cors_origins_live
|
||||
mtime = os.path.getmtime(ENV_PATH) if ENV_PATH.exists() else 0.0
|
||||
if _settings_cache is None or mtime > _env_mtime_cache:
|
||||
_settings_cache = Settings()
|
||||
_env_mtime_cache = mtime
|
||||
_cors_origins_live.clear()
|
||||
_cors_origins_live.extend(_settings_cache.cors_origins_list)
|
||||
return _settings_cache
|
||||
|
||||
|
||||
def get_cors_origins_live() -> List[str]:
|
||||
"""
|
||||
Список CORS origins для middleware; обновляется при изменении .env без перезапуска.
|
||||
Обработчики, которые используют get_settings() при каждом запросе, тоже видят новые значения.
|
||||
"""
|
||||
get_settings() # обновить кеш и _cors_origins_live при изменении .env
|
||||
return _cors_origins_live
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -6,14 +6,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
|
||||
from .config import settings
|
||||
from .config import settings, get_cors_origins_live, get_settings
|
||||
from .services.database import db
|
||||
from .services.redis_service import redis_service
|
||||
from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .services.crm_mysql_service import crm_mysql_service
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -93,14 +93,19 @@ app = FastAPI(
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS
|
||||
# CORS (список обновляется при изменении .env без перезапуска)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_origins=get_cors_origins_live(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# Обновление конфига с .env при каждом запросе, чтобы CORS и прочее подхватывали изменения
|
||||
@app.middleware("http")
|
||||
async def refresh_config_on_request(request, call_next):
|
||||
get_settings()
|
||||
return await call_next(request)
|
||||
|
||||
# API Routes
|
||||
app.include_router(sms.router)
|
||||
@@ -112,6 +117,8 @@ app.include_router(events.router)
|
||||
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
|
||||
app.include_router(session.router) # 🔑 Session management через Redis
|
||||
app.include_router(documents.router) # 📄 Documents upload and processing
|
||||
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
|
||||
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
WORKFLOW_ID = "b4K4u851b4JFivyD"
|
||||
N8N_URL = "https://n8n.clientright.pro"
|
||||
MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками
|
||||
MAX_RETRY_ATTEMPTS = 2 # Максимум попыток перезапуска подряд
|
||||
|
||||
|
||||
async def check_workflow_status() -> Optional[dict]:
|
||||
@@ -50,7 +51,7 @@ async def check_workflow_status() -> Optional[dict]:
|
||||
|
||||
async def restart_workflow() -> bool:
|
||||
"""
|
||||
Перезапуск workflow через n8n API
|
||||
Перезапуск workflow через n8n API с улучшенной обработкой зависших состояний
|
||||
|
||||
Returns:
|
||||
True если успешно, False при ошибке
|
||||
@@ -63,37 +64,64 @@ async def restart_workflow() -> bool:
|
||||
if not headers:
|
||||
return False
|
||||
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Шаг 1: Деактивировать workflow
|
||||
# Увеличиваем таймаут для обработки зависших workflow
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Шаг 1: Проверяем текущий статус
|
||||
logger.info(f"🔍 Проверяю текущий статус workflow {WORKFLOW_ID}...")
|
||||
status_response = await client.get(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if status_response.status_code == 200:
|
||||
workflow_data = status_response.json()
|
||||
is_active = workflow_data.get("active", False)
|
||||
logger.info(f"📊 Workflow активен: {is_active}")
|
||||
|
||||
# Шаг 2: Деактивировать workflow (даже если уже неактивен - для сброса состояния)
|
||||
logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...")
|
||||
try:
|
||||
deactivate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
|
||||
headers=headers
|
||||
headers=headers,
|
||||
timeout=15.0 # Отдельный таймаут для деактивации
|
||||
)
|
||||
|
||||
if deactivate_response.status_code not in [200, 404]:
|
||||
if deactivate_response.status_code in [200, 404]:
|
||||
logger.info("✅ Workflow деактивирован")
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ Неожиданный статус при деактивации: "
|
||||
f"{deactivate_response.status_code}"
|
||||
f"{deactivate_response.status_code} - {deactivate_response.text[:200]}"
|
||||
)
|
||||
else:
|
||||
logger.info("✅ Workflow деактивирован")
|
||||
# Продолжаем даже если деактивация не удалась - возможно workflow уже неактивен
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("⏱️ Таймаут при деактивации workflow (возможно завис)")
|
||||
# Продолжаем попытку активации - иногда помогает
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Ошибка при деактивации: {e}, продолжаю...")
|
||||
|
||||
# Задержка перед активацией
|
||||
import asyncio
|
||||
await asyncio.sleep(2)
|
||||
# Задержка перед активацией (увеличена для стабильности)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Шаг 2: Активировать workflow
|
||||
# Шаг 3: Активировать workflow
|
||||
logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...")
|
||||
try:
|
||||
activate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
|
||||
headers=headers
|
||||
headers=headers,
|
||||
timeout=15.0 # Отдельный таймаут для активации
|
||||
)
|
||||
|
||||
if activate_response.status_code == 200:
|
||||
logger.info("✅ Workflow активирован")
|
||||
|
||||
# Дополнительная задержка для инициализации trigger node
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# После успешного перезапуска отправляем сообщения из буфера
|
||||
await _send_buffered_messages()
|
||||
|
||||
@@ -104,9 +132,18 @@ async def restart_workflow() -> bool:
|
||||
f"{activate_response.status_code} - {activate_response.text[:200]}"
|
||||
)
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ Таймаут при активации workflow - возможно n8n перегружен")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}")
|
||||
logger.error(f"❌ Ошибка при активации workflow: {e}")
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ Общий таймаут при перезапуске workflow")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -65,17 +65,11 @@ class SMSService:
|
||||
logger.warning("SMS отправка отключена в конфигурации")
|
||||
return False
|
||||
|
||||
# 🔧 DEV: ПРИНУДИТЕЛЬНО ОТКЛЮЧЕНА ОТПРАВКА SMS
|
||||
# Раскомментировать для продакшена!
|
||||
logger.info(f"🔧 DEV MODE: SMS to {phone} ЗАБЛОКИРОВАНА (экономим бюджет!)")
|
||||
logger.info(f"📱 Message: {message}")
|
||||
return True
|
||||
|
||||
# DEBUG MODE: Не отправляем реальные SMS, экономим бюджет
|
||||
# if settings.debug or settings.app_env == "development":
|
||||
# logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
|
||||
# logger.info(f"📱 Message would be: {message}")
|
||||
# return True
|
||||
# 🔧 DEV MODE: Не отправляем реальные SMS в development, экономим бюджет
|
||||
if settings.debug or settings.app_env == "development":
|
||||
logger.info(f"🔧 DEV MODE: SMS to {phone} not sent (saving money!)")
|
||||
logger.info(f"📱 Message would be: {message}")
|
||||
return True # Возвращаем True чтобы код сохранился в Redis для проверки
|
||||
|
||||
try:
|
||||
# Получаем актуальный токен
|
||||
|
||||
132
backend/app/services/telegram_auth.py
Normal file
132
backend/app/services/telegram_auth.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Telegram WebApp (Mini App) auth helper.
|
||||
|
||||
В этом модуле:
|
||||
- Парсим и валидируем initData от Telegram WebApp
|
||||
- Проверяем подпись по токену бота из настроек
|
||||
- Возвращаем разобранные данные пользователя Telegram
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramAuthError(Exception):
|
||||
"""Ошибка проверки подлинности Telegram initData."""
|
||||
|
||||
|
||||
def _parse_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Разбирает строку initData в словарь.
|
||||
|
||||
Формат initData — это query string, см. Telegram WebApp docs.
|
||||
"""
|
||||
data: Dict[str, Any] = {}
|
||||
for key, value in parse_qsl(init_data, keep_blank_values=True):
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def verify_telegram_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверяет подпись initData согласно Telegram WebApp правилам.
|
||||
|
||||
Алгоритм из официальной документации:
|
||||
- Берём токен бота: BOT_TOKEN
|
||||
- Вычисляем secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
- Собираем data_check_string: строки "<key>=<value>" по всем полям, кроме 'hash',
|
||||
отсортированные по key, соединённые '\n'
|
||||
- Считаем хэш: HMAC_SHA256(secret_key, data_check_string)
|
||||
- Сравниваем с полем 'hash' из initData (hex)
|
||||
"""
|
||||
if not init_data:
|
||||
logger.warning("[TG] verify_telegram_init_data: init_data пустой")
|
||||
raise TelegramAuthError("init_data is empty")
|
||||
|
||||
bot_token = (getattr(settings, "telegram_bot_token", None) or "").strip()
|
||||
if not bot_token:
|
||||
logger.warning("[TG] verify_telegram_init_data: TELEGRAM_BOT_TOKEN не задан в .env")
|
||||
raise TelegramAuthError("Telegram bot token is not configured")
|
||||
|
||||
parsed = _parse_init_data(init_data)
|
||||
logger.info("[TG] initData распарсен, ключи: %s", list(parsed.keys()))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
if not received_hash:
|
||||
logger.warning("[TG] В initData отсутствует поле hash")
|
||||
raise TelegramAuthError("Missing hash in init_data")
|
||||
|
||||
# Формируем data_check_string
|
||||
data_check_items = []
|
||||
for key in sorted(parsed.keys()):
|
||||
value = parsed[key]
|
||||
data_check_items.append(f"{key}={value}")
|
||||
data_check_string = "\n".join(data_check_items)
|
||||
|
||||
# secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
secret_key = hmac.new(
|
||||
key="WebAppData".encode("utf-8"),
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
# HMAC_SHA256(secret_key, data_check_string)
|
||||
calculated_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("[TG] Подпись initData не совпадает (неверный токен бота или поддельные данные)")
|
||||
raise TelegramAuthError("Invalid init_data hash")
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_telegram_user(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Валидирует initData и возвращает данные пользователя Telegram.
|
||||
|
||||
В field `user` лежит JSON-строка с полями:
|
||||
{
|
||||
"id": 123456789,
|
||||
"first_name": "...",
|
||||
"last_name": "...",
|
||||
"username": "...",
|
||||
...
|
||||
}
|
||||
"""
|
||||
import json
|
||||
|
||||
parsed = verify_telegram_init_data(init_data)
|
||||
|
||||
user_raw = parsed.get("user")
|
||||
if not user_raw:
|
||||
logger.warning("[TG] В initData отсутствует поле user")
|
||||
raise TelegramAuthError("No user field in init_data")
|
||||
|
||||
try:
|
||||
user_obj = json.loads(user_raw)
|
||||
except Exception as e:
|
||||
raise TelegramAuthError(f"Failed to parse user JSON: {e}") from e
|
||||
|
||||
if "id" not in user_obj:
|
||||
raise TelegramAuthError("Telegram user.id is missing")
|
||||
|
||||
return {
|
||||
"telegram_user_id": str(user_obj.get("id")),
|
||||
"username": user_obj.get("username"),
|
||||
"first_name": user_obj.get("first_name"),
|
||||
"last_name": user_obj.get("last_name"),
|
||||
"language_code": user_obj.get("language_code"),
|
||||
"raw": user_obj,
|
||||
}
|
||||
|
||||
86
deploy-to-prod.sh
Executable file
86
deploy-to-prod.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Скрипт переноса изменений из DEV в PROD
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🚀 Перенос изменений из DEV в PROD"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Проверка что мы в правильной директории
|
||||
if [ ! -f "docker-compose.dev.yml" ]; then
|
||||
echo "❌ Ошибка: запустите скрипт из корня проекта ticket_form"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Проверка изменений в git
|
||||
echo "📊 Проверяю изменения в git..."
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "⚠️ Есть незакоммиченные изменения!"
|
||||
echo ""
|
||||
git status --short
|
||||
echo ""
|
||||
read -p "Закоммитить изменения перед деплоем? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "💾 Коммичу изменения..."
|
||||
git add -A
|
||||
git commit -m "chore: Изменения перед деплоем в prod $(date +%Y-%m-%d)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Push в dev репозиторий
|
||||
echo ""
|
||||
echo "📤 Отправляю изменения в DEV репозиторий..."
|
||||
if git remote | grep -q "aiform_dev"; then
|
||||
git push aiform_dev main 2>/dev/null || git push aiform_dev master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_dev"
|
||||
fi
|
||||
|
||||
# 3. Push в prod репозиторий
|
||||
echo ""
|
||||
echo "📤 Отправляю изменения в PROD репозиторий..."
|
||||
if git remote | grep -q "aiform_prod"; then
|
||||
git push aiform_prod main 2>/dev/null || git push aiform_prod master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_prod"
|
||||
else
|
||||
echo "⚠️ Remote 'aiform_prod' не найден. Добавьте:"
|
||||
echo " git remote add aiform_prod http://147.45.146.17:3002/negodiy/aiform_prod.git"
|
||||
fi
|
||||
|
||||
# 4. Пересборка prod контейнеров
|
||||
echo ""
|
||||
echo "🔨 Пересобираю PROD контейнеры..."
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
# 5. Перезапуск prod
|
||||
echo ""
|
||||
echo "🔄 Перезапускаю PROD окружение..."
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 6. Проверка статуса
|
||||
echo ""
|
||||
echo "⏳ Жду запуска (5 сек)..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ Деплой завершён!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "📍 PROD доступен:"
|
||||
echo " Frontend: http://localhost:5176"
|
||||
echo " Backend: http://localhost:8200"
|
||||
echo " Production: https://aiform.clientright.ru"
|
||||
echo ""
|
||||
echo "📊 Статус контейнеров:"
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
echo ""
|
||||
echo "📋 Логи (последние 20 строк):"
|
||||
docker-compose -f docker-compose.prod.yml logs --tail=20
|
||||
echo ""
|
||||
|
||||
62
docker-compose.prod.yml
Normal file
62
docker-compose.prod.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================
|
||||
# PRODUCTION ENVIRONMENT
|
||||
# Запуск: docker-compose -f docker-compose.prod.yml up -d
|
||||
# ============================================
|
||||
|
||||
services:
|
||||
ticket_form_frontend_prod:
|
||||
container_name: ticket_form_frontend_prod
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
ports:
|
||||
- "5176:3000"
|
||||
environment:
|
||||
- VITE_API_URL=https://aiform.clientright.ru
|
||||
- NODE_ENV=production
|
||||
networks:
|
||||
- ticket-form-prod-network
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "environment=production"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
ticket_form_backend_prod:
|
||||
container_name: ticket_form_backend_prod
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
network_mode: host # Для доступа к localhost MySQL/Redis
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- DEBUG=false
|
||||
- LOG_LEVEL=INFO
|
||||
volumes:
|
||||
- ./backend/logs:/app/logs
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "environment=production"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# В проде используем внешние БД (не создаём локальные)
|
||||
# PostgreSQL: 147.45.189.234:5432
|
||||
# Redis: localhost:6379 (системный)
|
||||
# MySQL: localhost:3306 (системный)
|
||||
|
||||
networks:
|
||||
ticket-form-prod-network:
|
||||
driver: bridge
|
||||
name: ticket-form-prod-network
|
||||
|
||||
68
docs/BROWSERLESS_CURL_EXAMPLE.sh
Normal file
68
docs/BROWSERLESS_CURL_EXAMPLE.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Пример curl запроса для Browserless (HTML → PDF)
|
||||
# Используйте этот запрос в HTTP Request ноде n8n
|
||||
# ============================================================================
|
||||
|
||||
# ВАРИАНТ 1: С data URL (HTML в base64)
|
||||
curl -X POST http://147.45.146.17:3000/pdf \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"url": "data:text/html;base64,PCFET0NUWVBFIGh0bWw+PGh0bWw+PGJvZHk+PGgxPlRlc3Q8L2gxPjwvYm9keT48L2h0bWw+",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "15mm"
|
||||
}
|
||||
}
|
||||
}'
|
||||
|
||||
# ============================================================================
|
||||
# ВАРИАНТ 2: С прямым HTML (если Browserless поддерживает)
|
||||
# ============================================================================
|
||||
# curl -X POST http://147.45.146.17:3000/pdf \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
# -d '{
|
||||
# "html": "<!DOCTYPE html><html><body><h1>Test</h1></body></html>",
|
||||
# "options": {
|
||||
# "format": "A4",
|
||||
# "printBackground": true,
|
||||
# "margin": {
|
||||
# "top": "20mm",
|
||||
# "right": "15mm",
|
||||
# "bottom": "20mm",
|
||||
# "left": "15mm"
|
||||
# }
|
||||
# }
|
||||
# }'
|
||||
|
||||
# ============================================================================
|
||||
# НАСТРОЙКА В HTTP REQUEST НОДЕ:
|
||||
# ============================================================================
|
||||
# Method: POST
|
||||
# URL: http://147.45.146.17:3000/pdf
|
||||
# Headers:
|
||||
# Content-Type: application/json
|
||||
# Authorization: Bearer YOUR_TOKEN (если требуется)
|
||||
# Body (JSON):
|
||||
# {
|
||||
# "url": "data:text/html;base64,{{ $json.html_base64_encoded }}",
|
||||
# "options": {
|
||||
# "format": "A4",
|
||||
# "printBackground": true,
|
||||
# "margin": {
|
||||
# "top": "20mm",
|
||||
# "right": "15mm",
|
||||
# "bottom": "20mm",
|
||||
# "left": "15mm"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# Response Format: Binary
|
||||
# ============================================================================
|
||||
163
docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md
Normal file
163
docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Настройка HTTP Request для Browserless Function API
|
||||
|
||||
## Готовые настройки для HTTP Request ноды
|
||||
|
||||
### Method
|
||||
`POST`
|
||||
|
||||
### URL
|
||||
```
|
||||
http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9
|
||||
```
|
||||
|
||||
### Headers
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/javascript"
|
||||
}
|
||||
```
|
||||
|
||||
### Body (Raw)
|
||||
**Content Type:** `application/javascript`
|
||||
|
||||
**Body:**
|
||||
```javascript
|
||||
export default async function ({ page }) {
|
||||
const html = `{{ $json.html }}`;
|
||||
|
||||
if (!html) {
|
||||
throw new Error('❌ HTML не передан');
|
||||
}
|
||||
|
||||
// универсальный sleep
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
await page.setViewport({ width: 1240, height: 1754 });
|
||||
|
||||
// Загружаем HTML напрямую
|
||||
await page.setContent(html, {
|
||||
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
|
||||
});
|
||||
|
||||
// Даём браузеру применить стили
|
||||
await sleep(300);
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
pdf_base64: pdfBuffer.toString('base64'),
|
||||
size_bytes: pdfBuffer.length,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Options
|
||||
- **Timeout:** `40000` (40 секунд)
|
||||
|
||||
### Response Format
|
||||
`JSON` (Browserless вернёт JSON с `pdf_base64`)
|
||||
|
||||
---
|
||||
|
||||
## Вариант с html_base64
|
||||
|
||||
Если у вас HTML в base64, используйте этот вариант:
|
||||
|
||||
```javascript
|
||||
export default async function ({ page }) {
|
||||
// Получаем HTML из base64
|
||||
const htmlBase64 = `{{ $json.html_base64 }}`;
|
||||
const html = Buffer.from(htmlBase64, 'base64').toString('utf8');
|
||||
|
||||
if (!html) {
|
||||
throw new Error('❌ HTML не передан');
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
await page.setViewport({ width: 1240, height: 1754 });
|
||||
|
||||
await page.setContent(html, {
|
||||
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
|
||||
});
|
||||
|
||||
await sleep(300);
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
pdf_base64: pdfBuffer.toString('base64'),
|
||||
size_bytes: pdfBuffer.length,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Полный Workflow
|
||||
|
||||
```
|
||||
[Code: Process Flights Data] ← Генерирует HTML
|
||||
↓
|
||||
[HTTP Request: Browserless Function] ← Используйте настройки выше
|
||||
↓
|
||||
[Code: Extract PDF Base64] ← Если нужно обработать ответ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Node: Extract PDF Base64 (опционально)
|
||||
|
||||
Если Browserless уже вернул `pdf_base64` в JSON, можно просто передать дальше:
|
||||
|
||||
```javascript
|
||||
const response = $input.first().json;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: response.pdf_base64,
|
||||
pdf_size_bytes: response.size_bytes,
|
||||
pdf_size_mb: (response.size_bytes / (1024 * 1024)).toFixed(2),
|
||||
status: response.status,
|
||||
success: true
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Преимущества этого подхода
|
||||
|
||||
✅ **Прямая работа с HTML** - не нужно конвертировать в data URL
|
||||
✅ **Полный контроль** - можете добавить любую логику в функцию
|
||||
✅ **Готовый base64** - Browserless сразу возвращает base64 PDF
|
||||
✅ **Надёжность** - sleep даёт время браузеру применить стили
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
Если получаете ошибки:
|
||||
- **"HTML не передан"** → Проверьте, что предыдущая нода вернула `html` или `html_base64`
|
||||
- **Timeout** → Увеличьте timeout в Options до 60000 (60 секунд)
|
||||
- **Пустой PDF** → Увеличьте sleep до 500-1000ms
|
||||
29
docs/N8N_BROWSERLESS_FUNCTION_SETUP.json
Normal file
29
docs/N8N_BROWSERLESS_FUNCTION_SETUP.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/javascript"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"contentType": "raw",
|
||||
"rawContentType": "application/javascript",
|
||||
"body": "export default async function ({ page }) {\n const html = `{{ $json.html }}`;\n\n if (!html) {\n throw new Error('❌ HTML не передан');\n }\n\n // универсальный sleep\n const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));\n\n await page.setViewport({ width: 1240, height: 1754 });\n\n // Загружаем HTML напрямую\n await page.setContent(html, {\n waitUntil: ['load', 'domcontentloaded', 'networkidle0'],\n });\n\n // Даём браузеру применить стили\n await sleep(300);\n\n const pdfBuffer = await page.pdf({\n format: 'A4',\n printBackground: true,\n margin: {\n top: '20mm',\n right: '15mm',\n bottom: '20mm',\n left: '15mm',\n },\n });\n\n return {\n status: 'success',\n pdf_base64: pdfBuffer.toString('base64'),\n size_bytes: pdfBuffer.length,\n };\n}",
|
||||
"options": {
|
||||
"timeout": 40000
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"name": "Browserless: HTML to PDF"
|
||||
}
|
||||
]
|
||||
}
|
||||
135
docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md
Normal file
135
docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Настройка HTTP Request ноды для Browserless
|
||||
|
||||
## Готовый запрос для вставки
|
||||
|
||||
### Вариант 1: С использованием html_base64 из предыдущей ноды
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**URL:** `http://147.45.146.17:3000/pdf`
|
||||
|
||||
**Headers:**
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_TOKEN"
|
||||
}
|
||||
```
|
||||
*Примечание: Если токен не требуется, уберите строку Authorization*
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"url": "data:text/html;base64,{{ $json.html_base64 }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "15mm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:** `Binary`
|
||||
|
||||
---
|
||||
|
||||
### Вариант 2: Если у вас HTML в строке (не base64)
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**URL:** `http://147.45.146.17:3000/pdf`
|
||||
|
||||
**Headers:**
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"html": "{{ $json.html }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "15mm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:** `Binary`
|
||||
|
||||
---
|
||||
|
||||
## Полный workflow
|
||||
|
||||
```
|
||||
[Code: Process Flights Data] ← Генерирует HTML
|
||||
↓
|
||||
[Code: HTML to Base64] ← Конвертирует HTML в base64 (если нужно)
|
||||
↓
|
||||
[HTTP Request: Browserless PDF] ← Используйте настройки выше
|
||||
↓
|
||||
[Code: Extract Base64 PDF] ← Конвертирует binary в base64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Node: HTML to Base64 (если нужно)
|
||||
|
||||
Если у вас HTML в строке, а нужен base64 для data URL:
|
||||
|
||||
```javascript
|
||||
const html = $json.html;
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html: html
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Node: Extract Base64 PDF (после HTTP Request)
|
||||
|
||||
```javascript
|
||||
const pdfBinary = $binary.data;
|
||||
const base64 = Buffer.isBuffer(pdfBinary)
|
||||
? pdfBinary.toString('base64')
|
||||
: Buffer.from(pdfBinary).toString('base64');
|
||||
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
Если получаете ошибку:
|
||||
- **"Bad or missing authentication"** → Проверьте токен или уберите Authorization header
|
||||
- **"Not Found"** → Проверьте URL эндпоинта
|
||||
- **Пустой ответ** → Проверьте формат HTML и data URL
|
||||
698
docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js
Normal file
698
docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js
Normal file
@@ -0,0 +1,698 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Обработка данных о рейсах из FlightAware и FlightRadar24
|
||||
// ============================================================================
|
||||
// Объединяет данные из двух источников и формирует красивый HTML для PDF
|
||||
// ============================================================================
|
||||
|
||||
// ==== ПОЛУЧЕНИЕ ВХОДНЫХ ДАННЫХ ====
|
||||
// Ожидаемая структура: массив с двумя элементами
|
||||
// [0] - данные из FlightAware (body.flights[])
|
||||
// [1] - данные из FlightRadar24 (body.data[])
|
||||
const inputItems = $input.all();
|
||||
|
||||
if (!inputItems || inputItems.length === 0) {
|
||||
return [{
|
||||
json: {
|
||||
error: 'Нет входных данных',
|
||||
html: '<html><body><h1>Ошибка: данные не получены</h1></body></html>',
|
||||
flights: [],
|
||||
sources: { flightaware: false, flightradar24: false }
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ==== ИЗВЛЕЧЕНИЕ ДАННЫХ ИЗ ИСТОЧНИКОВ ====
|
||||
let flightAwareData = [];
|
||||
let flightRadar24Data = [];
|
||||
|
||||
try {
|
||||
// Первый элемент - FlightAware
|
||||
const faItem = inputItems[0];
|
||||
if (faItem && faItem.json && faItem.json.body && faItem.json.body.flights) {
|
||||
flightAwareData = Array.isArray(faItem.json.body.flights)
|
||||
? faItem.json.body.flights
|
||||
: [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
// Второй элемент - FlightRadar24
|
||||
const fr24Item = inputItems[1];
|
||||
if (fr24Item && fr24Item.json && fr24Item.json.body && fr24Item.json.body.data) {
|
||||
flightRadar24Data = Array.isArray(fr24Item.json.body.data)
|
||||
? fr24Item.json.body.data
|
||||
: [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
|
||||
}
|
||||
|
||||
// ==== УТИЛИТЫ ====
|
||||
const safeStr = (v) => (v == null ? '' : String(v));
|
||||
const safeDate = (v) => {
|
||||
if (!v) return '—';
|
||||
try {
|
||||
const d = new Date(v);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '—';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}ч ${minutes}м`;
|
||||
};
|
||||
|
||||
const formatDistance = (km) => {
|
||||
if (!km) return '—';
|
||||
return `${Number(km).toFixed(2)} км`;
|
||||
};
|
||||
|
||||
// ==== ОБЪЕДИНЕНИЕ ДАННЫХ ПО REGISTRATION ====
|
||||
// Создаём карту для быстрого поиска
|
||||
const flightsMap = new Map();
|
||||
|
||||
// Добавляем данные из FlightAware
|
||||
flightAwareData.forEach(flight => {
|
||||
const reg = safeStr(flight.registration).trim();
|
||||
if (!reg) return;
|
||||
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(flight.flight_number),
|
||||
ident: safeStr(flight.ident),
|
||||
identIata: safeStr(flight.ident_iata),
|
||||
aircraftType: safeStr(flight.aircraft_type),
|
||||
flightAware: flight,
|
||||
flightRadar24: null
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).flightAware = flight;
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем данные из FlightRadar24
|
||||
flightRadar24Data.forEach(flight => {
|
||||
const reg = safeStr(flight.reg).trim();
|
||||
if (!reg) return;
|
||||
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(flight.flight),
|
||||
ident: safeStr(flight.callsign),
|
||||
identIata: safeStr(flight.flight),
|
||||
aircraftType: safeStr(flight.type),
|
||||
flightAware: null,
|
||||
flightRadar24: flight
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).flightRadar24 = flight;
|
||||
}
|
||||
});
|
||||
|
||||
// Преобразуем Map в массив
|
||||
const mergedFlights = Array.from(flightsMap.values());
|
||||
|
||||
// ==== ГЕНЕРАЦИЯ HTML ====
|
||||
const generateFlightCard = (flight) => {
|
||||
const fa = flight.flightAware;
|
||||
const fr24 = flight.flightRadar24;
|
||||
|
||||
let html = `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${flight.flightNumber || flight.ident || 'N/A'}</h2>
|
||||
<span class="registration">${flight.registration}</span>
|
||||
</div>
|
||||
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Тип самолёта:</span>
|
||||
<span class="value">${flight.aircraftType || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Идентификатор:</span>
|
||||
<span class="value">${flight.ident || '—'} (${flight.identIata || '—'})</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Данные из FlightAware
|
||||
if (fa) {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Плановый вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический прилёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус:</span>
|
||||
<span class="status-value">${safeStr(fa.status || '—')}</span>
|
||||
</div>
|
||||
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка вылета:</span>
|
||||
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка прилёта:</span>
|
||||
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_origin ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт вылета:</span>
|
||||
<span class="status-value">${fa.gate_origin}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_destination ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт прилёта:</span>
|
||||
<span class="status-value">${fa.gate_destination}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.baggage_claim ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Выдача багажа:</span>
|
||||
<span class="status-value">${fa.baggage_claim}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Данные из FlightRadar24
|
||||
if (fr24) {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fr24.orig_iata || '—')} (${safeStr(fr24.orig_icao || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fr24.dest_iata || '—')} (${safeStr(fr24.dest_icao || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fr24.datetime_takeoff)} ${fr24.runway_takeoff ? `(ВПП ${fr24.runway_takeoff})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fr24.datetime_landed)} ${fr24.runway_landed ? `(ВПП ${fr24.runway_landed})` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Время полёта:</span>
|
||||
<span class="status-value">${formatDuration(fr24.flight_time)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Фактическое расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr24.actual_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Кратчайшее расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr24.circle_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус полёта:</span>
|
||||
<span class="status-value">${fr24.flight_ended ? 'Завершён' : 'В процессе'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
};
|
||||
|
||||
// ==== ГЕНЕРАЦИЯ ПОЛНОГО HTML ДОКУМЕНТА ====
|
||||
const generateFullHTML = (flights) => {
|
||||
const now = new Date();
|
||||
const reportDate = now.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
let flightsHTML = '';
|
||||
if (flights.length === 0) {
|
||||
flightsHTML = '<div class="no-data">Данные о рейсах не найдены</div>';
|
||||
} else {
|
||||
flightsHTML = flights.map(flight => generateFlightCard(flight)).join('');
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Отчёт о рейсах</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 3px solid #2563eb;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #1e40af;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sources-info {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-tag.available {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.source-tag.unavailable {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.flight-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 25px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.flight-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flight-header h2 {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registration {
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flight-info {
|
||||
padding: 15px 20px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.source-section {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.source-section:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.source-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.source-badge.source-flightaware {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.source-badge.source-flightradar24 {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.source-missing {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.source-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.route-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.route-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.route-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.route-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-value {
|
||||
color: #111827;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.delay-negative {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.delay-positive {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
box-shadow: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.flight-card {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Отчёт о рейсах</h1>
|
||||
<div class="header-meta">
|
||||
<div>Дата формирования: ${reportDate}</div>
|
||||
<div class="sources-info">
|
||||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flights-container">
|
||||
${flightsHTML}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
// ==== ФОРМИРОВАНИЕ РЕЗУЛЬТАТА ====
|
||||
const html = generateFullHTML(mergedFlights);
|
||||
|
||||
// ==== ПОДГОТОВКА ДАННЫХ ДЛЯ КОНВЕРТАЦИИ В BASE64 PDF ====
|
||||
// Эти данные будут использованы в следующей HTTP Request ноде
|
||||
// для конвертации HTML в PDF и получения base64
|
||||
|
||||
// Настройки сервиса конвертации (замените на ваши)
|
||||
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
|
||||
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
|
||||
|
||||
// Подготовка запроса для HTTP Request ноды
|
||||
const pdfRequestData = {
|
||||
method: 'POST',
|
||||
url: PDF_SERVICE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
base64: true // Запрашиваем base64 напрямую
|
||||
})
|
||||
};
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html: html,
|
||||
flights: mergedFlights,
|
||||
flights_count: mergedFlights.length,
|
||||
sources: {
|
||||
flightaware: {
|
||||
available: flightAwareData.length > 0,
|
||||
count: flightAwareData.length
|
||||
},
|
||||
flightradar24: {
|
||||
available: flightRadar24Data.length > 0,
|
||||
count: flightRadar24Data.length
|
||||
}
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
|
||||
// Данные для конвертации в PDF (используйте в следующей HTTP Request ноде)
|
||||
pdf_request: pdfRequestData,
|
||||
pdf_request_method: pdfRequestData.method,
|
||||
pdf_request_url: pdfRequestData.url,
|
||||
pdf_request_headers: pdfRequestData.headers,
|
||||
pdf_request_body: pdfRequestData.body
|
||||
}
|
||||
}];
|
||||
110
docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js
Normal file
110
docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Извлечение Base64 PDF из ответа HTTP Request
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ HTTP Request ноды, которая конвертировала HTML в PDF
|
||||
// ============================================================================
|
||||
|
||||
const response = $input.first();
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Ответ от HTTP Request не получен');
|
||||
}
|
||||
|
||||
let base64 = null;
|
||||
let pdfSize = 0;
|
||||
|
||||
// ==== ВАРИАНТ 1: Сервис вернул base64 в JSON ====
|
||||
if (response.json) {
|
||||
// htmlpdfapi.com возвращает: { pdf: "base64..." }
|
||||
if (response.json.pdf) {
|
||||
base64 = response.json.pdf;
|
||||
pdfSize = Math.floor(base64.length * 0.75); // Примерный размер
|
||||
}
|
||||
// api2pdf.com возвращает: { Pdf: "base64..." }
|
||||
else if (response.json.Pdf) {
|
||||
base64 = response.json.Pdf;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
// pdfshift.io возвращает: { pdf: "base64..." }
|
||||
else if (response.json.pdf) {
|
||||
base64 = response.json.pdf;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
// Если base64 в другом поле
|
||||
else if (response.json.base64) {
|
||||
base64 = response.json.base64;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
// Если base64 в body
|
||||
else if (response.json.body && typeof response.json.body === 'string') {
|
||||
base64 = response.json.body;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 2: Сервис вернул binary PDF ====
|
||||
if (!base64 && response.binary && response.binary.data) {
|
||||
const pdfBinary = response.binary.data;
|
||||
|
||||
// Конвертируем binary в base64
|
||||
if (Buffer.isBuffer(pdfBinary)) {
|
||||
base64 = pdfBinary.toString('base64');
|
||||
pdfSize = pdfBinary.length;
|
||||
} else if (typeof pdfBinary === 'string') {
|
||||
// Если уже base64 строка
|
||||
base64 = pdfBinary;
|
||||
pdfSize = Buffer.from(base64, 'base64').length;
|
||||
} else {
|
||||
// Пытаемся преобразовать
|
||||
const buffer = Buffer.from(pdfBinary);
|
||||
base64 = buffer.toString('base64');
|
||||
pdfSize = buffer.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 3: PDF в текстовом формате (base64 строка) ====
|
||||
if (!base64 && response.json && typeof response.json === 'string') {
|
||||
base64 = response.json;
|
||||
pdfSize = Buffer.from(base64, 'base64').length;
|
||||
}
|
||||
|
||||
// ==== ПРОВЕРКА РЕЗУЛЬТАТА ====
|
||||
if (!base64) {
|
||||
console.error('❌ Не удалось извлечь base64. Структура ответа:', Object.keys(response));
|
||||
throw new Error('Не удалось извлечь base64 PDF из ответа. Проверьте формат ответа сервиса.');
|
||||
}
|
||||
|
||||
// Проверяем, что это действительно base64
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(base64)) {
|
||||
throw new Error('Извлечённые данные не являются валидным base64');
|
||||
}
|
||||
|
||||
const pdfSizeMB = (pdfSize / (1024 * 1024)).toFixed(2);
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const filename = `flights-report-${timestamp}.pdf`;
|
||||
|
||||
console.log('✅ Base64 PDF извлечён успешно');
|
||||
console.log('📊 Размер PDF:', pdfSizeMB, 'MB');
|
||||
|
||||
// ==== ВОЗВРАТ РЕЗУЛЬТАТА ====
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: pdfSize,
|
||||
pdf_size_mb: pdfSizeMB,
|
||||
filename: filename,
|
||||
success: true,
|
||||
generated_at: new Date().toISOString()
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИСПОЛЬЗОВАНИЕ РЕЗУЛЬТАТА:
|
||||
// ============================================================================
|
||||
// Теперь у вас есть base64 PDF в поле pdf_base64
|
||||
// Вы можете:
|
||||
// 1. Сохранить в файл
|
||||
// 2. Отправить по email
|
||||
// 3. Загрузить в S3/Nextcloud
|
||||
// 4. Вернуть в API response
|
||||
// ============================================================================
|
||||
99
docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js
Normal file
99
docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через Browserless (полная версия)
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
} else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
} else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
} else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
} else {
|
||||
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== НАСТРОЙКИ BROWSERLESS ==================
|
||||
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
|
||||
// ⚠️ ВАЖНО: Если Browserless требует токен, замените на ваш токен
|
||||
// Если токен не требуется, оставьте пустую строку или удалите Authorization header
|
||||
const BROWSERLESS_TOKEN = ''; // Замените на ваш токен, если требуется
|
||||
|
||||
// Конвертируем HTML в data URL для передачи в Browserless
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const dataUrl = `data:text/html;base64,${htmlBase64}`;
|
||||
|
||||
// Формируем headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Добавляем токен, если он указан
|
||||
if (BROWSERLESS_TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${BROWSERLESS_TOKEN}`;
|
||||
}
|
||||
|
||||
// ================== ПОДГОТОВКА ЗАПРОСА ==================
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: headers,
|
||||
|
||||
// Тело запроса - передаём HTML через data URL
|
||||
body: JSON.stringify({
|
||||
url: dataUrl,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Метаданные для отладки
|
||||
html_length: html.length,
|
||||
data_url_length: dataUrl.length,
|
||||
browserless_url: BROWSERLESS_URL
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// 1. Замените BROWSERLESS_TOKEN на ваш токен (если требуется)
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде настройте:
|
||||
// - Method: {{ $json.method }}
|
||||
// - URL: {{ $json.url }}
|
||||
// - Headers: {{ $json.headers }}
|
||||
// - Body: {{ $json.body }}
|
||||
// - Response Format: Binary (Browserless возвращает PDF как binary)
|
||||
// 4. После HTTP Request добавьте Code Node для конвертации binary в base64:
|
||||
//
|
||||
// const pdfBinary = $binary.data;
|
||||
// const base64 = Buffer.isBuffer(pdfBinary)
|
||||
// ? pdfBinary.toString('base64')
|
||||
// : Buffer.from(pdfBinary).toString('base64');
|
||||
//
|
||||
// return [{
|
||||
// json: {
|
||||
// pdf_base64: base64,
|
||||
// pdf_size_bytes: Buffer.from(base64, 'base64').length,
|
||||
// success: true
|
||||
// }
|
||||
// }];
|
||||
// ============================================================================
|
||||
99
docs/N8N_FLIGHTS_BROWSERLESS_PDF.js
Normal file
99
docs/N8N_FLIGHTS_BROWSERLESS_PDF.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через Browserless
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
|
||||
// Подготавливает запрос для HTTP Request ноды к Browserless
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
// Вариант 1: HTML уже есть в json.html
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
}
|
||||
// Вариант 2: HTML в base64
|
||||
else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
}
|
||||
// Вариант 3: HTML в другом поле
|
||||
else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
}
|
||||
// Вариант 4: Пытаемся получить из binary
|
||||
else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
}
|
||||
else {
|
||||
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== НАСТРОЙКИ BROWSERLESS ==================
|
||||
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
|
||||
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен Browserless
|
||||
|
||||
// ================== ВАРИАНТ 1: Использование data URL ==================
|
||||
// Browserless может принимать HTML через data URL
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const dataUrl = `data:text/html;base64,${htmlBase64}`;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}` // Если требуется токен
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataUrl, // Передаём HTML через data URL
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Альтернативный вариант (если Browserless поддерживает прямой HTML)
|
||||
body_alternative: JSON.stringify({
|
||||
html: html, // Прямая передача HTML (если поддерживается)
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Метаданные
|
||||
html_length: html.length,
|
||||
data_url_length: dataUrl.length
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// 1. Замените YOUR_TOKEN на ваш реальный токен Browserless (если требуется)
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде настройте:
|
||||
// - Method: {{ $json.method }}
|
||||
// - URL: {{ $json.url }}
|
||||
// - Headers: {{ $json.headers }}
|
||||
// - Body: {{ $json.body }}
|
||||
// - Response Format: Binary (или JSON, если Browserless возвращает base64)
|
||||
// 4. После HTTP Request добавьте Code Node для извлечения base64 из ответа
|
||||
// (используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js)
|
||||
// ============================================================================
|
||||
124
docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js
Normal file
124
docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через Browserless (вариант с прямым HTML)
|
||||
// ============================================================================
|
||||
// Альтернативный вариант - передача HTML напрямую в body
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
} else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
} else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
} else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
} else {
|
||||
throw new Error('HTML не найден');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== НАСТРОЙКИ ==================
|
||||
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
|
||||
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен
|
||||
|
||||
// ================== ВАРИАНТ: Использование /screenshot или /pdf ==================
|
||||
// Browserless может иметь разные эндпоинты
|
||||
|
||||
// Вариант A: POST /pdf с HTML в body
|
||||
const requestA = {
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Вариант B: POST /pdf с data URL
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const dataUrl = `data:text/html;base64,${htmlBase64}`;
|
||||
|
||||
const requestB = {
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataUrl,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Вариант C: POST /screenshot (если /pdf не работает)
|
||||
const requestC = {
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/screenshot`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataUrl,
|
||||
options: {
|
||||
type: 'pdf',
|
||||
format: 'A4',
|
||||
printBackground: true
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Используйте один из вариантов ниже
|
||||
// Попробуйте сначала вариант A, если не работает - B, затем C
|
||||
|
||||
// === ВАРИАНТ A: Прямой HTML ===
|
||||
method_a: requestA.method,
|
||||
url_a: requestA.url,
|
||||
headers_a: requestA.headers,
|
||||
body_a: requestA.body,
|
||||
|
||||
// === ВАРИАНТ B: Data URL ===
|
||||
method_b: requestB.method,
|
||||
url_b: requestB.url,
|
||||
headers_b: requestB.headers,
|
||||
body_b: requestB.body,
|
||||
|
||||
// === ВАРИАНТ C: Screenshot (PDF) ===
|
||||
method_c: requestC.method,
|
||||
url_c: requestC.url,
|
||||
headers_c: requestC.headers,
|
||||
body_c: requestC.body,
|
||||
|
||||
// Метаданные
|
||||
html_length: html.length,
|
||||
instruction: 'Попробуйте сначала вариант A в HTTP Request ноде'
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ОТЛАДКА:
|
||||
// ============================================================================
|
||||
// Если получаете ошибку аутентификации:
|
||||
// 1. Проверьте, нужен ли токен для вашего Browserless
|
||||
// 2. Если токен не требуется, уберите строку Authorization из headers
|
||||
// 3. Проверьте документацию Browserless: https://docs.browserless.io
|
||||
// ============================================================================
|
||||
112
docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md
Normal file
112
docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Полный Workflow: HTML → Base64 PDF
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
[HTTP Request: FlightAware]
|
||||
↓
|
||||
[HTTP Request: FlightRadar24]
|
||||
↓
|
||||
[Code: Process Flights Data] ← Генерирует HTML + подготавливает запрос для PDF
|
||||
↓
|
||||
[HTTP Request: Convert to PDF] ← Конвертирует HTML в base64 PDF
|
||||
↓
|
||||
[Code: Extract Base64 PDF] ← Извлекает base64 из ответа
|
||||
↓
|
||||
[Использование base64 PDF]
|
||||
```
|
||||
|
||||
## Настройка нод
|
||||
|
||||
### 1. Code: Process Flights Data
|
||||
|
||||
**Код:** Используйте обновлённый `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"html": "<!DOCTYPE html>...",
|
||||
"flights": [...],
|
||||
"pdf_request_method": "POST",
|
||||
"pdf_request_url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"pdf_request_headers": {...},
|
||||
"pdf_request_body": "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. HTTP Request: Convert to PDF
|
||||
|
||||
**Название:** `HTTP Request: Convert to PDF`
|
||||
|
||||
**Настройка:**
|
||||
- **Method:** `{{ $json.pdf_request_method }}`
|
||||
- **URL:** `{{ $json.pdf_request_url }}`
|
||||
- **Authentication:** None (или по необходимости)
|
||||
- **Headers:**
|
||||
```json
|
||||
{{ $json.pdf_request_headers }}
|
||||
```
|
||||
- **Body:**
|
||||
```json
|
||||
{{ $json.pdf_request_body }}
|
||||
```
|
||||
- **Response Format:** `JSON`
|
||||
|
||||
### 3. Code: Extract Base64 PDF
|
||||
|
||||
**Название:** `Code: Extract Base64 PDF`
|
||||
|
||||
**Код:** Используйте `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
|
||||
"pdf_size_mb": "0.12",
|
||||
"filename": "flights-report-2026-01-16.pdf",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Альтернатива: Использование Convert to File
|
||||
|
||||
Если вы хотите использовать ноду **Convert to File** для создания HTML файла, а затем конвертировать его в PDF:
|
||||
|
||||
### Вариант A: HTML файл → PDF через сервис
|
||||
|
||||
```
|
||||
[Code: Process Flights Data]
|
||||
↓
|
||||
[Convert to File] ← Operation: "html", Put Output File in Field: {{ $json.html }}
|
||||
↓
|
||||
[HTTP Request: Convert to PDF] ← Отправьте binary HTML файл в сервис конвертации
|
||||
↓
|
||||
[Code: Extract Base64 PDF]
|
||||
```
|
||||
|
||||
### Вариант B: Прямая конвертация HTML → Base64 PDF
|
||||
|
||||
Пропустите ноду Convert to File и используйте HTML напрямую:
|
||||
|
||||
```
|
||||
[Code: Process Flights Data]
|
||||
↓
|
||||
[HTTP Request: Convert to PDF] ← Используйте {{ $json.html }} в body
|
||||
↓
|
||||
[Code: Extract Base64 PDF]
|
||||
```
|
||||
|
||||
## Настройка API ключа
|
||||
|
||||
В файле `N8N_CODE_PROCESS_FLIGHTS_DATA.js` найдите строку:
|
||||
```javascript
|
||||
const PDF_API_KEY = 'YOUR_API_KEY';
|
||||
```
|
||||
|
||||
Замените `YOUR_API_KEY` на ваш реальный API ключ от сервиса конвертации.
|
||||
|
||||
## Популярные сервисы
|
||||
|
||||
1. **htmlpdfapi.com** - 100 PDF/месяц бесплатно
|
||||
2. **pdfshift.io** - 100 PDF/месяц бесплатно
|
||||
3. **api2pdf.com** - 50 PDF/месяц бесплатно
|
||||
132
docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js
Normal file
132
docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через браузер (Puppeteer/Playwright)
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
|
||||
// Подготавливает команду для Execute Command ноды с puppeteer
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
// Вариант 1: HTML уже есть в json.html
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
}
|
||||
// Вариант 2: HTML в base64
|
||||
else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
}
|
||||
// Вариант 3: HTML в другом поле
|
||||
else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
}
|
||||
// Вариант 4: Пытаемся получить из binary
|
||||
else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
}
|
||||
else {
|
||||
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== ВАРИАНТ 1: Execute Command с Puppeteer ==================
|
||||
// Требует: npm install puppeteer в контейнере n8n
|
||||
// Команда для Execute Command ноды:
|
||||
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const timestamp = Date.now();
|
||||
const htmlFile = `/tmp/flights-${timestamp}.html`;
|
||||
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
|
||||
|
||||
// Команда для Execute Command ноды:
|
||||
const command = `node -e "
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
const html = Buffer.from('${htmlBase64}', 'base64').toString('utf8');
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
await page.pdf({
|
||||
path: '${pdfFile}',
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
});
|
||||
await browser.close();
|
||||
const pdfBuffer = fs.readFileSync('${pdfFile}');
|
||||
const base64 = pdfBuffer.toString('base64');
|
||||
console.log(base64);
|
||||
fs.unlinkSync('${pdfFile}');
|
||||
})();
|
||||
"`;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Команда для Execute Command ноды
|
||||
command: command,
|
||||
|
||||
// Или используйте этот вариант (проще):
|
||||
html_file: htmlFile,
|
||||
pdf_file: pdfFile,
|
||||
html_base64: htmlBase64,
|
||||
|
||||
// Инструкция
|
||||
instruction: 'Используйте Execute Command ноду с одной из команд ниже'
|
||||
}
|
||||
}];
|
||||
|
||||
// ================== ВАРИАНТ 2: HTTP Request к сервису с браузером ==================
|
||||
// Раскомментируйте, если используете внешний сервис (Gotenberg, Browserless, etc.)
|
||||
|
||||
/*
|
||||
const PDF_SERVICE_URL = 'https://api.gotenberg.dev/forms/chromium/convert/html';
|
||||
// Или Browserless: 'https://chrome.browserless.io/pdf'
|
||||
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: PDF_SERVICE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
body: {
|
||||
files: [{
|
||||
name: 'index.html',
|
||||
content: html
|
||||
}],
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// ВАРИАНТ 1: Execute Command (если puppeteer установлен)
|
||||
// 1. Установите puppeteer в контейнере n8n:
|
||||
// docker exec -it <n8n_container> npm install puppeteer
|
||||
// 2. Добавьте Execute Command ноду после этого Code Node
|
||||
// 3. В команде используйте: {{ $json.command }}
|
||||
// 4. После Execute Command добавьте Code Node для извлечения base64 из вывода
|
||||
//
|
||||
// ВАРИАНТ 2: HTTP Request к Gotenberg (self-hosted браузер)
|
||||
// 1. Запустите Gotenberg: docker run -p 3000:3000 gotenberg/gotenberg:7
|
||||
// 2. Используйте код выше (раскомментируйте)
|
||||
// 3. Добавьте HTTP Request ноду
|
||||
//
|
||||
// ВАРИАНТ 3: HTTP Request к Browserless (cloud сервис)
|
||||
// 1. Зарегистрируйтесь на browserless.io
|
||||
// 2. Используйте их API для конвертации
|
||||
// ============================================================================
|
||||
96
docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js
Normal file
96
docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Конвертация HTML в Base64 PDF
|
||||
// ============================================================================
|
||||
// Используйте этот код после "Code: Process Flights Data"
|
||||
// для подготовки данных для конвертации в PDF и получения base64
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
const processedData = $('Code: Process Flights Data').first().json;
|
||||
|
||||
if (!processedData || !processedData.html) {
|
||||
throw new Error('HTML не получен из предыдущей ноды');
|
||||
}
|
||||
|
||||
const html = processedData.html;
|
||||
|
||||
// ==== ВАРИАНТ 1: HTTP Request к сервису, который возвращает base64 PDF ====
|
||||
// Используйте этот вариант с HTTP Request нодой после этого Code Node
|
||||
// Сервисы, которые поддерживают base64:
|
||||
// - htmlpdfapi.com
|
||||
// - pdfshift.io
|
||||
// - api2pdf.com
|
||||
// - и другие
|
||||
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: 'https://api.htmlpdfapi.com/v1/pdf', // Замените на ваш сервис
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY' // Замените на ваш API ключ
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
// Если сервис поддерживает прямое возвращение base64
|
||||
base64: true
|
||||
})
|
||||
}
|
||||
}];
|
||||
|
||||
// ==== ВАРИАНТ 2: Если сервис возвращает binary, конвертируем в base64 ====
|
||||
// Используйте этот код в Code Node ПОСЛЕ HTTP Request ноды
|
||||
// (когда получили PDF в binary формате)
|
||||
/*
|
||||
const pdfBinary = $binary.data; // Получаем binary данные из HTTP Request
|
||||
|
||||
// Конвертируем binary в base64
|
||||
const base64 = pdfBinary.toString('base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: pdfBinary.length,
|
||||
pdf_size_mb: (pdfBinary.length / (1024 * 1024)).toFixed(2),
|
||||
flights_count: processedData.flights_count,
|
||||
generated_at: processedData.generated_at,
|
||||
filename: `flights-report-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ==== ВАРИАНТ 3: Использование Execute Command с wkhtmltopdf ====
|
||||
// Если у вас установлен wkhtmltopdf на сервере n8n
|
||||
// Раскомментируйте и используйте в Execute Command ноде
|
||||
/*
|
||||
// Сохраняем HTML во временный файл
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const timestamp = Date.now();
|
||||
const htmlFile = `/tmp/flights-${timestamp}.html`;
|
||||
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
|
||||
|
||||
// Команда для Execute Command ноды:
|
||||
// echo '{{ $json.html_base64 }}' | base64 -d > {{ $json.html_file }} && \
|
||||
// wkhtmltopdf --page-size A4 --margin-top 20mm --margin-right 15mm --margin-bottom 20mm --margin-left 15mm \
|
||||
// --print-media-type {{ $json.html_file }} {{ $json.pdf_file }} && \
|
||||
// cat {{ $json.pdf_file }} | base64 && \
|
||||
// rm -f {{ $json.html_file }} {{ $json.pdf_file }}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html_file: htmlFile,
|
||||
pdf_file: pdfFile
|
||||
}
|
||||
}];
|
||||
*/
|
||||
35
docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md
Normal file
35
docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Улучшенное форматирование PDF отчёта
|
||||
|
||||
## Что изменено
|
||||
|
||||
### Уменьшены отступы:
|
||||
- **Padding секций:** с `20px` → `12px 18px`
|
||||
- **Margin между элементами:** с `20px` → `12px`
|
||||
- **Padding карточек:** с `20px` → `14px 18px`
|
||||
- **Отступы в timeline:** с `10px` → `6px`
|
||||
|
||||
### Уменьшены размеры шрифтов:
|
||||
- **Заголовки:** с `24px` → `20px`
|
||||
- **Основной текст:** с `14px` → `13px`
|
||||
- **Метки:** с `12px` → `11px`
|
||||
|
||||
### Более компактная компоновка:
|
||||
- **Route info:** уменьшен gap с `15px` → `12px`
|
||||
- **Status info:** уменьшен gap с `15px` → `10px`, minmax с `200px` → `180px`
|
||||
- **Timeline:** уменьшена ширина label с `180px` → `160px`
|
||||
|
||||
### Общие улучшения:
|
||||
- Уменьшен `line-height` с `1.6` → `1.4` для более плотного текста
|
||||
- Уменьшены отступы body с `20px` → `15px`
|
||||
- Уменьшены отступы container с `30px` → `20px`
|
||||
|
||||
## Результат
|
||||
|
||||
✅ **Более компактное отображение** - данные расположены ближе друг к другу
|
||||
✅ **Меньше разрывов** - плавные переходы между секциями
|
||||
✅ **Лучшая читаемость** - оптимальный баланс между компактностью и читаемостью
|
||||
✅ **Экономия места** - больше информации на странице
|
||||
|
||||
## Как применить
|
||||
|
||||
Скопируйте обновлённый код из `N8N_FLIGHTS_TO_BASE64.js` в вашу Code Node "причесываем данные".
|
||||
72
docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
Normal file
72
docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Полный цикл - HTML → Base64 PDF (всё в одном)
|
||||
// ============================================================================
|
||||
// Этот код делает всё: получает HTML, отправляет на конвертацию, получает base64
|
||||
// Требует настройки HTTP Request ноды или внешнего сервиса
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды "Code: Process Flights Data"
|
||||
const processedData = $('Code: Process Flights Data').first().json;
|
||||
|
||||
if (!processedData || !processedData.html) {
|
||||
throw new Error('HTML не получен из предыдущей ноды');
|
||||
}
|
||||
|
||||
const html = processedData.html;
|
||||
|
||||
// ==== НАСТРОЙКИ ====
|
||||
// Замените на ваши параметры
|
||||
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
|
||||
const PDF_API_KEY = 'YOUR_API_KEY'; // Замените на ваш ключ
|
||||
|
||||
// ==== ПОДГОТОВКА ЗАПРОСА ДЛЯ HTTP REQUEST ====
|
||||
// Этот код подготавливает данные для HTTP Request ноды
|
||||
// После этого Code Node добавьте HTTP Request ноду и используйте эти данные
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
http_method: 'POST',
|
||||
http_url: PDF_SERVICE_URL,
|
||||
http_headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
http_body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
base64: true // Запрашиваем base64 напрямую
|
||||
}),
|
||||
|
||||
// Метаданные
|
||||
html_length: html.length,
|
||||
flights_count: processedData.flights_count,
|
||||
generated_at: processedData.generated_at,
|
||||
|
||||
// Инструкция для следующей ноды
|
||||
next_step: 'HTTP Request → Code: Extract Base64 PDF'
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// 1. Этот Code Node подготавливает запрос
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде используйте:
|
||||
// - Method: {{ $json.http_method }}
|
||||
// - URL: {{ $json.http_url }}
|
||||
// - Headers: {{ $json.http_headers }}
|
||||
// - Body: {{ $json.http_body }}
|
||||
// 4. После HTTP Request добавьте Code Node с кодом из N8N_FLIGHTS_PDF_BASE64_FULL.js
|
||||
// для извлечения base64 из ответа
|
||||
// ============================================================================
|
||||
81
docs/N8N_FLIGHTS_PDF_BASE64_FULL.js
Normal file
81
docs/N8N_FLIGHTS_PDF_BASE64_FULL.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Полная обработка - HTML → Base64 PDF
|
||||
// ============================================================================
|
||||
// Этот код обрабатывает ответ от HTTP Request и возвращает base64 PDF
|
||||
// Используйте ПОСЛЕ HTTP Request ноды, которая конвертирует HTML в PDF
|
||||
// ============================================================================
|
||||
|
||||
// Получаем данные из HTTP Request ноды
|
||||
const httpResponse = $input.first();
|
||||
|
||||
if (!httpResponse) {
|
||||
throw new Error('Ответ от HTTP Request не получен');
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 1: Сервис вернул base64 напрямую в JSON ====
|
||||
if (httpResponse.json && httpResponse.json.pdf) {
|
||||
const base64 = httpResponse.json.pdf;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: Math.floor(base64.length * 0.75), // Примерный размер
|
||||
pdf_size_mb: (Math.floor(base64.length * 0.75) / (1024 * 1024)).toFixed(2),
|
||||
success: true,
|
||||
source: 'json_response'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 2: Сервис вернул binary данные ====
|
||||
if (httpResponse.binary && httpResponse.binary.data) {
|
||||
const pdfBinary = httpResponse.binary.data;
|
||||
|
||||
// Конвертируем binary в base64
|
||||
// В n8n binary.data может быть Buffer или строка
|
||||
let base64;
|
||||
if (Buffer.isBuffer(pdfBinary)) {
|
||||
base64 = pdfBinary.toString('base64');
|
||||
} else if (typeof pdfBinary === 'string') {
|
||||
// Если уже base64 строка
|
||||
base64 = pdfBinary;
|
||||
} else {
|
||||
// Пытаемся преобразовать
|
||||
base64 = Buffer.from(pdfBinary).toString('base64');
|
||||
}
|
||||
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true,
|
||||
source: 'binary_response'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 3: Сервис вернул base64 в поле body или data ====
|
||||
if (httpResponse.json) {
|
||||
const body = httpResponse.json.body || httpResponse.json.data || httpResponse.json;
|
||||
|
||||
if (body.pdf || body.base64 || body.content) {
|
||||
const base64 = body.pdf || body.base64 || body.content;
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true,
|
||||
source: 'body_field'
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
// ==== ОШИБКА: Не удалось извлечь PDF ====
|
||||
throw new Error('Не удалось извлечь PDF из ответа. Структура ответа: ' + JSON.stringify(Object.keys(httpResponse), null, 2));
|
||||
65
docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js
Normal file
65
docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Подготовка данных запроса рейса
|
||||
// ============================================================================
|
||||
// Используйте эту ноду ПЕРЕД "причесываем данные"
|
||||
// Она безопасно получает данные из ноды "запрос рейса" и передаёт их дальше
|
||||
// ============================================================================
|
||||
|
||||
// Получаем данные из ноды "запрос рейса"
|
||||
let requestData = {
|
||||
flight_number: null,
|
||||
departure_date_local: null,
|
||||
arrival_date_local: null
|
||||
};
|
||||
|
||||
try {
|
||||
const requestNode = $('запрос рейса');
|
||||
if (requestNode && requestNode.first()) {
|
||||
const requestJson = requestNode.first().json;
|
||||
if (requestJson) {
|
||||
requestData = {
|
||||
flight_number: requestJson.flight_number || requestJson.ident || requestJson.flight || null,
|
||||
departure_date_local: requestJson.departure_date_local || null,
|
||||
arrival_date_local: requestJson.arrival_date_local || null
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Не удалось получить данные из ноды "запрос рейса":', e.message);
|
||||
}
|
||||
|
||||
// Получаем данные из входных элементов (fallback)
|
||||
const inputItems = $input.all();
|
||||
inputItems.forEach(item => {
|
||||
if (item.json) {
|
||||
if (!requestData.flight_number && item.json.flight_number) {
|
||||
requestData.flight_number = item.json.flight_number;
|
||||
}
|
||||
if (!requestData.departure_date_local && item.json.departure_date_local) {
|
||||
requestData.departure_date_local = item.json.departure_date_local;
|
||||
}
|
||||
if (!requestData.arrival_date_local && item.json.arrival_date_local) {
|
||||
requestData.arrival_date_local = item.json.arrival_date_local;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Передаём данные дальше вместе с входными данными
|
||||
const outputItems = inputItems.map(item => ({
|
||||
...item,
|
||||
json: {
|
||||
...item.json,
|
||||
// Добавляем данные запроса
|
||||
request_flight_number: requestData.flight_number,
|
||||
request_departure_date: requestData.departure_date_local,
|
||||
request_arrival_date: requestData.arrival_date_local
|
||||
}
|
||||
}));
|
||||
|
||||
return outputItems.length > 0 ? outputItems : [{
|
||||
json: {
|
||||
request_flight_number: requestData.flight_number,
|
||||
request_departure_date: requestData.departure_date_local,
|
||||
request_arrival_date: requestData.arrival_date_local
|
||||
}
|
||||
}];
|
||||
193
docs/N8N_FLIGHTS_PROCESSING_GUIDE.md
Normal file
193
docs/N8N_FLIGHTS_PROCESSING_GUIDE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Обработка данных о рейсах в n8n
|
||||
|
||||
## Описание
|
||||
|
||||
Код для обработки данных о рейсах из двух источников (FlightAware и FlightRadar24), объединения их и генерации красивого HTML для последующей конвертации в PDF.
|
||||
|
||||
## Структура входных данных
|
||||
|
||||
Workflow должен получать данные в следующем формате:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"body": {
|
||||
"flights": [
|
||||
{
|
||||
"ident": "CES747",
|
||||
"registration": "B-1308",
|
||||
"origin": { "code_iata": "KMG", "name": "Kunming Changshui Int'l" },
|
||||
"destination": { "code_iata": "PVG", "name": "Shanghai Pudong Int'l" },
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"data": [
|
||||
{
|
||||
"flight": "MU747",
|
||||
"reg": "B-1308",
|
||||
"orig_iata": "KMG",
|
||||
"dest_iata": "PVG",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Установка в n8n
|
||||
|
||||
### Шаг 1: Добавить Code Node
|
||||
|
||||
1. В вашем workflow после получения данных из FlightAware и FlightRadar24
|
||||
2. Добавьте ноду **Code** (JavaScript)
|
||||
3. Назовите её: `Code: Process Flights Data`
|
||||
|
||||
### Шаг 2: Вставить код
|
||||
|
||||
Скопируйте содержимое файла `N8N_CODE_PROCESS_FLIGHTS_DATA.js` в Code Node.
|
||||
|
||||
### Шаг 3: Настройка выхода
|
||||
|
||||
Code Node вернёт объект с полями:
|
||||
- `html` - готовый HTML для конвертации в PDF
|
||||
- `flights` - массив объединённых данных о рейсах
|
||||
- `flights_count` - количество рейсов
|
||||
- `sources` - информация о доступности источников
|
||||
- `generated_at` - время генерации
|
||||
|
||||
## Конвертация HTML в Base64 PDF
|
||||
|
||||
### Вариант 1: HTTP Request → Base64 PDF (Рекомендуется)
|
||||
|
||||
**Шаг 1:** После Code Node добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
|
||||
- Этот код подготавливает запрос для HTTP Request
|
||||
|
||||
**Шаг 2:** Добавьте HTTP Request ноду:
|
||||
- Method: `POST`
|
||||
- URL: `{{ $json.http_url }}` (например, `https://api.htmlpdfapi.com/v1/pdf`)
|
||||
- Headers: `{{ $json.http_headers }}`
|
||||
- Body: `{{ $json.http_body }}`
|
||||
- Response Format: `JSON` или `Binary` (в зависимости от сервиса)
|
||||
|
||||
**Шаг 3:** После HTTP Request добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
|
||||
- Этот код извлекает base64 из ответа сервиса
|
||||
|
||||
**Результат:** В выходных данных будет поле `pdf_base64` с готовым PDF в формате base64
|
||||
|
||||
### Вариант 2: Прямой запрос к сервису
|
||||
|
||||
Используйте код из `N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js` для подготовки запроса к сервису конвертации.
|
||||
|
||||
**Популярные сервисы:**
|
||||
- **htmlpdfapi.com** - возвращает base64 в JSON
|
||||
- **pdfshift.io** - поддерживает base64
|
||||
- **api2pdf.com** - возвращает base64
|
||||
- **gotenberg.dev** - бесплатный self-hosted вариант
|
||||
|
||||
### Вариант 3: Execute Command с wkhtmltopdf
|
||||
|
||||
Если на сервере n8n установлен `wkhtmltopdf`:
|
||||
|
||||
1. Сохраните HTML во временный файл
|
||||
2. Выполните команду:
|
||||
```bash
|
||||
wkhtmltopdf --page-size A4 \
|
||||
--margin-top 20mm --margin-right 15mm \
|
||||
--margin-bottom 20mm --margin-left 15mm \
|
||||
--print-media-type input.html output.pdf && \
|
||||
cat output.pdf | base64
|
||||
```
|
||||
3. Получите base64 из вывода команды
|
||||
|
||||
### Использование base64 PDF
|
||||
|
||||
После получения base64 вы можете:
|
||||
- Сохранить в файл
|
||||
- Отправить по email
|
||||
- Загрузить в S3/Nextcloud
|
||||
- Вернуть в API response
|
||||
- Использовать в других workflow
|
||||
|
||||
## Особенности обработки
|
||||
|
||||
### Объединение данных
|
||||
|
||||
Данные объединяются по полю `registration` (номер самолёта):
|
||||
- FlightAware: `flight.registration`
|
||||
- FlightRadar24: `flight.reg`
|
||||
|
||||
Если для рейса есть данные только из одного источника, они всё равно будут отображены.
|
||||
|
||||
### Обработка отсутствующих данных
|
||||
|
||||
- Если данные из источника отсутствуют, показывается сообщение "Данные не получены"
|
||||
- Пустые значения отображаются как "—"
|
||||
- Даты форматируются в читаемый формат
|
||||
|
||||
### Форматирование
|
||||
|
||||
HTML включает:
|
||||
- Красивый дизайн с градиентами и карточками
|
||||
- Адаптивную вёрстку
|
||||
- Стили для печати (media queries для print)
|
||||
- Цветовую индикацию источников данных
|
||||
- Информацию о задержках (зелёный/красный)
|
||||
|
||||
## Пример workflow
|
||||
|
||||
```
|
||||
HTTP Request (FlightAware)
|
||||
↓
|
||||
HTTP Request (FlightRadar24)
|
||||
↓
|
||||
Code: Process Flights Data ← Вставить код отсюда
|
||||
↓
|
||||
HTML/CSS to PDF (или HTTP Request для конвертации)
|
||||
↓
|
||||
Save File / Send Email / etc.
|
||||
```
|
||||
|
||||
## Отладка
|
||||
|
||||
Если данные не обрабатываются:
|
||||
|
||||
1. Проверьте структуру входных данных через `console.log`:
|
||||
```javascript
|
||||
console.log('FlightAware:', JSON.stringify(flightAwareData, null, 2));
|
||||
console.log('FlightRadar24:', JSON.stringify(flightRadar24Data, null, 2));
|
||||
```
|
||||
|
||||
2. Убедитесь, что данные приходят в правильном порядке:
|
||||
- Первый элемент = FlightAware
|
||||
- Второй элемент = FlightRadar24
|
||||
|
||||
3. Проверьте наличие полей `body.flights` и `body.data`
|
||||
|
||||
## Дополнительные возможности
|
||||
|
||||
### Кастомизация HTML
|
||||
|
||||
Вы можете изменить стили в функции `generateFullHTML()`:
|
||||
- Цвета
|
||||
- Шрифты
|
||||
- Размеры
|
||||
- Расположение элементов
|
||||
|
||||
### Добавление дополнительных полей
|
||||
|
||||
В функции `generateFlightCard()` можно добавить отображение дополнительных полей из API.
|
||||
|
||||
### Фильтрация рейсов
|
||||
|
||||
Перед генерацией HTML можно отфильтровать рейсы:
|
||||
```javascript
|
||||
const filteredFlights = mergedFlights.filter(flight => {
|
||||
// Ваша логика фильтрации
|
||||
return flight.flightAware || flight.flightRadar24;
|
||||
});
|
||||
```
|
||||
236
docs/N8N_FLIGHTS_QUICK_START.md
Normal file
236
docs/N8N_FLIGHTS_QUICK_START.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Быстрый старт: HTML → Base64 PDF в n8n
|
||||
|
||||
## Проблема
|
||||
У вас есть HTML в формате:
|
||||
```json
|
||||
{
|
||||
"html": "<!DOCTYPE html>..."
|
||||
}
|
||||
```
|
||||
|
||||
Нужно получить base64 PDF.
|
||||
|
||||
## Решение: 3 ноды
|
||||
|
||||
### Шаг 1: Code Node - Подготовка запроса
|
||||
|
||||
**Название:** `Code: Prepare PDF Request`
|
||||
|
||||
**Код:** Скопируйте из `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`
|
||||
|
||||
**Важно:**
|
||||
- Замените `YOUR_API_KEY` на ваш реальный API ключ
|
||||
- Выберите сервис конвертации (htmlpdfapi.com, pdfshift.io и т.д.)
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"headers": {...},
|
||||
"body": "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 2: HTTP Request - Конвертация
|
||||
|
||||
**Название:** `HTTP Request: Convert to PDF`
|
||||
|
||||
**Настройка:**
|
||||
- **Method:** `{{ $json.method }}`
|
||||
- **URL:** `{{ $json.url }}`
|
||||
- **Authentication:** None (или Basic, если требуется)
|
||||
- **Headers:**
|
||||
```json
|
||||
{{ $json.headers }}
|
||||
```
|
||||
- **Body:**
|
||||
```json
|
||||
{{ $json.body }}
|
||||
```
|
||||
- **Response Format:** `JSON` (или `Binary`, если сервис возвращает binary)
|
||||
|
||||
**Что делает:** Отправляет HTML в сервис конвертации и получает PDF
|
||||
|
||||
---
|
||||
|
||||
### Шаг 3: Code Node - Извлечение Base64
|
||||
|
||||
**Название:** `Code: Extract Base64 PDF`
|
||||
|
||||
**Код:** Скопируйте из `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
|
||||
"pdf_size_bytes": 123456,
|
||||
"pdf_size_mb": "0.12",
|
||||
"filename": "flights-report-2026-01-16.pdf",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Готово!
|
||||
|
||||
Теперь у вас есть base64 PDF в поле `pdf_base64`.
|
||||
|
||||
## Что дальше?
|
||||
|
||||
### Вариант A: Сохранить в файл
|
||||
|
||||
Добавьте Code Node:
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
|
||||
return [{
|
||||
binary: {
|
||||
data: pdfBuffer,
|
||||
fileName: $('Code: Extract Base64 PDF').first().json.filename,
|
||||
mimeType: 'application/pdf'
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Write Binary File** или **Save to S3**.
|
||||
|
||||
### Вариант B: Вернуть в API
|
||||
|
||||
Добавьте Code Node перед Response:
|
||||
```javascript
|
||||
const pdfData = $('Code: Extract Base64 PDF').first().json;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
success: true,
|
||||
pdf_base64: pdfData.pdf_base64,
|
||||
pdf_size_mb: pdfData.pdf_size_mb,
|
||||
filename: pdfData.filename
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Вариант C: Отправить по Email
|
||||
|
||||
Добавьте Code Node:
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
const filename = $('Code: Extract Base64 PDF').first().json.filename;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Отчёт о рейсах',
|
||||
text: 'Во вложении отчёт о рейсах.',
|
||||
attachments: [{
|
||||
filename: filename,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}]
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Email Send**.
|
||||
|
||||
---
|
||||
|
||||
## Популярные сервисы конвертации
|
||||
|
||||
### 1. htmlpdfapi.com (рекомендуется)
|
||||
- **Бесплатно:** 100 PDF/месяц
|
||||
- **Платно:** от $9/месяц
|
||||
- **URL:** https://htmlpdfapi.com
|
||||
- **Возвращает:** `{ pdf: "base64..." }`
|
||||
|
||||
### 2. pdfshift.io
|
||||
- **Бесплатно:** 100 PDF/месяц
|
||||
- **Платно:** от $9/месяц
|
||||
- **URL:** https://pdfshift.io
|
||||
- **Возвращает:** binary или base64
|
||||
|
||||
### 3. api2pdf.com
|
||||
- **Бесплатно:** 50 PDF/месяц
|
||||
- **Платно:** от $9/месяц
|
||||
- **URL:** https://www.api2pdf.com
|
||||
- **Возвращает:** `{ Pdf: "base64..." }`
|
||||
|
||||
### 4. Self-hosted: Gotenberg
|
||||
- **Бесплатно:** полностью
|
||||
- **Требует:** Docker
|
||||
- **URL:** https://gotenberg.dev
|
||||
- **Возвращает:** binary PDF
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
### Проверка HTML
|
||||
В Code Node добавьте:
|
||||
```javascript
|
||||
console.log('HTML length:', html.length);
|
||||
console.log('HTML preview:', html.substring(0, 200));
|
||||
```
|
||||
|
||||
### Проверка ответа сервиса
|
||||
После HTTP Request добавьте Code Node:
|
||||
```javascript
|
||||
const response = $input.first();
|
||||
console.log('Response keys:', Object.keys(response));
|
||||
console.log('Response json keys:', response.json ? Object.keys(response.json) : 'no json');
|
||||
console.log('Response binary:', response.binary ? 'yes' : 'no');
|
||||
```
|
||||
|
||||
### Проверка base64
|
||||
После извлечения base64:
|
||||
```javascript
|
||||
const base64 = $json.pdf_base64;
|
||||
console.log('Base64 length:', base64.length);
|
||||
console.log('Base64 preview:', base64.substring(0, 50));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Частые проблемы
|
||||
|
||||
### Проблема: "HTML не найден"
|
||||
**Решение:** Проверьте, что HTML приходит в поле `html`. Если нет, измените первую строку в `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`:
|
||||
```javascript
|
||||
const html = $json.html || $json.body?.html || $json.data?.html || $json;
|
||||
```
|
||||
|
||||
### Проблема: "Не удалось извлечь base64"
|
||||
**Решение:**
|
||||
1. Проверьте формат ответа сервиса
|
||||
2. Добавьте логирование в `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
|
||||
3. Убедитесь, что сервис действительно вернул PDF
|
||||
|
||||
### Проблема: PDF пустой или повреждён
|
||||
**Решение:**
|
||||
1. Проверьте, что HTML валидный
|
||||
2. Убедитесь, что CSS включён в HTML (inline styles)
|
||||
3. Проверьте, что сервис поддерживает все используемые CSS свойства
|
||||
|
||||
---
|
||||
|
||||
## Готовый Workflow
|
||||
|
||||
```
|
||||
[Ваша нода с HTML]
|
||||
↓
|
||||
Code: Prepare PDF Request
|
||||
↓
|
||||
HTTP Request: Convert to PDF
|
||||
↓
|
||||
Code: Extract Base64 PDF
|
||||
↓
|
||||
[Использование base64]
|
||||
```
|
||||
|
||||
Всё готово! 🎉
|
||||
103
docs/N8N_FLIGHTS_REQUESTED_INFO.md
Normal file
103
docs/N8N_FLIGHTS_REQUESTED_INFO.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Отображение запрошенных рейсов без данных
|
||||
|
||||
## Проблема
|
||||
|
||||
Когда данных о рейсе нет, нужно показывать, по какому рейсу и запросу информация отсутствует.
|
||||
|
||||
## Решение
|
||||
|
||||
Код автоматически извлекает информацию о запрошенных рейсах и показывает их даже если данных нет.
|
||||
|
||||
## Способы передачи информации о запрошенных рейсах
|
||||
|
||||
### Вариант 1: Прямая передача (рекомендуется)
|
||||
|
||||
В предыдущей ноде (перед Code Node) добавьте информацию о запрошенных рейсах:
|
||||
|
||||
```javascript
|
||||
// В Code Node перед "причесываем данные"
|
||||
return [{
|
||||
json: {
|
||||
// Ваши данные
|
||||
...existingData,
|
||||
|
||||
// Информация о запрошенных рейсах
|
||||
requested_flights: ['MU747', 'CES747'], // Массив номеров рейсов
|
||||
// ИЛИ
|
||||
flight_number: 'MU747', // Один рейс
|
||||
// ИЛИ
|
||||
flight_numbers: ['MU747', 'CES747'] // Альтернативный формат
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Вариант 2: Автоматическое извлечение
|
||||
|
||||
Код автоматически пытается извлечь информацию о рейсах из:
|
||||
- URL запросов (параметры `ident`, `flight_number`, `flight`, `callsign`)
|
||||
- Query параметров
|
||||
- Body запросов
|
||||
- Прямых полей в JSON
|
||||
|
||||
## Что отображается
|
||||
|
||||
Если рейс был запрошен, но данных нет, показывается карточка:
|
||||
|
||||
```
|
||||
┌─ Рейс MU747 ───────────────┐
|
||||
│ Запрошен │
|
||||
│ │
|
||||
│ Запрошенный рейс: MU747 │
|
||||
│ │
|
||||
│ [FlightAware] │
|
||||
│ ✗ Данные не получены │
|
||||
│ │
|
||||
│ [FlightRadar24] │
|
||||
│ ✗ Данные не получены │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
## Пример использования
|
||||
|
||||
### В предыдущей ноде (HTTP Request или Code Node):
|
||||
|
||||
```javascript
|
||||
// После запросов к FlightAware и FlightRadar24
|
||||
return [{
|
||||
json: {
|
||||
data: [
|
||||
{ body: { flights: [...] } }, // FlightAware ответ
|
||||
{ body: { data: [...] } } // FlightRadar24 ответ
|
||||
],
|
||||
// Добавляем информацию о запрошенных рейсах
|
||||
requested_flights: ['MU747', 'CES747']
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Или в отдельной ноде перед обработкой:
|
||||
|
||||
```javascript
|
||||
// Code Node: Prepare Request Info
|
||||
const flightNumbers = ['MU747', 'CES747']; // Из вашего запроса
|
||||
|
||||
return [{
|
||||
json: {
|
||||
requested_flights: flightNumbers,
|
||||
// Другие данные...
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
## Преимущества
|
||||
|
||||
✅ **Прозрачность** - видно, какие рейсы запрашивались
|
||||
✅ **Отладка** - легко понять, почему данных нет
|
||||
✅ **Информативность** - пользователь видит, что запрос был выполнен
|
||||
✅ **Автоматика** - код пытается извлечь информацию автоматически
|
||||
|
||||
## Если данные всё равно не показываются
|
||||
|
||||
1. Проверьте, что передаёте `requested_flights` в предыдущей ноде
|
||||
2. Убедитесь, что формат правильный: массив строк или объект с полем `flight_number`
|
||||
3. Проверьте логи в Code Node - там будут сообщения о найденных запрошенных рейсах
|
||||
370
docs/N8N_FLIGHTS_SIMPLE_BINARY.js
Normal file
370
docs/N8N_FLIGHTS_SIMPLE_BINARY.js
Normal file
@@ -0,0 +1,370 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Отчёт о рейсах (HTML → Binary + Base64 PDF)
|
||||
// ============================================================================
|
||||
// Упрощённая версия с возвратом binary HTML и подготовкой для PDF конвертации
|
||||
// ============================================================================
|
||||
|
||||
const inputItems = $input.all();
|
||||
|
||||
// ================== FALLBACK ==================
|
||||
if (!inputItems || inputItems.length === 0) {
|
||||
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
|
||||
return [{
|
||||
binary: {
|
||||
data: Buffer.from(html, 'utf8'),
|
||||
mimeType: 'text/html',
|
||||
fileName: 'flights-report.html'
|
||||
},
|
||||
json: {
|
||||
html: html,
|
||||
flights_count: 0,
|
||||
error: 'Нет входных данных'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
|
||||
let flightAwareData = [];
|
||||
let flightRadar24Data = [];
|
||||
|
||||
try {
|
||||
const fa = inputItems[0]?.json?.body?.flights;
|
||||
if (Array.isArray(fa)) flightAwareData = fa;
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const fr = inputItems[1]?.json?.body?.data;
|
||||
if (Array.isArray(fr)) flightRadar24Data = fr;
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
|
||||
}
|
||||
|
||||
// ================== УТИЛИТЫ ==================
|
||||
const safeStr = v => (v == null ? '' : String(v));
|
||||
const safeDate = v => {
|
||||
if (!v) return '—';
|
||||
try {
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
|
||||
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
|
||||
|
||||
// ================== MERGE ПО REGISTRATION ==================
|
||||
const flightsMap = new Map();
|
||||
|
||||
flightAwareData.forEach(f => {
|
||||
const reg = safeStr(f.registration).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight_number),
|
||||
ident: safeStr(f.ident),
|
||||
identIata: safeStr(f.ident_iata),
|
||||
aircraftType: safeStr(f.aircraft_type),
|
||||
fa: f,
|
||||
fr: null
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fa = f;
|
||||
}
|
||||
});
|
||||
|
||||
flightRadar24Data.forEach(f => {
|
||||
const reg = safeStr(f.reg).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight),
|
||||
ident: safeStr(f.callsign),
|
||||
identIata: safeStr(f.flight),
|
||||
aircraftType: safeStr(f.type),
|
||||
fa: null,
|
||||
fr: f
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fr = f;
|
||||
}
|
||||
});
|
||||
|
||||
const flights = Array.from(flightsMap.values());
|
||||
|
||||
// ================== HTML GENERATION ==================
|
||||
const generateFlightCard = f => {
|
||||
const fa = f.fa;
|
||||
const fr = f.fr;
|
||||
|
||||
let card = `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||||
<span class="registration">${f.registration}</span>
|
||||
</div>
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Тип самолёта:</span>
|
||||
<span class="value">${f.aircraftType || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Идентификатор:</span>
|
||||
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (fa) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Прилёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Статус:</span>
|
||||
<span class="timeline-value">${safeStr(fa.status || '—')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (fr) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Время полёта:</span>
|
||||
<span class="status-value">${formatDuration(fr.flight_time)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
card += `</div>`;
|
||||
return card;
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const reportDate = now.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Отчёт о рейсах</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }
|
||||
.header-meta { color: #666; font-size: 14px; }
|
||||
.sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.source-tag.available { background: #d1fae5; color: #065f46; }
|
||||
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
|
||||
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 25px; overflow: hidden; background: white; }
|
||||
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.flight-header h2 { font-size: 24px; margin: 0; }
|
||||
.registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
|
||||
.flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||||
.info-row { display: flex; margin-bottom: 8px; }
|
||||
.info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }
|
||||
.info-row .value { color: #111827; }
|
||||
.source-section { border-top: 1px solid #e5e7eb; padding: 20px; }
|
||||
.source-section:first-of-type { border-top: none; }
|
||||
.source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
|
||||
.source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }
|
||||
.source-badge.source-flightaware { background: #3b82f6; }
|
||||
.source-badge.source-flightradar24 { background: #10b981; }
|
||||
.source-missing { color: #ef4444; font-size: 13px; font-style: italic; }
|
||||
.source-content { margin-left: 0; }
|
||||
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }
|
||||
.route-item { display: flex; flex-direction: column; }
|
||||
.route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.route-value { font-size: 16px; font-weight: 600; color: #111827; }
|
||||
.timeline { margin-bottom: 20px; }
|
||||
.timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
|
||||
.timeline-item:last-child { border-bottom: none; }
|
||||
.timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }
|
||||
.timeline-value { color: #111827; text-align: right; }
|
||||
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }
|
||||
.status-item { display: flex; flex-direction: column; }
|
||||
.status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-value { font-size: 14px; font-weight: 600; color: #111827; }
|
||||
.no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }
|
||||
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Отчёт о рейсах</h1>
|
||||
<div class="header-meta">
|
||||
<div>Дата формирования: ${reportDate}</div>
|
||||
<div class="sources-info">
|
||||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flights-container">
|
||||
${flights.length ? flights.map(generateFlightCard).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ================== ПОДГОТОВКА ДАННЫХ ДЛЯ PDF КОНВЕРТАЦИИ ==================
|
||||
// Настройки сервиса (замените на ваши)
|
||||
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf';
|
||||
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
|
||||
|
||||
// ================== RETURN ==================
|
||||
return [{
|
||||
// Binary HTML файл (для использования в Convert to File ноде или сохранения)
|
||||
binary: {
|
||||
data: Buffer.from(html, 'utf8'),
|
||||
mimeType: 'text/html',
|
||||
fileName: `flights-report-${now.toISOString().split('T')[0]}.html`
|
||||
},
|
||||
|
||||
// JSON данные
|
||||
json: {
|
||||
// HTML строка (для конвертации в PDF через HTTP Request)
|
||||
html: html,
|
||||
|
||||
// Метаданные
|
||||
flights_count: flights.length,
|
||||
generated_at: now.toISOString(),
|
||||
sources: {
|
||||
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
|
||||
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
|
||||
},
|
||||
|
||||
// Данные для конвертации в base64 PDF (используйте в следующей HTTP Request ноде)
|
||||
pdf_request: {
|
||||
method: 'POST',
|
||||
url: PDF_SERVICE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
},
|
||||
base64: true
|
||||
})
|
||||
},
|
||||
|
||||
// Удобные поля для HTTP Request ноды
|
||||
pdf_request_method: 'POST',
|
||||
pdf_request_url: PDF_SERVICE_URL,
|
||||
pdf_request_headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
pdf_request_body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
},
|
||||
base64: true
|
||||
})
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИСПОЛЬЗОВАНИЕ:
|
||||
// ============================================================================
|
||||
// 1. Binary HTML можно использовать в ноде "Convert to File" или сохранить
|
||||
// 2. JSON.html можно использовать для конвертации в PDF через HTTP Request
|
||||
// 3. JSON.pdf_request_* поля готовы для использования в HTTP Request ноде
|
||||
// 4. После HTTP Request используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js
|
||||
// для извлечения base64 PDF из ответа
|
||||
// ============================================================================
|
||||
630
docs/N8N_FLIGHTS_TO_BASE64.js
Normal file
630
docs/N8N_FLIGHTS_TO_BASE64.js
Normal file
@@ -0,0 +1,630 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Обработка данных о рейсах → Base64 HTML
|
||||
// ============================================================================
|
||||
// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]
|
||||
// Выход: base64 HTML
|
||||
// ============================================================================
|
||||
|
||||
const inputItems = $input.all();
|
||||
|
||||
// ================== FALLBACK ==================
|
||||
if (!inputItems || inputItems.length === 0) {
|
||||
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html: html,
|
||||
flights_count: 0,
|
||||
error: 'Нет входных данных'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
|
||||
// Новая структура: [{ data: [{ body: { flights: [...] }}, { error: {...} }, { flight_number, ... }] }]
|
||||
let flightAwareData = [];
|
||||
let flightRadar24Data = [];
|
||||
let requestData = null; // Данные из ноды "запрос рейса"
|
||||
let flightRadar24Error = null; // Ошибка от FlightRadar24
|
||||
|
||||
try {
|
||||
const firstItem = inputItems[0];
|
||||
if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {
|
||||
// Первый элемент массива data - FlightAware
|
||||
if (firstItem.json.data[0] && firstItem.json.data[0].body) {
|
||||
if (firstItem.json.data[0].body.flights) {
|
||||
flightAwareData = Array.isArray(firstItem.json.data[0].body.flights)
|
||||
? firstItem.json.data[0].body.flights
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
// Второй элемент массива data - FlightRadar24 (может быть ошибка)
|
||||
if (firstItem.json.data[1]) {
|
||||
// Проверяем, есть ли ошибка
|
||||
if (firstItem.json.data[1].error) {
|
||||
flightRadar24Error = firstItem.json.data[1].error;
|
||||
console.log('⚠️ Ошибка FlightRadar24:', flightRadar24Error.message);
|
||||
flightRadar24Data = [];
|
||||
} else if (firstItem.json.data[1].body && firstItem.json.data[1].body.data) {
|
||||
flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data)
|
||||
? firstItem.json.data[1].body.data
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
// Третий элемент массива data - данные из ноды "запрос рейса"
|
||||
if (firstItem.json.data[2] && firstItem.json.data[2].flight_number) {
|
||||
requestData = {
|
||||
flight_number: firstItem.json.data[2].flight_number,
|
||||
departure_date_local: firstItem.json.data[2].departure_date_local || null,
|
||||
arrival_date_local: firstItem.json.data[2].arrival_date_local || null
|
||||
};
|
||||
console.log('✅ Данные запроса получены:', requestData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения данных:', e.message);
|
||||
}
|
||||
|
||||
// ================== УТИЛИТЫ ==================
|
||||
const safeStr = v => (v == null ? '' : String(v));
|
||||
const safeDate = v => {
|
||||
if (!v) return '—';
|
||||
try {
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
|
||||
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
|
||||
|
||||
// ================== MERGE ПО REGISTRATION ==================
|
||||
const flightsMap = new Map();
|
||||
|
||||
// Добавляем данные из FlightAware
|
||||
flightAwareData.forEach(f => {
|
||||
const reg = safeStr(f.registration).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight_number),
|
||||
ident: safeStr(f.ident),
|
||||
identIata: safeStr(f.ident_iata),
|
||||
aircraftType: safeStr(f.aircraft_type),
|
||||
fa: f,
|
||||
fr: null
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fa = f;
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем данные из FlightRadar24
|
||||
flightRadar24Data.forEach(f => {
|
||||
const reg = safeStr(f.reg).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight),
|
||||
ident: safeStr(f.callsign),
|
||||
identIata: safeStr(f.flight),
|
||||
aircraftType: safeStr(f.type),
|
||||
fa: null,
|
||||
fr: f
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fr = f;
|
||||
}
|
||||
});
|
||||
|
||||
// ================== ДОБАВЛЕНИЕ ЗАПРОШЕННЫХ РЕЙСОВ БЕЗ ДАННЫХ ==================
|
||||
// Если есть информация о запрошенных рейсах, но нет данных - добавляем их
|
||||
// Пытаемся извлечь из предыдущих нод (HTTP Request) или получить из входных данных
|
||||
const allInputItems = $input.all();
|
||||
const firstItemForRequest = inputItems[0]; // Используем уже определённую переменную из блока выше
|
||||
|
||||
// Ищем информацию о запрошенных рейсах
|
||||
let requestedFlightNumbers = new Set();
|
||||
|
||||
// ВАРИАНТ 1: Получение данных из ноды "запрос рейса"
|
||||
// Используем данные, извлечённые выше из data[2]
|
||||
let requestFlightNumber = null;
|
||||
let requestDepartureDate = null;
|
||||
let requestArrivalDate = null;
|
||||
|
||||
if (requestData) {
|
||||
requestFlightNumber = requestData.flight_number;
|
||||
requestDepartureDate = requestData.departure_date_local;
|
||||
requestArrivalDate = requestData.arrival_date_local;
|
||||
|
||||
if (requestFlightNumber) {
|
||||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||||
}
|
||||
}
|
||||
|
||||
// Дополнительно ищем в других местах (fallback)
|
||||
allInputItems.forEach(item => {
|
||||
if (item.json) {
|
||||
// Прямые поля из ноды "запрос рейса"
|
||||
if (item.json.flight_number && (item.json.departure_date_local || item.json.arrival_date_local)) {
|
||||
if (!requestFlightNumber) {
|
||||
requestFlightNumber = item.json.flight_number || item.json.ident || item.json.flight;
|
||||
requestDepartureDate = item.json.departure_date_local || null;
|
||||
requestArrivalDate = item.json.arrival_date_local || null;
|
||||
|
||||
if (requestFlightNumber) {
|
||||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Данные, переданные из предыдущей ноды
|
||||
if (item.json.request_flight_number) {
|
||||
if (!requestFlightNumber) {
|
||||
requestFlightNumber = item.json.request_flight_number;
|
||||
requestDepartureDate = item.json.request_departure_date || null;
|
||||
requestArrivalDate = item.json.request_arrival_date || null;
|
||||
|
||||
if (requestFlightNumber) {
|
||||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ВАРИАНТ 2: Прямая передача из предыдущей ноды
|
||||
if (firstItemForRequest && firstItemForRequest.json) {
|
||||
// Массив запрошенных рейсов
|
||||
if (firstItemForRequest.json.requested_flights && Array.isArray(firstItemForRequest.json.requested_flights)) {
|
||||
firstItemForRequest.json.requested_flights.forEach(flight => {
|
||||
const flightNum = typeof flight === 'string' ? flight : (flight.flight_number || flight.ident || flight);
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Один рейс
|
||||
if (firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight) {
|
||||
const flightNum = firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight;
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
|
||||
// Массив flight_numbers
|
||||
if (firstItemForRequest.json.flight_numbers && Array.isArray(firstItemForRequest.json.flight_numbers)) {
|
||||
firstItemForRequest.json.flight_numbers.forEach(flightNum => {
|
||||
if (flightNum) requestedFlightNumbers.add(String(flightNum));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ВАРИАНТ 3: Извлечение из всех входных элементов
|
||||
allInputItems.forEach(item => {
|
||||
if (item.json) {
|
||||
if (item.json.flight_number) {
|
||||
requestedFlightNumbers.add(String(item.json.flight_number));
|
||||
}
|
||||
if (item.json.ident) {
|
||||
requestedFlightNumbers.add(String(item.json.ident));
|
||||
}
|
||||
if (item.json.flight) {
|
||||
requestedFlightNumbers.add(String(item.json.flight));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ВАРИАНТ 2: Извлечение из URL и параметров запросов
|
||||
allInputItems.forEach(item => {
|
||||
// Из URL запроса
|
||||
if (item.json && item.json.url) {
|
||||
const url = item.json.url;
|
||||
const flightMatch = url.match(/(?:ident|flight_number|flight|callsign)=([^&]+)/i);
|
||||
if (flightMatch) {
|
||||
requestedFlightNumbers.add(flightMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Из query параметров
|
||||
if (item.json && item.json.query) {
|
||||
const query = item.json.query;
|
||||
const flightNum = query.ident || query.flight_number || query.flight || query.callsign;
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Из body запроса
|
||||
if (item.json && item.json.body) {
|
||||
const body = item.json.body;
|
||||
const flightNum = body.ident || body.flight_number || body.flight || body.callsign;
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Прямо из json
|
||||
if (item.json) {
|
||||
const flightNum = item.json.ident || item.json.flight_number || item.json.flight || item.json.callsign;
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем запрошенные рейсы, для которых нет данных
|
||||
requestedFlightNumbers.forEach(flightNum => {
|
||||
// Проверяем, есть ли уже этот рейс в flightsMap
|
||||
let found = false;
|
||||
flightsMap.forEach((flight, reg) => {
|
||||
if (flight.flightNumber === flightNum || flight.ident === flightNum || flight.identIata === flightNum) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Если не найден - добавляем как запрошенный без данных
|
||||
if (!found) {
|
||||
flightsMap.set(`REQUESTED-${flightNum}`, {
|
||||
registration: '—',
|
||||
flightNumber: flightNum,
|
||||
ident: flightNum,
|
||||
identIata: flightNum,
|
||||
aircraftType: '—',
|
||||
fa: null,
|
||||
fr: null,
|
||||
isRequested: true // Флаг, что это запрошенный рейс без данных
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const flights = Array.from(flightsMap.values());
|
||||
|
||||
// ================== HTML GENERATION ==================
|
||||
// Делаем flightRadar24Error доступным в функции generateFlightCard
|
||||
const generateFlightCard = (f, fr24ErrorParam = null) => {
|
||||
const fa = f.fa;
|
||||
const fr = f.fr;
|
||||
const fr24Error = fr24ErrorParam; // Локальная переменная для использования в функции
|
||||
|
||||
// Если это запрошенный рейс без данных
|
||||
if (f.isRequested && !fa && !fr) {
|
||||
// Используем данные, полученные ранее из ноды "запрос рейса"
|
||||
let requestInfo = '';
|
||||
|
||||
// Проверяем, соответствует ли этот рейс запрошенному
|
||||
const matchesRequest = requestFlightNumber && (
|
||||
String(f.flightNumber) === String(requestFlightNumber) ||
|
||||
String(f.ident) === String(requestFlightNumber)
|
||||
);
|
||||
|
||||
if (matchesRequest) {
|
||||
if (requestDepartureDate) {
|
||||
requestInfo += `<div class="info-row"><span class="label">Дата вылета (запрос):</span><span class="value">${requestDepartureDate}</span></div>`;
|
||||
}
|
||||
if (requestArrivalDate) {
|
||||
requestInfo += `<div class="info-row"><span class="label">Дата прилёта (запрос):</span><span class="value">${requestArrivalDate}</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||||
<span class="registration">Запрошен</span>
|
||||
</div>
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Запрошенный рейс:</span>
|
||||
<span class="value">${f.flightNumber || f.ident || '—'}</span>
|
||||
</div>
|
||||
${requestInfo}
|
||||
</div>
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div style="padding: 10px; color: #666; font-size: 13px;">
|
||||
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div style="padding: 10px; color: #666; font-size: 13px;">
|
||||
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let card = `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||||
<span class="registration">${f.registration || '—'}</span>
|
||||
</div>
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Тип самолёта:</span>
|
||||
<span class="value">${f.aircraftType || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Идентификатор:</span>
|
||||
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Данные из FlightAware
|
||||
if (fa) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Плановый вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический прилёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус:</span>
|
||||
<span class="status-value">${safeStr(fa.status || '—')}</span>
|
||||
</div>
|
||||
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка вылета:</span>
|
||||
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка прилёта:</span>
|
||||
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_origin ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт вылета:</span>
|
||||
<span class="status-value">${fa.gate_origin}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_destination ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт прилёта:</span>
|
||||
<span class="status-value">${fa.gate_destination}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.baggage_claim ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Выдача багажа:</span>
|
||||
<span class="status-value">${fa.baggage_claim}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Данные из FlightRadar24
|
||||
if (fr) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Время полёта:</span>
|
||||
<span class="status-value">${formatDuration(fr.flight_time)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Фактическое расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Кратчайшее расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr.circle_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус полёта:</span>
|
||||
<span class="status-value">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
card += `</div>`;
|
||||
return card;
|
||||
};
|
||||
|
||||
// Генерация полного HTML
|
||||
const now = new Date();
|
||||
const reportDate = now.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Отчёт о рейсах</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.4; color: #333; background: #f5f5f5; padding: 15px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.header { border-bottom: 3px solid #2563eb; padding-bottom: 8px; margin-bottom: 8px; }
|
||||
.header h1 { color: #1e40af; font-size: 24px; margin-bottom: 4px; }
|
||||
.header-meta { color: #666; font-size: 13px; }
|
||||
.sources-info { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
|
||||
.source-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }
|
||||
.source-tag.available { background: #d1fae5; color: #065f46; }
|
||||
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
|
||||
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; overflow: hidden; background: white; }
|
||||
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 18px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.flight-header h2 { font-size: 20px; margin: 0; }
|
||||
.registration { background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
|
||||
.flight-info { padding: 12px 18px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||||
.info-row { display: flex; margin-bottom: 6px; }
|
||||
.info-row:last-child { margin-bottom: 0; }
|
||||
.info-row .label { font-weight: 600; color: #4b5563; width: 140px; flex-shrink: 0; font-size: 13px; }
|
||||
.info-row .value { color: #111827; font-size: 13px; }
|
||||
.source-section { border-top: 1px solid #e5e7eb; padding: 12px 18px; }
|
||||
.source-section:first-of-type { border-top: none; }
|
||||
.source-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.source-badge { display: inline-block; padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: white; }
|
||||
.source-badge.source-flightaware { background: #3b82f6; }
|
||||
.source-badge.source-flightradar24 { background: #10b981; }
|
||||
.source-missing { color: #ef4444; font-size: 12px; font-style: italic; }
|
||||
.source-content { margin-left: 0; }
|
||||
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||||
.route-item { display: flex; flex-direction: column; }
|
||||
.route-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.route-value { font-size: 14px; font-weight: 600; color: #111827; }
|
||||
.timeline { margin-bottom: 12px; }
|
||||
.timeline-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
|
||||
.timeline-item:last-child { border-bottom: none; }
|
||||
.timeline-label { font-weight: 500; color: #4b5563; width: 160px; flex-shrink: 0; font-size: 12px; }
|
||||
.timeline-value { color: #111827; text-align: right; font-size: 12px; }
|
||||
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||||
.status-item { display: flex; flex-direction: column; }
|
||||
.status-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-value { font-size: 13px; font-weight: 600; color: #111827; }
|
||||
.delay-negative { color: #10b981; }
|
||||
.delay-positive { color: #ef4444; }
|
||||
.no-data { text-align: center; padding: 40px 20px; color: #6b7280; font-size: 16px; }
|
||||
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 15px; } .flight-card { page-break-inside: avoid; margin-bottom: 15px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Отчёт о рейсах</h1>
|
||||
<div class="header-meta">
|
||||
<div>Дата формирования: ${reportDate}</div>
|
||||
<div class="sources-info">
|
||||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flights-container">
|
||||
${flights.length ? flights.map(f => generateFlightCard(f, flightRadar24Error)).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ================== HTML → BASE64 ==================
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
|
||||
// ================== RETURN ==================
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html: html,
|
||||
flights_count: flights.length,
|
||||
sources: {
|
||||
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
|
||||
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
|
||||
},
|
||||
generated_at: now.toISOString()
|
||||
}
|
||||
}];
|
||||
320
docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md
Normal file
320
docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Пример Workflow для обработки рейсов с Base64 PDF
|
||||
|
||||
## Структура Workflow
|
||||
|
||||
```
|
||||
HTTP Request (FlightAware)
|
||||
↓
|
||||
HTTP Request (FlightRadar24)
|
||||
↓
|
||||
Code: Process Flights Data ← N8N_CODE_PROCESS_FLIGHTS_DATA.js
|
||||
↓
|
||||
Code: Prepare PDF Request ← N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
|
||||
↓
|
||||
HTTP Request (Convert to PDF) ← Внешний сервис конвертации
|
||||
↓
|
||||
Code: Extract Base64 PDF ← N8N_FLIGHTS_PDF_BASE64_FULL.js
|
||||
↓
|
||||
[Использование base64 PDF]
|
||||
├─→ Save File
|
||||
├─→ Send Email
|
||||
├─→ Upload to S3
|
||||
└─→ Return in API Response
|
||||
```
|
||||
|
||||
## Детальная настройка нод
|
||||
|
||||
### 1. HTTP Request: FlightAware
|
||||
- **Method:** GET/POST (в зависимости от API)
|
||||
- **URL:** `https://flightaware.com/api/...`
|
||||
- **Authentication:** По необходимости
|
||||
|
||||
### 2. HTTP Request: FlightRadar24
|
||||
- **Method:** GET/POST (в зависимости от API)
|
||||
- **URL:** `https://flightradar24.com/api/...`
|
||||
- **Authentication:** По необходимости
|
||||
|
||||
### 3. Code: Process Flights Data
|
||||
**Код:** Скопируйте из `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
|
||||
|
||||
**Входные данные:**
|
||||
- Два элемента из предыдущих HTTP Request нод
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"html": "<!DOCTYPE html>...",
|
||||
"flights": [...],
|
||||
"flights_count": 2,
|
||||
"sources": {...},
|
||||
"generated_at": "2026-01-14T..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Code: Prepare PDF Request
|
||||
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
|
||||
|
||||
**Настройка:**
|
||||
- Замените `PDF_SERVICE_URL` на URL вашего сервиса
|
||||
- Замените `PDF_API_KEY` на ваш API ключ
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"http_method": "POST",
|
||||
"http_url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"http_headers": {...},
|
||||
"http_body": "{...}",
|
||||
"html_length": 12345,
|
||||
"flights_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 5. HTTP Request: Convert to PDF
|
||||
**Настройка:**
|
||||
- **Method:** `{{ $json.http_method }}`
|
||||
- **URL:** `{{ $json.http_url }}`
|
||||
- **Authentication:** По необходимости (через Headers)
|
||||
- **Headers:**
|
||||
```json
|
||||
{{ $json.http_headers }}
|
||||
```
|
||||
- **Body:**
|
||||
```json
|
||||
{{ $json.http_body }}
|
||||
```
|
||||
- **Response Format:** `JSON` или `Binary` (зависит от сервиса)
|
||||
|
||||
**Пример для htmlpdfapi.com:**
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
},
|
||||
"body": {
|
||||
"html": "{{ $('Code: Process Flights Data').first().json.html }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true
|
||||
},
|
||||
"base64": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Code: Extract Base64 PDF
|
||||
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
|
||||
|
||||
**Входные данные:**
|
||||
- Ответ от HTTP Request ноды (JSON или Binary)
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"pdf_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGg...",
|
||||
"pdf_size_bytes": 123456,
|
||||
"pdf_size_mb": "0.12",
|
||||
"success": true,
|
||||
"source": "json_response"
|
||||
}
|
||||
```
|
||||
|
||||
## Использование base64 PDF
|
||||
|
||||
### Вариант A: Сохранение в файл
|
||||
|
||||
**Code Node:**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
|
||||
// Конвертируем base64 в binary
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
|
||||
return [{
|
||||
binary: {
|
||||
data: pdfBuffer,
|
||||
fileName: filename,
|
||||
mimeType: 'application/pdf'
|
||||
},
|
||||
json: {
|
||||
filename: filename,
|
||||
size_bytes: pdfBuffer.length
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Write Binary File** или **Save to S3**.
|
||||
|
||||
### Вариант B: Отправка по Email
|
||||
|
||||
**Code Node:**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Отчёт о рейсах',
|
||||
text: 'Во вложении отчёт о рейсах.',
|
||||
attachments: [{
|
||||
filename: 'flights-report.pdf',
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}]
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Email Send**.
|
||||
|
||||
### Вариант C: Возврат в API Response
|
||||
|
||||
**Code Node (перед Response нодой):**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const processedData = $('Code: Process Flights Data').first().json;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
success: true,
|
||||
flights_count: processedData.flights_count,
|
||||
pdf_base64: base64,
|
||||
pdf_size_mb: $('Code: Extract Base64 PDF').first().json.pdf_size_mb,
|
||||
generated_at: processedData.generated_at
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Вариант D: Загрузка в S3/Nextcloud
|
||||
|
||||
**Code Node:**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
|
||||
return [{
|
||||
binary: {
|
||||
data: pdfBuffer,
|
||||
fileName: filename,
|
||||
mimeType: 'application/pdf'
|
||||
},
|
||||
json: {
|
||||
bucket: 'your-bucket',
|
||||
key: `reports/${filename}`,
|
||||
contentType: 'application/pdf'
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **S3 Upload** или **Nextcloud Upload**.
|
||||
|
||||
## Альтернативные сервисы конвертации
|
||||
|
||||
### 1. htmlpdfapi.com
|
||||
```javascript
|
||||
{
|
||||
"url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
},
|
||||
"body": {
|
||||
"html": "{{ HTML }}",
|
||||
"base64": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. pdfshift.io
|
||||
```javascript
|
||||
{
|
||||
"url": "https://api.pdfshift.io/v3/convert/pdf",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Basic " + Buffer.from("api:YOUR_API_KEY").toString("base64")
|
||||
},
|
||||
"body": {
|
||||
"source": "{{ HTML }}",
|
||||
"format": "A4"
|
||||
}
|
||||
}
|
||||
// Ответ содержит base64 в поле "pdf"
|
||||
```
|
||||
|
||||
### 3. api2pdf.com
|
||||
```javascript
|
||||
{
|
||||
"url": "https://v2.api2pdf.com/chrome/html",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Authorization": "YOUR_API_KEY",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"html": "{{ HTML }}",
|
||||
"inlinePdf": true,
|
||||
"fileName": "flights-report.pdf"
|
||||
}
|
||||
}
|
||||
// Ответ содержит base64 в поле "pdf"
|
||||
```
|
||||
|
||||
### 4. Self-hosted: Gotenberg
|
||||
```javascript
|
||||
{
|
||||
"url": "http://your-gotenberg-server:3000/forms/chromium/convert/html",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Content-Type": "multipart/form-data"
|
||||
},
|
||||
"body": {
|
||||
"files": [{
|
||||
"name": "index.html",
|
||||
"content": "{{ HTML }}"
|
||||
}]
|
||||
}
|
||||
}
|
||||
// Ответ - binary PDF, конвертируем в base64
|
||||
```
|
||||
|
||||
## Отладка
|
||||
|
||||
### Проверка HTML
|
||||
```javascript
|
||||
const html = $('Code: Process Flights Data').first().json.html;
|
||||
console.log('HTML length:', html.length);
|
||||
console.log('HTML preview:', html.substring(0, 500));
|
||||
```
|
||||
|
||||
### Проверка base64
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
console.log('Base64 length:', base64.length);
|
||||
console.log('Base64 preview:', base64.substring(0, 100));
|
||||
```
|
||||
|
||||
### Проверка размера PDF
|
||||
```javascript
|
||||
const data = $('Code: Extract Base64 PDF').first().json;
|
||||
console.log('PDF size:', data.pdf_size_mb, 'MB');
|
||||
console.log('PDF size bytes:', data.pdf_size_bytes);
|
||||
```
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
Добавьте IF Node после HTTP Request для проверки успешности:
|
||||
|
||||
```javascript
|
||||
// IF Node: Check PDF Conversion Success
|
||||
{{ $json.success === true }}
|
||||
```
|
||||
|
||||
Если ошибка - отправьте уведомление или сохраните HTML для ручной конвертации.
|
||||
140
docs/N8N_FLIGHTS_WORKING_SOLUTION.md
Normal file
140
docs/N8N_FLIGHTS_WORKING_SOLUTION.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# ✅ Рабочее решение: Обработка данных о рейсах → PDF
|
||||
|
||||
## Структура Workflow
|
||||
|
||||
```
|
||||
[Входные данные: FlightAware + FlightRadar24]
|
||||
↓
|
||||
[Code: причесываем данные] ← Генерирует HTML и конвертирует в base64
|
||||
↓
|
||||
[HTTP Request: Browserless PDF] ← Конвертирует HTML в PDF через браузер
|
||||
↓
|
||||
[Результат: PDF binary]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Нода 1: Code - "причесываем данные"
|
||||
|
||||
**Тип:** Code (JavaScript)
|
||||
|
||||
**Код:** См. файл `N8N_FLIGHTS_TO_BASE64.js`
|
||||
|
||||
**Что делает:**
|
||||
1. Извлекает данные из структуры `[{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]`
|
||||
2. Объединяет рейсы по `registration` (номер самолёта)
|
||||
3. Генерирует красивый HTML с CSS
|
||||
4. Конвертирует HTML в base64
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"html_base64": "PCFET0NUWVBFIGh0bWw+...",
|
||||
"html": "<!DOCTYPE html>...",
|
||||
"flights_count": 2,
|
||||
"sources": {
|
||||
"flightaware": { "available": true, "count": 2 },
|
||||
"flightradar24": { "available": true, "count": 2 }
|
||||
},
|
||||
"generated_at": "2026-01-16T07:23:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Нода 2: HTTP Request - "Browserless PDF"
|
||||
|
||||
**Тип:** HTTP Request
|
||||
|
||||
**Настройки:**
|
||||
|
||||
- **Method:** `POST`
|
||||
- **URL:** `http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9`
|
||||
- **Send Body:** ✅ Да
|
||||
- **Specify Body:** `JSON`
|
||||
- **JSON Body:**
|
||||
```json
|
||||
{
|
||||
"url": "data:text/html;base64, {{ $json.html_base64 }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "20mm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:** `Binary` (или `JSON`, если Browserless возвращает JSON)
|
||||
|
||||
---
|
||||
|
||||
## Результат
|
||||
|
||||
HTTP Request нода вернёт PDF в binary формате, который можно:
|
||||
- Сохранить в файл
|
||||
- Отправить по email
|
||||
- Загрузить в S3/Nextcloud
|
||||
- Конвертировать в base64 для API response
|
||||
|
||||
---
|
||||
|
||||
## Конвертация Binary PDF → Base64 (опционально)
|
||||
|
||||
Если нужен base64 PDF, добавьте Code Node после HTTP Request:
|
||||
|
||||
```javascript
|
||||
const pdfBinary = $binary.data;
|
||||
const base64 = Buffer.isBuffer(pdfBinary)
|
||||
? pdfBinary.toString('base64')
|
||||
: Buffer.from(pdfBinary).toString('base64');
|
||||
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Преимущества решения
|
||||
|
||||
✅ **Простота** - всего 2 ноды
|
||||
✅ **Надёжность** - Browserless использует реальный браузер
|
||||
✅ **Качество** - PDF с правильным форматированием и стилями
|
||||
✅ **Гибкость** - можно легко изменить параметры PDF (формат, отступы)
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
Если что-то не работает:
|
||||
|
||||
1. **Проверьте HTML** - в Code Node добавьте:
|
||||
```javascript
|
||||
console.log('HTML length:', html.length);
|
||||
console.log('HTML preview:', html.substring(0, 200));
|
||||
```
|
||||
|
||||
2. **Проверьте base64** - в Code Node добавьте:
|
||||
```javascript
|
||||
console.log('Base64 length:', htmlBase64.length);
|
||||
```
|
||||
|
||||
3. **Проверьте ответ Browserless** - в HTTP Request включите "Always Output Data" и проверьте ответ
|
||||
|
||||
---
|
||||
|
||||
## Готово! 🎉
|
||||
|
||||
Workflow работает и генерирует красивые PDF отчёты о рейсах!
|
||||
53
docs/N8N_FLIGHTS_WORKING_WORKFLOW.json
Normal file
53
docs/N8N_FLIGHTS_WORKING_WORKFLOW.json
Normal file
File diff suppressed because one or more lines are too long
102
docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js
Normal file
102
docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → Base64 PDF (простой вариант)
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML
|
||||
// Этот код подготовит запрос для HTTP Request ноды
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
// Если HTML пришёл в поле "html", используем его
|
||||
const html = $json.html || $json.body?.html || $json;
|
||||
|
||||
if (!html || typeof html !== 'string') {
|
||||
throw new Error('HTML не найден в входных данных. Проверьте структуру данных.');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ==== НАСТРОЙКИ СЕРВИСА КОНВЕРТАЦИИ ====
|
||||
// Выберите один из вариантов ниже и раскомментируйте его
|
||||
|
||||
// ==== ВАРИАНТ 1: htmlpdfapi.com (рекомендуется) ====
|
||||
// Бесплатный план: 100 PDF в месяц
|
||||
// URL: https://htmlpdfapi.com
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
method: 'POST',
|
||||
url: 'https://api.htmlpdfapi.com/v1/pdf',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY' // ⚠️ ЗАМЕНИТЕ на ваш API ключ
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
base64: true // Запрашиваем base64 напрямую
|
||||
})
|
||||
}
|
||||
}];
|
||||
|
||||
// ==== ВАРИАНТ 2: pdfshift.io ====
|
||||
// Раскомментируйте, если используете pdfshift.io
|
||||
/*
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: 'https://api.pdfshift.io/v3/convert/pdf',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Basic ' + Buffer.from('api:YOUR_API_KEY').toString('base64')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: html,
|
||||
format: 'A4',
|
||||
margin: '20mm'
|
||||
})
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ==== ВАРИАНТ 3: api2pdf.com ====
|
||||
// Раскомментируйте, если используете api2pdf.com
|
||||
/*
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: 'https://v2.api2pdf.com/chrome/html',
|
||||
headers: {
|
||||
'Authorization': 'YOUR_API_KEY',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
inlinePdf: true,
|
||||
fileName: 'flights-report.pdf'
|
||||
})
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ:
|
||||
// ============================================================================
|
||||
// 1. Этот Code Node подготавливает запрос
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде настройте:
|
||||
// - Method: {{ $json.method }}
|
||||
// - URL: {{ $json.url }}
|
||||
// - Headers: {{ $json.headers }}
|
||||
// - Body: {{ $json.body }}
|
||||
// 4. После HTTP Request добавьте Code Node с кодом из N8N_EXTRACT_BASE64_FROM_RESPONSE.js
|
||||
// для извлечения base64 из ответа
|
||||
// ============================================================================
|
||||
62
docs/N8N_PARSE_INIT_DATA.js
Normal file
62
docs/N8N_PARSE_INIT_DATA.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* n8n Code node: парсинг сырого init_data из Telegram WebApp
|
||||
*
|
||||
* Вход: объект с полем init_data (строка query string от Telegram).
|
||||
* Выход: тот же объект + поля init_data_parsed и user_decoded.
|
||||
*
|
||||
* Подключение: после Webhook — в Code передаётся $input.item.json.
|
||||
* init_data должен быть в $json.init_data (как шлёт наш бэкенд).
|
||||
*/
|
||||
|
||||
const item = $input.first().json;
|
||||
|
||||
// Сырая строка init_data (query string)
|
||||
const rawInitData = item.init_data || item.body?.init_data || '';
|
||||
|
||||
if (!rawInitData) {
|
||||
return [{ json: { ...item, init_data_error: 'init_data отсутствует' } }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит query string в объект (значения URL-декодированы)
|
||||
*/
|
||||
function parseQueryString(qs) {
|
||||
const result = {};
|
||||
const pairs = qs.split('&');
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=').map(s => decodeURIComponent(s || ''));
|
||||
if (key) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const parsed = parseQueryString(rawInitData);
|
||||
|
||||
// user приходит как URL-encoded JSON строка
|
||||
let userDecoded = null;
|
||||
if (parsed.user) {
|
||||
try {
|
||||
userDecoded = JSON.parse(parsed.user);
|
||||
} catch (e) {
|
||||
userDecoded = { _parse_error: String(e), raw: parsed.user };
|
||||
}
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
...item,
|
||||
init_data_parsed: {
|
||||
query_id: parsed.query_id || null,
|
||||
auth_date: parsed.auth_date ? parseInt(parsed.auth_date, 10) : null,
|
||||
hash: parsed.hash || null,
|
||||
signature: parsed.signature || null,
|
||||
user_raw: parsed.user || null,
|
||||
},
|
||||
user_decoded: userDecoded,
|
||||
// удобные поля для маппинга в CRM
|
||||
telegram_user_id: userDecoded?.id ?? null,
|
||||
telegram_username: userDecoded?.username ?? null,
|
||||
telegram_first_name: userDecoded?.first_name ?? null,
|
||||
telegram_last_name: userDecoded?.last_name ?? null,
|
||||
},
|
||||
}];
|
||||
147
docs/N8N_WORKFLOW_STUCK_FIX.md
Normal file
147
docs/N8N_WORKFLOW_STUCK_FIX.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 🔧 Решение проблемы зависших n8n workflow
|
||||
|
||||
## 🐛 Проблема
|
||||
|
||||
Workflow в n8n зависает и не может быть перезапущен даже через интерфейс. Redis Trigger node теряет соединение и не переподключается автоматически.
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. Улучшена логика перезапуска workflow
|
||||
|
||||
**Файл:** `backend/app/services/n8n_service.py`
|
||||
|
||||
**Изменения:**
|
||||
- ✅ Увеличены таймауты с 10 до 30 секунд (общий) и 15 секунд (для отдельных операций)
|
||||
- ✅ Добавлена обработка таймаутов при деактивации (продолжаем даже если деактивация зависла)
|
||||
- ✅ Увеличена задержка между деактивацией и активацией (3 секунды вместо 2)
|
||||
- ✅ Добавлена дополнительная задержка после активации для инициализации trigger node
|
||||
- ✅ Улучшено логирование ошибок с полным traceback
|
||||
|
||||
### 2. Улучшена проверка и перезапуск в фоне
|
||||
|
||||
**Файл:** `backend/app/api/claims.py`
|
||||
|
||||
**Изменения:**
|
||||
- ✅ Добавлены повторные попытки перезапуска (до 2 попыток)
|
||||
- ✅ Добавлена проверка подписчиков после перезапуска
|
||||
- ✅ Улучшено логирование процесса перезапуска
|
||||
|
||||
## 🚀 Как это работает
|
||||
|
||||
1. **При публикации сообщения в Redis:**
|
||||
- Проверяется количество подписчиков
|
||||
- Если подписчиков нет → сообщение сохраняется в буфер
|
||||
- Запускается фоновая задача перезапуска workflow
|
||||
|
||||
2. **Процесс перезапуска:**
|
||||
- Проверяется Redis lock (защита от частых перезапусков)
|
||||
- Проверяется статус workflow через API
|
||||
- Деактивируется workflow (даже если завис)
|
||||
- Ждёт 3 секунды
|
||||
- Активирует workflow
|
||||
- Ждёт 2 секунды для инициализации
|
||||
- Проверяет подписчиков
|
||||
- Отправляет сообщения из буфера
|
||||
|
||||
3. **Повторные попытки:**
|
||||
- Если первая попытка не удалась → повтор через 5 секунд
|
||||
- Максимум 2 попытки
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Проверка подписчиков вручную:
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
|
||||
```
|
||||
|
||||
### Проверка статуса workflow:
|
||||
|
||||
```bash
|
||||
curl -H "X-N8N-API-KEY: ..." "https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '.active'
|
||||
```
|
||||
|
||||
### Логи backend:
|
||||
|
||||
```bash
|
||||
tail -f /var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend.log | grep -i "workflow\|redis\|subscriber"
|
||||
```
|
||||
|
||||
## 🛠️ Если проблема повторится
|
||||
|
||||
### Вариант 1: Перезапуск через API (автоматически)
|
||||
|
||||
Код теперь автоматически пытается перезапустить workflow при обнаружении проблемы.
|
||||
|
||||
### Вариант 2: Ручной перезапуск через API
|
||||
|
||||
```bash
|
||||
# Деактивировать
|
||||
curl -X POST -H "X-N8N-API-KEY: ..." \
|
||||
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/deactivate"
|
||||
|
||||
# Подождать 5 секунд
|
||||
sleep 5
|
||||
|
||||
# Активировать
|
||||
curl -X POST -H "X-N8N-API-KEY: ..." \
|
||||
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/activate"
|
||||
```
|
||||
|
||||
### Вариант 3: Перезапуск n8n (крайний случай)
|
||||
|
||||
Если workflow всё ещё завис, может потребоваться перезапуск самого n8n:
|
||||
|
||||
```bash
|
||||
# Если n8n в Docker
|
||||
docker restart <n8n_container>
|
||||
|
||||
# Если n8n как системный сервис
|
||||
systemctl restart n8n
|
||||
```
|
||||
|
||||
## 🔍 Диагностика
|
||||
|
||||
### Проверка что workflow активен но не слушает:
|
||||
|
||||
```bash
|
||||
# 1. Проверить статус workflow
|
||||
curl -H "X-N8N-API-KEY: ..." \
|
||||
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '{active: .active, updatedAt: .updatedAt}'
|
||||
|
||||
# 2. Проверить подписчиков
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "..." PUBSUB NUMSUB "ticket_form:description"
|
||||
|
||||
# 3. Если active=true но подписчиков 0 → workflow завис
|
||||
```
|
||||
|
||||
### Проверка Redis соединений:
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "..." CLIENT LIST | grep "sub=1"
|
||||
```
|
||||
|
||||
## 📝 Рекомендации на будущее
|
||||
|
||||
1. **Мониторинг:**
|
||||
- Настроить автоматический мониторинг подписчиков (cron каждые 5 минут)
|
||||
- Алерты при отсутствии подписчиков более 10 минут
|
||||
|
||||
2. **Автоматический перезапуск n8n:**
|
||||
- Настроить health check для n8n
|
||||
- Автоматический перезапуск при обнаружении проблем
|
||||
|
||||
3. **Логирование:**
|
||||
- Включить детальное логирование в n8n
|
||||
- Мониторинг логов на ошибки Redis соединений
|
||||
|
||||
4. **Настройка Redis:**
|
||||
- Увеличить `tcp-keepalive` для стабильности соединений
|
||||
- Настроить `timeout` для неактивных соединений
|
||||
|
||||
## 🔗 Связанные файлы
|
||||
|
||||
- `backend/app/services/n8n_service.py` - логика перезапуска workflow
|
||||
- `backend/app/api/claims.py` - проверка подписчиков и запуск перезапуска
|
||||
- `docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md` - общая диагностика Redis Trigger
|
||||
- `docs/N8N_MEMORY_ISSUES.md` - проблемы с памятью в n8n
|
||||
122
docs/TELEGRAM_MINIAPP_FLOW.md
Normal file
122
docs/TELEGRAM_MINIAPP_FLOW.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Как срабатывает Telegram Mini App (по шагам)
|
||||
|
||||
Ты в Telegram нажимаешь кнопку «Открыть мини-апп» → открывается **aiform.clientright.ru**. Ниже — что происходит дальше и где.
|
||||
|
||||
---
|
||||
|
||||
## 1. Где открывается страница
|
||||
|
||||
- **Кто:** Telegram (клиент на телефоне/десктопе).
|
||||
- **Что:** Открывает aiform.clientright.ru **в своём встроенном браузере (WebView)** как Mini App.
|
||||
- **Важно:** В этом режиме Telegram сам подставляет в страницу свой скрипт и объект `window.Telegram.WebApp` с полем **initData** (подпись пользователя и данные). В обычном браузере по прямой ссылке этого объекта нет.
|
||||
|
||||
---
|
||||
|
||||
## 2. Загрузка фронта (aiform.clientright.ru)
|
||||
|
||||
- Загружается твой SPA (React): главная страница — форма заявки **ClaimForm**.
|
||||
- Рендерится первый экран формы (шаг 0).
|
||||
- Сразу при монтировании компонента запускается **useEffect** с функцией `tryTelegramAuth()` (в `ClaimForm.tsx`).
|
||||
|
||||
**Где в коде:** `frontend/src/pages/ClaimForm.tsx`, блок «Telegram Mini App: попытка авторизоваться через initData при первом заходе».
|
||||
|
||||
---
|
||||
|
||||
## 3. Проверка: это Mini App или обычный сайт?
|
||||
|
||||
Фронт делает:
|
||||
|
||||
1. Смотрит, есть ли `window.Telegram?.WebApp?.initData`.
|
||||
2. Если нет — ждёт 300 ms (на случай асинхронной подгрузки скрипта Telegram) и проверяет снова.
|
||||
3. Если после этого **нет** `initData` → в консоль пишется «Telegram WebApp не обнаружен», авторизация по Telegram **не вызывается**, форма ведёт себя как обычный веб-сайт (SMS, сессия из localStorage и т.д.).
|
||||
4. Если **есть** `initData`:
|
||||
- Проверяет, есть ли уже в **localStorage** ключ `session_token`.
|
||||
- Если **есть** → считаем, что пользователь уже залогинен, tg/auth не вызываем, дальше работает обычное восстановление сессии.
|
||||
- Если **нет** → идём в шаг 4.
|
||||
|
||||
**Итого:** срабатывание tg/auth **только** когда:
|
||||
- страница открыта **из Telegram** (есть `initData`),
|
||||
- и в localStorage **нет** сохранённого `session_token`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Запрос на бэкенд: POST /api/v1/tg/auth
|
||||
|
||||
- **Кто:** фронт (ClaimForm).
|
||||
- **Куда:** на тот же домен aiform.clientright.ru → запрос уходит на твой backend (через nginx/proxy на порт 8200).
|
||||
- **URL:** `POST /api/v1/tg/auth`.
|
||||
- **Тело:** `{ "init_data": "<строка initData от Telegram>" }`.
|
||||
|
||||
**Где в коде:** `ClaimForm.tsx` — `fetch('/api/v1/tg/auth', { method: 'POST', body: JSON.stringify({ init_data: webApp.initData }) })`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Обработка на бэкенде (tg/auth)
|
||||
|
||||
- **Где:** `backend/app/api/telegram_auth.py`, эндпоинт `POST /api/v1/tg/auth`.
|
||||
|
||||
Последовательно:
|
||||
|
||||
1. **Валидация initData** (`backend/app/services/telegram_auth.py`):
|
||||
- Проверка подписи через **TELEGRAM_BOT_TOKEN** из `.env`.
|
||||
- Если токена нет или подпись не совпадает → ответ **400** (или 500), фронт пишет «Telegram auth failed» и ведёт себя как обычный сайт.
|
||||
|
||||
2. **Извлечение пользователя Telegram:** из initData достаются `id`, `username`, `first_name`, `last_name`.
|
||||
|
||||
3. **Запрос в n8n:**
|
||||
- Бэкенд дергает **N8N_TG_AUTH_WEBHOOK** (URL из `.env`).
|
||||
- Передаёт: `telegram_user_id`, `username`, `first_name`, `last_name`, `session_token`, `form_id`.
|
||||
- Ожидает в ответе минимум **unified_id** (и при необходимости contact_id, phone, has_drafts).
|
||||
|
||||
4. **Создание сессии в Redis:**
|
||||
- По `session_token` + `unified_id` (+ phone, contact_id) создаётся запись сессии (как после SMS-логина).
|
||||
|
||||
5. **Ответ фронту:**
|
||||
`{ success: true, session_token, unified_id, contact_id?, phone?, has_drafts? }`.
|
||||
|
||||
Если на любом шаге ошибка (нет токена, n8n не вернул unified_id и т.д.) — бэкенд отдаёт ошибку, фронт считает tg/auth неуспешным и продолжает как обычный веб.
|
||||
|
||||
---
|
||||
|
||||
## 6. Что делает фронт после успешного ответа
|
||||
|
||||
- Сохраняет **session_token** в **localStorage** и в `sessionIdRef`.
|
||||
- Обновляет состояние формы: `unified_id`, `phone`, `contact_id`, `session_id`.
|
||||
- Ставит **isPhoneVerified = true** (шаг «телефон» считаем пройденным).
|
||||
- Если в ответе **has_drafts === true** → показывает экран выбора черновиков.
|
||||
- Если **has_drafts** нет или false → переводит на **шаг 1** (описание проблемы).
|
||||
|
||||
Дальше пользователь идёт по форме как обычно: описание → черновик/визард → подтверждение → оплата и т.д., но уже без ввода телефона и SMS, потому что он «залогинен» через Telegram.
|
||||
|
||||
---
|
||||
|
||||
## Сводка: где что срабатывает
|
||||
|
||||
| Шаг | Где | Что происходит |
|
||||
|-----|-----|----------------|
|
||||
| 1 | Telegram | Открывает aiform.clientright.ru в WebView, подставляет WebApp и initData |
|
||||
| 2 | Браузер (WebView) | Загружается SPA, монтируется ClaimForm |
|
||||
| 3 | ClaimForm.tsx (фронт) | Проверка: есть ли Telegram.WebApp.initData и нет ли session_token в localStorage |
|
||||
| 4 | ClaimForm.tsx (фронт) | POST /api/v1/tg/auth с init_data |
|
||||
| 5 | telegram_auth.py (бэкенд) | Валидация initData, запрос в n8n, создание сессии в Redis |
|
||||
| 6 | ClaimForm.tsx (фронт) | Сохранение session_token, переход на шаг черновиков или описание |
|
||||
|
||||
---
|
||||
|
||||
## Если открыть aiform.clientright.ru не из Telegram
|
||||
|
||||
- В обычном браузере (Chrome, Safari по прямой ссылке) **нет** `window.Telegram.WebApp`.
|
||||
- Фронт пишет в консоль «Telegram WebApp не обнаружен» и **не вызывает** /api/v1/tg/auth.
|
||||
- Работает обычный сценарий: ввод телефона → SMS → сессия и т.д.
|
||||
|
||||
---
|
||||
|
||||
## Что должно быть настроено
|
||||
|
||||
1. **В Telegram:** у бота должна быть кнопка/меню, открывающее Mini App с URL **https://aiform.clientright.ru** (или с путём на эту форму).
|
||||
2. **Backend .env:**
|
||||
- **TELEGRAM_BOT_TOKEN** — токен этого же бота (для проверки initData).
|
||||
- **N8N_TG_AUTH_WEBHOOK** — URL webhook в n8n, который по telegram_user_id возвращает unified_id (и при необходимости contact_id, phone, has_drafts).
|
||||
3. **n8n:** workflow по этому webhook принимает JSON с telegram_user_id и т.д. и отдаёт JSON с полем **unified_id** (обязательно).
|
||||
|
||||
Если что-то из этого не настроено, цепочка обрывается на шаге 5 (бэкенд/n8n), и пользователь остаётся в «обычном» режиме формы без авторизации через Telegram.
|
||||
@@ -5,6 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -52,8 +52,9 @@ export default function Step1Phone({
|
||||
setCodeSent(true);
|
||||
updateFormData({ phone });
|
||||
|
||||
// 🔧 DEV MODE: показываем debug код в модалке
|
||||
if (result.debug_code) {
|
||||
// 🔧 DEV MODE: показываем debug код в модалке (только в development)
|
||||
// В production debug_code не приходит с сервера, поэтому модалка не покажется
|
||||
if (result.debug_code && import.meta.env.MODE === 'development') {
|
||||
setDebugCode(result.debug_code);
|
||||
setShowDebugModal(true);
|
||||
}
|
||||
@@ -341,7 +342,8 @@ export default function Step1Phone({
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{/* 🔧 DEV MODE: Модалка с SMS кодом */}
|
||||
{/* 🔧 DEV MODE: Модалка с SMS кодом (только в development) */}
|
||||
{import.meta.env.MODE === 'development' && (
|
||||
<Modal
|
||||
title="🔧 DEV MODE - SMS Код"
|
||||
open={showDebugModal}
|
||||
@@ -393,6 +395,7 @@ export default function Step1Phone({
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Form, Input, Button, AutoComplete, message, Space, Divider } from 'antd
|
||||
import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200';
|
||||
const NSPK_BANKS_API = 'http://212.193.27.93/api/payouts/dictionaries/nspk-banks';
|
||||
// API для получения списка банков СБП через backend (избегаем Mixed Content ошибок)
|
||||
const NSPK_BANKS_API = `${API_BASE_URL}/api/v1/banks/nspk`;
|
||||
|
||||
interface Bank {
|
||||
bankid: string;
|
||||
@@ -51,10 +52,16 @@ export default function Step3Payment({
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Наш API возвращает формат: [{"bankId":"...","bankName":"..."}]
|
||||
let banksData: Bank[] = await response.json();
|
||||
|
||||
// ✅ Фильтруем банки без названия
|
||||
banksData = banksData.filter(bank => bank && bank.bankname && typeof bank.bankname === 'string');
|
||||
// Преобразуем формат нашего API в наш внутренний формат
|
||||
banksData = banksData
|
||||
.filter((bank: any) => bank && bank.bankName && typeof bank.bankName === 'string')
|
||||
.map((bank: any) => ({
|
||||
bankid: bank.bankId || '',
|
||||
bankname: bank.bankName
|
||||
}));
|
||||
|
||||
// Сортируем по названию для удобства
|
||||
banksData.sort((a, b) => {
|
||||
|
||||
@@ -98,7 +98,8 @@ export default function StepClaimConfirmation({
|
||||
false;
|
||||
|
||||
// Генерируем HTML форму здесь, на нашей стороне
|
||||
const html = generateConfirmationFormHTML(formData, contact_data_confirmed);
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://aiform.clientright.ru';
|
||||
const html = generateConfirmationFormHTML(formData, contact_data_confirmed, apiBaseUrl);
|
||||
setHtmlContent(html);
|
||||
setLoading(false);
|
||||
}, [claimPlanData]);
|
||||
|
||||
@@ -100,6 +100,7 @@ interface Props {
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
unified_id?: string;
|
||||
isTelegramMiniApp?: boolean; // ✅ Флаг Telegram Mini App
|
||||
onSelectDraft: (claimId: string) => void;
|
||||
onNewClaim: () => void;
|
||||
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
|
||||
@@ -175,6 +176,7 @@ export default function StepDraftSelection({
|
||||
phone,
|
||||
session_id,
|
||||
unified_id,
|
||||
isTelegramMiniApp,
|
||||
onSelectDraft,
|
||||
onNewClaim,
|
||||
onRestartDraft,
|
||||
@@ -211,7 +213,7 @@ export default function StepDraftSelection({
|
||||
console.log('🔍 StepDraftSelection: ответ API:', data);
|
||||
|
||||
// Определяем legacy черновики (без documents_required в payload)
|
||||
const processedDrafts = (data.drafts || []).map((draft: Draft) => {
|
||||
let processedDrafts = (data.drafts || []).map((draft: Draft) => {
|
||||
// Legacy только если:
|
||||
// 1. Статус 'draft' (старый формат) ИЛИ
|
||||
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
|
||||
@@ -224,6 +226,12 @@ export default function StepDraftSelection({
|
||||
};
|
||||
});
|
||||
|
||||
// ✅ В Telegram Mini App скрываем заявки "В работе"
|
||||
if (isTelegramMiniApp) {
|
||||
processedDrafts = processedDrafts.filter((draft: Draft) => draft.status_code !== 'in_work');
|
||||
console.log('🔍 Telegram Mini App: заявки "В работе" скрыты');
|
||||
}
|
||||
|
||||
setDrafts(processedDrafts);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки черновиков:', error);
|
||||
@@ -291,6 +299,27 @@ export default function StepDraftSelection({
|
||||
|
||||
// Кнопка действия
|
||||
const getActionButton = (draft: Draft) => {
|
||||
// Для заявок "В работе"
|
||||
if (draft.status_code === 'in_work') {
|
||||
// ✅ В веб-версии показываем кнопку "Просмотреть в Telegram"
|
||||
if (!isTelegramMiniApp) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileSearchOutlined />}
|
||||
onClick={() => {
|
||||
// Открываем Telegram бота
|
||||
window.open('https://t.me/klientprav_bot', '_blank');
|
||||
}}
|
||||
>
|
||||
Просмотреть в Telegram
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// ✅ В Telegram Mini App не показываем (но этот код не выполнится, т.к. заявки отфильтрованы)
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = getStatusConfig(draft);
|
||||
|
||||
return (
|
||||
@@ -521,7 +550,7 @@ export default function StepDraftSelection({
|
||||
</Text>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div style={{
|
||||
<div className="draft-actions" style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
@@ -529,6 +558,8 @@ export default function StepDraftSelection({
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
}}>
|
||||
{getActionButton(draft)}
|
||||
{/* Скрываем кнопку "Удалить" для заявок "В работе" */}
|
||||
{draft.status_code !== 'in_work' && (
|
||||
<Popconfirm
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
@@ -545,6 +576,7 @@ export default function StepDraftSelection({
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ interface Props {
|
||||
updateFormData: (data: any) => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
@@ -110,6 +111,7 @@ export default function StepWizardPlan({
|
||||
updateFormData,
|
||||
onNext,
|
||||
onPrev,
|
||||
backToDraftsList,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
|
||||
@@ -2271,7 +2273,7 @@ export default function StepWizardPlan({
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={onPrev}>← К списку заявок</Button>
|
||||
<Button onClick={backToDraftsList || onPrev}>← К списку заявок</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Функция генерации HTML формы подтверждения заявления
|
||||
// Основана на структуре из n8n Code node "Mini-app Подтверждение данных"
|
||||
|
||||
export function generateConfirmationFormHTML(data: any, contact_data_confirmed: boolean = false): string {
|
||||
export function generateConfirmationFormHTML(data: any, contact_data_confirmed: boolean = false, apiBaseUrl?: string): string {
|
||||
// API URL для загрузки банков (избегаем Mixed Content)
|
||||
const API_BASE_URL = apiBaseUrl || (typeof window !== 'undefined' && (window as any).API_BASE_URL) || 'https://aiform.clientright.ru';
|
||||
const BANKS_API_URL = `${API_BASE_URL}/api/v1/banks/nspk`;
|
||||
// Извлекаем SMS данные (до нормализации, так как структура может быть разной)
|
||||
const smsInputData = {
|
||||
prefix: data.sms_meta?.prefix || data.prefix || '',
|
||||
@@ -1667,7 +1670,9 @@ export function generateConfirmationFormHTML(data: any, contact_data_confirmed:
|
||||
|
||||
console.log('Loading NSPK banks...');
|
||||
|
||||
fetch('http://212.193.27.93/api/payouts/dictionaries/nspk-banks')
|
||||
// Используем backend endpoint через HTTPS (избегаем Mixed Content)
|
||||
var banksApiUrl = ${JSON.stringify(BANKS_API_URL)};
|
||||
fetch(banksApiUrl)
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* ========== ВЕБ (дефолт): как в aiform_dev ========== */
|
||||
.claim-form-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
@@ -51,3 +52,76 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Telegram Mini App: отдельный компактный скин ========== */
|
||||
.claim-form-container.telegram-mini-app {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 12px 10px max(16px, env(safe-area-inset-bottom));
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card {
|
||||
max-width: 100%;
|
||||
box-shadow: none;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head {
|
||||
padding: 10px 12px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps .ant-steps-item-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps .ant-steps-item-description {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps-content {
|
||||
min-height: 280px;
|
||||
padding: 8px 4px 12px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .ant-btn {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .ant-input,
|
||||
.claim-form-container.telegram-mini-app .ant-select-selector {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .ant-card-extra .ant-space-item .ant-btn,
|
||||
.claim-form-container.telegram-mini-app .ant-card-extra button {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Кнопки действий в черновиках - вертикально в Telegram */
|
||||
.claim-form-container.telegram-mini-app .draft-actions {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .draft-actions .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Steps, Card, message, Row, Col, Space } from 'antd';
|
||||
import { Steps, Card, message, Row, Col, Space, Spin } from 'antd';
|
||||
import Step1Phone from '../components/form/Step1Phone';
|
||||
import StepDescription from '../components/form/StepDescription';
|
||||
// Step1Policy убран - старый ERV флоу
|
||||
@@ -7,7 +7,7 @@ import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||
// Step2EventType, StepDocumentUpload убраны - старый ERV флоу
|
||||
import Step3Payment from '../components/form/Step3Payment';
|
||||
// Step3Payment убран - не используется
|
||||
import DebugPanel from '../components/DebugPanel';
|
||||
// getDocumentsForEventType убран - старый ERV флоу
|
||||
import './ClaimForm.css';
|
||||
@@ -105,14 +105,173 @@ export default function ClaimForm() {
|
||||
const [showDraftSelection, setShowDraftSelection] = useState(false);
|
||||
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
|
||||
const [hasDrafts, setHasDrafts] = useState(false);
|
||||
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
|
||||
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
|
||||
const [tgDebug, setTgDebug] = useState<string>('');
|
||||
/** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */
|
||||
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||
console.log('🔥 ClaimForm v3.9 - 2025-12-29 - Auto redirect to drafts after success');
|
||||
}, []);
|
||||
|
||||
// ✅ Восстановление сессии при загрузке страницы
|
||||
// Определение: зашли с веба или из Telegram Mini App. Дефолт — веб; при TG вешаем класс для отдельного скина.
|
||||
// Загружаем telegram-web-app.js только если есть признаки Telegram (чтобы не мусорить в консоли).
|
||||
useEffect(() => {
|
||||
const isTelegramContext = () => {
|
||||
// Проверяем URL, referrer и user agent на признаки Telegram
|
||||
const url = window.location.href;
|
||||
const ref = document.referrer;
|
||||
const ua = navigator.userAgent;
|
||||
return (
|
||||
url.includes('tgWebAppData') ||
|
||||
url.includes('tgWebAppVersion') ||
|
||||
ref.includes('telegram') ||
|
||||
ua.includes('Telegram')
|
||||
);
|
||||
};
|
||||
|
||||
if (isTelegramContext()) {
|
||||
// Загружаем скрипт Telegram SDK динамически
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
setTimeout(() => {
|
||||
const tg = (window as any).Telegram;
|
||||
const webApp = tg?.WebApp;
|
||||
const hasInitData = webApp?.initData && webApp.initData.length > 0;
|
||||
if (webApp && hasInitData) {
|
||||
setIsTelegramMiniApp(true);
|
||||
try {
|
||||
webApp.ready?.();
|
||||
webApp.expand?.();
|
||||
} catch (_) {}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ✅ Telegram Mini App: попытка авторизоваться через initData при первом заходе
|
||||
useEffect(() => {
|
||||
const tryTelegramAuth = async () => {
|
||||
try {
|
||||
// Только window: parent недоступен из-за cross-origin (iframe Telegram)
|
||||
const getTg = () => (window as any).Telegram;
|
||||
|
||||
// Ждём появления initData: скрипт Telegram может подгрузиться с задержкой
|
||||
const maxWaitMs = 2500;
|
||||
const intervalMs = 150;
|
||||
let webApp: TelegramWebApp | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts * intervalMs < maxWaitMs) {
|
||||
const tg = getTg();
|
||||
webApp = tg?.WebApp ?? null;
|
||||
if (webApp?.initData) {
|
||||
console.log('[TG] initData появился через', attempts * intervalMs, 'ms, длина=', webApp.initData.length);
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
|
||||
if (!webApp?.initData) {
|
||||
const tg = getTg();
|
||||
console.log('[TG] После ожидания', maxWaitMs, 'ms: Telegram=', !!tg, 'WebApp=', !!tg?.WebApp, 'initData=', !!tg?.WebApp?.initData, '→ пропускаем tg/auth');
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Логирование для отладки
|
||||
if (webApp.initDataUnsafe?.user) {
|
||||
const u = webApp.initDataUnsafe.user;
|
||||
console.log('[TG] initDataUnsafe.user:', { id: u.id, username: u.username, first_name: u.first_name });
|
||||
}
|
||||
|
||||
// Если сессия уже есть в localStorage — ничего не делаем, дальше сработает обычное restoreSession
|
||||
const existingToken = localStorage.getItem('session_token');
|
||||
if (existingToken) {
|
||||
setTgDebug('TG: session_token уже есть → tg/auth не вызываем');
|
||||
console.log('[TG] session_token уже в localStorage → tg/auth не вызываем');
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setTgDebug('TG: POST /api/v1/tg/auth...');
|
||||
console.log('[TG] Вызываем POST /api/v1/tg/auth, initData длина=', webApp.initData.length);
|
||||
|
||||
const response = await fetch('/api/v1/tg/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
init_data: webApp.initData,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[TG] /api/v1/tg/auth ответ: status=', response.status, 'ok=', response.ok, 'data=', data);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
console.warn('[TG] Telegram auth не успешен → показываем экран телефона/SMS. detail=', data.detail || data);
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionToken = data.session_token;
|
||||
|
||||
// Сохраняем session_token так же, как после SMS-логина
|
||||
if (sessionToken) {
|
||||
localStorage.setItem('session_token', sessionToken);
|
||||
sessionIdRef.current = sessionToken;
|
||||
}
|
||||
|
||||
// Сохраняем базовые данные пользователя (phone может быть пустым)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
unified_id: data.unified_id,
|
||||
phone: data.phone,
|
||||
contact_id: data.contact_id,
|
||||
session_id: sessionToken,
|
||||
}));
|
||||
|
||||
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
|
||||
setIsPhoneVerified(true);
|
||||
|
||||
// Если n8n сразу сообщил о наличии черновиков — показываем экран выбора
|
||||
if (data.has_drafts) {
|
||||
console.log('🤖 Telegram auth: has_drafts=true, переходим на экран черновиков');
|
||||
setShowDraftSelection(true);
|
||||
setHasDrafts(true);
|
||||
setCurrentStep(0);
|
||||
} else {
|
||||
// Иначе переходим сразу к описанию проблемы
|
||||
console.log('🤖 Telegram auth: черновиков нет, переходим к описанию проблемы');
|
||||
setCurrentStep(1);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setTgDebug(`TG: ошибка: ${msg}`);
|
||||
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
|
||||
} finally {
|
||||
setTelegramAuthChecked(true);
|
||||
}
|
||||
};
|
||||
|
||||
tryTelegramAuth();
|
||||
}, []);
|
||||
|
||||
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
|
||||
useEffect(() => {
|
||||
if (!telegramAuthChecked) {
|
||||
// Ждём, пока не закончим попытку Telegram-авторизации,
|
||||
// чтобы не гонять два параллельных restoreSession.
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreSession = async () => {
|
||||
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
|
||||
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
|
||||
@@ -180,12 +339,12 @@ export default function ClaimForm() {
|
||||
});
|
||||
});
|
||||
|
||||
message.success(`Добро пожаловать! Сессия восстановлена (${data.phone})`);
|
||||
message.success('Добро пожаловать!');
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
|
||||
} else {
|
||||
// Нет черновиков - переходим к описанию
|
||||
setCurrentStep(1);
|
||||
message.success(`Добро пожаловать! Сессия восстановлена (${data.phone})`);
|
||||
message.success('Добро пожаловать!');
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
||||
}
|
||||
} else {
|
||||
@@ -204,7 +363,7 @@ export default function ClaimForm() {
|
||||
};
|
||||
|
||||
restoreSession();
|
||||
}, []); // Запускаем только при загрузке
|
||||
}, [telegramAuthChecked]); // Запускаем только один раз, после попытки Telegram auth
|
||||
|
||||
// Получаем IP клиента один раз при монтировании
|
||||
useEffect(() => {
|
||||
@@ -277,8 +436,24 @@ export default function ClaimForm() {
|
||||
console.log('⏪ prevStep called');
|
||||
setCurrentStep((prev) => {
|
||||
console.log('📍 Current step:', prev, '→ Prev:', prev - 1);
|
||||
|
||||
// ✅ Если возвращаемся к шагу 0 и есть черновики - показываем список
|
||||
if (prev - 1 === 0 && formData.unified_id && hasDrafts) {
|
||||
console.log('📍 Возврат к списку черновиков');
|
||||
setShowDraftSelection(true);
|
||||
setSelectedDraftId(null);
|
||||
}
|
||||
|
||||
return prev - 1;
|
||||
});
|
||||
}, [formData.unified_id, hasDrafts]);
|
||||
|
||||
// ✅ Возврат к списку черновиков напрямую (без промежуточных шагов)
|
||||
const backToDraftsList = useCallback(() => {
|
||||
console.log('📋 Возврат к списку черновиков');
|
||||
setShowDraftSelection(true);
|
||||
setSelectedDraftId(null);
|
||||
setCurrentStep(0);
|
||||
}, []);
|
||||
|
||||
// Преобразование данных черновика в формат propertyName для формы подтверждения
|
||||
@@ -624,6 +799,13 @@ export default function ClaimForm() {
|
||||
const hasDocuments = Array.isArray(documentsMeta) && documentsMeta.length > 0;
|
||||
const isDraft = claim.status_code === 'draft';
|
||||
|
||||
// ✅ Запрещаем редактирование заявок "В работе"
|
||||
if (claim.status_code === 'in_work') {
|
||||
message.warning('Эта заявка уже в работе и не может быть изменена');
|
||||
console.log('⚠️ Попытка открыть заявку "В работе" для редактирования - запрещено');
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ НОВОЕ: Проверяем наличие form_draft (собранные данные из RAG)
|
||||
const formDraft = payload.form_draft;
|
||||
const hasFormDraft = !!(formDraft && formDraft.user && formDraft.offenders);
|
||||
@@ -1126,6 +1308,7 @@ export default function ClaimForm() {
|
||||
phone={formData.phone || ''}
|
||||
session_id={sessionIdRef.current}
|
||||
unified_id={formData.unified_id} // ✅ Передаём unified_id
|
||||
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
|
||||
onSelectDraft={handleSelectDraft}
|
||||
onNewClaim={handleNewClaim}
|
||||
/>
|
||||
@@ -1241,6 +1424,7 @@ export default function ClaimForm() {
|
||||
updateFormData={updateFormData}
|
||||
onPrev={prevStep}
|
||||
onNext={nextStep}
|
||||
backToDraftsList={backToDraftsList}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
@@ -1262,48 +1446,75 @@ export default function ClaimForm() {
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// ✅ СТАРЫЙ ФЛОУ: Step3Payment (только если нет StepClaimConfirmation)
|
||||
// Используется как fallback, если данные claim:plan не получены
|
||||
stepsArray.push({
|
||||
title: 'Заявление',
|
||||
description: 'Подтверждение',
|
||||
content: (
|
||||
<Step3Payment
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onPrev={prevStep}
|
||||
onSubmit={handleSubmit}
|
||||
isPhoneVerified={isPhoneVerified}
|
||||
setIsPhoneVerified={setIsPhoneVerified}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
// Step3Payment убран - не используется
|
||||
|
||||
return stepsArray;
|
||||
}, [formData, isPhoneVerified, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
||||
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
||||
|
||||
const handleReset = () => {
|
||||
console.log('🔄 Начать заново - возврат к списку черновиков');
|
||||
|
||||
// ✅ Генерируем новую сессию для новой заявки (но сохраняем авторизацию)
|
||||
const newSessionId = 'sess_' + generateUUIDv4();
|
||||
sessionIdRef.current = newSessionId;
|
||||
|
||||
setIsSubmitted(false);
|
||||
setFormData({
|
||||
setShowDraftSelection(false);
|
||||
setSelectedDraftId(null);
|
||||
|
||||
// ✅ Очищаем данные формы, НО сохраняем авторизацию (unified_id, phone, contact_id, isPhoneVerified)
|
||||
updateFormData({
|
||||
session_id: newSessionId,
|
||||
claim_id: undefined,
|
||||
voucher: '',
|
||||
claim_id: undefined, // ✅ Очищаем для новой заявки
|
||||
session_id: sessionIdRef.current,
|
||||
paymentMethod: 'sbp',
|
||||
problemDescription: undefined,
|
||||
wizardPlan: undefined,
|
||||
wizardAnswers: undefined,
|
||||
wizardPrefill: undefined,
|
||||
wizardPrefillArray: undefined,
|
||||
wizardCoverageReport: undefined,
|
||||
wizardUploads: undefined,
|
||||
wizardSkippedDocuments: undefined,
|
||||
eventType: undefined,
|
||||
// ✅ unified_id, phone, contact_id, isPhoneVerified НЕ очищаем
|
||||
});
|
||||
|
||||
// ✅ Проверяем черновики и возвращаемся к списку
|
||||
if (formData.unified_id && hasDrafts) {
|
||||
console.log('🔄 Есть черновики - показываем список');
|
||||
setShowDraftSelection(true);
|
||||
setCurrentStep(0);
|
||||
setIsPhoneVerified(false);
|
||||
} else {
|
||||
console.log('🔄 Нет черновиков - переходим к новой заявке');
|
||||
setCurrentStep(1); // StepDescription
|
||||
}
|
||||
|
||||
message.info('Форма сброшена');
|
||||
addDebugEvent('system', 'info', '🔄 Форма сброшена');
|
||||
};
|
||||
|
||||
// Обработчик кнопки "Выход" - завершить сессию и вернуться к Step1Phone
|
||||
// Обработчик кнопки "Выход"
|
||||
const handleExitToList = useCallback(async () => {
|
||||
console.log('🚪 Выход из системы');
|
||||
addDebugEvent('system', 'info', '🚪 Выход из системы');
|
||||
|
||||
// ✅ В Telegram Mini App — просто закрываем приложение
|
||||
if (isTelegramMiniApp) {
|
||||
try {
|
||||
const tg = (window as any).Telegram;
|
||||
const webApp = tg?.WebApp;
|
||||
if (webApp && typeof webApp.close === 'function') {
|
||||
webApp.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Ошибка при закрытии Telegram Mini App:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
|
||||
// Получаем session_token из localStorage
|
||||
const sessionToken = localStorage.getItem('session_token') || formData.session_id;
|
||||
|
||||
@@ -1328,42 +1539,50 @@ export default function ClaimForm() {
|
||||
// Удаляем session_token из localStorage
|
||||
localStorage.removeItem('session_token');
|
||||
|
||||
// ✅ Полный сброс: очищаем все данные авторизации и черновиков
|
||||
// Полный сброс: очищаем все данные авторизации и черновиков
|
||||
setIsSubmitted(false);
|
||||
setShowDraftSelection(false);
|
||||
setHasDrafts(false);
|
||||
setSelectedDraftId(null);
|
||||
|
||||
// ✅ Генерируем новую сессию для нового пользователя
|
||||
// Генерируем новую сессию для нового пользователя
|
||||
const newSessionId = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
sessionIdRef.current = newSessionId;
|
||||
|
||||
// ✅ Полностью очищаем formData, включая unified_id и phone
|
||||
// Полностью очищаем formData, включая unified_id и phone
|
||||
setFormData({
|
||||
voucher: '',
|
||||
claim_id: undefined,
|
||||
session_id: newSessionId,
|
||||
paymentMethod: 'sbp',
|
||||
unified_id: undefined, // ✅ Очищаем unified_id
|
||||
phone: undefined, // ✅ Очищаем phone
|
||||
contact_id: undefined, // ✅ Очищаем contact_id
|
||||
unified_id: undefined,
|
||||
phone: undefined,
|
||||
contact_id: undefined,
|
||||
is_new_contact: undefined,
|
||||
isPhoneVerified: false,
|
||||
});
|
||||
|
||||
// ✅ Сбрасываем флаг верификации телефона
|
||||
// Сбрасываем флаг верификации телефона
|
||||
setIsPhoneVerified(false);
|
||||
|
||||
// ✅ Переходим на экран входа (Step1Phone)
|
||||
// Если showDraftSelection = false и нет unified_id, то шаг 0 будет Step1Phone
|
||||
// Переходим на экран входа (Step1Phone)
|
||||
setCurrentStep(0);
|
||||
|
||||
message.info('Сессия завершена. До свидания!');
|
||||
addDebugEvent('system', 'info', '🔄 Форма сброшена');
|
||||
}, [formData.session_id, addDebugEvent]);
|
||||
}, [formData.session_id, addDebugEvent, isTelegramMiniApp]);
|
||||
|
||||
// ✅ Показываем loader пока идёт проверка Telegram auth и восстановление сессии
|
||||
if (!telegramAuthChecked || !sessionRestored) {
|
||||
return (
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Spin size="large" tip="Загрузка..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="claim-form-container" style={{ padding: '20px', background: '#ffffff' }}>
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
|
||||
<Row gutter={16}>
|
||||
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
|
||||
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
||||
|
||||
24
frontend/src/vite-env.d.ts
vendored
24
frontend/src/vite-env.d.ts
vendored
@@ -5,4 +5,28 @@ declare module '*.svg' {
|
||||
export default content;
|
||||
}
|
||||
|
||||
interface TelegramWebAppUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
}
|
||||
|
||||
interface TelegramWebApp {
|
||||
initData: string;
|
||||
initDataUnsafe: {
|
||||
user?: TelegramWebAppUser;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface TelegramNamespace {
|
||||
WebApp?: TelegramWebApp;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Telegram?: TelegramNamespace;
|
||||
}
|
||||
|
||||
|
||||
|
||||
55
start-dev.sh
Executable file
55
start-dev.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Запуск DEVELOPMENT окружения
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🚀 Запуск DEVELOPMENT окружения"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Проверка .env.dev
|
||||
if [ ! -f .env.dev ]; then
|
||||
echo "⚠️ Файл .env.dev не найден!"
|
||||
echo "📝 Создаю из .env.example..."
|
||||
if [ -f .env.example ]; then
|
||||
cp .env.example .env.dev
|
||||
echo "✅ Создан .env.dev (отредактируйте его!)"
|
||||
else
|
||||
echo "❌ Файл .env.example не найден!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "📦 Останавливаю существующие контейнеры..."
|
||||
docker-compose -f docker-compose.dev.yml down 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "🔨 Собираю и запускаю контейнеры..."
|
||||
docker-compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
echo ""
|
||||
echo "⏳ Жду запуска сервисов..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ DEVELOPMENT окружение запущено!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "📍 Доступные сервисы:"
|
||||
echo " Frontend: http://localhost:5175"
|
||||
echo " Backend: http://localhost:8200"
|
||||
echo " API Docs: http://localhost:8200/docs"
|
||||
echo ""
|
||||
echo "📊 Статус контейнеров:"
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
echo ""
|
||||
echo "📋 Логи:"
|
||||
echo " docker-compose -f docker-compose.dev.yml logs -f"
|
||||
echo ""
|
||||
|
||||
67
start-prod.sh
Executable file
67
start-prod.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Запуск PRODUCTION окружения
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🚀 Запуск PRODUCTION окружения"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Проверка .env.prod
|
||||
if [ ! -f .env.prod ]; then
|
||||
echo "⚠️ Файл .env.prod не найден!"
|
||||
echo "📝 Создаю из .env.example..."
|
||||
if [ -f .env.example ]; then
|
||||
cp .env.example .env.prod
|
||||
echo "✅ Создан .env.prod"
|
||||
echo "⚠️ ВАЖНО: Отредактируйте .env.prod перед запуском!"
|
||||
echo " - Установите APP_ENV=production"
|
||||
echo " - Установите DEBUG=false"
|
||||
echo " - Проверьте все URL и ключи API"
|
||||
read -p "Продолжить? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Файл .env.example не найден!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "📦 Останавливаю существующие контейнеры..."
|
||||
docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "🔨 Собираю и запускаю контейнеры..."
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
echo ""
|
||||
echo "⏳ Жду запуска сервисов..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ PRODUCTION окружение запущено!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "📍 Доступные сервисы:"
|
||||
echo " Frontend: http://localhost:5176"
|
||||
echo " Backend: http://localhost:8200"
|
||||
echo " API Docs: http://localhost:8200/docs"
|
||||
echo ""
|
||||
echo "📊 Статус контейнеров:"
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
echo ""
|
||||
echo "📋 Логи:"
|
||||
echo " docker-compose -f docker-compose.prod.yml logs -f"
|
||||
echo ""
|
||||
echo "⚠️ ВАЖНО: Проверьте healthcheck статус!"
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user