c5695bf0b6
Реализован M2-шаг-1: первый рабочий сквозной поток M2M-заявки от ЛК
через нашу систему и обратно, с двумя видимыми веб-интерфейсами.
internal/nsdadapter/mock/:
- mock NSDSender с реалистичным синтетическим Response и асинхронной
эмиссией Decision через настраиваемую задержку (Confirm/Reject/Timeout)
- использует собственный жизненный цикл, чтобы HTTP-контексты вызывающего
не прерывали эмиссию Decision до истечения DecisionDelay
internal/lkgateway/:
- REST по контракту ESIA Finance V1 (POST/GET/PATCH/list claims)
- admin web UI (/admin/, /admin/claims, /admin/claims/{id}, /admin/status):
- дашборд со статусом подсистем (postgres, crypto-service UDS,
nsd-adapter, lk-emulator callback) и счётчиками сделок
- журнал и карточка заявки с историей FSM, ответом НРД, решением
принимающей стороны и последним callback'ом
- in-memory SeedStore с 5 тестовыми клиентами и счетами депо
- фоновый consumeDecisions: подписан на mock.Sender.Decisions(),
применяет ApplyDecision и отправляет PATCH callback в ЛК
internal/lkemulator/:
- имитация ЛК клиента (порт 8083)
- веб-формы: журнал, форма «новая заявка», карточка заявки
- HTTP-клиент к lk-gateway (создание заявки + регистрация callback URL)
- приёмник PATCH callback'ов, локальное хранилище заявок,
автообновление страницы каждые 3 сек
cmd/lk-gateway/main.go и cmd/lk-emulator/main.go — заглушки заменены
на полные сервисы с graceful shutdown.
Сквозной поток проверен smoke-test'ом: подача заявки через форму
эмулятора → создание сделки в lk-gateway → Send в mock NSD →
эмиссия Decision через 3 сек → ApplyDecision → PATCH callback в ЛК →
эмулятор показывает confirmed. Дашборд lk-gateway: Total=1, Подтверждено=1.
make ci зелёный.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
387 lines
13 KiB
Go
387 lines
13 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
|
|
SenderCode: s.defaultSender,
|
|
ReceiverCode: s.defaultReceiver,
|
|
})
|
|
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
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
// 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 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
|
|
}
|