From 4cbefa41d556f4e32417437ba7fde1f47c8f1a9d Mon Sep 17 00:00:00 2001 From: zuevav Date: Fri, 25 Sep 2026 16:17:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20=D1=81=20?= =?UTF-8?q?=D0=9D=D0=A0=D0=94:=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=A8=D0=BB=D1=8E=D0=B7/=D0=9D=D0=A0=D0=94/M2M/=D0=9E?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0,=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=BC=20=D1=80=D0=B0=D0=B7=20?= =?UTF-8?q?=D0=B2=2010=20=D0=BC=D0=B8=D0=BD,=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=8F=20=D0=B7=D0=B0=20=D1=81=D1=83=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=C2=AB=D0=A1=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D0=B5=C2=BB,=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B5=20(30=20=D1=81?= =?UTF-8?q?),=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BE=2024=20=D1=87,=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=20=C2=AB=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=C2=BB=20=D0=B2=20=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/admin.go | 21 +- internal/lkgateway/livestatus.go | 8 +- internal/lkgateway/lkapi.go | 9 + internal/lkgateway/lkstore.go | 15 + internal/lkgateway/lktransfer.go | 13 + internal/lkgateway/nsdhealth.go | 568 ++++++++++++++++++ internal/lkgateway/nsdhealth_test.go | 187 ++++++ internal/lkgateway/nsdlinkwatch.go | 15 + internal/lkgateway/nsdpoller.go | 35 +- internal/lkgateway/sendfail.go | 78 ++- internal/lkgateway/sendfail_test.go | 3 +- internal/lkgateway/server.go | 39 +- .../lkgateway/web/templates/admin_claim.html | 2 +- .../lkgateway/web/templates/admin_status.html | 54 ++ internal/lkgateway/web/templates/layout.html | 38 +- internal/nsdadapter/igw/client.go | 16 + modules/lk/lk.go | 31 + modules/lk/templates.go | 5 +- 18 files changed, 1075 insertions(+), 62 deletions(-) create mode 100644 internal/lkgateway/nsdhealth.go create mode 100644 internal/lkgateway/nsdhealth_test.go 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}} + + + + + {{end}} + +
УчастокСостояниеЧем проверяем
{{.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}}
+ +

За последние сутки с {{.StripFrom}}, отрезок — 10 минут

+
+ {{range .Strip}}
{{.Title}}{{range .Cells}}{{end}}{{.Bad}}
{{end}} +
работает перебои сбой нет данных
+
+ + {{if .Fails}}

Не доставлены в НРД

+ + + {{range .Fails}} + + + + + + {{end}} +
ПереводПричинаПервая попыткаПопыток
{{slice .GUID 0 8}}{{.Text}}{{.FirstAt.Format "02.01 15:04"}}{{.Attempts}}{{if .GaveUp}}автоповтор остановлен (прошли сутки){{else}}отправится сам, когда НРД ответит{{end}}{{if can "operate"}}
{{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" .}}