Files
Bridge-and-Join-s/internal/lkgateway/alerts.go
T
fontvielle 32d88fc42a ЛК: итог «Исполнена» по MS010, GUID без учёта регистра, заявка при отказе МНП берётся один раз; тестовый контур не трогает боевой ЛК
Перевод 97EAD00C (заявка ЛК #71741, 23.10.2026) исполнен в QUIK и НРД (MS010),
но в ЛК статус не изменился, а сделка осталась «Подтверждена»:
- связки guid → заявка ЛК искались с учётом регистра: мы шлём GUID строчными,
  НРД отвечает заглавными — OnLKOutcome молча не находил заявку. Ключи —
  строчными (lkGUIDKey), старые записи приводятся при загрузке; итог модуля
  ЛК (lk.transfer_requests) — lower(guid);
- после MS010 по всем строкам поручения 16 сделка → «Исполнена» (CompleteSUB16),
  итог — в ЛК (outOnExecuted, каждый проход order-watch, повтор безвреден);
  в бою согласие контрагента — ещё не «Исполнена»: статус ждёт MS010, как в
  тесте «боевой ЛК → TEST3»; имитатор НРД — итог сразу.

Тестовый 28 с боевым ЛК взял ту же заявку и после отказа МНП («запрещен для
операции шорт» — бумагу уже заблокировал 9) брал её каждые 10 секунд: 108
сделок с новыми GUID и столько же писем.
- Сделка заведена, а отправка не прошла (МНП не заблокировал) — заявка
  помечается взятой и связывается с переводом: дальше — оператор в карточке.
- Боевой ЛК при тестовом профиле ИШ (lkProdOnTestNSD): заявки клиентов не
  берутся (кроме теста «боевой ЛК → тестовый НРД»), портфели не заполняются,
  предупреждение в настройках ЛК.
- Письма: в теме и первой строке — сервер bj и контур НРД («[bj ТЕСТ …]»
  у тестового); письма о входящих — метка только у тестового.
- История FSM — по МСК с датой, «текущее состояние» вместо «сейчас».
- Карточка теста «боевой ЛК → тестовый НРД» — только в режиме разработки
  (включённый тест виден всегда, чтобы его выключить).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-23 16:04:44 +03:00

369 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"mime"
"net"
"net/smtp"
"strconv"
"strings"
"time"
)
// alerts.go — внешняя доставка системных оповещений сверх ленты «Новости».
// Единая точка врезки — dispatchAlert, вызывается из raiseSystemAlert.
// Два канала: SMTP (почта оператору) и Zabbix trapper (хук в мониторинг).
// dispatchAlert рассылает оповещение по включённым внешним каналам.
// Best-effort: ошибки логируются, но НЕ поднимают новый алерт (иначе рекурсия).
func dispatchAlert(rc *RuntimeConfig, title, body string) {
if rc == nil {
return
}
cfg := rc.Snapshot()
a := cfg.Alerts
if a.SMTP.Enabled {
// администратор системы и отделы — по маршруту типа (сроки поручений / QUIK / сбои / обновления / прочие)
s := a.SMTP
s.To = notifyRecipients(cfg, alertKind(title))
if strings.TrimSpace(s.To) != "" {
tag, line := alertServer(cfg.NSD.Profile)
if err := sendSMTP(s, tag+title, line+body); err != nil {
log.Printf("lk-gateway: алерт SMTP не доставлен: %v", err)
}
}
}
if a.Zabbix.Enabled {
if info, err := sendZabbix(a.Zabbix, title+" — "+body); err != nil {
log.Printf("lk-gateway: алерт Zabbix не доставлен: %v", err)
} else {
log.Printf("lk-gateway: алерт Zabbix отправлен: %s", info)
}
}
}
// alertServer — от какого сервера bj письмо: метка в теме и строка в начале текста (23.10.2026: письмо
// тестового сервера о заявке клиента приняли за письмо боевого).
func alertServer(profile string) (tag, line string) {
host := primaryIPv4()
contour := "боевой контур НРД"
tag = "[bj " + host + "] "
if !nsdProfileProd(profile) {
contour = "ТЕСТОВЫЙ контур НРД"
tag = "[bj ТЕСТ " + host + "] "
}
return tag, fmt.Sprintf("Сервер bj: %s (%s, профиль %s)\n\n", adminBaseURL(), contour, pickStr(profile, "не задан"))
}
// nsdProfileProd — профиль ИШ боевого контура НРД (prod-gost); test3-… и пустой — тестовый.
func nsdProfileProd(profile string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(profile)), "prod")
}
// --- SMTP ---
// sendSMTP отправляет письмо согласно настройкам (STARTTLS/SSL/без шифрования,
// опциональная аутентификация). Таймаут 15 с.
func sendSMTP(s SMTPSettings, subject, body string) error {
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("не задан хост SMTP")
}
if strings.TrimSpace(s.From) == "" {
return fmt.Errorf("не задан отправитель (From)")
}
rcpts := splitRecipients(s.To)
if len(rcpts) == 0 {
return fmt.Errorf("не заданы получатели (To)")
}
return smtpDeliver(s, rcpts, buildMIME(s.From, rcpts, subject, body))
}
// smtpDeliver — транспорт SMTP: соединение, TLS, аутентификация и отправка
// готового MIME-сообщения. Выделено из sendSMTP для переиспользования в
// письмах с вложением (sendSMTPAttach, participant_form.go).
func smtpDeliver(s SMTPSettings, rcpts []string, msg []byte) error {
port := s.Port
if port == 0 {
port = 587
}
addr := net.JoinHostPort(s.Host, strconv.Itoa(port))
dialer := &net.Dialer{Timeout: 15 * time.Second}
var client *smtp.Client
var err error
if strings.EqualFold(s.Security, "ssl") {
// Неявный TLS (обычно порт 465).
conn, e := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: s.Host})
if e != nil {
return fmt.Errorf("TLS-соединение: %w", e)
}
client, err = smtp.NewClient(conn, s.Host)
} else {
conn, e := dialer.Dial("tcp", addr)
if e != nil {
return fmt.Errorf("соединение: %w", e)
}
client, err = smtp.NewClient(conn, s.Host)
}
if err != nil {
return fmt.Errorf("SMTP-клиент: %w", err)
}
defer client.Close()
if !strings.EqualFold(s.Security, "ssl") && !strings.EqualFold(s.Security, "none") {
// STARTTLS (по умолчанию, обычно порт 587).
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil {
return fmt.Errorf("STARTTLS: %w", err)
}
} else {
return fmt.Errorf("сервер не поддерживает STARTTLS — выберите «без шифрования» или «SSL»")
}
}
if strings.TrimSpace(s.Username) != "" {
if err := smtpAuthenticate(client, s.Username, s.Password, s.Host); err != nil {
return err
}
}
if err := client.Mail(s.From); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
for _, r := range rcpts {
if err := client.Rcpt(r); err != nil {
return fmt.Errorf("RCPT TO %s: %w", r, err)
}
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("DATA: %w", err)
}
if _, err := wc.Write(msg); err != nil {
return fmt.Errorf("запись тела: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("завершение тела: %w", err)
}
return client.Quit()
}
// smtpAuthenticate выбирает механизм по объявленным сервером (PLAIN/LOGIN/
// CRAM-MD5) и пробует их по очереди — многие серверы не поддерживают PLAIN
// (ошибка «504 Unrecognized authentication type»), а только LOGIN.
func smtpAuthenticate(client *smtp.Client, user, pass, host string) error {
ok, mechs := client.Extension("AUTH")
if !ok {
return fmt.Errorf("сервер не поддерживает аутентификацию (AUTH), но задан логин")
}
var tried []smtp.Auth
if strings.Contains(mechs, "PLAIN") {
tried = append(tried, smtp.PlainAuth("", user, pass, host))
}
if strings.Contains(mechs, "LOGIN") {
tried = append(tried, loginAuth(user, pass))
}
if strings.Contains(mechs, "CRAM-MD5") {
tried = append(tried, smtp.CRAMMD5Auth(user, pass))
}
if len(tried) == 0 {
return fmt.Errorf("сервер предлагает механизмы аутентификации, не поддерживаемые bj: [%s]", mechs)
}
var lastErr error
for _, a := range tried {
if err := client.Auth(a); err == nil {
return nil
} else {
lastErr = err
}
}
return fmt.Errorf("аутентификация не удалась (механизмы сервера: [%s]): %w", mechs, lastErr)
}
// loginAuth — механизм AUTH LOGIN (не входит в stdlib). Сервер шлёт запросы
// «Username:»/«Password:», отвечаем логином и паролем.
type loginAuthenticator struct{ user, pass string }
func loginAuth(user, pass string) smtp.Auth { return &loginAuthenticator{user, pass} }
func (a *loginAuthenticator) Start(_ *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", nil, nil
}
func (a *loginAuthenticator) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
p := strings.ToLower(string(fromServer))
switch {
case strings.Contains(p, "user"):
return []byte(a.user), nil
case strings.Contains(p, "pass"):
return []byte(a.pass), nil
default:
return nil, fmt.Errorf("неожиданный запрос сервера при LOGIN: %q", fromServer)
}
}
func splitRecipients(s string) []string {
var out []string
for _, p := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' }) {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// buildMIME собирает простое UTF-8 письмо (заголовки + текст).
func buildMIME(from string, to []string, subject, body string) []byte {
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + strings.Join(to, ", ") + "\r\n")
// RFC 2047 — тема на кириллице в base64 UTF-8.
b.WriteString("Subject: " + encodeHeaderWord(subject) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(body)
b.WriteString("\r\n")
return []byte(b.String())
}
// encodeHeaderWord кодирует заголовок как =?UTF-8?B?...?= (для кириллицы).
func encodeHeaderWord(s string) string {
return mime.BEncoding.Encode("UTF-8", s)
}
// --- Zabbix trapper (протокол zabbix_sender) ---
// sendZabbix шлёт значение элемента-trapper на сервер/прокси Zabbix.
// Возвращает строку info из ответа сервера (напр. "processed 1; failed 0").
func sendZabbix(z ZabbixSettings, value string) (string, error) {
if strings.TrimSpace(z.Server) == "" {
return "", fmt.Errorf("не задан адрес сервера Zabbix")
}
if strings.TrimSpace(z.Host) == "" || strings.TrimSpace(z.Key) == "" {
return "", fmt.Errorf("не заданы host/key элемента Zabbix")
}
port := z.Port
if port == 0 {
port = 10051
}
payload, _ := json.Marshal(map[string]any{
"request": "sender data",
"data": []map[string]any{{
"host": z.Host,
"key": z.Key,
"value": value,
}},
})
// Заголовок протокола: "ZBXD" \x01 + 8 байт длины (LE) + данные.
header := make([]byte, 13)
copy(header, "ZBXD")
header[4] = 0x01
binary.LittleEndian.PutUint64(header[5:], uint64(len(payload)))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
d := &net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", z.Server, port))
if err != nil {
return "", fmt.Errorf("соединение: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
if _, err := conn.Write(append(header, payload...)); err != nil {
return "", fmt.Errorf("отправка: %w", err)
}
// Ответ: 13-байтный заголовок + JSON.
respHdr := make([]byte, 13)
if _, err := readFull(conn, respHdr); err != nil {
return "", fmt.Errorf("ответ (заголовок): %w", err)
}
if string(respHdr[:4]) != "ZBXD" {
return "", fmt.Errorf("неожиданный ответ сервера Zabbix")
}
n := binary.LittleEndian.Uint64(respHdr[5:])
if n == 0 || n > 1<<20 {
return "принято", nil
}
buf := make([]byte, n)
if _, err := readFull(conn, buf); err != nil {
return "", fmt.Errorf("ответ (тело): %w", err)
}
var r struct {
Response string `json:"response"`
Info string `json:"info"`
}
if json.Unmarshal(buf, &r) == nil {
if r.Response != "success" {
return "", fmt.Errorf("Zabbix отклонил: %s", r.Info)
}
if strings.Contains(r.Info, "failed 1") || strings.Contains(r.Info, "failed: 1") {
return "", fmt.Errorf("Zabbix не принял значение (%s) — проверьте host/key в Zabbix", r.Info)
}
return r.Info, nil
}
return "принято", nil
}
func readFull(conn net.Conn, buf []byte) (int, error) {
total := 0
for total < len(buf) {
n, err := conn.Read(buf[total:])
total += n
if err != nil {
return total, err
}
}
return total, nil
}
// 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 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"
}
// mergeEmails — объединение списков адресов без дублей.
func mergeEmails(lists ...string) string {
seen := map[string]bool{}
var out []string
for _, l := range lists {
for _, e := range strings.Split(l, ",") {
e = strings.TrimSpace(e)
if e == "" || seen[strings.ToLower(e)] {
continue
}
seen[strings.ToLower(e)] = true
out = append(out, e)
}
}
return strings.Join(out, ", ")
}