feat: deliver authenticated merchant analytics API
This commit is contained in:
@@ -8,5 +8,5 @@ IMPORT_PATH=data/incoming/challenge_data.csv.gz
|
||||
DEMO_USERNAME=merchant-0001
|
||||
DEMO_PASSWORD=replace-with-at-least-12-characters
|
||||
SESSION_SECRET=replace-with-at-least-32-random-characters
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
FRONTEND_ORIGIN=http://127.0.0.1:5173
|
||||
COOKIE_SECURE=false
|
||||
|
||||
22
README.md
22
README.md
@@ -2,7 +2,27 @@
|
||||
|
||||
Go backend for merchant identity, dataset ingestion, deterministic analytics, authenticated APIs, and later AvalAI integration.
|
||||
|
||||
Current state: specifications, architecture, implementation plan, and task list are approved. Phase 4 implementation is authorized; the Go scaffold begins with Task F1.
|
||||
Current state: the local MVP implements database migration, streaming dataset import,
|
||||
secure demo identity, deterministic dashboard analytics, merchant-scoped details/CSV,
|
||||
the authenticated HTTP API, and a Redis-connected worker boundary.
|
||||
|
||||
## Local development
|
||||
|
||||
Copy `.env.example` to the ignored `.env`, load its variables in your shell, and run:
|
||||
|
||||
```text
|
||||
go run ./cmd/migrate
|
||||
go run ./cmd/importer
|
||||
go run ./cmd/admin
|
||||
go run ./cmd/worker
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
`cmd/admin` idempotently creates or resets `DEMO_USERNAME` using `DEMO_PASSWORD`
|
||||
and assigns it to the imported merchant with the largest session population. It
|
||||
never prints the password or stores it outside the Argon2id hash. The local API
|
||||
uses the `zpa_session` HttpOnly cookie when `COOKIE_SECURE=false`; secure
|
||||
deployments use the contract's `__Host-zpa_session` cookie.
|
||||
|
||||
## Local dataset input
|
||||
|
||||
|
||||
51
cmd/api/main.go
Normal file
51
cmd/api/main.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/api"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
logger.Error("configuration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := cfg.ValidateAPI(); err != nil {
|
||||
logger.Error("api configuration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
pool, err := database.Open(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
logger.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
handler := api.NewServer(identity.New(pool), analytics.New(pool), logger, cfg.FrontendOrigin, cfg.CookieSecure).Handler()
|
||||
server := &http.Server{Addr: cfg.HTTPAddr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 65 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
logger.Info("api listening", "address", cfg.HTTPAddr)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("api failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
33
cmd/worker/main.go
Normal file
33
cmd/worker/main.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
logger.Error("configuration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
client := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
|
||||
defer client.Close()
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer pingCancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
logger.Error("redis connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("analytics worker ready", "redis_address", cfg.RedisAddr)
|
||||
<-ctx.Done()
|
||||
}
|
||||
132
internal/analytics/details.go
Normal file
132
internal/analytics/details.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type DetailFilter struct {
|
||||
View string
|
||||
FailureStage string
|
||||
TerminalID string
|
||||
Cursor string
|
||||
Limit int
|
||||
}
|
||||
type Attempt struct {
|
||||
Sequence int `json:"sequence"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
InitTimeMS *int `json:"initTimeMs"`
|
||||
VerifyTimeMS *int `json:"verifyTimeMs"`
|
||||
}
|
||||
type Detail struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
SessionStatus string `json:"sessionStatus"`
|
||||
AmountRial int64 `json:"amountRial"`
|
||||
FeeAmountRial int64 `json:"feeAmountRial"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
VerifiedAt *time.Time `json:"verifiedAt"`
|
||||
SettledAt *time.Time `json:"settledAt"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
FailureStage *string `json:"failureStage"`
|
||||
Attempts []Attempt `json:"attempts"`
|
||||
}
|
||||
|
||||
func (s *Service) Details(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]Detail, string, error) {
|
||||
if filter.Limit <= 0 {
|
||||
filter.Limit = 25
|
||||
}
|
||||
if filter.Limit > 100 {
|
||||
return nil, "", errors.New("analytics: detail limit exceeds 100")
|
||||
}
|
||||
offset := 0
|
||||
if filter.Cursor != "" {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(filter.Cursor)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("analytics: invalid cursor")
|
||||
}
|
||||
offset, err = strconv.Atoi(string(decoded))
|
||||
if err != nil || offset < 0 {
|
||||
return nil, "", errors.New("analytics: invalid cursor")
|
||||
}
|
||||
}
|
||||
statusPredicate := "ps.status='Paid'"
|
||||
switch filter.View {
|
||||
case "UNVERIFIED_PAID":
|
||||
statusPredicate = "ps.status='Paid'"
|
||||
case "FAILED":
|
||||
statusPredicate = "ps.status='Failed'"
|
||||
case "RETRIED":
|
||||
statusPredicate = "(SELECT count(*) FROM payment_attempts x WHERE x.session_id=ps.id)>1"
|
||||
default:
|
||||
return nil, "", errors.New("analytics: invalid detail view")
|
||||
}
|
||||
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
|
||||
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
|
||||
WHERE ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3 AND ` + statusPredicate + `
|
||||
AND ($4='' OR t.source_key=$4) GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $5 OFFSET $6`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.Limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("querying session details: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
details := make([]Detail, 0, filter.Limit)
|
||||
ids := make([]int64, 0, filter.Limit)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var reachedBank bool
|
||||
var d Detail
|
||||
if err := rows.Scan(&id, &d.SessionID, &d.TerminalID, &d.SessionStatus, &d.AmountRial, &d.FeeAmountRial, &d.CreatedAt, &d.ExpiresAt, &d.VerifiedAt, &d.SettledAt, &d.AttemptCount, &reachedBank); err != nil {
|
||||
return nil, "", fmt.Errorf("scanning session detail: %w", err)
|
||||
}
|
||||
d.SessionID = "ses_" + d.SessionID
|
||||
d.Attempts = []Attempt{}
|
||||
if d.SessionStatus == "Failed" {
|
||||
stage := "OTHER_OBSERVED"
|
||||
if d.AttemptCount == 0 {
|
||||
stage = "NO_ATTEMPT"
|
||||
} else if reachedBank {
|
||||
stage = "REACHED_BANK"
|
||||
}
|
||||
d.FailureStage = &stage
|
||||
if filter.FailureStage != "" && filter.FailureStage != stage {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(details) < filter.Limit {
|
||||
details = append(details, d)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, "", fmt.Errorf("iterating session details: %w", err)
|
||||
}
|
||||
for i, id := range ids {
|
||||
attemptRows, err := s.pool.Query(ctx, `SELECT sequence,status,created_at,init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=$1 ORDER BY sequence`, id)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("querying attempts: %w", err)
|
||||
}
|
||||
attempts, err := pgx.CollectRows(attemptRows, func(row pgx.CollectableRow) (Attempt, error) {
|
||||
var a Attempt
|
||||
err := row.Scan(&a.Sequence, &a.Status, &a.CreatedAt, &a.InitTimeMS, &a.VerifyTimeMS)
|
||||
return a, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("collecting attempts: %w", err)
|
||||
}
|
||||
details[i].Attempts = attempts
|
||||
}
|
||||
next := ""
|
||||
if len(details) == filter.Limit {
|
||||
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
|
||||
}
|
||||
return details, next, nil
|
||||
}
|
||||
284
internal/analytics/service.go
Normal file
284
internal/analytics/service.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Period struct {
|
||||
PeriodID string `json:"periodId"`
|
||||
PeriodType string `json:"periodType"`
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
LocalStartAt time.Time `json:"localStartAt"`
|
||||
LocalEndAt time.Time `json:"localEndAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
JalaliLabelFA string `json:"jalaliLabelFa"`
|
||||
IsComplete bool `json:"isComplete"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
RunID string `json:"runId"`
|
||||
State string `json:"state"`
|
||||
Period Period `json:"period"`
|
||||
DatasetSnapshotID string `json:"datasetSnapshotId"`
|
||||
MetricDefinitionVersion string `json:"metricDefinitionVersion"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
EventsURL string `json:"eventsUrl"`
|
||||
Sections map[string]any `json:"sections"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
mu sync.RWMutex
|
||||
runs map[string]storedRun
|
||||
}
|
||||
|
||||
type storedRun struct {
|
||||
merchantID int64
|
||||
run Run
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, runs: map[string]storedRun{}} }
|
||||
|
||||
func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
||||
var start, end time.Time
|
||||
err := s.pool.QueryRow(ctx, `SELECT date_trunc('day',min(created_at)),date_trunc('day',max(created_at))+interval '1 day' FROM payment_sessions`).Scan(&start, &end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading dataset period: %w", err)
|
||||
}
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading tehran timezone: %w", err)
|
||||
}
|
||||
period := Period{PeriodID: "dataset-all", PeriodType: "DAY", StartAt: start.UTC(), EndAt: end.UTC(),
|
||||
LocalStartAt: start.In(location), LocalEndAt: end.In(location), Timezone: "Asia/Tehran",
|
||||
JalaliLabelFA: "کل دادههای واردشده", IsComplete: true, IsDefault: true}
|
||||
return []Period{period}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (Run, error) {
|
||||
key := fmt.Sprintf("%d:%s", merchantID, period.PeriodID)
|
||||
s.mu.RLock()
|
||||
existing, ok := s.runs[key]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
return existing.run, nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
run := Run{RunID: fmt.Sprintf("run_%d%d", merchantID, now.UnixNano()), State: "COMPLETE", Period: period,
|
||||
DatasetSnapshotID: "ds_localimport", MetricDefinitionVersion: "0.4.0", CreatedAt: now, UpdatedAt: now,
|
||||
Sections: map[string]any{}}
|
||||
run.EventsURL = "/api/v1/analytics/runs/" + run.RunID + "/events"
|
||||
metrics, err := s.metrics(ctx, merchantID, period)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
metadata := map[string]any{"calculatedAt": now, "datasetSnapshotId": run.DatasetSnapshotID, "metricDefinitionVersion": "0.4.0"}
|
||||
run.Sections["overview"] = map[string]any{"sectionId": "overview", "state": "READY", "metadata": metadata, "data": metrics.overview(period)}
|
||||
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 {
|
||||
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}
|
||||
s.runs[run.RunID] = storedRun{merchantID: merchantID, run: run}
|
||||
s.mu.Unlock()
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (s *Service) Run(merchantID int64, runID string) (Run, bool) {
|
||||
s.mu.RLock()
|
||||
stored, ok := s.runs[runID]
|
||||
s.mu.RUnlock()
|
||||
return stored.run, ok && stored.merchantID == merchantID
|
||||
}
|
||||
|
||||
type metricValues struct {
|
||||
total, paid, verified, unverified, failed, reversed, noAttempt, reachedBank, retried, recovered int64
|
||||
paidAmount, feeAmount int64
|
||||
initP50, initP95, verifyP50, verifyP95 *float64
|
||||
initSamples, verifySamples int64
|
||||
}
|
||||
|
||||
func (s *Service) metrics(ctx context.Context, merchantID int64, p Period) (metricValues, error) {
|
||||
var m metricValues
|
||||
query := `WITH scoped AS (
|
||||
SELECT ps.*,count(pa.id) attempts,bool_or(pa.status='InBank') reached_bank
|
||||
FROM payment_sessions ps LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
|
||||
WHERE ps.merchant_id=$1 AND ps.created_at >= $2 AND ps.created_at < $3 GROUP BY ps.id
|
||||
), timings AS (
|
||||
SELECT percentile_disc(0.5) within group(order by pa.init_time_ms)::float8 init_p50,
|
||||
percentile_disc(0.95) within group(order by pa.init_time_ms)::float8 init_p95,
|
||||
percentile_disc(0.5) within group(order by pa.verify_time_ms)::float8 verify_p50,
|
||||
percentile_disc(0.95) within group(order by pa.verify_time_ms)::float8 verify_p95,
|
||||
count(pa.init_time_ms) init_samples,count(pa.verify_time_ms) verify_samples
|
||||
FROM payment_attempts pa JOIN payment_sessions ps ON ps.id=pa.session_id
|
||||
WHERE ps.merchant_id=$1 AND ps.created_at >= $2 AND ps.created_at < $3)
|
||||
SELECT count(*),count(*) filter(where status in ('Paid','Verified')),
|
||||
count(*) filter(where status='Verified'),count(*) filter(where status='Paid'),count(*) filter(where status='Failed'),
|
||||
count(*) filter(where status='Reversed'),count(*) filter(where attempts=0),count(*) filter(where reached_bank),
|
||||
count(*) filter(where attempts>1),count(*) filter(where attempts>1 and status in ('Paid','Verified')),
|
||||
coalesce(sum(amount_rial) filter(where status in ('Paid','Verified')),0),coalesce(sum(fee_amount_rial) filter(where status in ('Paid','Verified')),0),
|
||||
t.init_p50,t.init_p95,t.verify_p50,t.verify_p95,t.init_samples,t.verify_samples
|
||||
FROM scoped CROSS JOIN timings t GROUP BY t.init_p50,t.init_p95,t.verify_p50,t.verify_p95,t.init_samples,t.verify_samples`
|
||||
err := s.pool.QueryRow(ctx, query, merchantID, p.StartAt, p.EndAt).Scan(&m.total, &m.paid, &m.verified, &m.unverified,
|
||||
&m.failed, &m.reversed, &m.noAttempt, &m.reachedBank, &m.retried, &m.recovered, &m.paidAmount,
|
||||
&m.feeAmount, &m.initP50, &m.initP95, &m.verifyP50, &m.verifyP95, &m.initSamples, &m.verifySamples)
|
||||
if err != nil {
|
||||
return metricValues{}, fmt.Errorf("calculating merchant metrics: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func integerMetric(id string, value int64, unit string) map[string]any {
|
||||
return map[string]any{"metricId": id, "value": value, "unit": unit}
|
||||
}
|
||||
func decimalMetric(id string, numerator, denominator int64, unit string) map[string]any {
|
||||
var value any
|
||||
if denominator > 0 {
|
||||
value = fmt.Sprintf("%.2f", float64(numerator)*100/float64(denominator))
|
||||
}
|
||||
return map[string]any{"metricId": id, "value": value, "unit": unit, "numerator": numerator, "denominator": denominator}
|
||||
}
|
||||
func unavailableComparison(period Period) map[string]any {
|
||||
return map[string]any{"state": "UNAVAILABLE", "baselinePeriod": period, "absoluteChange": nil, "relativeChangePercent": nil, "unavailableReason": "SOURCE_UNAVAILABLE", "isActionableInsightEligible": false}
|
||||
}
|
||||
|
||||
func (m metricValues) overview(period Period) map[string]any {
|
||||
return map[string]any{
|
||||
"bankPaidSessions": integerMetric("bank_paid_sessions", m.paid, "SESSIONS"), "bankPaidAmountRial": integerMetric("bank_paid_amount_rial", m.paidAmount, "RIAL"),
|
||||
"paymentSuccessRate": decimalMetric("payment_success_rate", m.paid, m.total, "PERCENT"), "verificationCompletionRate": decimalMetric("verification_completion_rate", m.verified, m.paid, "PERCENT"),
|
||||
"comparisons": map[string]any{"bank_paid_sessions": unavailableComparison(period), "bank_paid_amount_rial": unavailableComparison(period), "payment_success_rate": unavailableComparison(period), "verification_completion_rate": unavailableComparison(period)},
|
||||
}
|
||||
}
|
||||
func timing(id string, p50, p95 *float64, samples, eligible int64) map[string]any {
|
||||
var a, b any
|
||||
if p50 != nil {
|
||||
a = int64(math.Round(*p50))
|
||||
}
|
||||
if p95 != nil {
|
||||
b = int64(math.Round(*p95))
|
||||
}
|
||||
var coverage any
|
||||
if eligible > 0 {
|
||||
coverage = fmt.Sprintf("%.2f", float64(samples)*100/float64(eligible))
|
||||
}
|
||||
return map[string]any{"metricId": id, "p50Ms": a, "p95Ms": b, "sampleSize": samples, "eligibleSampleSize": eligible, "coveragePercent": coverage}
|
||||
}
|
||||
func (m metricValues) performance() map[string]any {
|
||||
return map[string]any{
|
||||
"bankPaidSessions": integerMetric("bank_paid_sessions", m.paid, "SESSIONS"), "verifiedSessions": integerMetric("verified_sessions", m.verified, "SESSIONS"), "unverifiedPaidSessions": integerMetric("unverified_paid_sessions", m.unverified, "SESSIONS"),
|
||||
"failedSessions": integerMetric("failed_sessions", m.failed, "SESSIONS"), "reversedSessions": integerMetric("reversed_sessions", m.reversed, "SESSIONS"), "noAttemptSessions": integerMetric("no_attempt_sessions", m.noAttempt, "SESSIONS"),
|
||||
"reachedBankSessions": integerMetric("reached_bank_sessions", m.reachedBank, "SESSIONS"), "retriedSessions": integerMetric("retried_sessions", m.retried, "SESSIONS"), "retryRecoveryRate": decimalMetric("retry_recovery_rate", m.recovered, m.retried, "PERCENT"),
|
||||
"initTiming": timing("init_time_ms", m.initP50, m.initP95, m.initSamples, m.total), "verifyTiming": timing("verify_time_ms", m.verifyP50, m.verifyP95, m.verifySamples, m.paid),
|
||||
}
|
||||
}
|
||||
func (m metricValues) fees(period Period) map[string]any {
|
||||
return map[string]any{"paidFeeAmountRial": integerMetric("paid_fee_amount_rial", m.feeAmount, "RIAL"), "bankPaidAmountRial": integerMetric("bank_paid_amount_rial", m.paidAmount, "RIAL"), "paidFeeRate": decimalMetric("paid_fee_rate", m.feeAmount, m.paidAmount, "RATIO"), "comparison": unavailableComparison(period)}
|
||||
}
|
||||
|
||||
func (s *Service) peerEligibility(ctx context.Context, merchantID int64, p Period) (int64, int64, error) {
|
||||
var peers, sessions int64
|
||||
err := s.pool.QueryRow(ctx, `SELECT count(distinct ps.merchant_id),count(*) FROM payment_sessions ps JOIN merchants m ON m.id=ps.merchant_id
|
||||
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.merchant_id<>$1 AND ps.created_at>=$2 AND ps.created_at<$3`, merchantID, p.StartAt, p.EndAt).Scan(&peers, &sessions)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("calculating peer eligibility: %w", err)
|
||||
}
|
||||
return peers, sessions, nil
|
||||
}
|
||||
|
||||
type peerRow struct {
|
||||
merchantID int64
|
||||
values [8]*float64
|
||||
}
|
||||
|
||||
func (s *Service) peerMetrics(ctx context.Context, merchantID int64, p Period) ([]map[string]any, error) {
|
||||
query := `WITH session_stats AS (SELECT ps.merchant_id,ps.id,ps.status,ps.amount_rial,ps.fee_amount_rial,count(pa.id) attempts
|
||||
FROM payment_sessions ps LEFT JOIN payment_attempts pa ON pa.session_id=ps.id JOIN merchants m ON m.id=ps.merchant_id
|
||||
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at>=$2 AND ps.created_at<$3 GROUP BY ps.id),
|
||||
aggregate AS (SELECT merchant_id,100.0*count(*) filter(where status in ('Paid','Verified'))/nullif(count(*),0) success,
|
||||
100.0*count(*) filter(where status='Verified')/nullif(count(*) filter(where status in ('Paid','Verified')),0) verification,
|
||||
100.0*count(*) filter(where attempts>1 and status in ('Paid','Verified'))/nullif(count(*) filter(where attempts>1),0) recovery,
|
||||
100.0*sum(fee_amount_rial) filter(where status in ('Paid','Verified'))/nullif(sum(amount_rial) filter(where status in ('Paid','Verified')),0) fee FROM session_stats GROUP BY merchant_id),
|
||||
timing AS (SELECT ps.merchant_id,percentile_disc(.5) within group(order by pa.init_time_ms)::float8 ip50,percentile_disc(.95) within group(order by pa.init_time_ms)::float8 ip95,
|
||||
percentile_disc(.5) within group(order by pa.verify_time_ms)::float8 vp50,percentile_disc(.95) within group(order by pa.verify_time_ms)::float8 vp95
|
||||
FROM payment_sessions ps JOIN payment_attempts pa ON pa.session_id=ps.id JOIN merchants m ON m.id=ps.merchant_id WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at>=$2 AND ps.created_at<$3 GROUP BY ps.merchant_id)
|
||||
SELECT a.merchant_id,a.success::float8,a.verification::float8,a.recovery::float8,t.ip50,t.ip95,t.vp50,t.vp95,a.fee::float8 FROM aggregate a JOIN timing t USING(merchant_id)`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, p.StartAt, p.EndAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calculating peer metrics: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
all := []peerRow{}
|
||||
for rows.Next() {
|
||||
var row peerRow
|
||||
if err := rows.Scan(&row.merchantID, &row.values[0], &row.values[1], &row.values[2], &row.values[3], &row.values[4], &row.values[5], &row.values[6], &row.values[7]); err != nil {
|
||||
return nil, fmt.Errorf("scanning peer metrics: %w", err)
|
||||
}
|
||||
all = append(all, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating peer metrics: %w", err)
|
||||
}
|
||||
ids := []string{"payment_success_rate", "verification_completion_rate", "retry_recovery_rate", "init_time_ms_p50", "init_time_ms_p95", "verify_time_ms_p50", "verify_time_ms_p95", "paid_fee_rate"}
|
||||
metrics := make([]map[string]any, 0, 8)
|
||||
for index, id := range ids {
|
||||
active := 0.0
|
||||
peers := []float64{}
|
||||
for _, row := range all {
|
||||
if row.values[index] == nil {
|
||||
continue
|
||||
}
|
||||
if row.merchantID == merchantID {
|
||||
active = *row.values[index]
|
||||
} else {
|
||||
peers = append(peers, *row.values[index])
|
||||
}
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
peers = append(peers, 0)
|
||||
}
|
||||
slices.Sort(peers)
|
||||
median := peers[len(peers)/2]
|
||||
if len(peers)%2 == 0 {
|
||||
median = (peers[len(peers)/2-1] + median) / 2
|
||||
}
|
||||
lowerIsBetter := index >= 3
|
||||
better := 0
|
||||
for _, value := range peers {
|
||||
if (!lowerIsBetter && active >= value) || (lowerIsBetter && active <= value) {
|
||||
better++
|
||||
}
|
||||
}
|
||||
percentile := float64(better) * 100 / float64(len(peers))
|
||||
direction := "HIGHER_IS_BETTER"
|
||||
if lowerIsBetter {
|
||||
direction = "LOWER_IS_BETTER"
|
||||
}
|
||||
metrics = append(metrics, map[string]any{"metricId": id, "cohortMedian": fmt.Sprintf("%.2f", median), "merchantPerformancePercentile": fmt.Sprintf("%.2f", percentile), "direction": direction})
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
362
internal/api/server.go
Normal file
362
internal/api/server.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
identity *identity.Service
|
||||
analytics *analytics.Service
|
||||
logger *slog.Logger
|
||||
frontendOrigin string
|
||||
cookieSecure bool
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
type tokenKey struct{}
|
||||
|
||||
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool) *Server {
|
||||
return &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("POST /api/v1/auth/sessions", s.login)
|
||||
mux.Handle("GET /api/v1/auth/session", s.authenticate(http.HandlerFunc(s.currentSession)))
|
||||
mux.Handle("DELETE /api/v1/auth/session", s.authenticate(s.csrf(http.HandlerFunc(s.logout))))
|
||||
mux.Handle("GET /api/v1/analytics/periods", s.authenticate(http.HandlerFunc(s.periods)))
|
||||
mux.Handle("POST /api/v1/analytics/runs", s.authenticate(s.csrf(http.HandlerFunc(s.startRun))))
|
||||
mux.Handle("GET /api/v1/analytics/runs/{runId}", s.authenticate(http.HandlerFunc(s.getRun)))
|
||||
mux.Handle("GET /api/v1/analytics/runs/{runId}/events", s.authenticate(http.HandlerFunc(s.events)))
|
||||
mux.Handle("GET /api/v1/analytics/session-details", s.authenticate(http.HandlerFunc(s.details)))
|
||||
mux.Handle("GET /api/v1/analytics/session-details.csv", s.authenticate(http.HandlerFunc(s.exportCSV)))
|
||||
return s.boundary(mux)
|
||||
}
|
||||
|
||||
func (s *Server) boundary(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := r.Header.Get("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = randomID()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(s.cookieName())
|
||||
if err != nil {
|
||||
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Authentication required")
|
||||
return
|
||||
}
|
||||
session, err := s.identity.Resolve(r.Context(), cookie.Value)
|
||||
if errors.Is(err, identity.ErrUnauthenticated) {
|
||||
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Authentication required")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("session resolution failed", "error", err)
|
||||
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), sessionKey{}, session)
|
||||
ctx = context.WithValue(ctx, tokenKey{}, cookie.Value)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) csrf(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := sessionFrom(r.Context())
|
||||
origin := r.Header.Get("Origin")
|
||||
if r.Header.Get("X-CSRF-Token") != session.CSRFToken || (origin != "" && origin != s.frontendOrigin) {
|
||||
s.problem(w, r, http.StatusForbidden, "FORBIDDEN", "Request could not be verified")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && origin != s.frontendOrigin {
|
||||
s.problem(w, r, http.StatusForbidden, "FORBIDDEN", "Request could not be verified")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var input struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil || input.Username == "" || input.Password == "" {
|
||||
s.problem(w, r, http.StatusUnprocessableEntity, "VALIDATION_FAILED", "Invalid login request")
|
||||
return
|
||||
}
|
||||
session, token, err := s.identity.Login(r.Context(), input.Username, input.Password)
|
||||
if errors.Is(err, identity.ErrUnauthenticated) {
|
||||
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Invalid username or password")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("login failed", "error", err)
|
||||
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: s.cookieName(), Value: token, Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, Expires: session.AbsoluteExpiresAt})
|
||||
s.writeJSON(w, http.StatusCreated, sessionResponse(session))
|
||||
}
|
||||
|
||||
func (s *Server) currentSession(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSON(w, http.StatusOK, sessionResponse(sessionFrom(r.Context())))
|
||||
}
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.identity.Logout(r.Context(), r.Context().Value(tokenKey{}).(string)); err != nil {
|
||||
s.logger.Error("logout failed", "error", err)
|
||||
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: s.cookieName(), Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
|
||||
periods, err := s.analytics.Periods(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("period discovery failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": periods[0].PeriodID, "periods": periods})
|
||||
}
|
||||
|
||||
func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
PeriodID string `json:"periodId"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid analytics request")
|
||||
return
|
||||
}
|
||||
periods, err := s.analytics.Periods(r.Context())
|
||||
if err != nil {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
var period analytics.Period
|
||||
found := false
|
||||
for _, candidate := range periods {
|
||||
if candidate.PeriodID == input.PeriodID {
|
||||
period = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
session := sessionFrom(r.Context())
|
||||
run, err := s.analytics.Start(r.Context(), session.MerchantID, period)
|
||||
if err != nil {
|
||||
s.logger.Error("analytics calculation failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/api/v1/analytics/runs/"+run.RunID)
|
||||
s.writeJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
|
||||
run, ok := s.analytics.Run(sessionFrom(r.Context()).MerchantID, r.PathValue("runId"))
|
||||
if !ok {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, 200, run)
|
||||
}
|
||||
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
run, ok := s.analytics.Run(sessionFrom(r.Context()).MerchantID, r.PathValue("runId"))
|
||||
if !ok {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
eventID := "evt_" + randomID()
|
||||
data := map[string]any{"eventId": eventID, "runId": run.RunID, "occurredAt": time.Now().UTC(), "recovery": "RESET_TO_SNAPSHOT", "run": run}
|
||||
payload, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "id: %s\nevent: run.snapshot\ndata: %s\n\n", eventID, payload)
|
||||
}
|
||||
|
||||
func (s *Server) details(w http.ResponseWriter, r *http.Request) {
|
||||
period, ok := s.requestPeriod(r)
|
||||
if !ok {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
limit := 25
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 || parsed > 100 {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid page limit")
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Cursor: r.URL.Query().Get("cursor"), Limit: limit}
|
||||
items, next, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
||||
if err != nil {
|
||||
s.logger.Error("detail query failed", "error", err)
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
|
||||
return
|
||||
}
|
||||
var nextValue any
|
||||
if next != "" {
|
||||
nextValue = next
|
||||
}
|
||||
s.writeJSON(w, 200, map[string]any{"period": period, "view": filter.View, "appliedFilters": map[string]any{"terminalId": nullable(filter.TerminalID), "failureStage": nullable(filter.FailureStage)}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
|
||||
}
|
||||
|
||||
func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
period, ok := s.requestPeriod(r)
|
||||
if !ok {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Limit: 100}
|
||||
items, _, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
||||
if err != nil {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid export request")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", strings.ToLower(filter.View)+"-"+period.PeriodID+".csv"))
|
||||
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
writer := csv.NewWriter(w)
|
||||
writer.UseCRLF = true
|
||||
_ = writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"})
|
||||
for _, item := range items {
|
||||
lastStatus, lastAt := "", ""
|
||||
if len(item.Attempts) > 0 {
|
||||
last := item.Attempts[len(item.Attempts)-1]
|
||||
lastStatus = persianStatus(last.Status)
|
||||
if last.CreatedAt != nil {
|
||||
lastAt = last.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
_ = writer.Write([]string{item.SessionID, item.TerminalID, persianStatus(item.SessionStatus), strconv.FormatInt(item.AmountRial, 10), strconv.FormatInt(item.FeeAmountRial, 10), item.CreatedAt.UTC().Format(time.RFC3339), formatTime(item.ExpiresAt), strconv.Itoa(item.AttemptCount), lastStatus, lastAt, formatTime(item.VerifiedAt), formatTime(item.SettledAt), stringValue(item.FailureStage)})
|
||||
}
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
func (s *Server) requestPeriod(r *http.Request) (analytics.Period, bool) {
|
||||
periods, err := s.analytics.Periods(r.Context())
|
||||
if err != nil {
|
||||
return analytics.Period{}, false
|
||||
}
|
||||
id := r.URL.Query().Get("periodId")
|
||||
for _, p := range periods {
|
||||
if p.PeriodID == id {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return analytics.Period{}, false
|
||||
}
|
||||
func nullable(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
func formatTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
func persianStatus(value string) string {
|
||||
switch value {
|
||||
case "Paid":
|
||||
return "پرداخت شده و تأیید نشده"
|
||||
case "Verified":
|
||||
return "تأیید شده"
|
||||
case "Failed":
|
||||
return "ناموفق"
|
||||
case "Reversed":
|
||||
return "برگشت خورده"
|
||||
case "InBank":
|
||||
return "در بانک"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSON(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
func (s *Server) cookieName() string {
|
||||
if s.cookieSecure {
|
||||
return "__Host-zpa_session"
|
||||
}
|
||||
return "zpa_session"
|
||||
}
|
||||
func sessionFrom(ctx context.Context) identity.Session {
|
||||
return ctx.Value(sessionKey{}).(identity.Session)
|
||||
}
|
||||
func sessionResponse(v identity.Session) map[string]any {
|
||||
return map[string]any{"user": map[string]any{"userId": fmt.Sprintf("user_%d", v.UserID), "username": v.Username}, "merchant": map[string]any{"merchantId": fmt.Sprintf("merchant_%d", v.MerchantID), "displayName": v.MerchantDisplayName, "profileIsSynthetic": v.ProfileIsSynthetic}, "createdAt": v.CreatedAt, "idleExpiresAt": v.IdleExpiresAt, "absoluteExpiresAt": v.AbsoluteExpiresAt, "csrfToken": v.CSRFToken}
|
||||
}
|
||||
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
s.logger.Error("response encoding failed", "error", err)
|
||||
}
|
||||
}
|
||||
func (s *Server) problem(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
value := map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/" + strings.ToLower(strings.ReplaceAll(code, "_", "-")), "title": http.StatusText(status), "status": status, "code": code, "detail": detail, "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID")}
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
s.logger.Error("problem encoding failed", "error", err)
|
||||
}
|
||||
}
|
||||
func randomID() string {
|
||||
data := make([]byte, 12)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return fmt.Sprint(time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(data)
|
||||
}
|
||||
30
internal/api/server_test.go
Normal file
30
internal/api/server_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestServerCookieName(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
secure bool
|
||||
expected string
|
||||
}{
|
||||
{name: "local development", secure: false, expected: "zpa_session"},
|
||||
{name: "secure deployment", secure: true, expected: "__Host-zpa_session"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := &Server{cookieSecure: tt.secure}
|
||||
if got := server.cookieName(); got != tt.expected {
|
||||
t.Fatalf("cookieName() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersianStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := persianStatus("Verified"); got != "تأیید شده" {
|
||||
t.Fatalf("persianStatus() = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user