Files
aiform_prod/backend/app/api/session.py
Fedor f2e144e9ca Session: дублировать сессии во внешний Redis для доступа из n8n
- backend/app/api/session.py: при записи сессии в локальный Redis (6383) теперь также дублируем те же ключи
  в внешний Redis (REDIS_HOST/REDIS_PORT) через redis_service.client.
- Дублируются оба вида ключей:
  - session:{channel}:{channel_user_id}
  - session:{session_token}
- Ошибки внешнего Redis не ломают авторизацию: при недоступности — warning в логах.
2026-02-27 10:33:07 +03:00

323 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Session management API endpoints
Обеспечивает управление сессиями пользователей через Redis:
- Верификация по session_token или по (channel, channel_user_id)
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
- 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
from ..services.redis_service import redis_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/session", tags=["session"])
# Redis connection (используем существующее подключение)
redis_client: Optional[redis.Redis] = None
# TTL для сессии по channel+channel_user_id (секунды). 0 = без TTL.
SESSION_BY_CHANNEL_TTL_HOURS = 24
def init_redis(redis_conn: Optional[redis.Redis]):
"""Initialize Redis connection (локальный Redis для сессий). None при shutdown."""
global redis_client
redis_client = redis_conn
def _session_key_by_channel(channel: str, channel_user_id: str) -> str:
"""Ключ Redis для сессии по каналу и id пользователя в канале."""
return f"session:{channel}:{channel_user_id}"
async def set_session_by_channel_user(
channel: str,
channel_user_id: str,
data: Dict[str, Any],
) -> None:
"""
Записать сессию в Redis по ключу session:{channel}:{channel_user_id}.
data: unified_id, phone, contact_id, chat_id, has_drafts, ...
"""
if not redis_client:
raise HTTPException(status_code=500, detail="Redis connection not initialized")
key = _session_key_by_channel(channel, channel_user_id)
payload = {
"unified_id": data.get("unified_id") or "",
"phone": data.get("phone") or "",
"contact_id": data.get("contact_id") or "",
"chat_id": str(channel_user_id),
"has_drafts": data.get("has_drafts", False),
"verified_at": datetime.utcnow().isoformat(),
}
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
body = json.dumps(payload)
if ttl:
await redis_client.setex(key, ttl, body)
else:
await redis_client.set(key, body)
# Дублируем сессию в внешний Redis, чтобы n8n мог читать по тем же ключам
try:
if redis_service.client:
if ttl:
await redis_service.client.setex(key, ttl, body)
else:
await redis_service.client.set(key, body)
except Exception as e:
logger.warning("Не удалось продублировать сессию в внешний Redis (channel): %s", e)
logger.info("Сессия записана: %s, unified_id=%s", key, payload.get("unified_id"))
async def get_session_by_channel_user(channel: str, channel_user_id: str) -> Optional[Dict[str, Any]]:
"""Прочитать сессию из Redis по channel и channel_user_id. Если нет — None."""
if not redis_client:
return None
key = _session_key_by_channel(channel, channel_user_id)
raw = await redis_client.get(key)
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
async def set_session_by_token(session_token: str, data: Dict[str, Any]) -> None:
"""Записать сессию в Redis по ключу session:{session_token} (для совместимости с profile/claims)."""
if not redis_client:
return
key = f"session:{session_token}"
payload = {
"unified_id": data.get("unified_id") or "",
"phone": data.get("phone") or "",
"contact_id": data.get("contact_id") or "",
"chat_id": data.get("chat_id") or "",
"has_drafts": data.get("has_drafts", False),
"verified_at": datetime.utcnow().isoformat(),
}
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
body = json.dumps(payload)
if ttl:
await redis_client.setex(key, ttl, body)
else:
await redis_client.set(key, body)
# Дублируем сессию по токену в внешний Redis для доступа из n8n
try:
if redis_service.client:
if ttl:
await redis_service.client.setex(key, ttl, body)
else:
await redis_service.client.set(key, body)
except Exception as e:
logger.warning("Не удалось продублировать сессию в внешний Redis (token): %s", e)
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
chat_id: Optional[str] = None # telegram_user_id или max_user_id
verified_at: Optional[str] = None
expires_in_seconds: Optional[int] = None
class SessionVerifyByChannelRequest(BaseModel):
channel: str # tg | max
channel_user_id: str
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'),
chat_id=session_data.get('chat_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)}")
@router.post("/verify-by-channel", response_model=SessionVerifyResponse)
async def verify_session_by_channel(request: SessionVerifyByChannelRequest):
"""
Проверить сессию по channel и channel_user_id (ключ Redis: session:{channel}:{channel_user_id}).
Используется, когда клиент не хранит session_token и передаёт channel + channel_user_id.
"""
try:
data = await get_session_by_channel_user(request.channel, request.channel_user_id)
if not data:
return SessionVerifyResponse(success=True, valid=False)
ttl = await redis_client.ttl(_session_key_by_channel(request.channel, request.channel_user_id)) if redis_client else 0
return SessionVerifyResponse(
success=True,
valid=True,
unified_id=data.get("unified_id"),
phone=data.get("phone"),
contact_id=data.get("contact_id"),
chat_id=data.get("chat_id"),
verified_at=data.get("verified_at"),
expires_in_seconds=ttl if ttl > 0 else None,
)
except Exception as e:
logger.exception("Ошибка verify-by-channel: %s", e)
raise HTTPException(status_code=500, detail="Ошибка проверки сессии")
class SessionCreateRequest(BaseModel):
session_token: str
unified_id: str
phone: str
contact_id: str
ttl_hours: int = 24
chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id
@router.post("/create")
async def create_session(request: SessionCreateRequest):
"""
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
"""
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()
}
if request.chat_id is not None:
session_data['chat_id'] = str(request.chat_id).strip()
# Сохраняем в 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)}")