d88963bd8d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
260 lines
11 KiB
Go
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
|
|
}
|