From 61c903ae0e89d0e102f1bc801a1d9cacecdd87ac Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 24 Aug 2026 18:31:20 +0500 Subject: [PATCH] =?UTF-8?q?fix(api):=20500=20=D0=BD=D0=B0=20GET=20/tasks/{?= =?UTF-8?q?id}=20=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1=D1=80=D0=B0=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=BA=20=D0=B7=D0=B0=D0=BA=D1=8D=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=BC=D1=83=20?= =?UTF-8?q?=D1=8E=D0=B7=D0=B5=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живая проверка после первого реального OAuth-логина: юзер вошёл, поиск отработал, но статус задачи не показывался — GET /api/tasks/{id} падал 500. В логе: AttributeError: 'NoneType' object has no attribute 'supports_population' на current_user.id. Причина: _load_user() при попадании в Redis-кэш делал User.__new__(User); u.__dict__.update(data) — выглядело как лёгкий объект без лишнего SELECT, но замапленные атрибуты User (id, email, ...) — дескрипторы данных SQLAlchemy: их __get__ обращается к InstanceState, которого у объекта в обход __init__/ORM-машинерии нет. Падало на КАЖДОМ запросе, где юзер брался из кэша (5 мин TTL) — то есть почти всегда, кроме первого запроса после логина/протухания кэша. Фикс: CachedUser — обычный dataclass с теми же полями, без дескрипторов, падать нечему. Заодно нашёл тем же грепом идентичный баг в resend_verification (трогал current_user.verification_token — не входит в кэшируемый набор полей, к тому же current_user из кэша не привязан к сессии — db.commit()/refresh() на нём тоже не сработали бы) — почистил по образцу update_profile/change_password: подгружает свежего юзера из БД. Подтверждено вручную на проде: сгенерировал JWT для реального юзера, GET /api/tasks/{id} возвращал 500 до фикса. Co-Authored-By: Claude Opus 4.8 --- services/api/app/api/auth.py | 15 +++++++++++---- services/api/app/core/security.py | 30 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/services/api/app/api/auth.py b/services/api/app/api/auth.py index fc464db..35953c1 100644 --- a/services/api/app/api/auth.py +++ b/services/api/app/api/auth.py @@ -213,15 +213,22 @@ async def resend_verification( detail="Email уже подтверждён", ) - if not current_user.verification_token: - current_user.verification_token = secrets.token_urlsafe(32) + # current_user из dependency может быть из Redis-кэша (без verification_token + # и не привязан к сессии — commit/refresh на нём не сработают) — грузим "живого" + result = await db.execute(select(User).where(User.id == current_user.id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + + if not user.verification_token: + user.verification_token = secrets.token_urlsafe(32) await db.commit() - await db.refresh(current_user) + await db.refresh(user) try: celery_app.send_task( "notify.send_verification", - args=[current_user.email, current_user.name, current_user.verification_token], + args=[user.email, user.name, user.verification_token], queue="queue.notify", ) except Exception as e: diff --git a/services/api/app/core/security.py b/services/api/app/core/security.py index e063c42..6da220d 100644 --- a/services/api/app/core/security.py +++ b/services/api/app/core/security.py @@ -2,6 +2,7 @@ import json import logging +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Any @@ -65,6 +66,28 @@ def _decode_token(token: str) -> int: ) from None +@dataclass +class CachedUser: + """Пользователь из Redis-кэша — обычный dataclass, НЕ SQLAlchemy-модель. + + Раньше здесь был User.__new__(User) + __dict__.update(data) — казалось + эквивалентом, но замапленные атрибуты User (id, email, ...) — дескрипторы + данных: их __get__ обращается к self.impl, который берётся из InstanceState, + а у объекта, созданного в обход __init__/ORM-машинерии, состояния нет. + Итог — AttributeError на любое обращение к полю из кэша (нашли на + GET /tasks/{id}: current_user.id падал 500-й, как только юзер брался не + из БД, а из кэша). Обычный dataclass с теми же именами полей — просто + plain-атрибуты, без дескрипторов, падать нечему. + """ + + id: int + email: str + name: str + plan: str + is_verified: bool + is_admin: bool + + async def _load_user(user_id: int, db: AsyncSession): """Загрузить пользователя из Redis-кэша или из БД.""" from app.models.user import User # избегаем circular import на уровне модуля @@ -75,12 +98,7 @@ async def _load_user(user_id: int, db: AsyncSession): # Пробуем кэш cached = await r.get(cache_key) if cached: - data = json.loads(cached) - # Возвращаем "живой" объект из БД только по id, но без лишнего SELECT - # Создаём User без ORM-связей (достаточно для проверок в роутерах) - u = User.__new__(User) - u.__dict__.update(data) - return u + return CachedUser(**json.loads(cached)) # Кэш пустой — идём в БД result = await db.execute(select(User).where(User.id == user_id))