Первичная настройка: ключ спрашивается один раз, затем обновления, модули и «Время и сеть»; DEPO одним шагом
- Первая страница — только администратор (и восстановление из копии); ключ продукта — после создания администратора, на шаге «Лицензия». Консоль первой загрузки ключ больше не спрашивает (для образа с комплектом bj). - «Подготовка модулей» начинает с проверки обновлений: есть — ставит (bj перезапускается, страница ждёт и продолжает), нет — дальше; выпуск, который уже ставили, по кругу не ставится. Затем модули лицензии и переход на «Время и сеть» (шаг 12) — часовой пояс клиент выбирает сам, шаг больше не проскакивает. - Учёт DEPO одним шагом: bj сам находит базу Fancy DEPO на сервере (по таблицам OD_FACES/OD_RESTS), имя базы не спрашивается. Один скрипт создаёт логин bj, таблицы обмена и права; по выбору — задание обработки в SQL Agent или обработка на сервере bj. Выполнить сразу (логин администратора СУБД не сохраняется), скачать скрипт для администратора или подключиться готовым логином. - Восстановление из копии — только на первой странице, в мастере его больше нет. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -71,3 +71,4 @@ DOC/*.bak.pdf
|
||||
|
||||
# Собранный бинарь в корне
|
||||
/bj-server
|
||||
__pycache__/
|
||||
|
||||
+20
-26
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
<label>Имя</label><input type="text" name="name" value="{{.Name}}" autocomplete="name">
|
||||
<label>Пароль, не короче 10 символов</label><input type="password" name="password" minlength="10" autocomplete="new-password" required>
|
||||
<label>Повтор пароля</label><input type="password" name="password2" minlength="10" autocomplete="new-password" required>
|
||||
{{if .NeedKey}}<label>Ключ продукта (BJ-XXXX-XXXX-XXXX) — если не вводили на сервере; можно позже в «Настройка → Лицензия»</label><input type="text" name="product_key" value="{{.ProductKey}}" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" style="text-transform:uppercase">{{end}}
|
||||
{{/* ключ продукта здесь не спрашивается: его спрашивает следующий экран — один раз, после создания администратора */}}
|
||||
<button type="submit">Создать администратора</button>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
</style>
|
||||
<div class="prep-head">
|
||||
<h1>Подготовка модулей</h1>
|
||||
<p>Ключ принят: <b>{{.Tenant}}</b>. bj ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.</p>
|
||||
<p>Ключ принят: <b>{{.Tenant}}</b>. bj проверяет обновления, ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.</p>
|
||||
</div>
|
||||
<div class="card prep">
|
||||
{{if .Flash}}<p style="margin:0 0 6px;color:var(--ok)">✓ {{.Flash}}</p>{{end}}
|
||||
<p class="muted" style="margin:0 0 4px;font-size:13px">Лицензия: план {{.Plan}}, {{.Until}}</p>
|
||||
<div id="prep-rows"><p class="muted" style="margin:12px 0">Проверяю состав лицензии…</p></div>
|
||||
<div id="prep-rows"><p class="muted" style="margin:12px 0">Проверяю обновления и состав лицензии…</p></div>
|
||||
<div id="prep-prog" style="display:none;margin-top:8px"><span class="muted" id="prep-stage" style="font-size:13px"></span><div class="prep-bar"><i id="prep-fill"></i></div></div>
|
||||
<div class="prep-foot">
|
||||
<span id="prep-sum" style="font-weight:600"></span>
|
||||
@@ -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:'<span style="color:var(--ok)">✓</span>',run:'<span class="spin" aria-label="идёт"></span>',wait:'<span class="muted">○</span>',warn:'<span style="color:var(--warn)">⚠</span>',err:'<span style="color:var(--err)">✗</span>',info:'<span class="muted">ⓘ</span>'};
|
||||
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='<span class="muted" style="font-weight:400">Ставлю и запускаю модули… обычно меньше минуты</span>'; nxt.style.display='none'; return false; }
|
||||
if(d.running || d.busy || (d.pending && !d.finished)){ sum.innerHTML='<span class="muted" style="font-weight:400">Обновления и модули… обычно пара минут</span>'; nxt.style.display='none'; return false; }
|
||||
nxt.style.display='';
|
||||
if(bad){ sum.innerHTML='<span style="color:var(--err)">✗ Не всё установилось</span> <span class="muted" style="font-weight:400">— продолжить можно, модули поставятся из «Настройка → Обновления»</span>'; return true; }
|
||||
if(warn){ sum.innerHTML='<span style="color:var(--ok)">✓ Готово</span> <span class="muted" style="font-weight:400">— есть замечание (⚠), продолжать можно</span>'; 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='<span class="spin"></span> <span class="muted" style="font-weight:400">bj перезапускается после обновления — подождите…</span>'; nxt.style.display='none'; }
|
||||
setTimeout(poll,3000); });
|
||||
}
|
||||
poll();
|
||||
})();
|
||||
|
||||
@@ -49,63 +49,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
|
||||
{{/* ============= ШАГ 1: Лицензия ============= */}}
|
||||
{{if eq .Step 1}}
|
||||
<div class="card" id="wiz-restore" style="border-style:dashed">
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-weight:600">Переустановка сервера? Восстановить из резервной копии</summary>
|
||||
<p class="muted" style="font-size:13px;margin:8px 0">Если у вас есть резервная копия bj на сетевом хранилище (Настройка → Резервное копирование), можно восстановить её вместо настройки с нуля: вернутся настройки, пользователи, лицензия, журнал заявок, база и папка шлюза НРД. Текущие данные этого сервера будут заменены. bj и шлюз НРД на время восстановления остановятся — обычно 1–3 минуты.</p>
|
||||
<style>#rs-form label:not(.chk){display:block;margin:0}#rs-form label:not(.chk) input,#rs-form label:not(.chk) select{width:100%;box-sizing:border-box;margin-top:4px}#rs-pick label{flex:1;min-width:220px}</style>
|
||||
<form id="rs-form" style="display:grid;gap:8px;max-width:640px" onsubmit="return false">
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
|
||||
<label>Хранилище (SMB)<input name="share" placeholder="//nas/backup" value="{{.Settings.Backup.Share}}"></label>
|
||||
<label>Папка<input name="dir" placeholder="bj-backup" value="{{.Settings.Backup.Dir}}"></label>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label>Логин хранилища<input name="user" autocomplete="off" value="{{.Settings.Backup.User}}"></label>
|
||||
<label>Пароль<input name="password" type="password" autocomplete="new-password"></label>
|
||||
</div>
|
||||
<div><button class="btn" type="button" id="rs-list">Показать копии</button></div>
|
||||
<div id="rs-pick" style="display:none;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||||
<label>Копия<select name="name" id="rs-name"></select></label>
|
||||
<button class="btn" type="button" id="rs-go" style="background:var(--err);border-color:var(--err)">Восстановить</button>
|
||||
</div>
|
||||
<div id="rs-out" class="muted" style="font-size:13px"></div>
|
||||
</form>
|
||||
<script>
|
||||
(function(){
|
||||
var f=document.getElementById('rs-form'), out=document.getElementById('rs-out'), pick=document.getElementById('rs-pick'), sel=document.getElementById('rs-name');
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
||||
function params(){ var p=new URLSearchParams(); ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); return p; }
|
||||
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
|
||||
document.getElementById('rs-list').onclick=function(){
|
||||
out.textContent='Читаю список копий…'; pick.style.display='none';
|
||||
fetch('/admin/setup/backup/list',{method:'POST',body:params(),cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||
if(!d.ok){ out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; }
|
||||
if(!d.list||!d.list.length){ out.textContent='На хранилище копий нет.'; return; }
|
||||
sel.innerHTML=d.list.map(function(n){return '<option value="'+esc(n)+'">'+esc(nice(n))+'</option>'}).join('');
|
||||
pick.style.display='flex'; out.textContent='Найдено копий: '+d.list.length+'. Выберите копию — обычно самую свежую.';
|
||||
}).catch(function(){ out.textContent='Не удалось получить список.'; });
|
||||
};
|
||||
document.getElementById('rs-go').onclick=function(){
|
||||
if(!confirm('Восстановить копию от '+nice(sel.value)+'? Текущие настройки и данные этого сервера будут заменены, bj и шлюз НРД перезапустятся.')) return;
|
||||
var p=params(); p.set('name', sel.value); var t0=Date.now();
|
||||
out.textContent='Запускаю восстановление…';
|
||||
fetch('/admin/setup/backup/restore',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||
if(!d.ok){ out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; }
|
||||
var iv=setInterval(function(){
|
||||
fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
|
||||
var r=s.restore; if(!r) return;
|
||||
out.textContent='Восстановление: '+r.msg;
|
||||
if(r.stage==='done'){ clearInterval(iv); out.innerHTML='<span style="color:var(--ok)">✓ '+esc(r.msg)+'. Сейчас откроется вход — войдите учётной записью из восстановленной копии.</span>'; setTimeout(function(){ location.href='/admin/'; }, 4000); }
|
||||
if(r.stage==='error'){ clearInterval(iv); out.innerHTML='<span style="color:var(--err)">✗ '+esc(r.msg)+'</span>'; }
|
||||
}).catch(function(){ out.textContent='bj перезапускается, жду…'; });
|
||||
if(Date.now()-t0>900000) clearInterval(iv);
|
||||
}, 3000);
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</details>
|
||||
</div>
|
||||
{{/* восстановление из резервной копии — только на первой странице (/first-run), в мастере его нет */}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.License}}ok{{else}}err{{end}}"></span>Лицензия</h2>
|
||||
<div class="help-block"><strong>Что это.</strong> Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.<br>
|
||||
@@ -148,117 +92,57 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<p class="muted">Модуль обмена с DEPO и QUIK не входит в текущую лицензию — шаг пропускается.</p>
|
||||
{{else}}
|
||||
{{$sub := .Sub}}
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin:6px 0 14px">{{range $i, $x := depoSubs}}<span style="font-size:12px;padding:4px 10px;border-radius:12px;{{if eq $x.Key $sub}}background:var(--accent);color:#fff{{else}}background:var(--card-2);color:var(--muted){{end}}">{{add $i 1}}. {{$x.Title}}</span>{{end}}</div>
|
||||
{{if eq $sub "setup"}}
|
||||
<div class="help-block"><strong>Что это.</strong> bj подключается к базе учёта DEPO (Fancy): сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и передаёт в DEPO поручения и их исполнение — документы в учёте создаются сами.<br>
|
||||
<strong>Что понадобится.</strong> Адрес сервера базы DEPO. Имя базы знать не нужно — bj найдёт её сам. Подготовить базу (логин bj, таблицы обмена, задание обработки) можно одним действием: логином администратора SQL — он используется один раз и не сохраняется, — или администратор выполнит один готовый скрипт.</div>
|
||||
{{$mode := .Settings.Fancy.DepoJobMode}}
|
||||
<form method="post" action="/admin/setup/wizard-depo/setup" id="wiz-depo" style="display:grid;gap:12px;max-width:680px;margin-top:10px">
|
||||
<div style="display:grid;grid-template-columns:1.2fr 2fr .8fr;gap:8px">
|
||||
<label style="margin:0">СУБД<select name="db_type" onchange="wizDepo()" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"><option value="mssql" {{if ne .Settings.Fancy.DBType "postgres"}}selected{{end}}>SQL Server</option><option value="postgres" {{if eq .Settings.Fancy.DBType "postgres"}}selected{{end}}>PostgreSQL</option></select></label>
|
||||
<label style="margin:0">Сервер DEPO (адрес или host\instance)<input type="text" name="server" value="{{.Settings.Fancy.Server}}" placeholder="адрес сервера" required style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label>
|
||||
<label style="margin:0">Порт<input type="text" name="port" value="{{if .Settings.Fancy.Port}}{{.Settings.Fancy.Port}}{{end}}" placeholder="1433" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label>
|
||||
</div>
|
||||
{{if .DepoCands}}<label style="margin:0">На сервере несколько баз DEPO — выберите нужную<select name="depo_database" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px">{{range .DepoCands}}<option>{{.}}</option>{{end}}</select></label>{{end}}
|
||||
<div id="wiz-depo-pgdb" style="display:none"><label style="margin:0">База DEPO <span class="muted" style="font-weight:400">(для PostgreSQL указывается вручную)</span><input type="text" name="depo_database" value="{{.Settings.Fancy.DepoDatabase}}" pattern="[A-Za-z][A-Za-z0-9_]*" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label></div>
|
||||
|
||||
{{if eq $sub "intro"}}
|
||||
<div class="help-block"><strong>Что это.</strong> Подключение bj к базе депозитарного учёта DEPO (Fancy).<br>
|
||||
<strong>Зачем.</strong> bj сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и сам передаёт в DEPO поручения и их исполнение — документы в учёте создаются автоматически. Отсюда же bj возьмёт реквизиты вашей организации (раздел «Организация»).<br>
|
||||
<strong>Что понадобится.</strong> Адрес сервера базы DEPO, логин и пароль — их даёт ваш администратор баз данных. Для создания таблиц обмена и джобы обработки может понадобиться разово логин администратора SQL — он не сохраняется. В базе DEPO должны быть процедуры обработки обмена (расширение bj2depo) и версия Fancy DEPO с поддержкой M2M — bj проверит их по имени и скажет, чего не хватает.<br>
|
||||
<strong>Как пойдём.</strong> Подключение → таблицы обмена → обработка → проверка. Каждый шаг проверяется сразу.</div>
|
||||
{{template "letterCard" (letter "dba")}}
|
||||
<div style="margin-top:14px"><a href="/admin/wizard?step=3&sub=conn" class="btn" style="text-decoration:none">Начать →</a></div>
|
||||
<div id="wiz-depo-job"><div style="font-weight:600;margin-bottom:4px">Обработку обмена в DEPO запускает</div>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0 0 4px"><input type="radio" name="depo_job" value="agent" {{if ne $mode "bj"}}checked{{end}} style="width:auto;margin-top:3px"> <span><b>Задание SQL Server Agent</b> <span class="muted" style="font-size:13px">— рекомендуется: работает, даже когда bj выключен. Скрипт создаст его сам.</span></span></label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0"><input type="radio" name="depo_job" value="bj" {{if eq $mode "bj"}}checked{{end}} style="width:auto;margin-top:3px"> <span><b>Сервер bj</b> <span class="muted" style="font-size:13px">— bj вызывает обработку каждые {{.DepoJob.Every}} с (если на сервере нет SQL Server Agent, например Express).</span></span></label>
|
||||
</div>
|
||||
|
||||
{{else if eq $sub "conn"}}
|
||||
<p>Адрес сервера учётной базы DEPO и логин, под которым bj будет с ней работать. По «Далее» bj сохранит и сразу проверит подключение.</p>
|
||||
<form method="post" action="/admin/setup/wizard-depo/conn" style="display:grid;gap:10px;max-width:560px">
|
||||
<div><label>Тип СУБД</label><select name="db_type" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px"><option value="mssql" {{if ne .Settings.Fancy.DBType "postgres"}}selected{{end}}>Microsoft SQL Server</option><option value="postgres" {{if eq .Settings.Fancy.DBType "postgres"}}selected{{end}}>PostgreSQL</option></select></div>
|
||||
<div><label>Сервер (адрес или host\instance)</label><input type="text" name="server" value="{{.Settings.Fancy.Server}}" placeholder="10.0.0.21" required style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px"></div>
|
||||
<div><label>Порт</label><input type="text" name="port" value="{{if .Settings.Fancy.Port}}{{.Settings.Fancy.Port}}{{else}}1433{{end}}" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px"></div>
|
||||
<div><label>База DEPO</label><input type="text" name="depo_database" value="{{.Settings.Fancy.DepoDatabase}}" placeholder="{{.Settings.Fancy.DepoDB}}" pattern="[A-Za-z][A-Za-z0-9_]*" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px"></div>
|
||||
<div><label>Логин</label><input type="text" name="user" value="{{.Settings.Fancy.User}}" autocomplete="off" required style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px"></div>
|
||||
<div><label>Пароль {{if .Settings.Fancy.Server}}<span class="muted">(пусто — оставить прежний)</span>{{end}}</label><input type="password" name="password" autocomplete="new-password" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px"></div>
|
||||
<details id="wiz-login-help" style="border:1px solid var(--border);border-radius:8px;padding:8px 12px;background:var(--card-2)">
|
||||
<summary style="cursor:pointer;font-weight:600">Логина для bj ещё нет? Как создать</summary>
|
||||
<div style="font-size:13px;line-height:1.5;margin-top:6px">
|
||||
<p style="margin:0 0 6px">bj нужен <b>отдельный логин</b> в базе DEPO. Его создаёт администратор СУБД. Права минимальные:</p>
|
||||
<ul style="margin:0 0 6px;padding-left:18px">
|
||||
<li><b>чтение</b> учётных таблиц DEPO (лица, счета, остатки, бумаги) — для сверки переводов;</li>
|
||||
<li><b>запись только в таблицы обмена</b> <code>FTV_M2M*</code> — их права выдаются на следующем экране, вместе с созданием таблиц.</li>
|
||||
</ul>
|
||||
<p style="margin:0 0 6px">Вариант 1 — администратор создаёт логин сам с этими правами. Вариант 2 — передайте ему скрипт: в нём подставлены тип СУБД, база, имя логина и пароль из полей выше (если пароль не введён — впишите его в скрипт сами). Скрипт с паролем не храните.</p>
|
||||
<a id="wiz-login-dl" href="#">Скачать скрипт создания логина</a> <span class="muted" id="wiz-login-pwnote" style="font-size:12px"></span>
|
||||
<pre class="copyable" id="wiz-login-pre" style="max-height:260px;margin-top:6px;font-size:12px">…</pre>
|
||||
<p class="muted" style="margin:4px 0 0">После выполнения скрипта впишите логин и пароль в поля выше и нажмите «Проверить и далее».</p>
|
||||
<div style="border-top:1px solid var(--border);padding-top:10px"><div style="font-weight:600;margin-bottom:6px">Как подготовить базу</div>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0 0 6px"><input type="radio" name="mode" value="auto" checked onchange="wizDepo()" style="width:auto;margin-top:3px"> <span><b>bj сделает всё сам</b> <span class="muted" style="font-size:13px">— нужен логин администратора SQL (sysadmin) на один раз: bj найдёт базу, создаст свой логин, таблицы обмена и задание. Логин администратора не сохраняется.</span></span></label>
|
||||
<div id="wiz-depo-auto" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0 0 8px 26px">
|
||||
<input type="text" name="admin_user" autocomplete="off" placeholder="логин администратора SQL, например sa" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<input type="password" name="admin_password" autocomplete="new-password" placeholder="пароль" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0 0 6px"><input type="radio" name="mode" value="script" onchange="wizDepo()" style="width:auto;margin-top:3px"> <span><b>Скрипт для администратора</b> <span class="muted" style="font-size:13px">— если логин администратора не дают: один файл делает то же самое (находит базу, создаёт логин bj, таблицы и задание).</span></span></label>
|
||||
<div id="wiz-depo-script" style="display:none;margin:0 0 8px 26px">
|
||||
<button type="submit" class="btn" formaction="/admin/setup/wizard-depo/script.sql" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Скачать скрипт</button>
|
||||
<span class="muted" style="font-size:12.5px">Пароль логина bj придумывает bj и уже вписал в скрипт. Администратор выполняет файл в SSMS — потом нажмите «Проверить».</span>
|
||||
{{template "letterCard" (letter "dba")}}
|
||||
</div>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0 0 6px"><input type="radio" name="mode" value="existing" onchange="wizDepo()" style="width:auto;margin-top:3px"> <span><b>Логин для bj уже есть</b> <span class="muted" style="font-size:13px">— база уже подготовлена раньше: только проверить.</span></span></label>
|
||||
<div id="wiz-depo-existing" style="display:none;grid-template-columns:1fr 1fr;gap:8px;margin:0 0 8px 26px">
|
||||
<input type="text" name="user" value="{{.Settings.Fancy.User}}" autocomplete="off" placeholder="логин bj" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="{{if .Settings.Fancy.Password}}пароль (пусто — прежний){{else}}пароль{{end}}" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
</details>
|
||||
<script>
|
||||
(function(){
|
||||
var f=document.currentScript.closest('form'), d=document.getElementById('wiz-login-help'), pre=document.getElementById('wiz-login-pre'), a=document.getElementById('wiz-login-dl'), note=document.getElementById('wiz-login-pwnote');
|
||||
function val(n){ var e=f.querySelector('[name='+n+']'); return e?e.value:''; }
|
||||
function params(){
|
||||
var db=f.querySelector('[name=depo_database]');
|
||||
var p=new URLSearchParams();
|
||||
p.set('type', val('db_type')||'mssql');
|
||||
p.set('db', db&&db.value.trim()?db.value.trim():(db?db.placeholder:''));
|
||||
p.set('login', val('user').trim()||'bj_user');
|
||||
p.set('password', val('password')); // только в теле POST — не в адресе и не в журналах
|
||||
return p;
|
||||
}
|
||||
function upd(){
|
||||
note.textContent = val('password') ? '— пароль из поля подставлен в скрипт' : '— пароль не введён: в скрипте заглушка, замените её';
|
||||
if(!d.open) return;
|
||||
var p=params(); p.set('download','0');
|
||||
fetch('/admin/setup/fancy/depo-login/script.sql',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.text()}).then(function(t){pre.textContent=t}).catch(function(){});
|
||||
}
|
||||
a.addEventListener('click', function(e){
|
||||
e.preventDefault();
|
||||
var p=params(), fm=document.createElement('form');
|
||||
fm.method='POST'; fm.action='/admin/setup/fancy/depo-login/script.sql'; fm.style.display='none';
|
||||
p.forEach(function(v,k){ var i=document.createElement('input'); i.type='hidden'; i.name=k; i.value=v; fm.appendChild(i); });
|
||||
document.body.appendChild(fm); fm.submit(); fm.remove();
|
||||
});
|
||||
d.addEventListener('toggle',upd);
|
||||
['db_type','depo_database','user','password'].forEach(function(n){var e=f.querySelector('[name='+n+']'); if(e){ e.addEventListener('change',upd); e.addEventListener('input',function(){ clearTimeout(e._t); e._t=setTimeout(upd,400); }); }});
|
||||
upd();
|
||||
})();
|
||||
</script>
|
||||
<input type="hidden" name="encrypt" value="{{if .Settings.Fancy.Encrypt}}{{.Settings.Fancy.Encrypt}}{{else}}disable{{end}}">
|
||||
<div style="display:flex;justify-content:space-between;margin-top:6px"><a href="/admin/wizard?step=3&sub=intro" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← Назад</a><button type="submit" class="btn">Проверить и далее →</button></div>
|
||||
</form>
|
||||
|
||||
{{else if eq $sub "tables"}}
|
||||
<p>Четыре таблицы обмена <code>FTV_M2M*</code> в базе {{.DepoTables.DB}}: через них bj передаёт поручения и события в DEPO. Данные учёта они не трогают. Кто их создаёт?</p>
|
||||
<form method="post" action="/admin/setup/wizard-depo/tables" style="display:grid;gap:10px;max-width:680px" id="wiz-tables">
|
||||
<label style="display:flex;gap:8px;align-items:flex-start"><input type="radio" name="depo_tables_mode" value="auto" {{if ne .Settings.Fancy.DepoTablesMode "script"}}checked{{end}} onchange="wizTbl()"> <span><b>bj создаёт сам</b> <span class="muted" style="font-size:13px">— по «Далее». Если у логина {{.DepoTables.User}} нет прав на создание таблиц, введите разово логин администратора SQL (не сохраняется).</span></span></label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start"><input type="radio" name="depo_tables_mode" value="script" {{if eq .Settings.Fancy.DepoTablesMode "script"}}checked{{end}} onchange="wizTbl()"> <span><b>Создаёт администратор по скрипту</b> <span class="muted" style="font-size:13px">— передайте ему скрипт, после выполнения нажмите «Проверить и далее».</span></span></label>
|
||||
<div id="wiz-tbl-auto" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px">
|
||||
<input type="text" name="admin_user" autocomplete="off" placeholder="логин администратора (необязательно)" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<input type="password" name="admin_password" autocomplete="new-password" placeholder="пароль" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div id="wiz-tbl-script" style="display:none"><a href="/admin/setup/fancy/depo-tables/script.sql">Скачать скрипт {{.DepoTables.ScriptFile}}</a>
|
||||
<details style="margin-top:6px"><summary style="cursor:pointer;font-size:13px">Посмотреть скрипт</summary><pre class="copyable" style="max-height:300px;overflow:auto;font-size:12px">{{.DepoTables.Script}}</pre></details>
|
||||
<input type="hidden" name="action" value="" id="wiz-tbl-action"></div>
|
||||
<div style="display:flex;justify-content:space-between;margin-top:6px"><a href="/admin/wizard?step=3&sub=conn" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← Назад</a><button type="submit" class="btn" id="wiz-tbl-btn">Создать и далее →</button></div>
|
||||
{{if not .DepoCands}}<details style="font-size:13px"><summary style="cursor:pointer;color:var(--accent)">Дополнительно: указать базу DEPO вручную (обычно не нужно)</summary>
|
||||
<input type="text" name="depo_database" id="wiz-depo-dbman" value="" placeholder="имя базы DEPO на сервере" pattern="[A-Za-z][A-Za-z0-9_]*" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:6px;max-width:320px"></details>{{end}}
|
||||
<div><button type="submit" class="btn" id="wiz-depo-btn">Подготовить базу и проверить →</button></div>
|
||||
</form>
|
||||
<script>function wizTbl(){var f=document.getElementById('wiz-tables'),sc=f.depo_tables_mode.value==='script';
|
||||
document.getElementById('wiz-tbl-auto').style.display=sc?'none':'grid';document.getElementById('wiz-tbl-script').style.display=sc?'block':'none';
|
||||
document.getElementById('wiz-tbl-action').value=sc?'check':'';document.getElementById('wiz-tbl-btn').textContent=sc?'Проверить и далее →':'Создать и далее →';}wizTbl();</script>
|
||||
|
||||
{{else if eq $sub "job"}}
|
||||
<p>Процедура <code>{{.DepoJob.Proc}}</code> в базе DEPO каждые {{.DepoJob.Every}} с превращает записи bj в документы и операции учёта. Кто её запускает?</p>
|
||||
{{if .DepoEdition}}<p class="muted" style="font-size:13px">Ваш сервер: {{.DepoEdition}} — {{if .DepoAgent}}есть SQL Server Agent, рекомендуем первый вариант.{{else}}без SQL Server Agent, подходит вариант «запускает bj».{{end}}</p>{{end}}
|
||||
{{$mode := .Settings.Fancy.DepoJobMode}}{{if not $mode}}{{$mode = .DepoJobDef}}{{end}}{{if not $mode}}{{$mode = "sql-auto"}}{{end}}
|
||||
<form method="post" action="/admin/setup/wizard-depo/job" style="display:grid;gap:10px;max-width:680px" id="wiz-job">
|
||||
{{if not .DepoTables.Postgres}}<label style="display:flex;gap:8px;align-items:flex-start"><input type="radio" name="depo_job_mode" value="sql-auto" {{if eq $mode "sql-auto"}}checked{{end}} onchange="wizJob()"> <span><b>Джоба SQL Server Agent, создаёт bj</b> <span class="muted" style="font-size:13px">— рекомендуется: работает независимо от bj. По «Далее» bj создаст джобу; нужен разово логин администратора SQL (sa) — не сохраняется.</span></span></label>{{end}}
|
||||
<label style="display:flex;gap:8px;align-items:flex-start"><input type="radio" name="depo_job_mode" value="bj" {{if eq $mode "bj"}}checked{{end}} onchange="wizJob()"> <span><b>Запускает сам bj</b> <span class="muted" style="font-size:13px">— для SQL Server Express и PostgreSQL. Администратору SQL нужно выдать одно право (скрипт ниже).</span></span></label>
|
||||
{{if not .DepoTables.Postgres}}<label style="display:flex;gap:8px;align-items:flex-start"><input type="radio" name="depo_job_mode" value="sql-manual" {{if eq $mode "sql-manual"}}checked{{end}} onchange="wizJob()"> <span><b>Джоба SQL Server Agent, создаёт администратор</b> <span class="muted" style="font-size:13px">— передайте ему скрипт.</span></span></label>{{end}}
|
||||
<div id="wiz-job-auto" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px">
|
||||
<input type="text" name="admin_user" autocomplete="off" placeholder="логин администратора SQL, например sa" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<input type="password" name="admin_password" autocomplete="new-password" placeholder="пароль" style="padding:8px;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p id="wiz-job-auto-hint" class="muted" style="font-size:12.5px;margin:-4px 0 0">Нет логина администратора SQL или его не хотят давать — выберите ниже «Джоба SQL Server Agent, создаёт администратор по скрипту»: тот же результат, скрипт выполняет администратор.</p>
|
||||
<div id="wiz-job-bj" style="display:none"><p style="margin:0 0 4px;font-size:13px"><b>Администратору SQL — выполнить один раз:</b></p><pre class="copyable" style="font-size:12px">{{.DepoJob.GrantScript}}</pre></div>
|
||||
<div id="wiz-job-manual" style="display:none"><p style="margin:0 0 4px;font-size:13px"><b>Скрипт для администратора SQL</b> — выполнить один раз под sysadmin, затем нажать «Проверить и далее». Повторный запуск безопасен.</p>
|
||||
<a href="/admin/setup/fancy/depo-job/script.sql">Скачать скрипт bj-depo-job-mssql.sql</a>
|
||||
<pre class="copyable" style="font-size:12px;max-height:260px">{{.DepoJob.AdminScript}}</pre></div>
|
||||
<input type="hidden" name="action" value="" id="wiz-job-action">
|
||||
<div style="display:flex;justify-content:space-between;margin-top:6px"><a href="/admin/wizard?step=3&sub=tables" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← Назад</a><button type="submit" class="btn" id="wiz-job-btn">Создать, проверить и далее →</button></div>
|
||||
<p class="muted" style="font-size:12px;margin:0">Проверка: джоба есть, включена, есть расписание и она запускалась за последнюю минуту. Работают ли процедуры обработки — отдельная проверка на экране «Готово».</p>
|
||||
<p style="font-size:12.5px;margin:0"><a href="/admin/wizard?step=3&sub=done">Дальше без проверки →</a> <span class="muted">— если обработку ещё настраивает администратор (например, ставит процедуры). Вернуться и проверить можно с экрана «Готово» кнопкой «Перепроверить всё».</span></p>
|
||||
</form>
|
||||
<script>function wizJob(){var f=document.getElementById('wiz-job'),m=f.depo_job_mode.value;
|
||||
document.getElementById('wiz-job-auto').style.display=m==='sql-auto'?'grid':'none';document.getElementById('wiz-job-auto-hint').style.display=m==='sql-auto'?'block':'none';document.getElementById('wiz-job-bj').style.display=m==='bj'?'block':'none';document.getElementById('wiz-job-manual').style.display=m==='sql-manual'?'block':'none';
|
||||
document.getElementById('wiz-job-action').value=m==='sql-auto'?'':'check';document.getElementById('wiz-job-btn').textContent=m==='sql-auto'?'Создать, проверить и далее →':'Проверить и далее →';}wizJob();</script>
|
||||
<script>function wizDepo(){var f=document.getElementById('wiz-depo'),pg=f.db_type.value==='postgres',m=f.mode.value;
|
||||
document.getElementById('wiz-depo-pgdb').style.display=pg?'block':'none';
|
||||
var man=document.getElementById('wiz-depo-dbman'); if(man) man.disabled=pg;
|
||||
f.querySelector('#wiz-depo-pgdb input').disabled=!pg;
|
||||
document.getElementById('wiz-depo-job').style.display=pg?'none':'block';
|
||||
if(!f.port.value) f.port.placeholder=pg?'5432':'1433';
|
||||
document.getElementById('wiz-depo-auto').style.display=m==='auto'?'grid':'none';
|
||||
document.getElementById('wiz-depo-script').style.display=m==='script'?'block':'none';
|
||||
document.getElementById('wiz-depo-existing').style.display=m==='existing'?'grid':'none';
|
||||
document.getElementById('wiz-depo-btn').textContent=m==='auto'?'Подготовить базу и проверить →':'Проверить →';}
|
||||
wizDepo();</script>
|
||||
|
||||
{{else if eq $sub "done"}}
|
||||
<table style="margin:6px 0 10px">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user