Files
Bridge-and-Join-s/internal/lkgateway/depo_setup.go
T
zuevav 21d87fb530 Учёт DEPO: задание SQL Server Agent — своё у каждой базы (bj2depo_<база>), имя в мастере
На одном сервере бывает несколько баз 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
2026-10-06 16:09:14 +03:00

289 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}