bf7c7b53bd
Настройка в шаге «Время и сеть» и Настройка → Сервер: дни недели, часы (по поясу сервера), включение правила. Запрос, полученный вне рабочего времени (и после включения правила), bj сразу отклоняет по всем бумагам кодом M2M06 «Приостановление операций по счёту депо зачисления» — кода «вне рабочего времени» в перечне НРД (инструкция по обмену M2M, раздел 7.4) нет, решение депозитария. Строка «Рабочее время» в проверке готовности; сквозной тест отказа через ИШ. Файл входящих — переменная (тесты не трогают сервер). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
903 lines
44 KiB
Go
903 lines
44 KiB
Go
package lkgateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"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"`
|
||
Fancy FancyMSSQLSettings `json:"fancy_mssql"`
|
||
Quik QuikMNPSettings `json:"quik_mnp"`
|
||
M2MAuto M2MAutoSettings `json:"m2m_auto"`
|
||
Crypto CryptoSettings `json:"crypto"`
|
||
NSD NSDSettings `json:"nsd"`
|
||
LK LKSettings `json:"lk"`
|
||
CACerts CACertsSettings `json:"ca_certs"`
|
||
NSDCerts NSDCertsSettings `json:"nsd_certs"`
|
||
News NewsSettings `json:"news"`
|
||
Alerts AlertSettings `json:"alerts"`
|
||
Incoming IncomingNotifySettings `json:"incoming_notify"`
|
||
Update UpdateSettings `json:"update"`
|
||
License LicenseSettings `json:"license"`
|
||
Branding BrandingSettings `json:"branding"`
|
||
NPR NPRSettings `json:"npr"`
|
||
QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport
|
||
Auth AuthSettings `json:"auth"`
|
||
Backup BackupSettings `json:"backup,omitempty"`
|
||
HTTPS HTTPSSettings `json:"https,omitempty"`
|
||
Time TimeSettings `json:"time,omitempty"`
|
||
WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06
|
||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// QuikMNPSettings — подключение к БД Модуля неторговых поручений QUIK (MSSQL).
|
||
// Используется для блокировки бумаг при выводе (M2M). БД боевая — работаем на
|
||
// чтение + вызовы штатных хранимых процедур API (роль InstrEu). Прямых INSERT/
|
||
// UPDATE в таблицы НЕ делаем.
|
||
type QuikMNPSettings struct {
|
||
Server string `json:"server"` // host или host\instance
|
||
Port int `json:"port"` // напр. 8849 (нестандартный)
|
||
Database string `json:"database"` // имя БД МНП (если требуется)
|
||
User string `json:"user"` // SQL-логин (роль InstrEu), напр. BROKER_88
|
||
Password string `json:"password"` // пароль
|
||
Encrypt string `json:"encrypt"` // disable | true (по умолчанию true+trust для боевой)
|
||
Trust bool `json:"trust"` // TrustServerCertificate (самоподписанный серт)
|
||
Verified bool `json:"verified"` // последнее тест-подключение прошло
|
||
LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам)
|
||
LastAt time.Time `json:"last_at"` // время последнего теста
|
||
M2MMissing string `json:"m2m_missing,omitempty"` // ключевые объекты M2M, не видные под логином при последней проверке
|
||
|
||
// Автоматический цикл M2M в МНП (пишущие операции к боевой БД).
|
||
WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП
|
||
Emulate bool `json:"emulate"` // имитация: шаги цикла проходят без записи в МНП (нет пользователя QUIK для блокировок)
|
||
AutoOut bool `json:"auto_out"` // исходящие: регистрировать «Вывод ЦБ (M2M)» при отправке, подписывать/снимать по ответу
|
||
AutoIn bool `json:"auto_in"` // входящие: регистрировать «Ввод ЦБ (M2M)» после приёма поручения НРД
|
||
PollTerminal bool `json:"poll_terminal"` // подхватывать поручения, поданные клиентами из QUIK (Export)
|
||
Account string `json:"account"` // торговый счёт для регистрации (напр. L01+00000F00)
|
||
SecOut string `json:"sec_out"` // код МНП-инструмента вывода (пусто = определить по классу INSTROUTM2M)
|
||
SecIn string `json:"sec_in"` // код МНП-инструмента ввода (пусто = по классу INSTRINM2M)
|
||
ClientCodeMap map[string]string `json:"client_code_map"` // исключения: номер портфеля DEPO («БР-4-4») → код клиента QUIK
|
||
}
|
||
|
||
// LicenseSettings — лицензионный ключ (подписанный токен).
|
||
type LicenseSettings struct {
|
||
Key string `json:"key"` // компактный токен payload.signature.keyid
|
||
PublicKey string `json:"public_key,omitempty"` // устарело: ключ проверки зашит в bj, поле не используется
|
||
|
||
// Онлайн-лицензирование (licenseclient.go).
|
||
InstallID string `json:"install_id,omitempty"` // идентификатор этой установки на сервере лицензий
|
||
LastSync time.Time `json:"last_sync,omitempty"` // последнее успешное обращение к серверу лицензий
|
||
LastAttempt time.Time `json:"last_attempt,omitempty"` // последняя попытка
|
||
LastOK bool `json:"last_ok,omitempty"`
|
||
LastMsg string `json:"last_msg,omitempty"`
|
||
}
|
||
|
||
// UpdateSettings — авто-обновления из артефактории (#18/#20).
|
||
type UpdateSettings struct {
|
||
BaseURL string `json:"base_url"` // https://updates.example.com
|
||
Channel string `json:"channel"` // "stable" | "beta"
|
||
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
|
||
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
||
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
||
LastResult string `json:"last_result"` // текст результата проверки
|
||
// CheckFailed — последняя ПРОВЕРКА обновлений не удалась (сервер недоступен, нет лицензии…):
|
||
// тогда версии из прошлой проверки не предлагаются. Неудачная установка сюда не относится.
|
||
CheckFailed bool `json:"check_failed,omitempty"`
|
||
// DisabledModules — модули, отключённые как снятые с лицензии (имя → когда): служба остановлена,
|
||
// в списке компонентов не показываются; при возврате в лицензию включаются обратно.
|
||
DisabledModules map[string]string `json:"disabled_modules,omitempty"`
|
||
Available string `json:"available_version"` // доступная версия (если новее)
|
||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
|
||
Components map[string]string `json:"components,omitempty"`
|
||
// ComponentsChannel — канал, в котором эти версии были найдены. Если канал
|
||
// сменили, данные прошлой проверки к новому каналу не относятся: предлагать
|
||
// по ним обновление нельзя (кнопка «Обновить» уходила бы в пустоту).
|
||
ComponentsChannel string `json:"components_channel,omitempty"`
|
||
// Notified — выпуск, о котором уже написали администратору («Доступно обновление bj»): по разу на выпуск.
|
||
Notified string `json:"notified,omitempty"`
|
||
}
|
||
|
||
// 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 и сохраняет сертификат, если он поменялся.
|
||
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"`
|
||
}
|
||
|
||
// FancyMSSQLSettings — подключение к MSSQL-серверу клиента: канал обмена с
|
||
// Fancy (очередь запросов из ЛК + ответы «крана») и журнал обмена с НРД.
|
||
// Аутентификация — SQL-логин (SQL Server Authentication).
|
||
type FancyMSSQLSettings struct {
|
||
DBType string `json:"db_type"` // "mssql" | "postgres" (пусто = mssql)
|
||
Server string `json:"server"` // host или host\instance
|
||
Port int `json:"port"` // 1433 (MSSQL) / 5432 (Postgres)
|
||
User string `json:"user"` // логин
|
||
Password string `json:"password"` // пароль
|
||
Encrypt string `json:"encrypt"` // MSSQL: disable|false|true; Postgres: sslmode
|
||
// AccountPrefix — начало номера торгового счёта клиента в OD_CLIENTS (у Фонтвьель «ВТНКЦ»): по нему bj
|
||
// находит счёт инвестора при сверке переводов, в цикле QUIK и при ручном вводе. Пусто — «ВТНКЦ».
|
||
AccountPrefix string `json:"account_prefix,omitempty"`
|
||
|
||
// Обработка трансферных таблиц DEPO силами bj (вместо джобы SQL Agent bj2depo):
|
||
// bj сам вызывает процедуру DepoJobProc каждые DepoJobEverySec секунд.
|
||
DepoJobRun bool `json:"depo_job_run"`
|
||
DepoJobMode string `json:"depo_job_mode"` // bj | sql-auto | sql-manual (пусто = не выбран)
|
||
DepoJobCheck string `json:"depo_job_check"` // результат последней проверки готовности
|
||
DepoJobCheckOK bool `json:"depo_job_check_ok"`
|
||
DepoJobCheckAt time.Time `json:"depo_job_check_at"`
|
||
// DepoProcCheck — работает ли обработка событий процедурами (BJ_PING), отдельно от запуска джобы.
|
||
DepoProcCheck string `json:"depo_proc_check,omitempty"`
|
||
DepoProcCheckOK bool `json:"depo_proc_check_ok,omitempty"`
|
||
DepoProcCheckAt time.Time `json:"depo_proc_check_at,omitempty"`
|
||
DepoJobEverySec int `json:"depo_job_every_sec"`
|
||
DepoJobProc string `json:"depo_job_proc"`
|
||
DepoJobRunAs string `json:"depo_job_run_as,omitempty"` // пользователь базы DEPO для EXECUTE AS процедуры (пусто = SYSDBA)
|
||
DepoJobLast string `json:"depo_job_last"` // последний результат (для карточки)
|
||
DepoJobLastAt time.Time `json:"depo_job_last_at"`
|
||
|
||
// Трансферные таблицы FTV_M2M* в базе DEPO (depotables.go).
|
||
DepoDatabase string `json:"depo_database"` // база DEPO; пусто = BJ_DEPO_DATA
|
||
SpectreDatabase string `json:"spectre_database,omitempty"` // база SPECTRE (брокерский учёт); пусто = BJ_SPECTRE_DATA
|
||
IISContractMarker string `json:"iis_contract_marker,omitempty"` // начало названия договора ИИС в DEPO (через запятую); пусто = БРИИС
|
||
DepoTablesMode string `json:"depo_tables_mode"` // auto (создаёт bj) | script (администратор СУБД)
|
||
DepoTablesCheck string `json:"depo_tables_check"`
|
||
DepoTablesCheckOK bool `json:"depo_tables_check_ok"`
|
||
DepoTablesCheckAt time.Time `json:"depo_tables_check_at"`
|
||
// Логин и пароль bj из выданного скрипта, ещё не проверенные входом. В работу (User/Password) — только
|
||
// после успешного входа: службы bj ходят в DEPO с рабочими, и неверный пароль от них за минуты блокирует
|
||
// логин на сервере (CHECK_POLICY) — для всех, кто им пользуется.
|
||
// Имя задания SQL Server Agent для этой базы (depoJobName); пусто — bj2depo, как до 06.10.2026.
|
||
DepoJobName string `json:"depo_job_name,omitempty"`
|
||
DepoNewUser string `json:"depo_new_user,omitempty"`
|
||
DepoNewPass string `json:"depo_new_password,omitempty"`
|
||
}
|
||
|
||
// IsPostgres сообщает, выбран ли PostgreSQL (иначе — MSSQL по умолчанию).
|
||
func (s FancyMSSQLSettings) IsPostgres() bool { return s.DBType == "postgres" }
|
||
|
||
// DefaultPort возвращает порт по типу СУБД, если он не задан явно.
|
||
func (s FancyMSSQLSettings) DefaultPort() int {
|
||
if s.Port != 0 {
|
||
return s.Port
|
||
}
|
||
if s.IsPostgres() {
|
||
return 5432
|
||
}
|
||
return 1433
|
||
}
|
||
|
||
// CryptoSettings — путь к PKCS#11 модулю и тип провайдера.
|
||
type CryptoSettings struct {
|
||
Provider string `json:"provider"` // "stub" | "validata"
|
||
SocketPath string `json:"socket_path"` // UDS crypto-service
|
||
ModulePath string `json:"module_path"` // путь до .so модуля PKCS#11
|
||
Profile string `json:"profile"` // активный профиль Валидаты (имя из pki1.conf)
|
||
}
|
||
|
||
// 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"` // имя контейнера (на стороне ИШ)
|
||
|
||
// Депозитарные реквизиты клиента — откуда списываются бумаги
|
||
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
|
||
// Наша организация — участник M2M (выбирается из справочника НРД или вводится при настройке).
|
||
OrgCode string `json:"org_code,omitempty"` // код участника (отправитель пакетов), напр. MC0000000000
|
||
OrgINN string `json:"org_inn,omitempty"` // ИНН — передающий/зачисляющий депозитарий в пакетах
|
||
OrgName string `json:"org_name,omitempty"`
|
||
|
||
DeponentCode string `json:"deponent_code"` // депозитарный код в НРД (место хранения)
|
||
AccountID string `json:"account_id"` // депозитарный счёт
|
||
SectionID string `json:"section_id"` // раздел депозитарного счёта
|
||
|
||
// IGWv904 — ИШ обновлён до версии 107.0.0.904+ (Astra 107.0.907+): с этой
|
||
// версии в config.xml ОБЯЗАТЕЛЬНО указывать businessLine=MOST для M2M; на
|
||
// версиях младше — НЕ допускается. По умолчанию false (совместимо со старым
|
||
// ИШ — элемент не добавляется).
|
||
IGWv904 bool `json:"igw_v904"`
|
||
|
||
// Контуры (05.10.2026): база DEPO для TEST3 и для боевого — на тесте часто копия учёта. Запоминаются при
|
||
// переключении контура, чтобы вернуться без перенастройки.
|
||
DepoDBTest string `json:"depo_db_test,omitempty"`
|
||
DepoDBProd string `json:"depo_db_prod,omitempty"`
|
||
SwitchedAt time.Time `json:"switched_at,omitempty"`
|
||
}
|
||
|
||
// LKSettings — настройки callback в ЛК клиента.
|
||
type LKSettings struct {
|
||
CallbackURL string `json:"callback_url"`
|
||
// PublicURL — адрес личного кабинета клиента (стенд/бой), куда оператор
|
||
// заходит подать тестовую заявку. Показывается ссылкой на дашборде.
|
||
PublicURL string `json:"lk_public_url"`
|
||
// APIKey — ключ доступа для модуля Личного кабинета клиента (bj-lk).
|
||
// Модуль ставится на веб-сервер клиента и обращается к bj-server по этому
|
||
// ключу (заголовок X-BJ-Key). Пустой ключ = модульный API выключен.
|
||
APIKey string `json:"lk_api_key"`
|
||
// CoreURL — адрес ядра bj, по которому кабинет к нему обращается (кабинет стоит
|
||
// на отдельном веб-сервере). Пусто — подставляем адрес текущего сервера.
|
||
CoreURL string `json:"lk_core_url,omitempty"`
|
||
// AgentMode — режим ожидания: у ядра нет адреса, доступного из интернета,
|
||
// поэтому не кабинет обращается к ядру, а ядро само подключается к кабинету.
|
||
AgentMode bool `json:"lk_agent_mode,omitempty"`
|
||
// CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания.
|
||
CabinetURL string `json:"lk_cabinet_url,omitempty"`
|
||
// PreviewOn — кабинет запущен на этом же сервере на отдельном порту (просмотр из сети
|
||
// депозитария без размещения на хостинге). PreviewListen — адрес:порт, по умолчанию :8098.
|
||
PreviewOn bool `json:"lk_preview_on,omitempty"`
|
||
PreviewListen string `json:"lk_preview_listen,omitempty"`
|
||
// BindNetwork — привязывать сессию клиента не только к браузеру, но и к сети /24.
|
||
// Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором.
|
||
BindNetwork bool `json:"lk_bind_network,omitempty"`
|
||
|
||
// RealNSDTest — тест «боевой ЛК → тестовый контур НРД»: заявки указанных клиентов проходят всё
|
||
// по-настоящему (DEPO, QUIK МНП, статусы в ЛК), а в НРД уходят с данными тест-инвестора (realtest.go).
|
||
RealNSDTest RealNSDTestSettings `json:"real_nsd_test,omitempty"`
|
||
// PortfolioSync — портфель ДЕПО из DEPO в ЛК ЕСИА Финанс (lkportfolio.go): каждый проход опроса ЛК.
|
||
PortfolioSync LKPortfolioSyncSettings `json:"portfolio_sync,omitempty"`
|
||
|
||
// Интеграция с ЛК (потребление back_office API платформы): два окружения.
|
||
ActiveEnv string `json:"lk_active_env"` // "test" | "prod"
|
||
Test LKEndpoint `json:"lk_test"`
|
||
Prod LKEndpoint `json:"lk_prod"`
|
||
|
||
// FXSource — откуда брать курсы валют для оценки валютных выпусков:
|
||
// пусто/"cbr" — Банк России (открытый XML, без ключа), "accounting" — курс
|
||
// учётной системы (OD_VALUES_RATES), если сервер стоит без выхода в интернет.
|
||
FXSource string `json:"lk_fx_source,omitempty"`
|
||
// SnapshotMode — ежедневный срез стоимости портфелей (из него кабинет рисует график):
|
||
// пусто — автоматически (ведём, если настроен экспорт QUIK), "on" — всегда, "off" — не вести.
|
||
// SnapshotAt — время снятия среза, «ЧЧ:ММ» (пусто = 20:30, после закрытия торгов).
|
||
SnapshotMode string `json:"lk_snapshot_mode,omitempty"`
|
||
SnapshotAt string `json:"lk_snapshot_at,omitempty"`
|
||
|
||
// EmulateNSD — заявки ЛК проходят весь путь (сверка DEPO, блокировка QUIK, сделка,
|
||
// статус в ЛК), но в НРД не уходят: отправку и ответ контрагента имитирует bj.
|
||
// Для боевого ЛК при тестовом профиле ИШ имитация включается принудительно.
|
||
EmulateNSD bool `json:"lk_emulate_nsd"`
|
||
}
|
||
|
||
// LKEndpoint — реквизиты подключения к API ЛК (HTTP Basic) для одного окружения.
|
||
type LKEndpoint struct {
|
||
BaseURL string `json:"base_url"` // напр. https://fontvielle.ef.winr.pro/manager/api
|
||
Login string `json:"login"` // Basic-логин
|
||
Password string `json:"password"` // Basic-пароль
|
||
LastOK bool `json:"last_ok"` // результат последней «Проверить связь»
|
||
LastMsg string `json:"last_msg"` // текст результата
|
||
LastAt time.Time `json:"last_at"` // время последней проверки
|
||
}
|
||
|
||
// 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
|
||
}
|
||
migrateNotifyRoutes(&rc.data) // и на новой установке: версия маршрутов сохранится с первой записью настроек
|
||
// Гарантируем разумные дефолты.
|
||
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
|
||
}
|
||
|
||
// BrandingSettings — оформление отчётов (логотип организации в шапке).
|
||
type BrandingSettings struct {
|
||
LogoDataURI string `json:"logo_data_uri,omitempty"` // data:image/...;base64,... (пусто = встроенный логотип по умолчанию)
|
||
LogoName string `json:"logo_name,omitempty"` // имя загруженного файла (для UI)
|
||
}
|
||
|
||
// UpdateBranding сохраняет настройки оформления (логотип отчётов).
|
||
func (r *RuntimeConfig) UpdateBranding(s BrandingSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Branding = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
// UpdateFancy сохраняет настройки подключения к MSSQL (канал обмена с Fancy).
|
||
func (r *RuntimeConfig) UpdateFancy(s FancyMSSQLSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Fancy = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateQuik сохраняет настройки подключения к БД МНП QUIK.
|
||
func (r *RuntimeConfig) UpdateQuik(s QuikMNPSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Quik = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// SaveLicense сохраняет лицензионные настройки.
|
||
func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error {
|
||
r.mu.Lock()
|
||
r.data.License = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// SaveUpdateSettings сохраняет настройки авто-обновлений.
|
||
func (r *RuntimeConfig) SaveUpdateSettings(s UpdateSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Update = 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()
|
||
applyBusinessLine(s.IGWv904) // сразу применяем businessLine к генератору config.xml
|
||
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()
|
||
}
|
||
|
||
// UpdateBackup сохраняет настройки резервного копирования.
|
||
func (r *RuntimeConfig) UpdateBackup(s BackupSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Backup = s
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateHTTPS сохраняет настройки HTTPS веб-интерфейса.
|
||
func (r *RuntimeConfig) UpdateHTTPS(s HTTPSSettings) error {
|
||
r.mu.Lock()
|
||
r.data.HTTPS = s
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateTime сохраняет настройки синхронизации времени.
|
||
func (r *RuntimeConfig) UpdateTime(s TimeSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Time = 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()
|
||
}
|
||
|
||
// AlertSettings — каналы внешней доставки системных оповещений (сверх ленты
|
||
// «Новости»). SMTP — почта оператору; Zabbix — trapper-хук в мониторинг.
|
||
type AlertSettings struct {
|
||
SMTP SMTPSettings `json:"smtp"`
|
||
Zabbix ZabbixSettings `json:"zabbix"`
|
||
}
|
||
|
||
// SMTPSettings — параметры отправки уведомлений по e-mail.
|
||
type SMTPSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Host string `json:"host"`
|
||
Port int `json:"port"` // 587 (STARTTLS), 465 (SSL), 25
|
||
Security string `json:"security"` // "starttls" | "ssl" | "none"
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
From string `json:"from"`
|
||
To string `json:"to"` // администратор системы (через запятую) — задаётся в «Кому писать»
|
||
// TestTo — куда слать проверочное письмо (поле в блоке «Почта»); пусто — администратору системы.
|
||
TestTo string `json:"test_to,omitempty"`
|
||
LastMsg string `json:"last_msg"`
|
||
LastAt time.Time `json:"last_at"`
|
||
}
|
||
|
||
// ZabbixSettings — trapper-хук: bj шлёт значение элемента данных на сервер/прокси
|
||
// Zabbix (протокол zabbix_sender). В Zabbix заводится host + item типа
|
||
// «Zabbix trapper» с указанным ключом; триггер по значению шлёт уведомления.
|
||
type ZabbixSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Server string `json:"server"` // адрес сервера/прокси Zabbix
|
||
Port int `json:"port"` // 10051 по умолчанию
|
||
Host string `json:"host"` // имя хоста в Zabbix (host.host)
|
||
Key string `json:"key"` // ключ элемента-trapper, напр. bj.alert
|
||
LastMsg string `json:"last_msg"`
|
||
LastAt time.Time `json:"last_at"`
|
||
}
|
||
|
||
func (r *RuntimeConfig) UpdateAlerts(s AlertSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Alerts = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// IncomingNotifySettings — уведомления депозитарию о входящих бумагах (перевод
|
||
// НАМ по M2M). Пока — письмо на почту «к вам переведена бумага, требует учёта»;
|
||
// позже здесь же сформируем пакет для загрузки в учётную систему.
|
||
type IncomingNotifySettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Emails string `json:"emails"` // депозитарий (через запятую)
|
||
EmailsBack string `json:"emails_back"` // бэк-офис
|
||
EmailsMiddle string `json:"emails_middle"` // мидл-офис
|
||
// Маршрутизация: тип уведомления → группы через запятую (admin,depo,back,middle). Адрес группы admin
|
||
// (администратор системы) — Alerts.SMTP.To. Типы: incoming (входящие переводы), orders (сроки депо-поручений),
|
||
// quik (QUIK МНП), infra (сбои шлюза, связи с НРД, служб, DEPO), updates (обновления, лицензия, сертификаты),
|
||
// system (прочие).
|
||
Routes map[string]string `json:"routes"`
|
||
// RoutesV — версия маршрутов: 2 — с группой «Администратор системы» (06.10.2026), см. migrateNotifyRoutes.
|
||
RoutesV int `json:"routes_v,omitempty"`
|
||
}
|
||
|
||
// Группы получателей и типы уведомлений (порядок — для формы). Администратор системы — тот, кто отвечает за
|
||
// сервер и ИТ: ему сбои и обновления, которые отделам (депозитарий, бэк- и мидл-офис) не нужны.
|
||
var notifyGroups = []struct{ Key, Title string }{{"admin", "Администратор"}, {"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}}
|
||
var notifyKinds = []struct{ Key, Title string }{
|
||
{"incoming", "Входящие переводы (получен / принят)"},
|
||
{"orders", "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"},
|
||
{"quik", "QUIK МНП (ошибки блокировки / списания / зачисления)"},
|
||
{"infra", "Сбои: шлюз НРД, связь с НРД, службы bj, обмен с DEPO"},
|
||
{"updates", "Обновления bj и шлюза, лицензия, сертификаты"},
|
||
{"system", "Прочие оповещения"},
|
||
}
|
||
|
||
// visibleNotifyKinds — типы уведомлений этой установки: ошибки QUIK МНП — только при модуле в лицензии.
|
||
func visibleNotifyKinds(rc *RuntimeConfig) []struct{ Key, Title string } {
|
||
var out []struct{ Key, Title string }
|
||
for _, k := range notifyKinds {
|
||
if k.Key == "quik" && rc != nil && !moduleAllowed(rc, "quik-mnp") {
|
||
continue
|
||
}
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои: сбои и обновления —
|
||
// администратору, переводы и поручения — отделам.
|
||
var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle",
|
||
"infra": "admin", "updates": "admin", "system": "admin,back"}
|
||
|
||
// RouteHas — получает ли группа уведомления данного типа (тип, которого нет в сохранённых маршрутах, — по умолчанию).
|
||
func (s IncomingNotifySettings) RouteHas(kind, group string) bool {
|
||
route, ok := s.Routes[kind]
|
||
if !ok {
|
||
route = defaultNotifyRoutes[kind]
|
||
}
|
||
for _, g := range strings.Split(route, ",") {
|
||
if strings.TrimSpace(g) == group {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// GroupEmails — адреса группы.
|
||
func (s IncomingNotifySettings) GroupEmails(group string) string {
|
||
switch group {
|
||
case "depo":
|
||
return s.Emails
|
||
case "back":
|
||
return s.EmailsBack
|
||
case "middle":
|
||
return s.EmailsMiddle
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// migrateNotifyRoutes — переход на маршруты с группой «Администратор системы». До неё адрес из раздела «Почта»
|
||
// получал все системные оповещения, а сбои и обновления были «прочими»: на существующей установке администратор
|
||
// получает всё то же, что и раньше (лишнее снимается в таблице), сбои и обновления идут тем же группам, что
|
||
// «прочие». Галочка «уведомления о переводах» заменена строкой таблицы: выключенная — пустой маршрут входящих.
|
||
// Новая установка (ни адреса, ни маршрутов) — маршруты по умолчанию.
|
||
func migrateNotifyRoutes(s *Settings) {
|
||
in := &s.Incoming
|
||
if in.RoutesV >= 2 {
|
||
return
|
||
}
|
||
in.RoutesV = 2
|
||
if in.Routes == nil && strings.TrimSpace(s.Alerts.SMTP.To) == "" && !in.Enabled {
|
||
in.Enabled = true
|
||
return
|
||
}
|
||
old := in.Routes
|
||
if old == nil {
|
||
old = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"}
|
||
}
|
||
r := map[string]string{}
|
||
for k, v := range old {
|
||
r[k] = v
|
||
}
|
||
r["infra"], r["updates"] = old["system"], old["system"]
|
||
if strings.TrimSpace(s.Alerts.SMTP.To) != "" {
|
||
for _, k := range []string{"orders", "quik", "infra", "updates", "system"} {
|
||
r[k] = strings.Trim(r[k]+",admin", ",")
|
||
}
|
||
}
|
||
if !in.Enabled {
|
||
r["incoming"] = ""
|
||
}
|
||
in.Enabled = true
|
||
in.Routes = r
|
||
}
|
||
|
||
// Recipients — адреса отделов (депозитарий, бэк-, мидл-офис), на которые направлен тип уведомления (без дублей);
|
||
// с администратором системы — notifyRecipients.
|
||
func (s IncomingNotifySettings) Recipients(kind string) string {
|
||
seen := map[string]bool{}
|
||
var out []string
|
||
for _, g := range notifyGroups {
|
||
if !s.RouteHas(kind, g.Key) {
|
||
continue
|
||
}
|
||
for _, e := range strings.Split(s.GroupEmails(g.Key), ",") {
|
||
e = strings.TrimSpace(e)
|
||
if e == "" || seen[strings.ToLower(e)] {
|
||
continue
|
||
}
|
||
seen[strings.ToLower(e)] = true
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
return strings.Join(out, ", ")
|
||
}
|
||
|
||
// notifyRecipients — все адреса типа уведомления: администратор системы (адрес из «Почты») и отделы.
|
||
func notifyRecipients(s Settings, kind string) string {
|
||
admin := ""
|
||
if s.Incoming.RouteHas(kind, "admin") {
|
||
admin = s.Alerts.SMTP.To
|
||
}
|
||
return mergeEmails(admin, s.Incoming.Recipients(kind))
|
||
}
|
||
|
||
func (r *RuntimeConfig) UpdateIncoming(s IncomingNotifySettings) error {
|
||
r.mu.Lock()
|
||
r.data.Incoming = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
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",
|
||
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 == "" {
|
||
if devMode(globalRC) {
|
||
return "DSN не задан — система в режиме in-memory (M2-демо)"
|
||
}
|
||
return "База данных не настроена — данные не сохраняются между перезапусками"
|
||
}
|
||
return "DSN настроен: " + maskDSN(dsn)
|
||
}
|
||
|
||
func cryptoMsg(c CryptoSettings) string {
|
||
if c.Provider == "" || c.Provider == "stub" {
|
||
if devMode(globalRC) {
|
||
return "Криптография не настроена (provider=stub) — реальная подпись недоступна."
|
||
}
|
||
return "Средство подписи (СКЗИ) не настроено — подпись и обмен с НРД недоступны."
|
||
}
|
||
if c.ModulePath == "" {
|
||
return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан."
|
||
}
|
||
return "Провайдер " + c.Provider + ", PKCS#11 модуль: " + c.ModulePath
|
||
}
|
||
|
||
func nsdMsg(n NSDSettings) string {
|
||
if n.IGWBaseURL == "" {
|
||
if devMode(globalRC) {
|
||
return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)"
|
||
}
|
||
return "ИШ НРД не настроен — обмен с НРД не выполняется"
|
||
}
|
||
if n.Profile == "" {
|
||
return "URL ИШ задан, но профиль не выбран"
|
||
}
|
||
return "Профиль " + n.Profile + ", ИШ " + n.IGWBaseURL
|
||
}
|
||
|
||
func lkMsg(l LKSettings) string {
|
||
if l.CallbackURL == "" {
|
||
if devMode(globalRC) {
|
||
return "Callback URL не настроен — используется встроенный lk-emulator"
|
||
}
|
||
return "Callback URL не настроен — статусы в личный кабинет не отправляются"
|
||
}
|
||
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
|
||
}
|