97e99b72c7
bj-quikexport (modules/quikexport + cmd/bj-quikexport, 127.0.0.1:8084): управление ARQA quikexport перенесено из bj-server. Настройки у службы в /var/lib/bj/.bj/quik-export/settings.json (при первом запуске переносятся из setup.json), раздел отдаётся фрагментом с индикатором меню, действия отвечают X-BJ-Flash. bj-server встраивает раздел в «Настройку», проксирует формы /admin/setup/quikexport/… и передаёт лицензию (X-BJ-Licensed). Общая проверка служебного ключа — modules/modkit (им пользуется и bj-npr), общее для прокси — lkgateway/modules.go. Выделенные из ядра модули (bj-npr, bj-quikexport: Component.FromCore) при обновлении ставятся и там, где их ещё нет, если входят в лицензию: release.MarkSplitModules, request.json "new", root-установщик ставит и включает unit-файл из комплекта. Если в обновлении меняется сам установщик, модули ставятся после перезапуска bj-server (ensureSplitModules). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
718 lines
31 KiB
Go
718 lines
31 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"`
|
||
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"` // главный предохранитель: без него ни одной записи в МНП
|
||
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
|
||
}
|