diff --git a/deploy/linux/sbin/bj-backup b/deploy/linux/sbin/bj-backup index d463bec..d6f0a0e 100755 --- a/deploy/linux/sbin/bj-backup +++ b/deploy/linux/sbin/bj-backup @@ -9,7 +9,7 @@ CRED=/etc/bj/backup-smb.cred STATUS=/var/lib/bj/backup-status.json [ -r "$CONF" ] && . "$CONF" SHARE=${SHARE:-} -DIR=${DIR:-BJ_Backup/bj} +DIR=${DIR:-bj-backup} KEEP_DAYS=${KEEP_DAYS:-14} TS=$(date +%Y-%m-%d_%H%M) W=$(mktemp -d /var/tmp/bj-backup.XXXXXX) diff --git a/deploy/linux/sbin/bj-backup-admin b/deploy/linux/sbin/bj-backup-admin index e849435..70e6c47 100755 --- a/deploy/linux/sbin/bj-backup-admin +++ b/deploy/linux/sbin/bj-backup-admin @@ -19,7 +19,7 @@ err() { echo "{\"ok\":false,\"error\":$(q "$1")}"; exit 0; } [ -r "$CONF" ] && . "$CONF" # хранилище из запроса (восстановление на новом сервере, проверка до сохранения) или из настроек SHARE_IN=$(j share); DIR_IN=$(j dir); USER_IN=$(j user); PASS_IN=$(j password) -SHARE=${SHARE_IN:-${SHARE:-}}; DIR=${DIR_IN:-${DIR:-BJ_Backup/bj}} +SHARE=${SHARE_IN:-${SHARE:-}}; DIR=${DIR_IN:-${DIR:-bj-backup}} case "$SHARE" in //*) ;; \\\\*) SHARE=$(printf '%s' "$SHARE" | tr '\\' '/') ;; ?*) SHARE="//$SHARE" ;; esac TMPCRED="" credfile() { diff --git a/internal/lkgateway/accprefix.go b/internal/lkgateway/accprefix.go new file mode 100644 index 0000000..6983119 --- /dev/null +++ b/internal/lkgateway/accprefix.go @@ -0,0 +1,102 @@ +package lkgateway + +// Префикс торгового счёта клиента в учёте DEPO (OD_CLIENTS.NUMBER). У Фонтвьель — «ВТНКЦ…»; у другого +// депозитария счета могут называться иначе, поэтому префикс — настройка (Fancy.AccountPrefix), и запросы +// к учёту берут его через accSQL. Подсказка — какие префиксы у открытых счетов этого учёта. + +import ( + "context" + "net/http" + "regexp" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql" +) + +const defaultAccPrefix = "ВТНКЦ" + +// accPrefixRe — только буквы, цифры и «_»: префикс подставляется в текст запроса. +var accPrefixRe = regexp.MustCompile(`^[\p{L}\p{N}_]{1,20}$`) + +func cleanAccPrefix(p string) string { + p = strings.TrimSpace(p) + if accPrefixRe.MatchString(p) { + return p + } + return "" +} + +// depoAccPrefix — префикс торговых счетов из настроек (по умолчанию «ВТНКЦ»). +func depoAccPrefix() string { + if globalRC != nil { + if p := cleanAccPrefix(globalRC.Snapshot().Fancy.AccountPrefix); p != "" { + return p + } + } + return defaultAccPrefix +} + +// accSQL — запрос к учёту с подставленным префиксом торгового счёта. +func accSQL(q string) string { return strings.ReplaceAll(q, "{ACC}", depoAccPrefix()) } + +type accPrefixCount struct { + Prefix string `json:"prefix"` + Count int `json:"count"` +} + +// depoAccPrefixInfo — сколько открытых счетов с текущим префиксом и какие префиксы вообще есть. +func depoAccPrefixInfo(ctx context.Context, f FancyMSSQLSettings) (cur int, cands []accPrefixCount, err error) { + if f.IsPostgres() { + return 0, nil, nil + } + db, err := fancyOpen(f, f.DepoDB()) + if err != nil { + return 0, nil, err + } + defer db.Close() + if err := db.QueryRowContext(ctx, accSQL(fsql.Acc02)).Scan(&cur); err != nil { + return 0, nil, err + } + rows, err := db.QueryContext(ctx, fsql.Acc01) + if err != nil { + return cur, nil, err + } + defer rows.Close() + for rows.Next() { + var c accPrefixCount + if rows.Scan(&c.Prefix, &c.Count) == nil { + cands = append(cands, c) + } + } + return cur, cands, rows.Err() +} + +// GET /admin/setup/fancy/acc-prefix — текущий префикс и подсказка; POST — сохранить. +func (h *setupHandlers) accPrefix(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + p := strings.TrimSpace(r.FormValue("account_prefix")) + if p != "" && cleanAccPrefix(p) == "" { + jsonOut(w, map[string]any{"ok": false, "error": "префикс — только буквы и цифры, до 20 знаков"}) + return + } + s := h.rc.Snapshot().Fancy + s.AccountPrefix = p + if err := h.rc.UpdateFancy(s); err != nil { + jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + } + s := h.rc.Snapshot().Fancy + out := map[string]any{"ok": true, "prefix": depoAccPrefix(), "set": cleanAccPrefix(s.AccountPrefix) != ""} + if strings.TrimSpace(s.Server) != "" { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + cur, cands, err := depoAccPrefixInfo(ctx, s) + if err != nil { + out["error"] = quikErrorDetail(err) + } + out["count"], out["candidates"] = cur, cands + } + jsonOut(w, out) +} diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 6f83686..4543ce9 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -58,6 +58,8 @@ var templateFuncs = template.FuncMap{ "lkPortfolioStates": lkPortfolioStates, "backupStatus": backupStatus, "backupWhen": backupWhen, + "depoAccPrefix": depoAccPrefix, + "certDaysLeft": certDaysLeft, "intList": func(v ...int) []int { return v }, "nsdSegTip": nsdSegTip, "devOn": authTemplateFuncs["devOn"], diff --git a/internal/lkgateway/backup.go b/internal/lkgateway/backup.go index 2f3daa7..bfe2d1e 100644 --- a/internal/lkgateway/backup.go +++ b/internal/lkgateway/backup.go @@ -20,8 +20,8 @@ import ( // BackupSettings — куда и как часто копировать (без пароля). type BackupSettings struct { Enabled bool `json:"enabled"` - Share string `json:"share,omitempty"` // //10.10.10.11/Backup - Dir string `json:"dir,omitempty"` // BJ_Backup/bj + Share string `json:"share,omitempty"` // //nas/backup + Dir string `json:"dir,omitempty"` // bj-backup User string `json:"user,omitempty"` PasswordSet bool `json:"password_set,omitempty"` EveryHours int `json:"every_hours,omitempty"` @@ -44,7 +44,7 @@ func (b BackupSettings) keep() int { return 14 } -func (b BackupSettings) dir() string { return pickStr(strings.TrimSpace(b.Dir), "BJ_Backup/bj") } +func (b BackupSettings) dir() string { return pickStr(strings.TrimSpace(b.Dir), "bj-backup") } // BackupStatus — итог последней копии (пишет root-скрипт). type BackupStatus struct { @@ -117,7 +117,7 @@ func registerBackup(mux *http.ServeMux, rc *RuntimeConfig) { b.KeepDays, _ = strconv.Atoi(r.FormValue("keep_days")) pass := r.FormValue("password") if b.Share == "" { - setupFlash(w, r, "Резервное копирование: укажите хранилище, например //10.10.10.11/Backup") + setupFlash(w, r, "Резервное копирование: укажите хранилище, например //nas/backup") return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) diff --git a/internal/lkgateway/branding.go b/internal/lkgateway/branding.go index d8de82a..e004eeb 100644 --- a/internal/lkgateway/branding.go +++ b/internal/lkgateway/branding.go @@ -67,7 +67,11 @@ func (h *setupHandlers) resetLogo(w http.ResponseWriter, r *http.Request) { setupFlash(w, r, "Логотип: ошибка сброса: "+err.Error()) return } - setupFlash(w, r, "Логотип сброшен на стандартный (встроенный Фонтвьель).") + if builtinLogoAllowed() { + setupFlash(w, r, "Логотип сброшен на стандартный (встроенный Фонтвьель).") + return + } + setupFlash(w, r, "Логотип сброшен: в отчётах логотипа нет, пока не загрузите свой.") } // logoMIME определяет MIME изображения по расширению и содержимому; "" — не картинка. diff --git a/internal/lkgateway/certexpiry.go b/internal/lkgateway/certexpiry.go new file mode 100644 index 0000000..9503729 --- /dev/null +++ b/internal/lkgateway/certexpiry.go @@ -0,0 +1,229 @@ +package lkgateway + +// Сроки сертификатов в справочнике ИШ (05.10.2026). Своих сертификатов bj напрямую не видит (ключи — в +// Валидате и ИШ), но знает содержимое справочника активного профиля ИШ: его раз в 6 часов читает сверка +// сертификатов НРД (nsdcerts.go, NSDCerts.LastStore). Свой сертификат — тот, что не из комплекта НРД, +// не самоподписанный (не УЦ) и выдан не НРД, бирже или НКЦ. По нему же отмечается шаг «Сертификаты». +// +// Уведомления: за 30, 14, 7, 3 и 1 день до окончания и в день окончания — для своего сертификата и для +// сертификатов НРД, если им нет замены с более поздним сроком. + +import ( + "encoding/json" + "fmt" + "log" + "os" + "regexp" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" +) + +// ИНН организаций, чьи сертификаты лежат в справочнике рядом с нашим (без ведущих нулей). +var counterpartINN = map[string]string{"7702165310": "НРД", "7702077840": "Московская Биржа", "7750004023": "НКЦ"} + +var subjectINNRe = regexp.MustCompile(`(?:^|,)\s*INN=0*(\d{10,12})`) + +func subjectINN(subject string) string { + if m := subjectINNRe.FindStringSubmatch(subject); m != nil { + return m[1] + } + return "" +} + +// storeNotAfter — срок из справочника («02.01.2006»): сертификат действует весь последний день. +func storeNotAfter(c NSDStoreCert) time.Time { + t, err := time.ParseInLocation("02.01.2006", strings.TrimSpace(c.NotAfter), time.Local) + if err != nil { + return time.Time{} + } + return t.Add(24*time.Hour - time.Second) +} + +func certDaysLeft(t time.Time) int { + if t.IsZero() { + return 1 << 20 + } + d := time.Until(t) + if d < 0 { + return -1 + } + return int(d.Hours() / 24) +} + +func bundleSerials() map[string]bool { + out := map[string]bool{} + bundle, _ := loadNSDCertBundle() + for _, b := range bundle { + out[normSerial(b.Serial)] = true + if b.ReplacesSerial != "" { + out[normSerial(b.ReplacesSerial)] = true + } + } + return out +} + +// storeCertKind — own (свой), counterpart (НРД/биржа/НКЦ), ca (УЦ) или "" (не сертификат). +func storeCertKind(c NSDStoreCert, bundle map[string]bool) string { + if c.Kind != "x509" { + return "" + } + if c.SelfSigned || strings.Contains(c.SubjectCN, "УЦ") { + return "ca" + } + if bundle[normSerial(c.Serial)] || counterpartINN[subjectINN(c.Subject)] != "" { + return "counterpart" + } + return "own" +} + +// ownStoreCerts — свои сертификаты в справочнике ИШ. +func ownStoreCerts(s Settings) []NSDStoreCert { + bundle := bundleSerials() + var out []NSDStoreCert + for _, c := range s.NSDCerts.LastStore { + if storeCertKind(c, bundle) == "own" { + out = append(out, c) + } + } + return out +} + +// storeCertsForUI — сертификаты справочника ИШ (кроме УЦ) для шага «Сертификаты»: свой — с отметкой «ключ есть». +func storeCertsForUI(s Settings) []cryptocli.Certificate { + bundle := bundleSerials() + var out []cryptocli.Certificate + seen := map[string]bool{} + for _, c := range s.NSDCerts.LastStore { + k := storeCertKind(c, bundle) + if k == "" || k == "ca" || seen[c.Serial] { + continue + } + seen[c.Serial] = true + out = append(out, cryptocli.Certificate{SubjectCN: c.SubjectCN, IssuerCN: c.IssuerCN, Serial: c.Serial, + NotAfter: storeNotAfter(c), INN: subjectINN(c.Subject), HasPrivateKey: k == "own"}) + } + return out +} + +var certExpiryMarks = []int{30, 14, 7, 3, 1, 0} + +var certExpiry = struct { + sync.Mutex + file string +}{file: "/var/lib/bj/.bj/cert-expiry.json"} + +// certExpiryTick — разослать уведомления о сроках (каждая отметка — один раз на сертификат). +func certExpiryTick(rc *RuntimeConfig) { + s := rc.Snapshot() + if len(s.NSDCerts.LastStore) == 0 { + return + } + certExpiry.Lock() + defer certExpiry.Unlock() + sent := map[string][]int{} + if raw, err := os.ReadFile(certExpiry.file); err == nil { + _ = json.Unmarshal(raw, &sent) + } + bundle := bundleSerials() + // сертификат НРД, которому уже есть замена с более поздним сроком, не тревожим + latest := map[string]time.Time{} + for _, c := range s.NSDCerts.LastStore { + if t := storeNotAfter(c); c.Kind == "x509" && t.After(latest[c.SubjectCN]) { + latest[c.SubjectCN] = t + } + } + changed := false + for _, c := range s.NSDCerts.LastStore { + kind := storeCertKind(c, bundle) + if kind != "own" && kind != "counterpart" { + continue + } + until := storeNotAfter(c) + if until.IsZero() || (kind == "counterpart" && latest[c.SubjectCN].After(until)) { + continue + } + days := certDaysLeft(until) + key := normSerial(c.Serial) + mark := -1 + for _, m := range certExpiryMarks { + if days <= m && !containsInt(sent[key], m) { + mark = m // самая близкая к сроку из ещё не отправленных + } + } + if mark < 0 { + continue + } + for _, m := range certExpiryMarks { + if m >= mark && !containsInt(sent[key], m) { + sent[key] = append(sent[key], m) + } + } + changed = true + title, body := certExpiryText(c, kind, days, until) + log.Printf("cert-expiry: %s", title) + raiseSystemAlert(rc, title, body) + } + if changed { + if raw, err := json.MarshalIndent(sent, "", " "); err == nil { + _ = os.WriteFile(certExpiry.file, raw, 0o600) + } + } +} + +func containsInt(a []int, v int) bool { + for _, x := range a { + if x == v { + return true + } + } + return false +} + +func certExpiryText(c NSDStoreCert, kind string, days int, until time.Time) (string, string) { + when := fmt.Sprintf("через %d дн.", days) + switch { + case days < 0: + when = "ИСТЁК" + case days == 0: + when = "истекает сегодня" + } + who := fmt.Sprintf("«%s» (издатель «%s», серийный номер %s)", c.SubjectCN, c.IssuerCN, c.Serial) + if kind == "own" { + title := "Сертификат ключа организации " + when + if days > 0 { + title = "Сертификат ключа организации истекает " + when + } + return title, fmt.Sprintf("Сертификат %s действует до %s включительно. После этой даты подписать пакеты для НРД будет нечем — переводы M2M остановятся. "+ + "Закажите новый ключ в Удостоверяющем центре Московской биржи заранее (выпуск занимает время) и установите его мастером установки ключа: Настройка → Криптография.", + who, until.Format("02.01.2006")) + } + org := pickStr(counterpartINN[subjectINN(c.Subject)], "НРД") + title := "Сертификат " + org + " в справочнике ИШ " + when + if days > 0 { + title = "Сертификат " + org + " в справочнике ИШ истекает " + when + } + return title, fmt.Sprintf("Сертификат %s действует до %s, замены с более поздним сроком в справочнике нет. Новые сертификаты НРД приезжают с обновлением bj — проверьте, что bj обновлён (Настройка → Обновления), и сверку сертификатов НРД (Настройка → Криптография).", + who, until.Format("02.01.2006")) +} + +// StartCertExpiryWatch — проверка сроков раз в 6 часов (первая — через 3 минуты после запуска). +func StartCertExpiryWatch(rc *RuntimeConfig) func() { + stop := make(chan struct{}) + go func() { + t := time.NewTimer(3 * time.Minute) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + certExpiryTick(rc) + t.Reset(6 * time.Hour) + } + } + }() + return func() { close(stop) } +} diff --git a/internal/lkgateway/depo_check.go b/internal/lkgateway/depo_check.go index f4f8f03..0e9a868 100644 --- a/internal/lkgateway/depo_check.go +++ b/internal/lkgateway/depo_check.go @@ -122,7 +122,7 @@ func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, se if p.faceOK { rows, e := db.QueryContext(ctx, - fsql.Chk02, + accSQL(fsql.Chk02), sql.Named("p1", p.faceID)) if e == nil { for rows.Next() { @@ -379,7 +379,7 @@ func partyChecks(p depoParty, ser, num, fio string) []depoCheck { "паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"), "паспорт "+ser+" № "+num+" не найден в картотеке (OD_FACES)")}, {Name: "Торговый счёт депо есть", OK: p.accAny != "", Code: "M2M02", - Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта ВТНКЦ… в OD_CLIENTS", "не проверяется — инвестор не найден"))}, + Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта "+depoAccPrefix()+"… в OD_CLIENTS", "не проверяется — инвестор не найден"))}, {Name: "Статус счёта строго «открыт»", OK: p.accOpen != "", Code: "M2M02", Detail: pick3(p.accAny == "", "не проверяется — счёта нет", pick3(p.accOpen != "", p.accOpen+" — открыт (STATE=0, без даты закрытия)", p.accAny+" — НЕ открыт (закрыт или STATE≠0)"))}, {Name: "Счёт не ИИС", OK: p.iisOK, Code: "M2M08", Detail: p.iisDet}, diff --git a/internal/lkgateway/depo_sim.go b/internal/lkgateway/depo_sim.go index 07bb73a..0a0c63a 100644 --- a/internal/lkgateway/depo_sim.go +++ b/internal/lkgateway/depo_sim.go @@ -37,7 +37,7 @@ func depoSimLists() (clients []depoSimClient, secs []depoSimSec) { defer cancel() rows, err := db.QueryContext(ctx, - fsql.Sim01) + accSQL(fsql.Sim01)) if err == nil { defer rows.Close() seen := map[string]bool{} diff --git a/internal/lkgateway/depoevents.go b/internal/lkgateway/depoevents.go index d892bab..7ce8270 100644 --- a/internal/lkgateway/depoevents.go +++ b/internal/lkgateway/depoevents.go @@ -218,7 +218,7 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d // ранний (то же правило, что при зачислении в QUIK); джоба по номеру находит OD_CLIENTS.ID (DEPO). var clientAcc sql.NullString if serN != "" && strings.TrimSpace(num) != "" { - _ = db.QueryRowContext(ctx, fsql.Ev03, + _ = db.QueryRowContext(ctx, accSQL(fsql.Ev03), sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&clientAcc) } hasDepoNumber := depoHasColumn(ctx, db, "FTV_M2M_LINES", "DEPO_NUMBER") @@ -958,6 +958,9 @@ func depoProcCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) { if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") { return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M*", false } + if msg := depoObjectsMissing(ctx, db, f); msg != "" { + return msg, false + } if ok, msg := depoRunAsCheck(ctx, db, f); !ok { return msg, false } @@ -1215,7 +1218,7 @@ func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTr serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "") numN := strings.TrimSpace(num) var name string - if err := db.QueryRowContext(ctx, fsql.Ev11, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil { + if err := db.QueryRowContext(ctx, accSQL(fsql.Ev11), sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil { return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num) } guid := strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF)) @@ -1284,7 +1287,7 @@ func depoSimulateOutgoing(ctx context.Context, rc *RuntimeConfig, svc *Service, serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "") numN := strings.TrimSpace(num) var name string - if err := db.QueryRowContext(ctx, fsql.Ev11, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil { + if err := db.QueryRowContext(ctx, accSQL(fsql.Ev11), sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil { return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num) } req := d.Request @@ -1400,3 +1403,38 @@ func mapInt(m map[string]any, k string) int64 { } return 0 } + +// Объекты в базе DEPO, без которых обработка событий не работает: таблицы Fancy с поддержкой M2M и +// процедуры расширения bj2depo (deploy/depo/fancy-m2m). Проверяются по имени сразу — без 40-секундного +// ожидания BJ_PING и без общего «не обработано». +var ( + depoFancyM2MTables = []string{"D_CLISER_M2M", "D_CLISER_M2M_DOL", "OD_M2M_ACCS", "OD_ONLINE_M2M_DEPO"} + depoBj2depoProcs = []string{"FTV_M2M_JOB", "FTV_M2M_PROCESS_CREATE", "FTV_M2M_PROCESS_EVENTS", "FTV_M2M_PROCESS_OPERATION", "FTV_M2M_PROCESS_WIRING"} +) + +func depoObjectsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string { + if f.IsPostgres() { + return "" + } + missing := func(names []string, typ string) []string { + var out []string + for _, n := range names { + var ok int + if err := db.QueryRowContext(ctx, "SELECT CASE WHEN OBJECT_ID(@n, @t) IS NULL THEN 0 ELSE 1 END", sql.Named("n", "dbo."+n), sql.Named("t", typ)).Scan(&ok); err == nil && ok == 0 { + out = append(out, n) + } + } + return out + } + if m := missing(depoFancyM2MTables, "U"); len(m) > 0 { + return "в базе " + f.DepoDB() + " нет таблиц Fancy DEPO с поддержкой M2M (" + strings.Join(m, ", ") + "): нужна версия Fancy DEPO с M2M — обратитесь к разработчику Fancy" + } + procs := depoBj2depoProcs + if p := strings.TrimSpace(f.DepoJobProc); p != "" && !strings.EqualFold(strings.TrimPrefix(p, "dbo."), "FTV_M2M_JOB") { + procs = []string{strings.TrimPrefix(p, "dbo.")} // своя процедура обработки — проверяем только её + } + if m := missing(procs, "P"); len(m) > 0 { + return "в базе " + f.DepoDB() + " нет процедур обработки bj2depo: " + strings.Join(m, ", ") + ". Их нужно установить в базу DEPO (порядок: WIRING → OPERATION → EVENTS → CREATE → JOB), затем — «Перепроверить всё»" + } + return "" +} diff --git a/internal/lkgateway/fsql/accprefix.go b/internal/lkgateway/fsql/accprefix.go new file mode 100644 index 0000000..3402ab8 --- /dev/null +++ b/internal/lkgateway/fsql/accprefix.go @@ -0,0 +1,10 @@ +package fsql + +// Acc01 — начала номеров открытых счетов в OD_CLIENTS (буквы до первой цифры) и их число: подсказка, какой +// префикс у торговых счетов клиентов в этом учёте. +var Acc01 = `SELECT TOP 8 p, COUNT(*) FROM (SELECT LEFT(NUMBER, PATINDEX('%[0-9]%', NUMBER + '0') - 1) AS p + FROM OD_CLIENTS WHERE STATE=0 AND D_CLOSE IS NULL AND NUMBER IS NOT NULL) x + WHERE p <> '' GROUP BY p ORDER BY COUNT(*) DESC` + +// Acc02 — число открытых счетов с выбранным префиксом. +var Acc02 = `SELECT COUNT(*) FROM OD_CLIENTS WHERE NUMBER LIKE N'{ACC}%' AND STATE=0 AND D_CLOSE IS NULL` diff --git a/internal/lkgateway/fsql/depo_check.go b/internal/lkgateway/fsql/depo_check.go index 1691151..c8c09a5 100644 --- a/internal/lkgateway/fsql/depo_check.go +++ b/internal/lkgateway/fsql/depo_check.go @@ -3,7 +3,7 @@ package fsql var Chk01 = `SELECT TOP 1 ISNULL(NULLIF(SELF_ID,0),ID), ISNULL(NAME,''), DATE_BIRTH FROM OD_FACES WHERE REPLACE(ISNULL(DOC_SER,''),' ','')=@p1 AND DOC_NUM=@p2 ORDER BY ISNULL(LAST_FLAG,0) DESC, ID DESC` -var Chk02 = `SELECT ID, ISNULL(NUMBER,''), ISNULL(STATE,-1), D_CLOSE FROM OD_CLIENTS WHERE FACE=@p1 AND NUMBER LIKE N'ВТНКЦ%' ORDER BY ID` +var Chk02 = `SELECT ID, ISNULL(NUMBER,''), ISNULL(STATE,-1), D_CLOSE FROM OD_CLIENTS WHERE FACE=@p1 AND NUMBER LIKE N'{ACC}%' ORDER BY ID` var Chk03 = `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'IS_QUAL' diff --git a/internal/lkgateway/fsql/depo_sim.go b/internal/lkgateway/fsql/depo_sim.go index a78e6ae..caecffa 100644 --- a/internal/lkgateway/fsql/depo_sim.go +++ b/internal/lkgateway/fsql/depo_sim.go @@ -2,7 +2,7 @@ package fsql var Sim01 = `SELECT TOP 25 ISNULL(f.NAME,''), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''), ISNULL(c.NUMBER,'') FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID - WHERE c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL + WHERE c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL AND LEN(ISNULL(f.DOC_SER,''))>0 AND LEN(ISNULL(f.DOC_NUM,''))>0 ORDER BY f.NAME` diff --git a/internal/lkgateway/fsql/depoevents.go b/internal/lkgateway/fsql/depoevents.go index 58112bd..871a58c 100644 --- a/internal/lkgateway/fsql/depoevents.go +++ b/internal/lkgateway/fsql/depoevents.go @@ -6,7 +6,7 @@ var Ev02 = `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_D var Ev03 = `SELECT TOP 1 c.NUMBER FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID - WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL + WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER` var Ev04 = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC) VALUES (@g, @d, @cp, @nm, @doc)` @@ -28,4 +28,4 @@ var Ev10 = `INSERT INTO dbo.FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_ VALUES (@inn, @code, @kind, @full, @short, @disp, @eng, @at, GETDATE(), 1)` var Ev11 = `SELECT TOP 1 f.NAME FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID - WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0` + WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0` diff --git a/internal/lkgateway/fsql/manual_depo.go b/internal/lkgateway/fsql/manual_depo.go index af69c9e..8554cf8 100644 --- a/internal/lkgateway/fsql/manual_depo.go +++ b/internal/lkgateway/fsql/manual_depo.go @@ -4,12 +4,12 @@ var Man01 = `SELECT TOP 20 ISNULL(NULLIF(f.SELF_ID,0),f.ID), ISNULL(f.NAME,''), f.DOC_DAT, ISNULL(f.DOC_PLACE,''), ISNULL(f.DOC_CODE,''), ISNULL(f.PHONE,'') FROM OD_FACES f WHERE ISNULL(f.LAST_FLAG,1)=1 AND (UPPER(f.NAME) LIKE UPPER(@q) OR f.DOC_NUM=@d OR REPLACE(ISNULL(f.DOC_SER,''),' ','')+f.DOC_NUM=@d) - AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=ISNULL(NULLIF(f.SELF_ID,0),f.ID) AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL) + AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=ISNULL(NULLIF(f.SELF_ID,0),f.ID) AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL) ORDER BY f.NAME` var Man02 = `SELECT c.ID, ISNULL(c.NUMBER,''), ISNULL((SELECT TOP 1 p.PORT_NUM FROM OD_CLIENTS_PORTS p WHERE p.CLIENT=c.ID ORDER BY p.PORT_DATE, p.PORT_NUM),'') - FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID` + FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID` var Man03 = `SELECT ISNULL(v.ISIN,''), ISNULL(v.NAME,''), SUM(CAST(r.K AS float)) FROM OD_RESTS r JOIN OD_VALUES v ON v.ID=r.VALUE_ID diff --git a/internal/lkgateway/fsql/quikcycle.go b/internal/lkgateway/fsql/quikcycle.go index d98fdaa..bfebd55 100644 --- a/internal/lkgateway/fsql/quikcycle.go +++ b/internal/lkgateway/fsql/quikcycle.go @@ -5,5 +5,5 @@ var Qk01 = `SELECT ic.ClassCode, sec.SecCode FROM ISecurities sec JOIN IClasses var Qk02 = `SELECT TOP 5 p.PORT_NUM FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID - WHERE REPLACE(f.DOC_SER,' ','')=@p1 AND f.DOC_NUM=@p2 AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 + WHERE REPLACE(f.DOC_SER,' ','')=@p1 AND f.DOC_NUM=@p2 AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER` diff --git a/internal/lkgateway/health.go b/internal/lkgateway/health.go index 804706c..2edc499 100644 --- a/internal/lkgateway/health.go +++ b/internal/lkgateway/health.go @@ -3,6 +3,7 @@ package lkgateway import ( "context" "fmt" + "hash/fnv" "log" "net" "os" @@ -247,8 +248,11 @@ func slugify(s string) string { } } out := strings.Trim(b.String(), "-") - if out == "" { - return "alert" + if out == "" || strings.Count(out, "-") == len(out) { + // заголовки по-русски дают пустую строку — без хеша разные оповещения за 10 минут склеивались бы в одно + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + return fmt.Sprintf("a%08x", h.Sum32()) } if len(out) > 40 { out = out[:40] diff --git a/internal/lkgateway/igate.go b/internal/lkgateway/igate.go new file mode 100644 index 0000000..2c750e7 --- /dev/null +++ b/internal/lkgateway/igate.go @@ -0,0 +1,54 @@ +package lkgateway + +// Интеграционный шлюз НРД (ИШ, служба igate) на этом сервере: состояние для мастера (шаг 8). + +import ( + "context" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" +) + +const igateDefaultURL = "http://localhost:8090" + +// igateInstalled — пакет ИШ стоит на этом сервере. +func igateInstalled() bool { + _, err := os.Stat("/opt/nsd/igate") + return err == nil +} + +// igateState — отвечает ли ИШ и в каком состоянии его движок. +func igateState(ctx context.Context, base string) (bool, string) { + base = strings.TrimRight(pickStr(strings.TrimSpace(base), igateDefaultURL), "/") + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(cctx, http.MethodGet, base+"/api/admin/engine/state", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + if !igateInstalled() { + return false, "интеграционный шлюз на этом сервере не установлен" + } + out, _ := exec.CommandContext(ctx, "systemctl", "is-active", "igate").Output() + return false, "шлюз установлен, но не отвечает по адресу " + base + " (служба igate: " + pickStr(strings.TrimSpace(string(out)), "неизвестно") + ")" + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + st := strings.Trim(strings.TrimSpace(string(body)), `"`) + if resp.StatusCode != http.StatusOK { + return false, "шлюз отвечает с ошибкой HTTP " + resp.Status + } + if st != "Running" { + return false, "шлюз отвечает, но его движок в состоянии «" + st + "» — нужен Running" + } + return true, "шлюз отвечает по адресу " + base + ", движок работает" +} + +func registerIgate(mux *http.ServeMux, rc *RuntimeConfig) { + mux.HandleFunc("/admin/setup/igate/state", func(w http.ResponseWriter, r *http.Request) { + ok, msg := igateState(r.Context(), rc.Snapshot().NSD.IGWBaseURL) + jsonOut(w, map[string]any{"ok": ok, "msg": msg, "installed": igateInstalled()}) + }) +} diff --git a/internal/lkgateway/lkportfolio.go b/internal/lkgateway/lkportfolio.go index baa40c7..ac6961c 100644 --- a/internal/lkgateway/lkportfolio.go +++ b/internal/lkgateway/lkportfolio.go @@ -176,7 +176,7 @@ type LKPortfolioSyncSettings struct { } func (s LKPortfolioSyncSettings) accountPrefix() string { - return pickStr(strings.TrimSpace(s.AccountPrefix), "ВТНКЦ") + return pickStr(strings.TrimSpace(s.AccountPrefix), depoAccPrefix()) } func (s LKPortfolioSyncSettings) contractTemplate() string { return pickStr(strings.TrimSpace(s.ContractTemplate), "ДД-{n}") diff --git a/internal/lkgateway/manual_depo.go b/internal/lkgateway/manual_depo.go index 07ae787..60f992d 100644 --- a/internal/lkgateway/manual_depo.go +++ b/internal/lkgateway/manual_depo.go @@ -73,7 +73,7 @@ func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) { // Действующая версия карточки (LAST_FLAG=1); счета привязаны к исходной записи (SELF_ID). // likeEscape: метасимволы LIKE (% _ [) в имени от пользователя означают сами себя, // иначе поиск «расползается» по всей базе (wildcard-injection). - rows, err := db.QueryContext(ctx, fsql.Man01, sql.Named("q", likeEscape(q)+"%"), sql.Named("d", digits)) + rows, err := db.QueryContext(ctx, accSQL(fsql.Man01), sql.Named("q", likeEscape(q)+"%"), sql.Named("d", digits)) if err != nil { return nil, "поиск в DEPO: " + err.Error() } @@ -117,7 +117,7 @@ func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) { rows.Close() out := make([]manualDepoFace, 0, len(found)) for _, r := range found { - ar, err := db.QueryContext(ctx, fsql.Man02, sql.Named("f", r.id)) + ar, err := db.QueryContext(ctx, accSQL(fsql.Man02), sql.Named("f", r.id)) if err == nil { for ar.Next() { var a manualDepoAcc diff --git a/internal/lkgateway/netutil.go b/internal/lkgateway/netutil.go new file mode 100644 index 0000000..b2911d4 --- /dev/null +++ b/internal/lkgateway/netutil.go @@ -0,0 +1,29 @@ +package lkgateway + +import ( + "net" + "strconv" + "strings" + "time" +) + +// outboundIP — адрес этого сервера, с которого он ходит к host (для отчётов: какой IP разрешить на той стороне). +// UDP-«соединение» пакетов не шлёт — система только выбирает маршрут и исходящий адрес. +func outboundIP(host string, port int) string { + h := strings.TrimSpace(strings.SplitN(host, `\`, 2)[0]) + if h == "" { + return "" + } + if port == 0 { + port = 1433 + } + c, err := net.DialTimeout("udp", net.JoinHostPort(h, strconv.Itoa(port)), time.Second) + if err != nil { + return "" + } + defer c.Close() + if a, ok := c.LocalAddr().(*net.UDPAddr); ok { + return a.IP.String() + } + return "" +} diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go new file mode 100644 index 0000000..7559878 --- /dev/null +++ b/internal/lkgateway/onboarding_test.go @@ -0,0 +1,77 @@ +package lkgateway + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAccPrefix(t *testing.T) { + for in, want := range map[string]string{"ВТНКЦ": "ВТНКЦ", " БР ": "БР", "AB_1": "AB_1", "a'b": "", "x%": "", "": ""} { + if got := cleanAccPrefix(in); got != want { + t.Errorf("cleanAccPrefix(%q)=%q, want %q", in, got, want) + } + } + if q := accSQL("LIKE N'{ACC}%'"); !strings.Contains(q, "N'ВТНКЦ%'") { + t.Errorf("accSQL по умолчанию: %s", q) + } +} + +func TestDoneNSDUnknownProfile(t *testing.T) { + var s Settings + s.NSD.IGWBaseURL, s.NSD.Profile = "http://localhost:8090", "prod" + if doneNSD(s) { + t.Fatal("профиль prod неизвестен bj — шаг не должен считаться выполненным") + } + s.NSD.Profile = "prod-gost" + if !doneNSD(s) { + t.Fatal("prod-gost — известный профиль") + } +} + +func TestStoreCertKindAndExpiry(t *testing.T) { + bundle := map[string]bool{} + own := NSDStoreCert{Kind: "x509", Subject: "O=Предприятие 1046,CN=Сотрудник 1046,INN=078000001046", SubjectCN: "Сотрудник 1046", IssuerCN: "Тестовый УЦ", Serial: "AA01"} + nsd := NSDStoreCert{Kind: "x509", Subject: "O=НКО АО НРД,CN=Петров,INN=007702165310", SubjectCN: "Петров", IssuerCN: "Тестовый УЦ", Serial: "BB02"} + ca := NSDStoreCert{Kind: "x509", SubjectCN: "Тестовый УЦ", SelfSigned: true} + for c, want := range map[*NSDStoreCert]string{&own: "own", &nsd: "counterpart", &ca: "ca"} { + if got := storeCertKind(*c, bundle); got != want { + t.Errorf("%s: %s, want %s", c.SubjectCN, got, want) + } + } + if subjectINN(nsd.Subject) != "7702165310" { + t.Errorf("subjectINN: %q", subjectINN(nsd.Subject)) + } + + // уведомления: за 20 дней — одно (отметка 30), повторно не шлётся; за 5 дней — следующее (7) + dir := t.TempDir() + certExpiry.file = filepath.Join(dir, "cert-expiry.json") + rc, err := NewRuntimeConfig(filepath.Join(dir, "setup.json")) + if err != nil { + t.Fatal(err) + } + own.NotAfter = time.Now().AddDate(0, 0, 20).Format("02.01.2006") + st := rc.Snapshot().NSDCerts + st.LastStore = []NSDStoreCert{own, nsd, ca} + _ = rc.UpdateNSDCerts(st) + count := func() int { return len(rc.Snapshot().News.Items) } + certExpiryTick(rc) + n1 := count() + certExpiryTick(rc) + if n1 != 1 || count() != 1 { + t.Fatalf("за 20 дней: ожидалось одно уведомление, было %d, затем %d", n1, count()) + } + own.NotAfter = time.Now().AddDate(0, 0, 5).Format("02.01.2006") + st.LastStore = []NSDStoreCert{own} + _ = rc.UpdateNSDCerts(st) + certExpiryTick(rc) + if count() != 2 { + t.Fatalf("за 5 дней: ожидалось второе уведомление, всего %d", count()) + } + raw, _ := os.ReadFile(certExpiry.file) + if !strings.Contains(string(raw), "7") { + t.Fatalf("отметка 7 не сохранена: %s", raw) + } +} diff --git a/internal/lkgateway/org.go b/internal/lkgateway/org.go index 9646a77..c086915 100644 --- a/internal/lkgateway/org.go +++ b/internal/lkgateway/org.go @@ -113,3 +113,30 @@ func (h *setupHandlers) saveOrg(w http.ResponseWriter, r *http.Request) { } setupFlash(w, r, msg) } + +var nsdReqRe = regexp.MustCompile(`^[A-Z0-9]{4,30}$`) + +// saveNSDRequisites — POST /admin/setup/nsd-requisites: депозитарный код, счёт и раздел организации в НРД +// (место хранения бумаг в пакетах). В мастере — на шаге «Организация», рядом с кодом и ИНН. +func (h *setupHandlers) saveNSDRequisites(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().NSD + dep := strings.ToUpper(strings.TrimSpace(r.FormValue("deponent_code"))) + acc := strings.ToUpper(strings.TrimSpace(r.FormValue("account_id"))) + sec := strings.ToUpper(strings.TrimSpace(r.FormValue("section_id"))) + for _, v := range []struct{ name, val string }{{"депозитарный код", dep}, {"депозитарный счёт", acc}, {"раздел счёта", sec}} { + if v.val != "" && !nsdReqRe.MatchString(v.val) { + setupFlash(w, r, "Реквизиты НРД: "+v.name+" — латинские буквы и цифры, без пробелов") + return + } + } + s.DeponentCode, s.AccountID, s.SectionID = dep, acc, sec + if err := h.rc.UpdateNSD(s); err != nil { + setupFlash(w, r, "Реквизиты НРД: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Реквизиты НРД сохранены: код "+pick(dep, "—")+", счёт "+pick(acc, "—")+", раздел "+pick(sec, "—")) +} diff --git a/internal/lkgateway/quikcycle.go b/internal/lkgateway/quikcycle.go index 7a3b587..9b1863d 100644 --- a/internal/lkgateway/quikcycle.go +++ b/internal/lkgateway/quikcycle.go @@ -445,7 +445,7 @@ func quikResolveClient(ctx context.Context, rc *RuntimeConfig, ser, num string) // Правило депозитария: при нескольких договорах клиента зачисление идёт на // САМЫЙ РАННИЙ договор — берём портфель с наименьшей датой (затем по дате // открытия счёта и номеру). - rows, err := db.QueryContext(cctx, fsql.Qk02, serN, numN) + rows, err := db.QueryContext(cctx, accSQL(fsql.Qk02), serN, numN) if err != nil { return "", "", fmt.Errorf("DEPO: %w", err) } diff --git a/internal/lkgateway/quikdb_handlers.go b/internal/lkgateway/quikdb_handlers.go index 303fd5f..7546230 100644 --- a/internal/lkgateway/quikdb_handlers.go +++ b/internal/lkgateway/quikdb_handlers.go @@ -184,7 +184,7 @@ func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(&b, "БД: %s\n", dfltDB(s.Database)) fmt.Fprintf(&b, "Логин: %s\n", s.User) fmt.Fprintf(&b, "Шифрование: encrypt=%s, TrustServerCertificate=%v\n", s.Encrypt, s.Trust) - fmt.Fprintf(&b, "Клиент (bj): исходящий IP 195.239.71.22, драйвер go-mssqldb\n") + fmt.Fprintf(&b, "Клиент (bj): исходящий IP %s, драйвер go-mssqldb\n", pickStr(outboundIP(s.Server, s.Port), "не определён")) fmt.Fprintf(&b, "----------------------------------------\n") res, err := quikProbe(r.Context(), s) @@ -218,9 +218,23 @@ func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(&b, " %-28s %s\n", k, mark) } s.Verified = true + s.M2MMissing = "" + if !res.M2MReady { + var miss []string + for _, k := range []string{"table:Export", "table:Contracts", "proc:sp_NewInstrSec"} { + if !res.Objects[k] { + miss = append(miss, strings.SplitN(k, ":", 2)[1]) + } + } + s.M2MMissing = strings.Join(miss, ", ") + } s.LastReport = b.String() s.LastAt = now.UTC() _ = h.rc.UpdateQuik(s) + if s.M2MMissing != "" { + setupFlash(w, r, fmt.Sprintf("QUIK МНП: подключение есть (БД=%s), но под этим логином не видно объектов M2M: %s. Проверьте имя базы и права логина (роль InstrEu). Полный отчёт ниже.", res.DBName, s.M2MMissing)) + return + } setupFlash(w, r, fmt.Sprintf("QUIK МНП: ПОДКЛЮЧЕНИЕ OK (БД=%s). Полный отчёт ниже.", res.DBName)) } diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index f52d9cc..f69ea58 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -59,6 +59,7 @@ type QuikMNPSettings struct { Verified bool `json:"verified"` // последнее тест-подключение прошло LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам) LastAt time.Time `json:"last_at"` // время последнего теста + M2MMissing string `json:"m2m_missing,omitempty"` // ключевые объекты M2M, не видные под логином при последней проверке // Автоматический цикл M2M в МНП (пишущие операции к боевой БД). WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП @@ -179,6 +180,9 @@ type FancyMSSQLSettings struct { User string `json:"user"` // логин Password string `json:"password"` // пароль Encrypt string `json:"encrypt"` // MSSQL: disable|false|true; Postgres: sslmode + // AccountPrefix — начало номера торгового счёта клиента в OD_CLIENTS (у Фонтвьель «ВТНКЦ»): по нему bj + // находит счёт инвестора при сверке переводов, в цикле QUIK и при ручном вводе. Пусто — «ВТНКЦ». + AccountPrefix string `json:"account_prefix,omitempty"` // Обработка трансферных таблиц DEPO силами bj (вместо джобы SQL Agent bj2depo): // bj сам вызывает процедуру DepoJobProc каждые DepoJobEverySec секунд. diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index f2dfbd1..c7e30b1 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -216,6 +216,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerJournal(mux, adminTpl, svc, incomingStore) registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) + registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go) registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) registerQuikOps(mux, rc, svc) @@ -365,7 +366,9 @@ func (s *Server) Run(ctx context.Context) error { // Доверенные сертификаты НРД: вшитый комплект → справочник активного // профиля ИШ (добавление/плановое удаление, с бэкапом и откатом). stopNSDCerts := StartNSDCertsSync(s.rc) + stopCertExpiry := StartCertExpiryWatch(s.rc) defer stopNSDCerts() + defer stopCertExpiry() // Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и // эмитирует новости в ленту. Дефолтные источники + дефолтные diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 9ca45fb..b869b74 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -21,6 +21,7 @@ import ( "github.com/jackc/pgx/v5" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter" ) // crypto-сертификаты на текущих токенах (для отображения на странице). @@ -28,20 +29,7 @@ import ( // Валидаты — здесь временно возвращаем пустой список, пока // crypto-service не получит метод ListCertificates. func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate { - s := h.rc.Snapshot() - if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" { - return nil - } - 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(), 5*time.Second) - defer cancel() - certs, _ := cli.FindCertificates(ctx) - return certs + return storeCertsForUI(h.rc.Snapshot()) } // setupHandlers — обработчики /admin/setup/*. @@ -84,6 +72,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/wizard-depo/tables", h.wizardDepoTables) mux.HandleFunc("/admin/setup/wizard-depo/job", h.wizardDepoJob) mux.HandleFunc("/admin/setup/wizard-depo/recheck", h.wizardDepoRecheck) + mux.HandleFunc("/admin/setup/fancy/acc-prefix", h.accPrefix) mux.HandleFunc("/admin/setup/license", h.saveLicense) mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate) mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow) @@ -91,6 +80,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo) mux.HandleFunc("/admin/setup/nsd", h.saveNSD) mux.HandleFunc("/admin/setup/org", h.saveOrg) + mux.HandleFunc("/admin/setup/nsd-requisites", h.saveNSDRequisites) mux.HandleFunc("/admin/setup/lk", h.saveLK) mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist) mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload) @@ -251,8 +241,8 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { // Валидата ставится с образа/установщиком: если она на месте, шаг выполнен без ввода параметров // (bj работает с ней через свой криптосервис, путь к модулю не нужен). d.Done.Crypto = d.ValidataVer != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "") - d.Done.Certs = len(d.Certs) > 0 - d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" + d.Done.Certs = len(ownValidCerts(d.Certs)) > 0 + d.Done.NSD = doneNSD(s) d.Done.TestRun = s.LastTest != nil || (!d.DevMode && d.Done.NSD) // вне режима разработки тестовой заявки нет // Мастер идёт по порядку: каждый шаг показывается, даже если он уже выполнен (установщиком, @@ -272,7 +262,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { step = wizardSteps } d.Step = step - if step == 4 && strings.TrimSpace(s.Fancy.Server) != "" && (orgCode(s) == "" || orgINN(s) == "") && depoDetectResult() == nil { + if step == 4 && strings.TrimSpace(s.Fancy.Server) != "" && !doneOrg(s) && depoDetectResult() == nil { // организация ещё не задана, а база DEPO подключена — определяем реквизиты сами: карточка // покажет их заполненными, пользователю остаётся проверить и сохранить ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) @@ -296,8 +286,8 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { d.Sub = "done" } } - d.Done.Org = orgCode(s) != "" && orgINN(s) != "" - d.Done.Quik = !d.DepoLicensed || s.Quik.Verified + d.Done.Org = doneOrg(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. @@ -721,6 +711,22 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) { SectionID: strings.TrimSpace(r.FormValue("section_id")), IGWv904: r.FormValue("igw_v904") == "on", } + cur := h.rc.Snapshot().NSD + if r.FormValue("keep_requisites") == "1" { // мастер: реквизиты НРД — на шаге «Организация» + s.DeponentCode, s.AccountID, s.SectionID = cur.DeponentCode, cur.AccountID, cur.SectionID + } + if _, ok := r.Form["key_container"]; !ok { + s.KeyContainer = cur.KeyContainer + } + if s.Profile == "prod" { // старый пункт мастера: такого профиля нет — bj ушёл бы в имитацию + s.Profile = "prod-gost" + } + if s.Profile != "" { + if _, err := nsdadapter.LookupProfile(s.Profile); err != nil { + setupFlash(w, r, "НРД: неизвестный контур "+s.Profile) + return + } + } if s.IGWBaseURL != "" { if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil { setupFlash(w, r, "nsd: невалидный URL: "+err.Error()) @@ -728,7 +734,7 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) { } // У ИШ нет /healthz — проверяем рабочий эндпоинт Web API. if err := tryHTTPHealth(s.IGWBaseURL + "/api/admin/engine/state"); err != nil { - setupFlash(w, r, "nsd: ИШ не отвечает (engine/state): "+err.Error()) + setupFlash(w, r, "Шлюз НРД не отвечает по адресу "+s.IGWBaseURL+": "+err.Error()+". Проверьте, что ИШ установлен и запущен (служба igate).") return } } @@ -736,7 +742,18 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) { setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error()) return } - setupFlash(w, r, "nsd-adapter: настройки сохранены") + msg := "Шлюз НРД: настройки сохранены" + if s.Profile != cur.Profile || s.IGWBaseURL != cur.IGWBaseURL { + // отправитель в НРД создаётся при запуске — перезапускаем bj, чтобы контур и адрес применились сразу + msg += ". bj перезапускается, чтобы применить контур и адрес, — обновите страницу через 10–15 секунд." + go func() { + time.Sleep(2 * time.Second) + if _, err := restartUnit(context.Background(), "bj-server"); err != nil { + log.Printf("setup: перезапуск bj-server после смены контура НРД: %v", err) + } + }() + } + setupFlash(w, r, msg) } func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) { diff --git a/internal/lkgateway/setupsteps.go b/internal/lkgateway/setupsteps.go index b420d47..336d26c 100644 --- a/internal/lkgateway/setupsteps.go +++ b/internal/lkgateway/setupsteps.go @@ -10,6 +10,7 @@ import ( "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter" ) type SetupStep struct { @@ -35,15 +36,7 @@ func setupProbes(rc *RuntimeConfig) (string, int) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() setupProbeCache.validata, _, _ = validataVersion(ctx) - setupProbeCache.certs = 0 - s := rc.Snapshot() - if s.Crypto.Provider != "" && s.Crypto.Provider != "stub" { - cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.Provider(s.Crypto.Provider), SocketPath: s.Crypto.SocketPath, ModulePath: s.Crypto.ModulePath}) - if certs, err := cli.FindCertificates(ctx); err == nil { - setupProbeCache.certs = len(certs) - } - cli.Close() - } + setupProbeCache.certs = len(ownValidCerts(storeCertsForUI(rc.Snapshot()))) setupProbeCache.at = time.Now() return setupProbeCache.validata, setupProbeCache.certs } @@ -60,14 +53,44 @@ func setupSteps(rc *RuntimeConfig) []SetupStep { if mnp { steps = append(steps, SetupStep{3, "Учёт DEPO", strings.TrimSpace(s.Fancy.Server) != "" && s.Fancy.DepoTablesCheckOK && s.Fancy.DepoJobCheckOK}) } - steps = append(steps, SetupStep{4, "Наша организация", orgCode(s) != "" && orgINN(s) != ""}) + steps = append(steps, SetupStep{4, "Наша организация", doneOrg(s)}) if mnp { - steps = append(steps, SetupStep{5, "QUIK МНП", s.Quik.Verified}) + steps = append(steps, SetupStep{5, "QUIK МНП", doneQuik(s)}) } steps = append(steps, SetupStep{6, "Валидата", validata != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "")}, SetupStep{7, "Сертификаты", certs > 0}, - SetupStep{8, "Шлюз НРД", s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""}, + SetupStep{8, "Шлюз НРД", doneNSD(s)}, ) return steps } + +// ownValidCerts — свои сертификаты (с закрытым ключом), срок которых не истёк: только они делают шаг +// «Сертификаты» выполненным (сертификаты НРД и УЦ в хранилище есть и без ключа организации). +func ownValidCerts(certs []cryptocli.Certificate) []cryptocli.Certificate { + var out []cryptocli.Certificate + now := time.Now() + for _, c := range certs { + if c.HasPrivateKey && (c.NotAfter.IsZero() || c.NotAfter.After(now)) { + out = append(out, c) + } + } + return out +} + +// doneOrg — организация и её депозитарные реквизиты в НРД заданы (без них перевод не сформировать). +func doneOrg(s Settings) bool { + return orgCode(s) != "" && orgINN(s) != "" && s.NSD.DeponentCode != "" && s.NSD.AccountID != "" && s.NSD.SectionID != "" +} + +// doneQuik — подключение к МНП проверено и ключевые объекты M2M видны. +func doneQuik(s Settings) bool { return s.Quik.Verified && s.Quik.M2MMissing == "" } + +// doneNSD — адрес ИШ задан и профиль известен bj (неизвестный профиль — молча режим имитации). +func doneNSD(s Settings) bool { + if s.NSD.IGWBaseURL == "" || s.NSD.Profile == "" { + return false + } + _, err := nsdadapter.LookupProfile(s.NSD.Profile) + return err == nil +} diff --git a/internal/lkgateway/web/templates/admin_depo_sim.html b/internal/lkgateway/web/templates/admin_depo_sim.html index b4bc12b..8167053 100644 --- a/internal/lkgateway/web/templates/admin_depo_sim.html +++ b/internal/lkgateway/web/templates/admin_depo_sim.html @@ -85,7 +85,7 @@ {{else}} - +
Инвестор в картотеке{{if .Result.Investor}}{{.Result.Investor}}{{else}}не найден{{end}}
Торговый счёт депо{{if .Result.Account}}{{.Result.Account}} — открыт{{else}}открытого ВТНКЦ-счёта нет{{end}}
Торговый счёт депо{{if .Result.Account}}{{.Result.Account}} — открыт{{else}}открытого торгового счёта нет{{end}}
{{range .Result.Secs}}

