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,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
|
||||
}
|
||||
Reference in New Issue
Block a user