715 lines
59 KiB
Markdown
715 lines
59 KiB
Markdown
# 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`** во всех JWT-запросах к `api-backend`, когда UX-сессия активна (рекомендуется для аналитики и логов; **не** является auth). `session-start` и `consents` требуют JWT.
|
||
- Все 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/v2/*` | `X-Service-Token`, private TLS |
|
||
| `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` |
|
||
| `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | `api-backend` | Keycloak SPI | `GET /internal/settings/v1/otp` | `Authorization: Bearer` |
|
||
| `SMS_SERVICE_TOKEN` | `sms-service` | Keycloak SPI | `POST/GET /internal/sms/v1/*` | `Authorization: Bearer` |
|
||
| `NOTIFICATIONS_TOKEN_<SOURCE>` | `api-backend` | соответствующий продюсер | `POST /internal/notifications/v1/*` | `Authorization: Bearer` |
|
||
|
||
Пары значений (должны совпадать):
|
||
|
||
- `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)
|
||
- `KEYCLOAK_SMS_SERVICE_TOKEN` (Keycloak) = `SMS_SERVICE_TOKEN` (`sms-service`)
|
||
|
||
Генерация: `openssl rand -hex 32`. Секреты не коммитить.
|
||
|
||
Для Notifications токен отдельный на каждый `source`: секрет существует только в deployment secret/env, а `notification_sources` хранит только hash. Токен разрешает identity продюсера и сравнивается constant-time; `source` в body обязан совпасть. Seed-источник `producer_test` и `NOTIFICATIONS_TOKEN_PRODUCER_TEST` предназначены для smoke Create/Cancel, не для бизнес-интеграции.
|
||
|
||
**Не путать с webhook-токенами** (публичные callback от Bitrix24, не internal service API):
|
||
|
||
| Переменная | Назначение |
|
||
|---|---|
|
||
| `BITRIX_APPLICATION_TOKEN` | проверка событий Bitrix24 → `bitrix-local-app` `/bitrix/handler` |
|
||
| `BITRIX_SYNC_CONTACT_RECEIVER_TOKEN` | query `token` штатного HTTP-webhook робота Contact → receiver `bitrix-sync`; дополнительно source IP CIDR allow-list |
|
||
| `BITRIX_SYNC_ALERT_RECEIVER_TOKEN` | query `token` штатного HTTP-webhook робота smart-process alert → receiver `bitrix-sync`; дополнительно source IP CIDR allow-list |
|
||
|
||
## 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 | Повторное сохранение согласий (новые версии документов); привязка к `user_id` | JWT + rate limit |
|
||
| `POST /api/v1/analytics/session-start` | `api-backend` | Expo frontend | Событие `session_start`, новая `UxSession` (только для авторизованного пользователя) | JWT + rate limit |
|
||
| `POST /api/v1/auth/bootstrap` | `api-backend` | Expo frontend | После OTP: `find-or-create` пользователя + сохранение согласий из тела запроса | 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 | Инициализация загрузки; ответ: `attachment_id` + **presigned PUT** в S3-quarantine | JWT |
|
||
| `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete` | `api-backend` | Expo frontend | Подтверждение загрузки, проверка объекта в quarantine, фиксация checksum/metadata | JWT |
|
||
| `GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url` | `api-backend` | Expo frontend | Presigned URL вложения чата; обязателен audit | JWT |
|
||
| `WS /api/v1/realtime` | `api-backend` | Expo frontend | Realtime-события чата, статусы доставки, unread | JWT |
|
||
| `GET /api/v1/public/notifications` | `api-backend` | Expo frontend | Активные гостевые кампании G | public + CORS/rate limit |
|
||
| `GET /api/v1/public/notification-types` | `api-backend` | Expo frontend | Публичный каталог видов с ETag, без серверных правил переходов | public + cache/rate limit |
|
||
| `GET /api/v1/notifications?place=home\|center` | `api-backend` | Expo frontend | Персональная выборка P с серверными лимитами 7/15 и сортировкой | JWT |
|
||
| `GET /api/v1/notifications/counter` | `api-backend` | Expo frontend | Счётчик непрочитанных в окне Центра | JWT |
|
||
| `GET /api/v1/notifications/{id}` | `api-backend` | Expo frontend | Деталка активного собственного уведомления | JWT |
|
||
| `POST /api/v1/notifications/{id}/read|hide|cta` | `api-backend` | Expo frontend | Идемпотентные действия и CTA по каталогу | JWT + rate limit |
|
||
| `POST /api/v1/notifications/{id}/buttons/{button_code}` | `api-backend` | Expo frontend | Единое действие кнопки деталки | JWT + rate limit |
|
||
| `GET /api/v1/notifications/{id}/documents/{document_id}/download-url` | `api-backend` | Expo frontend | Presigned GET + audit; первое скачивание любого связанного документа может скрыть уведомление | JWT |
|
||
| `POST/GET/DELETE /api/v1/uploads/*` | `api-backend` | Expo frontend | Универсальные upload drafts клиента | JWT + rate limit |
|
||
|
||
Единый формат ошибки:
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "profile_not_found",
|
||
"message": "Profile was not found",
|
||
"request_id": "01J00000000000000000000000",
|
||
"details": {}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Каталог публичных ошибок MVP
|
||
|
||
Все ошибки возвращаются в envelope выше. `details` не содержит PII, raw OTP, presigned URL и внутренние stack traces.
|
||
|
||
| HTTP | `error.code` | Когда | Retry |
|
||
|---|---|---|---|
|
||
| `400` | `validation_error` | Невалидное тело, query или header | нет |
|
||
| `400` | `phone_claim_missing` | В JWT нет канонического phone claim для `bootstrap` | нет |
|
||
| `400` | `mixed_content_not_allowed` | В сообщении одновременно текст и вложение | нет |
|
||
| `400` | `empty_message` | Нет текста и `attachment_id` | нет |
|
||
| `400` | `too_many_attachments` | Более одного вложения в MVP | нет |
|
||
| `400` | `attachment_not_completed` | `POST .../messages` с незавершённым upload | да, после `complete` |
|
||
| `400` | `attachment_checksum_mismatch` | Checksum клиента не совпал с объектом в S3 | нет |
|
||
| `401` | `unauthorized` | Нет access token или он невалиден | после auth |
|
||
| `401` | `token_expired` | Access token истёк | да, после refresh token grant |
|
||
| `403` | `consents_required` | Обязательные согласия не приняты | нет |
|
||
| `403` | `forbidden` | Доступ запрещён и ресурс не скрывается | нет |
|
||
| `404` | `not_found` | Ресурс не существует или принадлежит другому пользователю | нет |
|
||
| `409` | `idempotency_key_reused` | Тот же `Idempotency-Key` с другим fingerprint | нет |
|
||
| `409` | `notification_conflict` | `(source, external_id)` уже занят Create с другим fingerprint | нет |
|
||
| `409` | `notification_closed` | Действие по уже закрытому уведомлению | нет |
|
||
| `422` | `message_blocked` | Message Safety вернул final deny | нет |
|
||
| `422` | `button_not_allowed` | Кнопка не привязана к виду уведомления | нет |
|
||
| `429` | `rate_limit_exceeded` | Edge/API лимит превышен; должен быть `Retry-After`, если повтор допустим | да |
|
||
| `503` | `dependency_unavailable` | Circuit open или недоступны safety/Bitrix/S3 | да |
|
||
| `504` | `dependency_timeout` | Истёк timeout budget внешней зависимости | да |
|
||
|
||
`422 message_blocked` возвращает только стандартный public error envelope (`code`, generic `message`, `request_id`) без internal `rule_id`/`reason_code`. Пользовательский текст появляется отдельной локальной company-репликой из `text_resources` по мнемонике `safety.chat.blocked`.
|
||
|
||
Правило доступа к пользовательским ресурсам: для `dialog_id`, `message_id`, `attachment_id`, `document_id`, принадлежащих другому `user_id`, api-backend по умолчанию возвращает `404 not_found`, чтобы не раскрывать существование ресурса. `403 forbidden` используется только для операций, где сам факт ресурса уже известен пользователю или оператору.
|
||
|
||
### `POST /api/v1/auth/bootstrap` (после OTP)
|
||
|
||
Вызывается **один раз** после успешного OTP и получения JWT. Создаёт локального пользователя и **сразу** сохраняет согласия из тела (атомарно в одной транзакции). **Не** создаёт UX-сессию — для этого используется `POST /api/v1/analytics/session-start`.
|
||
|
||
**Заголовки:** `Authorization: Bearer <access_token>` — **единственный** источник идентичности пользователя.
|
||
|
||
**Как привязываются согласия (не через тело JSON):**
|
||
|
||
| Источник | Поле | Назначение |
|
||
|---|---|---|
|
||
| JWT claim `sub` | → `UserIdentity.keycloak_sub` | find-or-create пользователя; FK для `UserConsent.user_id` |
|
||
| JWT claim телефона | → `UserIdentity.phone_number` | номер, подтверждённый OTP в Keycloak (master auth-телефона) |
|
||
| Тело `consents` | версии / accepted | что именно принял пользователь |
|
||
| Тело `device` | metadata | устройство; не идентичность |
|
||
|
||
Телефон **не** передаётся в JSON body: клиент мог бы подставить чужой номер. api-backend читает телефон из **claims access token** Keycloak (канонический claim — по настройке realm; типично `phone_number` или `preferred_username` в E.164). Если claim отсутствует — **`400`** `phone_claim_missing`.
|
||
|
||
**Тело:**
|
||
|
||
```json
|
||
{
|
||
"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:** `POST /api/v1/auth/bootstrap` → `POST /api/v1/analytics/session-start` (если нужна новая UX-сессия) → чат.
|
||
|
||
**Ответ `200`:** `{ "user_id": "uuid", "profile_ready": true }`.
|
||
|
||
**Ошибки:** `401` (JWT), `403` `consents_required` (обязательные согласия не `accepted: true`), `400` (невалидные версии/тело / нет phone claim).
|
||
|
||
### `POST /api/v1/consents` (повторное принятие)
|
||
|
||
Не часть OTP-flow. Используется, когда уже есть `UserIdentity` и нужно зафиксировать **новые версии** документов (или повторное принятие).
|
||
|
||
**Заголовки:** `Authorization: Bearer <access_token>`, `X-Ux-Session-Id` (если есть).
|
||
|
||
```json
|
||
{
|
||
"consents": {
|
||
"personal_data": { "accepted": true, "version": "2026-06-10" },
|
||
"user_agreement": { "accepted": true, "version": "2026-06-10" },
|
||
"marketing": { "accepted": false, "version": "2026-06-10" }
|
||
}
|
||
}
|
||
```
|
||
|
||
api-backend сохраняет согласия с привязкой к **`user_id`** из JWT. Пользователь должен уже существовать (`bootstrap` выполнен), иначе **`404`** / **`409`** по контракту модуля. Обязательные согласия без `accepted: true` → **`403`** `consents_required`.
|
||
|
||
### `POST /api/v1/analytics/session-start` (событие `session_start`)
|
||
|
||
Вызывается frontend **только** при начале **новой** UX-сессии у **авторизованного** пользователя (есть валидный JWT и обычно уже выполнен `bootstrap`). **Не** вызывается в гостевом режиме.
|
||
|
||
**Заголовки:** `Authorization: Bearer <access_token>`, `X-Request-ID` (опционально).
|
||
|
||
**Тело:**
|
||
|
||
```json
|
||
{
|
||
"start_reason": "first_launch",
|
||
"device": {
|
||
"platform": "web",
|
||
"app_version": "1.0.0",
|
||
"device_id": "..."
|
||
}
|
||
}
|
||
```
|
||
|
||
- `start_reason` — обязательно: `first_launch` | `cold_start` | `idle_timeout`.
|
||
|
||
**Ответ `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).
|
||
|
||
**Ошибки:** `401` без/с невалидным JWT.
|
||
|
||
### Создание диалога
|
||
|
||
- `POST /api/v1/dialogs` — заголовок **`Idempotency-Key`** (обязателен); без заголовка → `400 validation_error`.
|
||
- Ответ `201` — создан новый активный диалог: `{ "dialog_id": "uuid", "status": "open" }`.
|
||
- Ответ `200` — у пользователя уже есть активный диалог или повторён тот же idempotent-запрос: `{ "dialog_id": "uuid", "status": "open" | "waiting_for_company" | "waiting_for_client" }`.
|
||
- **Один активный диалог** на пользователя: если уже есть диалог со статусом не `closed`, endpoint возвращает его (idempotent), новый не создаёт.
|
||
- Обязателен перед первым `POST .../messages` (включая популярный вопрос после auth), если у клиента ещё нет `dialog_id`.
|
||
- `dialog_id` = `external_chat_id` (см. [`arch-00-glossary.md`](arch-00-glossary.md)).
|
||
|
||
### Idempotency (G1)
|
||
|
||
| Правило | Значение |
|
||
|---|---|
|
||
| Заголовок | `Idempotency-Key` (UUID или opaque string ≤ 128 символов) |
|
||
| Endpoint | `POST /api/v1/dialogs`, `POST /api/v1/dialogs/{dialog_id}/messages` (и другие mutating POST по OpenAPI) |
|
||
| Хранение | Redis (DB `/0` api-backend): ключ → ответ / request fingerprint |
|
||
| TTL | **24 часа** |
|
||
| Повтор с тем же ключом и тем же телом | тот же HTTP-ответ, без повторного side-effect |
|
||
| Повтор с тем же ключом и **другим** телом | **`409`** `idempotency_key_reused` |
|
||
| Отсутствует на обязательном endpoint | **`400`** `validation_error` |
|
||
|
||
### Формат исходящего сообщения клиента (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`);
|
||
- пустое сообщение (нет `text` и нет `attachment_id`) → **`400`** `empty_message`;
|
||
- более одного вложения → **`400`** `too_many_attachments`;
|
||
- файловое сообщение в Bitrix24: `message.files` (signed URL), `message.text` пустой.
|
||
|
||
Post-MVP: допускается «текст + файлы» отдельной версией API.
|
||
|
||
### DTO чата и profile API MVP
|
||
|
||
`MessageResponse` — общий DTO для REST и WS:
|
||
|
||
```json
|
||
{
|
||
"message_id": "uuid",
|
||
"dialog_id": "uuid",
|
||
"sender_type": "client",
|
||
"content_kind": "text",
|
||
"text": "Здравствуйте",
|
||
"attachments": [],
|
||
"safety_status": "allowed",
|
||
"delivery_status": "delivered",
|
||
"created_at": "2026-07-09T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
`GET /api/v1/dialogs` возвращает `{ "items": [DialogSummary], "next_cursor": "opaque-or-null" }`, сортировка — по `updated_at desc`. `GET /api/v1/dialogs/{dialog_id}/messages?after=<cursor>&limit=50` возвращает `{ "items": [MessageResponse], "next_cursor": "opaque-or-null" }`, сортировка — по `created_at asc` для удобства append в чате. Cursor opaque; frontend не парсит его.
|
||
|
||
`GET /api/v1/me` возвращает блочный профиль:
|
||
|
||
```json
|
||
{
|
||
"user_id": "uuid",
|
||
"profile": {
|
||
"personal_data": {
|
||
"full_name": "string-or-null",
|
||
"citizenship": "string-or-null",
|
||
"russian_phone": "string-or-null",
|
||
"foreign_phone": "string-or-null",
|
||
"email": "string-or-null"
|
||
},
|
||
"documents": { "count": 0 }
|
||
}
|
||
}
|
||
```
|
||
|
||
`POST /api/v1/dialogs/{dialog_id}/messages`:
|
||
|
||
- заголовок `Idempotency-Key` обязателен;
|
||
- request body для текста: `{ "content_kind": "text", "text": "..." }`;
|
||
- request body для файла: `{ "content_kind": "file", "attachment_id": "uuid", "checksum": "sha256:..." }`;
|
||
- success `201`: `MessageResponse` с финальным `delivery_status=delivered`;
|
||
- safety deny: `422 message_blocked`, при этом запись может сохраняться с `safety_status=blocked`, `delivery_status=rejected`;
|
||
- dependency error: `503 dependency_unavailable` или `504 dependency_timeout`, `delivery_status=failed` если сообщение уже было создано.
|
||
|
||
### Загрузка вложения (MVP)
|
||
|
||
Байты файла идут **напрямую в S3-quarantine** по короткоживущему **presigned URL**. `api-backend` не проксирует тело файла: выдаёт URL, проверяет результат, управляет lifecycle (promote/delete).
|
||
|
||
1. `POST .../attachments/init` (JWT) → `{ attachment_id, upload_url, upload_headers, expires_at }` — **presigned PUT** в versioned S3-quarantine. Подпись обязательно включает `If-None-Match: *`, checksum header (`x-amz-checksum-sha256` либо подтверждённый эквивалент Selectel) и `Content-Type`; повторная запись того же key получает `412 Precondition Failed`.
|
||
2. Frontend загружает байты **напрямую в Selectel S3** по `upload_url` (не через `api-backend`).
|
||
3. `POST .../attachments/{attachment_id}/complete` с `checksum` (SHA-256) → api-backend получает authoritative `version_id`, ETag, size и server checksum; сравнивает client checksum и атомарно фиксирует `{quarantine_object_key, version_id, etag, checksum}`, `scan_status=pending`. Complete с другой версией/ETag/checksum → `409 resource_state_conflict`.
|
||
4. `POST .../messages` с `attachment_id` + `checksum` → Message Safety.
|
||
|
||
Правила безопасности:
|
||
|
||
- у клиента **нет** постоянных S3 access keys — только одноразовый/короткий presigned URL;
|
||
- presigned URL разрешает запись **только** в выделенный key в S3-quarantine (не в S3-data);
|
||
- bucket versioning включён; Safety читает только сохранённый `version_id` с conditional ETag match;
|
||
- allow-promote копирует именно эту version и использует conditional source ETag/checksum; mismatch запрещает delivery;
|
||
- TTL URL короткий (константа модуля / `app_settings`, ориентир минуты);
|
||
- скачивание из S3-data — отдельные **presigned GET** через `.../download-url` (с audit).
|
||
|
||
## 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"], "notifications": true }
|
||
```
|
||
|
||
4. Сервер отвечает `{ "type": "subscribed", "dialog_ids": ["uuid"], "notifications": true }`. Поле `notifications` опционально, default `false`; старые chat-клиенты совместимы.
|
||
|
||
**События сервер → клиент:**
|
||
|
||
| `type` | Назначение | Ключевые поля |
|
||
|---|---|---|
|
||
| `message.new` | Новое сообщение в диалоге | `dialog_id`, `message` (DTO как в REST) |
|
||
| `message.status` | Смена `safety_status` / `delivery_status` | `dialog_id`, `message_id`, `safety_status`, `delivery_status` |
|
||
| `dialog.status` | Смена `Dialog.status` | `dialog_id`, `status` |
|
||
| `notification.created` | Создано персональное уведомление | `event_id`, `occurred_at`, `notification`, `unread_count` |
|
||
| `notification.updated` | Изменено состояние/документы уведомления | `event_id`, `occurred_at`, `notification_id`, изменённые поля, `unread_count` |
|
||
| `notification.closed` | Уведомление закрыто | `event_id`, `occurred_at`, `notification_id`, `close_reason`, `unread_count` |
|
||
|
||
**Reconnect:**
|
||
|
||
- exponential backoff: 1s → 2s → 4s → … max 30s;
|
||
- после reconnect — повтор `subscribe` с актуальным списком `dialog_ids`;
|
||
- при недоступности WS > 30s — fallback на polling чата и, при подписке на уведомления, `GET /api/v1/notifications` + `/counter` раз в 60 секунд.
|
||
|
||
События уведомлений публикуются в `han:rt:user:{user_id}` на все соединения, включая инициатора. Массовый expire job не отправляет событие на каждую запись; reconnect/polling всегда выполняет REST reconcile.
|
||
|
||
**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) |
|
||
| `sms-service` | `sms-service/openapi.yaml` + callback JSON Schema | internal send/read; публичен только exact callback |
|
||
|
||
Правила:
|
||
|
||
- breaking change публичного API → новый path-prefix (`/api/v2`) + запись в arch-02;
|
||
- internal API версионируется тем же правилом (`/internal/{mnemonic}/v2/...`);
|
||
- OpenAPI генерируется или поддерживается вручную — на усмотрение модуля, но файл обязателен в DoD (arch-05).
|
||
|
||
## Producers ↔ api-backend: Notifications
|
||
|
||
| Контракт | Назначение | Защита |
|
||
|---|---|---|
|
||
| `POST /internal/notifications/v1/notifications` | Create персонального уведомления | private network + Bearer token конкретного `source` |
|
||
| `POST /internal/notifications/v1/notifications/cancel` | Cancel по `(source, external_id)` с `cancelled` или `paid` | то же |
|
||
|
||
Пара `(source, external_id)` уникальна бессрочно и заменяет `Idempotency-Key`: одинаковый canonical fingerprint возвращает существующую запись с `200`, другой — `409 notification_conflict`. Cancel идемпотентен; чужой `source` не раскрывается.
|
||
|
||
Каталог, валидация `details`, обязательных полей CTA и эффектов кнопок применяются по данным справочников без ветвления по `notification_type`. Инструкция `install_app` всегда возвращает открытие `instruction_url` в новой вкладке, без iframe/модалки.
|
||
|
||
При скрытии действие всегда ставит `visibility=hidden`. TTL из вида/default применяется только если `date_expired IS NULL`; уже заданная продюсером дата сохраняется. Первое успешное получение download URL для **любого** связанного документа считается началом скачивания и, при `hide_on_document_download=true`, один раз скрывает уведомление; последующие документы состояние не меняют.
|
||
|
||
## Keycloak SPI ↔ api-backend settings bridge
|
||
|
||
Keycloak SPI получает product limits OTP из `app_settings` через internal endpoint, а не через прямой доступ к `han_app`.
|
||
|
||
| Контракт | Владелец | Потребитель | Назначение | Защита |
|
||
|---|---|---|---|---|
|
||
| `GET /internal/settings/v1/otp` | `api-backend` | Keycloak SPI | OTP limits + `code_length`, `ttl_seconds`, `sms_order_timeout_ms`, cache metadata | internal network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` |
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
{
|
||
"max_send_attempts_per_24h": 3,
|
||
"min_seconds_between_attempts": 30,
|
||
"max_verify_attempts": 5,
|
||
"code_length": 6,
|
||
"ttl_seconds": 60,
|
||
"sms_order_timeout_ms": 3000,
|
||
"version": "2026-07-22T14:00:00Z",
|
||
"cache_ttl_seconds": 60
|
||
}
|
||
```
|
||
|
||
Ответ не содержит секретов и PII. Challenge сохраняет snapshot `code_length`, `ttl_seconds` и `version`. При недоступности endpoint Keycloak SPI использует последнее валидное cached value; если cache пустой — fail-closed для выдачи нового OTP.
|
||
|
||
## Keycloak SPI ↔ `sms-service`
|
||
|
||
Контракт действует в real mode; в mock mode Keycloak не вызывает `sms-service`. API доступен только в закрытой сети `backend`, Bearer token — парные `KEYCLOAK_SMS_SERVICE_TOKEN`/`SMS_SERVICE_TOKEN`. Caller v1 фиксирован как `keycloak`, process/template — `auth_otp`, channel — `SMS`, provider — `idgtl`; эти поля не доверяются request body.
|
||
|
||
### `POST /internal/sms/v1/send`
|
||
|
||
```json
|
||
{
|
||
"idempotency_key": "keycloak:challenge:<CHALLENGE_ID>",
|
||
"template_code": "auth_otp",
|
||
"locale": "ru",
|
||
"phone_e164": "+79001234567",
|
||
"substitutions": {"code": "<OTP>", "ttl_min": "<TTL_MIN>"},
|
||
"customer_ref": "<CHALLENGE_ID>",
|
||
"message_ttl_sec": 60
|
||
}
|
||
```
|
||
|
||
- Строгая проверка E.164, TTL Direct `60..86400`, locale и точного набора placeholders; неизвестный/пропущенный placeholder → `422 sms_request_invalid`.
|
||
- В одной transaction рендерится active approved `sms_template` и создаётся `sms_outbound_message` (`pending`/`unknown`); внешний Direct API в request handler не вызывается.
|
||
- Новый durable order → `202` с `sms_message_id`, `ordered_at`; идемпотентный повтор с тем же fingerprint → `200` и тот же id; тот же key с другим payload → `409 idempotency_key_reused`.
|
||
- Остальные коды: `401 unauthorized`, `429 rate_limit_exceeded`, `503 sms_service_unavailable`; envelope общий для arch-02.
|
||
- Keycloak считает заказ успешным только при `200/202` и валидном `sms_message_id`, сохраняет его в challenge/event и не запрашивает provider status.
|
||
|
||
### `GET /internal/sms/v1/messages/{sms_message_id}`
|
||
|
||
Диагностический read для Keycloak только по собственному `requester_service`. Телефон всегда masked; OTP, substitutions и `body_rendered` не возвращаются.
|
||
|
||
### `POST /callbacks/idgtl/sms`
|
||
|
||
Единственный публичный SMS endpoint. Только HTTPS и POST через root nginx; source IP `185.203.96.7` повторно сверяется перед production, применяется allowlist. Direct передаёт Basic auth, проверяемый `sms-service` по `IDGTL_SMS_CALLBACK_USERNAME`/`IDGTL_SMS_CALLBACK_PASSWORD`; credentials/Authorization не логируются.
|
||
|
||
Callback body — массив; items валидируются и дедуплицируются по `(message_uuid, callback_event, status, status_time)`. Повторы и out-of-order события ожидаемы. Callback обновляет только delivery fields журнала после DB commit, не уведомляет Keycloak и не влияет на OTP verify. Transient DB failure → 5xx для повтора Direct.
|
||
|
||
## Frontend ↔ Keycloak
|
||
|
||
Keycloak **обязателен** в production-like контуре с первого запуска (OTP, tokens, JWKS).
|
||
|
||
| Контракт | Владелец | Потребитель | Назначение |
|
||
|---|---|---|---|
|
||
| OIDC Authorization Code Flow with PKCE | Keycloak | Expo frontend | OTP-only login, token issue |
|
||
| OIDC Refresh Token Grant | Keycloak | Expo frontend | Обновление access token без OTP при действующем refresh token |
|
||
| OIDC logout | Keycloak | Expo frontend | Завершение сессии Keycloak, очистка tokens |
|
||
| OIDC Discovery (`/.well-known/openid-configuration`) | Keycloak | Expo frontend, `api-backend` | issuer, token/jwks endpoints |
|
||
| JWKS | Keycloak | `api-backend` | Проверка подписи access token (issuer, audience, exp) |
|
||
| OTP authenticator / SPI | Keycloak | — | Генерация/локальная проверка OTP, product limits, challenge lifecycle и вызов `sms-service` в real mode |
|
||
| PostgreSQL schema `keycloak` | Keycloak | Managed PostgreSQL | Учётные записи IdP |
|
||
|
||
Frontend не обращается напрямую к Keycloak DB и не хранит парольные credentials. Парольная авторизация в MVP отключена.
|
||
|
||
**`api-backend` ↔ Keycloak:** только **валидация JWT** по JWKS/discovery (кэш ключей). Admin REST / User API Keycloak в hot path **не** используются. Телефон и `sub` для `bootstrap` берутся из claims access token.
|
||
|
||
**OTP (Keycloak):** единственный канал первичной авторизации — телефон. При действующем refresh token OTP не показывается. Keycloak всегда является источником истины verify: mock сравнивает secret-код, real mode — локальный HMAC случайного OTP. `sms-service` только принимает durable order, рендерит шаблон, отправляет через Direct worker и ведёт provider journal. API верификации Direct `/verifier/send` и `/verifier/check` запрещён. Счётчики и product limits — только Keycloak/SPI (+ nginx edge).
|
||
|
||
**Clients в realm (MVP):**
|
||
|
||
| Client | Тип | Назначение |
|
||
|---|---|---|
|
||
| Frontend (Expo) | public + PKCE | login / refresh / logout |
|
||
| Backend confidential | optional | не нужен для hot path; зарезервирован под будущие admin/ops S2S |
|
||
|
||
### Жизненный цикл 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/v2/messages/check` | `message-safety` | `api-backend` | Проверка текста, ссылок и файлов | private HTTPS + internal CA + `X-Service-Token` |
|
||
| `GET /internal/safety/v2/messages/tasks/{task_id}` | `message-safety` | `api-backend` | Опрос до финального вердикта **внутри** того же public `POST .../messages` | private HTTPS + internal CA + `X-Service-Token` |
|
||
| Read S3-quarantine | Selectel S3 | `message-safety` | Чтение файла worker-ом при cache miss | read-only key |
|
||
|
||
HTTP-семантика target v2 от `message-safety`: `200 allow`, `403 deny`, `202 Accepted/pending`. Текущие `/v1/*` и `203` относятся только к legacy stub и не являются production-контрактом.
|
||
|
||
Normative details v2: каждый verdict/pending содержит `processing_mode=standard|mock` и `config_version`; `202` обязательно содержит `Location`, `Retry-After`, `task_id`, `expires_at` и существует только в standard mode; terminal `503 task_failed` — `terminal=true,retryable=false`; transient `503 dependency_unavailable` — `terminal=false,retryable=true`; `409 safety_request_conflict` — non-retryable caller invariant. Все domain deny имеют `reason_code=message_blocked`.
|
||
|
||
Emergency MOCK включается только root-owned helper/restart на ВМ2. В MOCK нет content/link/file checks и `202`: `TEXT_FREE`/`FILE_FREE=true` → sync `200`, false → canonical sync `403`. Auth/DTO/idempotency/audit/rate limits сохраняются. Public API не раскрывает `processing_mode`.
|
||
|
||
Поведение `api-backend`:
|
||
|
||
1. Синхронно вызывает `POST .../check`, получает один из трёх кодов.
|
||
2. При `200` / `403` — сразу завершает сценарий и отвечает клиенту.
|
||
3. При `202` сохраняет `task_id`, `Location`, deadline и **не ставит задачу в свою очередь анализа**; синхронно поллит `Location`, соблюдая `Retry-After`, до `200`/`403`, terminal failed `503` или timeout.
|
||
4. Решение «проверка быстрая или долгая» — только у `message-safety`. Ожидание poll держит **одно** клиентское HTTP-соединение; это не блокирует обработку других запросов (параллельные workers/async).
|
||
|
||
Checkpoint: на время poll — запись в **`safety_tasks`** (`han_app`) для recovery при crash/timeout (I1), не очередь анализа.
|
||
|
||
Recovery contract для `han_app.safety_tasks`:
|
||
|
||
- запись создаётся, когда `message-safety` вернул `202 pending`, и содержит `task_id`, `Location`, `message_id`, `attachment_id`, текущие `quarantine_object_key/version_id/ETag`, deadline и retry metadata;
|
||
- если клиентское HTTP-соединение оборвалось или api-backend упал, recovery job продолжает poll `GET /internal/safety/v2/messages/tasks/{task_id}`;
|
||
- final allow выполняет idempotent promote quarantine → S3-data и продолжает delivery checkpoint в Open Lines;
|
||
- final deny выполняет idempotent delete quarantine и выставляет `safety_status=blocked`, `delivery_status=rejected`;
|
||
- при final deny создаётся локальная company-реплика с `text_resources.mnemonic=safety.chat.blocked`; она публикуется как `message.new`, но не отправляется в Open Lines;
|
||
- timeout/circuit после recovery budget выставляет `delivery_status=failed`, оставляет audit trail и отдаёт объект на quarantine cleanup policy;
|
||
- recovery job не принимает новых сообщений и не решает, sync или async нужна проверка: это остаётся ответственностью `message-safety`.
|
||
|
||
Маппинг в App DB (`Message.safety_status` / `delivery_status` — см. [`arch-00-glossary.md`](arch-00-glossary.md)):
|
||
|
||
| HTTP / `message-safety` | `Message.safety_status` | `Message.delivery_status` (после завершения `POST .../messages`) | Финальный для клиента? |
|
||
|---|---|---|---|
|
||
| `200` / `allow` | `allowed` | `accepted` до вызова Open Lines; `delivered` только после успешной отправки в Open Lines | да |
|
||
| `403` / `deny` | `blocked` | `rejected` | да |
|
||
| `202` → затем `200`/`403` | как финальный | как финальный | да (после sync-wait) |
|
||
| terminal failed `503`, `retryable=false` | `pending` | `failed` | да: public `503`, не deny |
|
||
| `409 safety_request_conflict` | `pending` | `failed` | да: public `500` + alert, POST не повторять |
|
||
| timeout / circuit open | `pending` | `failed` | да: public `503/504`, не deny |
|
||
|
||
Для mock file allow `MessageAttachment.scan_status=bypassed`; значение `clean` запрещено, так как фактической проверки не было. `Message.safety_processing_mode` и `Message.safety_config_version` хранятся для audit, но отсутствуют в public DTO.
|
||
|
||
Circuit breaker + timeout budget (I2): при открытом circuit на `message-safety` — не слать сообщение в Bitrix; вернуть клиенту безопасную ошибку зависимости.
|
||
|
||
Доставка в Open Lines:
|
||
|
||
- api-backend сохраняет `Message` и delivery checkpoint/outbox запись в одной транзакции после финального safety `allow`;
|
||
- `delivery_status=accepted` не считается доставкой оператору и может быть виден только как промежуточный статус в логах/recovery;
|
||
- `delivery_status=delivered` выставляется после успешного ответа `POST /internal/openlines/v1/messages`;
|
||
- повтор delivery checkpoint идемпотентен по `message_id` и не создаёт дубль в Bitrix24;
|
||
- если `bitrix-local-app` или Bitrix24 недоступны после allow, `delivery_status=failed`, клиент получает dependency error, а recovery может повторить доставку только если контракт модуля явно разрешает безопасный retry без дубля.
|
||
|
||
## 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 чата.
|
||
|
||
### Inbox: входящие сообщения и файлы оператора (G5)
|
||
|
||
`POST /internal/openlines/v1/inbox` — нормализованное событие от `bitrix-local-app`. Минимальный контракт MVP:
|
||
|
||
```json
|
||
{
|
||
"event_type": "message.new",
|
||
"external_chat_id": "uuid",
|
||
"bitrix_message_id": "string",
|
||
"occurred_at": "2026-07-09T12:00:00Z",
|
||
"message": {
|
||
"text": "текст оператора или пустая строка",
|
||
"files": [
|
||
{
|
||
"name": "scan.pdf",
|
||
"mime_type": "application/pdf",
|
||
"size_bytes": 12345,
|
||
"download_url": "https://..."
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
Правила:
|
||
|
||
- `event_type`: `message.new` | `dialog.closed` (и др. по OpenAPI модуля);
|
||
- idempotency по `(external_chat_id, bitrix_message_id)` на стороне `api-backend`; повтор того же события возвращает `200`/`204` без повторного side-effect;
|
||
- если `api-backend` недоступен, `bitrix-local-app` хранит событие во внутреннем inbox, повторяет forward с exponential backoff и после исчерпания retry переводит запись в DLQ со статусом `dead_letter`;
|
||
- `bitrix-local-app` подтверждает доставку в Bitrix24 через `imconnector.send.status.delivery` только после успешного ответа `api-backend` или после идемпотентного duplicate-ack;
|
||
- файлы оператора: api-backend скачивает по `download_url` (timeout budget) и сохраняет в **S3-data attachments** + `MessageAttachment`; в MVP применяются те же продуктовые лимиты `chat.attachments.allowed_*` и `chat.attachments.max_size_mb`, что и для клиентских файлов;
|
||
- сообщения и файлы оператора считаются доверенным Bitrix24-channel: они **не** идут в quarantine и Message Safety, проходят только MIME/size validation и audit скачивания, затем сохраняются в S3-data. Остаточный malware-риск принят для MVP; UI/скачивание должны сохранять безопасный `Content-Disposition`/`Content-Type` и не исполнять active content.
|
||
- пустой `text` и пустой `files` → reject события;
|
||
- детальная JSON Schema — в `bitrix-local-app/openapi.yaml` и `api-backend/openapi.yaml`.
|
||
|
||
## 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` | Durable очередь App DB → Bitrix24 с lease/fencing и active-only dedup |
|
||
| `han.sync_suppress` (GUC) | PostgreSQL session | `bitrix-sync` | триггеры `han_app` | Подавление эхо-задач при записи данных от Bitrix24 в App DB |
|
||
| `bitrix_sync.entity_external_mapping` | PostgreSQL | `bitrix-sync` | `bitrix-sync` | Единственная каноническая active/closed/broken история `user_id` ↔ Contact; App DB не хранит `b24_id` |
|
||
| Обновление `sync_queue.status` | PostgreSQL | `bitrix-sync` | App DB | `pending/leased/retry_wait/processed/dead_letter/cancelled`, lease и safe error metadata |
|
||
| `bitrix_sync.workflow_instances` / `crm_commands` | PostgreSQL | `bitrix-sync` | `bitrix-sync` | Persisted scenario state и конкретные Bitrix batch subcommands |
|
||
| `bitrix_sync.webhook_inbox` | PostgreSQL | `bitrix-sync` | `bitrix-sync` | Durable приём, dedup и coalescing событий Битрикс24 |
|
||
|
||
Типы задач MVP (`sync_queue.task_type`):
|
||
|
||
- `contact.map_or_create` — матчинг/создание Contact, запись mapping в schema `bitrix_sync`, флаг регистрации в Bitrix24;
|
||
- `contact.update` — push только App-master телефона/служебных полей;
|
||
- `contact.deactivate` — flag `N`, закрытие active mapping без удаления Contact.
|
||
|
||
`contact.rebind` не является задачей `han_app.sync_queue`: это audited административный workflow, создаваемый только через `bitrix_sync.request_bitrix_contact_rebind`.
|
||
|
||
`bitrix-sync` **не создаёт** `UserIdentity` / `ClientProfile` в auth-flow; вход worker — задачи из `sync_queue`, созданные триггерами.
|
||
`entity_id` contact-задачи всегда равен `UserIdentity.id`; payload не содержит PII snapshot. Полный DDL/state-machine contract — [`../modules/module-07-bitrix-sync.md`](../modules/module-07-bitrix-sync.md), §§6–9.
|
||
|
||
### Internal HTTP `bitrix-sync` (ops, не hot path)
|
||
|
||
| Контракт | Владелец | Потребитель | Назначение | Защита |
|
||
|---|---|---|---|---|
|
||
| `GET /internal/sync/v1/status` | `bitrix-sync` | ops / мониторинг | Queue/workflow/webhook/reconciliation/limiter state без PII | internal network + `BITRIX_SYNC_SERVICE_TOKEN` |
|
||
|
||
Публичный/manual replay HTTP endpoint отсутствует. Controlled ops-действия используют утверждённые процедуры с audit; произвольный `UPDATE` mapping запрещён.
|
||
|
||
## 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.duplicate.findbycomm`, `crm.contact.get/add/update`, `crm.item.list`, `batch` | `bitrix-sync` → Bitrix24 | Первичный поиск, recovery, чтение, создание и точечное обновление Contact; reconciliation через `crm.item.list`, `entityTypeId=3`, `>=updatedTime`, `opened=1`, registration flag `=1` |
|
||
| Contact receiver URL `/bitrix/sync/webhook/contact?token=...` | HTTP-webhook робот Битрикс24 → `bitrix-sync` | `application/x-www-form-urlencoded`, query token, source IP CIDR allow-list, durable inbox; затем snapshot по ID |
|
||
| Alert receiver URL `/bitrix/sync/webhook/alert?token=...` | HTTP-webhook робот Битрикс24 → `bitrix-sync` | Form-urlencoded сигнал элемента smart process, query token и source IP CIDR allow-list |
|
||
| Smart process «Конфликты синхронизации» | `bitrix-sync` ↔ Bitrix24 | Business alerts с fingerprint, occurrence и SLA |
|
||
| PostgreSQL schema `bitrix_sync` | `bitrix-sync` ↔ PostgreSQL | Workflow/commands, inbox, snapshots, settings, alerts, reconciliation и technical DLQ |
|
||
| PostgreSQL schema `han_app` | `bitrix-sync` ↔ PostgreSQL | Очередь `sync_queue` и обновление профиля (Bitrix → App); canonical mapping хранится только в `bitrix_sync` |
|
||
|
||
Очередь `han_app.sync_queue` и write-back — в разделе «api-backend ↔ bitrix-sync» выше.
|
||
|
||
`bitrix-sync` использует отдельный secret DB URL с search path/access к `bitrix_sync` и точечными GRANT на `han_app`, отдельный входящий webhook технического пользователя для CRM REST и отдельные application tokens исходящих webhook. 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 API (`/0`), realtime/coordination (опц. `/1`); **не** OTP counters |
|
||
|
||
## 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` |
|
||
| `user_id` | из JWT |
|
||
| `guest_session_id` | не используется (endpoint только с JWT) |
|
||
| `request_id` | из `X-Request-ID` |
|
||
| `ip`, `user_agent` | из proxy headers |
|
||
|
||
Raw OTP и полный номер телефона в audit **не** пишутся.
|
||
|
||
### Audit: выдача download URL
|
||
|
||
При `GET /api/v1/documents/{document_id}/download-url` и `GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url` 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-сеть.
|