package lkgateway import ( "context" "errors" "fmt" "net/http" "net/url" "os" "strings" "time" "github.com/jackc/pgx/v5" ) // 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 делает короткое подключение через pgx и Ping. func tryPingPostgres(dsn string) error { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() conn, err := pgx.Connect(ctx, dsn) if err != nil { return err } defer conn.Close(ctx) return conn.Ping(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