73b246831c
- Безопасность: в мастере подпись к паролю выводила сам сохранённый пароль служебной записи домена — теперь только «задан/введён» (тест: пароль не попадает в HTML). Список сессий в настройках больше не содержит токены (по ним можно было войти под чужим именем) — короткий ID. - Домен: после подключения и «Доверять» блок остаётся открытым, страница возвращается к нему; карточка «Домен подключён» и «Найден домен» — крупно; поиск сотрудников и групп; группе — доступ с ролью (ADGroups), список групп с «убрать». - «Обмен с DEPO»: справочник FTV_M2M_HANDBOOK считается и когда переводов ещё нет (на свежей установке писало «не создана»). - Модуль 1С: onec-back «1С: база бэк-офиса (тест)» вместо мидл-офиса (в лицензиях не выдавался). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
911 lines
45 KiB
Go
911 lines
45 KiB
Go
package lkgateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// RuntimeConfig — конфигурация подсистем, редактируемая через admin UI
|
||
// без перезапуска. Сохраняется в JSON-файл (BJ_SETUP_PATH или
|
||
// ~/.bj/setup.json), грузится при старте.
|
||
type RuntimeConfig struct {
|
||
mu sync.RWMutex
|
||
path string
|
||
data Settings
|
||
}
|
||
|
||
// Settings — сериализуемое представление настроек.
|
||
type Settings struct {
|
||
Postgres PostgresSettings `json:"postgres"`
|
||
Fancy FancyMSSQLSettings `json:"fancy_mssql"`
|
||
Quik QuikMNPSettings `json:"quik_mnp"`
|
||
M2MAuto M2MAutoSettings `json:"m2m_auto"`
|
||
Crypto CryptoSettings `json:"crypto"`
|
||
NSD NSDSettings `json:"nsd"`
|
||
LK LKSettings `json:"lk"`
|
||
CACerts CACertsSettings `json:"ca_certs"`
|
||
NSDCerts NSDCertsSettings `json:"nsd_certs"`
|
||
News NewsSettings `json:"news"`
|
||
Alerts AlertSettings `json:"alerts"`
|
||
Incoming IncomingNotifySettings `json:"incoming_notify"`
|
||
Update UpdateSettings `json:"update"`
|
||
License LicenseSettings `json:"license"`
|
||
Branding BrandingSettings `json:"branding"`
|
||
NPR NPRSettings `json:"npr"`
|
||
QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport
|
||
Auth AuthSettings `json:"auth"`
|
||
Backup BackupSettings `json:"backup,omitempty"`
|
||
HTTPS HTTPSSettings `json:"https,omitempty"`
|
||
Time TimeSettings `json:"time,omitempty"`
|
||
WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06
|
||
OneC OneCSettings `json:"onec,omitempty"` // база 1С бэк-офиса (модуль onec-back)
|
||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// QuikMNPSettings — подключение к БД Модуля неторговых поручений QUIK (MSSQL).
|
||
// Используется для блокировки бумаг при выводе (M2M). БД боевая — работаем на
|
||
// чтение + вызовы штатных хранимых процедур API (роль InstrEu). Прямых INSERT/
|
||
// UPDATE в таблицы НЕ делаем.
|
||
type QuikMNPSettings struct {
|
||
Server string `json:"server"` // host или host\instance
|
||
Port int `json:"port"` // напр. 8849 (нестандартный)
|
||
Database string `json:"database"` // имя БД МНП (если требуется)
|
||
User string `json:"user"` // SQL-логин (роль InstrEu), напр. BROKER_88
|
||
Password string `json:"password"` // пароль
|
||
Encrypt string `json:"encrypt"` // disable | true (по умолчанию true+trust для боевой)
|
||
Trust bool `json:"trust"` // TrustServerCertificate (самоподписанный серт)
|
||
Verified bool `json:"verified"` // последнее тест-подключение прошло
|
||
LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам)
|
||
LastAt time.Time `json:"last_at"` // время последнего теста
|
||
M2MMissing string `json:"m2m_missing,omitempty"` // ключевые объекты M2M, не видные под логином при последней проверке
|
||
|
||
// Автоматический цикл M2M в МНП (пишущие операции к боевой БД).
|
||
WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП
|
||
Emulate bool `json:"emulate"` // имитация: шаги цикла проходят без записи в МНП (нет пользователя QUIK для блокировок)
|
||
AutoOut bool `json:"auto_out"` // исходящие: регистрировать «Вывод ЦБ (M2M)» при отправке, подписывать/снимать по ответу
|
||
AutoIn bool `json:"auto_in"` // входящие: регистрировать «Ввод ЦБ (M2M)» после приёма поручения НРД
|
||
PollTerminal bool `json:"poll_terminal"` // подхватывать поручения, поданные клиентами из QUIK (Export)
|
||
Account string `json:"account"` // торговый счёт для регистрации (напр. L01+00000F00)
|
||
SecOut string `json:"sec_out"` // код МНП-инструмента вывода (пусто = определить по классу INSTROUTM2M)
|
||
SecIn string `json:"sec_in"` // код МНП-инструмента ввода (пусто = по классу INSTRINM2M)
|
||
ClientCodeMap map[string]string `json:"client_code_map"` // исключения: номер портфеля DEPO («БР-4-4») → код клиента QUIK
|
||
}
|
||
|
||
// LicenseSettings — лицензионный ключ (подписанный токен).
|
||
type LicenseSettings struct {
|
||
Key string `json:"key"` // компактный токен payload.signature.keyid
|
||
PublicKey string `json:"public_key,omitempty"` // устарело: ключ проверки зашит в bj, поле не используется
|
||
|
||
// Онлайн-лицензирование (licenseclient.go).
|
||
InstallID string `json:"install_id,omitempty"` // идентификатор этой установки на сервере лицензий
|
||
LastSync time.Time `json:"last_sync,omitempty"` // последнее успешное обращение к серверу лицензий
|
||
LastAttempt time.Time `json:"last_attempt,omitempty"` // последняя попытка
|
||
LastOK bool `json:"last_ok,omitempty"`
|
||
LastMsg string `json:"last_msg,omitempty"`
|
||
}
|
||
|
||
// UpdateSettings — авто-обновления из артефактории (#18/#20).
|
||
type UpdateSettings struct {
|
||
BaseURL string `json:"base_url"` // https://updates.example.com
|
||
Channel string `json:"channel"` // "stable" | "beta"
|
||
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
|
||
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
||
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
||
LastResult string `json:"last_result"` // текст результата проверки
|
||
// CheckFailed — последняя ПРОВЕРКА обновлений не удалась (сервер недоступен, нет лицензии…):
|
||
// тогда версии из прошлой проверки не предлагаются. Неудачная установка сюда не относится.
|
||
CheckFailed bool `json:"check_failed,omitempty"`
|
||
// DisabledModules — модули, отключённые как снятые с лицензии (имя → когда): служба остановлена,
|
||
// в списке компонентов не показываются; при возврате в лицензию включаются обратно.
|
||
DisabledModules map[string]string `json:"disabled_modules,omitempty"`
|
||
Available string `json:"available_version"` // доступная версия (если новее)
|
||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
|
||
Components map[string]string `json:"components,omitempty"`
|
||
// ComponentsChannel — канал, в котором эти версии были найдены. Если канал
|
||
// сменили, данные прошлой проверки к новому каналу не относятся: предлагать
|
||
// по ним обновление нельзя (кнопка «Обновить» уходила бы в пустоту).
|
||
ComponentsChannel string `json:"components_channel,omitempty"`
|
||
// Notified — выпуск, о котором уже написали администратору («Доступно обновление bj»): по разу на выпуск.
|
||
Notified string `json:"notified,omitempty"`
|
||
// 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"`
|
||
}
|
||
|
||
// IsPostgres сообщает, выбран ли PostgreSQL (иначе — MSSQL по умолчанию).
|
||
func (s FancyMSSQLSettings) IsPostgres() bool { return s.DBType == "postgres" }
|
||
|
||
// DefaultPort возвращает порт по типу СУБД, если он не задан явно.
|
||
func (s FancyMSSQLSettings) DefaultPort() int {
|
||
if s.Port != 0 {
|
||
return s.Port
|
||
}
|
||
if s.IsPostgres() {
|
||
return 5432
|
||
}
|
||
return 1433
|
||
}
|
||
|
||
// CryptoSettings — путь к PKCS#11 модулю и тип провайдера.
|
||
type CryptoSettings struct {
|
||
Provider string `json:"provider"` // "stub" | "validata"
|
||
SocketPath string `json:"socket_path"` // UDS crypto-service
|
||
ModulePath string `json:"module_path"` // путь до .so модуля PKCS#11
|
||
Profile string `json:"profile"` // активный профиль Валидаты (имя из pki1.conf)
|
||
}
|
||
|
||
// NSDSettings — профиль и подключение к ИШ НРД.
|
||
type NSDSettings struct {
|
||
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
|
||
IGWBaseURL string `json:"igw_base_url"` // http://host:port
|
||
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
|
||
|
||
// Депозитарные реквизиты клиента — откуда списываются бумаги
|
||
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
|
||
// Наша организация — участник M2M (выбирается из справочника НРД или вводится при настройке).
|
||
OrgCode string `json:"org_code,omitempty"` // код участника (отправитель пакетов), напр. MC0000000000
|
||
OrgINN string `json:"org_inn,omitempty"` // ИНН — передающий/зачисляющий депозитарий в пакетах
|
||
OrgName string `json:"org_name,omitempty"`
|
||
|
||
DeponentCode string `json:"deponent_code"` // депозитарный код в НРД (место хранения)
|
||
AccountID string `json:"account_id"` // депозитарный счёт
|
||
SectionID string `json:"section_id"` // раздел депозитарного счёта
|
||
|
||
// IGWv904 — ИШ обновлён до версии 107.0.0.904+ (Astra 107.0.907+): с этой
|
||
// версии в config.xml ОБЯЗАТЕЛЬНО указывать businessLine=MOST для M2M; на
|
||
// версиях младше — НЕ допускается. По умолчанию false (совместимо со старым
|
||
// ИШ — элемент не добавляется).
|
||
IGWv904 bool `json:"igw_v904"`
|
||
|
||
// Контуры (05.10.2026): база DEPO для TEST3 и для боевого — на тесте часто копия учёта. Запоминаются при
|
||
// переключении контура, чтобы вернуться без перенастройки.
|
||
DepoDBTest string `json:"depo_db_test,omitempty"`
|
||
DepoDBProd string `json:"depo_db_prod,omitempty"`
|
||
SwitchedAt time.Time `json:"switched_at,omitempty"`
|
||
}
|
||
|
||
// LKSettings — настройки callback в ЛК клиента.
|
||
type LKSettings struct {
|
||
CallbackURL string `json:"callback_url"`
|
||
// PublicURL — адрес личного кабинета клиента (стенд/бой), куда оператор
|
||
// заходит подать тестовую заявку. Показывается ссылкой на дашборде.
|
||
PublicURL string `json:"lk_public_url"`
|
||
// APIKey — ключ доступа для модуля Личного кабинета клиента (bj-lk).
|
||
// Модуль ставится на веб-сервер клиента и обращается к bj-server по этому
|
||
// ключу (заголовок X-BJ-Key). Пустой ключ = модульный API выключен.
|
||
APIKey string `json:"lk_api_key"`
|
||
// CoreURL — адрес ядра bj, по которому кабинет к нему обращается (кабинет стоит
|
||
// на отдельном веб-сервере). Пусто — подставляем адрес текущего сервера.
|
||
CoreURL string `json:"lk_core_url,omitempty"`
|
||
// AgentMode — режим ожидания: у ядра нет адреса, доступного из интернета,
|
||
// поэтому не кабинет обращается к ядру, а ядро само подключается к кабинету.
|
||
AgentMode bool `json:"lk_agent_mode,omitempty"`
|
||
// CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания.
|
||
CabinetURL string `json:"lk_cabinet_url,omitempty"`
|
||
// PreviewOn — кабинет запущен на этом же сервере на отдельном порту (просмотр из сети
|
||
// депозитария без размещения на хостинге). PreviewListen — адрес:порт, по умолчанию :8098.
|
||
PreviewOn bool `json:"lk_preview_on,omitempty"`
|
||
PreviewListen string `json:"lk_preview_listen,omitempty"`
|
||
// BindNetwork — привязывать сессию клиента не только к браузеру, но и к сети /24.
|
||
// Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором.
|
||
BindNetwork bool `json:"lk_bind_network,omitempty"`
|
||
|
||
// RealNSDTest — тест «боевой ЛК → тестовый контур НРД»: заявки указанных клиентов проходят всё
|
||
// по-настоящему (DEPO, QUIK МНП, статусы в ЛК), а в НРД уходят с данными тест-инвестора (realtest.go).
|
||
RealNSDTest RealNSDTestSettings `json:"real_nsd_test,omitempty"`
|
||
// PortfolioSync — портфель ДЕПО из DEPO в ЛК ЕСИА Финанс (lkportfolio.go): каждый проход опроса ЛК.
|
||
PortfolioSync LKPortfolioSyncSettings `json:"portfolio_sync,omitempty"`
|
||
|
||
// Интеграция с ЛК (потребление back_office API платформы): два окружения.
|
||
ActiveEnv string `json:"lk_active_env"` // "test" | "prod"
|
||
Test LKEndpoint `json:"lk_test"`
|
||
Prod LKEndpoint `json:"lk_prod"`
|
||
|
||
// FXSource — откуда брать курсы валют для оценки валютных выпусков:
|
||
// пусто/"cbr" — Банк России (открытый XML, без ключа), "accounting" — курс
|
||
// учётной системы (OD_VALUES_RATES), если сервер стоит без выхода в интернет.
|
||
FXSource string `json:"lk_fx_source,omitempty"`
|
||
// SnapshotMode — ежедневный срез стоимости портфелей (из него кабинет рисует график):
|
||
// пусто — автоматически (ведём, если настроен экспорт QUIK), "on" — всегда, "off" — не вести.
|
||
// SnapshotAt — время снятия среза, «ЧЧ:ММ» (пусто = 20:30, после закрытия торгов).
|
||
SnapshotMode string `json:"lk_snapshot_mode,omitempty"`
|
||
SnapshotAt string `json:"lk_snapshot_at,omitempty"`
|
||
|
||
// EmulateNSD — заявки ЛК проходят весь путь (сверка DEPO, блокировка QUIK, сделка,
|
||
// статус в ЛК), но в НРД не уходят: отправку и ответ контрагента имитирует bj.
|
||
// Для боевого ЛК при тестовом профиле ИШ имитация включается принудительно.
|
||
EmulateNSD bool `json:"lk_emulate_nsd"`
|
||
}
|
||
|
||
// LKEndpoint — реквизиты подключения к API ЛК (HTTP Basic) для одного окружения.
|
||
type LKEndpoint struct {
|
||
BaseURL string `json:"base_url"` // напр. https://fontvielle.ef.winr.pro/manager/api
|
||
Login string `json:"login"` // Basic-логин
|
||
Password string `json:"password"` // Basic-пароль
|
||
LastOK bool `json:"last_ok"` // результат последней «Проверить связь»
|
||
LastMsg string `json:"last_msg"` // текст результата
|
||
LastAt time.Time `json:"last_at"` // время последней проверки
|
||
}
|
||
|
||
// TestRunResult — результат последнего тестового прогона.
|
||
type TestRunResult struct {
|
||
StartedAt time.Time `json:"started_at"`
|
||
FinishedAt time.Time `json:"finished_at"`
|
||
ClaimID string `json:"claim_id"`
|
||
FinalStatus string `json:"final_status"`
|
||
OK bool `json:"ok"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// NewRuntimeConfig создаёт runtime-конфиг, читая JSON из path (или дефолт).
|
||
func NewRuntimeConfig(path string) (*RuntimeConfig, error) {
|
||
if path == "" {
|
||
home, _ := os.UserHomeDir()
|
||
if home == "" {
|
||
home = "."
|
||
}
|
||
path = filepath.Join(home, ".bj", "setup.json")
|
||
}
|
||
rc := &RuntimeConfig{path: path}
|
||
if err := rc.load(); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return nil, err
|
||
}
|
||
migrateNotifyRoutes(&rc.data) // и на новой установке: версия маршрутов сохранится с первой записью настроек
|
||
// Гарантируем разумные дефолты.
|
||
if rc.data.Crypto.Provider == "" {
|
||
rc.data.Crypto.Provider = "stub"
|
||
}
|
||
if rc.data.Crypto.SocketPath == "" {
|
||
rc.data.Crypto.SocketPath = "/run/bj/crypto.sock"
|
||
}
|
||
return rc, nil
|
||
}
|
||
|
||
// Snapshot возвращает копию текущих настроек.
|
||
func (r *RuntimeConfig) Snapshot() Settings {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
out := r.data
|
||
if r.data.LastTest != nil {
|
||
t := *r.data.LastTest
|
||
out.LastTest = &t
|
||
}
|
||
return out
|
||
}
|
||
|
||
// BrandingSettings — оформление отчётов (логотип организации в шапке).
|
||
type BrandingSettings struct {
|
||
LogoDataURI string `json:"logo_data_uri,omitempty"` // data:image/...;base64,... (пусто = встроенный логотип по умолчанию)
|
||
LogoName string `json:"logo_name,omitempty"` // имя загруженного файла (для UI)
|
||
}
|
||
|
||
// UpdateBranding сохраняет настройки оформления (логотип отчётов).
|
||
func (r *RuntimeConfig) UpdateBranding(s BrandingSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Branding = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdatePostgres сохраняет postgres-настройки.
|
||
func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Postgres = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateFancy сохраняет настройки подключения к MSSQL (канал обмена с Fancy).
|
||
func (r *RuntimeConfig) UpdateFancy(s FancyMSSQLSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Fancy = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateQuik сохраняет настройки подключения к БД МНП QUIK.
|
||
func (r *RuntimeConfig) UpdateQuik(s QuikMNPSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Quik = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// SaveLicense сохраняет лицензионные настройки.
|
||
func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error {
|
||
r.mu.Lock()
|
||
r.data.License = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// SaveUpdateSettings сохраняет настройки авто-обновлений.
|
||
func (r *RuntimeConfig) SaveUpdateSettings(s UpdateSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Update = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateCrypto сохраняет crypto-настройки.
|
||
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Crypto = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateNSD сохраняет NSD-настройки.
|
||
func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error {
|
||
r.mu.Lock()
|
||
r.data.NSD = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
applyBusinessLine(s.IGWv904) // сразу применяем businessLine к генератору config.xml
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateLK сохраняет LK callback URL.
|
||
// UpdateCACerts сохраняет настройки авто-загрузки сертификатов УЦ.
|
||
func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error {
|
||
r.mu.Lock()
|
||
r.data.CACerts = s
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateBackup сохраняет настройки резервного копирования.
|
||
func (r *RuntimeConfig) UpdateBackup(s BackupSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Backup = s
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateHTTPS сохраняет настройки HTTPS веб-интерфейса.
|
||
func (r *RuntimeConfig) UpdateHTTPS(s HTTPSSettings) error {
|
||
r.mu.Lock()
|
||
r.data.HTTPS = s
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateTime сохраняет настройки синхронизации времени.
|
||
func (r *RuntimeConfig) UpdateTime(s TimeSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Time = s
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// UpdateNews заменяет всю ленту новостей.
|
||
func (r *RuntimeConfig) UpdateNews(s NewsSettings) error {
|
||
r.mu.Lock()
|
||
r.data.News = s
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// AddNews добавляет новость в начало ленты (newest first). Если в ленте уже
|
||
// есть новость с таким же ID — она обновляется (вместо дубликата).
|
||
func (r *RuntimeConfig) AddNews(item NewsItem) error {
|
||
r.mu.Lock()
|
||
if item.ID == "" {
|
||
item.ID = item.At.Format("20060102-150405") + "-" + item.Kind
|
||
}
|
||
if item.At.IsZero() {
|
||
item.At = time.Now()
|
||
}
|
||
// Дедуп по ID.
|
||
replaced := false
|
||
for i, ex := range r.data.News.Items {
|
||
if ex.ID == item.ID {
|
||
r.data.News.Items[i] = item
|
||
replaced = true
|
||
break
|
||
}
|
||
}
|
||
if !replaced {
|
||
r.data.News.Items = append([]NewsItem{item}, r.data.News.Items...)
|
||
}
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// DismissNews помечает новость скрытой по ID (не удаляет — для аудита).
|
||
func (r *RuntimeConfig) DismissNews(id string) error {
|
||
r.mu.Lock()
|
||
for i := range r.data.News.Items {
|
||
if r.data.News.Items[i].ID == id {
|
||
r.data.News.Items[i].Dismissed = true
|
||
}
|
||
}
|
||
r.data.UpdatedAt = time.Now()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// AlertSettings — каналы внешней доставки системных оповещений (сверх ленты
|
||
// «Новости»). SMTP — почта оператору; Zabbix — trapper-хук в мониторинг.
|
||
type AlertSettings struct {
|
||
SMTP SMTPSettings `json:"smtp"`
|
||
Zabbix ZabbixSettings `json:"zabbix"`
|
||
}
|
||
|
||
// SMTPSettings — параметры отправки уведомлений по e-mail.
|
||
type SMTPSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Host string `json:"host"`
|
||
Port int `json:"port"` // 587 (STARTTLS), 465 (SSL), 25
|
||
Security string `json:"security"` // "starttls" | "ssl" | "none"
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
From string `json:"from"`
|
||
To string `json:"to"` // администратор системы (через запятую) — задаётся в «Кому писать»
|
||
// TestTo — куда слать проверочное письмо (поле в блоке «Почта»); пусто — администратору системы.
|
||
TestTo string `json:"test_to,omitempty"`
|
||
LastMsg string `json:"last_msg"`
|
||
LastAt time.Time `json:"last_at"`
|
||
}
|
||
|
||
// ZabbixSettings — trapper-хук: bj шлёт значение элемента данных на сервер/прокси
|
||
// Zabbix (протокол zabbix_sender). В Zabbix заводится host + item типа
|
||
// «Zabbix trapper» с указанным ключом; триггер по значению шлёт уведомления.
|
||
type ZabbixSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Server string `json:"server"` // адрес сервера/прокси Zabbix
|
||
Port int `json:"port"` // 10051 по умолчанию
|
||
Host string `json:"host"` // имя хоста в Zabbix (host.host)
|
||
Key string `json:"key"` // ключ элемента-trapper, напр. bj.alert
|
||
LastMsg string `json:"last_msg"`
|
||
LastAt time.Time `json:"last_at"`
|
||
}
|
||
|
||
func (r *RuntimeConfig) UpdateAlerts(s AlertSettings) error {
|
||
r.mu.Lock()
|
||
r.data.Alerts = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// IncomingNotifySettings — уведомления депозитарию о входящих бумагах (перевод
|
||
// НАМ по M2M). Пока — письмо на почту «к вам переведена бумага, требует учёта»;
|
||
// позже здесь же сформируем пакет для загрузки в учётную систему.
|
||
type IncomingNotifySettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Emails string `json:"emails"` // депозитарий (через запятую)
|
||
EmailsBack string `json:"emails_back"` // бэк-офис
|
||
EmailsMiddle string `json:"emails_middle"` // мидл-офис
|
||
// Маршрутизация: тип уведомления → группы через запятую (admin,depo,back,middle). Адрес группы admin
|
||
// (администратор системы) — Alerts.SMTP.To. Типы: incoming (входящие переводы), orders (сроки депо-поручений),
|
||
// quik (QUIK МНП), infra (сбои шлюза, связи с НРД, служб, DEPO), updates (обновления, лицензия, сертификаты),
|
||
// system (прочие).
|
||
Routes map[string]string `json:"routes"`
|
||
// RoutesV — версия маршрутов: 2 — с группой «Администратор системы» (06.10.2026), см. migrateNotifyRoutes.
|
||
RoutesV int `json:"routes_v,omitempty"`
|
||
}
|
||
|
||
// Группы получателей и типы уведомлений (порядок — для формы). Администратор системы — тот, кто отвечает за
|
||
// сервер и ИТ: ему сбои и обновления, которые отделам (депозитарий, бэк- и мидл-офис) не нужны.
|
||
var notifyGroups = []struct{ Key, Title string }{{"admin", "Администратор"}, {"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}}
|
||
var notifyKinds = []struct{ Key, Title string }{
|
||
{"incoming", "Входящие переводы (получен / принят)"},
|
||
{"orders", "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"},
|
||
{"quik", "QUIK МНП (ошибки блокировки / списания / зачисления)"},
|
||
{"infra", "Сбои: шлюз НРД, связь с НРД, службы bj, обмен с DEPO"},
|
||
{"updates", "Обновления bj и шлюза, лицензия, сертификаты"},
|
||
{"system", "Прочие оповещения"},
|
||
}
|
||
|
||
// visibleNotifyKinds — типы уведомлений этой установки: ошибки QUIK МНП — только при модуле в лицензии.
|
||
func visibleNotifyKinds(rc *RuntimeConfig) []struct{ Key, Title string } {
|
||
var out []struct{ Key, Title string }
|
||
for _, k := range notifyKinds {
|
||
if k.Key == "quik" && rc != nil && !moduleAllowed(rc, "quik-mnp") {
|
||
continue
|
||
}
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои: сбои и обновления —
|
||
// администратору, переводы и поручения — отделам.
|
||
var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle",
|
||
"infra": "admin", "updates": "admin", "system": "admin,back"}
|
||
|
||
// RouteHas — получает ли группа уведомления данного типа (тип, которого нет в сохранённых маршрутах, — по умолчанию).
|
||
func (s IncomingNotifySettings) RouteHas(kind, group string) bool {
|
||
route, ok := s.Routes[kind]
|
||
if !ok {
|
||
route = defaultNotifyRoutes[kind]
|
||
}
|
||
for _, g := range strings.Split(route, ",") {
|
||
if strings.TrimSpace(g) == group {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// GroupEmails — адреса группы.
|
||
func (s IncomingNotifySettings) GroupEmails(group string) string {
|
||
switch group {
|
||
case "depo":
|
||
return s.Emails
|
||
case "back":
|
||
return s.EmailsBack
|
||
case "middle":
|
||
return s.EmailsMiddle
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// migrateNotifyRoutes — переход на маршруты с группой «Администратор системы». До неё адрес из раздела «Почта»
|
||
// получал все системные оповещения, а сбои и обновления были «прочими»: на существующей установке администратор
|
||
// получает всё то же, что и раньше (лишнее снимается в таблице), сбои и обновления идут тем же группам, что
|
||
// «прочие». Галочка «уведомления о переводах» заменена строкой таблицы: выключенная — пустой маршрут входящих.
|
||
// Новая установка (ни адреса, ни маршрутов) — маршруты по умолчанию.
|
||
func migrateNotifyRoutes(s *Settings) {
|
||
in := &s.Incoming
|
||
if in.RoutesV >= 2 {
|
||
return
|
||
}
|
||
in.RoutesV = 2
|
||
if in.Routes == nil && strings.TrimSpace(s.Alerts.SMTP.To) == "" && !in.Enabled {
|
||
in.Enabled = true
|
||
return
|
||
}
|
||
old := in.Routes
|
||
if old == nil {
|
||
old = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"}
|
||
}
|
||
r := map[string]string{}
|
||
for k, v := range old {
|
||
r[k] = v
|
||
}
|
||
r["infra"], r["updates"] = old["system"], old["system"]
|
||
if strings.TrimSpace(s.Alerts.SMTP.To) != "" {
|
||
for _, k := range []string{"orders", "quik", "infra", "updates", "system"} {
|
||
r[k] = strings.Trim(r[k]+",admin", ",")
|
||
}
|
||
}
|
||
if !in.Enabled {
|
||
r["incoming"] = ""
|
||
}
|
||
in.Enabled = true
|
||
in.Routes = r
|
||
}
|
||
|
||
// Recipients — адреса отделов (депозитарий, бэк-, мидл-офис), на которые направлен тип уведомления (без дублей);
|
||
// с администратором системы — notifyRecipients.
|
||
func (s IncomingNotifySettings) Recipients(kind string) string {
|
||
seen := map[string]bool{}
|
||
var out []string
|
||
for _, g := range notifyGroups {
|
||
if !s.RouteHas(kind, g.Key) {
|
||
continue
|
||
}
|
||
for _, e := range strings.Split(s.GroupEmails(g.Key), ",") {
|
||
e = strings.TrimSpace(e)
|
||
if e == "" || seen[strings.ToLower(e)] {
|
||
continue
|
||
}
|
||
seen[strings.ToLower(e)] = true
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
return strings.Join(out, ", ")
|
||
}
|
||
|
||
// notifyRecipients — все адреса типа уведомления: администратор системы (адрес из «Почты») и отделы.
|
||
func notifyRecipients(s Settings, kind string) string {
|
||
admin := ""
|
||
if s.Incoming.RouteHas(kind, "admin") {
|
||
admin = s.Alerts.SMTP.To
|
||
}
|
||
return mergeEmails(admin, s.Incoming.Recipients(kind))
|
||
}
|
||
|
||
func (r *RuntimeConfig) UpdateIncoming(s IncomingNotifySettings) error {
|
||
r.mu.Lock()
|
||
r.data.Incoming = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
|
||
r.mu.Lock()
|
||
r.data.LK = s
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// RecordTestRun сохраняет результат теста.
|
||
func (r *RuntimeConfig) RecordTestRun(res TestRunResult) error {
|
||
r.mu.Lock()
|
||
r.data.LastTest = &res
|
||
r.data.UpdatedAt = time.Now().UTC()
|
||
r.mu.Unlock()
|
||
return r.save()
|
||
}
|
||
|
||
// load читает JSON в r.data.
|
||
func (r *RuntimeConfig) load() error {
|
||
raw, err := os.ReadFile(r.path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return json.Unmarshal(raw, &r.data)
|
||
}
|
||
|
||
// save пишет JSON в r.path атомарно через tmp + rename.
|
||
func (r *RuntimeConfig) save() error {
|
||
r.mu.RLock()
|
||
raw, err := json.MarshalIndent(r.data, "", " ")
|
||
r.mu.RUnlock()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
dir := filepath.Dir(r.path)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
tmp := r.path + ".tmp"
|
||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, r.path)
|
||
}
|
||
|
||
// Readiness — сводная готовность подсистемы.
|
||
type Readiness struct {
|
||
Name string `json:"name"`
|
||
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
|
||
}
|