From 8905ebc6fb7c4aa2235b965b8fc6f52579e5ac1f Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 6 Oct 2026 11:42:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B0:=20=D0=BA=D0=BB=D1=8E=D1=87=20=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D1=88=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=20=D1=80=D0=B0=D0=B7,=20=D0=B7=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F,=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B8=20=D0=B8=20?= =?UTF-8?q?=C2=AB=D0=92=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B8=20=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D1=8C=C2=BB;=20DEPO=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Первая страница — только администратор (и восстановление из копии); ключ продукта — после создания администратора, на шаге «Лицензия». Консоль первой загрузки ключ больше не спрашивает (для образа с комплектом bj). - «Подготовка модулей» начинает с проверки обновлений: есть — ставит (bj перезапускается, страница ждёт и продолжает), нет — дальше; выпуск, который уже ставили, по кругу не ставится. Затем модули лицензии и переход на «Время и сеть» (шаг 12) — часовой пояс клиент выбирает сам, шаг больше не проскакивает. - Учёт DEPO одним шагом: bj сам находит базу Fancy DEPO на сервере (по таблицам OD_FACES/OD_RESTS), имя базы не спрашивается. Один скрипт создаёт логин bj, таблицы обмена и права; по выбору — задание обработки в SQL Agent или обработка на сервере bj. Выполнить сразу (логин администратора СУБД не сохраняется), скачать скрипт для администратора или подключиться готовым логином. - Восстановление из копии — только на первой странице, в мастере его больше нет. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- .gitignore | 1 + deploy/iso/bj-firstboot | 46 ++-- internal/lkgateway/depo_setup.go | 223 +++++++++++++++ internal/lkgateway/depodiscover.go | 98 +++++++ internal/lkgateway/firstrun.go | 12 +- internal/lkgateway/letters.go | 17 +- internal/lkgateway/onboarding_test.go | 45 ++- internal/lkgateway/prepare.go | 71 ++++- internal/lkgateway/setup.go | 8 +- .../web/templates/admin_prepare.html | 15 +- .../lkgateway/web/templates/admin_wizard.html | 210 ++++---------- internal/lkgateway/wizard_depo.go | 256 ++++++++++++++++-- 12 files changed, 763 insertions(+), 239 deletions(-) create mode 100644 internal/lkgateway/depo_setup.go create mode 100644 internal/lkgateway/depodiscover.go diff --git a/.gitignore b/.gitignore index 39a1e8e..b24a58e 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ DOC/*.bak.pdf # Собранный бинарь в корне /bj-server +__pycache__/ diff --git a/deploy/iso/bj-firstboot b/deploy/iso/bj-firstboot index 6d6f947..905f6f5 100755 --- a/deploy/iso/bj-firstboot +++ b/deploy/iso/bj-firstboot @@ -1,8 +1,9 @@ #!/bin/bash -# Первая загрузка сервера, установленного с образа Bridge&Join. Спрашивает ключ продукта на консоли; -# без ключа ставит ядро bj с образа (/var/lib/bj/bundle), а ключ вводится в браузере на первой -# странице настройки. Пока bj не установлен, вопрос повторяется при каждой загрузке (вопросы ждут ответа без таймера), -# адрес и подсказки всегда есть на экране входа (bj-welcome). +# Первая загрузка сервера, установленного с образа Bridge&Join: пароли учётных записей, постоянный адрес и +# установка ядра bj с образа (/var/lib/bj/bundle). Ключ продукта на консоли не спрашивается (06.10.2026): +# его вводят один раз — в браузере, после создания администратора. Пока bj не установлен, установка +# повторяется при каждой загрузке; адрес и подсказки всегда есть на экране входа (bj-welcome). +# Ключ на консоли спрашивается только у образа без комплекта bj (тогда bj качается с сервера по ключу). set -u MARK=/var/lib/bj/.bj/installed-from-iso SERVER=https://lic.zetit.ru @@ -107,11 +108,21 @@ clear printf '\n Bridge&Join: установка на сервер\n ────────────────────────────────────────────────────────────\n' printf ' Шаг 2 из 2 — установка bj. Система установлена, осталось поставить bj.\n\n' if [ -f "$BUNDLE/manifest.json" ]; then - printf ' Ключ продукта (BJ-XXXX-XXXX-XXXX) есть в портале клиента %s/portal/.\n' "$SERVER" - printf ' Вводить его здесь необязательно: нажмите Enter — ядро bj установится с образа,\n а ключ вы введёте в браузере на первой странице настройки (там его удобно вставить).\n\n' -else - printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER" + printf ' Ключ продукта вводить здесь не нужно: его спросит браузер после создания администратора.\n\n' + if install_bj --bundle "$BUNDLE" --server "$SERVER" --channel "$CHANNEL"; then + finish_quiet # адрес и код первичной настройки — на экране установки выше + show_accounts + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true + finish_screen + exit 0 + fi + show_accounts + printf ' Установка bj не завершилась. Она повторится при следующей загрузке, или сейчас вручную:\n sudo bj-installer install --bundle %s\n\n' "$BUNDLE" + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true + exit 0 fi +# образ без комплекта bj — ядро качается с сервера лицензий, для этого нужен ключ +printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER" for i in $(seq 1 30); do curl -fsS -m 5 -o /dev/null "$SERVER/healthz" && break [ "$i" = 1 ] && printf ' Жду сеть и связь с %s…\n' "$SERVER" @@ -119,26 +130,9 @@ for i in $(seq 1 30); do done while true; do CODE="" - if [ -f "$BUNDLE/manifest.json" ]; then - read -r -p " Ключ продукта (Enter — установить с образа, ключ ввести в браузере): " CODE < /dev/tty || true - else - read -r -p " Ключ продукта (Enter — установить позже): " CODE < /dev/tty || true - fi + read -r -p " Ключ продукта (Enter — установить позже): " CODE < /dev/tty || true CODE=$(printf '%s' "${CODE:-}" | tr -d ' ' | tr '[:lower:]' '[:upper:]') if [ -z "$CODE" ]; then - if [ -f "$BUNDLE/manifest.json" ]; then - if install_bj --bundle "$BUNDLE" --server "$SERVER" --channel "$CHANNEL"; then - finish_quiet # адрес и код первичной настройки — на экране установки выше - show_accounts - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true - finish_screen - exit 0 - fi - show_accounts - printf ' Установка bj не завершилась. Она повторится при следующей загрузке, или сейчас вручную:\n sudo bj-installer install --bundle %s\n\n' "$BUNDLE" - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true - exit 0 - fi show_accounts printf '\n Установка bj отложена. Вопрос повторится при следующей загрузке; вручную:\n sudo bj-installer install --code КЛЮЧ-ПРОДУКТА\n\n' read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true diff --git a/internal/lkgateway/depo_setup.go b/internal/lkgateway/depo_setup.go new file mode 100644 index 0000000..20106b9 --- /dev/null +++ b/internal/lkgateway/depo_setup.go @@ -0,0 +1,223 @@ +package lkgateway + +// Подготовка базы учёта DEPO одним действием (06.10.2026). Раньше это были три экрана мастера — логин bj, +// таблицы обмена, задание SQL Agent — и три скрипта. Теперь один скрипт делает всё: +// - находит базу DEPO на сервере сам (по учётным таблицам OD_FACES и OD_RESTS) — имя базы клиенту знать +// не нужно (SQL Server; для PostgreSQL базу назвать придётся: psql не умеет искать по чужим базам); +// - создаёт логин bj (пароль придумывает bj и запоминает) или обновляет его пароль; +// - создаёт таблицы обмена FTV_M2M* и выдаёт логину bj права: чтение учёта, запись в таблицы обмена; +// - по галочке «обработку запускает SQL Server Agent» — создаёт задание bj2depo, иначе обработку +// вызывает сам bj (тогда логину bj — право запускать процедуру обработки). +// Вариант «bj сделает сам» выполняет тот же скрипт логином администратора SQL (он не сохраняется), вариант +// «скрипт для администратора» отдаёт его файлом, вариант «логин уже есть» только проверяет. + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "strings" + "time" +) + +// depoSetupLogin — логин bj в DEPO (если в настройках уже есть допустимый — он). +func depoSetupLogin(f FancyMSSQLSettings) string { + if u := strings.TrimSpace(f.User); u != "" && depoLoginRe.MatchString(u) { + return u + } + return "bj_user" +} + +// depoGenPassword — пароль логина bj: 24 знака, есть заглавные, строчные и цифры (политика паролей SQL Server). +func depoGenPassword() string { + const up, low, dig = "ABCDEFGHJKLMNPQRSTUVWXYZ", "abcdefghijkmnpqrstuvwxyz", "23456789" + all := up + low + dig + pick := func(set string) byte { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(set)))) + return set[n.Int64()] + } + b := []byte{pick(up), pick(low), pick(dig)} + for len(b) < 24 { + b = append(b, pick(all)) + } + for i := len(b) - 1; i > 0; i-- { // перемешать, чтобы обязательные знаки не стояли в начале + j, _ := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + b[i], b[j.Int64()] = b[j.Int64()], b[i] + } + return string(b) +} + +// sqlStr — строковый литерал T-SQL / PostgreSQL (одинарная кавычка удваивается). +func sqlStr(s string) string { return strings.ReplaceAll(s, "'", "''") } + +// depoSetupScript — один скрипт подготовки базы DEPO. db — имя базы (пусто — найти на сервере; только SQL +// Server), agentJob — создать задание SQL Server Agent (иначе обработку вызывает bj). +func depoSetupScript(f FancyMSSQLSettings, db, login, pass string, agentJob bool) string { + if f.IsPostgres() { + return depoSetupScriptPG(f, db, login, pass) + } + var b strings.Builder + job := "обработку вызывает сам bj" + if agentJob { + 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 +DECLARE @found int; +IF @db = N'' +BEGIN + SELECT @found = COUNT(*) FROM sys.databases WHERE state = 0 AND database_id > 4 + AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_FACES') IS NOT NULL AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_RESTS') IS NOT NULL; + IF @found = 1 + SELECT @db = name FROM sys.databases WHERE state = 0 AND database_id > 4 + AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_FACES') IS NOT NULL AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_RESTS') IS NOT NULL; +END +IF @db = N'' OR DB_ID(@db) IS NULL +BEGIN + RAISERROR(N'База DEPO не найдена или на сервере их несколько. Впишите имя базы DEPO в @db в начале скрипта и запустите снова.', 16, 1); + RETURN; +END +PRINT N'База DEPO: ' + @db; +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)) +ELSE + EXEC(N'ALTER LOGIN [%[1]s] WITH PASSWORD = N''%[4]s'''); + +-- 2. Таблицы обмена FTV_M2M* (только недостающие таблицы, колонки и индексы — данные не меняются) +`, login, job, sqlStr(db), sqlStr(sqlStr(pass))) + for _, s := range depoTablesDDL(false) { + fmt.Fprintf(&b, "EXEC(@in + N'%s');\n", sqlStr(s)) + } + b.WriteString("\n-- 3. Права логину bj в базе DEPO: чтение учёта (сверка переводов), запись в таблицы обмена\n") + for _, s := range depoTablesGrantSQL(false, login) { + fmt.Fprintf(&b, "EXEC(@in + N'%s');\n", sqlStr(s)) + } + proc := depoJobProcName(f) + if agentJob { + fmt.Fprintf(&b, ` +-- 4. Задание SQL Server Agent «%[1]s»: каждые %[3]d с вызывает %[2]s — записи bj становятся документами учёта +IF NOT EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'%[1]s') +BEGIN + EXEC msdb.dbo.sp_add_job @job_name = N'%[1]s', @enabled = 1, @description = N'bj2depo: обработка таблиц обмена M2M (Bridge&Join)'; + EXEC msdb.dbo.sp_add_jobstep @job_name = N'%[1]s', @step_name = N'%[1]s', @subsystem = N'TSQL', @database_name = @db, @command = N'exec %[2]s;'; + EXEC msdb.dbo.sp_add_jobschedule @job_name = N'%[1]s', @name = N'%[1]s', @enabled = 1, @freq_type = 4, @freq_interval = 1, @freq_subday_type = 2, @freq_subday_interval = %[3]d, @active_start_time = 0; + EXEC msdb.dbo.sp_add_jobserver @job_name = N'%[1]s'; + PRINT N'Задание %[1]s создано.'; +END +ELSE + PRINT N'Задание %[1]s уже есть — не изменялось.'; +-- логину bj — чтение списка заданий и их истории: bj проверяет, что задание работает +EXEC(N'USE msdb; IF USER_ID(N''%[4]s'') IS NULL CREATE USER [%[4]s] FOR LOGIN [%[4]s]; ELSE IF EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N''%[4]s'' AND sid <> SUSER_SID(N''%[4]s'')) ALTER USER [%[4]s] WITH LOGIN = [%[4]s]; IF IS_ROLEMEMBER(N''SQLAgentReaderRole'', N''%[4]s'') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[4]s];'); +`, depoJobName, proc, depoJobEvery(f), login) + } else { + fmt.Fprintf(&b, ` +-- 4. Обработку вызывает bj: логину bj — право запускать процедуру %[1]s +EXEC(@in + N'IF OBJECT_ID(N''%[1]s'', N''P'') IS NOT NULL GRANT EXECUTE ON %[1]s TO [%[2]s] ELSE PRINT N''Процедуры обработки ещё не установлены: после их установки выполните GRANT EXECUTE ON %[1]s TO [%[2]s]'''); +`, proc, login) + } + b.WriteString("\nPRINT N'Готово: база DEPO подготовлена для bj.';\n") + return b.String() +} + +// depoSetupScriptPG — то же для PostgreSQL (psql: роль, подключение к базе DEPO, таблицы, права). +func depoSetupScriptPG(f FancyMSSQLSettings, db, login, pass string) string { + var b strings.Builder + fmt.Fprintf(&b, `-- Bridge&Join: подготовка базы учёта DEPO для bj. PostgreSQL 10 и новее. +-- Запуск суперпользователем или владельцем базы: psql -h <сервер> -U postgres -f bj-depo-setup-postgres.sql +-- Создаёт роль bj (%[2]s), таблицы обмена FTV_M2M* и права (чтение учёта, запись только в таблицы обмена). +-- Обработку обмена вызывает сам bj. Учётные данные скрипт не меняет. Можно запускать повторно. +-- Скрипт содержит пароль роли bj — после выполнения файл удалите. +\set ON_ERROR_STOP on + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '%[2]s') THEN + CREATE ROLE "%[2]s" LOGIN PASSWORD '%[3]s'; + ELSE + ALTER ROLE "%[2]s" LOGIN PASSWORD '%[3]s'; + END IF; +END $$; +GRANT CONNECT ON DATABASE "%[1]s" TO "%[2]s"; + +\c "%[1]s" +`, db, login, sqlStr(pass)) + for _, s := range depoTablesDDL(true) { + b.WriteString(s + ";\n") + } + for _, s := range depoTablesGrantSQL(true, login) { + b.WriteString(s + ";\n") + } + proc := depoJobProcName(f) + fmt.Fprintf(&b, "DO $$ BEGIN IF to_regproc('%[1]s') IS NOT NULL THEN EXECUTE 'GRANT EXECUTE ON FUNCTION %[1]s TO \"%[2]s\"'; END IF; END $$;\n", strings.ToLower(proc), login) + return b.String() +} + +// depoSetupFileName — имя файла скрипта. +func depoSetupFileName(f FancyMSSQLSettings) string { + if f.IsPostgres() { + return "bj-depo-setup-postgres.sql" + } + return "bj-depo-setup-mssql.sql" +} + +// depoSetupRun — «bj сделает сам»: тот же скрипт выполняется логином администратора (не сохраняется). +// Возвращает найденную базу DEPO. +func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, adminPass, login, pass string, agentJob bool) (string, error) { + adm := f + adm.User, adm.Password = strings.TrimSpace(adminUser), adminPass + if db == "" { + found, err := depoDiscover(ctx, adm) + switch { + case err != nil: + return "", fmt.Errorf("список баз сервера логином %s: %s", adm.User, quikErrorDetail(err)) + case len(found) == 0: + return "", fmt.Errorf("на сервере нет базы учёта DEPO (с таблицами %s), доступной логину %s", strings.Join(depoMarkerTables, " и "), adm.User) + case len(found) > 1: + return "", fmt.Errorf("на сервере несколько баз DEPO (%s) — укажите нужную в поле «База DEPO»", strings.Join(found, ", ")) + } + db = found[0] + } + if f.IsPostgres() { + return db, depoSetupRunPG(ctx, adm, db, login, pass) + } + conn, err := fancyOpen(adm, "") + if err != nil { + return db, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + if _, err := conn.ExecContext(cctx, depoSetupScript(f, db, login, pass, agentJob)); err != nil { + return db, fmt.Errorf("подготовка базы %s логином %s: %s%s", db, adm.User, quikErrorDetail(err), depoTablesRightsHint(f, adm.User, err)) + } + return db, nil +} + +func depoSetupRunPG(ctx context.Context, adm FancyMSSQLSettings, db, login, pass string) error { + conn, err := fancyOpen(adm, db) + if err != nil { + return err + } + defer conn.Close() + stmts := []string{ + fmt.Sprintf(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '%[1]s') THEN CREATE ROLE "%[1]s" LOGIN PASSWORD '%[2]s'; ELSE ALTER ROLE "%[1]s" LOGIN PASSWORD '%[2]s'; END IF; END $$`, login, sqlStr(pass)), + fmt.Sprintf(`GRANT CONNECT ON DATABASE "%s" TO "%s"`, db, login), + } + stmts = append(stmts, depoTablesDDL(true)...) + stmts = append(stmts, depoTablesGrantSQL(true, login)...) + for _, s := range stmts { + if _, err := conn.ExecContext(ctx, s); err != nil { + return fmt.Errorf("%s → %s", depoStmtHead(s), quikErrorDetail(err)) + } + } + return nil +} diff --git a/internal/lkgateway/depodiscover.go b/internal/lkgateway/depodiscover.go new file mode 100644 index 0000000..3521e0a --- /dev/null +++ b/internal/lkgateway/depodiscover.go @@ -0,0 +1,98 @@ +package lkgateway + +// Поиск базы DEPO на сервере (05.10.2026). Клиент не знает и не должен знать, как называется база учёта у +// него на сервере: на шаге «Подключение» он вводит только адрес, логин и пароль, а bj сам находит базы с +// учётными таблицами Fancy DEPO (лица OD_FACES и остатки OD_RESTS), к которым у этого логина есть доступ. +// Одна база — выбирается сразу, несколько — мастер предлагает выбрать, ни одной — понятная ошибка. + +import ( + "context" + "fmt" + "strings" + "time" +) + +// depoMarkerTables — по каким таблицам база опознаётся как DEPO (Fancy). +var depoMarkerTables = []string{"OD_FACES", "OD_RESTS"} + +// depoDiscoverMax — сколько баз сервера просматривать (на сервере могут быть десятки баз). +const depoDiscoverMax = 60 + +// depoDiscover — базы DEPO на сервере, доступные этому логину. +func depoDiscover(ctx context.Context, s FancyMSSQLSettings) ([]string, error) { + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if s.IsPostgres() { + return depoDiscoverPG(cctx, s) + } + conn, err := fancyOpen(s, "") + if err != nil { + return nil, err + } + defer conn.Close() + rows, err := conn.QueryContext(cctx, `SELECT name FROM sys.databases + WHERE state = 0 AND database_id > 4 AND HAS_DBACCESS(name) = 1 ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("список баз сервера: %w", err) + } + var names []string + for rows.Next() { + var n string + if rows.Scan(&n) == nil && validDBName(n) && len(names) < depoDiscoverMax { + names = append(names, n) + } + } + rows.Close() + var found []string + for _, n := range names { + // имя проверено validDBName — подставлять в [ ] безопасно + q := "SELECT CASE WHEN " + strings.Join(func() []string { + var c []string + for _, t := range depoMarkerTables { + c = append(c, "OBJECT_ID(N'["+n+"].dbo."+t+"') IS NOT NULL") + } + return c + }(), " AND ") + " THEN 1 ELSE 0 END" + var ok int + if conn.QueryRowContext(cctx, q).Scan(&ok) == nil && ok == 1 { + found = append(found, n) + } + } + return found, nil +} + +func depoDiscoverPG(ctx context.Context, s FancyMSSQLSettings) ([]string, error) { + conn, err := fancyOpen(s, "") + if err != nil { + return nil, err + } + defer conn.Close() + rows, err := conn.QueryContext(ctx, `SELECT datname FROM pg_database + WHERE datallowconn AND NOT datistemplate AND has_database_privilege(datname, 'CONNECT') ORDER BY datname`) + if err != nil { + return nil, fmt.Errorf("список баз сервера: %w", err) + } + var names []string + for rows.Next() { + var n string + if rows.Scan(&n) == nil && validDBName(n) && n != "postgres" && len(names) < depoDiscoverMax { + names = append(names, n) + } + } + rows.Close() + var found []string + for _, n := range names { + db, err := fancyOpen(s, n) + if err != nil { + continue + } + var cnt int + err = db.QueryRowContext(ctx, `SELECT count(DISTINCT lower(table_name)) FROM information_schema.tables + WHERE lower(table_name) = ANY($1::text[])`, "{"+strings.ToLower(strings.Join(depoMarkerTables, ","))+"}").Scan(&cnt) + db.Close() + if err == nil && cnt == len(depoMarkerTables) { + found = append(found, n) + } + } + return found, nil +} diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go index 3fecc28..ab37367 100644 --- a/internal/lkgateway/firstrun.go +++ b/internal/lkgateway/firstrun.go @@ -161,7 +161,8 @@ func registerFirstRun(mux *http.ServeMux) { s := globalSessions.New(u.Login, ip, r.UserAgent()) http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())}) log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip) - // ключ продукта, если его не вводили на сервере: активируем сразу, чтобы модули и обновления заработали + // ключ продукта — следующим экраном (06.10.2026: ключ спрашивается один раз, после администратора); + // поле сохранено для старых страниц, открытых до обновления if d.NeedKey && d.ProductKey != "" && globalRC != nil { actx, acancel := context.WithTimeout(r.Context(), 30*time.Second) err := LicenseActivate(actx, globalRC, d.ProductKey) @@ -171,14 +172,15 @@ func registerFirstRun(mux *http.ServeMux) { http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Ключ продукта не принят: "+err.Error()+". Проверьте его и введите ещё раз."), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/setup/prepare?next=/admin/wizard&flash="+url.QueryEscape("Администратор создан, ключ продукта принят."), http.StatusSeeOther) + http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape("/admin/wizard?step=12")+"&flash="+url.QueryEscape("Администратор создан, ключ продукта принят."), http.StatusSeeOther) return } if d.NeedKey { - http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Начнём с лицензии: без ключа продукта модули и обновления недоступны (шаг можно пропустить)."), http.StatusSeeOther) + http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Теперь ключ продукта — он есть в портале клиента lic.zetit.ru/portal: после него bj проверит обновления и поставит модули вашей лицензии."), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/wizard", http.StatusSeeOther) + // ключ уже есть (введён при установке) — сразу обновления и модули, потом «Время и сеть» + http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape("/admin/wizard?step=12")+"&flash="+url.QueryEscape("Администратор создан."), http.StatusSeeOther) return } w.WriteHeader(http.StatusUnauthorized) @@ -213,7 +215,7 @@ code{font-size:12px} -{{if .NeedKey}}{{end}} +{{/* ключ продукта здесь не спрашивается: его спрашивает следующий экран — один раз, после создания администратора */}} {{if .Error}}
{{.Error}}
{{end}}
Код потерян или истёк: на сервере выполните sudo bj-installer first-run-code.
diff --git a/internal/lkgateway/letters.go b/internal/lkgateway/letters.go index f958258..1cb5218 100644 --- a/internal/lkgateway/letters.go +++ b/internal/lkgateway/letters.go @@ -73,17 +73,18 @@ func letterFor(s Settings, key string) (Letter, bool) { case "dba": ip := pickStr(outboundIP(s.Fancy.Server, s.Fancy.Port), firstServerIP()) f := s.Fancy - login := "bj-depo-login-mssql.sql" - job := "3. Создать задание SQL Server Agent обработки обмена — скрипт bj-depo-job-mssql.sql. Задание вызывает процедуры обработки FTV_M2M_JOB и FTV_M2M_PROCESS_* (поставляются отдельно; устанавливаются в базу DEPO в порядке WIRING → OPERATION → EVENTS → CREATE → JOB)." - dbms := "SQL Server" + what := "— создаёт задание SQL Server Agent «" + depoJobName + "» для обработки обмена;\n" + run := "под логином с правами sysadmin (SSMS или sqlcmd)" if f.IsPostgres() { - login, dbms = "bj-depo-login-postgres.sql", "PostgreSQL" - job = "3. Выдать логину bj право запускать процедуру обработки обмена (её вызывает сам bj) — строка в мастере, раздел «Учёт DEPO» → «Обработка». Процедуры обработки FTV_M2M_* поставляются отдельно." + what = "" + run = "суперпользователем: psql -h <сервер> -U postgres -f " + depoSetupFileName(f) + } else if f.DepoJobMode == "bj" { + what = "— даёт логину bj право запускать процедуру обработки обмена;\n" } return Letter{Key: key, Title: "Письмо администратору базы DEPO", To: "Администратор СУБД учёта DEPO", - Subject: "Bridge&Join: доступ к базе DEPO " + f.DepoDB(), - Body: fmt.Sprintf("Коллеги, добрый день!\n\nДля подключения системы переводов ценных бумаг Bridge&Join к учёту DEPO нужно на сервере %s (база %s):\n\n1. Создать логин для bj с минимальными правами — скрипт %s: чтение учётных таблиц и запись только в таблицы обмена.\n2. Создать таблицы обмена FTV_M2M* — скрипт %s. Учётные данные они не меняют.\n%s\n4. Разрешить подключение к %s с сервера Bridge&Join (адрес %s).\n\nСкрипты прилагаются (скачиваются в мастере настройки bj, раздел «Учёт DEPO»). Все скрипты можно запускать повторно: если объект уже есть, он не меняется.%s", - orBracket(f.Server, "сервер DEPO"), f.DepoDB(), login, depoTablesFileName(f), job, dbms, orBracket(ip, "IP-адрес сервера bj"), sign)}, true + 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", + 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 610e4f0..14442bd 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -307,14 +307,22 @@ func TestPrepareScreen(t *testing.T) { t.Fatalf("нет %q", want) } } - for in, want := range map[string]string{"": "/admin/wizard", "/admin/setup?x=1": "/admin/setup?x=1", "https://evil": "/admin/wizard", "//evil/admin/": "/admin/wizard", "/admin/wizard\r\nX: y": "/admin/wizard"} { + const def = "/admin/wizard?step=12" // после ключа и модулей — «Время и сеть» + for in, want := range map[string]string{"": def, "/admin/setup?x=1": "/admin/setup?x=1", "https://evil": def, "//evil/admin/": def, "/admin/wizard\r\nX: y": def} { if got := safeNext(in); got != want { t.Errorf("safeNext(%q) = %q, ожидалось %q", in, got, want) } } rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") - if items := prepareItems(rc, false, false, ""); len(items) != 1 || items[0].State != "ok" { - t.Fatalf("без лицензии — только ядро: %+v", items) + if items := prepareItems(rc, false, false, ""); len(items) != 2 || items[0].Title != "Обновление bj" || items[0].State != "wait" || items[1].State != "ok" { + t.Fatalf("без лицензии — обновление (ещё не проверялось) и ядро: %+v", items) + } + // выпуск, который подготовка уже ставила, после перезапуска bj не ставится по кругу + oldTried := prepareTriedFile + prepareTriedFile = t.TempDir() + "/tried" + defer func() { prepareTriedFile = oldTried }() + if prepareTried("2026.10.06.1") || !prepareTried("2026.10.06.1") || prepareTried("2026.10.06.2") { + t.Fatal("prepareTried: первый раз — ставить, второй раз тот же выпуск — нет, новый выпуск — ставить") } } @@ -338,3 +346,34 @@ func TestFromLocalBundle(t *testing.T) { t.Fatal("другой выпуск — качать с сервера, а не брать из образа") } } + +// Один скрипт подготовки базы DEPO: сам ищет базу, создаёт логин, таблицы, права и (по выбору) задание. +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"} { + if !strings.Contains(s, want) { + t.Fatalf("в скрипте нет %q", want) + } + } + if strings.Contains(s, "BJ_DEPO_DATA") || strings.Contains(s, "\nGO") { + t.Fatal("в скрипте — имя нашей базы или GO (переменные @db между пакетами не живут)") + } + // каждая строка EXEC(@in + N'…') — закрытый литерал: чётное число кавычек + for _, line := range strings.Split(s, "\n") { + if strings.HasPrefix(line, "EXEC(") && strings.HasSuffix(line, "');") && strings.Count(line, "'")%2 != 0 { + t.Fatalf("нечётные кавычки: %s", line) + } + } + bj := depoSetupScript(f, "DEPO_X", "bj_user", "Pass1word", false) + if strings.Contains(bj, "sp_add_job") || !strings.Contains(bj, "GRANT EXECUTE ON dbo.FTV_M2M_JOB") || !strings.Contains(bj, "N'DEPO_X'") { + t.Fatal("обработка силами bj: без задания, с правом на процедуру и указанной базой") + } + pg := depoSetupScript(FancyMSSQLSettings{DBType: "postgres"}, "depo", "bj_user", "p'w", false) + if !strings.Contains(pg, `\c "depo"`) || !strings.Contains(pg, "PASSWORD 'p''w'") { + t.Fatal("PostgreSQL: подключение к базе и экранирование пароля") + } + if p := depoGenPassword(); len(p) != 24 || !strings.ContainsAny(p, "ABCDEFGHJKLMNPQRSTUVWXYZ") || !strings.ContainsAny(p, "23456789") { + t.Fatalf("пароль: %q", p) + } +} diff --git a/internal/lkgateway/prepare.go b/internal/lkgateway/prepare.go index 238d77d..7594db8 100644 --- a/internal/lkgateway/prepare.go +++ b/internal/lkgateway/prepare.go @@ -29,14 +29,25 @@ var prepareRun struct { sync.Mutex Running, Finished bool Result, Err string + UpdState, UpdText string // этап «Обновление bj»: пусто — ещё не проверялось в этом запуске bj } +// prepareBoot — метка запуска bj: страница видит перезапуск (после обновления ядра) и продолжает подготовку. +var prepareBoot = time.Now().UnixNano() + // prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет). var prepareCoreFeatures = []string{"quik-mnp", "esia-finance"} // prepareItems — состав лицензии этой установки и состояние каждого модуля. func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem { - out := []PrepareItem{{Title: "Ядро bj: обмен с НРД, переводы M2M", State: "ok", Detail: "установлено, версия " + BuildVersion}} + prepareRun.Lock() + us, ut := prepareRun.UpdState, prepareRun.UpdText + prepareRun.Unlock() + if us == "" { + us, ut = "wait", "проверю, есть ли обновление" + } + out := []PrepareItem{{Title: "Обновление bj", State: us, Detail: ut}, + {Title: "Ядро bj: обмен с НРД, переводы M2M", State: "ok", Detail: "установлено, версия " + BuildVersion}} for _, f := range prepareCoreFeatures { if moduleAllowed(rc, f) { out = append(out, PrepareItem{Title: moduleTitles[f], State: "ok", Detail: "входит в ядро — устанавливать нечего"}) @@ -160,6 +171,7 @@ func prepareStart(rc *RuntimeConfig) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) defer cancel() + prepareUpdates(ctx, rc) // сначала — обновления (при обновлении ядра bj перезапустится, страница продолжит) var msg, errText string if _, err := os.Stat(keymediaReqDir); err != nil { errText = "на сервере нет службы установки (bj-keymedia)" @@ -174,12 +186,60 @@ func prepareStart(rc *RuntimeConfig) { }() } +// prepareUpdates — первый этап подготовки: есть обновление — поставить, нет — дальше. +func prepareUpdates(ctx context.Context, rc *RuntimeConfig) { + set := func(state, text string) { + prepareRun.Lock() + prepareRun.UpdState, prepareRun.UpdText = state, text + prepareRun.Unlock() + } + set("run", "проверяю, есть ли обновление…") + if _, err := os.Stat(keymediaReqDir); err != nil { + set("warn", "на сервере нет службы установки (bj-keymedia) — обновления не ставятся") + return + } + u := NewUpdater(rc) + cctx, cancel := context.WithTimeout(ctx, 90*time.Second) + st, err := u.CheckForUpdate(cctx) + cancel() + switch { + case err != nil: + set("warn", "проверить обновления не удалось ("+err.Error()+") — продолжаю с установленной версией") + case !st.Configured: + set("warn", st.Message) + case !st.HasUpdate: + set("ok", "обновлений нет, установлена последняя версия "+BuildVersion) + case prepareTried(st.Available): + // уже ставили этот выпуск при подготовке (bj перезапускался) — не по кругу + set("warn", "выпуск "+st.Available+" поставлен не полностью: "+st.Message+" — доустановить можно в «Настройка → Обновления»") + default: + set("run", "есть обновление "+st.Available+" — ставлю; если обновится ядро, bj перезапустится, страница подождёт") + if msg, err := u.ApplyUpdate(ctx, nil); err != nil { + set("warn", "обновление не установилось: "+err.Error()+" — продолжаю с установленной версией") + } else { + set("ok", "обновление "+st.Available+" установлено: "+msg) + } + } +} + +// prepareTriedFile — какой выпуск подготовка уже ставила: после перезапуска bj тот же выпуск не ставится снова. +var prepareTriedFile = "/var/lib/bj/.bj/prepare-update-tried" + +func prepareTried(ver string) bool { + b, _ := os.ReadFile(prepareTriedFile) + if strings.TrimSpace(string(b)) == ver { + return true + } + _ = os.WriteFile(prepareTriedFile, []byte(ver+"\n"), 0o640) + return false +} + // safeNext — куда идти после подготовки: только свои страницы (как return_to). func safeNext(v string) string { if strings.HasPrefix(v, "/admin/") && !strings.HasPrefix(v, "//") && !strings.ContainsAny(v, "\r\n") { return v } - return "/admin/wizard" + return "/admin/wizard?step=12" } // PrepareData — данные страницы «Подготовка модулей». @@ -240,7 +300,12 @@ func registerPrepare(mux *http.ServeMux, h *setupHandlers) { busy = true } } - out := map[string]any{"running": running, "finished": finished, "items": items, "pending": pending, "busy": busy, "result": res, "error": errText} + prepareRun.Lock() + if prepareRun.UpdState == "" { + pending = true // обновления в этом запуске bj ещё не проверялись + } + prepareRun.Unlock() + out := map[string]any{"running": running, "finished": finished, "items": items, "pending": pending, "busy": busy, "result": res, "error": errText, "boot": prepareBoot} if running && pg.Active { out["stage"], out["done"], out["total"] = pg.Stage, pg.Done, pg.Total } diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 5690f90..e5d23cc 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -73,6 +73,8 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/wizard-depo/tables", h.wizardDepoTables) mux.HandleFunc("/admin/setup/wizard-depo/job", h.wizardDepoJob) mux.HandleFunc("/admin/setup/wizard-depo/recheck", h.wizardDepoRecheck) + mux.HandleFunc("/admin/setup/wizard-depo/setup", h.wizardDepoSetup) // подготовка базы одним действием + mux.HandleFunc("/admin/setup/wizard-depo/script.sql", h.wizardDepoScript) // один скрипт для администратора mux.HandleFunc("/admin/setup/fancy/acc-prefix", h.accPrefix) mux.HandleFunc("/admin/setup/license", h.saveLicense) mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate) @@ -173,6 +175,7 @@ type WizardData struct { DepoJobDef string // способ обработки по умолчанию для мастера Fancy *SetupData // шаг 3: общий блок «Обмен Fancy» (тот же, что в настройках) Sub string // экран шага 3 (учёт DEPO): intro|conn|tables|job|done + DepoCands []string // шаг 3: найдено несколько баз DEPO — выбор ValidataVer string // версия установленной «Валидаты Клиент L» (пусто — не установлена) CryptoSvc string // состояние службы bj-crypto (systemctl is-active) PGSummary string // куда подключена база bj, без пароля @@ -319,6 +322,7 @@ 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")) if r.URL.Query().Get("sub") == "" && d.Done.Depo { d.Sub = "done" } @@ -1428,8 +1432,8 @@ func (h *setupHandlers) licenseActivate(w http.ResponseWriter, r *http.Request) } // дальше — «Подготовка модулей»: модули лицензии ставятся и запускаются, затем возврат туда, откуда пришли next := safeReturnPath(r.FormValue("return_to")) - if strings.HasPrefix(next, "/admin/wizard") { - next = "/admin/wizard" // мастер сам откроется на первом несделанном шаге — теперь с шагами модулей лицензии + if next == "" || strings.HasPrefix(next, "/admin/wizard") { + next = "/admin/wizard?step=12" // после ключа — «Время и сеть»: часовой пояс клиент выбирает сам } http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape(next), http.StatusSeeOther) } diff --git a/internal/lkgateway/web/templates/admin_prepare.html b/internal/lkgateway/web/templates/admin_prepare.html index 1364aee..63dfbf4 100644 --- a/internal/lkgateway/web/templates/admin_prepare.html +++ b/internal/lkgateway/web/templates/admin_prepare.html @@ -18,12 +18,12 @@

Подготовка модулей

-

Ключ принят: {{.Tenant}}. bj ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.

+

Ключ принят: {{.Tenant}}. bj проверяет обновления, ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.

{{if .Flash}}

✓ {{.Flash}}

{{end}}

Лицензия: план {{.Plan}}, {{.Until}}

-

Проверяю состав лицензии…

+

Проверяю обновления и состав лицензии…

@@ -34,7 +34,7 @@ (function(){ var rows=document.getElementById('prep-rows'), sum=document.getElementById('prep-sum'), nxt=document.getElementById('prep-next'); var prog=document.getElementById('prep-prog'), stage=document.getElementById('prep-stage'), fill=document.getElementById('prep-fill'); - var next={{.Next}}, started=false, redirect=null; + var next={{.Next}}, started=false, redirect=null, boot=null, lost=0; function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})} var ic={ok:'✓',run:'',wait:'○',warn:'⚠',err:'✗',info:'ⓘ'}; function mb(n){return (n/1048576).toFixed(1).replace('.',',')+' МБ'} @@ -43,7 +43,7 @@ if(d.running && d.stage){ prog.style.display=''; stage.textContent=d.stage+(d.total?' — '+mb(d.done)+' из '+mb(d.total):''); fill.style.transform='scaleX('+(d.total?Math.min(1,d.done/d.total):0.15)+')'; } else prog.style.display='none'; var bad=d.items.filter(function(it){return it.state==='err'}).length, warn=d.items.filter(function(it){return it.state==='warn'}).length; - if(d.running || d.busy || (d.pending && !d.finished)){ sum.innerHTML='Ставлю и запускаю модули… обычно меньше минуты'; nxt.style.display='none'; return false; } + if(d.running || d.busy || (d.pending && !d.finished)){ sum.innerHTML='Обновления и модули… обычно пара минут'; nxt.style.display='none'; return false; } nxt.style.display=''; if(bad){ sum.innerHTML='✗ Не всё установилось — продолжить можно, модули поставятся из «Настройка → Обновления»'; return true; } if(warn){ sum.innerHTML='✓ Готово — есть замечание (⚠), продолжать можно'; return true; } @@ -53,9 +53,14 @@ } function poll(){ fetch('/admin/setup/prepare/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){ + lost=0; + if(boot!==null && d.boot!==boot){ started=false; if(redirect){ clearTimeout(redirect); redirect=null; } } // bj перезапустился после обновления — продолжаем + boot=d.boot; if(!started && d.pending && !d.running && !d.finished){ started=true; fetch('/admin/setup/prepare/start',{method:'POST',cache:'no-store'}).then(function(){ setTimeout(poll,600); }); draw(d); return; } if(!draw(d)) setTimeout(poll,1500); - }).catch(function(){ setTimeout(poll,3000); }); + }).catch(function(){ // bj перезапускается (обновилось ядро) — ждём + if(++lost>1){ prog.style.display='none'; sum.innerHTML=' bj перезапускается после обновления — подождите…'; nxt.style.display='none'; } + setTimeout(poll,3000); }); } poll(); })(); diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index deb625a..4ef7ca5 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -49,63 +49,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{/* ============= ШАГ 1: Лицензия ============= */}} {{if eq .Step 1}} -
-
- Переустановка сервера? Восстановить из резервной копии -

Если у вас есть резервная копия bj на сетевом хранилище (Настройка → Резервное копирование), можно восстановить её вместо настройки с нуля: вернутся настройки, пользователи, лицензия, журнал заявок, база и папка шлюза НРД. Текущие данные этого сервера будут заменены. bj и шлюз НРД на время восстановления остановятся — обычно 1–3 минуты.

- -
-
- - -
-
- - -
-
- -
-
- -
-
+{{/* восстановление из резервной копии — только на первой странице (/first-run), в мастере его нет */}}

Лицензия

Что это. Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.
@@ -148,117 +92,57 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }

Модуль обмена с DEPO и QUIK не входит в текущую лицензию — шаг пропускается.

{{else}} {{$sub := .Sub}} -
{{range $i, $x := depoSubs}}{{add $i 1}}. {{$x.Title}}{{end}}
+ {{if eq $sub "setup"}} +
Что это. bj подключается к базе учёта DEPO (Fancy): сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и передаёт в DEPO поручения и их исполнение — документы в учёте создаются сами.
+ Что понадобится. Адрес сервера базы DEPO. Имя базы знать не нужно — bj найдёт её сам. Подготовить базу (логин bj, таблицы обмена, задание обработки) можно одним действием: логином администратора SQL — он используется один раз и не сохраняется, — или администратор выполнит один готовый скрипт.
+ {{$mode := .Settings.Fancy.DepoJobMode}} +
+
+ + + +
+ {{if .DepoCands}}{{end}} + - {{if eq $sub "intro"}} -
Что это. Подключение bj к базе депозитарного учёта DEPO (Fancy).
- Зачем. bj сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и сам передаёт в DEPO поручения и их исполнение — документы в учёте создаются автоматически. Отсюда же bj возьмёт реквизиты вашей организации (раздел «Организация»).
- Что понадобится. Адрес сервера базы DEPO, логин и пароль — их даёт ваш администратор баз данных. Для создания таблиц обмена и джобы обработки может понадобиться разово логин администратора SQL — он не сохраняется. В базе DEPO должны быть процедуры обработки обмена (расширение bj2depo) и версия Fancy DEPO с поддержкой M2M — bj проверит их по имени и скажет, чего не хватает.
- Как пойдём. Подключение → таблицы обмена → обработка → проверка. Каждый шаг проверяется сразу.
- {{template "letterCard" (letter "dba")}} - +
Обработку обмена в DEPO запускает
+ + +
- {{else if eq $sub "conn"}} -

Адрес сервера учётной базы DEPO и логин, под которым bj будет с ней работать. По «Далее» bj сохранит и сразу проверит подключение.

- -
-
-
-
-
-
-
- Логина для bj ещё нет? Как создать -
-

bj нужен отдельный логин в базе DEPO. Его создаёт администратор СУБД. Права минимальные:

-
    -
  • чтение учётных таблиц DEPO (лица, счета, остатки, бумаги) — для сверки переводов;
  • -
  • запись только в таблицы обмена FTV_M2M* — их права выдаются на следующем экране, вместе с созданием таблиц.
  • -
-

Вариант 1 — администратор создаёт логин сам с этими правами. Вариант 2 — передайте ему скрипт: в нём подставлены тип СУБД, база, имя логина и пароль из полей выше (если пароль не введён — впишите его в скрипт сами). Скрипт с паролем не храните.

- Скачать скрипт создания логина -
…
-

После выполнения скрипта впишите логин и пароль в поля выше и нажмите «Проверить и далее».

+
Как подготовить базу
+ +
+ + +
+ + + + -
- - -
← Назад
-
- - {{else if eq $sub "tables"}} -

Четыре таблицы обмена FTV_M2M* в базе {{.DepoTables.DB}}: через них bj передаёт поручения и события в DEPO. Данные учёта они не трогают. Кто их создаёт?

-
- - -
- -
- -
← Назад
+ {{if not .DepoCands}}
Дополнительно: указать базу DEPO вручную (обычно не нужно) +
{{end}} +
- - - {{else if eq $sub "job"}} -

Процедура {{.DepoJob.Proc}} в базе DEPO каждые {{.DepoJob.Every}} с превращает записи bj в документы и операции учёта. Кто её запускает?

- {{if .DepoEdition}}

Ваш сервер: {{.DepoEdition}} — {{if .DepoAgent}}есть SQL Server Agent, рекомендуем первый вариант.{{else}}без SQL Server Agent, подходит вариант «запускает bj».{{end}}

{{end}} - {{$mode := .Settings.Fancy.DepoJobMode}}{{if not $mode}}{{$mode = .DepoJobDef}}{{end}}{{if not $mode}}{{$mode = "sql-auto"}}{{end}} -
- {{if not .DepoTables.Postgres}}{{end}} - - {{if not .DepoTables.Postgres}}{{end}} -
- - -
-

Нет логина администратора SQL или его не хотят давать — выберите ниже «Джоба SQL Server Agent, создаёт администратор по скрипту»: тот же результат, скрипт выполняет администратор.

- - - -
← Назад
-

Проверка: джоба есть, включена, есть расписание и она запускалась за последнюю минуту. Работают ли процедуры обработки — отдельная проверка на экране «Готово».

-

Дальше без проверки → — если обработку ещё настраивает администратор (например, ставит процедуры). Вернуться и проверить можно с экрана «Готово» кнопкой «Перепроверить всё».

-
- + {{else if eq $sub "done"}} diff --git a/internal/lkgateway/wizard_depo.go b/internal/lkgateway/wizard_depo.go index f18a1d7..84fd7e1 100644 --- a/internal/lkgateway/wizard_depo.go +++ b/internal/lkgateway/wizard_depo.go @@ -9,17 +9,17 @@ import ( "context" "net/http" "net/url" + "strconv" "strings" "time" ) // depoWizardSubs — экраны шага 3 по порядку. +// Шаг 3 — два экрана (06.10.2026): подготовка базы одним действием и проверка. Прежние адреса экранов +// (intro, conn, tables, job) ведут на подготовку. var depoWizardSubs = []struct{ Key, Title string }{ - {"intro", "Что это"}, - {"conn", "Подключение"}, - {"tables", "Таблицы обмена"}, - {"job", "Обработка"}, - {"done", "Готово"}, + {"setup", "Подключение и подготовка базы"}, + {"done", "Проверка"}, } func depoWizardSub(v string) string { @@ -28,11 +28,14 @@ func depoWizardSub(v string) string { return v } } - return "intro" + return "setup" } // wizDepoGo — перейти на экран шага 3 с сообщением (ok=false — сообщение об ошибке). func wizDepoGo(w http.ResponseWriter, r *http.Request, sub, msg string, ok bool) { + if sub == "intro" || sub == "conn" || sub == "tables" || sub == "job" { + sub = "setup" + } u := "/admin/wizard?step=3&sub=" + sub if msg != "" { if ok { @@ -45,21 +48,17 @@ func wizDepoGo(w http.ResponseWriter, r *http.Request, sub, msg string, ok bool) http.Redirect(w, r, u, http.StatusSeeOther) } -// wizardDepoConn — POST /admin/setup/wizard-depo/conn: сохранить подключение и сразу проверить его. +// wizardDepoConn — POST /admin/setup/wizard-depo/conn: сохранить подключение и сразу проверить его. Имя базы +// DEPO клиент не вводит: bj находит её на сервере сам (depoDiscover); вручную — только в «Дополнительно». func (h *setupHandlers) wizardDepoConn(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } s := h.fancyFromForm(r) - if db := strings.TrimSpace(r.FormValue("depo_database")); db != "" { - if !validDBName(db) { - wizDepoGo(w, r, "conn", "имя базы DEPO — только латинские буквы, цифры и _, начинается с буквы", false) - return - } - if db != s.DepoDatabase { - s.DepoTablesCheck, s.DepoTablesCheckOK = "", false - } - s.DepoDatabase = db + manual := strings.TrimSpace(r.FormValue("depo_database")) + if manual != "" && !validDBName(manual) { + wizDepoGo(w, r, "conn", "имя базы DEPO — только латинские буквы, цифры и _, начинается с буквы", false) + return } if s.Server == "" || s.User == "" { wizDepoGo(w, r, "conn", "укажите адрес сервера и логин", false) @@ -69,17 +68,54 @@ func (h *setupHandlers) wizardDepoConn(w http.ResponseWriter, r *http.Request) { wizDepoGo(w, r, "conn", "не сохранилось: "+err.Error(), false) return } - ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) defer cancel() - if _, err := fancyPing(ctx, s, "master"); err != nil { + if _, err := fancyPing(ctx, s, ""); err != nil { wizDepoGo(w, r, "conn", "к серверу подключиться не удалось — "+err.Error()+". Проверьте адрес, порт, логин и пароль.", false) return } - if _, err := fancyPing(ctx, s, s.DepoDB()); err != nil { - wizDepoGo(w, r, "conn", "сервер отвечает, но база "+s.DepoDB()+" недоступна этим логином — "+err.Error(), false) + db, how := manual, "указана вручную" + if db == "" { + found, err := depoDiscover(ctx, s) + switch { + case err != nil: + wizDepoGo(w, r, "conn", "сервер отвечает, но список баз получить не удалось — "+err.Error()+". Укажите базу DEPO вручную (ниже, «Дополнительно»).", false) + return + case len(found) == 0: + wizDepoGo(w, r, "conn", "на сервере не найдена база учёта DEPO (Fancy), доступная логину "+s.User+ + ": нет базы с таблицами "+strings.Join(depoMarkerTables, " и ")+". Проверьте, что логину выдан доступ к базе DEPO, или укажите её вручную («Дополнительно»).", false) + return + case len(found) > 1: + http.Redirect(w, r, "/admin/wizard?step=3&sub=conn&cands="+url.QueryEscape(strings.Join(found, ","))+ + "&flash="+url.QueryEscape("На сервере несколько баз DEPO — выберите, с какой работает bj."), http.StatusSeeOther) + return + } + db, how = found[0], "найдена на сервере" + } + if db != s.DepoDatabase { + s.DepoTablesCheck, s.DepoTablesCheckOK = "", false + } + s.DepoDatabase = db + if err := h.rc.UpdateFancy(s); err != nil { + wizDepoGo(w, r, "conn", "не сохранилось: "+err.Error(), false) return } - wizDepoGo(w, r, "tables", "подключение к "+s.Server+", база "+s.DepoDB()+" — работает", true) + if _, err := fancyPing(ctx, s, db); err != nil { + wizDepoGo(w, r, "conn", "сервер отвечает, но база "+db+" недоступна этим логином — "+err.Error(), false) + return + } + wizDepoGo(w, r, "tables", "подключение к "+s.Server+" работает; база DEPO — "+db+" ("+how+")", true) +} + +// depoCands — базы DEPO для выбора на экране «Подключение» (из адреса после поиска на сервере). +func depoCands(v string) []string { + var out []string + for _, n := range strings.Split(v, ",") { + if n = strings.TrimSpace(n); validDBName(n) { + out = append(out, n) + } + } + return out } // wizardDepoTables — POST /admin/setup/wizard-depo/tables: создать таблицы (bj) или проверить созданные @@ -178,13 +214,13 @@ func (h *setupHandlers) wizardDepoRecheck(w http.ResponseWriter, r *http.Request ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() if _, err := fancyPing(ctx, s, s.DepoDB()); err != nil { - wizDepoGo(w, r, "conn", "подключение не работает — "+err.Error(), false) + wizDepoGo(w, r, "setup", "подключение не работает — "+err.Error(), false) return } msg, ok := depoTablesCheck(ctx, s) h.saveDepoTablesCheck(msg, ok) if !ok { - wizDepoGo(w, r, "tables", msg, false) + wizDepoGo(w, r, "setup", msg, false) return } jmsg, jok := depoJobCheck(ctx, h.rc) @@ -194,8 +230,180 @@ func (h *setupHandlers) wizardDepoRecheck(w http.ResponseWriter, r *http.Request s.DepoProcCheck, s.DepoProcCheckOK, s.DepoProcCheckAt = pmsg, pok, time.Now() _ = h.rc.UpdateFancy(s) if !jok { - wizDepoGo(w, r, "job", jmsg, false) + wizDepoGo(w, r, "done", "обработка не запускается — "+jmsg, false) return } wizDepoGo(w, r, "done", "всё перепроверено", true) } + +// depoSetupForm — сервер DEPO и способ обработки из формы экрана «Подготовка базы» (логин bj не трогается). +func (h *setupHandlers) depoSetupForm(r *http.Request) (FancyMSSQLSettings, string, bool, string) { + s := h.rc.Snapshot().Fancy + dbType := "mssql" + if r.FormValue("db_type") == "postgres" { + dbType = "postgres" + } + port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) + if port == 0 { + port = map[string]int{"mssql": 1433, "postgres": 5432}[dbType] + } + server := strings.TrimSpace(r.FormValue("server")) + if server != s.Server || dbType != s.DBType { + s.DepoTablesCheck, s.DepoTablesCheckOK, s.DepoJobCheck, s.DepoJobCheckOK = "", false, "", false + s.DepoDatabase = "" + } + s.DBType, s.Port, s.Server = dbType, port, server + if s.Encrypt == "" { + s.Encrypt = "disable" + } + agent := r.FormValue("depo_job") != "bj" && dbType != "postgres" + s.DepoJobMode, s.DepoJobRun = "bj", !agent + if agent { + s.DepoJobMode = "sql-auto" + } + db := strings.TrimSpace(r.FormValue("depo_database")) + if db != "" && !validDBName(db) { + return s, "", agent, "имя базы DEPO — только латинские буквы, цифры и _, начинается с буквы" + } + if db == "" { + db = s.DepoDatabase + } + if dbType == "postgres" && db == "" { + return s, "", agent, "для PostgreSQL укажите имя базы DEPO" + } + if server == "" { + return s, db, agent, "укажите адрес сервера DEPO" + } + return s, db, agent, "" +} + +// depoEnsureLogin — логин bj и его пароль для скрипта (пароль придумывает bj и запоминает: после +// выполнения скрипта bj входит с ним; повторная выдача скрипта — с тем же паролем). +func depoEnsureLogin(s *FancyMSSQLSettings) { + login := depoSetupLogin(*s) + if s.User != login || s.Password == "" { + s.User, s.Password = login, depoGenPassword() + } +} + +// wizardDepoScript — POST /admin/setup/wizard-depo/script.sql: один скрипт подготовки базы файлом. +func (h *setupHandlers) wizardDepoScript(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !requireDepo(w, r) { + return + } + s, db, agent, bad := h.depoSetupForm(r) + if bad != "" { + wizDepoGo(w, r, "setup", bad, false) + return + } + depoEnsureLogin(&s) + s.DepoTablesMode = "script" + if err := h.rc.UpdateFancy(s); err != nil { + wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false) + return + } + body := depoSetupScript(s, db, s.User, s.Password, agent) + 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)) +} + +// wizardDepoSetup — POST /admin/setup/wizard-depo/setup: подготовить базу (mode=auto — bj сам логином +// администратора), проверить после скрипта (mode=script) или с готовым логином (mode=existing). +func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !requireDepo(w, r) { + return + } + s, db, agent, bad := h.depoSetupForm(r) + if bad != "" { + wizDepoGo(w, r, "setup", bad, false) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + note := "" + switch r.FormValue("mode") { + case "auto": + au := strings.TrimSpace(r.FormValue("admin_user")) + if au == "" { + wizDepoGo(w, r, "setup", "введите логин и пароль администратора SQL (они используются один раз и не сохраняются)", false) + return + } + depoEnsureLogin(&s) + found, err := depoSetupRun(ctx, s, db, au, r.FormValue("admin_password"), s.User, s.Password, agent) + if err != nil { + wizDepoGo(w, r, "setup", "база не подготовлена — "+err.Error(), false) + return + } + s.DepoDatabase, s.DepoTablesMode = found, "auto" + note = "база " + found + " подготовлена: логин " + s.User + ", таблицы обмена, права" + if agent { + note += ", задание " + depoJobName + } + note += ". " + case "existing": + u := strings.TrimSpace(r.FormValue("user")) + if u == "" { + wizDepoGo(w, r, "setup", "введите логин bj и пароль", false) + return + } + if u != s.User || r.FormValue("password") != "" { + s.User, s.Password = u, r.FormValue("password") + } + default: // script — администратор выполнил скрипт: логин и пароль bj уже в настройках + if s.User == "" || s.Password == "" { + wizDepoGo(w, r, "setup", "сначала скачайте скрипт и передайте администратору SQL", false) + return + } + } + if db != "" { + s.DepoDatabase = db + } + if err := h.rc.UpdateFancy(s); err != nil { + wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false) + return + } + if _, err := fancyPing(ctx, s, ""); err != nil { + wizDepoGo(w, r, "setup", note+"войти логином "+s.User+" не удалось — "+quikErrorDetail(err)+". Выполнен ли скрипт на сервере "+s.Server+"?", false) + return + } + if s.DepoDatabase == "" { // база не указана и скрипт её нашёл сам — найти и нам + found, err := depoDiscover(ctx, s) + switch { + case err != nil || len(found) == 0: + wizDepoGo(w, r, "setup", "логину "+s.User+" не видна ни одна база DEPO — выполнен ли скрипт? Можно указать базу вручную («Дополнительно»)", false) + return + case len(found) > 1: + http.Redirect(w, r, "/admin/wizard?step=3&sub=setup&cands="+url.QueryEscape(strings.Join(found, ","))+ + "&flash="+url.QueryEscape("Логину bj видны несколько баз DEPO — выберите нужную и проверьте ещё раз."), http.StatusSeeOther) + return + } + s.DepoDatabase = found[0] + _ = h.rc.UpdateFancy(s) + } + msg, ok := depoTablesCheck(ctx, s) + h.saveDepoTablesCheck(msg, ok) + if !ok { + wizDepoGo(w, r, "setup", note+msg, false) + return + } + if !s.IsPostgres() { + _ = depoWriteHandbook(h.rc) + } + // запуск обработки: только что созданное задание запускается в течение интервала — подождать + 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, "", "") + } + pmsg, pok := depoProcCheck(ctx, h.rc) // процедуры обработки ставятся отдельно — шаг не держат + 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) +}