Files
han-app/modules/module-01-api-backend.md
T

1413 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# module-01. Проектная спецификация `api-backend`
> Статус: целевая спецификация реализации MVP.
> Язык реализации: Python, FastAPI.
> Канонические источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md).
## 1. Назначение и приоритет
Документ определяет внутреннее устройство, модель данных, алгоритмы, эксплуатационные требования и Definition of Done сервиса `api-backend`. Он детализирует, но не изменяет зафиксированные архитектурные контракты.
При конфликте применяются приоритеты из `README.md`: канонические имена и семантика — `arch-00`, границы и сценарии — `arch-01`, HTTP-контракты — `arch-02`, инфраструктура — `arch-03`, настройки — `arch-04`, процесс — `arch-05`. Любое необходимое изменение внешнего контракта сначала вносится в `arch-02`, а не скрыто реализуется в модуле.
Все публичные пользовательские endpoint имеют префикс **`/api/v1`**. Все Open Lines internal endpoint, которыми владеет или которые вызывает `api-backend`, имеют префикс **`/internal/openlines/v1`**. Internal API не публикуется через `nginx`.
## 2. Ответственность и границы
### 2.1. Сервис отвечает за
- публичные настройки и контент frontend;
- проверку access token Keycloak по OIDC discovery/JWKS;
- локальный `find-or-create` пользователя после OTP, согласия и минимальный профиль;
- аналитические `UxSession`;
- чтение профиля и документов;
- создание и чтение диалогов и сообщений;
- авторизацию доступа к каждой пользовательской сущности через текущий `user_id`;
- upload lifecycle вложений: init, presigned PUT в S3-quarantine, complete, проверка metadata, promote/delete;
- orchestration Message Safety: check, sync-poll `task_id`, checkpoint и recovery;
- надежную и идемпотентную доставку разрешённых сообщений в `bitrix-local-app`;
- приём нормализованного inbox Open Lines и сохранение сообщений оператора;
- WebSocket realtime и REST polling fallback;
- API-level rate limiting;
- аудит, метрики, трассировку, health и readiness;
- чтение `app_settings`, `text_resources`, `popular_questions`;
- internal settings bridge для Keycloak SPI.
- Notification Center G/P: каталог, public/JWT/internal API, lifecycle, документы, realtime и фоновые задачи.
### 2.2. Сервис не отвечает за
- ввод, отправку и проверку OTP, refresh token grant и хранение IdP-сессий;
- Keycloak Admin API в hot path;
- выбор sync/async способа проверки Message Safety и сам анализ содержимого;
- OAuth Bitrix24, connector setup, `ONIMCONNECTOR*`, `dialog_sessions`;
- CRM Contact mapping и двустороннюю CRM-синхронизацию;
- ручное создание задач CRM sync: их создают только PostgreSQL-триггеры;
- хранение надежных бизнес-событий только в Redis;
- проксирование байтов upload/download через API;
- доставку документов компании из Bitrix24 в MVP;
- редактирование профиля через публичный API.
### 2.3. Зависимости
| Зависимость | Использование | Допустимая деградация |
|---|---|---|
| Managed PostgreSQL, схема `han_app` | источник прикладных данных и checkpoint | без БД сервис not-ready |
| Redis DB0 | rate limit, idempotency cache | write API fail-closed; read API ограниченно деградирует |
| Redis DB1 | realtime coordination | REST работает, WS/publish деградирует |
| Keycloak discovery/JWKS | JWT validation | cached JWKS разрешён до истечения cache; без валидного ключа protected API fail-closed |
| `message-safety` | проверка сообщений клиента | отправка сообщений недоступна, чтение работает |
| `bitrix-local-app` | Open Lines delivery/status | сообщение фиксируется как `failed`, recovery по outbox |
| Selectel S3 | вложения и документы | файловые операции недоступны, текстовый чат продолжает работать |
| OTEL Collector | telemetry export | не блокирует бизнес-запросы |
## 3. Технологический профиль
- Python 3.12+.
- FastAPI + Pydantic v2.
- ASGI server: Uvicorn; production — один контейнер с настраиваемым числом workers.
- SQLAlchemy 2 async + `asyncpg`.
- Alembic для миграций.
- `httpx.AsyncClient` для internal HTTP.
- S3-compatible async client или вызовы boto3 через ограниченный thread pool.
- Redis asyncio client.
- OpenTelemetry instrumentation для ASGI, HTTP client, SQLAlchemy и Redis.
- `structlog` либо стандартный logging с JSON formatter.
- Тесты: pytest, pytest-asyncio/anyio, HTTPX ASGI client, Testcontainers либо выделенная тестовая managed PostgreSQL-схема.
**Решение M1:** доменная логика не размещается в router-функциях и ORM-моделях. Router выполняет parsing/auth/dependency injection; use case задаёт транзакционную границу; repository работает с хранилищем; integration adapter инкапсулирует внешний протокол.
## 4. Структура FastAPI-компонентов
```text
api-backend/
app/
main.py
bootstrap.py
api/
dependencies.py
errors.py
middleware.py
routers/
public.py
auth.py
analytics.py
consents.py
profile.py
dialogs.py
attachments.py
realtime.py
internal_openlines.py
internal_settings.py
health.py
schemas/
common.py
auth.py
chat.py
profile.py
public.py
internal.py
realtime.py
application/
auth_bootstrap.py
consent_service.py
session_service.py
dialog_service.py
message_service.py
attachment_service.py
inbound_openlines.py
delivery_recovery.py
safety_recovery.py
settings_service.py
realtime_service.py
domain/
entities.py
enums.py
policies.py
errors.py
infrastructure/
db/
models.py
repositories/
unit_of_work.py
auth/
jwks.py
principal.py
clients/
message_safety.py
openlines.py
redis/
idempotency.py
rate_limit.py
realtime.py
s3/
client.py
keys.py
observability/
logging.py
metrics.py
tracing.py
workers/
delivery_outbox.py
safety_recovery.py
quarantine_cleanup.py
inbound_attachment.py
notification_expire.py
notification_draft_cleanup.py
settings.py
alembic/
tests/
unit/
integration/
contract/
e2e/
openapi.yaml
Dockerfile
docker-compose.yml
pyproject.toml
```
### 4.1. Middleware, порядок
1. trusted proxy middleware — принимает forwarded headers только от `nginx`;
2. request-id — валидирует/принимает `X-Request-ID` либо генерирует UUID/ULID;
3. W3C trace context;
4. structured access logging;
5. CORS из `security.cors.allowed_origins`;
6. error mapper в единый envelope;
7. metrics;
8. route dependencies: JWT, ownership, rate limit, idempotency.
Middleware не читает request body повторно и не логирует body, Authorization, query token WS или presigned URL.
## 5. API conventions
### 5.1. Заголовки
| Заголовок | Правило |
|---|---|
| `Authorization: Bearer <access_token>` | обязателен для protected REST |
| `X-Request-ID` | опционален от клиента; всегда присутствует в ответе |
| `X-Ux-Session-Id` | UUID, рекомендуется во всех JWT-запросах; не является auth |
| `Idempotency-Key` | обязателен для создания диалога и сообщения |
| `traceparent` | принимается и передаётся downstream |
| `Retry-After` | возвращается при retryable `429`, иногда `503` |
Все даты — RFC 3339 UTC. Публичные id — UUID. Неизвестные поля request DTO запрещаются (`extra="forbid"`). Размер строк, массивов и query limit ограничивается схемой.
### 5.2. Error envelope
```json
{
"error": {
"code": "not_found",
"message": "Resource was not found",
"request_id": "01J00000000000000000000000",
"details": {}
}
}
```
Обязательный каталог:
| HTTP | `code` |
|---|---|
| 400 | `validation_error`, `phone_claim_missing`, `mixed_content_not_allowed`, `empty_message`, `too_many_attachments`, `attachment_not_completed`, `attachment_checksum_mismatch` |
| 401 | `unauthorized`, `token_expired` |
| 403 | `consents_required`, `forbidden` |
| 404 | `not_found` |
| 409 | `idempotency_key_reused`, `resource_state_conflict` |
| 422 | `message_blocked` |
| 429 | `rate_limit_exceeded` |
| 503 | `dependency_unavailable` |
| 504 | `dependency_timeout` |
Pydantic `422` преобразуется в `400 validation_error`, чтобы соответствовать каноническому каталогу. `details` содержит только безопасные имена полей/ограничения. Для чужой сущности возвращается `404`, а не `403`.
### 5.3. Pagination
- dialogs: keyset cursor по `(updated_at DESC, id DESC)`;
- messages: keyset cursor по `(created_at ASC, id ASC)`;
- cursor — base64url от versioned JSON + HMAC, frontend его не разбирает;
- default `limit=50`, max `100`;
- `after` в polling означает строго позже позиции cursor.
## 6. Public и protected REST endpoints
### 6.1. Публичные read-only
#### `GET /api/v1/public/app-config`
Ответ — строгий DTO, не dump `app_settings`:
```json
{
"auth": {"phone_enabled": true, "password_enabled": false},
"operator": {"call_phone": "+74999591007"},
"messages": {"max_text_length": 4000},
"consents": {
"personal_data": {
"required": true,
"document_url": "https://www.han0107.ru/privacy/persdata-agree-mobile",
"privacy_policy_document_url": "https://www.han0107.ru/privacy",
"version": "2026-06-10"
},
"user_agreement": {"required": true, "document_url": "https://...", "version": "2026-06-10"},
"marketing": {"required": false, "document_url": "https://www.han0107.ru/privacy/ads-agree", "version": "2026-06-10"}
},
"attachments": {
"allowed_extensions": ["jpg", "jpeg", "png", "webp", "heic", "heif", "pdf"],
"allowed_mime_types": ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "application/pdf"],
"max_size_mb": 5
},
"ux": {"idle_timeout_minutes": 30}
}
```
`Cache-Control: public, max-age=<security.public_cache.max_age_seconds>`, ETag по версии cache. Rate limit per IP.
#### `GET /api/v1/public/content`
Возвращает только active `text_resources` и active `popular_questions`, отсортированные по `sort_order`.
```json
{
"locale": "ru",
"texts": {"home.welcome.title": "string"},
"popular_questions": [{"id": "uuid", "mnemonic": "string", "text": "string"}],
"version": "opaque"
}
```
**Допущение A1:** MVP принимает необязательный `locale`, но поддерживает только `ru`; иной locale нормализуется к `ru`. Это сохраняет будущую расширяемость без заявления о мультиязычности.
### 6.2. Auth bootstrap и согласия
#### `POST /api/v1/auth/bootstrap`
JWT обязателен. Request:
```json
{
"consents": {
"personal_data": {"accepted": true, "version": "2026-06-10"},
"user_agreement": {"accepted": true, "version": "2026-06-10"},
"marketing": {"accepted": false, "version": "2026-06-10"}
},
"device": {"platform": "ios", "app_version": "1.0.0", "device_id": "opaque"}
}
```
Response `200`: `{"user_id":"uuid","profile_ready":true}`.
Алгоритм в одной SERIALIZABLE-повторяемой либо READ COMMITTED + unique/upsert транзакции:
```text
principal = validate_jwt()
phone = canonical_phone_claim(principal)
if phone absent/invalid E.164: phone_claim_missing
validate consent versions against active app_settings
require required consents accepted
BEGIN
INSERT UserIdentity(keycloak_sub, phone_number, last_login_at)
ON CONFLICT(keycloak_sub) DO UPDATE phone_number, last_login_at
INSERT ClientProfile(user_id, russian_phone=phone) ON CONFLICT(user_id) DO NOTHING
INSERT immutable UserConsent rows for submitted versions
INSERT audit auth.bootstrap
COMMIT
return stable user_id
```
Повторный bootstrap безопасен: уникальный ключ согласия не создаёт дубль; `last_login_at` обновляется. Триггеры на `UserIdentity`/`ClientProfile` создают `contact.map_or_create`/`contact.update`/`contact.deactivate` в `sync_queue`; application code задач не вставляет. Полный trigger/dedup/lease contract задан в [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), §6.
#### `POST /api/v1/consents`
JWT, существующий пользователь. Сохраняет новые immutable записи согласий. Обязательные актуальные версии должны быть приняты. Response `201` с `recorded_at` и версиями.
### 6.3. UX session
#### `POST /api/v1/analytics/session-start`
Request:
```json
{
"start_reason": "first_launch",
"device": {"platform": "web", "app_version": "1.0.0", "device_id": "opaque"}
}
```
`start_reason`: `first_launch | cold_start | idle_timeout`. Response `201`:
```json
{"ux_session_id":"uuid","started_at":"2026-07-08T12:00:00Z"}
```
Создаёт `UxSession` и audit `session_start` атомарно. Не влияет на auth. Параметры устройства, включая исходный `device_id`, сохраняются в `UxSession` и в snapshot `UserConsent.device_json`; в audit/log значение не копируется.
### 6.4. Profile и documents
- `GET /api/v1/me` → блочный readonly профиль.
- `GET /api/v1/me/documents` → cursor list; в MVP обычно пустой.
- `GET /api/v1/documents/{document_id}` → metadata, только owner.
- `GET /api/v1/documents/{document_id}/download-url` → короткий presigned GET + audit.
`GET /api/v1/me`:
```json
{
"user_id": "uuid",
"profile": {
"personal_data": {
"full_name": null,
"citizenship": null,
"russian_phone": "+79991234567",
"foreign_phone": null,
"email": null
},
"documents": {"count": 0}
}
}
```
PATCH/PUT профиля в MVP отсутствует.
### 6.5. Dialogs
- `POST /api/v1/dialogs` — JWT + `Idempotency-Key`; `201` новый либо `200` существующий active.
- `GET /api/v1/dialogs` — история.
- `GET /api/v1/dialogs/{dialog_id}` — карточка.
- `GET /api/v1/dialogs/{dialog_id}/messages?after=&limit=` — история/polling.
`DialogSummary`:
```json
{
"dialog_id": "uuid",
"status": "waiting_for_company",
"last_message_preview": "string-or-null",
"unread_count": 0,
"created_at": "2026-07-09T12:00:00Z",
"updated_at": "2026-07-09T12:01:00Z"
}
```
Один active dialog на пользователя обеспечивается partial unique index. Создание:
```text
BEGIN
SELECT active dialog WHERE user_id=:current_user FOR UPDATE
if found: return 200
INSERT Dialog(id=uuid, user_id, status='open')
COMMIT
return 201
```
Конфликт concurrent INSERT перехватывается, после rollback читается победившая active запись и возвращается `200`.
### 6.6. Send message
#### `POST /api/v1/dialogs/{dialog_id}/messages`
JWT + ownership + required `Idempotency-Key`.
Text request: `{"content_kind":"text","text":"Здравствуйте"}`.
File request: `{"content_kind":"file","attachment_id":"uuid","checksum":"sha256:<hex>"}`.
Success `201` возвращает финальный `MessageResponse`:
```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"
}
```
На safety deny — `422 message_blocked`; исходный blocked text не сохраняется: применяется M8. В той же транзакции backend создаёт отдельную локальную `company`-реплику с текстом из `text_resources.mnemonic=safety.chat.blocked`; internal `rule_id` клиенту не передаётся. Реплика публикуется как `message.new`, но не отправляется в Open Lines. На dependency failure — `503/504`; если Message уже создан, его `delivery_status=failed`.
### 6.7. Attachments
- `POST /api/v1/dialogs/{dialog_id}/attachments/init`;
- `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete`;
- `GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url`.
Init request:
```json
{"file_name":"scan.pdf","mime_type":"application/pdf","size_bytes":12345}
```
Init response `201`:
```json
{
"attachment_id":"uuid",
"upload_url":"https://presigned...",
"upload_headers":{"Content-Type":"application/pdf"},
"expires_at":"2026-07-09T12:10:00Z"
}
```
Complete request: `{"checksum":"sha256:<64-lowercase-hex>"}`. Response `200` возвращает metadata и `scan_status=pending`.
Init и complete должны быть idempotent по состоянию attachment; повтор complete с тем же checksum возвращает прежний результат, с другим — `409 resource_state_conflict`.
### 6.8. Notification Center
Контракты путей и DTO — arch-02 и `notification-requirements.md`. Реализация читает `notification_types` и реестры CTA/кнопок/цветов; ветвление по `notification_type` запрещено. Home/center/counter применяют серверные лимиты 7/15, эффективный приоритет `COALESCE(priority_override, type.priority)` и ownership.
Действие скрытия всегда ставит `visibility='hidden'`. Если `date_expired` уже задано, оно сохраняется; TTL вида/default устанавливает `date_expired=now()+N days` только при `NULL`. Первое скачивание любого связанного документа при `hide_on_document_download=true` атомарно применяет этот эффект один раз.
`install_app_prompt` возвращает только внешнее действие открытия `instruction_url` в новой вкладке. Режимы iframe/modal и allow-list для них отсутствуют.
## 7. Internal endpoints
### 7.1. Inbox Open Lines
#### `POST /internal/openlines/v1/inbox`
Владелец — `api-backend`; caller — `bitrix-local-app`. Защита: private network + `Authorization: Bearer <BITRIX_API_INBOX_TOKEN>`, constant-time compare.
```json
{
"event_id":"stable-opaque",
"event_type":"message.new",
"external_chat_id":"uuid",
"bitrix_message_id":"string",
"occurred_at":"2026-07-09T12:00:00Z",
"message":{
"text":"Ответ",
"files":[{
"name":"scan.pdf",
"mime_type":"application/pdf",
"size_bytes":12345,
"download_url":"https://..."
}]
}
}
```
Поддерживаемые MVP события:
- `message.new`;
- `dialog.closed`.
Результаты:
- `201` — событие впервые применено;
- `200` или `204` — duplicate-ack;
- `400` — invalid normalized payload;
- `404` — неизвестный `external_chat_id`, retry допустим ограниченно;
- `503/504` — временная зависимость; local app повторяет.
Idempotency `message.new`: unique `(external_chat_id, bitrix_message_id)`. Для `dialog.closed`, где message id отсутствует, используется `event_id`; **допущение A2:** OpenAPI internal inbox должен сделать `event_id` обязательным для всех событий, сохранив `bitrix_message_id` обязательным для `message.new`. До уточнения upstream допустимый fallback fingerprint — SHA-256 от стабильных полей события.
### 7.2. Settings bridge
#### `GET /internal/settings/v1/otp`
Private network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`.
```json
{
"max_send_attempts_per_24h":3,
"min_seconds_between_attempts":30,
"version":"2026-07-10T08:00:00Z",
"cache_ttl_seconds":60
}
```
Не содержит PII или secrets. ETag/`If-None-Match` поддерживаются. Если обязательные настройки отсутствуют, endpoint возвращает `503`, а readiness — false.
### 7.3. Исходящие Open Lines calls
`api-backend` вызывает только:
- `POST /internal/openlines/v1/messages`;
- `GET /internal/openlines/v1/dialogs/{external_chat_id}`;
- опционально для readiness — `GET /internal/openlines/v1/status`.
Все вызовы: private network, `Authorization: Bearer <BITRIX_LOCAL_APP_INTERNAL_TOKEN>`, `X-Request-ID`, `traceparent`, idempotency по `message_id`.
### 7.4. Internal Notifications
- `POST /internal/notifications/v1/notifications` — Create;
- `POST /internal/notifications/v1/notifications/cancel` — Cancel.
Bearer token отдельный для каждого `source`; secret приходит из `NOTIFICATIONS_TOKEN_<SOURCE>`, в `notification_sources` хранится только hash, сравнение constant-time. `producer_test`/`NOTIFICATIONS_TOKEN_PRODUCER_TEST` служат smoke API. `(source, external_id)` уникальна бессрочно: одинаковый fingerprint → `200`, другой → `409 notification_conflict`.
## 8. JWT, JWKS и phone claims
### 8.1. Validation
Для каждого protected REST/WS:
1. извлечь Bearer token;
2. декодировать header, разрешить только настроенные asymmetric algorithms (`RS256` по умолчанию), запретить `none` и symmetric algorithms;
3. выбрать ключ по `kid` из JWKS cache;
4. при неизвестном `kid` выполнить один controlled refresh JWKS (single-flight);
5. проверить подпись, `iss == KEYCLOAK_PUBLIC_URL/realms/<KEYCLOAK_REALM>`, audience `KEYCLOAK_AUDIENCE`, `exp`, `nbf` с малым clock skew;
6. требовать непустой `sub`;
7. сформировать immutable `Principal`.
JWKS cache имеет positive TTL и short negative TTL для неизвестного `kid`; stale cached keys разрешены только в ограниченном grace window при временной недоступности Keycloak. Token и claims целиком не логируются.
### 8.2. Phone claim
Канонический claim задаётся realm mapping. Порядок MVP:
1. `phone_number`;
2. fallback `preferred_username` только если значение валидно как E.164.
Телефон нормализуется библиотекой libphonenumber и сохраняется в E.164. Body никогда не является источником auth-телефона. Отсутствие/невалидность при bootstrap → `400 phone_claim_missing`.
**Решение M2:** список phone claim names не вводится как новая бизнес-настройка. Это realm/infra contract; реализация фиксирует приоритет выше и покрывает его contract test. Если realm изменится, сначала обновляются архитектура/OpenAPI и realm config.
### 8.3. User resolution
После bootstrap `sub` разрешается в active `UserIdentity`. Protected endpoint, кроме bootstrap, при отсутствии локального пользователя возвращает `409 resource_state_conflict` с безопасным сообщением «bootstrap required». **TBD-1:** arch-02 допускает `404/409` только для consents; перед публикацией OpenAPI выбрать единый код. До решения используется `409`.
## 9. PostgreSQL: схема `han_app`
### 9.1. Общие правила
- UUID: PostgreSQL `uuid`, генерируется приложением UUIDv7 либо `gen_random_uuid()`.
- timestamps: `timestamptz`, UTC.
- все основные прикладные сущности: `id`, `record_status`, `status_changed_at`, `status_change_reason`, `created_at`, `updated_at`, `updater_user_id`;
- soft delete: `A`/`D`; физическое удаление прикладных строк запрещено;
- технические queue/checkpoint таблицы удаляются/архивируются по retention, что является разрешённым исключением;
- FK по умолчанию `ON DELETE RESTRICT`;
- строковые enum — PostgreSQL `varchar` + CHECK, чтобы миграция enum не блокировала rollout;
- все SQL параметризованы;
- DB role `han_app` имеет least privilege только на схему `han_app`;
- RLS в MVP не является единственным механизмом доступа; ownership всегда проверяет repository query.
### 9.2. `user_identities`
| Поле | Тип/ограничение |
|---|---|
| `id` | uuid PK |
| `keycloak_sub` | varchar(255) NOT NULL UNIQUE |
| `phone_number` | varchar(32) NOT NULL |
| `last_login_at` | timestamptz NOT NULL |
| common fields | обязательны |
Индексы: unique `keycloak_sub`; index на normalized `phone_number` для CRM trigger/map. Телефон не объявляется unique: миграция/merge IdP может временно дать конфликт, а identity master — Keycloak.
### 9.3. `ux_sessions`
`id` = `ux_session_id`; `user_id` FK; `start_reason` CHECK; `platform`, `app_version`, `device_id`; legacy `device_id_hash` nullable; `started_at`; common fields.
Индексы: `(user_id, started_at DESC)`, `(started_at)` для retention.
### 9.4. `user_consents`
`id`, `user_id`, `ux_session_id NULL`, `consent_type`, `document_version`, `accepted`, `accepted_at`, `client_ip` (inet), `user_agent_hash`, common fields.
CHECK consent type: `personal_data | user_agreement | marketing`. Unique `(user_id, consent_type, document_version)`. Запись immutable; исправление — новая версия или audit-backed administrative action. Обязательные согласия определяются текущим `app_settings`.
### 9.5. `client_profiles`
`id`, `user_id` UNIQUE FK, `full_name`, `citizenship`, `russian_phone`, `foreign_phone`, `email`, `source_updated_at`, common fields. CRM Contact ID в App DB не хранится; canonical mapping принадлежит schema `bitrix_sync`.
Индексы: unique active `user_id`; `updated_at`. PII поля не включаются в логи и generic audit payload.
### 9.6. `dialogs`
`id` = `dialog_id` = `external_chat_id`; `user_id`; `status`; `last_message_at`; `closed_at`; common fields.
CHECK status: `open | waiting_for_company | waiting_for_client | closed`.
Индексы:
```sql
CREATE UNIQUE INDEX uq_dialog_one_active_per_user
ON han_app.dialogs(user_id)
WHERE record_status='A' AND status IN ('open','waiting_for_company','waiting_for_client');
CREATE INDEX ix_dialogs_user_updated
ON han_app.dialogs(user_id, updated_at DESC, id DESC)
WHERE record_status='A';
```
Переходы:
- new → `open`;
- delivered client message → `waiting_for_company`;
- saved company message → `waiting_for_client`;
- `dialog.closed``closed`;
- из `closed` обратный переход запрещён.
### 9.7. `messages`
Поля: `id`, `dialog_id`, `sender_type`, `content_kind`, `text`, `safety_status`, `safety_processing_mode` (`standard | mock`), `safety_config_version` bigint, `delivery_status`, `related_message_id NULL` (self-FK), `external_message_id NULL`, `client_idempotency_key NULL`, `occurred_at`, common fields. Safety mode/config version — internal audit fields и не входят в public DTO.
CHECK:
- sender: `client | company`;
- content: `text | file`;
- safety: `pending | allowed | blocked` (`needs_review` зарезервирован, не создаётся);
- delivery: `accepted | processing | delivered | rejected | failed`;
- text message: `text <> ''`, кроме blocked client message после M8 redaction (`text=''` допустим только при `sender_type=client AND safety_status=blocked`);
- file message: `text = ''`;
- company message: `safety_status='allowed'`;
- synthetic safety company-replica: `related_message_id` указывает на blocked client message, `content_kind=text`, `delivery_status=delivered`, `external_message_id=NULL`;
- rejected → blocked; delivered → allowed.
Индексы: `(dialog_id, created_at, id) WHERE record_status='A'`; `(delivery_status, updated_at)` для recovery; unique `(dialog_id, external_message_id)` where external id not null; unique `(dialog_id, client_idempotency_key)` where not null; unique `(related_message_id) WHERE sender_type='company' AND related_message_id IS NOT NULL`.
**Решение M3:** исходящее сообщение создаётся до safety со статусами `pending/accepted`, чтобы `safety_tasks` всегда имел FK и crash checkpoint. При начале poll delivery может стать `processing`; клиенту этот промежуточный ответ не отдаётся.
### 9.8. `message_attachments`
Поля: `id`, `dialog_id`, `message_id NULL`, `owner_user_id`, `direction` (`client_upload | company_inbound`), `original_file_name`, `safe_file_name`, `mime_type`, `size_bytes`, `checksum_sha256`, `scan_status`, `storage_bucket`, `object_key`, `quarantine_object_key NULL`, `quarantine_version_id NULL`, `quarantine_etag NULL`, `upload_expires_at`, `completed_at`, common fields.
Ограничения:
- `size_bytes > 0`;
- SHA-256 — 64 lowercase hex;
- scan: `pending | clean | bypassed | infected | failed`; `bypassed` допустим только для file allow с `safety_processing_mode=mock`;
- до allow client file находится только в quarantine;
- attachment связывается максимум с одним message;
- для MVP у message максимум одно active attachment: unique partial `message_id`.
Индексы: `(owner_user_id, id)`, `(dialog_id, created_at)`, `(scan_status, updated_at)`, unique active object keys.
### 9.9. `documents`
Reserved MVP table: `id`, `user_id`, `name`, `mime_type`, `size_bytes`, `checksum_sha256`, `storage_bucket`, `object_key`, `sent_at`, common fields. Индекс `(user_id, sent_at DESC)`; unique `(storage_bucket, object_key)`. Заполнение внешней доставкой — post-MVP.
### 9.10. `safety_tasks`
Технический durable checkpoint:
- `id`, `task_id` UNIQUE;
- `message_id` UNIQUE;
- `attachment_id NULL`;
- `quarantine_object_key NULL`;
- `quarantine_version_id NULL`, `quarantine_etag NULL`;
- `task_location`;
- `processing_mode`, `config_version`, `rules_version NULL`, `last_poll_http_status NULL`;
- `status`: `polling | finalizing | completed | failed`;
- `deadline_at`, `next_poll_at`, `attempt_count`, `last_error_code`;
- `locked_at`, `locked_by`;
- timestamps.
Индексы: `(status, next_poll_at)`, `(deadline_at)`. Worker забирает `FOR UPDATE SKIP LOCKED`. Это не очередь анализа и не заменяет `message-safety`. Checkpoints `completed|failed` удаляются через 7 дней; active checkpoints — только после terminal reconciliation.
### 9.11. `delivery_outbox`
Durable намерение App → Open Lines:
- `id`, `message_id` UNIQUE;
- `external_chat_id`;
- `payload_json` с versioned internal DTO, без service token/presigned permanent secrets;
- `status`: `pending | processing | delivered | retry | dead_letter`;
- `attempt_count`, `next_attempt_at`, `last_error_code`;
- `locked_at`, `locked_by`, timestamps.
Индексы: `(status, next_attempt_at)`, `(locked_at)`. Message + outbox создаются/финализируются в одной транзакции после safety allow.
### 9.12. `openlines_inbox_receipts`
Durable idempotency приёма local app:
- `id`, `event_id`, `external_chat_id`, `bitrix_message_id NULL`;
- `event_type`, `payload_fingerprint`;
- `status`: `received | processing | applied | failed`;
- `message_id NULL`, `last_error_code`, timestamps.
Unique `event_id`; unique `(external_chat_id, bitrix_message_id)` where not null. Сам retry inbox принадлежит схеме `bitrix_local`; эта таблица — receipt/application checkpoint `api-backend`, а не дублирующая очередь local app.
### 9.13. `idempotency_records`
PostgreSQL durable fallback для завершённых mutating operations:
- `scope`, `user_id`, `idempotency_key`;
- `request_fingerprint`;
- `status`: `in_progress | completed | failed`;
- `response_status`, `response_body_json`;
- `resource_type`, `resource_id`;
- `expires_at`, timestamps;
- PK/unique `(scope, user_id, idempotency_key)`.
Redis остаётся быстрым слоем по arch-02. Durable row нужна, чтобы потеря Redis не повторила side effect. **Решение M4:** это уточнение надежности, не изменение внешнего контракта.
### 9.14. `audit_events`
Append-only: `id`, `event_type`, `actor_type`, `user_id NULL`, `ux_session_id NULL`, `request_id`, `trace_id`, `resource_type`, `resource_id`, `ip`, `user_agent_hash`, `outcome`, `metadata_json`, `created_at`.
Индексы: `(user_id, created_at DESC)`, `(resource_type, resource_id)`, `(event_type, created_at DESC)`, BRIN `created_at` при росте. Запрещены raw OTP, token, PII, file contents и presigned URL.
### 9.15. Настройки и контент
- `app_settings` — поля и правила из arch-04;
- `text_resources`: `id`, `mnemonic`, `locale`, `text_value`, `sort_order`, common fields; unique active `(mnemonic, locale)`;
- `popular_questions`: `id`, `mnemonic`, `locale`, `question_text`, `sort_order`, common fields; unique active `(mnemonic, locale)`;
- `sync_queue` — shared contract с `bitrix-sync`: migrations и trigger-функция принадлежат App DB/module-01, runtime claim выполняет `bitrix-sync` через минимальные GRANT.
- `bitrix_sync.entity_external_mapping` и rebind workflow принадлежат исключительно `bitrix-sync`; `api-backend` их не читает и не изменяет.
- существующие `han_app.entity_external_mapping`, `ClientProfile.bitrix_contact_id` и partial index удаляются expand/contract migration после переноса mapping и проверки отсутствия readers.
## 10. Alembic и транзакции
### 10.1. Migration policy
- schema-qualified DDL;
- отдельная migration DB role с DDL, runtime role без DDL;
- `alembic upgrade head` — отдельный deploy step до старта новой версии;
- миграции backward-compatible по expand/migrate/contract;
- destructive migration только после отдельного согласования и backup;
- seed `app_settings` idempotent и versioned;
- PostgreSQL triggers CRM sync создаются миграциями владельца App DB;
- migration smoke test с пустой БД и upgrade от предыдущей release;
- downgrade не обещается для data migration; вместо него forward-fix.
### 10.2. Транзакционные границы
Одна DB-транзакция не держится во время HTTP/S3 вызовов. Используется prepare → external call → finalize:
1. короткая транзакция создаёт message/checkpoint;
2. external safety poll выполняется без DB lock;
3. короткая транзакция блокирует message и применяет verdict идемпотентно;
4. S3 promote выполняется идемпотентно между checkpoint states;
5. message + delivery outbox фиксируются атомарно;
6. worker выполняет Open Lines call без открытой DB-транзакции;
7. результат фиксируется под row lock.
Isolation default READ COMMITTED. Для конкурентных state transitions — `SELECT ... FOR UPDATE`; для очередей — `SKIP LOCKED`; deadlock/serialization failure повторяется ограниченно с jitter.
## 11. Redis DB0/DB1
### 11.1. DB0: rate limits и idempotency
Ключи не содержат телефон, token или raw PII.
| Key | Value | TTL |
|---|---|---|
| `han:api:rl:user:{user_id}:{route}:{window}` | counter/token bucket | длина окна + jitter |
| `han:api:rl:ip:{ip_hash}:{route}:{window}` | counter | длина окна + jitter |
| `han:api:rl:dialog:{dialog_id}:message:{window}` | counter | длина окна + jitter |
| `han:api:rl:service:{service}:{route}:{window}` | counter | длина окна + jitter |
| `han:api:idem:{scope}:{user_id}:{key_hash}` | state, fingerprint, response | 24 часа |
| `han:api:idemlock:{scope}:{user_id}:{key_hash}` | owner token | 30 секунд, продлевается heartbeat |
| `han:api:jwks:negative:{kid_hash}` | marker | 30 секунд |
Rate limit выполняется atomic Lua script. При превышении возвращаются `429`, `Retry-After` и audit только для значимых/повторных abuse случаев.
### 11.2. DB1: realtime/coordination
| Key/channel | Назначение | TTL |
|---|---|---|
| `han:rt:user:{user_id}:connections` | set connection ids | heartbeat 90 сек |
| `han:rt:conn:{connection_id}` | user, subscriptions, server instance | 90 сек |
| `han:rt:dialog:{dialog_id}` | Pub/Sub channel | сообщения не сохраняются |
| `han:rt:user:{user_id}` | Pub/Sub channel | сообщения не сохраняются |
| `han:coord:lock:safety-recovery:{task_id}` | distributed lock | 30 сек |
| `han:coord:lock:delivery:{message_id}` | distributed lock | 30 сек |
| `han:settings:snapshot:{version}` | optional serialized public snapshot | 5 минут |
Redis Pub/Sub — ускоритель, не durable event bus. После reconnect клиент обязательно выполняет REST polling. Потеря DB1 не теряет сообщения.
## 12. Idempotency
Fingerprint = SHA-256 от canonical method + route template + normalized path params + canonical JSON body + authenticated user id. Authorization, request-id и UX-session не входят.
Алгоритм:
```text
validate key syntax/length
lookup Redis record
if completed and fingerprint equal: replay stored response
if fingerprint differs: 409 idempotency_key_reused
acquire short lock
lookup/insert durable idempotency_records
if durable completed: warm Redis and replay
if in_progress: wait briefly or return safe 409/503 Retry-After
execute use case
commit resource + durable response atomically where possible
store sanitized response in Redis for 24h
release lock
```
Не кэшируются transient `500/503/504` как окончательный результат, но уже созданный resource связывается с key, чтобы retry продолжил recovery вместо создания дубля. Ответ хранится без presigned URL; для init повтор генерирует новый URL к тому же attachment.
## 13. Message Safety: sync, poll, recovery
### 13.1. Основной алгоритм
```text
authorize dialog + current user
validate current consents and content union
enforce rate limits and idempotency
for file: lock attachment, require completed/pending and checksum equality
create Message(pending, accepted)
call POST /internal/safety/v2/messages/check
if 200 allow:
persist response.processing_mode, response.config_version
finalize_allow(processing_mode, config_version)
elif 403 deny:
persist response.processing_mode, response.config_version
finalize_deny(processing_mode, config_version)
elif 202 pending:
persist safety_tasks(task_id, Location, deadline, processing_mode=standard, config_version)
while monotonic_now < request_deadline:
sleep(backoff_with_jitter)
poll GET Location
if 200 allow: persist processing_mode/config_version; finalize_allow(...) and return
if 403 deny: persist processing_mode/config_version; finalize_deny(...) and return
if 503 and terminal=true and retryable=false:
mark failed and return 503
if other 4xx/5xx: return mapped dependency error
mark failed, retain checkpoint/quarantine
return 504
elif 409 and code=safety_request_conflict:
alert invariant violation, mark failed, return 500, do not repeat POST
else:
mark failed
return mapped dependency error
```
Polling interval начинается с server `Retry-After`, допускает capped exponential backoff и jitter, но не превышает caller env `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`. Каждый v2 verdict/pending содержит `processing_mode` и `config_version`; MOCK возвращает только sync `200/403`. Клиенту internal mode/config/`202` не возвращаются: public POST сохраняет синхронную семантику. Legacy stub `/v1` с `203`/`stub_final_error` поддерживается только временным adapter-ом до cutover и не является target production path.
Capability snapshot `/health/ready` допускается кэшировать не дольше 5 с для fast-fail: file требует `files`, text с URL — `links`, text без URL — `text`. При `processing_mode=mock` normal capabilities имеют состояние `bypassed` и не применяются как fast-fail gate. Snapshot не является correctness gate: definitive capability повторно проверяет `POST /check`. При unavailable в standard mode api-backend возвращает public `503 dependency_unavailable`, не создаёт delivery outbox и не меняет status на blocked.
### 13.2. Final allow
Для text: `safety_status=allowed`. Для file:
1. проверить checkpoint;
2. copy quarantine object в attachments bucket с conditional/idempotent key;
3. HeadObject destination, сверить checksum/size;
4. в транзакции изменить attachment на `clean` для standard mode или `bypassed` для MOCK, storage location на S3-data; message на `allowed/accepted` с фактическим `safety_processing_mode`; создать delivery outbox;
5. удалить quarantine object best-effort; при сбое cleanup повторит;
6. попытаться синхронно доставить outbox, чтобы исходный POST вернул финальный `delivered`.
### 13.3. Final deny
В одной транзакции:
1. client message → `blocked/rejected`, `text` заменяется пустой строкой/безопасным marker по M8;
2. attachment при наличии → `infected`;
3. создаётся ровно одна synthetic company-replica, связанная `related_message_id`, с `text` из active `text_resources(safety.chat.blocked, locale)`, `allowed/delivered`;
4. сохраняются только hash, internal rule/verdict/version в audit.
После commit quarantine удаляется best-effort. В Open Lines ничего не отправляется. Realtime публикует `message.status` исходного сообщения и `message.new` company-реплики. Public `422` содержит generic error envelope без `rule_id`.
### 13.4. Timeout/crash recovery
Фоновый worker:
```text
SELECT due safety_tasks FOR UPDATE SKIP LOCKED
claim with lease
poll safety by task_id
if pending before recovery deadline: schedule next_poll_at
if allow: idempotent promote + delivery checkpoint
if deny (canonical 403): idempotent delete + reject + company replica
if budget exhausted: mark task failed, message failed, preserve audit
```
HTTP disconnect не отменяет durable recovery. Клиентский retry с тем же idempotency key получает восстановленный результат либо текущую dependency error. Recovery не принимает решение о типе анализа.
**Решение M5:** `deadline_at = min(message_safety.expires_at, checkpoint.created_at + HAN_APP_SAFETY_RECOVERY_MAX_SEC)`, initial env = 1200 с. Client-facing wait остаётся 300 с; recovery продолжает без открытого клиентского соединения. Terminal Safety `503 retryable=false` немедленно завершает checkpoint как failed.
## 14. S3 attachment lifecycle
Object keys не содержат original filename или PII:
```text
quarantine/users/{user_uuid}/dialogs/{dialog_uuid}/{attachment_uuid}
attachments/dialogs/{dialog_uuid}/{attachment_uuid}
documents/users/{user_uuid}/{document_uuid}
```
Lifecycle:
1. `init`: allow-list extension + declared MIME + size; create metadata; presign exact key, MIME, max size, TTL;
2. direct PUT client → S3-quarantine;
3. `complete`: HeadObject конкретной version, size/MIME/server checksum; атомарно фиксирует `version_id + ETag + authoritative checksum`;
4. message send: attachment ownership/state/checksum;
5. allow: conditional copy сохранённой source version с ETag/checksum match + verify + DB finalize + quarantine delete;
6. deny: quarantine delete + infected metadata;
7. abandoned/failed: cleanup через 48 ч, только если нет active safety task;
8. download: owner check → audit commit → short presigned GET.
Extension и MIME оба должны быть разрешены; server normalizes filename and sets safe `Content-Disposition`. S3 credentials never reach frontend.
Presigned PUT обязательно подписывает `If-None-Match: *`, checksum header и `Content-Type`; versioning quarantine включён. Повторный PUT того же key получает `412`. При отсутствии подтверждённой поддержки этих условий выбранным S3 adapter production upload блокируется, а не деградирует до overwrite.
Inbound operator file:
- validate count/size/MIME and URL scheme/host policy;
- protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming;
- download with timeout to temporary stream, never local persistent disk;
- Message Safety/ClamAV не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP;
- upload directly to S3-data attachments;
- only then atomically save attachment/message and ack inbox.
## 15. Open Lines outbox/inbox и delivery
### 15.1. App → local app
Outbox worker и synchronous first attempt используют один dispatcher:
1. claim `delivery_outbox` lease;
2. build `/internal/openlines/v1/messages` DTO;
3. `Idempotency-Key = message_id`;
4. file message: generate short S3-data signed URL immediately before call; URL не сохранять в outbox/log;
5. call with circuit/timeout;
6. success/duplicate ack → outbox delivered, Message delivered, Dialog `waiting_for_company`;
7. transient failure → Message failed, outbox retry with exponential backoff;
8. permanent invalid payload → dead_letter + audit/alert.
Повторная доставка безопасна только при подтверждённой idempotency local app по `message_id`. До contract test этого свойства автоматический retry после ambiguous timeout ограничивается; выполняется reconciliation через `GET /internal/openlines/v1/dialogs/{external_chat_id}` и статус local app.
### 15.2. Local app → App
`bitrix-local-app` владеет durable inbox/retry/DLQ в `bitrix_local`. `api-backend`:
1. аутентифицирует service token;
2. резервирует receipt по event id/fingerprint;
3. duplicate applied → 200/204;
4. проверяет dialog;
5. для message.new сохраняет text/files, Message `company/allowed/delivered`, Dialog `waiting_for_client`;
6. для dialog.closed переводит Dialog в `closed`;
7. commit receipt applied;
8. после commit публикует realtime;
9. local app только после ack вызывает `imconnector.send.status.delivery`.
Публикация realtime после commit; сбой publish не откатывает сообщение, polling восстановит состояние.
## 16. CRM sync — только триггеры
`api-backend` не вызывает `bitrix-sync` по HTTP в пользовательском flow и не пишет `sync_queue` вручную.
Миграции создают triggers:
- insert active `UserIdentity`/`ClientProfile` → coalesced `contact.map_or_create`; trigger не читает schema `bitrix_sync`, наличие mapping проверяет worker;
- фактическое изменение App-master `UserIdentity.phone_number``contact.update`;
- переход `UserIdentity` или `ClientProfile` из active в inactive/deleted → `contact.deactivate`;
- возврат active записи → coalesced `contact.map_or_create`;
- изменения CRM-master `full_name`, `citizenship`, `email` не создают App→CRM задачу;
- trigger проверяет значения через `IS DISTINCT FROM`, а не только факт присутствия колонки в `UPDATE OF`;
- trigger строит deterministic dedup key, уникальный только среди активных queue rows; завершённая/cancelled/dead-letter запись не блокирует новое событие;
- при `current_setting('han.sync_suppress', true)='true'` задача не создаётся;
- trigger и business update находятся в одной транзакции.
`entity_id` всех contact-задач — `UserIdentity.id`; payload содержит только `schema_version`, `user_id`, безопасную причину и source timestamp, но не PII snapshot. Worker перечитывает актуальные identity/profile.
`bitrix-sync` получает ограниченные column/table GRANT, заданные module-07. Ошибка CRM не откатывает bootstrap и chat. Open Lines не зависит от CRM mapping.
## 17. Realtime
### 17.1. WebSocket contract
`WS /api/v1/realtime`, только WSS через nginx. **Решение M6:** JWT передаётся через WebSocket subprotocol, а query `access_token` поддерживается временно для совместимости, но удаляется из access logs. Предпочтительный формат: `Sec-WebSocket-Protocol: han.jwt.<base64url-token>`.
После auth:
```json
{"type":"connected","server_time":"2026-07-09T12:00:00Z"}
{"type":"subscribe","dialog_ids":["uuid"],"notifications":true}
{"type":"subscribed","dialog_ids":["uuid"],"notifications":true}
```
Events:
- `message.new` с `MessageResponse`;
- `message.status`;
- `dialog.status`;
- `notification.created`, `notification.updated`, `notification.closed` через `han:rt:user:{user_id}`, включая эхо инициатору;
- `ping`; client отвечает `pong`.
`notifications` опционально и по умолчанию `false`. Каждый subscribe проверяет ownership всех dialogs; чужие id не раскрываются. Лимиты: max connections/user, max subscriptions/connection, max frame bytes, subscribe rate. Slow consumer: bounded queue; при переполнении connection закрывается с retryable code, клиент восстанавливается polling.
### 17.2. Delivery semantics
WS — at-most-once best effort. DB — source of truth. Событие содержит `event_id` и `occurred_at` как расширение DTO реализации; **TBD-3:** добавить эти поля в OpenAPI без изменения event types. Client после reconnect:
1. refresh token при auth error;
2. reconnect с backoff 1,2,4…30s;
3. resubscribe;
4. запросить messages после последнего REST cursor;
5. если WS недоступен >30s — polling.
На одной replica возможен in-memory hub; DB1 Pub/Sub обязателен при более одной replica. G12 остаётся post-MVP для полной backpressure/sticky-session стратегии.
## 18. Settings
### 18.1. Startup
Pydantic Settings читает только infra env. `SettingsService` загружает обязательные `app_settings`, type-checks и строит immutable snapshot. Отсутствующий/невалидный обязательный ключ делает readiness false.
Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый snapshot заменяется атомарно. Ошибка refresh сохраняет last-known-good и поднимает metric. Public config ETag меняется с snapshot version.
### 18.2. Business settings
Используются все ключи seed arch-04: auth, OTP bridge, operator, consent, attachments, rate limits, UX, CORS/cache. Hardcode запрещён, кроме технических безопасных defaults, явно отмеченных TBD.
### 18.3. Infra env `api-backend`
Обязательные:
- `APP_ENV`, `API_PORT`, `LOG_LEVEL`;
- `DATABASE_URL`;
- `REDIS_URL`, `REDIS_REALTIME_URL`;
- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_AUDIENCE`;
- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`;
- `MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`, `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`;
- `MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`;
- `BITRIX_LOCAL_APP_BASE_URL`, `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, `BITRIX_API_INBOX_TOKEN`;
- `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`, `BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD`, `BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC`;
- `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`;
- `SELECTEL_S3_ENDPOINT_URL`, три bucket names, write access key/secret;
- `OTEL_EXPORTER_OTLP_ENDPOINT`.
- `NOTIFICATIONS_TOKEN_PRODUCER_TEST` и последующие `NOTIFICATIONS_TOKEN_<SOURCE>`; plaintext не сохраняется в БД/логах.
`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04.
## 19. Rate limiting
Два слоя обязательны: nginx edge и API Redis.
| Endpoint/group | Identity |
|---|---|
| public config/content | IP hash |
| bootstrap/consents/session | user + IP |
| create dialog/message | user + dialog + IP |
| attachment init/complete | user + dialog |
| download URL | user + resource group |
| notifications read/action/upload | user + IP / user / user |
| notifications public | IP hash |
| WS connect/subscribe | user + IP |
| internal inbox/settings | service identity + source network |
Значения user-facing лимитов — `app_settings`. Алгоритм — sliding window/token bucket через Lua. При Redis unavailable:
- message send, attachment init, download URL — fail-closed `503`;
- public GET — локальный conservative limiter с bounded memory;
- profile/history GET — допускается fail-open с метрикой и edge protection;
- internal inbox не отклоняется только из-за Redis: PostgreSQL idempotency остаётся.
OTP counters API не ведёт.
## 20. Resilience и circuit breakers
Для `message-safety` и `bitrix-local-app` отдельные breakers: closed/open/half-open, настройки arch-04. Считаются timeout, connect failure и 5xx; 4xx domain result не считается infrastructure failure.
Timeout budget:
- safety POST: `MESSAGE_SAFETY_POST_TIMEOUT_SEC`;
- safety poll GET: 2s на попытку, не больше общего poll budget;
- Open Lines: `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`;
- S3 operation: bounded connect/read timeout;
- inbound URL download: отдельный bounded timeout и max bytes.
Retry:
- GET/idempotent internal call — exponential backoff + full jitter;
- POST Open Lines — только с idempotency key;
- S3 copy/delete — по deterministic object key;
- не повторять safety check POST после ambiguous response без stable request/message id; сначала recovery/reconciliation.
Graceful shutdown прекращает принимать новые requests, закрывает WS, перестаёт claim worker rows, завершает текущие операции в grace period и освобождает leases.
## 21. Observability и audit
### 21.1. JSON logs
Обязательные поля: `timestamp`, `level`, `service.name=api-backend`, `module`, `event`, `request_id`, `trace_id`, `span_id`, `ux_session_id` (если передан), `route`, `method`, `status_code`, `duration_ms`, `dependency`, `error_code`.
Не логировать:
- Authorization/cookies/tokens/raw JWT;
- raw OTP;
- полный phone/email/name;
- request/response body чата;
- filenames при наличии PII;
- S3 credentials, object signed query, presigned URLs;
- Bitrix download URL;
- stack trace в публичном ответе.
### 21.2. Metrics
- request count/latency/error by route template;
- JWT/JWKS cache hit/refresh/failure;
- DB pool saturation/transaction latency;
- rate limit rejects;
- idempotency replay/conflict/in-progress;
- safety verdict/poll duration/timeout/recovery backlog;
- delivery outbox depth/age/retries/dead letter;
- inbox duplicate/apply/failure;
- S3 init/complete/promote/delete/cleanup failure;
- WS active/reconnect/publish/drop/slow consumer;
- settings snapshot age/refresh failure;
- circuit state/transitions;
- readiness dependency state.
Нельзя использовать user_id/dialog_id как metric labels.
### 21.3. Audit events
Минимум:
- `auth.bootstrap`;
- `consent.recorded`;
- `session_start`;
- `dialog.created`;
- `message.submitted`, `message.blocked`, `message.delivered`, `message.failed`;
- `attachment.upload_initialized/completed/promoted/rejected`;
- `attachment.download_url_issued`;
- `document.download_url_issued`;
- `notification.created/read/hidden/cta_invoked/button_pressed/closed`;
- `notification.document.download_url_issued`, `notification.documents.submitted`, `notification.expired_batch`;
- `openlines.inbox_applied`;
- повторные severe rate limit violations.
Audit записывается в той же транзакции с критическим изменением либо через durable outbox. Download URL выдаётся только после успешной audit записи.
## 22. Health
### `GET /health/live`
Только состояние event loop/process; не обращается к зависимостям. `200 {"status":"live"}`.
### `GET /health/ready`
Проверяет с малым timeout:
- PostgreSQL schema/revision и `SELECT 1`;
- Redis DB0 и DB1;
- наличие валидного JWKS cache/discovery;
- обязательный settings snapshot;
- S3 permissions для presign/Head/copy/delete через безопасную capability check без создания orphan;
- Message Safety readiness;
- worker heartbeat/backlog thresholds.
Open Lines недоступность отображается как component `degraded`, но не обязательно делает весь API not-ready, чтобы чтение продолжало работать. **Решение M7:** readiness HTTP `200` при доступных DB/auth/settings и `status=degraded` для Bitrix/S3 partial failure; `503` — когда сервис не способен безопасно обслуживать большинство protected API. Compose healthcheck оценивает HTTP code, мониторинг — component details.
Ответ не содержит secrets/internal credentials:
```json
{"status":"ready","components":{"postgres":"ok","redis":"ok","jwks":"ok","safety":"ok","openlines":"degraded","s3":"ok"}}
```
## 23. Security
- TLS только через nginx, internal HTTP только Docker backend network.
- Доверять proxy headers только от известных proxy CIDR.
- JWT validation fail-closed; алгоритм pinning; JWKS SSRF невозможен — URL строится из configured issuer/discovery.
- Service tokens сравниваются constant-time; rotation поддерживает current + previous token в короткое окно только после документирования env.
- Ownership predicate включён непосредственно в SQL (`id=:id AND user_id=:current_user AND record_status='A'`).
- CORS exact allow-list; wildcard с credentials запрещён.
- CSRF не требуется для Bearer API без cookie auth; если web перейдёт на cookies — обязателен CSRF token.
- Pydantic strict validation, max lengths, Unicode normalization для текста.
- SQL injection предотвращается ORM/parameterized SQL; dynamic sort только allow-list.
- SSRF защита inbound files; URL никогда не вызывается без проверки.
- S3 presign least privilege, short TTL, exact object key/content constraints.
- Content-Disposition безопасный; MIME sniffing protection.
- Secrets только env, не в repository/App DB/log.
- Dependency versions pin/lock, image scanning, non-root container, read-only filesystem, tmpfs `/tmp`.
- `docs`/OpenAPI UI в production либо отключены, либо доступны только ops; статический `openapi.yaml` остаётся артефактом.
- Error messages не раскрывают existence чужих ресурсов, SQL, hostnames и stack traces.
- Data retention и право удаления требуют отдельной legal policy; soft delete не заменяет обязательное уничтожение PII.
**Решение M8:** blocked message text не нужен продукту после deny. В `messages.text` хранится пустая строка/безопасный redacted marker, а audit хранит только rule/verdict id и hash содержимого. Если регуляторно требуется исходный текст, это отдельное согласованное изменение retention/security.
Оценка monitor-only semantic rules выполняется контролируемой, аудируемой выборкой из App DB: доступ только у утверждённой роли, выборка ограничена по времени/объёму, purpose фиксируется в audit. Текст не копируется в schema/логи Message Safety; там остаются hash, `rule_id` и version.
## 24. Docker/runtime
Service compose:
- `build: ./api-backend`, `expose: 8000`, без `ports`;
- networks: `backend`, `observability`;
- env только через `${VAR}` из root `.env`;
- healthcheck `/health/live` для процесса; root orchestration учитывает readiness;
- depends_on health только для локальных Redis/Keycloak; remote Message Safety не является Compose dependency и проверяется capability-aware на send path;
- managed PostgreSQL вне compose, TLS обязателен;
- stateless container, без persistent volume;
- init process для signal forwarding;
- non-root UID, dropped Linux capabilities, `no-new-privileges`;
- resource limits и ulimits задаются ops-профилем.
Startup:
1. parse/validate infra env;
2. configure structured logging/OTEL;
3. connect DB and verify Alembic revision;
4. connect Redis DB0/DB1;
5. load settings/content snapshot;
6. warm discovery/JWKS;
7. validate S3 bucket access;
8. start workers;
9. mark ready.
Notification expire запускается ежедневно в `notification.expire_job.run_at` с PostgreSQL advisory lock и set-based update; массовые WS-события не публикует. Cleanup удаляет просроченные drafts и соответствующие S3-объекты идемпотентно. Отдельные Compose-процессы используют зарегистрированные scripts `han-notification-expire-worker` и `han-notification-draft-cleanup-worker`; общий `han-cleanup-worker` сохраняет прежнюю очистку quarantine.
Nginx маршрутизирует `/api/*`, включая WS `/api/v1/realtime`. Для message POST `proxy_read_timeout >= MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s`. Internal paths наружу не маршрутизируются.
## 25. Ключевые user flows
### 25.1. Первый вопрос неавторизованного пользователя
1. public config/content;
2. пользователь выбирает вопрос/вводит текст;
3. frontend показывает согласия;
4. Keycloak OTP + PKCE;
5. bootstrap с consent body, identity из JWT;
6. session-start;
7. create dialog с idempotency;
8. send message с idempotency;
9. safety → Open Lines;
10. финальный MessageResponse.
Популярный вопрос не имеет отдельного backend flow: его text отправляется как обычное сообщение.
### 25.2. Возврат пользователя
Frontend выполняет refresh token grant. API не обновляет token. При новой UX-сессии — session-start; затем profile/history/chat. Успешный token refresh сам по себе новую UX session не создаёт.
### 25.3. Файловое сообщение
create/reuse dialog → init → direct immutable PUT quarantine → complete with version/ETag/checksum → send file message → safety `202` poll → allow conditional promote → delivery outbox → Open Lines → delivered. При deny quarantine удаляется, Bitrix не вызывается.
### 25.4. Ответ оператора
Bitrix event → local app durable inbox → `POST /internal/openlines/v1/inbox` → API receipt + DB message → commit → realtime → local app delivery ack. При WS failure frontend polling получает сообщение.
## 26. Тестовая стратегия
### 26.1. Unit
- JWT claims/phone normalization;
- Pydantic discriminated union text/file;
- status transition policies;
- canonical fingerprint/idempotency conflict;
- cursor encode/decode/tamper;
- rate-limit keying;
- circuit state;
- S3 object key generation;
- settings parsing/fail-fast;
- safe error/log redaction.
### 26.2. Integration
- Alembic empty upgrade and previous-version upgrade;
- unique active dialog under concurrency;
- bootstrap upsert + immutable consents + trigger-generated sync task;
- `han.sync_suppress` prevents echo;
- ownership queries and soft delete;
- outbox atomicity with message;
- inbox duplicate race;
- safety task `SKIP LOCKED` leases;
- Redis Lua limits and 24h idempotency;
- S3 init/complete/promote/delete with S3-compatible test endpoint;
- JWKS rotation/unknown kid/outage cache.
### 26.3. Contract
- generated FastAPI OpenAPI matches committed `api-backend/openapi.yaml`;
- Message Safety v2 POST `200/202/403`, task poll `202/200/403`, terminal failed `503` и conflict `409`; legacy stub adapter тестируется отдельно до cutover;
- Open Lines message idempotency and inbox schemas;
- settings bridge DTO/token;
- common request-id/trace propagation;
- error envelope for every 4xx/5xx.
### 26.4. E2E/failure
- first login → popular question delivered;
- silent refresh flow assumptions from frontend contract;
- text and file happy paths;
- file deny and cleanup;
- safety pending then allow/deny;
- API crash during poll and recovery;
- client disconnect during poll;
- Redis loss without duplicate message;
- local app timeout before/after accepting message;
- inbound event duplicate and file SSRF rejection;
- WS disconnect/reconnect/poll gap recovery;
- concurrent sends/idempotency;
- foreign user ids always 404;
- rate limits and Retry-After;
- dependency circuit open/half-open;
- no PII/secrets/presigned URLs in logs.
### 26.5. Performance targets
**TBD-4:** финальные SLO/RPS определяются load profile до production. Минимальные acceptance checks:
- public/profile/history p95 без внешних dependencies измеряется отдельно;
- text send p95 включает safety + Open Lines;
- file send допускает до poll max budget;
- 1 slow file poll не блокирует другие requests;
- DB pool и worker concurrency не исчерпываются при long polls;
- WS slow consumer не увеличивает память без границ.
## 27. Definition of Done
Модуль готов, когда:
- реализованы все перечисленные `/api/v1` и owned internal endpoints;
- committed `api-backend/openapi.yaml` соответствует runtime OpenAPI 3.1;
- пути Open Lines в коде/тестах используют только `/internal/openlines/v1`;
- schema `han_app`, constraints, indexes, triggers и seed созданы Alembic;
- migration upgrade проверен на пустой и предыдущей схеме;
- JWT/JWKS, phone claim, ownership и consent checks покрыты;
- idempotency переживает потерю Redis без повторного side effect;
- Message Safety sync/poll/recovery и S3 lifecycle покрыты failure tests;
- Open Lines outbox/inbox duplicate delivery покрыты contract/E2E tests;
- CRM задачи создают только DB triggers;
- realtime и polling не имеют gap при reconnect;
- business settings не hardcoded и не продублированы в env;
- rate limits работают на nginx и API уровнях;
- circuit breakers, timeout, graceful shutdown реализованы;
- JSON logs/metrics/traces/audit соответствуют требованиям и не содержат PII/secrets;
- `/health/live` и `/health/ready` проверены;
- контейнер non-root запускается в едином root Docker Compose без published port;
- `ruff check`, format check, mypy/pyright, unit/integration/contract/E2E tests успешны;
- dependency/security scan не имеет unresolved critical/high;
- подготовлены runbooks: migration, outbox/safety backlog, DLQ, quarantine cleanup, JWKS outage, token rotation;
- все допущения/TBD ниже закрыты либо явно приняты владельцем продукта/архитектуры.
## 28. Явные решения, допущения и TBD
### Зафиксированные решения модуля
- **M1:** layered FastAPI, транзакции в use cases, внешние адаптеры отдельно.
- **M2:** phone claim `phone_number`, fallback `preferred_username` только E.164.
- **M3:** Message создаётся до safety для durable crash checkpoint.
- **M4:** Redis idempotency дополнен durable PostgreSQL record.
- **M5:** recovery продолжается после client timeout; точный extended budget — TBD.
- **M6:** WS subprotocol предпочтительнее query token.
- **M7:** readiness различает critical not-ready и partial degraded.
- **M8:** denied text редактируется/не хранится в открытом виде.
### Допущения
- **A1:** locale API зарезервирован, MVP фактически `ru`.
- **A2:** inbox получит стабильный `event_id`; временно возможен deterministic fingerprint.
- **A3:** production S3 adapter подтверждает signed checksum headers, versioning и conditional requests; иначе immutable upload не включается.
### Требуют согласования
- **TBD-1:** единый код для protected endpoint до bootstrap (`409` предложен).
- **Решение M5:** extended recovery ограничен `HAN_APP_SAFETY_RECOVERY_MAX_SEC=1200` и Safety `expires_at`.
- **TBD-3:** добавить `event_id`/`occurred_at` в WS events и `event_id` в inbox OpenAPI.
- **TBD-4:** production SLO, RPS, concurrency, RPO/RTO и retention.
- **Решение M9:** файлы оператора не проходят Message Safety/AV в MVP; только MIME/size/audit, residual malware risk принят.
- **TBD-6:** legal retention/erasure для PII, audit, blocked messages и S3-data.
- **TBD-7:** точный max WS connections/subscriptions/frame и queue size.
- **TBD-8:** G10 — окончательный DTO/mapping public app-config при оформлении OpenAPI.
- **TBD-9:** G11 — API/WS deprecation policy до публичного релиза.
Ни один TBD не разрешает менять канонические endpoint, auth, enum или service boundaries без обновления соответствующего `arch-*`.