From ae93900a2bf6a0b25c0849d65017884ec1902318 Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 24 Sep 2026 23:37:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20M2M:=20=D1=83=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0-=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B0=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=20=D0=BB=D0=B8=D1=86=D0=B5=D0=BD=D0=B7?= =?UTF-8?q?=D0=B8=D0=B9;=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=B2=D0=BE=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=BE=D1=82=20=D0=9D=D0=A0=D0=94=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D1=80=D0=B0=D1=8E=D1=82=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D1=8E=20=D1=80=D0=B0=D0=B7=20=D0=B2=20=D1=81=D1=83=D1=82?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=88?= =?UTF-8?q?=D0=BB=D1=8E=D0=B7=D0=B0=20=E2=80=94=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D1=89=D0=B0=D1=8E=D1=82=D1=81=D1=8F?= 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/handbook_embed.go | 142 +++++++++++++++++++++++++-- internal/lkgateway/nsdpoller.go | 1 + internal/lkgateway/server.go | 1 + 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/internal/lkgateway/handbook_embed.go b/internal/lkgateway/handbook_embed.go index d4552ea..7b1774c 100644 --- a/internal/lkgateway/handbook_embed.go +++ b/internal/lkgateway/handbook_embed.go @@ -1,22 +1,146 @@ package lkgateway import ( + "bytes" + "context" _ "embed" + "encoding/json" + "fmt" + "io" + "log" + "net/http" "os" + "strings" + "time" ) -// Снимок справочника участников M2M (получен от НРД, открытые данные): на чистой установке -// шлюза НРД ещё нет, и справочник не получить — тогда поиск организации и остальные места -// работают по снимку, поставляемому с bj. Как только bj получит справочник от НРД (M2MTH), -// используется он. Снимок обновляется с выпусками bj. +// Справочник участников M2M (открытые данные НРД). Источники, из которых берётся самый свежий: +// - полученный от НРД этим bj (M2MTH через шлюз НРД) — handbookFile; +// - копия с сервера лицензий (её присылает установка со шлюзом НРД, помеченная источником) — +// handbookCloudFile, забирается раз в сутки; +// - снимок, встроенный в bj (чистая установка без шлюза и без связи). // //go:embed handbook/m2m-handbook.json var handbookSnapshot []byte -// readHandbook — справочник от НРД, если он уже получен, иначе встроенный снимок. -func readHandbook() ([]byte, error) { - if b, err := os.ReadFile(handbookFile); err == nil && len(b) > 0 { - return b, nil +const handbookCloudFile = "/var/lib/bj/.bj/m2m-handbook-cloud.json" + +func handbookAt(b []byte) time.Time { + var h struct { + At time.Time `json:"at"` + } + _ = json.Unmarshal(b, &h) + return h.At +} + +// readHandbook — самый свежий из доступных справочников. +func readHandbook() ([]byte, error) { + best := handbookSnapshot + for _, p := range []string{handbookFile, handbookCloudFile} { + if b, err := os.ReadFile(p); err == nil && len(b) > 0 && handbookAt(b).After(handbookAt(best)) { + best = b + } + } + return best, nil +} + +func handbookURL(rc *RuntimeConfig) string { + base := strings.TrimSpace(rc.Snapshot().Update.BaseURL) + if base == "" { + base = DefaultUpdateURL + } + return strings.TrimRight(base, "/") + "/v1/handbook" +} + +// pushHandbookAsync — отправить полученный от НРД справочник на сервер лицензий. Сервер примет его, +// только если лицензия отмечена как источник справочника; у остальных — тихий отказ. +func pushHandbookAsync(rc *RuntimeConfig, raw []byte) { + if rc == nil || strings.TrimSpace(rc.Snapshot().License.Key) == "" { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPut, handbookURL(rc), bytes.NewReader(raw)) + if err != nil { + return + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(rc.Snapshot().License.Key)) + req.Header.Set("Content-Type", "application/json") + resp, err := licenseHTTP.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { + log.Printf("lk-gateway: справочник участников отправлен на сервер лицензий") + } + }() +} + +// fetchHandbook — забрать справочник с сервера лицензий, если он свежее уже имеющихся. +func fetchHandbook(ctx context.Context, rc *RuntimeConfig) error { + key := strings.TrimSpace(rc.Snapshot().License.Key) + if key == "" { + return nil + } + if fi, err := os.Stat(handbookFile); err == nil && fi.Size() > 0 { + return nil // справочник уже приходит от НРД через шлюз — к серверу лицензий не обращаемся + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, handbookURL(rc), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+key) + resp, err := licenseHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return err + } + var doc struct { + At time.Time `json:"at"` + Participants []json.RawMessage `json:"participants"` + } + if json.Unmarshal(b, &doc) != nil || len(doc.Participants) == 0 || doc.At.IsZero() { + return fmt.Errorf("не справочник") + } + cur, _ := readHandbook() + if !doc.At.After(handbookAt(cur)) { + return nil + } + if err := os.WriteFile(handbookCloudFile, b, 0o600); err != nil { + return err + } + log.Printf("lk-gateway: справочник участников обновлён с сервера лицензий (%d участников, от %s)", len(doc.Participants), doc.At.Format("02.01.2006 15:04")) + return nil +} + +// handbookLoop — пока своего справочника от НРД нет (новая установка без шлюза), раз в сутки +// забирает копию с сервера лицензий; как только справочник пришёл от НРД — больше не обращается. +func handbookLoop(ctx context.Context, rc *RuntimeConfig) { + t := time.NewTimer(2 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + cctx, cancel := context.WithTimeout(ctx, time.Minute) + if err := fetchHandbook(cctx, rc); err != nil { + log.Printf("lk-gateway: справочник с сервера лицензий: %v", err) + } + cancel() + t.Reset(24 * time.Hour) } - return handbookSnapshot, nil } diff --git a/internal/lkgateway/nsdpoller.go b/internal/lkgateway/nsdpoller.go index 2b8cb03..aa4d0e8 100644 --- a/internal/lkgateway/nsdpoller.go +++ b/internal/lkgateway/nsdpoller.go @@ -331,5 +331,6 @@ func saveHandbook(hb *m2m.M2MTransferHandbook) { } if raw, err := json.Marshal(out); err == nil { _ = os.WriteFile(handbookFile, raw, 0o600) + pushHandbookAsync(globalRC, raw) // свежий справочник — на сервер лицензий для всех клиентов } } diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 8d06b68..fb03cf1 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -293,6 +293,7 @@ func (s *Server) Run(ctx context.Context) error { // в лог и в ленту, горутина перезапускается. Один сбой не роняет сервис. go superviseLoop(ctx, "consume-decisions", s.rc, s.consumeDecisions) go superviseLoop(ctx, "igw-watch", s.rc, s.igwWatchLoop) // сторож ИШ НРД: оповещения и автоперезапуск + go handbookLoop(ctx, s.rc) // справочник участников M2M с сервера лицензий (для установок без шлюза НРД) // Поллер входящих от НРД (только в реальном режиме ИШ): забирает // ответы робота/контрагента и применяет их через ApplyDecision.