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:
193
backend/app/api/session.py
Normal file
193
backend/app/api/session.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Session management API endpoints
|
||||
|
||||
Обеспечивает управление сессиями пользователей через Redis:
|
||||
- Верификация существующей сессии
|
||||
- Logout (удаление сессии)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import redis.asyncio as redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
||||
|
||||
# Redis connection (используем существующее подключение)
|
||||
redis_client: Optional[redis.Redis] = None
|
||||
|
||||
|
||||
def init_redis(redis_conn: redis.Redis):
|
||||
"""Initialize Redis connection"""
|
||||
global redis_client
|
||||
redis_client = redis_conn
|
||||
|
||||
|
||||
class SessionVerifyRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
|
||||
class SessionVerifyResponse(BaseModel):
|
||||
success: bool
|
||||
valid: bool
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
verified_at: Optional[str] = None
|
||||
expires_in_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class SessionLogoutRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
|
||||
class SessionLogoutResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/verify", response_model=SessionVerifyResponse)
|
||||
async def verify_session(request: SessionVerifyRequest):
|
||||
"""
|
||||
Проверить валидность сессии по session_token
|
||||
|
||||
Используется при загрузке страницы, чтобы восстановить сессию пользователя.
|
||||
Если сессия валидна - возвращаем unified_id, phone и другие данные.
|
||||
"""
|
||||
try:
|
||||
if not redis_client:
|
||||
raise HTTPException(status_code=500, detail="Redis connection not initialized")
|
||||
|
||||
session_key = f"session:{request.session_token}"
|
||||
|
||||
logger.info(f"🔍 Проверка сессии: {session_key}")
|
||||
|
||||
# Получаем данные сессии из Redis
|
||||
session_data_raw = await redis_client.get(session_key)
|
||||
|
||||
if not session_data_raw:
|
||||
logger.info(f"❌ Сессия не найдена или истекла: {session_key}")
|
||||
return SessionVerifyResponse(
|
||||
success=True,
|
||||
valid=False
|
||||
)
|
||||
|
||||
# Парсим данные сессии
|
||||
session_data = json.loads(session_data_raw)
|
||||
|
||||
# Получаем TTL (оставшееся время жизни)
|
||||
ttl = await redis_client.ttl(session_key)
|
||||
|
||||
logger.info(f"✅ Сессия валидна: unified_id={session_data.get('unified_id')}, TTL={ttl}s")
|
||||
|
||||
return SessionVerifyResponse(
|
||||
success=True,
|
||||
valid=True,
|
||||
unified_id=session_data.get('unified_id'),
|
||||
phone=session_data.get('phone'),
|
||||
contact_id=session_data.get('contact_id'),
|
||||
verified_at=session_data.get('verified_at'),
|
||||
expires_in_seconds=ttl if ttl > 0 else None
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга данных сессии: {e}")
|
||||
return SessionVerifyResponse(
|
||||
success=True,
|
||||
valid=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("❌ Ошибка проверки сессии")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка проверки сессии: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/logout", response_model=SessionLogoutResponse)
|
||||
async def logout_session(request: SessionLogoutRequest):
|
||||
"""
|
||||
Выход из сессии (удаление session_token из Redis)
|
||||
|
||||
Используется при клике на кнопку "Выход".
|
||||
"""
|
||||
try:
|
||||
if not redis_client:
|
||||
raise HTTPException(status_code=500, detail="Redis connection not initialized")
|
||||
|
||||
session_key = f"session:{request.session_token}"
|
||||
|
||||
logger.info(f"🚪 Выход из сессии: {session_key}")
|
||||
|
||||
# Удаляем сессию из Redis
|
||||
deleted = await redis_client.delete(session_key)
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"✅ Сессия удалена: {session_key}")
|
||||
return SessionLogoutResponse(
|
||||
success=True,
|
||||
message="Выход выполнен успешно"
|
||||
)
|
||||
else:
|
||||
logger.info(f"⚠️ Сессия не найдена (возможно, уже удалена): {session_key}")
|
||||
return SessionLogoutResponse(
|
||||
success=True,
|
||||
message="Сессия уже завершена"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Ошибка при выходе из сессии")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при выходе: {str(e)}")
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
session_token: str
|
||||
unified_id: str
|
||||
phone: str
|
||||
contact_id: str
|
||||
ttl_hours: int = 24
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_session(request: SessionCreateRequest):
|
||||
"""
|
||||
Создать новую сессию (вызывается после успешной SMS верификации)
|
||||
|
||||
Обычно вызывается из Step1Phone после получения данных от n8n.
|
||||
"""
|
||||
try:
|
||||
if not redis_client:
|
||||
raise HTTPException(status_code=500, detail="Redis connection not initialized")
|
||||
|
||||
session_key = f"session:{request.session_token}"
|
||||
|
||||
session_data = {
|
||||
'unified_id': request.unified_id,
|
||||
'phone': request.phone,
|
||||
'contact_id': request.contact_id,
|
||||
'verified_at': datetime.utcnow().isoformat(),
|
||||
'expires_at': (datetime.utcnow() + timedelta(hours=request.ttl_hours)).isoformat()
|
||||
}
|
||||
|
||||
# Сохраняем в Redis с TTL
|
||||
await redis_client.setex(
|
||||
session_key,
|
||||
request.ttl_hours * 3600, # TTL в секундах
|
||||
json.dumps(session_data)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Сессия создана: {session_key}, unified_id={request.unified_id}, TTL={request.ttl_hours}h")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'session_token': request.session_token,
|
||||
'expires_in_seconds': request.ttl_hours * 3600
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Ошибка создания сессии")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
|
||||
|
||||
Reference in New Issue
Block a user