DealDocumentScreening/src/contract_check/api/routes/README.md
febux b279d6c61a Switch observability to passive collection: remove OTLP push, Vector replaces otel-collector
- core/telemetry.py is now a no-op (no opentelemetry imports); entrypoints
  no longer call setup/shutdown telemetry
- Remove all opentelemetry-* deps from pyproject groups; regenerate uv.lock
- Drop otel_exporter_otlp_endpoint/otel_service_name settings; sentry and
  auth use "contract-check" instead
- Stop publishing worker metrics ports; bind API metrics to 127.0.0.1 by
  default via API_METRICS_BIND_HOST
- Replace otel-collector with Vector in observer profile (docker_logs +
  prometheus_scrape -> OpenObserve); add deploy/observability/vector-config.yaml
- Update .env.example, docs (ARCHITECTURE, DEPLOY), README, Makefile
- Rewrite tests/unit/test_telemetry.py for the no-op implementation
2026-09-06 19:17:08 +03:00

30 KiB
Raw Blame History

API Routers

FastAPI-роутеры сервиса contract_check.api. Все пути — префикс /api/v1 (кроме инфра-эндпоинтов /healthz, /readyz, /metrics).

Роутеры регистрируются в приложении через include_router; теги соответствуют атрибуту tags= каждого APIRouter.

Содержание


Аутентификация

Три независимые схемы, реализованные в api/deps.py:

