21d87fb530
На одном сервере бывает несколько баз DEPO (боевая и тестовая): общее имя bj2depo давало ложную зелёную проверку — тестовой базе засчитывалось боевое задание, а своё не создавалось. - Имя задания — настройка DepoJobName; в мастере, пункт 3, поле «Имя задания SQL Server Agent», по умолчанию bj2depo_<имя базы> (меняется вместе с базой, пока его не поправили). Установки старше — bj2depo, как было. - Скрипт до любых изменений останавливается, если задание с этим именем обрабатывает другую базу или эту базу уже обрабатывает задание с другим именем (иначе двойная обработка событий). - Проверка задания сравнивает его базу (sp_help_jobstep) с базой bj. Синтаксис проверен на SQL Server без выполнения (SET PARSEONLY); проверка задания на боевом сервере — зелёная (bj2depo ↔ BJ_DEPO_DATA). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
289 lines
16 KiB
Go
289 lines
16 KiB
Go
package lkgateway
|
||
|
||
// Подготовка базы учёта DEPO одним действием (06.10.2026). Раньше это были три экрана мастера — логин bj,
|
||
// таблицы обмена, задание SQL Agent — и три скрипта. Теперь один скрипт делает всё:
|
||
// - работает с базой, которую назвал клиент (сам по серверу не ищет), и сначала проверяет, что в ней есть
|
||
// учётные таблицы DEPO OD_FACES и OD_RESTS — опечатка в имени не уведёт в чужую базу;
|
||
// - создаёт логин 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 — имя базы DEPO (задаёт клиент), 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(f) + "»"
|
||
}
|
||
fmt.Fprintf(&b, `-- Bridge&Join: подготовка базы учёта DEPO для bj. Microsoft SQL Server.
|
||
SET NOCOUNT ON;
|
||
|
||
-- ===== Проверьте перед запуском ==========================================
|
||
-- База DEPO — точное имя базы учёта на этом сервере.
|
||
DECLARE @db sysname = N'%[3]s';
|
||
-- Пароль логина bj (%[1]s). Меняете — впишите тот же пароль в bj, шаг «Учёт DEPO».
|
||
DECLARE @pass nvarchar(128) = N'%[4]s';
|
||
-- =========================================================================
|
||
|
||
-- Что делает: проверяет, что @db — база DEPO, создаёт логин bj и таблицы обмена FTV_M2M*, выдаёт права
|
||
-- (чтение учёта, запись только в таблицы обмена). Обработка обмена: %[2]s.
|
||
-- Учётные данные DEPO не меняет. Запускать в SSMS или sqlcmd под логином с правами sysadmin.
|
||
-- Можно запускать повторно. В скрипте пароль логина bj — после выполнения файл удалите.
|
||
|
||
IF @pass = N''
|
||
BEGIN
|
||
RAISERROR(N'Впишите пароль логина bj в @pass в начале скрипта и запустите снова.', 16, 1);
|
||
RETURN;
|
||
END
|
||
IF @db = N''
|
||
BEGIN
|
||
RAISERROR(N'Впишите имя базы DEPO в @db в начале скрипта и запустите снова.', 16, 1);
|
||
RETURN;
|
||
END
|
||
IF DB_ID(@db) IS NULL
|
||
BEGIN
|
||
RAISERROR(N'Базы %%s на этом сервере нет. Проверьте имя в @db в начале скрипта.', 16, 1, @db);
|
||
RETURN;
|
||
END
|
||
IF OBJECT_ID(QUOTENAME(@db) + N'.dbo.OD_FACES', N'U') IS NULL OR OBJECT_ID(QUOTENAME(@db) + N'.dbo.OD_RESTS', N'U') IS NULL
|
||
BEGIN
|
||
RAISERROR(N'В базе %%s нет учётных таблиц DEPO (OD_FACES, OD_RESTS) — это не база DEPO. Проверьте имя в @db.', 16, 1, @db);
|
||
RETURN;
|
||
END
|
||
PRINT N'База DEPO: ' + @db;
|
||
DECLARE @in nvarchar(300) = N'USE ' + QUOTENAME(@db) + N'; ';
|
||
%[5]s
|
||
-- 1. Логин bj (если такой логин на сервере уже есть — ему ставится пароль из @pass)
|
||
-- (EXEC(...) склеивает только строки и переменные — команда собирается в @sql заранее)
|
||
DECLARE @sql nvarchar(max);
|
||
IF SUSER_ID(N'%[1]s') IS NULL
|
||
SET @sql = N'CREATE LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N''', CHECK_POLICY = ON, DEFAULT_DATABASE = ' + QUOTENAME(@db);
|
||
ELSE
|
||
SET @sql = N'ALTER LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N'''';
|
||
EXEC(@sql);
|
||
|
||
-- 2. Таблицы обмена FTV_M2M* (только недостающие таблицы, колонки и индексы — данные не меняются)
|
||
`, login, job, sqlStr(db), sqlStr(pass), depoJobGuard(f, agentJob))
|
||
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(f), 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 — после выполнения файл удалите.
|
||
-- Проверьте перед запуском: база DEPO — "%[1]s", роль bj — "%[2]s", её пароль — в строках PASSWORD ниже
|
||
-- (меняете — впишите тот же пароль в bj, шаг «Учёт DEPO»).
|
||
\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"
|
||
-- это база DEPO? (учётные таблицы OD_FACES и OD_RESTS) — иначе остановиться, ничего не создавая
|
||
DO $$ BEGIN
|
||
IF to_regclass('od_faces') IS NULL OR to_regclass('od_rests') IS NULL THEN
|
||
RAISE EXCEPTION 'В базе %% нет учётных таблиц DEPO (OD_FACES, OD_RESTS) — проверьте имя базы', current_database();
|
||
END IF;
|
||
END $$;
|
||
`, 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()
|
||
}
|
||
|
||
// depoJobGuard — проверки задания до любых изменений: у каждой базы DEPO на сервере своё задание. Задание с
|
||
// этим именем уже обрабатывает другую базу — стоп (иначе тест обработали бы боевым заданием и наоборот);
|
||
// эту базу уже обрабатывает задание с другим именем — стоп (два задания обрабатывали бы одни события дважды).
|
||
func depoJobGuard(f FancyMSSQLSettings, agentJob bool) string {
|
||
if !agentJob {
|
||
return ""
|
||
}
|
||
proc := depoJobProcName(f)
|
||
if i := strings.LastIndex(proc, "."); i >= 0 {
|
||
proc = proc[i+1:]
|
||
}
|
||
return fmt.Sprintf(`
|
||
-- Задание обработки «%[1]s» — только для этой базы: у каждой базы DEPO на сервере своё задание
|
||
IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs j JOIN msdb.dbo.sysjobsteps s ON s.job_id = j.job_id WHERE j.name = N'%[1]s' AND s.database_name <> @db)
|
||
BEGIN
|
||
RAISERROR(N'Задание %[1]s уже есть и обрабатывает другую базу. Задайте для базы %%s другое имя задания в bj (шаг «Учёт DEPO», пункт 3) и получите скрипт заново.', 16, 1, @db);
|
||
RETURN;
|
||
END
|
||
DECLARE @other sysname = (SELECT TOP 1 j.name FROM msdb.dbo.sysjobs j JOIN msdb.dbo.sysjobsteps s ON s.job_id = j.job_id
|
||
WHERE s.database_name = @db AND s.command LIKE N'%%%[2]s%%' AND j.name <> N'%[1]s');
|
||
IF @other IS NOT NULL
|
||
BEGIN
|
||
RAISERROR(N'Обработку базы %%s уже запускает задание %%s. Впишите в bj это имя задания (шаг «Учёт DEPO», пункт 3) и получите скрипт заново.', 16, 1, @db, @other);
|
||
RETURN;
|
||
END
|
||
`, depoJobName(f), proc)
|
||
}
|
||
|
||
// depoSetupFileName — имя файла скрипта.
|
||
func depoSetupFileName(f FancyMSSQLSettings) string {
|
||
if f.IsPostgres() {
|
||
return "bj-depo-setup-postgres.sql"
|
||
}
|
||
return "bj-depo-setup-mssql.sql"
|
||
}
|
||
|
||
// depoStep — ход подготовки для экрана: key — шаг, state — run (начат) | ok | warn, text — что делается или итог.
|
||
type depoStep func(key, state, text string)
|
||
|
||
// depoSetupRun — «bj сделает сам»: тот же скрипт выполняется логином администратора (не сохраняется) в базе
|
||
// db, которую назвал клиент; сначала — подключение и проверка, что это база DEPO. step — ход для экрана.
|
||
func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, adminPass, login, pass string, agentJob bool, step depoStep) error {
|
||
adm := f
|
||
adm.User, adm.Password = strings.TrimSpace(adminUser), adminPass
|
||
if !validDBName(db) {
|
||
return fmt.Errorf("укажите имя базы DEPO")
|
||
}
|
||
step("connect", "run", "Подключаюсь к серверу "+f.Server+" логином администратора «"+adm.User+"»")
|
||
if _, err := fancyPing(ctx, adm, ""); err != nil {
|
||
return fmt.Errorf("к серверу %s логином %s подключиться не удалось — %s", f.Server, adm.User, quikErrorDetail(err))
|
||
}
|
||
step("connect", "ok", "")
|
||
step("isdepo", "run", "Проверяю, что «"+db+"» — база учёта DEPO")
|
||
if err := depoCheckIsDepo(ctx, adm, db); err != nil {
|
||
return err
|
||
}
|
||
step("isdepo", "ok", "")
|
||
what := "логин bj «" + login + "», таблицы обмена, права"
|
||
if agentJob && !f.IsPostgres() {
|
||
what += ", задание «" + depoJobName(f) + "»"
|
||
}
|
||
step("apply", "run", "Применяю скрипт подготовки: "+what)
|
||
if f.IsPostgres() {
|
||
if err := depoSetupRunPG(ctx, adm, db, login, pass); err != nil {
|
||
return err
|
||
}
|
||
step("apply", "ok", "")
|
||
return nil
|
||
}
|
||
conn, err := fancyOpen(adm, "")
|
||
if err != nil {
|
||
return 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 fmt.Errorf("подготовка базы %s логином %s: %s%s", db, adm.User, quikErrorDetail(err), depoTablesRightsHint(f, adm.User, err))
|
||
}
|
||
step("apply", "ok", "")
|
||
return 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
|
||
}
|