Правки от GPT
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
| `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` |
|
||||
|
||||
Пары значений (должны совпадать):
|
||||
|
||||
@@ -79,9 +80,35 @@
|
||||
}
|
||||
```
|
||||
|
||||
### Каталог публичных ошибок 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 | нет |
|
||||
| `422` | `message_blocked` | Message Safety вернул final deny | нет |
|
||||
| `429` | `rate_limit_exceeded` | Edge/API лимит превышен; должен быть `Retry-After`, если повтор допустим | да |
|
||||
| `503` | `dependency_unavailable` | Circuit open или недоступны safety/Bitrix/S3 | да |
|
||||
| `504` | `dependency_timeout` | Истёк timeout budget внешней зависимости | да |
|
||||
|
||||
Правило доступа к пользовательским ресурсам: для `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 /analytics/session-start`.
|
||||
Вызывается **один раз** после успешного OTP и получения JWT. Создаёт локального пользователя и **сразу** сохраняет согласия из тела (атомарно в одной транзакции). **Не** создаёт UX-сессию — для этого используется `POST /api/v1/analytics/session-start`.
|
||||
|
||||
**Заголовки:** `Authorization: Bearer <access_token>` — **единственный** источник идентичности пользователя.
|
||||
|
||||
@@ -113,7 +140,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
**Порядок после OTP:** `POST /auth/bootstrap` → `POST /analytics/session-start` → чат.
|
||||
**Порядок после OTP:** `POST /api/v1/auth/bootstrap` → `POST /api/v1/analytics/session-start` (если нужна новая UX-сессия) → чат.
|
||||
|
||||
**Ответ `200`:** `{ "user_id": "uuid", "profile_ready": true }`.
|
||||
|
||||
@@ -175,7 +202,9 @@ Frontend сохраняет `ux_session_id` **в памяти** и переда
|
||||
|
||||
### Создание диалога
|
||||
|
||||
- `POST /api/v1/dialogs` — заголовок **`Idempotency-Key`** (обязателен); ответ `{ "dialog_id": "uuid", "status": "open" | "waiting_for_company" | "waiting_for_client" }`.
|
||||
- `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)).
|
||||
@@ -190,6 +219,7 @@ Frontend сохраняет `ux_session_id` **в памяти** и переда
|
||||
| TTL | **24 часа** |
|
||||
| Повтор с тем же ключом и тем же телом | тот же HTTP-ответ, без повторного side-effect |
|
||||
| Повтор с тем же ключом и **другим** телом | **`409`** `idempotency_key_reused` |
|
||||
| Отсутствует на обязательном endpoint | **`400`** `validation_error` |
|
||||
|
||||
### Формат исходящего сообщения клиента (MVP)
|
||||
|
||||
@@ -207,6 +237,53 @@ Frontend сохраняет `ux_session_id` **в памяти** и переда
|
||||
|
||||
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).
|
||||
@@ -270,6 +347,16 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
|
||||
- internal API версионируется тем же правилом (`/internal/{mnemonic}/v2/...`);
|
||||
- OpenAPI генерируется или поддерживается вручную — на усмотрение модуля, но файл обязателен в DoD (arch-05).
|
||||
|
||||
## 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.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, cache metadata | internal network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` |
|
||||
|
||||
Ответ не содержит секретов и PII. При недоступности endpoint Keycloak SPI использует последнее валидное cached value; если cache пустой — fail-closed для выдачи OTP.
|
||||
|
||||
## Frontend ↔ Keycloak
|
||||
|
||||
Keycloak **обязателен** в production-like контуре с первого запуска (OTP, tokens, JWKS).
|
||||
@@ -335,17 +422,34 @@ HTTP-семантика от `message-safety`: `200 allow`, `403 deny`, `203 pen
|
||||
|
||||
Checkpoint: на время poll — запись в **`safety_tasks`** (`han_app`) для recovery при crash/timeout (I1), не очередь анализа.
|
||||
|
||||
Recovery contract для `han_app.safety_tasks`:
|
||||
|
||||
- запись создаётся, когда `message-safety` вернул `203 pending`, и содержит `task_id`, `message_id`, `attachment_id`, текущий `quarantine_object_key`, deadline и retry metadata;
|
||||
- если клиентское HTTP-соединение оборвалось или api-backend упал, recovery job продолжает poll `GET /internal/safety/v1/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`;
|
||||
- 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` | `delivered` (после успешной отправки в Open Lines) | да |
|
||||
| `200` / `allow` | `allowed` | `accepted` до вызова Open Lines; `delivered` только после успешной отправки в Open Lines | да |
|
||||
| `403` / `deny` | `blocked` | `rejected` | да |
|
||||
| `203` → затем `200`/`403` | как финальный | как финальный | да (после sync-wait) |
|
||||
| timeout / circuit open | `pending` или `blocked` по политике модуля | `failed` | да (ошибка инфраструктуры) |
|
||||
|
||||
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/`.
|
||||
@@ -387,8 +491,11 @@ Circuit breaker + timeout budget (I2): при открытом circuit на `mes
|
||||
Правила:
|
||||
|
||||
- `event_type`: `message.new` | `dialog.closed` (и др. по OpenAPI модуля);
|
||||
- idempotency по `(external_chat_id, bitrix_message_id)` на стороне `api-backend`;
|
||||
- файлы оператора: api-backend скачивает по `download_url` (timeout budget) и сохраняет в **S3-data attachments** + `MessageAttachment`; MIME/size — те же продуктовые лимиты чата (`chat.attachments.*`) или отдельный allow-list модуля (зафиксировать в 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 для Message Safety: они не проходят outbound moderation pipeline, но проходят MIME/size validation, antivirus policy модуля и audit скачивания;
|
||||
- пустой `text` и пустой `files` → reject события;
|
||||
- детальная JSON Schema — в `bitrix-local-app/openapi.yaml` и `api-backend/openapi.yaml`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user