feat: Session persistence with Redis + Draft management fixes

- Implement session management API (/api/v1/session/create, verify, logout)
- Add session restoration from localStorage on page reload
- Fix session_id priority when loading drafts (use current, not old from DB)
- Add unified_id and claim_id to wizard payload sent to n8n
- Add Docker volume for frontend HMR (Hot Module Replacement)
- Add comprehensive session logging for debugging

Components updated:
- backend/app/api/session.py (NEW) - Session management endpoints
- backend/app/main.py - Include session router
- frontend/src/components/form/Step1Phone.tsx v2.0 - Create session after SMS
- frontend/src/pages/ClaimForm.tsx v3.8 - Session restoration & priority fix
- frontend/src/components/form/StepWizardPlan.tsx v1.4 - Add unified_id/claim_id
- docker-compose.yml - Add frontend volume for live reload

Session flow:
1. User verifies phone -> session created in Redis (24h TTL)
2. session_token saved to localStorage
3. Page reload -> session restored automatically
4. Draft selected -> current session_id used (not old from DB)
5. Wizard submit -> unified_id, claim_id, session_id sent to n8n
6. Logout -> session removed from Redis & localStorage

Fixes:
- Session token not persisting after page reload
- unified_id missing in n8n webhook payload
- Old session_id from draft overwriting current session
- Frontend changes requiring container rebuild
This commit is contained in:
AI Assistant
2025-11-20 18:31:42 +03:00
parent 4c8fda5f55
commit 3621ae6021
25 changed files with 3120 additions and 181 deletions

View File

