Правки от GPT

This commit is contained in:
mi
2026-07-10 10:49:58 +03:00
parent 8835677860
commit 50fc053979
7 changed files with 190 additions and 35 deletions
+113 -6
View File
@@ -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`.