Files
crm.clientright.ru/ticket_form/docs/PERSONAL_CABINET_ARCHITECTURE.md
Fedor 52fe013375 feat(ticket_form): unified_id/contact_id передача, исправлен мерж сессии, новая сессия для жалобы
- Добавлены unified_id и contact_id в TicketFormDescriptionRequest
- Исправлен CODE_MERGE_PROJECT_TO_SESSION.js - теперь сохраняются ВСЕ данные из body.other
- Добавлен fallback на получение other из Webhook напрямую
- Генерация новой session_id при создании новой жалобы (сохраняя авторизацию)
- Добавлен SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql для CRM контактов
- Создан SESSION_LOG_2025-11-25.md с документацией сессии
2025-11-25 20:02:21 +03:00

13 KiB
Raw Blame History

Архитектура личного кабинета и возобновления заполнения формы

Сценарии использования

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

Значение:

{
  "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):

# В 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. При чтении данных (личный кабинет):

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. При обновлении данных:

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 при каждом чтении

Реализация:

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 час)

Реализация:

ttl = 604800 if status == "draft" else 3600  # 7 дней или 1 час
await redis.set_json(redis_key, data, expire=ttl)

Личный кабинет: Список незавершенных заявок

Как получить список:

Вариант 1: Из PostgreSQL (рекомендую)

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 (если нужно очень быстро)

# Ищем все ключи 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:

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:

// 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 }}

При чтении (личный кабинет):

// 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)
  • Гибкость (можно отключить кеш, если не нужен)

Когда использовать:

  • Личный кабинет (список незавершенных заявок)
  • Возобновление заполнения формы
  • Быстрая загрузка состояния формы