Реализованы сервисы ВМ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
@@ -23,7 +23,7 @@
| Направление | `sender_type` | Кто инициирует | Проверка Message Safety | Доставка |
|---|---|---|---|---|
| **C→O. Клиент → оператор** | `client` | Frontend (JWT) | **Обязательна** до Open Lines | `api-backend``bitrix-local-app` → Open Lines |
| **O→C. Оператор → клиент** | `company` | Bitrix24 webhook → `bitrix-local-app` → inbox API | **Нет** outbound moderation (доверенный канал); MIME/size/antivirus policy модуля | App DB + WS / polling |
| **O→C. Оператор → клиент** | `company` | Bitrix24 webhook → `bitrix-local-app` → inbox API | **Нет** Message Safety/AV; только MIME/size, residual risk принят | App DB + WS / polling |
Правила:
@@ -65,7 +65,7 @@
- Push / deep link в чат (модель может быть push-ready позже).
- Админ-модерация очереди `needs_review` (enum зарезервирован, в MVP не создаётся).
- CRM Contact sync в hot path чата (`bitrix-sync` **не** участвует в Open Lines delivery).
- Реальная антивирус/LLM-модерация: в MVP допускается stub `message-safety` с фиксированными правилами теста; канонический контракт вердиктов — arch-02 (`200/203/403`).
- До production cutover допускается только явно маркированный stub v1; target Message Safety v2 имеет `200/202/403`, local-only URL checks и file scan.
---
@@ -162,12 +162,13 @@
|---|---|
| `pending` | В quarantine, проверка не завершена |
| `clean` | Allow, файл в S3-data (после promote) |
| `bypassed` | Forced allow в emergency MOCK; файл перенесён, но не проверялся |
| `infected` | Deny |
| `failed` | Ошибка инфраструктуры проверки |
`direction`: `client_upload` \| `company_inbound`.
Клиентский файл до allow живёт **только** в S3-quarantine. Постоянных access keys у клиента нет — только короткий presigned PUT/GET.
Клиентский файл до allow живёт **только** в versioned S3-quarantine. Presigned PUT подписывает `If-None-Match: *` и checksum; один object key нельзя перезаписать. Постоянных access keys у клиента нет.
### 5.5. Идентификаторы
@@ -229,12 +230,12 @@
### 6.3. Исходящий файл
1. `POST .../attachments/init` → presigned PUT в **S3-quarantine**.
2. Frontend грузит байты **напрямую в S3** (не через api-backend).
3. `POST .../attachments/{id}/complete` + checksum → HeadObject, metadata, `scan_status=pending`.
1. `POST .../attachments/init` → presigned PUT в versioned **S3-quarantine** со signed `If-None-Match: *`, checksum и `Content-Type`.
2. Frontend грузит байты напрямую; повторная запись key получает `412`.
3. `POST .../attachments/{id}/complete` + checksum → фиксация authoritative `version_id + ETag + checksum`, `scan_status=pending`.
4. `POST .../messages` с `content_kind=file`, `attachment_id`, `checksum`.
5. Safety (файл); при `203 pending` api-backend sync-poll `task_id` внутри того же HTTP-запроса клиента.
6. Allow → promote quarantine → S3-data attachments → delivery Open Lines (`message.files` signed URL, `message.text` пустой).
5. Safety (файл); при `202 pending` api-backend sync-poll `Location` внутри того же HTTP-запроса клиента.
6. Allow → conditional promote сохранённой S3 version (source ETag/checksum match) → S3-data attachments → delivery Open Lines.
7. Deny → quarantine delete, `blocked`/`rejected`.
Пока идёт poll safety, **это** клиентское соединение ждёт; параллельные запросы других клиентов не блокируются.
@@ -266,6 +267,8 @@
Лимиты MIME/size для файлов оператора в MVP — те же `chat.attachments.*`.
Файл оператора считается данными доверенного Bitrix24-channel: он не загружается в quarantine и не проходит Message Safety/ClamAV. Выполняются только MIME/size validation, безопасная выдача download response и audit. Остаточный malware-риск для MVP принят явно.
### 6.6. Закрытие диалога
Inbox `dialog.closed``Dialog.status=closed`. Frontend получает `dialog.status` по WS или при следующем GET. Новый активный — только новым `POST /dialogs`.
@@ -293,7 +296,7 @@ Inbox `dialog.closed` → `Dialog.status=closed`. Frontend получает `dia
|---|---|---|---|---|
| `200 allow` | `allowed` | `delivered` после успешной отправки; иначе `failed` | да (после allow) | `201` или `503`/`504` |
| `403 deny` | `blocked` | `rejected` | нет | `422 message_blocked` |
| `203 pending` → затем allow/deny | как финал | как финал | только после allow | финальный код после poll |
| `202 pending` → затем allow/deny | как финал | как финал | только после allow | финальный код после poll |
| timeout / circuit open | по политике модуля | `failed` | нет | `503`/`504` |
Клиенту **не** отдаётся промежуточный `processing` как успешный ответ `POST .../messages`.
@@ -370,8 +373,8 @@ Inbox `dialog.closed` → `Dialog.status=closed`. Frontend получает `dia
| Метод и путь | Кто → кто | Назначение |
|---|---|---|
| `POST /internal/safety/v1/messages/check` | api-backend → message-safety | Проверка |
| `GET /internal/safety/v1/messages/tasks/{task_id}` | api-backend → message-safety | Poll вердикта |
| `POST /internal/safety/v2/messages/check` | api-backend → message-safety | Проверка |
| `GET /internal/safety/v2/messages/tasks/{task_id}` | api-backend → message-safety | Poll вердикта; pending = `202` |
| `POST /internal/openlines/v1/messages` | api-backend → bitrix-local-app | Исходящая доставка |
| `GET /internal/openlines/v1/dialogs/{external_chat_id}` | api-backend → bitrix-local-app | Reconciliation |
| `POST /internal/openlines/v1/inbox` | bitrix-local-app → api-backend | Входящие события |
@@ -441,6 +444,8 @@ Partial unique: один active dialog на `user_id`.
| `content_kind` | `text` \| `file` |
| `text` | непустой для text; `''` для file |
| `safety_status` / `delivery_status` | §5.3 |
| `safety_processing_mode` | `standard | mock`, internal/audit only |
| `safety_config_version` | версия active Message Safety config, internal/audit only |
| `external_message_id` | Bitrix id для inbound |
| `client_idempotency_key` | опционально/связка с Idempotency-Key |
| `occurred_at` | |
@@ -452,7 +457,7 @@ Partial unique: один active dialog на `user_id`.
### 10.3. `message_attachments`
Поля: `id`, `dialog_id`, `message_id NULL` до привязки, `owner_user_id`, `direction`, имена/MIME/size/checksum, `scan_status`, storage keys (working + quarantine), timestamps, common fields.
Поля: `id`, `dialog_id`, `message_id NULL` до привязки, `owner_user_id`, `direction`, имена/MIME/size/checksum, `scan_status`, storage keys, `quarantine_version_id`, `quarantine_etag`, timestamps, common fields.
MVP: unique partial — не более одного active attachment на `message_id`.
@@ -480,7 +485,7 @@ attachments/... # проверенные вложения чата (clie
| Процесс | Владелец | Назначение |
|---|---|---|
| Safety recovery worker | api-backend | Доводит `203 pending` после обрыва клиентского HTTP |
| Safety recovery worker | api-backend | Доводит `202 pending` после обрыва клиентского HTTP |
| Delivery outbox worker | api-backend | Retry доставки в local app / Open Lines |
| Quarantine orphan cleanup | api-backend / ops | Удаляет просроченные объекты без active task/attachment |
| Inbox retry / DLQ | bitrix-local-app | Надёжность webhook → API |
@@ -551,6 +556,8 @@ Application-код **не** обходит outbox «в обход» для по
Этот документ собирает бизнес-смысл обмена сообщениями для аналитики и смежных фич; детальные алгоритмы — в module-спеках.
Для Safety: Product Owner принимает generic UX; Safety Service Owner — v2 contract/cutover; Rule Pack Owner — rules/corpus; Security Owner — monitor→deny и risks; Operations Owner — VM2/incident/restore. Назначения ролей фиксируются в release checklist.
---
## 16. Критерии приёмки
@@ -570,6 +577,16 @@ Application-код **не** обходит outbox «в обход» для по
13. Кнопка «Оператор» берёт номер только из `operator.call.phone`.
14. Популярный вопрос не имеет отдельного API — только text message.
15. Ownership: чужие dialog/attachment → `404`.
16. Любой Safety deny создаёт ровно одну company-реплику с mnemonic `safety.chat.blocked`; исходный blocked text редактируется, internal `rule_id` не виден клиенту.
17. В emergency MOCK normal checks не выполняются: отдельные fixed policy для text/file дают только sync allow/deny. Mode не виден клиенту; mock file allow хранится как `scan_status=bypassed`, а включение доступно `deploy` только через root-owned helper/restart и не имеет auto-expiry.
17. Semantic prompt-injection RU/EN возвращает allow + monitor audit; active content, URL policy и malware остаются hard deny.
18. `files=unavailable` не ломает text-only чат; `links=unavailable` блокирует только text с URL; Redis Safety outage не выключает core.
19. File async проходит `202` внутри api-backend до sticky final; public pending клиенту не возвращается.
20. EICAR, malformed/polyglot/encrypted/active PDF дают deny; dependency timeout даёт `503`, а не blocked.
21. Link pipeline не выполняет HTTP fetch; NXDOMAIN разрешяется с monitor, private/metadata IP блокируется.
22. S3 overwrite получает `412`; wrong version/ETag и conditional promote mismatch запрещают delivery.
23. Load acceptance module-05 §15.4 проходит: 10 text/s, 2 file/s, 5 slots, ≤100 pending; availability SLO в MVP не задаётся.
24. VM2 cutover/rollback gates module-10 пройдены; v1 stub не считается production control.
---
@@ -591,6 +608,10 @@ Application-код **не** обходит outbox «в обход» для по
| D10 | Популярный вопрос = обычный text send | §5.7 |
| D11 | Presigned upload напрямую в S3; api-backend не проксирует байты | §6.3 |
| D12 | `chat.attachments.*` — единые лимиты для чата (и reuse уведомлениями) | §5.6 |
| D13 | Generic Safety deny: company-реплика `safety.chat.blocked`, blocked text redacted, rule не раскрывается | §6.2, module-01 M8 |
| D14 | Emergency MOCK: независимые forced text/file allow/deny, root-owned helper для `deploy`, без auto-expiry | module-05 §2.3, module-10 |
| D14 | Semantic rules monitor-only; NXDOMAIN monitor allow | module-05 |
| D15 | Performance acceptance без availability SLO: 10 text/s, 2 file/s, 5 slots, 100 pending | module-05 §15.4 |
### 17.2. Открытые вопросы
@@ -598,9 +619,8 @@ Application-код **не** обходит outbox «в обход» для по
|---|---|---|---|
| Q1 | Индикатор непрочитанных сообщений чата + sync между устройствами (backlog 23–24) | Поле вроде `Dialog.client_last_opened_at` / `last_read_message_id` + WS/REST counter; **не** тип уведомления `message` | Новая мини-постановка |
| Q2 | Точный HTTP-код send в уже `closed` dialog | Зафиксировать в OpenAPI (`409 resource_state_conflict` или `422`) | Клиентский UX |
| Q3 | Политика показа blocked-сообщения в ленте (полный текст vs redacted) | Минимизация PII в хранении blocked (M8 module-01) + нейтральный UI | DB + frontend |
| Q4 | Создание нового dialog сразу после `closed` — всегда разрешено или по бизнес-правилу «сессия поддержки» | MVP: разрешить, пока соблюдён unique active | Продукт / поддержка |
| Q5 | Stub `message-safety` с `400` на GET task vs канон arch-02 `403` | Для prod — только канон `200/203/403`; stub не расширяет публичный контракт | module-05 / contract tests |
| Q5 | Stub v1 расходится с target v2 | Для production — только `200/202/403` и terminal failed `503`; stub изолирован adapter-ом до cutover | module-05 / contract tests |
| Q6 | Смешанный content text+files | Отдельный API version post-MVP | arch-02 breaking |
| Q7 | Unread badge на кнопке «Чат» vs бейдж колокольчика уведомлений | Развести визуально и в данных | UI + Q1 |
@@ -65,7 +65,7 @@
- **Новые механики CTA и новые кнопки деталки** сверх перечисленных в §5.3 и §5.4: их добавление требует кода и планируется отдельно.
- Раздел профиля «Документы» / архив оплат — не заменяются уведомлениями. Но документы компании из уведомлений **регистрируются в таблице `documents`**, чтобы будущий раздел профиля собрал их без миграции файлов.
- Запись в `sync_queue` из application-кода: только **триггер БД** (§10.8).
- **Фактическая доставка документов в Bitrix24.** `bitrix-sync` в текущем состоянии — no-op stub, очередь не обрабатывает. В scope этого релиза — только корректная постановка задачи в `sync_queue`; обработка — отдельная работа по `module-07`.
- **Фактическая доставка документов в Bitrix24.** Полный `bitrix-sync` первого релиза по module-07 обрабатывает только Contact; `document.client_uploaded` остаётся вне его scope и не claim-ится. В scope notification-релиза — только корректная постановка задачи в `sync_queue`.
- SMS/email поверх ЛК.
- История чатов как UI-раздел — deprecated; backend API диалогов этим ТЗ не удаляется.
- **Архив `lifecycle_status = closed` в UI v1 — нет.** Записи хранятся в БД бессрочно; ретенция и архивирование закрытых уведомлений не выполняются (§13).
@@ -904,7 +904,7 @@ Unique active `(notification_id, document_id)`. Сами файлы описыв
| `mime_type` | varchar(128) |
| `size_bytes` | bigint, CHECK > 0 |
| `checksum_sha256` | char(64) |
| `scan_status` | varchar(16), CHECK `pending` \| `clean` \| `infected` \| `failed` |
| `scan_status` | varchar(16), CHECK `pending` \| `clean` \| `bypassed` \| `infected` \| `failed`; `bypassed` — только Message Safety MOCK forced allow |
| `storage_bucket` / `object_key` | varchar |
| `quarantine_object_key` | varchar NULL |
| `upload_expires_at` / `completed_at` | timestamptz |
@@ -940,7 +940,7 @@ Unique active `(storage_bucket, object_key)`; unique `source_draft_id`; инде
- Действует общее правило подавления: при `current_setting('han.sync_suppress', true)='true'` задача не создаётся.
- Триггер и бизнес-транзакция — в одной транзакции. Application-код в `sync_queue` не пишет.
- `bitrix_sync_user` получает GRANT на чтение `client_documents` дополнительно к существующим (arch-03).
- Что именно происходит с задачей на стороне CRM — предмет `module-07`; в этом релизе `bitrix-sync` работает как no-op stub, задачи накапливаются в очереди (§3.2).
- Обработка `document.client_uploaded` — отдельное post-MVP расширение module-07; Contact worker не должен claim/ack такие задачи, они продолжают накапливаться в очереди (§3.2).
### 10.9. `notification_sources`
@@ -85,8 +85,8 @@
|---|---|---|---|
| **IdP user** | Keycloak (`keycloak` schema) | Realm user, phone verified, sessions, tokens | Keycloak |
| **UserIdentity** | App DB `user_identities` | Локальный `user_id`, связь `keycloak_sub`, кэш auth-телефона, `last_login_at` | Keycloak для телефона/`sub`; App DB для бизнес-FK |
| **ClientProfile** | App DB `client_profiles` | Кэш полей UI + `bitrix_contact_id` | UI-поля — последнее успешно синхронизированное значение (входящий поток MVP — Bitrix24); auth-телефон инициирует sync, но master телефона — Keycloak |
| **Bitrix Contact** | CRM Bitrix24 | Карточка клиента в CRM | Bitrix24 для CRM-полей; связь через `bitrix_contact_id` / `entity_external_mapping` |
| **ClientProfile** | App DB `client_profiles` | Кэш полей UI без CRM-идентификаторов | UI-поля — последнее успешно синхронизированное значение (входящий поток MVP — Bitrix24); auth-телефон инициирует sync, но master телефона — Keycloak |
| **Bitrix Contact** | CRM Bitrix24 | Карточка клиента в CRM | Bitrix24 для CRM-полей; связь `user_id ↔ b24_id` хранится только в `bitrix_sync.entity_external_mapping` |
| **Гость** | Только устройство | UI-state, локальные согласия до OTP, опционально `guest_session_id` | Нет серверной записи |
**Инвариант:** один verified phone ↔ один active Keycloak `sub`. Один `sub` ↔ одна active `UserIdentity`. Один `user_id` ↔ один `ClientProfile`.
@@ -266,8 +266,9 @@
| `sub` / существование IdP user | Keycloak | `user_identities.keycloak_sub` |
| Auth-телефон | Keycloak | `user_identities.phone_number`, seed `client_profiles.russian_phone` |
| Согласия (версия + accepted) | App DB `user_consents` | — |
| ФИО, гражданство, email, foreign_phone | Последний успешный sync (MVP: Bitrix → App) | `client_profiles` |
| `bitrix_contact_id` | Результат `bitrix-sync` | `client_profiles` |
| ФИО, гражданство, email | Последний успешный sync (MVP: Bitrix → App) | `client_profiles` |
| `foreign_phone` | Не синхронизируется в первом релизе | `client_profiles` |
| Связь с Contact | `bitrix-sync` | Только `bitrix_sync.entity_external_mapping`; в App DB не кэшируется |
| Tokens / auth session | Keycloak | secure storage на клиенте |
| `ux_session_id` | App DB + память клиента | заголовок запросов |
@@ -293,7 +294,7 @@ Application-код **не** пишет в `sync_queue`: задачи созда
| `phone_number` | Auth-телефон E.164 |
| `guest_session_id` | Локальный UUID устройства; не auth |
| `ux_session_id` | Аналитическая сессия |
| `bitrix_contact_id` | Contact в CRM (после sync) |
| `b24_id` | Внутренний идентификатор Contact; используется только `bitrix-sync` |
| `device_id` | Opaque id устройства в bootstrap / session-start; в audit/log не копируется как PII |
Публичные id — **UUID** (в App DB предпочтительно UUID v7, как в остальных доменах).
@@ -395,7 +396,6 @@ Unique `(user_id, consent_type, document_version)`. Записи immutable.
|---|---|---|
| `id` | uuid PK | |
| `user_id` | uuid UNIQUE FK | 1:1 с identity |
| `bitrix_contact_id` | varchar/nullable | После успешного map |
| `full_name` | varchar NULL | |
| `citizenship` | varchar NULL | |
| `russian_phone` | varchar NULL | Seed из auth-телефона |
@@ -404,7 +404,7 @@ Unique `(user_id, consent_type, document_version)`. Записи immutable.
| `source_updated_at` | timestamptz NULL | Метка источника sync |
| common fields | обязательны | |
Partial unique на `bitrix_contact_id` среди active. PII не попадает в логи и generic audit payload.
CRM Contact ID и mapping в `client_profiles` отсутствуют. PII не попадает в логи и generic audit payload.
### 10.4. `ux_sessions`
@@ -427,7 +427,8 @@ Partial unique на `bitrix_contact_id` среди active. PII не попада
|---|---|---|
| OTP challenge / counters / expiry | Keycloak SPI | До появления App user |
| SMS order / delivery journal | `sms-service` | Только доставка кода |
| `contact.map_or_create` / `contact.update` | `bitrix-sync` | После bootstrap / изменения профиля |
| `contact.map_or_create` / `contact.update` / `contact.deactivate` | `bitrix-sync` | После bootstrap / изменения телефона / деактивации |
| `contact.rebind` | `bitrix-sync` | Audited административное исправление ошибочного mapping |
| Token refresh / logout | Frontend + Keycloak | Не трогает App DB identity |
| Retention UX-сессий (если введён) | ops / module | Не удаляет `UserIdentity` |
@@ -445,7 +446,7 @@ Partial unique на `bitrix_contact_id` среди active. PII не попада
В audit **нет:** полного phone/email/name в свободном тексте логов общего контура, OTP raw code, tokens, presigned URL.
Метрики (минимум): число bootstrap/сутки, доля `consents_required`, доля `phone_claim_missing`, latency map Contact, доля пользователей без `bitrix_contact_id` спустя N минут после входа.
Метрики (минимум): число bootstrap/сутки, доля `consents_required`, доля `phone_claim_missing`. Latency map Contact и доля пользователей без active mapping спустя N минут считаются `bitrix-sync` по собственной схеме.
---