Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h

This commit is contained in:
Fedor
2026-02-24 16:17:59 +03:00
parent 6350f9015b
commit d8fe0b605b
23 changed files with 1785 additions and 449 deletions

View File

@@ -2,7 +2,8 @@
Session management API endpoints
Обеспечивает управление сессиями пользователей через Redis:
- Верификация существующей сессии
- Верификация по session_token или по (channel, channel_user_id)
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
- Logout (удаление сессии)
"""
@@ -22,13 +23,83 @@ 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: redis.Redis):
"""Initialize Redis connection"""
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
@@ -39,10 +110,16 @@ class SessionVerifyResponse(BaseModel):
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
@@ -92,6 +169,7 @@ async def verify_session(request: SessionVerifyRequest):
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
)
@@ -143,20 +221,47 @@ async def logout_session(request: SessionLogoutRequest):
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 верификации)
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
Обычно вызывается из Step1Phone после получения данных от n8n.
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
"""
try:
if not redis_client:
@@ -171,6 +276,8 @@ async def create_session(request: SessionCreateRequest):
'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(