From a9daad2fdaec235e7c683b0852b0ee53ca20ada0 Mon Sep 17 00:00:00 2001 From: chiyi Date: Sun, 9 Aug 2026 21:27:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(leonardo):=20=E6=AF=8F5=E5=88=86=E9=92=9F?= =?UTF-8?q?=E6=8C=89=E5=BA=93=E5=86=85=E6=97=B6=E9=97=B4=E6=88=B3=E7=BB=AD?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=20session=20=E5=B9=B6=E5=86=99=E5=9B=9E?= =?UTF-8?q?=E8=BD=AE=E6=8D=A2=20cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/maintenance.go | 6 +++ backend/internal/service/tokens.go | 69 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/backend/internal/service/maintenance.go b/backend/internal/service/maintenance.go index ca539eb..1f4ff41 100644 --- a/backend/internal/service/maintenance.go +++ b/backend/internal/service/maintenance.go @@ -165,6 +165,12 @@ func (m *MaintenanceService) tick(ctx context.Context) { // means the membership lapsed → disable+dead; otherwise re-sync the // credits balance and 恢复时间 (from the credits' weekly reset). m.tokenSvc.RefreshGrokLiveness(ctx) + // 1f. Keep leonardo's better-auth session rolling: get-session extends the + // session and rotates the cookie, so re-minting stops a dormant + // account's cookie from lapsing (a lapsed one can only be fixed by + // re-importing a browser cookie). Due-ness is read per account from + // meta["session_kept_at"], so a restart can't skip a renewal. + m.tokenSvc.RefreshLeonardoSessions(ctx) } // 2. Auto-renew Adobe cookies whose refresh interval has elapsed. diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index a4a8a36..9cfe6a8 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -56,6 +56,9 @@ type TokenService struct { // kreaActivating guards the once-per-day krea /app activation sweep so the 60s // maintenance tick can't pile up overlapping sweeps. kreaActivating atomic.Bool + // leonardoKeeping guards the leonardo session keep-alive sweep so the 60s + // maintenance tick can't pile up overlapping sweeps. + leonardoKeeping atomic.Bool } func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client) *TokenService { @@ -137,6 +140,72 @@ func (s *TokenService) RefreshExpiringTokens(ctx context.Context) { } } +// leonardoKeepaliveEvery is how long a leonardo cookie may go unrenewed. The due +// check reads each account's own meta["session_kept_at"], so the cadence survives +// a restart (no in-memory timer to lose) — after a restart every account whose +// stamp is older than this is simply due again. +const leonardoKeepaliveEvery = 5 * time.Minute + +// leonardoKeptAtKey stamps the last successful session keep-alive. +const leonardoKeptAtKey = "session_kept_at" + +// RefreshLeonardoSessions re-mints a session for every live leonardo account whose +// last keep-alive is older than leonardoKeepaliveEvery, instead of waiting for the +// ~1h bearer cache to lapse or for the account to be used. get-session rolls the +// better-auth session (expiresAt is pushed out) and may rotate CF_Access_Token / +// session_data, so the rotated cookie is written back. It never disables an +// account: a dead cookie is left to the quota-refresh strike logic, which +// double-checks before killing. +func (s *TokenService) RefreshLeonardoSessions(ctx context.Context) { + if s.leonardo == nil { + return + } + if !s.leonardoKeeping.CompareAndSwap(false, true) { + return // a sweep is already running + } + bg := context.WithoutCancel(ctx) + go func() { + defer s.leonardoKeeping.Store(false) + items, err := s.tokens.ListByPool(bg, "leonardo") + if err != nil { + return + } + due := time.Now().Add(-leonardoKeepaliveEvery).Unix() + proxied := false + for i := range items { + a := items[i] + if a.Dead || a.Status == "disabled" || strings.TrimSpace(a.Value) == "" { + continue + } + if at, ok := jsonMapInt(a.Meta, leonardoKeptAtKey); ok && int64(at) > due { + continue // renewed less than leonardoKeepaliveEvery ago + } + if !proxied { + s.applyProxy(bg) + proxied = true + } + callCtx, cancel := context.WithTimeout(bg, 90*time.Second) + _, err := s.leonardo.ProbeSession(callCtx, a.Value) + if err != nil { + cancel() + if errors.Is(err, leonardo.ErrAuth) { + log.Printf("leonardo %s: session keepalive auth failure (%v)", a.ID, err) + } + continue // leave the stamp alone so the next tick retries + } + fields := map[string]any{} + if fresh, ok := s.leonardo.RotatedCookie(a.Value); ok && strings.TrimSpace(fresh) != "" { + fields["value"] = fresh + } + meta := cloneJSONMap(a.Meta) + meta[leonardoKeptAtKey] = int(time.Now().Unix()) + fields["meta"] = meta + _, _ = s.tokens.Update(callCtx, "leonardo", a.ID, fields) + cancel() + } + }() +} + // ActivateKreaDue loads /app (Activate) for each krea account that hasn't been // synced since the most recent daily reset, then re-syncs its balance. Krea only // grants the daily free balance after the SSR app page loads, so without this an