From be83b8674793c2b2520a278c102936aa20b21dfe Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 6 Oct 2026 13:35:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=87=D1=91=D1=82=20DEPO=20=D0=B2=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=B5:=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=B1=D0=B0=D0=B7=D1=8B,=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=20=D0=B8=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20?= =?UTF-8?q?bj=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5,=20?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit База DEPO — видимое поле (необязательно: пусто — найдётся по учётным таблицам), порт заполнен по умолчанию. «Отдать скрипт администратору»: логин и пароль bj видны и меняются, «Показать скрипт» выводит его на странице с кнопкой «Скопировать». В начале скрипта — блок «Проверьте перед запуском»: @db и @pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/depo_setup.go | 37 ++++++++----- internal/lkgateway/letters.go | 2 +- internal/lkgateway/onboarding_test.go | 40 +++++++++++++- internal/lkgateway/setup.go | 11 ++++ .../lkgateway/web/templates/admin_wizard.html | 48 +++++++++++------ internal/lkgateway/wizard_depo.go | 52 +++++++++++++++++-- 6 files changed, 155 insertions(+), 35 deletions(-) diff --git a/internal/lkgateway/depo_setup.go b/internal/lkgateway/depo_setup.go index 20106b9..af61df3 100644 --- a/internal/lkgateway/depo_setup.go +++ b/internal/lkgateway/depo_setup.go @@ -57,19 +57,30 @@ func depoSetupScript(f FancyMSSQLSettings, db, login, pass string, agentJob bool return depoSetupScriptPG(f, db, login, pass) } var b strings.Builder - job := "обработку вызывает сам bj" + job := "вызывает сам bj" if agentJob { - job = "задание SQL Server Agent «" + depoJobName + "» для обработки обмена" + job = "задание SQL Server Agent «" + depoJobName + "»" } fmt.Fprintf(&b, `-- Bridge&Join: подготовка базы учёта DEPO для bj. Microsoft SQL Server. --- Один скрипт: находит базу DEPO на сервере, создаёт логин bj (%[1]s), таблицы обмена FTV_M2M*, выдаёт --- права (чтение учёта, запись только в таблицы обмена) и %[2]s. --- Учётные данные DEPO скрипт не меняет. Запускать в SSMS или sqlcmd под логином с правами sysadmin. --- Можно запускать повторно: уже сделанное не повторяется. Скрипт содержит пароль логина bj — после --- выполнения файл удалите. - SET NOCOUNT ON; -DECLARE @db sysname = N'%[3]s'; -- база DEPO; пусто — найти на сервере по таблицам OD_FACES и OD_RESTS + +-- ===== Проверьте перед запуском ========================================== +-- База DEPO: пусто — скрипт найдёт её сам по таблицам OD_FACES и OD_RESTS. +DECLARE @db sysname = N'%[3]s'; +-- Пароль логина bj (%[1]s). Меняете — впишите тот же пароль в bj, шаг «Учёт DEPO». +DECLARE @pass nvarchar(128) = N'%[4]s'; +-- ========================================================================= + +-- Что делает: находит базу DEPO, создаёт логин bj и таблицы обмена FTV_M2M*, выдаёт права +-- (чтение учёта, запись только в таблицы обмена). Обработка обмена: %[2]s. +-- Учётные данные DEPO не меняет. Запускать в SSMS или sqlcmd под логином с правами sysadmin. +-- Можно запускать повторно. В скрипте пароль логина bj — после выполнения файл удалите. + +IF @pass = N'' +BEGIN + RAISERROR(N'Впишите пароль логина bj в @pass в начале скрипта и запустите снова.', 16, 1); + RETURN; +END DECLARE @found int; IF @db = N'' BEGIN @@ -89,12 +100,12 @@ DECLARE @in nvarchar(300) = N'USE ' + QUOTENAME(@db) + N'; '; -- 1. Логин bj IF SUSER_ID(N'%[1]s') IS NULL - EXEC(N'CREATE LOGIN [%[1]s] WITH PASSWORD = N''%[4]s'', CHECK_POLICY = ON, DEFAULT_DATABASE = ' + QUOTENAME(@db)) + EXEC(N'CREATE LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N''', CHECK_POLICY = ON, DEFAULT_DATABASE = ' + QUOTENAME(@db)) ELSE - EXEC(N'ALTER LOGIN [%[1]s] WITH PASSWORD = N''%[4]s'''); + EXEC(N'ALTER LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N''''); -- 2. Таблицы обмена FTV_M2M* (только недостающие таблицы, колонки и индексы — данные не меняются) -`, login, job, sqlStr(db), sqlStr(sqlStr(pass))) +`, login, job, sqlStr(db), sqlStr(pass)) for _, s := range depoTablesDDL(false) { fmt.Fprintf(&b, "EXEC(@in + N'%s');\n", sqlStr(s)) } @@ -137,6 +148,8 @@ func depoSetupScriptPG(f FancyMSSQLSettings, db, login, pass string) string { -- Создаёт роль bj (%[2]s), таблицы обмена FTV_M2M* и права (чтение учёта, запись только в таблицы обмена). -- Обработку обмена вызывает сам bj. Учётные данные скрипт не меняет. Можно запускать повторно. -- Скрипт содержит пароль роли bj — после выполнения файл удалите. +-- Проверьте перед запуском: база DEPO — "%[1]s", роль bj — "%[2]s", её пароль — в строках PASSWORD ниже +-- (меняете — впишите тот же пароль в bj, шаг «Учёт DEPO»). \set ON_ERROR_STOP on DO $$ BEGIN diff --git a/internal/lkgateway/letters.go b/internal/lkgateway/letters.go index 1cb5218..3f3e544 100644 --- a/internal/lkgateway/letters.go +++ b/internal/lkgateway/letters.go @@ -83,7 +83,7 @@ func letterFor(s Settings, key string) (Letter, bool) { } return Letter{Key: key, Title: "Письмо администратору базы DEPO", To: "Администратор СУБД учёта DEPO", Subject: "Bridge&Join: подготовка базы DEPO на сервере " + orBracket(f.Server, "сервер DEPO"), - Body: fmt.Sprintf("Коллеги, добрый день!\n\nДля подключения системы Bridge&Join к учёту DEPO нужно на сервере %s выполнить один скрипт %s %s (файл прилагается — он скачивается в мастере настройки bj). Скрипт:\n— сам находит базу DEPO на сервере (по учётным таблицам);\n— создаёт логин bj: права — чтение учётных таблиц DEPO и запись только в таблицы обмена FTV_M2M*;\n— создаёт таблицы обмена FTV_M2M*; учётные данные он не меняет;\n%s\nСкрипт можно запускать повторно. В нём пароль логина bj — после выполнения файл удалите.\n\nТакже нужно разрешить подключение к серверу базы с сервера Bridge&Join (адрес %s). Процедуры обработки обмена FTV_M2M_* поставляются отдельно.%s", + Body: fmt.Sprintf("Коллеги, добрый день!\n\nДля подключения системы Bridge&Join к учёту DEPO нужно на сервере %s выполнить один скрипт %s %s (файл прилагается — он скачивается в мастере настройки bj). Скрипт:\n— в начале — имя базы DEPO (пусто — скрипт найдёт её сам по учётным таблицам) и пароль логина bj: их можно поправить;\n— создаёт логин bj: права — чтение учётных таблиц DEPO и запись только в таблицы обмена FTV_M2M*;\n— создаёт таблицы обмена FTV_M2M*; учётные данные он не меняет;\n%s\nСкрипт можно запускать повторно. В нём пароль логина bj — после выполнения файл удалите.\n\nТакже нужно разрешить подключение к серверу базы с сервера Bridge&Join (адрес %s). Процедуры обработки обмена FTV_M2M_* поставляются отдельно.%s", orBracket(f.Server, "сервер DEPO"), depoSetupFileName(f), run, what, orBracket(ip, "IP-адрес сервера bj"), sign)}, true } return Letter{}, false diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index 08b3833..71f1ccf 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -351,7 +351,7 @@ func TestFromLocalBundle(t *testing.T) { func TestDepoSetupScript(t *testing.T) { f := FancyMSSQLSettings{} s := depoSetupScript(f, "", "bj_user", "Pass1word", true) - for _, want := range []string{"DECLARE @db sysname = N'';", "OD_FACES", "CREATE LOGIN [bj_user] WITH PASSWORD = N''Pass1word''", "FTV_M2M_EVENTS", "db_datareader", "sp_add_job @job_name = N'bj2depo'", "@database_name = @db", "SQLAgentReaderRole"} { + for _, want := range []string{"DECLARE @db sysname = N'';", "DECLARE @pass nvarchar(128) = N'Pass1word';", "OD_FACES", "CREATE LOGIN [bj_user] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''')", "FTV_M2M_EVENTS", "db_datareader", "sp_add_job @job_name = N'bj2depo'", "@database_name = @db", "SQLAgentReaderRole"} { if !strings.Contains(s, want) { t.Fatalf("в скрипте нет %q", want) } @@ -443,6 +443,7 @@ func TestDumpOnboardingPages(t *testing.T) { t.Fatal(err) } _ = os.WriteFile(filepath.Join(dir, "prepare.html"), []byte(b.String()), 0o644) + _ = os.WriteFile(filepath.Join(dir, "depo-script.sql"), []byte(depoSetupScript(FancyMSSQLSettings{}, "", "bj_user", "Xk7pQ2mN9vR4tL8wZ3hB6cJd", true)), 0o644) } // Первая страница (администратор) — для снимка в TestDumpOnboardingPages. @@ -457,3 +458,40 @@ func TestDumpFirstRunPage(t *testing.T) { } _ = os.WriteFile(filepath.Join(dir, "first-run.html"), []byte(b.String()), 0o644) } + +// «Отдать скрипт администратору»: скрипт показывается на странице с базой и паролем с экрана, пароль +// запоминается (с ним bj потом проверяет подключение); короткий пароль не принимается. +func TestDepoScriptView(t *testing.T) { + priv := testLicenseKey(t) + rc := testRC(t) + now := time.Now().UTC() + tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-1", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro, + IssuedAt: now, ExpiresAt: now.Add(30 * 24 * time.Hour), ContractUntil: now.AddDate(1, 0, 0), Features: []string{"updates", "quik-mnp"}}) + if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil { + t.Fatal(err) + } + old := globalRC + globalRC = rc + defer func() { globalRC = old }() + h := &setupHandlers{rc: rc} + post := func(form url.Values) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/admin/setup/wizard-depo/script.sql", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.wizardDepoScript(w, r) + return w + } + form := url.Values{"server": {"10.0.0.5"}, "port": {"1433"}, "db_type": {"mssql"}, "depo_database": {"DEPO_X"}, + "mode": {"script"}, "bj_user": {"bj_user"}, "bj_password": {"Secret123x"}, "depo_job": {"agent"}, "view": {"1"}} + w := post(form) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "DECLARE @db sysname = N'DEPO_X';") || !strings.Contains(w.Body.String(), "N'Secret123x';") { + t.Fatalf("показ скрипта: %d %s", w.Code, w.Body.String()[:min(300, w.Body.Len())]) + } + if f := rc.Snapshot().Fancy; f.User != "bj_user" || f.Password != "Secret123x" || f.DepoTablesMode != "script" { + t.Fatalf("логин и пароль bj не запомнились: %+v", f.User) + } + form.Set("bj_password", "short") + if w := post(form); w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "8 знаков") { + t.Fatalf("короткий пароль: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 4a67958..622448f 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -175,6 +175,9 @@ type WizardData struct { Fancy *SetupData // шаг 3: общий блок «Обмен Fancy» (тот же, что в настройках) Sub string // экран шага 3 (учёт DEPO): intro|conn|tables|job|done DepoCands []string // шаг 3: найдено несколько баз DEPO — выбор + DepoUser string // шаг 3, «скрипт администратору»: логин bj, который создаст скрипт + DepoPass string // и его пароль (сохранённый или придуманный сейчас — сохраняется со скриптом) + DepoMode string // выбранный способ подготовки базы: auto|script|existing ValidataVer string // версия установленной «Валидаты Клиент L» (пусто — не установлена) CryptoSvc string // состояние службы bj-crypto (systemctl is-active) PGSummary string // куда подключена база bj, без пароля @@ -322,6 +325,14 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { if step == 3 { d.Sub = depoWizardSub(r.URL.Query().Get("sub")) d.DepoCands = depoCands(r.URL.Query().Get("cands")) + d.DepoUser, d.DepoPass = depoSetupLogin(s.Fancy), s.Fancy.Password + if s.Fancy.User != d.DepoUser || d.DepoPass == "" { + d.DepoPass = depoGenPassword() + } + d.DepoMode = "auto" + if s.Fancy.DepoTablesMode == "script" { + d.DepoMode = "script" + } if r.URL.Query().Get("sub") == "" && d.Done.Depo { d.Sub = "done" } diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index c7fc115..3a364a5 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -132,27 +132,36 @@ {{else}} {{$sub := .Sub}} {{if eq $sub "setup"}} -

bj сверяет каждый перевод с учётом DEPO и сам создаёт в нём поручения. Нужен только адрес сервера — базу DEPO bj найдёт сам.

+

bj сверяет каждый перевод с учётом DEPO и сам создаёт в нём поручения. Укажите сервер DEPO; имя базы можно не писать — bj найдёт её по учётным таблицам.

{{$mode := .Settings.Fancy.DepoJobMode}}
- +
- {{if .DepoCands}}{{end}} - + {{if .DepoCands}} + {{else}}{{end}}
Как подготовить базу
- +
- + @@ -165,21 +174,28 @@
- {{if not .DepoCands}}
Указать базу DEPO вручную (обычно не нужно) -
{{end}}
+ document.getElementById('wiz-depo-btn').textContent=m==='auto'?'Подготовить и проверить':'Проверить подключение';} + wizDepo(); + document.getElementById('wiz-depo-show').onclick=function(){ // скрипт — прямо на странице, с текущими полями + var f=document.getElementById('wiz-depo'), box=document.getElementById('wiz-depo-sqlbox'), pre=document.getElementById('wiz-depo-sql'), b=this; + if(!f.reportValidity()) return; + var fd=new FormData(f); fd.set('view','1'); b.disabled=true; + fetch('/admin/setup/wizard-depo/script.sql',{method:'POST',body:new URLSearchParams(fd),cache:'no-store'}) + .then(function(r){return r.text().then(function(t){return {ok:r.ok,t:t}})}) + .then(function(x){ pre.textContent=x.ok?x.t:('✗ '+x.t.trim()); box.hidden=false; b.textContent='Обновить скрипт'; }) + .catch(function(){ pre.textContent='✗ Не получилось — обновите страницу и попробуйте ещё раз.'; box.hidden=false; }) + .then(function(){ b.disabled=false; }); + }; {{else if eq $sub "done"}} diff --git a/internal/lkgateway/wizard_depo.go b/internal/lkgateway/wizard_depo.go index 84fd7e1..696861d 100644 --- a/internal/lkgateway/wizard_depo.go +++ b/internal/lkgateway/wizard_depo.go @@ -286,27 +286,64 @@ func depoEnsureLogin(s *FancyMSSQLSettings) { } } -// wizardDepoScript — POST /admin/setup/wizard-depo/script.sql: один скрипт подготовки базы файлом. +// depoScriptCreds — логин и пароль bj с экрана «Отдать скрипт администратору» (поля видны и меняются; +// пустой пароль — bj придумает сам). Возвращает текст ошибки. +func depoScriptCreds(r *http.Request, s *FancyMSSQLSettings) string { + if u := strings.TrimSpace(r.FormValue("bj_user")); u != "" { + if !depoLoginRe.MatchString(u) { + return "логин bj — латинские буквы, цифры, точка, дефис и _" + } + if u != s.User { + s.User, s.Password = u, "" + } + } + if p := r.FormValue("bj_password"); p != "" { + if len(p) < 8 { + return "пароль bj — не короче 8 знаков (SQL Server не примет короче)" + } + s.Password = p + } + return "" +} + +// wizardDepoScript — POST /admin/setup/wizard-depo/script.sql: один скрипт подготовки базы файлом +// (view=1 — текстом для показа на странице). func (h *setupHandlers) wizardDepoScript(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } + view := r.FormValue("view") != "" + fail := func(msg string) { + if view { + http.Error(w, msg, http.StatusBadRequest) + return + } + wizDepoGo(w, r, "setup", msg, false) + } s, db, agent, bad := h.depoSetupForm(r) + if bad == "" { + bad = depoScriptCreds(r, &s) + } if bad != "" { - wizDepoGo(w, r, "setup", bad, false) + fail(bad) return } depoEnsureLogin(&s) s.DepoTablesMode = "script" if err := h.rc.UpdateFancy(s); err != nil { - wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false) + fail("не сохранилось: " + err.Error()) return } body := depoSetupScript(s, db, s.User, s.Password, agent) + w.Header().Set("Cache-Control", "no-store") + if view { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(body)) + return + } if !s.IsPostgres() { body = "\uFEFF" + body // SSMS без BOM читает кириллицу в комментариях как ANSI } - w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/sql; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+depoSetupFileName(s)+`"`) _, _ = w.Write([]byte(body)) @@ -354,7 +391,12 @@ func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request) if u != s.User || r.FormValue("password") != "" { s.User, s.Password = u, r.FormValue("password") } - default: // script — администратор выполнил скрипт: логин и пароль bj уже в настройках + default: // script — администратор выполнил скрипт: логин и пароль bj — с экрана (они же в настройках) + if bad := depoScriptCreds(r, &s); bad != "" { + wizDepoGo(w, r, "setup", bad, false) + return + } + s.DepoTablesMode = "script" if s.User == "" || s.Password == "" { wizDepoGo(w, r, "setup", "сначала скачайте скрипт и передайте администратору SQL", false) return