feat(admin): dev-страница «Эмуляторы» — переключатели и настройки эмуляции НРД и МНП QUIK
В режиме разработки новая страница /admin/emulators с двумя клавишами: - «Эмулятор НРД» (флаг LK.EmulateNSD): заявки ЛК и ручного ввода идут через внутренний имитатор, в НРД ничего не отправляется. Настройки: задержка решения (0–300 с), исход (подтвердить/отклонить/не отвечать), код отказа M2M01–M2M09/18. Сендер перенастраивается в рантайме через потокобезопасный mock.Sender.Reconfigure — без перезапуска. - «Эмулятор МНП QUIK» (флаг QuikMNP.Emulate): шаги цикла без записи в боевую БД МНП. Настройки: задержка шага (0–60 с), точечные отказы по шагам (регистрация/подпись/отказ/зачисление) — эмулированный отказ ведёт себя как боевой: шаг OK=false, состояние не продвигается, повтор на следующем проходе. Настройки хранятся в setup.json (nsd_emu/mnp_emu), обратная совместимость — дефолты. Баннер «Режим эмуляции» показывается и при ручном включении эмуляторов (раньше зависел только от отсутствия ИШ). Пункт меню «Эмуляторы» и две плитки на дашборде — только в dev. Пути занесены в devOnlyPath. Попутно: исправлен race в TestLicenseActivateAndRenew (cleanup восстанавливал DefaultLicensePublicKey без moduleEnforceMu — падал под -race и на чистом HEAD).
This commit is contained in:
@@ -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) и точечные отказы по шагам (регистрация / подпись / снятие /
|
||||
зачисление) — шаг завершается как боевой отказ: состояние не продвигается,
|
||||
повтор на следующем проходе.
|
||||
|
||||
Баннер «Режим эмуляции» теперь честен и при настроенном ИШ: при ручном включении
|
||||
эмуляторов причина указывается явно.
|
||||
|
||||
---
|
||||
|
||||
## Готовность по областям
|
||||
|
||||
| Область | Готовность | Статус |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 "подтверждение"
|
||||
}
|
||||
@@ -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("задержка не прервалась по контексту")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("решение не пришло — задержка из настроек не применилась")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{{define "content"}}
|
||||
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Эмуляторы</h2>
|
||||
<p class="muted" style="margin:0">Служебные переключатели режима разработки: оба эмулятора работают только пока действует режим разработки.
|
||||
Эти же флаги доступны в «Настройке» (ЛК — «Имитация НРД», QUIK — «имитация») — состояние общее.</p>
|
||||
</div>
|
||||
|
||||
{{/* ===== Эмулятор НРД ===== */}}
|
||||
<div class="card" id="emu-nsd-card">
|
||||
<h2><span class="dot {{if .NSDEmuOn}}warn{{else}}ok{{end}}"></span>Эмулятор НРД
|
||||
{{if .NSDEmuOn}}<span class="hero-status warn">включён</span>{{end}}</h2>
|
||||
<p class="muted">Заявки из личного кабинета и ручного ввода проходят весь путь (сверка с DEPO, блокировка в QUIK, сделка, статусы),
|
||||
но <b>в НРД ничего не отправляется</b>: ответ контрагента формирует внутренний имитатор.</p>
|
||||
{{if and .NSDEmuOn (ne .NSDEmuWhy "включена в настройках")}}
|
||||
<p class="dq-warn">Сейчас имитация действует принудительно: {{.NSDEmuWhy}} — переключатель её не выключает.</p>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/emulators/nsd" id="emu-nsd-form" class="dq">
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600">
|
||||
<input type="checkbox" name="emulate" value="1" {{if .NSDFlag}}checked{{end}}>
|
||||
Эмулировать НРД (заявки в настоящий НРД не уходят)</label>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-top:10px">
|
||||
<label>Задержка решения, сек <span class="muted">(0..300)</span>
|
||||
<input type="number" name="delay_sec" min="0" max="300" value="{{.NSD.DelaySec}}" style="width:100%"></label>
|
||||
<label>Исход по заявке
|
||||
<select name="outcome" style="width:100%">
|
||||
<option value="confirm" {{if eq .NSD.Outcome "confirm"}}selected{{end}}>Подтвердить перевод</option>
|
||||
<option value="reject" {{if eq .NSD.Outcome "reject"}}selected{{end}}>Отклонить (код ниже)</option>
|
||||
<option value="timeout" {{if eq .NSD.Outcome "timeout"}}selected{{end}}>Не отвечать (таймаут)</option>
|
||||
</select></label>
|
||||
<label id="emu-nsd-code">Код отказа
|
||||
<select name="reject_code" style="width:100%">
|
||||
{{range .RejectCodes}}<option value="{{.Code}}" {{if eq $.NSD.RejectCode .Code}}selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></label>
|
||||
</div>
|
||||
<p class="dq-note" id="emu-nsd-st" aria-live="polite">Изменения сохраняются сразу.</p>
|
||||
<noscript><button type="submit" class="btn">Сохранить</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
<script>(function(){
|
||||
var f=document.getElementById('emu-nsd-form'), st=document.getElementById('emu-nsd-st'), code=document.getElementById('emu-nsd-code');
|
||||
function sync(){ code.style.opacity=(f.outcome.value==='reject')?'1':'.45'; }
|
||||
f.addEventListener('change', function(){ sync();
|
||||
fetch(f.action,{method:'POST',body:new URLSearchParams(new FormData(f)),headers:{'Content-Type':'application/x-www-form-urlencoded','X-BJ-Ajax':'1'},cache:'no-store'})
|
||||
.then(function(r){ return r.text().then(function(t){ st.textContent=(r.ok?'✓ Сохранено: ':'✗ ')+t; }); })
|
||||
.catch(function(){ st.textContent='✗ Не сохранилось — обновите страницу и выберите ещё раз.'; });
|
||||
});
|
||||
f.addEventListener('submit', function(e){ if(window.fetch) e.preventDefault(); });
|
||||
sync();
|
||||
})();</script>
|
||||
|
||||
{{/* ===== Эмулятор МНП QUIK ===== */}}
|
||||
<div class="card" id="emu-quik-card">
|
||||
<h2><span class="dot {{if .QuikEmuOn}}warn{{else}}ok{{end}}"></span>Эмулятор МНП QUIK
|
||||
{{if .QuikEmuOn}}<span class="hero-status warn">включён</span>{{end}}</h2>
|
||||
<p class="muted">Шаги цикла M2M в Модуле неторговых поручений (регистрация вывода и блокировка бумаг, подпись, снятие поручения,
|
||||
зачисление входящих) проходят и отмечаются «имитация», но <b>в боевую базу МНП ничего не записывается</b>.
|
||||
Имитация имеет приоритет над «записями в МНП».</p>
|
||||
<form method="post" action="/admin/emulators/quik" id="emu-quik-form" class="dq">
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600">
|
||||
<input type="checkbox" name="emulate" value="1" {{if .QuikEmuOn}}checked{{end}}>
|
||||
Эмулировать МНП (в QUIK ничего не пишется)</label>
|
||||
<div style="margin-top:10px;max-width:280px">
|
||||
<label>Задержка шага, сек <span class="muted">(0..60, повторяет ожидание ответа МНП)</span>
|
||||
<input type="number" name="step_delay_sec" min="0" max="60" value="{{.MNP.StepDelaySec}}" style="width:100%"></label>
|
||||
</div>
|
||||
<fieldset style="border:1px solid var(--border);border-radius:8px;margin-top:10px;padding:8px 12px">
|
||||
<legend class="muted" style="font-size:12px">Имитировать отказ МНП на шаге (состояние не продвигается, повтор — как при боевом отказе)</legend>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:6px">
|
||||
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="fail_register" value="1" {{if .MNP.FailRegister}}checked{{end}}> Регистрация (вывод)</label>
|
||||
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="fail_sign" value="1" {{if .MNP.FailSign}}checked{{end}}> Подпись (исполнение)</label>
|
||||
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="fail_deny" value="1" {{if .MNP.FailDeny}}checked{{end}}> Снятие поручения</label>
|
||||
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="fail_deposit" value="1" {{if .MNP.FailDeposit}}checked{{end}}> Зачисление (ввод)</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p class="dq-note" id="emu-quik-st" aria-live="polite">Изменения сохраняются сразу.</p>
|
||||
<noscript><button type="submit" class="btn">Сохранить</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
<script>(function(){
|
||||
var f=document.getElementById('emu-quik-form'), st=document.getElementById('emu-quik-st');
|
||||
f.addEventListener('change', function(){
|
||||
fetch(f.action,{method:'POST',body:new URLSearchParams(new FormData(f)),headers:{'Content-Type':'application/x-www-form-urlencoded','X-BJ-Ajax':'1'},cache:'no-store'})
|
||||
.then(function(r){ return r.text().then(function(t){ st.textContent=(r.ok?'✓ Сохранено: ':'✗ ')+t; }); })
|
||||
.catch(function(){ st.textContent='✗ Не сохранилось — обновите страницу и выберите ещё раз.'; });
|
||||
});
|
||||
f.addEventListener('submit', function(e){ if(window.fetch) e.preventDefault(); });
|
||||
})();</script>
|
||||
{{end}}
|
||||
@@ -57,6 +57,23 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{if and devOn (can "settings")}}{{/* ===== Режим разработки: эмуляторы ===== */}}
|
||||
<div class="tiles">
|
||||
<a href="/admin/emulators" class="tile">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/><path d="M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1"/></svg></span>
|
||||
<span class="t-title">Эмулятор НРД</span>
|
||||
<span class="t-sub">{{if .NSDEmuOn}}ВКЛЮЧЁН — в НРД ничего не отправляется{{else}}выключен{{end}}</span>
|
||||
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</a>
|
||||
<a href="/admin/emulators" class="tile">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M7 9h4M7 13h7"/></svg></span>
|
||||
<span class="t-title">Эмулятор МНП QUIK</span>
|
||||
<span class="t-sub">{{if .QuikEmuOn}}ВКЛЮЧЁН — в МНП ничего не пишется{{else}}выключен{{end}}</span>
|
||||
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ===== Сводка по переводам ===== */}}
|
||||
<div class="grid">
|
||||
<div class="stat accent">
|
||||
|
||||
@@ -554,7 +554,7 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd
|
||||
<a href="/admin/incoming">Входящие<span id="nav-incoming-badge" style="display:none;margin-left:5px;background:var(--err);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700"></span></a>
|
||||
</span>
|
||||
</span>
|
||||
{{end}}{{if can "view"}}<a href="/admin/manual" class="{{if eq .Active "manual"}}active{{end}}">Ручной ввод M2M</a>{{end}}{{if and devOn (can "settings")}}<a href="/admin/depo-test" class="{{if eq .Active "depotest"}}active{{end}}">Тест DEPO</a>{{end}}{{if and devOn (can "operate")}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}
|
||||
{{end}}{{if can "view"}}<a href="/admin/manual" class="{{if eq .Active "manual"}}active{{end}}">Ручной ввод M2M</a>{{end}}{{if and devOn (can "settings")}}<a href="/admin/depo-test" class="{{if eq .Active "depotest"}}active{{end}}">Тест DEPO</a>{{end}}{{if and devOn (can "operate")}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}{{if and devOn (can "settings")}}<a href="/admin/emulators" class="{{if eq .Active "emulators"}}active{{end}}">Эмуляторы</a>{{end}}
|
||||
{{if and .NPR (can "npr")}}<a href="/admin/npr" class="{{if eq .Active "npr"}}active{{end}}">НПР / УДС</a>{{end}}
|
||||
{{if or (can "view") (can "quik")}}<span class="dd{{if or (eq .Active "fancy") (eq .Active "quik") (eq .Active "news") (eq .Active "handbook")}} active{{end}}">
|
||||
<span class="ddbtn">Обмен ▾</span>
|
||||
|
||||
@@ -89,6 +89,21 @@ func (s *Sender) SetOutcome(guid m2m.UUID, out DecisionOutcome) {
|
||||
s.outcomes.Store(guid, out)
|
||||
}
|
||||
|
||||
// Reconfigure меняет настройки mock'а в рантайме (задержка, исход по умолчанию,
|
||||
// код отказа) — без пересоздания сендера: канал Decisions() привязан к экземпляру.
|
||||
func (s *Sender) Reconfigure(cfg Config) {
|
||||
s.mu.Lock()
|
||||
s.cfg = cfg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// config — текущие настройки (потокобезопасный снимок для Send).
|
||||
func (s *Sender) config() Config {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// Decisions — канал эмитированных от имени принимающей стороны Decision'ов.
|
||||
// Подписаться, считать в горутине, передавать в m2mcore.Deal.ReceiveDecision.
|
||||
func (s *Sender) Decisions() <-chan *m2m.M2MTransferDecision { return s.decisions }
|
||||
@@ -130,11 +145,12 @@ func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2
|
||||
|
||||
// Эмитим Decision в отдельной горутине. Используем lifeCtx, чтобы
|
||||
// HTTP-таймауты вызывающего не прерывали эмиссию.
|
||||
outcome := s.cfg.DefaultOutcome
|
||||
cfg := s.config()
|
||||
outcome := cfg.DefaultOutcome
|
||||
if v, ok := s.outcomes.Load(req.Header.GUID); ok {
|
||||
outcome = v.(DecisionOutcome)
|
||||
}
|
||||
go s.emitDecision(s.lifeCtx, req, outcome)
|
||||
go s.emitDecision(s.lifeCtx, req, cfg, outcome)
|
||||
_ = ctx
|
||||
|
||||
return resp, nil
|
||||
@@ -155,11 +171,11 @@ func (s *Sender) SendDecision(_ context.Context, decision *m2m.M2MTransferDecisi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest, outcome DecisionOutcome) {
|
||||
func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest, cfg Config, outcome DecisionOutcome) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(s.cfg.DecisionDelay):
|
||||
case <-time.After(cfg.DecisionDelay):
|
||||
}
|
||||
|
||||
if outcome == OutcomeTimeout {
|
||||
@@ -222,7 +238,7 @@ func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest,
|
||||
} else {
|
||||
ds.TransferDecision = m2m.DecisionTransfer{
|
||||
Rejection: &m2m.Rejection{
|
||||
Codes: []string{s.cfg.RejectionCode},
|
||||
Codes: []string{cfg.RejectionCode},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,3 +138,52 @@ func TestMockSetOutcomeOverride(t *testing.T) {
|
||||
t.Fatal("Decision не пришёл")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockReconfigure(t *testing.T) {
|
||||
cfg := mock.DefaultConfig()
|
||||
cfg.DecisionDelay = 20 * time.Millisecond
|
||||
s := mock.NewSender(cfg)
|
||||
if _, err := s.Send(context.Background(), validRequest()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case d := <-s.Decisions():
|
||||
if d.Data.Securities[0].TransferDecision.Confirmation == nil {
|
||||
t.Error("до Reconfigure ожидалось Confirmation")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Decision не пришёл")
|
||||
}
|
||||
|
||||
// Меняем исход и код отказа на лету — канал решений продолжает работать.
|
||||
cfg.DefaultOutcome = mock.OutcomeReject
|
||||
cfg.RejectionCode = "18"
|
||||
s.Reconfigure(cfg)
|
||||
if _, err := s.Send(context.Background(), validRequest()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case d := <-s.Decisions():
|
||||
rej := d.Data.Securities[0].TransferDecision.Rejection
|
||||
if rej == nil {
|
||||
t.Fatal("после Reconfigure ожидалось Rejection")
|
||||
}
|
||||
if len(rej.Codes) != 1 || rej.Codes[0] != "18" {
|
||||
t.Errorf("код отказа %v, ожидался 18", rej.Codes)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Decision не пришёл — канал не должен ломаться после Reconfigure")
|
||||
}
|
||||
|
||||
// И таймаут переключается на лету.
|
||||
cfg.DefaultOutcome = mock.OutcomeTimeout
|
||||
s.Reconfigure(cfg)
|
||||
if _, err := s.Send(context.Background(), validRequest()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-s.Decisions():
|
||||
t.Error("при OutcomeTimeout Decision не должен прилетать")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user