Оповещения: группа «Администратор системы», блок «Кому писать» открыт; копии: адрес хранилища как в проводнике

Администратору (адрес из «Почты») — сбои (ИШ, связь с НРД, службы bj, DEPO) и обновления (новый выпуск bj,
ИШ, лицензия, сертификаты); отделам — переводы и поручения. Существующие установки: администратор получает
всё, что получал. Новые оповещения: «Доступно обновление bj» (раз на выпуск), «Служба … не работает /
снова работает» (модули и служба ключей Валидаты). Резервные копии: \\сервер\папка или сервер + общая
папка по полям → //сервер/папка; понятная причина отказа доступа.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-06 22:44:35 +03:00
parent f07bb53162
commit d23ddf6e0c
16 changed files with 547 additions and 63 deletions
+1
View File
@@ -59,6 +59,7 @@ var templateFuncs = template.FuncMap{
"lkPortfolioStates": lkPortfolioStates,
"backupStatus": backupStatus,
"backupWhen": backupWhen,
"smbWin": smbWin,
"depoAccPrefix": depoAccPrefix,
"certDaysLeft": certDaysLeft,
"currentContour": func() string { return contourOf(currentNSDProfile()) },
+20 -4
View File
@@ -28,9 +28,9 @@ func dispatchAlert(rc *RuntimeConfig, title, body string) {
cfg := rc.Snapshot()
a := cfg.Alerts
if a.SMTP.Enabled {
// адрес алертов + группы по маршруту типа (сроки поручений / QUIK / системные)
// администратор системы и отделы — по маршруту типа (сроки поручений / QUIK / сбои / обновления / прочие)
s := a.SMTP
s.To = mergeEmails(a.SMTP.To, cfg.Incoming.Recipients(alertKind(title)))
s.To = notifyRecipients(cfg, alertKind(title))
if strings.TrimSpace(s.To) != "" {
if err := sendSMTP(s, "[bj] "+title, body); err != nil {
log.Printf("lk-gateway: алерт SMTP не доставлен: %v", err)
@@ -298,13 +298,29 @@ func readFull(conn net.Conn, buf []byte) (int, error) {
return total, nil
}
// alertKind — тип оповещения по заголовку (для маршрутизации по группам адресов).
// alertKind — тип оповещения по заголовку (маршрут «какой тип кому» — в настройках уведомлений). Сбои и
// обновления — администратору системы: падение ИШ или службы отделам ни к чему, им — переводы и поручения.
func alertKind(title string) string {
has := func(prefixes ...string) bool {
for _, p := range prefixes {
if strings.HasPrefix(title, p) {
return true
}
}
return false
}
switch {
case strings.HasPrefix(title, "НРД: депо-поручение") || strings.HasPrefix(title, "НРД: истёк срок"):
case has("НРД: депо-поручение", "НРД: истёк срок"):
return "orders"
case strings.Contains(title, "QUIK"):
return "quik"
case has("ИШ НРД", "Перезапуск ИШ НРД", "НРД не отвечает", "НРД всё ещё не отвечает", "НРД снова на связи",
"Проверка связи с НРД", "Сбой компонента", "Компонент не отвечает", "Служба ", "DEPO: bj не может",
"DEPO: вход логином", "DEPO: обработчик", "Резервная копия"):
return "infra"
case has("Доступно обновление", "ИШ обновлён", "ИШ не обновлён", "Лицензия", "Поддержка bj", "Модуль «",
"Сертификат", "Версия внешней системы"):
return "updates"
}
return "system"
}
+55 -3
View File
@@ -46,6 +46,49 @@ func (b BackupSettings) keep() int {
func (b BackupSettings) dir() string { return pickStr(strings.TrimSpace(b.Dir), "bj-backup") }
// smbLocation — адрес хранилища, как его вводят люди (\\nas\backup\bj как в проводнике, //nas/backup, smb://nas/backup;
// или сервер в поле «Хранилище», а общая папка — в поле «Папка»), → общая папка для smbclient (//сервер/папка) и
// путь внутри неё. Раньше «\\10.10.10.11\» + «backup_test» превращалось в //10.10.10.11//backup_test — без общей
// папки, и доступ не проверялся. Только сервер без общей папки — share «//сервер» (форма скажет, чего не хватает).
func smbLocation(share, dir string) (string, string) {
parts := func(s string) []string {
s = strings.ReplaceAll(strings.TrimSpace(s), `\`, "/")
if i := strings.Index(s, "://"); i > 0 && strings.EqualFold(s[:i], "smb") {
s = s[i+3:]
}
var out []string
for _, p := range strings.Split(s, "/") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
sp, dp := parts(share), parts(dir)
if len(sp) == 0 {
return "", strings.Join(dp, "/")
}
if len(sp) == 1 { // только сервер: общая папка — начало «Папки»
if len(dp) == 0 {
return "//" + sp[0], ""
}
sp, dp = []string{sp[0], dp[0]}, dp[1:]
}
sub := append(append([]string{}, sp[2:]...), dp...)
return "//" + sp[0] + "/" + sp[1], strings.Join(sub, "/")
}
// smbWin — адрес хранилища так, как его видят в проводнике Windows (//nas/backup → \\nas\backup).
func smbWin(share string) string {
if !strings.HasPrefix(share, "//") {
return share
}
return strings.ReplaceAll(share, "/", `\`)
}
// smbShareOK — в адресе есть и сервер, и общая папка.
func smbShareOK(share string) bool { return strings.Count(strings.TrimPrefix(share, "//"), "/") == 1 }
// BackupStatus — итог последней копии (пишет root-скрипт).
type BackupStatus struct {
At string `json:"at"`
@@ -85,6 +128,12 @@ func backupParams(r *http.Request) map[string]string {
p[k] = v
}
}
if p["share"] != "" { // адрес как в проводнике → общая папка и путь внутри неё
p["share"], p["dir"] = smbLocation(p["share"], p["dir"])
if p["dir"] == "" {
delete(p, "dir")
}
}
return p
}
@@ -110,14 +159,17 @@ func registerBackup(mux *http.ServeMux, rc *RuntimeConfig) {
}
b := rc.Snapshot().Backup
b.Enabled = r.FormValue("enabled") != ""
b.Share = strings.TrimSpace(r.FormValue("share"))
b.Dir = strings.TrimSpace(r.FormValue("dir"))
b.Share, b.Dir = smbLocation(r.FormValue("share"), r.FormValue("dir"))
b.User = strings.TrimSpace(r.FormValue("user"))
b.EveryHours, _ = strconv.Atoi(r.FormValue("every_hours"))
b.KeepDays, _ = strconv.Atoi(r.FormValue("keep_days"))
pass := r.FormValue("password")
if b.Share == "" {
setupFlash(w, r, "Резервное копирование: укажите хранилище, например //nas/backup")
setupFlash(w, r, `Резервное копирование: укажите сетевую папку, как в проводнике, например \\nas\backup`)
return
}
if !smbShareOK(b.Share) {
setupFlash(w, r, `Резервное копирование: укажите не только сервер, но и общую папку на нём — например \\`+strings.TrimPrefix(b.Share, "//")+`\backup`)
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
+2 -2
View File
@@ -222,8 +222,8 @@ code{font-size:12px}
</form>
<details style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">Переустановка сервера? Восстановить из резервной копии</summary>
<p class="hint">Вместо создания администратора можно вернуть сервер из резервной копии: настройки, пользователи, лицензия, заявки, база и папка шлюза НРД. Нужен одноразовый код (поле вверху) и доступ к хранилищу копий.</p>
<label>Хранилище (SMB)</label><input id="rs-share" placeholder="//nas/backup" autocomplete="off">
<label>Папка</label><input id="rs-dir" placeholder="bj-backup" autocomplete="off">
<label>Сетевая папка — как в проводнике</label><input id="rs-share" placeholder="\\nas\backup" autocomplete="off">
<label>Подпапка для копий</label><input id="rs-dir" placeholder="bj-backup" autocomplete="off">
<label>Логин хранилища</label><input id="rs-user" autocomplete="off">
<label>Пароль хранилища</label><input id="rs-pass" type="password" autocomplete="new-password">
<button type="button" id="rs-list">Показать копии</button>
+6 -6
View File
@@ -23,7 +23,7 @@ func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) {
}
cfg := rc.Snapshot()
inc := cfg.Incoming
if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" {
if !inc.Enabled || strings.TrimSpace(notifyRecipients(cfg, "incoming")) == "" {
return
}
smtp := cfg.Alerts.SMTP
@@ -34,13 +34,13 @@ func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) {
subject := "MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт"
body := buildIncomingEmailBody(it)
s := smtp
s.To = inc.Recipients("incoming") // получатели по маршруту «входящие переводы»
s.To = notifyRecipients(cfg, "incoming") // получатели по маршруту «входящие переводы»
if err := sendSMTP(s, subject, body); err != nil {
log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s не доставлено: %v", it.GUID, err)
return
}
log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s отправлено (%d бумаг) на %s",
it.GUID, len(it.Securities), inc.Recipients("incoming"))
it.GUID, len(it.Securities), notifyRecipients(cfg, "incoming"))
}
// notifyDepositoryAccepted отправляет письмо депозитарию о том, что входящий
@@ -51,7 +51,7 @@ func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed
}
cfg := rc.Snapshot()
inc := cfg.Incoming
if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" {
if !inc.Enabled || strings.TrimSpace(notifyRecipients(cfg, "incoming")) == "" {
return
}
smtp := cfg.Alerts.SMTP
@@ -94,12 +94,12 @@ func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed
b.WriteString("\nЭто уведомление сформировано автоматически системой Bridge-and-Join-s (bj).\n")
m := smtp
m.To = inc.Recipients("incoming")
m.To = notifyRecipients(cfg, "incoming")
if err := sendSMTP(m, subject, b.String()); err != nil {
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s не доставлено: %v", it.GUID, err)
return
}
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s отправлено на %s", it.GUID, inc.Recipients("incoming"))
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s отправлено на %s", it.GUID, notifyRecipients(cfg, "incoming"))
}
// buildIncomingEmailBody собирает текст письма депозитарию.
+138
View File
@@ -0,0 +1,138 @@
package lkgateway
import (
"path/filepath"
"strings"
"testing"
)
// Сбои и обновления — отдельные типы (по умолчанию — администратору системы), деловые — отделам.
func TestAlertKindAdmin(t *testing.T) {
cases := map[string]string{
"ИШ НРД недоступен": "infra",
"Перезапуск ИШ НРД не помог (попытка 1 из 3)": "infra",
"НРД не отвечает шлюзу": "infra",
"Компонент не отвечает: poll-incoming": "infra",
"Служба «Подпись» не работает": "infra",
"DEPO: вход логином bj_user не удался": "infra",
"Доступно обновление bj 2026.10.06.24": "updates",
"ИШ обновлён до 107.0.930": "updates",
"Лицензия: сервер лицензий отказал в продлении": "updates",
"Поддержка bj заканчивается через 10 дней": "updates",
"Сертификат ключа организации истекает через 5": "updates",
"НРД: депо-поручение не исполнено в срок 5 минут": "orders",
"QUIK МНП: не удалось зачислить бумаги": "quik",
"Перевод не доставлен в НРД": "system",
"DEPO: джоба не обработала перевод ab12": "system",
}
for title, want := range cases {
if got := alertKind(title); got != want {
t.Errorf("%q: %s, ожидалось %s", title, got, want)
}
}
}
// Существующая установка: администратор (адрес из «Почты») получает всё, что получал раньше; выключенные
// уведомления о переводах — пустой маршрут. Новая установка — маршруты по умолчанию.
func TestNotifyRoutesMigration(t *testing.T) {
var old Settings
old.Alerts.SMTP.To = "it@example.ru"
old.Incoming.Emails = "depo@example.ru"
migrateNotifyRoutes(&old)
r := old.Incoming.Routes
for _, k := range []string{"orders", "quik", "infra", "updates", "system"} {
if !old.Incoming.RouteHas(k, "admin") {
t.Errorf("%s: администратор должен остаться получателем (%q)", k, r[k])
}
}
if r["incoming"] != "" || old.Incoming.RouteHas("incoming", "depo") || !old.Incoming.Enabled || old.Incoming.RoutesV != 2 {
t.Errorf("выключенные уведомления о переводах: %+v", old.Incoming)
}
if !old.Incoming.RouteHas("infra", "back") || !old.Incoming.RouteHas("orders", "depo") {
t.Errorf("сбои — тем же отделам, что «прочие» раньше: %v", r)
}
if got := notifyRecipients(old, "infra"); got != "it@example.ru" {
t.Errorf("сбои: %q", got)
}
migrateNotifyRoutes(&old) // повторно — без изменений
if old.Incoming.Routes["orders"] != r["orders"] || strings.Count(old.Incoming.Routes["orders"], "admin") != 1 {
t.Errorf("повторная миграция: %v", old.Incoming.Routes)
}
var fresh Settings
migrateNotifyRoutes(&fresh)
fresh.Alerts.SMTP.To = "it@example.ru"
fresh.Incoming.Emails, fresh.Incoming.EmailsBack = "depo@example.ru", "back@example.ru"
if fresh.Incoming.Routes != nil || !fresh.Incoming.Enabled {
t.Fatalf("новая установка: %+v", fresh.Incoming)
}
want := map[string]string{
"incoming": "depo@example.ru, back@example.ru",
"orders": "depo@example.ru, back@example.ru",
"infra": "it@example.ru",
"updates": "it@example.ru",
"system": "it@example.ru, back@example.ru",
}
for k, w := range want {
if got := notifyRecipients(fresh, k); got != w {
t.Errorf("%s: %q, ожидалось %q", k, got, w)
}
}
}
// Служба пишет администратору, только пролежав две проверки подряд, один раз; поднялась — второе письмо.
func TestUnitsWatchTick(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
unitsWatch.Lock()
unitsWatch.down, unitsWatch.alerted = map[string]int{}, map[string]bool{}
unitsWatch.Unlock()
down := []watchedUnit{{Key: "bj-npr", Title: "Риск-монитор", Unit: "bj-npr", State: "failed/failed"}}
up := []watchedUnit{{Key: "bj-npr", Title: "Риск-монитор", Unit: "bj-npr", OK: true}}
count := func(sub string) int {
n := 0
for _, it := range rc.Snapshot().News.Items {
if strings.Contains(it.Title, sub) {
n++
}
}
return n
}
unitsWatchTick(rc, down)
if count("не работает") != 0 {
t.Fatal("после одной проверки письма быть не должно")
}
unitsWatchTick(rc, down)
unitsWatchTick(rc, down)
if count("не работает") != 1 {
t.Fatalf("оповещений о сбое: %d", count("не работает"))
}
unitsWatchTick(rc, up)
if count("снова работает") != 1 {
t.Fatalf("оповещений о восстановлении: %d", count("снова работает"))
}
}
// Адрес хранилища копий — как его вводят люди: в проводнике \\сервер\папка, или сервер и общая папка по разным полям.
func TestSMBLocation(t *testing.T) {
cases := []struct{ share, dir, wantShare, wantDir string }{
{`\\10.10.10.11\`, "backup_test", "//10.10.10.11/backup_test", ""},
{`\\10.10.10.11\backup_test`, "", "//10.10.10.11/backup_test", ""},
{`\\nas\backup\bj\copies`, "x", "//nas/backup", "bj/copies/x"},
{"//nas/backup", "bj-backup", "//nas/backup", "bj-backup"},
{"smb://nas/backup/", "", "//nas/backup", ""},
{"nas", "", "//nas", ""},
{"", "bj", "", "bj"},
}
for _, c := range cases {
s, d := smbLocation(c.share, c.dir)
if s != c.wantShare || d != c.wantDir {
t.Errorf("%q + %q → %q %q, ожидалось %q %q", c.share, c.dir, s, d, c.wantShare, c.wantDir)
}
}
if smbShareOK("//nas") || !smbShareOK("//nas/backup") || smbWin("//nas/backup") != `\\nas\backup` {
t.Error("smbShareOK/smbWin")
}
}
+71 -13
View File
@@ -110,6 +110,8 @@ type UpdateSettings struct {
// сменили, данные прошлой проверки к новому каналу не относятся: предлагать
// по ним обновление нельзя (кнопка «Обновить» уходила бы в пустоту).
ComponentsChannel string `json:"components_channel,omitempty"`
// Notified — выпуск, о котором уже написали администратору («Доступно обновление bj»): по разу на выпуск.
Notified string `json:"notified,omitempty"`
}
// NewsSettings — лента новостей (события системы, окна техработ, обновления
@@ -358,6 +360,7 @@ func NewRuntimeConfig(path string) (*RuntimeConfig, error) {
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"
@@ -595,18 +598,25 @@ type IncomingNotifySettings struct {
Emails string `json:"emails"` // депозитарий (через запятую)
EmailsBack string `json:"emails_back"` // бэк-офис
EmailsMiddle string `json:"emails_middle"` // мидл-офис
// Маршрутизация: тип уведомления → группы через запятую (depo,back,middle).
// Типы: incoming (входящие переводы), orders (сроки депо-поручений), quik (QUIK МНП), system (прочие).
// Маршрутизация: тип уведомления → группы через запятую (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 }{{"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}}
// Группы получателей и типы уведомлений (порядок — для формы). Администратор системы — тот, кто отвечает за
// сервер и ИТ: ему сбои и обновления, которые отделам (депозитарий, бэк- и мидл-офис) не нужны.
var notifyGroups = []struct{ Key, Title string }{{"admin", "Администратор"}, {"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}}
var notifyKinds = []struct{ Key, Title string }{
{"incoming", "Входящие переводы (получен / принят)"},
{"orders", "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"},
{"quik", "QUIK МНП (ошибки блокировки / списания / зачисления)"},
{"system", "Прочие системные оповещения"},
{"infra", "Сбои: шлюз НРД, связь с НРД, службы bj, обмен с DEPO"},
{"updates", "Обновления bj и шлюза, лицензия, сертификаты"},
{"system", "Прочие оповещения"},
}
// visibleNotifyKinds — типы уведомлений этой установки: ошибки QUIK МНП — только при модуле в лицензии.
@@ -621,16 +631,18 @@ func visibleNotifyKinds(rc *RuntimeConfig) []struct{ Key, Title string } {
return out
}
// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои.
var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"}
// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои: сбои и обновления —
// администратору, переводы и поручения — отделам.
var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle",
"infra": "admin", "updates": "admin", "system": "admin,back"}
// RouteHas — получает ли группа уведомления данного типа.
// RouteHas — получает ли группа уведомления данного типа (тип, которого нет в сохранённых маршрутах, — по умолчанию).
func (s IncomingNotifySettings) RouteHas(kind, group string) bool {
r := s.Routes
if r == nil {
r = defaultNotifyRoutes
route, ok := s.Routes[kind]
if !ok {
route = defaultNotifyRoutes[kind]
}
for _, g := range strings.Split(r[kind], ",") {
for _, g := range strings.Split(route, ",") {
if strings.TrimSpace(g) == group {
return true
}
@@ -651,7 +663,44 @@ func (s IncomingNotifySettings) GroupEmails(group string) string {
return ""
}
// Recipients — адреса всех групп, на которые направлен тип уведомления (без дублей).
// 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
@@ -671,6 +720,15 @@ func (s IncomingNotifySettings) Recipients(kind string) string {
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
+3 -2
View File
@@ -301,8 +301,9 @@ func (s *Server) Run(ctx context.Context) error {
// Все фоновые горутины — под supervise: паника ловится recover(), пишется
// в лог и в ленту, горутина перезапускается. Один сбой не роняет сервис.
go superviseLoop(ctx, "consume-decisions", s.rc, s.consumeDecisions)
go superviseLoop(ctx, "igw-watch", s.rc, s.igwWatchLoop) // сторож ИШ НРД: оповещения и автоперезапуск
go handbookLoop(ctx, s.rc) // справочник участников M2M с сервера лицензий (для установок без шлюза НРД)
go superviseLoop(ctx, "igw-watch", s.rc, s.igwWatchLoop) // сторож ИШ НРД: оповещения и автоперезапуск
go superviseLoop(ctx, "units-watch", s.rc, s.unitsWatchLoop) // службы модулей и ключей Валидаты: письмо администратору
go handbookLoop(ctx, s.rc) // справочник участников M2M с сервера лицензий (для установок без шлюза НРД)
// Поллер входящих от НРД (только в реальном режиме ИШ): забирает
// ответы робота/контрагента и применяет их через ApplyDecision.
+43 -15
View File
@@ -1041,7 +1041,7 @@ func (h *setupHandlers) saveAlertsSMTP(w http.ResponseWriter, r *http.Request) {
Username: strings.TrimSpace(r.FormValue("username")),
Password: pickStr(r.FormValue("password"), a.SMTP.Password), // пароль в страницу не выводится: пусто — прежний
From: strings.TrimSpace(r.FormValue("from")),
To: strings.TrimSpace(r.FormValue("to")),
To: a.SMTP.To, // адрес администратора системы — в «Кому писать» (notifyGroupsCard)
LastMsg: a.SMTP.LastMsg,
LastAt: a.SMTP.LastAt,
}
@@ -1058,8 +1058,22 @@ func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := h.rc.Snapshot().Alerts
err := sendSMTP(a.SMTP, "[bj] Проверка уведомлений", "Это проверочное письмо от bj-server. Если вы его получили — SMTP-канал оповещений работает.")
cfg := h.rc.Snapshot()
a := cfg.Alerts
// кому: администратор системы, а пока его адреса нет — все адреса отделов из «Кому писать»
test := a.SMTP
if strings.TrimSpace(test.To) == "" {
var all []string
for _, k := range notifyKinds {
all = append(all, cfg.Incoming.Recipients(k.Key))
}
test.To = mergeEmails(all...)
}
if strings.TrimSpace(test.To) == "" {
setupFlash(w, r, "Проверка SMTP: укажите хотя бы один адрес в блоке «Кому писать» и сохраните")
return
}
err := sendSMTP(test, "[bj] Проверка уведомлений", "Это проверочное письмо от bj-server. Если вы его получили — SMTP-канал оповещений работает.")
a.SMTP.LastAt = time.Now()
if err != nil {
a.SMTP.LastMsg = "ошибка: " + err.Error()
@@ -1067,9 +1081,9 @@ func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) {
setupFlash(w, r, "Проверка SMTP: "+err.Error())
return
}
a.SMTP.LastMsg = "проверочное письмо отправлено"
a.SMTP.LastMsg = "проверочное письмо отправлено на " + test.To
_ = h.rc.UpdateAlerts(a)
setupFlash(w, r, "Проверка SMTP: письмо отправлено на "+a.SMTP.To)
setupFlash(w, r, "Проверка SMTP: письмо отправлено на "+test.To)
}
// saveAlertsZabbix сохраняет настройки Zabbix-хука.
@@ -1121,19 +1135,24 @@ func (h *setupHandlers) saveIncomingNotify(w http.ResponseWriter, r *http.Reques
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// какие уведомления кому — только таблица: прежняя галочка «уведомления о переводах» — строка «Входящие»
s := IncomingNotifySettings{
Enabled: r.FormValue("enabled") == "on",
Enabled: true,
Emails: strings.TrimSpace(r.FormValue("emails")),
EmailsBack: strings.TrimSpace(r.FormValue("emails_back")),
EmailsMiddle: strings.TrimSpace(r.FormValue("emails_middle")),
Routes: map[string]string{},
RoutesV: 2,
}
prev := h.rc.Snapshot().Incoming.Routes
if prev == nil {
prev = defaultNotifyRoutes
}
cur := h.rc.Snapshot()
for _, k := range notifyKinds { // скрытые в форме типы (модуль не в лицензии) — прежний маршрут
s.Routes[k.Key] = prev[k.Key]
var gs []string
for _, g := range notifyGroups {
if cur.Incoming.RouteHas(k.Key, g.Key) {
gs = append(gs, g.Key)
}
}
s.Routes[k.Key] = strings.Join(gs, ",")
}
for _, k := range visibleNotifyKinds(h.rc) {
var gs []string
@@ -1148,6 +1167,15 @@ func (h *setupHandlers) saveIncomingNotify(w http.ResponseWriter, r *http.Reques
setupFlash(w, r, "Уведомления: ошибка сохранения: "+err.Error())
return
}
// адрес администратора системы хранится в настройках почты (Alerts.SMTP.To) — им же писали всегда
if _, ok := r.Form["emails_admin"]; ok {
a := cur.Alerts
a.SMTP.To = strings.TrimSpace(r.FormValue("emails_admin"))
if err := h.rc.UpdateAlerts(a); err != nil {
setupFlash(w, r, "Уведомления: ошибка сохранения адреса администратора: "+err.Error())
return
}
}
setupFlash(w, r, "Адреса и маршруты уведомлений сохранены")
}
@@ -1159,8 +1187,8 @@ func (h *setupHandlers) testIncomingNotify(w http.ResponseWriter, r *http.Reques
return
}
cfg := h.rc.Snapshot()
if strings.TrimSpace(cfg.Incoming.Recipients("incoming")) == "" {
setupFlash(w, r, "Проверка: сначала укажите почту депозитария")
if strings.TrimSpace(notifyRecipients(cfg, "incoming")) == "" {
setupFlash(w, r, "Проверка: сначала укажите адреса и отметьте в таблице, кому идут «Входящие переводы»")
return
}
smtp := cfg.Alerts.SMTP
@@ -1174,12 +1202,12 @@ func (h *setupHandlers) testIncomingNotify(w http.ResponseWriter, r *http.Reques
ReceivedAt: time.Now(),
Securities: []IncomingSecurity{{SecurityCode: "RU0009029540", ISIN: "RU0009029540", Quantity: "10"}},
}
smtp.To = cfg.Incoming.Recipients("incoming")
smtp.To = notifyRecipients(cfg, "incoming")
if err := sendSMTP(smtp, "[ПРОВЕРКА] MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт", buildIncomingEmailBody(sample)); err != nil {
setupFlash(w, r, "Проверка уведомления депозитарию: "+err.Error())
return
}
setupFlash(w, r, "Проверка: письмо-уведомление отправлено на "+cfg.Incoming.Recipients("incoming"))
setupFlash(w, r, "Проверка: письмо-уведомление отправлено на "+notifyRecipients(cfg, "incoming"))
}
// lkPostJSON — POST JSON к API ЛК с Basic; возвращает (код, тело).
+126
View File
@@ -0,0 +1,126 @@
package lkgateway
// unitswatch.go — сторож служб (06.10.2026): администратор системы узнаёт письмом, что служба модуля
// (bj-crypto, bj-npr, bj-quikexport, bj-lk) или служба ключей Валидаты не работает, — раньше это было видно
// только в карточке «Службы» или по ошибкам раздела. ИШ НРД сторожит igwwatch, сбои внутри bj-server — health.
// Служба должна лежать две проверки подряд: перезапуск при обновлении письма не вызывает.
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const unitsCheckEvery = 2 * time.Minute
var unitsWatch = struct {
sync.Mutex
down map[string]int // служба → проверок подряд, когда она не работала
alerted map[string]bool // о службе уже написали
}{down: map[string]int{}, alerted: map[string]bool{}}
// watchedUnit — служба под присмотром и её состояние сейчас.
type watchedUnit struct {
Key, Title, Unit string
OK bool
State string // как она лежит — для письма
Hint string // что из-за этого не работает и как поднять
}
func (s *Server) unitsWatchLoop(ctx context.Context) {
healthRegister("units-watch", 2*unitsCheckEvery)
t := time.NewTicker(unitsCheckEvery)
defer t.Stop()
for {
unitsWatchTick(s.rc, watchedUnits(s.rc))
healthBeat("units-watch")
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// watchedUnits — службы модулей этого сервера (кроме самого bj-server) и служба ключей Валидаты.
func watchedUnits(rc *RuntimeConfig) []watchedUnit {
var out []watchedUnit
for _, v := range unitViews(rc) {
if v.Unit == "bj-server" {
continue
}
st := v.State + "/" + v.Sub
if v.Result != "" {
st += ", причина: " + v.Result
}
if v.Since != "" {
st += ", с " + v.Since
}
out = append(out, watchedUnit{Key: v.Unit, Title: v.Title, Unit: v.Unit, OK: v.OK(), State: st,
Hint: "Перезапустить — Настройка → Обновления → «Службы», кнопка «Перезапустить»; если не поднимается — journalctl -u " + v.Unit + "."})
}
// служба ключей — когда она нужна: включена в автозапуск (bj включает её при установке ключа) или уже работала;
// на свежем сервере до установки ключа её может и не быть — это не сбой
if _, err := os.Stat("/opt/Validata/VDCSP/bin/amd64/vdcrysvc"); err == nil {
ok := procRunning("vdcrysvc")
unitsWatch.Lock()
seen := unitsWatch.alerted["vdcrysvc"] || unitsWatch.down["vdcrysvc"] > 0 || vdcrysvcSeen
if ok {
vdcrysvcSeen = true
}
unitsWatch.Unlock()
if !ok && !seen && !unitEnabled("vdmkdev.service") {
return out
}
out = append(out, watchedUnit{Key: "vdcrysvc", Title: "служба ключей Валидаты", Unit: "vdmkdev", OK: ok,
State: "процесс vdcrysvc не запущен",
Hint: "Без неё ключ подписи не работает: шлюз НРД не подпишет и не отправит документы. Запустить: sudo /etc/init.d/vdmkdev start."})
}
return out
}
// vdcrysvcSeen — служба ключей Валидаты работала с запуска bj (под unitsWatch).
var vdcrysvcSeen bool
// unitEnabled — служба включена в автозапуск (systemctl is-enabled, без прав root).
func unitEnabled(unit string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, "systemctl", "is-enabled", unit).Output()
return strings.TrimSpace(string(out)) == "enabled"
}
// procRunning — есть ли процесс с таким именем (по /proc/*/comm, без прав root).
func procRunning(name string) bool {
dirs, _ := filepath.Glob("/proc/[0-9]*/comm")
for _, f := range dirs {
if b, err := os.ReadFile(f); err == nil && strings.TrimSpace(string(b)) == name {
return true
}
}
return false
}
func unitsWatchTick(rc *RuntimeConfig, units []watchedUnit) {
unitsWatch.Lock()
defer unitsWatch.Unlock()
for _, u := range units {
if u.OK {
if unitsWatch.alerted[u.Key] {
raiseSystemAlert(rc, "Служба «"+u.Title+"» снова работает", "Служба "+u.Unit+" ("+u.Title+") снова запущена.")
}
delete(unitsWatch.down, u.Key)
delete(unitsWatch.alerted, u.Key)
continue
}
unitsWatch.down[u.Key]++
if unitsWatch.down[u.Key] >= 2 && !unitsWatch.alerted[u.Key] {
unitsWatch.alerted[u.Key] = true
raiseSystemAlert(rc, "Служба «"+u.Title+"» не работает", "Служба "+u.Unit+" ("+u.Title+") не работает: "+u.State+". "+u.Hint)
}
}
}
+19
View File
@@ -574,6 +574,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
defer cancel()
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
log.Printf("lk-gateway: %s", st.Message)
u.notifyUpdate(st)
}
}
// первая проверка через минуту после старта
@@ -596,6 +597,24 @@ func (u *Updater) updateLoop(ctx context.Context) {
}
}
// notifyUpdate — администратору системы: вышел выпуск, который на этом сервере ещё не установлен (по разу на выпуск).
func (u *Updater) notifyUpdate(st UpdateStatus) {
cfg := u.rc.Snapshot().Update
if st.Available == "" || cfg.Notified == st.Available {
return
}
cfg.Notified = st.Available
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
return
}
body := "Вышел выпуск bj " + st.Available + ", на этом сервере он ещё не установлен: " + strings.TrimPrefix(st.Message, "доступны обновления: ") +
". Установить — Настройка → Обновления, кнопка «Обновить»; обмен на время установки прерывается на минуту-две."
if n := strings.TrimSpace(st.Notes); n != "" {
body += "\n\nЧто нового:\n" + n
}
raiseSystemAlert(u.rc, "Доступно обновление bj "+st.Available, body)
}
// ApplyBundle — обновление из файла для закрытого контура: пакет с портала клиента
// (подписанный манифест + файлы компонентов). Что ставить, решает тот же план, что и при
// обновлении по сети: всё, что новее установленного, в порядке компонентов — ядро первым,
@@ -669,14 +669,12 @@
{{if eq .Step 9}}
<div class="card">
<h2><span class="dot {{if .Done.Alerts}}ok{{else}}warn{{end}}"></span>Оповещения и резервные копии</h2>
<p>bj напишет на почту, если НРД не отвечает, перевод застрял{{if .DepoLicensed}}, DEPO не обработал поручение{{end}} или истекает ключ, — и будет сам делать копии сервера. Обмен работает и без этого, но мы советуем настроить оба пункта.</p>
<p>bj напишет на почту, если НРД не отвечает, перевод застрял{{if .DepoLicensed}}, DEPO не обработал поручение{{end}} или истекает ключ, — и будет сам делать копии сервера. Настройте почтовый сервер, затем кому писать: администратору системы — о сбоях и обновлениях, отделам — о переводах и поручениях. Обмен работает и без этого, но о сбое тогда никто не узнает вовремя.</p>
<style>.wiz9 label:not(:has(input[type=checkbox])){display:block;margin:0}.wiz9 label input:not([type=checkbox]),.wiz9 label select,.wiz9 .grid input,.wiz9 .grid select{width:100%;box-sizing:border-box;margin-top:4px}</style>
<div class="wiz9">
{{with .Fancy}}
{{template "smtpCard" .}}
<details style="margin:10px 0 0"><summary style="cursor:pointer;color:var(--accent);font-size:13px">Разослать уведомления отделам — депозитарий, бэк-офис, мидл-офис (необязательно)</summary>
{{template "notifyGroupsCard" .}}
</details>
{{template "backupCard" .}}
{{end}}
</div>
@@ -2,7 +2,7 @@
{{define "smtpCard"}}
<div class="card" id="sec-smtp">
<h2>Почта (SMTP)</h2>
<p class="muted">Через какой почтовый сервер bj отправляет письма и кому.</p>
<p class="muted">Через какой почтовый сервер bj отправляет письма. Кому — в блоке «Кому писать».</p>
<form method="post" action="/admin/setup/alerts-smtp" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-bottom:12px"><input type="checkbox" name="enabled" style="width:auto" {{if .Settings.Alerts.SMTP.Enabled}}checked{{end}}> Включить почтовые уведомления</label>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
@@ -19,7 +19,6 @@
<div><label>Логин</label><input type="text" name="username" value="{{.Settings.Alerts.SMTP.Username}}" autocomplete="off" style="margin-top:3px"></div>
<div><label>Пароль {{if .Settings.Alerts.SMTP.Password}}<span class="muted">(задан; пусто — оставить)</span>{{end}}</label><input type="password" name="password" autocomplete="new-password" style="margin-top:3px"></div>
<div><label>Отправитель (From)</label><input type="text" name="from" value="{{.Settings.Alerts.SMTP.From}}" placeholder="bj@example.ru" style="margin-top:3px"></div>
<div><label>Получатели (через запятую)</label><input type="text" name="to" value="{{.Settings.Alerts.SMTP.To}}" placeholder="ops@example.ru, dev@example.ru" style="margin-top:3px"></div>
</div>
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button type="submit" class="btn">Сохранить</button>
@@ -41,24 +40,26 @@
{{define "notifyGroupsCard"}}
<div class="card" id="sec-notify-groups">
<h2>Адресаты уведомлений: депозитарий, бэк-офис, мидл-офис</h2>
<p class="muted">Три группы адресов и таблица «какой тип уведомления кому отправлять». Письма идут через SMTP из раздела «Почта». Адрес алертов из раздела «Почта» получает системные оповещения всегда; группы ниже добавляются по маршруту.</p>
<h2>Кому писать</h2>
<p class="muted">Администратору системы — то, что нужно ИТ: сбои шлюза НРД и служб bj, потеря связи с НРД, обновления, лицензия и сертификаты. Отделам — переводы и поручения. Что кому отправлять — в таблице; группе без адреса bj не пишет.</p>
<form method="post" action="/admin/setup/incoming-notify" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-bottom:12px"><input type="checkbox" name="enabled" style="width:auto" {{if .Settings.Incoming.Enabled}}checked{{end}}> Включить уведомления о переводах</label>
<div style="display:grid;gap:10px;max-width:640px">
<div><label>Администратор системы (через запятую)</label><input type="text" name="emails_admin" value="{{.Settings.Alerts.SMTP.To}}" placeholder="it@example.ru" style="margin-top:3px;width:100%"></div>
<div><label>Депозитарий (через запятую)</label><input type="text" name="emails" value="{{.Settings.Incoming.Emails}}" placeholder="depo@example.ru" style="margin-top:3px;width:100%"></div>
<div><label>Бэк-офис (через запятую)</label><input type="text" name="emails_back" value="{{.Settings.Incoming.EmailsBack}}" placeholder="backoffice@example.ru" style="margin-top:3px;width:100%"></div>
<div><label>Мидл-офис (через запятую)</label><input type="text" name="emails_middle" value="{{.Settings.Incoming.EmailsMiddle}}" placeholder="middle@example.ru" style="margin-top:3px;width:100%"></div>
</div>
<table style="margin-top:14px;max-width:760px">
<div style="overflow-x:auto;margin-top:14px">
<table style="max-width:760px">
<thead><tr><th>Тип уведомления</th>{{range .NotifyGroups}}<th style="text-align:center">{{.Title}}</th>{{end}}</tr></thead>
<tbody>{{$inc := .Settings.Incoming}}{{range $k := .NotifyKinds}}<tr><td>{{$k.Title}}</td>{{range $g := $.NotifyGroups}}<td style="text-align:center"><input type="checkbox" name="route_{{$k.Key}}_{{$g.Key}}" value="1" {{if $inc.RouteHas $k.Key $g.Key}}checked{{end}} style="width:auto"></td>{{end}}</tr>{{end}}</tbody>
<tbody>{{$inc := .Settings.Incoming}}{{range $k := .NotifyKinds}}<tr><td>{{$k.Title}}</td>{{range $g := $.NotifyGroups}}<td style="text-align:center"><input type="checkbox" name="route_{{$k.Key}}_{{$g.Key}}" value="1" {{if $inc.RouteHas $k.Key $g.Key}}checked{{end}} style="width:auto" aria-label="{{$k.Title}} — {{$g.Title}}"></td>{{end}}</tr>{{end}}</tbody>
</table>
</div>
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button type="submit" class="btn">Сохранить</button>
<button type="submit" formaction="/admin/setup/incoming-notify-test" class="btn btn-secondary">Отправить пробное уведомление</button>
<button type="submit" formaction="/admin/setup/incoming-notify-test" class="btn btn-secondary">Отправить пробное уведомление о переводе</button>
</div>
</form>
<p class="muted" style="font-size:12px;margin-top:8px">Требуется настроенный и включённый SMTP в разделе «Почта».</p>
<p class="muted" style="font-size:12px;margin-top:8px">Письма уходят через почтовый сервер из блока «Почта (SMTP)».</p>
</div>
{{end}}
@@ -9,9 +9,10 @@
<form method="post" action="/admin/setup/backup" id="bk-form" style="display:grid;gap:10px;max-width:640px">
<label class="chk" style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="enabled" style="width:auto"{{if $b.Enabled}} checked{{end}}> <b>Делать копии по расписанию</b></label>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:10px">
<label>Хранилище (SMB)<input name="share" value="{{$b.Share}}" placeholder="//nas/backup"></label>
<label>Папка<input name="dir" value="{{$b.Dir}}" placeholder="bj-backup"></label>
<label>Сетевая папка — как в проводнике<input name="share" value="{{smbWin $b.Share}}" placeholder="\\nas\backup"></label>
<label>Подпапка для копий<input name="dir" value="{{$b.Dir}}" placeholder="bj-backup"></label>
</div>
<p class="muted" style="font-size:12px;margin:-4px 0 0">Например <code>\\10.10.10.11\backup</code> — та же папка, что открывается в проводнике Windows с этим логином. Копии ложатся в подпапку внутри неё.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label>Логин хранилища<input name="user" value="{{$b.User}}" autocomplete="off"></label>
<label>Пароль {{if $b.PasswordSet}}<span class="muted">(задан; пусто — оставить)</span>{{end}}<input name="password" type="password" autocomplete="new-password"></label>