Справочник участников M2M: установка-источник отправляет его на сервер лицензий; установки без своего справочника от НРД забирают копию раз в сутки, после подключения шлюза — не обращаются

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-24 23:37:44 +03:00
parent 2a5a8d60fa
commit ae93900a2b
3 changed files with 135 additions and 9 deletions
+132 -8
View File
@@ -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 — справочник от НРД, если он уже получен, иначе встроенный снимок.
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) {
if b, err := os.ReadFile(handbookFile); err == nil && len(b) > 0 {
return b, nil
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
}
+1
View File
@@ -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) // свежий справочник — на сервер лицензий для всех клиентов
}
}
+1
View File
@@ -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.