30 KiB
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. Без аутентификации.
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 |
Ответ 200 — AuthResponse (см. ниже).
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 при ошибке проверки подписи. Ответ 200 — AuthResponse.
POST /api/v1/auth/telegram/miniapp
Mini App initData. Auth: нет (проверяется подпись initData).
Тело — TelegramMiniAppAuthRequest:
| Поле | Тип | Описание |
|---|---|---|
init_data |
str | Raw initData query string из Telegram.WebApp |
401 при ошибке. Ответ 200 — AuthResponse.
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> в заголовке.
Ответ 200 — TokenIntrospectResponse:
| Поле | Тип |
|---|---|
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 занят. 201 — TokenPairResponse.
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:
- Генерирует
secrets.token_urlsafe(32), хранит только SHA-256 от него вusers.password_reset_token_hashс TTL =PASSWORD_RESET_TTL_MINUTES. - Публикует
NotificationMessage(kind=password_reset)в очередьnotify.q(RabbitMQ). 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.
Passkeys (WebAuthn) + magic-link
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 |
Без лимита, если не задан |
Ответ 201 — raw-ключ возвращается только один раз:
{
"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..."
}
400 — rate_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.