9737c787f9
Инфраструктура M2M (живой обмен с НРД через ИШ): - обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение ответа; INFO → ждём Decision; идемпотентность поллера - fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO - сырой XML ответа НРД в карточке заявки (для пересылки в ТП) - тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes, 4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта - редирект из теста сразу в карточку заявки Мастер установки ключа Валидаты на флешку (admin/setup/keywizard): - пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник сертификатов (CRL) → перезапуск+проверка ИШ → готово - привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен, bj-server остаётся в песочнице - сохранение структуры профиля архива (spr<N>), перечисление съёмных USB Прочее: - пакет-доказательство для ТП НРД + форма регистрации участника M2M - эталонные образцы робота (DOC/m2m_robot_samples) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
12 KiB
Go
328 lines
12 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"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"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
|
|
)
|
|
|
|
// igwClientAdapter адаптирует igw.Client под узкий nsdadapter.IGWClient:
|
|
// разворачивает (channel, since, type) в igw.ListFilter.
|
|
type igwClientAdapter struct{ c *igw.Client }
|
|
|
|
func (a igwClientAdapter) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
|
|
return a.c.SendPackage(ctx, channel, packageType, body)
|
|
}
|
|
|
|
func (a igwClientAdapter) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]igw.Package, error) {
|
|
return a.c.ListIncoming(ctx, igw.ListFilter{Channel: channel, Date: since, Type: packageType})
|
|
}
|
|
|
|
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
|
|
type ServerConfig struct {
|
|
Addr string
|
|
DefaultSender m2m.DeponentCode
|
|
DefaultReceiver m2m.DeponentCode
|
|
CheckOptions func() CheckOptions
|
|
MockDecisionDelay time.Duration // 0 = дефолт 3 секунды
|
|
SetupPath string // путь к JSON-файлу runtime-конфига (пусто = ~/.bj/setup.json)
|
|
}
|
|
|
|
// Server — обвязка HTTP + сервис + workers.
|
|
type Server struct {
|
|
cfg ServerConfig
|
|
svc *Service
|
|
mock *mock.Sender
|
|
store *SeedStore
|
|
rc *RuntimeConfig
|
|
mux *http.ServeMux
|
|
server *http.Server
|
|
|
|
// igwClient/igwChannel заполнены только в реальном режиме (ИШ настроен).
|
|
// На них работает поллер входящих pollIncoming — забирает ответы НРД
|
|
// (M2MTransferDecision/Response) и применяет через svc.ApplyDecision.
|
|
igwClient *igw.Client
|
|
igwChannel string
|
|
}
|
|
|
|
// NewServer собирает Server с репозиторием, mock NSDSender, SeedStore
|
|
// и REST + Admin маршрутами. Выбор Repository:
|
|
// - если в runtime-конфиге (или ENV-fallback в cfg) задан PostgresDSN
|
|
// и pgx-Pool успешно создаётся — используется PostgresRepository;
|
|
// - иначе fallback на MemoryRepository (M2-демо).
|
|
func NewServer(cfg ServerConfig) (*Server, error) {
|
|
store := NewSeedStore()
|
|
mockCfg := mock.DefaultConfig()
|
|
mockCfg.NSDSenderCode = "MC0010300000"
|
|
if cfg.MockDecisionDelay > 0 {
|
|
mockCfg.DecisionDelay = cfg.MockDecisionDelay
|
|
}
|
|
|
|
rc, err := NewRuntimeConfig(cfg.SetupPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
|
|
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
|
|
// mockSender остаётся не-nil только в mock-режиме — на нём висит
|
|
// consumeDecisions (реальные Decision приходят поллером входящих ИШ).
|
|
var sender m2mcore.NSDSender
|
|
var mockSender *mock.Sender
|
|
var igwClient *igw.Client
|
|
var igwChannel string
|
|
{
|
|
s := rc.Snapshot()
|
|
if s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" {
|
|
prof, perr := nsdadapter.LookupProfile(s.NSD.Profile)
|
|
if perr != nil {
|
|
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)
|
|
sender = nsdadapter.NewSender(prof, igwClientAdapter{c: cl})
|
|
igwClient = cl
|
|
// Канал ИШ резолвится по составному коду <канал>+<депонент>.
|
|
igwChannel = prof.Channel + string(cfg.DefaultSender)
|
|
log.Printf("lk-gateway: реальный ИШ-адаптер — профиль %s, канал %s, ИШ %s",
|
|
prof.Name, igwChannel, s.NSD.IGWBaseURL)
|
|
}
|
|
}
|
|
if sender == nil {
|
|
mockSender = mock.NewSender(mockCfg)
|
|
sender = mockSender
|
|
log.Printf("lk-gateway: NSD mock-режим (Decision через эмуляцию)")
|
|
}
|
|
}
|
|
|
|
// Repository: pgx если DSN указан, иначе in-memory.
|
|
var repo m2mcore.Repository = m2mcore.NewMemoryRepository()
|
|
if dsn := rc.Snapshot().Postgres.DSN; dsn != "" {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
pgRepo, pgErr := m2mcore.NewPostgresRepository(ctx, dsn)
|
|
cancel()
|
|
if pgErr != nil {
|
|
log.Printf("lk-gateway: PostgresRepository отказал, fallback MemoryRepository: %v", pgErr)
|
|
} else {
|
|
repo = pgRepo
|
|
log.Printf("lk-gateway: PostgresRepository подключён (m2m_core.deals)")
|
|
}
|
|
}
|
|
|
|
svc := NewService(Config{
|
|
Repository: repo,
|
|
Sender: sender,
|
|
Store: store,
|
|
Recorder: m2mcore.NewMemoryRecorder(),
|
|
DefaultSender: cfg.DefaultSender,
|
|
DefaultReceiver: cfg.DefaultReceiver,
|
|
})
|
|
|
|
// Если runtime-конфиг уже содержит callback URL — применяем его.
|
|
if s := rc.Snapshot(); s.LK.CallbackURL != "" {
|
|
svc.callbackURL = s.LK.CallbackURL
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
RegisterAPI(mux, svc)
|
|
|
|
// CheckOptions берётся из runtime-конфига при каждом запросе на дашборд.
|
|
checkOpts := func() CheckOptions {
|
|
s := rc.Snapshot()
|
|
profile := "demo (mock NSD)"
|
|
if s.NSD.Profile != "" {
|
|
profile = s.NSD.Profile
|
|
}
|
|
return CheckOptions{
|
|
PostgresDSN: s.Postgres.DSN,
|
|
CryptoSocket: s.Crypto.SocketPath,
|
|
NSDAdapterURL: s.NSD.IGWBaseURL,
|
|
LKCallbackURL: s.LK.CallbackURL,
|
|
Profile: profile,
|
|
CryptoProvider: nonEmpty(s.Crypto.Provider, "stub"),
|
|
Timeout: 2 * time.Second,
|
|
}
|
|
}
|
|
if cfg.CheckOptions != nil {
|
|
// Опциональный override (например, из cmd/lk-gateway для override ENV-перетягивания).
|
|
checkOpts = cfg.CheckOptions
|
|
}
|
|
|
|
adminTpl, err := RegisterAdmin(mux, svc, rc, checkOpts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
registerSetup(mux, adminTpl, rc, svc)
|
|
registerHealth(mux)
|
|
registerSetCallback(mux, svc, rc)
|
|
registerSeedListing(mux, store)
|
|
|
|
return &Server{
|
|
cfg: cfg,
|
|
svc: svc,
|
|
mock: mockSender,
|
|
store: store,
|
|
rc: rc,
|
|
mux: mux,
|
|
igwClient: igwClient,
|
|
igwChannel: igwChannel,
|
|
server: &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// RuntimeConfig возвращает текущий runtime-конфиг (для тестов).
|
|
func (s *Server) RuntimeConfig() *RuntimeConfig { return s.rc }
|
|
|
|
func nonEmpty(s, def string) string {
|
|
if s == "" {
|
|
return def
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SetCallbackURL обновляет адрес, куда отправлять PATCH callback'и в ЛК.
|
|
func (s *Server) SetCallbackURL(url string) { s.svc.callbackURL = url }
|
|
|
|
// Service возвращает Service для тестов.
|
|
func (s *Server) Service() *Service { return s.svc }
|
|
|
|
// Mock возвращает mock-сендер.
|
|
func (s *Server) Mock() *mock.Sender { return s.mock }
|
|
|
|
// Store возвращает SeedStore.
|
|
func (s *Server) Store() *SeedStore { return s.store }
|
|
|
|
// Mux возвращает обработчик (для httptest).
|
|
func (s *Server) Mux() http.Handler { return s.mux }
|
|
|
|
// Run поднимает HTTP-сервер и фоновый Decisions-consumer.
|
|
// Блокируется до ctx.Done().
|
|
func (s *Server) Run(ctx context.Context) error {
|
|
go s.consumeDecisions(ctx)
|
|
|
|
// Поллер входящих от НРД (только в реальном режиме ИШ): забирает
|
|
// ответы робота/контрагента и применяет их через ApplyDecision.
|
|
if s.igwClient != nil && s.igwChannel != "" {
|
|
go s.pollIncoming(ctx)
|
|
}
|
|
|
|
// Фоновая авто-проверка обновлений из артефактории (если включена).
|
|
go NewUpdater(s.rc).updateLoop(ctx)
|
|
|
|
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
|
|
stopCACerts := StartCACertsAutoUpdater(s.rc)
|
|
defer stopCACerts()
|
|
|
|
// Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и
|
|
// эмитирует новости в ленту. Дефолтные источники + дефолтные
|
|
// новости (окно техработ TEST3, появление робота) сеются один раз.
|
|
EnsureDocSources(s.rc)
|
|
SeedDefaultNews(s.rc)
|
|
stopDocWatcher := StartDocWatcher(s.rc)
|
|
defer stopDocWatcher()
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
|
|
errCh <- s.server.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = s.server.Shutdown(shutdownCtx)
|
|
return nil
|
|
case err := <-errCh:
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
|
|
func (s *Server) consumeDecisions(ctx context.Context) {
|
|
if s.mock == nil {
|
|
// Реальный ИШ-режим: Decision приходят не из mock-канала, а через
|
|
// поллер входящих пакетов ИШ (отдельный механизм). Здесь нечего слушать.
|
|
<-ctx.Done()
|
|
return
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case d := <-s.mock.Decisions():
|
|
if d == nil {
|
|
continue
|
|
}
|
|
if err := s.svc.ApplyDecision(ctx, d); err != nil {
|
|
log.Printf("lk-gateway: ApplyDecision GUID=%s: %v", d.Header.GUID, err)
|
|
} else {
|
|
log.Printf("lk-gateway: Decision применён GUID=%s, callback в %s", d.Header.GUID, s.svc.callbackURL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func registerHealth(mux *http.ServeMux) {
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok\n"))
|
|
})
|
|
}
|
|
|
|
// registerSetCallback — служебный POST /admin/api/callback-url для
|
|
// эмулятора ЛК, чтобы сообщить gateway свой URL. Если URL уже сохранён
|
|
// в runtime-конфиге (пользователь явно настроил его через UI), запрос
|
|
// эмулятора игнорируется — приоритет у явно настроенного.
|
|
func registerSetCallback(mux *http.ServeMux, svc *Service, rc *RuntimeConfig) {
|
|
mux.HandleFunc("/admin/api/callback-url", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
url := r.URL.Query().Get("url")
|
|
if url == "" {
|
|
http.Error(w, "url required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if rc != nil {
|
|
if s := rc.Snapshot(); s.LK.CallbackURL != "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("kept-user-configured"))
|
|
return
|
|
}
|
|
}
|
|
svc.callbackURL = url
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
}
|
|
|
|
func registerSeedListing(mux *http.ServeMux, store *SeedStore) {
|
|
mux.HandleFunc("/admin/api/clients", func(w http.ResponseWriter, _ *http.Request) {
|
|
type c struct {
|
|
ID, LastName, FirstName, MiddleName string
|
|
}
|
|
out := make([]c, 0)
|
|
for _, cl := range store.Clients() {
|
|
out = append(out, c{ID: cl.ID, LastName: cl.LastName, FirstName: cl.FirstName, MiddleName: cl.MiddleName})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
})
|
|
}
|