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