Учёт 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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user