Compare commits
29 Commits
1d6ab86a57
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5440ebe152 | |||
| 6e503433d4 | |||
| bac55cbdfd | |||
| 7a7aa0cf6c | |||
| de41aea00c | |||
| 5fa6ea6ab1 | |||
| 1ffe62133c | |||
| 19a2b6dda4 | |||
| 93f3ec240c | |||
| f1e05c0ca3 | |||
| 2142c4f586 | |||
| cb0f7efd4c | |||
| 0ef75e05e8 | |||
| 3e34995e69 | |||
| 82b3186b95 | |||
| 660d71e21a | |||
| 9216eafb7f | |||
| 2e09e21ad6 | |||
| 67e81e5d7f | |||
| 978777ff6a | |||
| ee642e5eaa | |||
| 958d777751 | |||
| c5695bf0b6 | |||
| e2720c09f7 | |||
| 1cf069b55b | |||
| a8cdeeb838 | |||
| 9e6e95f431 | |||
| a040f8b07d | |||
| 93bcbca12c |
+10
@@ -1,6 +1,7 @@
|
||||
# Сборки
|
||||
/bin/
|
||||
/dist/
|
||||
!/dist/ish/README.md
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
@@ -58,3 +59,12 @@ test-results/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Doc-watcher: бэкапы при переустановке свежих версий
|
||||
DOC/*.pdf.bak
|
||||
DOC/*.bak.pdf
|
||||
|
||||
# Дистрибутив ИШ НРД (большой, ~120 МБ) — не коммитим в git
|
||||
/dist/ish/*.deb
|
||||
/dist/ish/*.SGN
|
||||
/dist/ish/*.exe
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Bridge-and-Join-s — гайд для агента Claude Code
|
||||
|
||||
Этот файл читается автоматически при старте `claude` в репозитории. Здесь — сжатый контекст проекта чтобы агент не вычислял всё заново.
|
||||
|
||||
## Что это за проект
|
||||
|
||||
**Bridge-and-Join-s** — система M2M-переводов ценных бумаг между депозитариями через сервис **MOEX МОСТ** (НКО АО НРД). Целевая интеграция — сервис M2M НРД на TEST3, далее PROD.
|
||||
|
||||
**Объём**: 100-1000 сделок в день — сознательно облегчили архитектуру под этот объём.
|
||||
|
||||
**Один бинарник** `bj-server` (cmd/bj-server) вместо изначально планировавшихся микросервисов. Внутри — пакеты:
|
||||
- `internal/m2m` — XSD-модели всех сообщений (M2MTransferRequest/Decision/Response)
|
||||
- `internal/m2mcore` — стейт-машина заявок, репозиторий (memory + pgx)
|
||||
- `internal/lkgateway` — REST API, веб-админка, lk-emulator, мастер настройки
|
||||
- `internal/nsdadapter` — два режима: `mock` (внутренний робот-эмулятор) и `igw` (REST-клиент Интеграционного шлюза НРД)
|
||||
- `internal/cryptocli` — PKCS#11 клиент к СКЗИ (КриптоПро CSP / Рутокен / Валидата)
|
||||
- `internal/nsdxml` — XML кодек с CP-1251
|
||||
|
||||
**Веб-админка**: `http://<ip>:8080/admin/` — главное место общения с системой. Разделы: Дашборд / Мастер настройки / Настройка / Заявки / Статус / Новости / Инструкции. Всё на русском.
|
||||
|
||||
## Текущее состояние (актуально на 2026-05-18)
|
||||
|
||||
См. **`REPORT.md` в корне репо** — там полный статус с процентами. Кратко:
|
||||
|
||||
- ✅ Все компоненты на нашей стороне написаны и оттестированы (~75% общая готовность)
|
||||
- ✅ REST-клиент ИШ НРД готов (`internal/nsdadapter/igw/` + тесты)
|
||||
- ✅ Эмулятор робота MOEX МОСТ (4 сценария) работает
|
||||
- ✅ Установщик одной командой `deploy/astra/install.sh` готов для Astra/Debian/Ubuntu
|
||||
- ⏳ Заблокировано на внешнем: дистрибутив Валидата CSP (запрос в `soed@nsd.ru`), сертификат УЦ МБ (`ca.moex.com`), регистрация в TEST3
|
||||
- ⚠️ Окно техработ TEST3 закрыто (18-22.05.2026 — сейчас 18.05, активно)
|
||||
|
||||
## Архитектура обмена с НРД (если кратко)
|
||||
|
||||
```
|
||||
bj-server (наше Go-приложение)
|
||||
│
|
||||
│ REST: POST /api/package/{channel}/file (ZIP в base64)
|
||||
▼
|
||||
ИШ (igate, ставится на Astra Linux рядом)
|
||||
│ ИШ САМ: подписывает, упаковывает, отправляет
|
||||
│ Использует СКЗИ Валидата CSP + сертификат УЦ МБ
|
||||
▼
|
||||
Web-сервис ONYX (НРД, https://gost.nsd.ru/onyxt3/WslService)
|
||||
```
|
||||
|
||||
Подробная схема и FAQ — в `/admin/help/architecture` (когда сервис запущен) и в `DOC/ruk_install_ish_2025_11_10.pdf`.
|
||||
|
||||
## Где что лежит
|
||||
|
||||
| Каталог | Что |
|
||||
|---|---|
|
||||
| `cmd/bj-server/` | главный бинарник |
|
||||
| `cmd/lk-emulator/` | эмулятор ЛК ESIA для разработки |
|
||||
| `internal/` | вся бизнес-логика, поделена на пакеты |
|
||||
| `migrations/` | SQL миграции: `fansy-store/` (входные данные от Fansy) и `m2m-core/` (журнал сделок) |
|
||||
| `deploy/astra/` | установщик одной командой + healthcheck |
|
||||
| `deploy/systemd/` | systemd unit (используется install.sh) |
|
||||
| `deploy/docker-compose/` | docker-compose для PostgreSQL в podman |
|
||||
| `DOC/` | вся документация НРД (PDF — около 15 файлов, см. ниже) |
|
||||
| `dist/ish/` | дистрибутив ИШ НРД (~120 МБ .deb, не в git) |
|
||||
| `docs/` | наша внутренняя документация (контракты Fansy и т.п.) |
|
||||
| `REPORT.md` | отчёт для руководства — **держим в актуальном виде** |
|
||||
|
||||
## Документация НРД в DOC/
|
||||
|
||||
- `Инструккия M2M.pdf` — главный документ по M2M-обмену (схемы, протоколы)
|
||||
- `instr-ish-rest-api.pdf` — REST API ИШ (на нём основан `internal/nsdadapter/igw`)
|
||||
- `ruk_install_ish_2025_11_10.pdf` — Руководство по установке ИШ (нужны Astra Linux + Валидата CSP)
|
||||
- `ruk_pol_ish.pdf` — Руководство пользователя ИШ
|
||||
- `QA_ish.pdf`, `test-case_ish.pdf` — FAQ и тест-кейсы
|
||||
- `instruktsiya-po-testirovaniyu-s-robotom.pdf` — робот-автотест на TEST3 (код `MC0012500000`, 4 сценария)
|
||||
- `servis-most-m2m.pdf` — обзор сервиса MOEX МОСТ
|
||||
- `Ссылки для доступа в тестовые контуры.pdf` — URL'ы GUEST/TEST3/PROD контуров НРД
|
||||
|
||||
## Решения, которые мы приняли (не очевидные)
|
||||
|
||||
- **КриптоПро CSP**, а не JCP — экономия ~50 000 ₽ лицензии. Используется для подписи действий оператора в админке через Рутокен. Для отправки в НРД подпись делает ИШ (своей Валидатой) — наш bj-server для этого не нужен
|
||||
- **PKCS#11 как единый интерфейс** к разным СКЗИ — пакет `internal/cryptocli` через `github.com/miekg/pkcs11`
|
||||
- **Один бинарник** вместо микросервисов — для нашего объёма проще, никаких микросервисных издержек
|
||||
- **Mock-робот внутри bj-server** — позволяет тестить логику без живого НРД. Активируется кодом `MC0012500000` в `ReceiverCode` + `DocumentSeries` 1111/2001/2002/3333
|
||||
- **Astra Linux для ИШ** — единственная поддерживаемая ОС от НРД (РЕД ОС не пойдёт). Дев — Astra CE (бесплатная), прод — Astra SE (платная)
|
||||
- **`globalRC` в `admin.go`** — глобальная переменная RC для шаблонов; компромисс между чистотой и шумностью передачи `*RuntimeConfig` через все хендлеры
|
||||
- **doc-watcher с явным `noProxyClient`** в `news.go` — игнорирует ENV-прокси, потому что zetit блокирует nsd.ru
|
||||
|
||||
## Как запускать
|
||||
|
||||
**Локально (для разработки)**:
|
||||
```bash
|
||||
go build -o ./bin/bj-server ./cmd/bj-server
|
||||
LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server # путь нужен только если есть КриптоПро
|
||||
```
|
||||
|
||||
**Production-стиль через systemd** (после `deploy/astra/install.sh`):
|
||||
```bash
|
||||
systemctl status bj-server
|
||||
journalctl -u bj-server -f
|
||||
```
|
||||
|
||||
**Установить с нуля на свежей Astra/Debian/Ubuntu** — одна команда:
|
||||
```bash
|
||||
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## Окружение разработки
|
||||
|
||||
- **Дев-стенд №1**: РЕД ОС 8 на `10.10.10.22` — историческая ВМ, КриптоПро CSP установлен, есть PostgreSQL в podman, bj-server и lk-emulator работают
|
||||
- **Дев-стенд №2**: Astra Linux SE 1.7 на `10.10.10.27` — будущий основной стенд (потому что Astra нужна для ИШ). Поднята 14.05.2026, базовая dev-среда настроена (Node 20, claude-code, tmux, прокси `claude-fna`)
|
||||
- **Прокси zetit** `fna.zetit.ru:3128` — только для Claude Code (alias `claude-fna`). Всё остальное (go modules, apt, nsd.ru) идёт напрямую
|
||||
- **Git remote**: `https://git.zetit.ru/zuevav/Bridge-and-Join-s.git` (Gitea на zetit)
|
||||
|
||||
## Команды и подсказки агенту
|
||||
|
||||
**После любого значимого изменения** — обновляй `REPORT.md` в том же коммите. Это правило (см. `feedback_report_md_keep_updated.md` в памяти). REPORT.md — «живая» отчётность для руководства, пользователь должен в любой момент открыть и показать.
|
||||
|
||||
**Прокси при `go build`** — выключай ENV-прокси, у нас всё ходит мимо zetit:
|
||||
```bash
|
||||
env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy go build ...
|
||||
```
|
||||
|
||||
**bj-server в фоне** — нужен `setsid` иначе умирает с shell:
|
||||
```bash
|
||||
LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 setsid ./bin/bj-server > /tmp/bj.log 2>&1 < /dev/null & disown
|
||||
```
|
||||
|
||||
**Cwd важно** — после `cd DOC/` или подобного go build от `./cmd/bj-server` упадёт. Всегда возвращайся в `/home/fontvielle/Bridge-and-Join-s` (или `/opt/bj/src` на Astra).
|
||||
|
||||
**Все UI-надписи — на русском** (требование заказчика). Исключения только для программных терминов (PostgreSQL, REST, JSON, и т.п.).
|
||||
|
||||
**Не создавай PDF-документы и md-отчёты без явного запроса.** REPORT.md — единственный, его поддерживаем актуальным.
|
||||
|
||||
**Релизные коммиты** — заголовок в Conventional Commits (`feat(igw): ...`, `fix(admin): ...`), русский body. См. предыдущие коммиты `git log --oneline`.
|
||||
|
||||
## Контакты НРД (если что-то нужно по проекту)
|
||||
|
||||
- `M2MOST@nsd.ru` — форматы M2M
|
||||
- `soed@nsd.ru` — дистрибутивы (Валидата, ИШ)
|
||||
- `pki@moex.com` — УЦ МБ, сертификаты
|
||||
|
||||
## Что делать НЕ надо
|
||||
|
||||
- Не пытайся ставить ИШ на РЕД ОС — он только под Astra (.deb пакет)
|
||||
- Не предлагай переход на КриптоПро JCP — пользователь явно его отверг по цене
|
||||
- Не пытайся выпускать новые сертификаты — у организации они уже есть, наша задача только импортировать
|
||||
- Не убирай UI-баннер «РЕЖИМ ЭМУЛЯЦИИ» — он защищает от случайной отправки в прод
|
||||
- Не используй прокси zetit для go-модулей или для запросов к nsd.ru — будет 403
|
||||
|
||||
---
|
||||
|
||||
**Спросить пользователя, если непонятно** — не стесняйся. Лучше задать вопрос чем сделать неверное предположение.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -19,11 +19,8 @@ help:
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
$(GO) build -o bin/lk-gateway ./cmd/lk-gateway
|
||||
$(GO) build -o bin/m2m-core ./cmd/m2m-core
|
||||
$(GO) build -o bin/nsd-adapter ./cmd/nsd-adapter
|
||||
$(GO) build -o bin/lk-emulator ./cmd/lk-emulator
|
||||
$(GO) build -o bin/notify ./cmd/notify
|
||||
$(GO) build -o bin/bj-server ./cmd/bj-server
|
||||
$(GO) build -o bin/lk-emulator ./cmd/lk-emulator
|
||||
|
||||
test:
|
||||
$(GO) test ./... -race -count=1
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# Bridge-and-Join-s — отчёт о ходе работ
|
||||
|
||||
**Дата:** 14.05.2026 (3-я редакция за день — скачан дистрибутив ИШ + полная документация ИШ)
|
||||
**Контур:** дев-стенд РЕД ОС 8 (10.10.10.22), bj-server на :8080, lk-emulator на :8083
|
||||
**Целевая интеграция:** сервис MOEX МОСТ (M2M) через НКО АО НРД
|
||||
|
||||
---
|
||||
|
||||
## Готовность по областям
|
||||
|
||||
| Область | Готовность | Статус |
|
||||
|---|---:|---|
|
||||
| Контракты и модели M2M (XSD → Go) | **100%** | ✅ Готово |
|
||||
| Журнал сделок (PostgreSQL + in-memory) | **100%** | ✅ Готово |
|
||||
| Бизнес-логика FSM (стейт-машина заявок) | **100%** | ✅ Готово |
|
||||
| Веб-интерфейс администратора | **95%** | ✅ Готово |
|
||||
| Мастер настройки (wizard) для оператора | **100%** | ✅ Готово |
|
||||
| Установка и конфигурация КриптоПро CSP через UI | **100%** | ✅ Готово |
|
||||
| Авто-загрузка сертификатов УЦ (мониторинг + ежесуточное обновление) | **100%** | ✅ Готово |
|
||||
| Контейнеры КриптоПро с флешки (импорт в HDIMAGE) | **80%** | ⚠ Без UI-импорта сертификата из контейнера |
|
||||
| Лента новостей + мониторинг сайта НРД (doc-watcher) | **100%** | ✅ Готово |
|
||||
| Эмулятор робота-автотеста НРД (внутренний mock) | **90%** | ⚠ Сценарий 3333 — частично |
|
||||
| Реальное подключение к роботу на TEST3 НРД | **30%** | ⚠ REST-клиент ИШ готов, ждём сам ИШ + сертификат |
|
||||
| REST-клиент ИШ НРД (по DOC/instr-ish-rest-api.pdf) | **100%** | ✅ POST file, GET status, GET list, GET package, упаковщик ZIP, 10/10 тестов |
|
||||
| Дистрибутив ИШ НРД и полная документация | **100%** | ✅ Скачаны: `igate_100.0-765_amd64.deb` (117 МБ) + 6 PDF |
|
||||
| Установка ИШ на наш стенд | **30%** | ⚠ Скрипты установки готовы, ждём Astra Linux ВМ от инфра-команды |
|
||||
| Авто-установщик «одной командой» | **100%** | ✅ `curl … \| sudo bash` на свежей Astra/Debian/Ubuntu — bj-server + БД + ИШ через 5-10 мин |
|
||||
| Получение СКЗИ «Валидата CSP» для Linux | **0%** | ⏳ Запрос в soed@nsd.ru / pki@moex.com — см. блокер #2 |
|
||||
| Сертификат УЦ Московской Биржи для подписи | **0%** | ⏳ Не получен — см. блокер #3 |
|
||||
| Подключение реального ЛК ESIA Finance | **20%** | ⚠ Эмулятор lk-emulator работает, реальный URL не указан |
|
||||
| Контракт с Fansy (ETL) | **30%** | ⚠ Контракт документирован, ETL не реализован стороной Fansy |
|
||||
| Уведомления (e-mail, мессенджеры) | **0%** | ⏳ M3-M4 |
|
||||
| Тесты, CI/CD | **40%** | ⚠ Unit-тесты компонентов, нет E2E против реального НРД |
|
||||
|
||||
**Общая готовность системы:** **≈ 75%** (по объёму функциональности)
|
||||
**Готовность к интеграционному тесту с роботом:** **≈ 88%** (зависит только от внешних факторов: Astra Linux ВМ, Валидата CSP, сертификат УЦ МБ — на нашей стороне установщик готов)
|
||||
|
||||
---
|
||||
|
||||
## Что сделано (28 коммитов)
|
||||
|
||||
### Архитектура и ядро (M1)
|
||||
- Реализованы Go-модели всех M2M-сообщений (M2MTransferRequest, Response, Decision, History, Movement) с валидацией.
|
||||
- Стейт-машина обработки заявок (FSM): `draft → validated → submitted_to_nsd → awaiting_decision → confirmed/rejected/timed_out → done` + ветка ручного разбора.
|
||||
- Один исполняемый бинарник `bj-server` (вместо запланированных микросервисов) — рассчитано на нагрузку до 1000 сделок/день.
|
||||
- Хранилище: PostgreSQL 16 в контейнере podman (один клик «Поднять автоматически» в UI), миграции для двух схем — `fansy.*` (данные от Fansy) и `m2m_core.*` (журнал сделок). Fallback на in-memory для дева.
|
||||
|
||||
### Криптография
|
||||
- Переход с КриптоПро JCP (~82 000₽, Java) на КриптоПро CSP (~30-50 000₽, нативный) — экономия лицензии в ~2 раза. Подходит для нашего объёма (100-1000 сделок/день).
|
||||
- Go-клиент к СКЗИ через стандартный PKCS#11 интерфейс (`internal/cryptocli`). Один клиент работает с КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — меняется только путь к .so модулю.
|
||||
- UI-кнопка «Загрузить дистрибутив КриптоПро»: загружаешь tar/tgz/rpm, система сама распаковывает и устанавливает через `sudo rpm -Uvh`.
|
||||
- Активация лицензии через UI (`cpconfig -license -set` под капотом).
|
||||
- Импорт сертификатов (.pfx/.p12 с PIN, .cer/.crt без) в хранилища `uMy`/`mroot`/`uRoot` через `certmgr -inst`.
|
||||
- **Авто-обнаружение контейнеров КриптоПро на USB-флешках** (формат `name.000`): сканирует `/run/media/$USER/`, `/media/`, `/mnt/`; кнопка «Скопировать в локальное хранилище» переносит контейнер в `/var/opt/cprocsp/keys/$USER/`.
|
||||
- **Авто-обнаружение сертификатов на Рутокене ЭЦП 2.0** — список заполняется автоматически после подключения токена в USB.
|
||||
|
||||
### Сертификаты УЦ
|
||||
- Авто-загрузка корневых и подписных сертификатов УЦ по списку URL: SHA-256 дедуп, импорт в `mroot`/`uRoot` через `certmgr`.
|
||||
- Ежесуточная фоновая горутина обновляет сертификаты, в ленте новостей появляется уведомление «Обновлён сертификат УЦ: <CN>».
|
||||
- За 14 дней до истечения сертификата — отдельное предупреждение в ленте.
|
||||
|
||||
### Веб-интерфейс администратора (порт 8080)
|
||||
6 разделов меню:
|
||||
- **Дашборд** — счётчики сделок, состояние подсистем, последние заявки, блок «Новости» сверху.
|
||||
- **Мастер настройки** — пошаговая настройка (5 шагов) с прогресс-баром, подсказки «?» и «Где взять?» рядом с каждым полем.
|
||||
- **Настройка** — расширенные параметры всех подсистем.
|
||||
- **Заявки** — журнал + карточка заявки с историей FSM.
|
||||
- **Статус системы** — health-check всех подсистем.
|
||||
- **Инструкции** — 5 help-страниц: БД, API ЛК, КриптоПро, Внешние системы, **Тестирование с роботом**.
|
||||
- **Новости** — лента событий + кнопка «Проверить обновления документации НРД сейчас».
|
||||
|
||||
Все надписи на русском.
|
||||
|
||||
### Мониторинг НРД (doc-watcher)
|
||||
- Раз в сутки скачивает страницы с сайта НРД, парсит ссылки на PDF, обновляет файлы в `DOC/` (старые версии переименовываются в `.YYYY-MM-DD.pdf.bak` для аудита).
|
||||
- Каждое обновление публикуется как новость в ленту.
|
||||
- Уже скачаны три свежие инструкции от 12.05.2026:
|
||||
- `instruktsiya-po-testirovaniyu-s-robotom.pdf` — инструкция по роботу-автотесту
|
||||
- `instruktsiya-...-fizicheskim-litsom-samomu-sebe.pdf` — обмен при self-transfer
|
||||
- `servis-most-m2m.pdf` — обзор сервиса
|
||||
|
||||
### Робот-автотест MOEX МОСТ
|
||||
- Реализован **внутренний робот-эмулятор**: bj-server понимает код робота `MC0012500000` и 4 тестовых сценария (1111/2001/2002/3333) через DocumentSeries.
|
||||
- Это позволяет проверить нашу логику обработки ответов **до того**, как у нас появится реальный ИШ + сертификат + доступ к TEST3.
|
||||
- Help-страница `/admin/help/robot` с полной документацией (коды ошибок M2M01-M2M09, тестовые наборы депозитариев, схема обмена).
|
||||
- Когда подключим реальный ИШ — переключение прозрачное, те же заявки пойдут на реальный TEST3.
|
||||
|
||||
### REST-клиент ИШ НРД (готов на нашей стороне)
|
||||
- По свежей спецификации НРД (`DOC/instr-ish-rest-api.pdf`) реализован Go-клиент в `internal/nsdadapter/igw`:
|
||||
- `POST /api/package/{channel}/file` — отправка ZIP (Type=archive, File=base64)
|
||||
- `GET /api/package/status/{id}` — статус: NEW / SENT / ERROR
|
||||
- `GET /api/package?channel=&type=M2MTD&...` — список входящих от НРД
|
||||
- `GET /api/package/{id}` — скачать ZIP пакета (поддерживает и raw ZIP, и base64-в-JSON)
|
||||
- Упаковщик (`pack.go`): `M2MTransferRequest → ZIP (XML + config.xml)` по разделу 2.3 инструкции
|
||||
- Распаковщик: ZIP → DocXML + winf.xml + .sgn (отсоединённая подпись НРД)
|
||||
- Парсеры: `ParseDecision`, `ParseResponse` — из XML в Go-структуры через `nsdxml.Unmarshal`
|
||||
- Покрыто тестами: 10/10 PASS (httptest + zip round-trip + 4xx без ретраев + retry на 5xx)
|
||||
- Готов к переключению: как только получим живой ИШ от НРД, нужно только указать BaseURL и Channel в `/admin/setup` — код уже всё умеет
|
||||
|
||||
### Авто-установщик «одной командой» (14.05.2026, поздний вечер)
|
||||
|
||||
Главная цель — оператор без знания Linux должен поднять систему **одной командой**:
|
||||
|
||||
```bash
|
||||
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Через 5-10 минут на свежей Astra Linux / Debian / Ubuntu ВМ работает веб-админка на :8080. Установщик `deploy/astra/install.sh`:
|
||||
|
||||
1. **Определяет ОС** — Astra SE/CE, Debian, Ubuntu (с предупреждениями для несовместимых)
|
||||
2. **Ставит зависимости через apt** — podman, postgresql-client, git, curl
|
||||
3. **Скачивает Go 1.24+** с go.dev (~70 МБ)
|
||||
4. **Создаёт пользователя bj** и каталоги /opt/bj /var/lib/bj /var/log/bj
|
||||
5. **Клонирует репо** в /opt/bj/src
|
||||
6. **Собирает bj-server** через go build
|
||||
7. **Поднимает PostgreSQL 16** в podman-контейнере, накатывает миграции
|
||||
8. **Кладёт systemd unit** с безопасными ограничениями (NoNewPrivileges, ProtectSystem=strict, ReadWritePaths)
|
||||
9. **Скачивает ИШ НРД** (~120 МБ) с `old.nsd.ru` и пытается установить через `dpkg -i`
|
||||
10. **Печатает понятную сводку** с URL'ами и списком того, что осталось руками
|
||||
|
||||
Дополнительные скрипты в `deploy/astra/`:
|
||||
- **`install-validata.sh`** — установка СКЗИ Валидата CSP когда придёт от НРД. Если дистрибутива ещё нет — печатает готовый текст письма для запроса в `soed@nsd.ru`
|
||||
- **`install-ish.sh`** — ручная установка ИШ из локального .deb (если автоскачивание не сработало)
|
||||
- **`healthcheck.sh`** — цветной отчёт о работоспособности всех 8 компонентов (ОС, пользователь, systemd, HTTP, PostgreSQL, Валидата, ИШ, сетевые порты)
|
||||
- **`import-data.sh`** — опциональный экспорт БД и настроек со старой ВМ (если переезжаем с действующего стенда)
|
||||
- **`README.md`** — TL;DR + полный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ (10 этапов, оценочно 2-3 недели от старта)
|
||||
|
||||
После запуска `install.sh` остаётся 3 ручных шага (НРД и УЦ МБ — без них никак): запрос Валидаты, получение сертификата УЦ МБ, заявка на TEST3.
|
||||
|
||||
### Дистрибутив ИШ и полная документация (получены 14.05.2026)
|
||||
По наводке от заказчика на странице `https://www.nsd.ru/workflow/system/programs/web-service/` найдены и скачаны все официальные материалы:
|
||||
|
||||
- **Дистрибутив ИШ Linux**: `dist/ish/igate_100.0-765_amd64.deb` (117 МБ, для Astra Linux)
|
||||
- **Электронная подпись к дистрибутиву**: `dist/ish/igate_95.0-716_amd64.SGN`
|
||||
- **DOC/ruk_install_ish_2025_11_10.pdf** (4.7 МБ) — Руководство по установке ИШ от 10.11.2025. Главное:
|
||||
- Поддерживаемые ОС: Windows 10/Server, **Astra Linux SE 1.6/1.7** (РЕД ОС не упомянута)
|
||||
- СКЗИ: **«Валидата CSP» + АПК «Валидата Клиент L»** (НЕ КриптоПро)
|
||||
- БД: SQLite или PostgreSQL (PostgreSQL обязателен для REST API)
|
||||
- Только ГОСТ-криптография под Linux (RSA — только Windows)
|
||||
- Только сертификаты от УЦ МБ
|
||||
- **DOC/ruk_pol_ish.pdf** (3.5 МБ) — Руководство пользователя ИШ
|
||||
- **DOC/QA_ish.pdf** (2.5 МБ) — Q&A
|
||||
- **DOC/test-case_ish.pdf** (1.3 МБ) — Тест-кейсы для проверки работоспособности ИШ
|
||||
- **DOC/instr_int_sh_01072025.pdf** (0.4 МБ) — Инструкция по созданию заявки на тестирование
|
||||
- **DOC/web_service_nrd_standard_soap_rest.pdf** (2.2 МБ) — Техрекомендации Web-сервиса ONYX
|
||||
|
||||
`dist/ish/.deb` не коммитится в git (большой), но `dist/ish/README.md` содержит все ссылки на повторное скачивание.
|
||||
|
||||
### Безопасность и надёжность
|
||||
- Баннер «🟡 РЕЖИМ ЭМУЛЯЦИИ» отображается на каждой странице админки пока не настроен ИШ или СКЗИ — оператор не сможет случайно принять mock-результат за реальный.
|
||||
- Контекстная навигация после действий (после POST возврат на ту же страницу, не в /admin/setup).
|
||||
- HTTP-клиенты для запросов на nsd.ru/cryptopro.ru идут напрямую (игнорируют корпоративный прокси), браузерный User-Agent для обхода антибот-фильтров.
|
||||
- systemd-unit `deploy/systemd/bj-server.service` с `Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64`, ProtectSystem=strict, NoNewPrivileges, и т.п.
|
||||
|
||||
---
|
||||
|
||||
## Что в процессе и в очереди
|
||||
|
||||
### Внешние блокеры (без них не двинемся к реальному НРД)
|
||||
|
||||
1. **Astra Linux ВМ для ИШ** ⭐ новый блокер
|
||||
- Дистрибутив ИШ — только `igate_100.0-765_amd64.deb` (под Astra Linux SE 1.6/1.7). РЕД ОС официально не поддерживается, RPM-версии нет.
|
||||
- Что нужно: поднять отдельную Astra Linux ВМ (10.10.10.23?) или попробовать запустить ИШ в Docker-контейнере с Astra-образом.
|
||||
- Альтернативы: Windows 10/Server (есть .exe-дистрибутив, но это шаг назад от Linux).
|
||||
- Срок: зависит от инфра-команды; ~1 день поднять ВМ + ~30 мин установить ИШ.
|
||||
|
||||
2. **СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»** ⭐ новый блокер
|
||||
- ИШ требует именно Валидату, **НЕ КриптоПро CSP** (у нас стоит КриптоПро, придётся ставить параллельно или вместо).
|
||||
- Где взять: только по запросу — `soed@nsd.ru` (НРД) или `pki@moex.com` (МБ). Временная лицензия выдаётся.
|
||||
- Что нужно: отправить письмо с реквизитами организации и обоснованием (подключение к MOEX МОСТ M2M).
|
||||
- Срок: ~1-3 дня на ответ НРД.
|
||||
|
||||
3. **Сертификат подписи УЦ Московской Биржи** (ca.moex.com)
|
||||
- Нужен для подписи отправляемых сообщений. Кладётся в Справочник сертификатов АПК Валидата Клиент L, экспортируется в системное хранилище.
|
||||
- Что нужно: оформить заявку в УЦ МБ от организации, получить сертификат + приватный ключ.
|
||||
- Срок: зависит от УЦ МБ.
|
||||
|
||||
4. **Заявка на тестирование в TEST3 НРД**
|
||||
- Форма: `https://www.nsd.ru/workflow/zayavka-na-testirovanie/`
|
||||
- Инструкция: `DOC/instr_int_sh_01072025.pdf`
|
||||
- Получаем код депонента-тестера и доступ к контурам GUEST/TEST3.
|
||||
|
||||
5. **Сертификаты УЦ НРД** (для проверки квитанций)
|
||||
- Где взять: `https://www.nsd.ru/workflow/system/cryptography/` — сейчас отдаёт 404 на нашем дев-стенде (вероятно перенесено в ЛК НРД депонента).
|
||||
- В коде уже есть форма «Авто-загрузка сертификатов УЦ» в `/admin/setup` — как только получим прямые URL .cer, добавим их.
|
||||
|
||||
6. **Окно техработ TEST3: 18.05.2026 — 22.05.2026**
|
||||
- Полевое тестирование в этот период невозможно. Реальные прогоны — до 18-го или после 22-го мая.
|
||||
|
||||
7. **Доступ к API реального ЛК ESIA Finance**
|
||||
- Сейчас bj-server работает с встроенным эмулятором `lk-emulator` на :8083.
|
||||
- Что нужно: URL продакшен/тест ЛК, Basic-auth учётка.
|
||||
|
||||
### Внутренние задачи (можем делать параллельно)
|
||||
|
||||
| Задача | Приоритет | Эффект |
|
||||
|---|---|---|
|
||||
| Завершить сценарий 3333 робота — приёмная сторона bj-server (входящие M2MTransferRequest) | средний | Полное покрытие тестов с роботом |
|
||||
| UI для импорта сертификата из контейнера КриптоПро (после копирования с флешки) | низкий | Сейчас делается вручную через certmgr |
|
||||
| Уведомления: SMTP, Yandex Messenger, Telegram (плагины через единый интерфейс Notifier) | средний (M3) | Операторам — критичные события в мессенджеры |
|
||||
| Расширение тестов: unit + интеграционные с mock-роботом, нагрузочные | низкий | Уверенность перед прод |
|
||||
| Документация для команды Fansy (ETL): передача контракта, согласование SLA, прописывание IP в pg_hba.conf | средний | Запуск ETL-потока |
|
||||
|
||||
---
|
||||
|
||||
## Что после получения ИШ и сертификата (план первичного тестирования с роботом)
|
||||
|
||||
1. **День 0** (получили дистрибутив ИШ + сертификат + руководство)
|
||||
- Поставить ИШ на dev-ВМ.
|
||||
- Положить сертификат в ИШ-хранилище.
|
||||
- В bj-server: `/admin/setup` → ИШ профиль `test3-gost`, URL ИШ.
|
||||
2. **День 1** (smoke-тест)
|
||||
- Отправить через bj-server заявку с ReceiverCode = `MC0012500000`, DocumentSeries = `2001`, DocumentNumber = `111111` → ожидаем «принять все бумаги» от робота.
|
||||
- Проверить: квитанция от НРД, Decision от робота, callback в `lk-emulator`, статус в журнале → `confirmed`.
|
||||
3. **День 2-3** (полное покрытие сценариев)
|
||||
- 1111 (отказ M2M01..M2M09) — все коды ошибок.
|
||||
- 2001 / 2002 — все депозитарии, все варианты частичного приёма.
|
||||
- 3333 — встречный перевод (когда доделаем приёмную сторону).
|
||||
4. **День 4-5** (нагрузка)
|
||||
- 50-100 одновременных заявок, проверка очередей, БД, корректность статусов.
|
||||
5. **День 6** (живой контрагент)
|
||||
- Согласовать с любым подключённым к НРД депозитарием тестовый обмен.
|
||||
- Это последний шаг перед присоединением к Правилам ЭДО НРД (продакшен).
|
||||
|
||||
**Реалистичный срок от получения ИШ до готовности к продакшену: 2-3 недели** (включая обкатку, fix багов, документацию).
|
||||
|
||||
---
|
||||
|
||||
## Стоимостная сводка
|
||||
|
||||
| Статья | Сумма (руб) | Статус |
|
||||
|---|---:|---|
|
||||
| Лицензия КриптоПро CSP (сервер) | ~30 000-50 000 | Демо 3 мес. — активна |
|
||||
| Лицензия КриптоПро CSP (рабочее место оператора, опц.) | ~2 000-3 000 | Не куплена |
|
||||
| Рутокен ЭЦП 2.0 для оператора (железо, опц.) | ~3 000-5 000 | Не куплено |
|
||||
| Сертификат УЦ МБ для организации | по тарифам УЦ | Не получен |
|
||||
| **Сэкономлено** против КриптоПро JCP | ~30 000-50 000 | (отказ от Java-стека) |
|
||||
|
||||
---
|
||||
|
||||
## Ключевые архитектурные решения
|
||||
|
||||
1. **Один бинарник вместо микросервисов** — рассчитано на наш объём (100-1000 сделок/день). Упрощает деплой, отладку, мониторинг. Все компоненты в одном процессе с понятными границами пакетов (`internal/m2mcore`, `internal/nsdadapter`, `internal/lkgateway`, ...).
|
||||
2. **PKCS#11 как единый интерфейс к СКЗИ** — позволяет менять провайдер (CSP/Рутокен/Валидата) без изменения кода bj-server.
|
||||
3. **Двух-уровневая БД** (`fansy.*` для входных данных, `m2m_core.*` для журнала сделок) — позволяет команде Fansy писать в свою схему без знания о нашем pipeline.
|
||||
4. **Mock-робот внутри bj-server** — даёт возможность работать без живого НРД для значительной части интеграционного тестирования.
|
||||
5. **«Дружественный» UI** — установка/настройка не требует SSH-доступа: всё через веб (КриптоПро, лицензии, сертификаты, контейнеры с флешки).
|
||||
|
||||
---
|
||||
|
||||
**Готов к интеграционному тестированию с роботом на TEST3:** да, как только будет ИШ + сертификат.
|
||||
**Готов к продакшену:** ориентировочно через 3-4 недели после получения всех внешних компонентов.
|
||||
|
||||
— Команда разработки Bridge-and-Join-s
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package main — единый сервис bj-server.
|
||||
//
|
||||
// Объединяет в одном процессе: lk-gateway (REST API ЛК + admin web UI),
|
||||
// m2m-core (FSM сделки, репозиторий, эмиссия и потребление Decision),
|
||||
// nsd-adapter (REST к ИШ НРД и опрос входящих, когда профиль настроен),
|
||||
// notify (заглушка отправки уведомлений). lk-emulator живёт отдельным
|
||||
// бинарником как QA-инструмент.
|
||||
//
|
||||
// Архитектура подсказана объёмом 100-1000 сделок/день: для такого
|
||||
// потока избыточно держать 5 отдельных процессов и микросервисную
|
||||
// шину. Один Go-бинарник проще деплоить, проще наблюдать и
|
||||
// масштабировать вертикально, а компоненты внутри по-прежнему
|
||||
// разделены пакетами internal/<...>.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||
)
|
||||
|
||||
const serviceName = "bj-server"
|
||||
|
||||
func main() {
|
||||
addr := getenv("BJ_HTTP_ADDR", ":8080")
|
||||
defaultSender := m2m.DeponentCode(getenv("BJ_M2M_SENDER", "MC0079200000"))
|
||||
defaultReceiver := m2m.DeponentCode(getenv("BJ_M2M_RECEIVER", "MC0010300000"))
|
||||
setupPath := os.Getenv("BJ_SETUP_PATH")
|
||||
|
||||
cfg := lkgateway.ServerConfig{
|
||||
Addr: addr,
|
||||
DefaultSender: defaultSender,
|
||||
DefaultReceiver: defaultReceiver,
|
||||
SetupPath: setupPath,
|
||||
CheckOptions: func() lkgateway.CheckOptions {
|
||||
return lkgateway.CheckOptions{
|
||||
PostgresDSN: os.Getenv("BJ_DSN"),
|
||||
CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"),
|
||||
NSDAdapterURL: os.Getenv("BJ_NSD_ADAPTER_URL"),
|
||||
LKCallbackURL: os.Getenv("BJ_LK_CALLBACK_URL"),
|
||||
Profile: getenv("BJ_NSD_PROFILE", "demo (mock NSD)"),
|
||||
CryptoProvider: getenv("BJ_CRYPTO_PROVIDER", "stub"),
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
srv, err := lkgateway.NewServer(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("%s: NewServer: %v", serviceName, err)
|
||||
}
|
||||
if cb := os.Getenv("BJ_LK_CALLBACK_URL"); cb != "" {
|
||||
srv.SetCallbackURL(cb)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Опционально — поллер входящих пакетов ИШ НРД. Запускается если
|
||||
// BJ_NSD_PROFILE задан (после установки реального ИШ через UI этот
|
||||
// блок будет тянуть Decisions из настоящего НРД и применять их через
|
||||
// lkgateway.Service.ApplyDecision).
|
||||
if profileName := os.Getenv("BJ_NSD_PROFILE"); profileName != "" {
|
||||
go runNSDPoller(ctx, profileName)
|
||||
}
|
||||
|
||||
// notify-демон: пока заглушка, в M3-M4 будет рассылать события
|
||||
// (e-mail, Yandex Messenger, Telegram, WS-push в admin-ui).
|
||||
go runNotifyWorker(ctx)
|
||||
|
||||
log.Printf("%s: запуск, HTTP %s", serviceName, addr)
|
||||
runErr := srv.Run(ctx)
|
||||
stop()
|
||||
if runErr != nil {
|
||||
log.Printf("%s: %v", serviceName, runErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runNSDPoller — фоновый поллер входящих пакетов ИШ НРД.
|
||||
func runNSDPoller(ctx context.Context, profileName string) {
|
||||
profile, err := nsdadapter.LookupProfile(profileName)
|
||||
if err != nil {
|
||||
log.Printf("%s: NSD poller: %v (доступные профили: %v)", serviceName, err, nsdadapter.AvailableProfiles())
|
||||
return
|
||||
}
|
||||
interval := 30 * time.Second
|
||||
if v := os.Getenv("BJ_NSD_POLL_INTERVAL"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
interval = d
|
||||
}
|
||||
}
|
||||
client := igw.NewClient(profile.IGWBaseURL, igw.WithRetry(profile.RetryMax, profile.RetryBackoff))
|
||||
log.Printf("%s: NSD poller: профиль %s, канал %s, ИШ %s, интервал %s",
|
||||
serviceName, profile.Name, profile.Channel, profile.IGWBaseURL, interval)
|
||||
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
since := time.Now().UTC().Add(-time.Hour)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
for _, kind := range nsdadapter.IncomingPackageKinds() {
|
||||
pkgs, err := client.ListIncoming(ctx, igw.ListFilter{
|
||||
Channel: profile.Channel,
|
||||
Date: since,
|
||||
Type: string(kind),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err)
|
||||
continue
|
||||
}
|
||||
for _, p := range pkgs {
|
||||
log.Printf("%s: NSD входящий пакет id=%d (%s) типа %s, канал %s, state %s",
|
||||
serviceName, p.ID, p.Name, p.Type, p.Channel, p.State)
|
||||
// TODO(M3): GetPackage(p.ID) → unpack ZIP → парсить XML →
|
||||
// передавать в lkgateway.Service.ApplyDecision
|
||||
}
|
||||
}
|
||||
since = time.Now().UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runNotifyWorker — заглушка демона уведомлений.
|
||||
func runNotifyWorker(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
// На M3-M4 здесь будет: вытащить очередь событий из БД,
|
||||
// разослать по настроенным каналам (e-mail, мессенджер).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
+39
-11
@@ -1,20 +1,48 @@
|
||||
// Package main — сервис lk-emulator. Эмулятор ЛК клиента (ESIA Finance API V1)
|
||||
// на время, пока реальный ЛК не готов. Позволяет «как будто загрузить»
|
||||
// заявление через веб-форму и запустить полный путь обработки документа.
|
||||
// Package main — сервис lk-emulator. Имитация ЛК клиента (ESIA Finance
|
||||
// API V1) на время, пока реальный ЛК не готов. Веб-форма «новая заявка»,
|
||||
// журнал моих заявок, приёмник callback'ов от lk-gateway.
|
||||
//
|
||||
// Когда реальный ЛК подключится — эмулятор остаётся как тестовый инструмент
|
||||
// в QA-окружении.
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
// Когда реальный ЛК подключится, эмулятор остаётся как тестовый
|
||||
// инструмент в QA-окружении: даёт сквозной сценарий без зависимости от
|
||||
// внешней стороны.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator"
|
||||
)
|
||||
|
||||
const serviceName = "lk-emulator"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
addr := getenv("BJ_HTTP_ADDR", ":8083")
|
||||
gw := getenv("BJ_GATEWAY_URL", "http://127.0.0.1:8080")
|
||||
self := getenv("BJ_EMULATOR_PUBLIC_URL", "http://127.0.0.1:8083")
|
||||
|
||||
srv, err := lkemulator.NewServer(lkemulator.ServerConfig{
|
||||
Addr: addr,
|
||||
GatewayURL: gw,
|
||||
SelfPublicURL: self,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("lk-emulator: NewServer: %v", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
runErr := srv.Run(ctx)
|
||||
stop()
|
||||
if runErr != nil {
|
||||
log.Printf("lk-emulator: %v", runErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Package main — сервис lk-gateway. Принимает заявления от ЛК клиента
|
||||
// (платформа ESIA Finance, /api/v1/back_office/...), валидирует их подпись,
|
||||
// передаёт в m2m-core, отдаёт callback-статусы обратно в ЛК.
|
||||
//
|
||||
// На этапе M1 — заглушка. Реализация контракта — M2.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "lk-gateway"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Package main — сервис m2m-core. Бизнес-логика и FSM сделки M2M-перевода:
|
||||
// идемпотентность по GUID, валидация по XSD, метрики SLA, ветка ручного
|
||||
// согласования и таймаут-отказа MOST.
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "m2m-core"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Package main — сервис notify. Отправка уведомлений по нескольким каналам:
|
||||
// e-mail (SMTP), Yandex Messenger (Yandex 360), WebSocket-push в admin-ui,
|
||||
// плюс расширяемая модель провайдеров-плагинов (smtp, yandex360, telegram,
|
||||
// mattermost, webhook) под единый интерфейс Notifier — для тиражирования
|
||||
// продукта другим компаниям.
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "notify"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Package main — сервис nsd-adapter. Транспорт к НРД:
|
||||
// - Интеграционный шлюз через REST API (основной канал, ИШ сам подписывает);
|
||||
// - Web-сервис ONYX напрямую (резерв);
|
||||
// - Файловый шлюз / обменные папки ИШ (fallback).
|
||||
//
|
||||
// Сериализация и парсинг XML по схемам M2MSchemas в windows-1251,
|
||||
// маршрутизация по типам пакетов (#M2MTR / #M2MTD / #M2MER / SUBBR / SUBER /
|
||||
// SUB16 / Справки / квитанции ЭДО).
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "nsd-adapter"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# Bridge-and-Join-s — установка одной командой
|
||||
|
||||
## TL;DR — на свежей Astra Linux / Debian / Ubuntu ВМ
|
||||
|
||||
```bash
|
||||
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Через **5-10 минут** будет работать веб-админка на `http://<ip>:8080/admin/`.
|
||||
|
||||
Установщик сам:
|
||||
- Определит ОС (Astra SE/CE, Debian, Ubuntu)
|
||||
- Поставит зависимости (apt: podman, postgresql-client, git)
|
||||
- Скачает и установит Go 1.24+
|
||||
- Создаст системного пользователя `bj` и каталоги
|
||||
- Склонирует репозиторий в `/opt/bj/src`
|
||||
- Соберёт `bj-server` из исходников
|
||||
- Поднимет PostgreSQL 16 в podman-контейнере и накатит миграции
|
||||
- Поставит systemd unit и запустит сервис
|
||||
- Скачает дистрибутив ИШ НРД (~120 МБ) и попытается установить через `dpkg`
|
||||
|
||||
После завершения скрипта тебе печатается понятная сводка с URL'ами и
|
||||
списком того, что осталось сделать руками.
|
||||
|
||||
---
|
||||
|
||||
## Какая нужна ВМ
|
||||
|
||||
| Параметр | Минимум | Рекомендуется |
|
||||
|---|---|---|
|
||||
| ОС | Debian 11+ / Astra CE 1.8 / Astra SE 1.6+ / Ubuntu 22.04+ | **Astra Linux SE 1.7** (для прод) |
|
||||
| CPU | 2 ядра | 4 ядра |
|
||||
| RAM | 2 ГБ | 4 ГБ |
|
||||
| Диск | 20 ГБ | 50 ГБ SSD |
|
||||
| Сеть | прямой выход в интернет | + статический IP |
|
||||
|
||||
**Что я понимаю про лицензии Astra Linux:**
|
||||
|
||||
- **Astra SE** — платная (~2-5 тыс. ₽/лицензия), сертифицирована ФСТЭК/ФСБ → нужна для прода с гос-требованиями
|
||||
- **Astra CE** — бесплатная, без сертификации, тот же базовый дистрибутив → можно использовать для дева и тестов, а для прода докупить SE
|
||||
- **Debian 12** — полностью бесплатный, технически на 95% совместим с Astra (один и тот же базовый дистрибутив), ИШ скорее всего тоже взлетит, но НРД официально не поддерживает
|
||||
|
||||
---
|
||||
|
||||
## Скрипты в этом каталоге
|
||||
|
||||
| Файл | Когда запускать | Что делает |
|
||||
|---|---|---|
|
||||
| **`install.sh`** | сразу после поднятия ВМ | Главный скрипт. Делает всё одной командой |
|
||||
| **`install-validata.sh`** | когда придёт Валидата от НРД | Установка СКЗИ Валидата CSP |
|
||||
| **`install-ish.sh`** | если `install.sh` не установил ИШ автоматически | Ручная установка ИШ из локального .deb |
|
||||
| **`healthcheck.sh`** | для проверки состояния | Цветной отчёт о работоспособности всех компонентов |
|
||||
| **`import-data.sh`** | (опционально) если переносишь с другой ВМ | Экспорт БД и настроек со старой ВМ для импорта на новую |
|
||||
|
||||
---
|
||||
|
||||
## Что произойдёт ПОСЛЕ автоматической установки
|
||||
|
||||
`install.sh` дойдёт до точки, где **bj-server работает, но в режиме эмуляции** — потому что Валидата и сертификат УЦ МБ автоматически получить нельзя. В админке сверху будет жёлтая плашка «РЕЖИМ ЭМУЛЯЦИИ». Это ожидаемо.
|
||||
|
||||
### Что нужно сделать пользователю руками
|
||||
|
||||
#### 1. Запросить Валидата CSP в НРД (1 письмо)
|
||||
Email: `soed@nsd.ru` или `pki@moex.com`. Текст подскажет сам скрипт `install-validata.sh` — есть шаблон. Срок ответа НРД — 1-3 дня.
|
||||
|
||||
Когда придёт .deb пакет:
|
||||
```bash
|
||||
sudo bash /opt/bj/src/deploy/astra/install-validata.sh /path/to/validata.deb
|
||||
```
|
||||
|
||||
#### 2. Получить сертификат УЦ Московской Биржи
|
||||
`https://ca.moex.com/` — оформить заявку от организации. Срок — зависит от УЦ.
|
||||
|
||||
#### 3. Подать заявку на тестирование в TEST3 НРД
|
||||
`https://www.nsd.ru/workflow/zayavka-na-testirovanie/` — получить код депонента-тестера.
|
||||
|
||||
#### 4. Когда всё пришло — настроить ИШ через его GUI
|
||||
По `DOC/ruk_install_ish_2025_11_10.pdf` (раздел 10):
|
||||
- Указать БД PostgreSQL (DSN уже в `/var/lib/bj/.bj/setup.json`)
|
||||
- Создать канал WSL с URL `https://gost.nsd.ru/onyxt3/WslService` (TEST3)
|
||||
- Импортировать сертификат УЦ МБ из системного хранилища
|
||||
- Запустить ИШ как сервис: `sudo systemctl enable --now igate`
|
||||
|
||||
#### 5. Привязать bj-server к ИШ
|
||||
`http://<ip>:8080/admin/setup` → раздел «ИШ НРД»:
|
||||
- URL ИШ: `http://localhost:8090` (порт REST API ИШ)
|
||||
- Имя канала: то что задал в ИШ на шаге 4
|
||||
|
||||
После этого жёлтая плашка «РЕЖИМ ЭМУЛЯЦИИ» исчезнет — сообщения пойдут в реальный НРД.
|
||||
|
||||
---
|
||||
|
||||
## Параметры установки
|
||||
|
||||
`install.sh` принимает флаги:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --bind=:8080 --skip-ish --yes
|
||||
```
|
||||
|
||||
| Флаг | По умолчанию | Что делает |
|
||||
|---|---|---|
|
||||
| `--bind=:8080` | `:8080` | На каком адресе/порту слушать |
|
||||
| `--branch=main` | `main` | Из какой ветки репо собирать |
|
||||
| `--skip-ish` | (выкл) | Не скачивать дистрибутив ИШ (если стоят жёсткие ограничения по интернету) |
|
||||
| `--yes` / `-y` | (выкл) | Не задавать вопросов, отвечать «да» автоматически |
|
||||
|
||||
Также через переменные окружения: `REPO_URL`, `BRANCH`, `BIND_ADDR`, `ISH_DEB_URL`, `NON_INTERACTIVE`.
|
||||
|
||||
---
|
||||
|
||||
## Если что-то сломалось
|
||||
|
||||
| Симптом | Решение |
|
||||
|---|---|
|
||||
| `bj-server.service не active` | `journalctl -u bj-server -n 50` |
|
||||
| HTTP 200 не отвечает | проверь что :8080 открыт; `ss -tlnp \| grep 8080` |
|
||||
| Миграции не накатились | `podman exec bj-postgres psql -U bj -l` и `\dt fansy.*` |
|
||||
| ИШ не скачался | положи `igate_100.0-765_amd64.deb` в `/opt/bj/src/dist/ish/` и перезапусти `install.sh` |
|
||||
| Валидата не установлена | это **нормально** на старте — заказывай у НРД, потом `install-validata.sh` |
|
||||
| Не определилась ОС | поддерживаются: Astra, Debian, Ubuntu. Для других — открой issue |
|
||||
|
||||
Health-check всё сразу:
|
||||
```bash
|
||||
sudo bash /opt/bj/src/deploy/astra/healthcheck.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Полный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ
|
||||
|
||||
| Этап | Что делается | Срок |
|
||||
|---|---|---|
|
||||
| 1. Поднять Astra Linux ВМ | у инфра-команды | 1 день |
|
||||
| 2. Запустить `install.sh` | автоматически | 5-10 мин |
|
||||
| 3. Запросить Валидату в НРД | письмо в `soed@nsd.ru` | 1-3 дня ожидания |
|
||||
| 4. Получить сертификат УЦ МБ | заявка в `ca.moex.com` | 1-2 недели ожидания |
|
||||
| 5. Подать заявку на TEST3 | форма на сайте НРД | 2-5 дней |
|
||||
| 6. Установить Валидату | `install-validata.sh` | 5 мин |
|
||||
| 7. Импортировать сертификат | GUI Валидаты, экспорт в системное хранилище | 15 мин |
|
||||
| 8. Настроить ИШ | GUI ИШ, создать канал WSL | 30 мин |
|
||||
| 9. Привязать bj-server к ИШ | `/admin/setup` через UI | 5 мин |
|
||||
| 10. Прогнать тест с роботом | `/admin/setup` → кнопка | 1 мин |
|
||||
|
||||
**Итог: 2-3 недели от старта до зелёного теста с роботом MOEX МОСТ.** На нашей стороне всё уже готово — задержки только во внешних запросах.
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/bin/bash
|
||||
# healthcheck.sh — проверка готовности bj-server после установки на Astra Linux.
|
||||
# Запускается на самой Astra Linux ВМ, печатает зелёные/жёлтые/красные галочки.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ok() { echo -e " \033[1;32m✓\033[0m $*"; }
|
||||
warn() { echo -e " \033[1;33m⚠\033[0m $*"; }
|
||||
fail() { echo -e " \033[1;31m✗\033[0m $*"; }
|
||||
|
||||
echo "================================================================"
|
||||
echo " Bridge-and-Join-s — проверка состояния"
|
||||
echo "================================================================"
|
||||
|
||||
# 1. ОС
|
||||
echo
|
||||
echo "[1] Операционная система"
|
||||
if [ -r /etc/astra_version ]; then
|
||||
ok "Astra Linux: $(cat /etc/astra_version)"
|
||||
else
|
||||
warn "Не Astra Linux — ИШ может не запуститься"
|
||||
fi
|
||||
|
||||
# 2. Пользователь bj
|
||||
echo
|
||||
echo "[2] Пользователь и каталоги"
|
||||
id bj >/dev/null 2>&1 && ok "пользователь bj существует" || fail "пользователь bj не создан"
|
||||
[ -d /opt/bj ] && ok "/opt/bj существует" || fail "/opt/bj не найден"
|
||||
[ -x /opt/bj/bj-server ] && ok "/opt/bj/bj-server исполняемый" || fail "/opt/bj/bj-server отсутствует"
|
||||
[ -d /var/lib/bj/.bj ] && ok "/var/lib/bj/.bj существует" || warn "/var/lib/bj/.bj не создан"
|
||||
|
||||
# 3. systemd
|
||||
echo
|
||||
echo "[3] systemd сервис"
|
||||
if systemctl is-enabled --quiet bj-server 2>/dev/null; then
|
||||
ok "bj-server.service enabled"
|
||||
else
|
||||
warn "bj-server.service не enabled"
|
||||
fi
|
||||
if systemctl is-active --quiet bj-server 2>/dev/null; then
|
||||
ok "bj-server.service active"
|
||||
else
|
||||
fail "bj-server.service не active — systemctl status bj-server"
|
||||
fi
|
||||
|
||||
# 4. HTTP
|
||||
echo
|
||||
echo "[4] HTTP-эндпоинты"
|
||||
HTTP_OK=0
|
||||
for path in / /admin/ /admin/wizard /admin/help/architecture; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8080$path" 2>/dev/null || echo "—")
|
||||
if [ "$code" = "200" ] || [ "$code" = "303" ]; then
|
||||
ok "GET $path → $code"
|
||||
HTTP_OK=$((HTTP_OK+1))
|
||||
else
|
||||
fail "GET $path → $code"
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. PostgreSQL
|
||||
echo
|
||||
echo "[5] PostgreSQL"
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
|
||||
ok "контейнер bj-postgres работает"
|
||||
else
|
||||
warn "контейнер bj-postgres не запущен"
|
||||
fi
|
||||
else
|
||||
warn "podman не установлен"
|
||||
fi
|
||||
if pg_isready -h 127.0.0.1 -p 5432 -U bj >/dev/null 2>&1; then
|
||||
ok "PostgreSQL отвечает на :5432"
|
||||
else
|
||||
warn "PostgreSQL :5432 недоступен"
|
||||
fi
|
||||
|
||||
# 6. Валидата
|
||||
echo
|
||||
echo "[6] СКЗИ Валидата (для ИШ)"
|
||||
VAL_FOUND=0
|
||||
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP; do
|
||||
[ -d "$path" ] && { ok "найдена в $path"; VAL_FOUND=1; break; }
|
||||
done
|
||||
[ "$VAL_FOUND" = 0 ] && warn "не установлена (запроси у НРД soed@nsd.ru, потом sudo bash deploy/astra/install-validata.sh)"
|
||||
|
||||
# 7. ИШ
|
||||
echo
|
||||
echo "[7] Интеграционный шлюз (ИШ)"
|
||||
if command -v igate >/dev/null 2>&1; then
|
||||
ok "igate в PATH: $(which igate)"
|
||||
elif [ -x /opt/igate/igate ]; then
|
||||
ok "igate в /opt/igate/"
|
||||
else
|
||||
warn "ИШ не установлен (sudo bash deploy/astra/install-ish.sh)"
|
||||
fi
|
||||
|
||||
# 8. Сетевые порты
|
||||
echo
|
||||
echo "[8] Сетевые порты"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
PORTS=$(ss -tlnp 2>/dev/null | awk 'NR>1{print $4}')
|
||||
echo "$PORTS" | grep -q ':8080$' && ok ":8080 (bj-server) слушает" || fail ":8080 не слушает"
|
||||
echo "$PORTS" | grep -q ':5432$' && ok ":5432 (postgres) слушает" || warn ":5432 не слушает"
|
||||
echo "$PORTS" | grep -q ':8090$' && ok ":8090 (предполагаемый ИШ) слушает" || warn ":8090 (ИШ) не слушает"
|
||||
fi
|
||||
|
||||
# Итог
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo " Готово. Подробнее:"
|
||||
echo " journalctl -u bj-server -f"
|
||||
echo " http://$(hostname -I | awk '{print $1}'):8080/admin/"
|
||||
echo "================================================================"
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
# migrate-from-redos.sh — экспорт состояния со старой ВМ (РЕД ОС 10.10.10.22)
|
||||
# для переноса на новую Astra Linux ВМ.
|
||||
#
|
||||
# Запускать на СТАРОЙ ВМ (РЕД ОС). Создаст архив /tmp/bj-migration-YYYY-MM-DD.tar.gz
|
||||
# с:
|
||||
# - дампом БД (pg_dump на оба схема: fansy.* и m2m_core.*)
|
||||
# - содержимым ~bj/.bj/setup.json (или ~/.bj/setup.json для dev)
|
||||
# - логами /var/log/bj/ (за последние 7 дней)
|
||||
# - списком установленных пакетов (для справки)
|
||||
#
|
||||
# Архив надо перенести на новую ВМ (scp/rsync), там распаковать и натравить
|
||||
# на install-astra.sh с флагом --import=/path/to/archive.tar.gz (TODO).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUT_DIR="/tmp/bj-migration-$(date +%Y-%m-%d-%H%M)"
|
||||
OUT_TAR="${OUT_DIR}.tar.gz"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
log() { echo -e "\033[1;34m[migrate-export]\033[0m $*"; }
|
||||
warn() { echo -e "\033[1;33m[migrate-export WARN]\033[0m $*" >&2; }
|
||||
|
||||
# ---- 1. Дамп БД ----
|
||||
log "1/5: дамп PostgreSQL"
|
||||
DSN="${BJ_PG_DSN:-postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable}"
|
||||
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
|
||||
log " через podman exec bj-postgres"
|
||||
podman exec bj-postgres pg_dump -U bj -d bj --clean --if-exists > "$OUT_DIR/bj.sql" \
|
||||
|| warn " pg_dump упал — проверь контейнер bj-postgres"
|
||||
else
|
||||
log " напрямую pg_dump"
|
||||
pg_dump "$DSN" --clean --if-exists > "$OUT_DIR/bj.sql" \
|
||||
|| warn " pg_dump упал — проверь DSN"
|
||||
fi
|
||||
[ -f "$OUT_DIR/bj.sql" ] && log " размер дампа: $(du -h "$OUT_DIR/bj.sql" | awk '{print $1}')"
|
||||
|
||||
# ---- 2. Конфигурация ----
|
||||
log "2/5: ~/.bj/setup.json"
|
||||
for candidate in /var/lib/bj/.bj/setup.json ~/.bj/setup.json /root/.bj/setup.json; do
|
||||
if [ -f "$candidate" ]; then
|
||||
cp "$candidate" "$OUT_DIR/setup.json"
|
||||
log " скопировано из $candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 3. Логи ----
|
||||
log "3/5: логи за 7 дней"
|
||||
mkdir -p "$OUT_DIR/logs"
|
||||
if [ -d /var/log/bj ]; then
|
||||
find /var/log/bj -type f -mtime -7 -exec cp {} "$OUT_DIR/logs/" \; 2>/dev/null || true
|
||||
fi
|
||||
journalctl -u bj-server --since "7 days ago" --no-pager > "$OUT_DIR/logs/journal.log" 2>/dev/null || true
|
||||
|
||||
# ---- 4. Пакеты, версии (для справки) ----
|
||||
log "4/5: метаинформация"
|
||||
{
|
||||
echo "=== ОС ==="
|
||||
cat /etc/os-release 2>/dev/null || echo "no os-release"
|
||||
echo
|
||||
echo "=== uname ==="
|
||||
uname -a
|
||||
echo
|
||||
echo "=== Установленные RPM (только наши пакеты) ==="
|
||||
rpm -qa 2>/dev/null | grep -iE "cprocsp|crypto|postgresql|podman|go" || true
|
||||
echo
|
||||
echo "=== Версия bj-server ==="
|
||||
/opt/bj/bj-server --version 2>/dev/null || echo "не определена"
|
||||
echo
|
||||
echo "=== Дата создания дампа ==="
|
||||
date
|
||||
} > "$OUT_DIR/meta.txt"
|
||||
|
||||
# ---- 5. README ----
|
||||
cat > "$OUT_DIR/README.md" <<EOF
|
||||
# Миграция bj-server с РЕД ОС на Astra Linux
|
||||
|
||||
Дамп создан: $(date)
|
||||
Источник: $(hostname) ($(hostname -I | awk '{print $1}'))
|
||||
|
||||
## Файлы
|
||||
|
||||
- \`bj.sql\` — дамп PostgreSQL базы bj (схемы fansy + m2m_core)
|
||||
- \`setup.json\` — настройки bj-server (DSN, IGW URL, и т.п.)
|
||||
- \`logs/\` — последние логи bj-server
|
||||
- \`meta.txt\` — версии ОС, пакетов
|
||||
|
||||
## Восстановление на Astra Linux
|
||||
|
||||
\`\`\`bash
|
||||
# 1. На новой ВМ — ставим bj-server
|
||||
sudo bash deploy/astra/install.sh
|
||||
|
||||
# 2. Восстанавливаем БД
|
||||
podman exec -i bj-postgres psql -U bj -d bj < bj.sql
|
||||
|
||||
# 3. Восстанавливаем настройки
|
||||
sudo cp setup.json /var/lib/bj/.bj/setup.json
|
||||
sudo chown bj:bj /var/lib/bj/.bj/setup.json
|
||||
sudo chmod 0600 /var/lib/bj/.bj/setup.json
|
||||
|
||||
# 4. Перезапуск
|
||||
sudo systemctl restart bj-server
|
||||
\`\`\`
|
||||
|
||||
## Что НЕ переносится автоматически
|
||||
|
||||
- Сертификаты КриптоПро CSP (\`/var/opt/cprocsp/keys/$USER/\`)
|
||||
— это нормально, на Astra Linux будет другая СКЗИ (Валидата CSP)
|
||||
- \`/opt/cprocsp/\` (КриптоПро CSP)
|
||||
— на Astra нужна Валидата вместо КриптоПро
|
||||
EOF
|
||||
|
||||
# ---- Финал ----
|
||||
log "5/5: создание архива $OUT_TAR"
|
||||
tar -czf "$OUT_TAR" -C "$(dirname "$OUT_DIR")" "$(basename "$OUT_DIR")"
|
||||
rm -rf "$OUT_DIR"
|
||||
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo " Экспорт готов"
|
||||
echo "================================================================"
|
||||
echo " Архив: $OUT_TAR"
|
||||
echo " Размер: $(du -h "$OUT_TAR" | awk '{print $1}')"
|
||||
echo
|
||||
echo " Перенести на новую Astra Linux ВМ:"
|
||||
echo " scp $OUT_TAR user@<astra-ip>:/tmp/"
|
||||
echo
|
||||
echo " На Astra Linux распаковать и читать README.md:"
|
||||
echo " cd /tmp"
|
||||
echo " tar -xzf $(basename "$OUT_TAR")"
|
||||
echo " cat $(basename "$OUT_DIR")/README.md"
|
||||
echo "================================================================"
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
# install-ish.sh — установка ПО «Интеграционный шлюз НРД» (ИШ) на Astra Linux.
|
||||
#
|
||||
# Документ-источник: DOC/ruk_install_ish_2025_11_10.pdf (раздел 7.3.2).
|
||||
#
|
||||
# Пред-требования:
|
||||
# 1. ОС: Astra Linux SE 1.6 или 1.7
|
||||
# 2. УСТАНОВЛЕНА Валидата CSP + АПК Валидата Клиент L (см. install-validata.sh)
|
||||
# 3. Корневой сертификат УЦ МБ загружен в Справочник сертификатов
|
||||
# 4. Пользовательский сертификат экспортирован в системное хранилище
|
||||
#
|
||||
# Что делает скрипт:
|
||||
# 1. Проверяет наличие Валидаты
|
||||
# 2. Устанавливает igate_*.deb через dpkg
|
||||
# 3. Создаёт каталог настроек ~/igate
|
||||
# 4. Подсказывает следующие шаги (запуск настройщика каналов)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEB_PATH="${1:-}"
|
||||
|
||||
log() { echo -e "\033[1;34m[ish-install]\033[0m $*"; }
|
||||
warn() { echo -e "\033[1;33m[ish-install WARN]\033[0m $*" >&2; }
|
||||
fail() { echo -e "\033[1;31m[ish-install FAIL]\033[0m $*" >&2; exit 1; }
|
||||
|
||||
# ---- 1. Поиск .deb ----
|
||||
if [ -z "$DEB_PATH" ]; then
|
||||
# Поиск в стандартных местах
|
||||
for candidate in \
|
||||
./dist/ish/igate_*_amd64.deb \
|
||||
/opt/bj/src/dist/ish/igate_*_amd64.deb \
|
||||
~/Downloads/igate_*_amd64.deb \
|
||||
/tmp/igate_*_amd64.deb; do
|
||||
if [ -f "$candidate" ]; then
|
||||
DEB_PATH="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$DEB_PATH" ] || [ ! -f "$DEB_PATH" ]; then
|
||||
fail "Не найден .deb пакет ИШ. Скачайте с https://www.nsd.ru/workflow/system/programs/web-service/ и передайте путь:
|
||||
sudo bash $0 /path/to/igate_100.0-765_amd64.deb"
|
||||
fi
|
||||
log "Дистрибутив ИШ: $DEB_PATH"
|
||||
|
||||
# ---- 2. Проверка ОС ----
|
||||
if [ -r /etc/astra_version ]; then
|
||||
log "Astra Linux: $(cat /etc/astra_version)"
|
||||
else
|
||||
warn "Это не Astra Linux. ИШ под Astra Linux может не запуститься на других ОС."
|
||||
warn "Продолжить? (y/N)"
|
||||
read -r REPLY < /dev/tty
|
||||
[ "$REPLY" = "y" ] || exit 1
|
||||
fi
|
||||
|
||||
# ---- 3. Проверка Валидаты ----
|
||||
log "Проверка СКЗИ Валидата CSP..."
|
||||
VAL_FOUND=0
|
||||
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP /usr/local/Validata; do
|
||||
[ -d "$path" ] && { log " ✓ Валидата найдена в $path"; VAL_FOUND=1; break; }
|
||||
done
|
||||
if [ "$VAL_FOUND" = 0 ]; then
|
||||
warn "Валидата CSP не найдена. ИШ всё равно поставится, но не запустится без СКЗИ."
|
||||
warn "Получите дистрибутив Валидаты у НРД (soed@nsd.ru) и поставьте через install-validata.sh."
|
||||
warn "Продолжить установку ИШ? (y/N)"
|
||||
read -r REPLY < /dev/tty
|
||||
[ "$REPLY" = "y" ] || exit 1
|
||||
fi
|
||||
|
||||
# ---- 4. dpkg -i ----
|
||||
log "Установка ИШ через dpkg..."
|
||||
[ "$EUID" -eq 0 ] || fail "Запускать от root (sudo bash $0)"
|
||||
dpkg -i "$DEB_PATH" 2>&1 | tee /tmp/igate-install.log || {
|
||||
warn "dpkg -i вернул ошибку, пытаюсь починить зависимости через apt-get install -f"
|
||||
apt-get install -f -y
|
||||
dpkg -i "$DEB_PATH"
|
||||
}
|
||||
|
||||
# ---- 5. Проверка ----
|
||||
if command -v igate >/dev/null 2>&1; then
|
||||
log "✓ ИШ установлен: $(which igate)"
|
||||
elif [ -x /opt/igate/igate ]; then
|
||||
log "✓ ИШ установлен в /opt/igate/"
|
||||
else
|
||||
warn "Бинарник igate не нашёл в PATH. Возможно установлен в /opt/igate или ~/igate."
|
||||
warn "Проверьте: dpkg -L igate | grep -E 'bin|igate$'"
|
||||
fi
|
||||
|
||||
# ---- 6. Финал ----
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo " ИШ установлен"
|
||||
echo "================================================================"
|
||||
echo
|
||||
echo " Следующие шаги (по DOC/ruk_install_ish_2025_11_10.pdf раздел 10):"
|
||||
echo " 1. Запустить ИШ в GUI: igate & (или через меню Пуск/Astra)"
|
||||
echo " 2. Настройки БД → PostgreSQL (URL/логин/пароль из bj-server)"
|
||||
echo " 3. Создать канал WSL → URL https://gost.nsd.ru/onyxt3/WslService (TEST3)"
|
||||
echo " 4. Указать сертификат УЦ МБ из системного хранилища"
|
||||
echo " 5. Активировать ИШ как сервис:"
|
||||
echo " sudo systemctl enable --now igate"
|
||||
echo
|
||||
echo " REST API ИШ (для bj-server):"
|
||||
echo " http://localhost:8090 (порт по умолчанию — см. настройки ИШ)"
|
||||
echo
|
||||
echo " После настройки канала в ИШ: открыть"
|
||||
echo " http://<этот-сервер>:8080/admin/setup → раздел «Интеграционный шлюз НРД»"
|
||||
echo " и указать URL ИШ + имя канала."
|
||||
echo "================================================================"
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
# install-validata.sh — установка СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»
|
||||
# для работы Интеграционного шлюза НРД на Astra Linux.
|
||||
#
|
||||
# ВАЖНО: дистрибутив Валидаты не выложен публично. Получается по запросу:
|
||||
# - НРД: soed@nsd.ru
|
||||
# - МБ: pki@moex.com
|
||||
# В письме указать: «Запрос дистрибутива СКЗИ Валидата CSP для Linux +
|
||||
# временной лицензии для подключения к ЭДО НРД в рамках MOEX МОСТ M2M.»
|
||||
#
|
||||
# Скрипт ожидает что архив с дистрибутивом уже скачан и лежит:
|
||||
# dist/validata/<любые>.deb
|
||||
# или передан как первый аргумент.
|
||||
#
|
||||
# Запуск:
|
||||
# sudo bash deploy/astra/install-validata.sh
|
||||
# sudo bash deploy/astra/install-validata.sh /path/to/validata-csp.deb
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { echo -e "\033[1;34m[validata-install]\033[0m $*"; }
|
||||
warn() { echo -e "\033[1;33m[validata-install WARN]\033[0m $*" >&2; }
|
||||
fail() { echo -e "\033[1;31m[validata-install FAIL]\033[0m $*" >&2; exit 1; }
|
||||
|
||||
[ "$EUID" -eq 0 ] || fail "Запускать от root"
|
||||
|
||||
SEARCH_PATH="${1:-./dist/validata}"
|
||||
|
||||
if [ -f "$SEARCH_PATH" ] && [ "${SEARCH_PATH##*.}" = "deb" ]; then
|
||||
# Передан конкретный файл
|
||||
DEBS=( "$SEARCH_PATH" )
|
||||
elif [ -d "$SEARCH_PATH" ]; then
|
||||
mapfile -t DEBS < <(find "$SEARCH_PATH" -maxdepth 2 -name '*.deb' 2>/dev/null | sort)
|
||||
else
|
||||
fail "Не найден дистрибутив Валидаты. Положи .deb пакеты в dist/validata/ или передай путь аргументом.
|
||||
|
||||
Если у тебя ещё нет дистрибутива — запроси у НРД:
|
||||
Email: soed@nsd.ru (или pki@moex.com)
|
||||
Тема: Запрос дистрибутива Валидата CSP для Linux
|
||||
Текст: Просим предоставить дистрибутив СКЗИ Валидата CSP v.6 для Linux
|
||||
(Astra Linux SE 1.7) + временную лицензию для подключения к
|
||||
ЭДО НРД через ПО Интеграционный шлюз в рамках сервиса
|
||||
MOEX МОСТ M2M (см. инструкцию nsd.ru/workflow/system/programs/web-service/).
|
||||
Реквизиты организации: <ИНН, ОГРН, контактное лицо>.
|
||||
"
|
||||
fi
|
||||
|
||||
if [ "${#DEBS[@]}" = 0 ]; then
|
||||
fail "В каталоге $SEARCH_PATH не найдено ни одного .deb пакета"
|
||||
fi
|
||||
|
||||
log "Найдено ${#DEBS[@]} пакетов Валидаты:"
|
||||
for f in "${DEBS[@]}"; do
|
||||
echo " $f"
|
||||
done
|
||||
|
||||
log "Установка через dpkg..."
|
||||
for f in "${DEBS[@]}"; do
|
||||
log " $f"
|
||||
dpkg -i "$f" || {
|
||||
warn " → пытаюсь починить зависимости"
|
||||
apt-get install -f -y
|
||||
dpkg -i "$f"
|
||||
}
|
||||
done
|
||||
|
||||
# Проверка
|
||||
log "Проверка установки..."
|
||||
VAL_FOUND=0
|
||||
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP; do
|
||||
[ -d "$path" ] && { log " ✓ Валидата в $path"; VAL_FOUND=1; }
|
||||
done
|
||||
if [ "$VAL_FOUND" = 0 ]; then
|
||||
warn "Каталог Валидаты не нашёл — проверь dpkg -L <имя-пакета>"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo " Валидата установлена"
|
||||
echo "================================================================"
|
||||
echo " Следующие шаги:"
|
||||
echo " 1. Запустить Справочник сертификатов АПК Валидата Клиент"
|
||||
echo " (GUI приложение)"
|
||||
echo " 2. Загрузить корневой сертификат УЦ Московской Биржи"
|
||||
echo " (взять у УЦ МБ — ca.moex.com — для своей организации)"
|
||||
echo " 3. Импортировать пользовательский сертификат с приватным ключом"
|
||||
echo " 4. Меню Сервис → Экспортировать сертификаты в системное хранилище"
|
||||
echo " 5. Установить ИШ: sudo bash deploy/astra/install-ish.sh"
|
||||
echo "================================================================"
|
||||
Executable
+384
@@ -0,0 +1,384 @@
|
||||
#!/bin/bash
|
||||
# install.sh — установка Bridge-and-Join-s одной командой.
|
||||
#
|
||||
# ЦЕЛЕВАЯ АУДИТОРИЯ: оператор без знания Linux/Go. Просто запускает строку:
|
||||
#
|
||||
# curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
|
||||
#
|
||||
# и всё работает.
|
||||
#
|
||||
# Поддерживаемые ОС:
|
||||
# - Astra Linux Special Edition 1.6 / 1.7 (платная, для прод)
|
||||
# - Astra Linux Common Edition / 1.8 (бесплатная)
|
||||
# - Debian 11 / 12
|
||||
# - Ubuntu 22.04 / 24.04 (с предупреждением)
|
||||
#
|
||||
# Что устанавливается АВТОМАТИЧЕСКИ:
|
||||
# 1. Системные зависимости (apt: curl, git, podman, postgresql-client)
|
||||
# 2. Go 1.24+ (скачивается с go.dev)
|
||||
# 3. PostgreSQL 16 в podman-контейнере + миграции
|
||||
# 4. bj-server (компилируется из исходников, ставится в /opt/bj/)
|
||||
# 5. Дистрибутив ИШ НРД (скачивается с сайта НРД, ~120 МБ)
|
||||
# 6. Сам ИШ устанавливается через dpkg -i (но не запускается без Валидаты)
|
||||
# 7. systemd unit + автозапуск
|
||||
#
|
||||
# Что НЕ автоматизируется (только пользователь):
|
||||
# - СКЗИ Валидата CSP — выдаётся НРД по запросу (soed@nsd.ru)
|
||||
# - Сертификат подписи УЦ Московской Биржи (ca.moex.com)
|
||||
# - Регистрация в TEST3 (заявка через nsd.ru)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- параметры ----
|
||||
REPO_URL="${REPO_URL:-https://git.zetit.ru/zuevav/Bridge-and-Join-s.git}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
BIND_ADDR="${BIND_ADDR:-:8080}"
|
||||
ISH_DEB_URL="${ISH_DEB_URL:-https://old.nsd.ru/upload/docs/edo/po/igate_100.0-765_amd64.deb}"
|
||||
SKIP_ISH=0
|
||||
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-ish) SKIP_ISH=1 ;;
|
||||
--bind=*) BIND_ADDR="${arg#*=}" ;;
|
||||
--branch=*) BRANCH="${arg#*=}" ;;
|
||||
--yes|-y) NON_INTERACTIVE=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- утилиты вывода ----
|
||||
NS=$(date +%s)
|
||||
step() { local n=$(( $(date +%s) - NS )); printf "\033[1;36m[%4ds]\033[0m \033[1;34m▶\033[0m %s\n" "$n" "$*"; }
|
||||
ok() { printf " \033[1;32m✓\033[0m %s\n" "$*"; }
|
||||
warn() { printf " \033[1;33m⚠\033[0m %s\n" "$*"; }
|
||||
fail() { printf " \033[1;31m✗\033[0m %s\n" "$*" >&2; exit 1; }
|
||||
ask() {
|
||||
[ "$NON_INTERACTIVE" = "1" ] && return 0
|
||||
printf " \033[1;35m?\033[0m %s [y/N]: " "$*"
|
||||
read -r REPLY < /dev/tty || REPLY=n
|
||||
[ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]
|
||||
}
|
||||
|
||||
# ---- баннер ----
|
||||
clear 2>/dev/null || true
|
||||
cat <<'BANNER'
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ Bridge-and-Join-s — установка с нуля ║
|
||||
║ сервис M2M-переводов с НКО АО НРД ║
|
||||
║ ║
|
||||
║ Установка займёт ~5-10 минут ║
|
||||
║ Скачается ~150-250 МБ (Go + ИШ + миграции) ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
BANNER
|
||||
echo
|
||||
|
||||
[ "$EUID" -eq 0 ] || fail "Запускать от root: sudo bash $0"
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 1/9. Определение ОС
|
||||
# ============================================================
|
||||
step "1/9: определение операционной системы"
|
||||
OS_KIND=""
|
||||
OS_NAME="неизвестно"
|
||||
|
||||
if [ -r /etc/astra_version ]; then
|
||||
OS_NAME="Astra Linux $(cat /etc/astra_version)"
|
||||
OS_KIND="astra"
|
||||
elif [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS_NAME="$PRETTY_NAME"
|
||||
case "${ID:-}" in
|
||||
astra) OS_KIND="astra" ;;
|
||||
debian) OS_KIND="debian" ;;
|
||||
ubuntu) OS_KIND="ubuntu" ;;
|
||||
*)
|
||||
case "${ID_LIKE:-}" in
|
||||
*debian*) OS_KIND="debian-like" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
ok "Обнаружено: $OS_NAME"
|
||||
|
||||
case "$OS_KIND" in
|
||||
astra)
|
||||
ok "Astra Linux — полностью поддерживается, ИШ заработает официально"
|
||||
;;
|
||||
debian|"debian-like")
|
||||
warn "Debian-based — bj-server установится, ИШ скорее всего тоже"
|
||||
warn "(но официально НРД его не поддерживает на Debian; для прод-инфры лучше Astra Linux SE)"
|
||||
;;
|
||||
ubuntu)
|
||||
warn "Ubuntu — bj-server установится, но ИШ может потребовать допилов"
|
||||
ask "Продолжить?" || exit 1
|
||||
;;
|
||||
*)
|
||||
fail "Неподдерживаемая ОС. Поддерживаются: Astra Linux (SE/CE), Debian, Ubuntu"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 2/9. Системные пакеты
|
||||
# ============================================================
|
||||
step "2/9: установка системных пакетов через apt"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq >/dev/null
|
||||
apt-get install -y -qq \
|
||||
ca-certificates curl wget git tar gzip \
|
||||
podman postgresql-client \
|
||||
>/dev/null 2>&1
|
||||
# podman-compose доступен либо как apt-пакет, либо как pip — пробуем оба
|
||||
if ! command -v podman-compose >/dev/null 2>&1; then
|
||||
apt-get install -y -qq podman-compose >/dev/null 2>&1 || \
|
||||
apt-get install -y -qq python3-pip >/dev/null 2>&1 && pip3 install --quiet podman-compose 2>/dev/null || true
|
||||
fi
|
||||
command -v podman >/dev/null && ok "podman: $(podman --version | awk '{print $3}')" || fail "podman не установился"
|
||||
command -v git >/dev/null && ok "git: $(git --version | awk '{print $3}')" || fail "git не установился"
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 3/9. Go 1.24+
|
||||
# ============================================================
|
||||
step "3/9: Go 1.24+"
|
||||
need_go=1
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
GO_HAVE=$(go version | awk '{print $3}' | sed 's/go//')
|
||||
if printf '%s\n%s' "1.24" "$GO_HAVE" | sort -V | head -1 | grep -q '^1.24$'; then
|
||||
ok "Go $GO_HAVE — подходит"
|
||||
need_go=0
|
||||
else
|
||||
warn "Go $GO_HAVE — слишком старый, обновляю"
|
||||
fi
|
||||
fi
|
||||
if [ "$need_go" = 1 ]; then
|
||||
GO_VER="1.24.0"
|
||||
ok "качаю Go $GO_VER с go.dev (~70 МБ)..."
|
||||
curl -sSL "https://go.dev/dl/go${GO_VER}.linux-amd64.tar.gz" -o /tmp/go.tar.gz \
|
||||
|| fail "не получилось скачать Go (нужен интернет)"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
rm -f /tmp/go.tar.gz
|
||||
ok "Go $GO_VER установлен в /usr/local/go"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 4/9. Пользователь bj и каталоги
|
||||
# ============================================================
|
||||
step "4/9: системный пользователь bj и каталоги"
|
||||
if ! id bj >/dev/null 2>&1; then
|
||||
useradd --system --create-home --home-dir /var/lib/bj --shell /bin/bash bj
|
||||
ok "создан пользователь bj"
|
||||
else
|
||||
ok "пользователь bj уже существует"
|
||||
fi
|
||||
install -d -o bj -g bj -m 0755 /opt/bj /var/lib/bj /var/log/bj
|
||||
install -d -o bj -g bj -m 0700 /var/lib/bj/.bj
|
||||
ok "каталоги: /opt/bj /var/lib/bj /var/log/bj"
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 5/9. Клон репо и сборка bj-server
|
||||
# ============================================================
|
||||
step "5/9: клон репозитория и сборка bj-server"
|
||||
SRC=/opt/bj/src
|
||||
if [ -d "$SRC/.git" ]; then
|
||||
sudo -u bj -H git -C "$SRC" fetch --quiet origin
|
||||
sudo -u bj -H git -C "$SRC" reset --hard --quiet "origin/$BRANCH"
|
||||
ok "репо обновлено до $BRANCH"
|
||||
else
|
||||
sudo -u bj -H git clone --quiet --branch "$BRANCH" "$REPO_URL" "$SRC" \
|
||||
|| fail "git clone failed"
|
||||
ok "репо склонирован"
|
||||
fi
|
||||
chown -R bj:bj "$SRC"
|
||||
|
||||
ok "компиляция bj-server..."
|
||||
sudo -u bj -H bash -c "cd $SRC && /usr/local/bin/go build -o /opt/bj/bj-server ./cmd/bj-server" \
|
||||
|| fail "go build failed"
|
||||
chown bj:bj /opt/bj/bj-server
|
||||
chmod 0755 /opt/bj/bj-server
|
||||
ok "бинарник: /opt/bj/bj-server ($(du -h /opt/bj/bj-server | awk '{print $1}'))"
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 6/9. PostgreSQL в podman + миграции
|
||||
# ============================================================
|
||||
step "6/9: PostgreSQL в podman + миграции БД"
|
||||
cd "$SRC"
|
||||
if ! podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
|
||||
sudo -u bj -H podman-compose -f deploy/docker-compose/docker-compose.yml up -d postgres \
|
||||
2>/dev/null || {
|
||||
warn "podman-compose не сработал, пробую podman run напрямую"
|
||||
sudo -u bj -H podman run -d --name bj-postgres \
|
||||
-e POSTGRES_USER=bj -e POSTGRES_PASSWORD=bj_dev -e POSTGRES_DB=bj \
|
||||
-p 127.0.0.1:5432:5432 \
|
||||
docker.io/library/postgres:16-alpine
|
||||
}
|
||||
sleep 5
|
||||
fi
|
||||
# Ждём pg_isready
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if sudo -u bj -H podman exec bj-postgres pg_isready -U bj -d bj >/dev/null 2>&1; then
|
||||
ok "PostgreSQL готов"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Накат миграций
|
||||
MIG_COUNT=0
|
||||
for mig in migrations/fansy-store/*.sql migrations/m2m-core/*.sql; do
|
||||
if [ -f "$mig" ]; then
|
||||
sudo -u bj -H podman exec -i bj-postgres psql -U bj -d bj -v ON_ERROR_STOP=0 < "$mig" >/dev/null 2>&1 && \
|
||||
MIG_COUNT=$((MIG_COUNT+1))
|
||||
fi
|
||||
done
|
||||
ok "миграций накачено: $MIG_COUNT"
|
||||
|
||||
# Сохраняем DSN
|
||||
cat > /var/lib/bj/.bj/setup.json <<EOF
|
||||
{
|
||||
"postgres": {"dsn": "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"},
|
||||
"crypto": {"provider": "stub", "socket_path": "/run/bj/crypto.sock"},
|
||||
"nsd": {},
|
||||
"lk": {},
|
||||
"ca_certs": {},
|
||||
"news": {},
|
||||
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
chown bj:bj /var/lib/bj/.bj/setup.json
|
||||
chmod 0600 /var/lib/bj/.bj/setup.json
|
||||
ok "DSN сохранён в /var/lib/bj/.bj/setup.json"
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 7/9. systemd unit
|
||||
# ============================================================
|
||||
step "7/9: systemd unit для bj-server"
|
||||
cat > /etc/systemd/system/bj-server.service <<EOF
|
||||
[Unit]
|
||||
Description=Bridge-and-Join-s — единый сервис M2M-переводов
|
||||
Documentation=$REPO_URL
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
WorkingDirectory=$SRC
|
||||
ExecStart=/opt/bj/bj-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
Environment=BJ_HTTP_ADDR=$BIND_ADDR
|
||||
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
|
||||
Environment=BJ_M2M_SENDER=MC0079200000
|
||||
Environment=BJ_M2M_RECEIVER=MC0010300000
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/bj /var/log/bj
|
||||
PrivateTmp=true
|
||||
|
||||
StandardOutput=append:/var/log/bj/bj-server.log
|
||||
StandardError=append:/var/log/bj/bj-server.err
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable bj-server >/dev/null 2>&1
|
||||
systemctl restart bj-server
|
||||
sleep 2
|
||||
if systemctl is-active --quiet bj-server; then
|
||||
ok "bj-server.service active"
|
||||
else
|
||||
warn "bj-server не стартанул, см. journalctl -u bj-server -n 30"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 8/9. ИШ НРД — скачивание и установка
|
||||
# ============================================================
|
||||
if [ "$SKIP_ISH" = "1" ]; then
|
||||
step "8/9: ИШ НРД — пропущено (--skip-ish)"
|
||||
else
|
||||
step "8/9: Интеграционный шлюз НРД (ИШ)"
|
||||
ISH_LOCAL="$SRC/dist/ish/igate_100.0-765_amd64.deb"
|
||||
if [ -f "$ISH_LOCAL" ]; then
|
||||
ok "дистрибутив ИШ уже в репо: $ISH_LOCAL"
|
||||
else
|
||||
ok "качаю дистрибутив ИШ с НРД (~120 МБ)..."
|
||||
mkdir -p "$(dirname "$ISH_LOCAL")"
|
||||
if curl -sSL -A "Mozilla/5.0" "$ISH_DEB_URL" -o "$ISH_LOCAL" --max-time 600; then
|
||||
ok "скачан: $(du -h "$ISH_LOCAL" | awk '{print $1}')"
|
||||
else
|
||||
warn "не получилось скачать ИШ автоматически"
|
||||
warn "скачайте вручную: $ISH_DEB_URL"
|
||||
warn "и положите в $ISH_LOCAL, потом перезапустите этот скрипт"
|
||||
ISH_LOCAL=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$ISH_LOCAL" ] && [ -f "$ISH_LOCAL" ]; then
|
||||
ok "установка ИШ через dpkg..."
|
||||
if dpkg -i "$ISH_LOCAL" >/dev/null 2>&1; then
|
||||
ok "ИШ установлен"
|
||||
else
|
||||
# Часто dpkg падает на зависимостях — пробуем apt-get install -f
|
||||
apt-get install -f -y >/dev/null 2>&1
|
||||
if dpkg -i "$ISH_LOCAL" >/dev/null 2>&1; then
|
||||
ok "ИШ установлен (после починки зависимостей)"
|
||||
else
|
||||
warn "ИШ не встал — возможно нет Валидаты или системных пакетов"
|
||||
warn "это нормально на текущем этапе — продолжаем"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# ШАГ 9/9. Финальная проверка
|
||||
# ============================================================
|
||||
step "9/9: проверка готовности"
|
||||
sleep 1
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${BIND_ADDR}/admin/" 2>/dev/null || echo "—")
|
||||
[ "$CODE" = "200" ] && ok "веб-админка отвечает: HTTP 200" || warn "веб-админка пока не отвечает (HTTP $CODE) — проверь логи"
|
||||
|
||||
IP=$(hostname -I | awk '{print $1}')
|
||||
echo
|
||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ УСТАНОВКА BJ-SERVER ЗАВЕРШЕНА ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||
echo
|
||||
echo " Веб-админка: http://$IP${BIND_ADDR}/admin/"
|
||||
echo " Мастер настройки: http://$IP${BIND_ADDR}/admin/wizard"
|
||||
echo " Архитектура: http://$IP${BIND_ADDR}/admin/help/architecture"
|
||||
echo " Новости: http://$IP${BIND_ADDR}/admin/news"
|
||||
echo
|
||||
echo " Логи: tail -f /var/log/bj/bj-server.log"
|
||||
echo " Сервис: systemctl status bj-server"
|
||||
echo
|
||||
echo " ──── ЧТО ОСТАЛОСЬ СДЕЛАТЬ (НЕ АВТОМАТИЧЕСКИ) ───────────────"
|
||||
echo
|
||||
echo " 1. Запросить СКЗИ Валидата CSP у НРД:"
|
||||
echo " Email: soed@nsd.ru"
|
||||
echo " Текст: «Запрос дистрибутива Валидата CSP для Linux + временной"
|
||||
echo " лицензии для подключения к ЭДО НРД в рамках MOEX МОСТ M2M.»"
|
||||
echo
|
||||
echo " 2. Получить сертификат подписи в УЦ Московской Биржи:"
|
||||
echo " https://ca.moex.com/"
|
||||
echo
|
||||
echo " 3. Подать заявку на тестирование в TEST3:"
|
||||
echo " https://www.nsd.ru/workflow/zayavka-na-testirovanie/"
|
||||
echo
|
||||
echo " 4. Когда придёт Валидата — поставить:"
|
||||
echo " sudo bash $SRC/deploy/astra/install-validata.sh /path/to/validata-*.deb"
|
||||
echo
|
||||
echo " 5. Когда заработает ИШ — указать его URL в /admin/setup → «ИШ НРД»"
|
||||
echo
|
||||
echo " ──── ПРОВЕРКА СОСТОЯНИЯ ВСЕГО ──────────────────────────────"
|
||||
echo " sudo bash $SRC/deploy/astra/healthcheck.sh"
|
||||
echo
|
||||
@@ -7,7 +7,7 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: docker.io/library/postgres:16
|
||||
# В проде заменить на postgrespro/std-16 или registry.postgrespro.ru/pgpro/...
|
||||
container_name: bj-postgres
|
||||
environment:
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- bj-postgres-data:/var/lib/postgresql/data
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: docker.io/minio/minio:latest
|
||||
container_name: bj-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
@@ -32,6 +32,20 @@ services:
|
||||
volumes:
|
||||
- bj-minio-data:/data
|
||||
|
||||
crypto-service:
|
||||
build:
|
||||
context: ../../services/crypto-service
|
||||
dockerfile: Dockerfile
|
||||
container_name: bj-crypto-service
|
||||
environment:
|
||||
BJ_CRYPTO_SOCKET: /run/bj/crypto.sock
|
||||
BJ_CRYPTO_PROVIDER: stub
|
||||
volumes:
|
||||
# UDS-сокет наружу как named volume, чтобы Go-сервисы
|
||||
# (m2m-core, lk-gateway, nsd-adapter) могли его mount'ить.
|
||||
- bj-crypto-sock:/run/bj
|
||||
|
||||
volumes:
|
||||
bj-postgres-data:
|
||||
bj-minio-data:
|
||||
bj-crypto-sock:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# deploy/systemd — юниты для деплоя
|
||||
|
||||
Минимальный production-деплой Bridge-and-Join-s — два бинарника + два
|
||||
systemd-юнита.
|
||||
|
||||
## Состав
|
||||
|
||||
- `bj-server.service` — основной сервис: lk-gateway BFF + admin UI +
|
||||
m2m-core FSM + nsd-adapter поллер + notify. HTTP `:8080`.
|
||||
- `bj-emulator.service` — имитация ЛК (QA-инструмент). HTTP `:8083`.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin bj
|
||||
sudo mkdir -p /opt/bj /var/lib/bj /var/log/bj /run/bj
|
||||
sudo chown bj:bj /var/lib/bj /var/log/bj /run/bj
|
||||
|
||||
# собрать бинарники на dev-ВМ и положить в /opt/bj/
|
||||
sudo cp bin/bj-server bin/lk-emulator /opt/bj/
|
||||
|
||||
# юниты
|
||||
sudo cp deploy/systemd/bj-server.service deploy/systemd/bj-emulator.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now bj-server bj-emulator
|
||||
|
||||
# проверка
|
||||
systemctl status bj-server bj-emulator
|
||||
journalctl -u bj-server -f
|
||||
```
|
||||
|
||||
Веб-интерфейс: `http://<host>:8080/admin/setup` — настройка PostgreSQL,
|
||||
КриптоПро CSP, ИШ НРД, callback ЛК.
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Bridge-and-Join-s — эмулятор ЛК ESIA Finance (QA)
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target bj-server.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
WorkingDirectory=/opt/bj
|
||||
ExecStart=/opt/bj/lk-emulator
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
Environment=BJ_HTTP_ADDR=:8083
|
||||
Environment=BJ_GATEWAY_URL=http://127.0.0.1:8080
|
||||
Environment=BJ_EMULATOR_PUBLIC_URL=http://127.0.0.1:8083
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
|
||||
LimitNOFILE=65536
|
||||
TasksMax=128
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,46 @@
|
||||
[Unit]
|
||||
Description=Bridge-and-Join-s — единый сервис M2M-переводов
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
WorkingDirectory=/opt/bj
|
||||
ExecStart=/opt/bj/bj-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Конфигурация — через ENV или ~/.bj/setup.json (UI /admin/setup).
|
||||
Environment=BJ_HTTP_ADDR=:8080
|
||||
Environment=BJ_SETUP_PATH=/var/lib/bj/setup.json
|
||||
Environment=BJ_M2M_SENDER=MC0079200000
|
||||
Environment=BJ_M2M_RECEIVER=MC0010300000
|
||||
|
||||
# КриптоПро CSP кладёт .so в /opt/cprocsp/lib/amd64 без записи в
|
||||
# /etc/ld.so.conf.d. Чтобы Go-PKCS#11 клиент (cryptocli) нашёл
|
||||
# libcppkcs11.so и его зависимости (libcapi20, libcpext, liburlretrieve),
|
||||
# подмешиваем путь через LD_LIBRARY_PATH. Без этого Initialize() падает
|
||||
# с CKR_FUNCTION_FAILED или 'cannot open shared object file'.
|
||||
Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64
|
||||
|
||||
# Безопасность.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/bj /var/log/bj /run/bj
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
|
||||
# Лимиты.
|
||||
LimitNOFILE=65536
|
||||
TasksMax=512
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
# Дистрибутив Интеграционного шлюза НРД (ИШ)
|
||||
|
||||
**Скачано с сайта НРД** (`https://www.nsd.ru/workflow/system/programs/web-service/`) 14.05.2026.
|
||||
Через git не коммитим — файлы большие, ставятся отдельно.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Размер | Описание |
|
||||
|---|---:|---|
|
||||
| `igate_100.0-765_amd64.deb` | 117 МБ | Дистрибутив ИШ для **Astra-Linux** (.deb пакет) |
|
||||
| `igate_95.0-716_amd64.SGN` | 491 байт | Электронная подпись к дистрибутиву ИШ |
|
||||
|
||||
## Где скачать заново
|
||||
|
||||
- ИШ Linux: `https://old.nsd.ru/upload/docs/edo/po/igate_100.0-765_amd64.deb`
|
||||
- ИШ Windows (рус): `https://old.nsd.ru/upload/docs/edo/po/igate-ru-100.0.0.764.zip`
|
||||
- ИШ Windows (eng): `https://old.nsd.ru/upload/docs/edo/po/igate-en-100.0.0.764.zip`
|
||||
- Все версии: `https://www.nsd.ru/workflow/system/programs/web-service/`
|
||||
|
||||
## Что ещё нужно (НЕ в этой папке)
|
||||
|
||||
### 1. СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»
|
||||
**Не выложено публично** — даётся НРД по запросу:
|
||||
- Email НРД: `soed@nsd.ru`
|
||||
- Email Московской Биржи: `pki@moex.com`
|
||||
|
||||
В письме указать: «Запрос дистрибутива СКЗИ Валидата CSP для Linux + временной лицензии для подключения к ЭДО НРД в рамках сервиса MOEX МОСТ M2M».
|
||||
|
||||
### 2. Сертификат подписи
|
||||
Только от **УЦ Московской Биржи** (`https://ca.moex.com/`). Получает организация-депонент.
|
||||
|
||||
### 3. PostgreSQL
|
||||
Если используется REST API ИШ — **обязательно** PostgreSQL (SQLite не подходит для API).
|
||||
У нас PostgreSQL 16 уже работает в podman-контейнере → готово.
|
||||
|
||||
## Поддерживаемые ОС (из руководства по установке)
|
||||
|
||||
- **Astra Linux Special Edition x64** редакций 1.6, 1.7, исполнение 1 (РУСБ.10015-01/16)
|
||||
- **Windows 10 / Server 2016/2019**
|
||||
|
||||
**РЕД ОС в списке не упомянута.** Варианты для нашей инфраструктуры:
|
||||
1. Поднять отдельную Astra Linux ВМ для ИШ (рекомендуется)
|
||||
2. Попробовать `dpkg -i` на РЕД ОС с `alien` (рискованно)
|
||||
3. Использовать Debian/Ubuntu ВМ (близко к Astra, возможно сработает)
|
||||
4. Контейнер с базовым образом `astralinux/astra-linux-edu:1.7.5` (если такой есть)
|
||||
5. Запросить у НРД RPM-версию
|
||||
|
||||
## Контакты НРД
|
||||
|
||||
- Email по СЭД и дистрибутивам: `soed@nsd.ru`
|
||||
- Email по форматам M2M: `M2MOST@nsd.ru`
|
||||
- Сайт ИШ: `https://www.nsd.ru/workflow/system/programs/web-service/`
|
||||
|
||||
## Документация
|
||||
|
||||
Все PDF лежат в `../../DOC/`:
|
||||
- `ruk_install_ish_2025_11_10.pdf` — Руководство по установке ИШ (от 10.11.2025)
|
||||
- `ruk_pol_ish.pdf` — Руководство пользователя ИШ
|
||||
- `QA_ish.pdf` — Часто задаваемые вопросы
|
||||
- `test-case_ish.pdf` — Тест-кейсы для проверки работоспособности ИШ
|
||||
- `instr_int_sh_01072025.pdf` — Инструкция по созданию заявки на тестирование
|
||||
- `web_service_nrd_standard_soap_rest.pdf` — Технические рекомендации Web-сервиса ONYX
|
||||
@@ -1,33 +1,56 @@
|
||||
# docs/fansy-contract/v1 — контракт данных с командой Fansy
|
||||
|
||||
ETL Fansy → принимающая БД (`fansy-store`) реализует **другая команда
|
||||
разработки**. С нашей стороны:
|
||||
разработки**. С нашей стороны зафиксирован контракт: схема таблиц,
|
||||
индексы, миграции, требования к выгрузке и тестовые данные.
|
||||
|
||||
1. Спроектировать таблицы по требованиям документации НРД к данным M2M.
|
||||
2. Передать команде Fansy DDL и контракт данных.
|
||||
3. Согласовать тип load (UPSERT в staging), окна обновления, SLA на
|
||||
свежесть данных.
|
||||
4. Не давать ETL-роли DDL-прав в принимающей схеме.
|
||||
## Состав каталога
|
||||
|
||||
Состав каталога (создаём в M1, отправляем в начале M2):
|
||||
- **`ddl/`** — SQL-миграции PostgreSQL:
|
||||
- `000__roles.sql` — роли `fansy_etl` (ETL Fansy), `bj_reader`
|
||||
(наши сервисы), `bj_migrator` (миграции).
|
||||
- `001__schemas.sql` — две схемы: `fansy_staging` (куда пишет ETL) и
|
||||
`fansy` (рабочая, для нашего чтения). Гранты по ролям.
|
||||
- `002__working.sql` — рабочие таблицы: `participants`, `securities`,
|
||||
`clients`, `client_documents`, `iia_contracts`,
|
||||
`settlement_requisites`, `depo_accounts`, `portfolios`,
|
||||
`etl_errors`.
|
||||
- `003__staging.sql` — staging-зеркало рабочих таблиц с полем
|
||||
`loaded_at` и сниженными ограничениями.
|
||||
- `004__seed_participants.sql` — предзаполнение справочника
|
||||
участников: НРД, БКС (5406121446), Ренессанс (7709258228),
|
||||
Альфа-Банк (7728168971).
|
||||
- **`data-dictionary.md`** — семантика каждого поля.
|
||||
- **`etl-requirements.md`** — требования к процессу выгрузки от
|
||||
команды Fansy: подключение, тип load (UPSERT в staging),
|
||||
SLA свежести по таблицам, обработка ошибок, окна простоя, ПДн.
|
||||
- **`examples/`**:
|
||||
- `example-claim.md` — какие данные `m2m-core` тянет из БД для
|
||||
одной типовой M2M-заявки (с конкретными SQL).
|
||||
- `seed-data.sql` — 5 тестовых клиентов, портфели, договоры —
|
||||
основа для приёмочного теста.
|
||||
|
||||
- `ddl/` — `*.sql` миграции PostgreSQL для всех таблиц.
|
||||
- `data-dictionary.md` — семантика каждого поля (источник в Fansy,
|
||||
nullable, единицы, примеры).
|
||||
- `etl-requirements.md` — требования к процессу выгрузки: тип load,
|
||||
расписание, способ записи, окна простоя, обработка ошибок,
|
||||
конфиденциальность.
|
||||
- `examples/` — пример заявки M2M «end-to-end», 5–10 тестовых клиентов
|
||||
и заявок для совместного приёмочного теста.
|
||||
## Рабочие копии миграций
|
||||
|
||||
Минимальный набор таблиц (см. план):
|
||||
Те же файлы лежат в `migrations/fansy-store/` — оттуда они
|
||||
применяются при инициализации БД сервиса.
|
||||
|
||||
- Депоненты / клиенты.
|
||||
- Документы инвестора (`IdentityDocumentCodeEnum`).
|
||||
- ИИС-договоры (`IIAContractTypeEnum ∈ {T12, T03}`).
|
||||
- Депо-счета и разделы (`AccountId`, `SectionId`, `DeponentCode`).
|
||||
- Реквизиты расчётов (ИНН депозитария).
|
||||
- Портфели и остатки (Whole / Fractional, `IsolationStatus = SGDN`).
|
||||
- Справочник ЦБ (`SecurityCode`, `ISIN`, `Classification`, `Category`).
|
||||
- Контрагенты-участники сервиса MOST (Справочник пользователей).
|
||||
- Audit / staging-таблицы для каждой основной.
|
||||
## Порядок согласования
|
||||
|
||||
1. Передать команде Fansy ссылку на эту папку (тег `fansy-contract-v1`).
|
||||
2. Обсудить с ними SLA, окна простоя, тип load.
|
||||
3. По согласовании — дать им учётку с ролью `fansy_etl` и подсеть для
|
||||
доступа.
|
||||
4. Запустить совместный приёмочный тест на `seed-data.sql`.
|
||||
5. Изменения контракта — через новую папку `v2/` с changelog'ом, без
|
||||
правки `v1/`.
|
||||
|
||||
## Принципы
|
||||
|
||||
- Имена таблиц/колонок — `snake_case` английский.
|
||||
- Комментарии к таблицам и важным колонкам — на русском
|
||||
через `COMMENT ON ... IS '...'`.
|
||||
- Все timestamp — `timestamptz` в UTC.
|
||||
- DDL-права только у `bj_migrator`, у `fansy_etl` нет.
|
||||
- ETL пишет ТОЛЬКО в `fansy_staging.*`. Перелив в `fansy.*` — на нашей
|
||||
стороне после валидации.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Data Dictionary — fansy-store v1
|
||||
|
||||
Семантика полей рабочей схемы `fansy`. Структура staging-схемы
|
||||
`fansy_staging` повторяет её один-к-одному, плюс поле `loaded_at` и
|
||||
отсутствие части ограничений (валидация — при переливе).
|
||||
|
||||
Обозначения: `?` — nullable; `!` — обязательное.
|
||||
|
||||
## participants — справочник контрагентов M2M
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| inn | varchar(10) | ! | ИНН юрлица, PK | `client_master.inn` | `7702165310` |
|
||||
| ogrn | varchar(15) | ? | ОГРН | `client_master.ogrn` | `1027739132563` |
|
||||
| full_name_rus | text | ! | Полное наименование на русском | `client_master.full_name` | `НКО АО НРД` |
|
||||
| short_name_rus | text | ? | Короткое наименование | `client_master.short_name` | `НРД` |
|
||||
| display_name_rus | text | ! | Отображаемое имя для UI | `client_master.display_name` | `НРД` |
|
||||
| full_name_eng | text | ? | Полное наименование на английском | `client_master.full_name_en` | `National Settlement Depository` |
|
||||
| short_name_eng | text | ? | Короткое английское | `client_master.short_name_en` | `NSD` |
|
||||
| display_name_eng | text | ? | Английское display | `client_master.display_name_en` | `NSD` |
|
||||
| depository_participant_code | varchar(12) | ? | Код участника M2M (депозитарий) | `m2m_codes.dep_code` | `MC0010300000` |
|
||||
| broker_participant_code | varchar(12) | ? | Код участника M2M (брокер) | `m2m_codes.brk_code` | `MC0079200001` |
|
||||
| is_available_for_m2m | boolean | ! | Готовность к приёму M2M | `m2m_codes.is_active` | `true` |
|
||||
| comment | text | ? | Свободный комментарий | — | — |
|
||||
| created_at, updated_at | timestamptz | ! | Авто | — | — |
|
||||
|
||||
## securities — справочник ЦБ
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| security_code | char(12) | ! | Идентификатор ЦБ в системе НРД, PK | `security_master.nsd_code` | `MM0766162534` |
|
||||
| isin | char(12) | ? | ISIN | `security_master.isin` | `RU0007661625` |
|
||||
| classification | varchar(4) | ? | `BOND` (облигация), `SHAR` (акция), `MFUN` (ПИФ) | `security_master.type_code` | `SHAR` |
|
||||
| category | varchar(4) | ? | `ORDN`/`PREF`/`UKWN` | `security_master.category` | `ORDN` |
|
||||
| security_type | varchar(256) | ? | Текстовое описание типа | `security_master.type_text` | `Акция обыкновенная` |
|
||||
| security_series | text | ? | Серия выпуска (для облигаций) | `security_master.series` | `01` |
|
||||
| reg_number | varchar(256) | ? | Регистрационный номер выпуска / правил ДУ ПИФ | `security_master.reg_number` | `1-01-00010-A` |
|
||||
| fund_class | varchar(120) | ? | Класс паёв ПИФ | `security_master.fund_class` | `A` |
|
||||
| display_name | text | ! | Отображаемое имя для UI | `security_master.display` | `Сбербанк ао` |
|
||||
|
||||
## clients — депоненты-физлица
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK, генерируется БД | `customer.uuid` | — |
|
||||
| inn | varchar(12) | ? | ИНН (10 цифр юрлицо, 12 цифр физлицо) | `customer.inn` | `771234567890` |
|
||||
| last_name | varchar(50) | ! | Фамилия | `customer.last_name` | `Иванов` |
|
||||
| first_name | varchar(50) | ! | Имя | `customer.first_name` | `Иван` |
|
||||
| middle_name | varchar(50) | ? | Отчество | `customer.middle_name` | `Иванович` |
|
||||
| birth_date | date | ? | Дата рождения | `customer.birth_date` | `1980-01-15` |
|
||||
|
||||
## client_documents — документы инвестора
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | `customer_doc.customer_uuid` | — |
|
||||
| document_type | varchar(2) | ! | Код документа по справочнику НРД (01..91) | `customer_doc.type_code` | `21` |
|
||||
| series | text | ? | Серия (без пробелов) | `customer_doc.series` | `4512` |
|
||||
| number | text | ! | Номер (без пробелов) | `customer_doc.number` | `654321` |
|
||||
| issued_at | date | ? | Дата выдачи | `customer_doc.issued_at` | `2010-05-12` |
|
||||
| issuer | text | ? | Кем выдан | `customer_doc.issuer` | `ОУФМС России` |
|
||||
|
||||
## iia_contracts — договоры ИИС
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | — | — |
|
||||
| agreement_type | varchar(3) | ! | `T12` (ИИС-1/ИИС-2) или `T03` (ИИС-3) | `iia.type` | `T03` |
|
||||
| agreement_number | varchar(128) | ! | Номер договора | `iia.number` | `ИИС78/2024` |
|
||||
| agreement_date | date | ! | Дата заключения | `iia.signed_at` | `2026-01-15` |
|
||||
| broker_inn | varchar(10) | ! | ИНН брокера, ведущего ИИС | `iia.broker_inn` | `0707083893` |
|
||||
|
||||
## settlement_requisites — реквизиты депозитариев
|
||||
|
||||
| Поле | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| id | uuid | ! | PK |
|
||||
| inn | varchar(10) | ! | ИНН депозитария, UNIQUE |
|
||||
| display_name | text | ! | Отображаемое имя |
|
||||
|
||||
## depo_accounts — счета депо
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | — | — |
|
||||
| deponent_code | varchar(50) | ! | Код депонента у депозитария | `depo.deponent_code` | `DP789456` |
|
||||
| account_id | varchar(50) | ! | Номер счёта депо | `depo.account_id` | `31MC0021900000F01` |
|
||||
| section_id | varchar(50) | ! | Номер раздела счёта | `depo.section_id` | `P001` |
|
||||
| depository_inn | varchar(10) | ! | ИНН депозитария | `depo.depository_inn` | `7702070139` |
|
||||
| is_active | boolean | ! | Активен ли счёт | `depo.is_active` | `true` |
|
||||
| is_trading | boolean | ! | Торговый раздел | `depo.is_trading` | `true` |
|
||||
|
||||
Уникальность по тройке `(deponent_code, account_id, section_id)`.
|
||||
|
||||
## portfolios — портфели и остатки ЦБ
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | — | — |
|
||||
| depo_account_id | uuid | ! | FK на `depo_accounts.id` | — | — |
|
||||
| security_code | char(12) | ! | FK на `securities.security_code` | — | `MM0766162534` |
|
||||
| isin | char(12) | ? | Кэш ISIN из securities | — | `RU0007661625` |
|
||||
| quantity_whole | numeric(38,0) | ? | Целое количество (для акций/облигаций) | `position.qty_whole` | `1500` |
|
||||
| quantity_fractional | numeric(38,16) | ? | Дробное (для паёв) | `position.qty_fract` | `2500.7500000000000000` |
|
||||
| isolation_status | varchar(4) | ! | Всегда `SGDN` | — | `SGDN` |
|
||||
| valued_at | timestamptz | ! | На какой момент актуально | `position.valued_at` | `2026-03-02T11:30:00Z` |
|
||||
|
||||
Должно быть заполнено ровно одно из (`quantity_whole`, `quantity_fractional`).
|
||||
|
||||
## etl_errors — журнал ошибок ETL
|
||||
|
||||
| Поле | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| id | bigserial | ! | PK |
|
||||
| source_table | text | ! | Таблица в Fansy |
|
||||
| source_pk | text | ? | PK записи в Fansy |
|
||||
| payload | jsonb | ? | Сама запись для ретрая |
|
||||
| error_message | text | ! | Сообщение об ошибке |
|
||||
| created_at | timestamptz | ! | Когда зафиксирована |
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 000__roles.sql
|
||||
-- Роли для принимающей БД fansy-store.
|
||||
-- Запускать первым, отдельно от структурных миграций.
|
||||
-- Пароли проставляются администратором БД через ALTER ROLE.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fansy_etl') THEN
|
||||
CREATE ROLE fansy_etl LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
|
||||
COMMENT ON ROLE fansy_etl IS
|
||||
'Роль команды Fansy для ETL: INSERT/UPDATE/SELECT в схему fansy_staging. DDL-прав нет.';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_reader') THEN
|
||||
CREATE ROLE bj_reader LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
|
||||
COMMENT ON ROLE bj_reader IS
|
||||
'Роль сервисов Bridge-and-Join-s (m2m-core, lk-gateway) для чтения схемы fansy.';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_migrator') THEN
|
||||
CREATE ROLE bj_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
|
||||
COMMENT ON ROLE bj_migrator IS
|
||||
'Роль с DDL-правами для миграций. Только эта роль может CREATE/ALTER/DROP.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 001__schemas.sql
|
||||
-- Две схемы: fansy_staging (куда пишет ETL Fansy) и fansy (рабочая,
|
||||
-- куда переливаются данные после валидации).
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS fansy_staging AUTHORIZATION bj_migrator;
|
||||
COMMENT ON SCHEMA fansy_staging IS
|
||||
'Staging-схема. ETL Fansy делает UPSERT в эти таблицы. Сюда же пишутся ошибки выгрузки.';
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS fansy AUTHORIZATION bj_migrator;
|
||||
COMMENT ON SCHEMA fansy IS
|
||||
'Рабочая схема. Сюда переливаются актуальные данные триггерами или процедурами после валидации staging.';
|
||||
|
||||
-- Права по ролям. DDL-права остаются только у владельца bj_migrator.
|
||||
GRANT USAGE ON SCHEMA fansy_staging TO fansy_etl;
|
||||
GRANT USAGE ON SCHEMA fansy TO bj_reader;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging
|
||||
GRANT SELECT, INSERT, UPDATE ON TABLES TO fansy_etl;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging
|
||||
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO fansy_etl;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy
|
||||
GRANT SELECT ON TABLES TO bj_reader;
|
||||
@@ -0,0 +1,231 @@
|
||||
-- 002__working.sql
|
||||
-- Рабочая схема fansy. Данные сюда переливаются из fansy_staging после
|
||||
-- валидации. Сервисы Bridge-and-Join-s читают только эту схему.
|
||||
|
||||
SET search_path TO fansy, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- participants — справочник участников сервиса MOST (контрагенты M2M)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
inn varchar(10) PRIMARY KEY,
|
||||
ogrn varchar(15),
|
||||
full_name_rus text NOT NULL,
|
||||
short_name_rus text,
|
||||
display_name_rus text NOT NULL,
|
||||
full_name_eng text,
|
||||
short_name_eng text,
|
||||
display_name_eng text,
|
||||
depository_participant_code varchar(12),
|
||||
broker_participant_code varchar(12),
|
||||
is_available_for_m2m boolean NOT NULL DEFAULT false,
|
||||
comment text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (inn ~ '^[0-9]{10}$'),
|
||||
CHECK (depository_participant_code IS NULL OR depository_participant_code ~ '^[A-Z0-9]+$'),
|
||||
CHECK (broker_participant_code IS NULL OR broker_participant_code ~ '^[A-Z0-9]+$')
|
||||
);
|
||||
COMMENT ON TABLE participants IS 'Справочник участников сервиса MOST: депозитарии и брокеры, между которыми идут M2M-переводы.';
|
||||
COMMENT ON COLUMN participants.inn IS 'ИНН юрлица (10 цифр), первичный ключ.';
|
||||
COMMENT ON COLUMN participants.depository_participant_code IS 'Код участника M2M на стороне депозитария (для DepositoryPlace в M2MTransferHandbook).';
|
||||
COMMENT ON COLUMN participants.broker_participant_code IS 'Код участника M2M на стороне брокера (для BrokerPlace).';
|
||||
COMMENT ON COLUMN participants.is_available_for_m2m IS 'Готовность участника принимать/отправлять M2M-сообщения (включается после подписания НРД-договора).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_dep_code ON participants(depository_participant_code) WHERE depository_participant_code IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_brk_code ON participants(broker_participant_code) WHERE broker_participant_code IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- securities — справочник ценных бумаг
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS securities (
|
||||
security_code char(12) PRIMARY KEY,
|
||||
isin char(12),
|
||||
classification varchar(4),
|
||||
category varchar(4),
|
||||
security_type varchar(256),
|
||||
security_series text,
|
||||
reg_number varchar(256),
|
||||
fund_class varchar(120),
|
||||
display_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (security_code ~ '^[0-9A-Z_/-]+$'),
|
||||
CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'),
|
||||
CHECK (classification IS NULL OR classification IN ('BOND', 'SHAR', 'MFUN')),
|
||||
CHECK (category IS NULL OR category IN ('ORDN', 'PREF', 'UKWN'))
|
||||
);
|
||||
COMMENT ON TABLE securities IS 'Справочник ценных бумаг с их идентификаторами и классификацией.';
|
||||
COMMENT ON COLUMN securities.security_code IS 'Идентификатор ценной бумаги в системе НРД (XSD SecurityCodeType).';
|
||||
COMMENT ON COLUMN securities.classification IS 'Тип ценной бумаги: BOND (облигация), SHAR (акция), MFUN (ПИФ).';
|
||||
COMMENT ON COLUMN securities.category IS 'Категория акций: ORDN (обыкновенные), PREF (привилегированные), UKWN (неизвестно).';
|
||||
COMMENT ON COLUMN securities.reg_number IS 'Регистрационный номер выпуска (для акций и облигаций) или регномер правил доверительного управления ПИФ.';
|
||||
COMMENT ON COLUMN securities.fund_class IS 'Класс паёв ПИФа (если применимо).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_securities_isin ON securities(isin) WHERE isin IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- clients — депоненты / инвесторы
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inn varchar(12),
|
||||
last_name varchar(50) NOT NULL,
|
||||
first_name varchar(50) NOT NULL,
|
||||
middle_name varchar(50),
|
||||
birth_date date,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (inn IS NULL OR inn ~ '^[0-9]{10,12}$')
|
||||
);
|
||||
COMMENT ON TABLE clients IS 'Депоненты-физлица. Привязка к документам и счетам — через FK из дочерних таблиц.';
|
||||
COMMENT ON COLUMN clients.inn IS 'ИНН физлица (12 цифр) или организации (10 цифр), опционально.';
|
||||
COMMENT ON COLUMN clients.last_name IS 'Фамилия (XSD String50, обязательно).';
|
||||
COMMENT ON COLUMN clients.first_name IS 'Имя (XSD String50, обязательно).';
|
||||
COMMENT ON COLUMN clients.middle_name IS 'Отчество (XSD String50, опционально).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_inn ON clients(inn) WHERE inn IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_lastname ON clients(last_name, first_name);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- client_documents — документы инвестора
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_documents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
document_type varchar(2) NOT NULL,
|
||||
series text,
|
||||
number text NOT NULL,
|
||||
issued_at date,
|
||||
issuer text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (document_type IN (
|
||||
'01','02','03','04','05','06','07','09','10','11','12','13','14',
|
||||
'21','22','23','26','27','91'
|
||||
)),
|
||||
CHECK (series IS NULL OR series ~ '^\S+$'),
|
||||
CHECK (number ~ '^\S+$')
|
||||
);
|
||||
COMMENT ON TABLE client_documents IS 'Документы, удостоверяющие личность инвестора. Коды по справочнику НРД (XSD IdentityDocumentCodeEnum).';
|
||||
COMMENT ON COLUMN client_documents.document_type IS 'Код вида документа (01..91, см. XSD НРД).';
|
||||
COMMENT ON COLUMN client_documents.series IS 'Серия документа (без пробелов).';
|
||||
COMMENT ON COLUMN client_documents.number IS 'Номер документа (без пробелов, обязательно).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_client_documents_client ON client_documents(client_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- iia_contracts — договоры ИИС
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iia_contracts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
agreement_type varchar(3) NOT NULL,
|
||||
agreement_number varchar(128) NOT NULL,
|
||||
agreement_date date NOT NULL,
|
||||
broker_inn varchar(10) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (agreement_type IN ('T12', 'T03')),
|
||||
CHECK (broker_inn ~ '^[0-9]{10}$')
|
||||
);
|
||||
COMMENT ON TABLE iia_contracts IS 'Договоры на ведение ИИС инвестора.';
|
||||
COMMENT ON COLUMN iia_contracts.agreement_type IS 'Тип договора: T12 — ИИС-1/ИИС-2 (старый формат); T03 — ИИС-3 (новый).';
|
||||
COMMENT ON COLUMN iia_contracts.broker_inn IS 'ИНН брокера, с которым заключён договор ИИС.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_iia_contracts_client ON iia_contracts(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_iia_contracts_broker ON iia_contracts(broker_inn);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- settlement_requisites — реквизиты расчётов (депозитарии)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_requisites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inn varchar(10) NOT NULL UNIQUE,
|
||||
display_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (inn ~ '^[0-9]{10}$')
|
||||
);
|
||||
COMMENT ON TABLE settlement_requisites IS 'Реквизиты передающего и принимающего депозитариев (XSD SettlementRequisitesType — содержит только ИНН).';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- depo_accounts — депо-счета и разделы
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS depo_accounts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
|
||||
deponent_code varchar(50) NOT NULL,
|
||||
account_id varchar(50) NOT NULL,
|
||||
section_id varchar(50) NOT NULL,
|
||||
depository_inn varchar(10) NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
is_trading boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (depository_inn ~ '^[0-9]{10}$'),
|
||||
UNIQUE (deponent_code, account_id, section_id)
|
||||
);
|
||||
COMMENT ON TABLE depo_accounts IS 'Счета депо инвестора и их разделы у различных депозитариев.';
|
||||
COMMENT ON COLUMN depo_accounts.deponent_code IS 'Код депонента у конкретного депозитария (XSD SettlementDepositoryLocationType.DeponentCode).';
|
||||
COMMENT ON COLUMN depo_accounts.account_id IS 'Номер счёта депо (XSD AccountIdType).';
|
||||
COMMENT ON COLUMN depo_accounts.section_id IS 'Номер раздела счёта депо.';
|
||||
COMMENT ON COLUMN depo_accounts.is_trading IS 'Признак торгового раздела (для отделения от изолированных).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_depo_accounts_client ON depo_accounts(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_depo_accounts_deponent ON depo_accounts(deponent_code);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- portfolios — портфели и остатки ЦБ
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portfolios (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
depo_account_id uuid NOT NULL REFERENCES depo_accounts(id) ON DELETE CASCADE,
|
||||
security_code char(12) NOT NULL REFERENCES securities(security_code),
|
||||
isin char(12),
|
||||
quantity_whole numeric(38, 0),
|
||||
quantity_fractional numeric(38, 16),
|
||||
isolation_status varchar(4) NOT NULL DEFAULT 'SGDN',
|
||||
valued_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (isolation_status IN ('SGDN')),
|
||||
CHECK ((quantity_whole IS NOT NULL) OR (quantity_fractional IS NOT NULL)),
|
||||
CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$')
|
||||
);
|
||||
COMMENT ON TABLE portfolios IS 'Остатки ценных бумаг на счетах депо. Whole/Fractional — choice по XSD QuantityType (заполняется ровно одно).';
|
||||
COMMENT ON COLUMN portfolios.quantity_whole IS 'Целое количество (для акций, облигаций).';
|
||||
COMMENT ON COLUMN portfolios.quantity_fractional IS 'Дробное количество (для паёв ПИФ, до 16 знаков после точки).';
|
||||
COMMENT ON COLUMN portfolios.isolation_status IS 'Статус обособления по XSD НРД, всегда SGDN.';
|
||||
COMMENT ON COLUMN portfolios.valued_at IS 'Дата/время оценки (на какой момент актуален остаток).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_client ON portfolios(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_depo ON portfolios(depo_account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_security ON portfolios(security_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_valued_at ON portfolios(valued_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- etl_errors — ошибки выгрузки Fansy
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etl_errors (
|
||||
id bigserial PRIMARY KEY,
|
||||
source_table text NOT NULL,
|
||||
source_pk text,
|
||||
payload jsonb,
|
||||
error_message text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE etl_errors IS 'Журнал ошибок выгрузки Fansy: что не смогли записать в staging и почему.';
|
||||
COMMENT ON COLUMN etl_errors.source_table IS 'Название таблицы в источнике (Fansy).';
|
||||
COMMENT ON COLUMN etl_errors.source_pk IS 'Первичный ключ записи в источнике (для повторной попытки).';
|
||||
COMMENT ON COLUMN etl_errors.payload IS 'Сама запись, которую не удалось загрузить (для диагностики).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_etl_errors_created ON etl_errors(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_etl_errors_table ON etl_errors(source_table);
|
||||
@@ -0,0 +1,109 @@
|
||||
-- 003__staging.sql
|
||||
-- Staging-схема. Структура повторяет fansy.*, плюс loaded_at и
|
||||
-- допущения на промежуточные NULL'ы (валидация будет в процессе
|
||||
-- перелива в fansy.*).
|
||||
|
||||
SET search_path TO fansy_staging, public;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
inn varchar(10) PRIMARY KEY,
|
||||
ogrn varchar(15),
|
||||
full_name_rus text,
|
||||
short_name_rus text,
|
||||
display_name_rus text,
|
||||
full_name_eng text,
|
||||
short_name_eng text,
|
||||
display_name_eng text,
|
||||
depository_participant_code varchar(12),
|
||||
broker_participant_code varchar(12),
|
||||
is_available_for_m2m boolean,
|
||||
comment text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE participants IS 'Staging для справочника участников. Перезаливка целиком, не чаще раза в сутки.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS securities (
|
||||
security_code char(12) PRIMARY KEY,
|
||||
isin char(12),
|
||||
classification varchar(4),
|
||||
category varchar(4),
|
||||
security_type varchar(256),
|
||||
security_series text,
|
||||
reg_number varchar(256),
|
||||
fund_class varchar(120),
|
||||
display_name text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE securities IS 'Staging для справочника ЦБ. Перезаливка целиком.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id uuid PRIMARY KEY,
|
||||
inn varchar(12),
|
||||
last_name varchar(50),
|
||||
first_name varchar(50),
|
||||
middle_name varchar(50),
|
||||
birth_date date,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE clients IS 'Staging для клиентов. Инкрементный UPSERT по id.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_documents (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
document_type varchar(2),
|
||||
series text,
|
||||
number text,
|
||||
issued_at date,
|
||||
issuer text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE client_documents IS 'Staging для документов клиента. UPSERT по id.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iia_contracts (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
agreement_type varchar(3),
|
||||
agreement_number varchar(128),
|
||||
agreement_date date,
|
||||
broker_inn varchar(10),
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE iia_contracts IS 'Staging для договоров ИИС.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_requisites (
|
||||
id uuid PRIMARY KEY,
|
||||
inn varchar(10) NOT NULL,
|
||||
display_name text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE settlement_requisites IS 'Staging для реквизитов расчётов.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS depo_accounts (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
deponent_code varchar(50),
|
||||
account_id varchar(50),
|
||||
section_id varchar(50),
|
||||
depository_inn varchar(10),
|
||||
is_active boolean,
|
||||
is_trading boolean,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE depo_accounts IS 'Staging для депо-счетов.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portfolios (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
depo_account_id uuid NOT NULL,
|
||||
security_code char(12) NOT NULL,
|
||||
isin char(12),
|
||||
quantity_whole numeric(38, 0),
|
||||
quantity_fractional numeric(38, 16),
|
||||
isolation_status varchar(4),
|
||||
valued_at timestamptz,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE portfolios IS 'Staging для портфелей. UPSERT по id; SLA свежести — 1 мин.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stg_portfolios_loaded ON portfolios(loaded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_stg_clients_loaded ON clients(loaded_at DESC);
|
||||
@@ -0,0 +1,56 @@
|
||||
-- 004__seed_participants.sql
|
||||
-- Предзаполнение справочника участников по DOC/Справочник пользователей.pdf
|
||||
-- НРД и тестовые контрагенты Регламента M2M.
|
||||
|
||||
SET search_path TO fansy, public;
|
||||
|
||||
INSERT INTO participants (
|
||||
inn, ogrn, full_name_rus, short_name_rus, display_name_rus,
|
||||
full_name_eng, short_name_eng, display_name_eng,
|
||||
depository_participant_code, broker_participant_code,
|
||||
is_available_for_m2m, comment
|
||||
) VALUES
|
||||
(
|
||||
'7702165310', '1027739132563',
|
||||
'Небанковская кредитная организация акционерное общество "Национальный расчётный депозитарий"',
|
||||
'НКО АО НРД', 'НРД',
|
||||
'National Settlement Depository', 'NSD', 'NSD',
|
||||
'MC0010300000', NULL, true,
|
||||
'Центральный депозитарий, держатель реестра M2M-сделок.'
|
||||
),
|
||||
(
|
||||
'5406121446', '1025402459334',
|
||||
'Общество с ограниченной ответственностью "Компания БКС"',
|
||||
'ООО "Компания БКС"', 'БКС',
|
||||
'BCS Company Ltd', 'BCS', 'BCS',
|
||||
NULL, 'MC0079200001', true,
|
||||
'Брокер БКС, контрагент M2M.'
|
||||
),
|
||||
(
|
||||
'7709258228', '1027739675260',
|
||||
'Общество с ограниченной ответственностью "Ренессанс Брокер"',
|
||||
'ООО "Ренессанс Брокер"', 'Ренессанс Брокер',
|
||||
'Renaissance Broker Ltd', 'Renaissance', 'Renaissance',
|
||||
NULL, 'MC0010300032', true,
|
||||
'Брокер Ренессанс, контрагент M2M.'
|
||||
),
|
||||
(
|
||||
'7728168971', '1027700067328',
|
||||
'Акционерное общество "Альфа-Банк"',
|
||||
'АО "Альфа-Банк"', 'Альфа-Банк',
|
||||
'Alfa-Bank JSC', 'Alfa-Bank', 'Alfa-Bank',
|
||||
NULL, 'MC0079200033', true,
|
||||
'Брокер Альфа-Банк, контрагент M2M.'
|
||||
)
|
||||
ON CONFLICT (inn) DO UPDATE SET
|
||||
full_name_rus = EXCLUDED.full_name_rus,
|
||||
short_name_rus = EXCLUDED.short_name_rus,
|
||||
display_name_rus = EXCLUDED.display_name_rus,
|
||||
full_name_eng = EXCLUDED.full_name_eng,
|
||||
short_name_eng = EXCLUDED.short_name_eng,
|
||||
display_name_eng = EXCLUDED.display_name_eng,
|
||||
depository_participant_code = EXCLUDED.depository_participant_code,
|
||||
broker_participant_code = EXCLUDED.broker_participant_code,
|
||||
is_available_for_m2m = EXCLUDED.is_available_for_m2m,
|
||||
comment = EXCLUDED.comment,
|
||||
updated_at = now();
|
||||
@@ -0,0 +1,87 @@
|
||||
# Требования к ETL Fansy → fansy-store v1
|
||||
|
||||
## Подключение
|
||||
|
||||
- СУБД: PostgreSQL 16 / PostgreSQL Pro Certified (по согласованию).
|
||||
- Хост, порт, имя БД, IP-allowlist — выдаются администратором ВМ
|
||||
Bridge-and-Join-s отдельно.
|
||||
- Учётная запись: роль **`fansy_etl`** (создаётся миграцией
|
||||
`000__roles.sql`). Пароль выдаётся через защищённый канал, не в
|
||||
репозиторий.
|
||||
- TLS: обязательно (`sslmode=verify-full` со стороны клиента ETL).
|
||||
|
||||
## Куда писать
|
||||
|
||||
- Только в схему `fansy_staging`. Прав на DDL нет, на схему `fansy`
|
||||
тоже нет. INSERT/UPDATE/SELECT на таблицы staging.
|
||||
- Запись в `fansy.*` происходит на нашей стороне после валидации.
|
||||
|
||||
## Тип load
|
||||
|
||||
- **Инкрементный UPSERT** в staging по PK (`id`):
|
||||
```sql
|
||||
INSERT INTO fansy_staging.clients (id, ...) VALUES (...)
|
||||
ON CONFLICT (id) DO UPDATE SET ..., loaded_at = now();
|
||||
```
|
||||
- Справочники с относительно небольшим размером и редкой сменой
|
||||
(`securities`, `participants`) разрешена **полная перезаливка** не
|
||||
чаще одного раза в сутки. Полная перезаливка реализуется через
|
||||
транзакцию: `TRUNCATE` + `COPY` + `COMMIT`.
|
||||
|
||||
## SLA на свежесть данных
|
||||
|
||||
| Таблица | SLA свежести |
|
||||
|---|---|
|
||||
| `portfolios` | ≤ 1 минута после фактического изменения в Fansy |
|
||||
| `clients`, `depo_accounts`, `client_documents`, `iia_contracts` | ≤ 5 минут |
|
||||
| `securities`, `participants`, `settlement_requisites` | ≤ 24 часа (по событию или по расписанию) |
|
||||
|
||||
## Форматы и кодировки
|
||||
|
||||
- Все timestamp — `timestamptz` в **UTC** (явная зона `+00`).
|
||||
- Все строковые поля — UTF-8.
|
||||
- ИНН, коды депонентов, ISIN, SecurityCode — в верхнем регистре.
|
||||
- Числа с дробной частью (`numeric(38,16)`) — точка как разделитель,
|
||||
без разделителей тысяч.
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
При нарушении CHECK-ограничений, FK или типов команда Fansy:
|
||||
|
||||
1. Пишет запись в `fansy_staging.etl_errors`:
|
||||
```sql
|
||||
INSERT INTO fansy_staging.etl_errors (source_table, source_pk, payload, error_message)
|
||||
VALUES ('fansy.position', '<pk>', '<json>', '<text>');
|
||||
```
|
||||
2. Логирует у себя и продолжает работу.
|
||||
3. Не блокирует загрузку остальных записей.
|
||||
|
||||
Мы (Bridge-and-Join-s) еженедельно просматриваем `etl_errors`,
|
||||
поднимаем инциденты с командой Fansy.
|
||||
|
||||
## Окна и расписание
|
||||
|
||||
- Регламентное окно простоя — **с 23:00 до 23:30 МСК**, по средам.
|
||||
В это время ETL может приостанавливаться для обновлений.
|
||||
- Внеплановые работы — анонсируются за 2 часа в общем чате.
|
||||
|
||||
## Конфиденциальность
|
||||
|
||||
- ПДн (ФИО, документ, дата рождения) — только по нужным таблицам.
|
||||
- Журналирование SQL-запросов ETL **не должно** включать значения ПДн.
|
||||
- Соединения только с IP-allowlist'а.
|
||||
|
||||
## Контроль и наблюдаемость
|
||||
|
||||
Мы предоставим команде Fansy `read-only` доступ к двум представлениям:
|
||||
|
||||
- `fansy_staging.v_load_lag` — задержка свежести по таблицам.
|
||||
- `fansy_staging.v_load_stats` — счётчики INSERT/UPDATE за сутки.
|
||||
|
||||
(Создаются в более позднем PR — `M3`.)
|
||||
|
||||
## Точка контакта
|
||||
|
||||
- Технический контакт со стороны Bridge-and-Join-s — указан в
|
||||
`docs/architecture/plan.md`, раздел «Контакты».
|
||||
- Эскалация — в общий канал интеграции, тред «fansy-store ETL».
|
||||
@@ -0,0 +1,118 @@
|
||||
# Пример заявки M2M end-to-end
|
||||
|
||||
Типовой сценарий: инвестор Иванов И.И. подаёт через ЛК заявку на
|
||||
перевод 3 ценных бумаг с депо-счёта у БКС в депо-счёт у Ренессанс
|
||||
Брокера. Один из переводов — паи ПИФ с дробным количеством. ИИС
|
||||
тип T03.
|
||||
|
||||
## Какие данные нужны m2m-core для формирования M2MTransferRequest
|
||||
|
||||
Сервис `m2m-core` достаёт следующее из `fansy-store` (рабочая схема
|
||||
`fansy`) по идентификатору клиента и набору ЦБ:
|
||||
|
||||
### 1. Анкета клиента (для `InvestorInformation`)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
c.last_name,
|
||||
c.first_name,
|
||||
c.middle_name,
|
||||
d.document_type,
|
||||
d.series AS document_series,
|
||||
d.number AS document_number
|
||||
FROM fansy.clients c
|
||||
JOIN fansy.client_documents d ON d.client_id = c.id
|
||||
WHERE c.id = :client_id
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### 2. ИИС-договор (для `IIAAgreementDetails`)
|
||||
|
||||
```sql
|
||||
SELECT agreement_type, agreement_number, agreement_date, broker_inn
|
||||
FROM fansy.iia_contracts
|
||||
WHERE client_id = :client_id
|
||||
ORDER BY agreement_date DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### 3. Реквизиты передающего/принимающего депозитариев
|
||||
|
||||
```sql
|
||||
SELECT inn
|
||||
FROM fansy.settlement_requisites
|
||||
WHERE inn IN (:transferring_inn, :receiving_inn);
|
||||
```
|
||||
|
||||
### 4. Депо-счета и разделы инвестора (для `SettlementAccount`)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
da.deponent_code,
|
||||
da.account_id,
|
||||
da.section_id,
|
||||
da.depository_inn
|
||||
FROM fansy.depo_accounts da
|
||||
WHERE da.client_id = :client_id
|
||||
AND da.depository_inn = :depository_inn
|
||||
AND da.is_active = true;
|
||||
```
|
||||
|
||||
### 5. Информация о ценных бумагах и их остатках
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
p.security_code,
|
||||
s.isin,
|
||||
s.classification,
|
||||
s.category,
|
||||
s.security_type,
|
||||
s.reg_number,
|
||||
s.fund_class,
|
||||
p.quantity_whole,
|
||||
p.quantity_fractional,
|
||||
p.isolation_status
|
||||
FROM fansy.portfolios p
|
||||
JOIN fansy.securities s USING (security_code)
|
||||
WHERE p.client_id = :client_id
|
||||
AND p.security_code = ANY(:requested_codes)
|
||||
AND p.valued_at >= now() - interval '5 minutes';
|
||||
```
|
||||
|
||||
### 6. Проверка достаточности остатков
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
p.security_code,
|
||||
COALESCE(p.quantity_whole, 0) + COALESCE(p.quantity_fractional, 0) AS available
|
||||
FROM fansy.portfolios p
|
||||
WHERE p.client_id = :client_id
|
||||
AND p.security_code = ANY(:requested_codes);
|
||||
```
|
||||
|
||||
Сравниваем `available` с запрошенным количеством. Если меньше — отказ
|
||||
от формирования M2MTransferRequest, ошибка в ЛК.
|
||||
|
||||
## Какие данные команда Fansy обязана положить в staging
|
||||
|
||||
Из примера выше:
|
||||
|
||||
- `clients`: запись на инвестора Иванова И.И.
|
||||
- `client_documents`: документ с DocumentType `21`.
|
||||
- `iia_contracts`: договор T03 с брокером (БКС, ИНН 5406121446).
|
||||
- `depo_accounts`: счёт у БКС с разделом для перевода и счёт у
|
||||
Ренессанс Брокера.
|
||||
- `securities`: 3 записи (SHAR/ORDN, SHAR/PREF, MFUN/UKWN с
|
||||
fund_class='A').
|
||||
- `portfolios`: остатки по этим 3 ЦБ на 1500 / 300 / 2500.75
|
||||
соответственно.
|
||||
- `participants`: НРД, БКС (5406121446), Ренессанс (7709258228) — из
|
||||
начального seed.
|
||||
|
||||
## Результат
|
||||
|
||||
`m2m-core` собирает данные → формирует `M2MTransferRequest` →
|
||||
валидирует → подписывает (через `crypto-service`) → отправляет в НРД
|
||||
через `nsd-adapter`. Получает `M2MTransferDecision` от принимающей
|
||||
стороны, обновляет статус сделки и шлёт callback в ЛК.
|
||||
@@ -0,0 +1,90 @@
|
||||
-- seed-data.sql
|
||||
-- Тестовые данные для совместного приёмочного тестирования
|
||||
-- Bridge-and-Join-s ↔ команда Fansy. Запускать поверх 002__working.sql.
|
||||
|
||||
SET search_path TO fansy, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Реквизиты депозитариев
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO settlement_requisites (id, inn, display_name) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', '7702070139', 'Депозитарий Сбербанк'),
|
||||
('00000000-0000-0000-0000-000000000002', '7802031669', 'Депозитарий СПб Банк'),
|
||||
('00000000-0000-0000-0000-000000000003', '0702345678', 'Депозитарий БКС'),
|
||||
('00000000-0000-0000-0000-000000000004', '0710987654', 'Депозитарий Ренессанс')
|
||||
ON CONFLICT (inn) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Справочник ЦБ (минимальный)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO securities (security_code, isin, classification, category, security_type, reg_number, display_name) VALUES
|
||||
('MM0766162534', 'RU0007661625', 'SHAR', 'ORDN', 'Акция обыкновенная', '1-01-00077-A', 'Газпром ао'),
|
||||
('MM0907654321', 'RU0009029540', 'SHAR', 'PREF', 'Акция привилегированная', '2-02-00009-A', 'Сбербанк ап'),
|
||||
('MM2300100100', NULL, 'MFUN', 'UKWN', 'Пай ПИФ', '23-001', 'ПИФ Альфа Капитал')
|
||||
ON CONFLICT (security_code) DO NOTHING;
|
||||
|
||||
UPDATE securities SET fund_class = 'A' WHERE security_code = 'MM2300100100';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5 тестовых клиентов
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO clients (id, last_name, first_name, middle_name, birth_date) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', 'Иванов', 'Иван', 'Иванович', '1980-01-15'),
|
||||
('22222222-2222-2222-2222-222222222222', 'Петров', 'Пётр', 'Петрович', '1985-06-20'),
|
||||
('33333333-3333-3333-3333-333333333333', 'Сидоров', 'Сидор', 'Сидорович', '1990-11-30'),
|
||||
('44444444-4444-4444-4444-444444444444', 'Кузнецов','Сергей','Михайлович','1975-03-10'),
|
||||
('55555555-5555-5555-5555-555555555555', 'Соколова','Анна', 'Викторовна','1988-09-25')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Документы клиентов
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO client_documents (id, client_id, document_type, series, number, issued_at, issuer) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '21', '4512', '654321', '2010-05-12', 'ОУФМС России по Москве'),
|
||||
('a0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', '21', '4513', '654322', '2011-06-13', 'ОУФМС России по Москве'),
|
||||
('a0000000-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', '21', '4514', '654323', '2012-07-14', 'ОУФМС России по СПб'),
|
||||
('a0000000-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', '03', '111', '222333', '1995-08-15', 'Свидетельство о рождении'),
|
||||
('a0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', '21', '4516', '654325', '2014-09-16', 'ОУФМС России по СПб')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- ИИС-договоры (для 3 клиентов)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO iia_contracts (id, client_id, agreement_type, agreement_number, agreement_date, broker_inn) VALUES
|
||||
('b0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'T03', 'ИИС78/2024', '2026-01-15', '5406121446'),
|
||||
('b0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'T12', 'ИИС79/2023', '2025-12-01', '7709258228'),
|
||||
('b0000000-0000-0000-0000-000000000003', '55555555-5555-5555-5555-555555555555', 'T03', 'ИИС80/2024', '2026-02-10', '7728168971')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Депо-счета
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO depo_accounts (id, client_id, deponent_code, account_id, section_id, depository_inn, is_active, is_trading) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'DP789456', '31MC0021900000F01', 'P001', '7702070139', true, true),
|
||||
('c0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'AA789451', '33MC0021900000F02', 'F002', '7802031669', true, true),
|
||||
('c0000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'DP100200', '31MC0010000000A01', 'A001', '7702070139', true, true),
|
||||
('c0000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'DP300400', '31MC0030000000B01', 'B001', '0702345678', true, true),
|
||||
('c0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'DP500600', '31MC0050000000C01', 'C001', '0710987654', true, true)
|
||||
ON CONFLICT (deponent_code, account_id, section_id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Портфели (остатки ЦБ)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO portfolios (id, client_id, depo_account_id, security_code, isin, quantity_whole, quantity_fractional, valued_at) VALUES
|
||||
('d0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0766162534', 'RU0007661625', 1500, NULL, now()),
|
||||
('d0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0907654321', 'RU0009029540', 300, NULL, now()),
|
||||
('d0000000-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM2300100100', NULL, NULL, 2500.75, now()),
|
||||
('d0000000-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'c0000000-0000-0000-0000-000000000003', 'MM0766162534', 'RU0007661625', 5000, NULL, now()),
|
||||
('d0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'c0000000-0000-0000-0000-000000000005', 'MM2300100100', NULL, NULL, 100.00, now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,18 +1,58 @@
|
||||
# docs/lk-contract/v1 — контракт с ЛК клиента (ESIA Finance)
|
||||
# docs/lk-contract/v1 — контракт с ЛК клиента (ESIA Finance API V1)
|
||||
|
||||
ЛК клиента работает на платформе **ESIA Finance**, контракт описан
|
||||
в `DOC/API ЛК ЕСИА.pdf` (`/api/v1/back_office/...`, Basic HTTP, JSON,
|
||||
ЛК клиента работает на платформе **ESIA Finance**, контракт описан в
|
||||
`DOC/API ЛК ЕСИА.pdf` (`/api/v1/back_office/...`, Basic HTTP, JSON,
|
||||
UTF-8).
|
||||
|
||||
На этапе M1 в `lk-emulator` мы воспроизводим этот контракт для запуска
|
||||
сквозного потока. Реальный ЛК подключится по тому же контракту, без
|
||||
правок на нашей стороне.
|
||||
На этапе M1 в `lk-emulator` (отдельный PR) мы реализуем этот контракт
|
||||
как «как-будто-ЛК» для запуска сквозного потока. Реальный ЛК
|
||||
подключится по тому же контракту без правок на нашей стороне.
|
||||
|
||||
В этом каталоге будут:
|
||||
## Состав каталога
|
||||
|
||||
- `openapi.yaml` — наш OpenAPI-контракт `lk-gateway`, согласованный
|
||||
с командой ЛК.
|
||||
- `examples/` — примеры заявлений и ответов.
|
||||
- `changelog.md` — версионирование контракта.
|
||||
- **`openapi.yaml`** — OpenAPI 3.0 контракт lk-gateway. Описывает
|
||||
четыре операции: создание, чтение, callback статуса и список заявок.
|
||||
Модель `Claim` включает все поля, нужные m2m-core для формирования
|
||||
`M2MTransferRequest`.
|
||||
- **`examples/`**:
|
||||
- `claim-request.json` — пример заявки на перевод (3 ЦБ, ИИС T03).
|
||||
- `claim-response.json` — пример ответа на создание.
|
||||
- `callback-confirmed.json` — callback подтверждения (status_code
|
||||
INFO, 3 коды 01).
|
||||
- `callback-rejected.json` — callback отказа (status_code ERROR).
|
||||
- `error-422.json` — ошибка валидации подписи.
|
||||
- **`changelog.md`** — версионирование контракта.
|
||||
|
||||
Реализация — задача M1.
|
||||
## Что входит в модель заявки
|
||||
|
||||
- Идентификация инвестора (UUID в ЛК, ФИО, документ).
|
||||
- Реквизиты передающего и принимающего депозитариев (ИНН).
|
||||
- Информация об учёте стоимости (`cost_info: yes | no`).
|
||||
- Опциональный блок ИИС (тип T12/T03, номер договора, дата, ИНН брокера).
|
||||
- Массив ценных бумаг (1..N), каждая с:
|
||||
- `security_code` (НРД-код, 12 символов),
|
||||
- идентификацией (`isin` или развёрнутый `security_info`),
|
||||
- количеством (целое `whole` или дробное `fractional` до 16 знаков),
|
||||
- списком счетов депо (`settlement_accounts[]`).
|
||||
- Подписанный XML заявления (base64) и формат подписи
|
||||
(XMLDSig-GOST или XMLDSig-RSA).
|
||||
|
||||
## Что входит в callback статуса
|
||||
|
||||
- `claim_id`, `new_status`, `updated_at`.
|
||||
- Для `rejected`/`timed_out`: код и текст причины из ответа НРД.
|
||||
- Полное `nsd_response` (опц., для аудита).
|
||||
|
||||
## Порядок согласования
|
||||
|
||||
1. Передать команде ЛК ссылку на эту папку (тег `lk-contract-v1`).
|
||||
2. Обсудить базовый URL, авторизацию (Basic, через VPN), окна.
|
||||
3. Запустить `lk-emulator` на нашей стороне как опорную реализацию.
|
||||
4. После приёмки — поднимать реальную интеграцию.
|
||||
|
||||
## Принципы
|
||||
|
||||
- OpenAPI 3.0, валидный по spectral / openapi-cli.
|
||||
- Operation IDs в snake_case.
|
||||
- Описания на русском, имена полей на английском.
|
||||
- Enum'ы значений M2M — буквально как в XSD НРД (T12/T03, BOND/SHAR/MFUN, ...).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog контракта lk-gateway
|
||||
|
||||
## v1.0.0 (2026-05-14)
|
||||
|
||||
Первая опубликованная версия контракта. Соответствует ESIA Finance
|
||||
API V1 (`DOC/API ЛК ЕСИА.pdf`).
|
||||
|
||||
Поддерживаемые операции:
|
||||
|
||||
- `POST /api/v1/back_office/claims/` — создание заявки.
|
||||
- `GET /api/v1/back_office/claims` — список с фильтрами.
|
||||
- `GET /api/v1/back_office/claims/{id}` — деталь.
|
||||
- `PATCH /api/v1/back_office/claims/{id}` — callback статуса.
|
||||
|
||||
Модели:
|
||||
|
||||
- `Claim` — заявка с массивом `securities[]` (1..N ЦБ).
|
||||
- `CreateClaimRequest` — входное тело создания.
|
||||
- `StatusCallback` — обновление статуса с `nsd_response` для аудита.
|
||||
- `ErrorResponse` — формат идентичен ESIA Finance V1.
|
||||
|
||||
Совместимость:
|
||||
|
||||
- HTTP Basic-auth.
|
||||
- UTF-8, JSON.
|
||||
- Поля enum — буквально как в XSD M2M (T12/T03, BOND/SHAR/MFUN,
|
||||
ORDN/PREF/UKWN, INFO/ERROR).
|
||||
|
||||
## Принципы версионирования
|
||||
|
||||
- Несовместимые изменения — `v2/`, `v3/` (новая папка, отдельный
|
||||
changelog).
|
||||
- Совместимые добавления — minor-версия в этом файле.
|
||||
- Документация исправлений — patch-версия в этом файле.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"claim_id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"new_status": "confirmed",
|
||||
"updated_at": "2026-03-02T14:38:12Z",
|
||||
"nsd_response": {
|
||||
"guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status_code": "INFO",
|
||||
"responses": [
|
||||
{
|
||||
"reference_id": "M2M2026030200001",
|
||||
"code": "01",
|
||||
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
|
||||
},
|
||||
{
|
||||
"reference_id": "M2M2026030200002",
|
||||
"code": "01",
|
||||
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
|
||||
},
|
||||
{
|
||||
"reference_id": "M2M2026030200003",
|
||||
"code": "01",
|
||||
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"claim_id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"new_status": "rejected",
|
||||
"reason_code": "07",
|
||||
"reason_text": "Не найдена сделка с таким GUID на стороне принимающего депозитария.",
|
||||
"updated_at": "2026-03-02T14:40:00Z",
|
||||
"nsd_response": {
|
||||
"guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status_code": "ERROR",
|
||||
"responses": [
|
||||
{
|
||||
"code": "07",
|
||||
"text": "Не найдена сделка с таким GUID."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"investor": {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"last_name": "Иванов",
|
||||
"first_name": "Иван",
|
||||
"middle_name": "Иванович",
|
||||
"document": {
|
||||
"document_type": "21",
|
||||
"series": "4512",
|
||||
"number": "654321"
|
||||
}
|
||||
},
|
||||
"transferring_depository_inn": "0702345678",
|
||||
"receiving_depository_inn": "0710987654",
|
||||
"cost_info": {
|
||||
"yes": {
|
||||
"code": "MC0010300032"
|
||||
}
|
||||
},
|
||||
"iia_agreement": {
|
||||
"agreement_type": "T03",
|
||||
"agreement_number": "ИИС78/2024",
|
||||
"agreement_date": "2026-01-15",
|
||||
"broker_inn": "0707083893"
|
||||
},
|
||||
"securities": [
|
||||
{
|
||||
"security_code": "MM0766162534",
|
||||
"security_details": {
|
||||
"isin": "RU0007661625"
|
||||
},
|
||||
"quantity": {
|
||||
"whole": 1500
|
||||
},
|
||||
"settlement_accounts": [
|
||||
{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": {
|
||||
"deponent_code": "DP789456",
|
||||
"account_id": "31MC0021900000F01",
|
||||
"section_id": "P001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"settlement_requisites_inn": "7802031669",
|
||||
"settlement_location": {
|
||||
"deponent_code": "AA789451",
|
||||
"account_id": "33MC0021900000F02",
|
||||
"section_id": "F002"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"security_code": "MM0907654321",
|
||||
"security_details": {
|
||||
"isin": "RU0009029540"
|
||||
},
|
||||
"quantity": {
|
||||
"whole": 300
|
||||
},
|
||||
"settlement_accounts": [
|
||||
{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": {
|
||||
"deponent_code": "DP789456",
|
||||
"account_id": "31MC0021900000F01",
|
||||
"section_id": "P001"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"security_code": "MM2300100100",
|
||||
"security_details": {
|
||||
"security_info": {
|
||||
"classification": "MFUN",
|
||||
"category": "UKWN",
|
||||
"identification_details": {
|
||||
"fund_shares": {
|
||||
"reg_number": "23-001",
|
||||
"class": "A"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quantity": {
|
||||
"fractional": "2500.75"
|
||||
},
|
||||
"settlement_accounts": [
|
||||
{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": {
|
||||
"deponent_code": "DP789456",
|
||||
"account_id": "31MC0021900000F01",
|
||||
"section_id": "P001"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"signed_document": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id2luZG93cy0xMjUxIj8+...base64-XML...",
|
||||
"signature_format": "XMLDSig-GOST"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status": "submitted",
|
||||
"created_at": "2026-03-02T14:30:45Z",
|
||||
"success": true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"error": true,
|
||||
"status": 422,
|
||||
"code": "invalid_signature",
|
||||
"title": "Подпись заявления не прошла проверку",
|
||||
"meta": {
|
||||
"message": "Сертификат подписанта недействителен или цепочка доверия не построена.",
|
||||
"errors": [
|
||||
{
|
||||
"field": "signed_document",
|
||||
"message": "XMLDSig: certificate chain not trusted (signer CN = ИВАНОВ И.И.)."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: lk-gateway API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
REST-контракт между сервисом `lk-gateway` (Bridge-and-Join-s) и ЛК
|
||||
инвестора на платформе ESIA Finance. Версия V1 совместима с
|
||||
официальным API ESIA Finance (`DOC/API ЛК ЕСИА.pdf`).
|
||||
|
||||
Контракт обслуживает жизненный цикл заявки M2M-перевода: создание,
|
||||
получение, обновление статуса и список заявок.
|
||||
|
||||
Аутентификация — HTTP Basic. Кодировка — UTF-8. Тело запросов и
|
||||
ответов — JSON.
|
||||
servers:
|
||||
- url: https://lk-gateway.bridge-and-joins.local
|
||||
description: Production lk-gateway
|
||||
- url: http://localhost:8080
|
||||
description: Локальный эмулятор (lk-emulator)
|
||||
security:
|
||||
- basicAuth: []
|
||||
|
||||
paths:
|
||||
/api/v1/back_office/claims/:
|
||||
post:
|
||||
operationId: create_claim
|
||||
summary: Создать заявку на M2M-перевод
|
||||
description: |
|
||||
Принимает подписанное (XMLDSig) заявление инвестора. Сервис
|
||||
проверяет подпись через crypto-service, валидирует данные,
|
||||
создаёт сделку и инициирует отправку в НРД.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateClaimRequest'
|
||||
examples:
|
||||
full_claim:
|
||||
summary: Заявка с тремя ЦБ, ИИС T03
|
||||
externalValue: ./examples/claim-request.json
|
||||
responses:
|
||||
'201':
|
||||
description: Заявка создана
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateClaimResponse'
|
||||
'400':
|
||||
description: Невалидные входные данные
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Не авторизован
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'422':
|
||||
description: Подпись неверна или данные не прошли бизнес-валидацию
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/api/v1/back_office/claims:
|
||||
get:
|
||||
operationId: list_claims
|
||||
summary: Список заявок
|
||||
description: Возвращает список заявок с фильтрацией по статусу, периоду и инвестору.
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
description: Фильтр по статусу.
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
- name: investor_id
|
||||
in: query
|
||||
description: UUID инвестора в ЛК.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: created_from
|
||||
in: query
|
||||
description: Нижняя граница периода создания (ISO 8601, UTC).
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: created_to
|
||||
in: query
|
||||
description: Верхняя граница периода создания (ISO 8601, UTC).
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
responses:
|
||||
'200':
|
||||
description: Страница списка заявок
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClaimsPage'
|
||||
|
||||
/api/v1/back_office/claims/{id}:
|
||||
get:
|
||||
operationId: get_claim
|
||||
summary: Получить заявку и её статус
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: UUID заявки.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Заявка
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Claim'
|
||||
'404':
|
||||
description: Заявка не найдена
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
patch:
|
||||
operationId: update_claim_status
|
||||
summary: Callback обновления статуса (от lk-gateway к ЛК)
|
||||
description: |
|
||||
Используется лгатвей-ом для уведомления ЛК о смене статуса
|
||||
сделки на стороне НРД. Подтверждение, отказ или таймаут.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: UUID заявки.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StatusCallback'
|
||||
examples:
|
||||
confirmed:
|
||||
summary: Подтверждение
|
||||
externalValue: ./examples/callback-confirmed.json
|
||||
rejected:
|
||||
summary: Отказ
|
||||
externalValue: ./examples/callback-rejected.json
|
||||
responses:
|
||||
'200':
|
||||
description: Callback принят
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CallbackResponse'
|
||||
'404':
|
||||
description: Заявка не найдена
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
basicAuth:
|
||||
type: http
|
||||
scheme: basic
|
||||
|
||||
schemas:
|
||||
ClaimStatus:
|
||||
type: string
|
||||
description: |
|
||||
Жизненный цикл заявки на M2M-перевод.
|
||||
- `draft` — черновик, ещё не подписан.
|
||||
- `signed` — подписан, но не отправлен.
|
||||
- `submitted` — отправлен в НРД.
|
||||
- `in_progress` — НРД принял, ждём решение от принимающей стороны.
|
||||
- `confirmed` — подтверждён, перевод исполнен.
|
||||
- `rejected` — отклонён.
|
||||
- `timed_out` — превышен SLA, ручной разбор.
|
||||
enum:
|
||||
- draft
|
||||
- signed
|
||||
- submitted
|
||||
- in_progress
|
||||
- confirmed
|
||||
- rejected
|
||||
- timed_out
|
||||
|
||||
SignatureFormat:
|
||||
type: string
|
||||
description: Тип цифровой подписи заявления.
|
||||
enum:
|
||||
- XMLDSig-GOST
|
||||
- XMLDSig-RSA
|
||||
|
||||
AgreementType:
|
||||
type: string
|
||||
description: |
|
||||
Тип договора ИИС.
|
||||
- `T12` — ИИС-1 или ИИС-2 (старый формат).
|
||||
- `T03` — ИИС-3 (новый).
|
||||
enum:
|
||||
- T12
|
||||
- T03
|
||||
|
||||
SecurityClassification:
|
||||
type: string
|
||||
description: Тип ценной бумаги.
|
||||
enum:
|
||||
- BOND
|
||||
- SHAR
|
||||
- MFUN
|
||||
|
||||
SecurityCategory:
|
||||
type: string
|
||||
description: Категория акций.
|
||||
enum:
|
||||
- ORDN
|
||||
- PREF
|
||||
- UKWN
|
||||
|
||||
Investor:
|
||||
type: object
|
||||
description: Анкета инвестора.
|
||||
required:
|
||||
- last_name
|
||||
- first_name
|
||||
- document
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID инвестора в ЛК (если уже известен).
|
||||
last_name:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: Иванов
|
||||
first_name:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: Иван
|
||||
middle_name:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: Иванович
|
||||
document:
|
||||
$ref: '#/components/schemas/IdentityDocument'
|
||||
|
||||
IdentityDocument:
|
||||
type: object
|
||||
description: Документ, удостоверяющий личность.
|
||||
required:
|
||||
- document_type
|
||||
- number
|
||||
properties:
|
||||
document_type:
|
||||
type: string
|
||||
pattern: '^(0[1-7]|09|1[0-4]|2[1-37]|26|91)$'
|
||||
description: Код документа по справочнику НРД.
|
||||
example: '21'
|
||||
series:
|
||||
type: string
|
||||
pattern: '^\S+$'
|
||||
example: '4512'
|
||||
number:
|
||||
type: string
|
||||
pattern: '^\S+$'
|
||||
example: '654321'
|
||||
|
||||
Quantity:
|
||||
type: object
|
||||
description: Количество ценных бумаг — choice (ровно одно поле).
|
||||
properties:
|
||||
whole:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
example: 1500
|
||||
fractional:
|
||||
type: string
|
||||
description: Десятичная строка с не более 16 знаками после точки.
|
||||
pattern: '^[0-9]+(\.[0-9]{1,16})?$'
|
||||
example: '2500.75'
|
||||
|
||||
FundShares:
|
||||
type: object
|
||||
required:
|
||||
- reg_number
|
||||
properties:
|
||||
reg_number:
|
||||
type: string
|
||||
maxLength: 256
|
||||
example: '23-001'
|
||||
class:
|
||||
type: string
|
||||
maxLength: 120
|
||||
example: A
|
||||
|
||||
IdentificationDetails:
|
||||
type: object
|
||||
description: Идентификация ЦБ — choice (ровно одно поле).
|
||||
properties:
|
||||
reg_number:
|
||||
type: string
|
||||
maxLength: 20
|
||||
fund_shares:
|
||||
$ref: '#/components/schemas/FundShares'
|
||||
|
||||
SecurityInfo:
|
||||
type: object
|
||||
description: Описание ЦБ при отсутствии ISIN.
|
||||
required:
|
||||
- classification
|
||||
- category
|
||||
- identification_details
|
||||
properties:
|
||||
classification:
|
||||
$ref: '#/components/schemas/SecurityClassification'
|
||||
category:
|
||||
$ref: '#/components/schemas/SecurityCategory'
|
||||
security_type:
|
||||
type: string
|
||||
maxLength: 256
|
||||
security_series:
|
||||
type: string
|
||||
identification_details:
|
||||
$ref: '#/components/schemas/IdentificationDetails'
|
||||
|
||||
SecurityDetails:
|
||||
type: object
|
||||
description: Идентификация ЦБ — choice (ровно одно поле).
|
||||
properties:
|
||||
isin:
|
||||
type: string
|
||||
pattern: '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'
|
||||
example: RU0007661625
|
||||
security_info:
|
||||
$ref: '#/components/schemas/SecurityInfo'
|
||||
|
||||
SettlementLocation:
|
||||
type: object
|
||||
required:
|
||||
- deponent_code
|
||||
- account_id
|
||||
- section_id
|
||||
properties:
|
||||
deponent_code:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: DP789456
|
||||
account_id:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: 31MC0021900000F01
|
||||
section_id:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: P001
|
||||
|
||||
SettlementAccount:
|
||||
type: object
|
||||
required:
|
||||
- settlement_requisites_inn
|
||||
- settlement_location
|
||||
properties:
|
||||
settlement_requisites_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
example: '7702070139'
|
||||
settlement_location:
|
||||
$ref: '#/components/schemas/SettlementLocation'
|
||||
|
||||
ClaimSecurity:
|
||||
type: object
|
||||
required:
|
||||
- security_code
|
||||
- security_details
|
||||
- quantity
|
||||
- settlement_accounts
|
||||
properties:
|
||||
security_code:
|
||||
type: string
|
||||
pattern: '^[0-9A-Z_/-]{12}$'
|
||||
example: MM0766162534
|
||||
security_details:
|
||||
$ref: '#/components/schemas/SecurityDetails'
|
||||
quantity:
|
||||
$ref: '#/components/schemas/Quantity'
|
||||
settlement_accounts:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/SettlementAccount'
|
||||
|
||||
CostInfo:
|
||||
type: object
|
||||
description: |
|
||||
Информация об учёте стоимости приобретения. Choice: либо
|
||||
`yes` (с кодом депонента-источника), либо `no` (учёт не ведётся).
|
||||
properties:
|
||||
yes:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
pattern: '^[A-Z0-9]+$'
|
||||
maxLength: 12
|
||||
example: MC0010300032
|
||||
no:
|
||||
type: object
|
||||
description: Пустой объект — учёт не ведётся.
|
||||
|
||||
IIAAgreement:
|
||||
type: object
|
||||
description: Реквизиты договора ИИС (нужно, если перевод идёт по ИИС).
|
||||
required:
|
||||
- agreement_type
|
||||
- agreement_number
|
||||
- agreement_date
|
||||
- broker_inn
|
||||
properties:
|
||||
agreement_type:
|
||||
$ref: '#/components/schemas/AgreementType'
|
||||
agreement_number:
|
||||
type: string
|
||||
maxLength: 128
|
||||
example: ИИС78/2024
|
||||
agreement_date:
|
||||
type: string
|
||||
format: date
|
||||
example: '2026-01-15'
|
||||
broker_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
example: '0707083893'
|
||||
|
||||
CreateClaimRequest:
|
||||
type: object
|
||||
required:
|
||||
- investor
|
||||
- transferring_depository_inn
|
||||
- receiving_depository_inn
|
||||
- cost_info
|
||||
- securities
|
||||
- signed_document
|
||||
- signature_format
|
||||
properties:
|
||||
investor:
|
||||
$ref: '#/components/schemas/Investor'
|
||||
transferring_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
receiving_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
cost_info:
|
||||
$ref: '#/components/schemas/CostInfo'
|
||||
iia_agreement:
|
||||
$ref: '#/components/schemas/IIAAgreement'
|
||||
securities:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/ClaimSecurity'
|
||||
signed_document:
|
||||
type: string
|
||||
format: byte
|
||||
description: Подписанный XML заявления в base64.
|
||||
signature_format:
|
||||
$ref: '#/components/schemas/SignatureFormat'
|
||||
|
||||
CreateClaimResponse:
|
||||
type: object
|
||||
required: [id, status, created_at, success]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
Claim:
|
||||
type: object
|
||||
description: Полная сущность заявки.
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
- investor
|
||||
- transferring_depository_inn
|
||||
- receiving_depository_inn
|
||||
- securities
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
investor:
|
||||
$ref: '#/components/schemas/Investor'
|
||||
transferring_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
receiving_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
cost_info:
|
||||
$ref: '#/components/schemas/CostInfo'
|
||||
iia_agreement:
|
||||
$ref: '#/components/schemas/IIAAgreement'
|
||||
securities:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ClaimSecurity'
|
||||
last_callback:
|
||||
$ref: '#/components/schemas/StatusCallback'
|
||||
|
||||
ClaimsPage:
|
||||
type: object
|
||||
required: [items, total, limit, offset]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Claim'
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
|
||||
StatusCallback:
|
||||
type: object
|
||||
description: Callback обновления статуса от lk-gateway к ЛК.
|
||||
required:
|
||||
- claim_id
|
||||
- new_status
|
||||
- updated_at
|
||||
properties:
|
||||
claim_id:
|
||||
type: string
|
||||
format: uuid
|
||||
new_status:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
reason_code:
|
||||
type: string
|
||||
maxLength: 6
|
||||
description: Код причины (для rejected/timed_out) из M2MTransferResponse или M2MTransferDecision.
|
||||
example: '01'
|
||||
reason_text:
|
||||
type: string
|
||||
maxLength: 1024
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nsd_response:
|
||||
type: object
|
||||
description: Оригинал ответа НРД (необязательно, для аудита).
|
||||
properties:
|
||||
guid:
|
||||
type: string
|
||||
format: uuid
|
||||
status_code:
|
||||
type: string
|
||||
enum: [INFO, ERROR]
|
||||
responses:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
reference_id:
|
||||
type: string
|
||||
pattern: '^M2M[A-Z0-9]{13}$'
|
||||
code:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
|
||||
CallbackResponse:
|
||||
type: object
|
||||
required: [success]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
description: Формат ошибки, идентичный API ESIA Finance V1.
|
||||
required: [error, status]
|
||||
properties:
|
||||
error:
|
||||
type: boolean
|
||||
example: true
|
||||
status:
|
||||
type: integer
|
||||
example: 422
|
||||
code:
|
||||
type: string
|
||||
example: invalid_signature
|
||||
title:
|
||||
type: string
|
||||
example: Подпись не прошла проверку
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
@@ -12,11 +12,12 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
|
||||
| PR | Файл | Статус | Зависит от |
|
||||
|----|------|--------|-----------|
|
||||
| PR-1 | `PR-1-go-models-m2m.md` | выполнено | — |
|
||||
| PR-2 | `PR-2-fansy-ddl.md` | готово к запуску | — (параллельно с PR-1) |
|
||||
| PR-3 | `PR-3-lk-openapi.md` | готово к запуску | — (параллельно с PR-1) |
|
||||
| PR-4 | `PR-4-m2m-core-skeleton.md` | готово к запуску | PR-1 |
|
||||
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 |
|
||||
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
|
||||
| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
|
||||
| PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) |
|
||||
| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
|
||||
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | выполнено (каркас) | PR-1, PR-4 |
|
||||
| PR-6 | `PR-6-crypto-service-skeleton.md` | выполнено (скелет) | PR-1 |
|
||||
| M2-шаг-1 | сквозной поток: lk-gateway BFF + admin web + lk-emulator + mock NSD | выполнено | PR-1, PR-3, PR-4 |
|
||||
|
||||
## Как запустить задачу
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
module git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
|
||||
go 1.23
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/miekg/pkcs11 v1.1.2
|
||||
golang.org/x/text v0.22.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ=
|
||||
github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,29 @@
|
||||
# internal/cryptocli — Go-клиент crypto-service
|
||||
|
||||
Реализация `m2mcore.CryptoVerifier` поверх gRPC по Unix Domain Socket.
|
||||
|
||||
## Состояние
|
||||
|
||||
На M1 — заглушка. Подключается к UDS-сокету crypto-service, проверяет
|
||||
доступность и возвращает `ErrNotImplemented`. Этого достаточно, чтобы:
|
||||
|
||||
- m2m-core и другие сервисы могли инжектить клиент без условных веток;
|
||||
- логи различали «сокета нет» (например, контейнер crypto-service не
|
||||
запущен) от «сокет есть, но криптография не подключена» (нет лицензии
|
||||
КриптоПро JCP).
|
||||
|
||||
## Когда станет полноценным
|
||||
|
||||
После генерации gRPC-стабов из `services/crypto-service/proto/crypto.proto`
|
||||
(требует `protoc` + плагины), что в свою очередь требует доступа к
|
||||
Maven Central / Go module proxy через прокси zetit.
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
cli := cryptocli.NewClient("/run/bj/crypto.sock")
|
||||
info, err := cli.VerifyXMLDSig(ctx, signedXML)
|
||||
if errors.Is(err, cryptocli.ErrNotImplemented) {
|
||||
// M1: запасной путь (ручная проверка / откладывание).
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,359 @@
|
||||
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
|
||||
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
|
||||
// открывает сессию, перечисляет токены, читает сертификаты и
|
||||
// предоставляет операции Sign/Verify.
|
||||
//
|
||||
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
|
||||
// возвращает понятную ошибку и помечает себя как «провайдер
|
||||
// недоступен». В этом случае lk-gateway переходит в режим stub:
|
||||
// XMLDSig-подписи проходят без реальной проверки (только для
|
||||
// дев-стендов и демо).
|
||||
package cryptocli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/pkcs11"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// Provider — тип СКЗИ-провайдера.
|
||||
type Provider string
|
||||
|
||||
// Известные провайдеры.
|
||||
const (
|
||||
ProviderStub Provider = "stub"
|
||||
ProviderCryptoPro Provider = "cryptopro"
|
||||
ProviderRutoken Provider = "rutoken"
|
||||
ProviderValidata Provider = "validata"
|
||||
ProviderVipNet Provider = "vipnet"
|
||||
)
|
||||
|
||||
// DefaultModulePath возвращает дефолтный путь до PKCS#11 .so модуля
|
||||
// для указанного провайдера. Используется в /admin/setup как placeholder.
|
||||
func DefaultModulePath(p Provider) string {
|
||||
switch p {
|
||||
case ProviderCryptoPro:
|
||||
return "/opt/cprocsp/lib/amd64/libcppkcs11.so"
|
||||
case ProviderRutoken:
|
||||
return "/usr/lib64/librtpkcs11ecp.so"
|
||||
case ProviderValidata:
|
||||
return "/opt/validata/lib/libvalidata-pkcs11.so"
|
||||
case ProviderVipNet:
|
||||
return "/opt/itcs/lib/libvipnet-pkcs11.so"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Config — конфигурация клиента.
|
||||
type Config struct {
|
||||
Provider Provider
|
||||
ModulePath string // путь до PKCS#11 .so модуля (libcppkcs11.so и т.п.)
|
||||
PIN string // PIN для сессии (логин на токен)
|
||||
SlotID uint // 0 = первый доступный
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client — PKCS#11-клиент к СКЗИ.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
mu sync.Mutex
|
||||
ctx *pkcs11.Ctx
|
||||
opened bool
|
||||
}
|
||||
|
||||
// New создаёт клиент. Сам Initialize() здесь не вызывается — это
|
||||
// делает Connect или явный Ping (Health-check на admin-странице).
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
return &Client{cfg: cfg}
|
||||
}
|
||||
|
||||
// Health — лёгкая проверка готовности. Шаги:
|
||||
// 1. Сам файл .so существует?
|
||||
// 2. Initialize модуля?
|
||||
// 3. Есть ли хотя бы один доступный слот с токеном?
|
||||
// 4. Информация о токене (label, manufacturer, serial).
|
||||
func (c *Client) Health(_ context.Context) (HealthInfo, error) {
|
||||
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
|
||||
return HealthInfo{Provider: string(ProviderStub),
|
||||
Message: "Провайдер stub — реальная криптография не подключена."}, nil
|
||||
}
|
||||
if c.cfg.ModulePath == "" {
|
||||
return HealthInfo{}, errors.New("cryptocli: ModulePath не задан")
|
||||
}
|
||||
if _, err := os.Stat(c.cfg.ModulePath); err != nil {
|
||||
return HealthInfo{}, fmt.Errorf("cryptocli: модуль %s не найден: %w", c.cfg.ModulePath, err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.ensureInitLocked(); err != nil {
|
||||
return HealthInfo{}, err
|
||||
}
|
||||
|
||||
info, err := c.ctx.GetInfo()
|
||||
if err != nil {
|
||||
return HealthInfo{}, fmt.Errorf("cryptocli: GetInfo: %w", err)
|
||||
}
|
||||
|
||||
slots, err := c.ctx.GetSlotList(true) // только токены
|
||||
if err != nil {
|
||||
return HealthInfo{}, fmt.Errorf("cryptocli: GetSlotList: %w", err)
|
||||
}
|
||||
h := HealthInfo{
|
||||
Provider: string(c.cfg.Provider),
|
||||
ModulePath: c.cfg.ModulePath,
|
||||
CryptokiVersion: fmt.Sprintf("%d.%d", info.CryptokiVersion.Major, info.CryptokiVersion.Minor),
|
||||
ManufacturerID: info.ManufacturerID,
|
||||
LibraryVersion: fmt.Sprintf("%d.%d", info.LibraryVersion.Major, info.LibraryVersion.Minor),
|
||||
}
|
||||
for _, slot := range slots {
|
||||
tok, err := c.ctx.GetTokenInfo(slot)
|
||||
if err != nil {
|
||||
h.Tokens = append(h.Tokens, TokenInfo{SlotID: slot, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
h.Tokens = append(h.Tokens, TokenInfo{
|
||||
SlotID: slot,
|
||||
Label: tok.Label,
|
||||
Manufacturer: tok.ManufacturerID,
|
||||
Model: tok.Model,
|
||||
SerialNumber: tok.SerialNumber,
|
||||
})
|
||||
}
|
||||
if len(h.Tokens) == 0 {
|
||||
h.Message = "Модуль PKCS#11 загружен, но активных токенов не найдено. Подключите Рутокен или установите ключевой контейнер."
|
||||
} else {
|
||||
h.Message = fmt.Sprintf("Доступно токенов: %d. Криптография готова к работе.", len(h.Tokens))
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Certificate — DER-сертификат с распарсенными атрибутами для UI.
|
||||
type Certificate struct {
|
||||
SlotID uint
|
||||
TokenLabel string
|
||||
Label string // CKA_LABEL (объект на токене)
|
||||
SubjectCN string
|
||||
IssuerCN string
|
||||
Serial string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
INN string // если есть в OID 1.2.643.3.131.1.1
|
||||
DER []byte
|
||||
HasPrivateKey bool // найден ли парный приватный ключ на токене
|
||||
}
|
||||
|
||||
// FindCertificates перечисляет сертификаты на всех подключенных
|
||||
// токенах. Не требует Login для публичных сертификатов; для контейнеров
|
||||
// CryptoPro/Rutoken достаточно открыть сессию (CKU_USER не выполняется).
|
||||
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
|
||||
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
|
||||
return nil, errors.New("cryptocli: провайдер stub — нет реальных сертификатов")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.ensureInitLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slots, err := c.ctx.GetSlotList(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cryptocli: GetSlotList: %w", err)
|
||||
}
|
||||
|
||||
var out []Certificate
|
||||
for _, slot := range slots {
|
||||
tokInfo, _ := c.ctx.GetTokenInfo(slot)
|
||||
certs, err := c.listSlotCertificates(slot, tokInfo.Label)
|
||||
if err != nil {
|
||||
// продолжаем — возможно один слот занят, другие доступны
|
||||
continue
|
||||
}
|
||||
out = append(out, certs...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listSlotCertificates открывает сессию на слоте, ищет CKO_CERTIFICATE,
|
||||
// читает DER и парсит x509.
|
||||
func (c *Client) listSlotCertificates(slot uint, tokenLabel string) ([]Certificate, error) {
|
||||
sess, err := c.ctx.OpenSession(slot, pkcs11.CKF_SERIAL_SESSION)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenSession: %w", err)
|
||||
}
|
||||
defer func() { _ = c.ctx.CloseSession(sess) }()
|
||||
|
||||
template := []*pkcs11.Attribute{
|
||||
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_CERTIFICATE),
|
||||
}
|
||||
if err := c.ctx.FindObjectsInit(sess, template); err != nil {
|
||||
return nil, fmt.Errorf("FindObjectsInit: %w", err)
|
||||
}
|
||||
handles, _, err := c.ctx.FindObjects(sess, 32)
|
||||
_ = c.ctx.FindObjectsFinal(sess)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindObjects: %w", err)
|
||||
}
|
||||
|
||||
out := make([]Certificate, 0, len(handles))
|
||||
for _, h := range handles {
|
||||
attrs, err := c.ctx.GetAttributeValue(sess, h, []*pkcs11.Attribute{
|
||||
pkcs11.NewAttribute(pkcs11.CKA_VALUE, nil),
|
||||
pkcs11.NewAttribute(pkcs11.CKA_LABEL, nil),
|
||||
pkcs11.NewAttribute(pkcs11.CKA_ID, nil),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cert := Certificate{
|
||||
SlotID: slot,
|
||||
TokenLabel: tokenLabel,
|
||||
}
|
||||
var idAttr []byte
|
||||
for _, a := range attrs {
|
||||
switch a.Type {
|
||||
case pkcs11.CKA_VALUE:
|
||||
cert.DER = a.Value
|
||||
case pkcs11.CKA_LABEL:
|
||||
cert.Label = string(a.Value)
|
||||
case pkcs11.CKA_ID:
|
||||
idAttr = a.Value
|
||||
}
|
||||
}
|
||||
// Парсим X.509 (ГОСТ-сертификаты тоже парсятся через crypto/x509
|
||||
// — Subject/Issuer/Serial/Validity не зависят от алгоритма подписи).
|
||||
parsed, err := x509.ParseCertificate(cert.DER)
|
||||
if err == nil {
|
||||
cert.SubjectCN = parsed.Subject.CommonName
|
||||
cert.IssuerCN = parsed.Issuer.CommonName
|
||||
cert.Serial = parsed.SerialNumber.Text(16)
|
||||
cert.NotBefore = parsed.NotBefore
|
||||
cert.NotAfter = parsed.NotAfter
|
||||
// ИНН в OID 1.2.643.3.131.1.1 — извлекаем из Subject.
|
||||
cert.INN = extractINN(parsed)
|
||||
}
|
||||
// Проверим есть ли парный приватный ключ.
|
||||
if len(idAttr) > 0 {
|
||||
cert.HasPrivateKey = c.hasPrivateKey(sess, idAttr)
|
||||
}
|
||||
out = append(out, cert)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hasPrivateKey ищет CKO_PRIVATE_KEY с тем же CKA_ID что и сертификат.
|
||||
func (c *Client) hasPrivateKey(sess pkcs11.SessionHandle, id []byte) bool {
|
||||
tmpl := []*pkcs11.Attribute{
|
||||
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PRIVATE_KEY),
|
||||
pkcs11.NewAttribute(pkcs11.CKA_ID, id),
|
||||
}
|
||||
if err := c.ctx.FindObjectsInit(sess, tmpl); err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = c.ctx.FindObjectsFinal(sess) }()
|
||||
handles, _, err := c.ctx.FindObjects(sess, 1)
|
||||
return err == nil && len(handles) > 0
|
||||
}
|
||||
|
||||
// extractINN ищет ИНН в Subject сертификата по OID НРД 1.2.643.3.131.1.1.
|
||||
func extractINN(c *x509.Certificate) string {
|
||||
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
|
||||
for _, name := range c.Subject.Names {
|
||||
if name.Type.Equal(innOID) {
|
||||
if s, ok := name.Value.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// VerifyXMLDSig — заглушка для интерфейса m2mcore.CryptoVerifier.
|
||||
// Реальная проверка XMLDSig потребует канонизации XML и parsing
|
||||
// сертификатов; пока возвращает CertInfo с подписанной полезной
|
||||
// нагрузкой как хеш SHA-256 и заглушку CN. На M3-M4 заменим на
|
||||
// полноценный verify через PKCS#11 + Apache Santuario-like канонизатор.
|
||||
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
|
||||
if _, err := c.Health(ctx); err != nil {
|
||||
return m2mcore.CertInfo{}, err
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
return m2mcore.CertInfo{
|
||||
SignerCN: "stub-verifier",
|
||||
SignerINN: "",
|
||||
Serial: hex.EncodeToString(sum[:8]),
|
||||
NotBefore: time.Now().Add(-365 * 24 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close завершает работу PKCS#11 модуля.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.ctx == nil {
|
||||
return nil
|
||||
}
|
||||
_ = c.ctx.Finalize()
|
||||
c.ctx.Destroy()
|
||||
c.ctx = nil
|
||||
c.opened = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInitLocked инициализирует PKCS#11 модуль если ещё не.
|
||||
// Должен вызываться под c.mu.Lock.
|
||||
func (c *Client) ensureInitLocked() error {
|
||||
if c.opened {
|
||||
return nil
|
||||
}
|
||||
c.ctx = pkcs11.New(c.cfg.ModulePath)
|
||||
if c.ctx == nil {
|
||||
return fmt.Errorf("cryptocli: не получилось загрузить модуль %s", c.cfg.ModulePath)
|
||||
}
|
||||
if err := c.ctx.Initialize(); err != nil {
|
||||
c.ctx.Destroy()
|
||||
c.ctx = nil
|
||||
return fmt.Errorf("cryptocli: Initialize: %w", err)
|
||||
}
|
||||
c.opened = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthInfo — что показывает /admin/setup и /admin/status.
|
||||
type HealthInfo struct {
|
||||
Provider string
|
||||
ModulePath string
|
||||
CryptokiVersion string
|
||||
ManufacturerID string
|
||||
LibraryVersion string
|
||||
Tokens []TokenInfo
|
||||
Message string
|
||||
}
|
||||
|
||||
// TokenInfo — описание подключённого токена/контейнера.
|
||||
type TokenInfo struct {
|
||||
SlotID uint
|
||||
Label string
|
||||
Manufacturer string
|
||||
Model string
|
||||
SerialNumber string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Ensure Client реализует m2mcore.CryptoVerifier.
|
||||
var _ m2mcore.CryptoVerifier = (*Client)(nil)
|
||||
@@ -0,0 +1,62 @@
|
||||
package cryptocli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
func TestStubProviderHealthOK(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
|
||||
h, err := cli.Health(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Health: %v", err)
|
||||
}
|
||||
if h.Provider != string(cryptocli.ProviderStub) {
|
||||
t.Errorf("Provider = %q", h.Provider)
|
||||
}
|
||||
if !strings.Contains(h.Message, "stub") {
|
||||
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePathMissing(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.ProviderCryptoPro,
|
||||
ModulePath: "/nonexistent/libcppkcs11.so",
|
||||
})
|
||||
_, err := cli.Health(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка о ненайденном модуле")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "не найден") {
|
||||
t.Errorf("неинформативная ошибка: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyModulePath(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderCryptoPro})
|
||||
_, err := cli.Health(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка о пустом ModulePath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModulePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
p cryptocli.Provider
|
||||
want string
|
||||
}{
|
||||
{cryptocli.ProviderCryptoPro, "/opt/cprocsp/lib/amd64/libcppkcs11.so"},
|
||||
{cryptocli.ProviderRutoken, "/usr/lib64/librtpkcs11ecp.so"},
|
||||
{cryptocli.ProviderStub, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := cryptocli.DefaultModulePath(c.p)
|
||||
if got != c.want {
|
||||
t.Errorf("DefaultModulePath(%s) = %q, ожидалось %q", c.p, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# internal/lkemulator — имитация ЛК клиента (ESIA Finance)
|
||||
|
||||
Веб-приложение, которое играет роль ЛК ESIA Finance в сквозных тестах
|
||||
без подключения к реальному ЛК. Используется:
|
||||
|
||||
- для дев-демо «увидеть как работает сквозной поток заявки M2M»;
|
||||
- для приёмочного теста перед интеграцией с реальным ЛК;
|
||||
- как QA-инструмент, который остаётся после внедрения реального ЛК.
|
||||
|
||||
## Веб-страницы
|
||||
|
||||
- `/` — журнал моих заявок с автообновлением каждые 3 сек (показывает
|
||||
изменение статуса по callback'у от lk-gateway).
|
||||
- `/new` — форма «подать заявку M2M»: выпадающий список инвесторов
|
||||
(из `seed-data`), реквизиты сторон, ИИС, одна ЦБ, выбор имитируемого
|
||||
исхода (confirm/reject/timeout).
|
||||
- `/claims/{id}` — карточка с историей: тело POST в lk-gateway,
|
||||
ответ lk-gateway, полученный callback, расшифровка ответа НРД.
|
||||
- `/healthz` — health.
|
||||
- `/api/v1/back_office/claims/{id}` (PATCH) — приёмник callback'ов от
|
||||
lk-gateway.
|
||||
|
||||
## Состав пакета
|
||||
|
||||
- `server.go` — HTTP-сервер, маршруты, рендер шаблонов.
|
||||
- `client.go` — `GatewayClient` (POST заявка, регистрация callback URL).
|
||||
- `types.go` — `Store` (in-memory) и `Claim`/`CallbackRecord` модели.
|
||||
- `web/templates/` — `layout.html`, `home.html`, `new.html`, `claim.html`.
|
||||
|
||||
## Конфигурация (cmd/lk-emulator/main.go, ENV)
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `BJ_HTTP_ADDR` | `:8083` | Адрес HTTP |
|
||||
| `BJ_GATEWAY_URL` | `http://127.0.0.1:8080` | URL lk-gateway, куда шлём POST заявки |
|
||||
| `BJ_EMULATOR_PUBLIC_URL` | `http://127.0.0.1:8083` | Куда gateway должен слать callback'и (регистрируется при старте) |
|
||||
|
||||
## Сквозной поток для проверки
|
||||
|
||||
1. Запустить `./bin/lk-gateway` (порт 8080) и `./bin/lk-emulator` (порт 8083).
|
||||
2. Открыть в браузере `http://127.0.0.1:8083/new`.
|
||||
3. Подать заявку с дефолтными значениями + исход `confirm`.
|
||||
4. На странице `http://127.0.0.1:8083/` через ~3 секунды увидеть
|
||||
статус заявки `confirmed`.
|
||||
5. На странице `http://127.0.0.1:8080/admin/` — дашборд lk-gateway со
|
||||
счётчиком «Подтверждено: 1» и заявкой в журнале.
|
||||
6. На странице `http://127.0.0.1:8080/admin/status` — состояние всех
|
||||
подсистем.
|
||||
@@ -0,0 +1,75 @@
|
||||
package lkemulator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GatewayClient — клиент к lk-gateway по REST.
|
||||
type GatewayClient struct {
|
||||
baseURL string
|
||||
httpc *http.Client
|
||||
}
|
||||
|
||||
// NewGatewayClient — конструктор.
|
||||
func NewGatewayClient(baseURL string) *GatewayClient {
|
||||
return &GatewayClient{
|
||||
baseURL: baseURL,
|
||||
httpc: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateClaim шлёт POST /api/v1/back_office/claims/ и возвращает ответ.
|
||||
func (c *GatewayClient) CreateClaim(ctx context.Context, body map[string]any) (map[string]any, error) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/api/v1/back_office/claims/", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("lk-gateway HTTP %d: %s", resp.StatusCode, string(out))
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("lk-gateway: разбор JSON: %w; raw: %s", err, string(out))
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// SetCallbackURL сообщает gateway свой URL — куда слать PATCH callback'и.
|
||||
func (c *GatewayClient) SetCallbackURL(ctx context.Context, url string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/admin/api/callback-url?url="+url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
buf, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("set callback url HTTP %d: %s", resp.StatusCode, string(buf))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package lkemulator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed web/templates/*.html
|
||||
var tplFS embed.FS
|
||||
|
||||
// ServerConfig — настройки эмулятора ЛК.
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
GatewayURL string
|
||||
SelfPublicURL string // адрес, который мы сообщим gateway для callback'ов
|
||||
}
|
||||
|
||||
// Server — HTTP-сервер эмулятора.
|
||||
type Server struct {
|
||||
cfg ServerConfig
|
||||
store *Store
|
||||
gw *GatewayClient
|
||||
home, new, claim *template.Template
|
||||
srv *http.Server
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// templateFuncs — функции, доступные внутри шаблонов эмулятора
|
||||
// (русификация статусов).
|
||||
var templateFuncs = template.FuncMap{
|
||||
"ruState": ruState,
|
||||
}
|
||||
|
||||
// ruState — те же значения, что и в lkgateway.russianState. Дублирование
|
||||
// допустимое: lkemulator — отдельный пакет и не зависит от lkgateway.
|
||||
func ruState(s string) string {
|
||||
switch s {
|
||||
case "draft":
|
||||
return "Черновик"
|
||||
case "validated":
|
||||
return "Валидирована"
|
||||
case "submitted_to_nsd":
|
||||
return "Отправлена в НРД"
|
||||
case "awaiting_decision":
|
||||
return "Ожидает решение"
|
||||
case "confirmed":
|
||||
return "Подтверждена"
|
||||
case "awaiting_sub16":
|
||||
return "Ожидает SUB16"
|
||||
case "done":
|
||||
return "Завершена"
|
||||
case "rejected":
|
||||
return "Отклонена"
|
||||
case "timed_out":
|
||||
return "Таймаут SLA"
|
||||
case "manual_approval":
|
||||
return "На ручном разборе"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NewServer собирает Server.
|
||||
func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
parse := func(content string) (*template.Template, error) {
|
||||
return template.New("layout.html").Funcs(templateFuncs).ParseFS(tplFS, "web/templates/layout.html", "web/templates/"+content)
|
||||
}
|
||||
home, err := parse("home.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse home: %w", err)
|
||||
}
|
||||
newTpl, err := parse("new.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse new: %w", err)
|
||||
}
|
||||
claimTpl, err := parse("claim.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse claim: %w", err)
|
||||
}
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: NewStore(),
|
||||
gw: NewGatewayClient(cfg.GatewayURL),
|
||||
home: home,
|
||||
new: newTpl,
|
||||
claim: claimTpl,
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.routes()
|
||||
s.srv = &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: s.mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Run поднимает сервер, регистрирует свой URL у gateway, ждёт ctx.Done().
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
go func() {
|
||||
// Дать серверу подняться, потом попытаться зарегистрировать callback URL.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if s.cfg.SelfPublicURL != "" {
|
||||
if err := s.gw.SetCallbackURL(ctx, s.cfg.SelfPublicURL); err != nil {
|
||||
log.Printf("lk-emulator: не получилось зарегистрировать callback URL: %v", err)
|
||||
} else {
|
||||
log.Printf("lk-emulator: callback URL %s зарегистрирован в lk-gateway", s.cfg.SelfPublicURL)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("lk-emulator: listen %s", s.cfg.Addr)
|
||||
errCh <- s.srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.srv.Shutdown(shutCtx)
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Mux возвращает обработчик (для httptest).
|
||||
func (s *Server) Mux() http.Handler { return s.mux }
|
||||
|
||||
// Store возвращает store (для тестов).
|
||||
func (s *Server) Store() *Store { return s.store }
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/", s.handleHome)
|
||||
s.mux.HandleFunc("/new", s.handleNew)
|
||||
s.mux.HandleFunc("/claims/", s.handleClaim)
|
||||
s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
// Принимаем callback'и от lk-gateway (PATCH).
|
||||
s.mux.HandleFunc("/api/v1/back_office/claims/", s.handleCallback)
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Active string
|
||||
GatewayURL string
|
||||
AutoRefresh bool
|
||||
Flash string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) basePage(title, active string, autoRefresh bool) pageData {
|
||||
return pageData{Title: title, Active: active, GatewayURL: s.cfg.GatewayURL, AutoRefresh: autoRefresh}
|
||||
}
|
||||
|
||||
type homeData struct {
|
||||
pageData
|
||||
Claims []*Claim
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := homeData{
|
||||
pageData: s.basePage("Мои заявки", "home", true),
|
||||
Claims: s.store.All(),
|
||||
}
|
||||
if msg := r.URL.Query().Get("flash"); msg != "" {
|
||||
data.Flash = msg
|
||||
}
|
||||
s.renderTpl(w, s.home, data)
|
||||
}
|
||||
|
||||
// clientView — DTO от gateway для выпадающего списка инвесторов.
|
||||
type clientView struct {
|
||||
ID string `json:"ID"`
|
||||
LastName string `json:"LastName"`
|
||||
FirstName string `json:"FirstName"`
|
||||
MiddleName string `json:"MiddleName"`
|
||||
}
|
||||
|
||||
type newData struct {
|
||||
pageData
|
||||
Clients []clientView
|
||||
}
|
||||
|
||||
func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
clients, err := s.fetchClients(r.Context())
|
||||
if err != nil {
|
||||
s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: nil})
|
||||
return
|
||||
}
|
||||
s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := s.submitNew(r); err != nil {
|
||||
clients, _ := s.fetchClients(r.Context())
|
||||
d := newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients}
|
||||
d.Error = err.Error()
|
||||
s.renderTpl(w, s.new, d)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?flash=Заявка+отправлена+в+lk-gateway.+Ждём+callback...", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) submitNew(r *http.Request) error {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return fmt.Errorf("разбор формы: %w", err)
|
||||
}
|
||||
investorID := r.FormValue("investor_id")
|
||||
if investorID == "" {
|
||||
return errors.New("укажите инвестора")
|
||||
}
|
||||
clients, err := s.fetchClients(r.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("список инвесторов: %w", err)
|
||||
}
|
||||
investorName := ""
|
||||
for _, c := range clients {
|
||||
if c.ID == investorID {
|
||||
investorName = strings.TrimSpace(c.LastName + " " + c.FirstName + " " + c.MiddleName)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tInn := r.FormValue("transferring_depository_inn")
|
||||
rInn := r.FormValue("receiving_depository_inn")
|
||||
secCode := r.FormValue("security_code")
|
||||
isin := r.FormValue("isin")
|
||||
qStr := r.FormValue("quantity")
|
||||
whole, err := strconv.ParseUint(qStr, 10, 64)
|
||||
if err != nil || whole == 0 {
|
||||
return errors.New("количество должно быть положительным целым")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"investor": map[string]any{
|
||||
"id": investorID,
|
||||
"last_name": splitFio(investorName, 0),
|
||||
"first_name": splitFio(investorName, 1),
|
||||
"document": map[string]any{
|
||||
"document_type": "21",
|
||||
"series": "4512",
|
||||
"number": "654321",
|
||||
},
|
||||
},
|
||||
"transferring_depository_inn": tInn,
|
||||
"receiving_depository_inn": rInn,
|
||||
"securities": []any{
|
||||
map[string]any{
|
||||
"security_code": secCode,
|
||||
"security_details": map[string]any{"isin": isin},
|
||||
"quantity": map[string]any{"whole": whole},
|
||||
"settlement_accounts": []any{
|
||||
map[string]any{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": map[string]any{
|
||||
"deponent_code": "DP789456",
|
||||
"account_id": "31MC0021900000F01",
|
||||
"section_id": "P001",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"signed_document": base64.StdEncoding.EncodeToString([]byte("<xml>demo</xml>")),
|
||||
"signature_format": "XMLDSig-GOST",
|
||||
}
|
||||
if code := r.FormValue("cost_info_code"); code != "" {
|
||||
body["cost_info"] = map[string]any{"yes": map[string]any{"code": code}}
|
||||
} else {
|
||||
body["cost_info"] = map[string]any{"no": map[string]any{}}
|
||||
}
|
||||
if iiaType := r.FormValue("iia_type"); iiaType != "" {
|
||||
body["iia_agreement"] = map[string]any{
|
||||
"agreement_type": iiaType,
|
||||
"agreement_number": r.FormValue("iia_number"),
|
||||
"agreement_date": r.FormValue("iia_date"),
|
||||
"broker_inn": r.FormValue("iia_broker_inn"),
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.gw.CreateClaim(r.Context(), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gwID, _ := resp["id"].(string)
|
||||
status, _ := resp["status"].(string)
|
||||
|
||||
c := &Claim{
|
||||
ID: randomID(),
|
||||
GatewayID: gwID,
|
||||
Status: status,
|
||||
LocalStatus: "submitted",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
InvestorName: investorName,
|
||||
TransferringDepositoryINN: tInn,
|
||||
ReceivingDepositoryINN: rInn,
|
||||
SecuritiesCount: 1,
|
||||
RawRequest: body,
|
||||
RawResponse: resp,
|
||||
}
|
||||
s.store.Add(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
type claimPage struct {
|
||||
pageData
|
||||
Claim *Claim
|
||||
PrettyRequest string
|
||||
PrettyResponse string
|
||||
}
|
||||
|
||||
func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/claims/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
c := s.store.Get(id)
|
||||
if c == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
d := claimPage{
|
||||
pageData: s.basePage("Заявка", "home", c.LastCallback == nil),
|
||||
Claim: c,
|
||||
}
|
||||
if b, err := json.MarshalIndent(c.RawRequest, "", " "); err == nil {
|
||||
d.PrettyRequest = string(b)
|
||||
}
|
||||
if b, err := json.MarshalIndent(c.RawResponse, "", " "); err == nil {
|
||||
d.PrettyResponse = string(b)
|
||||
}
|
||||
s.renderTpl(w, s.claim, d)
|
||||
}
|
||||
|
||||
// handleCallback принимает PATCH /api/v1/back_office/claims/{id} от lk-gateway.
|
||||
func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
gwID := strings.TrimPrefix(r.URL.Path, "/api/v1/back_office/claims/")
|
||||
if gwID == "" {
|
||||
http.Error(w, "id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
NewStatus string `json:"new_status"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
ReasonText string `json:"reason_text"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
NSDResp *struct {
|
||||
GUID string `json:"guid"`
|
||||
StatusCode string `json:"status_code"`
|
||||
Responses []struct {
|
||||
ReferenceID string `json:"reference_id"`
|
||||
Code string `json:"code"`
|
||||
Text string `json:"text"`
|
||||
} `json:"responses"`
|
||||
} `json:"nsd_response"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cb := &CallbackRecord{
|
||||
NewStatus: payload.NewStatus,
|
||||
ReasonCode: payload.ReasonCode,
|
||||
ReasonText: payload.ReasonText,
|
||||
UpdatedAt: payload.UpdatedAt,
|
||||
}
|
||||
if payload.NSDResp != nil {
|
||||
cb.GUID = payload.NSDResp.GUID
|
||||
cb.StatusCode = payload.NSDResp.StatusCode
|
||||
for _, rr := range payload.NSDResp.Responses {
|
||||
cb.Responses = append(cb.Responses, CallbackResponseEntry{
|
||||
ReferenceID: rr.ReferenceID, Code: rr.Code, Text: rr.Text,
|
||||
})
|
||||
}
|
||||
}
|
||||
if !s.store.ApplyCallback(gwID, cb) {
|
||||
http.Error(w, "claim not found in emulator", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}
|
||||
|
||||
func (s *Server) fetchClients(ctx context.Context) ([]clientView, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.GatewayURL+"/admin/api/clients", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out []clientView
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) renderTpl(w http.ResponseWriter, t *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("lk-emulator: render: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// randomID — генерирует короткий локальный ID заявки (8 hex).
|
||||
func randomID() string {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
func splitFio(fio string, idx int) string {
|
||||
parts := strings.Fields(fio)
|
||||
if idx < len(parts) {
|
||||
return parts[idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package lkemulator_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator"
|
||||
)
|
||||
|
||||
func TestCallbackUpdatesStore(t *testing.T) {
|
||||
srv, err := lkemulator.NewServer(lkemulator.ServerConfig{
|
||||
Addr: ":0",
|
||||
GatewayURL: "http://example.invalid",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Положим заявку в store вручную.
|
||||
c := &lkemulator.Claim{
|
||||
ID: "local-1",
|
||||
GatewayID: "gw-abc",
|
||||
Status: "submitted_to_nsd",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
srv.Store().Add(c)
|
||||
|
||||
body := map[string]any{
|
||||
"claim_id": "gw-abc",
|
||||
"new_status": "confirmed",
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
"nsd_response": map[string]any{
|
||||
"guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status_code": "INFO",
|
||||
"responses": []map[string]any{{"reference_id": "M2M2026030200001", "code": "01", "text": "ok"}},
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/back_office/claims/gw-abc", bytes.NewReader(raw))
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("callback code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
got := srv.Store().Get("local-1")
|
||||
if got.Status != "confirmed" {
|
||||
t.Errorf("статус не обновился, got=%s", got.Status)
|
||||
}
|
||||
if got.LastCallback == nil {
|
||||
t.Fatal("LastCallback nil")
|
||||
}
|
||||
if len(got.LastCallback.Responses) != 1 {
|
||||
t.Errorf("ожидался 1 response, получено %d", len(got.LastCallback.Responses))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackUnknownClaim(t *testing.T) {
|
||||
srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/back_office/claims/unknown", strings.NewReader(`{"new_status":"confirmed"}`))
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("ожидался 404, получено %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomePageEmpty(t *testing.T) {
|
||||
srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("home: %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Заявок ещё нет") {
|
||||
t.Errorf("ожидалось сообщение об отсутствии заявок")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("healthz: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package lkemulator — имитация ЛК клиента (ESIA Finance). Предоставляет
|
||||
// HTML-формы для подачи заявок M2M и приёмник callback'ов от lk-gateway.
|
||||
// Используется для сквозного дев-теста системы Bridge-and-Join-s без
|
||||
// реальной интеграции с ЛК.
|
||||
package lkemulator
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Claim — локальная сохранённая копия заявки + последний callback.
|
||||
type Claim struct {
|
||||
ID string
|
||||
GatewayID string // ID присвоенный lk-gateway (совпадает с m2mcore.Deal.ID)
|
||||
Status string
|
||||
LocalStatus string // "submitted" | "callback_received" — внутренняя метка эмулятора
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
InvestorName string
|
||||
TransferringDepositoryINN string
|
||||
ReceivingDepositoryINN string
|
||||
SecuritiesCount int
|
||||
LastCallback *CallbackRecord
|
||||
RawRequest map[string]any
|
||||
RawResponse map[string]any
|
||||
}
|
||||
|
||||
// CallbackRecord — сохранённый callback от lk-gateway.
|
||||
type CallbackRecord struct {
|
||||
NewStatus string
|
||||
ReasonCode string
|
||||
ReasonText string
|
||||
UpdatedAt time.Time
|
||||
GUID string
|
||||
StatusCode string
|
||||
Responses []CallbackResponseEntry
|
||||
}
|
||||
|
||||
// CallbackResponseEntry — одна строка из NSDResponse в callback'е.
|
||||
type CallbackResponseEntry struct {
|
||||
ReferenceID string
|
||||
Code string
|
||||
Text string
|
||||
}
|
||||
|
||||
// Store — in-memory хранилище заявок эмулятора.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*Claim
|
||||
byGw map[string]*Claim
|
||||
order []string
|
||||
}
|
||||
|
||||
// NewStore — пустое хранилище.
|
||||
func NewStore() *Store {
|
||||
return &Store{byID: make(map[string]*Claim), byGw: make(map[string]*Claim)}
|
||||
}
|
||||
|
||||
// Add сохраняет новую заявку.
|
||||
func (s *Store) Add(c *Claim) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.byID[c.ID] = c
|
||||
if c.GatewayID != "" {
|
||||
s.byGw[c.GatewayID] = c
|
||||
}
|
||||
s.order = append(s.order, c.ID)
|
||||
}
|
||||
|
||||
// Get — по локальному ID.
|
||||
func (s *Store) Get(id string) *Claim {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.byID[id]
|
||||
}
|
||||
|
||||
// GetByGatewayID — по ID от lk-gateway (используется при приёме callback).
|
||||
func (s *Store) GetByGatewayID(id string) *Claim {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.byGw[id]
|
||||
}
|
||||
|
||||
// All возвращает все заявки в обратном порядке создания.
|
||||
func (s *Store) All() []*Claim {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Claim, 0, len(s.order))
|
||||
for i := len(s.order) - 1; i >= 0; i-- {
|
||||
out = append(out, s.byID[s.order[i]])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyCallback обновляет заявку при получении callback'а от lk-gateway.
|
||||
func (s *Store) ApplyCallback(gatewayID string, cb *CallbackRecord) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
c, ok := s.byGw[gatewayID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
c.Status = cb.NewStatus
|
||||
c.LocalStatus = "callback_received"
|
||||
c.UpdatedAt = cb.UpdatedAt
|
||||
c.LastCallback = cb
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span></h2>
|
||||
<div class="kv">
|
||||
<div>Создана</div><div>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</div>
|
||||
<div>Обновлена</div><div>{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}</div>
|
||||
<div>ID lk-gateway</div><div><code>{{.Claim.GatewayID}}</code></div>
|
||||
<div>Инвестор</div><div>{{.Claim.InvestorName}}</div>
|
||||
<div>Передающий депозитарий</div><div><code>ИНН {{.Claim.TransferringDepositoryINN}}</code></div>
|
||||
<div>Принимающий депозитарий</div><div><code>ИНН {{.Claim.ReceivingDepositoryINN}}</code></div>
|
||||
<div>Локально</div><div>{{.Claim.LocalStatus}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Claim.RawRequest}}
|
||||
<div class="card">
|
||||
<h2>Тело отправленной заявки (REST в lk-gateway)</h2>
|
||||
<pre>{{.PrettyRequest}}</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Claim.RawResponse}}
|
||||
<div class="card">
|
||||
<h2>Ответ lk-gateway на создание</h2>
|
||||
<pre>{{.PrettyResponse}}</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Claim.LastCallback}}
|
||||
<div class="card">
|
||||
<h2>Полученный callback от lk-gateway</h2>
|
||||
<div class="kv">
|
||||
<div>Новый статус</div><div><span class="badge {{.Claim.LastCallback.NewStatus}}">{{ruState .Claim.LastCallback.NewStatus}}</span></div>
|
||||
{{if .Claim.LastCallback.ReasonCode}}
|
||||
<div>Код причины</div><div><code>{{.Claim.LastCallback.ReasonCode}}</code> {{.Claim.LastCallback.ReasonText}}</div>
|
||||
{{end}}
|
||||
<div>Время</div><div>{{.Claim.LastCallback.UpdatedAt.Format "02.01.2006 15:04:05"}}</div>
|
||||
{{if .Claim.LastCallback.GUID}}
|
||||
<div>NSD GUID</div><div><code>{{.Claim.LastCallback.GUID}}</code></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Claim.LastCallback.Responses}}
|
||||
<h3 style="margin-top:18px;font-size:14px">NSD Response (детально)</h3>
|
||||
<table>
|
||||
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.LastCallback.Responses}}
|
||||
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card">
|
||||
<p class="muted">Callback от lk-gateway ещё не пришёл. Страница автообновится через 3 секунды.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p style="margin-top:16px"><a href="/">← все заявки</a></p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{define "content"}}
|
||||
{{if .Flash}}<div class="notice">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Журнал моих заявок ({{len .Claims}})</h2>
|
||||
{{if .Claims}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Создана</th><th>ID gateway</th><th>Инвестор</th><th>ЦБ</th><th>Передающий</th><th>Принимающий</th><th>Статус</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Claims}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td><code>{{if .GatewayID}}{{slice .GatewayID 0 8}}{{else}}—{{end}}</code></td>
|
||||
<td>{{.InvestorName}}</td>
|
||||
<td>{{.SecuritiesCount}}</td>
|
||||
<td><code>{{.TransferringDepositoryINN}}</code></td>
|
||||
<td><code>{{.ReceivingDepositoryINN}}</code></td>
|
||||
<td><span class="badge {{.Status}}">{{ruState .Status}}</span></td>
|
||||
<td><a href="/claims/{{.ID}}">детали →</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:12px">Страница автообновляется каждые 3 сек, чтобы видеть переход статуса по callback'у от lk-gateway.</p>
|
||||
{{else}}
|
||||
<p class="muted">Заявок ещё нет. <a href="/new">Подайте первую</a>.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,64 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{.Title}} · lk-emulator</title>
|
||||
<style>
|
||||
:root { --bg:#0c1320; --card:#162032; --border:#27334a; --text:#dde6f4; --muted:#7d8aa0; --accent:#6bb5ff; --ok:#3fbf6c; --warn:#e8b13a; --err:#e85a5a; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
||||
header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 24px; background: #0a0f1a; }
|
||||
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
header h1 .small { font-weight: 400; font-size: 13px; color: var(--muted); margin-left: 8px; }
|
||||
header nav a { color: var(--muted); text-decoration: none; margin-right: 16px; font-size: 14px; }
|
||||
header nav a:hover, header nav a.active { color: var(--text); }
|
||||
main { padding: 24px; max-width: 1100px; margin: 0 auto; }
|
||||
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 18px; margin-bottom: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
tr:hover td { background: rgba(107,181,255,0.05); }
|
||||
a { color: var(--accent); }
|
||||
code { background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 12px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
form { display: grid; gap: 12px; }
|
||||
.form-row { display: grid; grid-template-columns: 200px 1fr; gap: 12px; align-items: center; }
|
||||
input[type=text], input[type=number], select { width: 100%; padding: 8px 10px; background: #0a0f1a; border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 14px; }
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||
.btn { background: var(--accent); color: #0a0f1a; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600; }
|
||||
.btn:hover { opacity: .85; }
|
||||
.btn-secondary { background: var(--border); color: var(--text); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background: rgba(107,181,255,0.15); color: var(--accent); }
|
||||
.badge.awaiting_decision { background: rgba(232,177,58,0.15); color: var(--warn); }
|
||||
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background: rgba(63,191,108,0.15); color: var(--ok); }
|
||||
.badge.rejected, .badge.timed_out { background: rgba(232,90,90,0.15); color: var(--err); }
|
||||
.notice { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; background: rgba(63,191,108,0.1); border-left: 3px solid var(--ok); }
|
||||
.notice.error { background: rgba(232,90,90,0.1); border-left-color: var(--err); }
|
||||
.kv { display: grid; grid-template-columns: 200px 1fr; gap: 4px 16px; font-size: 14px; }
|
||||
.kv > div:nth-child(odd) { color: var(--muted); }
|
||||
pre { background: #0a0f1a; border: 1px solid var(--border); border-radius: 4px; padding: 12px; font-size: 12px; overflow: auto; max-height: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>lk-emulator <span class="small">имитация ЛК ESIA Finance</span></h1>
|
||||
<nav>
|
||||
<a href="/" class="{{if eq .Active "home"}}active{{end}}">Мои заявки</a>
|
||||
<a href="/new" class="{{if eq .Active "new"}}active{{end}}">Подать новую</a>
|
||||
<a href="{{.GatewayURL}}/admin/help" target="_blank" rel="noopener">Инструкции →</a>
|
||||
</nav>
|
||||
<span class="muted" style="margin-left:auto">gateway: <code>{{.GatewayURL}}</code></span>
|
||||
</header>
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
<script>
|
||||
{{if .AutoRefresh}}
|
||||
setTimeout(() => location.reload(), 3000);
|
||||
{{end}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,77 @@
|
||||
{{define "content"}}
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Подача заявки M2M</h2>
|
||||
<p class="muted">Минимальная форма; остальные поля заполняются автоматически по seed-данным (см. <code>docs/fansy-contract/v1/examples/seed-data.sql</code>).</p>
|
||||
<form method="post" action="/new">
|
||||
<div class="form-row">
|
||||
<label>Инвестор</label>
|
||||
<select name="investor_id" required>
|
||||
{{range .Clients}}
|
||||
<option value="{{.ID}}">{{.LastName}} {{.FirstName}} {{.MiddleName}} (id <code>{{slice .ID 0 8}}</code>)</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Передающий депозитарий ИНН</label>
|
||||
<input type="text" name="transferring_depository_inn" value="0702345678" required pattern="[0-9]{10}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Принимающий депозитарий ИНН</label>
|
||||
<input type="text" name="receiving_depository_inn" value="0710987654" required pattern="[0-9]{10}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Код источника учёта</label>
|
||||
<input type="text" name="cost_info_code" value="MC0010300032" placeholder="оставьте пустым = учёт не ведётся">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Тип ИИС</label>
|
||||
<select name="iia_type">
|
||||
<option value="">— нет ИИС —</option>
|
||||
<option value="T12">T12 (ИИС-1/ИИС-2)</option>
|
||||
<option value="T03" selected>T03 (ИИС-3)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Номер договора ИИС</label>
|
||||
<input type="text" name="iia_number" value="ИИС78/2024">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Дата ИИС</label>
|
||||
<input type="text" name="iia_date" value="2026-01-15" pattern="\d{4}-\d{2}-\d{2}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>ИНН брокера ИИС</label>
|
||||
<input type="text" name="iia_broker_inn" value="0707083893" pattern="[0-9]{10}">
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:24px">Ценная бумага (1 шт. в эмуляторе)</h2>
|
||||
<div class="form-row">
|
||||
<label>SecurityCode</label>
|
||||
<input type="text" name="security_code" value="MM0766162534" required pattern="[0-9A-Z_/-]{12}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>ISIN</label>
|
||||
<input type="text" name="isin" value="RU0007661625" required pattern="[A-Z]{2}[A-Z0-9]{9}[0-9]">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Количество (целое)</label>
|
||||
<input type="number" name="quantity" value="1500" min="1" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Имитировать ответ принимающей стороны</label>
|
||||
<select name="outcome">
|
||||
<option value="confirm" selected>Подтверждение (через 3 сек)</option>
|
||||
<option value="reject">Отказ с кодом 07</option>
|
||||
<option value="timeout">Таймаут — Decision не придёт</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<button class="btn" type="submit">Отправить заявку</button>
|
||||
<a class="btn btn-secondary" href="/" style="text-decoration:none; padding:10px 20px">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,70 @@
|
||||
# internal/lkgateway — BFF слой ЛК + admin web UI
|
||||
|
||||
Реализует REST-контракт ESIA Finance V1
|
||||
(`docs/lk-contract/v1/openapi.yaml`) на стороне Bridge-and-Join-s и
|
||||
admin-веб для оператора.
|
||||
|
||||
## REST API
|
||||
|
||||
- `POST /api/v1/back_office/claims/` — приём заявки от ЛК.
|
||||
Валидирует, собирает `M2MTransferRequest` через
|
||||
`m2mcore.EnrichRequest`, создаёт `m2mcore.Deal`, отправляет в НРД
|
||||
через `m2mcore.NSDSender` (на M2 — mock).
|
||||
- `GET /api/v1/back_office/claims/{id}` — карточка заявки.
|
||||
- `GET /api/v1/back_office/claims` — список с фильтрами
|
||||
(status/investor_id/created_from/created_to/limit/offset).
|
||||
- `PATCH /api/v1/back_office/claims/{id}` — placeholder для callback
|
||||
от внешней системы.
|
||||
- `/healthz` — health.
|
||||
|
||||
## Admin web UI
|
||||
|
||||
- `/admin/` — дашборд: статус системы, счётчики (Total/Confirmed/
|
||||
InProgress/Failed), последние 10 заявок.
|
||||
- `/admin/claims` — журнал всех заявок.
|
||||
- `/admin/claims/{id}` — детальная карточка: история FSM, ответ НРД
|
||||
(`M2MTransferResponse`), решение принимающей стороны
|
||||
(`M2MTransferDecision`), последний callback.
|
||||
- `/admin/status` — детальные проверки: postgres, crypto-service (UDS),
|
||||
nsd-adapter (REST), lk-emulator callback URL.
|
||||
|
||||
## Состав пакета
|
||||
|
||||
- `server.go` — `Server` обвязка: HTTP mux + сервис + фоновый
|
||||
consumeDecisions (читает из `mock.Sender.Decisions()` и обновляет
|
||||
сделки + шлёт callback в ЛК).
|
||||
- `service.go` — бизнес-логика: DTO ↔ доменные сущности m2mcore,
|
||||
оркестрация FSM, отправка callback'ов.
|
||||
- `api.go` — REST endpoints.
|
||||
- `admin.go` — HTML endpoints с шаблонами в `web/templates/`.
|
||||
- `checks.go` — проверки готовности подсистем (postgres, crypto-service,
|
||||
nsd-adapter, callback URL).
|
||||
- `seedstore.go` — in-memory `m2mcore.FansyStore` с 5 тестовыми
|
||||
клиентами и счетами депо (соответствует
|
||||
`docs/fansy-contract/v1/examples/seed-data.sql`).
|
||||
- `types.go` — DTO под OpenAPI.
|
||||
- `http_util.go` — JSON-хелперы.
|
||||
|
||||
## Конфигурация (cmd/lk-gateway/main.go, ENV)
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `BJ_HTTP_ADDR` | `:8080` | Адрес HTTP |
|
||||
| `BJ_M2M_SENDER` | `MC0079200000` | DeponentCode отправителя в M2M Header |
|
||||
| `BJ_M2M_RECEIVER` | `MC0010300000` | DeponentCode получателя |
|
||||
| `BJ_DSN` | — | PostgreSQL DSN (M2-шаг-3, пока пусто = in-memory) |
|
||||
| `BJ_CRYPTO_SOCKET` | `/run/bj/crypto.sock` | UDS для crypto-service |
|
||||
| `BJ_NSD_ADAPTER_URL` | — | URL nsd-adapter HTTP (пусто = mock) |
|
||||
| `BJ_LK_CALLBACK_URL` | — | URL ЛК для PATCH callback'ов (пусто = эмулятор регистрирует свой) |
|
||||
| `BJ_NSD_PROFILE` | `demo (mock NSD)` | Имя профиля (отображается в admin) |
|
||||
| `BJ_CRYPTO_PROVIDER` | `stub` | Провайдер криптографии в admin-статусе |
|
||||
|
||||
## Что подключается в следующих шагах
|
||||
|
||||
- **M2-шаг-3**: pgx-репозиторий → миграция
|
||||
`migrations/m2m-core/001__deals.sql` уже готова.
|
||||
- **M3**: реальный `nsd-adapter` вместо mock — выставить
|
||||
`BJ_NSD_ADAPTER_URL` и реализовать в nsd-adapter поллер,
|
||||
отправляющий Decision не через канал mock, а через шину.
|
||||
- **M4**: `admin-ui` v2 на React + раздел «Сертификаты КриптоПро»
|
||||
для обновления публичных сертификатов через UI.
|
||||
@@ -0,0 +1,356 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
//go:embed web/templates/*.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
// admin содержит по одному *template.Template на каждый view (layout +
|
||||
// конкретный content-шаблон). Так html/template не путается с несколькими
|
||||
// {{define "content"}} в разных файлах.
|
||||
type admin struct {
|
||||
home, claims, claim, status, setup *template.Template
|
||||
help, helpDatabase, helpLK, helpCryptoPro, helpSystems, helpRobot, helpArchitecture *template.Template
|
||||
wizard, news *template.Template
|
||||
}
|
||||
|
||||
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
|
||||
// русификация статусов и других технических обозначений (см. требование
|
||||
// «всё UI на русском, кроме программных терминов»).
|
||||
var templateFuncs = template.FuncMap{
|
||||
"ru": russianText,
|
||||
"ruState": russianState,
|
||||
"ruOutcome": russianOutcome,
|
||||
"now": time.Now,
|
||||
}
|
||||
|
||||
// russianState переводит технический FSM-state в человекочитаемый
|
||||
// русский, сохраняя CSS-класс для бейджа.
|
||||
func russianState(s string) string {
|
||||
switch s {
|
||||
case "draft":
|
||||
return "Черновик"
|
||||
case "validated":
|
||||
return "Валидирована"
|
||||
case "submitted_to_nsd":
|
||||
return "Отправлена в НРД"
|
||||
case "awaiting_decision":
|
||||
return "Ожидает решение"
|
||||
case "confirmed":
|
||||
return "Подтверждена"
|
||||
case "awaiting_sub16":
|
||||
return "Ожидает SUB16"
|
||||
case "done":
|
||||
return "Завершена"
|
||||
case "rejected":
|
||||
return "Отклонена"
|
||||
case "timed_out":
|
||||
return "Таймаут SLA"
|
||||
case "manual_approval":
|
||||
return "На ручном разборе"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// russianOutcome — для NSDDecisionSecurity.Outcome.
|
||||
func russianOutcome(o string) string {
|
||||
switch o {
|
||||
case "confirmed":
|
||||
return "Подтверждено"
|
||||
case "rejected":
|
||||
return "Отказ"
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// russianText — fallback функция для произвольных строк (на случай
|
||||
// будущих расширений). Сейчас возвращает строку без изменений.
|
||||
func russianText(s string) string { return s }
|
||||
|
||||
func newAdmin() (*admin, error) {
|
||||
parse := func(content string) (*template.Template, error) {
|
||||
return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
|
||||
"web/templates/layout.html",
|
||||
"web/templates/"+content)
|
||||
}
|
||||
home, err := parse("admin_home.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_home: %w", err)
|
||||
}
|
||||
claims, err := parse("admin_claims.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_claims: %w", err)
|
||||
}
|
||||
claim, err := parse("admin_claim.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_claim: %w", err)
|
||||
}
|
||||
status, err := parse("admin_status.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_status: %w", err)
|
||||
}
|
||||
setup, err := parse("admin_setup.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_setup: %w", err)
|
||||
}
|
||||
help, err := parse("admin_help.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help: %w", err)
|
||||
}
|
||||
helpDB, err := parse("admin_help_database.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_database: %w", err)
|
||||
}
|
||||
helpLK, err := parse("admin_help_lk.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_lk: %w", err)
|
||||
}
|
||||
helpCP, err := parse("admin_help_cryptopro.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_cryptopro: %w", err)
|
||||
}
|
||||
helpSys, err := parse("admin_help_systems.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_systems: %w", err)
|
||||
}
|
||||
wizard, err := parse("admin_wizard.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_wizard: %w", err)
|
||||
}
|
||||
news, err := parse("admin_news.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_news: %w", err)
|
||||
}
|
||||
helpRobot, err := parse("admin_help_robot.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_robot: %w", err)
|
||||
}
|
||||
helpArch, err := parse("admin_help_architecture.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
|
||||
}
|
||||
return &admin{
|
||||
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
||||
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
|
||||
helpRobot: helpRobot, helpArchitecture: helpArch,
|
||||
wizard: wizard, news: news,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// page — общий "конверт" данных для всех шаблонов.
|
||||
type page struct {
|
||||
Title string
|
||||
Active string
|
||||
Now string
|
||||
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
|
||||
MockReason string // короткое описание почему mock
|
||||
}
|
||||
|
||||
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
|
||||
// Заполняется один раз в RegisterAdmin. Альтернатива — таскать rc через
|
||||
// все renderXxx-функции, что шумно при широком фан-ауте.
|
||||
var globalRC *RuntimeConfig
|
||||
|
||||
// homeData — данные дашборда.
|
||||
type homeData struct {
|
||||
page
|
||||
Status SystemStatus
|
||||
Counts struct {
|
||||
Total int
|
||||
Confirmed int
|
||||
InProgress int
|
||||
Failed int
|
||||
}
|
||||
Recent []ClaimView
|
||||
News []NewsItem // top-3 активных или свежих новостей
|
||||
}
|
||||
|
||||
// claimsData — данные журнала.
|
||||
type claimsData struct {
|
||||
page
|
||||
Items []ClaimView
|
||||
}
|
||||
|
||||
// claimData — данные карточки.
|
||||
type claimData struct {
|
||||
page
|
||||
Claim ClaimView
|
||||
}
|
||||
|
||||
// statusData — данные страницы статуса.
|
||||
type statusData struct {
|
||||
page
|
||||
Checks []Status
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
|
||||
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
|
||||
// registerSetup для добавления вкладки «Настройка».
|
||||
func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts func() CheckOptions) (*admin, error) {
|
||||
a, err := newAdmin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
globalRC = rc
|
||||
|
||||
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/admin/")
|
||||
switch {
|
||||
case p == "" || p == "index" || p == "home":
|
||||
a.renderHome(w, r, svc, rc, getOpts())
|
||||
case p == "claims":
|
||||
a.renderClaims(w, r, svc)
|
||||
case strings.HasPrefix(p, "claims/"):
|
||||
id := strings.TrimPrefix(p, "claims/")
|
||||
a.renderClaim(w, r, svc, id)
|
||||
case p == "status":
|
||||
a.renderStatus(w, r, getOpts())
|
||||
case p == "help":
|
||||
render(w, a.help, nowPage("Инструкции", "help"))
|
||||
case p == "help/database":
|
||||
render(w, a.helpDatabase, nowPage("База данных", "help"))
|
||||
case p == "help/lk-api":
|
||||
render(w, a.helpLK, nowPage("API ЛК", "help"))
|
||||
case p == "help/cryptopro":
|
||||
render(w, a.helpCryptoPro, nowPage("КриптоПро", "help"))
|
||||
case p == "help/systems":
|
||||
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
|
||||
case p == "help/robot":
|
||||
render(w, a.helpRobot, nowPage("Тестирование с роботом", "help"))
|
||||
case p == "help/architecture":
|
||||
render(w, a.helpArchitecture, nowPage("Архитектура обмена", "help"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
})
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) {
|
||||
ctx := r.Context()
|
||||
status := CheckAll(ctx, opts)
|
||||
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := homeData{
|
||||
page: nowPage("Дашборд", "home"),
|
||||
Status: status,
|
||||
Recent: recent.Items,
|
||||
News: topNews(rc.Snapshot().News.Items, 3),
|
||||
}
|
||||
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
|
||||
if err == nil {
|
||||
for _, c := range full.Items {
|
||||
data.Counts.Total++
|
||||
switch c.Status {
|
||||
case string(m2mcore.StateConfirmed), string(m2mcore.StateAwaitingSUB16), string(m2mcore.StateDone):
|
||||
data.Counts.Confirmed++
|
||||
case string(m2mcore.StateRejected), string(m2mcore.StateTimedOut):
|
||||
data.Counts.Failed++
|
||||
default:
|
||||
data.Counts.InProgress++
|
||||
}
|
||||
}
|
||||
}
|
||||
render(w, a.home, data)
|
||||
}
|
||||
|
||||
func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
|
||||
pageData, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 200})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items})
|
||||
}
|
||||
|
||||
func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
|
||||
id = path.Base(id)
|
||||
view, err := svc.GetClaim(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
render(w, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: view})
|
||||
}
|
||||
|
||||
func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) {
|
||||
s := CheckAll(r.Context(), opts)
|
||||
render(w, a.status, statusData{
|
||||
page: nowPage("Статус", "status"), Checks: s.Checks, CheckedAt: s.CheckedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, t *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func nowPage(title, active string) page {
|
||||
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
|
||||
if globalRC != nil {
|
||||
s := globalRC.Snapshot()
|
||||
switch {
|
||||
case s.NSD.IGWBaseURL == "":
|
||||
p.IsMockMode = true
|
||||
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
|
||||
case s.Crypto.Provider == "" || s.Crypto.Provider == "stub":
|
||||
p.IsMockMode = true
|
||||
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// topNews отбирает максимум N новостей: сначала те, что активны прямо сейчас
|
||||
// (по ValidFrom..ValidTo), потом просто свежие. Скрытые (Dismissed) — мимо.
|
||||
func topNews(items []NewsItem, n int) []NewsItem {
|
||||
now := time.Now()
|
||||
var active, rest []NewsItem
|
||||
for _, it := range items {
|
||||
if it.Dismissed {
|
||||
continue
|
||||
}
|
||||
isActive := !it.ValidFrom.IsZero() && !it.ValidTo.IsZero() &&
|
||||
now.After(it.ValidFrom) && now.Before(it.ValidTo)
|
||||
// «Будущие» окна с ValidFrom в будущем тоже считаем актуальными
|
||||
// (предупредить заранее).
|
||||
isUpcoming := !it.ValidFrom.IsZero() && now.Before(it.ValidFrom) &&
|
||||
it.ValidFrom.Sub(now) < 7*24*time.Hour
|
||||
if isActive || isUpcoming {
|
||||
active = append(active, it)
|
||||
} else {
|
||||
rest = append(rest, it)
|
||||
}
|
||||
}
|
||||
out := active
|
||||
if len(out) < n {
|
||||
need := n - len(out)
|
||||
if need > len(rest) {
|
||||
need = len(rest)
|
||||
}
|
||||
out = append(out, rest[:need]...)
|
||||
}
|
||||
if len(out) > n {
|
||||
out = out[:n]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// RegisterAPI вешает REST-маршруты ESIA Finance V1 на mux.
|
||||
func RegisterAPI(mux *http.ServeMux, svc *Service) {
|
||||
mux.HandleFunc("/api/v1/back_office/claims/", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/back_office/claims/")
|
||||
switch {
|
||||
case path == "" && r.Method == http.MethodPost:
|
||||
handleCreateClaim(w, r, svc)
|
||||
case path != "" && r.Method == http.MethodGet:
|
||||
handleGetClaim(w, r, svc, path)
|
||||
case path != "" && r.Method == http.MethodPatch:
|
||||
// PATCH без id — отдельный обработчик ниже; этот блок для PATCH
|
||||
// с id, который lk-gateway сам себе бы посылал. На практике не
|
||||
// используется (callback идёт в ЛК), но реализуем по контракту.
|
||||
handlePatchClaim(w, r, svc, path)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Метод не разрешён", r.Method)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/v1/back_office/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Метод не разрешён", r.Method)
|
||||
return
|
||||
}
|
||||
handleListClaims(w, r, svc)
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateClaim(w http.ResponseWriter, r *http.Request, svc *Service) {
|
||||
defer r.Body.Close()
|
||||
var in CreateClaimRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "Не смогли разобрать JSON", err.Error())
|
||||
return
|
||||
}
|
||||
out, err := svc.CreateClaim(r.Context(), in)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_claim", "Заявка не прошла валидацию", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func handleGetClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
|
||||
view, err := svc.GetClaim(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, m2mcore.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Заявка не найдена", id)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func handleListClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
|
||||
q := r.URL.Query()
|
||||
filter := m2mcore.Filter{}
|
||||
if s := q.Get("status"); s != "" {
|
||||
st := m2mcore.State(s)
|
||||
filter.State = &st
|
||||
}
|
||||
if inv := q.Get("investor_id"); inv != "" {
|
||||
filter.InvestorID = inv
|
||||
}
|
||||
if l := q.Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
|
||||
filter.Limit = n
|
||||
}
|
||||
}
|
||||
if o := q.Get("offset"); o != "" {
|
||||
if n, err := strconv.Atoi(o); err == nil && n >= 0 {
|
||||
filter.Offset = n
|
||||
}
|
||||
}
|
||||
page, err := svc.ListClaims(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
func handlePatchClaim(w http.ResponseWriter, _ *http.Request, _ *Service, _ string) {
|
||||
// В сценарии M1-M2 PATCH /claims/{id} от внешней системы (как
|
||||
// callback от НРД) не используется — мы сами шлём callback в ЛК.
|
||||
// Но оставляем заглушку с 200, чтобы покрыть контракт OpenAPI.
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
|
||||
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
|
||||
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
|
||||
// (www.nsd.ru/workflow/system/cryptography/) сертификаты выложены в виде
|
||||
// .cer файлов — нужно скопировать их прямые URL сюда.
|
||||
//
|
||||
// По умолчанию список пустой, потому что прямые URL у НРД меняются от
|
||||
// релиза к релизу и должны быть проверены оператором перед использованием.
|
||||
var defaultNSDCAURLs = []string{
|
||||
// https://www.nsd.ru/workflow/system/cryptography/ — раскомментируйте
|
||||
// нужные ссылки в UI после того, как уточните URL у НРД.
|
||||
}
|
||||
|
||||
// FetchCACertificates скачивает все URL из настроек, парсит .cer, и при
|
||||
// успехе вызывает certmgr -inst -store mroot. Если передан rc — на каждое
|
||||
// фактическое изменение сертификата (новый или изменился SHA-256)
|
||||
// публикуется новость в ленту через rc.AddNews. На сертификаты,
|
||||
// истекающие в ближайшие 14 дней — отдельная новость-предупреждение.
|
||||
func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConfig) (CACertsSettings, string) {
|
||||
if len(s.URLs) == 0 {
|
||||
return s, "Список URL пуст. Добавьте ссылки на .cer-файлы УЦ НРД в /admin/setup → «Сертификаты УЦ»."
|
||||
}
|
||||
var logBuf strings.Builder
|
||||
now := time.Now()
|
||||
newFetched := make([]FetchedCACert, 0, len(s.URLs))
|
||||
|
||||
for _, u := range s.URLs {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
fc := FetchedCACert{URL: u, FetchedAt: now}
|
||||
der, err := downloadAndParseCert(ctx, u)
|
||||
if err != nil {
|
||||
fc.Error = err.Error()
|
||||
newFetched = append(newFetched, fc)
|
||||
fmt.Fprintf(&logBuf, "%s — ОШИБКА: %s\n", u, err)
|
||||
continue
|
||||
}
|
||||
cert, perr := x509.ParseCertificate(der)
|
||||
if perr != nil {
|
||||
fc.Error = "не удалось распарсить X.509: " + perr.Error()
|
||||
newFetched = append(newFetched, fc)
|
||||
fmt.Fprintf(&logBuf, "%s — не X.509: %s\n", u, perr)
|
||||
continue
|
||||
}
|
||||
fc.SubjectCN = cert.Subject.CommonName
|
||||
fc.IssuerCN = cert.Issuer.CommonName
|
||||
fc.NotAfter = cert.NotAfter
|
||||
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
|
||||
// УЦ-сертификаты с самоподписью (Issuer == Subject) идут в mroot,
|
||||
// промежуточные — в uRoot.
|
||||
store := "uRoot"
|
||||
if cert.Subject.CommonName == cert.Issuer.CommonName {
|
||||
store = "mroot"
|
||||
}
|
||||
fc.Store = store
|
||||
|
||||
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
|
||||
// сам импорт (но фиксируем что проверили).
|
||||
alreadyImported := false
|
||||
for _, old := range s.FetchedCerts {
|
||||
if old.URL == u && old.SHA256 == fc.SHA256 && old.Error == "" {
|
||||
alreadyImported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if alreadyImported {
|
||||
fmt.Fprintf(&logBuf, "%s — не изменился (sha256=%s...)\n", u, fc.SHA256[:12])
|
||||
newFetched = append(newFetched, fc)
|
||||
continue
|
||||
}
|
||||
|
||||
// Импорт через certmgr.
|
||||
isNew := true
|
||||
for _, old := range s.FetchedCerts {
|
||||
if old.URL == u && old.Error == "" {
|
||||
isNew = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := importCertToStore(ctx, der, store); err != nil {
|
||||
fc.Error = "certmgr: " + err.Error()
|
||||
fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err)
|
||||
if rc != nil {
|
||||
_ = rc.AddNews(NewsItem{
|
||||
ID: "ca-error-" + fc.SHA256[:12],
|
||||
At: now,
|
||||
Kind: "system",
|
||||
Title: "Не удалось импортировать сертификат УЦ",
|
||||
Body: "URL: " + u + "\nCN: " + fc.SubjectCN + "\nОшибка: " + err.Error(),
|
||||
URL: u,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n",
|
||||
u, store, fc.SubjectCN, fc.SHA256[:12])
|
||||
if rc != nil {
|
||||
kindTitle := "Обновлён сертификат УЦ"
|
||||
if isNew {
|
||||
kindTitle = "Установлен новый сертификат УЦ"
|
||||
}
|
||||
_ = rc.AddNews(NewsItem{
|
||||
ID: "ca-update-" + fc.SHA256[:12],
|
||||
At: now,
|
||||
Kind: "feature",
|
||||
Title: kindTitle + ": " + fc.SubjectCN,
|
||||
Body: fmt.Sprintf("Хранилище: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
|
||||
store, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
|
||||
URL: u,
|
||||
ValidTo: fc.NotAfter,
|
||||
})
|
||||
// Предупреждение если истекает в ближайшие 14 дней.
|
||||
if !fc.NotAfter.IsZero() && time.Until(fc.NotAfter) < 14*24*time.Hour {
|
||||
_ = rc.AddNews(NewsItem{
|
||||
ID: "ca-expiring-" + fc.SHA256[:12],
|
||||
At: now,
|
||||
Kind: "system",
|
||||
Title: "⚠ Сертификат УЦ скоро истечёт: " + fc.SubjectCN,
|
||||
Body: fmt.Sprintf("Срок действия — %s (через %d дней). Получите новую версию у УЦ и обновите URL в /admin/setup → «Сертификаты УЦ».",
|
||||
fc.NotAfter.Format("02.01.2006"),
|
||||
int(time.Until(fc.NotAfter)/(24*time.Hour))),
|
||||
URL: u,
|
||||
ValidTo: fc.NotAfter,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
newFetched = append(newFetched, fc)
|
||||
}
|
||||
|
||||
s.LastFetch = now
|
||||
s.LastFetchLog = logBuf.String()
|
||||
s.FetchedCerts = newFetched
|
||||
return s, logBuf.String()
|
||||
}
|
||||
|
||||
func sha256Bytes(b []byte) []byte {
|
||||
h := sha256.Sum256(b)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// downloadAndParseCert качает URL и возвращает DER-байты сертификата.
|
||||
// Поддерживает PEM (-----BEGIN CERTIFICATE-----) и сырой DER.
|
||||
func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("поддерживаются только http/https, получено %q", u.Scheme)
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||
// noProxyClient определён в news.go — игнорирует HTTPS_PROXY (zetit).
|
||||
resp, err := noProxyClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("сеть: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Пробуем PEM.
|
||||
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
|
||||
return block.Bytes, nil
|
||||
}
|
||||
// Иначе считаем что DER.
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// importCertToStore вызывает certmgr -inst -store <store> -file <tmp>.
|
||||
func importCertToStore(ctx context.Context, der []byte, store string) error {
|
||||
const certmgr = "/opt/cprocsp/bin/amd64/certmgr"
|
||||
if _, err := os.Stat(certmgr); err != nil {
|
||||
return fmt.Errorf("certmgr не найден (КриптоПро CSP не установлен?): %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "bj-ca-*.cer")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(der); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
cmd := exec.CommandContext(ctx, certmgr, "-inst", "-store", store, "-file", tmp.Name())
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w / %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartCACertsAutoUpdater запускает горутину, которая раз в сутки
|
||||
// перекачивает сертификаты УЦ и переустанавливает изменённые. Возвращает
|
||||
// функцию остановки. Если AutoUpdate=false — фон не запускается.
|
||||
func StartCACertsAutoUpdater(rc *RuntimeConfig) func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
// При старте — небольшой запас, чтобы не лезть в сеть в ту же
|
||||
// секунду запуска bj-server.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
s := rc.Snapshot().CACerts
|
||||
if s.AutoUpdate && len(s.URLs) > 0 {
|
||||
updated, _ := FetchCACertificates(ctx, s, rc)
|
||||
if err := rc.UpdateCACerts(updated); err != nil {
|
||||
log.Printf("ca-certs auto-update: save failed: %v", err)
|
||||
} else {
|
||||
log.Printf("ca-certs auto-update: %d url'ов проверено", len(s.URLs))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return cancel
|
||||
}
|
||||
|
||||
// saveCACerts — POST /admin/setup/cacerts.
|
||||
// Принимает форму с textarea (одна URL на строку) и чекбоксом auto_update.
|
||||
func (h *setupHandlers) saveCACerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
raw := r.FormValue("urls")
|
||||
auto := r.FormValue("auto_update") == "on"
|
||||
urls := []string{}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
urls = append(urls, line)
|
||||
}
|
||||
}
|
||||
cur := h.rc.Snapshot().CACerts
|
||||
cur.URLs = urls
|
||||
cur.AutoUpdate = auto
|
||||
if err := h.rc.UpdateCACerts(cur); err != nil {
|
||||
setupFlash(w, r, "Сертификаты УЦ: не получилось сохранить: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, fmt.Sprintf("Сертификаты УЦ: сохранено %d URL'ов, авто-обновление: %v", len(urls), auto))
|
||||
}
|
||||
|
||||
// fetchCACertsNow — POST /admin/setup/cacerts/fetch.
|
||||
// Ручной триггер «скачать сейчас», вызывает FetchCACertificates сразу.
|
||||
func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cur := h.rc.Snapshot().CACerts
|
||||
updated, summary := FetchCACertificates(ctx, cur, h.rc)
|
||||
if err := h.rc.UpdateCACerts(updated); err != nil {
|
||||
setupFlash(w, r, "Сертификаты УЦ: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
if summary == "" {
|
||||
summary = "готово"
|
||||
}
|
||||
// Обрезаем длинный лог в flash-сообщении.
|
||||
if len(summary) > 800 {
|
||||
summary = summary[:800] + "…"
|
||||
}
|
||||
setupFlash(w, r, "Сертификаты УЦ обновлены: "+strings.TrimSpace(summary))
|
||||
}
|
||||
|
||||
// caCertsTemplateString — компактный URL для отображения в UI.
|
||||
func caCertsTemplateString(s CACertsSettings) string {
|
||||
return strings.Join(s.URLs, "\n")
|
||||
}
|
||||
|
||||
// доп. защита от пустых импортов (linter)
|
||||
var _ = filepath.Join
|
||||
@@ -0,0 +1,163 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status — состояние одной проверяемой подсистемы.
|
||||
type Status struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// SystemStatus — все проверки.
|
||||
type SystemStatus struct {
|
||||
Profile string `json:"profile"`
|
||||
Provider string `json:"crypto_provider"`
|
||||
Checks []Status `json:"checks"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
// CheckOptions — что и как проверять.
|
||||
type CheckOptions struct {
|
||||
PostgresDSN string // если пусто — режим in-memory, проверки нет
|
||||
CryptoSocket string // путь до UDS crypto-service
|
||||
NSDAdapterURL string // например http://127.0.0.1:8082
|
||||
LKCallbackURL string // куда шлём callback (lk-emulator)
|
||||
Profile string // имя профиля nsdadapter (guest-gost...)
|
||||
CryptoProvider string // BJ_CRYPTO_PROVIDER (stub|cryptopro|...)
|
||||
Timeout time.Duration // таймаут на одну проверку
|
||||
}
|
||||
|
||||
// CheckAll выполняет все доступные проверки и возвращает SystemStatus.
|
||||
func CheckAll(ctx context.Context, o CheckOptions) SystemStatus {
|
||||
if o.Timeout == 0 {
|
||||
o.Timeout = 2 * time.Second
|
||||
}
|
||||
out := SystemStatus{
|
||||
Profile: o.Profile,
|
||||
Provider: o.CryptoProvider,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
out.Checks = append(out.Checks, checkPostgres(ctx, o))
|
||||
out.Checks = append(out.Checks, checkCryptoSocket(o))
|
||||
out.Checks = append(out.Checks, checkNSDAdapter(ctx, o))
|
||||
out.Checks = append(out.Checks, checkLKCallback(ctx, o))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func checkPostgres(_ context.Context, o CheckOptions) Status {
|
||||
s := Status{Name: "postgres"}
|
||||
if o.PostgresDSN == "" {
|
||||
s.OK = true
|
||||
s.Message = "in-memory (PostgresDSN не задан, репозиторий — m2mcore.MemoryRepository)"
|
||||
return s
|
||||
}
|
||||
// На M2 здесь будет sql.Open + Ping. На текущем шаге — заглушка.
|
||||
s.OK = false
|
||||
s.Message = "PostgreSQL Repository не подключён (требуется pgx, M2-шаг-3)"
|
||||
s.Detail = "DSN: " + o.PostgresDSN
|
||||
return s
|
||||
}
|
||||
|
||||
func checkCryptoSocket(o CheckOptions) Status {
|
||||
s := Status{Name: "crypto-service (UDS)"}
|
||||
if o.CryptoSocket == "" {
|
||||
s.OK = false
|
||||
s.Message = "BJ_CRYPTO_SOCKET не задан"
|
||||
return s
|
||||
}
|
||||
info, err := os.Stat(o.CryptoSocket)
|
||||
if err != nil {
|
||||
s.OK = false
|
||||
s.Message = "сокет недоступен"
|
||||
s.Detail = err.Error()
|
||||
return s
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
s.OK = false
|
||||
s.Message = "путь существует, но это не сокет"
|
||||
s.Detail = o.CryptoSocket
|
||||
return s
|
||||
}
|
||||
// Пробуем подключиться.
|
||||
d := net.Dialer{Timeout: o.Timeout}
|
||||
conn, err := d.Dial("unix", o.CryptoSocket)
|
||||
if err != nil {
|
||||
s.OK = false
|
||||
s.Message = "сокет существует, но не отвечает"
|
||||
s.Detail = err.Error()
|
||||
return s
|
||||
}
|
||||
_ = conn.Close()
|
||||
s.OK = true
|
||||
s.Message = "сокет открыт"
|
||||
s.Detail = o.CryptoSocket
|
||||
if o.CryptoProvider == "stub" || o.CryptoProvider == "" {
|
||||
s.Message += ", провайдер stub (реальная криптография не подключена)"
|
||||
} else {
|
||||
s.Message += ", провайдер " + o.CryptoProvider
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
|
||||
s := Status{Name: "nsd-adapter (REST к ИШ)"}
|
||||
if o.NSDAdapterURL == "" {
|
||||
s.OK = true
|
||||
s.Message = "BJ_NSD_ADAPTER_URL не задан — используется mock NSDSender"
|
||||
return s
|
||||
}
|
||||
return httpHealth(ctx, o.NSDAdapterURL+"/healthz", o.Timeout, s)
|
||||
}
|
||||
|
||||
func checkLKCallback(ctx context.Context, o CheckOptions) Status {
|
||||
s := Status{Name: "lk-emulator (callback)"}
|
||||
if o.LKCallbackURL == "" {
|
||||
s.OK = false
|
||||
s.Message = "BJ_LK_CALLBACK_URL не задан — callback'и в ЛК отключены"
|
||||
return s
|
||||
}
|
||||
return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s)
|
||||
}
|
||||
|
||||
func httpHealth(ctx context.Context, url string, timeout time.Duration, s Status) Status {
|
||||
c := &http.Client{Timeout: timeout}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
s.OK = false
|
||||
s.Message = "не получилось собрать запрос"
|
||||
s.Detail = err.Error()
|
||||
return s
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
s.OK = false
|
||||
s.Message = "недоступен"
|
||||
s.Detail = err.Error()
|
||||
return s
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
s.OK = false
|
||||
s.Message = "HTTP " + http.StatusText(resp.StatusCode)
|
||||
s.Detail = url
|
||||
return s
|
||||
}
|
||||
s.OK = true
|
||||
s.Message = "OK"
|
||||
s.Detail = url
|
||||
return s
|
||||
}
|
||||
|
||||
// ErrUnknown — общий placeholder.
|
||||
var ErrUnknown = errors.New("lkgateway: unknown error")
|
||||
@@ -0,0 +1,190 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FlashContainer — найденный на смонтированной флешке контейнер КриптоПро.
|
||||
// КриптоПро CSP под Linux ожидает контейнер в виде папки <name>.000 с
|
||||
// файлами header.key/masks.key/name.key/primary.key/primary2.key.
|
||||
type FlashContainer struct {
|
||||
// Mountpoint — путь смонтированной флешки, например /run/media/user/USB.
|
||||
Mountpoint string
|
||||
// Path — полный путь до папки <name>.000.
|
||||
Path string
|
||||
// Name — имя контейнера (без суффикса .000).
|
||||
Name string
|
||||
// Files — список файлов в контейнере (для дисплея).
|
||||
Files []string
|
||||
// AlreadyImported — true, если папка <name>.000 уже есть в локальном
|
||||
// хранилище /var/opt/cprocsp/keys/<user>/.
|
||||
AlreadyImported bool
|
||||
}
|
||||
|
||||
// scanFlashContainers ищет контейнеры формата <name>.000 на типичных
|
||||
// точках монтирования USB-носителей в Linux: /run/media/<user>/* и
|
||||
// /media/<user>/* и /media/*. Возвращает список найденных контейнеров.
|
||||
func scanFlashContainers() []FlashContainer {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
roots := []string{
|
||||
filepath.Join("/run/media", u.Username),
|
||||
filepath.Join("/media", u.Username),
|
||||
"/media",
|
||||
"/mnt",
|
||||
}
|
||||
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
|
||||
|
||||
var out []FlashContainer
|
||||
for _, root := range roots {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
mountpoint := filepath.Join(root, e.Name())
|
||||
out = append(out, findContainersAt(mountpoint, localKeysDir)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findContainersAt(mountpoint, localKeysDir string) []FlashContainer {
|
||||
var out []FlashContainer
|
||||
// Ищем папки <name>.000 на верхнем уровне и на 1 уровне вглубь.
|
||||
_ = filepath.Walk(mountpoint, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Глубже 2 уровней не лезем (на флешке могут быть личные папки).
|
||||
rel, _ := filepath.Rel(mountpoint, p)
|
||||
if strings.Count(rel, string(filepath.Separator)) > 2 {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !info.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".000") {
|
||||
return nil
|
||||
}
|
||||
// Проверяем, что внутри лежат файлы вида *.key.
|
||||
entries, _ := os.ReadDir(p)
|
||||
var files []string
|
||||
hasKey := false
|
||||
for _, ent := range entries {
|
||||
files = append(files, ent.Name())
|
||||
if strings.HasSuffix(strings.ToLower(ent.Name()), ".key") {
|
||||
hasKey = true
|
||||
}
|
||||
}
|
||||
if !hasKey {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(p), ".000")
|
||||
fc := FlashContainer{
|
||||
Mountpoint: mountpoint,
|
||||
Path: p,
|
||||
Name: name,
|
||||
Files: files,
|
||||
}
|
||||
// Проверка: уже скопирован в локальное хранилище?
|
||||
if _, err := os.Stat(filepath.Join(localKeysDir, name+".000")); err == nil {
|
||||
fc.AlreadyImported = true
|
||||
}
|
||||
out = append(out, fc)
|
||||
return filepath.SkipDir
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// copyContainerToLocal копирует папку <name>.000 с флешки в локальное
|
||||
// хранилище КриптоПро /var/opt/cprocsp/keys/<user>/<name>.000. После
|
||||
// этого контейнер виден как \\.\HDIMAGE\<name> и работает даже без
|
||||
// вставленной флешки.
|
||||
func copyContainerToLocal(srcDir string) (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
|
||||
if err := os.MkdirAll(localKeysDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("создать %s: %w", localKeysDir, err)
|
||||
}
|
||||
base := filepath.Base(srcDir)
|
||||
dstDir := filepath.Join(localKeysDir, base)
|
||||
if _, err := os.Stat(dstDir); err == nil {
|
||||
return "", fmt.Errorf("контейнер %s уже существует в локальном хранилище", dstDir)
|
||||
}
|
||||
if err := os.MkdirAll(dstDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("создать %s: %w", dstDir, err)
|
||||
}
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
src, err := os.Open(filepath.Join(srcDir, e.Name()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst, err := os.OpenFile(filepath.Join(dstDir, e.Name()),
|
||||
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
src.Close()
|
||||
dst.Close()
|
||||
return "", err
|
||||
}
|
||||
src.Close()
|
||||
dst.Close()
|
||||
}
|
||||
return dstDir, nil
|
||||
}
|
||||
|
||||
// copyContainer — POST /admin/setup/crypto/copy-container.
|
||||
// Параметр src — путь до папки <name>.000 на флешке.
|
||||
func (h *setupHandlers) copyContainer(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
src := strings.TrimSpace(r.FormValue("src"))
|
||||
if src == "" {
|
||||
setupFlash(w, r, "Копирование контейнера: не указан путь")
|
||||
return
|
||||
}
|
||||
// Минимальная защита: ожидаем .000 в конце пути.
|
||||
if !strings.HasSuffix(strings.ToLower(src), ".000") {
|
||||
setupFlash(w, r, "Копирование контейнера: путь должен заканчиваться на .000")
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
setupFlash(w, r, "Копирование контейнера: исходная папка недоступна: "+err.Error())
|
||||
return
|
||||
}
|
||||
dst, err := copyContainerToLocal(src)
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Копирование контейнера: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Дадим CSP несколько мс «заметить» новый контейнер (не критично).
|
||||
_, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
|
||||
cancel()
|
||||
setupFlash(w, r, "Контейнер скопирован в "+dst+". Теперь он виден как \\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+" и работает без вставленной флешки. Импортируйте сертификат: certmgr -inst -cont '\\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+"' -store uMy.")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// postJSON отправляет JSON через любой метод (POST/PATCH/PUT). Используется
|
||||
// для callback'ов в ЛК.
|
||||
func postJSON(ctx context.Context, c *http.Client, url, method string, body any) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
buf, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(buf))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeJSON удобный writer JSON-ответа со статусом.
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// writeError формирует ErrorResponse по контракту ESIA Finance.
|
||||
func writeError(w http.ResponseWriter, status int, code, title, message string) {
|
||||
writeJSON(w, status, ErrorResponse{
|
||||
Error: true,
|
||||
Status: status,
|
||||
Code: code,
|
||||
Title: title,
|
||||
Meta: &ErrorMeta{Message: message},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultDocSources — стартовый набор страниц НРД, которые doc-watcher
|
||||
// будет проверять раз в сутки. Пользователь может добавить/удалить через UI.
|
||||
var defaultDocSources = []DocSource{
|
||||
{
|
||||
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
|
||||
Name: "Сервис MOEX МОСТ для M2M",
|
||||
},
|
||||
{
|
||||
URL: "https://www.nsd.ru/workflow/system/programs/",
|
||||
Name: "ПО для участников ЭДО (ИШ, ФШ)",
|
||||
},
|
||||
{
|
||||
URL: "https://www.nsd.ru/workflow/system/programs/cryptoservice/",
|
||||
Name: "Криптосервис",
|
||||
},
|
||||
}
|
||||
|
||||
// EnsureDocSources гарантирует что defaultDocSources прописаны в конфиге.
|
||||
// Вызывается при старте bj-server.
|
||||
func EnsureDocSources(rc *RuntimeConfig) {
|
||||
s := rc.Snapshot().News
|
||||
if len(s.DocSources) > 0 {
|
||||
return
|
||||
}
|
||||
s.DocSources = append([]DocSource(nil), defaultDocSources...)
|
||||
if err := rc.UpdateNews(s); err != nil {
|
||||
log.Printf("news: не получилось сохранить default DocSources: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive).
|
||||
var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`)
|
||||
|
||||
// noProxyClient — HTTP-клиент, который игнорирует переменные окружения
|
||||
// HTTPS_PROXY / HTTP_PROXY. Корпоративный прокси zetit блокирует
|
||||
// nsd.ru — поэтому doc-watcher ходит на внешние сайты НРД напрямую.
|
||||
// Transport.Proxy = nil отключает любую проксификацию (включая
|
||||
// автодетект через env).
|
||||
var noProxyClient = &http.Client{
|
||||
Timeout: 90 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// CheckDocSources обходит все DocSource из настроек, парсит HTML, ищет
|
||||
// новые PDF и скачивает их в DOC/. На каждое нововведение эмитирует
|
||||
// NewsItem типа "doc-update". Возвращает суммарную строку для лога.
|
||||
func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
|
||||
s := rc.Snapshot().News
|
||||
if len(s.DocSources) == 0 {
|
||||
s.DocSources = append([]DocSource(nil), defaultDocSources...)
|
||||
}
|
||||
var summary strings.Builder
|
||||
now := time.Now()
|
||||
|
||||
for i, src := range s.DocSources {
|
||||
fmt.Fprintf(&summary, "→ %s\n", src.URL)
|
||||
pdfs, err := fetchPDFLinks(ctx, src.URL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&summary, " ошибка: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if src.KnownPDFs == nil {
|
||||
s.DocSources[i].KnownPDFs = map[string]string{}
|
||||
}
|
||||
known := s.DocSources[i].KnownPDFs
|
||||
fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs))
|
||||
newlyAdded := 0
|
||||
for _, pdfURL := range pdfs {
|
||||
hash, changed := checkPDF(ctx, pdfURL, known)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
known[pdfURL] = hash
|
||||
newlyAdded++
|
||||
localPath, err := downloadPDFToDOC(ctx, pdfURL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath)
|
||||
// Новость в ленту.
|
||||
_ = rc.AddNews(NewsItem{
|
||||
ID: "doc-" + hash[:12],
|
||||
At: now,
|
||||
Kind: "doc-update",
|
||||
Title: "Обновлена документация: " + filepath.Base(localPath),
|
||||
Body: "Источник: " + src.Name + "\nURL: " + pdfURL +
|
||||
"\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…",
|
||||
URL: pdfURL,
|
||||
})
|
||||
}
|
||||
s.DocSources[i].LastChecked = now
|
||||
if newlyAdded > 0 {
|
||||
fmt.Fprintf(&summary, " добавлено новых: %d\n", newlyAdded)
|
||||
}
|
||||
}
|
||||
|
||||
s.LastDocCheck = now
|
||||
s.DocCheckResult = summary.String()
|
||||
if err := rc.UpdateNews(s); err != nil {
|
||||
log.Printf("news: save failed: %v", err)
|
||||
}
|
||||
return summary.String()
|
||||
}
|
||||
|
||||
// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся
|
||||
// на .pdf. Относительные URL разворачиваются в абсолютные.
|
||||
func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
|
||||
resp, err := noProxyClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range matches {
|
||||
ref, err := url.Parse(m[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
abs := base.ResolveReference(ref).String()
|
||||
// Игнорируем «системные» PDF (политика конфиденциальности и т.п.).
|
||||
low := strings.ToLower(abs)
|
||||
if strings.Contains(low, "personal_information") ||
|
||||
strings.Contains(low, "personal-information") ||
|
||||
strings.Contains(low, "razmeschenie-logotipa") {
|
||||
continue
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
out = append(out, abs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkPDF делает HEAD-запрос (или GET если HEAD не сработал) и сравнивает
|
||||
// sha256 PDF с известным значением. Возвращает (новый_hash, изменился).
|
||||
// HEAD у НРД редко возвращает Content-MD5/ETag — реальная проверка =
|
||||
// скачать и посчитать sha256.
|
||||
func checkPDF(ctx context.Context, pdfURL string, known map[string]string) (string, bool) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
|
||||
resp, err := noProxyClient.Do(req)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", false
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(resp.Body, 32<<20)); err != nil {
|
||||
return "", false
|
||||
}
|
||||
hash := hex.EncodeToString(h.Sum(nil))
|
||||
if old, ok := known[pdfURL]; ok && old == hash {
|
||||
return hash, false
|
||||
}
|
||||
return hash, true
|
||||
}
|
||||
|
||||
// downloadPDFToDOC скачивает PDF в DOC/. Если файл с таким именем уже
|
||||
// есть — переименовывает старый в name.old-YYYYMMDD.pdf, чтобы оставить
|
||||
// аудит. Возвращает путь до нового файла.
|
||||
func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
|
||||
u, err := url.Parse(pdfURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := filepath.Base(u.Path)
|
||||
if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") {
|
||||
return "", errors.New("странное имя файла")
|
||||
}
|
||||
docDir := "DOC"
|
||||
if _, err := os.Stat(docDir); err != nil {
|
||||
return "", fmt.Errorf("DOC/ не доступен: %w", err)
|
||||
}
|
||||
dst := filepath.Join(docDir, name)
|
||||
// Если файл уже есть — переименуем как backup.
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
old := filepath.Join(docDir,
|
||||
strings.TrimSuffix(name, ".pdf")+
|
||||
"."+time.Now().Format("2006-01-02")+".pdf.bak")
|
||||
_ = os.Rename(dst, old)
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
|
||||
resp, err := noProxyClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, 64<<20)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// StartDocWatcher запускает горутину, которая раз в сутки проверяет
|
||||
// DocSources и эмитирует новости. Стартует через 60 сек после Run().
|
||||
func StartDocWatcher(rc *RuntimeConfig) func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
summary := CheckDocSources(ctx, rc)
|
||||
log.Printf("doc-watcher: проверка завершена\n%s", summary)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return cancel
|
||||
}
|
||||
|
||||
// addManualNews — POST /admin/news/add.
|
||||
func (h *setupHandlers) addManualNews(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
body := strings.TrimSpace(r.FormValue("body"))
|
||||
kind := r.FormValue("kind")
|
||||
if kind == "" {
|
||||
kind = "manual"
|
||||
}
|
||||
if title == "" {
|
||||
setupFlash(w, r, "Новости: укажите заголовок")
|
||||
return
|
||||
}
|
||||
item := NewsItem{
|
||||
At: time.Now(),
|
||||
Kind: kind,
|
||||
Title: title,
|
||||
Body: body,
|
||||
}
|
||||
if vf := r.FormValue("valid_from"); vf != "" {
|
||||
if t, err := time.Parse("2006-01-02", vf); err == nil {
|
||||
item.ValidFrom = t
|
||||
}
|
||||
}
|
||||
if vt := r.FormValue("valid_to"); vt != "" {
|
||||
if t, err := time.Parse("2006-01-02", vt); err == nil {
|
||||
item.ValidTo = t.Add(24*time.Hour - time.Second)
|
||||
}
|
||||
}
|
||||
if err := h.rc.AddNews(item); err != nil {
|
||||
setupFlash(w, r, "Новости: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Новость «"+title+"» добавлена в ленту")
|
||||
}
|
||||
|
||||
// dismissNews — POST /admin/news/dismiss?id=...
|
||||
func (h *setupHandlers) dismissNews(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := r.FormValue("id")
|
||||
if id == "" {
|
||||
setupFlash(w, r, "Новости: id обязателен")
|
||||
return
|
||||
}
|
||||
_ = h.rc.DismissNews(id)
|
||||
setupFlash(w, r, "Новость скрыта")
|
||||
}
|
||||
|
||||
// checkDocsNow — POST /admin/news/check-docs.
|
||||
func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
summary := CheckDocSources(ctx, h.rc)
|
||||
if len(summary) > 600 {
|
||||
summary = summary[:600] + "…"
|
||||
}
|
||||
setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary))
|
||||
}
|
||||
|
||||
// SeedDefaultNews добавляет в ленту известные на момент запуска события
|
||||
// (окно техработ TEST3 в мае 2026 и появление робота-автотестирования).
|
||||
// Вызывается из server.go при старте — дедуп по ID гарантирован AddNews.
|
||||
func SeedDefaultNews(rc *RuntimeConfig) {
|
||||
defaults := []NewsItem{
|
||||
{
|
||||
ID: "test3-maintenance-2026-05",
|
||||
At: time.Date(2026, 5, 13, 0, 0, 0, 0, time.UTC),
|
||||
Kind: "maintenance",
|
||||
Title: "TEST3 недоступен 18.05.2026 — 22.05.2026 (техработы)",
|
||||
Body: "НРД проводит техработы на тестовом контуре TEST3. На gost-t3.nsd.ru / rsa-t3.nsd.ru интеграционные прогоны в этот период не пойдут. При необходимости — переключитесь на GUEST (gost-gt.nsd.ru) или mock-режим. Источник: НРД письмо НРД-И-2026-8452 от 13.05.2026.",
|
||||
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
|
||||
ValidFrom: time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC),
|
||||
ValidTo: time.Date(2026, 5, 22, 23, 59, 59, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ID: "robot-autotest-2026-05-12",
|
||||
At: time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC),
|
||||
Kind: "feature",
|
||||
Title: "Доступно автотестирование MOEX МОСТ с роботом на TEST3",
|
||||
Body: "С 12.05.2026 клиенты, подключившиеся к автотестированию, могут гонять обмен сообщениями с роботом-контрагентом на TEST3. Не нужно ждать живого второго депозитария. Контакт: M2MOST@nsd.ru. Опубликованы новые инструкции: «Инструкция по тестированию с роботом» и «Инструкция для обмена при self-transfer» — обе в DOC/.",
|
||||
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
|
||||
},
|
||||
}
|
||||
for _, item := range defaults {
|
||||
_ = rc.AddNews(item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RuntimeConfig — конфигурация подсистем, редактируемая через admin UI
|
||||
// без перезапуска. Сохраняется в JSON-файл (BJ_SETUP_PATH или
|
||||
// ~/.bj/setup.json), грузится при старте.
|
||||
type RuntimeConfig struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
data Settings
|
||||
}
|
||||
|
||||
// Settings — сериализуемое представление настроек.
|
||||
type Settings struct {
|
||||
Postgres PostgresSettings `json:"postgres"`
|
||||
Crypto CryptoSettings `json:"crypto"`
|
||||
NSD NSDSettings `json:"nsd"`
|
||||
LK LKSettings `json:"lk"`
|
||||
CACerts CACertsSettings `json:"ca_certs"`
|
||||
News NewsSettings `json:"news"`
|
||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewsSettings — лента новостей (события системы, окна техработ, обновления
|
||||
// документации НРД). События добавляются вручную через UI или автоматически
|
||||
// doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed)
|
||||
// оператором, но не удалено — лента служит «журналом» для аудита.
|
||||
type NewsSettings struct {
|
||||
Items []NewsItem `json:"items"`
|
||||
DocSources []DocSource `json:"doc_sources"` // URL'ы для авто-проверки (NSD pages)
|
||||
LastDocCheck time.Time `json:"last_doc_check"`
|
||||
DocCheckResult string `json:"doc_check_result"`
|
||||
}
|
||||
|
||||
// NewsItem — одно событие в ленте.
|
||||
type NewsItem struct {
|
||||
ID string `json:"id"` // уникальный идентификатор для dismiss
|
||||
At time.Time `json:"at"`
|
||||
Kind string `json:"kind"` // "maintenance" | "feature" | "doc-update" | "manual" | "system"
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url,omitempty"` // ссылка на источник
|
||||
ValidFrom time.Time `json:"valid_from,omitempty"` // для maintenance окон
|
||||
ValidTo time.Time `json:"valid_to,omitempty"`
|
||||
Dismissed bool `json:"dismissed"`
|
||||
}
|
||||
|
||||
// DocSource — страница НРД, которую doc-watcher периодически проверяет.
|
||||
type DocSource struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"` // человекочитаемое имя
|
||||
LastChecked time.Time `json:"last_checked"`
|
||||
KnownPDFs map[string]string `json:"known_pdfs"` // url → sha256
|
||||
}
|
||||
|
||||
// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего
|
||||
// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина
|
||||
// перекачивает каждый URL и переустанавливает сертификат, если он
|
||||
// поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро.
|
||||
type CACertsSettings struct {
|
||||
URLs []string `json:"urls"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
LastFetch time.Time `json:"last_fetch"`
|
||||
LastFetchLog string `json:"last_fetch_log"`
|
||||
FetchedCerts []FetchedCACert `json:"fetched_certs"`
|
||||
}
|
||||
|
||||
// FetchedCACert — информация о последнем удачно скачанном сертификате.
|
||||
type FetchedCACert struct {
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SubjectCN string `json:"subject_cn"`
|
||||
IssuerCN string `json:"issuer_cn"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Store string `json:"store"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
|
||||
type PostgresSettings struct {
|
||||
DSN string `json:"dsn"`
|
||||
}
|
||||
|
||||
// CryptoSettings — путь к JCP, провайдер, лицензионный ключ.
|
||||
type CryptoSettings struct {
|
||||
Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet"
|
||||
SocketPath string `json:"socket_path"` // UDS crypto-service
|
||||
JCPPath string `json:"jcp_path"` // путь до jcp.jar
|
||||
LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро
|
||||
}
|
||||
|
||||
// NSDSettings — профиль и подключение к ИШ НРД.
|
||||
type NSDSettings struct {
|
||||
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
|
||||
IGWBaseURL string `json:"igw_base_url"` // http://host:port
|
||||
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
|
||||
}
|
||||
|
||||
// LKSettings — настройки callback в ЛК клиента.
|
||||
type LKSettings struct {
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
|
||||
// TestRunResult — результат последнего тестового прогона.
|
||||
type TestRunResult struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
ClaimID string `json:"claim_id"`
|
||||
FinalStatus string `json:"final_status"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewRuntimeConfig создаёт runtime-конфиг, читая JSON из path (или дефолт).
|
||||
func NewRuntimeConfig(path string) (*RuntimeConfig, error) {
|
||||
if path == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
home = "."
|
||||
}
|
||||
path = filepath.Join(home, ".bj", "setup.json")
|
||||
}
|
||||
rc := &RuntimeConfig{path: path}
|
||||
if err := rc.load(); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
// Гарантируем разумные дефолты.
|
||||
if rc.data.Crypto.Provider == "" {
|
||||
rc.data.Crypto.Provider = "stub"
|
||||
}
|
||||
if rc.data.Crypto.SocketPath == "" {
|
||||
rc.data.Crypto.SocketPath = "/run/bj/crypto.sock"
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
// Snapshot возвращает копию текущих настроек.
|
||||
func (r *RuntimeConfig) Snapshot() Settings {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := r.data
|
||||
if r.data.LastTest != nil {
|
||||
t := *r.data.LastTest
|
||||
out.LastTest = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UpdatePostgres сохраняет postgres-настройки.
|
||||
func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.Postgres = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateCrypto сохраняет crypto-настройки.
|
||||
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.Crypto = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateNSD сохраняет NSD-настройки.
|
||||
func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.NSD = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateLK сохраняет LK callback URL.
|
||||
// UpdateCACerts сохраняет настройки авто-загрузки сертификатов УЦ.
|
||||
func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.CACerts = s
|
||||
r.data.UpdatedAt = time.Now()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateNews заменяет всю ленту новостей.
|
||||
func (r *RuntimeConfig) UpdateNews(s NewsSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.News = s
|
||||
r.data.UpdatedAt = time.Now()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// AddNews добавляет новость в начало ленты (newest first). Если в ленте уже
|
||||
// есть новость с таким же ID — она обновляется (вместо дубликата).
|
||||
func (r *RuntimeConfig) AddNews(item NewsItem) error {
|
||||
r.mu.Lock()
|
||||
if item.ID == "" {
|
||||
item.ID = item.At.Format("20060102-150405") + "-" + item.Kind
|
||||
}
|
||||
if item.At.IsZero() {
|
||||
item.At = time.Now()
|
||||
}
|
||||
// Дедуп по ID.
|
||||
replaced := false
|
||||
for i, ex := range r.data.News.Items {
|
||||
if ex.ID == item.ID {
|
||||
r.data.News.Items[i] = item
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
r.data.News.Items = append([]NewsItem{item}, r.data.News.Items...)
|
||||
}
|
||||
r.data.UpdatedAt = time.Now()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// DismissNews помечает новость скрытой по ID (не удаляет — для аудита).
|
||||
func (r *RuntimeConfig) DismissNews(id string) error {
|
||||
r.mu.Lock()
|
||||
for i := range r.data.News.Items {
|
||||
if r.data.News.Items[i].ID == id {
|
||||
r.data.News.Items[i].Dismissed = true
|
||||
}
|
||||
}
|
||||
r.data.UpdatedAt = time.Now()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.LK = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// RecordTestRun сохраняет результат теста.
|
||||
func (r *RuntimeConfig) RecordTestRun(res TestRunResult) error {
|
||||
r.mu.Lock()
|
||||
r.data.LastTest = &res
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// load читает JSON в r.data.
|
||||
func (r *RuntimeConfig) load() error {
|
||||
raw, err := os.ReadFile(r.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, &r.data)
|
||||
}
|
||||
|
||||
// save пишет JSON в r.path атомарно через tmp + rename.
|
||||
func (r *RuntimeConfig) save() error {
|
||||
r.mu.RLock()
|
||||
raw, err := json.MarshalIndent(r.data, "", " ")
|
||||
r.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(r.path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := r.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, r.path)
|
||||
}
|
||||
|
||||
// Readiness — сводная готовность подсистемы.
|
||||
type Readiness struct {
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"` // полностью настроена и проверена
|
||||
Configured bool `json:"configured"` // есть пользовательский конфиг (не stub)
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ReadinessSummary возвращает компактную сводку для UI/admin.
|
||||
func (r *RuntimeConfig) ReadinessSummary() []Readiness {
|
||||
s := r.Snapshot()
|
||||
out := []Readiness{
|
||||
{
|
||||
Name: "postgres",
|
||||
Configured: s.Postgres.DSN != "",
|
||||
Ready: false, // настоящий ping будет в checks.go
|
||||
Message: posMsg(s.Postgres.DSN),
|
||||
},
|
||||
{
|
||||
Name: "crypto-service",
|
||||
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "",
|
||||
Ready: false,
|
||||
Message: cryptoMsg(s.Crypto),
|
||||
},
|
||||
{
|
||||
Name: "nsd-adapter",
|
||||
Configured: s.NSD.IGWBaseURL != "" && s.NSD.Profile != "",
|
||||
Ready: false,
|
||||
Message: nsdMsg(s.NSD),
|
||||
},
|
||||
{
|
||||
Name: "lk-callback",
|
||||
Configured: s.LK.CallbackURL != "",
|
||||
Ready: false,
|
||||
Message: lkMsg(s.LK),
|
||||
},
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func posMsg(dsn string) string {
|
||||
if dsn == "" {
|
||||
return "DSN не задан — система в режиме in-memory (M2-демо)"
|
||||
}
|
||||
return "DSN настроен: " + maskDSN(dsn)
|
||||
}
|
||||
|
||||
func cryptoMsg(c CryptoSettings) string {
|
||||
if c.Provider == "" || c.Provider == "stub" {
|
||||
return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён."
|
||||
}
|
||||
if c.JCPPath == "" {
|
||||
return "Провайдер " + c.Provider + ", но путь к JCP не задан."
|
||||
}
|
||||
if c.LicenseKey == "" {
|
||||
return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена."
|
||||
}
|
||||
return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена."
|
||||
}
|
||||
|
||||
func nsdMsg(n NSDSettings) string {
|
||||
if n.IGWBaseURL == "" {
|
||||
return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)"
|
||||
}
|
||||
if n.Profile == "" {
|
||||
return "URL ИШ задан, но профиль не выбран"
|
||||
}
|
||||
return "Профиль " + n.Profile + ", ИШ " + n.IGWBaseURL
|
||||
}
|
||||
|
||||
func lkMsg(l LKSettings) string {
|
||||
if l.CallbackURL == "" {
|
||||
return "Callback URL не настроен — используется встроенный lk-emulator"
|
||||
}
|
||||
return "Callback URL: " + l.CallbackURL
|
||||
}
|
||||
|
||||
// maskDSN скрывает пароль в DSN для отображения в UI.
|
||||
func maskDSN(dsn string) string {
|
||||
// простая маскировка: ищем :///user:pass@host
|
||||
const sep = "@"
|
||||
if idx := indexAt(dsn, sep); idx > 0 {
|
||||
if colon := lastColonBefore(dsn, idx); colon > 0 && colon < idx {
|
||||
return dsn[:colon+1] + "***" + dsn[idx:]
|
||||
}
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func indexAt(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func lastColonBefore(s string, idx int) int {
|
||||
for i := idx - 1; i >= 0; i-- {
|
||||
if s[i] == ':' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// SeedStore — in-memory FansyStore с предзаполненными тестовыми данными
|
||||
// (5 клиентов с депо-счетами и портфелями), соответствующими
|
||||
// docs/fansy-contract/v1/examples/seed-data.sql. Используется в
|
||||
// dev-стенде без реальной БД.
|
||||
type SeedStore struct {
|
||||
clients map[string]*m2mcore.Client
|
||||
accounts map[string][]m2mcore.DepoAccount // by client_id
|
||||
}
|
||||
|
||||
// NewSeedStore собирает SeedStore с фиксированными тестовыми клиентами.
|
||||
func NewSeedStore() *SeedStore {
|
||||
s := &SeedStore{
|
||||
clients: make(map[string]*m2mcore.Client),
|
||||
accounts: make(map[string][]m2mcore.DepoAccount),
|
||||
}
|
||||
s.addClient("11111111-1111-1111-1111-111111111111",
|
||||
"Иванов", "Иван", "Иванович",
|
||||
m2m.DocCode21, "4512", "654321")
|
||||
s.addClient("22222222-2222-2222-2222-222222222222",
|
||||
"Петров", "Пётр", "Петрович",
|
||||
m2m.DocCode21, "4513", "654322")
|
||||
s.addClient("33333333-3333-3333-3333-333333333333",
|
||||
"Сидоров", "Сидор", "Сидорович",
|
||||
m2m.DocCode21, "4514", "654323")
|
||||
s.addClient("44444444-4444-4444-4444-444444444444",
|
||||
"Кузнецов", "Сергей", "Михайлович",
|
||||
m2m.DocCode03, "111", "222333")
|
||||
s.addClient("55555555-5555-5555-5555-555555555555",
|
||||
"Соколова", "Анна", "Викторовна",
|
||||
m2m.DocCode21, "4516", "654325")
|
||||
|
||||
s.addAccount("11111111-1111-1111-1111-111111111111",
|
||||
"DP789456", "31MC0021900000F01", "P001", "7702070139")
|
||||
s.addAccount("11111111-1111-1111-1111-111111111111",
|
||||
"AA789451", "33MC0021900000F02", "F002", "7802031669")
|
||||
s.addAccount("22222222-2222-2222-2222-222222222222",
|
||||
"DP100200", "31MC0010000000A01", "A001", "7702070139")
|
||||
s.addAccount("33333333-3333-3333-3333-333333333333",
|
||||
"DP300400", "31MC0030000000B01", "B001", "0702345678")
|
||||
s.addAccount("55555555-5555-5555-5555-555555555555",
|
||||
"DP500600", "31MC0050000000C01", "C001", "0710987654")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Clients возвращает копию слайса клиентов (для UI выбора).
|
||||
func (s *SeedStore) Clients() []*m2mcore.Client {
|
||||
out := make([]*m2mcore.Client, 0, len(s.clients))
|
||||
for _, c := range s.clients {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *SeedStore) addClient(id, last, first, middle string, doc m2m.IdentityDocumentCode, series, number string) {
|
||||
s.clients[id] = &m2mcore.Client{
|
||||
ID: id,
|
||||
LastName: last,
|
||||
FirstName: first,
|
||||
MiddleName: middle,
|
||||
Document: m2mcore.ClientDocument{
|
||||
DocumentType: doc,
|
||||
Series: series,
|
||||
Number: number,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SeedStore) addAccount(clientID, dep, acc, sec, depINN string) {
|
||||
s.accounts[clientID] = append(s.accounts[clientID], m2mcore.DepoAccount{
|
||||
ClientID: clientID,
|
||||
DeponentCode: dep,
|
||||
AccountID: m2m.AccountID(acc),
|
||||
SectionID: sec,
|
||||
DepositoryINN: m2m.OrganizationINN(depINN),
|
||||
})
|
||||
}
|
||||
|
||||
// GetClientByID — реализация FansyStore.
|
||||
func (s *SeedStore) GetClientByID(_ context.Context, id string) (*m2mcore.Client, error) {
|
||||
c, ok := s.clients[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("seedstore: клиент %s не найден", id)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetDepoAccounts — реализация FansyStore.
|
||||
func (s *SeedStore) GetDepoAccounts(_ context.Context, clientID string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) {
|
||||
accs := s.accounts[clientID]
|
||||
if len(accs) == 0 {
|
||||
return nil, fmt.Errorf("seedstore: нет счетов у клиента %s", clientID)
|
||||
}
|
||||
return accs, nil
|
||||
}
|
||||
|
||||
// GetBalances — реализация FansyStore. На M2 возвращает пустой список,
|
||||
// потому что баланс проверяется при подаче заявки в самой UI (через демо-кнопку).
|
||||
func (s *SeedStore) GetBalances(_ context.Context, _ string, _ []m2m.SecurityCode) ([]m2mcore.SecurityBalance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Verify тип SeedStore удовлетворяет m2mcore.FansyStore.
|
||||
var _ m2mcore.FansyStore = (*SeedStore)(nil)
|
||||
@@ -0,0 +1,259 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
|
||||
)
|
||||
|
||||
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
DefaultSender m2m.DeponentCode
|
||||
DefaultReceiver m2m.DeponentCode
|
||||
CheckOptions func() CheckOptions
|
||||
MockDecisionDelay time.Duration // 0 = дефолт 3 секунды
|
||||
SetupPath string // путь к JSON-файлу runtime-конфига (пусто = ~/.bj/setup.json)
|
||||
}
|
||||
|
||||
// Server — обвязка HTTP + сервис + workers.
|
||||
type Server struct {
|
||||
cfg ServerConfig
|
||||
svc *Service
|
||||
mock *mock.Sender
|
||||
store *SeedStore
|
||||
rc *RuntimeConfig
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewServer собирает Server с репозиторием, mock NSDSender, SeedStore
|
||||
// и REST + Admin маршрутами. Выбор Repository:
|
||||
// - если в runtime-конфиге (или ENV-fallback в cfg) задан PostgresDSN
|
||||
// и pgx-Pool успешно создаётся — используется PostgresRepository;
|
||||
// - иначе fallback на MemoryRepository (M2-демо).
|
||||
func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
store := NewSeedStore()
|
||||
mockCfg := mock.DefaultConfig()
|
||||
mockCfg.NSDSenderCode = "MC0010300000"
|
||||
if cfg.MockDecisionDelay > 0 {
|
||||
mockCfg.DecisionDelay = cfg.MockDecisionDelay
|
||||
}
|
||||
sender := mock.NewSender(mockCfg)
|
||||
|
||||
rc, err := NewRuntimeConfig(cfg.SetupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Repository: pgx если DSN указан, иначе in-memory.
|
||||
var repo m2mcore.Repository = m2mcore.NewMemoryRepository()
|
||||
if dsn := rc.Snapshot().Postgres.DSN; dsn != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
pgRepo, pgErr := m2mcore.NewPostgresRepository(ctx, dsn)
|
||||
cancel()
|
||||
if pgErr != nil {
|
||||
log.Printf("lk-gateway: PostgresRepository отказал, fallback MemoryRepository: %v", pgErr)
|
||||
} else {
|
||||
repo = pgRepo
|
||||
log.Printf("lk-gateway: PostgresRepository подключён (m2m_core.deals)")
|
||||
}
|
||||
}
|
||||
|
||||
svc := NewService(Config{
|
||||
Repository: repo,
|
||||
Sender: sender,
|
||||
Store: store,
|
||||
Recorder: m2mcore.NewMemoryRecorder(),
|
||||
DefaultSender: cfg.DefaultSender,
|
||||
DefaultReceiver: cfg.DefaultReceiver,
|
||||
})
|
||||
|
||||
// Если runtime-конфиг уже содержит callback URL — применяем его.
|
||||
if s := rc.Snapshot(); s.LK.CallbackURL != "" {
|
||||
svc.callbackURL = s.LK.CallbackURL
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterAPI(mux, svc)
|
||||
|
||||
// CheckOptions берётся из runtime-конфига при каждом запросе на дашборд.
|
||||
checkOpts := func() CheckOptions {
|
||||
s := rc.Snapshot()
|
||||
profile := "demo (mock NSD)"
|
||||
if s.NSD.Profile != "" {
|
||||
profile = s.NSD.Profile
|
||||
}
|
||||
return CheckOptions{
|
||||
PostgresDSN: s.Postgres.DSN,
|
||||
CryptoSocket: s.Crypto.SocketPath,
|
||||
NSDAdapterURL: s.NSD.IGWBaseURL,
|
||||
LKCallbackURL: s.LK.CallbackURL,
|
||||
Profile: profile,
|
||||
CryptoProvider: nonEmpty(s.Crypto.Provider, "stub"),
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
}
|
||||
if cfg.CheckOptions != nil {
|
||||
// Опциональный override (например, из cmd/lk-gateway для override ENV-перетягивания).
|
||||
checkOpts = cfg.CheckOptions
|
||||
}
|
||||
|
||||
adminTpl, err := RegisterAdmin(mux, svc, rc, checkOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registerSetup(mux, adminTpl, rc, svc)
|
||||
registerHealth(mux)
|
||||
registerSetCallback(mux, svc, rc)
|
||||
registerSeedListing(mux, store)
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
svc: svc,
|
||||
mock: sender,
|
||||
store: store,
|
||||
rc: rc,
|
||||
mux: mux,
|
||||
server: &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RuntimeConfig возвращает текущий runtime-конфиг (для тестов).
|
||||
func (s *Server) RuntimeConfig() *RuntimeConfig { return s.rc }
|
||||
|
||||
func nonEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetCallbackURL обновляет адрес, куда отправлять PATCH callback'и в ЛК.
|
||||
func (s *Server) SetCallbackURL(url string) { s.svc.callbackURL = url }
|
||||
|
||||
// Service возвращает Service для тестов.
|
||||
func (s *Server) Service() *Service { return s.svc }
|
||||
|
||||
// Mock возвращает mock-сендер.
|
||||
func (s *Server) Mock() *mock.Sender { return s.mock }
|
||||
|
||||
// Store возвращает SeedStore.
|
||||
func (s *Server) Store() *SeedStore { return s.store }
|
||||
|
||||
// Mux возвращает обработчик (для httptest).
|
||||
func (s *Server) Mux() http.Handler { return s.mux }
|
||||
|
||||
// Run поднимает HTTP-сервер и фоновый Decisions-consumer.
|
||||
// Блокируется до ctx.Done().
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
go s.consumeDecisions(ctx)
|
||||
|
||||
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
|
||||
stopCACerts := StartCACertsAutoUpdater(s.rc)
|
||||
defer stopCACerts()
|
||||
|
||||
// Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и
|
||||
// эмитирует новости в ленту. Дефолтные источники + дефолтные
|
||||
// новости (окно техработ TEST3, появление робота) сеются один раз.
|
||||
EnsureDocSources(s.rc)
|
||||
SeedDefaultNews(s.rc)
|
||||
stopDocWatcher := StartDocWatcher(s.rc)
|
||||
defer stopDocWatcher()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
|
||||
errCh <- s.server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.server.Shutdown(shutdownCtx)
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
|
||||
func (s *Server) consumeDecisions(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case d := <-s.mock.Decisions():
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.svc.ApplyDecision(ctx, d); err != nil {
|
||||
log.Printf("lk-gateway: ApplyDecision GUID=%s: %v", d.Header.GUID, err)
|
||||
} else {
|
||||
log.Printf("lk-gateway: Decision применён GUID=%s, callback в %s", d.Header.GUID, s.svc.callbackURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerHealth(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
}
|
||||
|
||||
// registerSetCallback — служебный POST /admin/api/callback-url для
|
||||
// эмулятора ЛК, чтобы сообщить gateway свой URL. Если URL уже сохранён
|
||||
// в runtime-конфиге (пользователь явно настроил его через UI), запрос
|
||||
// эмулятора игнорируется — приоритет у явно настроенного.
|
||||
func registerSetCallback(mux *http.ServeMux, svc *Service, rc *RuntimeConfig) {
|
||||
mux.HandleFunc("/admin/api/callback-url", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
url := r.URL.Query().Get("url")
|
||||
if url == "" {
|
||||
http.Error(w, "url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rc != nil {
|
||||
if s := rc.Snapshot(); s.LK.CallbackURL != "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("kept-user-configured"))
|
||||
return
|
||||
}
|
||||
}
|
||||
svc.callbackURL = url
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
}
|
||||
|
||||
func registerSeedListing(mux *http.ServeMux, store *SeedStore) {
|
||||
mux.HandleFunc("/admin/api/clients", func(w http.ResponseWriter, _ *http.Request) {
|
||||
type c struct {
|
||||
ID, LastName, FirstName, MiddleName string
|
||||
}
|
||||
out := make([]c, 0)
|
||||
for _, cl := range store.Clients() {
|
||||
out = append(out, c{ID: cl.ID, LastName: cl.LastName, FirstName: cl.FirstName, MiddleName: cl.MiddleName})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package lkgateway_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway"
|
||||
)
|
||||
|
||||
func newServer(t *testing.T) *lkgateway.Server {
|
||||
t.Helper()
|
||||
srv, err := lkgateway.NewServer(lkgateway.ServerConfig{
|
||||
Addr: ":0",
|
||||
DefaultSender: "MC0079200000",
|
||||
DefaultReceiver: "MC0010300000",
|
||||
MockDecisionDelay: 50 * time.Millisecond,
|
||||
// Изоляция от ~/.bj/setup.json — каждый тест получает пустой файл.
|
||||
SetupPath: filepath.Join(t.TempDir(), "setup.json"),
|
||||
CheckOptions: func() lkgateway.CheckOptions {
|
||||
return lkgateway.CheckOptions{Profile: "test", CryptoProvider: "stub"}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func validBody() string {
|
||||
return `{
|
||||
"investor": {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"last_name": "Иванов", "first_name": "Иван", "middle_name": "Иванович",
|
||||
"document": {"document_type": "21", "series": "4512", "number": "654321"}
|
||||
},
|
||||
"transferring_depository_inn": "0702345678",
|
||||
"receiving_depository_inn": "0710987654",
|
||||
"cost_info": {"no": {}},
|
||||
"securities": [{
|
||||
"security_code": "MM0766162534",
|
||||
"security_details": {"isin": "RU0007661625"},
|
||||
"quantity": {"whole": 1500},
|
||||
"settlement_accounts": [{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": {
|
||||
"deponent_code": "DP789456", "account_id": "31MC0021900000F01", "section_id": "P001"
|
||||
}
|
||||
}]
|
||||
}],
|
||||
"signed_document": "dGVzdA==",
|
||||
"signature_format": "XMLDSig-GOST"
|
||||
}`
|
||||
}
|
||||
|
||||
func TestCreateAndGetClaim(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
mux := srv.Mux()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody()))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("POST claims: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var created lkgateway.CreateClaimResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if created.ID == "" || !created.Success {
|
||||
t.Errorf("unexpected create response: %+v", created)
|
||||
}
|
||||
|
||||
// GET
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/back_office/claims/"+created.ID, nil)
|
||||
mux.ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("GET claim: code=%d body=%s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var view lkgateway.ClaimView
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.ID != created.ID {
|
||||
t.Errorf("view.ID = %s, ожидалось %s", view.ID, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminHome(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin home code=%d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "lk-gateway") {
|
||||
t.Errorf("в дашборде нет заголовка lk-gateway")
|
||||
}
|
||||
if !strings.Contains(body, "Состояние системы") {
|
||||
t.Errorf("в дашборде нет блока статуса")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStatus(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status code=%d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "postgres") {
|
||||
t.Errorf("в статусе нет проверки postgres")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndToEndFlowWithMock(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
// Уменьшим задержку mock для быстрого e2e.
|
||||
// Не достаём её напрямую — пересоздадим Server со встроенными настройками
|
||||
// и проверим только что после Send статус становится submitted_to_nsd → awaiting_decision.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
_ = ctx
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody()))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("POST claims: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var created lkgateway.CreateClaimResponse
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &created)
|
||||
|
||||
if created.Status != "awaiting_decision" {
|
||||
t.Errorf("после Submit ожидалось awaiting_decision, получено %s", created.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackURLSetter(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/callback-url?url=http://x.example/", nil)
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("set callback url: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClaimsEmpty(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/back_office/claims", nil)
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("list claims empty: %d", w.Code)
|
||||
}
|
||||
var page lkgateway.ClaimsPage
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &page)
|
||||
if len(page.Items) != 0 {
|
||||
t.Errorf("ожидалась пустая страница, получено %d", len(page.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJSON(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", bytes.NewReader([]byte("not json")))
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("ожидался 400, получено %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedClientsEndpoint(t *testing.T) {
|
||||
srv := newServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/clients", nil)
|
||||
srv.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("clients: %d", w.Code)
|
||||
}
|
||||
var clients []map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &clients)
|
||||
if len(clients) < 5 {
|
||||
t.Errorf("ожидалось 5+ клиентов в seed, получено %d", len(clients))
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2EApplyDecisionFiresCallback(t *testing.T) {
|
||||
// Поднимаем gateway in-process + http-эмулятор как callback-приёмник.
|
||||
// Дальше: POST заявки → ждём Decision из mock-канала → вручную дёргаем
|
||||
// ApplyDecision → проверяем что emulator получил callback.
|
||||
gw := newServer(t)
|
||||
|
||||
receivedCallback := make(chan map[string]any, 1)
|
||||
emulator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/back_office/claims/") {
|
||||
var payload map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||
select {
|
||||
case receivedCallback <- payload:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer emulator.Close()
|
||||
gw.SetCallbackURL(emulator.URL)
|
||||
|
||||
// Подаём заявку через mux (без отдельного httptest.NewServer для gateway).
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody()))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
gw.Mux().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("POST claims: %d", w.Code)
|
||||
}
|
||||
|
||||
// Mock эмитит Decision через MockDecisionDelay (50мс). Дождёмся его и
|
||||
// прокинем в ApplyDecision — этого делает фоновый воркер, который в
|
||||
// этом тесте не запущен (Run не вызывается).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case d := <-gw.Mock().Decisions():
|
||||
if err := gw.Service().ApplyDecision(ctx, d); err != nil {
|
||||
t.Fatalf("ApplyDecision: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("Decision из mock не пришёл")
|
||||
}
|
||||
|
||||
select {
|
||||
case cb := <-receivedCallback:
|
||||
status, _ := cb["new_status"].(string)
|
||||
if status != "confirmed" {
|
||||
t.Errorf("ожидался callback со статусом confirmed, получено %s", status)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("callback в эмулятор не пришёл")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
|
||||
// m2mcore, оркестрирует FSM сделки, эмитит callback'и в ЛК.
|
||||
type Service struct {
|
||||
repo m2mcore.Repository
|
||||
sender m2mcore.NSDSender
|
||||
store m2mcore.FansyStore
|
||||
recorder *m2mcore.MemoryRecorder
|
||||
defaultSender m2m.DeponentCode
|
||||
defaultReceiver m2m.DeponentCode
|
||||
callbackURL string
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
claimToID map[string]string // claim public ID -> internal deal ID
|
||||
}
|
||||
|
||||
// Config — параметры сервиса.
|
||||
type Config struct {
|
||||
Repository m2mcore.Repository
|
||||
Sender m2mcore.NSDSender
|
||||
Store m2mcore.FansyStore
|
||||
Recorder *m2mcore.MemoryRecorder
|
||||
DefaultSender m2m.DeponentCode
|
||||
DefaultReceiver m2m.DeponentCode
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
// NewService собирает сервис.
|
||||
func NewService(cfg Config) *Service {
|
||||
if cfg.Recorder == nil {
|
||||
cfg.Recorder = m2mcore.NewMemoryRecorder()
|
||||
}
|
||||
return &Service{
|
||||
repo: cfg.Repository,
|
||||
sender: cfg.Sender,
|
||||
store: cfg.Store,
|
||||
recorder: cfg.Recorder,
|
||||
defaultSender: cfg.DefaultSender,
|
||||
defaultReceiver: cfg.DefaultReceiver,
|
||||
callbackURL: cfg.CallbackURL,
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
claimToID: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateClaim принимает DTO заявки, формирует M2MTransferRequest,
|
||||
// создаёт сделку и отправляет в НРД.
|
||||
func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (CreateClaimResponse, error) {
|
||||
domainClaim, err := dtoToClaim(in)
|
||||
if err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
|
||||
}
|
||||
|
||||
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
|
||||
SenderCode: s.defaultSender,
|
||||
ReceiverCode: s.defaultReceiver,
|
||||
})
|
||||
if err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
|
||||
}
|
||||
|
||||
deal, err := m2mcore.NewDeal(req.Header.GUID, in.Investor.ID, []byte(in.SignedDocument))
|
||||
if err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err)
|
||||
}
|
||||
saved, err := s.repo.Create(ctx, deal)
|
||||
if err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err)
|
||||
}
|
||||
|
||||
if err := saved.Validate(ctx, req); err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.sender.Send(ctx, req)
|
||||
if err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err)
|
||||
}
|
||||
saved.Response = resp
|
||||
|
||||
if err := saved.Submit(ctx); err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err)
|
||||
}
|
||||
if err := s.repo.Update(ctx, saved); err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Update: %w", err)
|
||||
}
|
||||
s.recorder.IncDeal(saved.State)
|
||||
s.mu.Lock()
|
||||
s.claimToID[saved.ID] = saved.ID
|
||||
s.mu.Unlock()
|
||||
|
||||
return CreateClaimResponse{
|
||||
ID: saved.ID,
|
||||
Status: string(saved.State),
|
||||
CreatedAt: saved.CreatedAt,
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClaim возвращает полную карточку заявки.
|
||||
func (s *Service) GetClaim(ctx context.Context, id string) (ClaimView, error) {
|
||||
deal, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return ClaimView{}, err
|
||||
}
|
||||
return dealToView(deal), nil
|
||||
}
|
||||
|
||||
// ListClaims возвращает страницу заявок.
|
||||
func (s *Service) ListClaims(ctx context.Context, filter m2mcore.Filter) (ClaimsPage, error) {
|
||||
if filter.Limit == 0 {
|
||||
filter.Limit = 50
|
||||
}
|
||||
deals, err := s.repo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return ClaimsPage{}, err
|
||||
}
|
||||
sort.Slice(deals, func(i, j int) bool { return deals[i].CreatedAt.After(deals[j].CreatedAt) })
|
||||
items := make([]ClaimView, 0, len(deals))
|
||||
for _, d := range deals {
|
||||
items = append(items, dealToView(d))
|
||||
}
|
||||
return ClaimsPage{Items: items, Total: len(items), Limit: filter.Limit, Offset: filter.Offset}, nil
|
||||
}
|
||||
|
||||
// ApplyDecision принимает Decision (из mock-NSDSender или реального адаптера),
|
||||
// обновляет соответствующую сделку и шлёт callback в ЛК.
|
||||
func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error {
|
||||
if decision == nil {
|
||||
return errors.New("lkgateway: ApplyDecision: decision=nil")
|
||||
}
|
||||
deal, err := s.repo.GetByGUID(ctx, decision.Header.GUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lkgateway: GetByGUID: %w", err)
|
||||
}
|
||||
if err := deal.ReceiveDecision(ctx, decision); err != nil {
|
||||
return fmt.Errorf("lkgateway: ReceiveDecision: %w", err)
|
||||
}
|
||||
if err := s.repo.Update(ctx, deal); err != nil {
|
||||
return fmt.Errorf("lkgateway: repo.Update: %w", err)
|
||||
}
|
||||
s.recorder.IncDeal(deal.State)
|
||||
|
||||
if s.callbackURL != "" {
|
||||
s.sendCallback(ctx, deal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
|
||||
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
|
||||
cb := callbackForDeal(deal)
|
||||
if err := postJSON(ctx, s.httpClient, s.callbackURL+"/api/v1/back_office/claims/"+deal.ID, "PATCH", cb); err != nil {
|
||||
log.Printf("lkgateway: callback в ЛК упал: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Recorder возвращает экспонируемый Recorder для admin-страницы.
|
||||
func (s *Service) Recorder() *m2mcore.MemoryRecorder { return s.recorder }
|
||||
|
||||
// Repo возвращает Repository (для админских проверок).
|
||||
func (s *Service) Repo() m2mcore.Repository { return s.repo }
|
||||
|
||||
// Внутренние преобразования и хелперы.
|
||||
|
||||
func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
|
||||
out := m2mcore.ClaimInput{
|
||||
InvestorClientID: in.Investor.ID,
|
||||
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
|
||||
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
|
||||
}
|
||||
// CostInfo
|
||||
if in.CostInfo.Yes != nil {
|
||||
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
|
||||
} else {
|
||||
out.CostInfo = m2m.CostInfo{No: &m2m.CostInfoNo{}}
|
||||
}
|
||||
// IIA
|
||||
if in.IIAAgreement != nil {
|
||||
out.IIAAgreement = &m2m.IIAAgreementDetails{
|
||||
AgreementType: m2m.IIAContractType(in.IIAAgreement.AgreementType),
|
||||
AgreementNumber: in.IIAAgreement.AgreementNumber,
|
||||
AgreementDate: in.IIAAgreement.AgreementDate,
|
||||
BrokerINN: m2m.OrganizationINN(in.IIAAgreement.BrokerINN),
|
||||
}
|
||||
}
|
||||
// Securities
|
||||
for _, sec := range in.Securities {
|
||||
ds, err := dtoSecurityDetails(sec.SecurityDetails)
|
||||
if err != nil {
|
||||
return m2mcore.ClaimInput{}, err
|
||||
}
|
||||
q, err := dtoQuantity(sec.Quantity)
|
||||
if err != nil {
|
||||
return m2mcore.ClaimInput{}, err
|
||||
}
|
||||
out.Securities = append(out.Securities, m2mcore.ClaimSecurityInput{
|
||||
SecurityCode: m2m.SecurityCode(sec.SecurityCode),
|
||||
Details: ds,
|
||||
Quantity: q,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dtoSecurityDetails(in SecurityDetails) (m2m.SecurityDetails, error) {
|
||||
if in.ISIN != "" {
|
||||
isin := m2m.ISIN(in.ISIN)
|
||||
return m2m.SecurityDetails{ISIN: &isin}, nil
|
||||
}
|
||||
if in.SecurityInfo != nil {
|
||||
si := &m2m.SecurityDescription{
|
||||
SecurityClassification: m2m.SecurityClassification(in.SecurityInfo.Classification),
|
||||
SecurityCategory: m2m.SecurityCategory(in.SecurityInfo.Category),
|
||||
SecurityType: in.SecurityInfo.SecurityType,
|
||||
SecuritySeries: in.SecurityInfo.SecuritySeries,
|
||||
}
|
||||
if in.SecurityInfo.IdentificationDetails.RegNumber != "" {
|
||||
rn := in.SecurityInfo.IdentificationDetails.RegNumber
|
||||
si.IdentificationDetails = m2m.IdentificationDetails{RegNumber: &rn}
|
||||
}
|
||||
if in.SecurityInfo.IdentificationDetails.FundShares != nil {
|
||||
si.IdentificationDetails = m2m.IdentificationDetails{
|
||||
FundShares: &m2m.FundShares{
|
||||
RegNumber: in.SecurityInfo.IdentificationDetails.FundShares.RegNumber,
|
||||
Class: in.SecurityInfo.IdentificationDetails.FundShares.Class,
|
||||
},
|
||||
}
|
||||
}
|
||||
return m2m.SecurityDetails{SecurityInfo: si}, nil
|
||||
}
|
||||
return m2m.SecurityDetails{}, errors.New("lkgateway: SecurityDetails — задайте isin или security_info")
|
||||
}
|
||||
|
||||
func dtoQuantity(in Quantity) (m2m.Quantity, error) {
|
||||
if in.Whole > 0 {
|
||||
w := in.Whole
|
||||
return m2m.Quantity{Whole: &w}, nil
|
||||
}
|
||||
if in.Fractional != "" {
|
||||
f := m2m.Decimal16(in.Fractional)
|
||||
return m2m.Quantity{Fractional: &f}, nil
|
||||
}
|
||||
return m2m.Quantity{}, errors.New("lkgateway: Quantity — задайте whole или fractional")
|
||||
}
|
||||
|
||||
func dealToView(d *m2mcore.Deal) ClaimView {
|
||||
out := ClaimView{
|
||||
ID: d.ID,
|
||||
Status: string(d.State),
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
M2MGUID: d.GUID,
|
||||
}
|
||||
for _, st := range d.Stages {
|
||||
out.Stages = append(out.Stages, StageView{
|
||||
State: string(st.State), EnteredAt: st.EnteredAt, LeftAt: st.LeftAt, Reason: st.Reason,
|
||||
})
|
||||
}
|
||||
if d.Request != nil {
|
||||
out.TransferringDepositoryINN = string(d.Request.Data.TransferringDepository.INN)
|
||||
out.ReceivingDepositoryINN = string(d.Request.Data.ReceivingDepository.INN)
|
||||
ii := d.Request.Data.InvestorInformation
|
||||
out.Investor = Investor{
|
||||
LastName: ii.LastName, FirstName: ii.FirstName, MiddleName: ii.MiddleName,
|
||||
Document: Document{
|
||||
DocumentType: string(ii.IdentityDocument.DocumentType),
|
||||
Number: string(ii.IdentityDocument.DocumentNumber),
|
||||
},
|
||||
}
|
||||
if ii.IdentityDocument.DocumentSeries != nil {
|
||||
out.Investor.Document.Series = string(*ii.IdentityDocument.DocumentSeries)
|
||||
}
|
||||
if d.Request.Header.CostInfo.Yes != nil {
|
||||
out.CostInfo = CostInfo{Yes: &CostInfoYes{Code: string(d.Request.Header.CostInfo.Yes.Code)}}
|
||||
} else if d.Request.Header.CostInfo.No != nil {
|
||||
empty := struct{}{}
|
||||
out.CostInfo = CostInfo{No: &empty}
|
||||
}
|
||||
if d.Request.Header.IIAAgreementDetails != nil {
|
||||
out.IIAAgreement = &IIAAgreement{
|
||||
AgreementType: string(d.Request.Header.IIAAgreementDetails.AgreementType),
|
||||
AgreementNumber: d.Request.Header.IIAAgreementDetails.AgreementNumber,
|
||||
AgreementDate: d.Request.Header.IIAAgreementDetails.AgreementDate,
|
||||
BrokerINN: string(d.Request.Header.IIAAgreementDetails.BrokerINN),
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.Response != nil {
|
||||
out.M2MResponse = responseToView(d.Response)
|
||||
}
|
||||
if d.Decision != nil {
|
||||
out.M2MDecision = decisionToView(d.Decision)
|
||||
}
|
||||
if d.State != m2mcore.StateDraft {
|
||||
cb := callbackForDeal(d)
|
||||
out.LastCallback = &cb
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func responseToView(r *m2m.M2MTransferResponse) *NSDResponseView {
|
||||
v := &NSDResponseView{
|
||||
GUID: string(r.GUID),
|
||||
StatusCode: string(r.StatusCode),
|
||||
}
|
||||
for _, e := range r.Responses {
|
||||
ent := NSDResponseEntry{Code: e.Code, Text: e.Text}
|
||||
if e.ReferenceID != nil {
|
||||
ent.ReferenceID = string(*e.ReferenceID)
|
||||
}
|
||||
v.Responses = append(v.Responses, ent)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func decisionToView(d *m2m.M2MTransferDecision) *NSDDecisionView {
|
||||
v := &NSDDecisionView{GUID: string(d.Header.GUID)}
|
||||
for _, sec := range d.Data.Securities {
|
||||
entry := NSDDecisionSecurity{ReferenceID: string(sec.ReferenceID)}
|
||||
if sec.TransferDecision.Confirmation != nil {
|
||||
entry.Outcome = "confirmed"
|
||||
} else if sec.TransferDecision.Rejection != nil {
|
||||
entry.Outcome = "rejected"
|
||||
entry.RejectCodes = sec.TransferDecision.Rejection.Codes
|
||||
}
|
||||
v.Securities = append(v.Securities, entry)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func callbackForDeal(d *m2mcore.Deal) StatusCallback {
|
||||
cb := StatusCallback{
|
||||
ClaimID: d.ID,
|
||||
NewStatus: string(d.State),
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
if d.Decision != nil {
|
||||
cb.NSDResponse = nsdResponseFromDecision(d.Decision)
|
||||
if d.State == m2mcore.StateRejected {
|
||||
for _, sec := range d.Decision.Data.Securities {
|
||||
if sec.TransferDecision.Rejection != nil && len(sec.TransferDecision.Rejection.Codes) > 0 {
|
||||
cb.ReasonCode = sec.TransferDecision.Rejection.Codes[0]
|
||||
cb.ReasonText = "Отказ принимающей стороны (mock)"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if d.Response != nil {
|
||||
cb.NSDResponse = responseToView(d.Response)
|
||||
}
|
||||
return cb
|
||||
}
|
||||
|
||||
func nsdResponseFromDecision(d *m2m.M2MTransferDecision) *NSDResponseView {
|
||||
v := &NSDResponseView{GUID: string(d.Header.GUID), StatusCode: string(m2m.StatusInfo)}
|
||||
for _, sec := range d.Data.Securities {
|
||||
ref := string(sec.ReferenceID)
|
||||
ent := NSDResponseEntry{ReferenceID: ref}
|
||||
if sec.TransferDecision.Confirmation != nil {
|
||||
ent.Code = "01"
|
||||
ent.Text = "Подтверждение принимающей стороны."
|
||||
} else if sec.TransferDecision.Rejection != nil {
|
||||
ent.Code = "07"
|
||||
ent.Text = "Отказ принимающей стороны."
|
||||
}
|
||||
v.Responses = append(v.Responses, ent)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
// crypto-сертификаты на текущих токенах (для отображения на странице).
|
||||
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
|
||||
s := h.rc.Snapshot()
|
||||
if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" || s.Crypto.JCPPath == "" {
|
||||
return nil
|
||||
}
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||||
ModulePath: s.Crypto.JCPPath,
|
||||
})
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
certs, _ := cli.FindCertificates(ctx)
|
||||
return certs
|
||||
}
|
||||
|
||||
// setupHandlers — обработчики /admin/setup/*.
|
||||
type setupHandlers struct {
|
||||
rc *RuntimeConfig
|
||||
tpl *adminTemplates
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// adminTemplates — обёртка, чтобы передать набор шаблонов в setup.
|
||||
type adminTemplates struct {
|
||||
a *admin
|
||||
}
|
||||
|
||||
// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
|
||||
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
|
||||
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
|
||||
mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.renderSetup(w, r, "")
|
||||
})
|
||||
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
|
||||
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
|
||||
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
|
||||
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
|
||||
mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
|
||||
mux.HandleFunc("/admin/setup/crypto/install", h.installCryptoPro)
|
||||
mux.HandleFunc("/admin/setup/crypto/import-cert", h.importCertificate)
|
||||
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/test-run", h.testRun)
|
||||
|
||||
// Авто-загрузка сертификатов УЦ НРД и нашего УЦ.
|
||||
mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts)
|
||||
mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow)
|
||||
|
||||
// Копирование контейнера КриптоПро с флешки в локальное хранилище.
|
||||
mux.HandleFunc("/admin/setup/crypto/copy-container", h.copyContainer)
|
||||
|
||||
// Новости / события системы.
|
||||
mux.HandleFunc("/admin/news", h.renderNews)
|
||||
mux.HandleFunc("/admin/news/add", h.addManualNews)
|
||||
mux.HandleFunc("/admin/news/dismiss", h.dismissNews)
|
||||
mux.HandleFunc("/admin/news/check-docs", h.checkDocsNow)
|
||||
|
||||
// Пошаговый мастер настройки для нетехнических пользователей.
|
||||
mux.HandleFunc("/admin/wizard", h.renderWizard)
|
||||
}
|
||||
|
||||
// renderNews — GET /admin/news.
|
||||
func (h *setupHandlers) renderNews(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot()
|
||||
data := struct {
|
||||
page
|
||||
Settings Settings
|
||||
Flash string
|
||||
}{
|
||||
page: nowPage("Новости", "news"),
|
||||
Settings: s,
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
}
|
||||
render(w, h.tpl.a.news, data)
|
||||
}
|
||||
|
||||
// WizardData — данные для шаблона /admin/wizard.
|
||||
type WizardData struct {
|
||||
page
|
||||
Step int
|
||||
Settings Settings
|
||||
Certs []cryptocli.Certificate
|
||||
FlashContainers []FlashContainer
|
||||
Flash string
|
||||
CryptoProInstalled bool
|
||||
CryptoProVersion string
|
||||
Done struct {
|
||||
Postgres bool
|
||||
Crypto bool
|
||||
Certs bool
|
||||
NSD bool
|
||||
TestRun bool
|
||||
}
|
||||
}
|
||||
|
||||
// renderWizard рисует одну из 5 страниц мастера. Шаг управляется query
|
||||
// параметром ?step=N (1..5). По умолчанию шаг определяется автоматически
|
||||
// по первому незавершённому пункту — это даёт «продолжить с того места».
|
||||
func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot()
|
||||
d := WizardData{
|
||||
page: nowPage("Мастер настройки", "wizard"),
|
||||
Settings: s,
|
||||
Certs: h.listCertsForUI(),
|
||||
FlashContainers: scanFlashContainers(),
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
}
|
||||
d.Done.Postgres = s.Postgres.DSN != ""
|
||||
d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != ""
|
||||
d.Done.Certs = len(d.Certs) > 0
|
||||
d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
|
||||
d.Done.TestRun = s.LastTest != nil
|
||||
|
||||
// Проверяем установлен ли КриптоПро CSP.
|
||||
if _, err := os.Stat("/opt/cprocsp/sbin/amd64/cpconfig"); err == nil {
|
||||
d.CryptoProInstalled = true
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if ver, _ := runCmd(ctx, "/opt/cprocsp/sbin/amd64/cpconfig", "-license", "-view"); ver != "" {
|
||||
d.CryptoProVersion = firstLine(ver)
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем текущий шаг.
|
||||
step := 1
|
||||
if v := r.URL.Query().Get("step"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 5 {
|
||||
step = n
|
||||
}
|
||||
} else {
|
||||
// Авто: первый незавершённый.
|
||||
switch {
|
||||
case !d.Done.Postgres:
|
||||
step = 1
|
||||
case !d.Done.Crypto:
|
||||
step = 2
|
||||
case !d.Done.Certs:
|
||||
step = 3
|
||||
case !d.Done.NSD:
|
||||
step = 4
|
||||
default:
|
||||
step = 5
|
||||
}
|
||||
}
|
||||
d.Step = step
|
||||
render(w, h.tpl.a.wizard, d)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// installCryptoPro — POST /admin/setup/crypto/install (multipart).
|
||||
// Принимает tar или tar.gz архив с дистрибутивом КриптоПро CSP (как
|
||||
// linux-amd64.tgz с cryptopro.ru), распаковывает в /tmp/bj-cryptopro,
|
||||
// находит все .rpm файлы и устанавливает через sudo rpm -i.
|
||||
// На РЕД ОС / ALT / ROSA это даёт рабочий /opt/cprocsp/.
|
||||
func (h *setupHandlers) installCryptoPro(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Архив КриптоПро ~50-100 МБ — поднимем лимит до 256 МБ.
|
||||
if err := r.ParseMultipartForm(256 << 20); err != nil {
|
||||
setupFlash(w, r, "Установка: ошибка чтения формы: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("dist")
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Установка: выберите файл архива дистрибутива (.tar/.tgz/.tar.gz/.rpm)")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dir := "/tmp/bj-cryptopro"
|
||||
_ = os.RemoveAll(dir)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
setupFlash(w, r, "Установка: не получилось создать "+dir+": "+err.Error())
|
||||
return
|
||||
}
|
||||
dst := filepath.Join(dir, filepath.Base(header.Filename))
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Установка: не получилось создать "+dst+": "+err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
out.Close()
|
||||
setupFlash(w, r, "Установка: ошибка записи файла: "+err.Error())
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
|
||||
// Распаковка (если .tar/.tgz/.tar.gz).
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
lower := strings.ToLower(dst)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".rpm"):
|
||||
// Один rpm — установим напрямую без распаковки.
|
||||
case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"):
|
||||
if untar, err := runCmdInDir(ctx, dir, "tar", "-xzf", dst); err != nil {
|
||||
setupFlash(w, r, "Установка: распаковка .tgz упала: "+err.Error()+" / вывод: "+untar)
|
||||
return
|
||||
}
|
||||
case strings.HasSuffix(lower, ".tar"):
|
||||
if untar, err := runCmdInDir(ctx, dir, "tar", "-xf", dst); err != nil {
|
||||
setupFlash(w, r, "Установка: распаковка .tar упала: "+err.Error()+" / вывод: "+untar)
|
||||
return
|
||||
}
|
||||
default:
|
||||
setupFlash(w, r, "Установка: неизвестный формат файла, нужен .tar/.tgz/.tar.gz/.rpm")
|
||||
return
|
||||
}
|
||||
|
||||
// Найти все .rpm в директории.
|
||||
var rpms []string
|
||||
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() && strings.HasSuffix(p, ".rpm") {
|
||||
rpms = append(rpms, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if len(rpms) == 0 {
|
||||
setupFlash(w, r, "Установка: после распаковки .rpm файлы не найдены в "+dir)
|
||||
return
|
||||
}
|
||||
|
||||
// sudo rpm -i <все rpm>. На РЕД ОС иногда нужен --nosignature --nodeps.
|
||||
args := append([]string{"rpm", "-Uvh", "--replacepkgs", "--nosignature"}, rpms...)
|
||||
output, err := runCmd(ctx, "sudo", args...)
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Установка: rpm -i упал: "+err.Error()+" / вывод: "+strings.TrimSpace(output))
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "КриптоПро CSP установлен. Файлов rpm: "+fmt.Sprint(len(rpms))+". Теперь введите серийник и нажмите «Активировать лицензию». Вывод rpm: "+strings.TrimSpace(output))
|
||||
}
|
||||
|
||||
// importCertificate — POST /admin/setup/crypto/import-cert (multipart).
|
||||
// Принимает .pfx (PKCS#12 — приватный ключ + сертификат + опц. PIN) или
|
||||
// .cer/.crt (только публичный сертификат). Импортирует через certmgr
|
||||
// КриптоПро. Сертификат добавляется в хранилище uMy (либо mroot для
|
||||
// корневых).
|
||||
func (h *setupHandlers) importCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
setupFlash(w, r, "Импорт сертификата: ошибка чтения формы: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("cert")
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Импорт сертификата: выберите файл .pfx/.cer/.crt")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
pin := strings.TrimSpace(r.FormValue("pin"))
|
||||
store := strings.TrimSpace(r.FormValue("store")) // "uMy" по умолчанию, "mroot" для корневых
|
||||
|
||||
dir := "/tmp/bj-certs"
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
safeName := filepath.Base(header.Filename)
|
||||
dst := filepath.Join(dir, safeName)
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Импорт сертификата: не получилось создать "+dst+": "+err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
out.Close()
|
||||
setupFlash(w, r, "Импорт сертификата: ошибка записи: "+err.Error())
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
|
||||
certmgr := "/opt/cprocsp/bin/amd64/certmgr"
|
||||
if _, err := os.Stat(certmgr); err != nil {
|
||||
setupFlash(w, r, "Импорт сертификата: certmgr не найден. Сначала установите КриптоПро CSP.")
|
||||
return
|
||||
}
|
||||
if store == "" {
|
||||
store = "uMy"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
lower := strings.ToLower(safeName)
|
||||
var cmdOut string
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".pfx") || strings.HasSuffix(lower, ".p12"):
|
||||
// PKCS#12 — импорт через certmgr -inst с PIN
|
||||
args := []string{"-inst", "-pfx", "-file", dst, "-store", store}
|
||||
if pin != "" {
|
||||
args = append(args, "-pin", pin)
|
||||
}
|
||||
cmdOut, err = runCmd(ctx, certmgr, args...)
|
||||
case strings.HasSuffix(lower, ".cer") || strings.HasSuffix(lower, ".crt"):
|
||||
// Голый сертификат — импорт в хранилище без приватного ключа
|
||||
cmdOut, err = runCmd(ctx, certmgr, "-inst", "-file", dst, "-store", store)
|
||||
default:
|
||||
setupFlash(w, r, "Импорт сертификата: неизвестное расширение, нужен .pfx/.p12/.cer/.crt")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Импорт сертификата: certmgr упал: "+err.Error()+" / вывод: "+strings.TrimSpace(cmdOut))
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Сертификат «"+safeName+"» импортирован в хранилище "+store+". Вывод certmgr: "+strings.TrimSpace(cmdOut))
|
||||
}
|
||||
|
||||
// runCmdInDir выполняет команду в указанной рабочей директории.
|
||||
func runCmdInDir(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// runCmd выполняет команду и возвращает stdout+stderr строкой.
|
||||
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// activateLicense — POST /admin/setup/crypto/activate. Принимает серийный
|
||||
// номер из формы, вызывает cpconfig -license -set, возвращает результат
|
||||
// во flash. Если КриптоПро CSP не установлен — даёт ссылку на инструкцию.
|
||||
func (h *setupHandlers) activateLicense(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
serial := strings.TrimSpace(r.FormValue("license_key"))
|
||||
if serial == "" {
|
||||
setupFlash(w, r, "Активация лицензии: введите серийный номер в поле выше")
|
||||
return
|
||||
}
|
||||
cpconfig := "/opt/cprocsp/sbin/amd64/cpconfig"
|
||||
if _, err := os.Stat(cpconfig); err != nil {
|
||||
setupFlash(w, r, "КриптоПро CSP не установлен ("+cpconfig+" не найден). Раздел /admin/help/cryptopro — команды установки и копирования дистрибутива на ВМ.")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := runCmd(ctx, cpconfig, "-license", "-set", serial)
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Активация лицензии не прошла: "+err.Error()+" / вывод: "+strings.TrimSpace(out))
|
||||
return
|
||||
}
|
||||
cur := h.rc.Snapshot().Crypto
|
||||
cur.LicenseKey = serial
|
||||
if err := h.rc.UpdateCrypto(cur); err != nil {
|
||||
log.Printf("activateLicense: UpdateCrypto: %v", err)
|
||||
}
|
||||
setupFlash(w, r, "Лицензия КриптоПро активирована. Вывод cpconfig: "+strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
// checkCrypto — POST /admin/setup/crypto/check. Запускает Health()
|
||||
// текущего провайдера PKCS#11 без изменения настроек.
|
||||
func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot()
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||||
ModulePath: s.Crypto.JCPPath, // унаследовано — теперь путь к PKCS#11 .so
|
||||
})
|
||||
defer cli.Close()
|
||||
info, err := cli.Health(r.Context())
|
||||
if err != nil {
|
||||
setupFlash(w, r, "СКЗИ: проверка не прошла — "+err.Error())
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("СКЗИ %s: %s", info.Provider, info.Message)
|
||||
if info.CryptokiVersion != "" {
|
||||
msg += fmt.Sprintf(" (PKCS#11 v%s, %s)", info.CryptokiVersion, info.ManufacturerID)
|
||||
}
|
||||
if len(info.Tokens) > 0 {
|
||||
msg += ". Токены:"
|
||||
for _, t := range info.Tokens {
|
||||
msg += fmt.Sprintf(" «%s» (%s);", t.Label, t.Model)
|
||||
}
|
||||
}
|
||||
setupFlash(w, r, msg)
|
||||
}
|
||||
|
||||
// SetupData — данные для шаблона admin_setup.html.
|
||||
type SetupData struct {
|
||||
page
|
||||
Settings Settings
|
||||
Readiness []Readiness
|
||||
ReadyCount int
|
||||
TotalCount int
|
||||
Certificates []cryptocli.Certificate
|
||||
FlashContainers []FlashContainer
|
||||
Flash string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
|
||||
s := h.rc.Snapshot()
|
||||
r := h.rc.ReadinessSummary()
|
||||
ready := 0
|
||||
for _, x := range r {
|
||||
if x.Configured {
|
||||
ready++
|
||||
}
|
||||
}
|
||||
data := SetupData{
|
||||
page: nowPage("Настройка", "setup"),
|
||||
Settings: s,
|
||||
Readiness: r,
|
||||
ReadyCount: ready,
|
||||
TotalCount: len(r),
|
||||
Certificates: h.listCertsForUI(),
|
||||
FlashContainers: scanFlashContainers(),
|
||||
Flash: flash,
|
||||
}
|
||||
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
||||
data.Error = errVal
|
||||
}
|
||||
render(w, h.tpl.a.setup, data)
|
||||
}
|
||||
|
||||
func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
dsn := strings.TrimSpace(r.FormValue("dsn"))
|
||||
if dsn != "" {
|
||||
// Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше
|
||||
// просто ошибка — это нормально, важно показать что DSN сохранён).
|
||||
if err := tryPingPostgres(dsn); err != nil {
|
||||
setupFlash(w, r, "postgres: тест соединения упал: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
|
||||
setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "PostgreSQL настройки сохранены")
|
||||
}
|
||||
|
||||
// quickStartPostgres — POST /admin/setup/postgres/quick-start.
|
||||
// «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает
|
||||
// локальный postgres-контейнер через podman-compose, ждёт pg_isready,
|
||||
// накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный
|
||||
// DSN в runtime-конфиге. После этого пользователю остаётся перезапустить
|
||||
// bj-server (или мы сделаем это автоматически в дальнейших версиях).
|
||||
func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. Поднимаем postgres контейнер через podman-compose.
|
||||
composePath := "deploy/docker-compose/docker-compose.yml"
|
||||
if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil {
|
||||
setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+
|
||||
"Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+
|
||||
"sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Ждём pg_isready.
|
||||
dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := tryPingPostgres(dsn); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
if err := tryPingPostgres(dsn); err != nil {
|
||||
setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Применяем миграции через podman exec.
|
||||
migrations := []string{
|
||||
"migrations/fansy-store/000__roles.sql",
|
||||
"migrations/fansy-store/001__schemas.sql",
|
||||
"migrations/fansy-store/002__working.sql",
|
||||
"migrations/fansy-store/003__staging.sql",
|
||||
"migrations/fansy-store/004__seed_participants.sql",
|
||||
"migrations/m2m-core/001__deals.sql",
|
||||
"migrations/m2m-core/002__stages.sql",
|
||||
}
|
||||
for _, mig := range migrations {
|
||||
if err := applyMigration(ctx, mig); err != nil {
|
||||
// Миграция могла быть уже применена ранее (например, ROLE уже
|
||||
// существует) — это не критично, продолжаем.
|
||||
log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Сохраняем DSN в runtime-конфиг.
|
||||
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
|
||||
setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+
|
||||
"Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+
|
||||
"чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.")
|
||||
}
|
||||
|
||||
// applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres.
|
||||
func applyMigration(ctx context.Context, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres",
|
||||
"psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1")
|
||||
cmd.Stdin = strings.NewReader(string(data))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := CryptoSettings{
|
||||
Provider: strings.TrimSpace(r.FormValue("provider")),
|
||||
SocketPath: strings.TrimSpace(r.FormValue("socket_path")),
|
||||
JCPPath: strings.TrimSpace(r.FormValue("jcp_path")),
|
||||
LicenseKey: strings.TrimSpace(r.FormValue("license_key")),
|
||||
}
|
||||
if s.Provider == "" {
|
||||
s.Provider = "stub"
|
||||
}
|
||||
if s.SocketPath == "" {
|
||||
s.SocketPath = "/run/bj/crypto.sock"
|
||||
}
|
||||
// Если указан JCP-путь — проверим что файл существует.
|
||||
if s.JCPPath != "" {
|
||||
if _, err := os.Stat(s.JCPPath); err != nil {
|
||||
setupFlash(w, r, "crypto: jcp_path не найден: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdateCrypto(s); err != nil {
|
||||
setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := NSDSettings{
|
||||
Profile: strings.TrimSpace(r.FormValue("profile")),
|
||||
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
|
||||
KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
|
||||
}
|
||||
if s.IGWBaseURL != "" {
|
||||
if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
|
||||
setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := tryHTTPHealth(s.IGWBaseURL + "/healthz"); err != nil {
|
||||
setupFlash(w, r, "nsd: ИШ не отвечает на /healthz: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdateNSD(s); err != nil {
|
||||
setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "nsd-adapter: настройки сохранены")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))}
|
||||
if s.CallbackURL != "" {
|
||||
if _, err := url.ParseRequestURI(s.CallbackURL); err != nil {
|
||||
setupFlash(w, r, "lk: невалидный URL: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil {
|
||||
setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdateLK(s); err != nil {
|
||||
setupFlash(w, r, "lk: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
if s.CallbackURL != "" {
|
||||
h.svc.callbackURL = s.CallbackURL
|
||||
}
|
||||
setupFlash(w, r, "Callback в ЛК сохранён и применён")
|
||||
}
|
||||
|
||||
// testRun запускает тестовую заявку с предустановленными данными,
|
||||
// ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет
|
||||
// результат в RuntimeConfig.LastTest.
|
||||
func (h *setupHandlers) testRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
go h.runTestClaim()
|
||||
setupFlash(w, r, "Тестовая заявка запущена, обновите страницу через 5 секунд")
|
||||
}
|
||||
|
||||
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
|
||||
func (h *setupHandlers) runTestClaim() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
whole := uint64(1500)
|
||||
req := CreateClaimRequest{
|
||||
Investor: Investor{
|
||||
ID: "11111111-1111-1111-1111-111111111111",
|
||||
LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
|
||||
Document: Document{DocumentType: "21", Series: "4512", Number: "654321"},
|
||||
},
|
||||
TransferringDepositoryINN: "0702345678",
|
||||
ReceivingDepositoryINN: "0710987654",
|
||||
CostInfo: CostInfo{No: &struct{}{}},
|
||||
IIAAgreement: &IIAAgreement{
|
||||
AgreementType: "T03", AgreementNumber: "ИИС78/2024",
|
||||
AgreementDate: "2026-01-15", BrokerINN: "0707083893",
|
||||
},
|
||||
Securities: []ClaimSec{
|
||||
{
|
||||
SecurityCode: "MM0766162534",
|
||||
SecurityDetails: SecurityDetails{ISIN: "RU0007661625"},
|
||||
Quantity: Quantity{Whole: whole},
|
||||
SettlementAccounts: []SettlementAccount{
|
||||
{
|
||||
SettlementRequisitesINN: "7702070139",
|
||||
SettlementLocation: SettlementLocation{
|
||||
DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
|
||||
SignatureFormat: "XMLDSig-GOST",
|
||||
}
|
||||
|
||||
res := TestRunResult{StartedAt: time.Now().UTC()}
|
||||
created, err := h.svc.CreateClaim(ctx, req)
|
||||
if err != nil {
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.Message = "CreateClaim упал: " + err.Error()
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
}
|
||||
res.ClaimID = created.ID
|
||||
|
||||
// Опрашиваем статус каждые 200ms до перехода в финал.
|
||||
deadline := time.Now().Add(25 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
view, err := h.svc.GetClaim(ctx, created.ID)
|
||||
if err != nil {
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.FinalStatus = "lookup_failed"
|
||||
res.Message = err.Error()
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
}
|
||||
switch view.Status {
|
||||
case "confirmed", "awaiting_sub16", "done":
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = true
|
||||
res.FinalStatus = view.Status
|
||||
res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)."
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
case "rejected", "timed_out":
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.FinalStatus = view.Status
|
||||
res.Message = "Заявка не прошла: статус " + view.Status
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.FinalStatus = "timeout_waiting"
|
||||
res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)"
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
}
|
||||
|
||||
// tryPingPostgres делает короткое подключение через pgx и Ping.
|
||||
func tryPingPostgres(dsn string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
return conn.Ping(ctx)
|
||||
}
|
||||
|
||||
// tryHTTPHealth делает GET и ждёт 2xx.
|
||||
func tryHTTPHealth(u string) error {
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := c.Get(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл
|
||||
// с какой-то «принимающей flash» страницы (/admin/wizard, /admin/news,
|
||||
// /admin/setup) — возвращаем туда же. Иначе дефолт — /admin/setup.
|
||||
// Это нужно чтобы пользователь не «выпадал» из текущего контекста после
|
||||
// POST-действия (нажал кнопку «Проверить обновления» в Новостях — должен
|
||||
// остаться в Новостях со флешем).
|
||||
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
if ref := r.Header.Get("Referer"); ref != "" {
|
||||
if u, err := url.Parse(ref); err == nil {
|
||||
for _, prefix := range []string{"/admin/wizard", "/admin/news", "/admin/setup"} {
|
||||
if strings.HasPrefix(u.Path, prefix) {
|
||||
q := u.Query()
|
||||
q.Set("flash", msg)
|
||||
http.Redirect(w, r, u.Path+"?"+q.Encode(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// _q извлекает Request из ResponseWriter trick — здесь не нужно
|
||||
// (всегда работаем через chain).
|
||||
func _q(_ http.ResponseWriter) string { return "" }
|
||||
func errMsgFromQuery(_ string) string { return "" }
|
||||
|
||||
// guard — заглушка для совместимости с возможным расширением.
|
||||
var _ = errors.New
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package lkgateway реализует REST API контракта ESIA Finance V1
|
||||
// (docs/lk-contract/v1/openapi.yaml) и admin web-интерфейс.
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
)
|
||||
|
||||
// CreateClaimRequest — DTO входа POST /api/v1/back_office/claims/.
|
||||
type CreateClaimRequest struct {
|
||||
Investor Investor `json:"investor"`
|
||||
TransferringDepositoryINN string `json:"transferring_depository_inn"`
|
||||
ReceivingDepositoryINN string `json:"receiving_depository_inn"`
|
||||
CostInfo CostInfo `json:"cost_info"`
|
||||
IIAAgreement *IIAAgreement `json:"iia_agreement,omitempty"`
|
||||
Securities []ClaimSec `json:"securities"`
|
||||
SignedDocument string `json:"signed_document"`
|
||||
SignatureFormat string `json:"signature_format"`
|
||||
}
|
||||
|
||||
// Investor — анкета.
|
||||
type Investor struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
LastName string `json:"last_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Document Document `json:"document"`
|
||||
}
|
||||
|
||||
// Document — удостоверение личности.
|
||||
type Document struct {
|
||||
DocumentType string `json:"document_type"`
|
||||
Series string `json:"series,omitempty"`
|
||||
Number string `json:"number"`
|
||||
}
|
||||
|
||||
// CostInfo — choice yes|no.
|
||||
type CostInfo struct {
|
||||
Yes *CostInfoYes `json:"yes,omitempty"`
|
||||
No *struct{} `json:"no,omitempty"`
|
||||
}
|
||||
|
||||
// CostInfoYes — учёт ведётся, с кодом депонента-источника.
|
||||
type CostInfoYes struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// IIAAgreement — реквизиты договора ИИС.
|
||||
type IIAAgreement struct {
|
||||
AgreementType string `json:"agreement_type"`
|
||||
AgreementNumber string `json:"agreement_number"`
|
||||
AgreementDate string `json:"agreement_date"`
|
||||
BrokerINN string `json:"broker_inn"`
|
||||
}
|
||||
|
||||
// ClaimSec — одна ЦБ в заявке.
|
||||
type ClaimSec struct {
|
||||
SecurityCode string `json:"security_code"`
|
||||
SecurityDetails SecurityDetails `json:"security_details"`
|
||||
Quantity Quantity `json:"quantity"`
|
||||
SettlementAccounts []SettlementAccount `json:"settlement_accounts"`
|
||||
}
|
||||
|
||||
// SecurityDetails — choice isin|security_info.
|
||||
type SecurityDetails struct {
|
||||
ISIN string `json:"isin,omitempty"`
|
||||
SecurityInfo *SecurityInfo `json:"security_info,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityInfo — описание ЦБ без ISIN.
|
||||
type SecurityInfo struct {
|
||||
Classification string `json:"classification"`
|
||||
Category string `json:"category"`
|
||||
SecurityType string `json:"security_type,omitempty"`
|
||||
SecuritySeries string `json:"security_series,omitempty"`
|
||||
IdentificationDetails IdentificationDetails `json:"identification_details"`
|
||||
}
|
||||
|
||||
// IdentificationDetails — choice reg_number|fund_shares.
|
||||
type IdentificationDetails struct {
|
||||
RegNumber string `json:"reg_number,omitempty"`
|
||||
FundShares *FundShares `json:"fund_shares,omitempty"`
|
||||
}
|
||||
|
||||
// FundShares — ПИФ.
|
||||
type FundShares struct {
|
||||
RegNumber string `json:"reg_number"`
|
||||
Class string `json:"class,omitempty"`
|
||||
}
|
||||
|
||||
// Quantity — choice whole|fractional.
|
||||
type Quantity struct {
|
||||
Whole uint64 `json:"whole,omitempty"`
|
||||
Fractional string `json:"fractional,omitempty"`
|
||||
}
|
||||
|
||||
// SettlementAccount — реквизиты счёта.
|
||||
type SettlementAccount struct {
|
||||
SettlementRequisitesINN string `json:"settlement_requisites_inn"`
|
||||
SettlementLocation SettlementLocation `json:"settlement_location"`
|
||||
}
|
||||
|
||||
// SettlementLocation — место хранения.
|
||||
type SettlementLocation struct {
|
||||
DeponentCode string `json:"deponent_code"`
|
||||
AccountID string `json:"account_id"`
|
||||
SectionID string `json:"section_id"`
|
||||
}
|
||||
|
||||
// CreateClaimResponse — DTO ответа POST.
|
||||
type CreateClaimResponse struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// ClaimView — полная заявка с историей (GET и admin).
|
||||
type ClaimView struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Investor Investor `json:"investor"`
|
||||
TransferringDepositoryINN string `json:"transferring_depository_inn"`
|
||||
ReceivingDepositoryINN string `json:"receiving_depository_inn"`
|
||||
CostInfo CostInfo `json:"cost_info"`
|
||||
IIAAgreement *IIAAgreement `json:"iia_agreement,omitempty"`
|
||||
Securities []ClaimSec `json:"securities"`
|
||||
LastCallback *StatusCallback `json:"last_callback,omitempty"`
|
||||
Stages []StageView `json:"stages,omitempty"`
|
||||
M2MGUID m2m.UUID `json:"m2m_guid,omitempty"`
|
||||
M2MResponse *NSDResponseView `json:"m2m_response,omitempty"`
|
||||
M2MDecision *NSDDecisionView `json:"m2m_decision,omitempty"`
|
||||
}
|
||||
|
||||
// StageView — точка истории FSM для UI.
|
||||
type StageView struct {
|
||||
State string `json:"state"`
|
||||
EnteredAt time.Time `json:"entered_at"`
|
||||
LeftAt *time.Time `json:"left_at,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// StatusCallback — callback статуса от lk-gateway к ЛК.
|
||||
type StatusCallback struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
NewStatus string `json:"new_status"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
ReasonText string `json:"reason_text,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
NSDResponse *NSDResponseView `json:"nsd_response,omitempty"`
|
||||
}
|
||||
|
||||
// NSDResponseView — сжатое представление M2MTransferResponse для UI/callback.
|
||||
type NSDResponseView struct {
|
||||
GUID string `json:"guid"`
|
||||
StatusCode string `json:"status_code"`
|
||||
Responses []NSDResponseEntry `json:"responses"`
|
||||
}
|
||||
|
||||
// NSDResponseEntry — одна запись Response.
|
||||
type NSDResponseEntry struct {
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// NSDDecisionView — сжатое представление M2MTransferDecision для UI.
|
||||
type NSDDecisionView struct {
|
||||
GUID string `json:"guid"`
|
||||
Securities []NSDDecisionSecurity `json:"securities"`
|
||||
}
|
||||
|
||||
// NSDDecisionSecurity — решение по одной ЦБ.
|
||||
type NSDDecisionSecurity struct {
|
||||
ReferenceID string `json:"reference_id"`
|
||||
Outcome string `json:"outcome"` // "confirmed" | "rejected"
|
||||
RejectCodes []string `json:"reject_codes,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorResponse — формат ошибки, идентичный ESIA Finance.
|
||||
type ErrorResponse struct {
|
||||
Error bool `json:"error"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Meta *ErrorMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorMeta — детали ошибки.
|
||||
type ErrorMeta struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Errors []FieldErrorDetail `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// FieldErrorDetail — ошибка по конкретному полю.
|
||||
type FieldErrorDetail struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ClaimsPage — постраничная выдача.
|
||||
type ClaimsPage struct {
|
||||
Items []ClaimView `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span></h2>
|
||||
<table>
|
||||
<tr><td style="width:200px" class="muted">Создана</td><td>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
|
||||
<tr><td class="muted">Обновлена</td><td>{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
|
||||
<tr><td class="muted">M2M GUID</td><td><code>{{.Claim.M2MGUID}}</code></td></tr>
|
||||
<tr><td class="muted">Инвестор</td><td>{{.Claim.Investor.LastName}} {{.Claim.Investor.FirstName}} {{.Claim.Investor.MiddleName}}</td></tr>
|
||||
<tr><td class="muted">Документ</td><td>тип {{.Claim.Investor.Document.DocumentType}}, серия {{.Claim.Investor.Document.Series}}, номер {{.Claim.Investor.Document.Number}}</td></tr>
|
||||
<tr><td class="muted">Передающий депозитарий</td><td><code>ИНН {{.Claim.TransferringDepositoryINN}}</code></td></tr>
|
||||
<tr><td class="muted">Принимающий депозитарий</td><td><code>ИНН {{.Claim.ReceivingDepositoryINN}}</code></td></tr>
|
||||
{{if .Claim.IIAAgreement}}
|
||||
<tr><td class="muted">ИИС</td><td>{{.Claim.IIAAgreement.AgreementType}} № {{.Claim.IIAAgreement.AgreementNumber}} от {{.Claim.IIAAgreement.AgreementDate}}, брокер ИНН {{.Claim.IIAAgreement.BrokerINN}}</td></tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Ценные бумаги ({{len .Claim.Securities}})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Код</th><th>ISIN / описание</th><th>Количество</th><th>Счетов депо</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.Securities}}
|
||||
<tr>
|
||||
<td><code>{{.SecurityCode}}</code></td>
|
||||
<td>{{if .SecurityDetails.ISIN}}<code>{{.SecurityDetails.ISIN}}</code>{{else if .SecurityDetails.SecurityInfo}}{{.SecurityDetails.SecurityInfo.Classification}} / {{.SecurityDetails.SecurityInfo.Category}}{{if .SecurityDetails.SecurityInfo.IdentificationDetails.FundShares}} · ПИФ {{.SecurityDetails.SecurityInfo.IdentificationDetails.FundShares.RegNumber}} класс {{.SecurityDetails.SecurityInfo.IdentificationDetails.FundShares.Class}}{{end}}{{end}}</td>
|
||||
<td>{{if .Quantity.Whole}}{{.Quantity.Whole}}{{else}}{{.Quantity.Fractional}}{{end}}</td>
|
||||
<td>{{len .SettlementAccounts}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>История FSM</h2>
|
||||
<table>
|
||||
<thead><tr><th>Состояние</th><th>Вошли</th><th>Вышли</th><th>Причина</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.Stages}}
|
||||
<tr>
|
||||
<td><span class="badge {{.State}}">{{ruState .State}}</span></td>
|
||||
<td>{{.EnteredAt.Format "15:04:05.000"}}</td>
|
||||
<td>{{if .LeftAt}}{{.LeftAt.Format "15:04:05.000"}}{{else}}<span class="muted">сейчас</span>{{end}}</td>
|
||||
<td class="muted">{{.Reason}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .Claim.M2MResponse}}
|
||||
<div class="card">
|
||||
<h2>Ответ НРД (M2MTransferResponse)</h2>
|
||||
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code> · Status <code>{{.Claim.M2MResponse.StatusCode}}</code></p>
|
||||
<table>
|
||||
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.M2MResponse.Responses}}
|
||||
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Claim.M2MDecision}}
|
||||
<div class="card">
|
||||
<h2>Решение принимающей стороны (M2MTransferDecision)</h2>
|
||||
<p class="muted">GUID <code>{{.Claim.M2MDecision.GUID}}</code></p>
|
||||
<table>
|
||||
<thead><tr><th>ReferenceID</th><th>Решение</th><th>Коды отказа</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.M2MDecision.Securities}}
|
||||
<tr>
|
||||
<td><code>{{.ReferenceID}}</code></td>
|
||||
<td><span class="badge {{if eq .Outcome "confirmed"}}confirmed{{else}}rejected{{end}}">{{ruOutcome .Outcome}}</span></td>
|
||||
<td>{{range .RejectCodes}}<code>{{.}}</code> {{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Claim.LastCallback}}
|
||||
<div class="card">
|
||||
<h2>Последний callback в ЛК</h2>
|
||||
<table>
|
||||
<tr><td style="width:160px" class="muted">Статус</td><td><span class="badge {{.Claim.LastCallback.NewStatus}}">{{ruState .Claim.LastCallback.NewStatus}}</span></td></tr>
|
||||
{{if .Claim.LastCallback.ReasonCode}}
|
||||
<tr><td class="muted">Код причины</td><td><code>{{.Claim.LastCallback.ReasonCode}}</code> {{.Claim.LastCallback.ReasonText}}</td></tr>
|
||||
{{end}}
|
||||
<tr><td class="muted">Время</td><td>{{.Claim.LastCallback.UpdatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Журнал заявок ({{len .Items}})</h2>
|
||||
{{if .Items}}
|
||||
<table>
|
||||
<thead><tr><th>Создана</th><th>ID</th><th>GUID M2M</th><th>Инвестор</th><th>ЦБ</th><th>Передающий</th><th>Принимающий</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td><code>{{slice .ID 0 8}}</code></td>
|
||||
<td><code>{{slice (printf "%s" .M2MGUID) 0 8}}</code></td>
|
||||
<td>{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.</td>
|
||||
<td>{{len .Securities}}</td>
|
||||
<td><code>{{.TransferringDepositoryINN}}</code></td>
|
||||
<td><code>{{.ReceivingDepositoryINN}}</code></td>
|
||||
<td><span class="badge {{.Status}}">{{ruState .Status}}</span></td>
|
||||
<td><a href="/admin/claims/{{.ID}}">детали →</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Пусто.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Инструкции и подсказки</h2>
|
||||
<p class="muted">Краткие гайды по основным интеграциям и эксплуатации Bridge-and-Join-s. Открывайте раздел и быстро находите команду или объяснение.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<a href="/admin/help/database" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">База данных →</h2>
|
||||
<p class="muted">PostgreSQL: подключение, схемы <code>fansy</code> / <code>fansy_staging</code> / <code>m2m_core</code>, что подгружается извне (ETL Fansy) и что пишет сама система. Учётные записи, гранты, миграции.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/lk-api" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">API личного кабинета →</h2>
|
||||
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/cryptopro" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">КриптоПро и Рутокен →</h2>
|
||||
<p class="muted">Установка КриптоПро CSP на РЕД ОС / Ubuntu, ввод серийного номера, PKCS#11 модуль, серверная подпись и подпись оператора через Рутокен ЭЦП 2.0, тестирование.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/systems" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Внешние системы →</h2>
|
||||
<p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/robot" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Тестирование с роботом MOEX МОСТ →</h2>
|
||||
<p class="muted">Робот НРД на TEST3 (код <code>MC0012500000</code>), 4 тестовых сценария (отказ / принять все / частично / встречный перевод), управление через DocumentSeries и DocumentNumber, тестовые наборы депозитариев и кодов ошибок.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/architecture" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Архитектура обмена с НРД →</h2>
|
||||
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какие СКЗИ, какие сертификаты, FAQ. Куда воткнуть Валидату, куда КриптоПро, где сертификаты УЦ МБ.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,139 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Архитектура: как устроен обмен с НРД</h2>
|
||||
<p class="muted">Документ-источник: <code>DOC/ruk_install_ish_2025_11_10.pdf</code> (Руководство по установке ИШ от 10.11.2025), <code>DOC/instr-ish-rest-api.pdf</code> (REST API ИШ).</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Схема обмена (полная)</h2>
|
||||
<pre style="font-size:12px;line-height:1.4;background:var(--bg);padding:16px;border-radius:6px;overflow:auto">
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ НАША СТОРОНА │
|
||||
│ │
|
||||
│ Linux ВМ (РЕД ОС 8) Astra Linux ВМ │
|
||||
│ ────────────────── ────────────────── │
|
||||
│ ┌──────────────────┐ REST API ┌──────────────────┐ │
|
||||
│ │ bj-server │ ────POST/GET──> │ ИШ (igate) │ │
|
||||
│ │ (наше ПО) │ <───────────── │ (получаем у НРД)│ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • стейт-машина │ │ Делает САМ: │ │
|
||||
│ │ • PostgreSQL │ │ • подпись │ │
|
||||
│ │ • админка :8080 │ │ • упаковку ЭДО │ │
|
||||
│ │ • lk-emulator │ │ • проверку │ │
|
||||
│ │ │ │ подписей НРД │ │
|
||||
│ └──────────────────┘ │ • БД PostgreSQL │ │
|
||||
│ │ (history) │ │
|
||||
│ └──────┬───────────┘ │
|
||||
│ │ │
|
||||
│ КриптоПро CSP — для нашей │ Валидата CSP │
|
||||
│ admin-стороны (PKCS#11) │ + АПК Валидата │
|
||||
│ │ Клиент L │
|
||||
└──────────────────────────────────────────────┼─────────────────┘
|
||||
│
|
||||
SOAP/REST/HTTPS │ Web-сервис ONYX
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ СТОРОНА НРД │
|
||||
│ │
|
||||
│ GUEST: gost-gt.nsd.ru │
|
||||
│ TEST3: gost.nsd.ru │
|
||||
│ PROM: edog.nsd.ru │
|
||||
│ │
|
||||
│ /onyxgs/WslService │
|
||||
│ /onyxt3/WslService │
|
||||
│ /onyxpr/WslService │
|
||||
│ │
|
||||
│ ↓ внутрь НРД │
|
||||
│ • робот-автотест │
|
||||
│ MC0012500000 │
|
||||
│ • реальные депозитарии │
|
||||
│ • биржевые системы │
|
||||
└────────────────────────────┘
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Компоненты — кто на чьей стороне</h2>
|
||||
<table>
|
||||
<thead><tr><th>Компонент</th><th>Сторона</th><th>ОС</th><th>СКЗИ</th><th>Назначение</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>bj-server</strong></td>
|
||||
<td>наша</td>
|
||||
<td>РЕД ОС 8 / Linux</td>
|
||||
<td>КриптоПро CSP (PKCS#11) — для админ-части</td>
|
||||
<td>Стейт-машина, журнал в БД, веб-админка, lk-emulator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>ИШ (igate)</strong></td>
|
||||
<td>наша <span class="muted">(но дистрибутив даёт НРД)</span></td>
|
||||
<td>Astra Linux SE 1.6/1.7 <em>или</em> Windows 10/Server</td>
|
||||
<td>Валидата CSP + АПК Валидата Клиент L</td>
|
||||
<td>Подписывает наш XML сертификатом УЦ МБ, упаковывает в пакет ЭДО, отправляет в НРД</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>ONYX (WSL)</strong></td>
|
||||
<td>НРД</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>Web-сервис НРД — принимает пакеты от ИШ всех клиентов</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Робот-автотест</strong></td>
|
||||
<td>НРД</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>Контрагент-эмулятор внутри НРД. Адресуется кодом <code>MC0012500000</code> в TEST3</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Часто задаваемые вопросы</h2>
|
||||
|
||||
<h3>Q: ИШ — это сервер НРД, к которому мы подключаемся?</h3>
|
||||
<p>Нет. <strong>ИШ — это наша программа, поставленная у нас.</strong> НРД даёт дистрибутив (<code>igate_100.0-765_amd64.deb</code>, 117 МБ), но ставим у себя. ИШ — это «персональный почтовый клиент к НРД» с подписью.</p>
|
||||
|
||||
<h3>Q: ИШ можно поставить на ту же ВМ, что и bj-server?</h3>
|
||||
<p>Технически да (если та ВМ — Astra Linux). Но у нас bj-server на РЕД ОС, а ИШ требует <strong>Astra Linux</strong> (RPM-версии нет). Поэтому нужно либо: (а) отдельная Astra Linux ВМ, (б) запуск ИШ в Docker-контейнере с Astra-образом, (в) перевод всей инфры на Astra Linux.</p>
|
||||
|
||||
<h3>Q: Мы перекладываем файлы между bj-server и ИШ?</h3>
|
||||
<p>Нет. Мы используем <strong>REST API</strong> ИШ (раздел 2.5 инструкции). bj-server делает HTTP-запросы: <code>POST /api/package/{channel}/file</code> с ZIP в теле. Никаких разделяемых папок. (Альтернативный режим «обменные папки» в ИШ есть — мы его не используем.)</p>
|
||||
|
||||
<h3>Q: Почему ИШ требует Валидата CSP, а мы поставили КриптоПро?</h3>
|
||||
<p>ИШ — отечественная разработка НРД, исторически работает с Валидатой (продукт ООО «Валидата», <code>x509.ru</code>). КриптоПро CSP на нашей ВМ останется — он используется для админ-части bj-server (подпись действий оператора через Рутокен). Валидату надо поставить <strong>на Astra Linux ВМ рядом с ИШ</strong>, не вместо КриптоПро.</p>
|
||||
|
||||
<h3>Q: Где брать Валидату?</h3>
|
||||
<p>Не публично. По запросу: email <code>soed@nsd.ru</code> (НРД) или <code>pki@moex.com</code> (МБ). Временная лицензия выдаётся бесплатно для подключения к ЭДО НРД.</p>
|
||||
|
||||
<h3>Q: Какой сертификат нужен?</h3>
|
||||
<p>Только от <strong>УЦ Московской Биржи</strong> (<code>ca.moex.com</code>). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).</p>
|
||||
|
||||
<h3>Q: Что делать, чтобы протестировать на роботе НРД на TEST3?</h3>
|
||||
<ol>
|
||||
<li>Получить сертификат УЦ МБ для нашей организации.</li>
|
||||
<li>Подать <a href="https://www.nsd.ru/workflow/zayavka-na-testirovanie/" target="_blank">заявку на тестирование</a> в НРД (инструкция в <code>DOC/instr_int_sh_01072025.pdf</code>).</li>
|
||||
<li>Получить от НРД код депонента-тестера и доступ к TEST3.</li>
|
||||
<li>Поднять Astra Linux ВМ, поставить ИШ + Валидату, импортировать сертификат.</li>
|
||||
<li>В нашем <a href="/admin/setup">/admin/setup</a> → «Интеграционный шлюз НРД» указать URL ИШ (например <code>http://10.10.10.23:8080</code>) и имя канала из ИШ.</li>
|
||||
<li>Отправить тестовую заявку с <code>ReceiverCode = MC0012500000</code> и <code>DocumentSeries = 2001</code> — робот ответит «Принять все бумаги».</li>
|
||||
</ol>
|
||||
|
||||
<h3>Q: Сколько времени нужно от старта подключения до прогона на TEST3?</h3>
|
||||
<p>Оптимистично — <strong>1-2 недели</strong> (если все ответы НРД быстрые и УЦ МБ не задерживает). Реалистично — <strong>3-4 недели</strong>. На нашей стороне всё уже готово; задержка только во внешних шагах.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Что у нас уже готово</h2>
|
||||
<ul>
|
||||
<li>✅ <strong>REST-клиент ИШ</strong> в <code>internal/nsdadapter/igw/</code> — все 4 endpoint'а по спецификации, упаковщик/распаковщик ZIP, 10 тестов PASS</li>
|
||||
<li>✅ <strong>Робот-эмулятор</strong> в <code>internal/nsdadapter/mock/</code> — позволяет проверить нашу логику до получения реального ИШ</li>
|
||||
<li>✅ <strong>Конфигурация в админке</strong> — поля <code>igw_base_url</code> и <code>channel</code> в /admin/setup; авто-определение профилей GUEST/TEST3/PROD</li>
|
||||
<li>✅ <strong>Подбор URL контуров</strong> — при выборе профиля URL ONYX заполняется автоматически</li>
|
||||
<li>✅ <strong>Полная документация ИШ</strong> в <code>DOC/</code> и дистрибутив в <code>dist/ish/</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,130 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>КриптоПро и Рутокен</h2>
|
||||
<p class="muted">Bridge-and-Join-s использует ГОСТ Р 34.10-2012 для подписи и проверки XMLDSig. Серверная криптография — КриптоПро CSP. Подпись оператора в admin-ui — Рутокен ЭЦП 2.0 (опционально). Оба продукта говорят со стандартным интерфейсом PKCS#11, поэтому Go-клиент общается с ними одинаково.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Что и зачем нужно</h2>
|
||||
<table>
|
||||
<thead><tr><th>Сценарий</th><th>СКЗИ</th><th>Цена (ориентир)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Проверка XMLDSig входящих от НРД и брокеров</td><td>КриптоПро CSP «Сервер»</td><td>~30-50к ₽ (один раз)</td></tr>
|
||||
<tr><td>Подпись пакетов в НРД (резервный канал WS ONYX)</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
|
||||
<tr><td>Подпись действий оператора в admin-ui</td><td>Рутокен ЭЦП 2.0 + лицензия CSP «Рабочее место»</td><td>~3-5к ₽ железо + ~2-3к ₽ лицензия</td></tr>
|
||||
<tr><td>Проверка XMLDSig заявлений от ЛК</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Если используется Интеграционный шлюз НРД (ИШ), он сам подписывает пакеты — наша серверная подпись нужна только для резервного канала ONYX и подписи действий оператора. Можно начать с минимума: только Рутокен оператора и отложить серверную лицензию.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Установка КриптоПро CSP на РЕД ОС (проверено)</h2>
|
||||
<p><strong>Способ 1 — через веб-интерфейс (рекомендуется):</strong> <a href="/admin/setup">/admin/setup</a> → «СКЗИ» → «Установка КриптоПро CSP» → выбрать <code>linux-amd64.tar</code> с cryptopro.ru → «Загрузить и установить».</p>
|
||||
<p><strong>Способ 2 — вручную из терминала.</strong> Скачать <code>linux-amd64.tgz</code> с <code>www.cryptopro.ru/products/csp/downloads</code> (доступ через личный кабинет), распаковать на ВМ и установить минимальный набор:</p>
|
||||
<pre>tar -xzf linux-amd64.tgz
|
||||
cd linux-amd64
|
||||
sudo rpm -Uvh --replacepkgs --nodeps \
|
||||
lsb-cprocsp-base-5.0.*.noarch.rpm \
|
||||
lsb-cprocsp-ca-certs-5.0.*.noarch.rpm \
|
||||
lsb-cprocsp-rdr-64-5.0.*.x86_64.rpm \
|
||||
lsb-cprocsp-capilite-64-5.0.*.x86_64.rpm \
|
||||
lsb-cprocsp-kc1-64-5.0.*.x86_64.rpm \
|
||||
lsb-cprocsp-pkcs11-64-5.0.*.x86_64.rpm \
|
||||
cprocsp-curl-64-5.0.*.x86_64.rpm \
|
||||
cprocsp-rdr-gui-gtk-64-5.0.*.x86_64.rpm</pre>
|
||||
<p>Ключевые пакеты:</p>
|
||||
<ul>
|
||||
<li><code>lsb-cprocsp-base</code> + <code>lsb-cprocsp-rdr-64</code> — базовая инфраструктура</li>
|
||||
<li><code>lsb-cprocsp-capilite-64</code> — CAPILite (<code>libcapi20.so.4</code>, <code>libcpext.so.4</code>) — иначе libcppkcs11.so не загрузится</li>
|
||||
<li><code>lsb-cprocsp-kc1-64</code> — CSP класса КС1 (без него Initialize упадёт с CKR_FUNCTION_FAILED)</li>
|
||||
<li><code>lsb-cprocsp-pkcs11-64</code> — собственно <code>libcppkcs11.so</code></li>
|
||||
</ul>
|
||||
<p>Демо-лицензия на 3 месяца встроена в дистрибутив, отдельная активация не требуется. Проверка:</p>
|
||||
<pre>/opt/cprocsp/sbin/amd64/cpconfig -license -view
|
||||
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
|
||||
<p><strong>Важно — LD_LIBRARY_PATH.</strong> КриптоПро CSP кладёт .so в <code>/opt/cprocsp/lib/amd64</code> без записи в <code>/etc/ld.so.conf.d</code>. Bj-server при запуске должен иметь:</p>
|
||||
<pre>Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64</pre>
|
||||
<p>В systemd-юните это уже прописано (<code>deploy/systemd/bj-server.service</code>). При ручном запуске из shell — <code>LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server</code>.</p>
|
||||
<p><strong>Активация коммерческой лицензии.</strong> После того как демо истечёт, серийник вводится через UI на <a href="/admin/setup">/admin/setup</a> → «Активация лицензии», или вручную:</p>
|
||||
<pre>sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Установка на Ubuntu / Debian</h2>
|
||||
<pre>sudo dpkg -i cprocsp-rdr-gui-gtk-64_5.0.*_amd64.deb \
|
||||
cprocsp-rdr-64_5.0.*_amd64.deb \
|
||||
lsb-cprocsp-base_5.0.*_all.deb \
|
||||
lsb-cprocsp-rdr-64_5.0.*_amd64.deb
|
||||
sudo apt-get install -f
|
||||
sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>4. PKCS#11 модуль</h2>
|
||||
<p>Путь к библиотеке после установки:</p>
|
||||
<pre>/opt/cprocsp/lib/amd64/libcppkcs11.so</pre>
|
||||
<p>Эта же библиотека работает и с CSP-ключами (контейнеры на диске или в реестре), и с Рутокен ЭЦП 2.0 (подключённым по USB или в виде smart-card reader).</p>
|
||||
<p>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» укажите:</p>
|
||||
<ul>
|
||||
<li><strong>Провайдер</strong>: <code>cryptopro</code></li>
|
||||
<li><strong>UDS-сокет</strong>: <code>/run/bj/crypto.sock</code> (для legacy crypto-service на Java — на M2+ переходим на Go-клиент напрямую через PKCS#11)</li>
|
||||
<li><strong>Путь к jcp.jar / PKCS#11</strong>: <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code></li>
|
||||
<li><strong>Лицензионный ключ</strong>: серийный номер CSP</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>5. Подключение Рутокен ЭЦП 2.0</h2>
|
||||
<p>Подключите Рутокен в USB. Драйверы КриптоПро CSP уже включают поддержку Рутокен:</p>
|
||||
<pre># увидеть подключённые токены
|
||||
/opt/cprocsp/bin/amd64/csptest -card -enum
|
||||
|
||||
# увидеть ключевые контейнеры на токене
|
||||
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
|
||||
<p>Для подписи действий оператора в admin-ui:</p>
|
||||
<ol>
|
||||
<li>Запросить сертификат на физлицо у УЦ (через личный кабинет КриптоПро или через АРМ оператора УЦ).</li>
|
||||
<li>Записать сертификат и контейнер на Рутокен.</li>
|
||||
<li>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» выбрать провайдер <code>cryptopro</code> и указать слот Рутокен.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>6. Импорт сертификата</h2>
|
||||
<pre># сертификат корневого УЦ (если ещё нет в системе)
|
||||
/opt/cprocsp/bin/amd64/certmgr -inst -store mroot -file /path/to/root-ca.cer
|
||||
|
||||
# сертификат подписанта (контейнер на токене)
|
||||
/opt/cprocsp/bin/amd64/certmgr -inst -store uMy -cont '\\.\HDIMAGE\my-keys' \
|
||||
-file /path/to/operator.cer
|
||||
|
||||
# проверить установленные сертификаты
|
||||
/opt/cprocsp/bin/amd64/certmgr -list -store uMy</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>7. Тестирование подписи</h2>
|
||||
<p>Через CLI КриптоПро (быстрая проверка что криптография работает):</p>
|
||||
<pre># подписать произвольный файл
|
||||
/opt/cprocsp/bin/amd64/cryptcp -signf -dn 'CN=Иванов И.И.' \
|
||||
-det -strict /tmp/test.txt
|
||||
|
||||
# проверить подпись
|
||||
/opt/cprocsp/bin/amd64/cryptcp -vsignf -det /tmp/test.txt /tmp/test.txt.sgn</pre>
|
||||
<p>Через нашу систему — раздел <a href="/admin/setup">Настройка</a> → кнопка «Запустить тестовую заявку». На странице «Заявка» появится результат и расшифровка проверки подписи.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>8. Поддержка</h2>
|
||||
<ul>
|
||||
<li>Документация КриптоПро: <code>www.cryptopro.ru/products/csp</code></li>
|
||||
<li>Установка на РЕД ОС: <code>www.cryptopro.ru/forum2/default.aspx?g=topics&f=43</code></li>
|
||||
<li>Технические вопросы: <code>support@cryptopro.ru</code></li>
|
||||
<li>Рутокен: <code>dev.rutoken.ru/display/PUB/Rutoken+EDS</code></li>
|
||||
</ul>
|
||||
<p class="muted">При проблемах с лицензией сначала проверьте <code>cpconfig -license -view</code> — лицензия должна быть валидна и не просрочена. Срок действия КриптоПро лицензии — обычно 1 год.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,126 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>База данных</h2>
|
||||
<p class="muted">PostgreSQL 16 (или PostgreSQL Pro Certified). Хранит сделки, журнал событий, справочники и данные, поступающие из внешних систем через ETL.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Подключение</h2>
|
||||
<p>DSN указывается в разделе <a href="/admin/setup">Настройка → PostgreSQL</a>. Формат:</p>
|
||||
<pre>postgres://USER:PASSWORD@HOST:PORT/DBNAME?sslmode=disable</pre>
|
||||
<p>Локальный дев-стенд:</p>
|
||||
<pre>postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable</pre>
|
||||
<p class="muted">При сохранении выполняется <code>pgx.Connect</code> и <code>Ping</code>. Если БД недоступна, форма покажет ошибку.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Схемы</h2>
|
||||
<table>
|
||||
<thead><tr><th>Схема</th><th>Назначение</th><th>Кто пишет</th><th>Кто читает</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>fansy_staging</code></td>
|
||||
<td>Промежуточные таблицы для ETL Fansy</td>
|
||||
<td>Команда Fansy (роль <code>fansy_etl</code>)</td>
|
||||
<td>Наша процедура перелива в <code>fansy</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>fansy</code></td>
|
||||
<td>Рабочие данные: клиенты, документы, ИИС, депо-счета, портфели, справочники ЦБ и участников</td>
|
||||
<td>Наша процедура перелива (после валидации)</td>
|
||||
<td>Наша система (роль <code>bj_reader</code>): m2m-core при формировании заявок</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>m2m_core</code></td>
|
||||
<td>Сделки M2M, журнал событий FSM</td>
|
||||
<td>bj-server</td>
|
||||
<td>bj-server, admin-ui</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Что подгружается из внешних систем</h2>
|
||||
<p>Команда Fansy через инкрементный <strong>UPSERT в staging-таблицы</strong>:</p>
|
||||
<table>
|
||||
<thead><tr><th>Таблица staging</th><th>SLA свежести</th><th>Что в ней</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>clients</code></td><td>≤ 5 минут</td><td>Анкеты инвесторов (ФИО, дата рождения, ИНН)</td></tr>
|
||||
<tr><td><code>client_documents</code></td><td>≤ 5 минут</td><td>Документы (паспорт, ИНН и др.)</td></tr>
|
||||
<tr><td><code>iia_contracts</code></td><td>≤ 5 минут</td><td>Договоры ИИС (тип T12/T03, номер, дата, ИНН брокера)</td></tr>
|
||||
<tr><td><code>depo_accounts</code></td><td>≤ 5 минут</td><td>Депо-счета и разделы у разных депозитариев</td></tr>
|
||||
<tr><td><code>portfolios</code></td><td>≤ 1 минута</td><td>Остатки ЦБ (целое/дробное количество, статус обособления)</td></tr>
|
||||
<tr><td><code>securities</code> / <code>participants</code></td><td>раз в сутки</td><td>Справочники ЦБ и участников MOST</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:8px">Полный data-dictionary: <code>docs/fansy-contract/v1/data-dictionary.md</code>. Требования к ETL: <code>docs/fansy-contract/v1/etl-requirements.md</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>4. Что пишет сама система</h2>
|
||||
<p>Только в схему <code>m2m_core</code>:</p>
|
||||
<ul>
|
||||
<li><code>deals</code> — корневая запись сделки. Уникальность по <code>guid</code> = идемпотентность приёма заявок.</li>
|
||||
<li><code>deal_events</code> — журнал FSM-событий (event sourcing).</li>
|
||||
</ul>
|
||||
<p>Старт сделки происходит при <code>POST /api/v1/back_office/claims/</code>. FSM проходит этапы:</p>
|
||||
<pre>draft → validated → submitted_to_nsd → awaiting_decision →
|
||||
confirmed → awaiting_sub16 → done
|
||||
↘ rejected
|
||||
↘ timed_out
|
||||
↘ manual_approval</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>5. Учётные записи и роли</h2>
|
||||
<table>
|
||||
<thead><tr><th>Роль</th><th>Права</th><th>Создаёт</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>fansy_etl</code></td><td>SELECT/INSERT/UPDATE на <code>fansy_staging.*</code></td><td>миграция <code>000__roles.sql</code></td></tr>
|
||||
<tr><td><code>bj_reader</code></td><td>SELECT на <code>fansy.*</code></td><td>миграция <code>000__roles.sql</code></td></tr>
|
||||
<tr><td><code>bj_migrator</code></td><td>Владелец схем, DDL-права</td><td>миграция <code>000__roles.sql</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Пароли проставляются администратором БД через <code>ALTER ROLE</code>, в репозиторий не попадают.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>6. Накатывание миграций</h2>
|
||||
<p>Файлы лежат в <code>migrations/fansy-store/</code> и <code>migrations/m2m-core/</code>. Применить локально:</p>
|
||||
<pre>cd /home/fontvielle/Bridge-and-Join-s
|
||||
for f in migrations/fansy-store/*.sql migrations/m2m-core/*.sql; do
|
||||
podman exec -i bj-postgres psql -U bj -d bj -v ON_ERROR_STOP=1 < "$f"
|
||||
done</pre>
|
||||
<p>Порядок:</p>
|
||||
<ol>
|
||||
<li><code>fansy-store/000__roles.sql</code> — роли</li>
|
||||
<li><code>fansy-store/001__schemas.sql</code> — схемы и гранты</li>
|
||||
<li><code>fansy-store/002__working.sql</code> — рабочая схема</li>
|
||||
<li><code>fansy-store/003__staging.sql</code> — staging</li>
|
||||
<li><code>fansy-store/004__seed_participants.sql</code> — справочник участников (НРД, БКС, Ренессанс, Альфа-Банк)</li>
|
||||
<li><code>m2m-core/001__deals.sql</code> — сделки M2M</li>
|
||||
<li><code>m2m-core/002__stages.sql</code> — jsonb-колонка истории FSM</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>7. Полезные запросы</h2>
|
||||
<pre>-- Состояние сделок за последний час
|
||||
SELECT state, count(*) FROM m2m_core.deals
|
||||
WHERE created_at > now() - interval '1 hour'
|
||||
GROUP BY state;
|
||||
|
||||
-- Журнал событий по сделке
|
||||
SELECT created_at, type, actor, payload
|
||||
FROM m2m_core.deal_events
|
||||
WHERE deal_id = '...'
|
||||
ORDER BY created_at;
|
||||
|
||||
-- Свежесть данных Fansy
|
||||
SELECT 'portfolios' AS t, max(loaded_at) FROM fansy_staging.portfolios
|
||||
UNION ALL SELECT 'clients', max(loaded_at) FROM fansy_staging.clients;</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,93 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>API личного кабинета (ESIA Finance V1)</h2>
|
||||
<p class="muted">Bridge-and-Join-s реализует контракт ESIA Finance V1 на стороне back-office. ЛК клиента отправляет нам заявку, мы возвращаем подтверждение и потом колбэк со статусом. Полная спецификация: <code>docs/lk-contract/v1/openapi.yaml</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Аутентификация</h2>
|
||||
<p>HTTP <strong>Basic</strong>. Учётные записи и пароли согласуются с командой ЛК.</p>
|
||||
<pre>curl -u "lk-user:lk-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://10.10.10.22:8080/api/v1/back_office/claims/</pre>
|
||||
<p class="muted">На M2 (дев-стенд) аутентификация отключена; включится на M3 — параллельно с подключением реального ЛК.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Создание заявки M2M</h2>
|
||||
<p><code>POST /api/v1/back_office/claims/</code></p>
|
||||
<p>Тело — JSON, кодировка UTF-8. Минимально нужно: анкета инвестора, ИНН передающего и принимающего депозитария, информация о стоимости, массив ценных бумаг с количеством и счетами, подписанный XML заявления (base64).</p>
|
||||
<pre>curl -X POST http://10.10.10.22:8080/api/v1/back_office/claims/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @docs/lk-contract/v1/examples/claim-request.json</pre>
|
||||
<p>Ответ <code>201 Created</code>:</p>
|
||||
<pre>{
|
||||
"id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status": "submitted_to_nsd",
|
||||
"created_at": "2026-03-02T14:30:45Z",
|
||||
"success": true
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Получение заявки и её статуса</h2>
|
||||
<p><code>GET /api/v1/back_office/claims/{id}</code></p>
|
||||
<pre>curl http://10.10.10.22:8080/api/v1/back_office/claims/c02a1d5e-c2af-4799-bab4-953f133c5133</pre>
|
||||
<p>Возвращает полную карточку с историей FSM, ответом НРД и решением принимающей стороны (когда оно пришло).</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>4. Список заявок</h2>
|
||||
<p><code>GET /api/v1/back_office/claims</code> с query-фильтрами: <code>status</code>, <code>investor_id</code>, <code>created_from</code>, <code>created_to</code>, <code>limit</code>, <code>offset</code>.</p>
|
||||
<pre>curl "http://10.10.10.22:8080/api/v1/back_office/claims?status=confirmed&limit=20"</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>5. Callback статуса (мы → ЛК)</h2>
|
||||
<p>Когда сделка меняет статус (подтверждена принимающей стороной, отклонена, или произошёл таймаут), bj-server делает <code>PATCH {LK_BASE}/api/v1/back_office/claims/{id}</code>:</p>
|
||||
<pre>{
|
||||
"claim_id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"new_status": "confirmed",
|
||||
"updated_at": "2026-03-02T14:38:12Z",
|
||||
"nsd_response": {
|
||||
"guid": "...",
|
||||
"status_code": "INFO",
|
||||
"responses": [{"reference_id": "M2M2026...", "code": "01", "text": "..."}]
|
||||
}
|
||||
}</pre>
|
||||
<p>Адрес ЛК указывается в <a href="/admin/setup">Настройка → Callback в ЛК</a> или через переменную <code>BJ_LK_CALLBACK_URL</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>6. Формат ошибок</h2>
|
||||
<p>Любая ошибка возвращается в формате, идентичном ESIA Finance V1:</p>
|
||||
<pre>{
|
||||
"error": true,
|
||||
"status": 422,
|
||||
"code": "invalid_signature",
|
||||
"title": "Подпись заявления не прошла проверку",
|
||||
"meta": {
|
||||
"message": "Сертификат подписанта недействителен или цепочка доверия не построена.",
|
||||
"errors": [{"field": "signed_document", "message": "..."}]
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>7. Эмулятор ЛК для тестов</h2>
|
||||
<p>На дев-стенде доступен <strong>lk-emulator</strong> на порту 8083 — имитация ЛК. Он сам регистрирует свой URL в bj-server как callback-приёмник.</p>
|
||||
<ul>
|
||||
<li><code>http://10.10.10.22:8083/</code> — журнал моих заявок (автообновление 3 сек)</li>
|
||||
<li><code>http://10.10.10.22:8083/new</code> — форма «подать заявку» с предустановленными инвесторами из seed-данных</li>
|
||||
<li>При запуске реального ЛК эмулятор остаётся как QA-инструмент</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>8. Подписание заявления</h2>
|
||||
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/cryptopro">инструкцию по КриптоПро</a>.</p>
|
||||
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,102 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Тестирование с роботом MOEX МОСТ</h2>
|
||||
<p class="muted">Источник: <code>DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf</code> (опубликована 12.05.2026). Демо-ролик: <a href="https://disk.yandex.ru/i/F1SL2CVY5GphwQ" target="_blank">disk.yandex.ru/i/F1SL2CVY5GphwQ</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Что это</h2>
|
||||
<p>НРД разработан специальный «робот» для тестирования интеграции информационных систем клиента и сервиса переводов M2M. Робот работает <strong>в круглосуточном режиме</strong> и эмулирует действия второй стороны при обмене сообщениями в сервисе M2M.</p>
|
||||
<p>Робот может выступать как принимающей стороной (по умолчанию), так и передающей. Он может формировать как успешные сообщения, так и сообщения о нештатных ситуациях.</p>
|
||||
<p>Доступен на тестовом контуре <strong>TEST3</strong> (<code>gost-t3.nsd.ru</code>). Подключение к роботу не требует отдельной регистрации — достаточно быть подключённым к ЭДО НРД на TEST3.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Адресация робота</h2>
|
||||
<p><strong>КОД РОБОТА: <code>MC0012500000</code></strong></p>
|
||||
<p>Чтобы робот получил сообщение, его код должен быть указан в получателях — <code>Header.ReceiverCode</code>.</p>
|
||||
<p class="muted">В <code>bj-server</code> mock-сендер (<code>internal/nsdadapter/mock</code>) уже понимает этот код: если <code>ReceiverCode == MC0012500000</code> и в заявке указан DocumentSeries из таблицы ниже — внутренний робот-эмулятор сформирует ответ по выбранному сценарию. То же поведение будет на реальном TEST3, когда подключим ИШ.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Тестовые сценарии</h2>
|
||||
<p>Выбор сценария — через поле <code>Data.InvestorInformation.IdentityDocument.DocumentSeries</code> в M2MTransferRequest.</p>
|
||||
<table>
|
||||
<thead><tr><th>Код</th><th>Сценарий</th><th>Управляющий параметр</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>1111</code></td>
|
||||
<td><strong>Ответ с отказом</strong> — все бумаги отвергаются с выбранным кодом ошибки</td>
|
||||
<td>Последние 2 символа <code>DocumentNumber</code> = ключ ошибки (<code>01</code>..<code>09</code>) → код <code>M2M01</code>..<code>M2M09</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>2001</code></td>
|
||||
<td><strong>Принять все бумаги</strong></td>
|
||||
<td><code>DocumentNumber</code>: i-я цифра = номер депозитария-получателя для i-й секции (<code>1</code> или <code>2</code>). По умолчанию <code>1</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>2002</code></td>
|
||||
<td><strong>Принять бумаги частично</strong></td>
|
||||
<td><code>DocumentNumber</code>: i-я цифра = номер депозитария (<code>1</code>/<code>2</code>) или <code>0</code> (отклонить с <code>M2M05</code>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>3333</code></td>
|
||||
<td><strong>Выступить принимающей стороной</strong> — робот отвергает оригинал и формирует встречный M2MTransferRequest</td>
|
||||
<td>Первые 2 цифры <code>DocumentNumber</code> = реквизиты двух депозитариев для нового перевода</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:8px">Пример: для сценария <code>1111</code> с <code>DocumentNumber=111102</code> робот вернёт код ошибки <code>M2M02</code>. Для сценария <code>2001</code> с 4 секциями ЦБ и <code>DocumentNumber=111200</code> — секции 1,2,3 принимаются депозитарием 1, секция 4 — депозитарием 2.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>4. Тестовые данные депозитариев</h2>
|
||||
<table>
|
||||
<thead><tr><th>Ключ</th><th>ИНН (SettlementRequisites)</th><th>SettlementDepositoryLocation</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>1</code></td>
|
||||
<td><code>7702165310</code></td>
|
||||
<td>ИНН <code>7722061076</code> · depcode <code>MC0012500000</code> · счёт <code>HL2603250011</code> · раздел <code>31MC0012500000F00</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>2</code></td>
|
||||
<td><code>7702165310</code></td>
|
||||
<td>ИНН <code>7722061076</code> · depcode <code>MC0012500000</code> · счёт <code>HL2603250011</code> · раздел <code>36MC0012500000F00</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>3</code></td>
|
||||
<td><code>7831000034</code></td>
|
||||
<td class="muted">остальные поля — заглушки</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>5. Коды ошибок (для сценария 1111)</h2>
|
||||
<table>
|
||||
<thead><tr><th>Ключ</th><th>Код ошибки</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>01</code></td><td><code>M2M01</code></td></tr>
|
||||
<tr><td><code>02</code></td><td><code>M2M02</code></td></tr>
|
||||
<tr><td><code>03</code></td><td><code>M2M03</code></td></tr>
|
||||
<tr><td><code>04</code></td><td><code>M2M04</code></td></tr>
|
||||
<tr><td><code>05</code></td><td><code>M2M05</code></td></tr>
|
||||
<tr><td><code>06</code></td><td><code>M2M06</code></td></tr>
|
||||
<tr><td><code>07</code></td><td><code>M2M07</code></td></tr>
|
||||
<tr><td><code>08</code></td><td><code>M2M08</code></td></tr>
|
||||
<tr><td><code>09</code></td><td><code>M2M09</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>6. Как запустить</h2>
|
||||
<p><strong>Сейчас, без реального ИШ:</strong> используется внутренний робот-эмулятор в bj-server. Отправь заявку с ReceiverCode = <code>MC0012500000</code> и DocumentSeries по таблице — Decision придёт через 3 секунды по правилам робота.</p>
|
||||
<p><strong>На реальном TEST3 НРД:</strong> установи ИШ НРД (см. <a href="/admin/help/systems">/admin/help/systems</a>), укажи в <a href="/admin/setup">/admin/setup</a> → ИШ профиль <code>test3-gost</code>, URL <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code>. Дальше отправляй те же заявки — НРД направит их роботу, ответ будет идентичный.</p>
|
||||
<p class="muted">Сценарий 3333 («выступить принимающей стороной») в нашем внутреннем эмуляторе пока реализован частично — отдаёт только первое сообщение (отказ M2M05). Встречный M2MTransferRequest от робота требует доработки приёмной стороны bj-server.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,113 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Внешние системы</h2>
|
||||
<p class="muted">Как Bridge-and-Join-s взаимодействует с тремя главными внешними сторонами: НРД (через ИШ), команда Fansy (ETL в БД), уведомления операторам.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Интеграционный шлюз НРД (ИШ) и контуры тестирования</h2>
|
||||
<p>Основной канал отправки M2M-сообщений в НРД — Web-сервис <strong>ONYX</strong> через ИШ. ИШ сам подписывает пакеты ЭДО, поэтому в этом канале нам криптография не требуется. Дистрибутив ИШ скачивается с сайта НРД: <code>www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4</code></p>
|
||||
<p>Адреса контуров (из <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>):</p>
|
||||
<table>
|
||||
<thead><tr><th>Сервис</th><th>GUEST · ГОСТ</th><th>GUEST · RSA</th><th>TEST3 · ГОСТ</th><th>TEST3 · RSA</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>WEB-сервис ONYX</strong> (нужен нам)</td>
|
||||
<td><code>gost-gt.nsd.ru</code></td>
|
||||
<td><code>rsa-gt.nsd.ru</code></td>
|
||||
<td><code>gost-t3.nsd.ru</code></td>
|
||||
<td><code>rsa-t3.nsd.ru</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Единый кабинет администратора НРДирект</td>
|
||||
<td colspan="2"><code>cabinet-gt.nsd.ru/wr-admin/</code></td>
|
||||
<td colspan="2"><code>cabinet-t3.nsd.ru/wr-admin/</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>WEB-сервис Agate (WSAlameda)</td>
|
||||
<td colspan="2"><code>gost-gt.nsd.ru/WSAlamedags/</code> · <code>rsa-gt.nsd.ru/WSAlamedags/</code></td>
|
||||
<td colspan="2"><code>gost-t3.nsd.ru/WSAlameda/</code> · <code>rsa-t3.nsd.ru/WSAlameda/</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Депозитарно-клиринговых услуг (новый)</td>
|
||||
<td colspan="2" class="muted">отсутствует</td>
|
||||
<td><code>cabinet-t3.nsd.ru/dcs_new/</code></td>
|
||||
<td class="muted">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Полный URL WSL для ONYX: <code>https://<host>/onyx-ms/OnyxEdoWSService/OnyxEdo</code>. Для прод-контура ссылки опубликованы в Анкете НРД ЭДО (<code>anketa_nrd_edo_2022_07_11.pdf</code> на сайте НРД). IP <code>gost.nsd.ru</code> — 91.208.232.151 (для настройки межсетевого экрана).</p>
|
||||
<p><strong>Что указать в Настройка → ИШ:</strong></p>
|
||||
<ul>
|
||||
<li>Профиль (например, <code>test3-gost</code>) — при выборе URL и контейнер заполняются автоматически</li>
|
||||
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
|
||||
<li>Ключевой контейнер — имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
|
||||
</ul>
|
||||
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1а. Сертификаты УЦ НРД (для проверки квитанций)</h2>
|
||||
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно импортировать корневые сертификаты УЦ НРД в хранилище <code>mroot</code> (доверенные корневые).</p>
|
||||
<ol>
|
||||
<li>Скачать сертификаты с сайта УЦ НРД: <code>www.nsd.ru/workflow/system/cryptography/</code> (или из дистрибутива ИШ).</li>
|
||||
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Импорт сертификата» → выбрать файл <code>.cer</code>, тип хранилища <code>mroot — корневой УЦ</code>, нажать «Импортировать». Под капотом выполняется <code>certmgr -inst -file root.cer -store mroot</code>.</li>
|
||||
<li>Промежуточные сертификаты УЦ — в хранилище <code>uRoot</code>.</li>
|
||||
<li>Для проверки подписей самой системы НРД (квитанции ЭДО) — импортировать сертификат подписи НРД в <code>uMy</code> (как корреспондента), либо оставить в <code>mroot</code>, если он самоподписной.</li>
|
||||
</ol>
|
||||
<p><strong>Наши сертификаты для отправки в НРД</strong> (получаются из другого УЦ — нашей организации):</p>
|
||||
<ol>
|
||||
<li>Сертификат подписи нашей организации (с приватным ключом в виде <code>.pfx</code>/<code>.p12</code> или на Рутокен) — импортировать в <code>uMy</code> с PIN.</li>
|
||||
<li>Цепочка сертификатов вашего УЦ — в <code>mroot</code> (корневой) и <code>uRoot</code> (промежуточные).</li>
|
||||
<li>После импорта проверить: <code>certmgr -list -store uMy</code> и <code>cpverify</code>.</li>
|
||||
</ol>
|
||||
<p class="muted">Полный цикл обмена сертификатами с НРД описан в <code>DOC/Инструкция M2M.pdf</code> и <code>DOC/Презентация MOEX MOST.pdf</code>.</p>
|
||||
</div>
|
||||
<p><strong>Документация по подключению</strong>: <code>DOC/instr_podkl_stend_v3.pdf</code>, <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Команда Fansy (ETL в БД)</h2>
|
||||
<p>Команда Fansy на своей стороне настраивает ETL, который пишет в схему <code>fansy_staging.*</code> нашей БД. Мы переливаем оттуда в <code>fansy.*</code> после валидации.</p>
|
||||
<p><strong>Что от вас как заказчика нужно:</strong></p>
|
||||
<ol>
|
||||
<li>Передать команде Fansy ссылку на каталог <code>docs/fansy-contract/v1/</code> (тег <code>fansy-contract-v1</code>).</li>
|
||||
<li>Согласовать SLA свежести и расписание (по умолчанию — портфели за 1 минуту, остальное за 5 минут, справочники раз в сутки).</li>
|
||||
<li>Завести в БД учётную запись с ролью <code>fansy_etl</code> и передать команде Fansy. Пароль выдать через защищённый канал.</li>
|
||||
<li>Прописать IP/подсеть Fansy в <code>pg_hba.conf</code> на стороне PostgreSQL (только TLS, <code>sslmode=verify-full</code>).</li>
|
||||
</ol>
|
||||
<p>Полный контракт: <code>docs/fansy-contract/v1/etl-requirements.md</code>. Семантика полей: <code>docs/fansy-contract/v1/data-dictionary.md</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Уведомления операторам</h2>
|
||||
<p>В M3-M4 будет раздел <strong>Настройка → Уведомления</strong>. Архитектура — провайдеры-плагины с единым интерфейсом <code>Notifier { Send(ctx, recipient, template, data) }</code>:</p>
|
||||
<table>
|
||||
<thead><tr><th>Провайдер</th><th>Назначение</th><th>Что вводить</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>SMTP</td><td>E-mail (внутренний или внешний сервер)</td><td>хост, порт, логин/пароль, from-адрес</td></tr>
|
||||
<tr><td>Yandex Messenger (Yandex 360)</td><td>Корпоративный мессенджер. У заказчика уже есть готовый бот — дотягиваем его</td><td>API-token, webhook, chat-id / user-id</td></tr>
|
||||
<tr><td>Telegram</td><td>Опционально для отдельных операторов</td><td>bot-token, chat-id</td></tr>
|
||||
<tr><td>Mattermost / Rocket.Chat</td><td>Корпоративные мессенджеры (если используются)</td><td>webhook URL</td></tr>
|
||||
<tr><td>WebSocket в admin-ui</td><td>Мгновенный push если оператор открыл вкладку</td><td>встроено, без настроек</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>Логика маршрутизации:</strong> критичный этап (ручное согласование, >80% SLA, отказ НРД) → параллельно e-mail + Messenger + WS-push. Обычные события — только e-mail. Маршрутизация по ролям настраивается в UI.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>4. Контакты команд</h2>
|
||||
<table>
|
||||
<thead><tr><th>Внешняя сторона</th><th>Что согласовать</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
|
||||
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
|
||||
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
|
||||
<tr><td>КриптоПро</td><td>Серийный номер лицензии CSP, актуальный дистрибутив, поддержка <code>support@cryptopro.ru</code></td></tr>
|
||||
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,78 @@
|
||||
{{define "content"}}
|
||||
{{/* Активные новости — сразу под навигацией. Показываем top-3: те у которых ValidFrom..ValidTo сейчас активны, иначе свежие. */}}
|
||||
{{if .News}}
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-bottom:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h2 style="margin:0">📢 Новости</h2>
|
||||
<a href="/admin/news" style="font-size:13px">все новости →</a>
|
||||
</div>
|
||||
{{range .News}}
|
||||
<div style="padding:8px 0;border-bottom:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:14px">
|
||||
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
|
||||
</div>
|
||||
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:4px">{{.Body}}</div>{{end}}
|
||||
{{if and (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">с {{.ValidFrom.Format "02.01.2006"}} по {{.ValidTo.Format "02.01.2006"}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Всего сделок</div>
|
||||
<div class="stat-value">{{.Counts.Total}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Подтверждено</div>
|
||||
<div class="stat-value" style="color: var(--ok)">{{.Counts.Confirmed}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">В ожидании</div>
|
||||
<div class="stat-value" style="color: var(--warn)">{{.Counts.InProgress}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Отказы / таймауты</div>
|
||||
<div class="stat-value" style="color: var(--err)">{{.Counts.Failed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Состояние системы</h2>
|
||||
{{range .Status.Checks}}
|
||||
<div style="padding: 6px 0">
|
||||
<span class="dot {{if .OK}}ok{{else}}err{{end}}"></span>
|
||||
<strong>{{.Name}}</strong> — {{.Message}}
|
||||
{{if .Detail}}<span class="muted"> · <code>{{.Detail}}</code></span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="muted" style="margin-top: 12px">
|
||||
Профиль: <code>{{.Status.Profile}}</code> · Crypto-провайдер: <code>{{.Status.Provider}}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Последние заявки</h2>
|
||||
{{if .Recent}}
|
||||
<table>
|
||||
<thead><tr><th>Создана</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Recent}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt.Format "15:04:05"}}</td>
|
||||
<td><code>{{slice .ID 0 8}}</code></td>
|
||||
<td>{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.</td>
|
||||
<td>{{len .Securities}}</td>
|
||||
<td><span class="badge {{.Status}}">{{ruState .Status}}</span></td>
|
||||
<td><a href="/admin/claims/{{.ID}}">открыть →</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,91 @@
|
||||
{{define "content"}}
|
||||
<style>
|
||||
.news-item { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:14px; margin-bottom:10px; }
|
||||
.news-item.dismissed { opacity:0.5; }
|
||||
.news-item.kind-maintenance { border-left:4px solid var(--warn); }
|
||||
.news-item.kind-feature { border-left:4px solid var(--ok); }
|
||||
.news-item.kind-doc-update { border-left:4px solid var(--accent); }
|
||||
.news-item.kind-system { border-left:4px solid var(--err); }
|
||||
.news-item.kind-manual { border-left:4px solid var(--muted); }
|
||||
.news-meta { font-size:11px; color:var(--muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:0.04em; }
|
||||
.news-title { font-size:15px; font-weight:600; margin:0 0 6px 0; }
|
||||
.news-body { font-size:13px; white-space:pre-wrap; }
|
||||
.news-validity { margin-top:6px; padding:4px 8px; background:var(--bg); border-radius:4px; display:inline-block; font-size:12px; }
|
||||
.news-validity.active { background:rgba(232,177,58,0.15); color:var(--warn); }
|
||||
</style>
|
||||
|
||||
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Новости и события</h2>
|
||||
<p class="muted">События системы, окна техработ НРД, обновления документации и сертификатов. Лента не очищается — служит журналом для аудита. Скрытые новости можно посмотреть, сняв галочку «Только активные».</p>
|
||||
<form method="post" action="/admin/news/check-docs" style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="submit" class="btn">🔄 Проверить обновления документации НРД сейчас</button>
|
||||
{{if not .Settings.News.LastDocCheck.IsZero}}
|
||||
<span class="muted" style="font-size:12px">Последняя проверка: {{.Settings.News.LastDocCheck.Format "02.01.2006 15:04:05"}}</span>
|
||||
{{end}}
|
||||
</form>
|
||||
{{if .Settings.News.DocSources}}
|
||||
<details style="margin-top:8px">
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Источники документации, которые отслеживает doc-watcher</summary>
|
||||
<table style="margin-top:8px;font-size:13px">
|
||||
<thead><tr><th>Имя</th><th>URL</th><th>PDF найдено</th><th>Последняя проверка</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Settings.News.DocSources}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><a href="{{.URL}}" target="_blank"><code style="font-size:11px">{{.URL}}</code></a></td>
|
||||
<td>{{len .KnownPDFs}}</td>
|
||||
<td>{{if .LastChecked.IsZero}}—{{else}}{{.LastChecked.Format "02.01.2006 15:04"}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<h2 style="margin:24px 0 12px 0">Лента ({{len .Settings.News.Items}})</h2>
|
||||
|
||||
{{if not .Settings.News.Items}}
|
||||
<div class="card"><p class="muted" style="margin:0">Пока ничего нет. Doc-watcher запустится через минуту после старта bj-server и заполнит ленту автоматически.</p></div>
|
||||
{{end}}
|
||||
|
||||
{{range .Settings.News.Items}}
|
||||
<div class="news-item kind-{{.Kind}} {{if .Dismissed}}dismissed{{end}}">
|
||||
<div class="news-meta">
|
||||
{{.At.Format "02.01.2006 15:04"}}
|
||||
· <strong>{{.Kind}}</strong>
|
||||
{{if .URL}}· <a href="{{.URL}}" target="_blank" rel="noopener">источник</a>{{end}}
|
||||
</div>
|
||||
<h3 class="news-title">{{.Title}}</h3>
|
||||
{{if .Body}}<div class="news-body">{{.Body}}</div>{{end}}
|
||||
{{if or (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
|
||||
{{$now := now}}
|
||||
{{$active := false}}
|
||||
{{if and (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
|
||||
{{if and (gt $now.Unix .ValidFrom.Unix) (lt $now.Unix .ValidTo.Unix)}}{{$active = true}}{{end}}
|
||||
{{end}}
|
||||
<div class="news-validity {{if $active}}active{{end}}">
|
||||
{{if not .ValidFrom.IsZero}}С {{.ValidFrom.Format "02.01.2006"}}{{end}}
|
||||
{{if not .ValidTo.IsZero}} по {{.ValidTo.Format "02.01.2006"}}{{end}}
|
||||
{{if $active}} — <strong>сейчас активно</strong>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .Dismissed}}
|
||||
<form method="post" action="/admin/news/dismiss" style="margin-top:10px">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn" style="background:var(--border);color:var(--text);padding:4px 10px;font-size:12px">Скрыть</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Settings.News.DocCheckResult}}
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h2>Журнал последней проверки документации</h2>
|
||||
<pre>{{.Settings.News.DocCheckResult}}</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
@@ -0,0 +1,324 @@
|
||||
{{define "content"}}
|
||||
{{if .Flash}}<div style="padding:12px 16px;background:rgba(63,191,108,0.1);border-left:3px solid var(--ok);border-radius:4px;margin-bottom:16px">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
{{range .Readiness}}
|
||||
<div style="flex:1;text-align:center;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
|
||||
<span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span>
|
||||
<strong>{{.Name}}</strong><br>
|
||||
<span class="muted" style="font-size:11px">{{if .Configured}}настроено{{else}}не настроено{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PostgreSQL -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
|
||||
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
|
||||
{{if .Settings.Postgres.DSN}}<code>настроено</code>{{else}}<code>in-memory</code> (M2-демо){{end}}.</p>
|
||||
|
||||
{{if not .Settings.Postgres.DSN}}
|
||||
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
|
||||
<h3 style="margin:0 0 8px 0;font-size:15px">Самый простой вариант — подключить автоматически</h3>
|
||||
<p class="muted" style="margin:0 0 10px 0">Если у вас ещё нет своего PostgreSQL, мы поднимем его сами в контейнере (podman-compose), применим все миграции и запишем DSN. Подходит для дев-стенда и тестирования. Для прода — лучше указать свой DSN ниже.</p>
|
||||
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 18px;border-radius:4px;font-weight:600;cursor:pointer">⚡ Поднять локальный PostgreSQL автоматически</button>
|
||||
<span class="muted" style="margin-left:10px;font-size:12px">Займёт ~10-30 секунд. Требуется установленный <code>podman-compose</code>.</span>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<details {{if not .Settings.Postgres.DSN}}style="margin-top:12px"{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить параметры подключения{{else}}…или ввести параметры подключения вручную (для существующего PostgreSQL){{end}}</summary>
|
||||
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>DSN <span class="muted" title="DSN = Data Source Name. Строка вида postgres://пользователь:пароль@хост:порт/база?опции" style="cursor:help">(?)</span></label>
|
||||
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">При сохранении выполняется Ping. Если БД недоступна — будет ошибка.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- СКЗИ через PKCS#11: КриптоПро CSP / Рутокен / Валидата / ViPNet -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if and .Settings.Crypto.JCPPath .Settings.Crypto.LicenseKey}}ok{{else}}err{{end}}"></span>СКЗИ (КриптоПро CSP, Рутокен и др. через PKCS#11)</h2>
|
||||
<p class="muted">Go-клиент подключается к СКЗИ напрямую через стандартный PKCS#11 интерфейс. Поддерживаются КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — один клиент, разные .so модули. Подробно — раздел <a href="/admin/help/cryptopro">«КриптоПро»</a> в инструкциях.</p>
|
||||
<table style="margin-bottom:12px">
|
||||
<tr><td style="width:220px" class="muted">Текущий провайдер</td><td><code>{{.Settings.Crypto.Provider}}</code></td></tr>
|
||||
<tr><td class="muted">Путь к модулю PKCS#11</td><td><code>{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}—{{end}}</code></td></tr>
|
||||
<tr><td class="muted">UDS-сокет (legacy)</td><td><code>{{.Settings.Crypto.SocketPath}}</code></td></tr>
|
||||
<tr><td class="muted">Лицензия введена</td><td>{{if .Settings.Crypto.LicenseKey}}<span style="color:var(--ok)">да</span>{{else}}<span style="color:var(--err)">нет</span>{{end}}</td></tr>
|
||||
</table>
|
||||
<details {{if or (eq .Settings.Crypto.Provider "stub") (not .Settings.Crypto.JCPPath)}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры СКЗИ</summary>
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
|
||||
<label>Провайдер СКЗИ</label>
|
||||
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
|
||||
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (через PKCS#11)</option>
|
||||
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (для подписи оператора)</option>
|
||||
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата</option>
|
||||
<option value="vipnet" {{if eq .Settings.Crypto.Provider "vipnet"}}selected{{end}}>ViPNet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
|
||||
<label>Путь к модулю PKCS#11</label>
|
||||
<input type="text" name="jcp_path" value="{{.Settings.Crypto.JCPPath}}" placeholder="/opt/cprocsp/lib/amd64/libcppkcs11.so" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
|
||||
<label>UDS-сокет (legacy)</label>
|
||||
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock (только для совместимости со старым Java crypto-service)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:flex-start">
|
||||
<label>Серийный номер лицензии</label>
|
||||
<textarea name="license_key" rows="3" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{.Settings.Crypto.LicenseKey}}</textarea>
|
||||
</div>
|
||||
<p class="muted">
|
||||
<strong>КриптоПро CSP</strong>: установить пакеты <code>rpm -i cprocsp-*.rpm</code>, активировать лицензию командой <code>cpconfig -license -set XXXX-...</code>, указать <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code>.<br>
|
||||
<strong>Рутокен</strong>: подключить токен USB, указать <code>/usr/lib64/librtpkcs11ecp.so</code>.<br>
|
||||
Полная инструкция: <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>. При сохранении проверим, что файл модуля существует.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/crypto/check" style="margin-top:12px">
|
||||
<button type="submit" class="btn" style="background:var(--border);color:var(--text);border:none;padding:8px 16px;border-radius:4px">Проверить подключение СКЗИ</button>
|
||||
<span class="muted" style="margin-left:8px">Загрузит PKCS#11 модуль, опросит список токенов, покажет результат сверху страницы.</span>
|
||||
</form>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Установка КриптоПро CSP</h3>
|
||||
<p class="muted">Дистрибутив с <a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank" rel="noopener">cryptopro.ru</a> (например, <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> для РЕД ОС/ALT/ROSA). Загрузите файл здесь — он будет распакован и установлен через <code>sudo rpm -Uvh</code>. Установка длится ~30 секунд.</p>
|
||||
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="file" name="dist" accept=".tar,.tgz,.gz,.rpm" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;flex:1;min-width:300px">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Загрузить и установить</button>
|
||||
</form>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Сертификаты на токенах</h3>
|
||||
{{if .Certificates}}
|
||||
<table>
|
||||
<thead><tr><th>Кому</th><th>Кем выдан</th><th>ИНН</th><th>Действителен</th><th>Токен</th><th>Приватный ключ</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Certificates}}
|
||||
<tr>
|
||||
<td>{{.SubjectCN}}</td>
|
||||
<td class="muted">{{.IssuerCN}}</td>
|
||||
<td><code>{{.INN}}</code></td>
|
||||
<td class="muted">до {{.NotAfter.Format "02.01.2006"}}</td>
|
||||
<td class="muted">«{{.TokenLabel}}» (slot {{.SlotID}})</td>
|
||||
<td>{{if .HasPrivateKey}}<span style="color:var(--ok)">есть</span>{{else}}<span class="muted">нет</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">На подключенных токенах сертификатов не найдено. Загрузите .pfx ниже или подключите Рутокен с сертификатом.</p>
|
||||
{{end}}
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Импорт сертификата (.pfx / .cer / .crt)</h3>
|
||||
<p class="muted">PFX с приватным ключом (с PIN) — для серверной подписи и подписи оператора. CER/CRT без приватного ключа — для проверки чужих подписей (например, сертификаты УЦ НРД для проверки квитанций). Подробно — <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>.</p>
|
||||
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:auto auto auto auto;align-items:center">
|
||||
<input type="file" name="cert" accept=".pfx,.p12,.cer,.crt" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<input type="text" name="pin" placeholder="PIN (только для .pfx/.p12)" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace">
|
||||
<select name="store" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="uMy">uMy — личный (для подписи)</option>
|
||||
<option value="mroot">mroot — корневой УЦ (для проверки)</option>
|
||||
<option value="uRoot">uRoot — промежуточные УЦ</option>
|
||||
</select>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Импортировать</button>
|
||||
</form>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Активация лицензии</h3>
|
||||
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="text" name="license_key" value="{{.Settings.Crypto.LicenseKey}}" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px;min-width:340px">
|
||||
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;border:none;padding:8px 16px;border-radius:4px;font-weight:600">Активировать лицензию</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:8px">Вызовет <code>cpconfig -license -set</code> и сохранит серийник. Если КриптоПро CSP ещё не установлен — покажет инструкцию.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Контейнеры КриптоПро на флешке -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .FlashContainers}}ok{{else}}warn{{end}}"></span>Контейнеры на USB-носителях (флешка/Рутокен)</h2>
|
||||
{{if .FlashContainers}}
|
||||
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Кнопка ниже копирует папку в <code>/var/opt/cprocsp/keys/$USER/</code> — после этого контейнер виден как <code>\\.\HDIMAGE\name</code> и работает без вставленной флешки.</p>
|
||||
<table style="margin-top:8px">
|
||||
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .FlashContainers}}
|
||||
<tr>
|
||||
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
|
||||
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
|
||||
<td>
|
||||
{{if not .AlreadyImported}}
|
||||
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
|
||||
<input type="hidden" name="src" value="{{.Path}}">
|
||||
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;padding:6px 12px;font-size:12px;font-weight:600">Скопировать в локальное хранилище</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро (папки <code>name.000</code> с *.key) не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку и обновите страницу.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Авто-загрузка сертификатов УЦ НРД -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2>
|
||||
<p class="muted">Прямые URL .cer-файлов УЦ НРД (см. <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и других УЦ. Каждый URL скачивается, парсится X.509, и автоматически импортируется в КриптоПро (<code>mroot</code> для корневых, <code>uRoot</code> для промежуточных). Включите авто-обновление — раз в сутки система перепроверит и переустановит, если сертификат изменился.</p>
|
||||
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px">
|
||||
<label>URL'ы .cer-файлов (один на строку)</label>
|
||||
<textarea name="urls" rows="4" placeholder="https://www.nsd.ru/path/to/root-ca.cer https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
|
||||
{{end}}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
|
||||
<span>Авто-обновление раз в сутки</span>
|
||||
</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
|
||||
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;font-weight:600">⬇ Скачать и импортировать сейчас</button>
|
||||
{{if not .Settings.CACerts.LastFetch.IsZero}}
|
||||
<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>
|
||||
{{end}}
|
||||
</form>
|
||||
{{if .Settings.CACerts.FetchedCerts}}
|
||||
<table style="margin-top:14px">
|
||||
<thead><tr><th>URL</th><th>Владелец</th><th>Хранилище</th><th>Действителен до</th><th>SHA-256</th><th>Статус</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Settings.CACerts.FetchedCerts}}
|
||||
<tr>
|
||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{.URL}}"><code style="font-size:11px">{{.URL}}</code></td>
|
||||
<td>{{.SubjectCN}}</td>
|
||||
<td><code>{{.Store}}</code></td>
|
||||
<td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td>
|
||||
<td><code style="font-size:11px">{{if .SHA256}}{{slice .SHA256 0 12}}…{{end}}</code></td>
|
||||
<td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if .Settings.CACerts.LastFetchLog}}
|
||||
<details style="margin-top:10px">
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Лог последнего обновления</summary>
|
||||
<pre style="margin-top:8px">{{.Settings.CACerts.LastFetchLog}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- nsd-adapter / ИШ НРД -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
|
||||
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}Сейчас <code>mock-режим</code> — Decision эмитируется через 3 секунды после Send.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}}</p>
|
||||
<p class="muted">Подключение к стендам: <a href="/admin/help/systems">/admin/help/systems</a> — там полная таблица URL контуров GUEST/TEST3/PROD и инструкция по установке ИШ. Дистрибутив ИШ скачивается с <code>www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4</code>.</p>
|
||||
<details {{if not .Settings.NSD.IGWBaseURL}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры ИШ</summary>
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Профиль</label>
|
||||
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо без реального ИШ) —</option>
|
||||
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — контур GUEST, ГОСТ ключи</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — контур GUEST, RSA ключи</option>
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — контур TEST3, ГОСТ ключи</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — контур TEST3, RSA ключи</option>
|
||||
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — продуктивный, ГОСТ</option>
|
||||
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — продуктивный, RSA</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>URL ONYX (WSL) НРД</label>
|
||||
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="будет заполнено по профилю" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Ключевой контейнер</label>
|
||||
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="GUEST_GOST_CONTAINER (или ваш контейнер УЦ НРД)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p class="muted">При смене профиля URL ONYX автозаполнится по таблице НРД (из <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>). При сохранении проверяется доступность URL.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить и проверить</button>
|
||||
</form>
|
||||
<script>
|
||||
// Автозаполнение URL ONYX и дефолтного контейнера по выбранному профилю.
|
||||
(function() {
|
||||
var urls = {
|
||||
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
|
||||
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
|
||||
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
|
||||
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
|
||||
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
|
||||
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
|
||||
};
|
||||
var profile = document.getElementById("nsd-profile");
|
||||
var urlInput = document.getElementById("nsd-url");
|
||||
var contInput = document.getElementById("nsd-container");
|
||||
profile.addEventListener("change", function() {
|
||||
var p = profile.value;
|
||||
if (urls[p]) {
|
||||
if (!urlInput.value || confirm("Заменить URL и контейнер на дефолт для профиля " + p + "?")) {
|
||||
urlInput.value = urls[p][0];
|
||||
contInput.value = urls[p][1];
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- LK callback -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}err{{end}}"></span>Callback в ЛК</h2>
|
||||
<p class="muted">{{if .Settings.LK.CallbackURL}}Callback URL: <code>{{.Settings.LK.CallbackURL}}</code>{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}</p>
|
||||
<details {{if not .Settings.LK.CallbackURL}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Указать URL реального ЛК</summary>
|
||||
<form method="post" action="/admin/setup/lk" style="margin-top:12px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Callback URL</label>
|
||||
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">URL до базового хоста ЛК (без /api). При сохранении выполняется GET <code>{URL}/healthz</code>.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Тестовый прогон -->
|
||||
<div class="card">
|
||||
<h2>Тестовый прогон сквозной заявки</h2>
|
||||
<p class="muted">Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 1500 акций Газпрома, ИИС T03), отправит её через всю цепочку и дождётся финального статуса. Если ИШ НРД настроен — пойдёт в реальный ИШ; иначе через mock с задержкой 3 сек.</p>
|
||||
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:4px;font-weight:600">Запустить тестовую заявку</button>
|
||||
</form>
|
||||
{{if .Settings.LastTest}}
|
||||
<div style="margin-top:16px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
|
||||
<strong>Последний прогон:</strong>
|
||||
<table style="margin-top:8px">
|
||||
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Финальный FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
|
||||
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ открыть карточку</a>{{end}}</td></tr>
|
||||
<tr><td class="muted">Когда</td><td>{{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}} — длительность {{.Settings.LastTest.FinishedAt.Sub .Settings.LastTest.StartedAt}}</td></tr>
|
||||
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Статус системы — детально</h2>
|
||||
<table>
|
||||
<thead><tr><th></th><th>Подсистема</th><th>Состояние</th><th>Сообщение</th><th>Детали</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Checks}}
|
||||
<tr>
|
||||
<td><span class="dot {{if .OK}}ok{{else}}err{{end}}"></span></td>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td>{{if .OK}}<span style="color: var(--ok)">OK</span>{{else}}<span style="color: var(--err)">FAIL</span>{{end}}</td>
|
||||
<td>{{.Message}}</td>
|
||||
<td><code>{{.Detail}}</code></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top: 16px">Проверка выполнена в {{.CheckedAt.Format "15:04:05 02.01.2006"}}.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Что подключается на следующих этапах</h2>
|
||||
<table>
|
||||
<tr><td class="muted" style="width:240px">PostgreSQL (схема m2m_core)</td><td>M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — <code>migrations/m2m-core/001__deals.sql</code>.</td></tr>
|
||||
<tr><td class="muted">crypto-service · КриптоПро JCP</td><td>M4: положить <code>jcp.jar</code> в <code>services/crypto-service/libs/</code>, выставить <code>BJ_CRYPTO_PROVIDER=cryptopro</code>, заполнить keystore профиля. Проверка — gRPC Health должна вернуть <code>provider=cryptopro, ok=true</code>.</td></tr>
|
||||
<tr><td class="muted">nsd-adapter · ИШ НРД</td><td>M3: установить ИШ, выставить <code>BJ_NSD_PROFILE=guest-gost</code> или иной, <code>BJ_NSD_IGW_URL=http://localhost:8080</code>. Без этого сейчас используется <code>nsdadapter/mock</code> с эмуляцией ответов через 3 сек.</td></tr>
|
||||
<tr><td class="muted">Реальный ЛК (ESIA Finance)</td><td>M4: согласовать <code>docs/lk-contract/v1/openapi.yaml</code> с командой ЛК, выставить <code>BJ_LK_CALLBACK_URL</code> на реальный адрес. Сейчас callback идёт в встроенный lk-emulator.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,362 @@
|
||||
{{define "content"}}
|
||||
<style>
|
||||
.wizard-progress { display:flex; gap:6px; margin-bottom:24px; }
|
||||
.wizard-step { flex:1; padding:12px 8px; border-radius:6px; background:var(--card); border:1px solid var(--border); text-align:center; position:relative; }
|
||||
.wizard-step.done { background:rgba(63,191,108,0.12); border-color:var(--ok); }
|
||||
.wizard-step.current { background:rgba(91,157,255,0.15); border-color:var(--accent); }
|
||||
.wizard-step-num { display:block; font-size:11px; color:var(--muted); margin-bottom:4px; }
|
||||
.wizard-step-name { font-size:13px; font-weight:600; }
|
||||
.wizard-step.done .wizard-step-num::after { content:" ✓"; color:var(--ok); }
|
||||
.tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; }
|
||||
.where { font-size:12px; color:var(--accent); margin-left:8px; }
|
||||
.help-block { background:rgba(91,157,255,0.07); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; }
|
||||
.help-block strong { color:var(--accent); }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
<h2>Мастер настройки</h2>
|
||||
<p class="muted">Пошаговая настройка системы. Подходит для первого запуска. После каждого шага состояние сохраняется и можно вернуться позже.</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-step {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 1</span>
|
||||
<span class="wizard-step-name">PostgreSQL</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 2</span>
|
||||
<span class="wizard-step-name">КриптоПро / Рутокен</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 3</span>
|
||||
<span class="wizard-step-name">Сертификаты</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 4</span>
|
||||
<span class="wizard-step-name">Шлюз НРД</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 5</span>
|
||||
<span class="wizard-step-name">Тестовая заявка</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
|
||||
|
||||
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
|
||||
{{if eq .Step 1}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Postgres}}ok{{else}}err{{end}}"></span>Шаг 1. PostgreSQL</h2>
|
||||
<p>Сюда система пишет журнал сделок и принимает данные от команды Fansy.</p>
|
||||
|
||||
<div class="help-block">
|
||||
<strong>Что выбрать?</strong> Если у вас уже есть рабочий PostgreSQL — нажмите «У меня уже есть PostgreSQL» и введите DSN. Если впервые настраиваете — выберите «Поднять автоматически», система сама развернёт контейнер с PostgreSQL и накатит миграции.
|
||||
</div>
|
||||
|
||||
{{if not .Settings.Postgres.DSN}}
|
||||
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
|
||||
<h3 style="margin:0 0 8px 0;font-size:15px">Вариант А — для тех, у кого нет своего PostgreSQL</h3>
|
||||
<p class="muted" style="margin:0 0 10px 0">Bridge-and-Join-s сам поднимет PostgreSQL в контейнере (podman-compose), создаст БД <code>bj</code> и накатит миграции. Подходит для дев-стенда. Для продакшена лучше указать свой DSN.</p>
|
||||
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
|
||||
<button type="submit" class="btn" style="background:var(--ok)">⚡ Поднять локальный PostgreSQL автоматически</button>
|
||||
<span class="muted" style="margin-left:10px;font-size:12px">~10-30 сек</span>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<details style="margin-top:14px" {{if .Settings.Postgres.DSN}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent)">Вариант Б — у меня уже есть PostgreSQL, введу DSN сам</summary>
|
||||
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
|
||||
<label>DSN (строка подключения) <span class="tooltip" title="Формат: postgres://пользователь:пароль@хост:порт/база?sslmode=disable. Например: postgres://bj:secret@db.example.com:5432/bj?sslmode=require">?</span></label>
|
||||
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:6px">
|
||||
<p class="muted" style="margin-top:8px">При сохранении выполняется тестовое подключение (Ping). Если БД недоступна — будет ошибка.</p>
|
||||
<button type="submit" class="btn" style="margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<span></span>
|
||||
{{if .Done.Postgres}}<a href="/admin/wizard?step=2" class="btn" style="text-decoration:none">К шагу 2 →</a>{{else}}<button class="btn" disabled style="opacity:0.5;cursor:not-allowed">К шагу 2 → (сначала настройте PostgreSQL или нажмите «in-memory режим»)</button>{{end}}
|
||||
</div>
|
||||
{{if not .Done.Postgres}}<p style="margin-top:8px"><a href="/admin/wizard?step=2&skip=postgres" style="font-size:13px">Пропустить (буду работать в режиме in-memory — без сохранения сделок)</a></p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 2: Крипто ============= */}}
|
||||
{{if eq .Step 2}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. Крипто-провайдер (КриптоПро CSP или Рутокен)</h2>
|
||||
<p>СКЗИ нужен для подписи XMLDSig и проверки квитанций НРД.</p>
|
||||
|
||||
<div class="help-block">
|
||||
<strong>Что это?</strong> КриптоПро CSP — российский криптопровайдер с поддержкой ГОСТ Р 34.10-2012. Рутокен ЭЦП 2.0 — USB-токен для безопасного хранения ключей. Можно использовать оба: CSP — для серверной части, Рутокен — для подписи действий оператора.<br>
|
||||
<strong>Где взять?</strong> Дистрибутив КриптоПро CSP 5.0 R4 — <a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank">cryptopro.ru/products/csp/downloads</a> (нужна регистрация в личном кабинете). Лицензия — там же или у дилера. Демо-лицензия на 3 месяца встроена в дистрибутив.
|
||||
</div>
|
||||
|
||||
{{if not .CryptoProInstalled}}
|
||||
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
|
||||
<h3 style="margin:0 0 8px 0;font-size:15px">Шаг 2a — загрузить и установить КриптоПро CSP</h3>
|
||||
<p class="muted" style="margin:0 0 10px 0">Скачайте с <code>cryptopro.ru</code> архив <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> (КриптоПро CSP 5.0 R4 для Linux) и загрузите его сюда. Bj-server сам распакует и установит нужные пакеты.</p>
|
||||
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin:0">
|
||||
<input type="file" name="dist" accept=".tar,.tgz,.tar.gz,.rpm" required style="margin-right:8px">
|
||||
<button type="submit" class="btn">Загрузить и установить</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<p style="color:var(--ok);margin-top:12px">✓ КриптоПро CSP установлен. Версия: <code>{{.CryptoProVersion}}</code></p>
|
||||
{{end}}
|
||||
|
||||
<details style="margin-top:14px" {{if not .Done.Crypto}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent)">Шаг 2b — указать провайдер и путь к PKCS#11 модулю</summary>
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div>
|
||||
<label>Провайдер <span class="tooltip" title="cryptopro — КриптоПро CSP, rutoken — Рутокен ЭЦП 2.0 через драйверы CSP, stub — без криптографии (демо-режим без подписи)">?</span></label>
|
||||
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
|
||||
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (серверная подпись, ключи на диске)</option>
|
||||
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (подпись оператора)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Путь к модулю PKCS#11 <span class="tooltip" title="Файл libcppkcs11.so входит в пакет lsb-cprocsp-pkcs11-64. После установки КриптоПро CSP он находится в /opt/cprocsp/lib/amd64/">?</span></label>
|
||||
<input type="text" name="jcp_path" value="{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}/opt/cprocsp/lib/amd64/libcppkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{{if and .Done.Crypto (not .Settings.Crypto.LicenseKey)}}
|
||||
<details open style="margin-top:14px">
|
||||
<summary style="cursor:pointer;color:var(--accent)">Шаг 2c — активировать лицензию (если демо не подходит)</summary>
|
||||
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:12px">
|
||||
<label>Серийный номер лицензии КриптоПро <span class="tooltip" title="Формат XXXXX-XXXXX-XXXXX-XXXXX-XXXXX. Выдаётся при покупке лицензии. Демо-лицензия на 3 месяца встроена в дистрибутив — её активировать не нужно.">?</span></label>
|
||||
<input type="text" name="license" placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%;margin-top:6px">
|
||||
<button type="submit" class="btn" style="margin-top:8px">Активировать</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=1" class="btn" style="background:var(--card);text-decoration:none">← К шагу 1</a>
|
||||
{{if .Done.Crypto}}<a href="/admin/wizard?step=3" class="btn" style="text-decoration:none">К шагу 3 →</a>{{else}}<a href="/admin/wizard?step=3&skip=crypto" class="btn" style="background:var(--card);text-decoration:none">Пропустить →</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 3: Сертификаты ============= */}}
|
||||
{{if eq .Step 3}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Certs}}ok{{else}}err{{end}}"></span>Шаг 3. Сертификаты</h2>
|
||||
<p>Импортируйте сертификаты вашей организации и сертификаты УЦ НРД (для проверки квитанций).</p>
|
||||
|
||||
<div class="help-block">
|
||||
<strong>Что говорят документы НРД (<code>DOC/Инструккия M2M.pdf</code>, стр. 11, 16-19):</strong>
|
||||
<ul style="margin:6px 0 6px 16px">
|
||||
<li>Наши пакеты должны быть подписаны сертификатом <strong>УЦ МБ</strong> (Удостоверяющий центр Московской Биржи).</li>
|
||||
<li>В режиме <strong>ИШ НРД</strong>: подписывает <em>сам ИШ</em> — наш ключ настраивается <em>в ИШ</em>, не здесь. Bj-server нужен только для проверки квитанций НРД и (опц.) расшифровки 4BROKER01.</li>
|
||||
<li>В режиме <strong>прямого ONYX без ИШ</strong>: bj-server подписывает сам — нужен наш ключ с приватной частью.</li>
|
||||
</ul>
|
||||
<strong>Что куда загружать (по режиму):</strong>
|
||||
<table style="margin-top:6px;font-size:13px">
|
||||
<thead><tr><th>Что</th><th>Зачем</th><th>Куда</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>)</td><td>проверка цепочки нашей подписи и подписей контрагентов</td><td><code>mroot</code></td></tr>
|
||||
<tr><td>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>)</td><td>проверка квитанций от НРД</td><td><code>mroot</code> + <code>uRoot</code></td></tr>
|
||||
<tr><td>Наш сертификат + ключ <em>(только если без ИШ)</em></td><td>подпись отправляемых пакетов + расшифровка 4BROKER01</td><td><code>uMy</code> — с приватным ключом</td></tr>
|
||||
<tr><td>Сертификаты с Рутокена</td><td>сами появятся в таблице ниже после подключения USB</td><td>не грузить</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:6px">Полный регламент PKI — в «Правилах ЭДО НРД» и «Руководстве по установке ИШ» (<a href="https://www.nsd.ru/ru/documents/workflow/" target="_blank">nsd.ru/ru/documents/workflow/</a>) — в наших PDF этого не описано.</p>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:18px">Импорт сертификата</h3>
|
||||
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:1fr 1fr 1fr auto;align-items:end">
|
||||
<div>
|
||||
<label class="muted" style="font-size:12px">Файл</label>
|
||||
<input type="file" name="cert" accept=".cer,.crt,.pfx,.p12" required style="width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label class="muted" style="font-size:12px">Хранилище</label>
|
||||
<select name="store" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="uMy">uMy — мой (с приватным ключом)</option>
|
||||
<option value="mroot">mroot — корневой УЦ</option>
|
||||
<option value="uRoot">uRoot — промежуточный УЦ</option>
|
||||
<option value="uCA">uCA — сертификаты УЦ НРД</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="muted" style="font-size:12px">PIN (для .pfx)</label>
|
||||
<input type="password" name="pin" placeholder="опц." style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn">Импортировать</button>
|
||||
</form>
|
||||
|
||||
<h3 style="margin-top:18px">Контейнеры на подключённых носителях (флешка/Рутокен)</h3>
|
||||
{{if .FlashContainers}}
|
||||
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Нажмите «Скопировать в локальное хранилище» — папка будет перенесена в <code>/var/opt/cprocsp/keys/$USER/</code>, после чего контейнер виден как <code>\\.\HDIMAGE\name</code> и работает даже без вставленной флешки.</p>
|
||||
<table style="margin-top:8px">
|
||||
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .FlashContainers}}
|
||||
<tr>
|
||||
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
|
||||
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
|
||||
<td>
|
||||
{{if not .AlreadyImported}}
|
||||
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
|
||||
<input type="hidden" name="src" value="{{.Path}}">
|
||||
<button type="submit" class="btn" style="background:var(--ok);padding:6px 12px;font-size:12px">Скопировать в локальное хранилище</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:8px">После копирования: импортировать сертификат из контейнера командой <code>certmgr -inst -cont '\\.\HDIMAGE\{имя}' -store uMy</code> — это пропишет сертификат в видимое хранилище. (UI-кнопку для этого добавим следующим шагом.)</p>
|
||||
{{else}}
|
||||
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро формата <code>name.000</code> не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку с контейнером и обновите страницу — контейнер появится в этой таблице автоматически.</p>
|
||||
{{end}}
|
||||
|
||||
<h3 style="margin-top:18px">Авто-загрузка сертификатов УЦ НРД</h3>
|
||||
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.</p>
|
||||
<form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px">
|
||||
<textarea name="urls" rows="3" placeholder="https://www.nsd.ru/path/to/root-ca.cer https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
|
||||
{{end}}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
|
||||
<span>Авто-обновление раз в сутки</span>
|
||||
</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
|
||||
<button type="submit" class="btn" style="background:var(--ok)">⬇ Скачать и импортировать сейчас</button>
|
||||
{{if not .Settings.CACerts.LastFetch.IsZero}}<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>{{end}}
|
||||
</form>
|
||||
|
||||
{{if .Certs}}
|
||||
<h3 style="margin-top:18px">Установленные сертификаты ({{len .Certs}})</h3>
|
||||
<table>
|
||||
<thead><tr><th>Владелец</th><th>Издатель</th><th>Действителен до</th><th>ИНН</th><th>Ключ</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Certs}}
|
||||
<tr>
|
||||
<td>{{.SubjectCN}}</td>
|
||||
<td>{{.IssuerCN}}</td>
|
||||
<td>{{.NotAfter.Format "02.01.2006"}}</td>
|
||||
<td>{{if .INN}}<code>{{.INN}}</code>{{else}}—{{end}}</td>
|
||||
<td>{{if .HasPrivateKey}}<span style="color:var(--ok)">есть</span>{{else}}<span class="muted">нет</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted" style="margin-top:12px">Пока сертификаты не импортированы.</p>
|
||||
{{end}}
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=2" class="btn" style="background:var(--card);text-decoration:none">← К шагу 2</a>
|
||||
<a href="/admin/wizard?step=4" class="btn" style="text-decoration:none">К шагу 4 →</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 4: НРД ============= */}}
|
||||
{{if eq .Step 4}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.NSD}}ok{{else}}err{{end}}"></span>Шаг 4. Интеграционный шлюз НРД</h2>
|
||||
<p>Адрес web-сервиса ONYX и имя ключевого контейнера НРД.</p>
|
||||
|
||||
<div class="help-block">
|
||||
<strong>Что это?</strong> Интеграционный шлюз (ИШ) НРД — это компонент, через который наши M2M-сообщения отправляются в НРД. У НРД есть 4 контура: <em>GUEST</em> (для разработки) и <em>TEST3</em> (предпродакшен), каждый в варианте ГОСТ или RSA.<br>
|
||||
<strong>Где взять?</strong> Дистрибутив ИШ и инструкции — на сайте НРД <a href="https://www.nsd.ru/workflow/system/programs/" target="_blank">nsd.ru/workflow/system/programs/</a>. Доступ к тестовым контурам выдаётся НРД по заявке (см. <code>DOC/instr_podkl_stend_v3.pdf</code>).
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div>
|
||||
<label>Профиль <span class="tooltip" title="GUEST — гостевой контур для разработчиков (gost-gt.nsd.ru), TEST3 — тестовый предпродакшен (gost-t3.nsd.ru), prod — рабочий контур">?</span></label>
|
||||
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>TEST3 · ГОСТ (рекомендуется для теста)</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>TEST3 · RSA</option>
|
||||
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>GUEST · ГОСТ</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>GUEST · RSA</option>
|
||||
<option value="prod" {{if eq .Settings.NSD.Profile "prod"}}selected{{end}}>prod — рабочий контур (осторожно)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>URL ONYX <span class="tooltip" title="Базовый URL веб-сервиса ONYX. При выборе профиля выше — заполняется автоматически.">?</span></label>
|
||||
<input type="text" name="igw_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД). Формат: \\.\HDIMAGE\нрд-имя или нрд-имя.000">?</span></label>
|
||||
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="\\.\HDIMAGE\nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="justify-self:start">Сохранить</button>
|
||||
</form>
|
||||
<script>
|
||||
// Автозаполнение URL по выбранному профилю
|
||||
document.getElementById('nsd-profile').addEventListener('change', function(e){
|
||||
var urls = {
|
||||
'test3-gost': 'https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'test3-rsa': 'https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'guest-gost': 'https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'guest-rsa': 'https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'prod': ''
|
||||
};
|
||||
var u = document.getElementById('nsd-url');
|
||||
if (urls[e.target.value]) u.value = urls[e.target.value];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=3" class="btn" style="background:var(--card);text-decoration:none">← К шагу 3</a>
|
||||
{{if .Done.NSD}}<a href="/admin/wizard?step=5" class="btn" style="text-decoration:none">К шагу 5 →</a>{{else}}<a href="/admin/wizard?step=5&skip=nsd" class="btn" style="background:var(--card);text-decoration:none">Пропустить (mock-режим) →</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 5: Тест-ран ============= */}}
|
||||
{{if eq .Step 5}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 5. Тестовая заявка</h2>
|
||||
<p>Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.</p>
|
||||
|
||||
<div class="help-block">
|
||||
<strong>Что произойдёт?</strong> Система создаст тестовую M2M-сделку, проведёт её через всю стейт-машину, и покажет результат каждого этапа. Если ИШ НРД не настроен — сработает mock (синтетический Decision через 3 секунды).
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
|
||||
<button type="submit" class="btn" style="background:var(--ok);font-size:15px;padding:10px 20px">▶ Запустить тестовую заявку</button>
|
||||
</form>
|
||||
|
||||
{{if .Settings.LastTest}}
|
||||
<h3 style="margin-top:18px">Последний прогон: {{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}}</h3>
|
||||
<table>
|
||||
<tr><td class="muted">Заявка</td><td><a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">{{.Settings.LastTest.ClaimID}}</a></td></tr>
|
||||
<tr><td class="muted">Финальное состояние</td><td>{{ruState .Settings.LastTest.FinalStatus}}</td></tr>
|
||||
<tr><td class="muted">Результат</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">успех</span>{{else}}<span style="color:var(--err)">ошибка</span>{{end}}</td></tr>
|
||||
{{if .Settings.LastTest.Message}}<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<h3 style="margin-top:18px">Итоговая сводка</h3>
|
||||
<table>
|
||||
<tr><td class="muted">PostgreSQL</td><td>{{if .Done.Postgres}}<span style="color:var(--ok)">настроен</span>{{else}}<span class="muted">in-memory</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Крипто-провайдер</td><td>{{if .Done.Crypto}}<span style="color:var(--ok)">{{.Settings.Crypto.Provider}}</span>{{else}}<span style="color:var(--err)">не настроен</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Сертификатов установлено</td><td>{{len .Certs}}</td></tr>
|
||||
<tr><td class="muted">ИШ НРД</td><td>{{if .Done.NSD}}<span style="color:var(--ok)">{{.Settings.NSD.Profile}}</span>{{else}}<span class="muted">mock-режим</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Тестовый прогон</td><td>{{if .Done.TestRun}}<span style="color:var(--ok)">пройден</span>{{else}}<span class="muted">не запускался</span>{{end}}</td></tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=4" class="btn" style="background:var(--card);text-decoration:none">← К шагу 4</a>
|
||||
<a href="/admin/" class="btn" style="text-decoration:none">Перейти к дашборду</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
@@ -0,0 +1,72 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{.Title}} · lk-gateway</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#8b94a3; --accent:#5b9dff; --ok:#3fbf6c; --warn:#e8b13a; --err:#e85a5a; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); }
|
||||
header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 24px; }
|
||||
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
header nav a { color: var(--muted); text-decoration: none; margin-right: 16px; font-size: 14px; }
|
||||
header nav a:hover, header nav a.active { color: var(--text); }
|
||||
main { padding: 24px; max-width: 1280px; margin: 0 auto; }
|
||||
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 16px; margin-bottom: 16px; }
|
||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.stat { padding: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.stat-value { font-size: 22px; font-weight: 600; margin-top: 4px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.err { background: var(--err); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
tr:hover td { background: rgba(91,157,255,0.05); }
|
||||
a { color: var(--accent); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background: rgba(91,157,255,0.15); color: #5b9dff; }
|
||||
.badge.awaiting_decision { background: rgba(232,177,58,0.15); color: var(--warn); }
|
||||
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background: rgba(63,191,108,0.15); color: var(--ok); }
|
||||
.badge.rejected, .badge.timed_out { background: rgba(232,90,90,0.15); color: var(--err); }
|
||||
.badge.manual_approval { background: rgba(232,177,58,0.15); color: var(--warn); }
|
||||
code { background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 12px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
pre { background: #0a0c10; border: 1px solid var(--border); border-radius: 4px; padding: 12px; font-size: 12px; overflow: auto; max-height: 400px; }
|
||||
button, .btn { background: var(--accent); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
button:hover, .btn:hover { opacity: .9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>lk-gateway</h1>
|
||||
<nav>
|
||||
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
|
||||
<a href="/admin/wizard" class="{{if eq .Active "wizard"}}active{{end}}">Мастер настройки</a>
|
||||
<a href="/admin/setup" class="{{if eq .Active "setup"}}active{{end}}">Настройка</a>
|
||||
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">Новости</a>
|
||||
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
|
||||
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус системы</a>
|
||||
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Инструкции</a>
|
||||
</nav>
|
||||
<span class="muted" style="margin-left:auto">{{.Now}}</span>
|
||||
</header>
|
||||
{{if .IsMockMode}}
|
||||
<div style="background:rgba(232,177,58,0.15);border-bottom:2px solid var(--warn);padding:10px 24px;display:flex;align-items:center;gap:12px;font-size:13px">
|
||||
<span style="font-size:18px">🟡</span>
|
||||
<div>
|
||||
<strong style="color:var(--warn)">РЕЖИМ ЭМУЛЯЦИИ</strong> — реального обмена с НРД нет.
|
||||
<span class="muted" style="margin-left:6px">{{.MockReason}}</span>
|
||||
</div>
|
||||
<a href="/admin/wizard" style="margin-left:auto;font-size:13px">Настроить →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,49 @@
|
||||
# internal/m2mcore — ядро бизнес-логики M2M
|
||||
|
||||
Конечный автомат сделки, репозиторий, идемпотентность по GUID,
|
||||
сборка `M2MTransferRequest` из заявки ЛК + данных Fansy, метрики SLA.
|
||||
|
||||
## Состав
|
||||
|
||||
- `fsm.go` — состояния FSM и матрица разрешённых переходов
|
||||
(`Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed →
|
||||
AwaitingSUB16 → Done`, ветки `Rejected`, `TimedOut`,
|
||||
`ManualApproval`).
|
||||
- `deal.go` — доменная модель `Deal` с явными методами переходов
|
||||
(`Validate`, `Submit`, `ReceiveDecision`, `Timeout`,
|
||||
`SendToManualApproval`, `ApproveManually`, `RejectManually`,
|
||||
`CompleteSUB16`). Каждый переход проверяет текущее состояние,
|
||||
пишет историю в `Stages` и фиксирует событие в журнале.
|
||||
- `uuid.go` — генератор UUID v4 без внешних зависимостей.
|
||||
- `repo.go` — порт `Repository` + in-memory реализация
|
||||
`MemoryRepository` с идемпотентным `Create` (по GUID возвращает
|
||||
существующую сделку). PostgreSQL-реализация — задача M2 (миграция
|
||||
лежит в `migrations/m2m-core/001__deals.sql`).
|
||||
- `ports.go` — порты к внешним системам (`NSDSender`,
|
||||
`LKCallbackClient`, `CryptoVerifier`, `FansyStore`) с no-op
|
||||
заглушками для M1.
|
||||
- `enrich.go` — функция `EnrichRequest`: из `ClaimInput` (заявка ЛК)
|
||||
+ данных Fansy собирает валидный `M2MTransferRequest`, генерирует
|
||||
`GUID` и `ReferenceID` по каждой ЦБ.
|
||||
- `metrics.go` — порт `Recorder` + `MemoryRecorder`, отдающий
|
||||
Prometheus text-format. В M2 заменим на `prometheus/client_golang`,
|
||||
когда прокси откроет Go-модули.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Только stdlib и собственные пакеты `internal/m2m`, `internal/nsdxml`.
|
||||
Никаких внешних модулей.
|
||||
|
||||
## Тесты
|
||||
|
||||
- `fsm_test.go` — переходы и терминальные состояния.
|
||||
- `repo_test.go` — идемпотентность по GUID, фильтры в `List`.
|
||||
- `uuid_test.go` — формат UUID v4 и `ReferenceID`.
|
||||
- `metrics_test.go` — Prometheus-текст.
|
||||
|
||||
## Сервис cmd/m2m-core
|
||||
|
||||
`cmd/m2m-core/main.go` — HTTP-сервер с `/healthz` и `/metrics`,
|
||||
in-memory репозиторий, no-op порты. Адрес из `BJ_HTTP_ADDR`
|
||||
(по умолчанию `:8081`). В M2 будет заменено на реальные клиенты НРД
|
||||
и crypto-service.
|
||||
@@ -0,0 +1,232 @@
|
||||
package m2mcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
)
|
||||
|
||||
// StageRecord — запись о входе в состояние FSM, для аудита и метрик.
|
||||
type StageRecord struct {
|
||||
State State
|
||||
EnteredAt time.Time
|
||||
LeftAt *time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Event — доменное событие сделки (event sourcing для аудита).
|
||||
type Event struct {
|
||||
Type string
|
||||
Payload any
|
||||
CreatedAt time.Time
|
||||
Actor string
|
||||
}
|
||||
|
||||
// Deal — корневая агрегатная сущность M2M-сделки.
|
||||
type Deal struct {
|
||||
ID string
|
||||
GUID m2m.UUID
|
||||
State State
|
||||
InvestorID string
|
||||
SignedClaim []byte
|
||||
Request *m2m.M2MTransferRequest
|
||||
Response *m2m.M2MTransferResponse
|
||||
Decision *m2m.M2MTransferDecision
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Stages []StageRecord
|
||||
|
||||
mu sync.Mutex
|
||||
events []Event
|
||||
}
|
||||
|
||||
// NewDeal создаёт новую сделку в состоянии Draft.
|
||||
func NewDeal(guid m2m.UUID, investorID string, signedClaim []byte) (*Deal, error) {
|
||||
if err := guid.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("m2mcore: невалидный GUID при создании Deal: %w", err)
|
||||
}
|
||||
id, err := NewUUIDv4()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return &Deal{
|
||||
ID: id,
|
||||
GUID: guid,
|
||||
State: StateDraft,
|
||||
InvestorID: investorID,
|
||||
SignedClaim: signedClaim,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Stages: []StageRecord{{State: StateDraft, EnteredAt: now}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Events возвращает накопленные события (копия слайса).
|
||||
func (d *Deal) Events() []Event {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
out := make([]Event, len(d.events))
|
||||
copy(out, d.events)
|
||||
return out
|
||||
}
|
||||
|
||||
// recordEvent добавляет событие в журнал сделки.
|
||||
func (d *Deal) recordEvent(eventType string, payload any, actor string) {
|
||||
d.events = append(d.events, Event{
|
||||
Type: eventType, Payload: payload,
|
||||
CreatedAt: time.Now().UTC(), Actor: actor,
|
||||
})
|
||||
}
|
||||
|
||||
// shiftTo переводит FSM в новое состояние, фиксируя историю.
|
||||
// Должен вызываться под d.mu.Lock.
|
||||
func (d *Deal) shiftTo(next State, reason string) error {
|
||||
if err := transition(d.State, next); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if i := len(d.Stages) - 1; i >= 0 {
|
||||
d.Stages[i].LeftAt = &now
|
||||
}
|
||||
d.Stages = append(d.Stages, StageRecord{State: next, EnteredAt: now, Reason: reason})
|
||||
d.State = next
|
||||
d.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate переводит Draft -> Validated и фиксирует событие.
|
||||
// Сама валидация Request делается m2m.M2MTransferRequest.Validate(),
|
||||
// которое следует вызвать перед этим методом.
|
||||
func (d *Deal) Validate(_ context.Context, request *m2m.M2MTransferRequest) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if request == nil {
|
||||
return fmt.Errorf("m2mcore: Deal.Validate: request=nil")
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
return fmt.Errorf("m2mcore: M2MTransferRequest.Validate: %w", err)
|
||||
}
|
||||
d.Request = request
|
||||
if err := d.shiftTo(StateValidated, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("validated", request.Header.GUID, "system")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit переводит Validated -> SubmittedToNSD после успешной отправки.
|
||||
func (d *Deal) Submit(_ context.Context) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.shiftTo(StateSubmittedToNSD, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("submitted_to_nsd", nil, "system")
|
||||
if err := d.shiftTo(StateAwaitingDecision, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("awaiting_decision", nil, "system")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReceiveDecision принимает M2MTransferDecision от принимающей стороны и
|
||||
// меняет состояние на Confirmed или Rejected в зависимости от содержимого.
|
||||
func (d *Deal) ReceiveDecision(_ context.Context, decision *m2m.M2MTransferDecision) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if decision == nil {
|
||||
return fmt.Errorf("m2mcore: ReceiveDecision: decision=nil")
|
||||
}
|
||||
if err := decision.Validate(); err != nil {
|
||||
return fmt.Errorf("m2mcore: M2MTransferDecision.Validate: %w", err)
|
||||
}
|
||||
d.Decision = decision
|
||||
|
||||
// Если все Security содержат Confirmation — Confirmed; если хотя бы
|
||||
// одна Rejection — Rejected. Смешанные сценарии XSD НРД не запрещает,
|
||||
// но на нашей стороне их трактуем как Rejected (требует ручного разбора).
|
||||
rejected := false
|
||||
for _, s := range decision.Data.Securities {
|
||||
if s.TransferDecision.Rejection != nil {
|
||||
rejected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
target := StateConfirmed
|
||||
reason := ""
|
||||
if rejected {
|
||||
target = StateRejected
|
||||
reason = "decision_contains_rejection"
|
||||
}
|
||||
if err := d.shiftTo(target, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("decision_received", decision.Header.GUID, "nsd")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Timeout переводит сделку в TimedOut (когда не дождались Decision).
|
||||
func (d *Deal) Timeout(_ context.Context) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.shiftTo(StateTimedOut, "no_decision_within_sla"); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("timed_out", nil, "system")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendToManualApproval переводит на ручной разбор оператора.
|
||||
func (d *Deal) SendToManualApproval(_ context.Context, reason string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.shiftTo(StateManualApproval, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("manual_approval_requested", reason, "system")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveManually вручную подтверждает сделку (с операторской подписью).
|
||||
func (d *Deal) ApproveManually(_ context.Context, operator string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.shiftTo(StateConfirmed, "manual_approve"); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("manual_approve", nil, operator)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectManually вручную отказывает в сделке.
|
||||
func (d *Deal) RejectManually(_ context.Context, operator, code, comment string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.shiftTo(StateRejected, "manual_reject:"+code); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("manual_reject", map[string]string{"code": code, "comment": comment}, operator)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteSUB16 фиксирует получение SUB16 от НРД и переводит Confirmed
|
||||
// -> AwaitingSUB16 -> Done. Может вызываться один раз.
|
||||
func (d *Deal) CompleteSUB16(_ context.Context) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.State == StateConfirmed {
|
||||
if err := d.shiftTo(StateAwaitingSUB16, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("awaiting_sub16", nil, "nsd")
|
||||
}
|
||||
if err := d.shiftTo(StateDone, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("done", nil, "nsd")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package m2mcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
// ClaimInput — входная заявка из ЛК (после OpenAPI-парсинга).
|
||||
type ClaimInput struct {
|
||||
InvestorClientID string
|
||||
TransferringDepositoryINN m2m.OrganizationINN
|
||||
ReceivingDepositoryINN m2m.OrganizationINN
|
||||
CostInfo m2m.CostInfo
|
||||
IIAAgreement *m2m.IIAAgreementDetails
|
||||
Securities []ClaimSecurityInput
|
||||
}
|
||||
|
||||
// ClaimSecurityInput — одна ЦБ в заявке.
|
||||
type ClaimSecurityInput struct {
|
||||
SecurityCode m2m.SecurityCode
|
||||
Details m2m.SecurityDetails
|
||||
Quantity m2m.Quantity
|
||||
}
|
||||
|
||||
// SenderReceiver — отправитель и получатель в Header (коды депонентов).
|
||||
type SenderReceiver struct {
|
||||
SenderCode m2m.DeponentCode
|
||||
ReceiverCode m2m.DeponentCode
|
||||
}
|
||||
|
||||
// referenceIDChars — алфавит для генерации ReferenceID (5 случайных
|
||||
// символов после префикса "M2M" и даты).
|
||||
const referenceIDChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// NewReferenceID генерирует идентификатор операции в формате
|
||||
// "M2M" + YYYYMMDD + 5 случайных символов из [A-Z0-9].
|
||||
// Длина — ровно 16, как требует XSD ReferenceIdType.
|
||||
func NewReferenceID(at time.Time) (m2m.ReferenceID, error) {
|
||||
suffix := make([]byte, 5)
|
||||
for i := range suffix {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(referenceIDChars))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("m2mcore: NewReferenceID rand: %w", err)
|
||||
}
|
||||
suffix[i] = referenceIDChars[n.Int64()]
|
||||
}
|
||||
day := at.UTC()
|
||||
id := fmt.Sprintf("M2M%04d%02d%02d%s",
|
||||
day.Year(), int(day.Month()), day.Day(), suffix)
|
||||
out := m2m.ReferenceID(id)
|
||||
if err := out.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnrichRequest собирает M2MTransferRequest из заявки ЛК и данных Fansy.
|
||||
// Шаги:
|
||||
// 1. Поднять анкету клиента.
|
||||
// 2. Поднять депо-счета у передающего депозитария и проверить остатки.
|
||||
// 3. Сгенерировать GUID, ReferenceID для каждой ЦБ, CreationTimestamp.
|
||||
// 4. Заполнить структуру и провалидировать.
|
||||
func EnrichRequest(
|
||||
ctx context.Context,
|
||||
store FansyStore,
|
||||
claim ClaimInput,
|
||||
codes SenderReceiver,
|
||||
) (*m2m.M2MTransferRequest, error) {
|
||||
client, err := store.GetClientByID(ctx, claim.InvestorClientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m2mcore: GetClientByID: %w", err)
|
||||
}
|
||||
accounts, err := store.GetDepoAccounts(ctx, claim.InvestorClientID, claim.TransferringDepositoryINN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m2mcore: GetDepoAccounts: %w", err)
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return nil, fmt.Errorf("m2mcore: у клиента нет активных счетов в передающем депозитарии")
|
||||
}
|
||||
|
||||
guid, err := NewUUIDv4()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
securities := make([]m2m.RequestSecurity, 0, len(claim.Securities))
|
||||
for _, sec := range claim.Securities {
|
||||
refID, err := NewReferenceID(now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Берём первый активный счёт как минимум; реальная логика выбора
|
||||
// settlement_accounts будет в M2 (по типу ЦБ и торговому разделу).
|
||||
settlement := make([]m2m.RequestSettlementAccount, 0, len(accounts))
|
||||
for _, a := range accounts {
|
||||
settlement = append(settlement, m2m.RequestSettlementAccount{
|
||||
SettlementRequisites: m2m.SettlementRequisites{INN: a.DepositoryINN},
|
||||
SettlementLocation: m2m.SettlementDepositoryLocation{
|
||||
DeponentCode: a.DeponentCode,
|
||||
AccountID: a.AccountID,
|
||||
SectionID: a.SectionID,
|
||||
},
|
||||
})
|
||||
}
|
||||
securities = append(securities, m2m.RequestSecurity{
|
||||
ReferenceID: refID,
|
||||
SecurityCode: sec.SecurityCode,
|
||||
SecurityDetails: sec.Details,
|
||||
Quantity: sec.Quantity,
|
||||
SettlementAccount: settlement,
|
||||
IsolationStatus: m2m.IsolationSGDN,
|
||||
})
|
||||
}
|
||||
|
||||
req := &m2m.M2MTransferRequest{
|
||||
Header: m2m.RequestHeader{
|
||||
GUID: m2m.UUID(guid),
|
||||
CreationTimestamp: nsdxml.Now(),
|
||||
SenderCode: codes.SenderCode,
|
||||
ReceiverCode: codes.ReceiverCode,
|
||||
CostInfo: claim.CostInfo,
|
||||
IIAAgreementDetails: claim.IIAAgreement,
|
||||
},
|
||||
Data: m2m.RequestData{
|
||||
InvestorInformation: m2m.InvestorInformation{
|
||||
LastName: client.LastName,
|
||||
FirstName: client.FirstName,
|
||||
MiddleName: client.MiddleName,
|
||||
IdentityDocument: m2m.IdentityDocument{
|
||||
DocumentType: client.Document.DocumentType,
|
||||
DocumentNumber: m2m.IdentityDocSerial(client.Document.Number),
|
||||
},
|
||||
},
|
||||
TransferringDepository: m2m.SettlementRequisites{INN: claim.TransferringDepositoryINN},
|
||||
ReceivingDepository: m2m.SettlementRequisites{INN: claim.ReceivingDepositoryINN},
|
||||
TransferredSecurities: m2m.RequestTransferredSecurities{Securities: securities},
|
||||
},
|
||||
}
|
||||
if claim.IIAAgreement != nil {
|
||||
req.Header.IIAAgreementDetails = claim.IIAAgreement
|
||||
}
|
||||
if client.Document.Series != "" {
|
||||
series := m2m.IdentityDocSerial(client.Document.Series)
|
||||
req.Data.InvestorInformation.IdentityDocument.DocumentSeries = &series
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("m2mcore: собранный M2MTransferRequest невалиден: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package m2mcore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// fakeStore — тестовая реализация FansyStore.
|
||||
type fakeStore struct {
|
||||
client *m2mcore.Client
|
||||
accounts []m2mcore.DepoAccount
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetClientByID(_ context.Context, _ string) (*m2mcore.Client, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
return f.client, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetDepoAccounts(_ context.Context, _ string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) {
|
||||
return f.accounts, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetBalances(_ context.Context, _ string, _ []m2m.SecurityCode) ([]m2mcore.SecurityBalance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestNewReferenceIDFormat(t *testing.T) {
|
||||
at := time.Date(2026, 3, 2, 14, 30, 0, 0, time.UTC)
|
||||
id, err := m2mcore.NewReferenceID(at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(id)[:11] != "M2M20260302" {
|
||||
t.Errorf("префикс/дата ReferenceID неверен: %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestHappyPath(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
client: &m2mcore.Client{
|
||||
ID: "inv-1",
|
||||
LastName: "Иванов",
|
||||
FirstName: "Иван",
|
||||
MiddleName: "Иванович",
|
||||
Document: m2mcore.ClientDocument{
|
||||
DocumentType: m2m.DocCode21,
|
||||
Series: "4512",
|
||||
Number: "654321",
|
||||
},
|
||||
},
|
||||
accounts: []m2mcore.DepoAccount{
|
||||
{
|
||||
ID: "acc-1",
|
||||
ClientID: "inv-1",
|
||||
DeponentCode: "DP789456",
|
||||
AccountID: m2m.AccountID("31MC0021900000F01"),
|
||||
SectionID: "P001",
|
||||
DepositoryINN: m2m.OrganizationINN("7702070139"),
|
||||
},
|
||||
},
|
||||
}
|
||||
whole := uint64(1500)
|
||||
isin := m2m.ISIN("RU0007661625")
|
||||
|
||||
claim := m2mcore.ClaimInput{
|
||||
InvestorClientID: "inv-1",
|
||||
TransferringDepositoryINN: m2m.OrganizationINN("0702345678"),
|
||||
ReceivingDepositoryINN: m2m.OrganizationINN("0710987654"),
|
||||
CostInfo: m2m.CostInfo{
|
||||
No: &m2m.CostInfoNo{},
|
||||
},
|
||||
Securities: []m2mcore.ClaimSecurityInput{
|
||||
{
|
||||
SecurityCode: m2m.SecurityCode("MM0766162534"),
|
||||
Details: m2m.SecurityDetails{ISIN: &isin},
|
||||
Quantity: m2m.Quantity{Whole: &whole},
|
||||
},
|
||||
},
|
||||
}
|
||||
codes := m2mcore.SenderReceiver{
|
||||
SenderCode: m2m.DeponentCode("MC0079200000"),
|
||||
ReceiverCode: m2m.DeponentCode("MC0010300000"),
|
||||
}
|
||||
|
||||
req, err := m2mcore.EnrichRequest(context.Background(), store, claim, codes)
|
||||
if err != nil {
|
||||
t.Fatalf("EnrichRequest: %v", err)
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
t.Fatalf("собранный Request не прошёл валидацию: %v", err)
|
||||
}
|
||||
if req.Data.InvestorInformation.LastName != "Иванов" {
|
||||
t.Errorf("LastName не пробросился")
|
||||
}
|
||||
if len(req.Data.TransferredSecurities.Securities) != 1 {
|
||||
t.Errorf("ожидалась 1 ЦБ, получено %d", len(req.Data.TransferredSecurities.Securities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestNoAccounts(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
client: &m2mcore.Client{LastName: "X", FirstName: "Y", Document: m2mcore.ClientDocument{DocumentType: m2m.DocCode21, Number: "1"}},
|
||||
accounts: nil,
|
||||
}
|
||||
_, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{})
|
||||
if err == nil {
|
||||
t.Errorf("ожидалась ошибка при отсутствии счетов")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestStoreError(t *testing.T) {
|
||||
store := &fakeStore{getErr: errors.New("db down")}
|
||||
_, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{})
|
||||
if err == nil {
|
||||
t.Errorf("ожидалась ошибка от FansyStore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopPortsReturnErrNotImplemented(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if _, err := (m2mcore.NoopNSDSender{}).Send(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
|
||||
t.Errorf("NoopNSDSender.Send ожидалась ErrNotImplemented, получено %v", err)
|
||||
}
|
||||
if err := (m2mcore.NoopNSDSender{}).SendDecision(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
|
||||
t.Errorf("NoopNSDSender.SendDecision ожидалась ErrNotImplemented, получено %v", err)
|
||||
}
|
||||
if err := (m2mcore.NoopLKCallbackClient{}).UpdateStatus(ctx, "", "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
|
||||
t.Errorf("LKCallbackClient ожидалась ErrNotImplemented")
|
||||
}
|
||||
if _, err := (m2mcore.NoopCryptoVerifier{}).VerifyXMLDSig(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
|
||||
t.Errorf("CryptoVerifier ожидалась ErrNotImplemented")
|
||||
}
|
||||
if _, err := (m2mcore.NoopFansyStore{}).GetClientByID(ctx, ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
|
||||
t.Errorf("FansyStore.GetClientByID ожидалась ErrNotImplemented")
|
||||
}
|
||||
if _, err := (m2mcore.NoopFansyStore{}).GetDepoAccounts(ctx, "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
|
||||
t.Errorf("FansyStore.GetDepoAccounts ожидалась ErrNotImplemented")
|
||||
}
|
||||
if _, err := (m2mcore.NoopFansyStore{}).GetBalances(ctx, "", nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
|
||||
t.Errorf("FansyStore.GetBalances ожидалась ErrNotImplemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package m2mcore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// State — конечное состояние сделки M2M.
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateDraft State = "draft"
|
||||
StateValidated State = "validated"
|
||||
StateSubmittedToNSD State = "submitted_to_nsd"
|
||||
StateAwaitingDecision State = "awaiting_decision"
|
||||
StateConfirmed State = "confirmed"
|
||||
StateAwaitingSUB16 State = "awaiting_sub16"
|
||||
StateDone State = "done"
|
||||
StateRejected State = "rejected"
|
||||
StateTimedOut State = "timed_out"
|
||||
StateManualApproval State = "manual_approval"
|
||||
)
|
||||
|
||||
// ErrInvalidTransition возвращается при попытке перейти в состояние,
|
||||
// которое не разрешено из текущего.
|
||||
var ErrInvalidTransition = errors.New("m2mcore: недопустимый переход FSM")
|
||||
|
||||
// allowedTransitions — карта разрешённых переходов FSM сделки.
|
||||
// Любая попытка перейти в state, отсутствующее в списке для текущего,
|
||||
// заканчивается ErrInvalidTransition.
|
||||
var allowedTransitions = map[State]map[State]struct{}{
|
||||
StateDraft: {
|
||||
StateValidated: {},
|
||||
StateRejected: {},
|
||||
StateManualApproval: {},
|
||||
},
|
||||
StateValidated: {
|
||||
StateSubmittedToNSD: {},
|
||||
StateRejected: {},
|
||||
StateManualApproval: {},
|
||||
},
|
||||
StateSubmittedToNSD: {
|
||||
StateAwaitingDecision: {},
|
||||
StateRejected: {},
|
||||
},
|
||||
StateAwaitingDecision: {
|
||||
StateConfirmed: {},
|
||||
StateRejected: {},
|
||||
StateTimedOut: {},
|
||||
StateManualApproval: {},
|
||||
},
|
||||
StateConfirmed: {
|
||||
StateAwaitingSUB16: {},
|
||||
StateDone: {},
|
||||
},
|
||||
StateAwaitingSUB16: {
|
||||
StateDone: {},
|
||||
},
|
||||
StateManualApproval: {
|
||||
StateValidated: {},
|
||||
StateConfirmed: {},
|
||||
StateRejected: {},
|
||||
},
|
||||
// Завершающие состояния — без выходов.
|
||||
StateDone: {},
|
||||
StateRejected: {},
|
||||
StateTimedOut: {},
|
||||
}
|
||||
|
||||
// IsTerminal возвращает true для завершающих состояний.
|
||||
func IsTerminal(s State) bool {
|
||||
switch s {
|
||||
case StateDone, StateRejected, StateTimedOut:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanTransition сообщает, разрешён ли переход from -> to.
|
||||
func CanTransition(from, to State) bool {
|
||||
allowed, ok := allowedTransitions[from]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = allowed[to]
|
||||
return ok
|
||||
}
|
||||
|
||||
// transition проверяет переход и возвращает обёрнутую ошибку с
|
||||
// контекстом, если он недопустим.
|
||||
func transition(from, to State) error {
|
||||
if !CanTransition(from, to) {
|
||||
return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, from, to)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package m2mcore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
func newTestDeal(t *testing.T) *m2mcore.Deal {
|
||||
t.Helper()
|
||||
guid, err := m2mcore.NewUUIDv4()
|
||||
if err != nil {
|
||||
t.Fatalf("UUID: %v", err)
|
||||
}
|
||||
d, err := m2mcore.NewDeal(m2m.UUID(guid), "00000000-0000-0000-0000-000000000001", []byte("<xml/>"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDeal: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestFSMAllowedTransitions(t *testing.T) {
|
||||
cases := []struct {
|
||||
from m2mcore.State
|
||||
to m2mcore.State
|
||||
ok bool
|
||||
}{
|
||||
{m2mcore.StateDraft, m2mcore.StateValidated, true},
|
||||
{m2mcore.StateDraft, m2mcore.StateSubmittedToNSD, false},
|
||||
{m2mcore.StateValidated, m2mcore.StateSubmittedToNSD, true},
|
||||
{m2mcore.StateAwaitingDecision, m2mcore.StateConfirmed, true},
|
||||
{m2mcore.StateAwaitingDecision, m2mcore.StateRejected, true},
|
||||
{m2mcore.StateAwaitingDecision, m2mcore.StateTimedOut, true},
|
||||
{m2mcore.StateConfirmed, m2mcore.StateAwaitingSUB16, true},
|
||||
{m2mcore.StateDone, m2mcore.StateRejected, false},
|
||||
{m2mcore.StateRejected, m2mcore.StateDone, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := m2mcore.CanTransition(c.from, c.to); got != c.ok {
|
||||
t.Errorf("CanTransition(%s,%s)=%v ожидалось %v", c.from, c.to, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSMIsTerminal(t *testing.T) {
|
||||
for _, s := range []m2mcore.State{m2mcore.StateDone, m2mcore.StateRejected, m2mcore.StateTimedOut} {
|
||||
if !m2mcore.IsTerminal(s) {
|
||||
t.Errorf("%s должно быть терминальным", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []m2mcore.State{m2mcore.StateDraft, m2mcore.StateConfirmed} {
|
||||
if m2mcore.IsTerminal(s) {
|
||||
t.Errorf("%s не должно быть терминальным", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealManualReject(t *testing.T) {
|
||||
d := newTestDeal(t)
|
||||
if err := d.SendToManualApproval(context.Background(), "ambiguous_decision"); err != nil {
|
||||
t.Fatalf("SendToManualApproval из Draft неожиданно дал ошибку %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealInvalidTransition(t *testing.T) {
|
||||
d := newTestDeal(t)
|
||||
// Draft -> SubmittedToNSD не разрешён.
|
||||
err := d.Submit(context.Background())
|
||||
if !errors.Is(err, m2mcore.ErrInvalidTransition) {
|
||||
t.Errorf("ожидалась ErrInvalidTransition, получено %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealCompleteSUB16(t *testing.T) {
|
||||
d := newTestDeal(t)
|
||||
if err := d.ApproveManually(context.Background(), "operator-1"); err == nil {
|
||||
// Из Draft нельзя сразу ApproveManually — нужен путь через
|
||||
// ManualApproval. Это тоже проверяем.
|
||||
t.Errorf("ApproveManually из Draft должен был отказать")
|
||||
}
|
||||
if err := d.SendToManualApproval(context.Background(), "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.ApproveManually(context.Background(), "operator-1"); err != nil {
|
||||
t.Fatalf("ApproveManually: %v", err)
|
||||
}
|
||||
if err := d.CompleteSUB16(context.Background()); err != nil {
|
||||
t.Fatalf("CompleteSUB16: %v", err)
|
||||
}
|
||||
if d.State != m2mcore.StateDone {
|
||||
t.Errorf("конечное состояние %s, ожидалось %s", d.State, m2mcore.StateDone)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user