diff --git a/deploy/iso/build-iso-ubuntu.sh b/deploy/iso/build-iso-ubuntu.sh index 7e462dc..0787882 100755 --- a/deploy/iso/build-iso-ubuntu.sh +++ b/deploy/iso/build-iso-ubuntu.sh @@ -10,6 +10,8 @@ # Каталог Валидаты — zpki/zsdk (ПК «Валидата Клиент L» для Linux, fs.moex.com ClientL_Other.zip) # и libldap-2.4-2 из Ubuntu 20.04 (нужна zpki, в 22.04 её нет). Ubuntu 22.04 — ОС, на которой # «Валидата Клиент L» ставится официально (Debian 13 её установщик не принимает). +# IGATE= — дистрибутив интеграционного шлюза НРД (с nsd.ru): кладётся в образ, и шлюз +# ставится из мастера настройки без интернета («Установить с образа»). # Меню загрузки (grub, BIOS и UEFI): по умолчанию — установка Bridge&Join с автоответами. set -eu SRC=${1:?ubuntu-22.04.x-live-server-amd64.iso} @@ -39,6 +41,14 @@ FILES=$(python3 -c 'import json,sys,base64;s=json.load(open(sys.argv[1]));m=json [ -n "$FILES" ] || { echo "в релизе нет компонентов ядра"; exit 1; } for f in $FILES; do cp "$BUNDLE/$f" "$WORK/bj/bundle/"; done +# дистрибутив ИШ НРД (необязательно) +if [ -n "${IGATE:-}" ]; then + case "$(basename "$IGATE")" in igate-*-amd64.zip) ;; *) echo "IGATE — нужен igate-<версия>-amd64.zip с сайта НРД"; exit 1 ;; esac + unzip -l "$IGATE" | grep -q 'igate_.*_amd64\.deb' || { echo "в $IGATE нет igate_*.deb"; exit 1; } + mkdir -p "$WORK/bj/igate"; cp "$IGATE" "$WORK/bj/igate/" + echo "ИШ в образе: $(basename "$IGATE")" +fi + # слой обновлений Ubuntu (необязательно) LAYER=${LAYER:-} LNAME=ubuntu-server-minimal.ubuntu-server.bj diff --git a/deploy/iso/ubuntu/user-data.tmpl b/deploy/iso/ubuntu/user-data.tmpl index 3955722..03af5fc 100644 --- a/deploy/iso/ubuntu/user-data.tmpl +++ b/deploy/iso/ubuntu/user-data.tmpl @@ -30,6 +30,7 @@ autoinstall: - cp -r /cdrom/bj/bundle /target/var/lib/bj/bundle - cp -r /cdrom/bj/validata /target/var/lib/bj/validata - sh -c 'if [ -d /cdrom/bj/debs ]; then cp -r /cdrom/bj/debs /target/var/lib/bj/debs; fi' + - sh -c 'if [ -d /cdrom/bj/igate ]; then mkdir -p /target/var/lib/bj/igate-dist && cp /cdrom/bj/igate/igate-*-amd64.zip /target/var/lib/bj/igate-dist/; fi' - install -m 0755 /cdrom/bj/bj-installer /cdrom/bj/bj-firstboot /cdrom/bj/bj-welcome /cdrom/bj/bj-network /target/usr/local/sbin/ - install -m 0644 /cdrom/bj/bj-firstboot.service /cdrom/bj/bj-welcome.service /cdrom/bj/bj-welcome.timer /target/etc/systemd/system/ - install -m 0644 /cdrom/bj/channel /target/etc/bj/channel diff --git a/deploy/linux/sbin/bj-igate-admin b/deploy/linux/sbin/bj-igate-admin index 332d060..3388c6d 100755 --- a/deploy/linux/sbin/bj-igate-admin +++ b/deploy/linux/sbin/bj-igate-admin @@ -3,7 +3,8 @@ # Вызывает root-демон bj-keymedia по запросу из bj (мастер, шаг «Шлюз НРД»); параметры — JSON в BJ_EXTRA. # status — JSON: версия, служба, движок, каналы (контуры) # install — установить или обновить ИШ из дистрибутива НРД (в фоне; ход — /var/lib/bj/igate-install-status.json) -# BJ_EXTRA: {"file": "<путь к igate-*.zip|.deb>"} или {"version": "107.0.930"} — скачать с nsd.ru +# BJ_EXTRA: {"file": "<путь к igate-*.zip|.deb>"}, {"version": "107.0.930"} — скачать с nsd.ru, +# или {"bundled": "1"} — дистрибутив с образа (/var/lib/bj/igate-dist), без интернета # install-now — сама установка (запускается через systemd-run) # contour — создать или обновить канал контура и перезапустить ИШ # BJ_EXTRA: {"contour": "test3|prod", "crypto": "gost|rsa", "deponent": "MC…", "profile": "<профиль Валидаты>"} @@ -90,6 +91,7 @@ case "$cmd" in install) pgrep -f "bj-igate-admin install-now" >/dev/null && err "установка ИШ уже идёт" file=$(j file); ver=$(j version) + [ "$(j bundled)" = 1 ] && [ -z "$file$ver" ] && { file=$(ls /var/lib/bj/igate-dist/igate-*-amd64.zip 2>/dev/null | sort -V | tail -1); [ -n "$file" ] || err "в образе нет дистрибутива ИШ"; BJ_EXTRA="{\"file\":\"$file\"}"; } [ -n "$file$ver" ] || err "не указан дистрибутив ИШ (файл или версия)" [ -z "$file" ] || [ -f "$file" ] || err "файл дистрибутива не найден: $file" [[ -z "$ver" || "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || err "версия ИШ — вида 107.0.930" @@ -241,7 +243,7 @@ SQL systemctl enable igate >/dev/null 2>&1 systemctl restart igate || die "служба igate не запустилась" wait_engine 120 || die "ИШ запущен, но движок не перешёл в Running за 2 минуты. Журнал ИШ: $DATA_DIR/logs-svc/log.txt" - case "$(j file)" in /var/lib/bj/igate-upload/*) rm -f "$(j file)" ;; esac # загруженный архив больше не нужен + case "$(j file)" in /var/lib/bj/igate-upload/*) rm -f "$(j file)" ;; esac # загруженный архив больше не нужен (с образа — остаётся) st done "ИШ $(igate_version) установлен и работает; дальше — канал контура (TEST3 или боевой)" ;; contour) diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index 8dc844c..7748ba0 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -14,7 +14,7 @@ process_one(){ # интеграционный шлюз НРД: состояние, установка, канал контура (TEST3 | боевой); параметры — JSON в extra igate-status|igate-install|igate-contour) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-igate-admin "${action#igate-}" 2>&1) ;; # системные настройки сервера: серверы времени (NTP) - system-ntp) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin ntp 2>&1) ;; + system-ntp|system-tz) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin "${action#system-}" 2>&1) ;; # резервное копирование и восстановление (настройки bj → bj-backup-admin); параметры — JSON в extra backup-config|backup-test|backup-run|backup-list|backup-verify|backup-restore) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-backup-admin "${action#backup-}" 2>&1) ;; # Перезапуск службы модуля из админки. Список закрытый: чужую службу diff --git a/deploy/linux/sbin/bj-system-admin b/deploy/linux/sbin/bj-system-admin index 3ff4c98..5fa39e4 100755 --- a/deploy/linux/sbin/bj-system-admin +++ b/deploy/linux/sbin/bj-system-admin @@ -1,5 +1,6 @@ #!/bin/bash # bj-system-admin — системные настройки сервера bj из веб-интерфейса (через root-демон bj-keymedia). +# tz — часовой пояс сервера (BJ_EXTRA: "Europe/Moscow") # ntp — серверы времени (BJ_EXTRA: "ntp1.example.ru ntp2.example.ru"): ntpd, chrony или systemd-timesyncd — # что стоит на сервере. Время важно для подписей и обмена с НРД. set -uo pipefail @@ -42,5 +43,10 @@ PY systemctl restart systemd-timesyncd || err "служба systemd-timesyncd не перезапустилась" ok "серверы времени: $servers (служба systemd-timesyncd)" fi ;; + tz) + zone=$(printf '%s' "${BJ_EXTRA:-}" | tr -d ' ') + [[ "$zone" =~ ^[A-Za-z]+(/[A-Za-z_+-]+)*$ ]] && [ -f "/usr/share/zoneinfo/$zone" ] || err "неизвестный часовой пояс: $zone" + timedatectl set-timezone "$zone" 2>/dev/null || { ln -sf "/usr/share/zoneinfo/$zone" /etc/localtime && echo "$zone" > /etc/timezone; } || err "не удалось установить часовой пояс" + ok "часовой пояс сервера: $zone" ;; *) err "неизвестная команда: $cmd" ;; esac diff --git a/deploy/release/component-versions.json b/deploy/release/component-versions.json index da2f9df..d0f1ff5 100644 --- a/deploy/release/component-versions.json +++ b/deploy/release/component-versions.json @@ -1,11 +1,11 @@ { "bj-component-install": { - "fingerprint": "c6935ab9b97592629f8f997cabfb76fe64b9b41ad043150232753f46c5740774", - "version": "2026.10.05.15" + "fingerprint": "d3b3541f32bfdec0f87c3ec6937cad4bb9f0fc1d081807ce285f35c55d7863be", + "version": "2026.10.05.18" }, "bj-installer": { - "fingerprint": "39d6dca9b7789bc7cc585ba30958bfb4625a1d41cea149ec4dcfd998cf901045", - "version": "2026.10.05.15" + "fingerprint": "fa98721f711ada9d469923ca592a23936d853809e3e8e894b8ced0ddcecab815", + "version": "2026.10.05.18" }, "bj-lk": { "fingerprint": "37bf4ba7d13bc3cfb52032dfb95851b9d5d702f1a2244e185a1b6a5b87a5cfc6", @@ -20,8 +20,8 @@ "version": "2026.09.25.14" }, "bj-server": { - "fingerprint": "c07806b81189f3963bc74b209159b64ca2358e52f6587f11754d47bf7528cb47", - "version": "2026.10.05.15" + "fingerprint": "fdb494d78bafc78559d6938177634dfe2aea8e1acf4db8e86f50debdf1bf12f0", + "version": "2026.10.05.18" }, "crypto-service.jar": { "fingerprint": "2c4fd536bc9b1c20c69a70e8dcd374a61847b263aeb2e474c45debb01fbd8233", diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 53c93ce..5caea74 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -84,19 +84,24 @@ var templateFuncs = template.FuncMap{ } return "IP-сервера" }, - "timeSynced": func() bool { ok, _ := timeStatus(context.Background()); return ok }, - "intList": func(v ...int) []int { return v }, - "nsdSegTip": nsdSegTip, - "devOn": authTemplateFuncs["devOn"], - "devLicensed": authTemplateFuncs["devLicensed"], - "mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии - "isIIS": incomingIsIIS, // входящий перевод на ИИС - "depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера - "authOpen": authOpen, - "internalDocs": func() bool { return internalDocsBuilt }, - "roleTitle": roleTitle, - "stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c }, - "stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t }, + "timeSynced": func() bool { ok, _ := timeStatus(context.Background()); return ok }, + "serverTimeZone": serverTimeZone, + "letter": letterView, + "mailtoLink": mailtoLink, + "handbookDepos": handbookDepos, + "timeZones": func() any { return wizTimeZones }, + "intList": func(v ...int) []int { return v }, + "nsdSegTip": nsdSegTip, + "devOn": authTemplateFuncs["devOn"], + "devLicensed": authTemplateFuncs["devLicensed"], + "mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии + "isIIS": incomingIsIIS, // входящий перевод на ИИС + "depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера + "authOpen": authOpen, + "internalDocs": func() bool { return internalDocsBuilt }, + "roleTitle": roleTitle, + "stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c }, + "stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t }, "deref": func(v *float64) float64 { if v == nil { return 0 diff --git a/internal/lkgateway/chaintest.go b/internal/lkgateway/chaintest.go new file mode 100644 index 0000000..d093085 --- /dev/null +++ b/internal/lkgateway/chaintest.go @@ -0,0 +1,276 @@ +package lkgateway + +// «Проверить всю цепочку» — сквозная проверка без настоящего перевода (05.10.2026). Каждое звено — только +// если оно есть в лицензии и настроено: +// - учёт DEPO: проверочное событие BJ_PING обработано процедурами обмена; +// - QUIK МНП: объекты, инструменты, справочник получателей (только чтение); +// - QUIK МНП, по желанию: пробная блокировка 1 шт. бумаги клиента («Вывод ЦБ (M2M)») и сразу снятие; +// - подпись ключом организации (Валидата); +// - НРД: запрос справочника участников уходит через шлюз и подписывается, ответ приходит во входящие. +// Звенья идут по очереди; упавшее не останавливает остальные — видно всю картину. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" +) + +// ChainStep — звено проверки. +type ChainStep struct { + Key string `json:"key"` + Title string `json:"title"` + State string `json:"state"` // wait | run | ok | warn | err + Detail string `json:"detail,omitempty"` +} + +var chainTest struct { + sync.Mutex + Running bool + Started time.Time + Finished time.Time + Steps []ChainStep +} + +func chainSet(i int, state, detail string) { + chainTest.Lock() + defer chainTest.Unlock() + chainTest.Steps[i].State, chainTest.Steps[i].Detail = state, detail +} + +type chainQuikBlock struct { + Client, ISIN, INN string +} + +var ( + quikClientRe = regexp.MustCompile(`^[A-Za-z0-9_+-]{1,20}$`) + isinRe = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}[0-9]$`) + innRe = regexp.MustCompile(`^\d{10}$`) +) + +func chainRun(rc *RuntimeConfig, nsdReal bool, blk *chainQuikBlock) { + s := rc.Snapshot() + mnp := moduleAllowed(rc, "quik-mnp") + var steps []ChainStep + if mnp && strings.TrimSpace(s.Fancy.Server) != "" { + steps = append(steps, ChainStep{Key: "depo", Title: "Учёт DEPO: проверочное событие обработано"}) + } + if mnp && strings.TrimSpace(s.Quik.Server) != "" { + steps = append(steps, ChainStep{Key: "quik", Title: "QUIK МНП: модуль неторговых поручений готов"}) + if blk != nil { + steps = append(steps, ChainStep{Key: "quik-block", Title: "QUIK МНП: пробная блокировка 1 бумаги и снятие"}) + } + } + steps = append(steps, ChainStep{Key: "sign", Title: "Подпись ключом организации"}, ChainStep{Key: "nsd", Title: "НРД: запрос через шлюз — ответ получен"}) + for i := range steps { + steps[i].State = "wait" + } + chainTest.Lock() + chainTest.Steps, chainTest.Started, chainTest.Finished = steps, time.Now(), time.Time{} + chainTest.Unlock() + defer func() { + chainTest.Lock() + chainTest.Running, chainTest.Finished = false, time.Now() + chainTest.Unlock() + }() + + for i, st := range steps { + chainSet(i, "run", "") + state, detail := chainStepRun(rc, s, st.Key, nsdReal, blk) + chainSet(i, state, detail) + } +} + +func chainStepRun(rc *RuntimeConfig, s Settings, key string, nsdReal bool, blk *chainQuikBlock) (string, string) { + switch key { + case "depo": + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + db, err := depoEventsDB(rc) + if err != nil { + return "err", "подключение к DEPO: " + err.Error() + } + defer db.Close() + if msg := depoObjectsMissing(ctx, db, s.Fancy); msg != "" { + return "err", msg + } + if ok, msg := depoJobPing(ctx, db, 60*time.Second); !ok { + return "err", msg + } else { + return "ok", msg + } + case "quik": + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cs := quikMNPCheck(ctx, s.Quik) + var bad []string + for _, c := range cs { + if c.State == "err" { + bad = append(bad, c.Title+": "+c.Detail) + } + } + if len(bad) > 0 { + return "err", strings.Join(bad, "; ") + } + if mnpWorst(cs) == "warn" { + return "warn", "модуль на месте; есть замечания — подробно в разделе «QUIK МНП»" + } + return "ok", "подключение, объекты M2M, инструменты и справочник получателей на месте" + case "quik-block": + return chainQuikBlockRun(s, blk) + case "sign": + if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" { + return "err", "средство подписи не настроено" + } + cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.Provider(s.Crypto.Provider), SocketPath: s.Crypto.SocketPath, ModulePath: s.Crypto.ModulePath}) + defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + payload := []byte("") + sig, err := cli.SignXMLDSig(ctx, payload, "", s.Crypto.Profile) + if err != nil { + return "err", "подпись не получилась: " + err.Error() + } + return "ok", fmt.Sprintf("подпись получена (%d байт)", len(sig)) + case "nsd": + if !nsdReal { + return "err", "шлюз НРД не настроен — bj работает в режиме имитации (раздел «Шлюз НРД»)" + } + start := time.Now() + nsdProbeNow() + deadline := start.Add(4 * time.Minute) + for time.Now().Before(deadline) { + time.Sleep(2 * time.Second) + nsdH.Lock() + sent, reply, perr, deliv, delErr, delErrAt := nsdH.ProbeSentAt, nsdH.ProbeReplyAt, nsdH.ProbeErr, nsdH.LastDelivered, nsdH.LastDelErr, nsdH.LastDelErrAt + nsdH.Unlock() + if sent.After(start) && perr != "" { + return "err", "запрос не отправлен в шлюз: " + perr + } + if delErrAt.After(start) && delErr != "" { + return "err", "шлюз не доставил пакет в НРД: " + delErr + } + if reply.After(start) { + d := "НРД ответил за " + humanDur(reply.Sub(sent)) + " — подпись, шлюз, доставка и входящие работают" + if deliv.After(start) { + d = "пакет доставлен в НРД, " + d + } + return "ok", d + } + } + return "err", "НРД не ответил за 4 минуты — проверьте связь шлюза с контуром НРД (раздел «Шлюз НРД») и доступ к контуру" + } + return "err", "неизвестное звено" +} + +// chainQuikBlockRun — «Вывод ЦБ (M2M)» 1 шт. и немедленное снятие. Запись в МНП разрешена только для +// этой проверки — пользователь явно включил её и подтвердил. +func chainQuikBlockRun(s Settings, blk *chainQuikBlock) (string, string) { + q := s.Quik + q.Emulate, q.WritesEnabled = false, true + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + ext := "BJ-CHAIN-" + time.Now().Format("060102150405") + if err := quikEnsureRecipient(ctx, q, blk.INN, ""); err != nil { + return "err", "депозитарий-получатель в справочнике МНП: " + quikErrorDetail(err) + } + aid, err := quikExecNewInstr(ctx, q, true, ext, blk.Client, blk.ISIN, 1, blk.INN, true, "bj: проверка цепочки — пробная блокировка") + if err != nil { + return "err", "регистрация поручения: " + quikErrorDetail(err) + } + r := quikWaitAction(ctx, q, aid, 90*time.Second) + if r.QueryStatus != 2 || r.InstrNo == "" || r.InstrNo == "0" { + return "err", "МНП не принял поручение: " + pickStr(r.ReplyText, quikStatusResultName(r.QueryStatus)) + } + no, _ := strconv.ParseFloat(r.InstrNo, 64) + aid2, err := quikExecDeny(ctx, q, no, "bj: проверка цепочки — снятие пробной блокировки") + if err != nil { + return "err", "поручение №" + r.InstrNo + " зарегистрировано, но не снято: " + quikErrorDetail(err) + " — снимите его в QUIK вручную" + } + r2 := quikWaitAction(ctx, q, aid2, 90*time.Second) + if r2.QueryStatus != 2 { + return "err", "поручение №" + r.InstrNo + " зарегистрировано, снятие не подтверждено: " + pickStr(r2.ReplyText, quikStatusResultName(r2.QueryStatus)) + " — проверьте в QUIK" + } + return "ok", "поручение №" + r.InstrNo + " («Вывод ЦБ (M2M)», 1 шт. " + blk.ISIN + ") зарегистрировано — бумага заблокирована — и снято" +} + +func registerChainTest(mux *http.ServeMux, rc *RuntimeConfig, nsdReal bool) { + mux.HandleFunc("/admin/setup/chain-test", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + var blk *chainQuikBlock + if r.FormValue("quik_block") != "" { + if !moduleAllowed(rc, "quik-mnp") { + jsonOut(w, map[string]any{"ok": false, "error": "модуль QUIK не входит в лицензию"}) + return + } + b := chainQuikBlock{Client: strings.TrimSpace(r.FormValue("quik_client")), ISIN: strings.ToUpper(strings.TrimSpace(r.FormValue("quik_isin"))), INN: strings.TrimSpace(r.FormValue("quik_inn"))} + switch { + case !quikClientRe.MatchString(b.Client): + jsonOut(w, map[string]any{"ok": false, "error": "код клиента QUIK — буквы и цифры, например BR859"}) + return + case !isinRe.MatchString(b.ISIN): + jsonOut(w, map[string]any{"ok": false, "error": "ISIN — 12 знаков, например RU0009029540"}) + return + case !innRe.MatchString(b.INN): + jsonOut(w, map[string]any{"ok": false, "error": "ИНН депозитария-получателя — 10 цифр"}) + return + } + blk = &b + } + chainTest.Lock() + if chainTest.Running { + chainTest.Unlock() + jsonOut(w, map[string]any{"ok": false, "error": "проверка уже идёт"}) + return + } + chainTest.Running = true + chainTest.Unlock() + go chainRun(rc, nsdReal, blk) + jsonOut(w, map[string]any{"ok": true}) + }) + mux.HandleFunc("/admin/setup/chain-test/status", func(w http.ResponseWriter, r *http.Request) { + chainTest.Lock() + defer chainTest.Unlock() + out := map[string]any{"running": chainTest.Running, "steps": chainTest.Steps} + if !chainTest.Started.IsZero() { + out["started"] = chainTest.Started.Format("02.01.2006 15:04:05") + } + if !chainTest.Finished.IsZero() { + out["finished"] = chainTest.Finished.Format("15:04:05") + } + jsonOut(w, out) + }) +} + +// handbookDepos — депозитарии справочника участников M2M (кроме своей организации) — для выбора получателя +// пробной блокировки: они же есть в справочнике получателей МНП (bj синхронизирует его со справочником НРД). +func handbookDepos() []HandbookEntry { + raw, _ := readHandbook() + var hb struct { + Participants []HandbookEntry `json:"participants"` + } + _ = json.Unmarshal(raw, &hb) + own := "" + if globalRC != nil { + own = orgINN(globalRC.Snapshot()) + } + var out []HandbookEntry + for _, p := range hb.Participants { + if p.Kind == "depo" && p.INN != "" && p.INN != own { + out = append(out, p) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/lkgateway/igate.go b/internal/lkgateway/igate.go index 85784f6..3eaa3ec 100644 --- a/internal/lkgateway/igate.go +++ b/internal/lkgateway/igate.go @@ -1,6 +1,6 @@ package lkgateway -// Интеграционный шлюз НРД (ИШ, служба igate) на этом сервере: состояние для мастера (шаг 7). +// Интеграционный шлюз НРД (ИШ, служба igate) на этом сервере: состояние для мастера (глава «НРД»). import ( "context" @@ -14,6 +14,8 @@ import ( "regexp" "strings" "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" ) const igateDefaultURL = "http://localhost:8090" @@ -120,6 +122,7 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { res["deponent"] = pickStr(s.NSD.DeponentCode, orgCode(s)) res["depo_db"] = s.Fancy.DepoDB() res["depo_db_test"], res["depo_db_prod"] = s.NSD.DepoDBTest, s.NSD.DepoDBProd + res["bundled"] = igateBundledVersion() jsonOut(w, res) }) // установка или обновление ИШ: загруженный архив НРД или версия для скачивания с nsd.ru @@ -150,6 +153,12 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { return } params["file"] = dst + } else if r.FormValue("bundled") == "1" { + if igateBundledVersion() == "" { + jsonOut(w, map[string]any{"ok": false, "error": "в образе нет дистрибутива ИШ — скачайте с сайта НРД или загрузите файл"}) + return + } + params["bundled"] = "1" } else if v := strings.TrimSpace(r.FormValue("version")); igateVersionRe.MatchString(v) { params["version"] = v } else { @@ -187,7 +196,7 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { } dep := pickStr(s.NSD.DeponentCode, orgCode(s)) if dep == "" { - jsonOut(w, map[string]any{"ok": false, "error": "сначала укажите депозитарный код организации (шаг 4)"}) + jsonOut(w, map[string]any{"ok": false, "error": "сначала укажите депозитарный код организации (мастер, раздел «Организация»)"}) return } extra, _ := json.Marshal(map[string]string{"contour": contour, "crypto": crypto, "deponent": dep}) @@ -300,7 +309,7 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { name := map[string]string{"prod": "боевой", "test3": "тестовый TEST3"}[contour] msg := "bj переключён на " + name + " контур (база DEPO " + rc.Snapshot().Fancy.DepoDB() + ") и перезапускается — обновите страницу через 15 секунд" if !rc.Snapshot().Fancy.DepoTablesCheckOK { - msg += ". База DEPO сменилась — пройдите шаг 3 (таблицы обмена и обработка в этой базе)" + msg += ". База DEPO сменилась — пройдите в мастере раздел «Учёт DEPO» (таблицы обмена и обработка в этой базе)" } jsonOut(w, map[string]any{"ok": true, "output": msg}) }) @@ -318,3 +327,16 @@ func refreshCertStore(rc *RuntimeConfig) { } }() } + +// igateBundledVersion — версия ИШ, лежащего на сервере с образа (/var/lib/bj/igate-dist; "" — нет). +func igateBundledVersion() string { + m, _ := filepath.Glob("/var/lib/bj/igate-dist/igate-*-amd64.zip") + best := "" + for _, f := range m { + v := strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(filepath.Base(f), "igate-"), "-amd64.zip"), "-", ".") + if igateVersionRe.MatchString(v) && (best == "" || release.IsNewer(v, best)) { + best = v + } + } + return best +} diff --git a/internal/lkgateway/letters.go b/internal/lkgateway/letters.go new file mode 100644 index 0000000..f958258 --- /dev/null +++ b/internal/lkgateway/letters.go @@ -0,0 +1,133 @@ +package lkgateway + +// Готовые письма тем, без кого подключение не закончить (05.10.2026): НРД (доступ к тестовому и боевому +// контурам), ARQA (модуль неторговых поручений QUIK), администратору базы DEPO. Реквизиты подставляются +// из настроек; то, чего bj не знает (контактное лицо), — в квадратных скобках. У НРД бывают свои формы +// заявок — письмо тогда идёт сопроводительным. + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "strings" +) + +// Letter — готовое письмо. +type Letter struct { + Key, Title, To, Subject, Body string +} + +func letterOrg(s Settings) (name, inn, code string) { + inn = orgINN(s) + name = strings.TrimSpace(s.NSD.OrgName) + if name == "" && inn != "" { // наименование не введено — из справочника участников НРД по ИНН + raw, _ := readHandbook() + var hb struct { + Participants []HandbookEntry `json:"participants"` + } + _ = json.Unmarshal(raw, &hb) + for _, p := range hb.Participants { + if p.INN == inn { + name = pickStr(p.FullName, p.Name) + break + } + } + } + if name == "" && globalRC != nil { + name = licenseStatus(globalRC).Tenant + } + return pickStr(name, "[наименование организации]"), pickStr(inn, "[ИНН]"), pickStr(orgCode(s), "[код участника]") +} + +func orBracket(v, what string) string { return pickStr(strings.TrimSpace(v), "["+what+"]") } + +// letterFor — письмо по ключу: nsd-test | nsd-prod | arqa | dba. +func letterFor(s Settings, key string) (Letter, bool) { + name, inn, code := letterOrg(s) + sign := "\n\nКонтактное лицо: [ФИО, должность, телефон, e-mail]\n\nС уважением,\n[ФИО, должность]\n" + name + crypto := "ГОСТ" + if strings.HasSuffix(s.NSD.Profile, "rsa") { + crypto = "RSA" + } + req := fmt.Sprintf("— депозитарный код: %s\n— депозитарный счёт: %s, раздел: %s\n— программа обмена: Интеграционный шлюз НРД (ИШ), канал WSL, криптография %s\n— роль: депозитарий — участник переводов ценных бумаг M2M", + orBracket(s.NSD.DeponentCode, "депозитарный код"), orBracket(s.NSD.AccountID, "депозитарный счёт"), orBracket(s.NSD.SectionID, "раздел счёта"), crypto) + switch key { + case "nsd-test": + return Letter{Key: key, Title: "Письмо в НРД: доступ к тестовому контуру TEST3", To: "НКО АО НРД, служба поддержки электронного документооборота", + Subject: "Заявка на подключение к тестовому контуру TEST3 для переводов M2M — " + name, + Body: fmt.Sprintf("В НКО АО НРД\n(служба поддержки электронного документооборота)\n\n%s (ИНН %s, код участника %s) просит предоставить доступ к тестовому контуру TEST3 электронного документооборота НРД для обмена сообщениями о переводах ценных бумаг M2M.\n\nСведения для подключения:\n%s\n\nПросим также сообщить порядок получения тестового ключа электронной подписи (УЦ Московской биржи) и включить нас в тестирование сценариев переводов M2M.\n\nЕсли для подключения установлена форма заявки — просим её направить, а это письмо считать сопроводительным.%s", + name, inn, code, req, sign)}, true + case "nsd-prod": + return Letter{Key: key, Title: "Письмо в НРД: подключение к боевому контуру", To: "НКО АО НРД, служба поддержки электронного документооборота", + Subject: "Заявка на подключение к промышленному контуру для переводов M2M — " + name, + Body: fmt.Sprintf("В НКО АО НРД\n(служба поддержки электронного документооборота)\n\n%s (ИНН %s, код участника %s) завершила тестирование обмена сообщениями о переводах ценных бумаг M2M на контуре TEST3 и просит подключить промышленный контур электронного документооборота НРД.\n\nСведения для подключения:\n%s\n— ключ электронной подписи для промышленного контура: выдан УЦ Московской биржи [номер сертификата, срок действия]\n\nЕсли для подключения установлена форма заявки — просим её направить, а это письмо считать сопроводительным.%s", + name, inn, code, req, sign)}, true + case "arqa": + ip := pickStr(outboundIP(s.Quik.Server, s.Quik.Port), firstServerIP()) + return Letter{Key: key, Title: "Письмо в ARQA Technologies: модуль неторговых поручений", To: "ARQA Technologies, служба поддержки QUIK", + Subject: "Подключение модуля неторговых поручений QUIK для переводов M2M — " + name, + Body: fmt.Sprintf("В ARQA Technologies\n(служба поддержки QUIK)\n\n%s (ИНН %s) подключает систему Bridge&Join для переводов ценных бумаг M2M через НРД. Система работает с модулем неторговых поручений (МНП) QUIK через его базу данных. Просим:\n\n1. Подключить и настроить модуль неторговых поручений для переводов M2M:\n — классы инструментов INSTROUTM2M (вывод ценных бумаг) и INSTRINM2M (ввод ценных бумаг);\n — типы неторговых поручений для M2M (100/101) в QAdmin;\n — справочник депозитариев-получателей M2MDepoRecipients: его заполняет внешняя система.\n2. Выдать SQL-логин для внешней системы с правами API модуля (роль InstrEu): процедуры sp_NewInstrSec, sp_SignInstrSec, cancel_instruction; таблицы Export, Contracts, M2MDepoRecipients.\n3. Разрешить подключение к базе данных МНП с сервера Bridge&Join (адрес %s) и сообщить адрес сервера, порт и имя базы.\n4. После первого заполнения справочника M2MDepoRecipients перезапустить модуль неторговых поручений: справочник читается при запуске.%s", + name, inn, orBracket(ip, "IP-адрес сервера bj"), sign)}, true + case "dba": + ip := pickStr(outboundIP(s.Fancy.Server, s.Fancy.Port), firstServerIP()) + f := s.Fancy + login := "bj-depo-login-mssql.sql" + job := "3. Создать задание SQL Server Agent обработки обмена — скрипт bj-depo-job-mssql.sql. Задание вызывает процедуры обработки FTV_M2M_JOB и FTV_M2M_PROCESS_* (поставляются отдельно; устанавливаются в базу DEPO в порядке WIRING → OPERATION → EVENTS → CREATE → JOB)." + dbms := "SQL Server" + if f.IsPostgres() { + login, dbms = "bj-depo-login-postgres.sql", "PostgreSQL" + job = "3. Выдать логину bj право запускать процедуру обработки обмена (её вызывает сам bj) — строка в мастере, раздел «Учёт DEPO» → «Обработка». Процедуры обработки FTV_M2M_* поставляются отдельно." + } + return Letter{Key: key, Title: "Письмо администратору базы DEPO", To: "Администратор СУБД учёта DEPO", + Subject: "Bridge&Join: доступ к базе DEPO " + f.DepoDB(), + Body: fmt.Sprintf("Коллеги, добрый день!\n\nДля подключения системы переводов ценных бумаг Bridge&Join к учёту DEPO нужно на сервере %s (база %s):\n\n1. Создать логин для bj с минимальными правами — скрипт %s: чтение учётных таблиц и запись только в таблицы обмена.\n2. Создать таблицы обмена FTV_M2M* — скрипт %s. Учётные данные они не меняют.\n%s\n4. Разрешить подключение к %s с сервера Bridge&Join (адрес %s).\n\nСкрипты прилагаются (скачиваются в мастере настройки bj, раздел «Учёт DEPO»). Все скрипты можно запускать повторно: если объект уже есть, он не меняется.%s", + orBracket(f.Server, "сервер DEPO"), f.DepoDB(), login, depoTablesFileName(f), job, dbms, orBracket(ip, "IP-адрес сервера bj"), sign)}, true + } + return Letter{}, false +} + +func firstServerIP() string { + _, ips := localAddrs() + for _, ip := range ips { + if ip.To4() != nil && !ip.IsLoopback() { + return ip.String() + } + } + return "" +} + +// letterView — письмо для шаблона (по текущим настройкам). +func letterView(key string) *Letter { + if globalRC == nil { + return nil + } + if l, ok := letterFor(globalRC.Snapshot(), key); ok { + return &l + } + return nil +} + +// GET /admin/setup/letters/<ключ>.txt — письмо файлом. +func registerLetters(mux *http.ServeMux, rc *RuntimeConfig) { + mux.HandleFunc("/admin/setup/letters/", func(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/setup/letters/"), ".txt") + l, ok := letterFor(rc.Snapshot(), key) + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="bj-letter-`+key+`.txt"`) + _, _ = fmt.Fprintf(w, "Кому: %s\r\nТема: %s\r\n\r\n%s\r\n", l.To, l.Subject, strings.ReplaceAll(l.Body, "\n", "\r\n")) + }) +} + +// mailtoLink — ссылка «Открыть в почте» (тема и текст письма; пробелы и переводы строк — %20 и %0A). +func mailtoLink(l *Letter) template.URL { + if l == nil { + return "" + } + return template.URL("mailto:?subject=" + url.PathEscape(l.Subject) + "&body=" + url.PathEscape(l.Body)) +} diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index f6c82fe..494aad6 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -3,6 +3,7 @@ package lkgateway import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -182,3 +183,56 @@ func TestKeyArchiveContour(t *testing.T) { t.Fatalf("боевой сертификат: %q", c) } } + +func TestWizardChaptersWithoutLicense(t *testing.T) { + rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") + done := map[int]bool{2: true, 6: true} + path, chapters := wizPath(rc, done, 4) + want := []int{1, 12, 4, 7, 8, 9, 10} // без лицензии нет учёта DEPO, QUIK и модулей; база и Валидата — сделаны + if fmt.Sprint(path) != fmt.Sprint(want) { + t.Fatalf("путь %v, ожидался %v", path, want) + } + if len(chapters) != 4 || !chapters[1].Current || chapters[1].Steps[0].N != 4 { + t.Fatalf("главы: %+v", chapters) + } + if p, n := wizNeighbours(path, 4); p != 12 || n != 7 { + t.Fatalf("соседи шага 4: %d %d", p, n) + } + if d := wizDefaultStep(path, map[int]bool{1: true, 4: false}); d != 4 { + t.Fatalf("по умолчанию: %d", d) + } + all := map[int]bool{} + for _, n := range path { + all[n] = true + } + if d := wizDefaultStep(path, all); d != 10 { + t.Fatalf("всё сделано — проверка, а не %d", d) + } + if len(wizModuleViews(rc, rc.Snapshot())) != 0 { + t.Fatal("без лицензии модулей в мастере быть не должно") + } +} + +func TestLettersFill(t *testing.T) { + var s Settings + s.NSD.OrgName, s.NSD.OrgINN, s.NSD.OrgCode = "АО «Тест»", "7700000001", "MC0000000001" + s.NSD.DeponentCode, s.NSD.AccountID, s.NSD.SectionID = "MC0000000001", "HL000000001C", "36MC0000000001F00" + s.Fancy.Server = "sql.example" + for _, k := range []string{"nsd-test", "nsd-prod", "arqa", "dba"} { + l, ok := letterFor(s, k) + if !ok || !strings.Contains(l.Body, "АО «Тест»") || l.Subject == "" { + t.Errorf("%s: %+v", k, l) + } + } + l, _ := letterFor(s, "nsd-test") + if !strings.Contains(l.Body, "HL000000001C") || !strings.Contains(l.Body, "ГОСТ") { + t.Error("в письме НРД нет реквизитов") + } + s.Fancy.DBType = "postgres" + if l, _ := letterFor(s, "dba"); strings.Contains(l.Body, "SQL Server Agent") || !strings.Contains(l.Body, "postgres") { + t.Error("письмо администратору PostgreSQL не должно говорить о SQL Agent") + } + if _, ok := letterFor(s, "nope"); ok { + t.Error("неизвестное письмо") + } +} diff --git a/internal/lkgateway/readiness.go b/internal/lkgateway/readiness.go index 3de881b..1b3edd0 100644 --- a/internal/lkgateway/readiness.go +++ b/internal/lkgateway/readiness.go @@ -248,6 +248,10 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { return c }) run(func() ReadyCheck { return readyNetwork(ctx, s) }) + for i, m := range wizModuleViews(rc, s) { // модули по лицензии (без лицензии — нет и строки) + m := m + add(ReadyCheck{Order: 20 + i, Title: "Модуль: " + m.Title, Link: m.Link, State: map[bool]string{true: "ok", false: "warn"}[m.Ready], Detail: m.Status}) + } run(func() ReadyCheck { c := ReadyCheck{Order: 15, Title: "Учётные записи", Link: "/admin/setup#users"} var admins, others int diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index b8a08a8..cd1120c 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -221,6 +221,8 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение) registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат registerTime(mux, rc) // сервер времени (NTP) + registerTimeZone(mux) // часовой пояс сервера + registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) registerQuikOps(mux, rc, svc) @@ -229,6 +231,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerSetCallback(mux, svc, rc) registerSeedListing(mux, store) registerHandbookRequest(mux, igwClient, igwChannel) + registerChainTest(mux, rc, igwClient != nil) // «Проверить всю цепочку» (мастер, глава «Запуск») return &Server{ cfg: cfg, diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 3d5115b..8d1e15e 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -179,6 +179,15 @@ type WizardData struct { AutoDone []string // шаги до текущего, уже выполненные без участия пользователя, — с пояснением Certs []cryptocli.Certificate Flash string + // главы мастера (wizchapters.go): путь этой установки, соседние шаги, шапка + Path []int + Chapters []WizChapter + Prev, Next int + StepDone bool + StepRequired bool + StepTitle string + ChapterTitle string + Modules []WizModule // глава «Запуск»: модули по лицензии Done struct { License bool Depo bool @@ -190,6 +199,8 @@ type WizardData struct { NSD bool TestRun bool Alerts bool // оповещения настроены и резервные копии включены + Modules bool // модули по лицензии настроены + Server bool // время сервера синхронизируется } } @@ -247,23 +258,43 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { d.Done.NSD = doneNSD(s) d.Done.TestRun = s.LastTest != nil || (!d.DevMode && readinessDone()) // вне режима разработки — итоговая проверка готовности пройдена - // Мастер идёт по порядку: каждый шаг показывается, даже если он уже выполнен (установщиком, - // лицензией) — на нём написано, что сделано, и пользователь сам нажимает «Далее». Без ?step - // продолжаем со следующего за последним пройденным шагом. - progress := wizardProgress() - step := progress + 1 + d.Done.Org = doneOrg(s) + d.Done.Alerts = doneAlerts(s) + d.Done.Quik = !d.DepoLicensed || doneQuik(s) + d.Modules = wizModuleViews(h.rc, s) + d.Done.Server, _ = timeStatus(r.Context()) + d.Done.Modules = true + for _, m := range d.Modules { + if !m.Ready { + d.Done.Modules = false + } + } + // Главы (wizchapters.go): в пути только шаги, нужные этой установке — по лицензии и без сделанного + // установщиком. Без ?step — первый несделанный обязательный шаг, а если всё сделано — проверка. + done := map[int]bool{1: d.Done.License, 2: d.Done.Postgres, 3: d.Done.Depo, 4: d.Done.Org, 5: d.Done.Quik, 6: d.Done.Crypto, + 7: d.Done.NSD, 8: d.Done.Certs, 9: d.Done.Alerts, 10: d.Done.TestRun, 11: d.Done.Modules, 12: d.Done.Server} + path, _ := wizPath(h.rc, done, 0) + step := wizDefaultStep(path, done) if v := r.URL.Query().Get("step"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= wizardSteps { step = n - if n-1 > progress { - setWizardProgress(n - 1) - } } } - if step > wizardSteps { - step = wizardSteps - } d.Step = step + d.Path, d.Chapters = wizPath(h.rc, done, step) + d.Prev, d.Next = wizNeighbours(d.Path, step) + d.StepDone, d.StepRequired, d.StepTitle = done[step], wizRequired[step], wizStepTitles[step] + for _, c := range d.Chapters { + if c.Current { + d.ChapterTitle = c.Title + } + } + if done[2] { + d.AutoDone = append(d.AutoDone, "База bj создана установщиком"+pickStr(" — "+d.PGSummary, "")) + } + if done[6] { + d.AutoDone = append(d.AutoDone, "СКЗИ «Валидата Клиент L» "+d.ValidataVer+" установлена вместе с bj") + } if step == 4 && strings.TrimSpace(s.Fancy.Server) != "" && !doneOrg(s) && depoDetectResult() == nil { // организация ещё не задана, а база DEPO подключена — определяем реквизиты сами: карточка // покажет их заполненными, пользователю остаётся проверить и сохранить @@ -278,7 +309,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { } cancel() } - if (step >= 3 && step <= 5) || step == 9 { // DEPO, организация, QUIK МНП, оповещения и копии — общие блоки из «Настройки» + if (step >= 3 && step <= 5) || step == 9 || step == 11 { // DEPO, организация, QUIK МНП, оповещения и копии, модули — общие блоки из «Настройки» fd := h.setupData(w, r, "") d.Fancy = &fd } @@ -288,9 +319,6 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { d.Sub = "done" } } - d.Done.Org = doneOrg(s) - d.Done.Alerts = doneAlerts(s) - d.Done.Quik = !d.DepoLicensed || doneQuik(s) if step == 3 && d.DepoLicensed && strings.TrimSpace(s.Fancy.Server) != "" { // Есть ли у сервера SQL Agent: у Express его нет, у PostgreSQL — тоже; тогда обработку // запускает сам bj. Иначе по умолчанию джобу SQL Agent создаёт bj. @@ -334,26 +362,7 @@ func depoServerEdition(ctx context.Context, f FancyMSSQLSettings) (string, bool) // wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП, // 6 Валидата, 7 шлюз НРД, 8 ключ и сертификаты (ключ ставится в профиль ИШ — после шлюза), // 9 оповещения и резервные копии, 10 проверка готовности. -const wizardSteps = 10 - -// wizardProgressFile — номер последнего пройденного шага мастера (переход «Далее» на шаг N -// означает, что шаги до N пройдены). -const wizardProgressFile = "/var/lib/bj/.bj/wizard-progress" - -func wizardProgress() int { - b, err := os.ReadFile(wizardProgressFile) - if err != nil { - return 0 - } - n, _ := strconv.Atoi(strings.TrimSpace(string(b))) - return n -} - -func setWizardProgress(n int) { - if err := os.WriteFile(wizardProgressFile, []byte(strconv.Itoa(n)+"\n"), 0o600); err != nil { - log.Printf("wizard: прогресс мастера не сохранён: %v", err) - } -} +const wizardSteps = 12 // 11 — модули по лицензии (глава «Запуск»), 12 — время и сеть (глава «Сервер») // dsnSummary — «host:port, база name» из строки подключения PostgreSQL, без логина и пароля. func dsnSummary(dsn string) string { @@ -448,6 +457,7 @@ func (h *setupHandlers) testSignCrypto(w http.ResponseWriter, r *http.Request) { // SetupData — данные для шаблона admin_setup.html. type SetupData struct { page + FromWizard bool // раздел открыт из мастера (настройка модуля) — показать «Вернуться в мастер» Settings Settings Suggest *DepoRequisites // предложение реквизитов из DEPO Readiness []Readiness @@ -537,6 +547,7 @@ func (h *setupHandlers) setupData(w http.ResponseWriter, req *http.Request, flas License: licenseStatus(h.rc), Health: HealthSnapshot(), AdvancedUnlocked: advancedUnlocked(req), + FromWizard: req.URL.Query().Get("from") == "wizard", NSDCerts: nsdCertsView(s), Participants: handbookParticipants(), OrgINN: orgINN(s), diff --git a/internal/lkgateway/timesync.go b/internal/lkgateway/timesync.go index 2ca8101..99c1568 100644 --- a/internal/lkgateway/timesync.go +++ b/internal/lkgateway/timesync.go @@ -7,6 +7,7 @@ package lkgateway import ( "context" "net/http" + "os" "os/exec" "strings" "time" @@ -59,3 +60,71 @@ func asString(v any) string { } return "" } + +// wizTimeZones — часовые пояса России для выбора (время НРД — московское). +var wizTimeZones = []struct{ Zone, Title string }{ + {"Europe/Kaliningrad", "Калининград (UTC+2)"}, + {"Europe/Moscow", "Москва (UTC+3)"}, + {"Europe/Samara", "Самара (UTC+4)"}, + {"Asia/Yekaterinburg", "Екатеринбург (UTC+5)"}, + {"Asia/Omsk", "Омск (UTC+6)"}, + {"Asia/Novosibirsk", "Новосибирск (UTC+7)"}, + {"Asia/Krasnoyarsk", "Красноярск (UTC+7)"}, + {"Asia/Irkutsk", "Иркутск (UTC+8)"}, + {"Asia/Yakutsk", "Якутск (UTC+9)"}, + {"Asia/Vladivostok", "Владивосток (UTC+10)"}, + {"Asia/Magadan", "Магадан (UTC+11)"}, + {"Asia/Kamchatka", "Камчатка (UTC+12)"}, +} + +// serverTimeZone — часовой пояс сервера (timedatectl; запасной — /etc/timezone). +func serverTimeZone() string { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if out, err := exec.CommandContext(ctx, "timedatectl", "show", "-p", "Timezone", "--value").Output(); err == nil { + if z := strings.TrimSpace(string(out)); z != "" { + return z + } + } + if b, err := os.ReadFile("/etc/timezone"); err == nil { + return strings.TrimSpace(string(b)) + } + return time.Local.String() +} + +func registerTimeZone(mux *http.ServeMux) { + mux.HandleFunc("/admin/setup/timezone", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + zone := strings.TrimSpace(r.FormValue("zone")) + known := false + for _, z := range wizTimeZones { + if z.Zone == zone { + known = true + } + } + if !known { + setupFlash(w, r, "Часовой пояс: выберите из списка") + return + } + if zone == serverTimeZone() { + setupFlash(w, r, "Часовой пояс уже "+zone) + return + } + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + res, err := runKeymediaOp(ctx, "system-tz", map[string]string{"extra": zone}) + if err != nil || res["ok"] != true { + setupFlash(w, r, "Часовой пояс не изменён: "+pickStr(asString(res["error"]), errStr(err))) + return + } + // время процесса bj берётся при запуске — перезапускаем, чтобы журналы и сроки шли по новому поясу + go func() { + time.Sleep(2 * time.Second) + _, _ = restartUnit(context.Background(), "bj-server") + }() + setupFlash(w, r, asString(res["output"])+". bj перезапускается — обновите страницу через 15 секунд. Шлюз НРД перейдёт на новый пояс при своём следующем перезапуске.") + }) +} diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 2472828..94aa7c7 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -25,6 +25,7 @@
+ {{if .FromWizard}}
Вы настраиваете модуль из мастера настройки.← Вернуться в мастер
{{end}} {{/* ============ ОБЗОР ============ */}}
diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index e2f02c5..2ee4e16 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -21,26 +21,24 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } .help-block { background:var(--accent-weak); background:var(--accent-weak); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; } .help-block strong { color:var(--accent); } .wiz-card { max-width:680px; margin:0 auto; min-width:0; } +.wiz-sub { display:flex; justify-content:center; gap:6px; flex-wrap:wrap; margin:-12px auto 18px; max-width:680px; } +.wiz-sub a { font-size:12.5px; padding:4px 12px; border-radius:14px; background:var(--card-2); color:var(--muted); text-decoration:none; border:1px solid var(--border); } +.wiz-sub a.done { color:var(--ok); } +.wiz-sub a.cur { background:var(--accent); color:#fff; border-color:var(--accent); } +.wiz-nav { margin-top:20px; display:flex; justify-content:space-between; gap:8px; flex-wrap:wrap; } .wiz-card * { min-width:0; }

Настройка Bridge&Join

-

Пошаговый мастер первого запуска — состояние сохраняется после каждого шага

+

Четыре главы: сервер, учёт и организация, НРД, запуск. Всё сохраняется сразу — можно прерваться и вернуться

-
-

Шаг 1. Лицензия

+

Лицензия

Что это. Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.
Зачем. От лицензии зависят модули (обмен с DEPO и QUIK, личный кабинет, НПР/УДС) и получение обновлений: bj скачает и поставит модули, входящие в лицензию, автоматически.
Где взять. Портал клиента lic.zetit.ru/portal или письмо от ZETIT.
@@ -138,15 +136,14 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{end}} - + {{template "wizNav" .}}
{{end}} {{/* ============= ШАГ 3: Учёт DEPO (Fancy) — по экранам ============= */}} {{if eq .Step 3}}
-

