diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index d1379a7..2bdbedb 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -21,6 +21,7 @@ var templatesFS embed.FS type admin struct { home, claims, claim, status, setup *template.Template help, helpDatabase, helpLK, helpCryptoPro, helpSystems *template.Template + wizard *template.Template } // templateFuncs — функции, доступные внутри шаблонов. Главная задача — @@ -121,9 +122,14 @@ func newAdmin() (*admin, error) { if err != nil { return nil, fmt.Errorf("parse admin_help_systems: %w", err) } + wizard, err := parse("admin_wizard.html") + if err != nil { + return nil, fmt.Errorf("parse admin_wizard: %w", err) + } return &admin{ home: home, claims: claims, claim: claim, status: status, setup: setup, help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys, + wizard: wizard, }, nil } diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 6d04442..17d954b 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -59,6 +60,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service 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) @@ -67,6 +69,90 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service 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). @@ -365,6 +451,88 @@ func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) { 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) @@ -573,9 +741,21 @@ func tryHTTPHealth(u string) error { return nil } -// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query. +// setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл +// со страницы мастера (/admin/wizard), возвращаем туда же с сохранением +// номера шага — пользователь не должен «выпадать» из визарда после POST. func setupFlash(w http.ResponseWriter, r *http.Request, msg string) { - http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther) + 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 — здесь не нужно diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 01c9839..1a247f7 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -19,14 +19,26 @@
Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
{{if .Settings.Postgres.DSN}}настроено{{else}}in-memory (M2-демо){{end}}.
Если у вас ещё нет своего PostgreSQL, мы поднимем его сами в контейнере (podman-compose), применим все миграции и запишем DSN. Подходит для дев-стенда и тестирования. Для прода — лучше указать свой DSN ниже.
+ +Пошаговая настройка системы. Подходит для первого запуска. После каждого шага состояние сохраняется и можно вернуться позже.
+{{.Flash}}
Сюда система пишет журнал сделок и принимает данные от команды Fansy.
+ +Bridge-and-Join-s сам поднимет PostgreSQL в контейнере (podman-compose), создаст БД bj и накатит миграции. Подходит для дев-стенда. Для продакшена лучше указать свой DSN.
Пропустить (буду работать в режиме in-memory — без сохранения сделок)
{{end}} +СКЗИ нужен для подписи XMLDSig и проверки квитанций НРД.
+ +Скачайте с cryptopro.ru архив linux-amd64.tgz или linux-amd64.tar (КриптоПро CSP 5.0 R4 для Linux) и загрузите его сюда. Bj-server сам распакует и установит нужные пакеты.
✓ КриптоПро CSP установлен. Версия: {{.CryptoProVersion}}
Импортируйте сертификаты вашей организации и сертификаты УЦ НРД (для проверки квитанций).
+ +.pfx / .p12 на диске или контейнер на Рутокене) — для подписи отправляемых пакетов..cer) — в хранилище mroot.| Владелец | Издатель | Действителен до | ИНН | Ключ |
|---|---|---|---|---|
| {{.SubjectCN}} | +{{.IssuerCN}} | +{{.NotAfter.Format "02.01.2006"}} | +{{if .INN}}{{.INN}}{{else}}—{{end}} |
+ {{if .HasPrivateKey}}есть{{else}}нет{{end}} | +
Пока сертификаты не импортированы.
+ {{end}} + +Адрес web-сервиса ONYX и имя ключевого контейнера НРД.
+ +DOC/instr_podkl_stend_v3.pdf).
+ Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.
+ +| Заявка | {{.Settings.LastTest.ClaimID}} |
| Финальное состояние | {{ruState .Settings.LastTest.FinalStatus}} |
| Результат | {{if .Settings.LastTest.OK}}успех{{else}}ошибка{{end}} |
| Сообщение | {{.Settings.LastTest.Message}} |
| PostgreSQL | {{if .Done.Postgres}}настроен{{else}}in-memory{{end}} |
| Крипто-провайдер | {{if .Done.Crypto}}{{.Settings.Crypto.Provider}}{{else}}не настроен{{end}} |
| Сертификатов установлено | {{len .Certs}} |
| ИШ НРД | {{if .Done.NSD}}{{.Settings.NSD.Profile}}{{else}}mock-режим{{end}} |
| Тестовый прогон | {{if .Done.TestRun}}пройден{{else}}не запускался{{end}} |