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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-25 16:17:25 +03:00
parent a0f6ce9f7b
commit 4cbefa41d5
18 changed files with 1075 additions and 62 deletions
+18 -3
View File
@@ -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
+4 -4
View File
@@ -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":
+9
View File
@@ -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
+15
View File
@@ -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)
}
}
+13
View File
@@ -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)
}
+568
View File
@@ -0,0 +1,568 @@
package lkgateway
// Связь с НРД — «зелёный только при свежем подтверждении». Состояние складывается из участков
// пути, у каждого своё доказательство работы:
// - Шлюз: ИШ отвечает bj (igwwatch.go);
// - НРД: шлюз успешно опрашивает НРД — по журналу ИШ (nsdlinkwatch.go);
// - M2M: сквозная проверка — раз в 10 минут bj запрашивает у НРД справочник участников (#M2MHR)
// и ждёт ответ #M2MTH: он проходит весь путь до сервиса M2M и обратно;
// - Отправка: подтверждение доставки каждого пакета (igw.WithDeliveryHook) и недоставленные переводы.
// Отсутствие ошибок само по себе не считается «работает»: нет свежего подтверждения — жёлтый.
// Раз в минуту состояние пишется в историю (сутки), по ней на странице «Статус» рисуется полоса.
// Недоставленные переводы уходят повторно сами, как только НРД снова отвечает (до 24 ч — срок ответа НРД).
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
const (
nsdProbeEvery = 10 * time.Minute
nsdProbeWarnAfter = 3 * time.Minute // ответа на проверку нет 3 мин — жёлтый
nsdProbeErrAfter = 10 * time.Minute // 10 мин — красный
nsdPollErrAfter = 3 * time.Minute // шлюз получает от НРД ошибки 3 мин — красный (до этого жёлтый)
nsdPollStale = 5 * time.Minute // нет свежих опросов НРД в журнале — жёлтый
autoResendMaxAge = 24 * time.Hour // срок ответа НРД: старше — не повторяем сами
autoResendGap = 5 * time.Minute // между попытками по одной заявке
nsdHistoryKeep = 24 * 60 // минут
)
var nsdHistoryFile = "/var/lib/bj/.bj/nsd-health.json"
// Состояния участков по возрастанию тяжести.
const (
segOK = "ok"
segUnknown = "unknown"
segWarn = "warn"
segErr = "err"
segOff = "off"
)
func segRank(s string) int {
switch s {
case segOK:
return 0
case segUnknown:
return 1
case segWarn:
return 2
case segErr:
return 3
}
return -1
}
// NSDSeg — участок пути до НРД.
type NSDSeg struct {
Key string `json:"key"`
Title string `json:"title"`
State string `json:"state"`
Text string `json:"text"`
Detail string `json:"detail,omitempty"`
}
// NSDHealth — сводка для шапки, «Статуса» и кабинета клиента.
type NSDHealth struct {
Configured bool `json:"configured"`
State string `json:"state"` // худшее из участков
Segs []NSDSeg `json:"segs"`
LastAnswer time.Time `json:"last_answer"` // последнее подтверждение от НРД (любое)
Ago string `json:"ago"` // «40 с назад»
Failed int `json:"failed"` // недоставленных переводов
}
var nsdH struct {
sync.Mutex
ProbeSentAt time.Time
ProbeReplyAt time.Time
ProbeLatency time.Duration
ProbeErr string
LastDelivered time.Time
LastDelErrAt time.Time
LastDelErr string
probeAlerted bool
resendNotified map[string]bool
history []nsdSample
historyLoaded bool
historyDirtyCnt int
}
type nsdSample struct {
T int64 `json:"t"` // минута (unix/60)
S string `json:"s"` // по символу на участок: o w e u -
}
// nsdProbeReply — пришёл справочник: НРД ответил (по нашей проверке или сам).
func nsdProbeReply(at time.Time) {
nsdH.Lock()
defer nsdH.Unlock()
if !nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.Before(nsdH.ProbeSentAt) && at.After(nsdH.ProbeSentAt) {
nsdH.ProbeLatency = at.Sub(nsdH.ProbeSentAt)
}
nsdH.ProbeReplyAt = at
}
// nsdDelivery — итог передачи пакета в НРД (из клиента ИШ).
func nsdDelivery(_ string, status, code string) {
nsdH.Lock()
defer nsdH.Unlock()
switch status {
case igw.StatusSent:
nsdH.LastDelivered = time.Now()
case igw.StatusError:
nsdH.LastDelErrAt, nsdH.LastDelErr = time.Now(), code
}
}
func agoText(t time.Time, now time.Time) string {
if t.IsZero() {
return ""
}
d := now.Sub(t)
switch {
case d < time.Minute:
return fmt.Sprintf("%d с назад", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%d мин назад", int(d.Minutes()))
}
return humanDur(d) + " назад"
}
// nsdHealthNow — текущее состояние связи с НРД.
func nsdHealthNow(rc *RuntimeConfig, now time.Time) NSDHealth {
h := NSDHealth{State: segOff}
if rc == nil {
return h
}
base := strings.TrimSpace(rc.Snapshot().NSD.IGWBaseURL)
if base == "" {
return h
}
h.Configured = true
// Шлюз
igwWatch.mu.Lock()
gw := NSDSeg{Key: "igw", Title: "Шлюз"}
igwUp := igwWatch.Checked && igwWatch.Up
switch {
case !igwWatch.Checked:
gw.State, gw.Text = segUnknown, "проверяется"
case igwWatch.Up:
gw.State, gw.Text = segOK, "отвечает"
default:
gw.State, gw.Text, gw.Detail = segErr, "не отвечает "+humanDur(now.Sub(igwWatch.DownSince)), igwWatch.LastErr
}
igwWatch.mu.Unlock()
// НРД (опросы шлюза)
nsdWatch.Lock()
ns := NSDSeg{Key: "nsd", Title: "НРД"}
lastPollOK := nsdWatch.LastPollOK
switch {
case !igwLocal(base):
ns.State, ns.Text = segUnknown, "журнал шлюза недоступен (шлюз на другом сервере)"
case !igwUp:
ns.State, ns.Text = segUnknown, "неизвестно — шлюз не отвечает"
case nsdWatch.LastPollAt.IsZero():
ns.State, ns.Text = segUnknown, "нет данных опроса"
case !nsdWatch.DownSince.IsZero():
down := now.Sub(nsdWatch.DownSince)
ns.State = segWarn
if down >= nsdPollErrAfter {
ns.State = segErr
}
ns.Text = "отвечает шлюзу ошибками " + humanDur(down)
ns.Detail = nsdWatch.Contour + ": " + nsdWatch.LastErr
case now.Sub(nsdWatch.LastPollOK) > nsdPollStale:
ns.State, ns.Text = segWarn, "давно нет опросов НРД (последний "+agoText(nsdWatch.LastPollOK, now)+")"
default:
ns.State, ns.Text = segOK, "отвечает · "+agoText(nsdWatch.LastPollOK, now)
}
nsdWatch.Unlock()
nsdH.Lock()
// M2M — сквозная проверка
m := NSDSeg{Key: "m2m", Title: "M2M"}
pending := !nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.Before(nsdH.ProbeSentAt)
switch {
case nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.IsZero():
m.State, m.Text = segUnknown, "проверка ещё не выполнялась"
case nsdH.ProbeErr != "":
m.State, m.Text, m.Detail = segErr, "запрос проверки не доставлен", nsdH.ProbeErr
case pending && now.Sub(nsdH.ProbeSentAt) >= nsdProbeErrAfter:
m.State, m.Text = segErr, "НРД не ответил на проверку "+humanDur(now.Sub(nsdH.ProbeSentAt))
case pending && now.Sub(nsdH.ProbeSentAt) >= nsdProbeWarnAfter:
m.State, m.Text = segWarn, "ждём ответ НРД на проверку "+humanDur(now.Sub(nsdH.ProbeSentAt))
case pending && nsdH.ProbeReplyAt.IsZero():
m.State, m.Text = segUnknown, "проверка отправлена, ждём ответ"
default:
m.State, m.Text = segOK, "ответ на проверку "+agoText(nsdH.ProbeReplyAt, now)
if nsdH.ProbeLatency > 0 {
m.Text += fmt.Sprintf(" (за %d с)", int(nsdH.ProbeLatency.Seconds()))
}
if pending {
m.Text += " · идёт новая проверка"
}
}
// Отправка
sd := NSDSeg{Key: "send", Title: "Отправка"}
h.Failed = sendFailCount()
switch {
case h.Failed > 0:
sd.State, sd.Text = segErr, fmt.Sprintf("не доставлено переводов: %d — отправятся повторно сами, когда НРД ответит", h.Failed)
case !nsdH.LastDelErrAt.IsZero() && nsdH.LastDelErrAt.After(nsdH.LastDelivered):
sd.State, sd.Text, sd.Detail = segWarn, "последний пакет не доставлен "+agoText(nsdH.LastDelErrAt, now), nsdH.LastDelErr
case !nsdH.LastDelivered.IsZero():
sd.State, sd.Text = segOK, "последний пакет доставлен "+agoText(nsdH.LastDelivered, now)
default:
sd.State, sd.Text = segOK, "ошибок доставки нет"
}
for _, t := range []time.Time{lastPollOK, nsdH.ProbeReplyAt, nsdH.LastDelivered} {
if t.After(h.LastAnswer) {
h.LastAnswer = t
}
}
nsdH.Unlock()
h.Segs = []NSDSeg{gw, ns, m, sd}
h.State = segOK
for _, s := range h.Segs {
if segRank(s.State) > segRank(h.State) {
h.State = s.State
}
}
h.Ago = agoText(h.LastAnswer, now)
return h
}
// nsdLinkOK — НРД доступен для отправки: шлюз отвечает и (по журналу или проверке) НРД отвечает.
func nsdLinkOK(h NSDHealth) bool {
st := map[string]string{}
for _, s := range h.Segs {
st[s.Key] = s.State
}
if st["igw"] != segOK {
return false
}
switch st["nsd"] {
case segOK:
return true
case segUnknown: // журнала нет — судим по сквозной проверке
return st["m2m"] == segOK
}
return false
}
// --- проверка связи ---
var nsdProbeNowCh = make(chan struct{}, 1)
// nsdProbeNow — «Проверить связь сейчас».
func nsdProbeNow() {
select {
case nsdProbeNowCh <- struct{}{}:
default:
}
}
func (s *Server) sendNSDProbe(ctx context.Context) {
nsdH.Lock()
nsdH.ProbeSentAt, nsdH.ProbeErr = time.Now(), ""
nsdH.Unlock()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
if _, err := sendHandbookRequest(cctx, s.igwClient, s.igwChannel); err != nil {
nsdH.Lock()
nsdH.ProbeErr = err.Error()
nsdH.Unlock()
log.Printf("nsd-health: проверка связи (#M2MHR) не отправлена: %v", err)
}
}
// nsdHealthLoop — проверка раз в 10 мин, история раз в минуту, автоповтор недоставленных.
func (s *Server) nsdHealthLoop(ctx context.Context) {
healthRegister("nsd-health", 3*time.Minute)
nsdHistoryLoad()
probeT := time.NewTimer(time.Minute)
defer probeT.Stop()
minute := time.NewTicker(time.Minute)
defer minute.Stop()
for {
select {
case <-ctx.Done():
nsdHistorySave()
return
case <-nsdProbeNowCh:
if s.igwClient != nil {
s.sendNSDProbe(ctx)
}
case <-probeT.C:
if s.igwClient != nil {
s.sendNSDProbe(ctx)
}
probeT.Reset(nsdProbeEvery)
case <-minute.C:
h := nsdHealthNow(s.rc, time.Now())
nsdHistoryAdd(h, time.Now())
s.nsdProbeAlerts(h)
if nsdLinkOK(h) {
s.autoResend(ctx)
}
healthBeat("nsd-health")
}
}
}
// nsdProbeAlerts — оповещения по сквозной проверке (сбой опросов НРД сообщает nsdlinkwatch.go).
func (s *Server) nsdProbeAlerts(h NSDHealth) {
var m NSDSeg
for _, x := range h.Segs {
if x.Key == "m2m" {
m = x
}
}
nsdH.Lock()
was := nsdH.probeAlerted
switch {
case m.State == segErr && !was:
nsdH.probeAlerted = true
case m.State == segOK && was:
nsdH.probeAlerted = false
default:
nsdH.Unlock()
return
}
nsdH.Unlock()
if m.State == segErr {
raiseSystemAlert(s.rc, "НРД не отвечает на проверку M2M", "Сквозная проверка связи (запрос справочника участников M2M) не прошла: "+m.Text+". "+m.Detail+
" Пока так, переводы и решения могут не доходить до НРД. Недоставленные переводы bj отправит повторно сам, когда связь восстановится.")
return
}
raiseSystemAlert(s.rc, "Проверка связи с НРД снова проходит", "НРД ответил на сквозную проверку M2M: "+m.Text+".")
}
// autoResend — повторная отправка недоставленных переводов, когда НРД снова отвечает.
func (s *Server) autoResend(ctx context.Context) {
if s.svc == nil {
return
}
now := time.Now()
for guid, f := range sendFailList() {
first := f.FirstAt
if first.IsZero() {
first = f.At
}
if now.Sub(first) > autoResendMaxAge {
if sendFailGiveUp(guid) {
raiseSystemAlert(s.rc, "Перевод не доставлен в НРД за 24 часа",
fmt.Sprintf("Перевод %s не удалось доставить в НРД за сутки (попыток: %d). Автоповтор остановлен — срок ответа НРД истёк. Решите в карточке заявки: «Отправить повторно» или «Удалить».", shortGUID(guid), f.Attempts))
}
continue
}
if f.GaveUp || now.Sub(f.At) < autoResendGap {
continue
}
cctx, cancel := context.WithTimeout(ctx, time.Minute)
err := s.svc.ResendClaim(cctx, f.DealID)
cancel()
if err != nil {
log.Printf("nsd-health: автоповтор перевода %s: %v", shortGUID(guid), err)
continue
}
log.Printf("nsd-health: перевод %s отправлен повторно автоматически (попытка %d)", shortGUID(guid), f.Attempts+1)
raiseSystemAlert(s.rc, "Перевод отправлен в НРД повторно",
fmt.Sprintf("Связь с НРД восстановилась — перевод %s отправлен автоматически, НРД принял пакет.", shortGUID(guid)))
}
}
// --- история ---
func nsdSegChar(st string) byte {
switch st {
case segOK:
return 'o'
case segWarn:
return 'w'
case segErr:
return 'e'
case segUnknown:
return 'u'
}
return '-'
}
func nsdHistoryLoad() {
nsdH.Lock()
defer nsdH.Unlock()
if nsdH.historyLoaded {
return
}
nsdH.historyLoaded = true
if raw, err := os.ReadFile(nsdHistoryFile); err == nil {
_ = json.Unmarshal(raw, &nsdH.history)
}
}
func nsdHistorySave() {
nsdH.Lock()
raw, err := json.Marshal(nsdH.history)
nsdH.Unlock()
if err == nil {
_ = os.WriteFile(nsdHistoryFile, raw, 0o600)
}
}
func nsdHistoryAdd(h NSDHealth, now time.Time) {
if !h.Configured {
return
}
b := make([]byte, len(h.Segs))
for i, s := range h.Segs {
b[i] = nsdSegChar(s.State)
}
nsdH.Lock()
t := now.Unix() / 60
if n := len(nsdH.history); n > 0 && nsdH.history[n-1].T == t {
nsdH.history[n-1].S = string(b)
} else {
nsdH.history = append(nsdH.history, nsdSample{T: t, S: string(b)})
}
cut := t - nsdHistoryKeep
i := 0
for i < len(nsdH.history) && nsdH.history[i].T <= cut {
i++
}
nsdH.history = nsdH.history[i:]
nsdH.historyDirtyCnt++
save := nsdH.historyDirtyCnt >= 5
if save {
nsdH.historyDirtyCnt = 0
}
nsdH.Unlock()
if save {
nsdHistorySave()
}
}
// NSDStripCell — отрезок полосы (10 минут).
type NSDStripCell struct {
State string
Title string
}
// NSDStripRow — полоса одного участка за сутки.
type NSDStripRow struct {
Title string
Cells []NSDStripCell
Bad string // итог: «сбоев не было» / «сбой 2 ч 10 мин»
}
// nsdStrip — полосы за сутки по 10 минут (худшее состояние в отрезке).
func nsdStrip(titles []string, now time.Time) []NSDStripRow {
nsdHistoryLoad()
nsdH.Lock()
hist := append([]nsdSample(nil), nsdH.history...)
nsdH.Unlock()
const bucket = 10
end := now.Unix() / 60
start := end - nsdHistoryKeep + 1
nb := nsdHistoryKeep / bucket
rows := make([]NSDStripRow, len(titles))
for i, t := range titles {
rows[i] = NSDStripRow{Title: t, Cells: make([]NSDStripCell, nb)}
}
bad := make([]int, len(titles))
for _, smp := range hist {
if smp.T < start || smp.T > end {
continue
}
k := int(smp.T-start) / bucket
for i := range titles {
if i >= len(smp.S) {
continue
}
st := map[byte]string{'o': segOK, 'w': segWarn, 'e': segErr, 'u': segUnknown}[smp.S[i]]
if st == "" {
continue
}
if st == segErr {
bad[i]++
}
if segRank(st) > segRank(rows[i].Cells[k].State) || rows[i].Cells[k].State == "" {
rows[i].Cells[k].State = st
}
}
}
names := map[string]string{segOK: "работает", segWarn: "с перебоями", segErr: "сбой", segUnknown: "нет данных", "": "нет данных"}
for i := range rows {
for k := range rows[i].Cells {
from := time.Unix((start+int64(k*bucket))*60, 0)
c := &rows[i].Cells[k]
if c.State == "" {
c.State = "none"
}
c.Title = from.Format("15:04") + "–" + from.Add(bucket*time.Minute).Format("15:04") + ": " + names[strings.TrimPrefix(c.State, "none")]
}
if bad[i] == 0 {
rows[i].Bad = "сбоев не было"
} else {
rows[i].Bad = "сбой " + humanDur(time.Duration(bad[i])*time.Minute)
}
}
return rows
}
// --- HTTP ---
// registerNSDHealth — GET /admin/nsd-health.json (шапка обновляется сама), POST /admin/status/nsd-probe.
func registerNSDHealth(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/nsd-health.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(nsdHealthNow(rc, time.Now()))
})
mux.HandleFunc("/admin/status/nsd-probe", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
nsdProbeNow()
http.Redirect(w, r, "/admin/status?probe=1#nsd", http.StatusSeeOther)
})
}
// nsdClientNotice — текст для кабинета клиента (пусто — обмен работает).
func nsdClientNotice(h NSDHealth) string {
if !h.Configured || nsdLinkOK(h) {
return ""
}
for _, s := range h.Segs {
if (s.Key == "igw" || s.Key == "nsd" || s.Key == "m2m") && s.State == segErr {
return "Обмен с НРД временно недоступен. Поручения на перевод принимаются и будут отправлены в НРД автоматически, как только связь восстановится."
}
}
return ""
}
// nsdSegTip — подсказка к цепочке в шапке: все участки построчно.
func nsdSegTip(h NSDHealth) string {
var b strings.Builder
for _, s := range h.Segs {
b.WriteString(s.Title + ": " + s.Text)
if s.Detail != "" {
b.WriteString(" (" + s.Detail + ")")
}
b.WriteString("\n")
}
b.WriteString("Подробно — «Статус». Обновляется каждые 30 с.")
return b.String()
}
+187
View File
@@ -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)
}
}
}
+15
View File
@@ -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
+29 -6
View File
@@ -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
}
+72 -6
View File
@@ -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
}
+1 -2
View File
@@ -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) }
+3 -36
View File
@@ -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), запрос
@@ -33,7 +33,7 @@
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span>{{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/resend" style="margin:0 0 0 auto" onsubmit="this.querySelector('button').disabled=true;this.querySelector('button').textContent='Отправляем…';"><button type="submit" class="btn">Отправить повторно</button></form>{{end}}{{if and (or (eq .Claim.Status "draft") (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/delete-draft" style="margin:0{{if not (or .Fail (eq .Claim.Status "validated"))}} 0 0 auto{{end}}" onsubmit="return confirm('Удалить заявку? Она ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">{{if eq .Claim.Status "draft"}}Удалить черновик{{else}}Удалить{{end}}</button></form>{{end}}</h2>
{{with .Fail}}<div class="card" style="background:var(--err-weak,#fbe9e7);margin:8px 0">
<b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br>
<span class="muted">Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. Когда НРД снова будет доступен, нажмите «Отправить повторно» — уйдёт тот же запрос с тем же GUID.</span>
<span class="muted">Попытка {{.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}}</span>
</div>{{end}}
{{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}" data-sig="{{$l.Sig}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div>
<table>
@@ -1,4 +1,58 @@
{{define "content"}}
{{if .NSDLink.Configured}}<div class="card" id="nsd">
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Связь с НРД
<span class="badge {{if eq .NSDLink.State "ok"}}confirmed{{else if eq .NSDLink.State "err"}}rejected{{else}}awaiting_decision{{end}}">{{if eq .NSDLink.State "ok"}}работает{{else if eq .NSDLink.State "err"}}сбой{{else if eq .NSDLink.State "warn"}}с перебоями{{else}}нет данных{{end}}</span>
{{if can "operate"}}<form method="post" action="/admin/status/nsd-probe" style="margin:0 0 0 auto"><button class="btn" type="submit">Проверить связь сейчас</button></form>{{end}}
</h2>
{{if .Probe}}<p class="muted">Запрос проверки отправлен — ответ НРД обычно приходит за 20–60 с. Страница обновится сама.</p>{{end}}
<p class="muted" style="margin-top:0">Зелёный — только при свежем подтверждении от НРД, а не при отсутствии ошибок. {{if .NSDLink.Ago}}Последний ответ НРД — {{.NSDLink.Ago}}.{{end}}</p>
<table>
<thead><tr><th></th><th>Участок</th><th>Состояние</th><th>Чем проверяем</th></tr></thead>
<tbody>
{{range .NSDLink.Segs}}<tr>
<td><span class="nsd-dot {{.State}}"></span></td>
<td><strong>{{.Title}}</strong></td>
<td>{{.Text}}{{with .Detail}}<br><code>{{.}}</code>{{end}}</td>
<td class="muted" style="font-size:12.5px">{{if eq .Key "igw"}}bj обращается к шлюзу каждые 30 с{{else if eq .Key "nsd"}}журнал шлюза: ответы НРД на опросы входящих{{else if eq .Key "m2m"}}раз в 10 мин запрос справочника участников M2M и ответ на него — весь путь до сервиса M2M и обратно{{else}}подтверждение доставки каждого отправленного пакета{{end}}</td>
</tr>{{end}}
</tbody>
</table>
<h3 style="margin:18px 0 6px;font-size:14px">За последние сутки <span class="muted" style="font-weight:400;font-size:12px">с {{.StripFrom}}, отрезок — 10 минут</span></h3>
<div class="nsd-strip">
{{range .Strip}}<div class="nsd-row"><span class="nsd-t">{{.Title}}</span><span class="nsd-cells">{{range .Cells}}<i class="{{.State}}" title="{{.Title}}"></i>{{end}}</span><span class="nsd-b muted">{{.Bad}}</span></div>{{end}}
<div class="nsd-legend muted"><i class="ok"></i>работает <i class="warn"></i>перебои <i class="err"></i>сбой <i class="none"></i>нет данных</div>
</div>
{{if .Fails}}<h3 style="margin:18px 0 6px;font-size:14px">Не доставлены в НРД</h3>
<table>
<thead><tr><th>Перевод</th><th>Причина</th><th>Первая попытка</th><th>Попыток</th><th></th></tr></thead>
<tbody>{{range .Fails}}<tr>
<td><a href="/admin/claims/{{.DealID}}"><code>{{slice .GUID 0 8}}</code></a></td>
<td>{{.Text}}</td>
<td>{{.FirstAt.Format "02.01 15:04"}}</td>
<td>{{.Attempts}}</td>
<td>{{if .GaveUp}}<span style="color:var(--err)">автоповтор остановлен (прошли сутки)</span>{{else}}<span class="muted">отправится сам, когда НРД ответит</span>{{end}}{{if can "operate"}}<form method="post" action="/admin/claims/{{.DealID}}/resend" style="margin:4px 0 0"><button class="btn" type="submit" style="padding:2px 10px;font-size:12px">Отправить сейчас</button></form>{{end}}</td>
</tr>{{end}}</tbody>
</table>{{end}}
</div>
<style>
.nsd-dot { width:10px; height:10px; border-radius:50%; display:inline-block; background:var(--muted); }
.nsd-dot.ok { background:var(--ok); } .nsd-dot.warn { background:var(--warn); } .nsd-dot.err { background:var(--err); }
.nsd-strip { display:flex; flex-direction:column; gap:6px; }
.nsd-row { display:grid; grid-template-columns:80px 1fr 130px; align-items:center; gap:10px; }
.nsd-t { font-size:13px; font-weight:600; }
.nsd-b { font-size:12px; text-align:right; }
.nsd-cells { display:grid; grid-template-columns:repeat(144, 1fr); gap:1px; height:18px; }
.nsd-cells i, .nsd-legend i { display:block; border-radius:2px; background:var(--border); }
.nsd-cells i.ok, .nsd-legend i.ok { background:var(--ok); } .nsd-cells i.warn, .nsd-legend i.warn { background:var(--warn); }
.nsd-cells i.err, .nsd-legend i.err { background:var(--err); } .nsd-cells i.unknown { background:var(--muted); opacity:.35; }
.nsd-legend { display:flex; align-items:center; gap:6px; font-size:12px; margin-top:4px; }
.nsd-legend i { width:10px; height:10px; display:inline-block; margin-left:8px; }
@media (max-width:700px){ .nsd-row { grid-template-columns:1fr; } .nsd-b { text-align:left; } }
</style>
<script>setTimeout(function(){ if (!document.querySelector('main form input:focus')) location.reload(); }, {{if .Probe}}20000{{else}}60000{{end}});</script>
{{end}}
<div class="card">
<h2>Статус системы — детально</h2>
<table>
+35 -3
View File
@@ -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; }
</nav>
<div class="topbar-right">
<span class="sys-block">
{{if and .IGWDown (not .IsMockMode)}}<span class="sys-status err" title="{{.IGWDetail}}" style="background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)">
<span class="pulse"></span>ИШ НРД недоступен · {{.IGWDown}}</span>{{else if and .NSDDown (not .IsMockMode)}}<span class="sys-status err" title="{{.IGWDetail}}" style="background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)">
<span class="pulse"></span>НРД не отвечает</span>{{else}}<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
{{if and .NSDLink.Configured (not .IsMockMode)}}<a id="nsd-chain" href="/admin/status#nsd" class="sys-status nsd-chain {{.NSDLink.State}}" title="{{nsdSegTip .NSDLink}}">{{range $i, $s := .NSDLink.Segs}}{{if $i}}<span class="sep" aria-hidden="true">›</span>{{end}}<span class="seg {{$s.State}}"><i></i><span>{{$s.Title}}</span></span>{{end}}<span class="ago">{{if .NSDLink.Ago}}ответ НРД {{.NSDLink.Ago}}{{else}}ответа НРД ещё не было{{end}}</span></a>{{else}}<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
<span class="pulse"></span>{{if .IsMockMode}}{{if devOn}}Эмуляция{{else}}Нет связи с НРД{{end}}{{else}}НРД на связи{{end}}</span>{{end}}
<span class="clock">{{.Now}}</span>
</span>
@@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 += '<span class="sep" aria-hidden="true">›</span>';
html += '<span class="seg ' + esc(s.state) + '"><i></i><span>' + esc(s.title) + '</span></span>';
tip += s.title + ': ' + s.text + (s.detail ? ' (' + s.detail + ')' : '') + '\n';
});
html += '<span class="ago">' + (h.ago ? 'ответ НРД ' + esc(h.ago) : 'ответа НРД ещё не было') + '</span>';
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): изменилось что-то кроме строки статуса — страница
// перерисовывается сама; если оператор уже начал заполнять форму — только предложение обновить.
+16
View File
@@ -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 {
+31
View File
@@ -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
+4 -1
View File
@@ -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}}<div class="imp">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="#e8a33d" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11h.01"/></svg>
<span>Вы в кабинете клиента <b>{{.AsClient}}</b> от имени сотрудника. Поручение можно подготовить, подписывает его клиент.</span>
<form method="post" action="/back-to-admin" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="lnk" type="submit" style="color:#e8a33d">вернуться к списку клиентов</button></form></div>{{end}}{{end}}
<form method="post" action="/back-to-admin" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="lnk" type="submit" style="color:#e8a33d">вернуться к списку клиентов</button></form></div>{{end}}{{with .NSDNotice}}<div class="nsd-note" role="status">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11h.01"/></svg>
<span>{{.}}</span></div>{{end}}{{end}}
{{define "login"}}{{template "head" .}}
<div class="login">