package lkgateway import ( "context" "errors" "fmt" "io" "log" "net/http" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" ) // crypto-сертификаты на текущих токенах (для отображения на странице). func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate { s := h.rc.Snapshot() if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" || s.Crypto.JCPPath == "" { return nil } cli := cryptocli.New(cryptocli.Config{ Provider: cryptocli.Provider(s.Crypto.Provider), ModulePath: s.Crypto.JCPPath, }) defer cli.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() certs, _ := cli.FindCertificates(ctx) return certs } // 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/postgres/quick-start", h.quickStartPostgres) mux.HandleFunc("/admin/setup/crypto", h.saveCrypto) mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto) mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense) mux.HandleFunc("/admin/setup/crypto/install", h.installCryptoPro) mux.HandleFunc("/admin/setup/crypto/import-cert", h.importCertificate) mux.HandleFunc("/admin/setup/nsd", h.saveNSD) mux.HandleFunc("/admin/setup/lk", h.saveLK) mux.HandleFunc("/admin/setup/test-run", h.testRun) // Авто-загрузка сертификатов УЦ НРД и нашего УЦ. mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts) mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow) // Пошаговый мастер настройки для нетехнических пользователей. mux.HandleFunc("/admin/wizard", h.renderWizard) } // WizardData — данные для шаблона /admin/wizard. type WizardData struct { page Step int Settings Settings Certs []cryptocli.Certificate Flash string CryptoProInstalled bool CryptoProVersion string Done struct { Postgres bool Crypto bool Certs bool NSD bool TestRun bool } } // renderWizard рисует одну из 5 страниц мастера. Шаг управляется query // параметром ?step=N (1..5). По умолчанию шаг определяется автоматически // по первому незавершённому пункту — это даёт «продолжить с того места». func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := h.rc.Snapshot() d := WizardData{ page: nowPage("Мастер настройки", "wizard"), Settings: s, Certs: h.listCertsForUI(), Flash: r.URL.Query().Get("flash"), } d.Done.Postgres = s.Postgres.DSN != "" d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "" d.Done.Certs = len(d.Certs) > 0 d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" d.Done.TestRun = s.LastTest != nil // Проверяем установлен ли КриптоПро CSP. if _, err := os.Stat("/opt/cprocsp/sbin/amd64/cpconfig"); err == nil { d.CryptoProInstalled = true ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() if ver, _ := runCmd(ctx, "/opt/cprocsp/sbin/amd64/cpconfig", "-license", "-view"); ver != "" { d.CryptoProVersion = firstLine(ver) } } // Определяем текущий шаг. step := 1 if v := r.URL.Query().Get("step"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 5 { step = n } } else { // Авто: первый незавершённый. switch { case !d.Done.Postgres: step = 1 case !d.Done.Crypto: step = 2 case !d.Done.Certs: step = 3 case !d.Done.NSD: step = 4 default: step = 5 } } d.Step = step render(w, h.tpl.a.wizard, d) } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return strings.TrimSpace(s[:i]) } return strings.TrimSpace(s) } // installCryptoPro — POST /admin/setup/crypto/install (multipart). // Принимает tar или tar.gz архив с дистрибутивом КриптоПро CSP (как // linux-amd64.tgz с cryptopro.ru), распаковывает в /tmp/bj-cryptopro, // находит все .rpm файлы и устанавливает через sudo rpm -i. // На РЕД ОС / ALT / ROSA это даёт рабочий /opt/cprocsp/. func (h *setupHandlers) installCryptoPro(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } // Архив КриптоПро ~50-100 МБ — поднимем лимит до 256 МБ. if err := r.ParseMultipartForm(256 << 20); err != nil { setupFlash(w, r, "Установка: ошибка чтения формы: "+err.Error()) return } file, header, err := r.FormFile("dist") if err != nil { setupFlash(w, r, "Установка: выберите файл архива дистрибутива (.tar/.tgz/.tar.gz/.rpm)") return } defer file.Close() dir := "/tmp/bj-cryptopro" _ = os.RemoveAll(dir) if err := os.MkdirAll(dir, 0o755); err != nil { setupFlash(w, r, "Установка: не получилось создать "+dir+": "+err.Error()) return } dst := filepath.Join(dir, filepath.Base(header.Filename)) out, err := os.Create(dst) if err != nil { setupFlash(w, r, "Установка: не получилось создать "+dst+": "+err.Error()) return } if _, err := io.Copy(out, file); err != nil { out.Close() setupFlash(w, r, "Установка: ошибка записи файла: "+err.Error()) return } out.Close() // Распаковка (если .tar/.tgz/.tar.gz). ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() lower := strings.ToLower(dst) switch { case strings.HasSuffix(lower, ".rpm"): // Один rpm — установим напрямую без распаковки. case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"): if untar, err := runCmdInDir(ctx, dir, "tar", "-xzf", dst); err != nil { setupFlash(w, r, "Установка: распаковка .tgz упала: "+err.Error()+" / вывод: "+untar) return } case strings.HasSuffix(lower, ".tar"): if untar, err := runCmdInDir(ctx, dir, "tar", "-xf", dst); err != nil { setupFlash(w, r, "Установка: распаковка .tar упала: "+err.Error()+" / вывод: "+untar) return } default: setupFlash(w, r, "Установка: неизвестный формат файла, нужен .tar/.tgz/.tar.gz/.rpm") return } // Найти все .rpm в директории. var rpms []string _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() && strings.HasSuffix(p, ".rpm") { rpms = append(rpms, p) } return nil }) if len(rpms) == 0 { setupFlash(w, r, "Установка: после распаковки .rpm файлы не найдены в "+dir) return } // sudo rpm -i <все rpm>. На РЕД ОС иногда нужен --nosignature --nodeps. args := append([]string{"rpm", "-Uvh", "--replacepkgs", "--nosignature"}, rpms...) output, err := runCmd(ctx, "sudo", args...) if err != nil { setupFlash(w, r, "Установка: rpm -i упал: "+err.Error()+" / вывод: "+strings.TrimSpace(output)) return } setupFlash(w, r, "КриптоПро CSP установлен. Файлов rpm: "+fmt.Sprint(len(rpms))+". Теперь введите серийник и нажмите «Активировать лицензию». Вывод rpm: "+strings.TrimSpace(output)) } // importCertificate — POST /admin/setup/crypto/import-cert (multipart). // Принимает .pfx (PKCS#12 — приватный ключ + сертификат + опц. PIN) или // .cer/.crt (только публичный сертификат). Импортирует через certmgr // КриптоПро. Сертификат добавляется в хранилище uMy (либо mroot для // корневых). func (h *setupHandlers) importCertificate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } if err := r.ParseMultipartForm(64 << 20); err != nil { setupFlash(w, r, "Импорт сертификата: ошибка чтения формы: "+err.Error()) return } file, header, err := r.FormFile("cert") if err != nil { setupFlash(w, r, "Импорт сертификата: выберите файл .pfx/.cer/.crt") return } defer file.Close() pin := strings.TrimSpace(r.FormValue("pin")) store := strings.TrimSpace(r.FormValue("store")) // "uMy" по умолчанию, "mroot" для корневых dir := "/tmp/bj-certs" _ = os.MkdirAll(dir, 0o755) safeName := filepath.Base(header.Filename) dst := filepath.Join(dir, safeName) out, err := os.Create(dst) if err != nil { setupFlash(w, r, "Импорт сертификата: не получилось создать "+dst+": "+err.Error()) return } if _, err := io.Copy(out, file); err != nil { out.Close() setupFlash(w, r, "Импорт сертификата: ошибка записи: "+err.Error()) return } out.Close() certmgr := "/opt/cprocsp/bin/amd64/certmgr" if _, err := os.Stat(certmgr); err != nil { setupFlash(w, r, "Импорт сертификата: certmgr не найден. Сначала установите КриптоПро CSP.") return } if store == "" { store = "uMy" } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() lower := strings.ToLower(safeName) var cmdOut string switch { case strings.HasSuffix(lower, ".pfx") || strings.HasSuffix(lower, ".p12"): // PKCS#12 — импорт через certmgr -inst с PIN args := []string{"-inst", "-pfx", "-file", dst, "-store", store} if pin != "" { args = append(args, "-pin", pin) } cmdOut, err = runCmd(ctx, certmgr, args...) case strings.HasSuffix(lower, ".cer") || strings.HasSuffix(lower, ".crt"): // Голый сертификат — импорт в хранилище без приватного ключа cmdOut, err = runCmd(ctx, certmgr, "-inst", "-file", dst, "-store", store) default: setupFlash(w, r, "Импорт сертификата: неизвестное расширение, нужен .pfx/.p12/.cer/.crt") return } if err != nil { setupFlash(w, r, "Импорт сертификата: certmgr упал: "+err.Error()+" / вывод: "+strings.TrimSpace(cmdOut)) return } setupFlash(w, r, "Сертификат «"+safeName+"» импортирован в хранилище "+store+". Вывод certmgr: "+strings.TrimSpace(cmdOut)) } // runCmdInDir выполняет команду в указанной рабочей директории. func runCmdInDir(ctx context.Context, dir, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() return string(out), err } // runCmd выполняет команду и возвращает stdout+stderr строкой. func runCmd(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) out, err := cmd.CombinedOutput() return string(out), err } // activateLicense — POST /admin/setup/crypto/activate. Принимает серийный // номер из формы, вызывает cpconfig -license -set, возвращает результат // во flash. Если КриптоПро CSP не установлен — даёт ссылку на инструкцию. func (h *setupHandlers) activateLicense(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } serial := strings.TrimSpace(r.FormValue("license_key")) if serial == "" { setupFlash(w, r, "Активация лицензии: введите серийный номер в поле выше") return } cpconfig := "/opt/cprocsp/sbin/amd64/cpconfig" if _, err := os.Stat(cpconfig); err != nil { setupFlash(w, r, "КриптоПро CSP не установлен ("+cpconfig+" не найден). Раздел /admin/help/cryptopro — команды установки и копирования дистрибутива на ВМ.") return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() out, err := runCmd(ctx, cpconfig, "-license", "-set", serial) if err != nil { setupFlash(w, r, "Активация лицензии не прошла: "+err.Error()+" / вывод: "+strings.TrimSpace(out)) return } cur := h.rc.Snapshot().Crypto cur.LicenseKey = serial if err := h.rc.UpdateCrypto(cur); err != nil { log.Printf("activateLicense: UpdateCrypto: %v", err) } setupFlash(w, r, "Лицензия КриптоПро активирована. Вывод cpconfig: "+strings.TrimSpace(out)) } // checkCrypto — POST /admin/setup/crypto/check. Запускает Health() // текущего провайдера PKCS#11 без изменения настроек. func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := h.rc.Snapshot() cli := cryptocli.New(cryptocli.Config{ Provider: cryptocli.Provider(s.Crypto.Provider), ModulePath: s.Crypto.JCPPath, // унаследовано — теперь путь к PKCS#11 .so }) defer cli.Close() info, err := cli.Health(r.Context()) if err != nil { setupFlash(w, r, "СКЗИ: проверка не прошла — "+err.Error()) return } msg := fmt.Sprintf("СКЗИ %s: %s", info.Provider, info.Message) if info.CryptokiVersion != "" { msg += fmt.Sprintf(" (PKCS#11 v%s, %s)", info.CryptokiVersion, info.ManufacturerID) } if len(info.Tokens) > 0 { msg += ". Токены:" for _, t := range info.Tokens { msg += fmt.Sprintf(" «%s» (%s);", t.Label, t.Model) } } setupFlash(w, r, msg) } // SetupData — данные для шаблона admin_setup.html. type SetupData struct { page Settings Settings Readiness []Readiness ReadyCount int TotalCount int Certificates []cryptocli.Certificate 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), Certificates: h.listCertsForUI(), 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 настройки сохранены") } // quickStartPostgres — POST /admin/setup/postgres/quick-start. // «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает // локальный postgres-контейнер через podman-compose, ждёт pg_isready, // накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный // DSN в runtime-конфиге. После этого пользователю остаётся перезапустить // bj-server (или мы сделаем это автоматически в дальнейших версиях). func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() // 1. Поднимаем postgres контейнер через podman-compose. composePath := "deploy/docker-compose/docker-compose.yml" if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil { setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+ "Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+ "sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out)) return } // 2. Ждём pg_isready. dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable" deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { if err := tryPingPostgres(dsn); err == nil { break } time.Sleep(time.Second) } if err := tryPingPostgres(dsn); err != nil { setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error()) return } // 3. Применяем миграции через podman exec. migrations := []string{ "migrations/fansy-store/000__roles.sql", "migrations/fansy-store/001__schemas.sql", "migrations/fansy-store/002__working.sql", "migrations/fansy-store/003__staging.sql", "migrations/fansy-store/004__seed_participants.sql", "migrations/m2m-core/001__deals.sql", "migrations/m2m-core/002__stages.sql", } for _, mig := range migrations { if err := applyMigration(ctx, mig); err != nil { // Миграция могла быть уже применена ранее (например, ROLE уже // существует) — это не критично, продолжаем. log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err) } } // 4. Сохраняем DSN в runtime-конфиг. if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil { setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error()) return } setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+ "Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+ "чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.") } // applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres. func applyMigration(ctx context.Context, path string) error { data, err := os.ReadFile(path) if err != nil { return err } cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres", "psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1") cmd.Stdin = strings.NewReader(string(data)) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out))) } return nil } 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 с flash-сообщением в query. Если запрос пришёл // со страницы мастера (/admin/wizard), возвращаем туда же с сохранением // номера шага — пользователь не должен «выпадать» из визарда после POST. func setupFlash(w http.ResponseWriter, r *http.Request, msg string) { target := "/admin/setup" if ref := r.Header.Get("Referer"); ref != "" { if u, err := url.Parse(ref); err == nil && strings.HasPrefix(u.Path, "/admin/wizard") { q := u.Query() q.Set("flash", msg) target = u.Path + "?" + q.Encode() http.Redirect(w, r, target, http.StatusSeeOther) return } } http.Redirect(w, r, target+"?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