feat: add persisted page insight platform
This commit is contained in:
114
internal/insight/service.go
Normal file
114
internal/insight/service.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package insight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repository *Repository
|
||||
builder Builder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(repository *Repository, builder Builder) *Service {
|
||||
return &Service{repository: repository, builder: builder, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) Latest(ctx context.Context, merchantID int64, terminalID string, module Module) (Snapshot, error) {
|
||||
if terminalID != "" {
|
||||
// Validation is deliberately tenant-scoped and returns the same public result as a missing terminal.
|
||||
if _, err := s.builder.Build(ctx, merchantID, terminalID, module); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
}
|
||||
return s.repository.Latest(ctx, merchantID, terminalID, module)
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, merchantID int64, terminalID string, module Module, idempotencyKey string) (Job, bool, error) {
|
||||
return s.enqueue(ctx, merchantID, terminalID, module, "MANUAL", true, idempotencyKey)
|
||||
}
|
||||
|
||||
func (s *Service) Enqueue(ctx context.Context, merchantID int64, terminalID string, module Module, reason string) (Job, bool, error) {
|
||||
return s.enqueue(ctx, merchantID, terminalID, module, reason, false, "")
|
||||
}
|
||||
|
||||
func (s *Service) EnqueueAll(ctx context.Context, reason string) (int, error) {
|
||||
scopes, err := s.repository.MerchantScopes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
modules := []Module{ModuleOverview, ModulePayments, ModuleFees, ModulePeers, ModuleTransactions, ModuleGrowth}
|
||||
created := 0
|
||||
for _, scope := range scopes {
|
||||
for _, module := range modules {
|
||||
if _, cached, err := s.Enqueue(ctx, scope.MerchantID, scope.TerminalID, module, reason); err != nil {
|
||||
return created, err
|
||||
} else if !cached {
|
||||
created++
|
||||
}
|
||||
}
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Service) enqueue(ctx context.Context, merchantID int64, terminalID string, module Module, reason string, manual bool, key string) (Job, bool, error) {
|
||||
watermark, dataThrough, err := s.repository.DatasetMetadata(ctx)
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
catalog, err := s.repository.ActiveCatalogVersion(ctx)
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
tc := BuildTimeContext(s.now(), dataThrough)
|
||||
fingerprint := hash(fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s", merchantID, terminalID, module, tc.AsOfDate, watermark, catalog, AnalyticsVersion, "1.0.0"))
|
||||
requestHash := hash(fmt.Sprintf("%d|%s|%s|%t", merchantID, terminalID, module, manual))
|
||||
return s.repository.Create(ctx, CreateRequest{MerchantID: merchantID, TerminalID: terminalID, Module: module, TriggerReason: reason, ManualRefresh: manual, IdempotencyKey: key, RequestHash: requestHash, Fingerprint: fingerprint, Time: tc, Confidence: confidenceFor(tc.Freshness), CatalogVersion: catalog, Evidence: []byte(`{}`)})
|
||||
}
|
||||
|
||||
func (s *Service) GetJob(ctx context.Context, merchantID int64, jobID string) (Job, error) {
|
||||
return s.repository.GetJob(ctx, merchantID, jobID)
|
||||
}
|
||||
|
||||
func (s *Service) EventsAfter(ctx context.Context, merchantID int64, jobID string, after int64) ([]Event, error) {
|
||||
return s.repository.EventsAfter(ctx, merchantID, jobID, after)
|
||||
}
|
||||
|
||||
func (s *Service) ProcessNext(ctx context.Context) error {
|
||||
job, err := s.repository.Claim(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
analysis, err := s.builder.Build(ctx, job.MerchantID, job.TerminalID, job.Module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building %s insight: %w", job.Module, err)
|
||||
}
|
||||
// The deterministic copy is always publishable. A provider can replace it only after
|
||||
// evidence-reference validation; until then PARTIAL is explicit and last-good remains visible.
|
||||
return s.repository.Complete(ctx, job, analysis, "PARTIAL", "DETERMINISTIC_FALLBACK")
|
||||
}
|
||||
|
||||
func confidenceFor(f Freshness) Confidence {
|
||||
if f == FreshnessStale {
|
||||
return ConfidenceLow
|
||||
}
|
||||
return ConfidenceMedium
|
||||
}
|
||||
|
||||
func hash(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
value := make([]byte, 12)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
panic("crypto/rand unavailable")
|
||||
}
|
||||
return prefix + hex.EncodeToString(value)
|
||||
}
|
||||
Reference in New Issue
Block a user