Шаг 3. Учётная система DEPO (Fancy)

+

Учётная система DEPO (Fancy)

{{if not .DepoLicensed}}

Модуль обмена с DEPO и QUIK не входит в текущую лицензию — шаг пропускается.

{{else}} @@ -155,9 +152,10 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{if eq $sub "intro"}}
Что это. Подключение bj к базе депозитарного учёта DEPO (Fancy).
- Зачем. bj сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и сам передаёт в DEPO поручения и их исполнение — документы в учёте создаются автоматически. Отсюда же bj возьмёт реквизиты вашей организации (шаг 4).
+ Зачем. bj сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и сам передаёт в DEPO поручения и их исполнение — документы в учёте создаются автоматически. Отсюда же bj возьмёт реквизиты вашей организации (раздел «Организация»).
Что понадобится. Адрес сервера базы DEPO, логин и пароль — их даёт ваш администратор баз данных. Для создания таблиц обмена и джобы обработки может понадобиться разово логин администратора SQL — он не сохраняется. В базе DEPO должны быть процедуры обработки обмена (расширение bj2depo) и версия Fancy DEPO с поддержкой M2M — bj проверит их по имени и скажет, чего не хватает.
Как пойдём. Подключение → таблицы обмена → обработка → проверка. Каждый шаг проверяется сразу.
+ {{template "letterCard" (letter "dba")}} {{else if eq $sub "conn"}} @@ -270,19 +268,17 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } Обработка событий (процедуры){{if .Settings.Fancy.DepoProcCheckOK}}✓ {{.Settings.Fancy.DepoProcCheck}}{{else if .Settings.Fancy.DepoProcCheck}}⚠ {{.Settings.Fancy.DepoProcCheck}}{{else}}не проверялась — «Перепроверить всё»{{end}} {{if .Settings.Fancy.Server}}{{template "accPrefix" .}}{{end}} - {{if .Done.Depo}}

