Учёт DEPO: ход подготовки по шагам, понятный блок «Логин bj», пауза после неудачного входа

- «Подготовить» / «Проверить подключение» показывают ход: подключение логином
  администратора, проверка, что это база DEPO, применение скрипта, вход логином bj,
  таблицы обмена, задание, процедуры, «Готово». Сервер отдаёт шаги строками JSON
  (stream=1), на ошибке — шаг с крестиком и причина, без перехода со страницы.
- Блоки «Администратор SQL — нужен один раз, не сохраняется» и «Логин bj — под ним
  bj будет работать»: bj сохраняет и использует ровно то, что вписано; «другой
  пароль»; на итоге шага — каким логином bj работает.
- Пауза 15 минут после неудачного входа в DEPO (логин/пароль отвергнуты) для
  фоновых служб: с неверным паролем они за минуты блокировали логин на сервере
  (CHECK_POLICY) у всех, кто им пользуется. Действия человека идут без паузы;
  новый пароль — сразу; системное событие в «Новостях».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-06 15:46:25 +03:00
parent fbf6a23667
commit a9915bcc82
8 changed files with 395 additions and 53 deletions
+23 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+5 -5
View File
@@ -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
+125
View File
@@ -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
}
+56
View File
@@ -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)
}
}
+12
View File
@@ -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", "")
@@ -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 @@
</div>
<label class="dq-alt"><input type="radio" name="mode" value="existing"> База уже подготовлена раньше — у bj есть свой логин</label>
<div class="dq-panel" id="wiz-depo-auto">
<div class="dq-panel dq-box" id="wiz-depo-auto">
<div class="dq-box-h">Администратор SQL <span>— нужен один раз, чтобы подготовить базу; bj его не сохраняет</span></div>
<div class="dq-grid dq-2">
<label>Логин администратора SQL<input type="text" name="admin_user" autocomplete="off" spellcheck="false" placeholder="{{if $pg}}postgres{{else}}sa{{end}}"></label>
<label>Пароль администратора<input type="password" name="admin_password" autocomplete="new-password"></label>
<label>Логин<input type="text" name="admin_user" autocomplete="off" spellcheck="false" placeholder="{{if $pg}}postgres{{else}}sa{{end}}"></label>
<label>Пароль<input type="password" name="admin_password" autocomplete="new-password"></label>
</div>
</div>
{{/* логин bj — в обоих способах: на общем сервере SQL bj_user может быть уже занят другим bj */}}
<div class="dq-panel" id="wiz-depo-login">
<div class="dq-panel dq-box dq-box-bj" id="wiz-depo-login">
<div class="dq-box-h">Логин bj <span>— под ним bj будет работать с базой DEPO</span></div>
<div class="dq-grid dq-2">
<label>Логин bj<input type="text" name="bj_user" value="{{.DepoUser}}" autocomplete="off" spellcheck="false"></label>
<label>Пароль bj<input type="text" name="bj_password" value="{{.DepoPass}}" autocomplete="off" spellcheck="false" class="dq-mono"></label>
<label>Логин<input type="text" name="bj_user" value="{{.DepoUser}}" autocomplete="off" spellcheck="false"></label>
<label><span class="dq-lbl-row">Пароль <button type="button" class="dq-link" id="wiz-depo-gen">↻ другой пароль</button></span><input type="text" name="bj_password" value="{{.DepoPass}}" autocomplete="off" spellcheck="false" class="dq-mono"></label>
</div>
<p class="dq-note">С этим логином bj будет входить в базу DEPO — его создаст подготовка. Пароль придумал bj, можно вписать свой. <b>Если такой логин на сервере уже есть, его пароль будет заменён</b> — когда этим логином пользуется другая программа, впишите другое имя.</p>
<p class="dq-note">bj придумал их сам. Можно заменить своими — <b>bj сохранит и будет использовать ровно то, что здесь написано</b>: подготовка создаст на сервере этот логин с этим паролем<span id="wiz-depo-login-script"> (в скрипте — они же)</span>.</p>
<p class="dq-warn">Если логин с таким именем на сервере уже есть, ему будет поставлен этот пароль — программа, которая им пользуется, перестанет входить. Тогда впишите другое имя.</p>
</div>
<div class="dq-panel" id="wiz-depo-autogo">
@@ -244,6 +277,12 @@
</div>
<button type="submit" class="btn dq-go">Проверить подключение</button>
</div>
{{/* ход подготовки/проверки — по шагам, пока сервер работает (ответ приходит строками) */}}
<div class="dq-run" id="wiz-depo-run" hidden aria-live="polite">
<ol class="dq-steps" id="wiz-depo-steps"></ol>
<div class="dq-run-msg" id="wiz-depo-runmsg" hidden></div>
</div>
</section>
</form>
<script>(function(){
@@ -256,6 +295,7 @@
f.admin_user.placeholder=pg?'postgres':'sa';
var show={auto:['auto','login','autogo'], script:['login','script'], existing:['existing']}[m]||[];
['auto','login','autogo','script','existing'].forEach(function(k){ document.getElementById('wiz-depo-'+k).hidden=show.indexOf(k)<0; });
document.getElementById('wiz-depo-login-script').hidden=m!=='script';
}
function load(){ // скрипт — из текущих ответов; устаревший ответ (пока печатали дальше) не показываем
if(f.mode.value!=='script') return;
@@ -275,12 +315,64 @@
document.getElementById('wiz-depo-copy').onclick=function(){ var b=this;
window.bjCopy(pre.textContent).then(function(ok){ b.textContent=ok?'Скопировано ✓':'Выделите и скопируйте вручную'; b.classList.toggle('done',!!ok);
setTimeout(function(){ b.textContent='Скопировать'; b.classList.remove('done'); }, 2000); }); };
document.getElementById('wiz-depo-gen').onclick=function(){ // тот же набор, что у bj: без похожих 0/O, 1/l/I
var up='ABCDEFGHJKLMNPQRSTUVWXYZ', low='abcdefghijkmnpqrstuvwxyz', dig='23456789', all=up+low+dig, a=new Uint32Array(24), p=[];
crypto.getRandomValues(a);
p.push(up[a[0]%up.length], low[a[1]%low.length], dig[a[2]%dig.length]);
for(var i=3;i<24;i++) p.push(all[a[i]%all.length]);
for(var j=p.length-1;j>0;j--){ var k=a[j]%(j+1), t=p[j]; p[j]=p[k]; p[k]=t; }
f.bj_password.value=p.join(''); changed();
};
// «Подготовить» / «Проверить подключение»: ход по шагам вместо тишины до перехода на следующий экран
var run=document.getElementById('wiz-depo-run'), list=document.getElementById('wiz-depo-steps'), rmsg=document.getElementById('wiz-depo-runmsg');
function li(key){ return list.querySelector('[data-k="'+key+'"]'); }
function mark(el, st, text){ if(!el) return; el.className='dq-st is-'+st;
if(text){ var d=el.querySelector('.dq-st-d'); if(!d){ d=document.createElement('div'); d.className='dq-st-d'; el.querySelector('.dq-st-t').appendChild(d); } d.textContent=text; } }
function onStep(x){
if(x.state==='run'){
list.querySelectorAll('.is-run').forEach(function(e){ mark(e,'ok'); });
var e=li(x.step); if(!e){ e=document.createElement('li'); e.dataset.k=x.step; e.innerHTML='<span class="dq-st-ic"></span><div class="dq-st-t"><span></span></div>'; list.appendChild(e); }
e.querySelector('.dq-st-t > span').textContent=x.text; mark(e,'run');
} else mark(li(x.step), x.state, x.text);
}
function finish(x, btns){
var cur=list.querySelector('.is-run');
if(x&&x.ok){ if(cur) mark(cur,'ok');
var e=document.createElement('li'); e.className='dq-st is-ok is-final'; e.innerHTML='<span class="dq-st-ic"></span><div class="dq-st-t"><span>Готово — открываю итог</span></div>'; list.appendChild(e);
setTimeout(function(){ location.href=x.go; }, 1200); return; }
if(cur) mark(cur,'err');
rmsg.textContent=(x&&x.msg)?x.msg.replace(/^./,function(c){return c.toUpperCase()}):'Связь с bj прервалась — обновите страницу и попробуйте ещё раз.';
rmsg.hidden=false; btns.forEach(function(b){ b.disabled=false; });
}
f.addEventListener('submit', function(ev){
var b=ev.submitter; if(!b||!b.classList.contains('dq-go')||!window.fetch||!window.TextDecoder) return;
ev.preventDefault();
var btns=[].slice.call(f.querySelectorAll('.dq-go')); btns.forEach(function(x){ x.disabled=true; });
list.innerHTML=''; rmsg.hidden=true; run.hidden=false; run.scrollIntoView({block:'nearest',behavior:'smooth'});
var fd=new FormData(f); fd.set('stream','1');
var final=null;
fetch(f.action,{method:'POST',body:new URLSearchParams(fd),headers:{'Content-Type':'application/x-www-form-urlencoded'},cache:'no-store'})
.then(function(r){
if(!r.ok||!r.body||(r.headers.get('Content-Type')||'').indexOf('ndjson')<0) throw new Error('bad');
var rd=r.body.getReader(), dec=new TextDecoder(), buf='';
function line(t){ if(!t.trim()) return; var x=JSON.parse(t); if(x.done) final=x; else onStep(x); }
function pump(){ return rd.read().then(function(c){
if(c.done){ line(buf); return; }
buf+=dec.decode(c.value,{stream:true}); var i;
while((i=buf.indexOf('\n'))>=0){ line(buf.slice(0,i)); buf=buf.slice(i+1); }
return pump(); }); }
return pump();
})
.then(function(){ finish(final, btns); }, function(){ finish(null, btns); });
});
sync(); load();
})();</script>
{{else if eq $sub "done"}}
<table style="margin:6px 0 10px">
<tr><td>Подключение к базе DEPO</td><td>{{if .Settings.Fancy.Server}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.Server}}, база {{.DepoTables.DB}}</span>{{else}}<span style="color:var(--err)">не настроено</span>{{end}}</td></tr>
<tr><td>Подключение к базе DEPO</td><td>{{if .Settings.Fancy.Server}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.Server}}, база {{.DepoTables.DB}}, логин bj «{{.Settings.Fancy.User}}»</span>{{else}}<span style="color:var(--err)">не настроено</span>{{end}}</td></tr>
<tr><td>Таблицы обмена FTV_M2M</td><td>{{if .Settings.Fancy.DepoTablesCheckOK}}<span style="color:var(--ok)">✓ на месте</span>{{else}}<span style="color:var(--err)">✗ {{.Settings.Fancy.DepoTablesCheck}}</span>{{end}}</td></tr>
<tr><td>Запуск обработки</td><td>{{if .Settings.Fancy.DepoJobCheckOK}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.DepoJobCheck}}</span>{{else}}<span style="color:var(--err)">✗ {{.Settings.Fancy.DepoJobCheck}}</span>{{end}}</td></tr>
<tr><td>Обработка событий (процедуры)</td><td>{{if .Settings.Fancy.DepoProcCheckOK}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.DepoProcCheck}}</span>{{else if .Settings.Fancy.DepoProcCheck}}<span style="color:var(--warn)">⚠ {{.Settings.Fancy.DepoProcCheck}}</span>{{else}}<span class="muted">не проверялась — «Перепроверить всё»</span>{{end}}</td></tr>
+72 -35
View File
@@ -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())
}
step("login", "ok", "bj сохранил логин «"+s.User+"» и пароль — дальше работает под ними")
if mode != "auto" { // при подготовке база уже проверена логином администратора
step("isdepo", "run", "Проверяю, что «"+db+"» — база учёта DEPO")
if err := depoCheckIsDepo(ctx, s, db); err != nil { // имя базы задал клиент — та ли это база
wizDepoGo(w, r, "setup", note+err.Error(), false)
return
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
}