feat: живой цикл M2M с НРД + мастер установки ключа на флешку

Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

Мастер установки ключа Валидаты на флешку (admin/setup/keywizard):
- пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник
  сертификатов (CRL) → перезапуск+проверка ИШ → готово
- привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен,
  bj-server остаётся в песочнице
- сохранение структуры профиля архива (spr<N>), перечисление съёмных USB

Прочее:
- пакет-доказательство для ТП НРД + форма регистрации участника M2M
- эталонные образцы робота (DOC/m2m_robot_samples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-06-19 00:03:21 +03:00
parent 6e503433d4
commit 9737c787f9
110 changed files with 10771 additions and 1690 deletions
+92 -1
View File
@@ -12,6 +12,7 @@ import (
"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"
)
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
@@ -67,9 +68,13 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
}
receiver := s.defaultReceiver
if in.ReceiverCodeOverride != "" {
receiver = m2m.DeponentCode(in.ReceiverCodeOverride)
}
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
SenderCode: s.defaultSender,
ReceiverCode: s.defaultReceiver,
ReceiverCode: receiver,
})
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
@@ -163,6 +168,80 @@ func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDe
return nil
}
// ApplyServiceResponse применяет M2MTransferResponse (ответ сервиса МОСТ) к
// сделке: сохраняет ответ, при ERROR переводит сделку в Rejected и шлёт
// callback в ЛК. Сделку ищем по GUID ответа.
func (s *Service) ApplyServiceResponse(ctx context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
if resp == nil {
return errors.New("lkgateway: ApplyServiceResponse: resp=nil")
}
deal, err := s.findDealForResponse(ctx, resp)
if err != nil {
return fmt.Errorf("lkgateway: поиск сделки для ответа: %w", err)
}
prev := deal.State
if err := deal.ReceiveServiceResponse(ctx, resp, raw); err != nil {
return fmt.Errorf("lkgateway: ReceiveServiceResponse: %w", err)
}
if err := s.repo.Update(ctx, deal); err != nil {
return fmt.Errorf("lkgateway: repo.Update: %w", err)
}
// Состояние сменилось (ERROR → Rejected) — учитываем в метриках и шлём
// callback. На INFO состояние не меняется — callback не нужен.
if deal.State != prev {
s.recorder.IncDeal(deal.State)
if s.callbackURL != "" {
s.sendCallback(ctx, deal)
}
}
return nil
}
// zeroGUID — нулевой UUID, который НРД присылает в сервисных ошибках
// (напр. M2M14), когда не идентифицирует исходный запрос.
const zeroGUID = "00000000-0000-0000-0000-000000000000"
// findDealForResponse сопоставляет ответ МОСТ со сделкой. Сначала по GUID;
// если GUID нулевой/пустой или сделка по нему не найдена (реальное поведение
// НРД при M2M14 — ответ без нашего GUID и без ReferenceID), применяем
// эвристику: берём самую раннюю ожидающую решение заявку без ответа. Для
// тестового сценария «одна заявка в полёте» это однозначно; при множестве
// заявок in-flight такие сервисные ошибки в принципе неразличимы на стороне
// НРД, и FIFO — наилучшее доступное приближение.
func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransferResponse) (*m2mcore.Deal, error) {
guid := string(resp.GUID)
if guid != "" && guid != zeroGUID {
deal, err := s.repo.GetByGUID(ctx, resp.GUID)
if err == nil {
return deal, nil
}
if !errors.Is(err, m2mcore.ErrNotFound) {
return nil, err
}
}
// Fallback: ответ без идентифицируемого GUID.
st := m2mcore.StateAwaitingDecision
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
if err != nil {
return nil, err
}
var cand *m2mcore.Deal
for _, d := range deals {
if d.Response != nil {
continue // этой заявке ответ уже сопоставлен
}
if cand == nil || d.CreatedAt.Before(cand.CreatedAt) {
cand = d
}
}
if cand == nil {
return nil, m2mcore.ErrNotFound
}
log.Printf("lkgateway: ответ МОСТ с GUID=%s сопоставлен по эвристике (FIFO) заявке id=%s status=%s",
guid, cand.ID, resp.StatusCode)
return cand, nil
}
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
cb := callbackForDeal(deal)
@@ -185,6 +264,14 @@ func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
}
// Переопределение документа инвестора (тест с роботом: серия = сценарий).
if d := in.InvestorDocumentOverride; d != nil {
out.InvestorDocument = &m2mcore.ClientDocument{
DocumentType: m2m.IdentityDocumentCode(d.DocumentType),
Series: d.Series,
Number: d.Number,
}
}
// CostInfo
if in.CostInfo.Yes != nil {
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
@@ -304,6 +391,10 @@ func dealToView(d *m2mcore.Deal) ClaimView {
}
if d.Response != nil {
out.M2MResponse = responseToView(d.Response)
if len(d.RawResponse) > 0 {
// Ответ НРД в windows-1251 — декодируем в UTF-8 для показа.
out.M2MResponse.RawXML = string(nsdxml.DecodeWindows1251(d.RawResponse))
}
}
if d.Decision != nil {
out.M2MDecision = decisionToView(d.Decision)