From 958d777751bd2bebb1f7a43f1a3f3e5eaae7503d Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 14 May 2026 11:30:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(lk-gateway):=20admin=20setup=20wizard=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20UI=20+=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена вкладка «Настройка» в admin-панели lk-gateway. Позволяет ввести параметры каждой подсистемы прямо через веб-интерфейс, проверить подключение и запустить тестовую заявку в один клик. internal/lkgateway/runtimeconfig.go: - Runtime-конфиг с персистом в JSON (BJ_SETUP_PATH или ~/.bj/setup.json) - Поля: PostgresDSN, Crypto (provider/socket/jcp_path/license_key), NSD (profile/igw_base_url/key_container), LK (callback_url), LastTestRun (результат последнего тестового прогона) - ReadinessSummary() для блока «Готовность системы: X из Y» internal/lkgateway/setup.go: - GET /admin/setup — страница настройки - POST /admin/setup/postgres — DSN + sql.Ping (без pgx-драйвера упадёт на «unknown driver postgres», что покажет пользователю) - POST /admin/setup/crypto — provider/socket/jcp.jar/лицензия, проверка существования файла jcp.jar - POST /admin/setup/nsd — профиль/URL ИШ/контейнер, GET /healthz ИШ - POST /admin/setup/lk — callback URL + GET /healthz эмулятора/ЛК - POST /admin/setup/test-run — пробная сквозная заявка с предзаполнением (Иванов, 1500 акций Газпрома, ИИС T03), опрос статуса до финального internal/lkgateway/web/templates/admin_setup.html: - 4 карточки подсистем со статус-индикаторами (зелёная/красная точка) - Inline-формы через
/: открыты для не настроенных, свёрнуты для уже настроенных - Карточка «Тестовый прогон» с историей последнего результата - Прогресс «Готовность системы: X из Y» в верхней части internal/lkgateway/server.go: - Server.rc *RuntimeConfig — поднимается при NewServer - CheckOptions для admin-дашборда теперь берутся из runtime-конфига, а не только из ENV — изменения в /admin/setup сразу видны в /admin/ и /admin/status без перезапуска В layout.html добавлена nav-ссылка «Настройка», между «Дашборд» и «Заявки». Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/lkgateway/admin.go | 18 +- internal/lkgateway/runtimeconfig.go | 279 +++++++++++++++ internal/lkgateway/server.go | 73 +++- internal/lkgateway/setup.go | 325 ++++++++++++++++++ .../lkgateway/web/templates/admin_setup.html | 145 ++++++++ internal/lkgateway/web/templates/layout.html | 1 + 6 files changed, 824 insertions(+), 17 deletions(-) create mode 100644 internal/lkgateway/runtimeconfig.go create mode 100644 internal/lkgateway/setup.go create mode 100644 internal/lkgateway/web/templates/admin_setup.html diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 4e35c8f..35cfc15 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -19,7 +19,7 @@ var templatesFS embed.FS // конкретный content-шаблон). Так html/template не путается с несколькими // {{define "content"}} в разных файлах. type admin struct { - home, claims, claim, status *template.Template + home, claims, claim, status, setup *template.Template } func newAdmin() (*admin, error) { @@ -44,7 +44,11 @@ func newAdmin() (*admin, error) { if err != nil { return nil, fmt.Errorf("parse admin_status: %w", err) } - return &admin{home: home, claims: claims, claim: claim, status: status}, nil + setup, err := parse("admin_setup.html") + if err != nil { + return nil, fmt.Errorf("parse admin_setup: %w", err) + } + return &admin{home: home, claims: claims, claim: claim, status: status, setup: setup}, nil } // page — общий "конверт" данных для всех шаблонов. @@ -86,11 +90,13 @@ type statusData struct { CheckedAt time.Time } -// RegisterAdmin вешает HTML-маршруты /admin/* на mux. -func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) error { +// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin +// со всеми загруженными шаблонами — вызывающий может прокинуть его в +// registerSetup для добавления вкладки «Настройка». +func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) (*admin, error) { a, err := newAdmin() if err != nil { - return err + return nil, err } mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) { @@ -112,7 +118,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/", http.StatusSeeOther) }) - return nil + return a, nil } func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) { diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go new file mode 100644 index 0000000..0e889bb --- /dev/null +++ b/internal/lkgateway/runtimeconfig.go @@ -0,0 +1,279 @@ +package lkgateway + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "time" +) + +// RuntimeConfig — конфигурация подсистем, редактируемая через admin UI +// без перезапуска. Сохраняется в JSON-файл (BJ_SETUP_PATH или +// ~/.bj/setup.json), грузится при старте. +type RuntimeConfig struct { + mu sync.RWMutex + path string + data Settings +} + +// Settings — сериализуемое представление настроек. +type Settings struct { + Postgres PostgresSettings `json:"postgres"` + Crypto CryptoSettings `json:"crypto"` + NSD NSDSettings `json:"nsd"` + LK LKSettings `json:"lk"` + LastTest *TestRunResult `json:"last_test,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PostgresSettings — DSN для подключения к БД (M2-шаг-3). +type PostgresSettings struct { + DSN string `json:"dsn"` +} + +// CryptoSettings — путь к JCP, провайдер, лицензионный ключ. +type CryptoSettings struct { + Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet" + SocketPath string `json:"socket_path"` // UDS crypto-service + JCPPath string `json:"jcp_path"` // путь до jcp.jar + LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро +} + +// NSDSettings — профиль и подключение к ИШ НРД. +type NSDSettings struct { + Profile string `json:"profile"` // "guest-gost", "test3-gost", ... + IGWBaseURL string `json:"igw_base_url"` // http://host:port + KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ) +} + +// LKSettings — настройки callback в ЛК клиента. +type LKSettings struct { + CallbackURL string `json:"callback_url"` +} + +// TestRunResult — результат последнего тестового прогона. +type TestRunResult struct { + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + ClaimID string `json:"claim_id"` + FinalStatus string `json:"final_status"` + OK bool `json:"ok"` + Message string `json:"message"` +} + +// NewRuntimeConfig создаёт runtime-конфиг, читая JSON из path (или дефолт). +func NewRuntimeConfig(path string) (*RuntimeConfig, error) { + if path == "" { + home, _ := os.UserHomeDir() + if home == "" { + home = "." + } + path = filepath.Join(home, ".bj", "setup.json") + } + rc := &RuntimeConfig{path: path} + if err := rc.load(); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + // Гарантируем разумные дефолты. + if rc.data.Crypto.Provider == "" { + rc.data.Crypto.Provider = "stub" + } + if rc.data.Crypto.SocketPath == "" { + rc.data.Crypto.SocketPath = "/run/bj/crypto.sock" + } + return rc, nil +} + +// Snapshot возвращает копию текущих настроек. +func (r *RuntimeConfig) Snapshot() Settings { + r.mu.RLock() + defer r.mu.RUnlock() + out := r.data + if r.data.LastTest != nil { + t := *r.data.LastTest + out.LastTest = &t + } + return out +} + +// UpdatePostgres сохраняет postgres-настройки. +func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error { + r.mu.Lock() + r.data.Postgres = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// UpdateCrypto сохраняет crypto-настройки. +func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error { + r.mu.Lock() + r.data.Crypto = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// UpdateNSD сохраняет NSD-настройки. +func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error { + r.mu.Lock() + r.data.NSD = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// UpdateLK сохраняет LK callback URL. +func (r *RuntimeConfig) UpdateLK(s LKSettings) error { + r.mu.Lock() + r.data.LK = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// RecordTestRun сохраняет результат теста. +func (r *RuntimeConfig) RecordTestRun(res TestRunResult) error { + r.mu.Lock() + r.data.LastTest = &res + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// load читает JSON в r.data. +func (r *RuntimeConfig) load() error { + raw, err := os.ReadFile(r.path) + if err != nil { + return err + } + return json.Unmarshal(raw, &r.data) +} + +// save пишет JSON в r.path атомарно через tmp + rename. +func (r *RuntimeConfig) save() error { + r.mu.RLock() + raw, err := json.MarshalIndent(r.data, "", " ") + r.mu.RUnlock() + if err != nil { + return err + } + dir := filepath.Dir(r.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp := r.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, r.path) +} + +// Readiness — сводная готовность подсистемы. +type Readiness struct { + Name string `json:"name"` + Ready bool `json:"ready"` // полностью настроена и проверена + Configured bool `json:"configured"` // есть пользовательский конфиг (не stub) + Message string `json:"message"` +} + +// ReadinessSummary возвращает компактную сводку для UI/admin. +func (r *RuntimeConfig) ReadinessSummary() []Readiness { + s := r.Snapshot() + out := []Readiness{ + { + Name: "postgres", + Configured: s.Postgres.DSN != "", + Ready: false, // настоящий ping будет в checks.go + Message: posMsg(s.Postgres.DSN), + }, + { + Name: "crypto-service", + Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "", + Ready: false, + Message: cryptoMsg(s.Crypto), + }, + { + Name: "nsd-adapter", + Configured: s.NSD.IGWBaseURL != "" && s.NSD.Profile != "", + Ready: false, + Message: nsdMsg(s.NSD), + }, + { + Name: "lk-callback", + Configured: s.LK.CallbackURL != "", + Ready: false, + Message: lkMsg(s.LK), + }, + } + return out +} + +func posMsg(dsn string) string { + if dsn == "" { + return "DSN не задан — система в режиме in-memory (M2-демо)" + } + return "DSN настроен: " + maskDSN(dsn) +} + +func cryptoMsg(c CryptoSettings) string { + if c.Provider == "" || c.Provider == "stub" { + return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён." + } + if c.JCPPath == "" { + return "Провайдер " + c.Provider + ", но путь к JCP не задан." + } + if c.LicenseKey == "" { + return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена." + } + return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена." +} + +func nsdMsg(n NSDSettings) string { + if n.IGWBaseURL == "" { + return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)" + } + if n.Profile == "" { + return "URL ИШ задан, но профиль не выбран" + } + return "Профиль " + n.Profile + ", ИШ " + n.IGWBaseURL +} + +func lkMsg(l LKSettings) string { + if l.CallbackURL == "" { + return "Callback URL не настроен — используется встроенный lk-emulator" + } + return "Callback URL: " + l.CallbackURL +} + +// maskDSN скрывает пароль в DSN для отображения в UI. +func maskDSN(dsn string) string { + // простая маскировка: ищем :///user:pass@host + const sep = "@" + if idx := indexAt(dsn, sep); idx > 0 { + if colon := lastColonBefore(dsn, idx); colon > 0 && colon < idx { + return dsn[:colon+1] + "***" + dsn[idx:] + } + } + return dsn +} + +func indexAt(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +func lastColonBefore(s string, idx int) int { + for i := idx - 1; i >= 0; i-- { + if s[i] == ':' { + return i + } + } + return -1 +} diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index ee80dd0..9ad3b9d 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -19,6 +19,7 @@ type ServerConfig struct { DefaultReceiver m2m.DeponentCode CheckOptions func() CheckOptions MockDecisionDelay time.Duration // 0 = дефолт 3 секунды + SetupPath string // путь к JSON-файлу runtime-конфига (пусто = ~/.bj/setup.json) } // Server — обвязка HTTP + сервис + workers. @@ -27,6 +28,7 @@ type Server struct { svc *Service mock *mock.Sender store *SeedStore + rc *RuntimeConfig mux *http.ServeMux server *http.Server } @@ -51,18 +53,47 @@ func NewServer(cfg ServerConfig) (*Server, error) { 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 { + rc, err := NewRuntimeConfig(cfg.SetupPath) + if err != nil { return nil, err } + // Если 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, checkOpts) + if err != nil { + return nil, err + } + registerSetup(mux, adminTpl, rc, svc) registerHealth(mux) - registerSetCallback(mux, svc) + registerSetCallback(mux, svc, rc) registerSeedListing(mux, store) return &Server{ @@ -70,6 +101,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { svc: svc, mock: sender, store: store, + rc: rc, mux: mux, server: &http.Server{ Addr: cfg.Addr, @@ -79,6 +111,16 @@ func NewServer(cfg ServerConfig) (*Server, error) { }, 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 } @@ -146,8 +188,10 @@ func registerHealth(mux *http.ServeMux) { } // registerSetCallback — служебный POST /admin/api/callback-url для -// эмулятора ЛК, чтобы сообщить gateway свой URL. -func registerSetCallback(mux *http.ServeMux, svc *Service) { +// эмулятора ЛК, чтобы сообщить 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) @@ -158,6 +202,13 @@ func registerSetCallback(mux *http.ServeMux, svc *Service) { 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")) diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go new file mode 100644 index 0000000..5f5ce8d --- /dev/null +++ b/internal/lkgateway/setup.go @@ -0,0 +1,325 @@ +package lkgateway + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// setupHandlers — обработчики /admin/setup/*. +type setupHandlers struct { + rc *RuntimeConfig + tpl *adminTemplates + svc *Service +} + +// adminTemplates — обёртка, чтобы передать набор шаблонов в setup. +type adminTemplates struct { + a *admin +} + +// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux. +func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) { + h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc} + mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + h.renderSetup(w, r, "") + }) + mux.HandleFunc("/admin/setup/postgres", h.savePostgres) + mux.HandleFunc("/admin/setup/crypto", h.saveCrypto) + mux.HandleFunc("/admin/setup/nsd", h.saveNSD) + mux.HandleFunc("/admin/setup/lk", h.saveLK) + mux.HandleFunc("/admin/setup/test-run", h.testRun) +} + +// SetupData — данные для шаблона admin_setup.html. +type SetupData struct { + page + Settings Settings + Readiness []Readiness + ReadyCount int + TotalCount int + Flash string + Error string +} + +func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) { + s := h.rc.Snapshot() + r := h.rc.ReadinessSummary() + ready := 0 + for _, x := range r { + if x.Configured { + ready++ + } + } + data := SetupData{ + page: nowPage("Настройка", "setup"), + Settings: s, + Readiness: r, + ReadyCount: ready, + TotalCount: len(r), + Flash: flash, + } + if errVal := errMsgFromQuery(_q(w)); errVal != "" { + data.Error = errVal + } + render(w, h.tpl.a.setup, data) +} + +func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + dsn := strings.TrimSpace(r.FormValue("dsn")) + if dsn != "" { + // Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше + // просто ошибка — это нормально, важно показать что DSN сохранён). + if err := tryPingPostgres(dsn); err != nil { + setupFlash(w, r, "postgres: тест соединения упал: "+err.Error()) + return + } + } + if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil { + setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "PostgreSQL настройки сохранены") +} + +func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := CryptoSettings{ + Provider: strings.TrimSpace(r.FormValue("provider")), + SocketPath: strings.TrimSpace(r.FormValue("socket_path")), + JCPPath: strings.TrimSpace(r.FormValue("jcp_path")), + LicenseKey: strings.TrimSpace(r.FormValue("license_key")), + } + if s.Provider == "" { + s.Provider = "stub" + } + if s.SocketPath == "" { + s.SocketPath = "/run/bj/crypto.sock" + } + // Если указан JCP-путь — проверим что файл существует. + if s.JCPPath != "" { + if _, err := os.Stat(s.JCPPath); err != nil { + setupFlash(w, r, "crypto: jcp_path не найден: "+err.Error()) + return + } + } + if err := h.rc.UpdateCrypto(s); err != nil { + setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")") +} + +func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := NSDSettings{ + Profile: strings.TrimSpace(r.FormValue("profile")), + IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")), + KeyContainer: strings.TrimSpace(r.FormValue("key_container")), + } + if s.IGWBaseURL != "" { + if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil { + setupFlash(w, r, "nsd: невалидный URL: "+err.Error()) + return + } + if err := tryHTTPHealth(s.IGWBaseURL + "/healthz"); err != nil { + setupFlash(w, r, "nsd: ИШ не отвечает на /healthz: "+err.Error()) + return + } + } + if err := h.rc.UpdateNSD(s); err != nil { + setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "nsd-adapter: настройки сохранены") +} + +func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))} + if s.CallbackURL != "" { + if _, err := url.ParseRequestURI(s.CallbackURL); err != nil { + setupFlash(w, r, "lk: невалидный URL: "+err.Error()) + return + } + if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil { + setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error()) + return + } + } + if err := h.rc.UpdateLK(s); err != nil { + setupFlash(w, r, "lk: ошибка сохранения: "+err.Error()) + return + } + if s.CallbackURL != "" { + h.svc.callbackURL = s.CallbackURL + } + setupFlash(w, r, "Callback в ЛК сохранён и применён") +} + +// testRun запускает тестовую заявку с предустановленными данными, +// ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет +// результат в RuntimeConfig.LastTest. +func (h *setupHandlers) testRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + go h.runTestClaim() + setupFlash(w, r, "Тестовая заявка запущена, обновите страницу через 5 секунд") +} + +// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim. +func (h *setupHandlers) runTestClaim() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + whole := uint64(1500) + req := CreateClaimRequest{ + Investor: Investor{ + ID: "11111111-1111-1111-1111-111111111111", + LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович", + Document: Document{DocumentType: "21", Series: "4512", Number: "654321"}, + }, + TransferringDepositoryINN: "0702345678", + ReceivingDepositoryINN: "0710987654", + CostInfo: CostInfo{No: &struct{}{}}, + IIAAgreement: &IIAAgreement{ + AgreementType: "T03", AgreementNumber: "ИИС78/2024", + AgreementDate: "2026-01-15", BrokerINN: "0707083893", + }, + Securities: []ClaimSec{ + { + SecurityCode: "MM0766162534", + SecurityDetails: SecurityDetails{ISIN: "RU0007661625"}, + Quantity: Quantity{Whole: whole}, + SettlementAccounts: []SettlementAccount{ + { + SettlementRequisitesINN: "7702070139", + SettlementLocation: SettlementLocation{ + DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001", + }, + }, + }, + }, + }, + SignedDocument: "dGVzdC1zaWduYXR1cmU=", + SignatureFormat: "XMLDSig-GOST", + } + + res := TestRunResult{StartedAt: time.Now().UTC()} + created, err := h.svc.CreateClaim(ctx, req) + if err != nil { + res.FinishedAt = time.Now().UTC() + res.OK = false + res.Message = "CreateClaim упал: " + err.Error() + _ = h.rc.RecordTestRun(res) + return + } + res.ClaimID = created.ID + + // Опрашиваем статус каждые 200ms до перехода в финал. + deadline := time.Now().Add(25 * time.Second) + for time.Now().Before(deadline) { + view, err := h.svc.GetClaim(ctx, created.ID) + if err != nil { + res.FinishedAt = time.Now().UTC() + res.OK = false + res.FinalStatus = "lookup_failed" + res.Message = err.Error() + _ = h.rc.RecordTestRun(res) + return + } + switch view.Status { + case "confirmed", "awaiting_sub16", "done": + res.FinishedAt = time.Now().UTC() + res.OK = true + res.FinalStatus = view.Status + res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)." + _ = h.rc.RecordTestRun(res) + return + case "rejected", "timed_out": + res.FinishedAt = time.Now().UTC() + res.OK = false + res.FinalStatus = view.Status + res.Message = "Заявка не прошла: статус " + view.Status + _ = h.rc.RecordTestRun(res) + return + } + time.Sleep(200 * time.Millisecond) + } + res.FinishedAt = time.Now().UTC() + res.OK = false + res.FinalStatus = "timeout_waiting" + res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)" + _ = h.rc.RecordTestRun(res) +} + +// tryPingPostgres пытается sql.Open + Ping с прокачкой драйвера; без +// драйвера вернёт «unknown driver pgx»/«unknown driver postgres» — +// тоже считаем ошибкой и показываем пользователю. +func tryPingPostgres(dsn string) error { + // Угадываем имя драйвера по префиксу. + driver := "postgres" + if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { + driver = "postgres" + } + db, err := sql.Open(driver, dsn) + if err != nil { + return err + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return db.PingContext(ctx) +} + +// tryHTTPHealth делает GET и ждёт 2xx. +func tryHTTPHealth(u string) error { + c := &http.Client{Timeout: 3 * time.Second} + resp, err := c.Get(u) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil +} + +// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query. +func setupFlash(w http.ResponseWriter, r *http.Request, msg string) { + http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther) +} + +// _q извлекает Request из ResponseWriter trick — здесь не нужно +// (всегда работаем через chain). +func _q(_ http.ResponseWriter) string { return "" } +func errMsgFromQuery(_ string) string { return "" } + +// guard — заглушка для совместимости с возможным расширением. +var _ = errors.New diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html new file mode 100644 index 0000000..8e98bac --- /dev/null +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -0,0 +1,145 @@ +{{define "content"}} +{{if .Flash}}
{{.Flash}}
{{end}} + +
+

