Problem:
- After wizard form submission, need to wait for claim data from n8n
- Claim data comes via Redis channel claim:plan:{session_token}
- Need to display confirmation form with claim data
Solution:
1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token}
- Subscribes to Redis channel claim:plan:{session_token}
- Streams claim data from n8n to frontend
- Handles timeouts and errors gracefully
2. Frontend: Added subscription to claim:plan channel
- StepWizardPlan: After form submission, subscribes to SSE
- Waits for claim_plan_ready event
- Shows loading message while waiting
- On success: saves claimPlanData and shows confirmation step
3. New component: StepClaimConfirmation
- Displays claim confirmation form in iframe
- Receives claimPlanData from parent
- Generates HTML form (placeholder - should call n8n for real HTML)
- Handles confirmation/cancellation via postMessage
4. ClaimForm: Added conditional step for confirmation
- Shows StepClaimConfirmation when showClaimConfirmation=true
- Step appears after StepWizardPlan
- Only visible when claimPlanData is available
Flow:
1. User fills wizard form → submits
2. Form data sent to n8n via /api/v1/claims/wizard
3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token}
4. n8n processes data → publishes to Redis claim:plan:{session_token}
5. Backend receives → streams to frontend via SSE
6. Frontend receives → shows StepClaimConfirmation
7. User confirms → proceeds to next step
Files:
- backend/app/api/events.py: Added stream_claim_plan endpoint
- frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan
- frontend/src/components/form/StepClaimConfirmation.tsx: New component
- frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
434 lines
13 KiB
Markdown
434 lines
13 KiB
Markdown
# Архитектура личного кабинета и возобновления заполнения формы
|
||
|
||
## Сценарии использования
|
||
|
||
### 1. Пользователь начинает заполнять форму
|
||
```
|
||
1. Вводит телефон → SMS верификация
|
||
2. Заполняет шаг 1 (полис)
|
||
3. Заполняет шаг 2 (визард)
|
||
4. Закрывает браузер (не завершил)
|
||
```
|
||
|
||
### 2. Пользователь возвращается через час/день/неделю
|
||
```
|
||
1. Заходит в личный кабинет
|
||
2. Видит список незавершенных заявок
|
||
3. Нажимает "Продолжить заполнение"
|
||
4. Форма должна быстро загрузиться с сохраненным состоянием
|
||
```
|
||
|
||
---
|
||
|
||
## Варианты архитектуры
|
||
|
||
### Вариант 1: Только PostgreSQL (простой)
|
||
|
||
**Как работает:**
|
||
```
|
||
Личный кабинет → Запрос в PostgreSQL → Получение данных → Отображение формы
|
||
```
|
||
|
||
**Плюсы:**
|
||
- ✅ Просто (один источник данных)
|
||
- ✅ Всегда актуальные данные
|
||
- ✅ Нет рассинхронизации
|
||
|
||
**Минусы:**
|
||
- ❌ Каждый раз запрос к PostgreSQL (1-10 мс)
|
||
- ❌ Нагрузка на БД при частых обращениях
|
||
|
||
**Когда использовать:**
|
||
- Небольшая нагрузка
|
||
- Простота важнее скорости
|
||
|
||
---
|
||
|
||
### Вариант 2: PostgreSQL + Redis кеш (рекомендую)
|
||
|
||
**Как работает:**
|
||
|
||
#### При сохранении данных:
|
||
```
|
||
1. Сохраняем в PostgreSQL (основное хранилище)
|
||
2. Сохраняем в Redis с TTL 24 часа (быстрый доступ)
|
||
```
|
||
|
||
#### При чтении данных:
|
||
```
|
||
1. Пробуем Redis (быстро, 0.1-1 мс)
|
||
2. Если нет в кеше → PostgreSQL (1-10 мс)
|
||
3. Загружаем в Redis на 24 часа (для следующих обращений)
|
||
```
|
||
|
||
**Плюсы:**
|
||
- ✅ Быстрый доступ (если есть в кеше)
|
||
- ✅ Fallback на PostgreSQL (если кеш пуст)
|
||
- ✅ Автоматическая очистка (TTL 24 часа)
|
||
- ✅ Lazy loading (загружаем в Redis при первом обращении)
|
||
|
||
**Минусы:**
|
||
- ⚠️ Нужно обновлять оба хранилища
|
||
- ⚠️ Риск устаревших данных (если забыли обновить кеш)
|
||
|
||
**Когда использовать:**
|
||
- Средняя/высокая нагрузка
|
||
- Важна скорость загрузки
|
||
- Пользователи часто возвращаются к формам
|
||
|
||
---
|
||
|
||
### Вариант 3: Только Redis с периодической синхронизацией
|
||
|
||
**Как работает:**
|
||
```
|
||
1. Основное хранилище - Redis (TTL 7 дней)
|
||
2. Периодически синхронизируем с PostgreSQL (раз в час/день)
|
||
3. При завершении формы - сохраняем в PostgreSQL
|
||
```
|
||
|
||
**Плюсы:**
|
||
- ✅ Очень быстрый доступ
|
||
- ✅ Автоматическая очистка старых сессий
|
||
|
||
**Минусы:**
|
||
- ❌ Риск потери данных (если Redis упал)
|
||
- ❌ Сложнее синхронизация
|
||
- ❌ Нет истории изменений
|
||
|
||
**Когда использовать:**
|
||
- Не рекомендуется (рискованно)
|
||
|
||
---
|
||
|
||
## Рекомендуемая архитектура (Вариант 2)
|
||
|
||
### Структура данных в Redis:
|
||
|
||
**Ключ:** `claim:CLM-2025-11-18-GEQ3KL`
|
||
|
||
**Значение:**
|
||
```json
|
||
{
|
||
"claim_id": "CLM-2025-11-18-GEQ3KL",
|
||
"contact_id": "398523",
|
||
"phone": "72352352352",
|
||
"status": "draft",
|
||
"current_step": 3,
|
||
"payload": {
|
||
"answers": {...},
|
||
"wizard_plan": {...},
|
||
"documents_meta": [...]
|
||
},
|
||
"created_at": "2025-11-18T20:43:47.033Z",
|
||
"updated_at": "2025-11-18T20:44:59.217Z"
|
||
}
|
||
```
|
||
|
||
**TTL:** 24 часа (86400 секунд)
|
||
|
||
---
|
||
|
||
### Алгоритм работы:
|
||
|
||
#### 1. При сохранении данных (claimsave):
|
||
|
||
```python
|
||
# В n8n workflow после SQL запроса
|
||
|
||
# 1. Сохраняем в PostgreSQL (уже сделано)
|
||
# 2. Сохраняем в Redis для быстрого доступа
|
||
redis_key = f"claim:{claim_id}"
|
||
redis_value = {
|
||
"claim_id": claim_id,
|
||
"contact_id": contact_id,
|
||
"phone": phone,
|
||
"status": "draft",
|
||
"current_step": current_step,
|
||
"payload": {
|
||
"answers": answers,
|
||
"wizard_plan": wizard_plan,
|
||
"documents_meta": documents_meta
|
||
},
|
||
"updated_at": datetime.now().isoformat()
|
||
}
|
||
|
||
await redis.set_json(
|
||
redis_key,
|
||
redis_value,
|
||
expire=86400 # 24 часа
|
||
)
|
||
```
|
||
|
||
#### 2. При чтении данных (личный кабинет):
|
||
|
||
```python
|
||
async def get_claim_for_resume(claim_id: str):
|
||
# 1. Пробуем Redis (быстро)
|
||
cached = await redis.get_json(f"claim:{claim_id}")
|
||
if cached:
|
||
logger.info(f"✅ Cache hit: {claim_id}")
|
||
return cached
|
||
|
||
# 2. Если нет в кеше - из PostgreSQL
|
||
logger.info(f"🔄 Cache miss: {claim_id}, loading from PostgreSQL")
|
||
claim = await db.get_claim_by_claim_id(claim_id)
|
||
|
||
if not claim:
|
||
return None
|
||
|
||
# 3. Формируем данные для Redis
|
||
redis_data = {
|
||
"claim_id": claim_id,
|
||
"contact_id": claim.payload.get("contact_id"),
|
||
"phone": claim.payload.get("phone"),
|
||
"status": claim.status_code,
|
||
"current_step": calculate_current_step(claim.payload),
|
||
"payload": {
|
||
"answers": claim.payload.get("answers", {}),
|
||
"wizard_plan": claim.payload.get("wizard_plan"),
|
||
"documents_meta": claim.payload.get("documents_meta", [])
|
||
},
|
||
"updated_at": claim.updated_at.isoformat()
|
||
}
|
||
|
||
# 4. Сохраняем в Redis на 24 часа (lazy loading)
|
||
await redis.set_json(f"claim:{claim_id}", redis_data, expire=86400)
|
||
|
||
return redis_data
|
||
```
|
||
|
||
#### 3. При обновлении данных:
|
||
|
||
```python
|
||
async def update_claim(claim_id: str, data: dict):
|
||
# 1. Обновляем PostgreSQL (основное хранилище)
|
||
await db.update_claim(claim_id, data)
|
||
|
||
# 2. Обновляем Redis кеш (если есть)
|
||
redis_key = f"claim:{claim_id}"
|
||
if await redis.exists(redis_key):
|
||
cached = await redis.get_json(redis_key)
|
||
if cached:
|
||
# Мерджим данные
|
||
cached.update(data)
|
||
cached["updated_at"] = datetime.now().isoformat()
|
||
await redis.set_json(redis_key, cached, expire=86400)
|
||
|
||
# Или просто удаляем кеш (при следующем чтении загрузится из PostgreSQL)
|
||
# await redis.delete(redis_key)
|
||
```
|
||
|
||
---
|
||
|
||
## Стратегии TTL
|
||
|
||
### Вариант A: Фиксированный TTL (24 часа)
|
||
|
||
**Плюсы:**
|
||
- ✅ Просто
|
||
- ✅ Автоматическая очистка старых данных
|
||
|
||
**Минусы:**
|
||
- ❌ Может истечь, даже если пользователь активен
|
||
|
||
### Вариант B: Продлеваем TTL при обращении
|
||
|
||
**Плюсы:**
|
||
- ✅ Активные заявки не истекают
|
||
- ✅ Старые заявки автоматически очищаются
|
||
|
||
**Минусы:**
|
||
- ⚠️ Нужно продлевать TTL при каждом чтении
|
||
|
||
**Реализация:**
|
||
```python
|
||
async def get_claim_with_refresh(claim_id: str):
|
||
cached = await redis.get_json(f"claim:{claim_id}")
|
||
if cached:
|
||
# Продлеваем TTL на 24 часа
|
||
await redis.expire(f"claim:{claim_id}", 86400)
|
||
return cached
|
||
# ... загрузка из PostgreSQL
|
||
```
|
||
|
||
### Вариант C: Длинный TTL для незавершенных заявок
|
||
|
||
**Плюсы:**
|
||
- ✅ Незавершенные заявки хранятся долго (7 дней)
|
||
- ✅ Завершенные заявки удаляются быстро (1 час)
|
||
|
||
**Реализация:**
|
||
```python
|
||
ttl = 604800 if status == "draft" else 3600 # 7 дней или 1 час
|
||
await redis.set_json(redis_key, data, expire=ttl)
|
||
```
|
||
|
||
---
|
||
|
||
## Личный кабинет: Список незавершенных заявок
|
||
|
||
### Как получить список:
|
||
|
||
**Вариант 1: Из PostgreSQL (рекомендую)**
|
||
```sql
|
||
SELECT
|
||
id,
|
||
payload->>'claim_id' as claim_id,
|
||
status_code,
|
||
payload->'answers' as answers,
|
||
updated_at
|
||
FROM clpr_claims
|
||
WHERE
|
||
payload->>'claim_id' LIKE 'CLM-%'
|
||
AND status_code IN ('draft', 'in_work')
|
||
AND channel = 'web_form'
|
||
AND updated_at > NOW() - INTERVAL '30 days'
|
||
ORDER BY updated_at DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
**Вариант 2: Из Redis (если нужно очень быстро)**
|
||
```python
|
||
# Ищем все ключи claim:CLM-*
|
||
keys = await redis.keys("claim:CLM-*")
|
||
claims = []
|
||
for key in keys:
|
||
claim = await redis.get_json(key)
|
||
if claim and claim.get("status") in ["draft", "in_work"]:
|
||
claims.append(claim)
|
||
```
|
||
|
||
**Проблема:** Redis не предназначен для поиска по паттернам (медленно)
|
||
|
||
**Решение:** Использовать индекс в PostgreSQL:
|
||
```sql
|
||
CREATE INDEX idx_clpr_claims_status_channel
|
||
ON clpr_claims(status_code, channel)
|
||
WHERE status_code IN ('draft', 'in_work');
|
||
```
|
||
|
||
---
|
||
|
||
## Рекомендуемая архитектура
|
||
|
||
### Для веб-формы:
|
||
|
||
1. **Основное хранилище:** PostgreSQL (`clpr_claims`)
|
||
- Полные данные
|
||
- История изменений
|
||
- Надежность
|
||
|
||
2. **Кеш:** Redis (`claim:CLM-...`)
|
||
- Быстрый доступ
|
||
- TTL 24 часа
|
||
- Lazy loading (загружаем при первом обращении)
|
||
|
||
3. **Алгоритм:**
|
||
```
|
||
Чтение:
|
||
1. Redis (если есть) → возврат
|
||
2. PostgreSQL → загрузка → сохранение в Redis → возврат
|
||
|
||
Запись:
|
||
1. PostgreSQL (основное)
|
||
2. Redis (обновление кеша или удаление)
|
||
```
|
||
|
||
4. **TTL стратегия:**
|
||
- Незавершенные заявки (`draft`, `in_work`): 7 дней
|
||
- Завершенные заявки (`submitted`): 1 час
|
||
- Продлеваем TTL при обращении
|
||
|
||
---
|
||
|
||
## Реализация в n8n
|
||
|
||
### После `claimsave`:
|
||
|
||
```javascript
|
||
// Code Node: Save to Redis
|
||
const claim = $json.claim;
|
||
const channel = $json.channel || 'web_form';
|
||
|
||
if (channel === 'web_form') {
|
||
// Определяем TTL в зависимости от статуса
|
||
const status = claim.status_code || 'draft';
|
||
const ttl = (status === 'draft' || status === 'in_work')
|
||
? 604800 // 7 дней для незавершенных
|
||
: 3600; // 1 час для завершенных
|
||
|
||
return {
|
||
redis_key: `claim:${claim.claim_id_str}`,
|
||
redis_value: JSON.stringify({
|
||
claim_id: claim.claim_id_str,
|
||
contact_id: claim.payload?.contact_id,
|
||
phone: claim.payload?.phone,
|
||
status: status,
|
||
current_step: calculateStep(claim.payload),
|
||
payload: {
|
||
answers: claim.payload?.answers,
|
||
wizard_plan: claim.payload?.wizard_plan,
|
||
documents_meta: claim.payload?.documents_meta
|
||
},
|
||
updated_at: new Date().toISOString()
|
||
}),
|
||
ttl: ttl
|
||
};
|
||
}
|
||
|
||
// Redis Node: SET with TTL
|
||
// Key: {{ $json.redis_key }}
|
||
// Value: {{ $json.redis_value }}
|
||
// TTL: {{ $json.ttl }}
|
||
```
|
||
|
||
### При чтении (личный кабинет):
|
||
|
||
```javascript
|
||
// Code Node: Get claim with cache
|
||
const claim_id = $json.claim_id;
|
||
|
||
// 1. Пробуем Redis
|
||
const cached = await redis.get(`claim:${claim_id}`);
|
||
if (cached) {
|
||
return JSON.parse(cached);
|
||
}
|
||
|
||
// 2. Если нет - из PostgreSQL
|
||
// (выполняется SQL запрос)
|
||
const claim = await postgres.get_claim(claim_id);
|
||
|
||
// 3. Сохраняем в Redis
|
||
if (claim) {
|
||
await redis.set(`claim:${claim_id}`, JSON.stringify(claim), 'EX', 86400);
|
||
}
|
||
|
||
return claim;
|
||
```
|
||
|
||
---
|
||
|
||
## Итог
|
||
|
||
### Рекомендуемая архитектура:
|
||
|
||
1. **PostgreSQL** - основное хранилище (источник истины)
|
||
2. **Redis** - кеш для быстрого доступа (TTL 24 часа, продлеваем при обращении)
|
||
3. **Lazy loading** - загружаем в Redis при первом обращении
|
||
4. **Инвалидация** - обновляем или удаляем кеш при изменении данных
|
||
|
||
### Преимущества:
|
||
- ✅ Быстрый доступ (если есть в кеше)
|
||
- ✅ Надежность (данные в PostgreSQL)
|
||
- ✅ Автоматическая очистка (TTL)
|
||
- ✅ Гибкость (можно отключить кеш, если не нужен)
|
||
|
||
### Когда использовать:
|
||
- ✅ Личный кабинет (список незавершенных заявок)
|
||
- ✅ Возобновление заполнения формы
|
||
- ✅ Быстрая загрузка состояния формы
|
||
|
||
|
||
|