feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР

- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+326
View File
@@ -0,0 +1,326 @@
package lkgateway
import (
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"mime"
"net"
"net/smtp"
"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 = mergeEmails(a.SMTP.To, cfg.Incoming.Recipients(alertKind(title)))
if strings.TrimSpace(s.To) != "" {
if err := sendSMTP(s, "[bj] "+title, 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)
}
}
}
// --- 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)")
}
port := s.Port
if port == 0 {
port = 587
}
addr := fmt.Sprintf("%s:%d", s.Host, port)
msg := buildMIME(s.From, rcpts, subject, body)
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 {
switch {
case strings.HasPrefix(title, "НРД: депо-поручение") || strings.HasPrefix(title, "НРД: истёк срок"):
return "orders"
case strings.Contains(title, "QUIK"):
return "quik"
}
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, ", ")
}