{{.Security}} → {{if eq .Recommend "confirm"}}принять{{else}}отказ {{.RejectCode}} ({{m2mCode .RejectCode}}){{end}}

diff --git a/internal/lkgateway/web/templates/admin_incoming_detail.html b/internal/lkgateway/web/templates/admin_incoming_detail.html index d8a85c3..695d634 100644 --- a/internal/lkgateway/web/templates/admin_incoming_detail.html +++ b/internal/lkgateway/web/templates/admin_incoming_detail.html @@ -54,7 +54,7 @@ {{else}} - +
Инвестор в картотеке{{if .Depo.Investor}}{{.Depo.Investor}}{{else}}не найден{{end}}
Торговый счёт депо{{if .Depo.Account}}{{.Depo.Account}} — открыт{{else}}открытого ВТНКЦ-счёта нет{{end}}
Торговый счёт депо{{if .Depo.Account}}{{.Depo.Account}} — открыт{{else}}открытого торгового счёта нет{{end}}
{{range .Depo.Secs}} diff --git a/internal/lkgateway/web/templates/admin_m2mpreview.html b/internal/lkgateway/web/templates/admin_m2mpreview.html index a665401..357fe22 100644 --- a/internal/lkgateway/web/templates/admin_m2mpreview.html +++ b/internal/lkgateway/web/templates/admin_m2mpreview.html @@ -119,7 +119,7 @@ {{if not .Depo.Ran}}

