433 lines
16 KiB
Go
433 lines
16 KiB
Go
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("GET /api/v1/analytics/terminals", s.authenticate(http.HandlerFunc(s.terminals)))
|
|
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)))
|
|
mux.Handle("GET /api/v1/analytics/sessions/{sessionId}", s.authenticate(http.HandlerFunc(s.sessionDetail)))
|
|
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
|
|
}
|
|
if len(periods) == 0 {
|
|
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
|
return
|
|
}
|
|
defaultPeriodID := periods[0].PeriodID
|
|
for _, period := range periods {
|
|
if period.IsDefault {
|
|
defaultPeriodID = period.PeriodID
|
|
break
|
|
}
|
|
}
|
|
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": defaultPeriodID, "periods": periods})
|
|
}
|
|
|
|
func (s *Server) terminals(w http.ResponseWriter, r *http.Request) {
|
|
terminals, err := s.analytics.Terminals(r.Context(), sessionFrom(r.Context()).MerchantID)
|
|
if err != nil {
|
|
s.logger.Error("terminal discovery failed", "error", err)
|
|
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusOK, map[string]any{"terminals": terminals})
|
|
}
|
|
|
|
func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
PeriodID string `json:"periodId"`
|
|
TerminalID string `json:"terminalId"`
|
|
}
|
|
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, input.TerminalID)
|
|
if errors.Is(err, analytics.ErrUnknownTerminal) {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
minAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
|
|
if !valid {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid minimum amount")
|
|
return
|
|
}
|
|
maxAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
|
|
if !valid {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid maximum amount")
|
|
return
|
|
}
|
|
filter := analytics.DetailFilter{
|
|
View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"),
|
|
TerminalID: r.URL.Query().Get("terminalId"), Status: r.URL.Query().Get("status"),
|
|
MinAmountRial: minAmount, MaxAmountRial: maxAmount,
|
|
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), "status": nullable(filter.Status),
|
|
"minAmountRial": filter.MinAmountRial, "maxAmountRial": filter.MaxAmountRial,
|
|
}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
|
|
}
|
|
|
|
func (s *Server) sessionDetail(w http.ResponseWriter, r *http.Request) {
|
|
detail, found, err := s.analytics.Session(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("sessionId"))
|
|
if err != nil {
|
|
s.logger.Error("session detail query failed", "error", err)
|
|
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
|
return
|
|
}
|
|
if !found {
|
|
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
|
return
|
|
}
|
|
s.writeJSON(w, 200, detail)
|
|
}
|
|
|
|
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 optionalNonNegativeInt64(value string) (*int64, bool) {
|
|
if value == "" {
|
|
return nil, true
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || parsed < 0 {
|
|
return nil, false
|
|
}
|
|
return &parsed, true
|
|
}
|
|
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)
|
|
}
|