Initial open-source release (MIT): image2api AI gateway
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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user