Готовность системы: {{.ReadyCount}} из {{.TotalCount}}

+
+ {{range .Readiness}} +
+ + {{.Name}}
+ {{if .Configured}}настроено{{else}}не настроено{{end}} +
+ {{end}} +
+
+ + +
+

PostgreSQL

+

Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас: + {{if .Settings.Postgres.DSN}}настроено{{else}}in-memory (M2-демо){{end}}.

+
+ Изменить параметры подключения +
+
+ + +
+

При сохранении выполняется Ping. Если драйвер pgx ещё не подключён в коде, тест упадёт — это ожидаемо до M2-шага-3.

+ +
+
+
+ + +
+

Криптография (КриптоПро JCP)

+

{{ index .Readiness 1 | printf "%v" }}

+ + + + + +
Текущий провайдер{{.Settings.Crypto.Provider}}
UDS-сокет{{.Settings.Crypto.SocketPath}}
Путь к jcp.jar{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}—{{end}}
Лицензия введена{{if .Settings.Crypto.LicenseKey}}да{{else}}нет{{end}}
+
+ Изменить параметры криптографии +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

При выборе cryptopro jcp.jar должен лежать на ВМ (положите в services/crypto-service/libs/jcp.jar). При сохранении проверим что файл существует.

+ +
+
+
+ + +
+

