fix: harden terminal-scoped analytics contracts

Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
This commit is contained in:
2026-08-20 21:29:18 +03:30
parent 857f29635e
commit d6de48fd15
14 changed files with 577 additions and 103 deletions

View File

@@ -1,6 +1,7 @@
package api
import (
"bytes"
"context"
"crypto/rand"
"encoding/csv"
@@ -8,16 +9,22 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
)
const maxExportBytes = 50 << 20
var errExportBytesExceeded = errors.New("export exceeds byte limit")
type Server struct {
identity *identity.Service
analytics *analytics.Service
@@ -150,6 +157,11 @@ func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
periods, err = filterPeriodsByType(periods, r.URL.Query().Get("periodType"))
if err != nil {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid reporting period type")
return
}
if len(periods) == 0 {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
@@ -257,23 +269,16 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
}
limit = parsed
}
minAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
if !valid {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid minimum amount")
filter, err := detailFilterFromRequest(r, limit)
if err != nil {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
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 errors.Is(err, analytics.ErrInvalidCursor) {
s.problem(w, r, 400, "INVALID_CURSOR", "Invalid detail cursor")
return
}
if err != nil {
s.logger.Error("detail query failed", "error", err)
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
@@ -309,30 +314,99 @@ func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
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)
filter, err := detailFilterFromRequest(r, 100)
if err != nil {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid export request")
return
}
exportContext, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
items, err := s.analytics.ExportDetails(exportContext, sessionFrom(r.Context()).MerchantID, period, filter)
if errors.Is(err, analytics.ErrExportTooLarge) {
s.problem(w, r, 413, "EXPORT_TOO_LARGE", "Export exceeds the allowed row limit")
return
}
if errors.Is(err, context.DeadlineExceeded) {
s.problem(w, r, 504, "EXPORT_TIMEOUT", "Export timed out")
return
}
if err != nil {
s.logger.Error("session export failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
encoded, err := renderExportCSV(items, maxExportBytes)
if errors.Is(err, errExportBytesExceeded) {
s.problem(w, r, 413, "EXPORT_TOO_LARGE", "Export exceeds the allowed byte limit")
return
}
if err != nil {
s.logger.Error("session export encoding failed", "error", err)
s.problem(w, r, 500, "INTERNAL_ERROR", "Export could not be encoded")
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)
filename := fmt.Sprintf("%s-%s-%s.csv", strings.ToLower(filter.View), period.PeriodID, time.Now().UTC().Format("20060102T150405Z"))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", filename, filename))
if _, err := w.Write(encoded); err != nil {
s.logger.Warn("session export response interrupted", "error", err)
}
}
func renderExportCSV(items []analytics.ExportDetail, maxBytes int64) ([]byte, error) {
var buffer bytes.Buffer
limited := &limitedWriter{writer: &buffer, remaining: maxBytes}
if _, err := limited.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return nil, err
}
writer := csv.NewWriter(limited)
writer.UseCRLF = true
_ = writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"})
if err := writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"}); err != nil {
return nil, err
}
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)
}
if item.LastAttemptStatus != nil {
lastStatus = persianStatus(*item.LastAttemptStatus)
}
if item.LastAttemptAt != nil {
lastAt = item.LastAttemptAt.UTC().Format(time.RFC3339)
}
if err := writer.Write([]string{spreadsheetSafeText(item.SessionID), spreadsheetSafeText(item.TerminalID), spreadsheetSafeText(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), spreadsheetSafeText(lastStatus), lastAt, formatTime(item.VerifiedAt), formatTime(item.SettledAt), spreadsheetSafeText(stringValue(item.FailureStage))}); err != nil {
return nil, err
}
_ = 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()
if err := writer.Error(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
type limitedWriter struct {
writer io.Writer
remaining int64
}
func (w *limitedWriter) Write(data []byte) (int, error) {
if int64(len(data)) > w.remaining {
return 0, errExportBytesExceeded
}
written, err := w.writer.Write(data)
w.remaining -= int64(written)
return written, err
}
func spreadsheetSafeText(value string) string {
trimmed := strings.TrimLeftFunc(value, unicode.IsSpace)
if value == "" || trimmed == "" {
return value
}
first := trimmed[0]
if first == '=' || first == '+' || first == '-' || first == '@' || value[0] == '\t' || value[0] == '\r' || value[0] == '\n' {
return "'" + value
}
return value
}
func (s *Server) requestPeriod(r *http.Request) (analytics.Period, bool) {
@@ -364,6 +438,45 @@ func optionalNonNegativeInt64(value string) (*int64, bool) {
}
return &parsed, true
}
func detailFilterFromRequest(r *http.Request, limit int) (analytics.DetailFilter, error) {
minimum, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
if !valid {
return analytics.DetailFilter{}, errors.New("invalid minimum amount")
}
maximum, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
if !valid {
return analytics.DetailFilter{}, errors.New("invalid maximum amount")
}
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: minimum, MaxAmountRial: maximum, Cursor: r.URL.Query().Get("cursor"), Limit: limit,
}
if err := filter.Validate(); err != nil {
return analytics.DetailFilter{}, err
}
return filter, nil
}
func filterPeriodsByType(periods []analytics.Period, requested string) ([]analytics.Period, error) {
if requested == "" {
return periods, nil
}
allowed := map[string]bool{"DAY": true, "WEEK": true, "JALALI_MONTH": true, "PERSIAN_SEASON": true}
if !allowed[requested] {
return nil, errors.New("invalid reporting period type")
}
filtered := make([]analytics.Period, 0, 1)
for _, period := range periods {
if period.PeriodType == requested {
period.IsDefault = len(filtered) == 0
filtered = append(filtered, period)
}
}
return filtered, nil
}
func formatTime(value *time.Time) string {
if value == nil {
return ""