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