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}}
Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
+ {{if .Settings.Postgres.DSN}}настроено{{else}}in-memory (M2-демо){{end}}.
{{ 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}} |
{{if not .Settings.NSD.IGWBaseURL}}Сейчас mock-режим — Decision эмитируется через 3 секунды после Send.{{else}}Профиль {{.Settings.NSD.Profile}}, ИШ {{.Settings.NSD.IGWBaseURL}}.{{end}}
{{if .Settings.LK.CallbackURL}}Callback URL: {{.Settings.LK.CallbackURL}}{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}
Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 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}} |