feat(m2m-core): FSM сделки, репозиторий, идемпотентность по GUID, метрики SLA
- internal/m2mcore/fsm.go: конечный автомат с переходами Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed → AwaitingSUB16 → Done, ветки Rejected/TimedOut/ManualApproval - internal/m2mcore/deal.go: доменная модель Deal с методами Validate/Submit/ReceiveDecision/Timeout/SendToManualApproval/ApproveManually/RejectManually/CompleteSUB16, журнал событий - internal/m2mcore/uuid.go: генератор UUID v4 без внешних зависимостей (crypto/rand) - internal/m2mcore/repo.go: порт Repository + MemoryRepository с идемпотентным Create по GUID - internal/m2mcore/ports.go: порты NSDSender/LKCallbackClient/CryptoVerifier/FansyStore с no-op заглушками для M1 - internal/m2mcore/enrich.go: EnrichRequest — сборка M2MTransferRequest из ClaimInput + Fansy, генерация ReferenceID по каждой ЦБ - internal/m2mcore/metrics.go: порт Recorder + MemoryRecorder в Prometheus-text формате - cmd/m2m-core/main.go: HTTP-сервер с /healthz и /metrics, graceful shutdown - migrations/m2m-core/001__deals.sql: схема для PostgreSQL-Repository (для M2) Покрытие: 63.1%. make ci зелёный. Без внешних Go-зависимостей (pgx и prometheus подключим в M2, когда прокси zetit откроет Go-модули). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("<xml/>"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user