From a9915bcc828f722ebb8d504f23f118ab2a2743b5 Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 6 Oct 2026 15:46:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=87=D1=91=D1=82=20DEPO:=20=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=88=D0=B0=D0=B3=D0=B0=D0=BC,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D1=8F=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=20=C2=AB=D0=9B=D0=BE=D0=B3=D0=B8=D0=BD=20bj=C2=BB,?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B5=D1=83=D0=B4=D0=B0=D1=87=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «Подготовить» / «Проверить подключение» показывают ход: подключение логином администратора, проверка, что это база DEPO, применение скрипта, вход логином bj, таблицы обмена, задание, процедуры, «Готово». Сервер отдаёт шаги строками JSON (stream=1), на ошибке — шаг с крестиком и причина, без перехода со страницы. - Блоки «Администратор SQL — нужен один раз, не сохраняется» и «Логин bj — под ним bj будет работать»: bj сохраняет и использует ровно то, что вписано; «другой пароль»; на итоге шага — каким логином bj работает. - Пауза 15 минут после неудачного входа в DEPO (логин/пароль отвергнуты) для фоновых служб: с неверным паролем они за минуты блокировали логин на сервере (CHECK_POLICY) у всех, кто им пользуется. Действия человека идут без паузы; новый пароль — сразу; системное событие в «Новостях». Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/depo_setup.go | 26 +++- internal/lkgateway/fancydb.go | 2 +- internal/lkgateway/fancydb_handlers.go | 10 +- internal/lkgateway/fancygate.go | 125 ++++++++++++++++++ internal/lkgateway/fancygate_test.go | 56 ++++++++ internal/lkgateway/onboarding_test.go | 12 ++ .../lkgateway/web/templates/admin_wizard.html | 108 +++++++++++++-- internal/lkgateway/wizard_depo.go | 109 ++++++++++----- 8 files changed, 395 insertions(+), 53 deletions(-) create mode 100644 internal/lkgateway/fancygate.go create mode 100644 internal/lkgateway/fancygate_test.go diff --git a/internal/lkgateway/depo_setup.go b/internal/lkgateway/depo_setup.go index 4d37dd1..c732b8a 100644 --- a/internal/lkgateway/depo_setup.go +++ b/internal/lkgateway/depo_setup.go @@ -192,19 +192,38 @@ func depoSetupFileName(f FancyMSSQLSettings) string { return "bj-depo-setup-mssql.sql" } +// depoStep — ход подготовки для экрана: key — шаг, state — run (начат) | ok | warn, text — что делается или итог. +type depoStep func(key, state, text string) + // depoSetupRun — «bj сделает сам»: тот же скрипт выполняется логином администратора (не сохраняется) в базе -// db, которую назвал клиент; сначала — проверка, что это база DEPO. -func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, adminPass, login, pass string, agentJob bool) error { +// db, которую назвал клиент; сначала — подключение и проверка, что это база DEPO. step — ход для экрана. +func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, adminPass, login, pass string, agentJob bool, step depoStep) error { adm := f adm.User, adm.Password = strings.TrimSpace(adminUser), adminPass if !validDBName(db) { return fmt.Errorf("укажите имя базы DEPO") } + step("connect", "run", "Подключаюсь к серверу "+f.Server+" логином администратора «"+adm.User+"»") + if _, err := fancyPing(ctx, adm, ""); err != nil { + return fmt.Errorf("к серверу %s логином %s подключиться не удалось — %s", f.Server, adm.User, quikErrorDetail(err)) + } + step("connect", "ok", "") + step("isdepo", "run", "Проверяю, что «"+db+"» — база учёта DEPO") if err := depoCheckIsDepo(ctx, adm, db); err != nil { return err } + step("isdepo", "ok", "") + what := "логин bj «" + login + "», таблицы обмена, права" + if agentJob && !f.IsPostgres() { + what += ", задание «" + depoJobName + "»" + } + step("apply", "run", "Применяю скрипт подготовки: "+what) if f.IsPostgres() { - return depoSetupRunPG(ctx, adm, db, login, pass) + if err := depoSetupRunPG(ctx, adm, db, login, pass); err != nil { + return err + } + step("apply", "ok", "") + return nil } conn, err := fancyOpen(adm, "") if err != nil { @@ -216,6 +235,7 @@ func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, admi if _, err := conn.ExecContext(cctx, depoSetupScript(f, db, login, pass, agentJob)); err != nil { return fmt.Errorf("подготовка базы %s логином %s: %s%s", db, adm.User, quikErrorDetail(err), depoTablesRightsHint(f, adm.User, err)) } + step("apply", "ok", "") return nil } diff --git a/internal/lkgateway/fancydb.go b/internal/lkgateway/fancydb.go index 71a5fdb..f7daee0 100644 --- a/internal/lkgateway/fancydb.go +++ b/internal/lkgateway/fancydb.go @@ -95,7 +95,7 @@ func fancyOpen(s FancyMSSQLSettings, db string) (*sql.DB, error) { if s.IsPostgres() && db == "" { db = fancyAdminDB(s) } - conn, err := sql.Open(fancyDriver(s), fancyDSN(s, db)) + conn, err := fancyOpenGated(s, fancyDSN(s, db)) // после неудачного входа — пауза, чтобы не заблокировать логин if err != nil { return nil, err } diff --git a/internal/lkgateway/fancydb_handlers.go b/internal/lkgateway/fancydb_handlers.go index 1e9bf76..3266332 100644 --- a/internal/lkgateway/fancydb_handlers.go +++ b/internal/lkgateway/fancydb_handlers.go @@ -126,7 +126,7 @@ func (h *setupHandlers) createFancyDepoTables(w http.ResponseWriter, r *http.Req setupFlash(w, r, "Таблицы DEPO: сначала сохраните подключение к серверу БД") return } - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 2*time.Minute) defer cancel() msg, err := depoTablesCreate(ctx, s, r.FormValue("admin_user"), r.FormValue("admin_password")) if err != nil { @@ -158,7 +158,7 @@ func (h *setupHandlers) checkFancyDepoTables(w http.ResponseWriter, r *http.Requ setupFlash(w, r, "Таблицы DEPO: сначала сохраните подключение к серверу БД") return } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), time.Minute) defer cancel() msg, ok := depoTablesCheck(ctx, s) h.saveDepoTablesCheck(msg, ok) @@ -235,7 +235,7 @@ func (h *setupHandlers) checkFancyDepoJob(w http.ResponseWriter, r *http.Request http.Error(w, "method", http.StatusMethodNotAllowed) return } - ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 90*time.Second) defer cancel() msg, ok := depoJobCheck(ctx, h.rc) pmsg, pok := depoProcCheck(ctx, h.rc) @@ -263,7 +263,7 @@ func (h *setupHandlers) createFancyDepoJob(w http.ResponseWriter, r *http.Reques http.Error(w, "method", http.StatusMethodNotAllowed) return } - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 60*time.Second) defer cancel() if err := depoJobCreateAgent(ctx, h.rc, r.FormValue("admin_user"), r.FormValue("admin_password")); err != nil { setupFlash(w, r, "Создание джобы: "+err.Error()) @@ -313,7 +313,7 @@ func (h *setupHandlers) testFancy(w http.ResponseWriter, r *http.Request) { return } // Проверяем соединение к серверу (master) — БД может ещё не существовать. - ver, err := fancyPing(r.Context(), s, "master") + ver, err := fancyPing(depoLoginForce(r.Context()), s, "master") if err != nil { setupFlash(w, r, fancyDBLabel(s)+": соединение не удалось — "+err.Error()) return diff --git a/internal/lkgateway/fancygate.go b/internal/lkgateway/fancygate.go new file mode 100644 index 0000000..7a03401 --- /dev/null +++ b/internal/lkgateway/fancygate.go @@ -0,0 +1,125 @@ +package lkgateway + +// Защита логина DEPO от блокировки (06.10.2026). На SQL Server логины с CHECK_POLICY подчиняются политике +// блокировки Windows: несколько неудачных входов подряд — и логин заблокирован для всех, кто им пользуется. +// Службы bj ходят в DEPO каждые несколько секунд; с неверным паролем (сменили на сервере, опечатка в +// настройке, второй bj с тем же логином) они за минуты блокировали логин — так 06.10 тестовая ВМ с логином +// bj_user остановила обмен боевого bj. Поэтому после неудачного входа bj с этими логином и паролем больше +// не пробует depoLoginPause — кроме входа, который человек запустил сам (проверка в мастере, кнопки +// настройки): для него depoLoginForce. Новый пароль — новая попытка сразу. + +import ( + "context" + "crypto/sha256" + "database/sql" + "database/sql/driver" + "encoding/hex" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgconn" + mssql "github.com/microsoft/go-mssqldb" +) + +// depoLoginPause — сколько не повторять вход после неудачи (политика блокировки считает попытки за окно; +// раз в 15 минут — заведомо реже порога). +const depoLoginPause = 15 * time.Minute + +type depoLoginFail struct { + at time.Time + err string +} + +var depoLoginFails = struct { + sync.Mutex + m map[string]depoLoginFail +}{m: map[string]depoLoginFail{}} + +type depoLoginForceKey struct{} + +// depoLoginForce — вход по действию человека: выполняется и во время паузы после неудачи. +func depoLoginForce(ctx context.Context) context.Context { + return context.WithValue(ctx, depoLoginForceKey{}, true) +} + +// depoLoginKey — сервер, логин и отпечаток пароля (сам пароль в памяти ключом не держим). +func depoLoginKey(s FancyMSSQLSettings) string { + h := sha256.Sum256([]byte(s.Password)) + return fancyDriver(s) + "|" + s.Server + "|" + strconv.Itoa(s.Port) + "|" + s.User + "|" + hex.EncodeToString(h[:8]) +} + +// depoIsLoginFailure — сервер отверг логин или пароль (то, что считает политика блокировки). +func depoIsLoginFailure(err error) bool { + var me mssql.Error + if errors.As(err, &me) { + switch me.Number { + case 18456, 18486, 18487, 18488: // неверный логин/пароль, заблокирован, пароль истёк, нужно сменить + return true + } + } + var pe *pgconn.PgError + if errors.As(err, &pe) { + return pe.Code == "28P01" || pe.Code == "28000" + } + return false +} + +// fancyConnector — подключение к DEPO через паузу после неудачного входа. +type fancyConnector struct { + base driver.Connector + key string + user string + srv string +} + +func (c *fancyConnector) Driver() driver.Driver { return c.base.Driver() } + +func (c *fancyConnector) Connect(ctx context.Context) (driver.Conn, error) { + force, _ := ctx.Value(depoLoginForceKey{}).(bool) + depoLoginFails.Lock() + f, failed := depoLoginFails.m[c.key] + depoLoginFails.Unlock() + if failed && !force && time.Since(f.at) < depoLoginPause { + return nil, fmt.Errorf("вход в DEPO логином %s не удался в %s (%s) — bj не повторяет его до %s, чтобы неудачные попытки не заблокировали логин на сервере. Проверьте логин и пароль: Настройка → «Учёт DEPO»", + c.user, f.at.Format("15:04"), f.err, f.at.Add(depoLoginPause).Format("15:04")) + } + conn, err := c.base.Connect(ctx) + switch { + case err != nil && depoIsLoginFailure(err): + depoLoginFails.Lock() + depoLoginFails.m[c.key] = depoLoginFail{at: time.Now(), err: quikErrorDetail(err)} + depoLoginFails.Unlock() + if !failed && !force { + raiseSystemAlert(globalRC, "DEPO: вход логином "+c.user+" не удался", + "Сервер "+c.srv+" отверг логин или пароль bj. Чтобы неудачные попытки не заблокировали логин, bj не повторяет вход "+ + strconv.Itoa(int(depoLoginPause/time.Minute))+" минут. Проверьте логин и пароль в настройке «Учёт DEPO».\n\n"+quikErrorDetail(err)) + } + case err == nil && failed: + depoLoginFails.Lock() + delete(depoLoginFails.m, c.key) + depoLoginFails.Unlock() + } + return conn, err +} + +// fancyOpenGated — пул подключений к DEPO с паузой после неудачного входа. +func fancyOpenGated(s FancyMSSQLSettings, dsn string) (*sql.DB, error) { + probe, err := sql.Open(fancyDriver(s), dsn) // без подключения: только чтобы взять драйвер + if err != nil { + return nil, err + } + drv := probe.Driver() + _ = probe.Close() + dc, ok := drv.(driver.DriverContext) + if !ok { + return sql.Open(fancyDriver(s), dsn) + } + base, err := dc.OpenConnector(dsn) + if err != nil { + return nil, err + } + return sql.OpenDB(&fancyConnector{base: base, key: depoLoginKey(s), user: s.User, srv: s.Server}), nil +} diff --git a/internal/lkgateway/fancygate_test.go b/internal/lkgateway/fancygate_test.go new file mode 100644 index 0000000..c60d05b --- /dev/null +++ b/internal/lkgateway/fancygate_test.go @@ -0,0 +1,56 @@ +package lkgateway + +import ( + "context" + "database/sql/driver" + "errors" + "strings" + "testing" + + mssql "github.com/microsoft/go-mssqldb" +) + +type fakeDepoConnector struct { + calls int + err error +} + +func (f *fakeDepoConnector) Connect(context.Context) (driver.Conn, error) { + f.calls++ + return nil, f.err +} +func (f *fakeDepoConnector) Driver() driver.Driver { return nil } + +// После неудачного входа службы bj с тем же паролем к серверу не ходят (иначе блокировка логина у всех, +// кто им пользуется); вход по действию человека — идёт; сетевые ошибки паузы не дают; удачный вход её снимает. +func TestDepoLoginPause(t *testing.T) { + old := globalRC + globalRC = nil + defer func() { globalRC = old }() + ctx := context.Background() + base := &fakeDepoConnector{err: mssql.Error{Number: 18456, Message: "Login failed for user 'bj_user'."}} + c := &fancyConnector{base: base, key: "test|pause", user: "bj_user", srv: "sql"} + if _, err := c.Connect(ctx); err == nil || base.calls != 1 { + t.Fatalf("первый вход: %v, обращений %d", err, base.calls) + } + if _, err := c.Connect(ctx); err == nil || base.calls != 1 || !strings.Contains(err.Error(), "не повторяет") { + t.Fatalf("служба во время паузы не должна ходить на сервер: %v, обращений %d", err, base.calls) + } + if _, _ = c.Connect(depoLoginForce(ctx)); base.calls != 2 { + t.Fatalf("вход по действию человека должен идти: обращений %d", base.calls) + } + base.err = nil + if _, err := c.Connect(depoLoginForce(ctx)); err != nil { + t.Fatal(err) + } + if _, _ = c.Connect(ctx); base.calls != 4 { + t.Fatalf("удачный вход снимает паузу: обращений %d", base.calls) + } + net := &fakeDepoConnector{err: errors.New("dial tcp 10.0.0.1:1433: i/o timeout")} + c2 := &fancyConnector{base: net, key: "test|net", user: "bj_user"} + _, _ = c2.Connect(ctx) + _, _ = c2.Connect(ctx) + if net.calls != 2 { + t.Fatalf("сетевая ошибка — не повод для паузы: обращений %d", net.calls) + } +} diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index c13bd17..64a3579 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -532,6 +532,18 @@ func TestDepoScriptView(t *testing.T) { if f := rc.Snapshot().Fancy; f.User != "bj_user" || f.Password != "Working1x" || f.DepoNewUser != "bj_new" || f.DepoNewPass != "Newpass12" { t.Fatalf("вход не удался — рабочие логин/пароль не должны меняться: %q/%q, черновик %q", f.User, f.Password, f.DepoNewUser) } + // с экрана (stream=1) — ход по шагам строками JSON, последняя — итог; адрес ошибки — на тот же экран + chk.Set("stream", "1") + r = httptest.NewRequest(http.MethodPost, "/admin/setup/wizard-depo/setup", strings.NewReader(chk.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + cw = httptest.NewRecorder() + h.wizardDepoSetup(cw, r) + lines := strings.Split(strings.TrimSpace(cw.Body.String()), "\n") + if !strings.Contains(cw.Header().Get("Content-Type"), "ndjson") || len(lines) < 2 || + !strings.Contains(lines[0], `"step":"login"`) || !strings.Contains(lines[0], `"state":"run"`) || + !strings.Contains(lines[len(lines)-1], `"done":true`) || !strings.Contains(lines[len(lines)-1], `"ok":false`) { + t.Fatalf("ход проверки: %s", cw.Body.String()) + } // экран пересобирает скрипт на ходу — и до того, как вписан адрес сервера form.Set("bj_password", "Other123x") form.Set("server", "") diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 0a850be..79c3210 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -83,6 +83,36 @@ .dq-sql pre { max-height:380px; min-height:140px; font-size:12px; margin:0; transition:opacity .2s; } .dq-sql pre.dq-busy { opacity:.5; } .dq.is-pg [data-ms], .dq:not(.is-pg) [data-pg] { display:none !important; } +.dq-box { border:1px solid var(--border); border-radius:12px; padding:14px 16px 16px; } +.dq-box-bj { background:color-mix(in srgb, var(--accent-weak) 45%, transparent); } +.dq-box-h { font-weight:600; font-size:14.5px; margin:0 0 10px; } +.dq-box-h span { color:var(--muted); font-weight:400; font-size:13px; } +.dq-lbl-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; } +.wiz-card .dq-link { background:none; border:0; padding:0; color:var(--accent); font-size:12.5px; font-weight:500; cursor:pointer; border-radius:4px; } +.dq-link:hover { text-decoration:underline; } +.dq-warn { margin:10px 0 0; padding:8px 12px; border-radius:8px; background:var(--warn-weak); color:var(--text-2); font-size:13px; } +/* ход подготовки: строки шагов со значком состояния */ +.dq-run { margin-top:16px; border:1px solid var(--border); border-radius:12px; padding:12px 16px; } +.dq-steps { list-style:none; margin:0; padding:0; display:grid; gap:2px; } +.dq-st { display:flex; gap:10px; align-items:flex-start; padding:6px 0; font-size:14px; } +.dq-st-t { min-width:0; } +.dq-st-d { color:var(--muted); font-size:12.5px; margin-top:2px; overflow-wrap:anywhere; } +.dq-st-ic { flex:none; position:relative; width:18px; height:18px; border-radius:50%; margin-top:1px; box-sizing:border-box; } +.dq-st.is-run .dq-st-ic { border:2px solid var(--accent-weak); border-top-color:var(--accent); animation:dq-spin .8s linear infinite; } +.dq-st.is-run { color:var(--text); font-weight:500; } +.dq-st.is-ok .dq-st-ic { background:var(--ok); } +.dq-st.is-ok .dq-st-ic::after { content:""; position:absolute; left:50%; top:45%; width:4px; height:8px; border:solid #fff; border-width:0 1.8px 1.8px 0; transform:translate(-50%,-50%) rotate(45deg); } +.dq-st.is-ok:not(.is-final) { color:var(--text-2); } +.dq-st.is-final { font-weight:600; color:var(--ok); } +.dq-st.is-warn .dq-st-ic, .dq-st.is-err .dq-st-ic { color:#fff; font-size:12px; font-weight:700; line-height:18px; text-align:center; } +.dq-st.is-warn .dq-st-ic { background:var(--warn); } +.dq-st.is-warn .dq-st-ic::after { content:"!"; } +.dq-st.is-err .dq-st-ic { background:var(--err); } +.dq-st.is-err .dq-st-ic::after { content:"×"; } +.dq-st.is-err { color:var(--err); } +.dq-run-msg { margin-top:8px; padding:10px 12px; border-radius:8px; background:var(--err-weak, rgba(200,40,40,.08)); font-size:13.5px; overflow-wrap:anywhere; white-space:pre-wrap; } +@keyframes dq-spin { to { transform:rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .dq-st.is-run .dq-st-ic { animation:none; border-color:var(--accent); } } @media (max-width:600px) { .dq-tiles, .dq-srv, .dq-2 { grid-template-columns:minmax(0,1fr); } .dq-sql-head { flex-wrap:wrap; } .dq-sql-st { flex-basis:100%; } } .tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; } .help-block { background:var(--accent-weak); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:10px; } @@ -202,20 +232,23 @@ -
+
+
Администратор SQL — нужен один раз, чтобы подготовить базу; bj его не сохраняет
- - + +
{{/* логин bj — в обоих способах: на общем сервере SQL bj_user может быть уже занят другим bj */}} -
+
+
Логин bj — под ним bj будет работать с базой DEPO
- - + +
-

