package lkgateway import ( "encoding/json" "errors" "log" "os" "path/filepath" "regexp" "strings" "sync" "time" ) // RuntimeConfig — конфигурация подсистем, редактируемая через admin UI // без перезапуска. Сохраняется в JSON-файл (BJ_SETUP_PATH или // ~/.bj/setup.json), грузится при старте. type RuntimeConfig struct { mu sync.RWMutex path string data Settings } // AcceptIIS — принимаем ли входящие переводы на ИИС (режим «всё или ничего»). func (r *RuntimeConfig) AcceptIIS() bool { r.mu.RLock() defer r.mu.RUnlock() return r.data.AcceptIIS } // UpdateAcceptIIS — включить/выключить приём входящих переводов на ИИС. func (r *RuntimeConfig) UpdateAcceptIIS(on bool) error { r.mu.Lock() r.data.AcceptIIS = on r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // 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 AcceptIIS bool `json:"accept_iis,omitempty"` // принимать входящие переводы на ИИС (режим «всё или ничего»); по умолчанию выкл — ИИС отклоняется целиком OneC OneCSettings `json:"onec,omitempty"` // база 1С бэк-офиса (модуль onec-back) NSDEmu NSDEmuSettings `json:"nsd_emu,omitempty"` // эмулятор НРД (страница «Эмуляторы», только режим разработки) MNPEmu MNPEmuSettings `json:"mnp_emu,omitempty"` // эмулятор МНП QUIK (страница «Эмуляторы», только режим разработки) 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 для блокировок) RealWrite bool `json:"real_write"` // режим разработки: РЕАЛЬНАЯ запись поручений в боевую МНП, но на подставного клиента RealClientCode string `json:"real_client_code,omitempty"` // код реального клиента QUIK, на которого уходят поручения в режиме real_write 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"` // Pending — компоненты, отложенные до нового установщика обновлений (ставятся после перезапуска bj-server). Pending []string `json:"pending,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"` // Local — прежняя база bj на этом сервере после переноса на сервер организации (pgmove.go): не удаляется, // остаётся запасной копией. Local string `json:"local,omitempty"` } // 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"` // DepoProcOverrides — правка текстов процедур bj2depo под клиента (имя процедуры -> SQL); пусто = встроенный текст. DepoProcOverrides map[string]string `json:"depo_proc_overrides,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"` // время последней проверки } // NSDEmuSettings — настройки эмулятора НРД (действуют при LK.EmulateNSD в режиме // разработки, страница «Эмуляторы»): каким решением и через сколько отвечает // имитатор контрагента. Нулевые поля (старый setup.json) = дефолты. type NSDEmuSettings struct { DelaySec int `json:"delay_sec,omitempty"` // задержка решения контрагента, сек (0..300; 0 = дефолт 30) Outcome string `json:"outcome,omitempty"` // confirm | reject | timeout (пусто = confirm) RejectCode string `json:"reject_code,omitempty"` // код отказа M2Mxx для outcome=reject (пусто = 07) } // Defaults подставляет дефолты вместо нулевых полей (обратная совместимость). func (s NSDEmuSettings) Defaults() NSDEmuSettings { if s.DelaySec <= 0 { s.DelaySec = int(lkEmuDecisionDelay / time.Second) } if s.Outcome == "" { s.Outcome = "confirm" } if s.RejectCode == "" { s.RejectCode = "07" } return s } // MNPEmuSettings — настройки эмулятора МНП QUIK (действуют при QuikMNP.Emulate): // задержка каждого шага цикла и точечные отказы по шагам — для отработки // сценариев «МНП отказал» без боевой базы. type MNPEmuSettings struct { StepDelaySec int `json:"step_delay_sec,omitempty"` // задержка шага, сек (0..60; 0 = без задержки) FailRegister bool `json:"fail_register,omitempty"` // регистрация завершается отказом FailSign bool `json:"fail_sign,omitempty"` // подпись (исполнение) завершается отказом FailDeny bool `json:"fail_deny,omitempty"` // снятие поручения завершается отказом FailDeposit bool `json:"fail_deposit,omitempty"` // зачисление (входящие) завершается отказом } // FailFor — для шага настроен отказ (все шаги успешны, если галочки сняты). func (s MNPEmuSettings) FailFor(action string) bool { switch action { case "register": return s.FailRegister case "sign": return s.FailSign case "deny": return s.FailDeny case "deposit": return s.FailDeposit } return false } // nsdEmuRejectCodes — допустимые коды отказа принимающей стороны (M2M). var nsdEmuRejectCodes = []string{"01", "02", "03", "04", "05", "06", "07", "08", "09", "18"} // UpdateNSDEmu сохраняет настройки эмулятора НРД (с валидацией). func (r *RuntimeConfig) UpdateNSDEmu(s NSDEmuSettings) error { if s.DelaySec < 0 || s.DelaySec > 300 { return errors.New("задержка решения — от 0 до 300 секунд") } switch s.Outcome { case "", "confirm", "reject", "timeout": default: return errors.New("исход — confirm, reject или timeout") } if s.RejectCode != "" && !containsStr(nsdEmuRejectCodes, s.RejectCode) { return errors.New("код отказа — M2M01..M2M09 или M2M18") } r.mu.Lock() r.data.NSDEmu = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // UpdateMNPEmu сохраняет настройки эмулятора МНП QUIK (с валидацией). func (r *RuntimeConfig) UpdateMNPEmu(s MNPEmuSettings) error { if s.StepDelaySec < 0 || s.StepDelaySec > 60 { return errors.New("задержка шага — от 0 до 60 секунд") } r.mu.Lock() r.data.MNPEmu = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // 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} loadErr := rc.load() if loadErr != nil && !errors.Is(loadErr, os.ErrNotExist) { return nil, loadErr } migrateNotifyRoutes(&rc.data) // и на новой установке: версия маршрутов сохранится с первой записью настроек if migrateM2MAutoDefault(&rc.data, time.Now()) && loadErr == nil { if err := rc.save(); err != nil { // момент включения — отсчёт «за сутки до» для автоматики; должен пережить перезапуск log.Printf("setup: автоматический режим M2M по умолчанию не записан: %v", err) } } // Гарантируем разумные дефолты. if rc.data.Crypto.Provider == "" { rc.data.Crypto.Provider = "stub" } if rc.data.Crypto.SocketPath == "" { rc.data.Crypto.SocketPath = "/run/bj/crypto.sock" } applyClientProfileFile(rc) // профиль клиента (lic): предзаполнить ещё не настроенные разделы 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() } // quikRealClientCodeRe — код подставного клиента QUIK (режим real_write): латиница/цифры/дефис/подчёркивание. var quikRealClientCodeRe = regexp.MustCompile(`^[A-Za-z0-9_-]{2,32}$`) // UpdateQuik сохраняет настройки подключения к БД МНП QUIK. func (r *RuntimeConfig) UpdateQuik(s QuikMNPSettings) error { if c := strings.TrimSpace(s.RealClientCode); c != "" && !quikRealClientCodeRe.MatchString(c) { return errors.New("код подставного клиента QUIK — 2..32 символа: латиница, цифры, дефис, подчёркивание") } 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"` Title string `json:"title,omitempty"` // для «Обзора»: по-человечески 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", Title: "База bj", Configured: s.Postgres.DSN != "", Ready: false, // настоящий ping будет в checks.go Message: posMsg(s.Postgres.DSN), }, { Name: "crypto-service", Title: "СКЗИ «Валидата»", Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub", Ready: false, Message: cryptoMsg(s.Crypto), }, { Name: "nsd-adapter", Title: "Шлюз НРД", Configured: s.NSD.IGWBaseURL != "" && s.NSD.Profile != "", Ready: false, Message: nsdMsg(s.NSD), }, } // уведомления в личный кабинет — только при модуле ЛК в лицензии: без него нечего настраивать if moduleAllowed(r, "esia-finance") || devMode(r) { out = append(out, Readiness{Name: "lk-callback", Title: "Уведомления в ЕСИА Финанс", Configured: s.LK.CallbackURL != "", 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 }