Сверка не выполнена: {{.Depo.Err}}

{{else}} -
Инвестор: {{if .Depo.Investor}}{{.Depo.Investor}}{{else}}не найден в картотеке{{end}} · Торговый счёт: {{if .Depo.Account}}{{.Depo.Account}}{{else}}нет открытого ВТНКЦ-счёта{{end}}
+
Инвестор: {{if .Depo.Investor}}{{.Depo.Investor}}{{else}}не найден в картотеке{{end}} · Торговый счёт: {{if .Depo.Account}}{{.Depo.Account}}{{else}}нет открытого торгового счёта{{end}}
{{range .Depo.Secs}}
{{.Security}} → {{if eq .Recommend "confirm"}}списать можно{{else}}отказ {{.RejectCode}} ({{m2mCode .RejectCode}}){{end}}
diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 21937f1..712cda4 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -654,7 +654,7 @@ +
Запуск обработки (джоба){{if .Settings.Fancy.DepoJobCheckOK}}✓ {{.Settings.Fancy.DepoJobCheck}}{{else}}✗ {{.Settings.Fancy.DepoJobCheck}}{{end}}
Обработка событий (процедуры){{if .Settings.Fancy.DepoProcCheckOK}}✓ {{.Settings.Fancy.DepoProcCheck}}{{else if .Settings.Fancy.DepoProcCheck}}⚠ {{.Settings.Fancy.DepoProcCheck}}{{else}}не проверялась — «Перепроверить всё»{{end}}
+ {{if .Settings.Fancy.Server}}{{template "accPrefix" .}}{{end}} {{if .Done.Depo}}

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