Учёт DEPO подключён и проверен. Можно переходить к шагу 4 — там bj предложит реквизиты вашей организации из DEPO.

{{else}}

Не всё проверено — можно продолжить настройку (шаг 4 и дальше) и вернуться сюда позже. Пока обработка не работает, записи bj ждут в таблицах обмена со статусом TODO и будут обработаны, когда она заработает.

{{end}} + {{if .Done.Depo}}

Учёт DEPO подключён и проверен. Дальше — «Организация»: bj предложит реквизиты вашей организации из DEPO.

{{else}}

Не всё проверено — можно продолжить настройку (шаг 4 и дальше) и вернуться сюда позже. Пока обработка не работает, записи bj ждут в таблицах обмена со статусом TODO и будут обработаны, когда она заработает.

{{end}}
{{end}} {{end}} - + {{template "wizNav" .}}
{{end}} {{/* ============= ШАГ 2: База bj (PostgreSQL) ============= */}} {{if eq .Step 2}}
-

Шаг 2. База данных bj (PostgreSQL)

+

База данных bj (PostgreSQL)

База самого bj: журнал заявок и поручений, история обмена с НРД и DEPO, пользователи.

{{if .Done.Postgres}}

✓ База готова{{if .PGSummary}}: {{.PGSummary}}{{end}}. Её создал установщик — ничего делать не нужно, переходите к следующему шагу.

