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:
zuevav
2026-10-07 21:34:39 +03:00
parent 773c98d81e
commit fd238c1078
16 changed files with 775 additions and 17 deletions
+22 -1
View File
@@ -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) и точечные отказы по шагам (регистрация / подпись / снятие /
зачисление) — шаг завершается как боевой отказ: состояние не продвигается,
повтор на следующем проходе.
Баннер «Режим эмуляции» теперь честен и при настроенном ИШ: при ручном включении
эмуляторов причина указывается явно.
---
## Готовность по областям
| Область | Готовность | Статус |
+22
View File
@@ -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
+1 -1
View File
@@ -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
}
+161
View File
@@ -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 "подтверждение"
}
+208
View File
@@ -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("задержка не прервалась по контексту")
}
}
+7 -1
View File
@@ -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
}
+28 -2
View File
@@ -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
}
+33
View File
@@ -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("решение не пришло — задержка из настроек не применилась")
}
}
+27 -6
View File
@@ -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)
+86
View File
@@ -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"`
+2
View File
@@ -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">
+1 -1
View File
@@ -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>
+21 -5
View File
@@ -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},
},
}
}
+49
View File
@@ -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):
}
}