{{else}}

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

{{end}} {{end}} @@ -277,7 +278,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{if .Done.Depo}}К шагу 4 →{{else}}Пропустить шаг →{{end}} {{end}} -{{/* ============= ШАГ 1: PostgreSQL ============= */}} +{{/* ============= ШАГ 2: База bj (PostgreSQL) ============= */}} {{if eq .Step 2}}

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

@@ -285,7 +286,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{if .Done.Postgres}}

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

{{end}} {{if not .Done.Postgres}}
- Что выбрать? Если у вас уже есть рабочий PostgreSQL — нажмите «У меня уже есть PostgreSQL» и введите DSN. Если впервые настраиваете — выберите «Поднять автоматически», система сама развернёт контейнер с PostgreSQL и накатит миграции. + Обычно базу создаёт установщик — если вы ставили bj с образа или командой установки, этот шаг уже выполнен. Если база bj на отдельном сервере PostgreSQL — введите строку подключения ниже{{if devOn}}, или поднимите локальную базу автоматически (режим разработки){{end}}.
{{end}} {{if and devOn (not .Settings.Postgres.DSN)}} @@ -311,9 +312,9 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
← К шагу 1 - {{if .Done.Postgres}}К шагу 3 →{{else}}{{end}} + {{if .Done.Postgres}}К шагу 3 →{{else}}{{end}}
- {{if not .Done.Postgres}}

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

