Files
Bridge-and-Join-s/internal/lkgateway/setup.go
T
fontvielle cb0f7efd4c feat(admin): мастер настройки /admin/wizard + авто-подъём PostgreSQL одной кнопкой
Для пользователя без IT-навыков — пошаговая настройка (5 шагов) с
прогресс-баром, подсказками «?» рядом с каждым полем и блоками
«Что это?» / «Где взять?» в каждом шаге. Шаги: PostgreSQL → КриптоПро →
Сертификаты → ИШ НРД → Тестовая заявка. Авто-определение текущего шага
по первому незавершённому пункту, навигация Назад/Далее, мягкие пропуски
(in-memory / mock-режимы).

В шаге 1 — « Поднять локальный PostgreSQL автоматически»: одна кнопка
запускает podman-compose, ждёт pg_isready, накатывает миграции
fansy-store + m2m-core, сохраняет DSN в runtime-конфиг. setupFlash теперь
возвращает пользователя на /admin/wizard, если POST пришёл оттуда —
визард не «теряется» после действий.

Mastered tasks: #41, #42, #43.
2026-05-14 15:46:31 +03:00

768 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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