Files
han-app/architectory/arch-02-api-contracts.md
T
2026-07-09 11:03:44 +03:00

396 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# arch-02. API-контракты и связность взаимодействий
> Термины — в [`arch-00-glossary.md`](arch-00-glossary.md). Общая схема — в [`arch-01-system-architecture.md`](arch-01-system-architecture.md).
## Назначение
Этот документ — канонический реестр API-контрактов между frontend, backend-сервисами и внешними системами. Его цель — контролировать связность: если сервис описан как участник сценария, здесь должен быть указан контракт, направление вызова, владелец и потребитель. Когда появятся профильные спецификации модулей, они могут дублировать здесь зафиксированные контракты для удобства разработки.
## Правила связности
- Любой новый endpoint, webhook, worker-contract или внешний вызов сначала добавляется в этот файл; при появлении профильного документа модуля-владельца — дублируется там для детализации реализации.
- Публичные пользовательские API находятся под `/api/v1`; internal API не публикуются наружу через `nginx`.
- Internal HTTP API между backend-сервисами используют единую маску: **`/internal/{service_mnemonic}/v1/{resource}`**, где `{service_mnemonic}` — короткое имя владельца endpoint (см. [`arch-00-glossary.md`](arch-00-glossary.md), «Мнемоники internal API»). Health-check остаётся на `/health/*`.
- OpenAPI 3.1 обязателен для HTTP-контрактов `api-backend`, `message-safety`, `bitrix-sync` и `bitrix-local-app` — файлы `{service}/openapi.yaml` в репозитории сервиса (см. раздел «OpenAPI»); для Bitrix24 REST фиксируются используемые методы и payload-мэппинг.
- Все service-to-service вызовы передают `X-Request-ID` и по возможности W3C `traceparent`.
- Frontend передаёт **`X-Ux-Session-Id`** во всех запросах к `api-backend`, когда UX-сессия активна (рекомендуется для аналитики и логов; **не** является auth).
- Все internal API защищаются service token и закрытой Docker/VPC-сетью.
## Service tokens (internal API)
Все internal endpoint (`/internal/*`) доступны **только** из Docker/VPC-сети и требуют service token. Endpoint не публикуются через `nginx` (исключение — ops внутри VPC).
| Переменная | Кто проверяет | Кто передаёт | Endpoint | Заголовок |
|---|---|---|---|---|
| `MESSAGE_SAFETY_SERVICE_TOKEN` | `message-safety` | `api-backend` | `POST/GET /internal/safety/v1/*` | `X-Service-Token` |
| `BITRIX_INTERNAL_API_TOKEN` | `bitrix-local-app` | `api-backend` | `POST/GET /internal/openlines/v1/*` | `Authorization: Bearer` |
| `BITRIX_LOCAL_APP_INTERNAL_TOKEN` | — | `api-backend` (исходящий) | то же | `Authorization: Bearer` |
| `BITRIX_API_INBOX_TOKEN` | `api-backend` | `bitrix-local-app` | `POST /internal/openlines/v1/inbox` | `Authorization: Bearer` |
| `BITRIX_API_FORWARD_TOKEN` | — | `bitrix-local-app` (исходящий) | то же | `Authorization: Bearer` |
| `BITRIX_SYNC_SERVICE_TOKEN` | `bitrix-sync` | ops / мониторинг | `GET /internal/sync/v1/*` | `Authorization: Bearer` или `X-Service-Token` |
Пары значений (должны совпадать):
- `BITRIX_LOCAL_APP_INTERNAL_TOKEN` (api-backend) = `BITRIX_INTERNAL_API_TOKEN` (bitrix-local-app)
- `BITRIX_API_FORWARD_TOKEN` (bitrix-local-app) = `BITRIX_API_INBOX_TOKEN` (api-backend)
Генерация: `openssl rand -hex 32`. Секреты не коммитить.
**Не путать с webhook-токенами** (публичные callback от Bitrix24, не internal service API):
| Переменная | Назначение |
|---|---|
| `BITRIX_APPLICATION_TOKEN` | проверка событий Bitrix24 → `bitrix-local-app` `/bitrix/handler` |
| `BITRIX_SYNC_WEBHOOK_TOKEN` | проверка webhook Bitrix24 → `bitrix-sync` `/bitrix/sync/webhook/contact` |
## Frontend ↔ api-backend
| Контракт | Владелец | Потребитель | Назначение | Auth |
|---|---|---|---|---|
| `GET /api/v1/public/app-config` | `api-backend` | Expo frontend | Публичные настройки: OTP, оператор, лимиты, файлы, **UX idle timeout** | public + CORS/rate limit |
| `GET /api/v1/public/content` | `api-backend` | Expo frontend | Тексты по мнемоникам и популярные вопросы | public + CORS/rate limit |
| `POST /api/v1/consents` | `api-backend` | Expo frontend | Сохранение согласий перед OTP; тело включает `guest_session_id`, версии документов, device metadata | public + `guest_session_id` + rate limit |
| `POST /api/v1/analytics/session-start` | `api-backend` | Expo frontend | Событие `session_start`, новая `UxSession` | public + rate limit |
| `POST /api/v1/auth/bootstrap` | `api-backend` | Expo frontend | После OTP: `find-or-create` пользователя, связь согласий, привязка `user_id` к `UxSession` | JWT |
| `POST /api/v1/dialogs` | `api-backend` | Expo frontend | Создание диалога перед первым сообщением (в т.ч. после популярного вопроса) | JWT + idempotency |
| `GET /api/v1/me` | `api-backend` | Expo frontend | Профиль текущего клиента | JWT |
| `GET /api/v1/me/documents` | `api-backend` | Expo frontend | Список документов (MVP: может быть пустым; доставка — post-MVP) | JWT |
| `GET /api/v1/documents/{document_id}` | `api-backend` | Expo frontend | Метаданные документа (post-MVP) | JWT |
| `GET /api/v1/documents/{document_id}/download-url` | `api-backend` | Expo frontend | Presigned URL; обязателен audit | JWT |
| `GET /api/v1/dialogs` | `api-backend` | Expo frontend | История диалогов | JWT |
| `GET /api/v1/dialogs/{dialog_id}` | `api-backend` | Expo frontend | Карточка диалога | JWT |
| `GET /api/v1/dialogs/{dialog_id}/messages` | `api-backend` | Expo frontend | История сообщений, polling fallback | JWT |
| `POST /api/v1/dialogs/{dialog_id}/messages` | `api-backend` | Expo frontend | Отправка сообщения клиента (MVP: `content_kind` `text` или `file`, см. ниже) | JWT + idempotency + safety |
| `POST /api/v1/dialogs/{dialog_id}/attachments/init` | `api-backend` | Expo frontend | Инициализация загрузки в S3-quarantine | JWT |
| `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete` | `api-backend` | Expo frontend | Завершение загрузки и фиксация checksum/metadata | JWT |
| `WS /api/v1/realtime` | `api-backend` | Expo frontend | Realtime-события чата, статусы доставки, unread | JWT |
Единый формат ошибки:
```json
{
"error": {
"code": "profile_not_found",
"message": "Profile was not found",
"request_id": "01J00000000000000000000000",
"details": {}
}
}
```
### `POST /api/v1/consents` (тело запроса)
```json
{
"guest_session_id": "550e8400-e29b-41d4-a716-446655440000",
"consents": {
"personal_data": { "accepted": true, "version": "2026-06-10" },
"user_agreement": { "accepted": true, "version": "2026-06-10" },
"marketing": { "accepted": false, "version": "2026-06-10" }
},
"device": {
"platform": "ios",
"app_version": "1.0.0",
"device_id": "..."
}
}
```
После OTP api-backend связывает запись с `UserIdentity` по `guest_session_id` (в рамках `POST /api/v1/auth/bootstrap`). TTL guest-записи — 24 ч.
### `POST /api/v1/analytics/session-start` (событие `session_start`)
Вызывается frontend **только** при начале **новой** UX-сессии (см. arch-01, «Аналитическая UX-сессия»). **Не** привязан к OTP и JWT.
**Заголовки:** `X-Request-ID` (опционально).
**Тело:**
```json
{
"start_reason": "first_launch",
"guest_session_id": "550e8400-e29b-41d4-a716-446655440000",
"device": {
"platform": "web",
"app_version": "1.0.0",
"device_id": "..."
}
}
```
- `start_reason` — обязательно: `first_launch` | `cold_start` | `idle_timeout`;
- `guest_session_id` — опционально (если уже создан в гостевом режиме).
**Ответ `201`:**
```json
{
"ux_session_id": "660e8400-e29b-41d4-a716-446655440001",
"started_at": "2026-07-08T12:00:00Z"
}
```
Frontend сохраняет `ux_session_id` **в памяти** и передаёт **`X-Ux-Session-Id`** в последующих запросах.
**Повторный вызов в рамках той же UX-сессии не требуется** (возврат из фона в пределах idle timeout).
### `POST /api/v1/auth/bootstrap` (после OTP)
Вызывается **один раз** после успешного OTP и получения JWT. **Не** создаёт UX-сессию.
**Заголовки:** `Authorization: Bearer <access_token>`, `X-Ux-Session-Id` (рекомендуется).
**Тело:**
```json
{
"guest_session_id": "550e8400-e29b-41d4-a716-446655440000",
"ux_session_id": "660e8400-e29b-41d4-a716-446655440001"
}
```
**Ответ `200`:** `{ "user_id": "uuid", "profile_ready": true }`.
**Ошибки:** `401` (JWT), `403` (согласия не приняты / guest session истёк).
### Создание диалога
- `POST /api/v1/dialogs` — idempotency key в заголовке; ответ `{ "dialog_id": "uuid", "status": "open" }`.
- Обязателен перед первым `POST .../messages` (включая популярный вопрос после auth).
- `dialog_id` = `external_chat_id` (см. [`arch-00-glossary.md`](arch-00-glossary.md)).
### Формат исходящего сообщения клиента (MVP)
Имена `content_kind`, полей — [`arch-00-glossary.md`](arch-00-glossary.md). Правила:
| `content_kind` | Тело `POST .../messages` | `Message.text` | `MessageAttachment` |
|---|---|---|---|
| `text` | непустой `text`; без вложения | текст | 0 записей |
| `file` | `attachment_id` + `checksum`; `text` пустой | пустая строка | ровно 1 запись |
- непустой `text` **и** вложение → **`400`** `mixed_content_not_allowed` (до `message-safety`);
- пустое сообщение → **`403`** `empty_message`;
- более одного вложения → **`400`** `too_many_attachments`;
- файловое сообщение в Bitrix24: `message.files` (signed URL), `message.text` пустой.
Post-MVP: допускается «текст + файлы» отдельной версией API.
## Realtime (`WS /api/v1/realtime`)
Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` или subprotocol (реализация — в модуле `api-backend`).
**Подключение:**
1. Клиент открывает WS с валидным access token.
2. Сервер отправляет `{ "type": "connected", "server_time": "ISO8601" }`.
3. Клиент отправляет подписку:
```json
{ "type": "subscribe", "dialog_ids": ["uuid"] }
```
4. Сервер отвечает `{ "type": "subscribed", "dialog_ids": ["uuid"] }`.
**События сервер → клиент:**
| `type` | Назначение | Ключевые поля |
|---|---|---|
| `message.new` | Новое сообщение в диалоге | `dialog_id`, `message` (DTO как в REST) |
| `message.status` | Смена статуса доставки/safety | `dialog_id`, `message_id`, `status` |
| `dialog.status` | Смена статуса диалога | `dialog_id`, `status` |
**Reconnect:**
- exponential backoff: 1s → 2s → 4s → … max 30s;
- после reconnect — повтор `subscribe` с актуальным списком `dialog_ids`;
- при недоступности WS > 30s — fallback на polling `GET .../messages?after=<cursor>`.
**Ping:** сервер может слать `{ "type": "ping" }` каждые 30s; клиент отвечает `{ "type": "pong" }`.
## OpenAPI
| Сервис | Файл | Публикуется наружу |
|---|---|---|
| `api-backend` | `api-backend/openapi.yaml` | да (`/api/v1/*`, health) |
| `message-safety` | `message-safety/openapi.yaml` | нет (internal) |
| `bitrix-local-app` | `bitrix-local-app/openapi.yaml` | частично (`/bitrix/*`, health) |
| `bitrix-sync` | `bitrix-sync/openapi.yaml` | нет (internal + webhook) |
Правила:
- breaking change публичного API → новый path-prefix (`/api/v2`) + запись в arch-02;
- internal API версионируется тем же правилом (`/internal/{mnemonic}/v2/...`);
- OpenAPI генерируется или поддерживается вручную — на усмотрение модуля, но файл обязателен в DoD (arch-05).
## Frontend ↔ Keycloak
| Контракт | Владелец | Потребитель | Назначение |
|---|---|---|---|
| OIDC Authorization Code Flow with PKCE | Keycloak | Expo frontend | OTP-only login, token issue, refresh |
| OIDC Refresh Token Grant | Keycloak | Expo frontend | Обновление access token без OTP при действующем refresh token |
| OIDC logout | Keycloak | Expo frontend | Завершение сессии Keycloak, очистка tokens |
| JWKS / discovery | Keycloak | Expo frontend, `api-backend` | Проверка issuer, audience и ключей |
Frontend не обращается напрямую к Keycloak DB и не хранит парольные credentials. Парольная авторизация в MVP отключена.
**OTP (Keycloak):** единственный канал первичной авторизации — телефон. OTP-flow нужен, когда refresh token отсутствует или истёк. При действующем refresh token frontend использует **Refresh Token Grant** и не показывает OTP. После ввода кода **Keycloak проверяет OTP**: при `KEYCLOAK_OTP_MOCK_ENABLED=true` — сверка с `KEYCLOAK_OTP_MOCK_CODE` (`.env`); при `false` — сверка с OTP от SMS-провайдера (post-MVP, [`!Backlog.md`](../../HAN_chat/!Backlog.md)). `api-backend` OTP не проверяет, только JWT.
### Жизненный цикл access token (frontend)
Детали — arch-01, «Обновление access token (frontend)». Кратко:
| Механизм | Когда | Действие |
|---|---|---|
| **Scheduler** | за ~60 с до `exp` access token | Refresh Token Grant → новые tokens, перепланировать таймер |
| **401 interceptor** | `api-backend` / WS отклонил access token | single-flight refresh → **один** retry запроса |
| **Открытие приложения** | cold start / resume | silent refresh, если refresh token ещё действителен |
Правила:
- refresh выполняет **только frontend** (Keycloak token endpoint); `api-backend` на `401` **не** обновляет токен;
- параллельные запросы при refresh — очередь + single-flight;
- провал refresh → очистка tokens, гостевой режим, OTP при следующем защищённом действии;
- успешный refresh **не** создаёт UX-сессию (`session_start`).
**401 от `api-backend`:** единый формат ошибки (см. выше); типичный `code`: `unauthorized` / `token_expired` — frontend трактует как сигнал к refresh+retry (если refresh token ещё валиден).
## api-backend ↔ message-safety
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
| `POST /internal/safety/v1/messages/check` | `message-safety` | `api-backend` | Синхронная проверка текста, ссылок и файлов по cache/rules | internal network + `X-Service-Token` |
| `GET /internal/safety/v1/messages/tasks/{task_id}` | `message-safety` | `api-backend` | Опрос async-проверки файлов | internal network + `X-Service-Token` |
| Read S3-quarantine | Selectel S3 | `message-safety` | Чтение файла worker-ом при cache miss | read-only key |
HTTP-семантика: `200 allow`, `403 deny`, `203 pending`. `api-backend` не выбирает sync/async режим, а только интерпретирует ответ.
Маппинг в App DB (`Message.safety_status` — см. [`arch-00-glossary.md`](arch-00-glossary.md)):
| HTTP / `message-safety` | `Message.safety_status` | Финальный? |
|---|---|---|
| `200` / `allow` | `allowed` | да |
| `403` / `deny` | `blocked` | да |
| `203` / `pending` | `pending` | нет |
## api-backend ↔ bitrix-local-app (Open Lines)
Мнемоника сервиса: **`openlines`**. Endpoint Open Lines на стороне `bitrix-local-app` и приёмник событий на стороне `api-backend` используют один префикс `/internal/openlines/v1/`.
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
| `POST /internal/openlines/v1/messages` | `bitrix-local-app` | `api-backend` | Отправка сообщения клиента в Bitrix24 Open Lines | Bearer `BITRIX_INTERNAL_API_TOKEN` |
| `GET /internal/openlines/v1/dialogs/{external_chat_id}` | `bitrix-local-app` | `api-backend` | Получение маппинга `dialog_id``bitrix_chat_id` | Bearer token |
| `GET /internal/openlines/v1/status` | `bitrix-local-app` | ops / `api-backend` | Статус OAuth и `imconnector.status` | Bearer token |
| `POST /internal/openlines/v1/setup/retry` | `bitrix-local-app` | ops | Повтор register/activate/event.bind | Bearer token |
| `POST /internal/openlines/v1/inbox` | `api-backend` | `bitrix-local-app` | Forward нормализованных событий оператора | Bearer `BITRIX_API_INBOX_TOKEN` |
`external_chat_id` всегда равен `dialog_id` приложения. `bitrix-sync` не участвует в hot path чата.
## api-backend ↔ bitrix-sync
**без синхронного HTTP** в пользовательских сценариях. Связь — PostgreSQL-триггеры в App DB → очередь `han_app.sync_queue` + прямой доступ `bitrix-sync` к `han_app` для write-back. `api-backend` **не создаёт** задачи синхронизации вручную.
### Очередь, триггеры и write-back (основной контракт MVP)
| Контракт | Тип | Владелец | Потребитель | Назначение |
|---|---|---|---|---|
| `han_app.sync_queue` | PostgreSQL | триггеры `han_app` (миграции App DB) | `bitrix-sync` | Асинхронная очередь App DB → Bitrix24: триггер ставит задачу при изменении отслеживаемых полей |
| `han.sync_suppress` (GUC) | PostgreSQL session | `bitrix-sync` | триггеры `han_app` | Подавление эхо-задач при записи данных от Bitrix24 в App DB |
| `ClientProfile.bitrix_contact_id` | PostgreSQL | `bitrix-sync` | App DB | Маппинг профиля на CRM Contact после map/create |
| `han_app.entity_external_mapping` | PostgreSQL | `bitrix-sync` | App DB | Универсальный маппинг App entity ↔ Bitrix entity (MVP: Contact) |
| Обновление `sync_queue.status` | PostgreSQL | `bitrix-sync` | App DB | `processed` / `failed` / `dead_letter`, retry metadata |
Типы задач MVP (`sync_queue.task_type`):
- `contact.map_or_create` — матчинг/создание Contact, запись `bitrix_contact_id`, флаг регистрации в Bitrix24;
- `contact.update` — push изменений профиля в Bitrix24.
`bitrix-sync` **не создаёт** `UserIdentity` / `ClientProfile` в auth-flow; вход worker — задачи из `sync_queue`, созданные триггерами.
### Internal HTTP `bitrix-sync` (ops, не hot path)
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
| `GET /internal/sync/v1/status` | `bitrix-sync` | ops / мониторинг | Глубина очереди, dead letter, последний успешный run | internal network + `BITRIX_SYNC_SERVICE_TOKEN` |
Повтор dead letter и ручной replay в MVP — через БД/ops-процедуры; отдельный HTTP replay-endpoint — post-MVP.
## bitrix-local-app ↔ Bitrix24
| Контракт | Направление | Назначение |
|---|---|---|
| `GET/POST /bitrix/install` | Bitrix24 → `bitrix-local-app` | Установка local app, OAuth lifecycle |
| `GET/POST /bitrix/handler` | Bitrix24 → `bitrix-local-app` | `ONIMCONNECTOR*`, `ONAPPINSTALL`, `ONAPPUNINSTALL` |
| `imconnector.register` | `bitrix-local-app` → Bitrix24 | Регистрация `han_mobile_app` |
| `imconnector.activate` | `bitrix-local-app` → Bitrix24 | Привязка к линии 8 |
| `event.bind` | `bitrix-local-app` → Bitrix24 | Подписка на события коннектора |
| `imconnector.send.messages` | `bitrix-local-app` → Bitrix24 | Доставка сообщения клиента оператору |
| `imconnector.send.status.delivery` | `bitrix-local-app` → Bitrix24 | Подтверждение доставки входящего события |
## bitrix-sync ↔ Bitrix24 CRM
| Контракт | Направление | Назначение |
|---|---|---|
| `crm.contact.get/list/add/update` | `bitrix-sync` → Bitrix24 | Поиск, создание и обновление Contact |
| `POST /bitrix/sync/webhook/contact` | Bitrix24 (робот) → `bitrix-sync` | Исходящий webhook при изменении полей Contact, зарегистрированного в приложении |
| PostgreSQL schema `bitrix_sync` | `bitrix-sync` ↔ PostgreSQL | Worker state, field mapping, retry/dead letter audit |
| PostgreSQL schema `han_app` | `bitrix-sync` ↔ PostgreSQL | Очередь `sync_queue`, маппинг ID, обновление профиля (Bitrix → App) |
Очередь `han_app.sync_queue` и write-back — в разделе «api-backend ↔ bitrix-sync» выше.
`bitrix-sync` использует `BITRIX_SYNC_APP_DATABASE_URL` для `han_app` + `bitrix_sync`, только `BITRIX_SYNC_CRM_*` для Bitrix24 CRM REST и не читает OAuth-токены `bitrix-local-app`.
## api-backend ↔ внешние хранилища
| Контракт | Внешний сервис | Назначение |
|---|---|---|
| PostgreSQL schema `han_app` | Managed PostgreSQL | App DB: пользователи, профили, диалоги, сообщения, настройки, sync_queue, audit |
| Selectel S3 `han-chat-quarantine` | Selectel S3 | Временное хранение вложений клиента до verdict |
| Selectel S3 `han-chat-attachments` | Selectel S3 | Проверенные файлы чата |
| Selectel S3 `han-chat-documents` | Selectel S3 | Документы компании для клиента |
| Redis | `redis` | rate limits, OTP counters, coordination/realtime state |
## Observability-контракты
| Контракт | Владелец | Потребители | Назначение |
|---|---|---|---|
| OTLP gRPC/HTTP | `otel-collector` (`observability`) | backend-сервисы | Приём traces/logs/metrics |
| JSON stdout logs | каждый сервис | platform logs / оператор | Техническая диагностика; **`ux_session_id`** из `X-Ux-Session-Id`, если передан |
| Audit / analytics events в App DB | `api-backend` | аналитика, расследования | `session_start` и чувствительные действия без PII |
### Analytics: `session_start`
При `POST /api/v1/analytics/session-start` api-backend создаёт запись:
| Поле | Пример |
|---|---|
| `event_type` | `session_start` |
| `ux_session_id` | UUID новой UX-сессии |
| `start_reason` | `first_launch` / `cold_start` / `idle_timeout` |
| `guest_session_id` | UUID или null |
| `user_id` | null (до auth bootstrap) |
| `request_id` | из `X-Request-ID` |
| `ip`, `user_agent` | из proxy headers |
Raw OTP и полный номер телефона в audit **не** пишутся.
### Audit: выдача download URL
При `GET /api/v1/documents/{document_id}/download-url` и аналогичных endpoint вложений чата api-backend создаёт запись:
| Поле | Пример |
|---|---|
| `event_type` | `document.download_url_issued`, `attachment.download_url_issued` |
| `user_id` | UUID пользователя |
| `resource_type` | `document` / `attachment` |
| `resource_id` | UUID ресурса |
| `ux_session_id` | из `X-Ux-Session-Id` |
| `request_id` | из `X-Request-ID` |
| `ip`, `user_agent` | из proxy headers |
Presigned URL и содержимое файла в audit **не** пишутся. Структура таблицы — модуль `database`.
## Health-контракты
Все backend-сервисы имеют `GET /health/live` и `GET /health/ready`. Наружу публикуются только health endpoints, которые нужны `nginx`/Bitrix24; internal services проверяются через Docker/VPC-сеть.