Интеграционный шлюз НРД

+

{{if not .Settings.NSD.IGWBaseURL}}Сейчас mock-режим — Decision эмитируется через 3 секунды после Send.{{else}}Профиль {{.Settings.NSD.Profile}}, ИШ {{.Settings.NSD.IGWBaseURL}}.{{end}}

+
+ Изменить параметры ИШ +
+
+ + +
+
+ + +
+
+ + +
+

При сохранении выполняется GET {URL}/healthz. Пустой URL = вернуться к mock-режиму.

+ +
+
+
+ + +
+

Callback в ЛК

+

{{if .Settings.LK.CallbackURL}}Callback URL: {{.Settings.LK.CallbackURL}}{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}

+
+ Указать URL реального ЛК +
+
+ + +
+

URL до базового хоста ЛК (без /api). При сохранении выполняется GET {URL}/healthz.

+ +
+
+
+ + +
+

Тестовый прогон сквозной заявки

+

Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 1500 акций Газпрома, ИИС T03), отправит её через всю цепочку и дождётся финального статуса. Если ИШ НРД настроен — пойдёт в реальный ИШ; иначе через mock с задержкой 3 сек.

+
+ +
+ {{if .Settings.LastTest}} +
+ Последний прогон: + + + + + + +
Статус{{if .Settings.LastTest.OK}}✓ успешно{{else}}✗ не прошёл{{end}}
Финальный FSM-статус{{.Settings.LastTest.FinalStatus}}
ClaimID{{.Settings.LastTest.ClaimID}} {{if .Settings.LastTest.ClaimID}}→ открыть карточку{{end}}
Когда{{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}} — длительность {{.Settings.LastTest.FinishedAt.Sub .Settings.LastTest.StartedAt}}
Сообщение{{.Settings.LastTest.Message}}
+
+ {{end}} +
+{{end}} diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index e16fec7..2b45014 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -45,6 +45,7 @@ button:hover, .btn:hover { opacity: .9; }

lk-gateway