2025-11-20 18:31:42 +03:00
|
|
|
|
"""
|
|
|
|
|
|
Session management API endpoints
|
|
|
|
|
|
|
|
|
|
|
|
Обеспечивает управление сессиями пользователей через Redis:
|
2026-02-24 16:17:59 +03:00
|
|
|
|
- Верификация по session_token или по (channel, channel_user_id)
|
|
|
|
|
|
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
|
2025-11-20 18:31:42 +03:00
|
|
|
|
- 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
|
|
|
|
|
|
|
2026-02-27 10:33:07 +03:00
|
|
|
|
from ..services.redis_service import redis_service
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
|
|
|
|
|
|
|
|
|
|
|
# Redis connection (используем существующее подключение)
|
|
|
|
|
|
redis_client: Optional[redis.Redis] = None
|
|
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
# TTL для сессии по channel+channel_user_id (секунды). 0 = без TTL.
|
|
|
|
|
|
SESSION_BY_CHANNEL_TTL_HOURS = 24
|
2025-11-20 18:31:42 +03:00
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
|
|
|
|
|
|
def init_redis(redis_conn: Optional[redis.Redis]):
|
|
|
|
|
|
"""Initialize Redis connection (локальный Redis для сессий). None при shutdown."""
|
2025-11-20 18:31:42 +03:00
|
|
|
|
global redis_client
|
|
|
|
|
|
redis_client = redis_conn
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
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
|
2026-02-27 10:33:07 +03:00
|
|
|
|
body = json.dumps(payload)
|
2026-02-24 16:17:59 +03:00
|
|
|
|
if ttl:
|
2026-02-27 10:33:07 +03:00
|
|
|
|
await redis_client.setex(key, ttl, body)
|
2026-02-24 16:17:59 +03:00
|
|
|
|
else:
|
2026-02-27 10:33:07 +03:00
|
|
|
|
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)
|
2026-02-24 16:17:59 +03:00
|
|
|
|
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
|
2026-02-27 10:33:07 +03:00
|
|
|
|
body = json.dumps(payload)
|
2026-02-24 16:17:59 +03:00
|
|
|
|
if ttl:
|
2026-02-27 10:33:07 +03:00
|
|
|
|
await redis_client.setex(key, ttl, body)
|
2026-02-24 16:17:59 +03:00
|
|
|
|
else:
|
2026-02-27 10:33:07 +03:00
|
|
|
|
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)
|
2026-02-24 16:17:59 +03:00
|
|
|
|
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
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
|
2026-02-24 16:17:59 +03:00
|
|
|
|
chat_id: Optional[str] = None # telegram_user_id или max_user_id
|
2025-11-20 18:31:42 +03:00
|
|
|
|
verified_at: Optional[str] = None
|
|
|
|
|
|
expires_in_seconds: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
class SessionVerifyByChannelRequest(BaseModel):
|
|
|
|
|
|
channel: str # tg | max
|
|
|
|
|
|
channel_user_id: str
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
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'),
|
2026-02-24 16:17:59 +03:00
|
|
|
|
chat_id=session_data.get('chat_id'),
|
2025-11-20 18:31:42 +03:00
|
|
|
|
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)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
@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="Ошибка проверки сессии")
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
class SessionCreateRequest(BaseModel):
|
|
|
|
|
|
session_token: str
|
|
|
|
|
|
unified_id: str
|
|
|
|
|
|
phone: str
|
|
|
|
|
|
contact_id: str
|
|
|
|
|
|
ttl_hours: int = 24
|
2026-02-24 16:17:59 +03:00
|
|
|
|
chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id
|
2025-11-20 18:31:42 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/create")
|
|
|
|
|
|
async def create_session(request: SessionCreateRequest):
|
|
|
|
|
|
"""
|
2026-02-24 16:17:59 +03:00
|
|
|
|
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
|
2025-11-20 18:31:42 +03:00
|
|
|
|
|
2026-02-24 16:17:59 +03:00
|
|
|
|
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
|
2025-11-20 18:31:42 +03:00
|
|
|
|
"""
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
2026-02-24 16:17:59 +03:00
|
|
|
|
if request.chat_id is not None:
|
|
|
|
|
|
session_data['chat_id'] = str(request.chat_id).strip()
|
2025-11-20 18:31:42 +03:00
|
|
|
|
|
|
|
|
|
|
# Сохраняем в 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)}")
|
|
|
|
|
|
|