78 KiB
module-01. Проектная спецификация api-backend
Статус: целевая спецификация реализации MVP.
Язык реализации: Python, FastAPI.
Канонические источники:README.md,arch-00-glossary.md,arch-01-system-architecture.md,arch-02-api-contracts.md,arch-03-docker-compose-blueprint.md,arch-04-settings-and-content.md,arch-05-agent-development-process.md,arch-06-service-hosting-security.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-компонентов
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, порядок
- trusted proxy middleware — принимает forwarded headers только от
nginx; - request-id — валидирует/принимает
X-Request-IDлибо генерирует UUID/ULID; - W3C trace context;
- structured access logging;
- CORS из
security.cors.allowed_origins; - error mapper в единый envelope;
- metrics;
- 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
{
"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, max100; afterв polling означает строго позже позиции cursor.
6. Public и protected REST endpoints
6.1. Публичные read-only
GET /api/v1/public/app-config
Ответ — строгий DTO, не dump app_settings:
{
"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.
{
"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:
{
"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 транзакции:
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, §6.
POST /api/v1/consents
JWT, существующий пользователь. Сохраняет новые immutable записи согласий. Обязательные актуальные версии должны быть приняты. Response 201 с recorded_at и версиями.
6.3. UX session
POST /api/v1/analytics/session-start
Request:
{
"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:
{"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:
{
"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:
{
"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. Создание:
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:
{
"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:
{"file_name":"scan.pdf","mime_type":"application/pdf","size_bytes":12345}
Init response 201:
{
"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.
{
"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.
{
"max_send_attempts_per_24h":3,
"min_seconds_between_attempts":30,
"max_verify_attempts":5,
"code_length":6,
"ttl_seconds":300,
"sms_order_timeout_ms":75000,
"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:
- извлечь Bearer token;
- декодировать header, разрешить только настроенные asymmetric algorithms (
RS256по умолчанию), запретитьnoneи symmetric algorithms; - выбрать ключ по
kidиз JWKS cache; - при неизвестном
kidвыполнить один controlled refresh JWKS (single-flight); - проверить подпись,
iss == KEYCLOAK_PUBLIC_URL/realms/<KEYCLOAK_REALM>, audienceKEYCLOAK_AUDIENCE,exp,nbfс малым clock skew; - требовать непустой
sub; - сформировать 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:
phone_number;- 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» по arch-02.
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.
Индексы:
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_idUNIQUE;message_idUNIQUE;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_idUNIQUE;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_settingsidempotent и 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:
- короткая транзакция создаёт message/checkpoint;
- external safety poll выполняется без DB lock;
- короткая транзакция блокирует message и применяет verdict идемпотентно;
- S3 promote выполняется идемпотентно между checkpoint states;
- message + delivery outbox фиксируются атомарно;
- worker выполняет Open Lines call без открытой DB-транзакции;
- результат фиксируется под 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_hash}:{window} |
ZSET timestamps либо counter | длина окна + jitter |
han:api:rl:ip:{ip_hmac}:{route_hash}:{window} |
ZSET/counter | длина окна + jitter |
han:api:rl:dialog:{dialog_id}:message:{window} |
counter | длина окна + jitter |
han:api:rl:service:{service}:{route_hash}:{window} |
counter/token bucket | длина окна + jitter |
han:api:idem:{scope}:{user_id}:{key_hmac} |
state, fingerprint, response | 24 часа |
han:api:idemlock:{scope}:{user_id}:{key_hmac} |
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 |
ZSET connection id → heartbeat | 120 сек |
han:rt:conn:{connection_id} |
HASH: user, subscriptions, server instance, last_seen | 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:coord:lock:settings-refresh:{instance} |
distributed lock | 30 сек |
han:settings:snapshot:{version} |
optional serialized public snapshot | 5 минут |
Redis Pub/Sub — ускоритель, не durable event bus. После reconnect клиент обязательно выполняет REST polling. Потеря DB1 не теряет сообщения.
Канонический формат всех Redis keys, типов и TTL принадлежит module-04-redis-vm1.md; эта таблица не вводит альтернативный namespace.
12. Idempotency
Fingerprint = SHA-256 от canonical method + route template + normalized path params + canonical JSON body + authenticated user id. Authorization, request-id и UX-session не входят.
Алгоритм:
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. Основной алгоритм
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
Перед POST caller формирует strict wire DTO module-05 §8.1: checksum_sha256 из App DB передаётся как attachment.checksum с prefix sha256:, quarantine_version_id — как attachment.quarantine_version_id, ETag — как attachment.quarantine_etag. Unknown fields не отправляются; text/file union проверяется до вызова.
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 GET /internal/safety/status через private :8443 допускается кэшировать не дольше 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.
Location из 202 принимается только как origin-relative path /internal/safety/v2/messages/tasks/{task_id} и резолвится относительно origin MESSAGE_SAFETY_URL. Absolute URL, другой host или иной prefix отклоняются без HTTP-запроса; recovery применяет то же правило.
13.2. Final allow
Для text: safety_status=allowed. Для file:
- проверить checkpoint;
- copy quarantine object в attachments bucket с conditional/idempotent key;
- HeadObject destination, сверить checksum/size;
- в транзакции изменить attachment на
cleanдля standard mode илиbypassedдля MOCK, storage location на S3-data; message наallowed/acceptedс фактическимsafety_processing_mode; создать delivery outbox; - удалить quarantine object best-effort; при сбое cleanup повторит;
- попытаться синхронно доставить outbox, чтобы исходный POST вернул финальный
delivered.
13.3. Final deny
В одной транзакции:
- client message →
blocked/rejected,textзаменяется пустой строкой/безопасным marker по M8; - attachment при наличии →
infected; - создаётся ровно одна synthetic company-replica, связанная
related_message_id, сtextиз activetext_resources(safety.chat.blocked, locale),allowed/delivered; - сохраняются только 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:
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:
quarantine/users/{user_uuid}/dialogs/{dialog_uuid}/{attachment_uuid}
attachments/dialogs/{dialog_uuid}/{attachment_uuid}
documents/users/{user_uuid}/{document_uuid}
Lifecycle:
init: allow-list extension + declared MIME + size; create metadata; presign exact key, MIME, max size, TTL;- direct PUT client → S3-quarantine;
complete: HeadObject конкретной version, size/MIME/server checksum; атомарно фиксируетversion_id + ETag + authoritative checksum;- message send: attachment ownership/state/checksum;
- allow: conditional copy сохранённой source version с ETag/checksum match + verify + DB finalize + quarantine delete;
- deny: quarantine delete + infected metadata;
- abandoned/failed: cleanup через 48 ч, только если нет active safety task;
- 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:
- claim
delivery_outboxlease; - build
/internal/openlines/v1/messagesDTO; Idempotency-Key = message_id;- file message: generate short S3-data signed URL immediately before call; URL не сохранять в outbox/log;
- call with circuit/timeout;
- success/duplicate ack → outbox delivered, Message delivered, Dialog
waiting_for_company; - transient failure → Message failed, outbox retry with exponential backoff;
- 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:
- аутентифицирует service token;
- резервирует receipt по event id/fingerprint;
- duplicate applied → 200/204;
- проверяет dialog;
- для message.new сохраняет text/files, Message
company/allowed/delivered, Dialogwaiting_for_client; - для dialog.closed переводит Dialog в
closed; - commit receipt applied;
- после commit публикует realtime;
- 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→ coalescedcontact.map_or_create; trigger не читает schemabitrix_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:
{"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:
- refresh token при auth error;
- reconnect с backoff 1,2,4…30s;
- resubscribe;
- запросить messages после последнего REST cursor;
- если 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_API_PREFIX,MESSAGE_SAFETY_CA_FILE,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.
В production validator принимает только remote https://<private-vm2-name>:8443, требует читаемый MESSAGE_SAFETY_CA_FILE, отклоняет plaintext и cross-host Docker hostname. Host bind CA задаётся runbook-переменной MESSAGE_SAFETY_CA_HOST_PATH; runtime использует только container path MESSAGE_SAFETY_CA_FILE.
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 как dependency status, но не core readiness gate;
- worker heartbeat/backlog thresholds.
Open Lines или remote Message Safety недоступность отображается как component degraded, но не делает весь API not-ready, чтобы auth и чтение продолжали работать. Send path при недоступном Safety остаётся fail-closed. Решение M7: readiness HTTP 200 при доступных DB/auth/settings и status=degraded для Safety/Bitrix/S3 partial failure; 503 — когда сервис не способен безопасно обслуживать большинство protected API. Compose healthcheck оценивает HTTP code, мониторинг — component details.
Ответ не содержит secrets/internal credentials:
{"status":"ready","components":{"postgres":"ok","redis":"ok","jwks":"ok","safety":"ok","openlines":"degraded","s3":"ok"}}
23. Security
- Public TLS терминирует nginx ВМ1; internal HTTP допустим только внутри Docker backend network одной VM. МежVM вызов Message Safety выполняется напрямую из
api-backendчерез private HTTPS nginx ВМ2:8443с проверкой internal CA и без fallback на Docker DNS/plaintext. - Доверять 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:
- parse/validate infra env;
- configure structured logging/OTEL;
- connect DB and verify Alembic revision;
- connect Redis DB0/DB1;
- load settings/content snapshot;
- warm discovery/JWKS;
- validate S3 bucket access;
- start workers;
- 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. Первый вопрос неавторизованного пользователя
- public config/content;
- пользователь выбирает вопрос/вводит текст;
- frontend показывает согласия;
- Keycloak OTP + PKCE;
- bootstrap с consent body, identity из JWT;
- session-start;
- create dialog с idempotency;
- send message с idempotency;
- safety → Open Lines;
- финальный 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_suppressprevents echo;- ownership queries and soft delete;
- outbox atomicity with message;
- inbox duplicate race;
- safety task
SKIP LOCKEDleases; - 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 poll202/200/403, terminal failed503и conflict409; 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, fallbackpreferred_usernameтолько E.164. - M3: Message создаётся до safety для durable crash checkpoint.
- M4: Redis idempotency дополнен durable PostgreSQL record.
- M5: recovery продолжается после client timeout; initial extended budget
HAN_APP_SAFETY_RECOVERY_MAX_SEC=1200. - 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-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-*.