9e6e95f431
- 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>
143 lines
4.4 KiB
Go
143 lines
4.4 KiB
Go
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
|
|
}
|