diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 7bfddca..335a0f1 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -14,7 +14,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос | PR-1 | `PR-1-go-models-m2m.md` | выполнено | — | | PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) | | PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) | -| PR-4 | `PR-4-m2m-core-skeleton.md` | готово к запуску | PR-1 | +| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 | | PR-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 | | PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 | diff --git a/internal/m2mcore/README.md b/internal/m2mcore/README.md new file mode 100644 index 0000000..661b8ce --- /dev/null +++ b/internal/m2mcore/README.md @@ -0,0 +1,49 @@ +# internal/m2mcore — ядро бизнес-логики M2M + +Конечный автомат сделки, репозиторий, идемпотентность по GUID, +сборка `M2MTransferRequest` из заявки ЛК + данных Fansy, метрики SLA. + +## Состав + +- `fsm.go` — состояния FSM и матрица разрешённых переходов + (`Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed → + AwaitingSUB16 → Done`, ветки `Rejected`, `TimedOut`, + `ManualApproval`). +- `deal.go` — доменная модель `Deal` с явными методами переходов + (`Validate`, `Submit`, `ReceiveDecision`, `Timeout`, + `SendToManualApproval`, `ApproveManually`, `RejectManually`, + `CompleteSUB16`). Каждый переход проверяет текущее состояние, + пишет историю в `Stages` и фиксирует событие в журнале. +- `uuid.go` — генератор UUID v4 без внешних зависимостей. +- `repo.go` — порт `Repository` + in-memory реализация + `MemoryRepository` с идемпотентным `Create` (по GUID возвращает + существующую сделку). PostgreSQL-реализация — задача M2 (миграция + лежит в `migrations/m2m-core/001__deals.sql`). +- `ports.go` — порты к внешним системам (`NSDSender`, + `LKCallbackClient`, `CryptoVerifier`, `FansyStore`) с no-op + заглушками для M1. +- `enrich.go` — функция `EnrichRequest`: из `ClaimInput` (заявка ЛК) + + данных Fansy собирает валидный `M2MTransferRequest`, генерирует + `GUID` и `ReferenceID` по каждой ЦБ. +- `metrics.go` — порт `Recorder` + `MemoryRecorder`, отдающий + Prometheus text-format. В M2 заменим на `prometheus/client_golang`, + когда прокси откроет Go-модули. + +## Зависимости + +Только stdlib и собственные пакеты `internal/m2m`, `internal/nsdxml`. +Никаких внешних модулей. + +## Тесты + +- `fsm_test.go` — переходы и терминальные состояния. +- `repo_test.go` — идемпотентность по GUID, фильтры в `List`. +- `uuid_test.go` — формат UUID v4 и `ReferenceID`. +- `metrics_test.go` — Prometheus-текст. + +## Сервис cmd/m2m-core + +`cmd/m2m-core/main.go` — HTTP-сервер с `/healthz` и `/metrics`, +in-memory репозиторий, no-op порты. Адрес из `BJ_HTTP_ADDR` +(по умолчанию `:8081`). В M2 будет заменено на реальные клиенты НРД +и crypto-service. diff --git a/internal/m2mcore/deal.go b/internal/m2mcore/deal.go new file mode 100644 index 0000000..dbf924f --- /dev/null +++ b/internal/m2mcore/deal.go @@ -0,0 +1,232 @@ +package m2mcore + +import ( + "context" + "fmt" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" +) + +// StageRecord — запись о входе в состояние FSM, для аудита и метрик. +type StageRecord struct { + State State + EnteredAt time.Time + LeftAt *time.Time + Reason string +} + +// Event — доменное событие сделки (event sourcing для аудита). +type Event struct { + Type string + Payload any + CreatedAt time.Time + Actor string +} + +// Deal — корневая агрегатная сущность M2M-сделки. +type Deal struct { + ID string + GUID m2m.UUID + State State + InvestorID string + SignedClaim []byte + Request *m2m.M2MTransferRequest + Response *m2m.M2MTransferResponse + Decision *m2m.M2MTransferDecision + CreatedAt time.Time + UpdatedAt time.Time + Stages []StageRecord + + mu sync.Mutex + events []Event +} + +// NewDeal создаёт новую сделку в состоянии Draft. +func NewDeal(guid m2m.UUID, investorID string, signedClaim []byte) (*Deal, error) { + if err := guid.Validate(); err != nil { + return nil, fmt.Errorf("m2mcore: невалидный GUID при создании Deal: %w", err) + } + id, err := NewUUIDv4() + if err != nil { + return nil, err + } + now := time.Now().UTC() + return &Deal{ + ID: id, + GUID: guid, + State: StateDraft, + InvestorID: investorID, + SignedClaim: signedClaim, + CreatedAt: now, + UpdatedAt: now, + Stages: []StageRecord{{State: StateDraft, EnteredAt: now}}, + }, nil +} + +// Events возвращает накопленные события (копия слайса). +func (d *Deal) Events() []Event { + d.mu.Lock() + defer d.mu.Unlock() + out := make([]Event, len(d.events)) + copy(out, d.events) + return out +} + +// recordEvent добавляет событие в журнал сделки. +func (d *Deal) recordEvent(eventType string, payload any, actor string) { + d.events = append(d.events, Event{ + Type: eventType, Payload: payload, + CreatedAt: time.Now().UTC(), Actor: actor, + }) +} + +// shiftTo переводит FSM в новое состояние, фиксируя историю. +// Должен вызываться под d.mu.Lock. +func (d *Deal) shiftTo(next State, reason string) error { + if err := transition(d.State, next); err != nil { + return err + } + now := time.Now().UTC() + if i := len(d.Stages) - 1; i >= 0 { + d.Stages[i].LeftAt = &now + } + d.Stages = append(d.Stages, StageRecord{State: next, EnteredAt: now, Reason: reason}) + d.State = next + d.UpdatedAt = now + return nil +} + +// Validate переводит Draft -> Validated и фиксирует событие. +// Сама валидация Request делается m2m.M2MTransferRequest.Validate(), +// которое следует вызвать перед этим методом. +func (d *Deal) Validate(_ context.Context, request *m2m.M2MTransferRequest) error { + d.mu.Lock() + defer d.mu.Unlock() + if request == nil { + return fmt.Errorf("m2mcore: Deal.Validate: request=nil") + } + if err := request.Validate(); err != nil { + return fmt.Errorf("m2mcore: M2MTransferRequest.Validate: %w", err) + } + d.Request = request + if err := d.shiftTo(StateValidated, ""); err != nil { + return err + } + d.recordEvent("validated", request.Header.GUID, "system") + return nil +} + +// Submit переводит Validated -> SubmittedToNSD после успешной отправки. +func (d *Deal) Submit(_ context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.shiftTo(StateSubmittedToNSD, ""); err != nil { + return err + } + d.recordEvent("submitted_to_nsd", nil, "system") + if err := d.shiftTo(StateAwaitingDecision, ""); err != nil { + return err + } + d.recordEvent("awaiting_decision", nil, "system") + return nil +} + +// ReceiveDecision принимает M2MTransferDecision от принимающей стороны и +// меняет состояние на Confirmed или Rejected в зависимости от содержимого. +func (d *Deal) ReceiveDecision(_ context.Context, decision *m2m.M2MTransferDecision) error { + d.mu.Lock() + defer d.mu.Unlock() + if decision == nil { + return fmt.Errorf("m2mcore: ReceiveDecision: decision=nil") + } + if err := decision.Validate(); err != nil { + return fmt.Errorf("m2mcore: M2MTransferDecision.Validate: %w", err) + } + d.Decision = decision + + // Если все Security содержат Confirmation — Confirmed; если хотя бы + // одна Rejection — Rejected. Смешанные сценарии XSD НРД не запрещает, + // но на нашей стороне их трактуем как Rejected (требует ручного разбора). + rejected := false + for _, s := range decision.Data.Securities { + if s.TransferDecision.Rejection != nil { + rejected = true + break + } + } + target := StateConfirmed + reason := "" + if rejected { + target = StateRejected + reason = "decision_contains_rejection" + } + if err := d.shiftTo(target, reason); err != nil { + return err + } + d.recordEvent("decision_received", decision.Header.GUID, "nsd") + return nil +} + +// Timeout переводит сделку в TimedOut (когда не дождались Decision). +func (d *Deal) Timeout(_ context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.shiftTo(StateTimedOut, "no_decision_within_sla"); err != nil { + return err + } + d.recordEvent("timed_out", nil, "system") + return nil +} + +// SendToManualApproval переводит на ручной разбор оператора. +func (d *Deal) SendToManualApproval(_ context.Context, reason string) error { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.shiftTo(StateManualApproval, reason); err != nil { + return err + } + d.recordEvent("manual_approval_requested", reason, "system") + return nil +} + +// ApproveManually вручную подтверждает сделку (с операторской подписью). +func (d *Deal) ApproveManually(_ context.Context, operator string) error { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.shiftTo(StateConfirmed, "manual_approve"); err != nil { + return err + } + d.recordEvent("manual_approve", nil, operator) + return nil +} + +// RejectManually вручную отказывает в сделке. +func (d *Deal) RejectManually(_ context.Context, operator, code, comment string) error { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.shiftTo(StateRejected, "manual_reject:"+code); err != nil { + return err + } + d.recordEvent("manual_reject", map[string]string{"code": code, "comment": comment}, operator) + return nil +} + +// CompleteSUB16 фиксирует получение SUB16 от НРД и переводит Confirmed +// -> AwaitingSUB16 -> Done. Может вызываться один раз. +func (d *Deal) CompleteSUB16(_ context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + if d.State == StateConfirmed { + if err := d.shiftTo(StateAwaitingSUB16, ""); err != nil { + return err + } + d.recordEvent("awaiting_sub16", nil, "nsd") + } + if err := d.shiftTo(StateDone, ""); err != nil { + return err + } + d.recordEvent("done", nil, "nsd") + return nil +} diff --git a/internal/m2mcore/enrich.go b/internal/m2mcore/enrich.go new file mode 100644 index 0000000..20a5209 --- /dev/null +++ b/internal/m2mcore/enrich.go @@ -0,0 +1,157 @@ +package m2mcore + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +// ClaimInput — входная заявка из ЛК (после OpenAPI-парсинга). +type ClaimInput struct { + InvestorClientID string + TransferringDepositoryINN m2m.OrganizationINN + ReceivingDepositoryINN m2m.OrganizationINN + CostInfo m2m.CostInfo + IIAAgreement *m2m.IIAAgreementDetails + Securities []ClaimSecurityInput +} + +// ClaimSecurityInput — одна ЦБ в заявке. +type ClaimSecurityInput struct { + SecurityCode m2m.SecurityCode + Details m2m.SecurityDetails + Quantity m2m.Quantity +} + +// SenderReceiver — отправитель и получатель в Header (коды депонентов). +type SenderReceiver struct { + SenderCode m2m.DeponentCode + ReceiverCode m2m.DeponentCode +} + +// referenceIDChars — алфавит для генерации ReferenceID (5 случайных +// символов после префикса "M2M" и даты). +const referenceIDChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +// NewReferenceID генерирует идентификатор операции в формате +// "M2M" + YYYYMMDD + 5 случайных символов из [A-Z0-9]. +// Длина — ровно 16, как требует XSD ReferenceIdType. +func NewReferenceID(at time.Time) (m2m.ReferenceID, error) { + suffix := make([]byte, 5) + for i := range suffix { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(referenceIDChars)))) + if err != nil { + return "", fmt.Errorf("m2mcore: NewReferenceID rand: %w", err) + } + suffix[i] = referenceIDChars[n.Int64()] + } + day := at.UTC() + id := fmt.Sprintf("M2M%04d%02d%02d%s", + day.Year(), int(day.Month()), day.Day(), suffix) + out := m2m.ReferenceID(id) + if err := out.Validate(); err != nil { + return "", err + } + return out, nil +} + +// EnrichRequest собирает M2MTransferRequest из заявки ЛК и данных Fansy. +// Шаги: +// 1. Поднять анкету клиента. +// 2. Поднять депо-счета у передающего депозитария и проверить остатки. +// 3. Сгенерировать GUID, ReferenceID для каждой ЦБ, CreationTimestamp. +// 4. Заполнить структуру и провалидировать. +func EnrichRequest( + ctx context.Context, + store FansyStore, + claim ClaimInput, + codes SenderReceiver, +) (*m2m.M2MTransferRequest, error) { + client, err := store.GetClientByID(ctx, claim.InvestorClientID) + if err != nil { + return nil, fmt.Errorf("m2mcore: GetClientByID: %w", err) + } + accounts, err := store.GetDepoAccounts(ctx, claim.InvestorClientID, claim.TransferringDepositoryINN) + if err != nil { + return nil, fmt.Errorf("m2mcore: GetDepoAccounts: %w", err) + } + if len(accounts) == 0 { + return nil, fmt.Errorf("m2mcore: у клиента нет активных счетов в передающем депозитарии") + } + + guid, err := NewUUIDv4() + if err != nil { + return nil, err + } + now := time.Now() + + securities := make([]m2m.RequestSecurity, 0, len(claim.Securities)) + for _, sec := range claim.Securities { + refID, err := NewReferenceID(now) + if err != nil { + return nil, err + } + // Берём первый активный счёт как минимум; реальная логика выбора + // settlement_accounts будет в M2 (по типу ЦБ и торговому разделу). + settlement := make([]m2m.RequestSettlementAccount, 0, len(accounts)) + for _, a := range accounts { + settlement = append(settlement, m2m.RequestSettlementAccount{ + SettlementRequisites: m2m.SettlementRequisites{INN: a.DepositoryINN}, + SettlementLocation: m2m.SettlementDepositoryLocation{ + DeponentCode: a.DeponentCode, + AccountID: a.AccountID, + SectionID: a.SectionID, + }, + }) + } + securities = append(securities, m2m.RequestSecurity{ + ReferenceID: refID, + SecurityCode: sec.SecurityCode, + SecurityDetails: sec.Details, + Quantity: sec.Quantity, + SettlementAccount: settlement, + IsolationStatus: m2m.IsolationSGDN, + }) + } + + req := &m2m.M2MTransferRequest{ + Header: m2m.RequestHeader{ + GUID: m2m.UUID(guid), + CreationTimestamp: nsdxml.Now(), + SenderCode: codes.SenderCode, + ReceiverCode: codes.ReceiverCode, + CostInfo: claim.CostInfo, + IIAAgreementDetails: claim.IIAAgreement, + }, + Data: m2m.RequestData{ + InvestorInformation: m2m.InvestorInformation{ + LastName: client.LastName, + FirstName: client.FirstName, + MiddleName: client.MiddleName, + IdentityDocument: m2m.IdentityDocument{ + DocumentType: client.Document.DocumentType, + DocumentNumber: m2m.IdentityDocSerial(client.Document.Number), + }, + }, + TransferringDepository: m2m.SettlementRequisites{INN: claim.TransferringDepositoryINN}, + ReceivingDepository: m2m.SettlementRequisites{INN: claim.ReceivingDepositoryINN}, + TransferredSecurities: m2m.RequestTransferredSecurities{Securities: securities}, + }, + } + if claim.IIAAgreement != nil { + req.Header.IIAAgreementDetails = claim.IIAAgreement + } + if client.Document.Series != "" { + series := m2m.IdentityDocSerial(client.Document.Series) + req.Data.InvestorInformation.IdentityDocument.DocumentSeries = &series + } + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("m2mcore: собранный M2MTransferRequest невалиден: %w", err) + } + return req, nil +} diff --git a/internal/m2mcore/enrich_test.go b/internal/m2mcore/enrich_test.go new file mode 100644 index 0000000..d7cadf6 --- /dev/null +++ b/internal/m2mcore/enrich_test.go @@ -0,0 +1,150 @@ +package m2mcore_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// fakeStore — тестовая реализация FansyStore. +type fakeStore struct { + client *m2mcore.Client + accounts []m2mcore.DepoAccount + getErr error +} + +func (f *fakeStore) GetClientByID(_ context.Context, _ string) (*m2mcore.Client, error) { + if f.getErr != nil { + return nil, f.getErr + } + return f.client, nil +} + +func (f *fakeStore) GetDepoAccounts(_ context.Context, _ string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) { + return f.accounts, nil +} + +func (f *fakeStore) GetBalances(_ context.Context, _ string, _ []m2m.SecurityCode) ([]m2mcore.SecurityBalance, error) { + return nil, nil +} + +func TestNewReferenceIDFormat(t *testing.T) { + at := time.Date(2026, 3, 2, 14, 30, 0, 0, time.UTC) + id, err := m2mcore.NewReferenceID(at) + if err != nil { + t.Fatal(err) + } + if string(id)[:11] != "M2M20260302" { + t.Errorf("префикс/дата ReferenceID неверен: %q", id) + } +} + +func TestEnrichRequestHappyPath(t *testing.T) { + store := &fakeStore{ + client: &m2mcore.Client{ + ID: "inv-1", + LastName: "Иванов", + FirstName: "Иван", + MiddleName: "Иванович", + Document: m2mcore.ClientDocument{ + DocumentType: m2m.DocCode21, + Series: "4512", + Number: "654321", + }, + }, + accounts: []m2mcore.DepoAccount{ + { + ID: "acc-1", + ClientID: "inv-1", + DeponentCode: "DP789456", + AccountID: m2m.AccountID("31MC0021900000F01"), + SectionID: "P001", + DepositoryINN: m2m.OrganizationINN("7702070139"), + }, + }, + } + whole := uint64(1500) + isin := m2m.ISIN("RU0007661625") + + claim := m2mcore.ClaimInput{ + InvestorClientID: "inv-1", + TransferringDepositoryINN: m2m.OrganizationINN("0702345678"), + ReceivingDepositoryINN: m2m.OrganizationINN("0710987654"), + CostInfo: m2m.CostInfo{ + No: &m2m.CostInfoNo{}, + }, + Securities: []m2mcore.ClaimSecurityInput{ + { + SecurityCode: m2m.SecurityCode("MM0766162534"), + Details: m2m.SecurityDetails{ISIN: &isin}, + Quantity: m2m.Quantity{Whole: &whole}, + }, + }, + } + codes := m2mcore.SenderReceiver{ + SenderCode: m2m.DeponentCode("MC0079200000"), + ReceiverCode: m2m.DeponentCode("MC0010300000"), + } + + req, err := m2mcore.EnrichRequest(context.Background(), store, claim, codes) + if err != nil { + t.Fatalf("EnrichRequest: %v", err) + } + if err := req.Validate(); err != nil { + t.Fatalf("собранный Request не прошёл валидацию: %v", err) + } + if req.Data.InvestorInformation.LastName != "Иванов" { + t.Errorf("LastName не пробросился") + } + if len(req.Data.TransferredSecurities.Securities) != 1 { + t.Errorf("ожидалась 1 ЦБ, получено %d", len(req.Data.TransferredSecurities.Securities)) + } +} + +func TestEnrichRequestNoAccounts(t *testing.T) { + store := &fakeStore{ + client: &m2mcore.Client{LastName: "X", FirstName: "Y", Document: m2mcore.ClientDocument{DocumentType: m2m.DocCode21, Number: "1"}}, + accounts: nil, + } + _, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{}) + if err == nil { + t.Errorf("ожидалась ошибка при отсутствии счетов") + } +} + +func TestEnrichRequestStoreError(t *testing.T) { + store := &fakeStore{getErr: errors.New("db down")} + _, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{}) + if err == nil { + t.Errorf("ожидалась ошибка от FansyStore") + } +} + +func TestNoopPortsReturnErrNotImplemented(t *testing.T) { + ctx := context.Background() + if _, err := (m2mcore.NoopNSDSender{}).Send(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) { + t.Errorf("NoopNSDSender.Send ожидалась ErrNotImplemented, получено %v", err) + } + if err := (m2mcore.NoopNSDSender{}).SendDecision(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) { + t.Errorf("NoopNSDSender.SendDecision ожидалась ErrNotImplemented, получено %v", err) + } + if err := (m2mcore.NoopLKCallbackClient{}).UpdateStatus(ctx, "", "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) { + t.Errorf("LKCallbackClient ожидалась ErrNotImplemented") + } + if _, err := (m2mcore.NoopCryptoVerifier{}).VerifyXMLDSig(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) { + t.Errorf("CryptoVerifier ожидалась ErrNotImplemented") + } + if _, err := (m2mcore.NoopFansyStore{}).GetClientByID(ctx, ""); !errors.Is(err, m2mcore.ErrNotImplemented) { + t.Errorf("FansyStore.GetClientByID ожидалась ErrNotImplemented") + } + if _, err := (m2mcore.NoopFansyStore{}).GetDepoAccounts(ctx, "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) { + t.Errorf("FansyStore.GetDepoAccounts ожидалась ErrNotImplemented") + } + if _, err := (m2mcore.NoopFansyStore{}).GetBalances(ctx, "", nil); !errors.Is(err, m2mcore.ErrNotImplemented) { + t.Errorf("FansyStore.GetBalances ожидалась ErrNotImplemented") + } +} diff --git a/internal/m2mcore/fsm.go b/internal/m2mcore/fsm.go new file mode 100644 index 0000000..d5df308 --- /dev/null +++ b/internal/m2mcore/fsm.go @@ -0,0 +1,96 @@ +package m2mcore + +import ( + "errors" + "fmt" +) + +// State — конечное состояние сделки M2M. +type State string + +const ( + StateDraft State = "draft" + StateValidated State = "validated" + StateSubmittedToNSD State = "submitted_to_nsd" + StateAwaitingDecision State = "awaiting_decision" + StateConfirmed State = "confirmed" + StateAwaitingSUB16 State = "awaiting_sub16" + StateDone State = "done" + StateRejected State = "rejected" + StateTimedOut State = "timed_out" + StateManualApproval State = "manual_approval" +) + +// ErrInvalidTransition возвращается при попытке перейти в состояние, +// которое не разрешено из текущего. +var ErrInvalidTransition = errors.New("m2mcore: недопустимый переход FSM") + +// allowedTransitions — карта разрешённых переходов FSM сделки. +// Любая попытка перейти в state, отсутствующее в списке для текущего, +// заканчивается ErrInvalidTransition. +var allowedTransitions = map[State]map[State]struct{}{ + StateDraft: { + StateValidated: {}, + StateRejected: {}, + StateManualApproval: {}, + }, + StateValidated: { + StateSubmittedToNSD: {}, + StateRejected: {}, + StateManualApproval: {}, + }, + StateSubmittedToNSD: { + StateAwaitingDecision: {}, + StateRejected: {}, + }, + StateAwaitingDecision: { + StateConfirmed: {}, + StateRejected: {}, + StateTimedOut: {}, + StateManualApproval: {}, + }, + StateConfirmed: { + StateAwaitingSUB16: {}, + StateDone: {}, + }, + StateAwaitingSUB16: { + StateDone: {}, + }, + StateManualApproval: { + StateValidated: {}, + StateConfirmed: {}, + StateRejected: {}, + }, + // Завершающие состояния — без выходов. + StateDone: {}, + StateRejected: {}, + StateTimedOut: {}, +} + +// IsTerminal возвращает true для завершающих состояний. +func IsTerminal(s State) bool { + switch s { + case StateDone, StateRejected, StateTimedOut: + return true + } + return false +} + +// CanTransition сообщает, разрешён ли переход from -> to. +func CanTransition(from, to State) bool { + allowed, ok := allowedTransitions[from] + if !ok { + return false + } + _, ok = allowed[to] + return ok +} + +// transition проверяет переход и возвращает обёрнутую ошибку с +// контекстом, если он недопустим. +func transition(from, to State) error { + if !CanTransition(from, to) { + return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, from, to) + } + return nil +} diff --git a/internal/m2mcore/fsm_test.go b/internal/m2mcore/fsm_test.go new file mode 100644 index 0000000..bb7ed59 --- /dev/null +++ b/internal/m2mcore/fsm_test.go @@ -0,0 +1,96 @@ +package m2mcore_test + +import ( + "context" + "errors" + "testing" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +func newTestDeal(t *testing.T) *m2mcore.Deal { + t.Helper() + guid, err := m2mcore.NewUUIDv4() + if err != nil { + t.Fatalf("UUID: %v", err) + } + d, err := m2mcore.NewDeal(m2m.UUID(guid), "00000000-0000-0000-0000-000000000001", []byte("")) + if err != nil { + t.Fatalf("NewDeal: %v", err) + } + return d +} + +func TestFSMAllowedTransitions(t *testing.T) { + cases := []struct { + from m2mcore.State + to m2mcore.State + ok bool + }{ + {m2mcore.StateDraft, m2mcore.StateValidated, true}, + {m2mcore.StateDraft, m2mcore.StateSubmittedToNSD, false}, + {m2mcore.StateValidated, m2mcore.StateSubmittedToNSD, true}, + {m2mcore.StateAwaitingDecision, m2mcore.StateConfirmed, true}, + {m2mcore.StateAwaitingDecision, m2mcore.StateRejected, true}, + {m2mcore.StateAwaitingDecision, m2mcore.StateTimedOut, true}, + {m2mcore.StateConfirmed, m2mcore.StateAwaitingSUB16, true}, + {m2mcore.StateDone, m2mcore.StateRejected, false}, + {m2mcore.StateRejected, m2mcore.StateDone, false}, + } + for _, c := range cases { + if got := m2mcore.CanTransition(c.from, c.to); got != c.ok { + t.Errorf("CanTransition(%s,%s)=%v ожидалось %v", c.from, c.to, got, c.ok) + } + } +} + +func TestFSMIsTerminal(t *testing.T) { + for _, s := range []m2mcore.State{m2mcore.StateDone, m2mcore.StateRejected, m2mcore.StateTimedOut} { + if !m2mcore.IsTerminal(s) { + t.Errorf("%s должно быть терминальным", s) + } + } + for _, s := range []m2mcore.State{m2mcore.StateDraft, m2mcore.StateConfirmed} { + if m2mcore.IsTerminal(s) { + t.Errorf("%s не должно быть терминальным", s) + } + } +} + +func TestDealManualReject(t *testing.T) { + d := newTestDeal(t) + if err := d.SendToManualApproval(context.Background(), "ambiguous_decision"); err != nil { + t.Fatalf("SendToManualApproval из Draft неожиданно дал ошибку %v", err) + } +} + +func TestDealInvalidTransition(t *testing.T) { + d := newTestDeal(t) + // Draft -> SubmittedToNSD не разрешён. + err := d.Submit(context.Background()) + if !errors.Is(err, m2mcore.ErrInvalidTransition) { + t.Errorf("ожидалась ErrInvalidTransition, получено %v", err) + } +} + +func TestDealCompleteSUB16(t *testing.T) { + d := newTestDeal(t) + if err := d.ApproveManually(context.Background(), "operator-1"); err == nil { + // Из Draft нельзя сразу ApproveManually — нужен путь через + // ManualApproval. Это тоже проверяем. + t.Errorf("ApproveManually из Draft должен был отказать") + } + if err := d.SendToManualApproval(context.Background(), "test"); err != nil { + t.Fatal(err) + } + if err := d.ApproveManually(context.Background(), "operator-1"); err != nil { + t.Fatalf("ApproveManually: %v", err) + } + if err := d.CompleteSUB16(context.Background()); err != nil { + t.Fatalf("CompleteSUB16: %v", err) + } + if d.State != m2mcore.StateDone { + t.Errorf("конечное состояние %s, ожидалось %s", d.State, m2mcore.StateDone) + } +} diff --git a/internal/m2mcore/helpers_test.go b/internal/m2mcore/helpers_test.go new file mode 100644 index 0000000..9b0f10a --- /dev/null +++ b/internal/m2mcore/helpers_test.go @@ -0,0 +1,16 @@ +package m2mcore_test + +import ( + "testing" + "time" +) + +// mustParseTime — общий тестовый хелпер. +func mustParseTime(t *testing.T, s string) time.Time { + t.Helper() + tt, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("parse time: %v", err) + } + return tt +} diff --git a/internal/m2mcore/metrics.go b/internal/m2mcore/metrics.go new file mode 100644 index 0000000..2454578 --- /dev/null +++ b/internal/m2mcore/metrics.go @@ -0,0 +1,142 @@ +package m2mcore + +import ( + "fmt" + "io" + "sort" + "strings" + "sync" + "time" +) + +// Recorder — интерфейс записи метрик. NoopRecorder используется по +// умолчанию; реальная реализация (Prometheus) подключается в M2, когда +// внешние Go-зависимости становятся доступны. +type Recorder interface { + StageDuration(stage State, d time.Duration) + IncDeal(state State) + IncSLABreach(stage State, budget string) +} + +// NoopRecorder ничего не пишет. +type NoopRecorder struct{} + +// StageDuration — no-op. +func (NoopRecorder) StageDuration(State, time.Duration) {} + +// IncDeal — no-op. +func (NoopRecorder) IncDeal(State) {} + +// IncSLABreach — no-op. +func (NoopRecorder) IncSLABreach(State, string) {} + +// MemoryRecorder — простая in-memory реализация Recorder, удобная для +// тестов и для /metrics-endpoint в M1 (Prometheus text format). +type MemoryRecorder struct { + mu sync.Mutex + // stageDurations: stage -> сумма длительностей и счётчик + stageDurations map[State]struct { + Sum time.Duration + Count uint64 + } + dealsByState map[State]uint64 + slaBreaches map[string]uint64 // ключ: "stage|budget" +} + +// NewMemoryRecorder создаёт MemoryRecorder с пустыми счётчиками. +func NewMemoryRecorder() *MemoryRecorder { + return &MemoryRecorder{ + stageDurations: make(map[State]struct { + Sum time.Duration + Count uint64 + }), + dealsByState: make(map[State]uint64), + slaBreaches: make(map[string]uint64), + } +} + +// StageDuration фиксирует длительность этапа FSM. +func (m *MemoryRecorder) StageDuration(stage State, d time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + v := m.stageDurations[stage] + v.Sum += d + v.Count++ + m.stageDurations[stage] = v +} + +// IncDeal увеличивает счётчик сделок в заданном итоговом состоянии. +func (m *MemoryRecorder) IncDeal(state State) { + m.mu.Lock() + defer m.mu.Unlock() + m.dealsByState[state]++ +} + +// IncSLABreach фиксирует превышение SLA-бюджета на этапе. +func (m *MemoryRecorder) IncSLABreach(stage State, budget string) { + m.mu.Lock() + defer m.mu.Unlock() + m.slaBreaches[fmt.Sprintf("%s|%s", stage, budget)]++ +} + +// WritePrometheus сериализует накопленные метрики в формате Prometheus +// text exposition (HELP/TYPE + значения). Подходит для /metrics. +func (m *MemoryRecorder) WritePrometheus(w io.Writer) error { + m.mu.Lock() + defer m.mu.Unlock() + + fmt.Fprintf(w, "# HELP m2m_stage_duration_seconds_sum Сумма длительностей этапа в секундах\n") + fmt.Fprintf(w, "# TYPE m2m_stage_duration_seconds_sum counter\n") + stages := sortedStates(m.stageDurations) + for _, s := range stages { + v := m.stageDurations[s] + fmt.Fprintf(w, "m2m_stage_duration_seconds_sum{stage=%q} %f\n", s, v.Sum.Seconds()) + fmt.Fprintf(w, "m2m_stage_duration_seconds_count{stage=%q} %d\n", s, v.Count) + } + + fmt.Fprintf(w, "# HELP m2m_deals_total Сделки по итоговому состоянию\n") + fmt.Fprintf(w, "# TYPE m2m_deals_total counter\n") + dstates := sortedStatesCount(m.dealsByState) + for _, s := range dstates { + fmt.Fprintf(w, "m2m_deals_total{state=%q} %d\n", s, m.dealsByState[s]) + } + + fmt.Fprintf(w, "# HELP m2m_sla_breaches_total Превышения SLA-бюджета\n") + fmt.Fprintf(w, "# TYPE m2m_sla_breaches_total counter\n") + keys := make([]string, 0, len(m.slaBreaches)) + for k := range m.slaBreaches { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + stage, budget, ok := strings.Cut(k, "|") + if !ok { + continue + } + fmt.Fprintf(w, "m2m_sla_breaches_total{stage=%q,budget=%q} %d\n", stage, budget, m.slaBreaches[k]) + } + return nil +} + +// sortedStates возвращает состояния как отсортированный слайс для +// детерминированного вывода. +func sortedStates(m map[State]struct { + Sum time.Duration + Count uint64 +}) []State { + out := make([]State, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func sortedStatesCount(m map[State]uint64) []State { + out := make([]State, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/internal/m2mcore/metrics_test.go b/internal/m2mcore/metrics_test.go new file mode 100644 index 0000000..3d9528c --- /dev/null +++ b/internal/m2mcore/metrics_test.go @@ -0,0 +1,44 @@ +package m2mcore_test + +import ( + "bytes" + "strings" + "testing" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +func TestMemoryRecorderPrometheus(t *testing.T) { + r := m2mcore.NewMemoryRecorder() + r.StageDuration(m2mcore.StateAwaitingDecision, 12*time.Second) + r.StageDuration(m2mcore.StateAwaitingDecision, 8*time.Second) + r.IncDeal(m2mcore.StateConfirmed) + r.IncDeal(m2mcore.StateConfirmed) + r.IncDeal(m2mcore.StateRejected) + r.IncSLABreach(m2mcore.StateAwaitingDecision, "5m") + + var buf bytes.Buffer + if err := r.WritePrometheus(&buf); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{ + `m2m_stage_duration_seconds_count{stage="awaiting_decision"} 2`, + `m2m_stage_duration_seconds_sum{stage="awaiting_decision"} 20`, + `m2m_deals_total{state="confirmed"} 2`, + `m2m_deals_total{state="rejected"} 1`, + `m2m_sla_breaches_total{stage="awaiting_decision",budget="5m"} 1`, + } { + if !strings.Contains(out, want) { + t.Errorf("в выводе нет %q\n---\n%s", want, out) + } + } +} + +func TestNoopRecorder(t *testing.T) { + var r m2mcore.Recorder = m2mcore.NoopRecorder{} + r.StageDuration(m2mcore.StateConfirmed, time.Second) + r.IncDeal(m2mcore.StateConfirmed) + r.IncSLABreach(m2mcore.StateAwaitingDecision, "5m") +} diff --git a/internal/m2mcore/ports.go b/internal/m2mcore/ports.go new file mode 100644 index 0000000..55f49b7 --- /dev/null +++ b/internal/m2mcore/ports.go @@ -0,0 +1,130 @@ +package m2mcore + +import ( + "context" + "errors" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" +) + +// CertInfo — описание подписанта (заполняется CryptoVerifier). +type CertInfo struct { + SignerCN string + SignerINN string + Serial string + NotBefore time.Time + NotAfter time.Time +} + +// Client — анкета инвестора, нужная для enrich Request. +type Client struct { + ID string + LastName string + FirstName string + MiddleName string + BirthDate *time.Time + Document ClientDocument +} + +// ClientDocument — документ, удостоверяющий личность. +type ClientDocument struct { + DocumentType m2m.IdentityDocumentCode + Series string + Number string +} + +// DepoAccount — счёт депо инвестора у депозитария. +type DepoAccount struct { + ID string + ClientID string + DeponentCode string + AccountID m2m.AccountID + SectionID string + DepositoryINN m2m.OrganizationINN +} + +// SecurityBalance — остаток по ценной бумаге на конкретном счёте депо. +type SecurityBalance struct { + SecurityCode m2m.SecurityCode + ISIN m2m.ISIN + QuantityWhole *uint64 + QuantityFractional *m2m.Decimal16 + IsolationStatus m2m.IsolationStatus + ValuedAt time.Time +} + +// NSDSender — порт отправки в НРД (через ИШ или резервный канал WS ONYX). +type NSDSender interface { + // Send отправляет запрос на перевод и возвращает квитанцию НРД. + Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) + // SendDecision отправляет решение принимающей стороны. + SendDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error +} + +// LKCallbackClient — порт уведомления ЛК клиента об изменении статуса. +type LKCallbackClient interface { + UpdateStatus(ctx context.Context, claimID, status, reason string) error +} + +// CryptoVerifier — порт проверки и формирования XMLDSig-подписей. +type CryptoVerifier interface { + VerifyXMLDSig(ctx context.Context, payload []byte) (CertInfo, error) +} + +// FansyStore — порт чтения данных из принимающей БД fansy-store. +type FansyStore interface { + GetClientByID(ctx context.Context, id string) (*Client, error) + GetDepoAccounts(ctx context.Context, clientID string, depositoryINN m2m.OrganizationINN) ([]DepoAccount, error) + GetBalances(ctx context.Context, depoAccountID string, codes []m2m.SecurityCode) ([]SecurityBalance, error) +} + +// ErrNotImplemented возвращается заглушками портов. +var ErrNotImplemented = errors.New("m2mcore: не реализовано (M1 заглушка)") + +// NoopNSDSender — заглушка NSDSender для M1. +type NoopNSDSender struct{} + +// Send возвращает ErrNotImplemented. +func (NoopNSDSender) Send(context.Context, *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) { + return nil, ErrNotImplemented +} + +// SendDecision возвращает ErrNotImplemented. +func (NoopNSDSender) SendDecision(context.Context, *m2m.M2MTransferDecision) error { + return ErrNotImplemented +} + +// NoopLKCallbackClient — заглушка LKCallbackClient для M1. +type NoopLKCallbackClient struct{} + +// UpdateStatus возвращает ErrNotImplemented. +func (NoopLKCallbackClient) UpdateStatus(context.Context, string, string, string) error { + return ErrNotImplemented +} + +// NoopCryptoVerifier — заглушка CryptoVerifier для M1. +type NoopCryptoVerifier struct{} + +// VerifyXMLDSig возвращает ErrNotImplemented. +func (NoopCryptoVerifier) VerifyXMLDSig(context.Context, []byte) (CertInfo, error) { + return CertInfo{}, ErrNotImplemented +} + +// NoopFansyStore — заглушка FansyStore для M1. +type NoopFansyStore struct{} + +// GetClientByID возвращает ErrNotImplemented. +func (NoopFansyStore) GetClientByID(context.Context, string) (*Client, error) { + return nil, ErrNotImplemented +} + +// GetDepoAccounts возвращает ErrNotImplemented. +func (NoopFansyStore) GetDepoAccounts(context.Context, string, m2m.OrganizationINN) ([]DepoAccount, error) { + return nil, ErrNotImplemented +} + +// GetBalances возвращает ErrNotImplemented. +func (NoopFansyStore) GetBalances(context.Context, string, []m2m.SecurityCode) ([]SecurityBalance, error) { + return nil, ErrNotImplemented +} diff --git a/internal/m2mcore/repo.go b/internal/m2mcore/repo.go new file mode 100644 index 0000000..1a341f3 --- /dev/null +++ b/internal/m2mcore/repo.go @@ -0,0 +1,155 @@ +package m2mcore + +import ( + "context" + "errors" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" +) + +// ErrNotFound возвращается, когда сделка не найдена. +var ErrNotFound = errors.New("m2mcore: сделка не найдена") + +// Filter описывает фильтры выборки сделок. +type Filter struct { + State *State + InvestorID string + CreatedFrom *time.Time + CreatedTo *time.Time + Limit int + Offset int +} + +// Repository — порт хранилища сделок. +type Repository interface { + // Create вставляет сделку с идемпотентностью по GUID: повторный вызов + // для существующего GUID возвращает уже существующую сделку (без + // модификации). + Create(ctx context.Context, deal *Deal) (*Deal, error) + // GetByGUID находит сделку по M2M GUID. + GetByGUID(ctx context.Context, guid m2m.UUID) (*Deal, error) + // GetByID находит сделку по внутреннему UUID. + GetByID(ctx context.Context, id string) (*Deal, error) + // Update сохраняет изменения сделки. + Update(ctx context.Context, deal *Deal) error + // List возвращает сделки по фильтру. + List(ctx context.Context, f Filter) ([]*Deal, error) + // AppendEvent добавляет аудит-событие к сделке. + AppendEvent(ctx context.Context, dealID string, ev Event) error +} + +// MemoryRepository — in-memory реализация Repository для тестов и +// dev-стенда без PostgreSQL. +type MemoryRepository struct { + mu sync.RWMutex + byID map[string]*Deal + byGUID map[m2m.UUID]string + events map[string][]Event +} + +// NewMemoryRepository создаёт пустое in-memory хранилище. +func NewMemoryRepository() *MemoryRepository { + return &MemoryRepository{ + byID: make(map[string]*Deal), + byGUID: make(map[m2m.UUID]string), + events: make(map[string][]Event), + } +} + +// Create вставляет сделку или возвращает существующую по GUID. +func (r *MemoryRepository) Create(_ context.Context, deal *Deal) (*Deal, error) { + r.mu.Lock() + defer r.mu.Unlock() + if id, ok := r.byGUID[deal.GUID]; ok { + return r.byID[id], nil + } + r.byID[deal.ID] = deal + r.byGUID[deal.GUID] = deal.ID + return deal, nil +} + +// GetByGUID возвращает сделку по GUID или ErrNotFound. +func (r *MemoryRepository) GetByGUID(_ context.Context, guid m2m.UUID) (*Deal, error) { + r.mu.RLock() + defer r.mu.RUnlock() + id, ok := r.byGUID[guid] + if !ok { + return nil, ErrNotFound + } + return r.byID[id], nil +} + +// GetByID возвращает сделку по внутреннему ID или ErrNotFound. +func (r *MemoryRepository) GetByID(_ context.Context, id string) (*Deal, error) { + r.mu.RLock() + defer r.mu.RUnlock() + d, ok := r.byID[id] + if !ok { + return nil, ErrNotFound + } + return d, nil +} + +// Update в in-memory импликации no-op: указатель уже хранится. +func (r *MemoryRepository) Update(_ context.Context, deal *Deal) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.byID[deal.ID]; !ok { + return ErrNotFound + } + return nil +} + +// List перебирает сделки и фильтрует на лету. +func (r *MemoryRepository) List(_ context.Context, f Filter) ([]*Deal, error) { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*Deal, 0) + for _, d := range r.byID { + if f.State != nil && d.State != *f.State { + continue + } + if f.InvestorID != "" && d.InvestorID != f.InvestorID { + continue + } + if f.CreatedFrom != nil && d.CreatedAt.Before(*f.CreatedFrom) { + continue + } + if f.CreatedTo != nil && d.CreatedAt.After(*f.CreatedTo) { + continue + } + out = append(out, d) + } + if f.Offset > 0 && f.Offset < len(out) { + out = out[f.Offset:] + } else if f.Offset >= len(out) { + return nil, nil + } + if f.Limit > 0 && f.Limit < len(out) { + out = out[:f.Limit] + } + return out, nil +} + +// AppendEvent добавляет событие в журнал сделки. +func (r *MemoryRepository) AppendEvent(_ context.Context, dealID string, ev Event) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.byID[dealID]; !ok { + return ErrNotFound + } + r.events[dealID] = append(r.events[dealID], ev) + return nil +} + +// EventsOf возвращает все события сделки (только для тестов и дев-логов). +func (r *MemoryRepository) EventsOf(dealID string) []Event { + r.mu.RLock() + defer r.mu.RUnlock() + src := r.events[dealID] + out := make([]Event, len(src)) + copy(out, src) + return out +} diff --git a/internal/m2mcore/repo_test.go b/internal/m2mcore/repo_test.go new file mode 100644 index 0000000..f0974d3 --- /dev/null +++ b/internal/m2mcore/repo_test.go @@ -0,0 +1,82 @@ +package m2mcore_test + +import ( + "context" + "errors" + "testing" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +func TestRepoCreateIdempotentByGUID(t *testing.T) { + ctx := context.Background() + repo := m2mcore.NewMemoryRepository() + + // Используем валидный 36-символьный UUID (XSD НРД не требует битов + // версии/варианта, только длину и hex). + const guidStr = "c02a1d5e-c2af-4799-bab4-953f133c5133" + d1, err := m2mcore.NewDeal(m2m.UUID(guidStr), "inv-1", []byte("a")) + if err != nil { + t.Fatal(err) + } + d2, err := m2mcore.NewDeal(m2m.UUID(guidStr), "inv-1", []byte("a")) + if err != nil { + t.Fatal(err) + } + + saved1, err := repo.Create(ctx, d1) + if err != nil { + t.Fatal(err) + } + saved2, err := repo.Create(ctx, d2) + if err != nil { + t.Fatal(err) + } + if saved1.ID != saved2.ID { + t.Errorf("идемпотентность нарушена: %s != %s", saved1.ID, saved2.ID) + } +} + +func TestRepoGetByGUIDNotFound(t *testing.T) { + repo := m2mcore.NewMemoryRepository() + _, err := repo.GetByGUID(context.Background(), m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133")) + if !errors.Is(err, m2mcore.ErrNotFound) { + t.Errorf("ожидалась ErrNotFound, получено %v", err) + } +} + +func TestRepoListFilters(t *testing.T) { + ctx := context.Background() + repo := m2mcore.NewMemoryRepository() + + mkDeal := func(guid, inv string) { + t.Helper() + d, err := m2mcore.NewDeal(m2m.UUID(guid), inv, []byte("x")) + if err != nil { + t.Fatal(err) + } + if _, err := repo.Create(ctx, d); err != nil { + t.Fatal(err) + } + } + mkDeal("00000000-0000-0000-0000-000000000001", "inv-A") + mkDeal("00000000-0000-0000-0000-000000000002", "inv-A") + mkDeal("00000000-0000-0000-0000-000000000003", "inv-B") + + all, err := repo.List(ctx, m2mcore.Filter{}) + if err != nil { + t.Fatal(err) + } + if len(all) != 3 { + t.Errorf("ожидалось 3 сделки, получено %d", len(all)) + } + + onlyA, err := repo.List(ctx, m2mcore.Filter{InvestorID: "inv-A"}) + if err != nil { + t.Fatal(err) + } + if len(onlyA) != 2 { + t.Errorf("ожидалось 2 сделки inv-A, получено %d", len(onlyA)) + } +} diff --git a/internal/m2mcore/uuid.go b/internal/m2mcore/uuid.go new file mode 100644 index 0000000..92cbb7b --- /dev/null +++ b/internal/m2mcore/uuid.go @@ -0,0 +1,20 @@ +package m2mcore + +import ( + "crypto/rand" + "fmt" +) + +// NewUUIDv4 генерирует UUID v4 в каноническом формате 8-4-4-4-12. Без +// внешних зависимостей: использует crypto/rand и выставляет биты +// версии (4) и варианта (RFC 4122) согласно стандарту. +func NewUUIDv4() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("m2mcore: чтение crypto/rand: %w", err) + } + b[6] = (b[6] & 0x0F) | 0x40 // версия 4 в старших битах 7-го байта + b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 вариант (10xx) в 9-м байте + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil +} diff --git a/internal/m2mcore/uuid_test.go b/internal/m2mcore/uuid_test.go new file mode 100644 index 0000000..5d3ceac --- /dev/null +++ b/internal/m2mcore/uuid_test.go @@ -0,0 +1,45 @@ +package m2mcore_test + +import ( + "regexp" + "testing" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +func TestNewUUIDv4Format(t *testing.T) { + pattern := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + seen := make(map[string]struct{}) + for i := 0; i < 100; i++ { + u, err := m2mcore.NewUUIDv4() + if err != nil { + t.Fatal(err) + } + if !pattern.MatchString(u) { + t.Errorf("UUID %q не соответствует RFC4122 v4", u) + } + if _, dup := seen[u]; dup { + t.Errorf("сгенерирован дубликат %q", u) + } + seen[u] = struct{}{} + // Также должен проходить XSD-валидатор НРД. + if err := (m2m.UUID(u)).Validate(); err != nil { + t.Errorf("UUID %q отвергнут XSD-валидатором: %v", u, err) + } + } +} + +func TestNewReferenceID(t *testing.T) { + at := mustParseTime(t, "2026-03-02T14:30:45Z") + id, err := m2mcore.NewReferenceID(at) + if err != nil { + t.Fatal(err) + } + if len(id) != 16 { + t.Errorf("ReferenceID длина %d, ожидалось 16", len(id)) + } + if err := id.Validate(); err != nil { + t.Errorf("ReferenceID %q отвергнут валидатором: %v", id, err) + } +} diff --git a/migrations/m2m-core/001__deals.sql b/migrations/m2m-core/001__deals.sql new file mode 100644 index 0000000..26a7518 --- /dev/null +++ b/migrations/m2m-core/001__deals.sql @@ -0,0 +1,40 @@ +-- 001__deals.sql +-- Таблицы для PostgreSQL-реализации Repository в M2 (когда pgx будет +-- доступен через прокси). В M1 m2m-core работает с MemoryRepository. + +CREATE SCHEMA IF NOT EXISTS m2m_core; +COMMENT ON SCHEMA m2m_core IS 'Состояние сделок M2M, события и аудит.'; + +CREATE TABLE IF NOT EXISTS m2m_core.deals ( + id uuid PRIMARY KEY, + guid uuid NOT NULL UNIQUE, + state varchar(32) NOT NULL, + investor_id uuid, + signed_claim bytea, + request_xml bytea, + response_xml bytea, + decision_xml bytea, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE m2m_core.deals IS 'Корневая запись о M2M-сделке. Уникальность по guid обеспечивает идемпотентность.'; +COMMENT ON COLUMN m2m_core.deals.guid IS 'GUID, который ушёл в M2MTransferRequest.Header.GUID.'; +COMMENT ON COLUMN m2m_core.deals.state IS 'Текущее состояние FSM: draft/validated/submitted_to_nsd/awaiting_decision/confirmed/awaiting_sub16/done/rejected/timed_out/manual_approval.'; + +CREATE INDEX IF NOT EXISTS idx_deals_state ON m2m_core.deals(state); +CREATE INDEX IF NOT EXISTS idx_deals_investor ON m2m_core.deals(investor_id) WHERE investor_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_deals_created ON m2m_core.deals(created_at DESC); + +CREATE TABLE IF NOT EXISTS m2m_core.deal_events ( + id bigserial PRIMARY KEY, + deal_id uuid NOT NULL REFERENCES m2m_core.deals(id) ON DELETE CASCADE, + type varchar(64) NOT NULL, + payload jsonb, + actor text, + created_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE m2m_core.deal_events IS 'Журнал событий сделки (event-sourcing для аудита).'; +COMMENT ON COLUMN m2m_core.deal_events.actor IS 'Кто инициировал событие: system, nsd, operator-, lk-callback и т.д.'; + +CREATE INDEX IF NOT EXISTS idx_deal_events_deal ON m2m_core.deal_events(deal_id, created_at); +CREATE INDEX IF NOT EXISTS idx_deal_events_type ON m2m_core.deal_events(type);