Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
+638
View File
@@ -0,0 +1,638 @@
# Бизнес-постановка: Обмен сообщениями (Chat / Dialog)
**Статус:** v1 — консолидация принятых решений из `HAN_chat_specification` (`arch-00``arch-05`, `module-01`, `module-05`, `module-06`); открытые вопросы зафиксированы в §17
**Продукт:** HAN Chat (клиентское приложение + `api-backend` + `message-safety` + `bitrix-local-app` / Open Lines)
**Источники:** архитектура `HAN_chat_specification`; макет Figma (**не канон** — только визуализация; при расхождении приоритет у этого ТЗ и arch-документов)
**Связанный backlog:** непрочитанные сообщения на кнопке «Чат» (синхронизация между устройствами); UX ошибки отложенного сообщения после входа
**Смежно:** пользователь (auth/bootstrap), уведомления (`send_chat_message`, кнопки Чат/Оператор), популярные вопросы, Message Safety
**Нормативная часть — §1–§16.** §17 — ненормативный журнал решений и открытых вопросов; при расхождении с §1–§16 приоритет у §1–§16. При расхождении этого документа с arch/module после их обновления — приоритет у arch/module до синхронизации.
---
## 1. Цель
Дать клиенту канал диалога с оператором компании: отправка текста и файлов из приложения в Bitrix24 Open Lines и получение ответов оператора в реальном времени — с проверкой исходящих сообщений (Message Safety), идемпотентностью, ownership и деградацией без потери уже принятых данных.
Чат — канал **«клиент ↔ оператор»**. Уведомления — канал **«компания → клиент»** вне чата (см. `notification-requirements.md`). Непрочитанные ответы оператора **не** моделируются как уведомления; индикатор непрочитанного чата — отдельная фича (§3.2, §17.2).
---
## 2. Два направления обмена
| Направление | `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 | **Нет** Message Safety/AV; только MIME/size, residual risk принят | App DB + WS / polling |
Правила:
1. Чат доступен **только авторизованному** клиенту (JWT + `bootstrap`). Гость инициирует auth; текст сохраняется локально и отправляется после входа (§6.4).
2. У пользователя **не более одного активного** диалога (`open` \| `waiting_for_company` \| `waiting_for_client`).
3. `dialog_id` приложения **равен** `external_chat_id` для Open Lines.
4. MVP: одно исходящее сообщение — либо текст, либо ровно один файл (`content_kind`), не оба сразу.
5. Клиент на `POST .../messages` получает **только финальный** результат (или ошибку инфраструктуры), не промежуточное «обрабатывается».
6. Источник истины ленты — App DB; WS — at-most-once best effort; после reconnect — REST reconcile.
---
## 3. Границы релиза
### 3.1. В scope
- Один активный диалог на пользователя; ленивое создание перед первым сообщением.
- Исходящие: `content_kind` `text` \| `file`; входящие: текст и файлы оператора.
- Orchestration Message Safety: sync check + sync-wait poll `task_id` + checkpoint `safety_tasks` + recovery.
- Presigned upload клиента в S3-quarantine → complete → safety → promote в S3-data attachments.
- Durable outbox App → Open Lines; idempotent delivery по `message_id`.
- Inbox Open Lines → App: `message.new`, `dialog.closed`; idempotency по `(external_chat_id, bitrix_message_id)` / `event_id`.
- Realtime `WS /api/v1/realtime` + polling fallback истории сообщений.
- Популярные вопросы как обычная отправка текста после auth.
- Общий механизм **отложенного сообщения** на frontend (ручной ввод, популярный вопрос, CTA уведомлений `send_chat_message`).
- Кнопки UI «Чат» и «Оператор» (`tel:``operator.call.phone`) — смежно с уведомлениями.
- Нормализация текста входящих из Bitrix (удаление служебной разметки отправителя) — уже закрытый дефект backlog.
- Ownership, rate limits, `Idempotency-Key` (TTL 24 ч) на create dialog / send message.
- Константы `chat.attachments.*`, `rate_limit.message_send.*`, `operator.call.phone`.
### 3.2. Вне scope
- Смешанное сообщение «текст + файл(ы)» (post-MVP, отдельная версия API).
- Несколько вложений в одном исходящем сообщении.
- UI-раздел «История чатов / список диалогов» как продуктовый экран — **deprecated** для MVP frontend (один чат с компанией); backend `GET /api/v1/dialogs` **не удаляется**.
- Индикатор непрочитанных сообщений чата и sync между устройствами (`Dialog.client_last_opened_at` и аналоги) — backlog п.2324; **не** путать с бейджем уведомлений.
- Typing indicators, реакции, редактирование/удаление сообщений клиентом, ответы на конкретное сообщение (quote/reply).
- Голосовые / видеосообщения, стикеры, произвольные форматы сверх `chat.attachments.*`.
- Push / deep link в чат (модель может быть push-ready позже).
- Админ-модерация очереди `needs_review` (enum зарезервирован, в MVP не создаётся).
- CRM Contact sync в hot path чата (`bitrix-sync` **не** участвует в Open Lines delivery).
- До production cutover допускается только явно маркированный stub v1; target Message Safety v2 имеет `200/202/403`, local-only URL checks и file scan.
---
## 4. UI
| Место | Поведение |
|---|---|
| Главная: поле ввода | Отправка текста; без auth → согласия → OTP → bootstrap; затем немедленный переход в чат и отложенная отправка |
| Главная: популярные вопросы | Тап = автоотправка текста вопроса (тот же поток, что ручной ввод) |
| Кнопка «Чат» | Открывает текущий активный диалог или создаёт его при отсутствии |
| Кнопка «Оператор» | `tel:` на `operator.call.phone` (не чат-сообщение) |
| Экран чата | Лента сообщений клиента и компании; статусы доставки по DTO |
| Вложения | Выбор файла → init → PUT в quarantine → complete → send `content_kind=file` |
| Гость в Центре уведомлений / чате | Auth-gate; после входа — ЛК |
Визуал — по Figma. Figma не канон статусов доставки и состава API.
---
## 5. Бизнес-модель
### 5.1. Сущности
| Сущность | Схема | Назначение |
|---|---|---|
| `Dialog` | `han_app` | Диалог клиента с Open Lines |
| `Message` | `han_app` | Сообщение в диалоге |
| `MessageAttachment` | `han_app` | Вложение (client upload или company inbound) |
| `safety_tasks` | `han_app` | Checkpoint sync-wait Message Safety (техническая) |
| `delivery_outbox` | `han_app` | Durable намерение доставки в Open Lines |
| `openlines_inbox_receipts` | `han_app` | Idempotency приёма событий local app |
| `dialog_sessions` | `bitrix_local` | Маппинг чата у `bitrix-local-app` |
| `popular_questions` | `han_app` | Справочник текстов быстрых вопросов (не сообщения) |
### 5.2. `Dialog.status`
| Значение | Смысл |
|---|---|
| `open` | Диалог создан, сообщений ещё нет |
| `waiting_for_company` | Последнее значимое — исходящее от клиента; ждём оператора |
| `waiting_for_client` | Последнее значимое — входящее от оператора; ждём клиента |
| `closed` | Закрыт в Open Lines (`dialog.closed` / `ONIMCONNECTORDIALOGFINISH`) |
Переходы:
| Событие | Новый статус |
|---|---|
| `POST /dialogs` (новый) | `open` |
| Успешная доставка исходящего клиента в Open Lines | `waiting_for_company` |
| Сохранено входящее от оператора | `waiting_for_client` |
| Inbox `dialog.closed` | `closed` |
Из `closed` обратный переход **запрещён**. Новый активный диалог после закрытия — снова через `POST /dialogs`, когда продукт это разрешит; инвариант «один active» сохраняется.
### 5.3. Сообщение: вид и статусы
**`content_kind` (исходящее MVP):**
| `content_kind` | Тело запроса | `Message.text` | Вложения |
|---|---|---|---|
| `text` | непустой `text` | текст | 0 |
| `file` | `attachment_id` + `checksum` | пустая строка | ровно 1 |
Запрещено: текст + файл → `400 mixed_content_not_allowed`; пустое → `400 empty_message`; >1 вложение → `400 too_many_attachments`.
**`sender_type`:** `client` \| `company`.
**`safety_status`:**
| Значение | Смысл |
|---|---|
| `pending` | Проверка не завершена |
| `allowed` | Финальный allow |
| `blocked` | Финальный deny |
| `needs_review` | Зарезервирован, в MVP не создаётся |
Входящие `company` всегда `safety_status=allowed`.
**`delivery_status`:**
| Значение | Смысл | Клиенту как финал `POST .../messages`? |
|---|---|---|
| `accepted` | Принято API, safety/доставка ещё не финализированы | нет (промежуточный) |
| `processing` | Sync-wait safety | нет |
| `delivered` | Allow + ушло в Open Lines (или входящее сохранено) | да |
| `rejected` | Deny Message Safety | да (`422 message_blocked`) |
| `failed` | Инфраструктурная ошибка (Bitrix/S3/timeout), не safety-deny | да (`503`/`504`) |
Инварианты: `rejected``blocked`; `delivered``allowed`.
### 5.4. Вложение
| `scan_status` | Смысл |
|---|---|
| `pending` | В quarantine, проверка не завершена |
| `clean` | Allow, файл в S3-data (после promote) |
| `bypassed` | Forced allow в emergency MOCK; файл перенесён, но не проверялся |
| `infected` | Deny |
| `failed` | Ошибка инфраструктуры проверки |
`direction`: `client_upload` \| `company_inbound`.
Клиентский файл до allow живёт **только** в versioned S3-quarantine. Presigned PUT подписывает `If-None-Match: *` и checksum; один object key нельзя перезаписать. Постоянных access keys у клиента нет.
### 5.5. Идентификаторы
| Имя | Назначение |
|---|---|
| `dialog_id` | UUID диалога = `external_chat_id` Open Lines |
| `message_id` | UUID сообщения; ключ idempotent delivery |
| `attachment_id` | UUID вложения |
| `bitrix_message_id` | ID сообщения в Bitrix для inbox dedup |
| `task_id` | Async-проверка Message Safety |
| `Idempotency-Key` | Клиентский ключ create dialog / send (Redis + durable fallback), TTL 24 ч |
### 5.6. Константы (`app_settings`)
| Ключ | Смысл | Seed |
|---|---|---|
| `chat.message.max_length` | Макс. длина нормализованного текста; public config для счётчика `n/max` | `4000` |
| `chat.attachments.allowed_extensions` | Разрешённые расширения | jpg,jpeg,png,webp,heic,heif,pdf |
| `chat.attachments.allowed_mime_types` | Разрешённые MIME | image/jpeg, image/png, …, application/pdf |
| `chat.attachments.disallowed_extensions` | Явный deny-list | svg,doc,docx,xls,xlsx,csv |
| `chat.attachments.max_size_mb` | Макс. размер | `5` |
| `chat.attachments.storage` | Провайдер | `selectel_s3` |
| `chat.attachments.upload_mode` | Режим | `presigned_put` |
| `chat.attachments.safety_scan_required` | Safety обязателен | `true` |
| `chat.attachments.presigned_upload_ttl_seconds` | TTL upload URL | `600` |
| `rate_limit.message_send.per_user` | Лимит отправок | `30/minute` |
| `rate_limit.message_send.per_dialog` | Лимит на диалог | `20/minute` |
| `rate_limit.download_url.per_user` | Лимит download-url | `60/hour` |
| `operator.call.phone` | Телефон кнопки «Оператор» | `+74999591007` |
Те же `chat.attachments.*` переиспользуются уведомлениями для документов клиента.
### 5.7. Популярные вопросы
- Справочник `popular_questions` отдаётся public content API.
- Отдельного backend-flow нет: после auth текст уходит как обычное `content_kind=text`.
- Без auth — тот же механизм отложенного сообщения, что у ручного ввода.
---
## 6. Поведение UI и сценарии
### 6.1. Создание / открытие диалога
1. Frontend перед первым `POST .../messages` вызывает `POST /api/v1/dialogs` с `Idempotency-Key`.
2. Нет active → `201`, `status=open`.
3. Есть active → `200`, возвращается существующий (новый не создаётся).
4. Кнопка «Чат» открывает этот диалог (или создаёт при отсутствии).
### 6.2. Исходящее текстовое сообщение
1. `POST /dialogs` (если нет `dialog_id`).
2. `POST .../messages` с `content_kind=text`, `Idempotency-Key`.
3. Rate limits (nginx + app).
4. Message Safety (текст, ссылки).
5. Allow → outbox → Open Lines → `delivery_status=delivered`, `Dialog``waiting_for_company`.
6. Deny → `422 message_blocked`, в Open Lines **не** уходит; backend сохраняет в истории отдельную `company`-реплику с бизнес-текстом для сообщения или документа.
7. Dependency failure → `503`/`504`, при уже созданном Message — `delivery_status=failed`.
### 6.3. Исходящий файл
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 (файл); при `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, **это** клиентское соединение ждёт; параллельные запросы других клиентов не блокируются.
### 6.4. Отложенное сообщение (гость → после auth)
Общий frontend-механизм для:
- ручного ввода;
- популярного вопроса;
- CTA уведомлений с `send_chat_message` (после входа в контуре G).
Порядок:
1. Клиент инициирует отправку без JWT → согласия → OTP → `bootstrap``session-start`.
2. Экран авторизации **завершается после успешного bootstrap**, даже если последующая отправка в чат упадёт.
3. Затем `POST /dialogs``POST .../messages` с сохранённым текстом.
4. Ошибка Bitrix/safety показывается **в контексте чата**, не как «не удалось завершить вход» (backlog п.21).
### 6.5. Входящее от оператора
1. Bitrix24 `ONIMCONNECTOR*``bitrix-local-app` (inbox, retry, DLQ).
2. Forward в `POST /internal/openlines/v1/inbox` (`message.new`).
3. api-backend: ownership по `external_chat_id`, save Message `company`/`allowed`/`delivered`, файлы → S3-data + `MessageAttachment`.
4. Текст очищается от служебной разметки отправителя Bitrix (BBCode-префиксы имени и т.п.) — клиент видит чистый текст ответа.
5. `Dialog.status``waiting_for_client`.
6. Publish WS `message.new`; при недоступности WS — клиент подтянет через polling.
7. Local app ack delivery в Bitrix после успешного apply / duplicate-ack.
Лимиты 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`.
### 6.7. Realtime и fallback
1. `WS /api/v1/realtime` + JWT.
2. После `connected``subscribe` с `dialog_ids` (и опционально `notifications`).
3. События чата: `message.new`, `message.status`, `dialog.status`.
4. Reconnect: backoff 1s…30s; повтор `subscribe`.
5. WS недоступен > 30s → polling `GET .../messages?after=<cursor>` (и notifications counter при подписке).
6. Ping/pong ~30s.
### 6.8. Скачивание вложения
`GET .../attachments/{id}/download-url` → короткий presigned GET + audit `attachment.download_url_issued`. URL в логи/audit не пишется. Ownership обязателен.
---
## 7. Матрицы поведения
### 7.1. Исходящее: safety → delivery
| Вердикт safety | `safety_status` | `delivery_status` (финал клиенту) | Open Lines | HTTP клиенту |
|---|---|---|---|---|
| `200 allow` | `allowed` | `delivered` после успешной отправки; иначе `failed` | да (после allow) | `201` или `503`/`504` |
| `403 deny` | `blocked` | `rejected` | нет | `422 message_blocked` |
| `202 pending` → затем allow/deny | как финал | как финал | только после allow | финальный код после poll |
| timeout / circuit open | по политике модуля | `failed` | нет | `503`/`504` |
Клиенту **не** отдаётся промежуточный `processing` как успешный ответ `POST .../messages`.
### 7.2. Идемпотентность клиента
| Ситуация | Результат |
|---|---|
| Тот же `Idempotency-Key` + то же тело | Тот же HTTP-ответ, без повторного side-effect |
| Тот же ключ + другое тело | `409 idempotency_key_reused` |
| Нет ключа на обязательном endpoint | `400 validation_error` |
| TTL | 24 часа (Redis); durable fallback в `idempotency_records` |
Доставка в Open Lines идемпотентна по `message_id`. Inbox `message.new` — unique `(external_chat_id, bitrix_message_id)`.
### 7.3. Деградация зависимостей
| Зависимость | Влияние на чат |
|---|---|
| Redis DB0 | Write fail-closed; read ограниченно деградирует |
| Redis DB1 (realtime) | REST работает; WS/publish деградирует → polling |
| `message-safety` | Отправка недоступна; чтение истории работает |
| `bitrix-local-app` / Bitrix | После allow сообщение может стать `failed`; recovery по outbox |
| S3 | Файловые операции недоступны; текстовый чат продолжает работать |
| Open Lines в readiness | Может быть `degraded`; не обязано валить весь API |
### 7.4. Ownership и ошибки доступа
| Ситуация | Код |
|---|---|
| Чужой `dialog_id` / `attachment_id` / `message` | `404` (существование не раскрывается) |
| Нет/невалиден JWT | `401` |
| Диалог `closed`, политика запрещает send | по контракту модуля (`409`/`422` — уточнить в OpenAPI при публикации) |
| Safety deny | `422 message_blocked` |
---
## 8. Идентификация и корреляция
- Публичные id — UUID.
- `dialog_id` генерирует приложение при create и передаётся в Open Lines как `external_chat_id`.
- При первой доставке `bitrix-local-app` создаёт/обновляет `dialog_sessions`.
- `X-Request-ID`, `traceparent`, опционально `X-Ux-Session-Id` — для логов/audit; не auth.
- Connector Bitrix: `han_mobile_app`, Open Line id по env/глоссарию.
---
## 9. API
Общие конвенции — arch-02: `/api/v1/*` JWT, `/api/v1/public/*`, `/internal/{mnemonic}/v1/*`; JSON snake_case; даты RFC 3339 UTC; cursors opaque.
### 9.1. Клиентские (JWT)
| Метод и путь | Назначение |
|---|---|
| `POST /api/v1/dialogs` | Find-or-create active dialog (`Idempotency-Key`) |
| `GET /api/v1/dialogs` | Список/история диалогов (API есть; UI MVP — один чат) |
| `GET /api/v1/dialogs/{dialog_id}` | Карточка диалога |
| `GET /api/v1/dialogs/{dialog_id}/messages?after=&limit=` | История / polling |
| `POST /api/v1/dialogs/{dialog_id}/messages` | Отправка (`Idempotency-Key` + safety) |
| `POST .../attachments/init` | Presigned PUT quarantine |
| `POST .../attachments/{id}/complete` | Подтверждение upload |
| `GET .../attachments/{id}/download-url` | Presigned GET + audit |
| `WS /api/v1/realtime` | События чата (и опционально уведомлений) |
### 9.2. Public
| Метод и путь | Назначение |
|---|---|
| `GET /api/v1/public/content` | Тексты + `popular_questions` |
| `GET /api/v1/public/settings` | В т.ч. публичные флаги/лимиты UI при `is_public` |
### 9.3. Internal
| Метод и путь | Кто → кто | Назначение |
|---|---|---|
| `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 | Входящие события |
### 9.4. Ошибки домена чата
| Код | HTTP | Когда |
|---|---|---|
| `validation_error` | 400 | Нет Idempotency-Key, невалидное тело |
| `empty_message` | 400 | Нет текста и вложения |
| `mixed_content_not_allowed` | 400 | Текст и файл вместе |
| `too_many_attachments` | 400 | >1 вложение |
| `attachment_not_completed` | 400 | Send до complete |
| `attachment_checksum_mismatch` | 400 | Checksum не совпал |
| `unauthorized` | 401 | JWT |
| `message_blocked` | 422 | Safety deny |
| `idempotency_key_reused` | 409 | Ключ с другим телом |
| `resource_state_conflict` | 409 | Конфликт состояния (напр. complete с другим checksum) |
| `rate_limit_exceeded` | 429 | Лимит |
| `dependency_unavailable` | 503 | Circuit / недоступна зависимость |
| `dependency_timeout` | 504 | Timeout зависимости |
### 9.5. DTO `MessageResponse` (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"
}
```
Сортировка сообщений: `created_at ASC` (append в ленте). Диалоги: `updated_at DESC`.
---
## 10. Модель данных (схема `han_app`)
Общие правила — module-01 §9.1 / arch-05.
### 10.1. `dialogs`
| Поле | Описание |
|---|---|
| `id` | = `dialog_id` = `external_chat_id` |
| `user_id` | FK владельца |
| `status` | enum §5.2 |
| `last_message_at` | |
| `closed_at` | при `closed` |
| common fields | обязательны |
Partial unique: один active dialog на `user_id`.
### 10.2. `messages`
| Поле | Описание |
|---|---|
| `id` | `message_id` |
| `dialog_id` | FK |
| `sender_type` | `client` \| `company` |
| `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` | |
| common fields | |
Индексы: лента `(dialog_id, created_at, id)`; recovery по `delivery_status`; unique inbound `(dialog_id, external_message_id)`.
**Решение:** исходящее создаётся до завершения safety со статусами `pending`/`accepted`, чтобы `safety_tasks` имел FK (crash checkpoint).
### 10.3. `message_attachments`
Поля: `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`.
### 10.4. Технические таблицы
| Таблица | Назначение | Физическая очистка |
|---|---|---|
| `safety_tasks` | Poll/recovery Message Safety | да, по retention |
| `delivery_outbox` | App → Open Lines | по статусам/retention модуля |
| `openlines_inbox_receipts` | Dedup inbox | по политике модуля |
| `idempotency_records` | Durable fallback Idempotency-Key | по `expires_at` |
### 10.5. Схема ключей S3 (чат)
```text
quarantine/... # клиентский upload до вердикта
attachments/... # проверенные вложения чата (client + company)
```
Бакет documents (`han-chat-documents`) — для документов профиля/уведомлений, **не** hot path чата Open Lines.
---
## 11. Фоновые процессы
| Процесс | Владелец | Назначение |
|---|---|---|
| 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 |
| Realtime publish | api-backend + Redis DB1 | Fan-out WS; при сбое — polling |
Application-код **не** обходит outbox «в обход» для повторной доставки без idempotency.
---
## 12. Audit и observability
| `event_type` | Когда |
|---|---|
| `dialog.created` | Новый диалог |
| `message.submitted` | Принято к обработке |
| `message.blocked` | Safety deny |
| `message.delivered` | Успех Open Lines / inbound saved |
| `message.failed` | Инфраструктурный fail |
| `attachment.upload_initialized` / `completed` / `promoted` / `rejected` | Lifecycle файла |
| `attachment.download_url_issued` | Presigned GET |
| `openlines.inbox_applied` | Входящее применено |
Метрики: latency send, safety poll duration/timeout, outbox depth/age/DLQ, inbox duplicate/apply, WS reconnect/drop, rate limit rejects. **Нельзя** использовать `user_id`/`dialog_id` как metric labels.
В логах нет: тел сообщений, tokens, presigned URL, Bitrix download URL, полного PII.
---
## 13. Хранение
- `Dialog` / `Message` / `MessageAttachment` — прикладные строки, soft-delete, физическое удаление запрещено.
- Закрытые диалоги и их сообщения остаются в БД (история API); UI MVP показывает текущий чат.
- Quarantine и технические checkpoint — исключения с retention.
- S3-data attachments живут с записью вложения; lifecycle бакетов — ops-политика.
---
## 14. Смежные сервисы
| Компонент | Роль в чате |
|---|---|
| **Frontend** | UI чата, отложенное сообщение, upload, WS/polling, кнопки Чат/Оператор |
| **api-backend** | Dialogs/messages/attachments, safety orchestration, outbox, inbox apply, realtime |
| **message-safety** | Вердикт allow/deny/pending по исходящим |
| **bitrix-local-app** | Connector Open Lines, исходящие/входящие, `dialog_sessions` |
| **Bitrix24 Open Lines** | Рабочее место оператора |
| **S3** | Quarantine + attachments |
| **Redis** | Rate limit, idempotency cache, realtime coordination |
| **nginx** | `/api/*` + WS upgrade; `proxy_read_timeout` ≥ safety poll budget + запас |
| **bitrix-sync** | **Не** в hot path чата |
Порядок работ (если поднимать домен с нуля): (1) dialogs + messages text path + idempotency → (2) safety orchestration → (3) Open Lines out + inbox → (4) attachments → (5) WS + polling → (6) recovery/outbox hardening.
---
## 15. Влияние на arch-документы
| Документ | Содержание по чату |
|---|---|
| `arch-00-glossary.md` | `Dialog`, `Message`, статусы, Open Lines terms |
| `arch-01-system-architecture.md` | Потоки C→O и O→C, создание диалога |
| `arch-02-api-contracts.md` | REST/WS/safety/openlines контракты |
| `arch-03-docker-compose-blueprint.md` | Сервисы, WS location, timeouts |
| `arch-04-settings-and-content.md` | `chat.attachments.*`, rate limits, operator phone |
| `module-01-api-backend.md` | Алгоритмы, таблицы, workers |
| `module-05-message-safety.md` | Stub/production safety |
| `module-06-bitrix-local-app.md` | Connector / inbox / out |
Этот документ собирает бизнес-смысл обмена сообщениями для аналитики и смежных фич; детальные алгоритмы — в 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. Критерии приёмки
1. Без JWT отправить сообщение нельзя; после auth отложенный текст/популярный вопрос уходит штатным `POST /dialogs``POST .../messages`.
2. Не более одного active dialog; повторный `POST /dialogs` возвращает существующий.
3. Text и file взаимоисключающи; mixed/empty/too many → соответствующие `400`.
4. `POST .../messages` возвращает только финальный статус; deny → `422 message_blocked` без доставки в Bitrix.
5. Allow → сообщение видно оператору в Open Lines; `Dialog``waiting_for_company`.
6. Ответ оператора появляется в клиенте через WS или polling; `Dialog``waiting_for_client`; текст без служебной разметки имени из Bitrix.
7. `dialog.closed` закрывает диалог; из `closed` нельзя вернуться тем же id.
8. Файлы: quarantine → safety → promote; deny чистит quarantine; download только presigned + audit.
9. Идемпотентность create/send соблюдается 24 ч; повтор delivery/inbox не плодит дубли.
10. При падении WS > 30s клиент уходит в polling и не теряет сообщения, уже лежащие в App DB.
11. Недоступность safety блокирует send, но не чтение истории; недоступность S3 не ломает text-only чат.
12. Ошибка отправки после успешного bootstrap не выглядит как ошибка входа.
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.
---
## 17. Журнал решений и открытых вопросов (ненормативно)
### 17.1. Принятые решения
| # | Решение | Раздел / источник |
|---|---|---|
| D1 | Чат ≠ уведомления; непрочитанный чат не есть Notification | §1, notification-requirements |
| D2 | Один active dialog; `dialog_id` = `external_chat_id` | §2, arch-01 |
| D3 | MVP content: text XOR file | §5.3, arch-02 |
| D4 | Клиент ждёт финальный вердикт на одном HTTP; poll safety внутри api-backend | §6.2–§6.3 |
| D5 | Исходящие проходят Message Safety; входящие оператора — доверенный канал | §2, arch-02 |
| D6 | Durable outbox + idempotent delivery; inbox dedup | §6, module-01 |
| D7 | WS обязателен как primary realtime; polling — fallback | §6.7 |
| D8 | UI истории диалогов deprecated; API списка сохраняется | §3.2 |
| D9 | Отложенное сообщение — общий frontend-механизм | §6.4 |
| 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. Открытые вопросы
| # | Вопрос | Предложение | Влияние |
|---|---|---|---|
| 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 |
| Q4 | Создание нового dialog сразу после `closed` — всегда разрешено или по бизнес-правилу «сессия поддержки» | MVP: разрешить, пока соблюдён unique active | Продукт / поддержка |
| 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 |
---
## 18. Связь со смежными доменами
| Домен | Связь |
|---|---|
| **Пользователь** | Чат только после JWT + bootstrap; отложенное сообщение после входа |
| **Уведомления** | CTA `send_chat_message` пишет в чат; кнопки Чат/Оператор на главной; тип `message` в уведомлениях **запрещён** |
| **Документы профиля** | Другой бакет/реестр; не заменяют вложения чата |
| **CRM (`bitrix-sync`)** | Contact map по телефону параллельно; не доставляет сообщения Open Lines |
Владелец ленты и статусов доставки — `api-backend` + App DB; рабочее место оператора — Bitrix24 Open Lines через `bitrix-local-app`.
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
# Бизнес-постановка: Пользователь (User)
**Статус:** v1 — консолидация принятых решений из `HAN_chat_specification` (`arch-00``arch-05`, `module-01`, `module-08`); открытые вопросы зафиксированы в §17
**Продукт:** HAN Chat (клиентское приложение + `api-backend` + Keycloak)
**Источники:** архитектура `HAN_chat_specification`; макет Figma (**не канон** — только визуализация; при расхождении приоритет у этого ТЗ и arch-документов)
**Связанный backlog:** кнопка «Войти»; история устройств входа; дифференцированные ошибки OTP; debounce SMS; тестовый пользователь с фиксированным SMS-входом
**Смежно:** уведомления (контуры G/P), чат, согласия, UX-сессия, CRM Contact через `bitrix-sync`
**Нормативная часть — §1–§16.** §17 — ненормативный журнал решений и открытых вопросов; при расхождении с §1–§16 приоритет у §1–§16. При расхождении этого документа с arch/module после их обновления — приоритет у arch/module до синхронизации.
---
## 1. Цель
Дать клиенту устойчивую идентичность в HAN Chat: вход по подтверждённому телефону, локальную карточку пользователя в App DB, согласия, readonly-профиль в ЛК и связь с Contact в Bitrix24 — без смешения гостевого просмотра и персонального кабинета.
Гость изучает сервис без записи в App DB. Авторизованный клиент получает персональные данные, чат, персональные уведомления и профиль. Auth-идентичность принадлежит Keycloak; приложение владеет бизнес-карточкой пользователя, согласиями и кэшем профиля для UI.
---
## 2. Два режима клиента
| Режим | Кто это | Идентичность на бэкенде | Что доступно |
|---|---|---|---|
| **G. Гость** | Клиент без действующего JWT | Нет `UserIdentity`; опциональный локальный `guest_session_id` только на устройстве | UI + `GET /api/v1/public/*`; гостевые уведомления (контур G) |
| **A. Авторизованный** | Клиент с валидным access token и выполненным `bootstrap` | `user_identities` (`keycloak_sub` ↔ JWT `sub`) | JWT API: чат, профиль, согласия, персональные уведомления, UX-сессия |
Правила:
1. До успешного OTP + `bootstrap` клиент — только гость. Write-endpoint (`consents`, `session-start`, чат, профиль и т.д.) **требуют JWT**.
2. После авторизации гостевой контент **не** переносится в персональный (как в уведомлениях: G не мигрирует в P).
3. `guest_session_id` **не** является auth и **не** открывает write API.
4. Наличие refresh token в secure storage позволяет вернуться в режим **A** без OTP (§6.3); отсутствие/истечение refresh → снова гость до следующего защищённого действия.
5. Access token проверяет `api-backend`; refresh выполняет **только frontend** через Keycloak. Backend refresh **не** делает.
---
## 3. Границы релиза
### 3.1. В scope
- OTP-only вход по номеру телефона (Keycloak; mock до controlled SMS cutover).
- OIDC Authorization Code + PKCE; refresh / logout; silent return без OTP при валидном refresh.
- Локальный `find-or-create` `UserIdentity` + минимальный `ClientProfile` через `POST /api/v1/auth/bootstrap`.
- Согласия: обязательные `personal_data` + `user_agreement`, опциональный `marketing`; фиксация версий в `user_consents`.
- Readonly блочный профиль `GET /api/v1/me`; зарезервированный `GET /api/v1/me/documents` (MVP может быть пустым).
- Аналитическая `UxSession` для авторизованного клиента (`session-start`, заголовок `X-Ux-Session-Id`).
- Асинхронный map/create Contact в Bitrix24 по телефону через `sync_queue` (не блокирует вход).
- Нормализация телефона в E.164; phone claim только из JWT, не из body клиента.
- Ownership: все пользовательские ресурсы адресуются через `user_id` из JWT.
### 3.2. Вне scope
- Пароль, email-OTP, social login, magic link.
- Редактирование профиля клиентом (PATCH/PUT отсутствуют).
- Доставка документов компании в блок «Документы» (post-MVP; API зарезервирован).
- История устройств входа (backlog п.19) — модель и UI позже.
- Смена телефона как продуктовый self-service flow (policy Keycloak есть; продуктовый UX — отдельно).
- Merge/reassignment двух `sub` на один телефон — только administrative policy, не side effect login.
- Гостевая запись согласий и UX-сессии в App DB.
- Создание `UserIdentity` / `ClientProfile` сервисом `bitrix-sync` (sync не участвует в OTP-flow).
---
## 4. Изменения UI (относительно гостевого экрана)
| Место | Гость | Авторизованный |
|---|---|---|
| Главная | Публичный контент, гостевые уведомления | Персональные уведомления, чат доступен |
| Отправка сообщения / популярный вопрос | Сначала согласия → OTP → bootstrap → отправка отложенного текста | Штатная отправка |
| Центр уведомлений | Auth-gate «Авторизоваться» | Список персональных |
| Профиль | Недоступен / ведёт на вход | Readonly блоки «Личные данные», «Документы» |
| Кнопка «Войти» | Запускает поток авторизации | Скрыта / заменена профилем (по макету) |
| Выход | — | Очистка tokens → гостевой UI |
Визуал — по Figma. Figma не канон поведения и состава полей профиля: канон — §5.5 и arch-01.
---
## 5. Бизнес-модель
### 5.1. Слои идентичности (не смешивать)
| Слой | Где живёт | Что хранит | Master |
|---|---|---|---|
| **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 без 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`.
### 5.2. Связанные сущности (часть домена «пользователь», но не сам User)
| Сущность | Роль | Когда появляется |
|---|---|---|
| `UserConsent` | Факт принятия документа конкретной версии | `bootstrap` или `POST /consents` |
| `UxSession` | Аналитический период активности | `session-start` **только** у авторизованного |
| `Dialog` / `Message` | Чат с оператором | После auth, лениво при первом сообщении |
| Notification (P) | Персональные уведомления | `user_id` NOT NULL |
`UxSession` **не** является механизмом авторизации и **не** заменяет JWT.
### 5.3. Согласия
| Тип | Обязательность (seed) | Документ / URL | Версия |
|---|---|---|---|
| `personal_data` | да (`consent.personal_data.required=true`) | `consent.personal_data.document_url` (+ политика `consent.privacy_policy.document_url`) | `consent.personal_data.version` |
| `user_agreement` | да | `consent.user_agreement.document_url` | `consent.user_agreement.version` |
| `marketing` | нет | `consent.marketing.document_url` | `consent.marketing.version` |
Правила:
1. Pop-up согласий показывается **до** OTP; до получения JWT факт принятия хранится **только на клиенте**.
2. Серверная фиксация — в `bootstrap` (атомарно с созданием пользователя) или позже через `POST /api/v1/consents` при смене версий документов.
3. Запись `UserConsent` **immutable**: unique `(user_id, consent_type, document_version)`; исправление — новая версия документа или administrative action с audit.
4. Обязательные согласия без `accepted: true``403 consents_required`; вход в ЛК / write API с непринятыми актуальными обязательными версиями блокируется.
5. Keycloak consent screen **не** заменяет продуктовые согласия API.
### 5.4. Auth-телефон
- Единственный канал MVP: номер телефона + OTP.
- Нормализация: libphonenumber → canonical E.164.
- В App DB телефон пишется **только** из JWT claims при `bootstrap` / обновлении identity, **никогда** из body клиента.
- Порядок claim: `phone_number`, иначе `preferred_username` только если значение валидно как E.164.
- Отсутствие/невалидность при bootstrap → `400 phone_claim_missing`.
- Утечка существования номера запрещена на стороне Keycloak (одинаковый внешний ответ для нового/существующего).
- В `ClientProfile` при создании копируется в `russian_phone` (минимальный профиль); дальнейшее обогащение — из CRM sync.
### 5.5. Профиль (UI)
Блочная модель. Редактирование клиентом **недоступно**.
**Блок «Личные данные»:**
| Поле | Источник отображения | Примечание |
|---|---|---|
| ФИО (`full_name`) | `client_profiles` | Может быть `null` до sync из Bitrix24 |
| Гражданство (`citizenship`) | `client_profiles` | `null` до заполнения |
| Телефон РФ (`russian_phone`) | `client_profiles` | При bootstrap = auth-телефон |
| Зарубежный телефон (`foreign_phone`) | `client_profiles` | Опционально |
| Email (`email`) | `client_profiles` | Опционально |
**Блок «Документы»:**
- перечень документов компании, дата, наименование, скачивание;
- в MVP список может быть пустым; доставка из Bitrix24 — post-MVP;
- API: `GET /api/v1/me/documents`, `GET /api/v1/documents/{id}`, `.../download-url` с audit.
Макет Figma может показывать дополнительные секции (патент, РВП и т.п.) — это **не** канон MVP-модели данных; расширение блоков — отдельное решение.
### 5.6. Жизненный цикл пользователя
| Состояние | Условие | Что видит клиент |
|---|---|---|
| Гость | Нет валидного JWT | Публичный UI |
| OTP in progress | Идёт challenge в Keycloak | Экраны телефона / кода |
| Authenticated, bootstrap pending | Есть JWT, нет local `UserIdentity` | Frontend обязан вызвать `bootstrap`; прочие protected → `409` «bootstrap required» |
| Authenticated, ready | Есть `UserIdentity` + актуальные обязательные согласия | Полный ЛК |
| Soft-deleted | `record_status='D'` на identity (админ) | Доступ запрещён; детали — operational policy |
Бизнес-«удаление аккаунта» клиентом в MVP **не** моделируется. Soft-delete — административный контур (arch-05).
### 5.7. Константы (`app_settings`)
| Ключ | Смысл | Default / seed |
|---|---|---|
| `auth.phone.enabled` | Вход по телефону | `true` |
| `auth.password.enabled` | Пароль | `false` |
| `otp.phone.max_send_attempts_per_24h` | Лимит отправок OTP | `3` |
| `otp.phone.min_seconds_between_attempts` | Минимальный интервал между отправками | `30` |
| `otp.phone.max_verify_attempts` | Лимит проверок кода | `5` |
| `otp.phone.code_length` | Длина кода | `6` |
| `otp.phone.ttl_seconds` | TTL кода | `60` |
| `otp.phone.sms_order_timeout_ms` | Таймаут заказа SMS | `3000` |
| `consent.*` | URL/версии/required флагов согласий | см. arch-04 |
| `ux.session.idle_timeout_minutes` | Idle → новая UX-сессия | `30` |
Счётчики OTP ведёт **Keycloak/SPI**, не `api-backend`. Продуктовые `otp.phone.*` Keycloak читает через settings bridge `GET /internal/settings/v1/otp`.
---
## 6. Поведение UI и сценарии
### 6.1. Гостевой режим
1. Клиент открывает приложение → гостевой UI.
2. Доступен только `GET /api/v1/public/*` (+ статика).
3. Согласия и `session-start` в App DB **не** пишутся.
4. Попытка защищённого действия (сообщение, Центр уведомлений, профиль) → поток авторизации.
### 6.2. Поток первой авторизации (OTP)
1. Триггер: отправка сообщения / популярный вопрос / «Войти» / иное действие, требующее auth.
2. Pop-up согласий; обязательные должны быть приняты локально.
3. Форма телефона → Keycloak OTP-flow (mock или real SMS через `sms-service`).
4. Успешная проверка OTP → tokens (Authorization Code + PKCE).
5. `POST /api/v1/auth/bootstrap` с локальными согласиями и `device` metadata.
6. `POST /api/v1/analytics/session-start` при необходимости новой UX-сессии.
7. Триггер БД ставит `contact.map_or_create` в `sync_queue` (асинхронно; ошибка CRM **не** откатывает вход).
8. Frontend продолжает исходное действие (в т.ч. отложенное сообщение / популярный вопрос).
**UX-инвариант (backlog п.21):** ошибка отправки отложенного сообщения после успешного bootstrap **не** должна выглядеть как «не удалось завершить вход». Вход завершён на шаге 5–6; ошибка Bitrix/чата показывается в контексте чата.
### 6.3. Возврат без OTP
1. Есть валидный refresh token → Refresh Token Grant → access token.
2. При необходимости — `session-start`.
3. OTP не показывается.
4. Нет/истёк refresh → гость до следующего защищённого действия.
### 6.4. Поддержание сессии (tokens)
- Frontend проактивно обновляет access token (~60 с до `exp`), single-flight.
- Успешный refresh **не** создаёт новую UX-сессию.
- `401` от API → один refresh + retry исходного запроса; провал refresh → очистка tokens → гость.
- То же для WebSocket `/api/v1/realtime`.
### 6.5. UX-сессия
Новая `UxSession` только при:
| `start_reason` | Когда |
|---|---|
| `first_launch` | В памяти нет `ux_session_id` |
| `cold_start` | Kill app / закрытие вкладки |
| `idle_timeout` | Простой > `ux.session.idle_timeout_minutes` |
`ux_session_id` хранится **только в памяти** (не в localStorage). Передаётся как `X-Ux-Session-Id`. Отсутствие заголовка API не блокирует (кроме endpoint, где id обязателен).
### 6.6. Профиль
- Открывается только авторизованным.
- Данные — `GET /api/v1/me`; поля могут быть частично пустыми до CRM sync.
- Редактирование недоступно; изменение ФИО/email и т.п. — через процессы компании (Bitrix24 → sync).
- Документы — отдельный блок; скачивание с audit.
### 6.7. Выход
1. Frontend инициирует logout у Keycloak (revocation по policy модуля).
2. Очищает access/refresh tokens и in-memory UX-сессию.
3. UI переходит в гостевой режим.
4. Локальный `UserIdentity` в App DB **не** удаляется.
---
## 7. Матрицы поведения
### 7.1. Что требует auth
| Действие | Гость | Авторизованный |
|---|---|---|
| `GET /api/v1/public/*` | да | да |
| Просмотр главной / гостевых уведомлений | да | нет (после входа — только P) |
| Отправка сообщения / вложение | нет → OTP | да |
| `POST /auth/bootstrap` | нет (нужен JWT после OTP) | да (идемпотентно) |
| `POST /consents`, `session-start` | нет | да |
| `GET /me`, чат, персональные уведомления | нет | да |
| `WS /api/v1/realtime` | нет | да |
### 7.2. Источник истины полей
| Поле / факт | Master | Куда кэшируется |
|---|---|---|
| `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 | Последний успешный 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 + память клиента | заголовок запросов |
### 7.3. Bootstrap — идемпотентность
| Повторный вызов | Результат |
|---|---|
| Тот же `sub`, те же версии согласий | `200`, тот же `user_id`; `last_login_at` обновляется; дублей consent нет |
| Тот же `sub`, новые версии согласий | Новые immutable строки consent + update identity |
| JWT без phone claim | `400 phone_claim_missing` |
| Обязательные consents не accepted | `403 consents_required` |
Application-код **не** пишет в `sync_queue`: задачи создают триггеры на insert/update `UserIdentity` / `ClientProfile`.
---
## 8. Идентификация
| Идентификатор | Назначение |
|---|---|
| `keycloak_sub` | Subject JWT; ключ find-or-create |
| `user_id` | PK `user_identities`; FK всех персональных сущностей App DB |
| `phone_number` | Auth-телефон E.164 |
| `guest_session_id` | Локальный UUID устройства; не auth |
| `ux_session_id` | Аналитическая сессия |
| `b24_id` | Внутренний идентификатор Contact; используется только `bitrix-sync` |
| `device_id` | Opaque id устройства в bootstrap / session-start; в audit/log не копируется как PII |
Публичные id — **UUID** (в App DB предпочтительно UUID v7, как в остальных доменах).
---
## 9. API
Общие конвенции — arch-02.
### 9.1. Клиентские (JWT)
| Метод и путь | Назначение |
|---|---|
| `POST /api/v1/auth/bootstrap` | Find-or-create пользователя + согласия |
| `POST /api/v1/consents` | Повторная фиксация версий согласий |
| `POST /api/v1/analytics/session-start` | Новая `UxSession` |
| `GET /api/v1/me` | Readonly блочный профиль |
| `GET /api/v1/me/documents` | Список документов (MVP может быть пустым) |
| `GET /api/v1/documents/{id}` | Metadata документа (owner only) |
| `GET /api/v1/documents/{id}/download-url` | Presigned GET + audit |
Auth у Keycloak: публичные OIDC endpoints через `/auth/*` (не часть `api-backend`).
### 9.2. Public (без JWT)
| Метод и путь | Назначение |
|---|---|
| `GET /api/v1/public/settings` (и связанные public) | Флаги auth, URL/версии согласий, OTP UI-параметры по `is_public` |
### 9.3. Internal (смежные)
| Метод и путь | Кто → кто | Назначение |
|---|---|---|
| `GET /internal/settings/v1/otp` | Keycloak SPI → api-backend | Продуктовые OTP limits |
| `POST /internal/sms/v1/send` | Keycloak → sms-service | Заказ SMS OTP (real mode) |
### 9.4. Ошибки (домен пользователя)
| Код | HTTP | Когда |
|---|---|---|
| `phone_claim_missing` | 400 | Нет канонического телефона в JWT при bootstrap |
| `validation_error` | 400 | Невалидные версии/тело согласий или device |
| `unauthorized` | 401 | Нет/невалиден JWT |
| `consents_required` | 403 | Обязательные согласия не приняты |
| `resource_state_conflict` | 409 | Protected endpoint до bootstrap («bootstrap required»); **открытый вопрос TBD-1** по унификации с `404` для consents |
| `profile_not_found` | 404 | Профиль не найден (по контракту envelope) |
| `rate_limit_exceeded` | 429 | Превышен лимит |
Дифференцированные тексты ошибок OTP на UI (неверный код / истёк / лимит send / лимит verify) — backlog п.10; контракт Keycloak/frontend уточняется отдельно, в этом ТЗ фиксируется требование продукта.
### 9.5. Rate limiting
| Зона | Identity |
|---|---|
| Auth edge (`nginx`) | IP |
| bootstrap / consents / session-start | user + IP |
| OTP product limits | phone (Keycloak counters) |
---
## 10. Модель данных (схема `han_app`)
Общие правила — module-01 §9.1 / arch-05: UUID PK, `timestamptz` UTC, common fields, soft-delete `A`/`D`, FK `ON DELETE RESTRICT`.
### 10.1. `user_identities`
| Поле | Тип | Описание |
|---|---|---|
| `id` | uuid PK | `user_id` |
| `keycloak_sub` | varchar(255) NOT NULL UNIQUE | JWT `sub` |
| `phone_number` | varchar(32) NOT NULL | E.164 из JWT |
| `last_login_at` | timestamptz NOT NULL | Обновляется на bootstrap |
| common fields | обязательны | |
Индексы: unique `keycloak_sub`; index на `phone_number` для CRM map. **Телефон в App DB не unique:** identity master — Keycloak; временный конфликт при merge/миграции допустим на уровне данных, но продуктово один phone = один active `sub`.
### 10.2. `user_consents`
| Поле | Тип | Описание |
|---|---|---|
| `id` | uuid PK | |
| `user_id` | uuid FK | |
| `ux_session_id` | uuid NULL | Если сессия уже есть |
| `consent_type` | varchar | `personal_data` \| `user_agreement` \| `marketing` |
| `document_version` | varchar | Версия из `app_settings` |
| `accepted` | boolean | |
| `accepted_at` | timestamptz | |
| `client_ip` | inet | |
| `user_agent_hash` | varchar | |
| device snapshot | jsonb / поля | По module-01 (`device_json` и т.п.) |
| common fields | обязательны | |
Unique `(user_id, consent_type, document_version)`. Записи immutable.
### 10.3. `client_profiles`
| Поле | Тип | Описание |
|---|---|---|
| `id` | uuid PK | |
| `user_id` | uuid UNIQUE FK | 1:1 с identity |
| `full_name` | varchar NULL | |
| `citizenship` | varchar NULL | |
| `russian_phone` | varchar NULL | Seed из auth-телефона |
| `foreign_phone` | varchar NULL | |
| `email` | varchar NULL | |
| `source_updated_at` | timestamptz NULL | Метка источника sync |
| common fields | обязательны | |
CRM Contact ID и mapping в `client_profiles` отсутствуют. PII не попадает в логи и generic audit payload.
### 10.4. `ux_sessions`
`id` = `ux_session_id`; `user_id`; `start_reason`; `platform`; `app_version`; `device_id`; `started_at`; common fields.
### 10.5. Триггеры sync
| Событие | `task_type` |
|---|---|
| Insert active `UserIdentity` / `ClientProfile` без mapping | `contact.map_or_create` |
| Изменение tracked profile / auth-phone полей | `contact.update` |
Подавление эха: GUC `han.sync_suppress` при записи из `bitrix-sync`. Ошибка CRM не откатывает bootstrap и чат.
---
## 11. Фоновые и смежные процессы
| Процесс | Владелец | Связь с пользователем |
|---|---|---|
| OTP challenge / counters / expiry | Keycloak SPI | До появления App user |
| SMS order / delivery journal | `sms-service` | Только доставка кода |
| `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` |
---
## 12. Audit и observability
| `event_type` | Actor | Когда |
|---|---|---|
| `auth.bootstrap` | user | Успешный bootstrap |
| `consent.recorded` | user | Запись согласий (bootstrap или `/consents`) |
| `session_start` | user | Новая UX-сессия |
| `document.download_url_issued` | user | Скачивание документа профиля |
| OTP security events | Keycloak | Send/verify attempts (schema `keycloak`, phone HMAC/masked) |
В audit **нет:** полного phone/email/name в свободном тексте логов общего контура, OTP raw code, tokens, presigned URL.
Метрики (минимум): число bootstrap/сутки, доля `consents_required`, доля `phone_claim_missing`. Latency map Contact и доля пользователей без active mapping спустя N минут считаются `bitrix-sync` по собственной схеме.
---
## 13. Хранение данных и PII
- `UserIdentity`, `ClientProfile`, `UserConsent` — прикладные строки, soft-delete, физическое удаление запрещено (arch-05).
- Auth-мастер PII телефона — Keycloak; App DB держит кэш для FK/CRM/UI.
- Согласия хранятся бессрочно как юридически значимый журнал (immutable rows).
- Гостевые локальные согласия на устройстве до OTP **не** являются серверным журналом и при сбое до bootstrap могут быть потеряны — клиент проходит согласия снова.
- Right-to-erasure / удаление аккаунта клиентом — вне scope MVP; потребует отдельной политики по IdP + App DB + CRM.
---
## 14. Смежные сервисы
| Компонент | Ответственность в домене User |
|---|---|
| **Frontend** | Гость/ЛК, согласия UI, OTP UX, tokens, refresh, bootstrap/session-start, профиль readonly, отложенное сообщение после входа |
| **Keycloak** | IdP, OTP, phone uniqueness, tokens, sessions |
| **api-backend** | Bootstrap, consents, me/profile, JWT validation, ownership, settings bridge OTP |
| **sms-service** | Durable order SMS (real mode) |
| **bitrix-sync** | Map/update Contact; **не** создаёт UserIdentity |
| **nginx** | `/auth/*`, edge rate limit auth |
| **App DB** | Таблицы §10, триггеры sync |
Порядок работ (если дорабатывать домен): (1) Keycloak OTP + phone claims → (2) bootstrap + consents + identity/profile → (3) session-start → (4) me/profile UI → (5) CRM map → (6) documents post-MVP.
---
## 15. Влияние на arch-документы
| Документ | Статус относительно этой постановки |
|---|---|
| `arch-00-glossary.md` | Термины `UserIdentity`, `ClientProfile`, `UserConsent`, `UxSession`, `guest_session_id`, `keycloak_sub` уже заданы |
| `arch-01-system-architecture.md` | Потоки гостя, OTP, возврата, профиля — канон сценариев |
| `arch-02-api-contracts.md` | Контракты bootstrap / consents / session-start / me |
| `arch-04-settings-and-content.md` | `auth.*`, `otp.phone.*`, `consent.*`, `ux.session.*` |
| `module-01-api-backend.md` | Таблицы и алгоритмы bootstrap |
| `module-08-keycloak.md` | OTP-only phone flow |
| `module-07-bitrix-sync.md` | Обработка `contact.*` задач |
Этот документ **не заменяет** module-спеки; он собирает бизнес-смысл сущности «Пользователь» для аналитики и смежных фич (уведомления, чат, документы).
---
## 16. Критерии приёмки
1. Гость видит только public API; write без JWT недоступен; `guest_session_id` не открывает API.
2. Вход только по телефону + OTP; пароль/email/social отсутствуют.
3. После OTP `bootstrap` создаёт/находит `UserIdentity`, минимальный `ClientProfile`, пишет согласия; телефон берётся из JWT, не из body.
4. Повторный bootstrap идемпотентен; `last_login_at` обновляется.
5. Без обязательных согласий — `403 consents_required`; без phone claim — `400 phone_claim_missing`.
6. Protected endpoint до bootstrap — безопасный отказ (`409` до закрытия TBD-1).
7. Возврат с валидным refresh — без OTP; провал refresh — гостевой UI.
8. `session-start` только с JWT; в гостевом режиме не вызывается; idle/cold/first_launch создают новую UX-сессию.
9. `GET /me` отдаёт блочный readonly профиль; PATCH/PUT нет.
10. Ошибка CRM sync не ломает вход; Contact мапится асинхронно.
11. Один active phone ↔ один active `sub` на стороне Keycloak; merge не происходит молча при login.
12. Отложенное сообщение / популярный вопрос после auth уходит штатно; ошибка доставки не маскируется под ошибку входа.
13. Выход очищает tokens и возвращает в гостевой UI, не удаляя `UserIdentity`.
14. PII не светится в обычных логах/audit payload; OTP code не логируется.
---
## 17. Журнал решений и открытых вопросов (ненормативно)
### 17.1. Принятые решения
| # | Решение | Раздел / источник |
|---|---|---|
| D1 | Два режима: гость и авторизованный; гостевые данные в App DB не пишутся | §2, arch-01 |
| D2 | Слои IdP / UserIdentity / ClientProfile / Bitrix Contact разделены; master auth — Keycloak | §5.1 |
| D3 | OTP-only phone; password disabled | §3, module-08 |
| D4 | Согласия продуктовые в API; Keycloak их не заменяет; серверная запись только после JWT | §5.3 |
| D5 | Телефон только из JWT claims | §5.4, arch-02 |
| D6 | Профиль readonly и блочный; документы — отдельный блок, доставка post-MVP | §5.5 |
| D7 | Bootstrap атомарный + идемпотентный; sync через триггеры БД | §7.3 |
| D8 | UX-сессия ≠ auth; только для авторизованных; хранение id в памяти | §6.5, arch-00 |
| D9 | CRM не блокирует авторизацию | §6.2 |
| D10 | Один verified phone = один active `sub` | module-08 |
### 17.2. Открытые вопросы
| # | Вопрос | Предложение | Влияние |
|---|---|---|---|
| Q1 | Единый код для protected endpoint до bootstrap: `409` vs `404` (TBD-1 module-01) | Оставить `409 resource_state_conflict` | OpenAPI, клиентский UX |
| Q2 | Дифференцированные ошибки OTP на UI (backlog п.10) | Зафиксировать словарь кодов Keycloak → frontend texts | module-08 + frontend |
| Q3 | История устройств входа (backlog п.19) | Отдельная сущность/таблица, не смешивать с `UxSession` | Новая постановка |
| Q4 | Debounce/backoff SMS после интеграции провайдера (backlog п.11) | Надстройка над `otp.phone.min_seconds_between_attempts` | Keycloak SPI |
| Q5 | Тестовый пользователь с фиксированным SMS (backlog п.16) | Операционный allow-list / mock per-phone, не дырка в prod limits | ops + module-08 |
| Q6 | Продуктовый self-service смены телефона | Позже: re-auth + OTP нового номера + invalidate sessions | Keycloak + bootstrap |
| Q7 | Клиентское удаление аккаунта / right-to-erasure | Вне MVP; отдельная юридическая и техническая постановка | IdP + App + CRM |
| Q8 | Расширение блоков профиля сверх «Личные данные» / «Документы» (как в Figma-моках) | Только после продуктового решения; Figma не канон | UI + `client_profiles` / новые таблицы |
---
## 18. Связь с уведомлениями
| Аспект | Гость | Авторизованный пользователь |
|---|---|---|
| Контур уведомлений | G (`guest_notifications`) | P (`notifications.user_id`) |
| Бейдж непрочитанных | нет | да |
| Центр уведомлений | auth-gate | список |
| Перенос G → P при логине | **запрещён** | — |
Домен User задаёт, **кто** видит контур P; домен Notification задаёт **что** показывается. Владелец персональных записей — всегда `user_identities.id`.