diff --git a/deploy/linux/sbin/bj-backup b/deploy/linux/sbin/bj-backup
index 5ccd3fb..7ff6a51 100755
--- a/deploy/linux/sbin/bj-backup
+++ b/deploy/linux/sbin/bj-backup
@@ -47,7 +47,33 @@ tarok() { tar "$@"; local rc=$?; [ $rc -le 1 ]; }
[ -n "$SHARE" ] || fail "не задано хранилище (Настройка → Резервное копирование)"
[ -r "$CRED" ] || fail "нет логина хранилища (Настройка → Резервное копирование)"
-runuser -u postgres -- pg_dump -Fc bj > "$W/bj.pgdump" || fail "дамп базы bj"
+# база bj: на этом сервере — от имени postgres; на сервере PostgreSQL организации (перенесена в мастере) — по её
+# подключению из настроек bj. Сервер организации новее нашего pg_dump — копия без базы, с пометкой: базу на сервере
+# организации резервирует её администратор баз.
+DBNOTE=""
+mapfile -t BJDB < <(python3 - <<'PY'
+import json,urllib.parse
+try:
+ d=json.load(open("/var/lib/bj/.bj/setup.json")).get("postgres",{}).get("dsn","")
+ u=urllib.parse.urlparse(d); h=(u.hostname or "").lower()
+ if d and h not in ("","localhost","127.0.0.1","::1") and not h.startswith("/"):
+ host="["+u.hostname+"]" if ":" in u.hostname else u.hostname
+ netloc=(urllib.parse.quote(u.username or "")+"@" if u.username else "")+host+(":"+str(u.port) if u.port else "")
+ print("ext"); print(urllib.parse.urlunparse((u.scheme,netloc,u.path,"",u.query,""))); print(urllib.parse.unquote(u.password or "")); print(u.hostname)
+except Exception: pass
+PY
+)
+if [ "${BJDB[0]:-}" = ext ]; then
+ if ! PGPASSWORD="${BJDB[2]:-}" pg_dump -Fc --no-owner --no-privileges --dbname="${BJDB[1]}" > "$W/bj.pgdump" 2>"$W/pgdump.err"; then
+ rm -f "$W/bj.pgdump"
+ DBNOTE="база bj на сервере организации ${BJDB[3]} в копию не вошла ($(grep -v '^\s*$' "$W/pgdump.err" | tail -1 | head -c 160)) — её резервирует администратор баз"
+ else
+ DBNOTE="база bj — с сервера организации ${BJDB[3]}"
+ fi
+ rm -f "$W/pgdump.err"
+else
+ runuser -u postgres -- pg_dump -Fc bj > "$W/bj.pgdump" || fail "дамп базы bj"
+fi
if runuser -u postgres -- psql -Atc "select 1 from pg_database where datname='igate'" 2>/dev/null | grep -q 1; then
runuser -u postgres -- pg_dump -Fc igate > "$W/igate.pgdump" || fail "дамп базы igate"
fi
@@ -83,5 +109,5 @@ smb "cd \"$DIR\"; ls" 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]{4}-[0-9]{
smb "cd \"$DIR/$d\"; prompt; mask *; mdel *; cd ..; rmdir \"$d\"" >/dev/null 2>&1 && echo "удалена старая копия $d"
fi
done
-status true "размер $SIZE"
-echo "копия $DIR/$TS готова ($SIZE)"
+status true "размер $SIZE${DBNOTE:+; $DBNOTE}"
+echo "копия $DIR/$TS готова ($SIZE)${DBNOTE:+; $DBNOTE}"
diff --git a/deploy/linux/sbin/bj-backup-admin b/deploy/linux/sbin/bj-backup-admin
index 09876d4..9d8fdf8 100755
--- a/deploy/linux/sbin/bj-backup-admin
+++ b/deploy/linux/sbin/bj-backup-admin
@@ -121,8 +121,17 @@ case "$cmd" in
printf '%s' "$out" | grep -q "NT_STATUS" && err "не удалось скачать копию $name: $(printf '%s' "$out" | grep -o 'NT_STATUS[A-Z_]*' | head -1)"
[ -s "$W/SHA256SUMS" ] || err "в копии $name нет SHA256SUMS — копия неполная"
(cd "$W" && sha256sum -c SHA256SUMS >/dev/null 2>&1) || err "контрольные суммы копии $name не сходятся — копия повреждена"
- [ -s "$W/bj.pgdump" ] && [ -s "$W/bj-data.tar.gz" ] || err "в копии $name нет базы или данных bj"
- pg_restore -l < "$W/bj.pgdump" >/dev/null 2>&1 || err "база bj в копии $name не читается"
+ [ -s "$W/bj-data.tar.gz" ] || err "в копии $name нет данных bj"
+ if [ -s "$W/bj.pgdump" ]; then
+ pg_restore -l < "$W/bj.pgdump" >/dev/null 2>&1 || err "база bj в копии $name не читается"
+ elif [ -z "$(tar -xzOf "$W/bj-data.tar.gz" var/lib/bj/.bj/setup.json 2>/dev/null | python3 -c '
+import json,sys,urllib.parse
+try:
+ h=(urllib.parse.urlparse(json.load(sys.stdin).get("postgres",{}).get("dsn","")).hostname or "").lower()
+ print(h if h not in ("","localhost","127.0.0.1","::1") else "")
+except Exception: pass')" ]; then
+ err "в копии $name нет базы bj"
+ fi # база на сервере организации, не вошедшая в копию, — её резервирует администратор баз
tar -tzf "$W/bj-data.tar.gz" >/dev/null 2>&1 || err "архив данных bj в копии $name не читается"
ok "копия $name цела: $(cd "$W" && ls | grep -vc SHA256SUMS) файлов, $(du -sh "$W" | cut -f1), контрольные суммы сходятся, база и архивы читаются" ;;
restore)
@@ -148,14 +157,27 @@ PY
st download "скачивание копии $NAME"
smbclient "$SHARE" -A /run/bj-restore.cred -m SMB3 -c "cd \"$DIR/$NAME\"; lcd $W; prompt; mget *" >/dev/null 2>&1 || die "не удалось скачать копию $NAME"
(cd "$W" && sha256sum -c SHA256SUMS >/dev/null 2>&1) || die "контрольные суммы копии не сходятся — копия повреждена"
- [ -s "$W/bj.pgdump" ] && [ -s "$W/bj-data.tar.gz" ] || die "в копии нет базы или данных bj"
+ [ -s "$W/bj-data.tar.gz" ] || die "в копии нет данных bj"
+ # база bj на сервере PostgreSQL организации (перенесена в мастере): её не восстанавливаем поверх — это база
+ # организации, у неё свежие данные и свой администратор; bj после восстановления настроек подключится к ней
+ EXTDB=$(tar -xzOf "$W/bj-data.tar.gz" var/lib/bj/.bj/setup.json 2>/dev/null | python3 -c '
+import json,sys,urllib.parse
+try:
+ h=(urllib.parse.urlparse(json.load(sys.stdin).get("postgres",{}).get("dsn","")).hostname or "").lower()
+ print(h if h not in ("","localhost","127.0.0.1","::1") else "")
+except Exception: pass')
+ [ -n "$EXTDB" ] || [ -s "$W/bj.pgdump" ] || die "в копии нет базы bj"
st stop "остановка bj и шлюза НРД"
systemctl stop bj-lk bj-npr bj-server igate >/dev/null 2>&1
+ if [ -n "$EXTDB" ]; then
+ st db "база bj — на сервере организации $EXTDB: не восстанавливается, bj подключится к ней"
+ else
st db "восстановление базы bj"
# каталог копии доступен только root — дамп подаём postgres через stdin; сообщения — по-английски для разбора ошибок
LC_ALL=C PGOPTIONS='-c lc_messages=C' runuser -u postgres -- pg_restore --clean --if-exists -d bj < "$W/bj.pgdump" >/dev/null 2>"$W/pg.err"
# «does not exist» при --clean на пустой базе — не ошибка; остальное — ошибка
if grep -i "error" "$W/pg.err" | grep -vqi "does not exist"; then die "база bj: $(grep -i error "$W/pg.err" | grep -vi 'does not exist' | head -1)"; fi
+ fi
if [ -s "$W/igate.pgdump" ]; then
st db "восстановление базы шлюза НРД"
# новый сервер: ИШ ещё не ставили — готовим пользователя, роль, базу и доступ без пароля (как ставит
diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go
index 7cd20c3..23bf602 100644
--- a/internal/lkgateway/admin.go
+++ b/internal/lkgateway/admin.go
@@ -60,6 +60,7 @@ var templateFuncs = template.FuncMap{
"backupStatus": backupStatus,
"backupWhen": backupWhen,
"smbWin": smbWin,
+ "pgLocal": pgDSNLocal,
"whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} },
"weekdayNames": func() []string { return weekdayShort },
"depoAccPrefix": depoAccPrefix,
diff --git a/internal/lkgateway/igate.go b/internal/lkgateway/igate.go
index 29c4dea..6267c93 100644
--- a/internal/lkgateway/igate.go
+++ b/internal/lkgateway/igate.go
@@ -139,6 +139,15 @@ var igateVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) {
// состояние ИШ и каналов + что выбрано в bj
mux.HandleFunc("/admin/setup/igate/status", func(w http.ResponseWriter, r *http.Request) {
+ // пока записывается канал, root-обработчик занят им (до двух минут) и запрос состояния ждал бы в очереди —
+ // страница после перезагрузки висела на «Проверяю…»; отдаём последнее известное состояние
+ if v := igateContourJob.view(); v.Running {
+ if last := igateLastStatus.get(); last != nil {
+ last["busy"] = "идёт запись канала " + strings.ToUpper(v.Contour)
+ jsonOut(w, last)
+ return
+ }
+ }
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
res, err := runKeymediaOp(ctx, "igate-status", nil)
@@ -146,6 +155,7 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) {
jsonOut(w, map[string]any{"ok": false, "error": err.Error()})
return
}
+ defer igateLastStatus.set(res)
s := rc.Snapshot()
res["active_contour"] = ""
if s.NSD.Profile != "" {
@@ -240,24 +250,16 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) {
jsonOut(w, map[string]any{"ok": false, "error": "сначала укажите депозитарный код организации (мастер, раздел «Организация»)"})
return
}
- extra, _ := json.Marshal(map[string]string{"contour": contour, "crypto": crypto, "deponent": dep})
- ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
- defer cancel()
- res, err := runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": string(extra)})
- if err == nil && res["ok"] == true && rc.Snapshot().NSD.Profile == "" {
- // контур в bj ещё не выбран (новая установка) — первый настроенный канал и есть рабочий
- n := rc.Snapshot().NSD
- n.Profile, n.IGWBaseURL = contour+"-"+crypto, pickStr(n.IGWBaseURL, igateDefaultURL)
- if rc.UpdateNSD(n) == nil {
- res["output"] = asString(res["output"]) + ". bj работает с этим контуром и перезапускается — обновите страницу через 15 секунд"
- go func() {
- time.Sleep(2 * time.Second)
- _, _ = restartUnit(context.Background(), "bj-server")
- }()
- }
+ if !igateContourJob.start(contour) {
+ jsonOut(w, map[string]any{"ok": true, "running": true}) // уже идёт — страница покажет ход
+ return
}
- refreshCertStore(rc)
- jsonOut(w, opResult(res, err))
+ go igateContourRun(rc, contour, crypto, dep)
+ jsonOut(w, map[string]any{"ok": true, "started": true})
+ })
+ // ход записи канала: страница показывает его и после перезагрузки
+ mux.HandleFunc("/admin/setup/igate/contour-status", func(w http.ResponseWriter, r *http.Request) {
+ jsonOut(w, igateContourJob.view())
})
// переключить bj на контур: профиль отправки, база DEPO контура; bj перезапускается
mux.HandleFunc("/admin/setup/igate/switch", func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/lkgateway/igatecontour.go b/internal/lkgateway/igatecontour.go
new file mode 100644
index 0000000..fe6dd49
--- /dev/null
+++ b/internal/lkgateway/igatecontour.go
@@ -0,0 +1,153 @@
+package lkgateway
+
+// Запись канала контура НРД в ИШ (06.10.2026). Root-обработчик пишет канал в базу ИШ и перезапускает шлюз — до
+// двух минут. Раньше это делал сам запрос страницы: перезагрузка страницы прерывала ожидание, а запрос
+// состояния ИШ вставал в очередь за записью канала — страница висела на «Проверяю…» без объяснений, и человек
+// мог уйти «Далее» посреди перезапуска. Теперь запись идёт в фоне bj, страница показывает её ход (и после
+// перезагрузки), а в конце bj проверяет, что шлюз действительно загрузил канал.
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
+)
+
+// igateContourView — ход записи канала для страницы.
+type igateContourView struct {
+ Running bool `json:"running"`
+ Contour string `json:"contour,omitempty"`
+ Stage string `json:"stage,omitempty"` // write | engine | channel | done
+ Started int64 `json:"started,omitempty"`
+ Done bool `json:"done,omitempty"`
+ OK bool `json:"ok"`
+ Msg string `json:"msg,omitempty"`
+ At int64 `json:"at,omitempty"`
+}
+
+type igateContourState struct {
+ sync.Mutex
+ v igateContourView
+}
+
+var igateContourJob igateContourState
+
+func (j *igateContourState) start(contour string) bool {
+ j.Lock()
+ defer j.Unlock()
+ if j.v.Running {
+ return false
+ }
+ j.v = igateContourView{Running: true, Contour: contour, Stage: "write", Started: time.Now().Unix(), OK: true}
+ return true
+}
+
+func (j *igateContourState) stage(st string) {
+ j.Lock()
+ j.v.Stage = st
+ j.Unlock()
+}
+
+func (j *igateContourState) finish(ok bool, msg string) {
+ j.Lock()
+ j.v.Running, j.v.Done, j.v.OK, j.v.Msg, j.v.Stage, j.v.At = false, true, ok, msg, "done", time.Now().Unix()
+ j.Unlock()
+}
+
+func (j *igateContourState) view() igateContourView {
+ j.Lock()
+ defer j.Unlock()
+ v := j.v
+ if v.Done && time.Since(time.Unix(v.At, 0)) > 15*time.Minute {
+ return igateContourView{OK: true} // итог давний — не показываем
+ }
+ return v
+}
+
+// igateLastStatus — последнее состояние ИШ от root-обработчика (отдаётся, пока он занят записью канала).
+var igateLastStatus lastStatus
+
+type lastStatus struct {
+ sync.Mutex
+ m map[string]any
+}
+
+func (l *lastStatus) set(m map[string]any) {
+ l.Lock()
+ l.m = m
+ l.Unlock()
+}
+
+func (l *lastStatus) get() map[string]any {
+ l.Lock()
+ defer l.Unlock()
+ if l.m == nil {
+ return nil
+ }
+ out := map[string]any{}
+ for k, v := range l.m {
+ out[k] = v
+ }
+ return out
+}
+
+// igateContourRun — запись канала контура в ИШ и проверка, что шлюз его загрузил (в фоне, без запроса страницы).
+func igateContourRun(rc *RuntimeConfig, contour, crypto, dep string) {
+ extra := `{"contour":"` + contour + `","crypto":"` + crypto + `","deponent":"` + dep + `"}`
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
+ defer cancel()
+ go func() { // этап «перезапуск ИШ» — root-обработчик пишет канал за секунды и дальше ждёт движок
+ time.Sleep(4 * time.Second)
+ if v := igateContourJob.view(); v.Running && v.Stage == "write" {
+ igateContourJob.stage("engine")
+ }
+ }()
+ res, err := runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": extra})
+ if err != nil || res["ok"] != true {
+ msg := errStr(err)
+ if e, ok := res["error"].(string); ok && e != "" {
+ msg = e
+ }
+ igateContourJob.finish(false, msg)
+ return
+ }
+ out := asString(res["output"])
+ code := strings.ToUpper(contour) + dep
+ if prof, err := nsdadapter.LookupProfile(contour + "-" + crypto); err == nil {
+ code = prof.Channel + dep
+ }
+ enabled := strings.Contains(out, "включён")
+ if enabled { // канал с ключом: шлюз должен его загрузить — иначе любой обмен даёт HTTP 500
+ igateContourJob.stage("channel")
+ base := pickStr(rc.Snapshot().NSD.IGWBaseURL, igateDefaultURL)
+ loaded := false
+ for i := 0; i < 20 && !loaded; i++ {
+ if found, en, err := igateChannelState(ctx, base, code); err == nil && found && en {
+ loaded = true
+ break
+ }
+ time.Sleep(3 * time.Second)
+ }
+ if !loaded {
+ igateContourJob.finish(false, "канал "+code+" записан, но шлюз его не загрузил — журнал ИШ: /var/lib/igate/.local/share/NSD Integration Gateway/logs-svc/log.txt")
+ return
+ }
+ out += "; шлюз загрузил канал " + code
+ }
+ if rc.Snapshot().NSD.Profile == "" {
+ // контур в bj ещё не выбран (новая установка) — первый настроенный канал и есть рабочий
+ n := rc.Snapshot().NSD
+ n.Profile, n.IGWBaseURL = contour+"-"+crypto, pickStr(n.IGWBaseURL, igateDefaultURL)
+ if rc.UpdateNSD(n) == nil {
+ out += ". bj работает с этим контуром и перезапускается — страница обновится сама"
+ go func() {
+ time.Sleep(3 * time.Second)
+ _, _ = restartUnit(context.Background(), "bj-server")
+ }()
+ }
+ }
+ refreshCertStore(rc)
+ igateContourJob.finish(true, out)
+}
diff --git a/internal/lkgateway/pgmove.go b/internal/lkgateway/pgmove.go
new file mode 100644
index 0000000..ef86aa8
--- /dev/null
+++ b/internal/lkgateway/pgmove.go
@@ -0,0 +1,353 @@
+package lkgateway
+
+// База bj на своём сервере PostgreSQL организации (06.10.2026). Установщик кладёт базу bj на этот же сервер —
+// так и остаётся по умолчанию. Если у организации свой сервер PostgreSQL (с резервным копированием и
+// администратором баз), базу можно держать там: шаг «База bj» проверяет подключение и права, переносит данные
+// (pg_dump этой базы → psql в пустую базу на том сервере), сверяет число строк в каждой таблице, переключает
+// bj и перезапускает его. Прежняя база на этом сервере не удаляется — остаётся запасной копией.
+// Только PostgreSQL: другие СУБД bj для своей базы не поддерживает.
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// pgTarget — сервер PostgreSQL организации для базы bj (из формы).
+type pgTarget struct{ Host, Port, DB, User, Password, SSL string }
+
+func pgTargetFromForm(r *http.Request) (pgTarget, string) {
+ t := pgTarget{
+ Host: strings.TrimSpace(r.FormValue("pg_host")), Port: strings.TrimSpace(r.FormValue("pg_port")),
+ DB: strings.TrimSpace(r.FormValue("pg_db")), User: strings.TrimSpace(r.FormValue("pg_user")),
+ Password: r.FormValue("pg_password"), SSL: r.FormValue("pg_ssl"),
+ }
+ if t.Port == "" {
+ t.Port = "5432"
+ }
+ switch t.SSL {
+ case "disable", "require", "verify-full":
+ default:
+ t.SSL = "prefer"
+ }
+ switch {
+ case t.Host == "" || strings.ContainsAny(t.Host, "/ @"):
+ return t, "укажите адрес сервера PostgreSQL"
+ case pgLocalHost(t.Host):
+ return t, "это адрес этого же сервера — база bj и так здесь; укажите сервер PostgreSQL организации"
+ case t.DB == "" || t.User == "":
+ return t, "укажите базу и пользователя"
+ }
+ if p, err := strconv.Atoi(t.Port); err != nil || p < 1 || p > 65535 {
+ return t, "порт — число от 1 до 65535"
+ }
+ return t, ""
+}
+
+// dsn — строка подключения; withPass=false — без пароля (для pg_dump/psql пароль идёт через PGPASSWORD,
+// чтобы не светиться в списке процессов).
+func (t pgTarget) dsn(withPass bool) string {
+ u := url.URL{Scheme: "postgres", Host: net.JoinHostPort(t.Host, t.Port), Path: "/" + t.DB}
+ if withPass && t.Password != "" {
+ u.User = url.UserPassword(t.User, t.Password)
+ } else {
+ u.User = url.User(t.User)
+ }
+ u.RawQuery = "sslmode=" + t.SSL
+ return u.String()
+}
+
+func pgLocalHost(h string) bool {
+ h = strings.Trim(strings.ToLower(h), "[]")
+ return h == "" || h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasPrefix(h, "/")
+}
+
+// pgDSNLocal — база по этой строке подключения на этом же сервере.
+func pgDSNLocal(dsn string) bool {
+ u, err := url.Parse(strings.TrimSpace(dsn))
+ return err != nil || pgLocalHost(u.Hostname())
+}
+
+// pgSplitDSN — строка подключения без пароля и пароль отдельно (для внешних программ).
+func pgSplitDSN(dsn string) (string, string) {
+ u, err := url.Parse(strings.TrimSpace(dsn))
+ if err != nil || u.User == nil {
+ return dsn, ""
+ }
+ pw, _ := u.User.Password()
+ u.User = url.User(u.User.Username())
+ return u.String(), pw
+}
+
+// pgInfo — что известно о базе: версия сервера, права, таблицы.
+type pgInfo struct {
+ Version string
+ Major int
+ CanCreate bool // CREATE в базе: создать схемы bj
+ PublicOK bool // CREATE в схеме public (с PostgreSQL 15 по умолчанию есть только у владельца базы)
+ Tables []string
+ Rows map[string]int64 // для сверки после переноса
+}
+
+func pgInspect(ctx context.Context, dsn string, counts bool) (pgInfo, error) {
+ info := pgInfo{Rows: map[string]int64{}}
+ cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ conn, err := pgx.Connect(cctx, dsn)
+ if err != nil {
+ return info, err
+ }
+ defer conn.Close(context.Background())
+ var num int
+ if err := conn.QueryRow(cctx, "SELECT current_setting('server_version'), current_setting('server_version_num')::int, has_database_privilege(current_user, current_database(), 'CREATE'), COALESCE(has_schema_privilege(current_user, 'public', 'CREATE'), false)").
+ Scan(&info.Version, &num, &info.CanCreate, &info.PublicOK); err != nil {
+ return info, err
+ }
+ info.Major = num / 10000
+ rows, err := conn.Query(cctx, "SELECT quote_ident(table_schema)||'.'||quote_ident(table_name) FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') AND table_schema NOT LIKE 'pg_toast%' ORDER BY 1")
+ if err != nil {
+ return info, err
+ }
+ for rows.Next() {
+ var n string
+ if rows.Scan(&n) == nil {
+ info.Tables = append(info.Tables, n)
+ }
+ }
+ rows.Close()
+ if counts {
+ for _, t := range info.Tables {
+ var c int64
+ if err := conn.QueryRow(ctx, "SELECT count(*) FROM "+t).Scan(&c); err != nil {
+ return info, fmt.Errorf("подсчёт строк %s: %w", t, err)
+ }
+ info.Rows[t] = c
+ }
+ }
+ return info, nil
+}
+
+// pgPublicUsed — в базе bj есть таблицы в схеме public (тогда на новом сервере нужно право создавать в ней).
+func pgPublicUsed(tables []string) bool {
+ for _, t := range tables {
+ if strings.HasPrefix(t, "public.") {
+ return true
+ }
+ }
+ return false
+}
+
+// pgCheckTarget — годится ли сервер организации: подключение, версия не старше нашей, права, пустая база.
+func pgCheckTarget(ctx context.Context, src pgInfo, t pgTarget) (pgInfo, string) {
+ dst, err := pgInspect(ctx, t.dsn(true), false)
+ if err != nil {
+ return dst, "не удалось подключиться к " + t.Host + ":" + t.Port + ", база " + t.DB + ": " + pgErrRu(err)
+ }
+ switch {
+ case src.Major > 0 && dst.Major < src.Major:
+ return dst, fmt.Sprintf("на сервере организации PostgreSQL %s, а база bj сейчас на %s — нужен PostgreSQL %d или новее", dst.Version, src.Version, src.Major)
+ case !dst.CanCreate:
+ return dst, "у пользователя " + t.User + " нет права создавать объекты в базе " + t.DB + " (GRANT CREATE ON DATABASE " + t.DB + " TO " + t.User + ") — проще всего сделать его владельцем базы"
+ case pgPublicUsed(src.Tables) && !dst.PublicOK:
+ return dst, "у пользователя " + t.User + " нет права создавать таблицы в схеме public базы " + t.DB + " (с PostgreSQL 15 оно есть только у владельца базы) — сделайте его владельцем базы: ALTER DATABASE " + t.DB + " OWNER TO " + t.User
+ case len(dst.Tables) > 0:
+ return dst, fmt.Sprintf("в базе %s уже есть таблицы (%d, например %s) — для bj нужна пустая база", t.DB, len(dst.Tables), dst.Tables[0])
+ }
+ return dst, ""
+}
+
+func pgErrRu(err error) string {
+ s := err.Error()
+ switch {
+ case strings.Contains(s, "password authentication failed"):
+ return "неверный пользователь или пароль"
+ case strings.Contains(s, "does not exist") && strings.Contains(s, "database"):
+ return "такой базы нет — создайте пустую базу"
+ case strings.Contains(s, "no pg_hba.conf entry"):
+ return "сервер не пускает с адреса bj — администратору баз нужно разрешить подключение в pg_hba.conf"
+ case strings.Contains(s, "connection refused"), strings.Contains(s, "i/o timeout"), strings.Contains(s, "no route to host"):
+ return "сервер не отвечает на этом адресе и порту (" + s + ")"
+ }
+ return s
+}
+
+// pgCopy — данные базы bj на сервер организации: pg_dump (простой SQL без владельцев и прав) → psql.
+func pgCopy(ctx context.Context, srcDSN string, t pgTarget) error {
+ src, srcPass := pgSplitDSN(srcDSN)
+ dump := exec.CommandContext(ctx, "pg_dump", "--no-owner", "--no-privileges", "--format=plain", "--dbname="+src)
+ dump.Env = append(os.Environ(), "PGPASSWORD="+srcPass)
+ load := exec.CommandContext(ctx, "psql", "--quiet", "--no-psqlrc", "-v", "ON_ERROR_STOP=1", "--dbname="+t.dsn(false))
+ load.Env = append(os.Environ(), "PGPASSWORD="+t.Password) // без PGOPTIONS: lc_messages — только суперпользователю
+ pipe, err := dump.StdoutPipe()
+ if err != nil {
+ return err
+ }
+ load.Stdin = pipe
+ var dumpErr, loadErr strings.Builder
+ dump.Stderr, load.Stderr, load.Stdout = &dumpErr, &loadErr, io.Discard
+ if err := load.Start(); err != nil {
+ return fmt.Errorf("psql: %w", err)
+ }
+ if err := dump.Start(); err != nil {
+ _ = load.Process.Kill()
+ return fmt.Errorf("pg_dump: %w", err)
+ }
+ de := dump.Wait()
+ le := load.Wait()
+ if de != nil {
+ return fmt.Errorf("pg_dump: %v %s", de, lastLine(dumpErr.String()))
+ }
+ if le != nil {
+ return fmt.Errorf("загрузка на сервер организации: %v %s", le, lastLine(loadErr.String()))
+ }
+ return nil
+}
+
+func lastLine(s string) string {
+ sc := bufio.NewScanner(strings.NewReader(s))
+ last := ""
+ for sc.Scan() {
+ if l := strings.TrimSpace(sc.Text()); l != "" {
+ last = l
+ }
+ }
+ return last
+}
+
+// pgCompare — расхождения числа строк (пусто — всё сошлось).
+func pgCompare(src, dst pgInfo) []string {
+ var bad []string
+ for _, t := range src.Tables {
+ if d, ok := dst.Rows[t]; !ok {
+ bad = append(bad, t+": нет на новом сервере")
+ } else if d != src.Rows[t] {
+ bad = append(bad, fmt.Sprintf("%s: было %d строк, стало %d", t, src.Rows[t], d))
+ }
+ }
+ sort.Strings(bad)
+ return bad
+}
+
+func (h *setupHandlers) registerPGMove(mux *http.ServeMux) {
+ mux.HandleFunc("/admin/setup/postgres/move", h.pgMove)
+}
+
+// pgMove — POST /admin/setup/postgres/move: check=1 — только проверить сервер организации; иначе перенести
+// и переключиться. Ход — строками JSON (как подготовка DEPO): этапы и итог.
+func (h *setupHandlers) pgMove(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ 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()
+ }
+ }
+ step := func(key, state, text string) { send(map[string]string{"step": key, "state": state, "text": text}) }
+ done := func(ok bool, msg string) {
+ send(map[string]any{"done": true, "ok": ok, "msg": msg, "go": "/admin/wizard?step=2&flash=" + url.QueryEscape(msg)})
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
+ defer cancel()
+
+ cur := h.rc.Snapshot().Postgres.DSN
+ t, bad := pgTargetFromForm(r)
+ if bad != "" {
+ done(false, bad)
+ return
+ }
+ if !pgDSNLocal(cur) {
+ done(false, "база bj уже не на этом сервере — перенос делается с базы на этом сервере")
+ return
+ }
+ step("src", "run", "База bj на этом сервере")
+ src, err := pgInspect(ctx, cur, false)
+ if err != nil {
+ step("src", "err", pgErrRu(err))
+ done(false, "база bj на этом сервере недоступна: "+pgErrRu(err))
+ return
+ }
+ step("src", "ok", fmt.Sprintf("PostgreSQL %s, таблиц: %d", src.Version, len(src.Tables)))
+ step("dst", "run", "Подключение к серверу организации "+t.Host+":"+t.Port+", база "+t.DB)
+ dst, why := pgCheckTarget(ctx, src, t)
+ if why != "" {
+ step("dst", "err", why)
+ done(false, why)
+ return
+ }
+ step("dst", "ok", "PostgreSQL "+dst.Version+", база пустая, права есть")
+ if r.FormValue("check") != "" {
+ done(true, "Сервер организации подходит: PostgreSQL "+dst.Version+", база "+t.DB+" пустая, права есть. Можно переносить.")
+ return
+ }
+
+ step("copy", "run", "Копирование данных bj (pg_dump → psql)")
+ start := time.Now()
+ if err := pgCopy(ctx, cur, t); err != nil {
+ step("copy", "err", err.Error())
+ done(false, "данные не перенесены: "+err.Error()+". bj продолжает работать с базой на этом сервере; на сервере организации очистите базу "+t.DB+" и повторите")
+ return
+ }
+ step("copy", "ok", "за "+humanDur(time.Since(start)))
+ step("verify", "run", "Сверка: число строк в каждой таблице")
+ srcN, err1 := pgInspect(ctx, cur, true)
+ dstN, err2 := pgInspect(ctx, t.dsn(true), true)
+ if err1 != nil || err2 != nil {
+ e := err1
+ if e == nil {
+ e = err2
+ }
+ step("verify", "err", e.Error())
+ done(false, "сверка не выполнена: "+e.Error()+". bj продолжает работать с базой на этом сервере")
+ return
+ }
+ if diff := pgCompare(srcN, dstN); len(diff) > 0 {
+ // пока шло копирование, bj мог дописать строки — это не повод переключаться на неполную базу
+ step("verify", "err", strings.Join(diff, "; "))
+ done(false, "после копирования данные не совпали ("+strings.Join(diff, "; ")+"). bj продолжает работать с базой на этом сервере; очистите базу "+t.DB+" на сервере организации и повторите в спокойное время")
+ return
+ }
+ var total int64
+ for _, n := range srcN.Rows {
+ total += n
+ }
+ step("verify", "ok", fmt.Sprintf("таблиц %d, строк %d — совпадает", len(srcN.Tables), total))
+ step("switch", "run", "Переключение bj на сервер организации и перезапуск")
+ if err := h.rc.UpdatePostgres(PostgresSettings{DSN: t.dsn(true), Local: cur}); err != nil {
+ step("switch", "err", err.Error())
+ done(false, "настройки не сохранены: "+err.Error())
+ return
+ }
+ step("switch", "ok", "bj перезапускается — через полминуты он работает с базой на "+t.Host)
+ log.Printf("postgres: база bj перенесена на %s:%s/%s (%d таблиц, %d строк); прежняя база на этом сервере сохранена", t.Host, t.Port, t.DB, len(srcN.Tables), total)
+ done(true, "База bj перенесена на сервер организации "+t.Host+", база "+t.DB+". Прежняя база на этом сервере сохранена как запасная копия.")
+ go func() { // перезапуск после ответа: страница успевает показать итог
+ time.Sleep(2 * time.Second)
+ rctx, rcancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer rcancel()
+ if _, err := restartUnit(rctx, "bj-server"); err != nil {
+ log.Printf("postgres: перезапуск после переноса: %v", err)
+ }
+ }()
+}
diff --git a/internal/lkgateway/pgmove_live_test.go b/internal/lkgateway/pgmove_live_test.go
new file mode 100644
index 0000000..3a0adc6
--- /dev/null
+++ b/internal/lkgateway/pgmove_live_test.go
@@ -0,0 +1,63 @@
+package lkgateway
+
+import (
+ "context"
+ "net/url"
+ "os"
+ "testing"
+)
+
+// Перенос базы bj на живом PostgreSQL: BJ_PGMOVE_SRC — строка подключения исходной базы, BJ_PGMOVE_DST — пустой
+// целевой (для проверки допускается этот же сервер). Без переменных — пропуск.
+func TestPGMoveLive(t *testing.T) {
+ srcDSN, dstDSN := os.Getenv("BJ_PGMOVE_SRC"), os.Getenv("BJ_PGMOVE_DST")
+ if srcDSN == "" || dstDSN == "" {
+ t.Skip("нет BJ_PGMOVE_SRC/BJ_PGMOVE_DST")
+ }
+ u, _ := url.Parse(dstDSN)
+ pw, _ := u.User.Password()
+ tg := pgTarget{Host: u.Hostname(), Port: u.Port(), DB: u.Path[1:], User: u.User.Username(), Password: pw, SSL: u.Query().Get("sslmode")}
+ ctx := context.Background()
+ src, err := pgInspect(ctx, srcDSN, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, why := pgCheckTarget(ctx, src, tg); why != "" {
+ t.Fatal(why)
+ }
+ if err := pgCopy(ctx, srcDSN, tg); err != nil {
+ t.Fatal(err)
+ }
+ s, _ := pgInspect(ctx, srcDSN, true)
+ d, err := pgInspect(ctx, tg.dsn(true), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := pgCompare(s, d); len(diff) > 0 {
+ t.Fatalf("расхождения: %v", diff)
+ }
+ var n int64
+ for _, c := range s.Rows {
+ n += c
+ }
+ t.Logf("перенесено: таблиц %d, строк %d (PostgreSQL %s → %s)", len(s.Tables), n, s.Version, d.Version)
+ if _, why := pgCheckTarget(ctx, src, tg); why == "" {
+ t.Fatal("после переноса база не пустая — повторная проверка должна отказать")
+ }
+}
+
+func TestPGTargetForm(t *testing.T) {
+ tg := pgTarget{Host: "db.local", Port: "5432", DB: "bj", User: "bj", Password: "p@ss:w/rd", SSL: "require"}
+ if got := tg.dsn(true); got != "postgres://bj:p%40ss%3Aw%2Frd@db.local:5432/bj?sslmode=require" {
+ t.Errorf("dsn: %s", got)
+ }
+ if s, p := pgSplitDSN(tg.dsn(true)); p != "p@ss:w/rd" || s != "postgres://bj@db.local:5432/bj?sslmode=require" {
+ t.Errorf("split: %s / %s", s, p)
+ }
+ if !pgDSNLocal("postgres://bj:x@127.0.0.1:5432/bj") || pgDSNLocal("postgres://bj:x@10.0.0.5:5432/bj") {
+ t.Error("pgDSNLocal")
+ }
+ if d := pgCompare(pgInfo{Tables: []string{"a.t"}, Rows: map[string]int64{"a.t": 3}}, pgInfo{Rows: map[string]int64{"a.t": 2}}); len(d) != 1 {
+ t.Errorf("сверка: %v", d)
+ }
+}
diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go
index e53b319..d54533f 100644
--- a/internal/lkgateway/runtimeconfig.go
+++ b/internal/lkgateway/runtimeconfig.go
@@ -175,6 +175,9 @@ type FetchedCACert struct {
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
type PostgresSettings struct {
DSN string `json:"dsn"`
+ // Local — прежняя база bj на этом сервере после переноса на сервер организации (pgmove.go): не удаляется,
+ // остаётся запасной копией.
+ Local string `json:"local,omitempty"`
}
// FancyMSSQLSettings — подключение к MSSQL-серверу клиента: канал обмена с
diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go
index 2daf669..a4510cb 100644
--- a/internal/lkgateway/setup.go
+++ b/internal/lkgateway/setup.go
@@ -57,6 +57,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
})
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
+ h.registerPGMove(mux)
h.registerFancy(mux) // вкладка «Обмен Fancy (MSSQL)»
h.registerQuik(mux) // вкладка «QUIK МНП» (блокировка бумаг)
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html
index 64141a5..f8b2183 100644
--- a/internal/lkgateway/web/templates/admin_wizard.html
+++ b/internal/lkgateway/web/templates/admin_wizard.html
@@ -218,9 +218,9 @@
Точное имя базы учёта DEPO — bj будет работать только с ней. Перед подготовкой bj проверит, что в ней есть учётные таблицы DEPO, и в чужую базу ничего не запишет.Где база DEPO
bj придумал их сам. Можно заменить своими — bj сохранит и будет использовать ровно то, что здесь написано: подготовка создаст на сервере этот логин с этим паролем (в скрипте — они же).
Если логин с таким именем на сервере уже есть, ему будет поставлен этот пароль — программа, которая им пользуется, перестанет входить. Тогда впишите другое имя.
@@ -286,8 +286,8 @@База самого bj: журнал заявок и поручений, история обмена с НРД и DEPO, пользователи.
- {{if .Done.Postgres}}Если у организации есть свой сервер PostgreSQL — с резервным копированием и администратором баз, — базу bj можно держать на нём. bj перенесёт данные, сверит число строк в каждой таблице и переключится сам; база на этом сервере останется запасной копией. Нужно: пустая база на сервере организации, пользователь — её владелец, доступ с адреса bj ({{serverIP}}) к порту PostgreSQL. Только PostgreSQL той же версии, что здесь, или новее.
+ + + +Пропустить (режим разработки: in-memory — без сохранения сделок)
{{end}} @@ -634,7 +679,7 @@ https://{{serverIP}}:8443/Чтобы адрес не менялся, задайте постоянный: на консоли сервера — sudo bj-network.
Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}
{{end}} + {{if .AutoDone}}Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}{{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}. Базу bj можно держать и на своём сервере PostgreSQL →{{end}}
{{end}} + +