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) }