package lkgateway // Связь с НРД — «зелёный только при свежем подтверждении». Состояние складывается из участков // пути, у каждого своё доказательство работы: // - Шлюз: ИШ отвечает bj (igwwatch.go); // - НРД: шлюз успешно опрашивает НРД — по журналу ИШ (nsdlinkwatch.go); // - M2M: сквозная проверка — раз в час и по кнопке «Проверить связь сейчас» bj запрашивает у НРД справочник участников (#M2MHR) // и ждёт ответ #M2MTH: он проходит весь путь до сервиса M2M и обратно; // - Отправка: подтверждение доставки каждого пакета (igw.WithDeliveryHook) и недоставленные переводы. // Отсутствие ошибок само по себе не считается «работает»: нет свежего подтверждения — жёлтый. // Раз в минуту состояние пишется в историю (сутки), по ней на странице «Статус» рисуется полоса. // Недоставленные переводы уходят повторно сами, как только НРД снова отвечает (до 24 ч — срок ответа НРД). import ( "context" "encoding/json" "fmt" "html/template" "log" "net/http" "os" "strings" "sync" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" ) const ( nsdProbeEvery = time.Hour // 22.10.2026: было 10 мин — лишняя нагрузка на НРД; живость между проверками видна по журналу ИШ 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" // nsdProbeFile — время последней проверки M2M и ответа на неё: переживает перезапуск bj. Без него каждый // перезапуск (установка обновления) слал запрос справочника через минуту после старта; 22.10.2026 на 9 // набралось больше 100 запросов за день, и с 14:01 НРД перестал на них отвечать. var nsdProbeFile = "/var/lib/bj/.bj/nsd-probe.json" type nsdProbeSaved struct { Sent time.Time `json:"sent"` Reply time.Time `json:"reply"` } func nsdProbeSave() { nsdH.Lock() v := nsdProbeSaved{Sent: nsdH.ProbeSentAt, Reply: nsdH.ProbeReplyAt} nsdH.Unlock() if raw, err := json.Marshal(v); err == nil { _ = os.WriteFile(nsdProbeFile, raw, 0o600) } } // nsdProbeLoad — восстановить время проверки после перезапуска; возвращает, когда слать следующую. func nsdProbeLoad(now time.Time) time.Duration { var v nsdProbeSaved if raw, err := os.ReadFile(nsdProbeFile); err == nil && json.Unmarshal(raw, &v) == nil { nsdH.Lock() if nsdH.ProbeSentAt.IsZero() { nsdH.ProbeSentAt = v.Sent } if v.Reply.After(nsdH.ProbeReplyAt) { nsdH.ProbeReplyAt = v.Reply } nsdH.Unlock() if left := nsdProbeEvery - now.Sub(v.Sent); left > time.Minute { return left } } return time.Minute } // Состояния участков по возрастанию тяжести. 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() if !nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.Before(nsdH.ProbeSentAt) && at.After(nsdH.ProbeSentAt) { nsdH.ProbeLatency = at.Sub(nsdH.ProbeSentAt) } nsdH.ProbeReplyAt = at nsdH.Unlock() nsdProbeSave() } // 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 = segWarn, "НРД не ответил на проверку "+humanDur(now.Sub(nsdH.ProbeSentAt)) if !nsdH.ProbeReplyAt.IsZero() { m.Text += " (прошлый ответ " + agoText(nsdH.ProbeReplyAt, now) + ")" } 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 s.Key == "m2m" && ns.State == segOK && s.State != segErr { continue // НРД отвечает шлюзу — задержка ответа на справочник не красит шапку (видна в панели) } if s.State != segUnknown && segRank(s.State) > segRank(h.State) { h.State = s.State } } if h.State == segOK && !nsdLinkOK(h) { h.State = segUnknown } 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() nsdProbeSave() 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 — проверка раз в час, история раз в минуту, автоповтор недоставленных. func (s *Server) nsdHealthLoop(ctx context.Context) { healthRegister("nsd-health", 3*time.Minute) nsdHistoryLoad() probeT := time.NewTimer(nsdProbeLoad(time.Now())) // после перезапуска — не раньше часа с прошлой проверки 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 "" } // nsdSegPop — всплывающая панель цепочки в шапке: участки с цветной точкой. Строки «последний ответ» // нет — время ответа уже в участке «НРД». func nsdSegPop(h NSDHealth) template.HTML { esc := template.HTMLEscapeString var b strings.Builder b.WriteString(``) for _, s := range h.Segs { b.WriteString(`` + esc(s.Title) + `` + esc(s.Text)) if s.Detail != "" { b.WriteString(`` + esc(s.Detail) + ``) } b.WriteString(``) } b.WriteString(`Подробно — «Статус»`) return template.HTML(b.String()) } // 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") } if h.Ago != "" { b.WriteString("Последний ответ НРД " + h.Ago + "\n") } else { b.WriteString("Ответа НРД ещё не было\n") } b.WriteString("Подробно — «Статус». Обновляется каждые 30 с.") return b.String() }