Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
5.3 KiB
Go
186 lines
5.3 KiB
Go
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)
|
|
}
|