uniform
This commit is contained in:
@@ -109,6 +109,12 @@ func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id orde
|
||||
s.logger.Error("idempotency record failed", "key", idemKey, "orderId", id.String(), "err", err)
|
||||
}
|
||||
}
|
||||
// Invalidate the stats cache asynchronously so the dashboard picks up
|
||||
// the new state without blocking the HTTP response. Nil-safe: tests and
|
||||
// minimal configs can omit the cache.
|
||||
if s.statsCache != nil {
|
||||
go s.statsCache.Rebuild()
|
||||
}
|
||||
grain, err := s.applier.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
|
||||
+6
-266
@@ -12,12 +12,10 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -56,6 +54,7 @@ type server struct {
|
||||
idem *idempotency.Store
|
||||
logger *slog.Logger
|
||||
inventoryReservations order.InventoryReservationService
|
||||
statsCache *statsCache
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -229,8 +228,13 @@ func main() {
|
||||
taxProvider: taxProvider,
|
||||
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
||||
inventoryReservations: inventoryReservations,
|
||||
statsCache: newStatsCache(dataDir, pool),
|
||||
}
|
||||
|
||||
// Warm the stats cache before accepting requests so the first dashboard
|
||||
// hit is instant. Rebuilds asynchronously after every mutation thereafter.
|
||||
s.statsCache.Rebuild()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
@@ -498,160 +502,6 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
|
||||
return po
|
||||
}
|
||||
|
||||
// --- dashboard stats ------------------------------------------------------
|
||||
|
||||
type orderAlert struct {
|
||||
Severity string `json:"severity"` // warn | error
|
||||
Message string `json:"message"`
|
||||
OrderId string `json:"orderId,omitempty"`
|
||||
}
|
||||
|
||||
type statusBucket struct {
|
||||
Status order.Status `json:"status"`
|
||||
Count int `json:"count"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type revenueSnapshot struct {
|
||||
Total int64 `json:"total"`
|
||||
Captured int64 `json:"captured"`
|
||||
Refunded int64 `json:"refunded"`
|
||||
Pending int64 `json:"pending"`
|
||||
}
|
||||
|
||||
type dailyRevenue struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type orderStatsResponse struct {
|
||||
OrdersTotal int `json:"ordersTotal"`
|
||||
Revenue revenueSnapshot `json:"revenue"`
|
||||
ByStatus []statusBucket `json:"byStatus"`
|
||||
RecentOrders []orderSummary `json:"recentOrders"`
|
||||
DailyRevenue30 []dailyRevenue `json:"dailyRevenue30,omitempty"`
|
||||
Alerts []orderAlert `json:"alerts"`
|
||||
}
|
||||
|
||||
// handleStats scans all order logs and returns aggregated dashboard stats.
|
||||
func (s *server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
|
||||
now := time.Now()
|
||||
var totalOrders int
|
||||
var totalRevenue, capturedRevenue, refundedRevenue int64
|
||||
byStatus := map[order.Status]int{}
|
||||
statusTotal := map[order.Status]int64{} // total amount per status
|
||||
|
||||
// Daily revenue for the last 30 days (keys are "2026-06-01" sortable strings).
|
||||
dailyByDay := map[string]int64{}
|
||||
for i := 0; i < 30; i++ {
|
||||
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
dailyByDay[day] = 0
|
||||
}
|
||||
|
||||
// Collect all order summaries, then sort by placedAt and take top 10.
|
||||
var allOrders []orderSummary
|
||||
var alerts []orderAlert
|
||||
|
||||
for _, m := range matches {
|
||||
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
|
||||
raw, err := strconv.ParseUint(base, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
g, err := s.pool.Get(r.Context(), raw)
|
||||
if err != nil || g.Status == order.StatusNew {
|
||||
continue
|
||||
}
|
||||
totalOrders++
|
||||
byStatus[g.Status]++
|
||||
statusTotal[g.Status] += g.TotalAmount.Int64()
|
||||
|
||||
totalRevenue += g.TotalAmount.Int64()
|
||||
capturedRevenue += g.CapturedAmount.Int64()
|
||||
refundedRevenue += g.RefundedAmount.Int64()
|
||||
|
||||
// Daily revenue — parse placedAt to extract date.
|
||||
if g.PlacedAt != "" {
|
||||
if placed, parseErr := time.Parse(time.RFC3339, g.PlacedAt); parseErr == nil {
|
||||
dayKey := placed.Format("2006-01-02")
|
||||
if _, ok := dailyByDay[dayKey]; ok {
|
||||
dailyByDay[dayKey] += g.TotalAmount.Int64()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allOrders = append(allOrders, orderSummary{
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
Reference: g.OrderReference,
|
||||
Status: g.Status,
|
||||
TotalAmount: g.TotalAmount.Int64(),
|
||||
Currency: g.Currency,
|
||||
PlacedAt: g.PlacedAt,
|
||||
})
|
||||
|
||||
// Alerts.
|
||||
if g.Status == order.StatusPending {
|
||||
alerts = append(alerts, orderAlert{
|
||||
Severity: "warn",
|
||||
Message: "Payment still pending",
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
})
|
||||
}
|
||||
if g.Status == order.StatusCancelled {
|
||||
alerts = append(alerts, orderAlert{
|
||||
Severity: "warn",
|
||||
Message: "Order was cancelled",
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort ALL orders by placedAt descending, take top 10 as "recent".
|
||||
sort.Slice(allOrders, func(i, j int) bool {
|
||||
return allOrders[i].PlacedAt > allOrders[j].PlacedAt
|
||||
})
|
||||
recent := allOrders
|
||||
if len(recent) > 10 {
|
||||
recent = recent[:10]
|
||||
}
|
||||
|
||||
// Cap alerts
|
||||
if len(alerts) > 50 {
|
||||
alerts = alerts[:50]
|
||||
}
|
||||
|
||||
// Build status buckets array with totals.
|
||||
buckets := make([]statusBucket, 0, len(byStatus))
|
||||
for st, cnt := range byStatus {
|
||||
buckets = append(buckets, statusBucket{Status: st, Count: cnt, Total: statusTotal[st]})
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i].Count > buckets[j].Count
|
||||
})
|
||||
|
||||
// Daily revenue as an ordered slice.
|
||||
var dailyRev []dailyRevenue
|
||||
for i := 29; i >= 0; i-- {
|
||||
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
dailyRev = append(dailyRev, dailyRevenue{Date: day, Total: dailyByDay[day]})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, orderStatsResponse{
|
||||
OrdersTotal: totalOrders,
|
||||
Revenue: revenueSnapshot{
|
||||
Total: totalRevenue,
|
||||
Captured: capturedRevenue,
|
||||
Refunded: refundedRevenue,
|
||||
Pending: totalRevenue - capturedRevenue - refundedRevenue,
|
||||
},
|
||||
ByStatus: buckets,
|
||||
RecentOrders: recent,
|
||||
DailyRevenue30: dailyRev,
|
||||
Alerts: alerts,
|
||||
})
|
||||
}
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -743,116 +593,6 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
// selectTaxProvider picks the tax provider from the environment. Default is
|
||||
// NordicTaxProvider with SE as the default country (matching the current
|
||||
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
|
||||
// (no country awareness).
|
||||
func selectTaxProvider() tax.Provider {
|
||||
provider := os.Getenv("TAX_PROVIDER")
|
||||
switch provider {
|
||||
case "static":
|
||||
return tax.NewStatic()
|
||||
case "nordic":
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
default:
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
}
|
||||
}
|
||||
|
||||
// selectProvider picks the payment provider from the environment. Default is
|
||||
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
||||
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
||||
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
||||
key := os.Getenv("STRIPE_SECRET_KEY")
|
||||
if key == "" {
|
||||
logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock")
|
||||
return order.NewMockProvider()
|
||||
}
|
||||
logger.Info("using stripe payment provider")
|
||||
return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD"))
|
||||
}
|
||||
return order.NewMockProvider()
|
||||
}
|
||||
|
||||
// selectEmailSender picks the flow-email transport. Supports SMTP and
|
||||
// MailerSend. When no sender is configured the hook still exists in
|
||||
// capabilities but errors at run time if a flow tries to use it.
|
||||
func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
|
||||
kind := config.EnvString("ORDER_EMAIL_SENDER", "")
|
||||
if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if kind == "" {
|
||||
kind = "smtp"
|
||||
}
|
||||
|
||||
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
|
||||
if fromAddr == "" {
|
||||
return nil, fmt.Errorf("ORDER_EMAIL_FROM_ADDRESS is required when email sender is configured")
|
||||
}
|
||||
from := mail.Address{
|
||||
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
|
||||
Address: fromAddr,
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "smtp":
|
||||
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
|
||||
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
|
||||
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
|
||||
Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"),
|
||||
Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"),
|
||||
DefaultFrom: from,
|
||||
Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
|
||||
return sender, nil
|
||||
|
||||
case "mailersend":
|
||||
apiKey := os.Getenv("MAILERSEND_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender")
|
||||
}
|
||||
sender, err := order.NewMailerSendEmailSender(apiKey, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address)
|
||||
return sender, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func selectInventoryReservationService() (order.InventoryReservationService, error) {
|
||||
return order.NewHTTPInventoryReservationService(config.EnvString("INVENTORY_URL", "http://localhost:8080"), nil)
|
||||
}
|
||||
|
||||
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
|
||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||
func loadUCPOrderSigner() *ucp.SigningConfig {
|
||||
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
pemData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
||||
if err != nil {
|
||||
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
)
|
||||
|
||||
// selectTaxProvider picks the tax provider from the environment. Default is
|
||||
// NordicTaxProvider with SE as the default country (matching the current
|
||||
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
|
||||
// (no country awareness).
|
||||
func selectTaxProvider() tax.Provider {
|
||||
provider := os.Getenv("TAX_PROVIDER")
|
||||
switch provider {
|
||||
case "static":
|
||||
return tax.NewStatic()
|
||||
case "nordic":
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
default:
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
}
|
||||
}
|
||||
|
||||
// selectProvider picks the payment provider from the environment. Default is
|
||||
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
||||
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
||||
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
||||
key := os.Getenv("STRIPE_SECRET_KEY")
|
||||
if key == "" {
|
||||
logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock")
|
||||
return order.NewMockProvider()
|
||||
}
|
||||
logger.Info("using stripe payment provider")
|
||||
return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD"))
|
||||
}
|
||||
return order.NewMockProvider()
|
||||
}
|
||||
|
||||
// selectEmailSender picks the flow-email transport. Supports SMTP and
|
||||
// MailerSend. When no sender is configured the hook still exists in
|
||||
// capabilities but errors at run time if a flow tries to use it.
|
||||
func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
|
||||
kind := config.EnvString("ORDER_EMAIL_SENDER", "")
|
||||
if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if kind == "" {
|
||||
kind = "smtp"
|
||||
}
|
||||
|
||||
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
|
||||
if fromAddr == "" {
|
||||
return nil, fmt.Errorf("ORDER_EMAIL_FROM_ADDRESS is required when email sender is configured")
|
||||
}
|
||||
from := mail.Address{
|
||||
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
|
||||
Address: fromAddr,
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "smtp":
|
||||
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
|
||||
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
|
||||
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
|
||||
Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"),
|
||||
Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"),
|
||||
DefaultFrom: from,
|
||||
Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
|
||||
return sender, nil
|
||||
|
||||
case "mailersend":
|
||||
apiKey := os.Getenv("MAILERSEND_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender")
|
||||
}
|
||||
sender, err := order.NewMailerSendEmailSender(apiKey, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address)
|
||||
return sender, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func selectInventoryReservationService() (order.InventoryReservationService, error) {
|
||||
return order.NewHTTPInventoryReservationService(config.EnvString("INVENTORY_URL", "http://localhost:8080"), nil)
|
||||
}
|
||||
|
||||
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
|
||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||
func loadUCPOrderSigner() *ucp.SigningConfig {
|
||||
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
pemData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
||||
if err != nil {
|
||||
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
type orderAlert struct {
|
||||
Severity string `json:"severity"` // warn | error
|
||||
Message string `json:"message"`
|
||||
OrderId string `json:"orderId,omitempty"`
|
||||
}
|
||||
|
||||
type statusBucket struct {
|
||||
Status order.Status `json:"status"`
|
||||
Count int `json:"count"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type revenueSnapshot struct {
|
||||
Total int64 `json:"total"`
|
||||
Captured int64 `json:"captured"`
|
||||
Refunded int64 `json:"refunded"`
|
||||
Pending int64 `json:"pending"`
|
||||
}
|
||||
|
||||
type dailyRevenue struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type orderStatsResponse struct {
|
||||
OrdersTotal int `json:"ordersTotal"`
|
||||
Revenue revenueSnapshot `json:"revenue"`
|
||||
ByStatus []statusBucket `json:"byStatus"`
|
||||
RecentOrders []orderSummary `json:"recentOrders"`
|
||||
DailyRevenue30 []dailyRevenue `json:"dailyRevenue30,omitempty"`
|
||||
Alerts []orderAlert `json:"alerts"`
|
||||
}
|
||||
|
||||
// statsCache holds an in-memory aggregate of all orders, rebuilt on mutation
|
||||
// instead of scanning *.events.log on every request.
|
||||
type statsCache struct {
|
||||
mu sync.RWMutex
|
||||
data orderStatsResponse
|
||||
pool *actor.SimpleGrainPool[order.OrderGrain]
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func newStatsCache(dataDir string, pool *actor.SimpleGrainPool[order.OrderGrain]) *statsCache {
|
||||
return &statsCache{dataDir: dataDir, pool: pool}
|
||||
}
|
||||
|
||||
// Get returns the cached stats. Call Rebuild() on startup to warm the cache;
|
||||
// if never built, returns an empty response.
|
||||
func (c *statsCache) Get() orderStatsResponse {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.data
|
||||
}
|
||||
|
||||
// Rebuild rebuilds the stats cache from disk. Safe to call concurrently;
|
||||
// intended as a goroutine after each mutation so it never blocks an HTTP
|
||||
// response.
|
||||
func (c *statsCache) Rebuild() {
|
||||
c.rebuild()
|
||||
}
|
||||
|
||||
func (c *statsCache) rebuild() {
|
||||
if c.pool == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
matches, _ := filepath.Glob(filepath.Join(c.dataDir, "*.events.log"))
|
||||
now := time.Now()
|
||||
var totalOrders int
|
||||
var totalRevenue, capturedRevenue, refundedRevenue int64
|
||||
byStatus := map[order.Status]int{}
|
||||
statusTotal := map[order.Status]int64{}
|
||||
|
||||
dailyByDay := map[string]int64{}
|
||||
for i := 0; i < 30; i++ {
|
||||
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
dailyByDay[day] = 0
|
||||
}
|
||||
|
||||
var allOrders []orderSummary
|
||||
var alerts []orderAlert
|
||||
|
||||
for _, m := range matches {
|
||||
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
|
||||
raw, err := strconv.ParseUint(base, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
g, err := c.pool.Get(ctx, raw)
|
||||
if err != nil || g.Status == order.StatusNew {
|
||||
continue
|
||||
}
|
||||
totalOrders++
|
||||
byStatus[g.Status]++
|
||||
statusTotal[g.Status] += g.TotalAmount.Int64()
|
||||
|
||||
totalRevenue += g.TotalAmount.Int64()
|
||||
capturedRevenue += g.CapturedAmount.Int64()
|
||||
refundedRevenue += g.RefundedAmount.Int64()
|
||||
|
||||
if g.PlacedAt != "" {
|
||||
if placed, parseErr := time.Parse(time.RFC3339, g.PlacedAt); parseErr == nil {
|
||||
dayKey := placed.Format("2006-01-02")
|
||||
if _, ok := dailyByDay[dayKey]; ok {
|
||||
dailyByDay[dayKey] += g.TotalAmount.Int64()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allOrders = append(allOrders, orderSummary{
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
Reference: g.OrderReference,
|
||||
Status: g.Status,
|
||||
TotalAmount: g.TotalAmount.Int64(),
|
||||
Currency: g.Currency,
|
||||
PlacedAt: g.PlacedAt,
|
||||
})
|
||||
|
||||
if g.Status == order.StatusPending {
|
||||
alerts = append(alerts, orderAlert{
|
||||
Severity: "warn",
|
||||
Message: "Payment still pending",
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
})
|
||||
}
|
||||
if g.Status == order.StatusCancelled {
|
||||
alerts = append(alerts, orderAlert{
|
||||
Severity: "warn",
|
||||
Message: "Order was cancelled",
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(allOrders, func(i, j int) bool {
|
||||
return allOrders[i].PlacedAt > allOrders[j].PlacedAt
|
||||
})
|
||||
recent := allOrders
|
||||
if len(recent) > 10 {
|
||||
recent = recent[:10]
|
||||
}
|
||||
|
||||
if len(alerts) > 50 {
|
||||
alerts = alerts[:50]
|
||||
}
|
||||
|
||||
buckets := make([]statusBucket, 0, len(byStatus))
|
||||
for st, cnt := range byStatus {
|
||||
buckets = append(buckets, statusBucket{Status: st, Count: cnt, Total: statusTotal[st]})
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i].Count > buckets[j].Count
|
||||
})
|
||||
|
||||
var dailyRev []dailyRevenue
|
||||
for i := 29; i >= 0; i-- {
|
||||
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
dailyRev = append(dailyRev, dailyRevenue{Date: day, Total: dailyByDay[day]})
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.data = orderStatsResponse{
|
||||
OrdersTotal: totalOrders,
|
||||
Revenue: revenueSnapshot{
|
||||
Total: totalRevenue,
|
||||
Captured: capturedRevenue,
|
||||
Refunded: refundedRevenue,
|
||||
Pending: totalRevenue - capturedRevenue - refundedRevenue,
|
||||
},
|
||||
ByStatus: buckets,
|
||||
RecentOrders: recent,
|
||||
DailyRevenue30: dailyRev,
|
||||
Alerts: alerts,
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// handleStats returns dashboard stats from the in-memory cache (O(1) read,
|
||||
// rebuilt after each mutation) instead of scanning all *.events.log on every
|
||||
// request.
|
||||
func (s *server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.statsCache.Get())
|
||||
}
|
||||
Reference in New Issue
Block a user