fix: harden terminal-scoped analytics contracts

Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
This commit is contained in:
2026-08-20 21:29:18 +03:30
parent 857f29635e
commit d6de48fd15
14 changed files with 577 additions and 103 deletions

View File

@@ -49,9 +49,10 @@ type PaymentHistoryPoint struct {
}
type Service struct {
pool *pgxpool.Pool
mu sync.RWMutex
runs map[string]storedRun
pool *pgxpool.Pool
cursorSecret []byte
mu sync.RWMutex
runs map[string]storedRun
}
type storedRun struct {
@@ -59,7 +60,9 @@ type storedRun struct {
run Run
}
func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, runs: map[string]storedRun{}} }
func New(pool *pgxpool.Pool, cursorSecret string) *Service {
return &Service{pool: pool, cursorSecret: []byte(cursorSecret), runs: map[string]storedRun{}}
}
func (s *Service) Periods(ctx context.Context) ([]Period, error) {
var start, end time.Time
@@ -105,21 +108,27 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
run.Sections["overview"] = map[string]any{"sectionId": "overview", "state": "READY", "metadata": metadata, "data": metrics.overview(period, history)}
run.Sections["paymentPerformance"] = map[string]any{"sectionId": "payment_performance", "state": "READY", "metadata": metadata, "data": metrics.performance()}
run.Sections["fees"] = map[string]any{"sectionId": "fees", "state": "READY", "metadata": metadata, "data": metrics.fees(period)}
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
if err != nil {
return Run{}, err
}
if peerCount < 10 || peerSessions < 1000 {
if !peerComparisonSupported(terminalID) {
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
"reason": "INSUFFICIENT_COHORT", "eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions,
"reason": "TERMINAL_SCOPE_UNSUPPORTED", "eligiblePeerCount": 0, "combinedPeerSessionCount": 0,
"requiredPeerCount": 10, "requiredPeerSessionCount": 1000}
} else {
peerMetrics, err := s.peerMetrics(ctx, merchantID, period)
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
if err != nil {
return Run{}, err
}
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "READY", "metadata": metadata,
"data": map[string]any{"eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions, "metrics": peerMetrics}}
if peerCount < 10 || peerSessions < 1000 {
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
"reason": "INSUFFICIENT_COHORT", "eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions,
"requiredPeerCount": 10, "requiredPeerSessionCount": 1000}
} else {
peerMetrics, err := s.peerMetrics(ctx, merchantID, period)
if err != nil {
return Run{}, err
}
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "READY", "metadata": metadata,
"data": map[string]any{"eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions, "metrics": peerMetrics}}
}
}
s.mu.Lock()
s.runs[key] = storedRun{merchantID: merchantID, run: run}
@@ -128,6 +137,8 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
return run, nil
}
func peerComparisonSupported(terminalID string) bool { return terminalID == "" }
func (s *Service) Run(merchantID int64, runID string) (Run, bool) {
s.mu.RLock()
stored, ok := s.runs[runID]