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
+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"`