Files
Bridge-and-Join-s/internal/lkgateway/wizard_depo.go
T
zuevav 4adc1398f6 База bj на своём PostgreSQL; «Не заполнено: …» для пустых полей; запись канала ИШ — в фоне, с ходом на странице
- шаг «База bj»: перенос на сервер PostgreSQL организации (проверка подключения, версии, прав, пустой базы;
  pg_dump → psql; сверка числа строк по таблицам; переключение и перезапуск); прежняя база — запасная копия;
  резервные копии: дамп внешней базы по её подключению, восстановление её не трогает
- все формы: пустые видимые обязательные поля называются по имени и подсвечиваются; DEPO — и на сервере
- «Обновить/Создать канал»: в фоне bj, ход по этапам с таймером (и после перезагрузки страницы), переходы
  заблокированы до конца, в конце проверка, что шлюз загрузил канал; состояние ИШ во время записи — без
  очереди к root-обработчику (страница больше не висит на «Проверяю…»)

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

487 lines
21 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
// Шаг 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"))
var miss []string
if au == "" {
miss = append(miss, "логин администратора SQL")
}
if r.FormValue("admin_password") == "" {
miss = append(miss, "пароль администратора SQL")
}
if len(miss) > 0 {
return fail("не заполнено: " + strings.Join(miss, ", ") + " (они используются один раз и не сохраняются)")
}
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
}