package lkgateway // Шаг 3 мастера настройки (учёт DEPO) — маленький мастер по экранам вместо простыни настроек: // описание → подключение → трансферные таблицы → обработка (job) → готово. Каждый экран делает // своё действие по «Далее» и сразу проверяет результат; при ошибке экран не меняется, ошибка // показывается понятным текстом. Полный раздел «Обмен Fancy» остаётся в «Настройке». import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" "time" ) // depoWizardSubs — экраны шага 3 по порядку. // Шаг 3 — два экрана (06.10.2026): подготовка базы одним действием и проверка. Прежние адреса экранов // (intro, conn, tables, job) ведут на подготовку. var depoWizardSubs = []struct{ Key, Title string }{ {"setup", "Подключение и подготовка базы"}, {"done", "Проверка"}, } func depoWizardSub(v string) string { for _, s := range depoWizardSubs { if s.Key == v { return v } } return "setup" } // wizDepoGo — перейти на экран шага 3 с сообщением (ok=false — сообщение об ошибке). func wizDepoGo(w http.ResponseWriter, r *http.Request, sub, msg string, ok bool) { http.Redirect(w, r, wizDepoURL(sub, msg, ok), http.StatusSeeOther) } // wizDepoURL — адрес экрана шага 3 с сообщением. func wizDepoURL(sub, msg string, ok bool) string { if sub == "intro" || sub == "conn" || sub == "tables" || sub == "job" { sub = "setup" } u := "/admin/wizard?step=3&sub=" + sub if msg != "" { if ok { msg = "✓ " + msg } else { msg = "✗ " + msg } u += "&flash=" + url.QueryEscape(msg) } return u } // wizardDepoConn — POST /admin/setup/wizard-depo/conn: сохранить подключение и сразу проверить его. Имя базы // DEPO клиент задаёт сам — bj по серверу не ищет, только проверяет, что это база DEPO. func (h *setupHandlers) wizardDepoConn(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } s := h.fancyFromForm(r) manual := strings.TrimSpace(r.FormValue("depo_database")) if !validDBName(manual) { wizDepoGo(w, r, "conn", "укажите имя базы DEPO — латинские буквы, цифры и _, начинается с буквы", false) return } if s.Server == "" || s.User == "" { wizDepoGo(w, r, "conn", "укажите адрес сервера и логин", false) return } if err := h.rc.UpdateFancy(s); err != nil { wizDepoGo(w, r, "conn", "не сохранилось: "+err.Error(), false) return } ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 45*time.Second) defer cancel() if _, err := fancyPing(ctx, s, ""); err != nil { wizDepoGo(w, r, "conn", "к серверу подключиться не удалось — "+err.Error()+". Проверьте адрес, порт, логин и пароль.", false) return } db := manual if err := depoCheckIsDepo(ctx, s, db); err != nil { wizDepoGo(w, r, "conn", err.Error(), false) return } 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+" работает; база DEPO — "+db, true) } // wizardDepoTables — POST /admin/setup/wizard-depo/tables: создать таблицы (bj) или проверить созданные // администратором по скрипту. func (h *setupHandlers) wizardDepoTables(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } s := h.rc.Snapshot().Fancy mode := r.FormValue("depo_tables_mode") if mode != "script" { mode = "auto" } if s.DepoTablesMode != mode { s.DepoTablesMode = mode _ = h.rc.UpdateFancy(s) } ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 2*time.Minute) defer cancel() if mode == "auto" && r.FormValue("action") != "check" { if _, err := depoTablesCreate(ctx, s, r.FormValue("admin_user"), r.FormValue("admin_password")); err != nil { wizDepoGo(w, r, "tables", "таблицы не созданы — "+err.Error()+". Если у логина bj нет прав, введите разово логин администратора SQL.", false) return } } msg, ok := depoTablesCheck(ctx, s) h.saveDepoTablesCheck(msg, ok) if !ok { wizDepoGo(w, r, "tables", msg, false) return } if !s.IsPostgres() { _ = depoWriteHandbook(h.rc) } wizDepoGo(w, r, "job", "таблицы обмена на месте: "+msg, true) } // wizardDepoJob — POST /admin/setup/wizard-depo/job: выбрать, кто запускает обработку, сделать нужное // (для «джобу создаёт bj» — создать) и проверить служебным событием. func (h *setupHandlers) wizardDepoJob(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } s := h.rc.Snapshot().Fancy mode := r.FormValue("depo_job_mode") if mode != "bj" && mode != "sql-auto" && mode != "sql-manual" { mode = "sql-auto" } if s.DepoJobMode != mode { s.DepoJobCheck, s.DepoJobCheckOK = "", false } s.DepoJobMode, s.DepoJobRun = mode, mode == "bj" if err := h.rc.UpdateFancy(s); err != nil { wizDepoGo(w, r, "job", "не сохранилось: "+err.Error(), false) return } ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 90*time.Second) defer cancel() jobNote := "" if mode == "sql-auto" && r.FormValue("action") != "check" { if err := depoJobCreateAgent(ctx, h.rc, r.FormValue("admin_user"), r.FormValue("admin_password")); err != nil { if !strings.Contains(err.Error(), "уже существует") { wizDepoGo(w, r, "job", "джоба не создана — "+err.Error(), false) return } // джобы хранятся в msdb: при восстановлении базы DEPO из копии джоба остаётся — не пересоздаём jobNote = "джоба " + depoJobName(h.rc.Snapshot().Fancy) + " на сервере уже есть (bj её не пересоздавал). " } else { jobNote = "джоба " + depoJobName(h.rc.Snapshot().Fancy) + " создана. " } } // проверяем запуск джобы (не процедуры): только что созданная джоба запускается в течение интервала — ждём 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() _ = h.rc.UpdateFancy(s) if !ok { wizDepoGo(w, r, "job", "обработка пока не работает — "+msg, false) return } wizDepoGo(w, r, "done", "обработка работает: "+msg, true) } // wizardDepoRecheck — POST /admin/setup/wizard-depo/recheck: перепроверить всё на экране «Готово». func (h *setupHandlers) wizardDepoRecheck(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } s := h.rc.Snapshot().Fancy ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 90*time.Second) defer cancel() if _, err := fancyPing(ctx, s, s.DepoDB()); err != nil { wizDepoGo(w, r, "setup", "подключение не работает — "+err.Error(), false) return } msg, ok := depoTablesCheck(ctx, s) h.saveDepoTablesCheck(msg, ok) if !ok { wizDepoGo(w, r, "setup", msg, false) 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, "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 == "" { // bj по серверу базу не ищет: с какой базой работать, клиент говорит сам return s, "", agent, "укажите имя базы DEPO — как она называется на сервере" } if !validDBName(db) { return s, "", agent, "имя базы DEPO — только латинские буквы, цифры и _, начинается с буквы" } if agent { // задание обработки — своё у каждой базы: по умолчанию bj2depo_<база> jn := strings.TrimSpace(r.FormValue("depo_job_name")) if jn == "" { jn = depoJobNameFor(db) } if !depoJobNameRe.MatchString(jn) { return s, db, agent, "имя задания — латинские буквы, цифры, точка, дефис и _" } s.DepoJobName = jn } 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() } } // depoBjCreds — логин и пароль bj с экрана подготовки. Видны в обоих способах — «логин администратора» и // «скрипт»: на общем сервере SQL логин bj_user может быть уже занят другим bj (подготовка сменила бы ему // пароль) — тогда вписывают другое имя. Пустой пароль — bj придумает сам. Возвращает текст ошибки. func depoBjCreds(r *http.Request, s *FancyMSSQLSettings) string { if u := strings.TrimSpace(r.FormValue("bj_user")); u != "" { if !depoLoginRe.MatchString(u) { return "логин bj — латинские буквы, цифры, точка, дефис и _" } if u != s.User { s.User, s.Password = u, "" } } if p := r.FormValue("bj_password"); p != "" { if len(p) < 8 { return "пароль bj — не короче 8 знаков (SQL Server не примет короче)" } s.Password = p } return "" } // wizardDepoScript — POST /admin/setup/wizard-depo/script.sql: один скрипт подготовки базы файлом // (view=1 — текстом для показа на странице: экран пересобирает его при каждой правке ответов). func (h *setupHandlers) wizardDepoScript(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } view := r.FormValue("view") != "" fail := func(msg string) { if view { http.Error(w, msg, http.StatusBadRequest) return } wizDepoGo(w, r, "setup", msg, false) } s, db, agent, bad := h.depoSetupForm(r) if bad == "" { bad = depoBjCreds(r, &s) } if bad != "" { fail(bad) return } depoEnsureLogin(&s) s.DepoTablesMode = "script" // показ на странице (экран пересобирает скрипт на ходу) ничего не сохраняет; файл — уходит администратору: // запомнить логин и пароль из него, но только черновиком — в работу они пойдут после проверки входа if !view && s.Server != "" { if err := h.rc.UpdateFancy(depoDraft(s, h.rc.Snapshot().Fancy)); err != nil { fail("не сохранилось: " + err.Error()) return } } body := depoSetupScript(s, db, s.User, s.Password, agent) w.Header().Set("Cache-Control", "no-store") if view { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte(body)) return } if !s.IsPostgres() { body = "\uFEFF" + body // SSMS без BOM читает кириллицу в комментариях как ANSI } w.Header().Set("Content-Type", "application/sql; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+depoSetupFileName(s)+`"`) _, _ = w.Write([]byte(body)) } // depoDraft — настройки экрана с логином и паролем bj черновиком: рабочими остаются прежние (saved), новые — // в DepoNewUser/DepoNewPass до успешного входа. func depoDraft(s, saved FancyMSSQLSettings) FancyMSSQLSettings { d := s d.User, d.Password = saved.User, saved.Password d.DepoNewUser, d.DepoNewPass = s.User, s.Password return d } // wizardDepoSetup — POST /admin/setup/wizard-depo/setup: подготовить базу (mode=auto — bj сам логином // администратора), проверить после скрипта (mode=script) или с готовым логином (mode=existing). // stream=1 (экран с JavaScript) — ход по шагам строками JSON, чтобы после «Подготовить» было видно, что // происходит: подключение, проверка базы, скрипт, вход bj, проверки; последняя строка — итог и куда перейти. func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !requireDepo(w, r) { return } ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 3*time.Minute) defer cancel() if r.FormValue("stream") == "" { sub, msg, ok := h.depoSetupDo(ctx, r, func(string, string, string) {}) wizDepoGo(w, r, sub, msg, ok) return } w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Accel-Buffering", "no") fl, _ := w.(http.Flusher) enc := json.NewEncoder(w) send := func(v any) { _ = enc.Encode(v) if fl != nil { fl.Flush() } } sub, msg, ok := h.depoSetupDo(ctx, r, func(key, state, text string) { send(map[string]string{"step": key, "state": state, "text": text}) }) send(map[string]any{"done": true, "ok": ok, "msg": msg, "go": wizDepoURL(sub, msg, ok)}) } // depoSetupDo — подготовка или проверка базы DEPO по форме экрана; итог — куда перейти (sub) и сообщение. func (h *setupHandlers) depoSetupDo(ctx context.Context, r *http.Request, step depoStep) (string, string, bool) { fail := func(msg string) (string, string, bool) { return "setup", msg, false } saved := h.rc.Snapshot().Fancy s, db, agent, bad := h.depoSetupForm(r) if bad == "" && s.Server == "" { bad = "укажите адрес сервера DEPO" } if bad != "" { return fail(bad) } mode, note := r.FormValue("mode"), "" switch mode { case "auto": au := strings.TrimSpace(r.FormValue("admin_user")) if au == "" { return fail("введите логин и пароль администратора SQL (они используются один раз и не сохраняются)") } if bad := depoBjCreds(r, &s); bad != "" { return fail(bad) } depoEnsureLogin(&s) if err := depoSetupRun(ctx, s, db, au, r.FormValue("admin_password"), s.User, s.Password, agent, step); err != nil { return fail("база не подготовлена — " + err.Error()) } s.DepoTablesMode = "auto" note = "база " + db + " подготовлена: логин " + s.User + ", таблицы обмена, права" if agent { note += ", задание " + depoJobName(s) } note += ". " case "existing": u := strings.TrimSpace(r.FormValue("user")) if u == "" { return fail("введите логин bj и пароль") } if u != s.User || r.FormValue("password") != "" { s.User, s.Password = u, r.FormValue("password") } default: // script — администратор выполнил скрипт: логин и пароль bj — с экрана (они же в настройках) if bad := depoBjCreds(r, &s); bad != "" { return fail(bad) } s.DepoTablesMode = "script" if s.User == "" || s.Password == "" { return fail("сначала скачайте скрипт и передайте администратору SQL") } } s.DepoDatabase = db if err := h.rc.UpdateFancy(depoDraft(s, saved)); err != nil { return fail("не сохранилось: " + err.Error()) } step("login", "run", "Вхожу в DEPO логином bj «"+s.User+"»") if _, err := fancyPing(ctx, s, ""); err != nil { return fail(note + "войти логином " + s.User + " не удалось — " + quikErrorDetail(err) + ". Выполнен ли скрипт на сервере " + s.Server + "?") } s.DepoNewUser, s.DepoNewPass = "", "" // вход проверен — логин и пароль в работу if err := h.rc.UpdateFancy(s); err != nil { return fail("не сохранилось: " + err.Error()) } step("login", "ok", "bj сохранил логин «"+s.User+"» и пароль — дальше работает под ними") if mode != "auto" { // при подготовке база уже проверена логином администратора step("isdepo", "run", "Проверяю, что «"+db+"» — база учёта DEPO") if err := depoCheckIsDepo(ctx, s, db); err != nil { // имя базы задал клиент — та ли это база return fail(note + err.Error()) } step("isdepo", "ok", "") } step("tables", "run", "Проверяю таблицы обмена FTV_M2M и права логина bj") msg, ok := depoTablesCheck(ctx, s) h.saveDepoTablesCheck(msg, ok) if !ok { return fail(note + msg) } if !s.IsPostgres() { _ = depoWriteHandbook(h.rc) } step("tables", "ok", "") // запуск обработки: только что созданное задание запускается в течение интервала — подождать if agent { step("job", "run", "Проверяю задание SQL Server Agent «"+depoJobName(s)+"» — до 30 секунд") } else { step("job", "run", "Проверяю, что bj может запускать обработку обмена") } 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, "", "") } step("job", map[bool]string{true: "ok", false: "warn"}[jok], jmsg) step("procs", "run", "Проверяю процедуры обработки обмена") pmsg, pok := depoProcCheck(ctx, h.rc) // процедуры обработки ставятся отдельно — шаг не держат step("procs", map[bool]string{true: "ok", false: "warn"}[pok], pmsg) 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) return "done", note + "подключение и таблицы обмена проверены", true }