package service import ( "context" "errors" "strings" "time" "backend/internal/model" "backend/internal/provider/adobe" "backend/internal/repo" "gorm.io/datatypes" ) type RefreshProfileService struct { profiles *repo.RefreshProfileRepository tokens *repo.TokenRepository adobe *adobe.Client } func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *repo.TokenRepository, adobeClient *adobe.Client) *RefreshProfileService { return &RefreshProfileService{ profiles: profiles, tokens: tokens, adobe: adobeClient, } } func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) { return s.profiles.List(ctx) } func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error { if s.adobe == nil || s.tokens == nil { return errors.New("refresh client not configured") } profile, err := s.profiles.Get(ctx, id) if err != nil { return err } if profile.Pool != "adobe" || profile.Kind != "adobe_cookie" { return errors.New("unsupported refresh profile") } now := time.Now() _, _ = s.profiles.Update(ctx, id, map[string]any{ "last_attempt_at": now, }) result, err := s.adobe.ExchangeCookie(ctx, profile.Cookie) if err != nil { failures := profile.ConsecutiveFailures + 1 // Exponential backoff: 60s per consecutive failure, capped at 1h. secs := 60 * failures if secs > 3600 { secs = 3600 } msg := err.Error() if len(msg) > 300 { msg = msg[:300] } _, _ = s.profiles.Update(ctx, id, map[string]any{ "last_error": msg, "consecutive_failures": failures, "next_retry_at": now.Add(time.Duration(secs) * time.Second), }) // After repeated failures the cookie can no longer mint a token — it's // genuinely dead (expired/revoked). Lock the pool token (disabled+dead) // so the UI flags it red. A single failure may be a transient blip, so // only escalate after a few in a row (mirrors Python RefreshManager). if failures >= 3 { _, _ = s.tokens.Update(ctx, profile.Pool, id, map[string]any{ "status": "disabled", "dead": true, }) } return err } tokenPatch := map[string]any{ "value": result.AccessToken, "status": "active", "dead": false, "fails": 0, "updated_at": now, } email, exp := parseJWTEmailExpiry(result.AccessToken) if email != "" { tokenPatch["account_email"] = email } if exp != nil { tokenPatch["cached_quota_reset_after"] = exp.Format(time.RFC3339) } if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil { if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" { tokenPatch["account_email"] = email } if displayName := strings.TrimSpace(stringValue(profileData["display_name"])); displayName != "" { tokenPatch["account_display_name"] = displayName } } if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil { meta := datatypes.JSONMap{ "cached_quota_at": int(time.Now().Unix()), } if remaining, ok := quotaData["remaining"].(int); ok { meta["cached_quota_remaining"] = remaining } if used, ok := quotaData["used"].(int); ok { meta["cached_quota_used"] = used } if total, ok := quotaData["total"].(int); ok { meta["cached_quota_total"] = total } tokenPatch["meta"] = meta if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" { tokenPatch["cached_quota_reset_after"] = resetAfter } } if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil { return err } interval := profile.IntervalSeconds if interval <= 0 { interval = 54000 } _, err = s.profiles.Update(ctx, id, map[string]any{ "last_success_at": now, "next_retry_at": now.Add(time.Duration(interval) * time.Second), "last_error": "", "consecutive_failures": 0, }) return err } // RefreshDue refreshes every enabled profile whose next_retry_at has passed. // Driven by the background maintenance loop so Adobe cookies auto-renew without // an admin clicking "refresh". Individual failures are recorded on the profile // (backoff + dead escalation) and don't abort the sweep. func (s *RefreshProfileService) RefreshDue(ctx context.Context) (int, error) { if s.adobe == nil || s.tokens == nil { return 0, nil } due, err := s.profiles.ListDue(ctx, time.Now()) if err != nil { return 0, err } refreshed := 0 for _, p := range due { if p.Pool != "adobe" || p.Kind != "adobe_cookie" { continue } if err := s.RefreshNow(ctx, p.ID); err != nil { continue } refreshed++ } return refreshed, nil } func (s *RefreshProfileService) Update(ctx context.Context, id string, body map[string]any) (*model.RefreshProfile, error) { patch := map[string]any{} if raw, ok := body["enabled"]; ok { patch["enabled"] = boolValueWithDefault(raw, false) } if raw, ok := body["name"]; ok { patch["name"] = stringValue(raw) } if raw, ok := body["interval_seconds"]; ok { n := intValue(raw) if n <= 0 { return nil, errors.New("interval_seconds must be positive") } patch["interval_seconds"] = n } if len(patch) == 0 { return s.profiles.Get(ctx, id) } return s.profiles.Update(ctx, id, patch) } func (s *RefreshProfileService) Delete(ctx context.Context, id string) error { return s.profiles.Delete(ctx, id) }