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
+142
View File
@@ -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
}