""" 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 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 if ttl: await redis_client.setex(key, ttl, json.dumps(payload)) else: await redis_client.set(key, json.dumps(payload)) 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 if ttl: await redis_client.setex(key, ttl, json.dumps(payload)) else: await redis_client.set(key, json.dumps(payload)) 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)}")