{{end}} + {{if and devOn (not .Done.Postgres)}}

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

{{end}}
{{end}} @@ -325,6 +326,20 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } Откуда взять. Если на шаге 3 подключена база DEPO, bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите себя из справочника участников НРД или введите вручную. {{if .Suggest}}{{if .Suggest.OrgName}}{{if not .Done.Org}}

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

{{end}}{{end}}{{end}} {{with .Fancy}}{{template "orgSection" .}}{{end}} +
+

Депозитарные реквизиты в НРД

+

Где в НРД учитываются бумаги вашей организации: место хранения указывается в каждом пакете перевода. Берутся из договора с НРД{{if .Settings.Fancy.Server}} — или кнопкой «Определить из DEPO»{{end}}.

+ {{if .Suggest}}{{if .Suggest.AccountID}}
Найдено в DEPO: счёт {{.Suggest.AccountID}}, код {{.Suggest.DeponentCode}}, раздел {{.Suggest.SectionID}}.{{range .Suggest.Notes}}
⚠ {{.}}{{end}}
{{end}}{{end}} +
+ +
+ + + +
+
{{if .Settings.Fancy.Server}}{{end}}
+
+

Логотип организации (необязательно)

Показывается справа в шапке отчётов для регулятора. Можно загрузить сейчас или позже в «Настройка → Оформление».