Зависимость Заголовок Кому выдаётся Где используется
AuthDep Authorization: Bearer <svc-token> Сервисный токен (адаптеры bot/web/cli) /auth/telegram/* — вход от имени бота
CurrentUserDep Authorization: Bearer <user-jwt> JWT пользователя (Telegram-логин) /me, /documents, /reports, /b2b/keys*
ApiKeyAuthDep X-API-Key: <raw-key> B2B-ключ внешнего клиента /analyze, /b2b/reports/*, /b2b/usage
  • Сервисный токен и B2B-ключ хешируются (hash_token, hash_api_key), в БД хранится только хеш; raw-значение возвращается один раз при создании.
  • ApiKeyAuthDep дополнительно применяет token-bucket rate limit и месячный квоты (429 при превышении).
  • CurrentUserDep проверяет, что пользователь ещё есть в таблице users (stateless JWT + defence-in-depth).

health

Файл: health.py. Тег: health. Без аутентификации.

GET /healthz

Liveness-проба. Возвращает 200 всегда.

{ "status": "ok" }

GET /readyz

Readiness-проба. Выполняет SELECT 1 в БД.

{ "status": "ok" }

Если БД недоступна:

{ "status": "not_ready", "reason": "db: <error>" }

metrics

Файл: metrics.py. Тег: metrics. Без аутентификации, если METRICS_BEARER_TOKEN не задан; иначе требуется Authorization: Bearer <token>.

GET /metrics

Exposition-эндпоинт Prometheus. Тело — текст в формате text/plain; version=0.0.4 (prometheus_client.generate_latest).


auth

Файлы: auth/telegram.py, password.py, passkeys.py, magic_links.py, support.py. Тег: auth.

Три источника идентичности сходятся к одному JWT:

POST /api/v1/auth/telegram/bot

Бот (aiogram) обменивает проверенный telegram_id на JWT пользователя. Auth: AuthDep (сервисный токен бота).

Тело — TelegramBotAuthRequest:

Поле Тип Условие Описание
telegram_id int > 0 Verified Telegram user id из aiogram

Ответ 200AuthResponse (см. ниже).

POST /api/v1/auth/telegram/web

Callback Telegram Login Widget. Auth: нет (проверяется подпись Telegram).

Тело — TelegramWebAuthRequest:

Поле Тип Условие
id int > 0
first_name str?
last_name str?
username str?
photo_url str?
auth_date int
hash str

401 при ошибке проверки подписи. Ответ 200AuthResponse.

POST /api/v1/auth/telegram/miniapp

Mini App initData. Auth: нет (проверяется подпись initData).

Тело — TelegramMiniAppAuthRequest:

Поле Тип Описание
init_data str Raw initData query string из Telegram.WebApp

401 при ошибке. Ответ 200AuthResponse.

AuthResponse

{
  "access_token": "<jwt>",
  "token_type": "bearer",
  "expires_in": 3600,
  "user_id": "uuid",
  "telegram_id": 123456789
}

expires_in = jwt_access_ttl_minutes * 60 из настроек.

GET /api/v1/auth/me

Интроспекция Bearer-JWT. Auth: Authorization: Bearer <jwt> в заголовке.

Ответ 200TokenIntrospectResponse:

Поле Тип
sub str (UUID)
telegram_id int
type str
exp int (unix)

401 — отсутствует/невалиден.

GET /api/v1/auth/me/permissions

Заглушка под будущее RBAC.

{ "permissions": ["upload", "read_reports", "read_me"] }

WebUI auth (email + password)

Шесть эндпоинтов для SPA-фронта. Возвращают JWT-пару: короткий access (default 24h) + длинный refresh (default 30d, ttl в Redis). Refresh-token поддерживает отзыв через logout; access невалидируется только истечением TTL.

Метод Путь Auth Описание
POST /api/v1/auth/register Регистрация → 201 + JWT pair
POST /api/v1/auth/login Вход → 200 + JWT pair
POST /api/v1/auth/logout Отзыв refresh-токена (в теле)
GET /api/v1/auth/me Bearer Текущий пользователь (расширенный ответ)
POST /api/v1/auth/forgot-password Запрос сброса (202, всегда одинаковый ответ)
POST /api/v1/auth/reset-password Сброс пароля по токену из письма

POST /api/v1/auth/register

Тело — RegisterRequest:

Поле Тип Условие
email EmailStr валидный email
password str 8..128 символов

409 если email занят. 201TokenPairResponse.

POST /api/v1/auth/login

Тело — LoginRequest (те же поля). 401 при неверных кредах.

TokenPairResponse

{
  "access_token": "<jwt>",
  "refresh_token": "<jwt>",
  "token_type": "bearer",
  "expires_in": 86400,
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "telegram_id": null,
    "credits_left": 0,
    "is_active": true,
    "created_at": "2026-08-12T..."
  }
}

POST /api/v1/auth/logout

Тело — LogoutRequest:

Поле Тип
refresh_token str

Идемпотентен. Access-токен остаётся валидным до истечения своего TTL (см. JWT_ACCESS_TTL_MINUTES); refresh уничтожается в Redis сразу.

POST /api/v1/auth/forgot-password

Тело — ForgotPasswordRequest (email). Всегда отвечает 202 OkResponse с телом {"ok": true, "detail": "if the email exists, a reset link was sent"} — чтобы не раскрывать, какие адреса зарегистрированы. API:

  1. Генерирует secrets.token_urlsafe(32), хранит только SHA-256 от него в users.password_reset_token_hash с TTL = PASSWORD_RESET_TTL_MINUTES.
  2. Публикует NotificationMessage(kind=password_reset) в очередь notify.q (RabbitMQ).
  3. worker-notify достаёт сообщение и шлёт письмо через SMTP (SMTP_HOST/SMTP_PORT/SMTP_USE_TLS/SMTP_FROM). При пустом SMTP_HOST (dev) тело письма пишется в лог.

POST /api/v1/auth/reset-password

Тело — ResetPasswordRequest:

Поле Тип Условие
token str из письма
password str 8..128 символов

400 при невалидном/просроченном токене. Успех: пароль перезаписывается (argon2id), password_reset_token_hash сбрасывается, все активные refresh-токены этого пользователя отзываются в Redis (принудительный re-login на всех устройствах).

GET /api/v1/auth/me

Расширение прежнего introspect-эндпоинта: теперь читает Bearer access JWT, ищет пользователя в БД и возвращает полный профиль. Старые поля (sub, telegram_id, type, exp) сохранены для совместимости; добавлены email, credits_left, is_active, created_at.

Passwordless-методы веб-аутентификации. Оба выдают один access JWT (без refresh): passkeyAuthenticationResponseSchema / magicLinkVerifyResponseSchema на фронте.

Метод Путь Auth Описание
POST /api/v1/auth/passkeys/register/start Bearer PublicKeyCredentialCreationOptions + challenge в Redis
POST /api/v1/auth/passkeys/register/finish Bearer Проверка attestation → 201, ключ сохранён
POST /api/v1/auth/passkeys/authenticate/start PublicKeyCredentialRequestOptions (discoverable)
POST /api/v1/auth/passkeys/authenticate/finish Проверка assertion → access JWT
GET /api/v1/auth/passkeys Bearer Список пасскеев пользователя
DEL /api/v1/auth/passkeys/{id} Bearer Удалить пасскей (только свой)
POST /api/v1/auth/magic-link/request Одноразовая ссылка входа на email
POST /api/v1/auth/magic-link/verify Обмен токена из письма на access JWT

Детали:

  • Ceremonies — по два шага (start/finish). Challenge живёт в Redis PASSKEY_CHALLENGE_TTL_SECONDS (default 120 c), одноразовый. Регистрация: challenge ключуется user_id; аутентификация — самим challenge (клиент возвращает его в finish-запросе).
  • authenticatorSelection: residentKey=required, userVerification=required → discoverable-креды, allowCredentials пустой; пользователь определяется на finish по credential.id из БД.
  • RP-параметры: PASSKEY_RP_ID / PASSKEY_RP_NAME / PASSKEY_RP_ORIGINS (comma-separated). Флаги PASSKEY_ENABLED, MAGIC_LINK_ENABLED включают/выключают группы эндпоинтов (404 при выключении).
  • Хранение: таблица passkey_credentials (credential_id/public_key — base64url, sign_count — защита от клонирования, ротация на каждом входе).
  • Magic-link повторяет паттерн forgot-password: SHA-256 хеш токена + TTL (MAGIC_LINK_TTL_MINUTES, default 15) в users, письмо через notify.q (kind=magic_link), ссылка {WEB_APP_BASE_URL}/magic-link?token=.... Ответ request всегда одинаковый (не раскрывает зарегистрированные email), verify одноразовый — хеш сбрасывается сразу.

POST /api/v1/auth/passkeys/register/start

Тело (опционально): { "device_name": "YubiKey 5" }. Ответ — PasskeyRegistrationOptions (camelCase, base64url-строки; соответствует zod-схеме фронта): challenge, rp {id, name, origin}, user {id, name, displayName}, pubKeyCredParams[], timeout, attestation="none", excludeCredentials[], authenticatorSelection {residentKey, userVerification}.

POST /api/v1/auth/passkeys/register/finish

Тело: { "credential": {...result of navigator.credentials.create()...}, "device_name"? } (base64url). Ответ 201{ "verified": true, "credentialId": "<base64url>" }. 400 — нет/просрочен challenge, битый attestation; 409 — credential уже зарегистрирован.

POST /api/v1/auth/passkeys/authenticate/start|finish

start — без тела, ответ PasskeyAuthenticationOptions: challenge, timeout, rpId, allowCredentials=[], userVerification. finish — тело { "challenge": "<из start>", "credential": {...result of navigator.credentials.get()...} }; ответ — access JWT (см. ниже). 400 — неизвестный/просроченный challenge или битая подпись; 401 — неизвестный credential; 403 — аккаунт отключён.

GET /api/v1/auth/passkeys / DELETE /api/v1/auth/passkeys/{id}

Список — массив { id, credentialID, credentialPublicKey, counter, userId, deviceName, createdAt }, новые первыми. Удаление чужого/несуществующего — 404, успех — 204 No Content (пустое тело).

POST /api/v1/auth/magic-link/request / verify

request — тело { "email" }, всегда 200 { success, message, expires_in } (секунды). verify — тело { "token" }, ответ:

{
  "access_token": "<jwt>",
  "token_type": "bearer",
  "expires_in": 86400,
  "user_id": "uuid",
  "email": "user@example.com"
}

400 — невалидный/просроченный токен (протухшие сбрасываются сразу).


me

Файл: me.py. Тег: me. Auth: CurrentUserDep (user JWT).

GET /api/v1/me

Профиль пользователя и баланс кредитов.

{ "telegram_id": 123456789, "credits_left": 7 }

GET /api/v1/me/profile / PATCH /api/v1/me/profile

Пассивные пользовательские настройки (миграция 0011; строка создаётся лениво на первом чтении, отсутствующие поля = дефолты). НИКОГДА не попадает в LLM-промпты.

PATCH — частичное обновление любого поля:

{
  "language": "ru",
  "timezone": "Europe/Minsk",
  "notif_prefs": { "report_ready": true, "security": true, "marketing": false },
  "dashboard_prefs": { "severity_filter": "all", "per_page": 10, "density": "comfortable" }
}

422 — неизвестный язык (ru|be|en), не-IANA таймзона, неизвестные ключи prefs. Ответ обоих методов — полное UserProfile.

GET /api/v1/me/overview

Дашборд одной строкой:

{
  "docs": { "total": 12, "done": 10, "failed": 1, "in_progress": 1 },
  "credits_left": 7,
  "plan": { "code": "pro", "name": "Pro", "quota": 20, "quota_used": 3, "period_end": "..." },
  "billing_hold": false
}

plan = null без активной подписки; billing_hold=true — загрузки отвечают 402.


billing

Файл: billing.py. Тег: billing. Auth: CurrentUserDep, кроме каталога. Все суммы — копейки (int). YOOKASSA_ENABLED=false ⇒ мутации отвечают 503, GET /billing/plans читается.

Метод Путь Описание
GET /api/v1/billing/plans каталог активных тарифов + price_per_doc_kopecks
POST /api/v1/billing/checkout топ-ап или подписка → 201 {invoice_id, confirmation_url}
GET /api/v1/billing/invoices свои счета (limit/offset)
GET /api/v1/billing/invoices/{id} свой счёт (404 чужой)
GET /api/v1/billing/subscription текущая подписка (active/past_due)
POST /api/v1/billing/subscriptions/autorenew {enabled: bool} на активную подписку
POST /api/v1/billing/refund авто-возврат по правилу D10

POST /api/v1/billing/checkout

Тело — один из двух вариантов:

{ "kind": "topup", "credits": 20 }          // пакеты 1/5/20 × PRICE_PER_DOC_KOPECKS
{ "kind": "subscription", "plan_code": "pro" }

Создаёт pending-счёт + платёж ЮKassa (Idempotence-Key = invoice uuid). 409 — уже есть активная подписка; 422 — неизвестный пакет/тариф; 502 — провайдер недоступен (счёт отменяется).

POST /api/v1/billing/refund

Тело { "invoice_id": "uuid" }. Считает возврат: полный при возрасте ≤ REFUND_WINDOW_DAYS (14) И использовании ≤ 20% купленного, иначе пропорциональный max(0, amount used × price_per_doc). Исполняет провайдер-возврат, клавбэк оставшихся активов, при уходе баланса в минус — billing_hold. Ответ:

{ "invoice_id": "uuid", "refunded_kopecks": 99500, "kind": "proportional",
  "reason": "used 15 of 20", "billing_hold": false }

404 чужой/несуществующий счёт; 409 — уже возвращен / не возвращаем (zero).


webhooks

Файл: webhooks.py. Тег: webhooks.

POST /api/v1/webhooks/yookassa

Auth: Basic (shopId:secretKey, сверка constant-time). Уведомления ЮKassa payment.* / refund.*. Push-payload НЕ доверяем: платёж всегда перезапрашивается через REST, статус применяется идемпотентным стейт-машина (core/billing/fulfillment.py — общий с worker-billing). Всегда 200 (ignored/unknown_invoice/disabled в теле), чтобы ЮKassa не ретраила.


pay page

Файл: ../billing/pay_page.py. Тег: billing.

GET /pay/{invoice_id}?token=…

Server-rendered HTML-статус счёта после редиректа с ЮKassa. token — короткоживущий HS256 JWT (BILLING_RETURN_JWT_SECRET, иначе JWT_SECRET; TTL BILLING_RETURN_TOKEN_TTL_MINUTES). 403 — нет/просрочен/чужой токен. Показывает статус (pending/succeeded/cancelled) и сумму; для pending — кнопку на confirmation_url.


documents

Файл: documents.py. Тег: documents. Auth: CurrentUserDep.

POST /api/v1/documents

Загрузить документ на анализ. Резервирует кредит, кладёт файл в MinIO, создаёт строки documents + jobs, публикует DocumentUploaded в RabbitMQ.

Запрос — multipart/form-data:

Поле Тип Описание
file UploadFile Расширения: .pdf, .docx, .rtf, .txt, .csv, .png, .jpg, .jpeg, .tif, .tiff (см. core/extraction/formats.py)

Ответ 202 (результат upload_and_enqueue):

{
  "document_id": "uuid",
  "correlation_id": "uuid",
  "credits_left": 6
}

Ошибки:

  • 400 — нет имени / неподдерживаемый формат / пустой файл;
  • 402 — нет кредитов (no credits available);
  • 500 — сбой MinIO/БД/RabbitMQ (кредит возвращается).

reports

Файл: reports.py. Тег: reports. Auth: CurrentUserDep.

GET /api/v1/reports/{document_id}

Опрос статуса/результата анализа. Документ обязан принадлежать аутентифицированному пользователю.

Параметр пути: document_id — UUID.

Пока анализ не готов (status != "done"):

{
  "document_id": "uuid",
  "status": "queued | extracting | analyzing | failed",
  "stage": "<current stage>"
}

Готовый отчёт (status == "done"):

{
  "document_id": "uuid",
  "status": "done",
  "filename": "contract.pdf",
  "markdown": "<markdown-отчёт>",
  "findings": { ... },
  "model_used": "<llm model>",
  "prompt_tokens": 1234,
  "eval_tokens": 5678,
  "latency_ms": 91011
}

404 — отчёт не найден (или чужой).

GET /api/v1/reports/{document_id}/events

SSE-стрим статусов анализа (text/event-stream). Аутентификация и проверка владельца — до открытия стрима (401/404 как обычные HTTP ошибки). Альтернатива опросу GET /reports/{document_id} для web-SPA: сервер сам поллит БД (SSE_POLL_INTERVAL_SECONDS) и шлёт событие при смене статуса.

Auth: Authorization: Bearer <jwt> или query-параметр ?access_token=<jwt> — нативный EventSource не умеет ставить заголовки, поэтому для web-SPA:

new EventSource(`/api/v1/reports/${docId}/events?access_token=${jwt}`)

Нюанс: query-строка может попасть в access-логи прокси — передавать только короткоживущие access-токены (не refresh).

Каждый кадр данных (безымянное событие → onmessage в EventSource) несёт payload контракта web-клиента analysisResultSchema (camelCase; см. api/schemas/analysis_result.py):

{
  "id": "uuid",
  "fileName": "contract.pdf",
  "fileType": "application/pdf",
  "fileSize": 12345,
  "status": "pending | processing | completed | failed",
  "issues": [
    {
      "id": "penalties-0",
      "severity": "critical | warning | info",
      "category": "penalties",
      "title": "Неустойки / штрафы",
      "description": "<риск>\n\nРекомендация: <рекомендация>",
      "fragment": "<цитата> (п. <section_ref>)",
      "lineNumber": null
    }
  ],
  "summary": "Критичных: 1, предупреждений: 1, замечаний: 0",
  "riskScore": 6,
  "createdAt": "2026-09-02T17:35:34.018348+00:00",
  "completedAt": "2026-09-02T17:36:10.104222+00:00"
}

Маппинг:

Поле Источник
status queued → pending; extracting/prescreening/ocr/analyzing → processing; done/manual_review → completed; failed → failed
issues reports.content_json.findings (severity: high→critical, medium→warning, low→info)
riskScore вес находок: critical=4, warning=2, info=1, cap 10 (не хранится, вычисляется)
summary строка с количеством находок (не хранится, вычисляется)
completedAt reports.created_at; null, пока нет отчёта

События стрима:

  • безымянные кадры — снимок при подключении, затем по одному на смену статуса; кадр с status: completed | failed терминальный, стрим закрывается (клиенту нужно вызвать es.close(), иначе EventSource переподключится);
  • timeout — стрим жил дольше SSE_MAX_STREAM_SECONDS; клиент переподключается (EventSource делает это сам) или падает на опрос;
  • error — документ исчез посреди стрима.

Между кадрами без изменений шлются комментарии : keep-alive, чтобы прокси не рвали соединение. Заголовок X-Accel-Buffering: no отключает буферизацию nginx.

404 — отчёт не найден (или чужой).


b2b

Файл: b2b.py. Тег: b2b.

Анализ-эндпоинты используют X-API-Key (ApiKeyAuthDep). Эндпоинты управления ключами используют user JWT (CurrentUserDep), т.к. вызываются внутренними адаптерами от имени пользователя (telegram_id).

GET /api/v1/b2b/profile / PUT /api/v1/b2b/profile

Настройки профиля владельца ключа — те же поля, что у PATCH /api/v1/me/profile (язык, таймзона, prefs). Auth: X-API-Key. PUT заменяет переданные поля целиком (не partial). Биллинг-эндпоинты партнёрам не открываются (D4).

POST /api/v1/analyze

Загрузить документ под B2B-ключом. Auth: X-API-Key.

Запрос — multipart/form-data (file), как в POST /api/v1/documents.

Резервирует кредит у владельца ключа, пишет api_key_requests, инкрементит api_keys.monthly_used. Логирование usage — best-effort (не валит загрузку при сбое записи).

Ответ 202 — как у POST /api/v1/documents.

GET /api/v1/b2b/reports/{document_id}

Опрос отчёта, scoped к владельцу ключа. Auth: X-API-Key.

Параметр пути: document_id — UUID.

Формат ответа — идентичен GET /api/v1/reports/{document_id}. 404 — чужой/несуществующий.

GET /api/v1/b2b/usage

Текущее использование ключа. Auth: X-API-Key.

{
  "api_key_id": "uuid",
  "rate_limit_rps": 3,
  "monthly_quota": 1000,
  "monthly_used": 17,
  "requests_this_month": 17,
  "resets_at": "2026-09-01T00:00:00+00:00"
}

POST /api/v1/b2b/keys

Создать новый B2B-ключ. Auth: CurrentUserDep.

Тело — CreateApiKeyRequest:

Поле Тип Default Описание
name str Метка ключа
rate_limit_rps int? 3 Должно быть > 0
monthly_quota int? null Без лимита, если не задан

Ответ 201raw-ключ возвращается только один раз:

{
  "api_key": "sk_b2b_...",
  "id": "uuid",
  "name": "prod-webhooks",
  "rate_limit_rps": 3,
  "monthly_quota": null,
  "monthly_used": 0,
  "revoked": false,
  "created_at": "2026-08-12T..."
}

400rate_limit_rps не положителен; 500 — сбой БД.

GET /api/v1/b2b/keys

Список ключей пользователя. Auth: CurrentUserDep.

Ответ — массив объектов формы ApiKeyResponse:

Поле Тип
id UUID
name str
rate_limit_rps int
monthly_quota int?
monthly_used int
revoked bool
created_at ISO-str

Сортировка — created_at DESC. Raw-ключи не возвращаются.

POST /api/v1/b2b/keys/{key_id}/revoke

Отозвать ключ. Auth: CurrentUserDep. Параметр пути: key_id — UUID.

{ "id": "uuid", "revoked": true }

404 — ключ не найден, чужой или уже отозван.

GET /api/v1/b2b/keys/{key_id}/usage

Помесячное использование конкретного ключа. Auth: CurrentUserDep.

{
  "key_id": "uuid",
  "name": "prod-webhooks",
  "rate_limit_rps": 3,
  "monthly_quota": 1000,
  "monthly_used": 17,
  "resets_at": "2026-09-01T...",
  "monthly_requests": [
    { "month": "2026-08-01T...", "requests": 17 }
  ]
}

404 — ключ не найден или чужой.


Коды ошибок

Общий формат ошибки FastAPI:

{ "detail": "<сообщение>" }
Код Когда
400 Неверный запрос (bad format / пустой файл / bad arg)
401 Нет/невалиден токен или API-ключ
402 no credits available (при резерве кредита)
404 Ресурс не найден или чужой
429 Rate limit / месячный квот B2B-ключа исчерпан
500 Сбой инфраструктуры (MinIO/PG/RabbitMQ)

При 429 от rate limiter добавляется заголовок Retry-After.