diff --git a/REPORT.md b/REPORT.md index 95f2f9a..4271c58 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,11 +1,32 @@ # Bridge-and-Join-s — отчёт о ходе работ -**Дата:** 14.05.2026 (3-я редакция за день — скачан дистрибутив ИШ + полная документация ИШ) +**Дата:** 07.10.2026 (файл в целом отражает состояние на май 2026; ниже — свежий блок, полная ревизия отчёта — отдельной задачей) **Контур:** дев-стенд РЕД ОС 8 (10.10.10.22), bj-server на :8080, lk-emulator на :8083 **Целевая интеграция:** сервис MOEX МОСТ (M2M) через НКО АО НРД --- +## Обновление 07.10.2026: dev-страница «Эмуляторы» + +В режиме разработки (лицензия с `dev_until`) появилась страница `/admin/emulators` +(пункт меню «Эмуляторы», плитки на дашборде) — переключатели и настройки эмуляции: + +- **Эмулятор НРД** — заявки ЛК идут через внутренний имитатор, в НРД ничего не + отправляется (флаг `LK.EmulateNSD`). Настройки (`Settings.NSDEmu`): задержка + решения (0..300 с, дефолт 30), исход (подтвердить / отклонить / не отвечать), + код отказа M2M01..M2M09, M2M18. Применяются в рантайме через + `mock.Sender.Reconfigure` — сендер не пересоздаётся, канал решений тот же. +- **Эмулятор МНП QUIK** — шаги цикла МНП без записи в боевую БД (флаг + `QuikMNP.Emulate`). Настройки (`Settings.MNPEmu`): задержка шага (0..60 с, + ctx-aware) и точечные отказы по шагам (регистрация / подпись / снятие / + зачисление) — шаг завершается как боевой отказ: состояние не продвигается, + повтор на следующем проходе. + +Баннер «Режим эмуляции» теперь честен и при настроенном ИШ: при ручном включении +эмуляторов причина указывается явно. + +--- + ## Готовность по областям | Область | Готовность | Статус | diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 9872696..5da2cab 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -29,6 +29,7 @@ type admin struct { wizard, news, keyWizard, prepare *template.Template handbook *template.Template m2mtest, m2mpreview, depoSim, depoTest *template.Template + emulators *template.Template journal *template.Template } @@ -418,6 +419,10 @@ func newAdmin() (*admin, error) { if err != nil { return nil, fmt.Errorf("parse admin_depo_test: %w", err) } + emulators, err := parse("admin_emulators.html") + if err != nil { + return nil, fmt.Errorf("parse admin_emulators: %w", err) + } journal, err := parse("admin_journal.html") if err != nil { return nil, fmt.Errorf("parse admin_journal: %w", err) @@ -433,6 +438,7 @@ func newAdmin() (*admin, error) { m2mpreview: m2mpreview, depoSim: depoSim, depoTest: depoTest, + emulators: emulators, journal: journal, }, nil } @@ -478,6 +484,10 @@ type homeData struct { // LKUrl — адрес тестового ЛК (для первичного теста интеграции). LKUrl string + + // Состояние эмуляторов (dev-плитки дашборда, страница «Эмуляторы»). + NSDEmuOn bool + QuikEmuOn bool } // claimsData — данные журнала. @@ -603,6 +613,8 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, News: topNews(visibleNews(rc, rc.Snapshot().News.Items), 3), LKUrl: rc.Snapshot().LK.PublicURL, } + data.NSDEmuOn, _ = lkEmulateNSD(rc) + data.QuikEmuOn = rc.Snapshot().Quik.Emulate && devMode(rc) // Готовность — по тем же шагам, что в мастере настройки (с учётом лицензии). for _, st := range setupSteps(rc) { data.TotalCount++ @@ -893,6 +905,16 @@ func nowPage(title, active string) page { if p.DevMode { p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен" } + case s.LK.EmulateNSD && p.DevMode: + // ИШ настроен, но заявки ЛК идут через имитатор (страница «Эмуляторы») — баннер честный. + p.IsMockMode = true + p.MockReason = "Эмулятор НРД включён вручную — заявки из личного кабинета в НРД не отправляются" + } + if s.Quik.Emulate && p.DevMode { + if p.MockReason != "" { + p.MockReason += "; " + } + p.MockReason += "эмулятор МНП QUIK включён — в боевую базу МНП ничего не пишется" } } return p diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 3d24c17..4c753b9 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -955,7 +955,7 @@ func devOnlyPath(p string) bool { // их использует депозитарий для проверки обработки трансферных таблиц на боевой базе (22.09.2026). for _, pre := range []string{"/admin/m2m-testing", "/admin/depo-test", "/admin/setup/test-nsd", "/admin/setup/test-run", "/admin/setup/lk-simulate", "/admin/setup/postgres/quick-start", "/admin/help/robot", "/admin/api/clients", "/admin/api/callback-url", - "/admin/setup/advanced-unlock", "/admin/setup/advanced-lock"} { + "/admin/setup/advanced-unlock", "/admin/setup/advanced-lock", "/admin/emulators"} { if p == pre || strings.HasPrefix(p, pre+"/") { return true } diff --git a/internal/lkgateway/emulators.go b/internal/lkgateway/emulators.go new file mode 100644 index 0000000..6466b8f --- /dev/null +++ b/internal/lkgateway/emulators.go @@ -0,0 +1,161 @@ +package lkgateway + +// emulators.go — dev-страница «Эмуляторы» (/admin/emulators, только режим разработки): +// переключатели и настройки эмуляции НРД (имитатор контрагента для заявок ЛК) и МНП QUIK +// (шаги цикла без записи в боевую БД). Переключатели пишут существующие флаги +// LK.EmulateNSD и QuikMNP.Emulate — чекбоксы в «Настройке» показывают то же состояние. + +import ( + "fmt" + "net/http" + "strconv" + "strings" +) + +// emuRejectOption — вариант кода отказа для эмулятора НРД (value — «07», подпись — с названием). +type emuRejectOption struct{ Code, Label string } + +func emuRejectOptions() []emuRejectOption { + out := make([]emuRejectOption, 0, len(nsdEmuRejectCodes)) + for _, c := range nsdEmuRejectCodes { + out = append(out, emuRejectOption{c, "M2M" + c + " — " + m2mCodeText("M2M"+c)}) + } + return out +} + +// registerEmulators вешает /admin/emulators (GET — страница, POST …/nsd и …/quik — сохранение, ajax). +func registerEmulators(mux *http.ServeMux, a *admin, rc *RuntimeConfig) { + mux.HandleFunc("/admin/emulators", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := rc.Snapshot() + nsdOn, nsdWhy := lkEmulateNSD(rc) + data := struct { + page + NSDEmuOn bool + NSDEmuWhy string + NSDFlag bool + NSD NSDEmuSettings + RejectCodes []emuRejectOption + QuikEmuOn bool + MNP MNPEmuSettings + Flash string + }{ + page: nowPage("Эмуляторы", "emulators"), + NSDEmuOn: nsdOn, + NSDEmuWhy: nsdWhy, + NSDFlag: s.LK.EmulateNSD, + NSD: s.NSDEmu.Defaults(), + RejectCodes: emuRejectOptions(), + QuikEmuOn: s.Quik.Emulate && devMode(rc), + MNP: s.MNPEmu, + Flash: r.URL.Query().Get("flash"), + } + render(w, a.emulators, data) + }) + + mux.HandleFunc("/admin/emulators/nsd", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + lk := rc.Snapshot().LK + lk.EmulateNSD = r.FormValue("emulate") != "" + delay, err := strconv.Atoi(strings.TrimSpace(r.FormValue("delay_sec"))) + if err != nil && strings.TrimSpace(r.FormValue("delay_sec")) != "" { + emuAjaxReply(w, r, http.StatusBadRequest, "Эмулятор НРД: задержка — целое число секунд") + return + } + emu := NSDEmuSettings{ + DelaySec: delay, + Outcome: strings.TrimSpace(r.FormValue("outcome")), + RejectCode: strings.TrimSpace(r.FormValue("reject_code")), + } + if err := rc.UpdateNSDEmu(emu); err != nil { + emuAjaxReply(w, r, http.StatusBadRequest, "Эмулятор НРД: "+err.Error()) + return + } + if err := rc.UpdateLK(lk); err != nil { + emuAjaxReply(w, r, http.StatusInternalServerError, "Эмулятор НРД: не сохранилось: "+err.Error()) + return + } + applyNSDEmuSettings(rc) + msg := "выключен — заявки ЛК идут в НРД по обычному пути" + if on, why := lkEmulateNSD(rc); on { + eff := rc.Snapshot().NSDEmu.Defaults() + msg = fmt.Sprintf("включён (%s) — в НРД ничего не отправляется; решение контрагента: %s через %d сек", + why, emuOutcomeRu(eff.Outcome), eff.DelaySec) + } + emuAjaxReply(w, r, http.StatusOK, "Эмулятор НРД: "+msg) + }) + + mux.HandleFunc("/admin/emulators/quik", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + q := rc.Snapshot().Quik + q.Emulate = r.FormValue("emulate") != "" + delay, err := strconv.Atoi(strings.TrimSpace(r.FormValue("step_delay_sec"))) + if err != nil && strings.TrimSpace(r.FormValue("step_delay_sec")) != "" { + emuAjaxReply(w, r, http.StatusBadRequest, "Эмулятор МНП: задержка — целое число секунд") + return + } + emu := MNPEmuSettings{ + StepDelaySec: delay, + FailRegister: r.FormValue("fail_register") != "", + FailSign: r.FormValue("fail_sign") != "", + FailDeny: r.FormValue("fail_deny") != "", + FailDeposit: r.FormValue("fail_deposit") != "", + } + if err := rc.UpdateMNPEmu(emu); err != nil { + emuAjaxReply(w, r, http.StatusBadRequest, "Эмулятор МНП: "+err.Error()) + return + } + if err := rc.UpdateQuik(q); err != nil { + emuAjaxReply(w, r, http.StatusInternalServerError, "Эмулятор МНП: не сохранилось: "+err.Error()) + return + } + msg := "выключен — шаги цикла МНП выполняются в QUIK по-настоящему" + if q.Emulate && devMode(rc) { + msg = "включён — шаги цикла (блокировка, подпись, снятие, зачисление) проходят без записи в МНП" + var fails []string + for _, f := range []struct { + on bool + name string + }{{emu.FailRegister, "регистрация"}, {emu.FailSign, "подпись"}, {emu.FailDeny, "снятие"}, {emu.FailDeposit, "зачисление"}} { + if f.on { + fails = append(fails, f.name) + } + } + if len(fails) > 0 { + msg += "; отказ на шагах: " + strings.Join(fails, ", ") + } + } + emuAjaxReply(w, r, http.StatusOK, "Эмулятор МНП: "+msg) + }) +} + +// emuAjaxReply — ответ сохранения: ajax (X-BJ-Ajax) — строкой с кодом, без JS — редирект с flash. +func emuAjaxReply(w http.ResponseWriter, r *http.Request, code int, msg string) { + if r.Header.Get("X-BJ-Ajax") != "" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(code) + _, _ = w.Write([]byte(msg)) + return + } + setupFlash(w, r, msg) +} + +// emuOutcomeRu — исход эмулятора НРД по-русски. +func emuOutcomeRu(outcome string) string { + switch outcome { + case "reject": + return "отказ" + case "timeout": + return "не отвечает (таймаут)" + } + return "подтверждение" +} diff --git a/internal/lkgateway/emulators_test.go b/internal/lkgateway/emulators_test.go new file mode 100644 index 0000000..691714f --- /dev/null +++ b/internal/lkgateway/emulators_test.go @@ -0,0 +1,208 @@ +package lkgateway + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" +) + +// Настройки эмуляторов: валидация и обратная совместимость (старый setup.json без полей = дефолты). +func TestEmulatorSettingsValidation(t *testing.T) { + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + for _, s := range []NSDEmuSettings{ + {DelaySec: -1}, {DelaySec: 301}, + {Outcome: "explode"}, {RejectCode: "99"}, {RejectCode: "M2M07"}, + } { + if err := rc.UpdateNSDEmu(s); err == nil { + t.Errorf("UpdateNSDEmu(%+v): ожидалась ошибка", s) + } + } + if err := rc.UpdateNSDEmu(NSDEmuSettings{DelaySec: 45, Outcome: "reject", RejectCode: "18"}); err != nil { + t.Fatal(err) + } + for _, s := range []MNPEmuSettings{{StepDelaySec: -1}, {StepDelaySec: 61}} { + if err := rc.UpdateMNPEmu(s); err == nil { + t.Errorf("UpdateMNPEmu(%+v): ожидалась ошибка", s) + } + } + if err := rc.UpdateMNPEmu(MNPEmuSettings{StepDelaySec: 5, FailRegister: true}); err != nil { + t.Fatal(err) + } + // Перечитываем файл: настройки переживают перезапуск. + rc2, err := NewRuntimeConfig(rc.path) + if err != nil { + t.Fatal(err) + } + s := rc2.Snapshot() + if s.NSDEmu.DelaySec != 45 || s.NSDEmu.Outcome != "reject" || s.NSDEmu.RejectCode != "18" { + t.Errorf("NSDEmu после перезагрузки: %+v", s.NSDEmu) + } + if !s.MNPEmu.FailRegister || s.MNPEmu.StepDelaySec != 5 { + t.Errorf("MNPEmu после перезагрузки: %+v", s.MNPEmu) + } + // Дефолты для нулевых полей (старый setup.json). + d := NSDEmuSettings{}.Defaults() + if d.DelaySec != 30 || d.Outcome != "confirm" || d.RejectCode != "07" { + t.Errorf("дефолты NSDEmu: %+v", d) + } +} + +// Страница «Эмуляторы» — только в режиме разработки: вне него 404 (гейт в authMiddleware). +func TestEmulatorsDevGate(t *testing.T) { + for _, p := range []string{"/admin/emulators", "/admin/emulators/nsd", "/admin/emulators/quik"} { + if !devOnlyPath(p) || requiredPerm(p, http.MethodGet) != "dev" { + t.Errorf("%s должен быть служебным", p) + } + } + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + registerEmulators(mux, nil, rc) + h := authMiddleware(rc, mux) + for _, m := range []string{http.MethodGet, http.MethodPost} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(m, "/admin/emulators", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("%s вне режима разработки: %d, ожидали 404", m, rec.Code) + } + } +} + +// Сохранение со страницы «Эмуляторы»: ajax-POST пишет настройки и флаги. +func TestEmulatorsSave(t *testing.T) { + devModeForced = true + defer func() { devModeForced = false }() + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + a, err := newAdmin() + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + registerEmulators(mux, a, rc) + + post := func(path string, form url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-BJ-Ajax", "1") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec + } + + rec := post("/admin/emulators/nsd", url.Values{ + "emulate": {"1"}, "delay_sec": {"45"}, "outcome": {"reject"}, "reject_code": {"18"}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("nsd: %d %s", rec.Code, rec.Body.String()) + } + s := rc.Snapshot() + if !s.LK.EmulateNSD || s.NSDEmu.DelaySec != 45 || s.NSDEmu.Outcome != "reject" || s.NSDEmu.RejectCode != "18" { + t.Errorf("после сохранения НРД: emulate=%v emu=%+v", s.LK.EmulateNSD, s.NSDEmu) + } + + rec = post("/admin/emulators/quik", url.Values{ + "emulate": {"1"}, "step_delay_sec": {"7"}, "fail_register": {"1"}, "fail_deposit": {"1"}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("quik: %d %s", rec.Code, rec.Body.String()) + } + s = rc.Snapshot() + if !s.Quik.Emulate || s.MNPEmu.StepDelaySec != 7 || !s.MNPEmu.FailRegister || !s.MNPEmu.FailDeposit || s.MNPEmu.FailSign { + t.Errorf("после сохранения МНП: emulate=%v emu=%+v", s.Quik.Emulate, s.MNPEmu) + } + + // Валидация на хендлере: плохой код отказа — 400, настройки не тронуты. + rec = post("/admin/emulators/nsd", url.Values{"delay_sec": {"10"}, "outcome": {"reject"}, "reject_code": {"42"}}) + if rec.Code != http.StatusBadRequest { + t.Errorf("плохой код отказа: %d, ожидали 400", rec.Code) + } + rec = post("/admin/emulators/quik", url.Values{"step_delay_sec": {"600"}}) + if rec.Code != http.StatusBadRequest { + t.Errorf("задержка 600: %d, ожидали 400", rec.Code) + } + + // Страница собирается и показывает обе карточки. + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/emulators", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET: %d", rec.Code) + } + for _, want := range []string{"Эмулятор НРД", "Эмулятор МНП QUIK", "в НРД ничего не отправляется", "M2M18"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("на странице нет %q", want) + } + } +} + +// Эмулированный отказ МНП: шаг завершается как боевой отказ — QuikStep OK=false, Err заполнена, +// состояние связки не продвигается; повторный вызов без отказа проходит. +func TestMNPEmuFailRegister(t *testing.T) { + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + q := rc.Snapshot().Quik + q.Emulate = true + if err := rc.UpdateQuik(q); err != nil { + t.Fatal(err) + } + if err := rc.UpdateMNPEmu(MNPEmuSettings{FailRegister: true}); err != nil { + t.Fatal(err) + } + l, err := QuikOutRegister(context.Background(), rc, "emu-fail-test-guid", "CL1", "", "RU0009029540", 10, "7702070139", "", false) + if err == nil { + t.Fatal("ожидался отказ регистрации") + } + if l == nil || l.State != "error" || !strings.Contains(l.Err, "имитация отказа") { + t.Fatalf("связка после отказа: %+v", l) + } + if st := l.LastStep(); st == nil || st.OK || st.Action != "register" { + t.Fatalf("шаг: %+v", st) + } + // Сняли отказ — повтор (как следующий проход цикла) проходит. + if err := rc.UpdateMNPEmu(MNPEmuSettings{}); err != nil { + t.Fatal(err) + } + l, err = QuikOutRegister(context.Background(), rc, "emu-fail-test-guid", "CL1", "", "RU0009029540", 10, "7702070139", "", false) + if err != nil || l.State != "registered" { + t.Fatalf("повтор без отказа: %v, %+v", err, l) + } +} + +// Задержка шага эмулятора МНП учитывает контекст: отмена/таймаут прерывают ожидание. +func TestMNPEmuStepDelayCtx(t *testing.T) { + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + q := rc.Snapshot().Quik + q.Emulate = true + if err := rc.UpdateQuik(q); err != nil { + t.Fatal(err) + } + if err := rc.UpdateMNPEmu(MNPEmuSettings{StepDelaySec: 60}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + start := time.Now() + if _, err := QuikOutRegister(ctx, rc, "emu-delay-test-guid", "CL1", "", "RU0009029540", 5, "", "", false); err == nil { + t.Fatal("ожидался обрыв по контексту") + } + if time.Since(start) > 5*time.Second { + t.Fatal("задержка не прервалась по контексту") + } +} diff --git a/internal/lkgateway/licenseclient_test.go b/internal/lkgateway/licenseclient_test.go index 044ab0b..4020edd 100644 --- a/internal/lkgateway/licenseclient_test.go +++ b/internal/lkgateway/licenseclient_test.go @@ -24,7 +24,13 @@ func testLicenseKey(t *testing.T) ed25519.PrivateKey { } prev := DefaultLicensePublicKey DefaultLicensePublicKey = base64.StdEncoding.EncodeToString(pub) - t.Cleanup(func() { DefaultLicensePublicKey = prev }) + t.Cleanup(func() { + // Ждём возможный фоновый enforceModuleLicense (он читает ключ в licenseStatus), + // иначе восстановление ключа гоняется с ним под -race. + moduleEnforceMu.Lock() + defer moduleEnforceMu.Unlock() + DefaultLicensePublicKey = prev + }) return priv } diff --git a/internal/lkgateway/lkemulate.go b/internal/lkgateway/lkemulate.go index 0f2b582..e4071e4 100644 --- a/internal/lkgateway/lkemulate.go +++ b/internal/lkgateway/lkemulate.go @@ -130,7 +130,7 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep if devMode(rc) { sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД" } - sentDetail := lkQuikSummary(guid) + fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay) + sentDetail := lkQuikSummary(guid) + fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelayNow(rc)) if realNSD { sentTitle, sentDetail = "Бумаги заблокированы, запрос перевода отправлен в НРД (TEST3)", lkQuikSummary(guid)+" Ждём решения контрагента от НРД." } @@ -142,7 +142,7 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, отправлено в НРД TEST3 с подменой инвестора", id, shortGUID(guid)) return "sent", nil } - log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, заявка передана имитатору НРД — решение контрагента через %s", id, shortGUID(guid), lkEmuDecisionDelay) + log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, заявка передана имитатору НРД — решение контрагента через %s", id, shortGUID(guid), lkEmuDecisionDelayNow(rc)) return "sent", nil } @@ -328,3 +328,29 @@ func newLKEmuSender() *mock.Sender { cfg.NSDSenderCode = "MC0010300000" return mock.NewSender(cfg) } + +// applyNSDEmuSettings переносит настройки эмулятора НРД (страница «Эмуляторы») +// в конфиг работающего сендера — без пересоздания: канал Decisions() привязан +// к экземпляру. Вызывается при старте и после сохранения настроек. +func applyNSDEmuSettings(rc *RuntimeConfig) { + if lkEmuSender == nil { + return + } + s := rc.Snapshot().NSDEmu.Defaults() + cfg := mock.DefaultConfig() + cfg.NSDSenderCode = "MC0010300000" + cfg.DecisionDelay = time.Duration(s.DelaySec) * time.Second + switch s.Outcome { + case "reject": + cfg.DefaultOutcome = mock.OutcomeReject + case "timeout": + cfg.DefaultOutcome = mock.OutcomeTimeout + } + cfg.RejectionCode = s.RejectCode + lkEmuSender.Reconfigure(cfg) +} + +// lkEmuDecisionDelayNow — текущая задержка решения имитатора (для текстов в ЛК). +func lkEmuDecisionDelayNow(rc *RuntimeConfig) time.Duration { + return time.Duration(rc.Snapshot().NSDEmu.Defaults().DelaySec) * time.Second +} diff --git a/internal/lkgateway/lkemulate_test.go b/internal/lkgateway/lkemulate_test.go index 8fb899a..e75bb05 100644 --- a/internal/lkgateway/lkemulate_test.go +++ b/internal/lkgateway/lkemulate_test.go @@ -1,9 +1,11 @@ package lkgateway import ( + "context" "path/filepath" "strings" "testing" + "time" ) func TestLKDepoRejectText(t *testing.T) { @@ -64,3 +66,34 @@ func TestLKEmulateNSD(t *testing.T) { t.Errorf("флаг в настройках: %v %q", on, why) } } + +// applyNSDEmuSettings переносит настройки со страницы «Эмуляторы» в конфиг работающего +// сендера: исход и код отказа меняются без пересоздания (канал решений тот же). +func TestApplyNSDEmuSettings(t *testing.T) { + old := lkEmuSender + lkEmuSender = newLKEmuSender() + defer func() { lkEmuSender.Stop(); lkEmuSender = old }() + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + if err := rc.UpdateNSDEmu(NSDEmuSettings{DelaySec: 1, Outcome: "reject", RejectCode: "18"}); err != nil { + t.Fatal(err) + } + applyNSDEmuSettings(rc) + if _, err := lkEmuSender.Send(context.Background(), sendFailRequest()); err != nil { + t.Fatal(err) + } + select { + case d := <-lkEmuSender.Decisions(): + rej := d.Data.Securities[0].TransferDecision.Rejection + if rej == nil { + t.Fatal("ожидался отказ (outcome=reject)") + } + if len(rej.Codes) != 1 || rej.Codes[0] != "18" { + t.Errorf("код отказа %v, ожидался 18", rej.Codes) + } + case <-time.After(3 * time.Second): + t.Fatal("решение не пришло — задержка из настроек не применилась") + } +} diff --git a/internal/lkgateway/quikcycle.go b/internal/lkgateway/quikcycle.go index 9b1863d..cb596eb 100644 --- a/internal/lkgateway/quikcycle.go +++ b/internal/lkgateway/quikcycle.go @@ -474,7 +474,28 @@ func quikActive(s QuikMNPSettings) bool { // quikEmulated — шаг цикла в режиме имитации: связка переходит в состояние state, в МНП ничего не // пишется (нет пользователя QUIK для блокировок и лимитов); события в DEPO — как при реальной работе. -func quikEmulated(rc *RuntimeConfig, guid, isin, action, state, what string) *QuikLink { +// Настройки MNPEmu (страница «Эмуляторы»): StepDelaySec — пауза перед шагом (повторяет ожидание ответа +// МНП), Fail<Шаг> — шаг завершается отказом, как боевой: состояние не продвигается, повтор на следующем проходе. +func quikEmulated(ctx context.Context, rc *RuntimeConfig, guid, isin, action, state, what string) (*QuikLink, error) { + emu := rc.Snapshot().MNPEmu + if emu.StepDelaySec > 0 { + d := time.Duration(emu.StepDelaySec) * time.Second + if d > 60*time.Second { + d = 60 * time.Second + } + select { + case <-ctx.Done(): + return globalQuikLinks.Get(guid, isin), ctx.Err() + case <-time.After(d): + } + } + if emu.FailFor(action) { + err := fmt.Errorf("имитация отказа МНП: %s (настройки эмулятора, страница «Эмуляторы»)", what) + globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: action, Reply: err.Error(), OK: false}) + }) + return quikFail(rc, guid, isin, action, err), err + } l := globalQuikLinks.update(guid, isin, func(l *QuikLink) { if l.InstrNo == "" { l.InstrNo = "ИМИТ-" + shortGUID(guid) @@ -484,7 +505,7 @@ func quikEmulated(rc *RuntimeConfig, guid, isin, action, state, what string) *Qu }) log.Printf("quik: %s %s (имитация, в МНП не записано): %s × %v", shortGUID(guid), what, isin, l.Qty) go depoOnQuik(rc, guid, isin, action, l.InstrNo) - return l + return l, nil } func quikStep(action string, aid int64, r QuikActionResult, ok bool) QuikStep { @@ -504,7 +525,7 @@ func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, con l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty }) if s.Emulate { - return quikEmulated(rc, guid, isin, "register", "registered", "вывод зарегистрирован, бумаги заблокированы"), nil + return quikEmulated(ctx, rc, guid, isin, "register", "registered", "вывод зарегистрирован, бумаги заблокированы") } if strings.TrimSpace(recipientName) == "" { recipientName = m2mPartyName(recipientINN) // наименование из справочника НРД @@ -558,7 +579,7 @@ func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment str return l, nil } if s.Emulate { - return quikEmulated(rc, guid, isin, "sign", "signed", "поручение подписано, бумаги списаны"), nil + return quikEmulated(ctx, rc, guid, isin, "sign", "signed", "поручение подписано, бумаги списаны") } var no float64 fmt.Sscan(l.InstrNo, &no) @@ -592,7 +613,7 @@ func QuikOutDeny(ctx context.Context, rc *RuntimeConfig, guid, isin, reason stri return l, nil } if s.Emulate { - return quikEmulated(rc, guid, isin, "deny", "denied", "поручение снято ("+reason+")"), nil + return quikEmulated(ctx, rc, guid, isin, "deny", "denied", "поручение снято ("+reason+")") } var no float64 fmt.Sscan(l.InstrNo, &no) @@ -625,7 +646,7 @@ func QuikInDeposit(ctx context.Context, rc *RuntimeConfig, guid, clientID, contr l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "in", clientID, contract, isin, qty }) if s.Emulate { - return quikEmulated(rc, guid, isin, "deposit", "deposited", "ввод зарегистрирован, бумаги зачислены"), nil + return quikEmulated(ctx, rc, guid, isin, "deposit", "deposited", "ввод зарегистрирован, бумаги зачислены") } if strings.TrimSpace(senderINN) != "" { _ = quikEnsureRecipient(ctx, s, senderINN, senderName) diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 733f1ad..6e1b86a 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -45,6 +45,8 @@ type Settings struct { Time TimeSettings `json:"time,omitempty"` WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06 OneC OneCSettings `json:"onec,omitempty"` // база 1С бэк-офиса (модуль onec-back) + NSDEmu NSDEmuSettings `json:"nsd_emu,omitempty"` // эмулятор НРД (страница «Эмуляторы», только режим разработки) + MNPEmu MNPEmuSettings `json:"mnp_emu,omitempty"` // эмулятор МНП QUIK (страница «Эмуляторы», только режим разработки) LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -345,6 +347,90 @@ type LKEndpoint struct { LastAt time.Time `json:"last_at"` // время последней проверки } +// NSDEmuSettings — настройки эмулятора НРД (действуют при LK.EmulateNSD в режиме +// разработки, страница «Эмуляторы»): каким решением и через сколько отвечает +// имитатор контрагента. Нулевые поля (старый setup.json) = дефолты. +type NSDEmuSettings struct { + DelaySec int `json:"delay_sec,omitempty"` // задержка решения контрагента, сек (0..300; 0 = дефолт 30) + Outcome string `json:"outcome,omitempty"` // confirm | reject | timeout (пусто = confirm) + RejectCode string `json:"reject_code,omitempty"` // код отказа M2Mxx для outcome=reject (пусто = 07) +} + +// Defaults подставляет дефолты вместо нулевых полей (обратная совместимость). +func (s NSDEmuSettings) Defaults() NSDEmuSettings { + if s.DelaySec <= 0 { + s.DelaySec = int(lkEmuDecisionDelay / time.Second) + } + if s.Outcome == "" { + s.Outcome = "confirm" + } + if s.RejectCode == "" { + s.RejectCode = "07" + } + return s +} + +// MNPEmuSettings — настройки эмулятора МНП QUIK (действуют при QuikMNP.Emulate): +// задержка каждого шага цикла и точечные отказы по шагам — для отработки +// сценариев «МНП отказал» без боевой базы. +type MNPEmuSettings struct { + StepDelaySec int `json:"step_delay_sec,omitempty"` // задержка шага, сек (0..60; 0 = без задержки) + FailRegister bool `json:"fail_register,omitempty"` // регистрация завершается отказом + FailSign bool `json:"fail_sign,omitempty"` // подпись (исполнение) завершается отказом + FailDeny bool `json:"fail_deny,omitempty"` // снятие поручения завершается отказом + FailDeposit bool `json:"fail_deposit,omitempty"` // зачисление (входящие) завершается отказом +} + +// FailFor — для шага настроен отказ (все шаги успешны, если галочки сняты). +func (s MNPEmuSettings) FailFor(action string) bool { + switch action { + case "register": + return s.FailRegister + case "sign": + return s.FailSign + case "deny": + return s.FailDeny + case "deposit": + return s.FailDeposit + } + return false +} + +// nsdEmuRejectCodes — допустимые коды отказа принимающей стороны (M2M). +var nsdEmuRejectCodes = []string{"01", "02", "03", "04", "05", "06", "07", "08", "09", "18"} + +// UpdateNSDEmu сохраняет настройки эмулятора НРД (с валидацией). +func (r *RuntimeConfig) UpdateNSDEmu(s NSDEmuSettings) error { + if s.DelaySec < 0 || s.DelaySec > 300 { + return errors.New("задержка решения — от 0 до 300 секунд") + } + switch s.Outcome { + case "", "confirm", "reject", "timeout": + default: + return errors.New("исход — confirm, reject или timeout") + } + if s.RejectCode != "" && !containsStr(nsdEmuRejectCodes, s.RejectCode) { + return errors.New("код отказа — M2M01..M2M09 или M2M18") + } + r.mu.Lock() + r.data.NSDEmu = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// UpdateMNPEmu сохраняет настройки эмулятора МНП QUIK (с валидацией). +func (r *RuntimeConfig) UpdateMNPEmu(s MNPEmuSettings) error { + if s.StepDelaySec < 0 || s.StepDelaySec > 60 { + return errors.New("задержка шага — от 0 до 60 секунд") + } + r.mu.Lock() + r.data.MNPEmu = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + // TestRunResult — результат последнего тестового прогона. type TestRunResult struct { StartedAt time.Time `json:"started_at"` diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 7341e43..408c3b2 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -190,6 +190,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerIncoming(mux, adminTpl, inDeps) globalOutgoingStore = NewOutgoingStore() registerDepoSim(mux, adminTpl) + registerEmulators(mux, adminTpl, rc) // dev-страница «Эмуляторы»: переключатели и настройки эмуляции НРД и МНП QUIK // корень сайта: http://сервер:8080/ → рабочая страница (до входа authMiddleware отправит на вход, // до первичной настройки — на /first-run); прочие неизвестные адреса — 404. mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -316,6 +317,7 @@ func (s *Server) Run(ctx context.Context) error { // Имитатор НРД для заявок ЛК в тестовом режиме (lkemulate.go): решения контрагента // применяются тем же обработчиком, что настоящие ответы из ИШ. lkEmuSender = newLKEmuSender() + applyNSDEmuSettings(s.rc) // настройки эмулятора НРД со страницы «Эмуляторы» go superviseLoop(ctx, "lk-emulator", s.rc, s.consumeEmulatedDecisions) // Отказы шлюза по заявкам ЛК: ждём ответ Fancy (DEPO) и только тогда ставим «Отклонена» в ЛК. go superviseLoop(ctx, "lk-flow", s.rc, s.lkFlowLoop) diff --git a/internal/lkgateway/web/templates/admin_emulators.html b/internal/lkgateway/web/templates/admin_emulators.html new file mode 100644 index 0000000..c8bdf7d --- /dev/null +++ b/internal/lkgateway/web/templates/admin_emulators.html @@ -0,0 +1,90 @@ +{{define "content"}} +{{if .Flash}}
Служебные переключатели режима разработки: оба эмулятора работают только пока действует режим разработки. + Эти же флаги доступны в «Настройке» (ЛК — «Имитация НРД», QUIK — «имитация») — состояние общее.
+Заявки из личного кабинета и ручного ввода проходят весь путь (сверка с DEPO, блокировка в QUIK, сделка, статусы), + но в НРД ничего не отправляется: ответ контрагента формирует внутренний имитатор.
+ {{if and .NSDEmuOn (ne .NSDEmuWhy "включена в настройках")}} +Сейчас имитация действует принудительно: {{.NSDEmuWhy}} — переключатель её не выключает.
+ {{end}} + +Шаги цикла M2M в Модуле неторговых поручений (регистрация вывода и блокировка бумаг, подпись, снятие поручения, + зачисление входящих) проходят и отмечаются «имитация», но в боевую базу МНП ничего не записывается. + Имитация имеет приоритет над «записями в МНП».
+ +