@@ -350,7 +365,9 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } Если МНП у вас ещё нет. Обратитесь в ARQA Technologies (arqatech.com, служба поддержки QUIK) и попросите подключить модуль неторговых поручений для работы с внешней системой. После подключения ARQA выдаст адрес сервера МНП и учётные данные — их и нужно ввести ниже.
Если МНП уже есть. Адрес сервера и учётные данные — у вашего администратора QUIK.
{{if not .DepoLicensed}}

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

- {{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}}{{end}} + {{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}} + {{if .Settings.Quik.M2MMissing}}

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

{{end}} +
Справочник депозитариев-получателей. Чтобы QUIK принимал поручения M2M, в МНП должен быть справочник депозитариев-получателей (M2MDepoRecipients): bj заполняет его сам по справочнику участников НРД. Модуль МНП читает справочник только при запуске — после первого заполнения попросите администратора QUIK перезапустить модуль неторговых поручений, иначе QUIK ответит «Неизвестный депозитарий получателя».
{{quikRecipientsInfo}}
{{end}}
← К шагу 4 {{if .Done.Quik}}К шагу 6 →{{else}}Пропустить →{{end}} @@ -358,14 +375,14 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{end}} -{{/* ============= ШАГ 2: Крипто (Валидата) ============= */}} +{{/* ============= ШАГ 6: Валидата ============= */}} {{if eq .Step 6}}

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

