fix: deliver validated AI insight responses

This commit is contained in:
2026-08-22 15:47:38 +08:00
parent ca55c2fb3d
commit 3fba4261ee
68 changed files with 6870 additions and 493 deletions

View File

@@ -18,8 +18,91 @@ 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)
RefreshSection(context.Context, int64, string, insight.Module, insight.Section, string) (insight.Job, bool, error)
Enqueue(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)
LatestForPeriod(context.Context, int64, string, insight.Module, insight.PeriodScope) (insight.Snapshot, error)
RefreshForPeriod(context.Context, int64, string, insight.Module, insight.PeriodScope, string) (insight.Job, bool, error)
RefreshSectionForPeriod(context.Context, int64, string, insight.Module, insight.Section, insight.PeriodScope, string) (insight.Job, bool, error)
}
func (s *Server) refreshAIInsightSection(w http.ResponseWriter, r *http.Request) {
module, moduleOK := insight.ParseModule(r.PathValue("module"))
section, sectionOK := insight.ParseSection(module, r.PathValue("sectionId"))
if !moduleOK || s.insights == nil {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
if !sectionOK {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unsupported AI insight section")
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"`
PeriodID string `json:"periodId"`
}
if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid insight refresh request")
return
}
period, periodOK := s.insightPeriod(r.Context(), module, input.PeriodID)
if !periodOK {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
return
}
var job insight.Job
var cached bool
var err error
if period.PeriodID != "" {
job, cached, err = s.insights.RefreshSectionForPeriod(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, section, period, key)
} else {
job, cached, err = s.insights.RefreshSection(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, section, key)
}
if s.writeInsightRefreshResult(w, r, job, cached, err) {
return
}
}
func (s *Server) writeInsightRefreshResult(w http.ResponseWriter, r *http.Request, job insight.Job, cached bool, err error) bool {
switch {
case errors.Is(err, insight.ErrUnknownTerminal):
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return true
case errors.Is(err, insight.ErrIdempotencyConflict):
s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request")
return true
case errors.Is(err, insight.ErrOnboardingRequired):
s.problem(w, r, 409, "ONBOARDING_REQUIRED", "Complete terminal onboarding before generating AI insights")
return true
case errors.Is(err, insight.ErrRefreshLimited), errors.Is(err, insight.ErrRefreshCooldown):
next := nextTehranDay(time.Now())
var cooldown *insight.RefreshCooldownError
if errors.As(err, &cooldown) {
next = cooldown.NextRefreshAt
}
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": "AI section refresh is not available yet", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": next})
return true
case err != nil:
s.logger.Error("creating AI insight refresh failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "AI insight unavailable")
return true
}
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)
return true
}
func (s *Server) latestAIInsight(w http.ResponseWriter, r *http.Request) {
@@ -28,12 +111,23 @@ func (s *Server) latestAIInsight(w http.ResponseWriter, r *http.Request) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
terminalID, ok := insightTerminalQuery(r)
terminalID, periodID, ok := insightScopeQuery(r)
if !ok {
s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId is accepted")
s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId and periodId are accepted")
return
}
result, err := s.insights.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID, module)
period, periodOK := s.insightPeriod(r.Context(), module, periodID)
if !periodOK {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
return
}
var result insight.Snapshot
var err error
if period.PeriodID != "" {
result, err = s.insights.LatestForPeriod(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID, module, period)
} else {
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
@@ -59,37 +153,26 @@ func (s *Server) refreshAIInsight(w http.ResponseWriter, r *http.Request) {
}
var input struct {
TerminalID string `json:"terminalId"`
PeriodID string `json:"periodId"`
}
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")
period, periodOK := s.insightPeriod(r.Context(), module, input.PeriodID)
if !periodOK {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
return
}
status := http.StatusAccepted
if cached {
status = http.StatusOK
var job insight.Job
var cached bool
var err error
if period.PeriodID != "" {
job, cached, err = s.insights.RefreshForPeriod(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, period, key)
} else {
job, cached, err = s.insights.Refresh(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, key)
}
w.Header().Set("Location", "/api/v1/analytics/ai-insight-refreshes/"+job.JobID)
s.writeJSON(w, status, job)
s.writeInsightRefreshResult(w, r, job, cached, err)
}
func (s *Server) getAIInsightJob(w http.ResponseWriter, r *http.Request) {
@@ -165,14 +248,33 @@ func (s *Server) aiInsightEvents(w http.ResponseWriter, r *http.Request) {
}
}
func insightTerminalQuery(r *http.Request) (string, bool) {
func insightScopeQuery(r *http.Request) (string, string, bool) {
for key := range r.URL.Query() {
if key != "terminalId" {
return "", false
if key != "terminalId" && key != "periodId" {
return "", "", false
}
}
value := r.URL.Query().Get("terminalId")
return value, len(value) <= 128
terminalID, periodID := r.URL.Query().Get("terminalId"), r.URL.Query().Get("periodId")
return terminalID, periodID, len(terminalID) <= 128 && len(periodID) <= 128
}
func (s *Server) insightPeriod(ctx context.Context, module insight.Module, periodID string) (insight.PeriodScope, bool) {
if periodID == "" {
return insight.PeriodScope{}, true
}
if module != insight.ModuleTransactions || s.analytics == nil {
return insight.PeriodScope{}, false
}
periods, err := s.analytics.Periods(ctx)
if err != nil {
return insight.PeriodScope{}, false
}
for _, period := range periods {
if period.PeriodID == periodID {
return insight.PeriodScope{PeriodID: period.PeriodID, PeriodType: period.PeriodType, StartAt: period.StartAt, EndAt: period.EndAt, JalaliLabelFA: period.JalaliLabelFA}, true
}
}
return insight.PeriodScope{}, false
}
func nextTehranDay(now time.Time) time.Time {