{{end}} @@ -311,10 +307,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } -
- ← К шагу 1 - {{if .Done.Postgres}}К шагу 3 →{{else}}{{end}} -
+ {{template "wizNav" .}} {{if and devOn (not .Done.Postgres)}}

Пропустить (режим разработки: in-memory — без сохранения сделок)

{{end}}
{{end}} @@ -322,9 +315,9 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{/* ============= ШАГ 4: Наша организация ============= */}} {{if eq .Step 4}}
-

Шаг 4. Наша организация

+

Наша организация

Что это. Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: код участника, ИНН и наименование попадают в пакеты НРД и в поручения QUIK.
- Откуда взять. Если на шаге 3 подключена база DEPO, bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите себя из справочника участников НРД или введите вручную.
+ Откуда взять. Если подключена база DEPO (раздел «Учёт DEPO»), bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите себя из справочника участников НРД или введите вручную.
{{if .Suggest}}{{if .Suggest.OrgName}}{{if not .Done.Org}}

✓ Определено из DEPO: {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}, код {{.Suggest.OrgCode}}{{if .Suggest.AccountID}}, счёт НРД {{.Suggest.AccountID}}{{end}}. Поля ниже заполнены — проверьте и нажмите «Сохранить».

{{end}}{{end}}{{end}} {{with .Fancy}}{{template "orgSection" .}}{{end}}
@@ -351,40 +344,35 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
-
- ← К шагу 3 - {{if .Done.Org}}К шагу 5 →{{else}}Пропустить →{{end}} -
+ {{template "wizNav" .}}
{{end}} {{/* ============= ШАГ 5: QUIK МНП ============= */}} {{if eq .Step 5}}
-