-

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

+

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

{{if .ValidataVer}}

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

-

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

+

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

{{else}}

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

@@ -384,7 +401,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{end}} -{{/* ============= ШАГ 3: Сертификаты ============= */}} +{{/* ============= ШАГ 7: Сертификаты ============= */}} {{if eq .Step 7}}

Шаг 7. Сертификаты

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

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

{{else}}

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

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

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

{{else}}

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

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

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

@@ -415,7 +432,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); } {{.SubjectCN}} {{.IssuerCN}} - {{.NotAfter.Format "02.01.2006"}} + {{.NotAfter.Format "02.01.2006"}}{{if .HasPrivateKey}}{{$d := certDaysLeft .NotAfter}}{{if lt $d 0}} истёк{{else if le $d 30}} осталось {{$d}} дн.{{end}}{{end}} {{if .INN}}{{.INN}}{{else}}—{{end}} {{if .HasPrivateKey}}есть{{else}}нет{{end}} @@ -433,61 +450,48 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{end}} -{{/* ============= ШАГ 4: НРД ============= */}} +{{/* ============= ШАГ 8: Шлюз НРД ============= */}} {{if eq .Step 8}}
-

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

-

Адрес web-сервиса ONYX и имя ключевого контейнера НРД.

- +

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

- Что это? Интеграционный шлюз (ИШ) НРД — это компонент, через который наши M2M-сообщения отправляются в НРД. {{if devOn}}У НРД есть 4 контура: GUEST (для разработки) и TEST3 (предпродакшен), каждый в варианте ГОСТ или RSA.{{else}}У НРД есть тестовый контур (TEST3) и боевой контур, каждый в варианте ГОСТ или RSA.{{end}}
- К кому обратиться. Программу ИШ выпускает НРД (дистрибутив и инструкции — nsd.ru/workflow/system/programs). Доступ к контурам НРД (сначала тестовый TEST3, затем боевой) выдаёт НРД по заявке: обратитесь к вашему менеджеру НРД или в клиентскую поддержку по электронному документообороту. НРД сообщит ваш депозитарный код, счёт и раздел — если учёт в DEPO подключён, bj подставит их сам. + Что это. Интеграционный шлюз — программа НРД на этом же сервере: через неё bj отправляет пакеты M2M в НРД и получает ответы. bj работает со шлюзом по его адресу на сервере (обычно http://localhost:8090).
+ К кому обратиться. Дистрибутив ИШ выпускает НРД (nsd.ru/workflow/system/programs). Доступ к контурам — сначала тестовому TEST3, затем боевому — НРД открывает по заявке: обратитесь к своему менеджеру НРД или в поддержку электронного документооборота.
- -
-
- - -
-
- - -
-
- - -
-
-

Депозитарные реквизиты организации в НРД. {{if .Settings.Fancy.Server}}Их можно взять из базы DEPO (Fancy): кнопка «Определить из DEPO» подставит найденное в пустые поля — проверьте и сохраните.{{else}}Если учёт ведётся в Fancy DEPO — сначала подключите базу DEPO на шаге 3, тогда реквизиты подставятся из DEPO автоматически; иначе введите их из договора с НРД.{{end}}

- {{if .Settings.Fancy.Server}}{{end}} - {{if .Suggest}}{{if .Suggest.AccountID}}
Найдено в DEPO: счёт {{.Suggest.AccountID}}, код {{.Suggest.DeponentCode}}, раздел {{.Suggest.SectionID}}{{if .Suggest.OrgName}}; организация {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}{{end}}.{{range .Suggest.Notes}}
⚠ {{.}}{{end}}
{{end}}{{end}} -
-
-
- +

Проверяю шлюз…

+ {{template "igateInstall" .}} + + + {{if .Settings.NSD.IGWv904}}{{end}} + + + + +

По «Сохранить» bj проверит, что шлюз отвечает по этому адресу.

+ +
-

Организация (код участника, ИНН, наименование) — на шаге 4.

+

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

-
← К шагу 7 {{if .Done.NSD}}К шагу 9 →{{else}}Пропустить ({{if devOn}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →{{end}} diff --git a/internal/lkgateway/web/templates/part_accprefix.html b/internal/lkgateway/web/templates/part_accprefix.html new file mode 100644 index 0000000..5ea4a78 --- /dev/null +++ b/internal/lkgateway/web/templates/part_accprefix.html @@ -0,0 +1,35 @@ +{{define "accPrefix"}} +
+
Торговые счета клиентов в DEPO
+

По началу номера счёта в OD_CLIENTS bj находит счёт инвестора: при проверке перевода, в цикле QUIK и при ручном вводе. Если префикс неверный, все входящие переводы получат отказ «нет счёта».

+
+ + + + +
+
+
+ +{{end}} diff --git a/internal/lkgateway/web/templates/part_fancy.html b/internal/lkgateway/web/templates/part_fancy.html index 8963c08..27a85fd 100644 --- a/internal/lkgateway/web/templates/part_fancy.html +++ b/internal/lkgateway/web/templates/part_fancy.html @@ -26,6 +26,7 @@
+ {{if .Settings.Fancy.Server}}{{template "accPrefix" .}}{{end}}
diff --git a/internal/lkgateway/web/templates/part_igate.html b/internal/lkgateway/web/templates/part_igate.html new file mode 100644 index 0000000..a9a67f0 --- /dev/null +++ b/internal/lkgateway/web/templates/part_igate.html @@ -0,0 +1 @@ +{{define "igateInstall"}}{{end}}