Files
Bridge-and-Join-s/internal/nsdadapter/mock/sender.go
T
zuevav fd238c1078 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).
2026-10-07 21:34:39 +03:00

265 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mock — заглушка NSDSender для локальных стендов без реального
// Интеграционного шлюза НРД. Возвращает синтетические M2MTransferResponse
// (синхронно, сразу) и эмитит M2MTransferDecision (асинхронно, через
// настраиваемую задержку) для каждой отправленной заявки.
//
// Подходит для:
// - сквозного дев-теста (ЛК → m2m-core → mock → callback в ЛК);
// - демонстраций «увидеть как оно работает» без подключения к НРД;
// - юнит-тестов компонентов выше уровня транспорта.
package mock
import (
"context"
"errors"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// DecisionOutcome — что должен вернуть mock после задержки.
type DecisionOutcome int
const (
OutcomeConfirm DecisionOutcome = iota
OutcomeReject
OutcomeTimeout
)
// Config — настройки mock-сендера.
type Config struct {
// DecisionDelay — задержка между Send и эмиссией Decision в канал.
DecisionDelay time.Duration
// DefaultOutcome — каким будет Decision, если не задан per-Request override.
DefaultOutcome DecisionOutcome
// RejectionCode — какой код отказа возвращать в OutcomeReject (6 chars max).
RejectionCode string
// SenderCode/ReceiverCode для эмитированной Decision Header (берётся из
// исходного Request обменом местами).
NSDSenderCode m2m.DeponentCode
}
// DefaultConfig — разумные дефолты: подтверждение через 3 секунды.
func DefaultConfig() Config {
return Config{
DecisionDelay: 3 * time.Second,
DefaultOutcome: OutcomeConfirm,
RejectionCode: "07",
}
}
// Sender — mock реализация m2mcore.NSDSender.
type Sender struct {
cfg Config
decisions chan *m2m.M2MTransferDecision
outcomes sync.Map // map[m2m.UUID]DecisionOutcome — override per GUID
mu sync.Mutex
stats stats
lifeCtx context.Context // независимый от HTTP-запроса контекст для эмиссии Decision
lifeCancel context.CancelFunc
}
type stats struct {
Sent uint64
Confirmed uint64
Rejected uint64
TimedOut uint64
}
// NewSender создаёт mock с указанной конфигурацией.
func NewSender(cfg Config) *Sender {
ctx, cancel := context.WithCancel(context.Background())
return &Sender{
cfg: cfg,
decisions: make(chan *m2m.M2MTransferDecision, 64),
lifeCtx: ctx,
lifeCancel: cancel,
}
}
// Stop отменяет внутренний контекст mock'а, останавливая все запущенные
// emit-горутины.
func (s *Sender) Stop() { s.lifeCancel() }
// SetOutcome задаёт исход для конкретной заявки по GUID (вызывается до Send).
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 }
// Stats — текущие счётчики (для UI).
func (s *Sender) Stats() (sent, confirmed, rejected, timedOut uint64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.stats.Sent, s.stats.Confirmed, s.stats.Rejected, s.stats.TimedOut
}
// Send имитирует отправку в НРД. Возвращает синтетический Response
// с StatusCode=INFO. Через DecisionDelay в канал прилетает Decision.
func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) {
if req == nil {
return nil, errors.New("nsdadapter/mock: Send: req=nil")
}
if err := req.Validate(); err != nil {
return nil, err
}
s.mu.Lock()
s.stats.Sent++
s.mu.Unlock()
// Синтетический Response: принимаем заявку, по каждой ЦБ — INFO 01.
resp := &m2m.M2MTransferResponse{
GUID: req.Header.GUID,
StatusCode: m2m.StatusInfo,
}
for _, sec := range req.Data.TransferredSecurities.Securities {
refID := sec.ReferenceID
resp.Responses = append(resp.Responses, m2m.Response{
ReferenceID: &refID,
Code: "01",
Text: "Запрос на перевод принят НРД и направлен принимающей стороне (mock).",
})
}
// Эмитим Decision в отдельной горутине. Используем lifeCtx, чтобы
// HTTP-таймауты вызывающего не прерывали эмиссию.
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, cfg, outcome)
_ = ctx
return resp, nil
}
// SendDecision имитирует отправку нашего Decision принимающей стороне (мы — реципиент).
// Mock просто фиксирует статистику.
func (s *Sender) SendDecision(_ context.Context, decision *m2m.M2MTransferDecision) error {
if decision == nil {
return errors.New("nsdadapter/mock: SendDecision: decision=nil")
}
if err := decision.Validate(); err != nil {
return err
}
s.mu.Lock()
s.stats.Sent++
s.mu.Unlock()
return nil
}
func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest, cfg Config, outcome DecisionOutcome) {
select {
case <-ctx.Done():
return
case <-time.After(cfg.DecisionDelay):
}
if outcome == OutcomeTimeout {
s.mu.Lock()
s.stats.TimedOut++
s.mu.Unlock()
// При таймауте Decision не приходит вообще — это и эмулируем.
return
}
// Робот-автотест НРД: если ReceiverCode == MC0012500000 и DocumentSeries
// задан (1111/2001/2002/3333) — формируем ответ по сценарию из
// «Инструкции по тестированию с роботом» (DOC/instruktsiya-po-...pdf).
// Это позволяет проверить нашу логику обработки ответов до того, как
// у нас будет реальный ИШ + сертификат + доступ к TEST3.
if decision := simulateRobotDecision(req); decision != nil {
s.mu.Lock()
// Грубая статистика: считаем «робот-ответ» как Confirmed если хоть
// одна бумага подтверждена, иначе Rejected.
hasConfirm := false
for _, ds := range decision.Data.Securities {
if ds.TransferDecision.Confirmation != nil {
hasConfirm = true
break
}
}
if hasConfirm {
s.stats.Confirmed++
} else {
s.stats.Rejected++
}
s.mu.Unlock()
select {
case <-ctx.Done():
case s.decisions <- decision:
}
return
}
decision := &m2m.M2MTransferDecision{
Header: m2m.DecisionHeader{
GUID: req.Header.GUID,
CreationTimestamp: nsdxml.Now(),
SenderCode: req.Header.ReceiverCode,
ReceiverCode: req.Header.SenderCode,
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
},
Data: m2m.DecisionData{
ReceivingDepository: req.Data.ReceivingDepository,
},
}
for _, sec := range req.Data.TransferredSecurities.Securities {
ds := m2m.DecisionSecurity{ReferenceID: sec.ReferenceID}
if outcome == OutcomeConfirm {
ds.TransferDecision = m2m.DecisionTransfer{
Confirmation: &m2m.Confirmation{
SettlementAccount: sec.SettlementAccount[0],
},
}
} else {
ds.TransferDecision = m2m.DecisionTransfer{
Rejection: &m2m.Rejection{
Codes: []string{cfg.RejectionCode},
},
}
}
decision.Data.Securities = append(decision.Data.Securities, ds)
}
s.mu.Lock()
switch outcome {
case OutcomeConfirm:
s.stats.Confirmed++
case OutcomeReject:
s.stats.Rejected++
}
s.mu.Unlock()
select {
case <-ctx.Done():
case s.decisions <- decision:
}
}
// Verify тип Sender удовлетворяет m2mcore.NSDSender.
var _ m2mcore.NSDSender = (*Sender)(nil)