Шаг 5. QUIK — модуль неторговых поручений

+

QUIK — модуль неторговых поручений

Что это. Модуль неторговых поручений QUIK (МНП) — продукт компании ARQA Technologies, разработчика QUIK. Через него bj блокирует бумаги клиента в QUIK на время перевода, снимает блокировку при отказе и после исполнения проводит поручение.
Если МНП у вас ещё нет. Обратитесь в ARQA Technologies (arqatech.com, служба поддержки QUIK) и попросите подключить модуль неторговых поручений для работы с внешней системой. После подключения ARQA выдаст адрес сервера МНП и учётные данные — их и нужно ввести ниже.
Если МНП уже есть. Адрес сервера и учётные данные — у вашего администратора QUIK.
+ {{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{end}} {{if not .DepoLicensed}}

Модуль неторговых поручений не входит в текущую лицензию — шаг пропускается.

{{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}} {{if .Settings.Quik.Server}}{{template "mnpCheck" .}}{{end}} {{if .Settings.Quik.M2MMissing}}

✗ Подключение есть, но под этим логином не видно объектов M2M: {{.Settings.Quik.M2MMissing}}. Проверьте имя базы МНП и права логина (роль InstrEu) и нажмите «Проверить» ещё раз.

{{end}}
Справочник депозитариев-получателей. Чтобы QUIK принимал поручения M2M, в МНП должен быть справочник депозитариев-получателей (M2MDepoRecipients): bj заполняет его сам по справочнику участников НРД. Модуль МНП читает справочник только при запуске — после первого заполнения попросите администратора QUIK перезапустить модуль неторговых поручений, иначе QUIK ответит «Неизвестный депозитарий получателя».
{{quikRecipientsInfo}}
{{end}} -
- ← К шагу 4 - {{if .Done.Quik}}К шагу 6 →{{else}}Пропустить →{{end}} -
+ {{template "wizNav" .}}
{{end}} {{/* ============= ШАГ 6: Валидата ============= */}} {{if eq .Step 6}}
-

Шаг 6. СКЗИ «Валидата Клиент L»

-

СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (шаг 8).

+

СКЗИ «Валидата Клиент L»

+

СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (раздел «Ключ»).

{{if .ValidataVer}}

✓ Валидата установлена вместе с bj: версия {{.ValidataVer}}, каталог /opt/Validata/VDCSP. Ничего настраивать на этом шаге не нужно.

-

bj работает с Валидатой через свою службу криптографии bj-crypto{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (шаг 1, кнопка «Обновить сейчас»), а работает с ключом, установленным на шаге 8{{end}}.

+

bj работает с Валидатой через свою службу криптографии bj-crypto{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (раздел «Лицензия», кнопка «Обновить сейчас»), а работает с ключом, установленным в разделе «Ключ»{{end}}.

{{else}}

Валидата на этом сервере не найдена.

@@ -396,21 +384,20 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{end}} {{end}} -
- ← К шагу 5 - {{if .Done.Crypto}}К шагу 7 →{{else}}Пропустить →{{end}} -
+ {{template "wizNav" .}}
{{end}} {{/* ============= ШАГ 7: Шлюз НРД (до ключа: ключ ставится в профиль ИШ) ============= */}} {{if eq .Step 7}}
-

Шаг 7. Интеграционный шлюз НРД (ИШ)

+

Интеграционный шлюз НРД (ИШ)

Что это. Интеграционный шлюз — программа НРД на этом же сервере: через неё bj отправляет пакеты M2M в НРД и получает ответы. bj работает со шлюзом по его адресу на сервере (обычно http://localhost:8090).
К кому обратиться. Дистрибутив ИШ выпускает НРД (nsd.ru/workflow/system/programs). Доступ к контурам — сначала тестовому TEST3, затем боевому — НРД открывает по заявке: обратитесь к своему менеджеру НРД или в поддержку электронного документооборота.
+ {{template "letterCard" (letter "nsd-test")}} + {{template "letterCard" (letter "nsd-prod")}} {{template "igateInstall" .}}
Дополнительно: адрес шлюза (обычно не нужно)
@@ -427,20 +414,17 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
-

Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код {{.Settings.NSD.DeponentCode}}, счёт {{.Settings.NSD.AccountID}}, раздел {{.Settings.NSD.SectionID}}{{else}}не заданы{{end}} — на шаге 4.

-
- ← К шагу 6 - {{if .Done.NSD}}К шагу 8: ключ →{{else}}Пропустить ({{if devOn}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →{{end}} -
+

Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код {{.Settings.NSD.DeponentCode}}, счёт {{.Settings.NSD.AccountID}}, раздел {{.Settings.NSD.SectionID}}{{else}}не заданы{{end}} — в разделе «Организация».

+ {{template "wizNav" .}}
{{end}} {{/* ============= ШАГ 8: Ключ и сертификаты (после шлюза: ключ ставится в профиль ИШ) ============= */}} {{if eq .Step 8}}
-

Шаг 8. Ключ и сертификаты

+

Ключ и сертификаты

Здесь две части: ключ и сертификат вашей организации (ставите вы) и сертификаты НРД и удостоверяющих центров (bj ведёт их сам).

- {{if not .Done.NSD}}

Ключ устанавливается в профиль интеграционного шлюза — сначала установите шлюз НРД на шаге 7.

{{end}} + {{if not .Done.NSD}}

Ключ устанавливается в профиль интеграционного шлюза — сначала установите шлюз в разделе «Шлюз НРД».

{{end}}

1. Ключ и сертификат организации

Что это. Ключ электронной подписи вашей организации для обмена с НРД и его сертификат.
@@ -456,7 +440,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } Откуда берутся. НРД и Московская биржа публикуют сертификаты в постоянных местах: комплект ГОСТ МБ, НКЦ и НРД — https://fs.moex.com/cdp/sert/GOST.zip, сертификаты УЦ — https://fs.moex.com/cdp/. Когда там появляется новый сертификат, мы добавляем его в комплект bj, и он приезжает к вам с обновлением.
Состояние, ручная проверка и выключение — «Настройка → Криптография → Сертификаты НРД в справочнике ИШ».
- {{if .Settings.NSDCerts.LastRows}}

Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.

{{else}}

Первая сверка пройдёт сразу после установки ключа (шлюз НРД — шаг 7).

{{end}} + {{if .Settings.NSDCerts.LastRows}}

Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.

{{else}}

Первая сверка пройдёт сразу после установки ключа.

{{end}} {{if .Certs}}

Установленные сертификаты ({{len .Certs}})

@@ -478,17 +462,61 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }

Ключ организации ещё не установлен. Можно пропустить этот шаг и вернуться к нему, когда будет архив ключа.

{{end}} -
- ← К шагу 7 - К шагу 9 → + {{template "wizNav" .}} +
+{{end}} + +{{/* ============= ШАГ 12: Время и сеть (глава «Сервер») ============= */}} +{{if eq .Step 12}} +
+

Время и сеть сервера

+ {{if .AutoDone}}
Сделано автоматически.{{range .AutoDone}}
✓ {{.}}{{end}}
{{end}} +

Часовой пояс

+

По нему — время в журналах, уведомлениях и сроках поручений. Время НРД — московское; если сервер в другом поясе, выберите свой.

+ {{$tz := serverTimeZone}} +
+ + + + сейчас: {{$tz}} +
+

Сервер времени

+

{{if .Done.Server}}✓ время синхронизируется{{else}}⚠ время не синхронизируется — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор){{end}}

+
+ + + +
+

Адрес и HTTPS

+

Сервер: {{serverAddrs}}. Вход по HTTPS: https://{{serverIP}}:8443/.

+

Чтобы адрес не менялся, задайте постоянный — на консоли сервера: sudo bj-network. Перенаправление на HTTPS и сертификат организации — в «Настройка → Сервер».

+ {{template "wizNav" .}} +
+{{end}} + +{{/* ============= ШАГ 11: Модули по лицензии (глава «Запуск») ============= */}} +{{if eq .Step 11}} +
+

Модули по лицензии

+

Здесь только модули, которые входят в вашу лицензию. Каждый настраивается в своём разделе — кнопка «Настроить» откроет его, а вернуться в мастер можно ссылкой вверху раздела.

+ {{range .Modules}} +
+ +

{{.What}}

+

{{if .Ready}}✓{{else}}⚠{{end}} {{.Status}}

+ {{end}} + {{template "wizNav" .}}
{{end}} {{/* ============= ШАГ 9: Оповещения и резервные копии ============= */}} {{if eq .Step 9}}
-

Шаг 9. Оповещения и резервные копии

+

Оповещения и резервные копии

Зачем. bj сам следит за обменом: НРД не отвечает, перевод не доставлен, ошибка обработки в DEPO, истекает сертификат ключа. Чтобы об этом узнали сразу, а не открыв bj, — укажите почту. Резервные копии позволят восстановить сервер целиком (настройки, заявки, шлюз НРД) после сбоя или переустановки.
Можно позже. Шаг необязательный — обмен работает и без него, но мы настоятельно советуем настроить оба пункта.
@@ -501,10 +529,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{template "backupCard" .}} {{end}}
-
- ← К шагу 8 - {{if .Done.Alerts}}К проверке →{{else}}Пропустить →{{end}} -
+ {{template "wizNav" .}}
{{end}} @@ -512,7 +537,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{if eq .Step 10}}
{{if devOn}} -

