Files
Bridge-and-Join-s/internal/lkgateway/chaintest.go
T

260 lines
11 KiB
Go

package lkgateway
// «Проверить всю цепочку» — сквозная проверка без настоящего перевода (05.10.2026). Каждое звено — только
// если оно есть в лицензии и настроено:
// - учёт DEPO: проверочное событие BJ_PING обработано процедурами обмена;
// - QUIK МНП: объекты, инструменты, справочник получателей (только чтение);
// - QUIK МНП, по желанию: пробная блокировка 1 шт. бумаги клиента («Вывод ЦБ (M2M)») и сразу снятие;
// - НРД: запрос справочника участников уходит через шлюз — шлюз подписывает его ключом организации (ИШ —
// единственный владелец ключа для НРД), НРД принимает и отвечает, ответ приходит во входящие.
// Звенья идут по очереди; упавшее не останавливает остальные — видно всю картину.
import (
"context"
"encoding/json"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// 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: "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 "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
}