Мастер настройки — 4 главы (Сервер, Учёт и организация, НРД, Запуск) вместо 10 шагов: в пути только нужное этой установке — без лицензии учёта DEPO, QUIK и модулей их шагов нет, сделанное установщиком (база, Валидата) показано строкой; общая навигация «Назад/Далее»; раздел «Время и сеть» с часовым поясом; модули по лицензии (кабинет, ЕСИА Финанс, экспорт QUIK, НПР) с переходом в раздел и возвратом в мастер; «Проверить всю цепочку» (DEPO, QUIK, пробная блокировка по желанию, подпись, НРД); готовые письма в НРД, ARQA и администратору DEPO; ИШ с образа без интернета

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 18:04:29 +03:00
parent 7a5673e91f
commit f0dec0c238
22 changed files with 1020 additions and 129 deletions
+10
View File
@@ -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=<igate-…-amd64.zip> — дистрибутив интеграционного шлюза НРД (с 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
+1
View File
@@ -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
+4 -2
View File
@@ -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)
+1 -1
View File
@@ -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) ;;
# Перезапуск службы модуля из админки. Список закрытый: чужую службу
+6
View File
@@ -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
+6 -6
View File
@@ -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",
+18 -13
View File
@@ -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
+276
View File
@@ -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("<bj-chain-test ts=\"" + time.Now().UTC().Format(time.RFC3339) + "\"/>")
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
}
+25 -3
View File
@@ -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
}
+133
View File
@@ -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))
}
+54
View File
@@ -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("неизвестное письмо")
}
}
+4
View File
@@ -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
+3
View File
@@ -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,
+46 -35
View File
@@ -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),
+69
View File
@@ -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 секунд. Шлюз НРД перейдёт на новый пояс при своём следующем перезапуске.")
})
}
@@ -25,6 +25,7 @@
</nav>
<div class="settings-body">
{{if .FromWizard}}<div class="card" style="background:var(--accent-weak);margin-bottom:12px;display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;align-items:center"><span>Вы настраиваете модуль из мастера настройки.</span><a href="/admin/wizard?step=11" class="btn" style="text-decoration:none">← Вернуться в мастер</a></div>{{end}}
{{/* ============ ОБЗОР ============ */}}
<section class="settings-section active" id="sec-overview">
@@ -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; }
</style>
<div class="wiz-head">
<h1>Настройка Bridge&amp;Join</h1>
<p>Пошаговый мастер первого запуска — состояние сохраняется после каждого шага</p>
<p>Четыре главы: сервер, учёт и организация, НРД, запуск. Всё сохраняется сразу — можно прерваться и вернуться</p>
</div>
<div class="wizard-progress" style="max-width:940px">
<a href="/admin/wizard?step=1" title="Перейти к шагу 1" class="wstep {{if .Done.License}}done{{end}} {{if eq .Step 1}}current{{end}}"><span class="bub">{{if .Done.License}}✓{{else}}1{{end}}</span><span class="lbl">Лицензия</span></a>
<a href="/admin/wizard?step=2" title="Перейти к шагу 2" class="wstep {{if .Done.Postgres}}done{{end}} {{if eq .Step 2}}current{{end}}"><span class="bub">{{if .Done.Postgres}}✓{{else}}2{{end}}</span><span class="lbl">База bj</span></a>
<a href="/admin/wizard?step=3" title="Перейти к шагу 3" class="wstep {{if .Done.Depo}}done{{end}} {{if eq .Step 3}}current{{end}}"><span class="bub">{{if .Done.Depo}}✓{{else}}3{{end}}</span><span class="lbl">Учёт DEPO</span></a>
<a href="/admin/wizard?step=4" title="Перейти к шагу 4" class="wstep {{if .Done.Org}}done{{end}} {{if eq .Step 4}}current{{end}}"><span class="bub">{{if .Done.Org}}✓{{else}}4{{end}}</span><span class="lbl">Организация</span></a>
<a href="/admin/wizard?step=5" title="Перейти к шагу 5" class="wstep {{if .Done.Quik}}done{{end}} {{if eq .Step 5}}current{{end}}"><span class="bub">{{if .Done.Quik}}✓{{else}}5{{end}}</span><span class="lbl">QUIK МНП</span></a>
<a href="/admin/wizard?step=6" title="Перейти к шагу 6" class="wstep {{if .Done.Crypto}}done{{end}} {{if eq .Step 6}}current{{end}}"><span class="bub">{{if .Done.Crypto}}✓{{else}}6{{end}}</span><span class="lbl">Валидата</span></a>
<a href="/admin/wizard?step=7" title="Перейти к шагу 7" class="wstep {{if .Done.NSD}}done{{end}} {{if eq .Step 7}}current{{end}}"><span class="bub">{{if .Done.NSD}}✓{{else}}7{{end}}</span><span class="lbl">Шлюз НРД</span></a>
<a href="/admin/wizard?step=8" title="Перейти к шагу 8" class="wstep {{if .Done.Certs}}done{{end}} {{if eq .Step 8}}current{{end}}"><span class="bub">{{if .Done.Certs}}✓{{else}}8{{end}}</span><span class="lbl">Ключ</span></a>
<a href="/admin/wizard?step=9" title="Перейти к шагу 9" class="wstep {{if .Done.Alerts}}done{{end}} {{if eq .Step 9}}current{{end}}"><span class="bub">{{if .Done.Alerts}}✓{{else}}9{{end}}</span><span class="lbl">Оповещения</span></a>
<a href="/admin/wizard?step=10" title="Перейти к шагу 10" class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 10}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}10{{end}}</span><span class="lbl">Проверка</span></a>
<div class="wizard-progress" style="max-width:720px">
{{range .Chapters}}<a href="/admin/wizard?step={{.First}}" title="Глава {{.N}}: {{.Title}}" class="wstep {{if .Done}}done{{end}} {{if .Current}}current{{end}}"><span class="bub">{{if .Done}}✓{{else}}{{.N}}{{end}}</span><span class="lbl">{{.Title}}</span></a>
{{end}}
</div>
{{range .Chapters}}{{if and .Current (gt (len .Steps) 1)}}<div class="wiz-sub">{{range .Steps}}<a href="/admin/wizard?step={{.N}}" class="{{if .Current}}cur{{end}}{{if .Done}} done{{end}}">{{if .Done}}✓ {{end}}{{.Title}}</a>{{end}}</div>{{end}}{{end}}
<p style="text-align:center;margin:-14px 0 18px;font-size:13px"><a href="/admin/">Пропустить мастер и перейти в систему →</a> <span class="muted">— вернуться к нему можно из «Настройка → Мастер настройки»</span></p>
{{if .Flash}}<div class="card wiz-card" style="background:var(--accent-weak)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
@@ -109,7 +107,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
</details>
</div>
<div class="card">
<h2><span class="dot {{if .Done.License}}ok{{else}}err{{end}}"></span>Шаг 1. Лицензия</h2>
<h2><span class="dot {{if .Done.License}}ok{{else}}err{{end}}"></span>Лицензия</h2>
<div class="help-block"><strong>Что это.</strong> Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.<br>
<strong>Зачем.</strong> От лицензии зависят модули (обмен с DEPO и QUIK, личный кабинет, НПР/УДС) и получение обновлений: bj скачает и поставит модули, входящие в лицензию, автоматически.<br>
<strong>Где взять.</strong> Портал клиента <a href="https://lic.zetit.ru/portal/" target="_blank">lic.zetit.ru/portal</a> или письмо от ZETIT.</div>
@@ -138,15 +136,14 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<input type="text" name="code" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" style="padding:8px;text-transform:uppercase;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<button type="submit" class="btn" style="justify-self:start">{{if .Done.License}}Сменить ключ{{else}}Активировать{{end}}</button>
</form>{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between"><span></span>
{{if .Done.License}}<a href="/admin/wizard?step=2" class="btn" style="text-decoration:none">К шагу 2 →</a>{{else}}<a href="/admin/wizard?step=2" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">Пропустить (ввести позже) →</a>{{end}}</div>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 3: Учёт DEPO (Fancy) — по экранам ============= */}}
{{if eq .Step 3}}
<div class="card">
<h2><span class="dot {{if .Done.Depo}}ok{{else}}err{{end}}"></span>Шаг 3. Учётная система DEPO (Fancy)</h2>
<h2><span class="dot {{if .Done.Depo}}ok{{else}}err{{end}}"></span>Учётная система DEPO (Fancy)</h2>
{{if not .DepoLicensed}}
<p class="muted">Модуль обмена с DEPO и QUIK не входит в текущую лицензию — шаг пропускается.</p>
{{else}}
@@ -155,9 +152,10 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{if eq $sub "intro"}}
<div class="help-block"><strong>Что это.</strong> Подключение bj к базе депозитарного учёта DEPO (Fancy).<br>
<strong>Зачем.</strong> bj сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и сам передаёт в DEPO поручения и их исполнение — документы в учёте создаются автоматически. Отсюда же bj возьмёт реквизиты вашей организации (шаг 4).<br>
<strong>Зачем.</strong> bj сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и сам передаёт в DEPO поручения и их исполнение — документы в учёте создаются автоматически. Отсюда же bj возьмёт реквизиты вашей организации (раздел «Организация»).<br>
<strong>Что понадобится.</strong> Адрес сервера базы DEPO, логин и пароль — их даёт ваш администратор баз данных. Для создания таблиц обмена и джобы обработки может понадобиться разово логин администратора SQL — он не сохраняется. В базе DEPO должны быть процедуры обработки обмена (расширение bj2depo) и версия Fancy DEPO с поддержкой M2M — bj проверит их по имени и скажет, чего не хватает.<br>
<strong>Как пойдём.</strong> Подключение → таблицы обмена → обработка → проверка. Каждый шаг проверяется сразу.</div>
{{template "letterCard" (letter "dba")}}
<div style="margin-top:14px"><a href="/admin/wizard?step=3&sub=conn" class="btn" style="text-decoration:none">Начать →</a></div>
{{else if eq $sub "conn"}}
@@ -270,19 +268,17 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<tr><td>Обработка событий (процедуры)</td><td>{{if .Settings.Fancy.DepoProcCheckOK}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.DepoProcCheck}}</span>{{else if .Settings.Fancy.DepoProcCheck}}<span style="color:var(--warn)">⚠ {{.Settings.Fancy.DepoProcCheck}}</span>{{else}}<span class="muted">не проверялась — «Перепроверить всё»</span>{{end}}</td></tr>
</table>
{{if .Settings.Fancy.Server}}{{template "accPrefix" .}}{{end}}
{{if .Done.Depo}}<p>Учёт DEPO подключён и проверен. Можно переходить к шагу 4 — там bj предложит реквизиты вашей организации из DEPO.</p>{{else}}<p class="muted" style="font-size:13px">Не всё проверено — можно продолжить настройку (шаг 4 и дальше) и вернуться сюда позже. Пока обработка не работает, записи bj ждут в таблицах обмена со статусом TODO и будут обработаны, когда она заработает.</p>{{end}}
{{if .Done.Depo}}<p>Учёт DEPO подключён и проверен. Дальше — «Организация»: bj предложит реквизиты вашей организации из DEPO.</p>{{else}}<p class="muted" style="font-size:13px">Не всё проверено — можно продолжить настройку (шаг 4 и дальше) и вернуться сюда позже. Пока обработка не работает, записи bj ждут в таблицах обмена со статусом TODO и будут обработаны, когда она заработает.</p>{{end}}
<form method="post" action="/admin/setup/wizard-depo/recheck" style="margin:0"><button type="submit" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Перепроверить всё</button></form>
{{end}}
{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=2" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 2</a>
<a href="/admin/wizard?step=4" class="btn" style="{{if not .Done.Depo}}background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);{{end}}text-decoration:none">{{if .Done.Depo}}К шагу 4 →{{else}}Пропустить шаг →{{end}}</a></div>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 2: База bj (PostgreSQL) ============= */}}
{{if eq .Step 2}}
<div class="card">
<h2><span class="dot {{if .Done.Postgres}}ok{{else}}err{{end}}"></span>Шаг 2. База данных bj (PostgreSQL)</h2>
<h2><span class="dot {{if .Done.Postgres}}ok{{else}}err{{end}}"></span>База данных bj (PostgreSQL)</h2>
<p>База самого bj: журнал заявок и поручений, история обмена с НРД и DEPO, пользователи.</p>
{{if .Done.Postgres}}<p>✓ База готова{{if .PGSummary}}: {{.PGSummary}}{{end}}. Её создал установщик — ничего делать не нужно, переходите к следующему шагу.</p>{{end}}
@@ -311,10 +307,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
</form>
</details>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=1" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 1</a>
{{if .Done.Postgres}}<a href="/admin/wizard?step=3" class="btn" style="text-decoration:none">К шагу 3 →</a>{{else}}<button class="btn" disabled style="opacity:0.5;cursor:not-allowed">К шагу 3 → (сначала подключите базу)</button>{{end}}
</div>
{{template "wizNav" .}}
{{if and devOn (not .Done.Postgres)}}<p style="margin-top:8px"><a href="/admin/wizard?step=3&skip=postgres" style="font-size:13px">Пропустить (режим разработки: in-memory — без сохранения сделок)</a></p>{{end}}
</div>
{{end}}
@@ -322,9 +315,9 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{/* ============= ШАГ 4: Наша организация ============= */}}
{{if eq .Step 4}}
<div class="card">
<h2><span class="dot {{if .Done.Org}}ok{{else}}err{{end}}"></span>Шаг 4. Наша организация</h2>
<h2><span class="dot {{if .Done.Org}}ok{{else}}err{{end}}"></span>Наша организация</h2>
<div class="help-block"><strong>Что это.</strong> Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: код участника, ИНН и наименование попадают в пакеты НРД и в поручения QUIK.<br>
<strong>Откуда взять.</strong> Если на шаге 3 подключена база DEPO, bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите себя из справочника участников НРД или введите вручную.</div>
<strong>Откуда взять.</strong> Если подключена база DEPO (раздел «Учёт DEPO»), bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите себя из справочника участников НРД или введите вручную.</div>
{{if .Suggest}}{{if .Suggest.OrgName}}{{if not .Done.Org}}<p style="margin:8px 0;padding:8px 12px;border-radius:8px;background:var(--accent-weak)">✓ <strong>Определено из DEPO</strong>: {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}, код {{.Suggest.OrgCode}}{{if .Suggest.AccountID}}, счёт НРД {{.Suggest.AccountID}}{{end}}. Поля ниже заполнены — проверьте и нажмите «Сохранить».</p>{{end}}{{end}}{{end}}
{{with .Fancy}}{{template "orgSection" .}}{{end}}
<div class="card" id="nsd-req" style="margin-top:12px">
@@ -351,40 +344,35 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<button type="submit" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">{{if .Settings.Branding.LogoDataURI}}Заменить логотип{{else}}Загрузить логотип{{end}}</button>
</form>
</div>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=3" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 3</a>
<a href="/admin/wizard?step=5" class="btn" style="{{if not .Done.Org}}background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);{{end}}text-decoration:none">{{if .Done.Org}}К шагу 5 →{{else}}Пропустить →{{end}}</a>
</div>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 5: QUIK МНП ============= */}}
{{if eq .Step 5}}
<div class="card">
<h2><span class="dot {{if .Done.Quik}}ok{{else}}err{{end}}"></span>Шаг 5. QUIK — модуль неторговых поручений</h2>
<h2><span class="dot {{if .Done.Quik}}ok{{else}}err{{end}}"></span>QUIK — модуль неторговых поручений</h2>
<div class="help-block"><strong>Что это.</strong> Модуль неторговых поручений QUIK (МНП) — продукт компании ARQA Technologies, разработчика QUIK. Через него bj блокирует бумаги клиента в QUIK на время перевода, снимает блокировку при отказе и после исполнения проводит поручение.<br>
<strong>Если МНП у вас ещё нет.</strong> Обратитесь в ARQA Technologies (<a href="https://arqatech.com" target="_blank" rel="noopener">arqatech.com</a>, служба поддержки QUIK) и попросите подключить модуль неторговых поручений для работы с внешней системой. После подключения ARQA выдаст адрес сервера МНП и учётные данные — их и нужно ввести ниже.<br>
<strong>Если МНП уже есть.</strong> Адрес сервера и учётные данные — у вашего администратора QUIK.</div>
{{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{end}}
{{if not .DepoLicensed}}<p class="muted">Модуль неторговых поручений не входит в текущую лицензию — шаг пропускается.</p>
{{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}}
{{if .Settings.Quik.Server}}{{template "mnpCheck" .}}{{end}}
{{if .Settings.Quik.M2MMissing}}<p style="color:var(--err);margin:8px 0">✗ Подключение есть, но под этим логином не видно объектов M2M: {{.Settings.Quik.M2MMissing}}. Проверьте имя базы МНП и права логина (роль InstrEu) и нажмите «Проверить» ещё раз.</p>{{end}}
<div class="help-block" style="margin-top:12px"><strong>Справочник депозитариев-получателей.</strong> Чтобы QUIK принимал поручения M2M, в МНП должен быть справочник депозитариев-получателей (<code>M2MDepoRecipients</code>): bj заполняет его сам по справочнику участников НРД. <b>Модуль МНП читает справочник только при запуске</b> — после первого заполнения попросите администратора QUIK перезапустить модуль неторговых поручений, иначе QUIK ответит «Неизвестный депозитарий получателя».<br><span class="muted">{{quikRecipientsInfo}}</span></div>{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=4" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 4</a>
<a href="/admin/wizard?step=6" class="btn" style="{{if not .Done.Quik}}background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);{{end}}text-decoration:none">{{if .Done.Quik}}К шагу 6 →{{else}}Пропустить →{{end}}</a>
</div>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 6: Валидата ============= */}}
{{if eq .Step 6}}
<div class="card">
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 6. СКЗИ «Валидата Клиент L»</h2>
<p>СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (шаг 8).</p>
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>СКЗИ «Валидата Клиент L»</h2>
<p>СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (раздел «Ключ»).</p>
{{if .ValidataVer}}
<p>✓ Валидата установлена вместе с bj: версия {{.ValidataVer}}, каталог <code>/opt/Validata/VDCSP</code>. Ничего настраивать на этом шаге не нужно.</p>
<p class="muted" style="font-size:13px">bj работает с Валидатой через свою службу криптографии <code>bj-crypto</code>{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (шаг 1, кнопка «Обновить сейчас»), а работает с ключом, установленным на шаге 8{{end}}.</p>
<p class="muted" style="font-size:13px">bj работает с Валидатой через свою службу криптографии <code>bj-crypto</code>{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (раздел «Лицензия», кнопка «Обновить сейчас»), а работает с ключом, установленным в разделе «Ключ»{{end}}.</p>
{{else}}
<p style="color:var(--err)">Валидата на этом сервере не найдена.</p>
<div class="help-block">
@@ -396,21 +384,20 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
</form>{{end}}
{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=5" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 5</a>
{{if .Done.Crypto}}<a href="/admin/wizard?step=7" class="btn" style="text-decoration:none">К шагу 7 →</a>{{else}}<a href="/admin/wizard?step=7&skip=crypto" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">Пропустить →</a>{{end}}
</div>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 7: Шлюз НРД (до ключа: ключ ставится в профиль ИШ) ============= */}}
{{if eq .Step 7}}
<div class="card">
<h2><span class="dot {{if .Done.NSD}}ok{{else}}err{{end}}"></span>Шаг 7. Интеграционный шлюз НРД (ИШ)</h2>
<h2><span class="dot {{if .Done.NSD}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
<div class="help-block">
<strong>Что это.</strong> Интеграционный шлюз — программа НРД на этом же сервере: через неё bj отправляет пакеты M2M в НРД и получает ответы. bj работает со шлюзом по его адресу на сервере (обычно <code>http://localhost:8090</code>).<br>
<strong>К кому обратиться.</strong> Дистрибутив ИШ выпускает НРД (<a href="https://www.nsd.ru/workflow/system/programs/" target="_blank" rel="noopener">nsd.ru/workflow/system/programs</a>). Доступ к контурам — сначала тестовому TEST3, затем боевому — НРД открывает по заявке: обратитесь к своему менеджеру НРД или в поддержку электронного документооборота.
</div>
{{template "letterCard" (letter "nsd-test")}}
{{template "letterCard" (letter "nsd-prod")}}
{{template "igateInstall" .}}
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent);font-size:13px">Дополнительно: адрес шлюза (обычно не нужно)</summary>
<form method="post" action="/admin/setup/nsd" style="margin-top:10px;display:grid;gap:10px;max-width:680px">
@@ -427,20 +414,17 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</details>
<p class="muted" style="font-size:13px;margin-top:12px">Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код <code>{{.Settings.NSD.DeponentCode}}</code>, счёт <code>{{.Settings.NSD.AccountID}}</code>, раздел <code>{{.Settings.NSD.SectionID}}</code>{{else}}<span style="color:var(--err)">не заданы</span>{{end}} — на <a href="/admin/wizard?step=4#nsd-req">шаге 4</a>.</p>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=6" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 6</a>
{{if .Done.NSD}}<a href="/admin/wizard?step=8" class="btn" style="text-decoration:none">К шагу 8: ключ →</a>{{else}}<a href="/admin/wizard?step=8&skip=nsd" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">Пропустить ({{if devOn}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →</a>{{end}}
</div>
<p class="muted" style="font-size:13px;margin-top:12px">Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код <code>{{.Settings.NSD.DeponentCode}}</code>, счёт <code>{{.Settings.NSD.AccountID}}</code>, раздел <code>{{.Settings.NSD.SectionID}}</code>{{else}}<span style="color:var(--err)">не заданы</span>{{end}} — в разделе <a href="/admin/wizard?step=4#nsd-req">«Организация»</a>.</p>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 8: Ключ и сертификаты (после шлюза: ключ ставится в профиль ИШ) ============= */}}
{{if eq .Step 8}}
<div class="card">
<h2><span class="dot {{if .Done.Certs}}ok{{else}}err{{end}}"></span>Шаг 8. Ключ и сертификаты</h2>
<h2><span class="dot {{if .Done.Certs}}ok{{else}}err{{end}}"></span>Ключ и сертификаты</h2>
<p>Здесь две части: ключ и сертификат вашей организации (ставите вы) и сертификаты НРД и удостоверяющих центров (bj ведёт их сам).</p>
{{if not .Done.NSD}}<p style="padding:8px 12px;border-radius:8px;background:var(--accent-weak);margin:8px 0">Ключ устанавливается в профиль интеграционного шлюза — сначала установите шлюз НРД на <a href="/admin/wizard?step=7">шаге 7</a>.</p>{{end}}
{{if not .Done.NSD}}<p style="padding:8px 12px;border-radius:8px;background:var(--accent-weak);margin:8px 0">Ключ устанавливается в профиль интеграционного шлюза — сначала установите шлюз в разделе <a href="/admin/wizard?step=7">«Шлюз НРД»</a>.</p>{{end}}
<h3 style="margin-top:14px">1. Ключ и сертификат организации</h3>
<div class="help-block"><strong>Что это.</strong> Ключ электронной подписи вашей организации для обмена с НРД и его сертификат.<br>
@@ -456,7 +440,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<strong>Откуда берутся.</strong> НРД и Московская биржа публикуют сертификаты в постоянных местах: комплект ГОСТ МБ, НКЦ и НРД — <code>https://fs.moex.com/cdp/sert/GOST.zip</code>, сертификаты УЦ — <code>https://fs.moex.com/cdp/</code>. Когда там появляется новый сертификат, мы добавляем его в комплект bj, и он приезжает к вам с обновлением.<br>
Состояние, ручная проверка и выключение — «Настройка → Криптография → Сертификаты НРД в справочнике ИШ».
</div>
{{if .Settings.NSDCerts.LastRows}}<p class="muted" style="font-size:13px">Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.</p>{{else}}<p class="muted" style="font-size:13px">Первая сверка пройдёт сразу после установки ключа (шлюз НРД — шаг 7).</p>{{end}}
{{if .Settings.NSDCerts.LastRows}}<p class="muted" style="font-size:13px">Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.</p>{{else}}<p class="muted" style="font-size:13px">Первая сверка пройдёт сразу после установки ключа.</p>{{end}}
{{if .Certs}}
<h3 style="margin-top:18px">Установленные сертификаты ({{len .Certs}})</h3>
@@ -478,17 +462,61 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<p class="muted" style="margin-top:12px">Ключ организации ещё не установлен. Можно пропустить этот шаг и вернуться к нему, когда будет архив ключа.</p>
{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=7" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 7</a>
<a href="/admin/wizard?step=9" class="btn" style="text-decoration:none">К шагу 9 →</a>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 12: Время и сеть (глава «Сервер») ============= */}}
{{if eq .Step 12}}
<div class="card">
<h2><span class="dot {{if .Done.Server}}ok{{else}}warn{{end}}"></span>Время и сеть сервера</h2>
{{if .AutoDone}}<div class="help-block"><strong>Сделано автоматически.</strong>{{range .AutoDone}}<br>✓ {{.}}{{end}}</div>{{end}}
<h3 style="margin:14px 0 4px">Часовой пояс</h3>
<p class="muted" style="margin:0 0 6px;font-size:13px">По нему — время в журналах, уведомлениях и сроках поручений. Время НРД — московское; если сервер в другом поясе, выберите свой.</p>
{{$tz := serverTimeZone}}
<form method="post" action="/admin/setup/timezone" style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<input type="hidden" name="return_to" value="/admin/wizard?step=12">
<select name="zone" style="padding:8px;min-width:260px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">{{range timeZones}}<option value="{{.Zone}}" {{if eq .Zone $tz}}selected{{end}}>{{.Title}}</option>{{end}}</select>
<button type="submit" class="btn">Применить</button>
<span class="muted" style="font-size:12.5px">сейчас: {{$tz}}</span>
</form>
<h3 style="margin:16px 0 4px">Сервер времени</h3>
<p style="margin:0 0 6px;font-size:13px">{{if .Done.Server}}<span style="color:var(--ok)">✓ время синхронизируется</span>{{else}}<span style="color:var(--warn)">⚠ время не синхронизируется — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор)</span>{{end}}</p>
<form method="post" action="/admin/setup/time" style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<input type="hidden" name="return_to" value="/admin/wizard?step=12">
<input name="servers" value="{{.Settings.Time.Servers}}" placeholder="10.10.10.1 ntp.example.ru" style="padding:8px;min-width:260px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<button type="submit" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Применить</button>
</form>
<h3 style="margin:16px 0 4px">Адрес и HTTPS</h3>
<p style="margin:0;font-size:13px">Сервер: <code>{{serverAddrs}}</code>. Вход по HTTPS: <a href="https://{{serverIP}}:8443/admin/">https://{{serverIP}}:8443/</a>.</p>
<p class="muted" style="font-size:12.5px;margin:4px 0 0">Чтобы адрес не менялся, задайте постоянный — на консоли сервера: <code>sudo bj-network</code>. Перенаправление на HTTPS и сертификат организации — в «Настройка → Сервер».</p>
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 11: Модули по лицензии (глава «Запуск») ============= */}}
{{if eq .Step 11}}
<div class="card">
<h2><span class="dot {{if .Done.Modules}}ok{{else}}warn{{end}}"></span>Модули по лицензии</h2>
<p class="muted" style="margin:0 0 8px">Здесь только модули, которые входят в вашу лицензию. Каждый настраивается в своём разделе — кнопка «Настроить» откроет его, а вернуться в мастер можно ссылкой вверху раздела.</p>
{{range .Modules}}
<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin:8px 0">
<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;flex-wrap:wrap">
<b><span class="dot {{if .Ready}}ok{{else}}warn{{end}}"></span>{{.Title}}</b>
<a href="{{.Link}}" class="btn" style="text-decoration:none;padding:5px 12px">{{if .Ready}}Открыть{{else}}Настроить{{end}} →</a>
</div>
<p class="muted" style="font-size:13px;margin:6px 0 2px">{{.What}}</p>
<p style="font-size:13px;margin:0;color:var({{if .Ready}}--ok{{else}}--warn{{end}})">{{if .Ready}}✓{{else}}⚠{{end}} {{.Status}}</p>
</div>
{{end}}
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 9: Оповещения и резервные копии ============= */}}
{{if eq .Step 9}}
<div class="card">
<h2><span class="dot {{if .Done.Alerts}}ok{{else}}warn{{end}}"></span>Шаг 9. Оповещения и резервные копии</h2>
<h2><span class="dot {{if .Done.Alerts}}ok{{else}}warn{{end}}"></span>Оповещения и резервные копии</h2>
<div class="help-block"><strong>Зачем.</strong> bj сам следит за обменом: НРД не отвечает, перевод не доставлен, ошибка обработки в DEPO, истекает сертификат ключа. Чтобы об этом узнали сразу, а не открыв bj, — укажите почту. Резервные копии позволят восстановить сервер целиком (настройки, заявки, шлюз НРД) после сбоя или переустановки.<br>
<strong>Можно позже.</strong> Шаг необязательный — обмен работает и без него, но мы настоятельно советуем настроить оба пункта.</div>
<style>.wiz9 label:not(:has(input[type=checkbox])){display:block;margin:0}.wiz9 label input:not([type=checkbox]),.wiz9 label select,.wiz9 .grid input,.wiz9 .grid select{width:100%;box-sizing:border-box;margin-top:4px}</style>
@@ -501,10 +529,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{template "backupCard" .}}
{{end}}
</div>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=8" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 8</a>
<a href="/admin/wizard?step=10" class="btn" style="{{if not .Done.Alerts}}background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);{{end}}text-decoration:none">{{if .Done.Alerts}}К проверке →{{else}}Пропустить →{{end}}</a>
</div>
{{template "wizNav" .}}
</div>
{{end}}
@@ -512,7 +537,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{if eq .Step 10}}
<div class="card">
{{if devOn}}
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 10. Тестовая заявка</h2>
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Тестовая заявка</h2>
<p>Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.</p>
<div class="help-block">
@@ -533,7 +558,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
</table>
{{end}}
{{end}}
{{if not devOn}}<h2><span class="dot" id="rd-dot"></span>Шаг 10. Готовность к работе</h2>{{else}}<h3 style="margin-top:18px">Готовность к работе</h3>{{end}}
{{if not devOn}}<h2><span class="dot" id="rd-dot"></span>Готовность к работе</h2>{{else}}<h3 style="margin-top:18px">Готовность к работе</h3>{{end}}
<p class="muted" style="margin:4px 0 10px">bj проверяет всё, что нужно для обмена с НРД, прямо сейчас. Красное — обмен работать не будет, жёлтое — работать будет, но лучше исправить. У каждой строки — ссылка туда, где это исправляется.</p>
<div id="rd-sum" style="font-weight:600;margin:6px 0">Проверяю…</div>
<table id="rd-tab" style="width:100%"><tbody></tbody></table>
@@ -554,13 +579,18 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
document.getElementById('rd-again').onclick=load; load();
})();
</script>
</div>
{{template "chainTest" .}}
<div class="card">
<h2>Письма для подключения</h2>
<p class="muted" style="margin:0 0 6px">Готовые письма с вашими реквизитами — тем, без кого подключение не закончить.</p>
{{template "letterCard" (letter "nsd-test")}}
{{template "letterCard" (letter "nsd-prod")}}
{{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{template "letterCard" (letter "dba")}}{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=9" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 9</a>
<a href="/admin/" class="btn" style="text-decoration:none">Перейти к дашборду</a>
</div>
{{template "wizNav" .}}
</div>
{{end}}
@@ -3,7 +3,7 @@
{{$b := .Settings.Backup}}{{$bs := backupStatus}}
<div class="card">
<h2><span class="dot {{if not $b.Enabled}}warn{{else if and $bs $bs.OK}}ok{{else}}err{{end}}"></span>Копии сервера bj на сетевое хранилище</h2>
<p class="muted">Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (шаг 1, «Восстановить из резервной копии»).</p>
<p class="muted">Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (глава «Сервер», «Восстановить из резервной копии»).</p>
<p style="margin:8px 0">{{if $bs}}Последняя копия: <b>{{backupWhen $bs.At}}</b> — {{if $bs.OK}}<span style="color:var(--ok)">✓ {{$bs.Dir}}, {{$bs.Msg}}</span>{{else}}<span style="color:var(--err)">✗ {{$bs.Msg}}</span>{{end}}{{else}}<span class="muted">Копий ещё не было.</span>{{end}}</p>
<style>#bk-form label:not(.chk){display:block;margin:0}#bk-form label:not(.chk) input,#bk-form label:not(.chk) select{width:100%;box-sizing:border-box;margin-top:4px}</style>
<form method="post" action="/admin/setup/backup" id="bk-form" style="display:grid;gap:10px;max-width:640px">
@@ -16,6 +16,7 @@
<details id="ig-inst" style="margin-top:8px">
<summary style="cursor:pointer;color:var(--accent)" id="ig-inst-sum">Установить или обновить ИШ</summary>
<p class="muted" style="font-size:13px;margin:8px 0">Дистрибутив ИШ для Linux выпускает НРД. bj скачает его с сайта НРД сам или возьмёт загруженный архив <code>igate-…-amd64.zip</code>. Установка занимает 2–5 минут: пакеты для работы без графики, пользователь igate, база ИШ, служба. Настройки ИШ сохраняются, прежняя версия копируется в /var/backups.</p>
<div id="ig-bundled" style="display:none;margin-bottom:10px"><button type="button" class="btn" id="ig-bnd">Установить с образа (без интернета)</button> <span class="muted" id="ig-bnd-v" style="font-size:12.5px"></span></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<label class="f">Версия на сайте НРД<input id="ig-ver" value="107.0.930" style="width:140px"></label>
<button type="button" class="btn" id="ig-dl">Скачать с сайта НРД и установить</button>
@@ -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('<span class="muted">канала в ИШ нет</span>'); if(d.installed) a.push('<button type="button" class="btn sm" data-act="contour" data-c="'+x[0]+'">Настроить канал</button>'); }
else {
s.push(ok('канал '+ch.code)+' <span class="muted" style="font-size:12px">'+esc(ch.crypto+' · '+ch.endpoint)+'</span>');
@@ -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; }
@@ -0,0 +1,68 @@
{{/* Готовое письмо (letters.go): текст с реквизитами, копирование, файл, открыть в почте. Аргумент — *Letter. */}}
{{define "letterCard"}}{{with .}}
<details class="letter" style="border:1px solid var(--border);border-radius:8px;padding:8px 12px;margin:8px 0;background:var(--card)">
<summary style="cursor:pointer;font-weight:600">✉ {{.Title}}</summary>
<p class="muted" style="font-size:12.5px;margin:6px 0">Кому: {{.To}}<br>Тема: {{.Subject}}</p>
<pre class="copyable" style="white-space:pre-wrap;font-size:12.5px;max-height:320px;overflow:auto">{{.Body}}</pre>
<p class="muted" style="font-size:12px;margin:4px 0 8px">В квадратных скобках — то, чего bj не знает: впишите перед отправкой.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a class="btn" href="/admin/setup/letters/{{.Key}}.txt" style="text-decoration:none">Скачать текст</a>
<a class="btn" href="{{mailtoLink .}}" style="text-decoration:none;background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Открыть в почте</a>
</div>
</details>
{{end}}{{end}}
{{/* Проверка всей цепочки (chaintest.go). Данные — WizardData. */}}
{{define "chainTest"}}
<div class="card" id="chain">
<h2><span class="dot" id="ch-dot"></span>Проверить всю цепочку</h2>
<p class="muted" style="margin:0 0 8px">Сквозная проверка без настоящего перевода: bj по очереди проходит каждое звено и показывает, какое работает, а какое нет.{{if .DepoLicensed}} Учёт DEPO — проверочное событие, QUIK — только чтение (пробная блокировка — по желанию, ниже).{{end}} Подпись — ключом организации. НРД — запрос справочника участников через шлюз с ответом НРД.</p>
{{if .DepoLicensed}}
<details style="margin:6px 0 10px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Добавить пробную блокировку в QUIK (1 бумага клиента — заблокировать и сразу снять)</summary>
<div id="ch-blk" style="display:grid;gap:8px;margin-top:8px;max-width:640px">
<p class="muted" style="font-size:12.5px;margin:0">В боевом QUIK будет зарегистрировано поручение «Вывод ЦБ (M2M)» на 1 шт. и сразу снято: в QUIK оно останется в истории как снятое. Нужны код клиента в QUIK и бумага, которая у клиента есть.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<label style="margin:0">Код клиента в QUIK<input id="ch-client" placeholder="BR859" autocomplete="off" style="width:100%;box-sizing:border-box;margin-top:4px"></label>
<label style="margin:0">ISIN бумаги<input id="ch-isin" value="RU0009029540" autocomplete="off" style="width:100%;box-sizing:border-box;margin-top:4px"></label>
</div>
<label style="margin:0">Депозитарий-получатель (условный)<select id="ch-inn" style="width:100%;box-sizing:border-box;margin-top:4px">{{range handbookDepos}}<option value="{{.INN}}">{{.Name}} · ИНН {{.INN}}</option>{{end}}</select></label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400;margin:0"><input type="checkbox" id="ch-blk-on" style="width:auto"> Разрешаю записать в QUIK пробное поручение и снять его</label>
</div>
</details>
{{end}}
<button type="button" class="btn" id="ch-run" style="background:var(--ok);border-color:var(--ok)">▶ Проверить всю цепочку</button>
<div id="ch-sum" style="font-weight:600;margin:10px 0 4px"></div>
<table style="width:100%"><tbody id="ch-rows"></tbody></table>
</div>
<script>
(function(){
var rows=document.getElementById('ch-rows'), sum=document.getElementById('ch-sum'), dot=document.getElementById('ch-dot'), btn=document.getElementById('ch-run');
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
var ic={wait:'<span class="muted">○</span>',run:'<span style="color:var(--accent)">◌</span>',ok:'<span style="color:var(--ok)">✓</span>',warn:'<span style="color:var(--warn)">⚠</span>',err:'<span style="color:var(--err)">✗</span>'};
function draw(d){
var st=d.steps||[];
rows.innerHTML=st.map(function(s){return '<tr><td style="width:22px;vertical-align:top">'+ic[s.state]+'</td><td style="vertical-align:top;padding-right:10px"><b>'+esc(s.title)+'</b>'+(s.state==='run'?' <span class="muted">— проверяю…</span>':'')+(s.detail?'<br><span class="muted" style="font-size:12.5px">'+esc(s.detail)+'</span>':'')+'</td></tr>'}).join('');
if(d.running){ sum.textContent='Проверяю цепочку… (до нескольких минут: НРД отвечает не сразу)'; btn.disabled=true; dot.className='dot'; return; }
btn.disabled=false;
if(!st.length){ sum.textContent=''; return; }
var bad=st.filter(function(s){return s.state==='err'});
if(bad.length){ sum.innerHTML='<span style="color:var(--err)">✗ Не работает: '+esc(bad.map(function(s){return s.title}).join('; '))+'</span>'; dot.className='dot err'; }
else { sum.innerHTML='<span style="color:var(--ok)">✓ Вся цепочка работает'+(d.finished?' (проверено в '+esc(d.finished)+')':'')+'</span>'; dot.className='dot ok'; }
}
function poll(){ fetch('/admin/setup/chain-test/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){ draw(d); if(d.running) setTimeout(poll,2000); }).catch(function(){ setTimeout(poll,4000); }); }
btn.onclick=function(){
var p=new URLSearchParams(), on=document.getElementById('ch-blk-on');
if(on && on.checked){
p.set('quik_block','1'); p.set('quik_client',document.getElementById('ch-client').value.trim());
p.set('quik_isin',document.getElementById('ch-isin').value.trim()); p.set('quik_inn',document.getElementById('ch-inn').value);
if(!confirm('Зарегистрировать в QUIK пробное поручение «Вывод ЦБ (M2M)» на 1 шт. и сразу снять его?')) return;
}
sum.textContent='Запускаю…';
fetch('/admin/setup/chain-test',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(!d.ok){ sum.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; } setTimeout(poll,800); });
};
poll();
})();
</script>
{{end}}
@@ -0,0 +1,7 @@
{{/* Навигация шага мастера: соседние шаги этой установки (лицензия, сделанное автоматически — wizchapters.go). */}}
{{define "wizNav"}}
<div class="wiz-nav">
{{if .Prev}}<a href="/admin/wizard?step={{.Prev}}" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← Назад</a>{{else}}<span></span>{{end}}
{{if .Next}}{{if or .StepDone (not .StepRequired)}}<a href="/admin/wizard?step={{.Next}}" class="btn" style="text-decoration:none">Далее →</a>{{else}}<a href="/admin/wizard?step={{.Next}}" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none" title="Можно вернуться к этому позже">Пропустить пока →</a>{{end}}{{else}}<a href="/admin/" class="btn" style="text-decoration:none">Перейти к работе →</a>{{end}}
</div>
{{end}}
+185
View File
@@ -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
}