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:
@@ -14,7 +14,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
|
|||||||
| PR-1 | `PR-1-go-models-m2m.md` | выполнено | — |
|
| PR-1 | `PR-1-go-models-m2m.md` | выполнено | — |
|
||||||
| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
|
| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
|
||||||
| PR-3 | `PR-3-lk-openapi.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-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 |
|
||||||
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
|
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-<login>, 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);
|
||||||
Reference in New Issue
Block a user