Учёт DEPO: шаг «Обработка» проверяет запуск джобы (есть, включена, расписание, запускалась недавно — по msdb), работа процедур (BJ_PING) — отдельной проверкой «Обработка событий»; логину bj — SQLAgentReaderRole для проверки

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 14:33:34 +03:00
parent 4beb42ed45
commit e31e35e663
6 changed files with 165 additions and 36 deletions
+139 -30
View File
@@ -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
+9 -3
View File
@@ -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.
+4
View File
@@ -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)
@@ -197,7 +197,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<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">Проверка: bj кладёт служебное событие и ждёт до 40 секунд, пока обработка его отметит.</p>
<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;
@@ -208,7 +208,8 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<table style="margin:6px 0 10px">
<tr><td>Подключение к базе DEPO</td><td>{{if .Settings.Fancy.Server}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.Server}}, база {{.DepoTables.DB}}</span>{{else}}<span style="color:var(--err)">не настроено</span>{{end}}</td></tr>
<tr><td>Таблицы обмена FTV_M2M</td><td>{{if .Settings.Fancy.DepoTablesCheckOK}}<span style="color:var(--ok)">✓ на месте</span>{{else}}<span style="color:var(--err)">✗ {{.Settings.Fancy.DepoTablesCheck}}</span>{{end}}</td></tr>
<tr><td>Обработка (job)</td><td>{{if .Settings.Fancy.DepoJobCheckOK}}<span style="color:var(--ok)">✓ работает</span>{{else}}<span style="color:var(--err)">✗ {{.Settings.Fancy.DepoJobCheck}}</span>{{end}}</td></tr>
<tr><td>Запуск обработки (джоба)</td><td>{{if .Settings.Fancy.DepoJobCheckOK}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.DepoJobCheck}}</span>{{else}}<span style="color:var(--err)">✗ {{.Settings.Fancy.DepoJobCheck}}</span>{{end}}</td></tr>
<tr><td>Обработка событий (процедуры)</td><td>{{if .Settings.Fancy.DepoProcCheckOK}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.DepoProcCheck}}</span>{{else if .Settings.Fancy.DepoProcCheck}}<span style="color:var(--warn)">⚠ {{.Settings.Fancy.DepoProcCheck}}</span>{{else}}<span class="muted">не проверялась — «Перепроверить всё»</span>{{end}}</td></tr>
</table>
{{if .Done.Depo}}<p>Учёт DEPO подключён и проверен. Можно переходить к шагу 4 — там bj предложит реквизиты вашей организации из DEPO.</p>{{else}}<p class="muted" style="font-size:13px">Не всё проверено — можно продолжить настройку (шаг 4 и дальше) и вернуться сюда позже. Пока обработка не работает, записи bj ждут в таблицах обмена со статусом TODO и будут обработаны, когда она заработает.</p>{{end}}
<form method="post" action="/admin/setup/wizard-depo/recheck" style="margin:0"><button type="submit" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Перепроверить всё</button></form>
@@ -109,6 +109,7 @@
<span class="muted" style="font-size:12px">для вариантов с джобой bj пишет служебное событие BJ_PING и ждёт до 40 с, пока обработчик его отметит</span>
</div>
{{if .Settings.Fancy.DepoJobCheck}}<p style="margin-top:10px;font-size:13px;{{if .Settings.Fancy.DepoJobCheckOK}}color:var(--ok){{else}}color:var(--err){{end}}">{{if .Settings.Fancy.DepoJobCheckOK}}✓{{else}}✗{{end}} {{.Settings.Fancy.DepoJobCheck}} <span class="muted">({{.Settings.Fancy.DepoJobCheckAt.Format "02.01.2006 15:04"}})</span></p>{{end}}
{{if .Settings.Fancy.DepoProcCheck}}<p style="margin-top:4px;font-size:13px;{{if .Settings.Fancy.DepoProcCheckOK}}color:var(--ok){{else}}color:var(--warn){{end}}">Обработка событий (процедуры): {{if .Settings.Fancy.DepoProcCheckOK}}✓{{else}}⚠{{end}} {{.Settings.Fancy.DepoProcCheck}}</p>{{end}}
{{if and (eq .Settings.Fancy.DepoJobMode "bj") .Settings.Fancy.DepoJobLast}}<p class="muted" style="font-size:12px;margin-top:6px">Последний вызов процедуры — {{.Settings.Fancy.DepoJobLast}}</p>{{end}}
{{end}}
</div>
+9 -1
View File
@@ -151,7 +151,13 @@ func (h *setupHandlers) wizardDepoJob(w http.ResponseWriter, r *http.Request) {
jobNote = "джоба " + depoJobName + " создана. "
}
}
msg, ok := depoJobCheck(ctx, h.rc)
// проверяем запуск джобы (не процедуры): только что созданная джоба запускается в течение интервала — ждём
au, ap := r.FormValue("admin_user"), r.FormValue("admin_password")
msg, ok := depoJobRunnerCheck(ctx, h.rc, au, ap)
for i := 0; !ok && i < 6 && strings.Contains(msg, "ни разу не запускалась"); i++ {
time.Sleep(5 * time.Second)
msg, ok = depoJobRunnerCheck(ctx, h.rc, au, ap)
}
msg = jobNote + msg
s = h.rc.Snapshot().Fancy
s.DepoJobCheck, s.DepoJobCheckOK, s.DepoJobCheckAt = msg, ok, time.Now()
@@ -182,8 +188,10 @@ func (h *setupHandlers) wizardDepoRecheck(w http.ResponseWriter, r *http.Request
return
}
jmsg, jok := depoJobCheck(ctx, h.rc)
pmsg, pok := depoProcCheck(ctx, h.rc) // обработка событий — отдельно, шаг не держит
s = h.rc.Snapshot().Fancy
s.DepoJobCheck, s.DepoJobCheckOK, s.DepoJobCheckAt = jmsg, jok, time.Now()
s.DepoProcCheck, s.DepoProcCheckOK, s.DepoProcCheckAt = pmsg, pok, time.Now()
_ = h.rc.UpdateFancy(s)
if !jok {
wizDepoGo(w, r, "job", jmsg, false)