arm-ka-docs/context/03-architecture.md
Andrey Epifancev a0be5ab5a1 docs: Срез 0 под факты платформы — NATS без JetStream, Python 3.11, PostgreSQL 16, без CI и Nexus
По ответам пользователя 03.10.2026: обмен через recognition_job перестроен на core NATS (queue group'ы,
ack сервиса = конверт status=processing, повторная публикация ядром 30 с × 3 → dlq, дедуп по result_msg_id);
capability ci-template заменена на quality-checks (make check / make check-full, конфигураций CI нет);
compose: PostgreSQL 16, NATS без --jetstream, python:3.11-slim; пакеты raisa_* фильтруются при dev-установке,
npm raisa-* → optionalDependencies. Обновлены 03-architecture, 09-delivery-plan, 00-workspace-layout,
proposal/design/specs/tasks change'а; открытые вопросы к платформе закрыты, остались Node и JWT РАИСА.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 16:21:39 +04:00

276 lines
28 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.

# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.13)
## Компоненты (= репозитории, см. `00-workspace-layout.md`)
```
┌──────────────┐ JWT (email) ┌──────────────────────────────┐
браузер ──────▶│ React SPA │ ───────────────▶│ arm-core (FastAPI) │
└──────────────┘ │ REST /api/v1, policy-слой, │
│ единственный писатель в БД │
└───────┬──────────┬───────────┘
│ │
PostgreSQL │ │ S3 (uploads/ results/ exports/ archive/)
│ │
NATS (core) ◀─────────────────┘ outbox │
recognition.tabular.request │
recognition.image.request │
recognition.result ───────────────────▶ arm-core (inbox, дедуп по msg_id)
│
┌───────────────┴───────────────┐
┌────────▼────────┐ ┌─────────▼────────┐ ┌──────────────────┐
│ tabular-parser │ │ scan-gateway │ ◀─────▶│ ИС Сканирование │ (внешняя, горячие папки)
│ XLS/XLSX/XLSB │ │ PDF/PNG/JPG │ └──────────────────┘
│ stateless, без БД│ │ stateless, без БД│
└─────────────────┘ └──────────────────┘
```
- **arm-core** — монолитное ядро: справочники, файлы/документы, маппинг результатов распознавания, консолидация
(вычисления), согласование, ЕХ, аудит, права. Один Alembic, одна схема.
- **tabular-parser** — читает файл из S3 `uploads/`, находит на листах ОСВ (§1) и формы ББ/ОФР (§12) **по содержимому**,
пишет нормализованный JSON в S3 `results/`, публикует `recognition.result`. Контракт результата —
`docs/contracts/recognition-result.schema.json` (черновик; финализировать в срезе 2). Правила распознавания
заголовков/группировок — `rules/*.yaml`.
- **scan-gateway** — адаптер ИС Сканирование: переименовать файл `UUID_имя.pdf`, положить в «Входящие», опросить
«Исходящие/UUID/UUID.json», сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), нормализовать
в тот же контракт результата, опубликовать `recognition.result`. Таймаут 5 мин → `timeout`.
- **Frontend** — React + TS по шаблону команды; экраны по макету `dkb_24.09.26` (справочник ГК, консолидации,
первичные документы, проверка распознавания, сводная ОСВ, шахматка, корректировки, гудвилл, КФО, ЕХ, журнал, роли).
## Контракт обмена через NATS (DECISION-4)
Схемы — `docs/contracts/` (v1.0): `recognition-request.schema.json` и `recognition-result-message.schema.json` — тела
сообщений, `recognition-result.schema.json` — файл результата в S3; примеры — `docs/contracts/examples/`.
| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` |
|---|---|---|---|---|
| запрос | `recognition.tabular.request` / `recognition.image.request` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` |
| результат | `recognition.result` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `error {code, message}`, `service {name, version}`, `result_s3 {bucket, key}`, `result_schema_version`, `raw_s3_key` (только scan-gateway), `metrics` | `result:<job_id>:<attempt>` |
`group_schema` в конверт не входит — ядро берёт его из `documents[].group_schema` файла результата при маппинге.
Конверт со `status = processing` — подтверждение взятия в работу (ack), `Nats-Msg-Id = ack:<job_id>:<attempt>`.
**На платформе NATS без JetStream** (ответ 03.10.2026): нет durable consumer'ов, серверного дедупа и redelivery.
Поэтому гарантии доставки — на уровне приложения: сервисы подписаны queue group'ами (`tabular-parser`, `scan-gateway`),
ядро — queue group `arm-core` на `recognition.result`; сервис сразу по получении запроса публикует ack `processing`;
ядро, не получив ack за `ACK_TIMEOUT_SECONDS` (30), публикует запрос повторно с тем же `Nats-Msg-Id`, не более
`OUTBOX_MAX_REPUBLISH` (3) раз, затем `dlq`. Сервис держит process-local множество `job_id` в работе и на повторный
запрос отвечает ack'ом без второй обработки. Появится JetStream — меняются только publisher/subscriber.
Правила:
- `recognition_job` создаётся в статусе `queued` **в одной транзакции** со `stored_file`; фоновый публикатор переводит в
`published` (`request_msg_id` = `Nats-Msg-Id`), ack сервиса — в `processing` (`started_at`). Повтор публикации — по
`queued` старше N секунд и по `published` без ack.
- Результат принимается подпиской queue group; `result_msg_id UNIQUE` = дедуп. Нет ack после повторов → `dlq`;
потерянный результат → `reply_deadline` → `timeout`; replay = сброс в `queued` с `attempt + 1`.
- Результат одного файла содержит **N документов**; ядро на каждый создаёт `document` и маппит данные.
- Сервисы не знают про БД и про бизнес-смысл: они возвращают структуру файла; нормализация счетов, резолв
контрагентов, проверки — в ядре.
## Схема ключей S3
**Один бакет** (`OPENBAO__MINIO__MINIO_BUCKET` шаблона); роль объекта — первый сегмент ключа:
`{bucket_role}/{file_id}/{job_id}/{name}`. Роли: `uploads` (исходники, срок — весь срок хранения), `results`
(нормализованные JSON; ретеншн — ARCH-010, открыт), `exports` (7 дней), `archive` (data lake). Все объекты регистрируются
в `s3_object` (`bucket_role`, `bucket`, `key`, sha256, size, expires_at). Сырой ответ OCR `raw/{job_id}.json` — по
конвенции, без записи в БД. Lifecycle-правила — по префиксам.
## Аутентификация и авторизация
1. SPA получает JWT от РАИСА; каждый запрос несёт `Authorization: Bearer`.
2. arm-core валидирует подпись/срок, извлекает email, находит `app_user` (`is_active`); нет пользователя — 403.
3. Права: `effective_permissions = ∪ role_permission(roles(user))`. Проверка в зависимости FastAPI
`require(permission_code)`.
4. **Policy-слой** поверх прав (отдельный модуль, чистые функции): видимость по филиалу (`regional_office`),
по ЗО (`business_segment`), допустимость действия по статусу версии (`consolidation_status`) и по активной
блокировке (`consolidation_lock`). Право говорит «может», policy — «эту ли и сейчас ли».
5. Локальная разработка: заглушка-эмитент JWT с теми же claims (feature-flag `AUTH_DEV_ISSUER`).
## Слои arm-core (по раскладке шаблона команды)
Структура — раскладка шаблона `api/src/` (см. «Соглашения шаблона» ниже); `tabular-parser` и `scan-gateway` — те же
каталоги без `web/`, `db/`, `models/`, `alembic/`. Соответствие прежним названиям этого документа: `backend/app/` →
`api/src/`, `integrations/` → `core/`, `migrations/` → `api/alembic/`, `frontend/` → `web/`.
```
api/src/
api/endpoints/ # роутеры FastAPI по контекстам: me, directories, files, documents, consolidation, kfo, ex, audit, users
api/dependencies/ # фабрики Depends: сессия, сервисы, current_user, require(permission)
domain/ # чистая предметная логика без I/O: правила ОСВ, периоды, матчинг, элиминация, проверки, пороги,
# машины статусов, policy — pydantic-модели на входе и выходе
services/ # use-cases: оркестрация domain + repositories + outbox + audit_event
repositories/ # SQLAlchemy-запросы, в т. ч. запросы-вычисления (сводная ОСВ, шахматка)
models/ # ORM 1:1 с DBML v5.13; имена таблиц/колонок/enum — как в DBML
schemas/ # pydantic DTO API /api/v1
core/ # config, clients (S3; NATS outbox-publisher и result-consumer), auth (JWT РАИСА / dev-эмитент),
# logger, exception_handlers
db/ # engine, сессия
api/alembic/ # миграции
api/scripts/ # check_dbml_vs_orm.py, seed
api/tests/ # фикстуры — из ../fixtures (ARM_KA_FIXTURES)
web/ # SPA
openspec/
```
Принципы: `domain/` тестируется без БД на фикстурах из `../fixtures`; вычисляемые объекты консолидации возвращаются
как pydantic-модели и **не** имеют ORM-таблиц; все мутации идут через `services/` с записью `audit_event`.
## Статусы и машины состояний
Три независимые машины: `file_status` (техника файла), `document_status` (проверка аналитиком), `consolidation_status`
(маршрут КФО) + `recognition_status` (жизненный цикл задания). Переходы — `05-domain-rules.md` §9. Реализовать как
явные таблицы переходов в `domain/`, а не `if`-ы по роутерам.
## Соглашения шаблона команды
Три сервисных репозитория развёрнуты из нулевого коммита репозитория команды `RSHB/DKB_ARMKA` (`ba172f8`, «Шаблон
FastAPI» платформы РАИСА/aurora): дерево импортировано как есть (`arm-core@ba92222`), доменный код удалён, каркас
оставлен (`8e1f423`). **Документы подстраиваются под шаблон, а не наоборот.** Ниже — что шаблон задаёт и что мы к нему
добавляем. Один и тот же текст действует для всех трёх репозиториев; отличия сервисов — в разделе «Что вырезано».
### Раскладка репозитория
```
<repo>/
README.md описание шаблона (в Срезе 0 заменяется описанием сервиса)
raisa_config.yml профиль ресурсов платформы (cpu/ram); читает платформа при деплое
setup/ бутстрап разработчика: setup.sh (venv + pip + npm i); pip.conf и .npmrc → внутренний Nexus банка
api/ backend. РАБОЧИЙ КАТАЛОГ для всех команд: от него ищутся .env, pytest.ini и web/ для раздачи SPA
run_server.py точка входа платформы: uvicorn, host/port из SERVER__HOST / SERVER__PORT, lifespan=on
requirements.txt прод-зависимости, pinned (fastapi 0.135, SQLAlchemy 2.0.36, alembic 1.14, asyncpg, pydantic 2.13,
pydantic-settings, nats-py 2.14, miniopy-async, smbprotocol, loguru, sentry-sdk, raisa_fastapi_protected_api)
requirements-dev.txt dev-зависимости (pytest-asyncio, pytest-env, openpyxl, …) — пины рассинхронизированы с requirements.txt
pytest.ini asyncio_mode=auto, loop scope session, env APP_ENV=test / ENV_FILE=.env.test, testpaths=tests
alembic.ini, alembic/ env.py берёт URL из настроек приложения, схема dkb, compare_type=True, include_schemas
src/
__init__.py экспортирует app и get_settings
app.py FastAPI: lifespan (SMB-сессия, MinIO, NATS), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=false),
GET /healthcheck, include_router, раздача SPA из api/web/ (SPAStaticFiles, fallback index.html)
api/router.py агрегирующий APIRouter; api/endpoints/* — роутеры; api/dependencies/* — фабрики Depends
core/config.py pydantic-settings (см. «Конфигурация»); модуль создаёт settings при импорте
core/clients.py клиенты инфраструктуры (S3Storage поверх miniopy-async)
core/nats_callback.py обработчики сообщений NATS
core/logger.py loguru: setup_logger, перехват std logging и uvicorn, request_id, шумовые пути
core/exception_handlers.py ошибки → JSON {"detail": …}
common/exceptions.py доменные исключения (NotFoundError, CouldntCreateError)
db/db.py async engine (asyncpg), search_path=dkb, get_async_session
models/base.py DeclarativeBase с MetaData(schema="dkb"), UUIDPrimaryMixin (uuid4 на стороне приложения)
schemas/ pydantic DTO и enum'ы API
repositories/ запросы: класс на таблицу, принимает AsyncSession
services/ use-cases
tests/conftest.py
web/ frontend (остаётся только в arm-core): React 18 + TS, rsbuild, antd, Redux Toolkit, biome
```
Наши дополнения к `api/src/`, не меняющие существующих каталогов: `domain/` — чистая предметная логика без I/O
(правила §1–§10, машины статусов, policy); `core/auth.py` — JWT РАИСА и dev-эмитент; `api/scripts/` — служебные скрипты
(`check_dbml_vs_orm.py`, seed). Пути в документах вида `rules/*.yaml`, `scripts/…`, `tests/…` отсчитываются от `api/`.
### Конфигурация
- `pydantic-settings`, файл `api/.env`, вложенность через `__`, лишние переменные игнорируются. Все инфраструктурные
секреты — под префиксом `OPENBAO` (в проде заполняет OpenBao): `OPENBAO__DATABASE__DATABASE_URL`,
`OPENBAO__MINIO__{MINIO_URL, MINIO_ADMIN_LOGIN, MINIO_ADMIN_PASSWORD, MINIO_BUCKET}`,
`OPENBAO__NATS__{NATS_URL, NATS_SEED, NATS_JWT}`, `OPENBAO__SMB__{SMB_HOST, SMB_PATH, SMB_LOGIN, SMB_PASSWORD}`.
Прочее: `DEBUG`, `ROOT_PATH`, `VERSION`, `SERVER__HOST`, `SERVER__PORT`.
- `settings` создаётся при импорте (`core/config.py`, `db/db.py`, `app.py`) — импорт почти любого модуля требует
полного окружения; тесты получают его из `.env.test`.
- `DEBUG=true` — режим разработки шаблона: NATS не подключается, middleware РАИСА не импортируется. Наши флаги
разработки (`AUTH_DEV_ISSUER`, `SCAN_EMULATOR`, режим горячих папок) добавляются так же — отдельными полями `Settings`,
по умолчанию выключены; в проде ни один не включается.
### База данных и миграции
- SQLAlchemy 2.0 async + asyncpg. Одна схема PostgreSQL **`dkb`** — зашита в шаблоне (MetaData, `search_path`,
`version_table_schema` и `include_name` Alembic). Следуем шаблону, не `public`.
- Alembic берёт URL из настроек, не из `alembic.ini`; ревизии — `api/alembic/versions/`, имя файла стандартное
`<rev>_<slug>.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`.
- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.13.
### Инфраструктурные клиенты
- **S3** — `miniopy-async` (`Minio`, `cert_check=False`), **один бакет** `MINIO_BUCKET`; роли `s3_object.bucket_role`
становятся первым сегментом ключа (`uploads/…`, `results/…`, `raw/…`, `exports/…`, `archive/…`).
- **NATS** — `nats-py`; аутентификация nkeys-seed + JWT (`NATS_SEED`, `NATS_JWT`) через `user_jwt_cb`/`signature_cb`;
соединение и подписки создаются в lifespan и живут в `app.state.nats` / `app.state.nats_subs`. Core NATS без
JetStream: подписки с queue group, заголовки `Nats-Msg-Id` ставим сами, ack и повторы — на уровне приложения.
- **SMB** (горячие папки ИС Сканирование) — `smbprotocol`/`smbclient`, сессия регистрируется в lifespan; нужен только `scan-gateway`.
- **Внешняя аутентификация** — пакет `raisa_fastapi_protected_api` из внутреннего Nexus (`AuthorizationMiddleware`,
`ProtectedSettings`, …), импортируется только при `DEBUG=false`; фронтовая пара — `raisa-fastapi-protected-web`.
Вне сети банка оба пакета недоступны (см. вопросы Среза 0).
### DI, слои, ошибки, логи
- Контейнера DI нет: ручные фабрики `Depends` в `api/dependencies/` — фабрика получает `AsyncSession` из
`get_async_session`, собирает репозитории и сервис; инфраструктурные клиенты берёт из `request.app.state` (`storage`, `nats`).
- Поток запроса: `api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`. Доменные исключения из
`common/exceptions.py` маппятся в HTTP в `core/exception_handlers.py` (RequestValidationError → 422, IntegrityError → 409,
SQLAlchemyError → 500, HTTPException как есть, прочее → 500); тело всегда `{"detail": …}`, тексты по-русски.
- Логи — loguru (`core/logger.setup_logger`): перехват std logging и uvicorn, `request_id` в extra, успешные обращения к
`/healthcheck`, `/docs`, статике не логируются.
### Тесты
- pytest + pytest-asyncio (`asyncio_mode=auto`, loop scope `session`) + pytest-env; запуск из `api/`; тесты в `api/tests/`.
- Тестовую БД шаблон не задаёт. Решение: тесты, которым нужна БД, идут на PostgreSQL из docker-compose (enum'ы,
partial unique, jsonb, `NULLS NOT DISTINCT` в SQLite не воспроизводятся); `domain/` тестируется без БД.
### Линтеры и типизация
- Backend: шаблон декларирует black / isort / pylint (бейджи README), конфигов не содержит. Принимаем `ruff`
(format — совместим с black; lint с правилами isort `I` и подмножеством pylint `PL`) и `mypy`; конфиг — `api/pyproject.toml`
(решение Среза 0; откат на black/isort/pylint — замена одного файла конфига и шага CI).
- Frontend: `biome` 1.9 (`npx biome check src`): ширина строки 140, одинарные кавычки, `bracketSameLine`,
`noExplicitAny` warn, `useAwait`/`noEmptyBlockStatements` error; типы — `npx tsc --noEmit` (strict). ESLint/Prettier нет.
### Сборка и деплой
- Платформа РАИСА запускает `api/run_server.py` с `SERVER__HOST`/`SERVER__PORT` из окружения; ресурсы — `raisa_config.yml`.
Python на платформе — **3.11** (ответ 03.10.2026); версия Node неизвестна (локально `node:22`).
- `Dockerfile`, `docker-compose.yml`, `.dockerignore` шаблон держит в `.gitignore` («Custom ignore»): образ собирает
платформа своим CI/CD, в который мы не лезем. Для локальной разработки: compose — на уровне workspace
(`docs/dev/docker-compose.yml`), `Dockerfile` (`python:3.11-slim`) — в корне каждого сервиса, строка `Dockerfile`
убирается из `.gitignore` (единственная правка шаблона; решение Среза 0).
- Фронт: `npm run build` (rsbuild) → `web/dist/` → копируется в `api/web/`, откуда FastAPI раздаёт SPA; роутеры
подключаются **до** mount'а `/`.
- Зеркала пакетов: pip и npm настроены на `nexus.dev.raisa.go.rshbank.ru` (`setup/pip.conf`, `setup/.npmrc`). Вне банка
Nexus недоступен до прода: dev-установка ставит `requirements.txt` без строк `raisa_*` (`make venv`), в
`web/package.json` пакеты `raisa-*` переносятся в `optionalDependencies`; импорты `raisa_fastapi_protected_api` условные
(`DEBUG=false`), как в шаблоне.
- CI в Gitea нет и не планируется; конфигураций CI в репозиториях нет. Проверки качества — одинаковые цели `Makefile`
во всех репозиториях: `make check` (ruff, mypy, pytest без Docker, идентичность контракта) и `make check-full`
(+ интеграционные тесты против compose, для arm-core — цикл миграций и `check_dbml_vs_orm.py`); `make check-full`
обязателен перед PR. Описание — `docs/dev/checks.md`.
### Frontend (только arm-core)
- React 18.3 + TypeScript 5.7 (strict), сборка rsbuild 1.6 (`npm run start | build | preview`); основной UI-кит — antd 6
(+ `@ant-design/icons`); установлены также tailwind 3 + daisyui 4 (тема `rshb`), `react-daisyui`, `raisa-ui` — точечно;
состояние — Redux Toolkit 2 (`store/store.ts`, слайсы в `store/slices/`, типизированные `useAppDispatch`/`useAppSelector`);
маршрутизация — react-router-dom 7 (`BrowserRouter basename=Config.APP_ADDRESS`, маршруты в `App.tsx`); `pdfjs-dist` — PDF.
- Структура `web/src/`: `layouts/` — страницы, `widgets/` — составные блоки, `components/` — примитивы, `store/`, `conf/`;
стили — CSS-модули (`styles.module.css`) и токены antd (`ConfigProvider` в `index.tsx`).
- Переменные окружения только с префиксом `REACT_APP_` (rsbuild `loadEnv`), читаются через `conf/config.ts`: все запросы к
бэку — `${Config.API_URL}/api/v1/…` (в сборке относительный путь); `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка
(по умолчанию `/aurora/apps/fastapi-dkb-armka`) задаёт `base`/`assetPrefix` rsbuild и `basename` роутера.
### Что вырезано в tabular-parser и scan-gateway
Сервисы распознавания — тот же шаблон без фронта и без БД (DECISION-4). На момент Среза 0 оба репозитория — полные копии
каркаса `arm-core`; change `slice-0-skeleton` каждого сервиса удаляет: `web/` целиком; `setup/.npmrc` и npm-шаги
`setup/setup.sh`; из `app.py` — mount SPA (`SPAStaticFiles`, `os.makedirs("web")`), CORS, импорт
`raisa_fastapi_protected_api` (внешнего API нет — только `/healthcheck`); `db/`, `models/`, `repositories/`, `alembic/`,
`alembic.ini` и `OPENBAO.DATABASE` из настроек; из `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`,
`raisa_fastapi_protected_api`; шаблонный `README.md` заменяется описанием сервиса. `tabular-parser` дополнительно без SMB
(`smbprotocol`, `OPENBAO.SMB`, регистрация сессии в lifespan). Остаются: `run_server.py`, `raisa_config.yml`, `setup/setup.sh`
+ `pip.conf`, `pytest.ini`, настройки `OPENBAO__MINIO__*` / `OPENBAO__NATS__*` / `DEBUG`, `core/config.py`, `core/clients.py`,
`core/nats_callback.py`, `core/logger.py`, `core/exception_handlers.py`, `common/exceptions.py`. Точный список — в
`CLAUDE.md` сервиса и в `tasks.md` его change'а.
## Что ожидаем от платформы (для запроса ресурсов)
По ответам платформы (03.10.2026): PostgreSQL **16** (одна БД, схема `dkb` — создавать схемы можно), NATS **без
JetStream** (3 subject `recognition.*`; аутентификация nkeys/JWT, как в шаблоне), S3-совместимое хранилище (один бакет;
префиксы-роли + lifecycle по префиксам), Python 3.11 в базовом образе, Nexus (pip/npm, пакеты `raisa_*`) — только внутри
банка, CI/CD — пайплайн платформы (мы отдаём команды `make check` / `make check-full`). Секреты — через переменные
окружения (`OPENBAO__*`). Открыто: версия Node, параметры JWT РАИСА.