Добавлен GET /tasks/{public_id}/text, читает извлечённый текст из
staged_works.text_key (тот же текст, по которому считались
position_start/position_end) — тот же MinIO-объект, что уже читает
админка для превью. Фронтенд: сворачиваемый блок с текстом работы,
где заимствования/цитаты/тематически близкие фрагменты подсвечены
разными цветами с тултипом на источник.
Живая проверка: юзер сделал один поиск (давно завершился, status='done'),
второй поиск сразу упёрся в "Превышен лимит одновременных задач" — на
free-тарифе лимит 1.
Причина: acquire_concurrent_slot() инкрементирует Redis-счётчик
concurrent:{user_id} при создании КАЖДОЙ задачи (search.py, documents.py),
а release_concurrent_slot() — которая должна его декрементировать по
завершении — НЕ ВЫЗЫВАЛАСЬ НИГДЕ В КОДЕ (grep подтвердил: только
определение, ни одного вызова). Счётчик только рос, лимит превышался
навсегда для практически любого юзера после первой же задачи — до
часового TTL-автосброса.
Фикс — не "доставить забытый release()" (это лечит симптом, но оставляет
класс бага: счётчик и реальность могут разойтись любым другим путём), а
убрать сам отдельный счётчик. check_concurrent_limit() считает активные
задачи (status IN queued/processing) напрямую в Postgres — Task.status уже
корректно обновляется во всех воркерах (проверено многократно в этой
сессии), рассинхронизация невозможна по конструкции. Redis-лимиты
(дневные/месячные, Lua-скрипт) не тронуты — там свой, рабочий, механизм.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Живая проверка после первого реального 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 <noreply@anthropic.com>
Живая проверка Google-входа упала с "401 Unauthorized" на /token, но лог
показывал только код статуса — тело ответа (там у Google/Яндекс error/
error_description с точной причиной: invalid_client и т.п.) терялось.
_raise_for_status_verbose() оборачивает raise_for_status(), добавляя resp.text
в сообщение исключения — на все 4 вызова (token+userinfo × google+yandex).
Чисто диагностическое изменение, поведение не меняет. Тесты/линт/mypy — ок.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Реализовано без authlib, на голом httpx (AsyncClient — синхронный httpx
блокировал бы event loop API на время внешнего запроса), по образцу двух
провайдеров:
- Миграция 004: hashed_password → nullable (OAuth-юзеры без пароля),
oauth_provider/oauth_id + уникальный индекс на пару.
- app/core/security.py: verify_password защищён от hashed=None (иначе TypeError
при попытке OAuth-юзера войти по паролю — нашёл при ревью, не баг-репорт).
- app/core/oauth.py: get_authorize_url()/exchange_code() — единый интерфейс для
google/yandex. Redirect URI: <APP_URL>/api/auth/<provider>/callback.
- app/api/auth.py: GET /auth/{provider}/login (редирект на согласие, state в
httponly-cookie от CSRF) и /callback (обмен code, find-or-create юзера по
oauth_id → по email для привязки существующего аккаунта → новый без пароля,
is_verified=email_verified от провайдера). Токен фронту — через URL-фрагмент
#token=..., не query (не уходит в логи/Referer).
- Фронтенд: OAuthButtons (Login/Register), страница /oauth/callback (читает
фрагмент → GET /auth/me → setAuth → редирект в кабинет).
- 6 юнит-тестов чистой логики сборки ссылок (app/core/oauth.py) — первый тест-
контур для api/ в этой сессии (pytest.ini/conftest/requirements-test по
образцу остальных сервисов), добавлен в общий run_tests.sh + mypy-гейт.
GOOGLE_CLIENT_ID/SECRET уже в .env (юзер создал OAuth-клиент), YANDEX_* пусты —
эндпоинты в этом случае отвечают 503, не падают. .env.example документирует обе
пары. Тестов всего: 118 (было 112).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Раньше видимости в прод не было — только email-монитор (костыль). Добавлено
без нагрузки по умолчанию (профиль не поднимается, пока не попросят):
- API: /metrics через prometheus-fastapi-instrumentator (кол-во/латентность
запросов по хендлерам);
- Prometheus (infra/prometheus/prometheus.yml) скрейпит API и Flower — из Flower
приходят метрики Celery (задачи, время, воркеры) вообще без доп. кода;
- Grafana с автопровижном источника Prometheus (infra/grafana/provisioning),
пароль через GRAFANA_ADMIN_PASSWORD;
- docker-compose.prod.yml: сервисы prometheus/grafana под profiles:[observability]
+ volumes; .env.example и README пополнены.
Запуск: docker compose --profile observability up -d prometheus grafana.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Второй CI-гейт после тестов: ruff как статический анализатор всего Python-кода
(services + scripts). Раньше ни линта, ни проверки типов в CI не было вовсе.
Конфиг ruff.toml: правила E/F/W/I/UP/B/SIM/C4, line-length 100. Осознанно
выключены E501 (длину держит форматтер; длинные RU-комментарии — норма),
B008 (Depends()/Query() в дефолтах — идиома FastAPI, не баг) и UP042
((str, Enum)→StrEnum меняет __str__/сериализацию — не трогаем).
Починено под ноль находок:
- B904 (11): raise ... from exc / from None — читаемые цепочки исключений в
Celery-ретраях и HTTPException, ошибки обработки не маскируют исходные.
- SIM105 (5): try/except/pass → contextlib.suppress (faiss remove_ids, lsh.remove,
сброс кэша, ws-disconnect, парс года).
- C416/SIM108/B905/F841/UP035/UP017/F401/I001: dict(rows), тернарник, zip strict,
мёртвая переменная, устаревшие импорты, timezone.utc→UTC, чистка/сортировка.
Обвязка: scripts/run_lint.sh (ruff в изолированном python:3.11-slim), шаг «Линт»
в job test перед юнит-тестами (падаем раньше). make lint / make lint-fix.
Все 41 юнит-тест по-прежнему зелёные, изменённые файлы компилируются.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Три бага, найденные при аудите прода:
- search и documents диспатчили Celery-задачу ДО commit() — быстрый воркер
читал задачу раньше, чем транзакция закоммичена, и падал «задача не найдена»
(поиск не работал вовсе; плагиат спасала латентность скачивания из MinIO).
Теперь коммитим до диспатча.
- notify.send_task_done обращался к user.email/name и task.type ПОСЛЕ закрытия
сессии → DetachedInstanceError, письма о завершении уходили в ретраи.
Значения достаются внутри сессии.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Бэкенд:
- PATCH /auth/me — изменение имени и/или email. Смена email проверяет
уникальность, сбрасывает is_verified и отправляет новое письмо
подтверждения. Пользователь перезагружается из БД (объект из Redis-кэша
не привязан к сессии), кэш инвалидируется после изменения.
- POST /auth/change-password — смена пароля с подтверждением текущего;
отклоняет неверный текущий и совпадение нового со старым.
Фронтенд:
- Страница /settings: карточка персональных данных (имя, email со статусом
подтверждения и предупреждением о повторной верификации) и карточка смены
пароля с проверкой совпадения.
- Ссылка «Настройки» в карточке профиля кабинета, методы API-клиента.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_current_user кэширует пользователя в Redis на 5 минут. verify_email
не сбрасывал этот кэш после user.is_verified = True — если пользователь
до подтверждения хотя бы раз дёрнул защищённый эндпоинт (закешировался
как неверифицированный), он получал "Необходимо подтвердить email адрес"
ещё до 5 минут после реального подтверждения по ссылке из письма.
- Резенд письма верификации (/auth/resend-verification), модалка на
фронте с поллингом статуса, страница /verify-email/:token
- SMTP переведён на собственный Postfix (mail.jze9mail.ru, STARTTLS,
SMTP_TLS_VERIFY) вместо Yandex-заглушки в дефолтах и .env.example
- OLLAMA_URL и модель в worker-gpu синхронизированы с новым GPU-хостом
(llama3:8b -> qwen2.5:7b, которой раньше не было на сервере)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Redis:
- Singleton ConnectionPool (redis.asyncio), 50 connections — не создаём
новое TCP-соединение на каждый HTTP-запрос
Rate limiter:
- Полностью переписан на async/await
- Lua-скрипт _LUA_CHECK_AND_INCR — атомарная проверка+инкремент без race condition
- Lua-скрипт _LUA_ACQUIRE_CONCURRENT — атомарный захват слота задачи
- Старый паттерн INCR→check→DECR удалён (race condition при конкурентных запросах)
Security:
- get_current_user кэширует пользователя в Redis на 5 минут (TTL)
Раньше: SELECT users на каждый HTTP-запрос
Теперь: Redis GET (кэш) → SELECT users (только при промахе)
- hashed_password НЕ кладётся в кэш
- invalidate_user_cache() для сброса при смене тарифа/пароля
- get_ws_user() для WebSocket через ?token=JWT (браузеры не могут
передавать Authorization header при WS-handshake)
WebSocket:
- Добавлена аутентификация (Depends(get_ws_user))
- Проверка ownership задачи ДО accept() соединения
- Чужой task_id → закрытие с кодом 4004
URL obfuscation:
- Task.public_id = secrets.token_urlsafe(16) = 22 случайных base64url символа
- Клиент работает только с public_id, внутренний UUID не раскрывается
- Все роутеры переключены на public_id в WHERE условиях
- TaskResponse больше не возвращает input_data (там minio_key и т.д.)
- Миграция 002_add_task_public_id.py
MinIO:
- Singleton клиент (не создаём новый на каждый upload)
- ensure_bucket() вызывается один раз при старте (lifespan), не на каждый запрос
- Путь uploads/{doc_uuid}{ext} — user_id убран из пути
CORS:
- Убраны wildcard allow_methods/allow_headers (несовместимы с credentials=True)
- Явный список: methods=[GET,POST,DELETE,OPTIONS], headers=[Authorization,Content-Type,Accept]
- Swagger/OpenAPI доступны только в ENVIRONMENT=development
Documents:
- Content-Length проверяется ДО чтения тела (ранняя отбивка больших файлов)
- Повторная проверка реального размера после чтения (защита от поддельного заголовка)
- Используем get_current_verified_user вместо get_current_user (требуем подтверждённый email)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>