Files
Bridge-and-Join-s/internal/lkgateway/runtimeconfig.go
T
zuevav d47229c9a5 Автоматический режим M2M и имитация QUIK МНП
Автоматический режим (Настройка → НРД, выключен по умолчанию): входящие переводы — решение по свежей
сверке с DEPO без оператора (пакет только целиком; бумага для квал. инвесторов при неполной или
непрочитанной квалификации — отказ M2M08), поручение 16/1 — как только пришёл черновик MF010,
поручение 16 по исходящим — по черновику. Нет сверки или ИШ — повтор каждые 3 минуты и оповещение,
без сверки ничего не принимается. Обрабатывается только появившееся не раньше суток до включения
(старые черновики в INBOX не подаются). Решение не отправляется повторно по уже отвеченному переводу.

Имитация QUIK МНП (Настройка → QUIK МНП → цикл): регистрация, подпись, снятие и зачисление проходят
как выполненные с пометкой «имитация», в МНП ничего не пишется; события в DEPO — как обычно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:59:23 +03:00

720 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
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"` // время последнего теста
// Автоматический цикл 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"` // текст результата проверки
Available string `json:"available_version"` // доступная версия (если новее)
Notes string `json:"notes,omitempty"` // заметки доступного релиза
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
Components map[string]string `json:"components,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
// Обработка трансферных таблиц 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"`
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
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"`
}
// 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). Из договора/письма НРД.
DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000
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"`
}
// 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"`
// Интеграция с ЛК (потребление back_office API платформы): два окружения.
ActiveEnv string `json:"lk_active_env"` // "test" | "prod"
Test LKEndpoint `json:"lk_test"`
Prod LKEndpoint `json:"lk_prod"`
// 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
}
// Гарантируем разумные дефолты.
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()
}
// 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"` // получатели через запятую
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"` // мидл-офис
// Маршрутизация: тип уведомления → группы через запятую (depo,back,middle).
// Типы: incoming (входящие переводы), orders (сроки депо-поручений), quik (QUIK МНП), system (прочие).
Routes map[string]string `json:"routes"`
}
// Группы получателей и типы уведомлений (порядок — для формы).
var notifyGroups = []struct{ Key, Title string }{{"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}}
var notifyKinds = []struct{ Key, Title string }{
{"incoming", "Входящие переводы (получен / принят)"},
{"orders", "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"},
{"quik", "QUIK МНП (ошибки блокировки / списания / зачисления)"},
{"system", "Прочие системные оповещения"},
}
// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои.
var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"}
// RouteHas — получает ли группа уведомления данного типа.
func (s IncomingNotifySettings) RouteHas(kind, group string) bool {
r := s.Routes
if r == nil {
r = defaultNotifyRoutes
}
for _, g := range strings.Split(r[kind], ",") {
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 ""
}
// Recipients — адреса всех групп, на которые направлен тип уведомления (без дублей).
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, ", ")
}
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 == "" {
return "DSN не задан — система в режиме in-memory (M2-демо)"
}
return "DSN настроен: " + maskDSN(dsn)
}
func cryptoMsg(c CryptoSettings) string {
if c.Provider == "" || c.Provider == "stub" {
return "Криптография не настроена (provider=stub) — реальная подпись недоступна."
}
if c.ModulePath == "" {
return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан."
}
return "Провайдер " + c.Provider + ", PKCS#11 модуль: " + c.ModulePath
}
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
}