feat(m2m): сквозной поток с веб-интерфейсами — lk-gateway BFF + admin UI + lk-emulator + mock NSD
Реализован M2-шаг-1: первый рабочий сквозной поток M2M-заявки от ЛК
через нашу систему и обратно, с двумя видимыми веб-интерфейсами.
internal/nsdadapter/mock/:
- mock NSDSender с реалистичным синтетическим Response и асинхронной
эмиссией Decision через настраиваемую задержку (Confirm/Reject/Timeout)
- использует собственный жизненный цикл, чтобы HTTP-контексты вызывающего
не прерывали эмиссию Decision до истечения DecisionDelay
internal/lkgateway/:
- REST по контракту ESIA Finance V1 (POST/GET/PATCH/list claims)
- admin web UI (/admin/, /admin/claims, /admin/claims/{id}, /admin/status):
- дашборд со статусом подсистем (postgres, crypto-service UDS,
nsd-adapter, lk-emulator callback) и счётчиками сделок
- журнал и карточка заявки с историей FSM, ответом НРД, решением
принимающей стороны и последним callback'ом
- in-memory SeedStore с 5 тестовыми клиентами и счетами депо
- фоновый consumeDecisions: подписан на mock.Sender.Decisions(),
применяет ApplyDecision и отправляет PATCH callback в ЛК
internal/lkemulator/:
- имитация ЛК клиента (порт 8083)
- веб-формы: журнал, форма «новая заявка», карточка заявки
- HTTP-клиент к lk-gateway (создание заявки + регистрация callback URL)
- приёмник PATCH callback'ов, локальное хранилище заявок,
автообновление страницы каждые 3 сек
cmd/lk-gateway/main.go и cmd/lk-emulator/main.go — заглушки заменены
на полные сервисы с graceful shutdown.
Сквозной поток проверен smoke-test'ом: подача заявки через форму
эмулятора → создание сделки в lk-gateway → Send в mock NSD →
эмиссия Decision через 3 сек → ApplyDecision → PATCH callback в ЛК →
эмулятор показывает confirmed. Дашборд lk-gateway: Total=1, Подтверждено=1.
make ci зелёный.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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/mock"
|
||||
)
|
||||
|
||||
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
DefaultSender m2m.DeponentCode
|
||||
DefaultReceiver m2m.DeponentCode
|
||||
CheckOptions func() CheckOptions
|
||||
MockDecisionDelay time.Duration // 0 = дефолт 3 секунды
|
||||
}
|
||||
|
||||
// Server — обвязка HTTP + сервис + workers.
|
||||
type Server struct {
|
||||
cfg ServerConfig
|
||||
svc *Service
|
||||
mock *mock.Sender
|
||||
store *SeedStore
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewServer собирает Server с in-memory репозиторием, mock NSDSender,
|
||||
// SeedStore и REST + Admin маршрутами.
|
||||
func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
store := NewSeedStore()
|
||||
mockCfg := mock.DefaultConfig()
|
||||
mockCfg.NSDSenderCode = "MC0010300000"
|
||||
if cfg.MockDecisionDelay > 0 {
|
||||
mockCfg.DecisionDelay = cfg.MockDecisionDelay
|
||||
}
|
||||
sender := mock.NewSender(mockCfg)
|
||||
|
||||
svc := NewService(Config{
|
||||
Repository: m2mcore.NewMemoryRepository(),
|
||||
Sender: sender,
|
||||
Store: store,
|
||||
Recorder: m2mcore.NewMemoryRecorder(),
|
||||
DefaultSender: cfg.DefaultSender,
|
||||
DefaultReceiver: cfg.DefaultReceiver,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterAPI(mux, svc)
|
||||
if cfg.CheckOptions == nil {
|
||||
cfg.CheckOptions = func() CheckOptions {
|
||||
return CheckOptions{Profile: "demo (mock NSD)", CryptoProvider: "stub"}
|
||||
}
|
||||
}
|
||||
if err := RegisterAdmin(mux, svc, cfg.CheckOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registerHealth(mux)
|
||||
registerSetCallback(mux, svc)
|
||||
registerSeedListing(mux, store)
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
svc: svc,
|
||||
mock: sender,
|
||||
store: store,
|
||||
mux: mux,
|
||||
server: &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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) {
|
||||
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.
|
||||
func registerSetCallback(mux *http.ServeMux, svc *Service) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user