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:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
@@ -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)
}