696 lines
27 KiB
Markdown
696 lines
27 KiB
Markdown
# API Routers
|
||
|
||
FastAPI-роутеры сервиса `contract_check.api`. Все пути — префикс `/api/v1`
|
||
(кроме инфра-эндпоинтов `/healthz`, `/readyz`, `/metrics`).
|
||
|
||
Роутеры регистрируются в приложении через `include_router`; теги соответствуют
|
||
атрибуту `tags=` каждого `APIRouter`.
|
||
|
||
## Содержание
|
||
|
||
- [Аутентификация](#аутентификация)
|
||
- [health](#health)
|
||
- [metrics](#metrics)
|
||
- [auth](#auth)
|
||
- [me](#me)
|
||
- [billing](#billing)
|
||
- [webhooks](#webhooks)
|
||
- [pay page](#pay-page)
|
||
- [documents](#documents)
|
||
- [reports](#reports)
|
||
- [b2b](#b2b)
|
||
- [Коды ошибок](#коды-ошибок)
|
||
|
||
---
|
||
|
||
## Аутентификация
|
||
|
||
Три независимые схемы, реализованные в [`api/deps.py`](../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.py). Тег: `health`. Без аутентификации.
|
||
|
||
### `GET /healthz`
|
||
|
||
Liveness-проба. Возвращает `200` всегда.
|
||
|
||
```json
|
||
{ "status": "ok" }
|
||
```
|
||
|
||
### `GET /readyz`
|
||
|
||
Readiness-проба. Выполняет `SELECT 1` в БД.
|
||
|
||
```json
|
||
{ "status": "ok" }
|
||
```
|
||
|
||
Если БД недоступна:
|
||
|
||
```json
|
||
{ "status": "not_ready", "reason": "db: <error>" }
|
||
```
|
||
|
||
---
|
||
|
||
## metrics
|
||
|
||
Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации.
|
||
|
||
### `GET /metrics`
|
||
|
||
Exposition-эндпоинт Prometheus. Тело — текст в формате
|
||
`text/plain; version=0.0.4` (`prometheus_client.generate_latest`).
|
||
|
||
---
|
||
|
||
## auth
|
||
|
||
Файлы: [`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`
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```json
|
||
{ "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`
|
||
|
||
```json
|
||
{
|
||
"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`.
|
||
|
||
### 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" }`, ответ:
|
||
|
||
```json
|
||
{
|
||
"access_token": "<jwt>",
|
||
"token_type": "bearer",
|
||
"expires_in": 86400,
|
||
"user_id": "uuid",
|
||
"email": "user@example.com"
|
||
}
|
||
```
|
||
|
||
`400` — невалидный/просроченный токен (протухшие сбрасываются сразу).
|
||
|
||
---
|
||
|
||
## me
|
||
|
||
Файл: [`me.py`](me.py). Тег: `me`. **Auth:** `CurrentUserDep` (user JWT).
|
||
|
||
### `GET /api/v1/me`
|
||
|
||
Профиль пользователя и баланс кредитов.
|
||
|
||
```json
|
||
{ "telegram_id": 123456789, "credits_left": 7 }
|
||
```
|
||
|
||
### `GET /api/v1/me/profile` / `PATCH /api/v1/me/profile`
|
||
|
||
Пассивные пользовательские настройки (миграция 0011; строка создаётся лениво
|
||
на первом чтении, отсутствующие поля = дефолты). НИКОГДА не попадает в LLM-промпты.
|
||
|
||
PATCH — частичное обновление любого поля:
|
||
|
||
```json
|
||
{
|
||
"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`
|
||
|
||
Дашборд одной строкой:
|
||
|
||
```json
|
||
{
|
||
"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.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`
|
||
|
||
Тело — один из двух вариантов:
|
||
|
||
```json
|
||
{ "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`. Ответ:
|
||
|
||
```json
|
||
{ "invoice_id": "uuid", "refunded_kopecks": 99500, "kind": "proportional",
|
||
"reason": "used 15 of 20", "billing_hold": false }
|
||
```
|
||
|
||
`404` чужой/несуществующий счёт; `409` — уже возвращен / не возвращаем (zero).
|
||
|
||
---
|
||
|
||
## webhooks
|
||
|
||
Файл: [`webhooks.py`](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/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.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`](../services.py)):
|
||
|
||
```json
|
||
{
|
||
"document_id": "uuid",
|
||
"correlation_id": "uuid",
|
||
"credits_left": 6
|
||
}
|
||
```
|
||
|
||
Ошибки:
|
||
- `400` — нет имени / неподдерживаемый формат / пустой файл;
|
||
- `402` — нет кредитов (`no credits available`);
|
||
- `500` — сбой MinIO/БД/RabbitMQ (кредит возвращается).
|
||
|
||
---
|
||
|
||
## reports
|
||
|
||
Файл: [`reports.py`](reports.py). Тег: `reports`. **Auth:** `CurrentUserDep`.
|
||
|
||
### `GET /api/v1/reports/{document_id}`
|
||
|
||
Опрос статуса/результата анализа. Документ обязан принадлежать
|
||
аутентифицированному пользователю.
|
||
|
||
Параметр пути: `document_id` — UUID.
|
||
|
||
Пока анализ не готов (`status != "done"`):
|
||
|
||
```json
|
||
{
|
||
"document_id": "uuid",
|
||
"status": "queued | extracting | analyzing | failed",
|
||
"stage": "<current stage>"
|
||
}
|
||
```
|
||
|
||
Готовый отчёт (`status == "done"`):
|
||
|
||
```json
|
||
{
|
||
"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` — отчёт не найден (или чужой).
|
||
|
||
---
|
||
|
||
## b2b
|
||
|
||
Файл: [`b2b.py`](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`.
|
||
|
||
```json
|
||
{
|
||
"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-ключ возвращается только один раз**:
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```json
|
||
{ "id": "uuid", "revoked": true }
|
||
```
|
||
|
||
`404` — ключ не найден, чужой или уже отозван.
|
||
|
||
### `GET /api/v1/b2b/keys/{key_id}/usage`
|
||
|
||
Помесячное использование конкретного ключа. **Auth:** `CurrentUserDep`.
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
{ "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`.
|