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:
fontvielle
2026-05-14 00:52:12 +03:00
parent a040f8b07d
commit 9e6e95f431
16 changed files with 1455 additions and 1 deletions
+155
View File
@@ -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
}