Files
Bridge-and-Join-s/internal/lkgateway/wizard_depo.go
T
zuevav 35f70a1b49 Учёт DEPO: имя базы обязательно, bj базу по серверу не ищет
Имя базы DEPO задаёт клиент — скрипт и подготовка логином администратора
работают только с ней. Автопоиск по серверу убран (экран, скрипт, подготовка,
проверка подключения, старый экран conn), выбор из нескольких найденных баз —
тоже. Вместо поиска — защита от опечатки: скрипт (SQL Server и PostgreSQL),
подготовка и проверка подключения сначала убеждаются, что в названной базе
есть учётные таблицы OD_FACES и OD_RESTS, иначе останавливаются, ничего не
меняя.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-06 15:18:15 +03:00

411 lines
17 KiB
Go

package lkgateway
// Шаг 3 мастера настройки (учёт DEPO) — маленький мастер по экранам вместо простыни настроек:
// описание → подключение → трансферные таблицы → обработка (job) → готово. Каждый экран делает
// своё действие по «Далее» и сразу проверяет результат; при ошибке экран не меняется, ошибка
// показывается понятным текстом. Полный раздел «Обмен Fancy» остаётся в «Настройке».
import (
"context"
"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) {
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)
}
http.Redirect(w, r, u, http.StatusSeeOther)
}
// 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(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(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(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 + " на сервере уже есть (bj её не пересоздавал). "
} else {
jobNote = "джоба " + depoJobName + " создана. "
}
}
// проверяем запуск джобы (не процедуры): только что созданная джоба запускается в течение интервала — ждём
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(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 — только латинские буквы, цифры и _, начинается с буквы"
}
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()
}
}
// depoScriptCreds — логин и пароль bj с экрана «Отдать скрипт администратору» (поля видны и меняются;
// пустой пароль — bj придумает сам). Возвращает текст ошибки.
func depoScriptCreds(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 = depoScriptCreds(r, &s)
}
if bad != "" {
fail(bad)
return
}
depoEnsureLogin(&s)
s.DepoTablesMode = "script"
if s.Server != "" { // скрипт собирается и до адреса сервера (экран обновляет его на ходу) — без адреса не сохраняем
if err := h.rc.UpdateFancy(s); 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))
}
// wizardDepoSetup — POST /admin/setup/wizard-depo/setup: подготовить базу (mode=auto — bj сам логином
// администратора), проверить после скрипта (mode=script) или с готовым логином (mode=existing).
func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !requireDepo(w, r) {
return
}
s, db, agent, bad := h.depoSetupForm(r)
if bad == "" && s.Server == "" {
bad = "укажите адрес сервера DEPO"
}
if bad != "" {
wizDepoGo(w, r, "setup", bad, false)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
note := ""
switch r.FormValue("mode") {
case "auto":
au := strings.TrimSpace(r.FormValue("admin_user"))
if au == "" {
wizDepoGo(w, r, "setup", "введите логин и пароль администратора SQL (они используются один раз и не сохраняются)", false)
return
}
depoEnsureLogin(&s)
if err := depoSetupRun(ctx, s, db, au, r.FormValue("admin_password"), s.User, s.Password, agent); err != nil {
wizDepoGo(w, r, "setup", "база не подготовлена — "+err.Error(), false)
return
}
s.DepoTablesMode = "auto"
note = "база " + db + " подготовлена: логин " + s.User + ", таблицы обмена, права"
if agent {
note += ", задание " + depoJobName
}
note += ". "
case "existing":
u := strings.TrimSpace(r.FormValue("user"))
if u == "" {
wizDepoGo(w, r, "setup", "введите логин bj и пароль", false)
return
}
if u != s.User || r.FormValue("password") != "" {
s.User, s.Password = u, r.FormValue("password")
}
default: // script — администратор выполнил скрипт: логин и пароль bj — с экрана (они же в настройках)
if bad := depoScriptCreds(r, &s); bad != "" {
wizDepoGo(w, r, "setup", bad, false)
return
}
s.DepoTablesMode = "script"
if s.User == "" || s.Password == "" {
wizDepoGo(w, r, "setup", "сначала скачайте скрипт и передайте администратору SQL", false)
return
}
}
s.DepoDatabase = db
if err := h.rc.UpdateFancy(s); err != nil {
wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false)
return
}
if _, err := fancyPing(ctx, s, ""); err != nil {
wizDepoGo(w, r, "setup", note+"войти логином "+s.User+" не удалось — "+quikErrorDetail(err)+". Выполнен ли скрипт на сервере "+s.Server+"?", false)
return
}
if err := depoCheckIsDepo(ctx, s, db); err != nil { // имя базы задал клиент — та ли это база
wizDepoGo(w, r, "setup", note+err.Error(), false)
return
}
msg, ok := depoTablesCheck(ctx, s)
h.saveDepoTablesCheck(msg, ok)
if !ok {
wizDepoGo(w, r, "setup", note+msg, false)
return
}
if !s.IsPostgres() {
_ = depoWriteHandbook(h.rc)
}
// запуск обработки: только что созданное задание запускается в течение интервала — подождать
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, "", "")
}
pmsg, pok := depoProcCheck(ctx, h.rc) // процедуры обработки ставятся отдельно — шаг не держат
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)
wizDepoGo(w, r, "done", note+"подключение и таблицы обмена проверены", true)
}