From e31e35e663d4714c9515ee5cb442a8ceafc2e300 Mon Sep 17 00:00:00 2001 From: zuevav Date: Mon, 5 Oct 2026 14:33:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=87=D1=91=D1=82=20DEPO:=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B3=20=C2=AB=D0=9E=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=C2=BB=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B4=D0=B6?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=20(=D0=B5=D1=81=D1=82=D1=8C,=20=D0=B2=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B0,=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=BB=D0=B0=D1=81=D1=8C=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=B2=D0=BD=D0=BE=20=E2=80=94=20=D0=BF=D0=BE?= =?UTF-8?q?=20msdb),=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=B4=D1=83=D1=80=20(BJ=5FPING)=20=E2=80=94?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=BE=D0=B9=20=C2=AB?= =?UTF-8?q?=D0=9E=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=C2=BB;=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BD=D1=83=20bj=20=E2=80=94=20SQLAgentReaderRole?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/depoevents.go | 169 ++++++++++++++---- internal/lkgateway/fancydb_handlers.go | 12 +- internal/lkgateway/runtimeconfig.go | 4 + .../lkgateway/web/templates/admin_wizard.html | 5 +- .../lkgateway/web/templates/part_fancy.html | 1 + internal/lkgateway/wizard_depo.go | 10 +- 6 files changed, 165 insertions(+), 36 deletions(-) diff --git a/internal/lkgateway/depoevents.go b/internal/lkgateway/depoevents.go index f23a5bf..ebb3c5a 100644 --- a/internal/lkgateway/depoevents.go +++ b/internal/lkgateway/depoevents.go @@ -777,7 +777,12 @@ END ELSE PRINT N'Джоба %[1]s уже есть — не изменялась.'; GO -`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f)) + +-- Логину bj — чтение списка джоб и их истории: bj проверяет, что джоба запускается по расписанию. +IF USER_ID(N'%[5]s') IS NULL CREATE USER [%[5]s] FOR LOGIN [%[5]s]; +IF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[5]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[5]s]; +GO +`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f), pickStr(strings.TrimSpace(f.User), "bj_user")) } // depoJobGrantScript — права для варианта «внутри bj». @@ -807,11 +812,121 @@ func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, str } time.Sleep(3 * time.Second) } - return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s. Проверьте по порядку: 1) в базе DEPO есть процедуры обработки (FTV_M2M_JOB и FTV_M2M_PROCESS_*) — без них джоба запускается и падает; 2) джоба bj2depo есть и включена, служба SQL Server Agent запущена (история джобы в SSMS покажет ошибку); 3) пользователь, от имени которого работают процедуры (SYSDBA), есть в базе", wait) + return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s — процедуры обработки не отработали. Проверьте: 1) в базе DEPO есть процедуры FTV_M2M_JOB и FTV_M2M_PROCESS_*; 2) пользователь, от имени которого они работают (SYSDBA), есть в базе; 3) ошибку последнего запуска джобы — в её истории в SSMS", wait) } // depoJobCheck — проверка готовности выбранного способа обработки. func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) { + return depoJobRunnerCheck(ctx, rc, "", "") +} + +// depoJobRunnerCheck — запускалка обработки (05.10.2026: шаг «Обработка» проверяет только её, а не процедуры): +// SQL Agent — джоба есть, включена, есть расписание и она запускалась недавно; «bj сам» — право EXECUTE. +// adminUser/adminPass — разовый логин администратора (если введён), иначе — логин bj (нужен SQLAgentReaderRole в msdb). +func depoJobRunnerCheck(ctx context.Context, rc *RuntimeConfig, adminUser, adminPass string) (string, bool) { + f := rc.Snapshot().Fancy + switch f.DepoJobMode { + case "bj": + db, err := depoEventsDB(rc) + if err != nil { + return "подключение к DEPO: " + err.Error(), false + } + defer db.Close() + var n int + _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n) + if n == 0 { + return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false + } + return fmt.Sprintf("право на %s есть; bj будет вызывать её каждые %d с", depoJobProcName(f), depoJobEvery(f)), true + case "sql-auto", "sql-manual": + return depoAgentJobStatus(ctx, f, adminUser, adminPass) + } + return "способ обработки не выбран", false +} + +// depoAgentJobStatus — состояние джобы SQL Agent по msdb. +func depoAgentJobStatus(ctx context.Context, f FancyMSSQLSettings, adminUser, adminPass string) (string, bool) { + conn := f + if strings.TrimSpace(adminUser) != "" { + conn.User, conn.Password = strings.TrimSpace(adminUser), adminPass + } + mdb, err := fancyOpen(conn, "msdb") + if err != nil { + return "подключение к msdb: " + err.Error(), false + } + defer mdb.Close() + var ( + jobEnabled, schedEnabled, subType, subInt sql.NullInt64 + runDate, runTime, runStatus sql.NullInt64 + runMsg sql.NullString + now time.Time + ) + err = mdb.QueryRowContext(ctx, `SELECT j.enabled, s.enabled, s.freq_subday_type, s.freq_subday_interval, + h.run_date, h.run_time, h.run_status, h.message, GETDATE() + FROM msdb.dbo.sysjobs j + LEFT JOIN msdb.dbo.sysjobschedules js ON js.job_id = j.job_id + LEFT JOIN msdb.dbo.sysschedules s ON s.schedule_id = js.schedule_id + OUTER APPLY (SELECT TOP 1 run_date, run_time, run_status, message FROM msdb.dbo.sysjobhistory + WHERE job_id = j.job_id AND step_id = 0 ORDER BY instance_id DESC) h + WHERE j.name = @n`, sql.Named("n", depoJobName)).Scan(&jobEnabled, &schedEnabled, &subType, &subInt, &runDate, &runTime, &runStatus, &runMsg, &now) + if err == sql.ErrNoRows { + return "джобы " + depoJobName + " на сервере нет — создайте её (вариант «создаёт bj» с логином администратора или скрипт для администратора)", false + } + if err != nil { + return "список джоб недоступен логину " + conn.User + " (" + quikErrorDetail(err) + "). Чтобы bj мог проверять джобу, администратору SQL — один раз: " + strings.ReplaceAll(depoJobReaderGrant(f), "\n", " "), false + } + if jobEnabled.Int64 != 1 { + return "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА — включите её в SQL Server Agent", false + } + if !schedEnabled.Valid || schedEnabled.Int64 != 1 { + return "у джобы " + depoJobName + " нет включённого расписания — она не будет запускаться", false + } + every := time.Duration(depoJobEvery(f)) * time.Second + if subType.Int64 == 2 && subInt.Int64 > 0 { // каждые N секунд + every = time.Duration(subInt.Int64) * time.Second + } else if subType.Int64 == 4 && subInt.Int64 > 0 { // каждые N минут + every = time.Duration(subInt.Int64) * time.Minute + } + sched := fmt.Sprintf("джоба %s включена, запускается каждые %s", depoJobName, humanEvery(every)) + if !runDate.Valid || runDate.Int64 == 0 { + return sched + ", но ещё ни разу не запускалась — запущена ли служба SQL Server Agent?", false + } + d, t := runDate.Int64, runTime.Int64 + last := time.Date(int(d/10000), time.Month(d/100%100), int(d%100), int(t/10000), int(t/100%100), int(t%100), 0, now.Location()) + age := now.Sub(last) + limit := 3 * every + if limit < time.Minute { + limit = time.Minute + } + if age > limit { + return fmt.Sprintf("%s, но последний запуск был %s (%s назад) — запущена ли служба SQL Server Agent?", sched, last.Format("02.01 15:04:05"), humanDur(age)), false + } + msg := fmt.Sprintf("%s; последний запуск %s (%d с назад)", sched, last.Format("15:04:05"), int(age.Seconds())) + if runStatus.Int64 == 0 { // запуск есть, но обработка внутри упала — это не про джобу + txt := strings.TrimSpace(runMsg.String) + if r := []rune(txt); len(r) > 300 { + txt = string(r[:300]) + "…" + } + msg += ". Запуск завершился ошибкой обработки — это уже не про джобу, а про процедуры (см. «Обработка событий»): " + txt + } + return msg, true +} + +func humanEvery(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%d с", int(d.Seconds())) + } + return fmt.Sprintf("%d мин", int(d.Minutes())) +} + +// depoJobReaderGrant — право логину bj видеть джобы и их историю (для проверки джобы из bj). +func depoJobReaderGrant(f FancyMSSQLSettings) string { + return fmt.Sprintf("USE msdb;\nIF USER_ID(N'%[1]s') IS NULL CREATE USER [%[1]s] FOR LOGIN [%[1]s];\nIF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[1]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[1]s];", f.User) +} + +// depoProcCheck — работает ли сама обработка событий (процедуры FTV_M2M_*): таблицы, пользователь +// процедур, ошибки обработчика, проверочное событие BJ_PING. Отдельно от запускалки (джобы). +func depoProcCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) { f := rc.Snapshot().Fancy db, err := depoEventsDB(rc) if err != nil { @@ -819,7 +934,7 @@ func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) { } defer db.Close() if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") { - return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M* — создайте их: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", false + return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M*", false } if ok, msg := depoRunAsCheck(ctx, db, f); !ok { return msg, false @@ -828,37 +943,13 @@ func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) { return fmt.Sprintf("обработчик за последние сутки не смог обработать записей: %d (последняя: %s %s — %s). Устраните причину и верните их в очередь: Обмен с DEPO → «Вернуть в очередь»", len(fails), fails[0].TableRu(), fails[0].Type, fails[0].Comment), false } - switch f.DepoJobMode { - case "bj": - var n int - _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n) - if n == 0 { - return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false - } - start := time.Now() + if f.DepoJobMode == "bj" { if _, err := execProc(ctx, db, depoJobProcName(f)); err != nil { return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false } - return fmt.Sprintf("право есть, процедура %s выполнена за %s; bj будет вызывать её каждые %d с", depoJobProcName(f), time.Since(start).Round(time.Millisecond), depoJobEvery(f)), true - case "sql-auto", "sql-manual": - // 1) джоба видна через msdb? - var seen string - if mdb, err := fancyOpen(f, "msdb"); err == nil { - var enabled int - if e := mdb.QueryRowContext(ctx, "SELECT enabled FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&enabled); e == nil { - if enabled == 1 { - seen = "джоба " + depoJobName + " есть и включена; " - } else { - seen = "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА; " - } - } - mdb.Close() - } - // 2) реально ли обрабатывает: BJ_PING - ok, msg := depoJobPing(ctx, db, 40*time.Second) - return seen + msg, ok } - return "способ обработки не выбран", false + ok, msg := depoJobPing(ctx, db, 40*time.Second) + return msg, ok } // depoJobCreateAgent — создать джобу SQL Agent от имени логина bj (нужна роль SQLAgentOperatorRole). @@ -880,6 +971,7 @@ func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig, adminUser, admin return fmt.Errorf("нет доступа к msdb (%s). Нужны права: %s", quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " ")) } if n > 0 { + depoGrantJobReader(ctx, mdb, f) // джоба осталась (msdb не восстанавливали) — права на проверку всё равно выдаём return fmt.Errorf("джоба %s уже существует", depoJobName) } for _, stmt := range strings.Split(depoJobAgentScript(f), "\n") { @@ -892,9 +984,26 @@ func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig, adminUser, admin } } log.Printf("depo-job: джоба SQL Agent %s создана", depoJobName) + depoGrantJobReader(ctx, mdb, f) return nil } +// depoGrantJobReader — пока есть логин администратора: логину bj — чтение списка джоб (для проверки). +func depoGrantJobReader(ctx context.Context, mdb *sql.DB, f FancyMSSQLSettings) { + if !depoLoginRe.MatchString(f.User) { + return + } + for _, q := range strings.Split(depoJobReaderGrant(f), "\n") { + if strings.HasPrefix(q, "USE ") { + continue + } + if _, err := mdb.ExecContext(ctx, q); err != nil { + log.Printf("depo-job: право SQLAgentReaderRole логину %s: %s", f.User, quikErrorDetail(err)) + return + } + } +} + // DepoJobView — данные карточки «Где запускается обработка». type DepoJobView struct { Name, Proc, GrantScript, AgentRights, AgentScript string diff --git a/internal/lkgateway/fancydb_handlers.go b/internal/lkgateway/fancydb_handlers.go index 4476ce3..1e9bf76 100644 --- a/internal/lkgateway/fancydb_handlers.go +++ b/internal/lkgateway/fancydb_handlers.go @@ -238,14 +238,20 @@ func (h *setupHandlers) checkFancyDepoJob(w http.ResponseWriter, r *http.Request ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() msg, ok := depoJobCheck(ctx, h.rc) + pmsg, pok := depoProcCheck(ctx, h.rc) s := h.rc.Snapshot().Fancy s.DepoJobCheck, s.DepoJobCheckOK, s.DepoJobCheckAt = msg, ok, time.Now() + s.DepoProcCheck, s.DepoProcCheckOK, s.DepoProcCheckAt = pmsg, pok, time.Now() _ = h.rc.UpdateFancy(s) + run := "НЕ готово — " if ok { - setupFlash(w, r, "Обработка DEPO: готово — "+msg) - } else { - setupFlash(w, r, "Обработка DEPO: НЕ готово — "+msg) + run = "готово — " } + proc := "НЕ работает — " + if pok { + proc = "работает — " + } + setupFlash(w, r, "Запуск обработки (джоба): "+run+msg+" · Обработка событий (процедуры): "+proc+pmsg) } // createFancyDepoJob — POST /admin/setup/fancy/depo-job/create: создать джобу SQL Agent от имени bj. diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index b86ed1d..c3bf321 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -186,6 +186,10 @@ type FancyMSSQLSettings struct { DepoJobCheck string `json:"depo_job_check"` // результат последней проверки готовности DepoJobCheckOK bool `json:"depo_job_check_ok"` DepoJobCheckAt time.Time `json:"depo_job_check_at"` + // DepoProcCheck — работает ли обработка событий процедурами (BJ_PING), отдельно от запуска джобы. + DepoProcCheck string `json:"depo_proc_check,omitempty"` + DepoProcCheckOK bool `json:"depo_proc_check_ok,omitempty"` + DepoProcCheckAt time.Time `json:"depo_proc_check_at,omitempty"` DepoJobEverySec int `json:"depo_job_every_sec"` DepoJobProc string `json:"depo_job_proc"` DepoJobRunAs string `json:"depo_job_run_as,omitempty"` // пользователь базы DEPO для EXECUTE AS процедуры (пусто = SYSDBA) diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 28b886d..8107e7b 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -197,7 +197,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{.DepoJob.AdminScript}}
← Назад
-

Проверка: bj кладёт служебное событие и ждёт до 40 секунд, пока обработка его отметит.

+

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

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