Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)

This commit is contained in:
mi
2026-08-13 18:52:42 +03:00
parent 5100ba9fc3
commit 99605b1c77
144 changed files with 15295 additions and 1120 deletions
+47 -26
View File
@@ -22,7 +22,7 @@
| Переменная | Кто проверяет | Кто передаёт | Endpoint | Заголовок |
|---|---|---|---|---|
| `MESSAGE_SAFETY_SERVICE_TOKEN` | `message-safety` | `api-backend` | `POST/GET /internal/safety/v1/*` | `X-Service-Token` |
| `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` |
@@ -47,7 +47,8 @@
| Переменная | Назначение |
|---|---|
| `BITRIX_APPLICATION_TOKEN` | проверка событий Bitrix24 → `bitrix-local-app` `/bitrix/handler` |
| `BITRIX_SYNC_WEBHOOK_TOKEN` | проверка webhook Bitrix24 → `bitrix-sync` `/bitrix/sync/webhook/contact` |
| `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
@@ -121,6 +122,8 @@
| `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)
@@ -305,15 +308,17 @@ Post-MVP: допускается «текст + файлы» отдельной
Байты файла идут **напрямую в S3-quarantine** по короткоживущему **presigned URL**. `api-backend` не проксирует тело файла: выдаёт URL, проверяет результат, управляет lifecycle (promote/delete).
1. `POST .../attachments/init` (JWT) → `{ attachment_id, upload_url, upload_headers?, expires_at }` `upload_url` = **presigned PUT** (или POST policy) в **S3-quarantine**; ключ объекта и ограничения (bucket, key prefix, `Content-Type`, max size) задаёт `api-backend`.
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 проверяет наличие объекта в quarantine (HeadObject / размер / checksum), фиксирует metadata, `scan_status=pending`.
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).
@@ -492,27 +497,32 @@ Frontend не обращается напрямую к Keycloak DB и не хр
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
| `POST /internal/safety/v1/messages/check` | `message-safety` | `api-backend` | Проверка текста, ссылок и файлов | internal network + `X-Service-Token` |
| `GET /internal/safety/v1/messages/tasks/{task_id}` | `message-safety` | `api-backend` | Опрос до финального вердикта **внутри** того же `POST .../messages` | internal network + `X-Service-Token` |
| `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-семантика от `message-safety`: `200 allow`, `403 deny`, `203 pending`.
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. При `203` **не ставит задачу в свою очередь анализа**; регулярно и синхронно поллит `GET .../tasks/{task_id}` до `200`/`403` или timeout (`MESSAGE_SAFETY_TASK_POLL_MAX_SEC`), затем отвечает клиенту.
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` вернул `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}`;
- запись создаётся, когда `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`.
@@ -522,8 +532,12 @@ Recovery contract для `han_app.safety_tasks`:
|---|---|---|---|
| `200` / `allow` | `allowed` | `accepted` до вызова Open Lines; `delivered` только после успешной отправки в Open Lines | да |
| `403` / `deny` | `blocked` | `rejected` | да |
| `203` → затем `200`/`403` | как финальный | как финальный | да (после sync-wait) |
| timeout / circuit open | `pending` или `blocked` по политике модуля | `failed` | да (ошибка инфраструктуры) |
| `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; вернуть клиенту безопасную ошибку зависимости.
@@ -580,7 +594,7 @@ Circuit breaker + timeout budget (I2): при открытом circuit на `mes
- если `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 скачивания;
- сообщения и файлы оператора считаются доверенным 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`.
@@ -592,26 +606,31 @@ Circuit breaker + timeout budget (I2): при открытом circuit на `mes
| Контракт | Тип | Владелец | Потребитель | Назначение |
|---|---|---|---|---|
| `han_app.sync_queue` | PostgreSQL | триггеры `han_app` (миграции App DB) | `bitrix-sync` | Асинхронная очередь App DB → Bitrix24: триггер ставит задачу при изменении отслеживаемых полей |
| `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 |
| `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 |
| `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, запись `bitrix_contact_id`, флаг регистрации в Bitrix24;
- `contact.update` — push изменений профиля в Bitrix24.
- `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), §§69.
### Internal HTTP `bitrix-sync` (ops, не hot path)
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
| `GET /internal/sync/v1/status` | `bitrix-sync` | ops / мониторинг | Глубина очереди, dead letter, последний успешный run | internal network + `BITRIX_SYNC_SERVICE_TOKEN` |
| `GET /internal/sync/v1/status` | `bitrix-sync` | ops / мониторинг | Queue/workflow/webhook/reconciliation/limiter state без PII | internal network + `BITRIX_SYNC_SERVICE_TOKEN` |
Повтор dead letter и ручной replay в MVP — через БД/ops-процедуры; отдельный HTTP replay-endpoint — post-MVP.
Публичный/manual replay HTTP endpoint отсутствует. Controlled ops-действия используют утверждённые процедуры с audit; произвольный `UPDATE` mapping запрещён.
## bitrix-local-app ↔ Bitrix24
@@ -629,14 +648,16 @@ Circuit breaker + timeout budget (I2): при открытом circuit на `mes
| Контракт | Направление | Назначение |
|---|---|---|
| `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) |
| `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` использует `BITRIX_SYNC_APP_DATABASE_URL` для `han_app` + `bitrix_sync`, только `BITRIX_SYNC_CRM_*` для Bitrix24 CRM REST и не читает OAuth-токены `bitrix-local-app`.
`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 ↔ внешние хранилища