feat: add persisted page insight platform

This commit is contained in:
2026-08-21 18:51:00 +08:00
parent 8507b5d017
commit 17e3f1a7d0
18 changed files with 1587 additions and 3 deletions

185
internal/api/insights.go Normal file
View File

@@ -0,0 +1,185 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/insight"
)
var insightJobIDPattern = regexp.MustCompile(`^insightjob_[a-f0-9]{24}$`)
type insightService interface {
Latest(context.Context, int64, string, insight.Module) (insight.Snapshot, error)
Refresh(context.Context, int64, string, insight.Module, string) (insight.Job, bool, error)
GetJob(context.Context, int64, string) (insight.Job, error)
EventsAfter(context.Context, int64, string, int64) ([]insight.Event, error)
}
func (s *Server) latestAIInsight(w http.ResponseWriter, r *http.Request) {
module, ok := insight.ParseModule(r.PathValue("module"))
if !ok || s.insights == nil {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
terminalID, ok := insightTerminalQuery(r)
if !ok {
s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId is accepted")
return
}
result, err := s.insights.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID, module)
if errors.Is(err, insight.ErrNotFound) || errors.Is(err, insight.ErrUnknownTerminal) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
if err != nil {
s.logger.Error("loading AI insight failed", "error", err, "module", module)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "AI insight unavailable")
return
}
s.writeJSON(w, 200, result)
}
func (s *Server) refreshAIInsight(w http.ResponseWriter, r *http.Request) {
module, ok := insight.ParseModule(r.PathValue("module"))
if !ok || s.insights == nil {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
key := r.Header.Get("Idempotency-Key")
if !validIdempotencyKey(key) {
s.problem(w, r, 422, "VALIDATION_FAILED", "A valid Idempotency-Key is required")
return
}
var input struct {
TerminalID string `json:"terminalId"`
}
if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid insight refresh request")
return
}
job, cached, err := s.insights.Refresh(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, key)
switch {
case errors.Is(err, insight.ErrUnknownTerminal):
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
case errors.Is(err, insight.ErrIdempotencyConflict):
s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request")
return
case errors.Is(err, insight.ErrRefreshLimited):
next := nextTehranDay(time.Now())
w.Header().Set("Retry-After", strconv.FormatInt(max(1, int64(time.Until(next).Seconds())), 10))
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(429)
_ = json.NewEncoder(w).Encode(map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/refresh-limited", "title": "Too Many Requests", "status": 429, "code": "REFRESH_LIMITED", "detail": "Daily refresh is not available yet", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": next})
return
case err != nil:
s.logger.Error("creating AI insight refresh failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "AI insight unavailable")
return
}
status := http.StatusAccepted
if cached {
status = http.StatusOK
}
w.Header().Set("Location", "/api/v1/analytics/ai-insight-refreshes/"+job.JobID)
s.writeJSON(w, status, job)
}
func (s *Server) getAIInsightJob(w http.ResponseWriter, r *http.Request) {
if s.insights == nil || !insightJobIDPattern.MatchString(r.PathValue("jobId")) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
job, err := s.insights.GetJob(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("jobId"))
if errors.Is(err, insight.ErrNotFound) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
if err != nil {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "AI insight unavailable")
return
}
s.writeJSON(w, 200, job)
}
func (s *Server) aiInsightEvents(w http.ResponseWriter, r *http.Request) {
if s.insights == nil || !insightJobIDPattern.MatchString(r.PathValue("jobId")) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
merchantID, jobID := sessionFrom(r.Context()).MerchantID, r.PathValue("jobId")
if _, err := s.insights.GetJob(r.Context(), merchantID, jobID); errors.Is(err, insight.ErrNotFound) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
} else if err != nil {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "AI insight unavailable")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
s.problem(w, r, 500, "INTERNAL_ERROR", "Streaming unavailable")
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")
last, _ := strconv.ParseInt(r.Header.Get("Last-Event-ID"), 10, 64)
ctx, cancel := context.WithTimeout(r.Context(), time.Hour)
defer cancel()
poll, heartbeat := time.NewTicker(2*time.Second), time.NewTicker(15*time.Second)
defer poll.Stop()
defer heartbeat.Stop()
for {
events, err := s.insights.EventsAfter(ctx, merchantID, jobID, last)
if err != nil {
return
}
for _, event := range events {
payload, _ := json.Marshal(event)
_, _ = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.ID, event.Type, payload)
last = event.ID
flusher.Flush()
}
job, err := s.insights.GetJob(ctx, merchantID, jobID)
if err != nil {
return
}
if job.State == "COMPLETED" || job.State == "FAILED" {
return
}
select {
case <-ctx.Done():
return
case <-poll.C:
case <-heartbeat.C:
_, _ = fmt.Fprint(w, ": heartbeat\n\n")
flusher.Flush()
}
}
}
func insightTerminalQuery(r *http.Request) (string, bool) {
for key := range r.URL.Query() {
if key != "terminalId" {
return "", false
}
}
value := r.URL.Query().Get("terminalId")
return value, len(value) <= 128
}
func nextTehranDay(now time.Time) time.Time {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
location = time.FixedZone("Asia/Tehran", 3*60*60+30*60)
}
local := now.In(location)
return time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, location)
}

View File

@@ -19,6 +19,7 @@ import (
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/insight"
)
const maxExportBytes = 50 << 20
@@ -37,6 +38,7 @@ type Server struct {
identity *identity.Service
analytics *analytics.Service
growth growthReportService
insights insightService
logger *slog.Logger
frontendOrigin string
cookieSecure bool
@@ -45,6 +47,11 @@ type Server struct {
growthSSEPollInterval time.Duration
}
func (s *Server) WithInsights(service *insight.Service) *Server {
s.insights = service
return s
}
type sessionKey struct{}
type tokenKey struct{}
@@ -74,6 +81,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/analytics/growth-reports/latest", s.authenticate(http.HandlerFunc(s.latestGrowthReport)))
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}", s.authenticate(http.HandlerFunc(s.getGrowthReport)))
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}/events", s.authenticate(http.HandlerFunc(s.growthReportEvents)))
mux.Handle("GET /api/v1/analytics/ai-insights/{module}", s.authenticate(http.HandlerFunc(s.latestAIInsight)))
mux.Handle("POST /api/v1/analytics/ai-insights/{module}/refreshes", s.authenticate(s.csrf(http.HandlerFunc(s.refreshAIInsight))))
mux.Handle("GET /api/v1/analytics/ai-insight-refreshes/{jobId}", s.authenticate(http.HandlerFunc(s.getAIInsightJob)))
mux.Handle("GET /api/v1/analytics/ai-insight-refreshes/{jobId}/events", s.authenticate(http.HandlerFunc(s.aiInsightEvents)))
return s.boundary(mux)
}