Files
Bridge-and-Join-s/internal/lkgateway/service.go
zuevav 9737c787f9 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>
2026-06-19 00:03:21 +03:00

478 lines
17 KiB
Go

package lkgateway
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"sort"
"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"
)
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
// m2mcore, оркестрирует FSM сделки, эмитит callback'и в ЛК.
type Service struct {
repo m2mcore.Repository
sender m2mcore.NSDSender
store m2mcore.FansyStore
recorder *m2mcore.MemoryRecorder
defaultSender m2m.DeponentCode
defaultReceiver m2m.DeponentCode
callbackURL string
httpClient *http.Client
mu sync.RWMutex
claimToID map[string]string // claim public ID -> internal deal ID
}
// Config — параметры сервиса.
type Config struct {
Repository m2mcore.Repository
Sender m2mcore.NSDSender
Store m2mcore.FansyStore
Recorder *m2mcore.MemoryRecorder
DefaultSender m2m.DeponentCode
DefaultReceiver m2m.DeponentCode
CallbackURL string
}
// NewService собирает сервис.
func NewService(cfg Config) *Service {
if cfg.Recorder == nil {
cfg.Recorder = m2mcore.NewMemoryRecorder()
}
return &Service{
repo: cfg.Repository,
sender: cfg.Sender,
store: cfg.Store,
recorder: cfg.Recorder,
defaultSender: cfg.DefaultSender,
defaultReceiver: cfg.DefaultReceiver,
callbackURL: cfg.CallbackURL,
httpClient: &http.Client{Timeout: 5 * time.Second},
claimToID: make(map[string]string),
}
}
// CreateClaim принимает DTO заявки, формирует M2MTransferRequest,
// создаёт сделку и отправляет в НРД.
func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (CreateClaimResponse, error) {
domainClaim, err := dtoToClaim(in)
if err != nil {
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: receiver,
})
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
}
deal, err := m2mcore.NewDeal(req.Header.GUID, in.Investor.ID, []byte(in.SignedDocument))
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err)
}
saved, err := s.repo.Create(ctx, deal)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err)
}
if err := saved.Validate(ctx, req); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err)
}
resp, err := s.sender.Send(ctx, req)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err)
}
saved.Response = resp
if err := saved.Submit(ctx); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err)
}
if err := s.repo.Update(ctx, saved); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Update: %w", err)
}
s.recorder.IncDeal(saved.State)
s.mu.Lock()
s.claimToID[saved.ID] = saved.ID
s.mu.Unlock()
return CreateClaimResponse{
ID: saved.ID,
Status: string(saved.State),
CreatedAt: saved.CreatedAt,
Success: true,
}, nil
}
// GetClaim возвращает полную карточку заявки.
func (s *Service) GetClaim(ctx context.Context, id string) (ClaimView, error) {
deal, err := s.repo.GetByID(ctx, id)
if err != nil {
return ClaimView{}, err
}
return dealToView(deal), nil
}
// ListClaims возвращает страницу заявок.
func (s *Service) ListClaims(ctx context.Context, filter m2mcore.Filter) (ClaimsPage, error) {
if filter.Limit == 0 {
filter.Limit = 50
}
deals, err := s.repo.List(ctx, filter)
if err != nil {
return ClaimsPage{}, err
}
sort.Slice(deals, func(i, j int) bool { return deals[i].CreatedAt.After(deals[j].CreatedAt) })
items := make([]ClaimView, 0, len(deals))
for _, d := range deals {
items = append(items, dealToView(d))
}
return ClaimsPage{Items: items, Total: len(items), Limit: filter.Limit, Offset: filter.Offset}, nil
}
// ApplyDecision принимает Decision (из mock-NSDSender или реального адаптера),
// обновляет соответствующую сделку и шлёт callback в ЛК.
func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error {
if decision == nil {
return errors.New("lkgateway: ApplyDecision: decision=nil")
}
deal, err := s.repo.GetByGUID(ctx, decision.Header.GUID)
if err != nil {
return fmt.Errorf("lkgateway: GetByGUID: %w", err)
}
if err := deal.ReceiveDecision(ctx, decision); err != nil {
return fmt.Errorf("lkgateway: ReceiveDecision: %w", err)
}
if err := s.repo.Update(ctx, deal); err != nil {
return fmt.Errorf("lkgateway: repo.Update: %w", err)
}
s.recorder.IncDeal(deal.State)
if s.callbackURL != "" {
s.sendCallback(ctx, deal)
}
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)
if err := postJSON(ctx, s.httpClient, s.callbackURL+"/api/v1/back_office/claims/"+deal.ID, "PATCH", cb); err != nil {
log.Printf("lkgateway: callback в ЛК упал: %v", err)
}
}
// Recorder возвращает экспонируемый Recorder для admin-страницы.
func (s *Service) Recorder() *m2mcore.MemoryRecorder { return s.recorder }
// Repo возвращает Repository (для админских проверок).
func (s *Service) Repo() m2mcore.Repository { return s.repo }
// Внутренние преобразования и хелперы.
func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
out := m2mcore.ClaimInput{
InvestorClientID: in.Investor.ID,
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)}}
} else {
out.CostInfo = m2m.CostInfo{No: &m2m.CostInfoNo{}}
}
// IIA
if in.IIAAgreement != nil {
out.IIAAgreement = &m2m.IIAAgreementDetails{
AgreementType: m2m.IIAContractType(in.IIAAgreement.AgreementType),
AgreementNumber: in.IIAAgreement.AgreementNumber,
AgreementDate: in.IIAAgreement.AgreementDate,
BrokerINN: m2m.OrganizationINN(in.IIAAgreement.BrokerINN),
}
}
// Securities
for _, sec := range in.Securities {
ds, err := dtoSecurityDetails(sec.SecurityDetails)
if err != nil {
return m2mcore.ClaimInput{}, err
}
q, err := dtoQuantity(sec.Quantity)
if err != nil {
return m2mcore.ClaimInput{}, err
}
out.Securities = append(out.Securities, m2mcore.ClaimSecurityInput{
SecurityCode: m2m.SecurityCode(sec.SecurityCode),
Details: ds,
Quantity: q,
})
}
return out, nil
}
func dtoSecurityDetails(in SecurityDetails) (m2m.SecurityDetails, error) {
if in.ISIN != "" {
isin := m2m.ISIN(in.ISIN)
return m2m.SecurityDetails{ISIN: &isin}, nil
}
if in.SecurityInfo != nil {
si := &m2m.SecurityDescription{
SecurityClassification: m2m.SecurityClassification(in.SecurityInfo.Classification),
SecurityCategory: m2m.SecurityCategory(in.SecurityInfo.Category),
SecurityType: in.SecurityInfo.SecurityType,
SecuritySeries: in.SecurityInfo.SecuritySeries,
}
if in.SecurityInfo.IdentificationDetails.RegNumber != "" {
rn := in.SecurityInfo.IdentificationDetails.RegNumber
si.IdentificationDetails = m2m.IdentificationDetails{RegNumber: &rn}
}
if in.SecurityInfo.IdentificationDetails.FundShares != nil {
si.IdentificationDetails = m2m.IdentificationDetails{
FundShares: &m2m.FundShares{
RegNumber: in.SecurityInfo.IdentificationDetails.FundShares.RegNumber,
Class: in.SecurityInfo.IdentificationDetails.FundShares.Class,
},
}
}
return m2m.SecurityDetails{SecurityInfo: si}, nil
}
return m2m.SecurityDetails{}, errors.New("lkgateway: SecurityDetails — задайте isin или security_info")
}
func dtoQuantity(in Quantity) (m2m.Quantity, error) {
if in.Whole > 0 {
w := in.Whole
return m2m.Quantity{Whole: &w}, nil
}
if in.Fractional != "" {
f := m2m.Decimal16(in.Fractional)
return m2m.Quantity{Fractional: &f}, nil
}
return m2m.Quantity{}, errors.New("lkgateway: Quantity — задайте whole или fractional")
}
func dealToView(d *m2mcore.Deal) ClaimView {
out := ClaimView{
ID: d.ID,
Status: string(d.State),
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
M2MGUID: d.GUID,
}
for _, st := range d.Stages {
out.Stages = append(out.Stages, StageView{
State: string(st.State), EnteredAt: st.EnteredAt, LeftAt: st.LeftAt, Reason: st.Reason,
})
}
if d.Request != nil {
out.TransferringDepositoryINN = string(d.Request.Data.TransferringDepository.INN)
out.ReceivingDepositoryINN = string(d.Request.Data.ReceivingDepository.INN)
ii := d.Request.Data.InvestorInformation
out.Investor = Investor{
LastName: ii.LastName, FirstName: ii.FirstName, MiddleName: ii.MiddleName,
Document: Document{
DocumentType: string(ii.IdentityDocument.DocumentType),
Number: string(ii.IdentityDocument.DocumentNumber),
},
}
if ii.IdentityDocument.DocumentSeries != nil {
out.Investor.Document.Series = string(*ii.IdentityDocument.DocumentSeries)
}
if d.Request.Header.CostInfo.Yes != nil {
out.CostInfo = CostInfo{Yes: &CostInfoYes{Code: string(d.Request.Header.CostInfo.Yes.Code)}}
} else if d.Request.Header.CostInfo.No != nil {
empty := struct{}{}
out.CostInfo = CostInfo{No: &empty}
}
if d.Request.Header.IIAAgreementDetails != nil {
out.IIAAgreement = &IIAAgreement{
AgreementType: string(d.Request.Header.IIAAgreementDetails.AgreementType),
AgreementNumber: d.Request.Header.IIAAgreementDetails.AgreementNumber,
AgreementDate: d.Request.Header.IIAAgreementDetails.AgreementDate,
BrokerINN: string(d.Request.Header.IIAAgreementDetails.BrokerINN),
}
}
}
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)
}
if d.State != m2mcore.StateDraft {
cb := callbackForDeal(d)
out.LastCallback = &cb
}
return out
}
func responseToView(r *m2m.M2MTransferResponse) *NSDResponseView {
v := &NSDResponseView{
GUID: string(r.GUID),
StatusCode: string(r.StatusCode),
}
for _, e := range r.Responses {
ent := NSDResponseEntry{Code: e.Code, Text: e.Text}
if e.ReferenceID != nil {
ent.ReferenceID = string(*e.ReferenceID)
}
v.Responses = append(v.Responses, ent)
}
return v
}
func decisionToView(d *m2m.M2MTransferDecision) *NSDDecisionView {
v := &NSDDecisionView{GUID: string(d.Header.GUID)}
for _, sec := range d.Data.Securities {
entry := NSDDecisionSecurity{ReferenceID: string(sec.ReferenceID)}
if sec.TransferDecision.Confirmation != nil {
entry.Outcome = "confirmed"
} else if sec.TransferDecision.Rejection != nil {
entry.Outcome = "rejected"
entry.RejectCodes = sec.TransferDecision.Rejection.Codes
}
v.Securities = append(v.Securities, entry)
}
return v
}
func callbackForDeal(d *m2mcore.Deal) StatusCallback {
cb := StatusCallback{
ClaimID: d.ID,
NewStatus: string(d.State),
UpdatedAt: d.UpdatedAt,
}
if d.Decision != nil {
cb.NSDResponse = nsdResponseFromDecision(d.Decision)
if d.State == m2mcore.StateRejected {
for _, sec := range d.Decision.Data.Securities {
if sec.TransferDecision.Rejection != nil && len(sec.TransferDecision.Rejection.Codes) > 0 {
cb.ReasonCode = sec.TransferDecision.Rejection.Codes[0]
cb.ReasonText = "Отказ принимающей стороны (mock)"
break
}
}
}
} else if d.Response != nil {
cb.NSDResponse = responseToView(d.Response)
}
return cb
}
func nsdResponseFromDecision(d *m2m.M2MTransferDecision) *NSDResponseView {
v := &NSDResponseView{GUID: string(d.Header.GUID), StatusCode: string(m2m.StatusInfo)}
for _, sec := range d.Data.Securities {
ref := string(sec.ReferenceID)
ent := NSDResponseEntry{ReferenceID: ref}
if sec.TransferDecision.Confirmation != nil {
ent.Code = "01"
ent.Text = "Подтверждение принимающей стороны."
} else if sec.TransferDecision.Rejection != nil {
ent.Code = "07"
ent.Text = "Отказ принимающей стороны."
}
v.Responses = append(v.Responses, ent)
}
return v
}