Шаг 10. Тестовая заявка

+

Тестовая заявка

Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.

@@ -533,7 +558,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{end}} {{end}} - {{if not devOn}}

Шаг 10. Готовность к работе

{{else}}

Готовность к работе

{{end}} + {{if not devOn}}

Готовность к работе

{{else}}

Готовность к работе

{{end}}

bj проверяет всё, что нужно для обмена с НРД, прямо сейчас. Красное — обмен работать не будет, жёлтое — работать будет, но лучше исправить. У каждой строки — ссылка туда, где это исправляется.

Проверяю…
@@ -554,13 +579,18 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } document.getElementById('rd-again').onclick=load; load(); })(); +
+{{template "chainTest" .}} +
+

Письма для подключения

+

Готовые письма с вашими реквизитами — тем, без кого подключение не закончить.

+ {{template "letterCard" (letter "nsd-test")}} + {{template "letterCard" (letter "nsd-prod")}} + {{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{template "letterCard" (letter "dba")}}{{end}} - + {{template "wizNav" .}}
{{end}} diff --git a/internal/lkgateway/web/templates/part_backup.html b/internal/lkgateway/web/templates/part_backup.html index bd605c9..0623831 100644 --- a/internal/lkgateway/web/templates/part_backup.html +++ b/internal/lkgateway/web/templates/part_backup.html @@ -3,7 +3,7 @@ {{$b := .Settings.Backup}}{{$bs := backupStatus}}

Копии сервера bj на сетевое хранилище

-

Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (шаг 1, «Восстановить из резервной копии»).

+

Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (глава «Сервер», «Восстановить из резервной копии»).

{{if $bs}}Последняя копия: {{backupWhen $bs.At}} — {{if $bs.OK}}✓ {{$bs.Dir}}, {{$bs.Msg}}{{else}}✗ {{$bs.Msg}}{{end}}{{else}}Копий ещё не было.{{end}}

diff --git a/internal/lkgateway/web/templates/part_igate.html b/internal/lkgateway/web/templates/part_igate.html index fb5e016..4ecf66b 100644 --- a/internal/lkgateway/web/templates/part_igate.html +++ b/internal/lkgateway/web/templates/part_igate.html @@ -16,6 +16,7 @@
Установить или обновить ИШ

Дистрибутив ИШ для Linux выпускает НРД. bj скачает его с сайта НРД сам или возьмёт загруженный архив igate-…-amd64.zip. Установка занимает 2–5 минут: пакеты для работы без графики, пользователь igate, база ИШ, служба. Настройки ИШ сохраняются, прежняя версия копируется в /var/backups.

+
@@ -60,11 +61,12 @@ if(!d.installed){ head.innerHTML=bad('ИШ на этом сервере не установлен')+' — установите его ниже.'; document.getElementById('ig-inst').open=true; } else head.innerHTML=(d.engine==='Running'?ok('ИШ '+d.version+' работает'):bad('ИШ '+d.version+' установлен, движок: '+(d.engine||'не отвечает')+', служба: '+d.service)); if(d.installed) document.getElementById('ig-inst-sum').textContent='Обновить ИШ (сейчас '+d.version+')'; + if(d.bundled){ document.getElementById('ig-bundled').style.display='block'; document.getElementById('ig-bnd-v').textContent='версия '+d.bundled+' — дистрибутив НРД записан на сервер при установке с образа'; } var dep=d.deponent||'', cr=document.getElementById('ig-crypto').value; var rows=[['test3','Тестовый TEST3'],['prod','Боевой']].map(function(x){ var code=(x[0]==='prod'?'PROD':'TEST3')+dep, ch=(d.channels||[]).filter(function(c){return c.code===code})[0], act=d.active_contour===x[0]; var s=[], a=[]; - if(!dep) s.push(bad('нет депозитарного кода (шаг 4)')); + if(!dep) s.push(bad('нет депозитарного кода (мастер → «Организация»)')); else if(!ch){ s.push('канала в ИШ нет'); if(d.installed) a.push(''); } else { s.push(ok('канал '+ch.code)+' '+esc(ch.crypto+' · '+ch.endpoint)+''); @@ -104,6 +106,8 @@ if(s.stage==='error'){ clearInterval(iv); out.innerHTML=bad(s.msg); } }).catch(function(){}); if(Date.now()-t0>1200000) clearInterval(iv); },3000); } + document.getElementById('ig-bnd').onclick=function(){ var p=new URLSearchParams(); p.set('bundled','1'); + out.textContent='Запускаю…'; post('/admin/setup/igate/install',p).then(function(d){ if(!d.ok){out.innerHTML=bad(d.error);return} out.textContent=d.output; poll(); }); }; document.getElementById('ig-dl').onclick=function(){ var p=new URLSearchParams(); p.set('version',document.getElementById('ig-ver').value.trim()); out.textContent='Запускаю…'; post('/admin/setup/igate/install',p).then(function(d){ if(!d.ok){out.innerHTML=bad(d.error);return} out.textContent=d.output; poll(); }); }; document.getElementById('ig-up').onclick=function(){ var f=document.getElementById('ig-file').files[0]; if(!f){ out.textContent='Выберите файл igate-…-amd64.zip'; return; } diff --git a/internal/lkgateway/web/templates/part_letters.html b/internal/lkgateway/web/templates/part_letters.html new file mode 100644 index 0000000..0a1f46c --- /dev/null +++ b/internal/lkgateway/web/templates/part_letters.html @@ -0,0 +1,68 @@ +{{/* Готовое письмо (letters.go): текст с реквизитами, копирование, файл, открыть в почте. Аргумент — *Letter. */}} +{{define "letterCard"}}{{with .}} +
+ ✉ {{.Title}} +

Кому: {{.To}}
Тема: {{.Subject}}

+
{{.Body}}
+

В квадратных скобках — то, чего bj не знает: впишите перед отправкой.

+ +
+{{end}}{{end}} + +{{/* Проверка всей цепочки (chaintest.go). Данные — WizardData. */}} +{{define "chainTest"}} +
+

Проверить всю цепочку

+

Сквозная проверка без настоящего перевода: bj по очереди проходит каждое звено и показывает, какое работает, а какое нет.{{if .DepoLicensed}} Учёт DEPO — проверочное событие, QUIK — только чтение (пробная блокировка — по желанию, ниже).{{end}} Подпись — ключом организации. НРД — запрос справочника участников через шлюз с ответом НРД.

+ {{if .DepoLicensed}} +
+ Добавить пробную блокировку в QUIK (1 бумага клиента — заблокировать и сразу снять) +
+

В боевом QUIK будет зарегистрировано поручение «Вывод ЦБ (M2M)» на 1 шт. и сразу снято: в QUIK оно останется в истории как снятое. Нужны код клиента в QUIK и бумага, которая у клиента есть.

+
+ + +
+ + +
+
+ {{end}} + +
+
+
+ +{{end}} diff --git a/internal/lkgateway/web/templates/part_wiznav.html b/internal/lkgateway/web/templates/part_wiznav.html new file mode 100644 index 0000000..d70278f --- /dev/null +++ b/internal/lkgateway/web/templates/part_wiznav.html @@ -0,0 +1,7 @@ +{{/* Навигация шага мастера: соседние шаги этой установки (лицензия, сделанное автоматически — wizchapters.go). */}} +{{define "wizNav"}} +
+ {{if .Prev}}← Назад{{else}}{{end}} + {{if .Next}}{{if or .StepDone (not .StepRequired)}}Далее →{{else}}Пропустить пока →{{end}}{{else}}Перейти к работе →{{end}} +
+{{end}} diff --git a/internal/lkgateway/wizchapters.go b/internal/lkgateway/wizchapters.go new file mode 100644 index 0000000..058f9ce --- /dev/null +++ b/internal/lkgateway/wizchapters.go @@ -0,0 +1,185 @@ +package lkgateway + +// Мастер настройки — 4 главы вместо 10 шагов (05.10.2026). Внутри глав — прежние шаги (адреса ?step=N не +// меняются: на них ссылаются проверка готовности, письма, кнопки «Исправить»). Шаг показывается, только +// если он нужен этой установке: +// - не входит в лицензию (учёт DEPO и QUIK — «quik-mnp», модули) — шага нет вовсе; +// - сделан установщиком без участия человека (база bj, Валидата) — шага нет в пути, в главе «Сервер» +// это показано строкой «сделано автоматически». + +// wizChapters — главы мастера: название и шаги по порядку. +var wizChapters = []struct { + Title string + Steps []int +}{ + {"Сервер", []int{1, 2, 6, 12}}, + {"Учёт и организация", []int{3, 4, 5}}, + {"НРД", []int{7, 8}}, + {"Запуск", []int{11, 9, 10}}, +} + +// wizStepTitles — короткие названия шагов (подзаголовки внутри главы). +var wizStepTitles = map[int]string{ + 1: "Лицензия", 2: "База bj", 6: "Валидата", 12: "Время и сеть", + 3: "Учёт DEPO", 4: "Организация", 5: "QUIK МНП", + 7: "Шлюз НРД", 8: "Ключ", + 11: "Модули", 9: "Оповещения и копии", 10: "Проверка", +} + +// wizRequired — шаги, без которых обмен не работает: на них мастер возвращает, пока они не сделаны. +var wizRequired = map[int]bool{1: true, 2: true, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true} + +// wizModules — лицензируемые модули, которые настраиваются в главе «Запуск». +var wizModules = []string{"web-cabinet", "esia-finance", "quik-export", "npr"} + +// WizStep — шаг в пути мастера. +type WizStep struct { + N int + Title string + Done bool + Current bool +} + +// WizChapter — глава в шапке мастера. +type WizChapter struct { + N int + Title string + Steps []WizStep + Done bool + Current bool + First int // первый шаг главы (переход по клику) +} + +// wizVisible — нужен ли шаг этой установке (лицензия и автоматика). +func wizVisible(rc *RuntimeConfig, n int, done map[int]bool) bool { + switch n { + case 2, 6: // база bj и Валидата обычно ставятся установщиком — шаг нужен, только если не сделано + return !done[n] + case 3, 5: + return moduleAllowed(rc, "quik-mnp") + case 11: + for _, m := range wizModules { + if moduleAllowed(rc, m) { + return true + } + } + return false + } + return true +} + +// wizPath — шаги этой установки по порядку и главы для шапки. +func wizPath(rc *RuntimeConfig, done map[int]bool, current int) (path []int, chapters []WizChapter) { + for ci, ch := range wizChapters { + c := WizChapter{N: ci + 1, Title: ch.Title, Done: true} + for _, n := range ch.Steps { + if !wizVisible(rc, n, done) { + continue + } + path = append(path, n) + c.Steps = append(c.Steps, WizStep{N: n, Title: wizStepTitles[n], Done: done[n], Current: n == current}) + if c.First == 0 { + c.First = n + } + if !done[n] { + c.Done = false + } + if n == current { + c.Current = true + } + } + if len(c.Steps) == 0 { + continue + } + // переход по главе — на первый несделанный шаг главы + for _, s := range c.Steps { + if !s.Done { + c.First = s.N + break + } + } + chapters = append(chapters, c) + } + return path, chapters +} + +// wizNeighbours — предыдущий и следующий шаг в пути (0 — нет). +func wizNeighbours(path []int, current int) (prev, next int) { + for i, n := range path { + if n == current { + if i > 0 { + prev = path[i-1] + } + if i+1 < len(path) { + next = path[i+1] + } + return + } + } + if len(path) > 0 { // текущий шаг не в пути (открыт по прямой ссылке) — «Далее» ведёт в начало пути + next = path[0] + } + return +} + +// wizDefaultStep — куда открывается мастер без ?step: первый обязательный несделанный шаг, иначе проверка. +func wizDefaultStep(path []int, done map[int]bool) int { + for _, n := range path { + if wizRequired[n] && !done[n] { + return n + } + } + return 10 +} + +// WizModule — модуль по лицензии в главе «Запуск»: что делает, готов ли, где настраивается. +type WizModule struct { + Key, Title, What, Status, Link string + Ready bool +} + +// wizModuleViews — модули этой лицензии (без лицензии модуля в мастере нет). +func wizModuleViews(rc *RuntimeConfig, s Settings) []WizModule { + var out []WizModule + for _, key := range wizModules { + if !moduleAllowed(rc, key) { + continue + } + m := WizModule{Key: key} + switch key { + case "web-cabinet": + m.Title = "Личный кабинет клиента" + m.What = "Наш кабинет для клиентов депозитария: программа bj-lk на веб-сервере организации — клиент подаёт поручения на перевод, видит портфель и ход перевода." + m.Link = "/admin/setup?from=wizard#cabinet" + m.Ready = s.LK.APIKey != "" + m.Status = map[bool]string{true: "ключ кабинета выдан — кабинет подключается к bj", false: "кабинет ещё не установлен: скачайте дистрибутив и ключ в разделе модуля"}[m.Ready] + case "esia-finance": + m.Title = "ЕСИА Финанс" + m.What = "Интеграция с платформой личного кабинета ЕСИА Финанс: заявки клиентов на перевод приходят в bj, статусы и портфель уходят обратно." + m.Link = "/admin/setup?from=wizard#lk" + m.Ready = s.LK.PublicURL != "" && (s.LK.Prod.LastOK || s.LK.Test.LastOK) + switch { + case m.Ready: + m.Status = "адрес кабинета задан, связь с API проверена" + case s.LK.PublicURL != "": + m.Status = "адрес кабинета задан, связь с API ещё не проверена" + default: + m.Status = "не настроено: адрес кабинета и доступ к API" + } + case "quik-export": + m.Title = "Экспорт биржевой информации QUIK" + m.What = "Выгрузка данных QUIK (позиции, сделки, клиенты) в базу bj — для кабинета клиента и риск-монитора." + m.Link = "/admin/setup?from=wizard#quikexport" + m.Ready = unitState("bj-quikexport").State == "active" + m.Status = map[bool]string{true: "служба экспорта работает", false: "служба экспорта не запущена — настройте подключение в разделе модуля"}[m.Ready] + case "npr": + m.Title = "Риск-монитор НПР / УДС" + m.What = "Нормативы покрытия риска и уровень достаточности средств клиентов маржинальной торговли по данным экспорта QUIK." + m.Link = "/admin/setup?from=wizard#quikexport" + m.Ready = s.NPR.Enabled && s.NPR.DSN != "" + m.Status = map[bool]string{true: "включён, база экспорта QUIK подключена", false: "не включён"}[m.Ready] + } + out = append(out, m) + } + return out +}