2025-11-19 18:46:48 +03:00
|
|
|
|
# Архитектура личного кабинета и возобновления заполнения формы
|
|
|
|
|
|
|
|
|
|
|
|
## Сценарии использования
|
|
|
|
|
|
|
|
|
|
|
|
### 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)
|
|
|
|
|
|
- ✅ Гибкость (можно отключить кеш, если не нужен)
|
|
|
|
|
|
|
|
|
|
|
|
### Когда использовать:
|
|
|
|
|
|
- ✅ Личный кабинет (список незавершенных заявок)
|
|
|
|
|
|
- ✅ Возобновление заполнения формы
|
|
|
|
|
|
- ✅ Быстрая загрузка состояния формы
|
|
|
|
|
|
|
2025-11-21 15:57:18 +03:00
|
|
|
|
|
2025-11-24 13:36:14 +03:00
|
|
|
|
|