diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go
index 0f3dcab..37190b8 100644
--- a/internal/lkgateway/admin.go
+++ b/internal/lkgateway/admin.go
@@ -52,6 +52,7 @@ var templateFuncs = template.FuncMap{
"curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"],
"sendFailText": sendFailText,
+ "nsdSegTip": nsdSegTip,
"devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"],
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
@@ -361,8 +362,9 @@ type page struct {
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
IGWDown string // ИШ НРД не отвечает: «N мин» (пусто — отвечает или не проверялся)
IGWDetail string
- NSDDown string // ИШ отвечает, но НРД не отвечает шлюзу (nsdlinkwatch.go)
- DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
+ NSDDown string // ИШ отвечает, но НРД не отвечает шлюзу (nsdlinkwatch.go)
+ NSDLink NSDHealth // цепочка «Шлюз · НРД · M2M · Отправка» для шапки (nsdhealth.go)
+ DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
}
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
@@ -417,6 +419,10 @@ type statusData struct {
page
Checks []Status
CheckedAt time.Time
+ Strip []NSDStripRow // связь с НРД за сутки (nsdhealth.go)
+ StripFrom string // начало полосы
+ Fails []SendFailView // недоставленные переводы
+ Probe bool // только что запрошена проверка связи
}
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
@@ -786,8 +792,16 @@ func (a *admin) renderHandbook(w http.ResponseWriter, _ *http.Request) {
func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) {
s := CheckAll(r.Context(), opts)
+ pg := nowPage("Статус", "status")
+ var titles []string
+ for _, sg := range pg.NSDLink.Segs {
+ titles = append(titles, sg.Title)
+ }
+ now := time.Now()
render(w, a.status, statusData{
- page: nowPage("Статус", "status"), Checks: s.Checks, CheckedAt: s.CheckedAt,
+ page: pg, Checks: s.Checks, CheckedAt: s.CheckedAt,
+ Strip: nsdStrip(titles, now), StripFrom: now.Add(-24 * time.Hour).Format("02.01 15:04"),
+ Fails: sendFailViews(), Probe: r.URL.Query().Get("probe") != "",
})
}
@@ -805,6 +819,7 @@ func nowPage(title, active string) page {
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
if globalRC != nil {
p.NPR = nprAvailable(globalRC)
+ p.NSDLink = nsdHealthNow(globalRC, time.Now())
p.DevMode = devMode(globalRC)
if down, d, det := igwStatus(); down {
p.IGWDown, p.IGWDetail = humanDur(d), det
diff --git a/internal/lkgateway/livestatus.go b/internal/lkgateway/livestatus.go
index 4036de4..c962d0a 100644
--- a/internal/lkgateway/livestatus.go
+++ b/internal/lkgateway/livestatus.go
@@ -21,9 +21,9 @@ import (
// LiveStatus — состояние для показа.
type LiveStatus struct {
- Text string `json:"text"` // чего ждём / чем закончилось
- Kind string `json:"kind"` // wait | warn | ok | bad
- Active bool `json:"active"` // в работе: страница продолжает опрашивать
+ Text string `json:"text"` // чего ждём / чем закончилось
+ Kind string `json:"kind"` // wait | warn | ok | bad
+ Active bool `json:"active"` // в работе: страница продолжает опрашивать
Sig string `json:"sig,omitempty"` // отпечаток состояния: изменился — карточка перерисовывается
}
@@ -112,7 +112,7 @@ func liveOutgoingState(c ClaimView) LiveStatus {
watch = globalOrderWatch.Get(guid)
}
if f := sendFailGet(guid); f != nil && guid != "" {
- return LiveStatus{Text: "не доставлена в НРД — " + sendFailText(f) + "; нужна повторная отправка", Kind: "err", Active: true}
+ return LiveStatus{Text: "не доставлена в НРД — " + sendFailText(f) + "; отправится повторно сама, когда НРД ответит", Kind: "err", Active: true}
}
switch c.Status {
case "draft", "validated":
diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go
index 9b1bb1d..acc5ebd 100644
--- a/internal/lkgateway/lkapi.go
+++ b/internal/lkgateway/lkapi.go
@@ -596,6 +596,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
lkJSON(w, http.StatusNotFound, map[string]string{"error": "клиент не найден"})
})
+ // Состояние обмена с НРД для баннера кабинета: пусто — работает (nsdhealth.go).
+ mux.HandleFunc("/api/lk/nsd-status", func(w http.ResponseWriter, r *http.Request) {
+ if !auth(w, r) {
+ return
+ }
+ h := nsdHealthNow(rc, time.Now())
+ lkJSON(w, http.StatusOK, map[string]any{"ok": nsdClientNotice(h) == "", "notice": nsdClientNotice(h)})
+ })
+
mux.HandleFunc("/api/lk/transfer", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go
index 829cc48..ff4f9b6 100644
--- a/internal/lkgateway/lkstore.go
+++ b/internal/lkgateway/lkstore.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log"
"strings"
"sync"
"time"
@@ -330,3 +331,17 @@ func lkClaimsByContract(ctx context.Context, rc *RuntimeConfig, contract string,
}
return out, rows.Err()
}
+
+// lkQueuedToSent — перевод, не доходивший до НРД, доставлен: заявка клиента «ожидает отправки»
+// становится «отправлена». Заявки не из модуля ЛК молча пропускаются.
+func lkQueuedToSent(rc *RuntimeConfig, guid string) {
+ db, err := lkDB(rc)
+ if err != nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
+ defer cancel()
+ if _, err := db.ExecContext(ctx, "UPDATE lk.transfer_requests SET status='sent' WHERE lower(guid)=lower($1) AND status='queued'", guid); err != nil {
+ log.Printf("lk-gateway: заявка ЛК GUID=%s → отправлена: %v", guid, err)
+ }
+}
diff --git a/internal/lkgateway/lktransfer.go b/internal/lkgateway/lktransfer.go
index 00be6f6..1e404b2 100644
--- a/internal/lkgateway/lktransfer.go
+++ b/internal/lkgateway/lktransfer.go
@@ -10,6 +10,7 @@ import (
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
@@ -71,6 +72,8 @@ func lkStatusRu(s string) string {
return "На проверке"
case "ready":
return "Проверена, отправляется в НРД"
+ case "queued":
+ return "Принята, ожидает отправки в НРД"
case "sent":
return "Отправлена в НРД"
case "rejected":
@@ -246,6 +249,16 @@ func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKU
sender = lkEmuSender
}
if _, err := s.SendBuiltRequestVia(ctx, sender, req, lkInvestorUUID(u.Contract), nil); err != nil {
+ var nd *igw.NotDeliveredError
+ if errors.As(err, &nd) {
+ // НРД сейчас не принимает пакеты: заявка сохранена и уйдёт сама, когда связь восстановится
+ // (nsdhealth.go, автоповтор) — клиенту это не ошибка.
+ _ = lkMarkStatus(ctx, rc, id, "queued")
+ return LKTransferResult{
+ OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "queued",
+ Message: "Заявка принята. Обмен с НРД сейчас временно недоступен — заявка будет отправлена автоматически, как только связь восстановится. Статус — в разделе «Мои заявки».",
+ }, nil
+ }
_ = lkMarkStatus(ctx, rc, id, "error")
return res, fmt.Errorf("заявка сохранена, но не отправлена: %w", err)
}
diff --git a/internal/lkgateway/nsdhealth.go b/internal/lkgateway/nsdhealth.go
new file mode 100644
index 0000000..940ab76
--- /dev/null
+++ b/internal/lkgateway/nsdhealth.go
@@ -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()
+}
diff --git a/internal/lkgateway/nsdhealth_test.go b/internal/lkgateway/nsdhealth_test.go
new file mode 100644
index 0000000..fa2b97d
--- /dev/null
+++ b/internal/lkgateway/nsdhealth_test.go
@@ -0,0 +1,187 @@
+package lkgateway
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+func nsdTestRC(t *testing.T) *RuntimeConfig {
+ t.Helper()
+ rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := rc.Snapshot().NSD
+ n.IGWBaseURL = "http://localhost:8090"
+ if err := rc.UpdateNSD(n); err != nil {
+ t.Fatal(err)
+ }
+ return rc
+}
+
+func nsdTestReset(t *testing.T) {
+ t.Helper()
+ old := sendFailFile
+ sendFailFile = filepath.Join(t.TempDir(), "sf.json")
+ sendFails.loaded = false
+ oldHist := nsdHistoryFile
+ nsdHistoryFile = filepath.Join(t.TempDir(), "h.json")
+ t.Cleanup(func() {
+ sendFailFile, sendFails.loaded, nsdHistoryFile = old, false, oldHist
+ igwWatch.mu.Lock()
+ igwWatch.Checked, igwWatch.Up = false, false
+ igwWatch.mu.Unlock()
+ nsdWatch.Lock()
+ nsdWatch.DownSince, nsdWatch.LastPollAt, nsdWatch.LastPollOK = time.Time{}, time.Time{}, time.Time{}
+ nsdWatch.Unlock()
+ nsdH.Lock()
+ nsdH.ProbeSentAt, nsdH.ProbeReplyAt, nsdH.ProbeErr, nsdH.history, nsdH.historyLoaded = time.Time{}, time.Time{}, "", nil, false
+ nsdH.Unlock()
+ })
+}
+
+func segState(h NSDHealth, key string) string {
+ for _, s := range h.Segs {
+ if s.Key == key {
+ return s.State
+ }
+ }
+ return ""
+}
+
+// Зелёный — только при свежих подтверждениях; сбой опросов и молчание на проверку — красный.
+func TestNSDHealthStates(t *testing.T) {
+ nsdTestReset(t)
+ rc := nsdTestRC(t)
+ now := time.Now()
+ igwWatch.mu.Lock()
+ igwWatch.Checked, igwWatch.Up = true, true
+ igwWatch.mu.Unlock()
+ nsdWatch.Lock()
+ nsdWatch.LastPollAt, nsdWatch.LastPollOK = now.Add(-10*time.Second), now.Add(-10*time.Second)
+ nsdWatch.Unlock()
+ nsdH.Lock()
+ nsdH.ProbeSentAt, nsdH.ProbeReplyAt = now.Add(-5*time.Minute), now.Add(-4*time.Minute)
+ nsdH.Unlock()
+
+ h := nsdHealthNow(rc, now)
+ if h.State != segOK || !nsdLinkOK(h) || nsdClientNotice(h) != "" || h.Ago == "" {
+ t.Fatalf("всё работает: %+v", h)
+ }
+
+ nsdWatch.Lock()
+ nsdWatch.DownSince, nsdWatch.LastErr = now.Add(-5*time.Minute), "#404"
+ nsdWatch.Unlock()
+ h = nsdHealthNow(rc, now)
+ if segState(h, "nsd") != segErr || h.State != segErr || nsdLinkOK(h) || nsdClientNotice(h) == "" {
+ t.Fatalf("НРД отвечает ошибками 5 мин: %+v", h)
+ }
+
+ nsdWatch.Lock()
+ nsdWatch.DownSince = time.Time{}
+ nsdWatch.LastPollOK = now.Add(-20 * time.Minute) // тишина — не «работает»
+ nsdWatch.Unlock()
+ if h = nsdHealthNow(rc, now); segState(h, "nsd") != segWarn {
+ t.Fatalf("давно нет опросов: %+v", h)
+ }
+
+ nsdWatch.Lock()
+ nsdWatch.LastPollOK = now
+ nsdWatch.Unlock()
+ nsdH.Lock()
+ nsdH.ProbeSentAt = now.Add(-12 * time.Minute)
+ nsdH.ProbeReplyAt = now.Add(-25 * time.Minute)
+ nsdH.Unlock()
+ if h = nsdHealthNow(rc, now); segState(h, "m2m") != segErr {
+ t.Fatalf("НРД молчит на проверку 12 мин: %+v", h)
+ }
+}
+
+// Автоповтор: НРД снова отвечает — недоставленный перевод уходит сам; старше суток — не трогаем.
+func TestAutoResend(t *testing.T) {
+ nsdTestReset(t)
+ oldHook, oldQ := sentHook, quikSendHook
+ defer func() { sentHook, quikSendHook = oldHook, oldQ }()
+ sentHook, quikSendHook = nil, nil
+
+ ctx := context.Background()
+ snd := &flakySender{fail: true}
+ svc := NewService(Config{Repository: m2mcore.NewMemoryRepository(), Sender: snd})
+ s := &Server{svc: svc}
+ req := sendFailRequest()
+ if _, err := svc.SendBuiltRequest(ctx, req, "inv", nil); err == nil {
+ t.Fatal("ожидалась ошибка доставки")
+ }
+ guid := string(req.Header.GUID)
+
+ s.autoResend(ctx) // последняя попытка только что — ждём 5 мин
+ if sendFailGet(guid) == nil {
+ t.Fatal("рано повторять")
+ }
+ sendFails.Lock()
+ f := sendFails.m[guid]
+ f.At = time.Now().Add(-6 * time.Minute)
+ sendFails.m[guid] = f
+ sendFails.Unlock()
+
+ snd.fail = false
+ s.autoResend(ctx)
+ if sendFailGet(guid) != nil {
+ t.Fatal("перевод должен уйти автоматически")
+ }
+ d, _ := svc.Repo().GetByGUID(ctx, req.Header.GUID)
+ if d.State != m2mcore.StateAwaitingDecision {
+ t.Fatalf("после автоповтора: %s", d.State)
+ }
+
+ // Старше суток — автоповтор останавливается.
+ sendFails.Lock()
+ sendFails.m["old"] = SendFailure{DealID: "x", At: time.Now().Add(-26 * time.Hour), FirstAt: time.Now().Add(-26 * time.Hour), Attempts: 9}
+ sendFails.Unlock()
+ s.autoResend(ctx)
+ if f := sendFailGet("old"); f == nil || !f.GaveUp {
+ t.Fatalf("старше суток: %+v", f)
+ }
+}
+
+func TestNSDStripAndStatusRender(t *testing.T) {
+ nsdTestReset(t)
+ now := time.Now()
+ nsdH.Lock()
+ nsdH.historyLoaded = true
+ nsdH.Unlock()
+ for i := 30; i >= 0; i-- {
+ st := segOK
+ if i < 5 {
+ st = segErr
+ }
+ nsdHistoryAdd(NSDHealth{Configured: true, Segs: []NSDSeg{{State: segOK}, {State: st}, {State: segOK}, {State: segOK}}}, now.Add(-time.Duration(i)*time.Minute))
+ }
+ rows := nsdStrip([]string{"Шлюз", "НРД", "M2M", "Отправка"}, now)
+ if len(rows) != 4 || len(rows[1].Cells) != 144 || rows[1].Cells[143].State != segErr || rows[0].Bad != "сбоев не было" || !strings.HasPrefix(rows[1].Bad, "сбой") {
+ t.Fatalf("полоса: %+v / %q", rows[1].Cells[143], rows[1].Bad)
+ }
+
+ a, err := newAdmin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pg := page{Title: "Статус"}
+ pg.NSDLink = NSDHealth{Configured: true, State: segErr, Segs: []NSDSeg{{Key: "igw", Title: "Шлюз", State: segOK, Text: "отвечает"}, {Key: "nsd", Title: "НРД", State: segErr, Text: "отвечает шлюзу ошибками"}}}
+ var b bytes.Buffer
+ data := statusData{page: pg, Strip: rows, Fails: []SendFailView{{GUID: "d7a182d4-4f79", DealID: "7f1c5cfb", Text: "контур НРД не принял пакет", FirstAt: now, Attempts: 2}}}
+ if err := a.status.ExecuteTemplate(&b, "layout", data); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"Связь с НРД", "nsd-cells", "Не доставлены в НРД", "Проверить связь сейчас", "id=\"nsd-chain\"", "отправится сам"} {
+ if !strings.Contains(b.String(), want) {
+ t.Fatalf("нет %q", want)
+ }
+ }
+}
diff --git a/internal/lkgateway/nsdlinkwatch.go b/internal/lkgateway/nsdlinkwatch.go
index 013cac2..bab350d 100644
--- a/internal/lkgateway/nsdlinkwatch.go
+++ b/internal/lkgateway/nsdlinkwatch.go
@@ -85,6 +85,8 @@ var nsdWatch struct {
Contour string
Alerted bool
LastRemind time.Time
+ LastPollAt time.Time // последний опрос НРД шлюзом (по журналу)
+ LastPollOK time.Time // последний успешный
}
// nsdLinkStatus — для страницы статуса: не отвечает ли НРД шлюзу.
@@ -109,6 +111,19 @@ func nsdLinkTick(rc *RuntimeConfig, now time.Time) {
}
w := &nsdWatch
w.Lock()
+ w.LastPollAt = last.At
+ if last.OK {
+ w.LastPollOK = last.At
+ } else if firstFail != nil && w.LastPollOK.IsZero() {
+ // в хвосте журнала нет успешного опроса после сбоя — ищем последний успешный до него
+ for _, ln := range strings.Split(string(tail), "\n") {
+ if m := nsdPollRe.FindStringSubmatch(strings.TrimRight(ln, "\r")); m != nil && m[3] == "OK" {
+ if at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local); err == nil {
+ w.LastPollOK = at
+ }
+ }
+ }
+ }
if last.OK {
since, alerted := w.DownSince, w.Alerted
w.DownSince, w.LastErr, w.Alerted = time.Time{}, "", false
diff --git a/internal/lkgateway/nsdpoller.go b/internal/lkgateway/nsdpoller.go
index aa4d0e8..527ddc1 100644
--- a/internal/lkgateway/nsdpoller.go
+++ b/internal/lkgateway/nsdpoller.go
@@ -253,13 +253,17 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
log.Printf("lk-gateway: поллер справочник M2M id=%d: разбор: %v", p.ID, err)
return nil
}
- saveHandbook(&hb)
+ nsdProbeReply(time.Now())
+ if !saveHandbook(&hb) {
+ log.Printf("lk-gateway: поллер справочник M2M id=%d: без изменений (%d участников) — связь с НРД подтверждена", p.ID, len(hb.Participants.Participants))
+ return nil
+ }
go func() {
if err := depoWriteHandbook(s.rc); err != nil {
log.Printf("depo-events: справочник после получения: %v", err)
}
}()
- log.Printf("lk-gateway: поллер СПРАВОЧНИК M2M получен id=%d: участников %d, мест расчётов %d",
+ log.Printf("lk-gateway: поллер СПРАВОЧНИК M2M получен id=%d: участников %d, мест расчётов %d (состав изменился)",
p.ID, len(hb.Participants.Participants), len(hb.SettlementPlaces.Places))
return nil
default:
@@ -300,8 +304,10 @@ type HandbookEntry struct {
EngName string `json:"eng_name,omitempty"`
}
-// saveHandbook раскладывает справочник в плоский список и пишет в файл.
-func saveHandbook(hb *m2m.M2MTransferHandbook) {
+// saveHandbook раскладывает справочник в плоский список и пишет в файл. Возвращает, изменился ли
+// состав: справочник запрашивается раз в 10 минут (сквозная проверка связи, nsdhealth.go), и без
+// изменений его не нужно ни рассылать, ни переписывать в DEPO.
+func saveHandbook(hb *m2m.M2MTransferHandbook) (changed bool) {
out := struct {
At string `json:"at"`
Participants []HandbookEntry `json:"participants"`
@@ -324,13 +330,30 @@ func saveHandbook(hb *m2m.M2MTransferHandbook) {
e.Code, e.Kind = string(p.BrokerPlace.ParticipantCode), "broker"
}
out.Participants = append(out.Participants, e)
- log.Printf("lk-gateway: справочник M2M: ИНН %s → %s [%s] %s", e.INN, e.Code, e.Kind, e.Name)
}
for _, pl := range hb.SettlementPlaces.Places {
out.Places = append(out.Places, HandbookEntry{INN: string(pl.INN), Name: pl.ShortName, DisplayName: pl.DisplayName, Kind: "place"})
}
+ changed = true
+ if old, err := os.ReadFile(handbookFile); err == nil {
+ var prev struct {
+ Participants []HandbookEntry `json:"participants"`
+ Places []HandbookEntry `json:"settlement_places"`
+ }
+ if json.Unmarshal(old, &prev) == nil {
+ a, _ := json.Marshal([]any{prev.Participants, prev.Places})
+ b, _ := json.Marshal([]any{out.Participants, out.Places})
+ changed = string(a) != string(b)
+ }
+ }
if raw, err := json.Marshal(out); err == nil {
_ = os.WriteFile(handbookFile, raw, 0o600)
- pushHandbookAsync(globalRC, raw) // свежий справочник — на сервер лицензий для всех клиентов
+ if changed {
+ for _, e := range out.Participants {
+ log.Printf("lk-gateway: справочник M2M: ИНН %s → %s [%s] %s", e.INN, e.Code, e.Kind, e.Name)
+ }
+ pushHandbookAsync(globalRC, raw) // свежий справочник — на сервер лицензий для всех клиентов
+ }
}
+ return changed
}
diff --git a/internal/lkgateway/sendfail.go b/internal/lkgateway/sendfail.go
index f0106be..9efdf8f 100644
--- a/internal/lkgateway/sendfail.go
+++ b/internal/lkgateway/sendfail.go
@@ -12,6 +12,7 @@ import (
"fmt"
"log"
"os"
+ "sort"
"sync"
"time"
@@ -26,8 +27,10 @@ type SendFailure struct {
PackageID string `json:"package_id,omitempty"`
Code string `json:"code,omitempty"`
Error string `json:"error"`
- At time.Time `json:"at"`
+ At time.Time `json:"at"` // последняя попытка
+ FirstAt time.Time `json:"first_at"` // первая попытка: автоповтор — не дольше суток от неё
Attempts int `json:"attempts"`
+ GaveUp bool `json:"gave_up,omitempty"` // сутки прошли — автоповтор остановлен
}
var sendFailFile = "/var/lib/bj/.bj/send-failures.json"
@@ -72,6 +75,9 @@ func sendFailNote(guid, dealID string, err error) SendFailure {
sendFailLoad()
f := sendFails.m[guid]
f.DealID, f.Error, f.At, f.Attempts = dealID, err.Error(), time.Now(), f.Attempts+1
+ if f.FirstAt.IsZero() {
+ f.FirstAt = f.At
+ }
f.PackageID, f.Code = "", ""
var nd *igw.NotDeliveredError
if errors.As(err, &nd) {
@@ -82,14 +88,50 @@ func sendFailNote(guid, dealID string, err error) SendFailure {
return f
}
+// sendFailClear — перевод доставлен. Если до этого он не доходил — заявка клиента из «ожидает
+// отправки» переходит в «отправлена».
func sendFailClear(guid string) {
sendFails.Lock()
- defer sendFails.Unlock()
- sendFailLoad()
- if _, ok := sendFails.m[guid]; ok {
+ _, ok := sendFails.m[guid]
+ if !sendFails.loaded {
+ sendFailLoad()
+ _, ok = sendFails.m[guid]
+ }
+ if ok {
delete(sendFails.m, guid)
sendFailSave()
}
+ sendFails.Unlock()
+ if ok && globalRC != nil {
+ go lkQueuedToSent(globalRC, guid)
+ }
+}
+
+// sendFailList — копия всех недоставленных (GUID → неудача).
+func sendFailList() map[string]SendFailure {
+ sendFails.Lock()
+ defer sendFails.Unlock()
+ sendFailLoad()
+ out := make(map[string]SendFailure, len(sendFails.m))
+ for k, v := range sendFails.m {
+ out[k] = v
+ }
+ return out
+}
+
+// sendFailGiveUp — остановить автоповтор (true — только что остановлен, нужно сообщить).
+func sendFailGiveUp(guid string) bool {
+ sendFails.Lock()
+ defer sendFails.Unlock()
+ sendFailLoad()
+ f, ok := sendFails.m[guid]
+ if !ok || f.GaveUp {
+ return false
+ }
+ f.GaveUp = true
+ sendFails.m[guid] = f
+ sendFailSave()
+ return true
}
// noteSendFailure — записать неудачу, событие сделки и оповещение.
@@ -97,10 +139,13 @@ func (s *Service) noteSendFailure(ctx context.Context, deal *m2mcore.Deal, req *
guid := string(req.Header.GUID)
f := sendFailNote(guid, deal.ID, err)
_ = s.repo.AppendEvent(ctx, deal.ID, m2mcore.Event{Type: "send_failed", Payload: f, CreatedAt: time.Now().UTC(), Actor: "system"})
- log.Printf("lk-gateway: перевод %s (заявка %s) НЕ доставлен в НРД: %v", shortGUID(guid), shortID(deal.ID), err)
+ log.Printf("lk-gateway: перевод %s (заявка %s) НЕ доставлен в НРД (попытка %d): %v", shortGUID(guid), shortID(deal.ID), f.Attempts, err)
+ if f.Attempts > 1 { // о повторных неудачах автоповтора не шумим — сообщили при первой
+ return
+ }
inv := req.Data.InvestorInformation
raiseSystemAlert(globalRC, "Перевод не доставлен в НРД",
- fmt.Sprintf("Перевод %s (%s %s): %v. Заявка осталась «Не отправлена», в DEPO отправка не отмечена. Когда НРД снова будет доступен — «Отправить повторно» в карточке заявки.",
+ fmt.Sprintf("Перевод %s (%s %s): %v. Заявка осталась «Не отправлена», в DEPO отправка не отмечена. bj отправит её повторно сам, как только НРД снова ответит (в течение суток); можно и вручную — «Отправить повторно» в карточке заявки.",
shortGUID(guid), inv.LastName, inv.FirstName, err))
}
@@ -162,3 +207,24 @@ func sendFailText(f *SendFailure) string {
}
return f.Error
}
+
+// SendFailView — строка «недоставленные переводы» на странице «Статус».
+type SendFailView struct {
+ GUID, DealID, Text string
+ At, FirstAt time.Time
+ Attempts int
+ GaveUp bool
+}
+
+func sendFailViews() []SendFailView {
+ var out []SendFailView
+ for guid, f := range sendFailList() {
+ first := f.FirstAt
+ if first.IsZero() {
+ first = f.At
+ }
+ out = append(out, SendFailView{GUID: guid, DealID: f.DealID, Text: sendFailText(&f), At: f.At, FirstAt: first, Attempts: f.Attempts, GaveUp: f.GaveUp})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].FirstAt.Before(out[j].FirstAt) })
+ return out
+}
diff --git a/internal/lkgateway/sendfail_test.go b/internal/lkgateway/sendfail_test.go
index 0b72074..2f91c41 100644
--- a/internal/lkgateway/sendfail_test.go
+++ b/internal/lkgateway/sendfail_test.go
@@ -4,8 +4,8 @@ import (
"context"
"path/filepath"
"sync/atomic"
- "time"
"testing"
+ "time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
@@ -117,5 +117,4 @@ func sendFailRequest() *m2m.M2MTransferRequest {
}
}
-
func waitShort() { time.Sleep(10 * time.Millisecond) }
diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go
index fb03cf1..8cceacc 100644
--- a/internal/lkgateway/server.go
+++ b/internal/lkgateway/server.go
@@ -6,7 +6,6 @@ import (
"fmt"
"log"
"net/http"
- "os"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
@@ -100,7 +99,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
log.Printf("lk-gateway: профиль ИШ %q неизвестен (%v) — fallback mock", s.NSD.Profile, perr)
} else {
prof.IGWBaseURL = s.NSD.IGWBaseURL // override URL из setup.json
- cl := igw.NewClient(s.NSD.IGWBaseURL)
+ cl := igw.NewClient(s.NSD.IGWBaseURL, igw.WithDeliveryHook(nsdDelivery))
sender = nsdadapter.NewSender(prof, igwClientAdapter{c: cl})
igwClient = cl
// Канал ИШ резолвится по составному коду <канал>+<депонент>.
@@ -215,6 +214,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerOutgoing(mux, adminTpl, outDeps)
registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям
registerJournal(mux, adminTpl, svc, incomingStore)
+ registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас»
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
registerHealth(mux)
registerQuikOps(mux, rc, svc)
@@ -322,7 +322,7 @@ func (s *Server) Run(ctx context.Context) error {
// Авто-обновление справочника участников M2M раз в сутки (только реальный
// ИШ). Тик суточный — в heartbeat-монитор не регистрируем, только recover.
- go superviseLoop(ctx, "handbook-refresh", s.rc, s.handbookRefreshLoop)
+ go superviseLoop(ctx, "nsd-health", s.rc, s.nsdHealthLoop) // связь с НРД: проверка раз в 10 мин (справочник), история, автоповтор
// QUIK МНП: автоматический цикл M2M (регистрация до отправки — хук в
// SendBuiltRequest; подпись/снятие/зачисление по MS010 — опросчик).
@@ -470,39 +470,6 @@ func registerHandbookRequest(mux *http.ServeMux, igwClient *igw.Client, channel
})
}
-// handbookRefreshLoop раз в сутки обновляет справочник участников M2M, чтобы он
-// оставался актуальным. Первый запрос — вскоре после старта, если справочника
-// ещё нет или он старше суток. Работает только в реальном режиме ИШ.
-func (s *Server) handbookRefreshLoop(ctx context.Context) {
- if s.igwClient == nil || s.igwChannel == "" {
- <-ctx.Done() // нечего делать в mock-режиме, но не выходим (иначе supervise перезапустит)
- return
- }
- const interval = 24 * time.Hour
- initial := time.Minute
- if fi, err := os.Stat(handbookFile); err == nil && time.Since(fi.ModTime()) < interval {
- initial = interval - time.Since(fi.ModTime()) // справочник свежий — ждём до суток
- }
- timer := time.NewTimer(initial)
- defer timer.Stop()
- log.Printf("lk-gateway: авто-обновление справочника M2M включено (интервал %s, первый запрос через %s)", interval, initial.Round(time.Second))
- for {
- select {
- case <-ctx.Done():
- return
- case <-timer.C:
- cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
- if id, err := sendHandbookRequest(cctx, s.igwClient, s.igwChannel); err != nil {
- log.Printf("lk-gateway: авто-обновление справочника: %v", err)
- } else {
- log.Printf("lk-gateway: авто-обновление справочника: запрос отправлен (#M2MHR id=%s)", id)
- }
- cancel()
- timer.Reset(interval)
- }
- }
-}
-
// registerSetCallback — служебный POST /admin/api/callback-url для
// эмулятора ЛК, чтобы сообщить gateway свой URL. Если URL уже сохранён
// в runtime-конфиге (пользователь явно настроил его через UI), запрос
diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html
index cbd3f83..f9ed8ca 100644
--- a/internal/lkgateway/web/templates/admin_claim.html
+++ b/internal/lkgateway/web/templates/admin_claim.html
@@ -33,7 +33,7 @@
Заявка {{slice .Claim.ID 0 8}} · {{ruState .Claim.Status}} {{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}} {{end}}{{if and (or (eq .Claim.Status "draft") (eq .Claim.Status "validated")) (can "operate")}}{{end}}
{{with .Fail}}
Перевод не доставлен в НРД — {{sendFailText .}}.
- Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. Когда НРД снова будет доступен, нажмите «Отправить повторно» — уйдёт тот же запрос с тем же GUID.
+ Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. {{if .GaveUp}}Сутки прошли — автоповтор остановлен: отправьте вручную или удалите заявку.{{else}}Ничего нажимать не нужно: как только НРД снова ответит, bj сам отправит тот же запрос с тем же GUID (повтор — в течение суток). Кнопка «Отправить повторно» — если нужно попробовать прямо сейчас.{{end}}
{{end}}
{{$l := liveOut .Claim}}{{$l.Text}}
diff --git a/internal/lkgateway/web/templates/admin_status.html b/internal/lkgateway/web/templates/admin_status.html
index 81d5441..4791b2f 100644
--- a/internal/lkgateway/web/templates/admin_status.html
+++ b/internal/lkgateway/web/templates/admin_status.html
@@ -1,4 +1,58 @@
{{define "content"}}
+{{if .NSDLink.Configured}}
+
Связь с НРД
+ {{if eq .NSDLink.State "ok"}}работает{{else if eq .NSDLink.State "err"}}сбой{{else if eq .NSDLink.State "warn"}}с перебоями{{else}}нет данных{{end}}
+ {{if can "operate"}} {{end}}
+
+ {{if .Probe}}
Запрос проверки отправлен — ответ НРД обычно приходит за 20–60 с. Страница обновится сама.
{{end}}
+
Зелёный — только при свежем подтверждении от НРД, а не при отсутствии ошибок. {{if .NSDLink.Ago}}Последний ответ НРД — {{.NSDLink.Ago}}.{{end}}
+
+ Участок Состояние Чем проверяем
+
+ {{range .NSDLink.Segs}}
+
+ {{.Title}}
+ {{.Text}}{{with .Detail}}{{.}}{{end}}
+ {{if eq .Key "igw"}}bj обращается к шлюзу каждые 30 с{{else if eq .Key "nsd"}}журнал шлюза: ответы НРД на опросы входящих{{else if eq .Key "m2m"}}раз в 10 мин запрос справочника участников M2M и ответ на него — весь путь до сервиса M2M и обратно{{else}}подтверждение доставки каждого отправленного пакета{{end}}
+ {{end}}
+
+
+
+
За последние сутки с {{.StripFrom}}, отрезок — 10 минут
+
+ {{range .Strip}}
{{.Title}} {{range .Cells}} {{end}} {{.Bad}}
{{end}}
+
работает перебои сбой нет данных
+
+
+ {{if .Fails}}
Не доставлены в НРД
+
+ Перевод Причина Первая попытка Попыток
+ {{range .Fails}}
+ {{slice .GUID 0 8}}
+ {{.Text}}
+ {{.FirstAt.Format "02.01 15:04"}}
+ {{.Attempts}}
+ {{if .GaveUp}}автоповтор остановлен (прошли сутки) {{else}}отправится сам, когда НРД ответит {{end}}{{if can "operate"}}{{end}}
+ {{end}}
+
{{end}}
+
+
+
+{{end}}
Статус системы — детально
diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html
index 738fe3b..fce1301 100644
--- a/internal/lkgateway/web/templates/layout.html
+++ b/internal/lkgateway/web/templates/layout.html
@@ -100,6 +100,15 @@ body {
.theme-toggle svg { width:16px; height:16px; }
.theme-toggle:hover { background:var(--border); color:var(--text); }
.topbar .clock { font-size:11px; color:var(--muted); font-family:var(--mono); font-variant-numeric:tabular-nums; letter-spacing:-0.02em; }
+.nsd-chain { text-decoration:none; gap:6px; }
+.nsd-chain .seg { display:inline-flex; align-items:center; gap:4px; }
+.nsd-chain .seg i { width:8px; height:8px; border-radius:50%; background:var(--muted); display:inline-block; }
+.nsd-chain .seg.ok i { background:var(--ok); } .nsd-chain .seg.warn i { background:var(--warn); } .nsd-chain .seg.err i { background:var(--err); }
+.nsd-chain .seg.err { color:var(--err); } .nsd-chain .sep { color:var(--muted); font-weight:400; }
+.nsd-chain .ago { font-weight:400; color:var(--muted); }
+.nsd-chain.err { background:var(--err-weak); border-color:var(--err); }
+.nsd-chain.warn { background:var(--warn-weak); }
+@media (max-width:1100px){ .nsd-chain .seg > span, .nsd-chain .ago, .nsd-chain .sep { display:none } }
.sys-block { display:inline-flex; flex-direction:column; align-items:center; gap:2px; line-height:1.1; }
.sys-block .sys-status { padding:3px 9px 3px 7px; font-size:11.5px; }
.user-dd .ddbtn.user-chip { display:inline-flex; flex-direction:column; align-items:flex-start; padding:4px 10px; border-radius:8px; margin:0; max-width:220px; }
@@ -349,9 +358,7 @@ label { font-size:13px; font-weight:500; }
- {{if and .IGWDown (not .IsMockMode)}}
- ИШ НРД недоступен · {{.IGWDown}} {{else if and .NSDDown (not .IsMockMode)}}
- НРД не отвечает {{else}}
+ {{if and .NSDLink.Configured (not .IsMockMode)}}{{range $i, $s := .NSDLink.Segs}}{{if $i}}› {{end}}{{$s.Title}} {{end}}{{if .NSDLink.Ago}}ответ НРД {{.NSDLink.Ago}}{{else}}ответа НРД ещё не было{{end}} {{else}}
{{if .IsMockMode}}{{if devOn}}Эмуляция{{else}}Нет связи с НРД{{end}}{{else}}НРД на связи{{end}} {{end}}
{{.Now}}
@@ -555,6 +562,31 @@ label { font-size:13px; font-weight:500; }
}
poll();
setInterval(poll, 12000); // каждые 12 сек
+ // Цепочка связи с НРД в шапке: обновляется каждые 30 с, чтобы открытая страница не показывала старое.
+ (function(){
+ var el = document.getElementById('nsd-chain');
+ if (!el) return;
+ function esc(t){ return String(t).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
+ function upd(){
+ fetch('/admin/nsd-health.json', {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(h){
+ if (!h.configured) return;
+ var html = '', tip = '';
+ (h.segs||[]).forEach(function(s, i){
+ if (i) html += '› ';
+ html += '' + esc(s.title) + ' ';
+ tip += s.title + ': ' + s.text + (s.detail ? ' (' + s.detail + ')' : '') + '\n';
+ });
+ html += '' + (h.ago ? 'ответ НРД ' + esc(h.ago) : 'ответа НРД ещё не было') + ' ';
+ el.innerHTML = html;
+ el.className = 'sys-status nsd-chain ' + h.state;
+ el.title = tip + 'Подробно — «Статус». Обновляется каждые 30 с.';
+ }).catch(function(){
+ el.className = 'sys-status nsd-chain warn';
+ el.title = 'Нет связи с bj — состояние НРД не обновляется';
+ });
+ }
+ setInterval(upd, 30000);
+ })();
// Онлайн-статус заявок в работе: строки с data-live и data-active опрашиваются, пока ждут.
// Карточка заявки (live-big с data-sig): изменилось что-то кроме строки статуса — страница
// перерисовывается сама; если оператор уже начал заполнять форму — только предложение обновить.
diff --git a/internal/nsdadapter/igw/client.go b/internal/nsdadapter/igw/client.go
index 2e4800b..1d027a0 100644
--- a/internal/nsdadapter/igw/client.go
+++ b/internal/nsdadapter/igw/client.go
@@ -42,6 +42,8 @@ type Client struct {
retryWait time.Duration
// confirmWait — сколько ждать от ИШ итога передачи пакета в НРД (SENT/ERROR). 0 — не ждать.
confirmWait time.Duration
+ // onDelivery — итог передачи каждого пакета в НРД (SENT | ERROR | NEW — не дождались), для мониторинга.
+ onDelivery func(packageID, status, code string)
}
// Option настраивает Client.
@@ -63,6 +65,11 @@ func WithConfirmWait(d time.Duration) Option {
return func(cl *Client) { cl.confirmWait = d }
}
+// WithDeliveryHook — сообщать итог передачи каждого пакета в НРД (для мониторинга связи).
+func WithDeliveryHook(f func(packageID, status, code string)) Option {
+ return func(cl *Client) { cl.onDelivery = f }
+}
+
// NotDeliveredError — ИШ принял пакет, но передать его в НРД не смог (статус ERROR): например,
// контур НРД недоступен (ERRORXML_UPLOAD_FAILED). Пакет в НРД не ушёл — отправку нужно повторить.
type NotDeliveredError struct {
@@ -171,6 +178,12 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, z
return id, nil
}
+func (c *Client) delivered(id, status, code string) {
+ if c.onDelivery != nil {
+ c.onDelivery(id, status, code)
+ }
+}
+
// waitDelivered — ИШ отвечает id сразу, а в НРД передаёт пакет следом (обычно за доли секунды).
// Ждём итог: SENT — доставлен; ERROR — не доставлен (*NotDeliveredError). Если статус узнать
// не удалось или пакет всё ещё в очереди ИШ — считаем отправленным, как раньше.
@@ -186,13 +199,16 @@ func (c *Client) waitDelivered(ctx context.Context, id string) error {
}
switch strings.ToUpper(st.Status) {
case StatusSent:
+ c.delivered(id, StatusSent, "")
return nil
case StatusError:
+ c.delivered(id, StatusError, st.Error)
return &NotDeliveredError{PackageID: id, Code: st.Error}
case "": // ИШ не сообщает статус — ждать нечего
return nil
}
if time.Now().Add(wait).After(deadline) {
+ c.delivered(id, StatusNew, "")
return nil
}
select {
diff --git a/modules/lk/lk.go b/modules/lk/lk.go
index 4b04337..217bec3 100644
--- a/modules/lk/lk.go
+++ b/modules/lk/lk.go
@@ -55,6 +55,34 @@ type Server struct {
handlerOnce sync.Once
handler http.Handler
+
+ nsdMu sync.Mutex // состояние обмена с НРД для баннера (кэш 30 с)
+ nsdAt time.Time
+ nsdNotice string
+}
+
+// nsdBanner — предупреждение «обмен с НРД недоступен» для вошедшего клиента (пусто — всё работает).
+// Спрашивает ядро не чаще раза в 30 с; ядро недоступно — баннер не показываем (об этом скажет сама страница).
+func (s *Server) nsdBanner(r *http.Request) string {
+ sess := session(r)
+ if sess == "" {
+ return ""
+ }
+ s.nsdMu.Lock()
+ defer s.nsdMu.Unlock()
+ if time.Since(s.nsdAt) < 30*time.Second {
+ return s.nsdNotice
+ }
+ var out struct {
+ Notice string `json:"notice"`
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+ if err := s.bj().call(ctx, http.MethodGet, "/api/lk/nsd-status", sess, nil, &out); err == nil {
+ s.nsdNotice = out.Notice
+ }
+ s.nsdAt = time.Now()
+ return s.nsdNotice
}
// New — кабинет с разобранными шаблонами.
@@ -734,6 +762,9 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
data["AsClient"] = c.Value
}
data["CSRF"] = csrfToken(w, r)
+ if _, ok := data["NSDNotice"]; !ok {
+ data["NSDNotice"] = s.nsdBanner(r)
+ }
if _, ok := data["Flash"]; !ok {
if f := takeFlash(w, r); f != "" {
data["Flash"] = f
diff --git a/modules/lk/templates.go b/modules/lk/templates.go
index 253fc40..bfefbe3 100644
--- a/modules/lk/templates.go
+++ b/modules/lk/templates.go
@@ -60,6 +60,7 @@ a{color:var(--acc)}a:hover{color:#1e3395}
.stat .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:22px}
.rside{display:flex;flex-direction:column;align-items:flex-end;gap:10px}
.asof{font-size:11.5px;color:#8e8879;letter-spacing:.04em}
+.nsd-note{background:var(--err-bg);color:var(--err-tx);padding:10px 20px;font-size:13.5px;display:flex;gap:10px;align-items:center}
.imp{background:var(--board);color:#f3f0e7;padding:10px 20px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
main{max-width:1180px;margin:0 auto;padding:22px 20px 34px}
h1{font-size:23px;margin:0 0 6px;letter-spacing:-.01em}h2{font-size:15px;margin:0 0 12px;letter-spacing:-.005em}
@@ -137,7 +138,9 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{if .AsClient}}
Вы в кабинете клиента {{.AsClient}} от имени сотрудника. Поручение можно подготовить, подписывает его клиент.
-
{{end}}{{end}}
+ {{end}}{{with .NSDNotice}}{{end}}{{end}}
{{define "login"}}{{template "head" .}}