С этим логином bj будет входить в базу DEPO — его создаст подготовка. Пароль придумал bj, можно вписать свой. Если такой логин на сервере уже есть, его пароль будет заменён — когда этим логином пользуется другая программа, впишите другое имя.

+

bj придумал их сам. Можно заменить своими — bj сохранит и будет использовать ровно то, что здесь написано: подготовка создаст на сервере этот логин с этим паролем (в скрипте — они же).

+

Если логин с таким именем на сервере уже есть, ему будет поставлен этот пароль — программа, которая им пользуется, перестанет входить. Тогда впишите другое имя.

@@ -244,6 +277,12 @@
+ + {{/* ход подготовки/проверки — по шагам, пока сервер работает (ответ приходит строками) */}} + {{else if eq $sub "done"}} - + diff --git a/internal/lkgateway/wizard_depo.go b/internal/lkgateway/wizard_depo.go index d7df3cb..8e391a0 100644 --- a/internal/lkgateway/wizard_depo.go +++ b/internal/lkgateway/wizard_depo.go @@ -7,6 +7,7 @@ package lkgateway import ( "context" + "encoding/json" "net/http" "net/url" "strconv" @@ -33,6 +34,11 @@ func depoWizardSub(v string) string { // wizDepoGo — перейти на экран шага 3 с сообщением (ok=false — сообщение об ошибке). func wizDepoGo(w http.ResponseWriter, r *http.Request, sub, msg string, ok bool) { + http.Redirect(w, r, wizDepoURL(sub, msg, ok), http.StatusSeeOther) +} + +// wizDepoURL — адрес экрана шага 3 с сообщением. +func wizDepoURL(sub, msg string, ok bool) string { if sub == "intro" || sub == "conn" || sub == "tables" || sub == "job" { sub = "setup" } @@ -45,7 +51,7 @@ func wizDepoGo(w http.ResponseWriter, r *http.Request, sub, msg string, ok bool) } u += "&flash=" + url.QueryEscape(msg) } - http.Redirect(w, r, u, http.StatusSeeOther) + return u } // wizardDepoConn — POST /admin/setup/wizard-depo/conn: сохранить подключение и сразу проверить его. Имя базы @@ -68,7 +74,7 @@ func (h *setupHandlers) wizardDepoConn(w http.ResponseWriter, r *http.Request) { wizDepoGo(w, r, "conn", "не сохранилось: "+err.Error(), false) return } - ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 45*time.Second) defer cancel() if _, err := fancyPing(ctx, s, ""); err != nil { wizDepoGo(w, r, "conn", "к серверу подключиться не удалось — "+err.Error()+". Проверьте адрес, порт, логин и пароль.", false) @@ -105,7 +111,7 @@ func (h *setupHandlers) wizardDepoTables(w http.ResponseWriter, r *http.Request) s.DepoTablesMode = mode _ = h.rc.UpdateFancy(s) } - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 2*time.Minute) defer cancel() if mode == "auto" && r.FormValue("action") != "check" { if _, err := depoTablesCreate(ctx, s, r.FormValue("admin_user"), r.FormValue("admin_password")); err != nil { @@ -144,7 +150,7 @@ func (h *setupHandlers) wizardDepoJob(w http.ResponseWriter, r *http.Request) { wizDepoGo(w, r, "job", "не сохранилось: "+err.Error(), false) return } - ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 90*time.Second) defer cancel() jobNote := "" if mode == "sql-auto" && r.FormValue("action") != "check" { @@ -183,7 +189,7 @@ func (h *setupHandlers) wizardDepoRecheck(w http.ResponseWriter, r *http.Request return } s := h.rc.Snapshot().Fancy - ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 90*time.Second) defer cancel() if _, err := fancyPing(ctx, s, s.DepoDB()); err != nil { wizDepoGo(w, r, "setup", "подключение не работает — "+err.Error(), false) @@ -332,37 +338,60 @@ func depoDraft(s, saved FancyMSSQLSettings) FancyMSSQLSettings { // wizardDepoSetup — POST /admin/setup/wizard-depo/setup: подготовить базу (mode=auto — bj сам логином // администратора), проверить после скрипта (mode=script) или с готовым логином (mode=existing). +// stream=1 (экран с JavaScript) — ход по шагам строками JSON, чтобы после «Подготовить» было видно, что +// происходит: подключение, проверка базы, скрипт, вход bj, проверки; последняя строка — итог и куда перейти. func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 3*time.Minute) + defer cancel() + if r.FormValue("stream") == "" { + sub, msg, ok := h.depoSetupDo(ctx, r, func(string, string, string) {}) + wizDepoGo(w, r, sub, msg, ok) + return + } + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + fl, _ := w.(http.Flusher) + enc := json.NewEncoder(w) + send := func(v any) { + _ = enc.Encode(v) + if fl != nil { + fl.Flush() + } + } + sub, msg, ok := h.depoSetupDo(ctx, r, func(key, state, text string) { + send(map[string]string{"step": key, "state": state, "text": text}) + }) + send(map[string]any{"done": true, "ok": ok, "msg": msg, "go": wizDepoURL(sub, msg, ok)}) +} + +// depoSetupDo — подготовка или проверка базы DEPO по форме экрана; итог — куда перейти (sub) и сообщение. +func (h *setupHandlers) depoSetupDo(ctx context.Context, r *http.Request, step depoStep) (string, string, bool) { + fail := func(msg string) (string, string, bool) { return "setup", msg, false } saved := h.rc.Snapshot().Fancy s, db, agent, bad := h.depoSetupForm(r) if bad == "" && s.Server == "" { bad = "укажите адрес сервера DEPO" } if bad != "" { - wizDepoGo(w, r, "setup", bad, false) - return + return fail(bad) } - ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) - defer cancel() - note := "" - switch r.FormValue("mode") { + mode, note := r.FormValue("mode"), "" + switch mode { case "auto": au := strings.TrimSpace(r.FormValue("admin_user")) if au == "" { - wizDepoGo(w, r, "setup", "введите логин и пароль администратора SQL (они используются один раз и не сохраняются)", false) - return + return fail("введите логин и пароль администратора SQL (они используются один раз и не сохраняются)") } if bad := depoBjCreds(r, &s); bad != "" { - wizDepoGo(w, r, "setup", bad, false) - return + return fail(bad) } depoEnsureLogin(&s) - if err := depoSetupRun(ctx, s, db, au, r.FormValue("admin_password"), s.User, s.Password, agent); err != nil { - wizDepoGo(w, r, "setup", "база не подготовлена — "+err.Error(), false) - return + if err := depoSetupRun(ctx, s, db, au, r.FormValue("admin_password"), s.User, s.Password, agent, step); err != nil { + return fail("база не подготовлена — " + err.Error()) } s.DepoTablesMode = "auto" note = "база " + db + " подготовлена: логин " + s.User + ", таблицы обмена, права" @@ -373,60 +402,68 @@ func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request) case "existing": u := strings.TrimSpace(r.FormValue("user")) if u == "" { - wizDepoGo(w, r, "setup", "введите логин bj и пароль", false) - return + return fail("введите логин bj и пароль") } if u != s.User || r.FormValue("password") != "" { s.User, s.Password = u, r.FormValue("password") } default: // script — администратор выполнил скрипт: логин и пароль bj — с экрана (они же в настройках) if bad := depoBjCreds(r, &s); bad != "" { - wizDepoGo(w, r, "setup", bad, false) - return + return fail(bad) } s.DepoTablesMode = "script" if s.User == "" || s.Password == "" { - wizDepoGo(w, r, "setup", "сначала скачайте скрипт и передайте администратору SQL", false) - return + return fail("сначала скачайте скрипт и передайте администратору SQL") } } s.DepoDatabase = db if err := h.rc.UpdateFancy(depoDraft(s, saved)); err != nil { - wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false) - return + return fail("не сохранилось: " + err.Error()) } + step("login", "run", "Вхожу в DEPO логином bj «"+s.User+"»") if _, err := fancyPing(ctx, s, ""); err != nil { - wizDepoGo(w, r, "setup", note+"войти логином "+s.User+" не удалось — "+quikErrorDetail(err)+". Выполнен ли скрипт на сервере "+s.Server+"?", false) - return + return fail(note + "войти логином " + s.User + " не удалось — " + quikErrorDetail(err) + ". Выполнен ли скрипт на сервере " + s.Server + "?") } s.DepoNewUser, s.DepoNewPass = "", "" // вход проверен — логин и пароль в работу if err := h.rc.UpdateFancy(s); err != nil { - wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false) - return + return fail("не сохранилось: " + err.Error()) } - if err := depoCheckIsDepo(ctx, s, db); err != nil { // имя базы задал клиент — та ли это база - wizDepoGo(w, r, "setup", note+err.Error(), false) - return + step("login", "ok", "bj сохранил логин «"+s.User+"» и пароль — дальше работает под ними") + if mode != "auto" { // при подготовке база уже проверена логином администратора + step("isdepo", "run", "Проверяю, что «"+db+"» — база учёта DEPO") + if err := depoCheckIsDepo(ctx, s, db); err != nil { // имя базы задал клиент — та ли это база + return fail(note + err.Error()) + } + step("isdepo", "ok", "") } + step("tables", "run", "Проверяю таблицы обмена FTV_M2M и права логина bj") msg, ok := depoTablesCheck(ctx, s) h.saveDepoTablesCheck(msg, ok) if !ok { - wizDepoGo(w, r, "setup", note+msg, false) - return + return fail(note + msg) } if !s.IsPostgres() { _ = depoWriteHandbook(h.rc) } + step("tables", "ok", "") // запуск обработки: только что созданное задание запускается в течение интервала — подождать + if agent { + step("job", "run", "Проверяю задание SQL Server Agent «"+depoJobName+"» — до 30 секунд") + } else { + step("job", "run", "Проверяю, что bj может запускать обработку обмена") + } jmsg, jok := depoJobRunnerCheck(ctx, h.rc, "", "") for i := 0; !jok && i < 6 && strings.Contains(jmsg, "ни разу не запускалась"); i++ { time.Sleep(5 * time.Second) jmsg, jok = depoJobRunnerCheck(ctx, h.rc, "", "") } + step("job", map[bool]string{true: "ok", false: "warn"}[jok], jmsg) + step("procs", "run", "Проверяю процедуры обработки обмена") pmsg, pok := depoProcCheck(ctx, h.rc) // процедуры обработки ставятся отдельно — шаг не держат + step("procs", map[bool]string{true: "ok", false: "warn"}[pok], pmsg) cur := h.rc.Snapshot().Fancy cur.DepoJobCheck, cur.DepoJobCheckOK, cur.DepoJobCheckAt = jmsg, jok, time.Now() cur.DepoProcCheck, cur.DepoProcCheckOK, cur.DepoProcCheckAt = pmsg, pok, time.Now() _ = h.rc.UpdateFancy(cur) - wizDepoGo(w, r, "done", note+"подключение и таблицы обмена проверены", true) + return "done", note + "подключение и таблицы обмена проверены", true }
Подключение к базе DEPO{{if .Settings.Fancy.Server}}✓ {{.Settings.Fancy.Server}}, база {{.DepoTables.DB}}{{else}}не настроено{{end}}
Подключение к базе DEPO{{if .Settings.Fancy.Server}}✓ {{.Settings.Fancy.Server}}, база {{.DepoTables.DB}}, логин bj «{{.Settings.Fancy.User}}»{{else}}не настроено{{end}}
Таблицы обмена FTV_M2M{{if .Settings.Fancy.DepoTablesCheckOK}}✓ на месте{{else}}✗ {{.Settings.Fancy.DepoTablesCheck}}{{end}}
Запуск обработки{{if .Settings.Fancy.DepoJobCheckOK}}✓ {{.Settings.Fancy.DepoJobCheck}}{{else}}✗ {{.Settings.Fancy.DepoJobCheck}}{{end}}
Обработка событий (процедуры){{if .Settings.Fancy.DepoProcCheckOK}}✓ {{.Settings.Fancy.DepoProcCheck}}{{else if .Settings.Fancy.DepoProcCheck}}⚠ {{.Settings.Fancy.DepoProcCheck}}{{else}}не проверялась — «Перепроверить всё»{{end}}