@@ -186,29 +186,40 @@ async def list_drafts(
if not unified_id and not phone and not session_id:
raise HTTPException(status_code=400, detail="Необходимо указать unified_id, phone или session_id")
query = """
SELECT
c.id,
c.payload->>'claim_id' as claim_id,
c.session_token,
c.status_code,
c.channel,
c.payload,
c.created_at,
c.updated_at
FROM clpr_claims c
WHERE 1=1
"""
params = []
# Используем запрос из документации SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql
if unified_id:
# Основной способ - поиск по unified_id
query += " AND c.unified_id = $1"
params.append(unified_id)
query = """
SELECT
c.id,
c.payload->>'claim_id' as claim_id,
c.session_token,
c.status_code,
c.channel,
c.payload,
c.created_at,
c.updated_at
FROM clpr_claims c
WHERE c.unified_id = $1
ORDER BY c.updated_at DESC
LIMIT 20
"""
params = [unified_id]
logger.info(f"🔍 Searching by unified_id: {unified_id}")
elif phone:
# Fallback: ищем через clpr_user_accounts и clpr_users
query += """
AND c.unified_id = (
query = """
SELECT
c.id,
c.payload->>'claim_id' as claim_id,
c.session_token,
c.status_code,
c.channel,
c.payload,
c.created_at,
c.updated_at
FROM clpr_claims c
WHERE c.unified_id = (
SELECT u.unified_id
FROM clpr_user_accounts ua
JOIN clpr_users u ON u.id = ua.user_id
@@ -216,32 +227,73 @@ async def list_drafts(
AND ua.channel_user_id = $1
LIMIT 1
)
ORDER BY c.updated_at DESC
LIMIT 20
"""
params.append(phone)
params = [phone]
logger.info(f"🔍 Searching by phone (fallback): {phone}")
elif session_id:
# Fallback: поиск по session_token
query += " AND c.session_token = $1"
params.append(session_id)
query += " ORDER BY c.updated_at DESC LIMIT 20"
query = """
SELECT
c.id,
c.payload->>'claim_id' as claim_id,
c.session_token,
c.status_code,
c.channel,
c.payload,
c.created_at,
c.updated_at
FROM clpr_claims c
WHERE c.session_token = $1
ORDER BY c.updated_at DESC
LIMIT 20
"""
params = [session_id]
logger.info(f"🔍 Searching by session_id (fallback): {session_id}")
else:
# Это не должно произойти, т.к. проверка выше
raise HTTPException(status_code=400, detail="Необходимо указать unified_id, phone или session_id")
# Простой тест: проверяем, что unified_id вообще есть в базе
test_count = 0
test_count_null = 0
if unified_id:
try:
test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id)
# Также проверяем, сколько записей с NULL unified_id для этого пользователя (через phone)
if phone:
test_count_null = await db.fetch_val("""
SELECT COUNT(*) FROM clpr_claims c
WHERE c.unified_id IS NULL
AND c.channel = 'web_form'
AND c.payload->>'phone' = $1
""", phone)
logger.info(f"🔍 Test COUNT: unified_id={unified_id}{test_count} records")
if test_count_null > 0:
logger.warning(f"⚠️ Found {test_count_null} records with NULL unified_id for phone={phone}")
except Exception as e:
logger.error(f"❌ Ошибка тестового COUNT: {e}")
rows = await db.fetch_all(query, *params)
# Детальное логирование для отладки
logger.info(f"🔍 Drafts query: unified_id={unified_id}, phone={phone}, session_id={session_id}")
logger.info(f"🔍 SQL query: {query}")
logger.info(f"🔍 SQL params: {params}")
logger.info(f"🔍 Test COUNT result: {test_count}")
logger.info(f"🔍 Rows found: {len(rows)}")
# ВРЕМЕННО: возвращаем тестовые данные для отладки
debug_info = {
"unified_id": unified_id,
"test_count": test_count,
"test_count_null": test_count_null,
"rows_found": len(rows),
"query": query[:100] if len(query) > 100 else query,
"params": params
"query": query[:200] if len(query) > 200 else query,
"params": params,
"phone": phone,
"session_id": session_id
}
drafts = []
@@ -275,7 +327,8 @@ async def list_drafts(
return {
"success": True,
"count": len(drafts),
"drafts": drafts
"drafts": drafts,
"debug": debug_info # ВРЕМЕННО: для отладки
}
except HTTPException:
@@ -293,26 +346,33 @@ async def get_draft(claim_id: str):
Возвращает все данные формы для продолжения заполнения
"""
try:
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
# Убираем фильтры по channel и status_code, чтобы находить черновики из всех каналов
query = """
SELECT
id,
payload->>'claim_id' as claim_id,
session_token,
status_code,
channel,
payload,
created_at,
updated_at
FROM clpr_claims
WHERE payload->>'claim_id' = $1
AND status_code = 'draft'
AND channel = 'web_form'
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
LIMIT 1
"""
row = await db.fetch_one(query, claim_id)
logger.info(f"🔍 Найдено записей: {1 if row else 0}")
if row:
logger.info(f"🔍 Найден черновик: id={row.get('id')}, claim_id={row.get('claim_id')}, channel={row.get('channel')}, status={row.get('status_code')}")
if not row:
raise HTTPException(status_code=404, detail="Черновик не найден")
raise HTTPException(status_code=404, detail=f"Черновик не найден: {claim_id}")
# Обрабатываем payload - может быть строкой (JSONB) или уже dict
payload_raw = row.get('payload')
@@ -326,13 +386,20 @@ async def get_draft(claim_id: str):
else:
payload = {}
# Извлекаем claim_id из payload, если его нет в row
claim_id_from_payload = payload.get('claim_id') if isinstance(payload, dict) else None
final_claim_id = row.get('claim_id') or claim_id_from_payload
logger.info(f"🔍 Загружен черновик: id={row.get('id')}, claim_id={final_claim_id}, channel={row.get('channel')}")
return {
"success": True,
"claim": {
"id": str(row['id']),
"claim_id": row.get('claim_id'),
"claim_id": final_claim_id, # ✅ Используем claim_id из payload, если его нет в row
"session_token": row.get('session_token'),
"status_code": row.get('status_code'),
"channel": row.get('channel'), # ✅ Добавляем 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
@@ -393,6 +460,85 @@ async def get_claim(claim_id: str):
}
@router.get("/wizard/load/{claim_id}")
async def load_wizard_data(claim_id: str):
"""
Загрузить данные визарда из PostgreSQL по claim_id
Используется после получения claim_id из ocr_events.
Возвращает полные данные для построения формы (wizard_plan, problem_description и т.д.)
"""
try:
logger.info(f"🔍 Загрузка данных визарда для claim_id={claim_id}")
# Ищем заявку по claim_id (может быть UUID или строка CLM-...)
query = """
SELECT
id,
payload->>'claim_id' as claim_id,
session_token,
unified_id,
status_code,
channel,
payload,
created_at,
updated_at
FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
LIMIT 1
"""
row = await db.fetch_one(query, claim_id)
if not row:
raise HTTPException(status_code=404, detail=f"Заявка не найдена: {claim_id}")
# Обрабатываем payload - может быть строкой (JSONB) или уже dict
payload_raw = row.get('payload')
if isinstance(payload_raw, str):
try:
payload = json.loads(payload_raw) if payload_raw else {}
except (json.JSONDecodeError, TypeError):
payload = {}
elif isinstance(payload_raw, dict):
payload = payload_raw
else:
payload = {}
# Извлекаем claim_id из payload, если его нет в row
claim_id_from_payload = payload.get('claim_id') if isinstance(payload, dict) else None
final_claim_id = row.get('claim_id') or claim_id_from_payload or str(row['id'])
logger.info(f"✅ Загружены данные визарда: claim_id={final_claim_id}, has_wizard_plan={payload.get('wizard_plan') is not None}")
return {
"success": True,
"claim_id": final_claim_id,
"session_token": row.get('session_token'),
"unified_id": row.get('unified_id'),
"status_code": row.get('status_code'),
"channel": row.get('channel'),
"wizard_plan": payload.get('wizard_plan'),
"problem_description": payload.get('problem_description'),
"wizard_answers": payload.get('answers'),
"answers_prefill": payload.get('answers_prefill'),
"documents_meta": payload.get('documents_meta', []),
"ai_agent1_facts": payload.get('ai_agent1_facts'),
"ai_agent13_rag": payload.get('ai_agent13_rag'),
"coverage_report": payload.get('coverage_report'),
"phone": payload.get('phone'),
"email": payload.get('email'),
"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,
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Ошибка при загрузке данных визарда")
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
@router.post("/description")
async def publish_ticket_form_description(payload: TicketFormDescriptionRequest):
"""
@@ -404,7 +550,7 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
event = {
"type": "ticket_form_description",
"session_id": payload.session_id,
"claim_id": payload.claim_id,
"claim_id": payload.claim_id, # Опционально - может быть None
"phone": payload.phone,
"email": payload.email,
"description": payload.problem_description.strip(),
@@ -413,7 +559,7 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
}
logger.info(
"📝 TicketForm description received",
extra={"session_id": payload.session_id, "claim_id": payload.claim_id},
extra={"session_id": payload.session_id, "claim_id": payload.claim_id or "not_set"},
)
await redis_service.publish(channel, json.dumps(event, ensure_ascii=False))
logger.info(