Мониторинг связи с НРД: участки Шлюз/НРД/M2M/Отправка, сквозная проверка справочником раз в 10 мин, история за сутки на «Статусе», цепочка в шапке (30 с), автоповтор недоставленных до 24 ч, баннер и статус «ожидает отправки» в кабинете клиента

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-09-25 16:17:25 +03:00
parent a0f6ce9f7b
commit 4cbefa41d5
18 changed files with 1075 additions and 62 deletions
+568
View File
@@ -0,0 +1,568 @@
package lkgateway
// Связь с НРД — «зелёный только при свежем подтверждении». Состояние складывается из участков
// пути, у каждого своё доказательство работы:
// - Шлюз: ИШ отвечает bj (igwwatch.go);
// - НРД: шлюз успешно опрашивает НРД — по журналу ИШ (nsdlinkwatch.go);
// - M2M: сквозная проверка — раз в 10 минут bj запрашивает у НРД справочник участников (#M2MHR)
// и ждёт ответ #M2MTH: он проходит весь путь до сервиса M2M и обратно;
// - Отправка: подтверждение доставки каждого пакета (igw.WithDeliveryHook) и недоставленные переводы.
// Отсутствие ошибок само по себе не считается «работает»: нет свежего подтверждения — жёлтый.
// Раз в минуту состояние пишется в историю (сутки), по ней на странице «Статус» рисуется полоса.
// Недоставленные переводы уходят повторно сами, как только НРД снова отвечает (до 24 ч — срок ответа НРД).
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
const (
nsdProbeEvery = 10 * time.Minute
nsdProbeWarnAfter = 3 * time.Minute // ответа на проверку нет 3 мин — жёлтый
nsdProbeErrAfter = 10 * time.Minute // 10 мин — красный
nsdPollErrAfter = 3 * time.Minute // шлюз получает от НРД ошибки 3 мин — красный (до этого жёлтый)
nsdPollStale = 5 * time.Minute // нет свежих опросов НРД в журнале — жёлтый
autoResendMaxAge = 24 * time.Hour // срок ответа НРД: старше — не повторяем сами
autoResendGap = 5 * time.Minute // между попытками по одной заявке
nsdHistoryKeep = 24 * 60 // минут
)
var nsdHistoryFile = "/var/lib/bj/.bj/nsd-health.json"
// Состояния участков по возрастанию тяжести.
const (
segOK = "ok"
segUnknown = "unknown"
segWarn = "warn"
segErr = "err"
segOff = "off"
)
func segRank(s string) int {
switch s {
case segOK:
return 0
case segUnknown:
return 1
case segWarn:
return 2
case segErr:
return 3
}
return -1
}
// NSDSeg — участок пути до НРД.
type NSDSeg struct {
Key string `json:"key"`
Title string `json:"title"`
State string `json:"state"`
Text string `json:"text"`
Detail string `json:"detail,omitempty"`
}
// NSDHealth — сводка для шапки, «Статуса» и кабинета клиента.
type NSDHealth struct {
Configured bool `json:"configured"`
State string `json:"state"` // худшее из участков
Segs []NSDSeg `json:"segs"`
LastAnswer time.Time `json:"last_answer"` // последнее подтверждение от НРД (любое)
Ago string `json:"ago"` // «40 с назад»
Failed int `json:"failed"` // недоставленных переводов
}
var nsdH struct {
sync.Mutex
ProbeSentAt time.Time
ProbeReplyAt time.Time
ProbeLatency time.Duration
ProbeErr string
LastDelivered time.Time
LastDelErrAt time.Time
LastDelErr string
probeAlerted bool
resendNotified map[string]bool
history []nsdSample
historyLoaded bool
historyDirtyCnt int
}
type nsdSample struct {
T int64 `json:"t"` // минута (unix/60)
S string `json:"s"` // по символу на участок: o w e u -
}
// nsdProbeReply — пришёл справочник: НРД ответил (по нашей проверке или сам).
func nsdProbeReply(at time.Time) {
nsdH.Lock()
defer nsdH.Unlock()
if !nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.Before(nsdH.ProbeSentAt) && at.After(nsdH.ProbeSentAt) {
nsdH.ProbeLatency = at.Sub(nsdH.ProbeSentAt)
}
nsdH.ProbeReplyAt = at
}
// nsdDelivery — итог передачи пакета в НРД (из клиента ИШ).
func nsdDelivery(_ string, status, code string) {
nsdH.Lock()
defer nsdH.Unlock()
switch status {
case igw.StatusSent:
nsdH.LastDelivered = time.Now()
case igw.StatusError:
nsdH.LastDelErrAt, nsdH.LastDelErr = time.Now(), code
}
}
func agoText(t time.Time, now time.Time) string {
if t.IsZero() {
return ""
}
d := now.Sub(t)
switch {
case d < time.Minute:
return fmt.Sprintf("%d с назад", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%d мин назад", int(d.Minutes()))
}
return humanDur(d) + " назад"
}
// nsdHealthNow — текущее состояние связи с НРД.
func nsdHealthNow(rc *RuntimeConfig, now time.Time) NSDHealth {
h := NSDHealth{State: segOff}
if rc == nil {
return h
}
base := strings.TrimSpace(rc.Snapshot().NSD.IGWBaseURL)
if base == "" {
return h
}
h.Configured = true
// Шлюз
igwWatch.mu.Lock()
gw := NSDSeg{Key: "igw", Title: "Шлюз"}
igwUp := igwWatch.Checked && igwWatch.Up
switch {
case !igwWatch.Checked:
gw.State, gw.Text = segUnknown, "проверяется"
case igwWatch.Up:
gw.State, gw.Text = segOK, "отвечает"
default:
gw.State, gw.Text, gw.Detail = segErr, "не отвечает "+humanDur(now.Sub(igwWatch.DownSince)), igwWatch.LastErr
}
igwWatch.mu.Unlock()
// НРД (опросы шлюза)
nsdWatch.Lock()
ns := NSDSeg{Key: "nsd", Title: "НРД"}
lastPollOK := nsdWatch.LastPollOK
switch {
case !igwLocal(base):
ns.State, ns.Text = segUnknown, "журнал шлюза недоступен (шлюз на другом сервере)"
case !igwUp:
ns.State, ns.Text = segUnknown, "неизвестно — шлюз не отвечает"
case nsdWatch.LastPollAt.IsZero():
ns.State, ns.Text = segUnknown, "нет данных опроса"
case !nsdWatch.DownSince.IsZero():
down := now.Sub(nsdWatch.DownSince)
ns.State = segWarn
if down >= nsdPollErrAfter {
ns.State = segErr
}
ns.Text = "отвечает шлюзу ошибками " + humanDur(down)
ns.Detail = nsdWatch.Contour + ": " + nsdWatch.LastErr
case now.Sub(nsdWatch.LastPollOK) > nsdPollStale:
ns.State, ns.Text = segWarn, "давно нет опросов НРД (последний "+agoText(nsdWatch.LastPollOK, now)+")"
default:
ns.State, ns.Text = segOK, "отвечает · "+agoText(nsdWatch.LastPollOK, now)
}
nsdWatch.Unlock()
nsdH.Lock()
// M2M — сквозная проверка
m := NSDSeg{Key: "m2m", Title: "M2M"}
pending := !nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.Before(nsdH.ProbeSentAt)
switch {
case nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.IsZero():
m.State, m.Text = segUnknown, "проверка ещё не выполнялась"
case nsdH.ProbeErr != "":
m.State, m.Text, m.Detail = segErr, "запрос проверки не доставлен", nsdH.ProbeErr
case pending && now.Sub(nsdH.ProbeSentAt) >= nsdProbeErrAfter:
m.State, m.Text = segErr, "НРД не ответил на проверку "+humanDur(now.Sub(nsdH.ProbeSentAt))
case pending && now.Sub(nsdH.ProbeSentAt) >= nsdProbeWarnAfter:
m.State, m.Text = segWarn, "ждём ответ НРД на проверку "+humanDur(now.Sub(nsdH.ProbeSentAt))
case pending && nsdH.ProbeReplyAt.IsZero():
m.State, m.Text = segUnknown, "проверка отправлена, ждём ответ"
default:
m.State, m.Text = segOK, "ответ на проверку "+agoText(nsdH.ProbeReplyAt, now)
if nsdH.ProbeLatency > 0 {
m.Text += fmt.Sprintf(" (за %d с)", int(nsdH.ProbeLatency.Seconds()))
}
if pending {
m.Text += " · идёт новая проверка"
}
}
// Отправка
sd := NSDSeg{Key: "send", Title: "Отправка"}
h.Failed = sendFailCount()
switch {
case h.Failed > 0:
sd.State, sd.Text = segErr, fmt.Sprintf("не доставлено переводов: %d — отправятся повторно сами, когда НРД ответит", h.Failed)
case !nsdH.LastDelErrAt.IsZero() && nsdH.LastDelErrAt.After(nsdH.LastDelivered):
sd.State, sd.Text, sd.Detail = segWarn, "последний пакет не доставлен "+agoText(nsdH.LastDelErrAt, now), nsdH.LastDelErr
case !nsdH.LastDelivered.IsZero():
sd.State, sd.Text = segOK, "последний пакет доставлен "+agoText(nsdH.LastDelivered, now)
default:
sd.State, sd.Text = segOK, "ошибок доставки нет"
}
for _, t := range []time.Time{lastPollOK, nsdH.ProbeReplyAt, nsdH.LastDelivered} {
if t.After(h.LastAnswer) {
h.LastAnswer = t
}
}
nsdH.Unlock()
h.Segs = []NSDSeg{gw, ns, m, sd}
h.State = segOK
for _, s := range h.Segs {
if segRank(s.State) > segRank(h.State) {
h.State = s.State
}
}
h.Ago = agoText(h.LastAnswer, now)
return h
}
// nsdLinkOK — НРД доступен для отправки: шлюз отвечает и (по журналу или проверке) НРД отвечает.
func nsdLinkOK(h NSDHealth) bool {
st := map[string]string{}
for _, s := range h.Segs {
st[s.Key] = s.State
}
if st["igw"] != segOK {
return false
}
switch st["nsd"] {
case segOK:
return true
case segUnknown: // журнала нет — судим по сквозной проверке
return st["m2m"] == segOK
}
return false
}
// --- проверка связи ---
var nsdProbeNowCh = make(chan struct{}, 1)
// nsdProbeNow — «Проверить связь сейчас».
func nsdProbeNow() {
select {
case nsdProbeNowCh <- struct{}{}:
default:
}
}
func (s *Server) sendNSDProbe(ctx context.Context) {
nsdH.Lock()
nsdH.ProbeSentAt, nsdH.ProbeErr = time.Now(), ""
nsdH.Unlock()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
if _, err := sendHandbookRequest(cctx, s.igwClient, s.igwChannel); err != nil {
nsdH.Lock()
nsdH.ProbeErr = err.Error()
nsdH.Unlock()
log.Printf("nsd-health: проверка связи (#M2MHR) не отправлена: %v", err)
}
}
// nsdHealthLoop — проверка раз в 10 мин, история раз в минуту, автоповтор недоставленных.
func (s *Server) nsdHealthLoop(ctx context.Context) {
healthRegister("nsd-health", 3*time.Minute)
nsdHistoryLoad()
probeT := time.NewTimer(time.Minute)
defer probeT.Stop()
minute := time.NewTicker(time.Minute)
defer minute.Stop()
for {
select {
case <-ctx.Done():
nsdHistorySave()
return
case <-nsdProbeNowCh:
if s.igwClient != nil {
s.sendNSDProbe(ctx)
}
case <-probeT.C:
if s.igwClient != nil {
s.sendNSDProbe(ctx)
}
probeT.Reset(nsdProbeEvery)
case <-minute.C:
h := nsdHealthNow(s.rc, time.Now())
nsdHistoryAdd(h, time.Now())
s.nsdProbeAlerts(h)
if nsdLinkOK(h) {
s.autoResend(ctx)
}
healthBeat("nsd-health")
}
}
}
// nsdProbeAlerts — оповещения по сквозной проверке (сбой опросов НРД сообщает nsdlinkwatch.go).
func (s *Server) nsdProbeAlerts(h NSDHealth) {
var m NSDSeg
for _, x := range h.Segs {
if x.Key == "m2m" {
m = x
}
}
nsdH.Lock()
was := nsdH.probeAlerted
switch {
case m.State == segErr && !was:
nsdH.probeAlerted = true
case m.State == segOK && was:
nsdH.probeAlerted = false
default:
nsdH.Unlock()
return
}
nsdH.Unlock()
if m.State == segErr {
raiseSystemAlert(s.rc, "НРД не отвечает на проверку M2M", "Сквозная проверка связи (запрос справочника участников M2M) не прошла: "+m.Text+". "+m.Detail+
" Пока так, переводы и решения могут не доходить до НРД. Недоставленные переводы bj отправит повторно сам, когда связь восстановится.")
return
}
raiseSystemAlert(s.rc, "Проверка связи с НРД снова проходит", "НРД ответил на сквозную проверку M2M: "+m.Text+".")
}
// autoResend — повторная отправка недоставленных переводов, когда НРД снова отвечает.
func (s *Server) autoResend(ctx context.Context) {
if s.svc == nil {
return
}
now := time.Now()
for guid, f := range sendFailList() {
first := f.FirstAt
if first.IsZero() {
first = f.At
}
if now.Sub(first) > autoResendMaxAge {
if sendFailGiveUp(guid) {
raiseSystemAlert(s.rc, "Перевод не доставлен в НРД за 24 часа",
fmt.Sprintf("Перевод %s не удалось доставить в НРД за сутки (попыток: %d). Автоповтор остановлен — срок ответа НРД истёк. Решите в карточке заявки: «Отправить повторно» или «Удалить».", shortGUID(guid), f.Attempts))
}
continue
}
if f.GaveUp || now.Sub(f.At) < autoResendGap {
continue
}
cctx, cancel := context.WithTimeout(ctx, time.Minute)
err := s.svc.ResendClaim(cctx, f.DealID)
cancel()
if err != nil {
log.Printf("nsd-health: автоповтор перевода %s: %v", shortGUID(guid), err)
continue
}
log.Printf("nsd-health: перевод %s отправлен повторно автоматически (попытка %d)", shortGUID(guid), f.Attempts+1)
raiseSystemAlert(s.rc, "Перевод отправлен в НРД повторно",
fmt.Sprintf("Связь с НРД восстановилась — перевод %s отправлен автоматически, НРД принял пакет.", shortGUID(guid)))
}
}
// --- история ---
func nsdSegChar(st string) byte {
switch st {
case segOK:
return 'o'
case segWarn:
return 'w'
case segErr:
return 'e'
case segUnknown:
return 'u'
}
return '-'
}
func nsdHistoryLoad() {
nsdH.Lock()
defer nsdH.Unlock()
if nsdH.historyLoaded {
return
}
nsdH.historyLoaded = true
if raw, err := os.ReadFile(nsdHistoryFile); err == nil {
_ = json.Unmarshal(raw, &nsdH.history)
}
}
func nsdHistorySave() {
nsdH.Lock()
raw, err := json.Marshal(nsdH.history)
nsdH.Unlock()
if err == nil {
_ = os.WriteFile(nsdHistoryFile, raw, 0o600)
}
}
func nsdHistoryAdd(h NSDHealth, now time.Time) {
if !h.Configured {
return
}
b := make([]byte, len(h.Segs))
for i, s := range h.Segs {
b[i] = nsdSegChar(s.State)
}
nsdH.Lock()
t := now.Unix() / 60
if n := len(nsdH.history); n > 0 && nsdH.history[n-1].T == t {
nsdH.history[n-1].S = string(b)
} else {
nsdH.history = append(nsdH.history, nsdSample{T: t, S: string(b)})
}
cut := t - nsdHistoryKeep
i := 0
for i < len(nsdH.history) && nsdH.history[i].T <= cut {
i++
}
nsdH.history = nsdH.history[i:]
nsdH.historyDirtyCnt++
save := nsdH.historyDirtyCnt >= 5
if save {
nsdH.historyDirtyCnt = 0
}
nsdH.Unlock()
if save {
nsdHistorySave()
}
}
// NSDStripCell — отрезок полосы (10 минут).
type NSDStripCell struct {
State string
Title string
}
// NSDStripRow — полоса одного участка за сутки.
type NSDStripRow struct {
Title string
Cells []NSDStripCell
Bad string // итог: «сбоев не было» / «сбой 2 ч 10 мин»
}
// nsdStrip — полосы за сутки по 10 минут (худшее состояние в отрезке).
func nsdStrip(titles []string, now time.Time) []NSDStripRow {
nsdHistoryLoad()
nsdH.Lock()
hist := append([]nsdSample(nil), nsdH.history...)
nsdH.Unlock()
const bucket = 10
end := now.Unix() / 60
start := end - nsdHistoryKeep + 1
nb := nsdHistoryKeep / bucket
rows := make([]NSDStripRow, len(titles))
for i, t := range titles {
rows[i] = NSDStripRow{Title: t, Cells: make([]NSDStripCell, nb)}
}
bad := make([]int, len(titles))
for _, smp := range hist {
if smp.T < start || smp.T > end {
continue
}
k := int(smp.T-start) / bucket
for i := range titles {
if i >= len(smp.S) {
continue
}
st := map[byte]string{'o': segOK, 'w': segWarn, 'e': segErr, 'u': segUnknown}[smp.S[i]]
if st == "" {
continue
}
if st == segErr {
bad[i]++
}
if segRank(st) > segRank(rows[i].Cells[k].State) || rows[i].Cells[k].State == "" {
rows[i].Cells[k].State = st
}
}
}
names := map[string]string{segOK: "работает", segWarn: "с перебоями", segErr: "сбой", segUnknown: "нет данных", "": "нет данных"}
for i := range rows {
for k := range rows[i].Cells {
from := time.Unix((start+int64(k*bucket))*60, 0)
c := &rows[i].Cells[k]
if c.State == "" {
c.State = "none"
}
c.Title = from.Format("15:04") + "–" + from.Add(bucket*time.Minute).Format("15:04") + ": " + names[strings.TrimPrefix(c.State, "none")]
}
if bad[i] == 0 {
rows[i].Bad = "сбоев не было"
} else {
rows[i].Bad = "сбой " + humanDur(time.Duration(bad[i])*time.Minute)
}
}
return rows
}
// --- HTTP ---
// registerNSDHealth — GET /admin/nsd-health.json (шапка обновляется сама), POST /admin/status/nsd-probe.
func registerNSDHealth(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/nsd-health.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(nsdHealthNow(rc, time.Now()))
})
mux.HandleFunc("/admin/status/nsd-probe", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
nsdProbeNow()
http.Redirect(w, r, "/admin/status?probe=1#nsd", http.StatusSeeOther)
})
}
// nsdClientNotice — текст для кабинета клиента (пусто — обмен работает).
func nsdClientNotice(h NSDHealth) string {
if !h.Configured || nsdLinkOK(h) {
return ""
}
for _, s := range h.Segs {
if (s.Key == "igw" || s.Key == "nsd" || s.Key == "m2m") && s.State == segErr {
return "Обмен с НРД временно недоступен. Поручения на перевод принимаются и будут отправлены в НРД автоматически, как только связь восстановится."
}
}
return ""
}
// nsdSegTip — подсказка к цепочке в шапке: все участки построчно.
func nsdSegTip(h NSDHealth) string {
var b strings.Builder
for _, s := range h.Segs {
b.WriteString(s.Title + ": " + s.Text)
if s.Detail != "" {
b.WriteString(" (" + s.Detail + ")")
}
b.WriteString("\n")
}
b.WriteString("Подробно — «Статус». Обновляется каждые 30 с.")
return b.String()
}