From 6190f814d958568052ec7ba1c557b2e107f5fa90 Mon Sep 17 00:00:00 2001 From: chiyi Date: Sun, 9 Aug 2026 02:17:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(leonardo):=20=E8=BF=9E=E7=BB=AD=203=20?= =?UTF-8?q?=E6=AC=A1=E9=89=B4=E6=9D=83=E5=A4=B1=E8=B4=A5=E6=89=8D=E5=88=A4?= =?UTF-8?q?=E6=AD=BB=EF=BC=8Cget-session=20=E8=A1=A5=E9=BD=90=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E5=A4=B4=E5=B9=B6=E4=BF=9D=E6=8A=A4=20sessio?= =?UTF-8?q?n=5Fdata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/provider/leonardo/client.go | 83 +++++++++++++++----- backend/internal/service/tokens.go | 34 ++++++-- backend/internal/service/v1.go | 42 +++++++++- 3 files changed, 133 insertions(+), 26 deletions(-) diff --git a/backend/internal/provider/leonardo/client.go b/backend/internal/provider/leonardo/client.go index ec2a0b4..a28d01c 100644 --- a/backend/internal/provider/leonardo/client.go +++ b/backend/internal/provider/leonardo/client.go @@ -30,6 +30,9 @@ const ( graphqlURL = "https://api.leonardo.ai/v1/graphql" schemaVersion = "1.255.2" userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" + // sec-ch-ua must agree with userAgent's major version — a mismatch is itself a + // bot signal. + secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"` ) var ( @@ -137,6 +140,14 @@ func mergeCookies(cookie string, setCookies []string) string { return strings.Join(out, "; ") } +// keepsSession reports whether a merged cookie still carries BOTH components +// get-session needs: the session token and better-auth's session_data cache. +// A merge that loses either one (a Set-Cookie clearing a cache chunk) must be +// discarded — sending it would answer 200 null, i.e. look like a dead account. +func keepsSession(cookie string) bool { + return strings.Contains(cookie, "__Secure-better-auth.session_token") && HasSessionData(cookie) +} + // Session is the result of /api/auth/get-session: the short-lived bearer plus the // ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed // and the CDN image path) and the human-facing account fields. @@ -187,19 +198,30 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error return nil, err } req = req.WithContext(ctx) + // Header set/order copied from a real browser's get-session call (HAR): a + // same-origin GET carries NO origin header and DOES carry the ua client hints + // + priority — sending origin while omitting the hints is exactly the shape + // Vercel's checkpoint 429s. req.Header = http.Header{ - "accept": {"*/*"}, - "accept-language": {"en-US,en;q=0.9"}, - "cookie": {send}, - "origin": {appBase}, - "referer": {appBase + "/"}, - "user-agent": {userAgent}, - "sec-fetch-dest": {"empty"}, - "sec-fetch-mode": {"cors"}, - "sec-fetch-site": {"same-origin"}, + "accept": {"*/*"}, + "accept-language": {"en-US,en;q=0.9"}, + "cache-control": {"no-cache"}, + "cookie": {send}, + "pragma": {"no-cache"}, + "priority": {"u=1, i"}, + "referer": {appBase + "/"}, + "sec-ch-ua": {secChUA}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-origin"}, + "user-agent": {userAgent}, http.HeaderOrderKey: { - "accept", "accept-language", "cookie", "origin", "referer", - "user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", + "accept", "accept-language", "cache-control", "cookie", "pragma", + "priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile", + "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", + "sec-fetch-site", "user-agent", }, } resp, err := client.Do(req) @@ -208,14 +230,20 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send { - c.mu.Lock() - c.rotated[cookie] = merged - c.mu.Unlock() - send = merged + // Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页 + // also sends Set-Cookie (often CLEARING better-auth cookies), and persisting + // that would strip the session_data cache — after which get-session answers + // 200 null and a perfectly healthy account looks dead. + if resp.StatusCode == 200 { + if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send && keepsSession(merged) { + c.mu.Lock() + c.rotated[cookie] = merged + c.mu.Unlock() + send = merged + } } if resp.StatusCode == 401 { - return nil, ErrAuth + return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160)) } if resp.StatusCode != 200 { // 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 — @@ -240,8 +268,10 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream) } if strings.TrimSpace(raw.Session.AccessToken) == "" { - // No bearer despite 200 → the cookie no longer authenticates. - return nil, ErrAuth + // No bearer despite 200 → the cookie no longer authenticates. Carry the body + // so the log says WHICH shape it was (null session vs a session without a + // token) instead of a bare "auth failed". + return nil, fmt.Errorf("%w: get-session 200 without accessToken: %s", ErrAuth, clip(body, 160)) } uid := raw.Session.UserID if uid == "" { @@ -278,6 +308,14 @@ func (c *Client) session(ctx context.Context, cookie string, force bool) (*Sessi return c.GetSession(ctx, cookie) } +// ProbeSession force-mints a session from the cookie, bypassing the cached +// bearer. Callers use it to double-check an auth failure before killing an +// account: a rejected bearer (rotation race / expired token) still yields a +// working cookie here, only a genuinely dead cookie returns ErrAuth. +func (c *Client) ProbeSession(ctx context.Context, cookie string) (*Session, error) { + return c.session(ctx, cookie, true) +} + // callGraphQL runs one GraphQL call for an account cookie. The bearer only lives // ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted // from the cookie and the call retried once. Only a cookie that itself stops @@ -411,7 +449,11 @@ func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byt "accept": {"*/*"}, "accept-language": {"en-US,en;q=0.9"}, "origin": {appBase}, + "priority": {"u=1, i"}, "referer": {appBase + "/"}, + "sec-ch-ua": {secChUA}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, "user-agent": {userAgent}, "authorization": {"Bearer " + accessToken}, "x-leo-schema-version": {schemaVersion}, @@ -419,7 +461,8 @@ func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byt "sec-fetch-mode": {"cors"}, "sec-fetch-site": {"same-site"}, http.HeaderOrderKey: { - "content-type", "accept", "accept-language", "origin", "referer", + "content-type", "accept", "accept-language", "origin", "priority", + "referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "user-agent", "authorization", "x-leo-schema-version", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", }, diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index 65e4bd8..a4a8a36 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -1114,6 +1114,24 @@ func (s *TokenService) ImportCustomAccount(ctx context.Context, baseURL, apiKey, return item, nil } +// leonardoAuthStrikeLimit 是 leonardo 号被判死前允许的连续鉴权失败次数。上游偶发 +// 返回 200 null / 401(cookie 轮换竞态、人机校验)时一次就判死会误杀健康号,所以要 +// 连续失败到这个次数才判死;任何一次成功都会清零。 +const leonardoAuthStrikeLimit = 3 + +// leonardoAuthStrike 记一次鉴权失败:返回要写回的 meta、当前连续失败次数,以及是否 +// 该判死。 +func leonardoAuthStrike(item *model.TokenAccount, reason string) (datatypes.JSONMap, int, bool) { + meta := cloneJSONMap(item.Meta) + strikes := 1 + if n, ok := jsonMapInt(item.Meta, "auth_fails"); ok { + strikes = n + 1 + } + meta["auth_fails"] = strikes + meta["last_auth_error"] = reason + return meta, strikes, strikes >= leonardoAuthStrikeLimit +} + // finishPending writes the terminal status/dead flag and clears the pending_check // marker (merging any cached quota) for a background import probe. func (s *TokenService) finishPending(ctx context.Context, pool, id, status string, dead bool, quotaMeta map[string]any) { @@ -1469,17 +1487,23 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a s.persistLeonardoCookie(ctx, item.ID, item.Value) if err != nil { if errors.Is(err, leonardo.ErrAuth) { - _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ - "status": "disabled", - "dead": true, - "fails": gorm.Expr("fails + 1"), - }) + meta, strikes, kill := leonardoAuthStrike(item, "quota refresh: "+err.Error()) + patch := map[string]any{"meta": meta, "fails": gorm.Expr("fails + 1")} + if kill { + patch["status"] = "disabled" + patch["dead"] = true + log.Printf("account leonardo/%s disabled after %d consecutive auth failures: %v", item.ID, strikes, err) + } else { + log.Printf("leonardo %s: auth failure %d/%d on quota refresh (%v) — kept active", item.ID, strikes, leonardoAuthStrikeLimit, err) + } + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, patch) } return nil, err } patch := map[string]any{} meta := cloneJSONMap(item.Meta) meta["cached_quota_at"] = int(time.Now().Unix()) + meta["auth_fails"] = 0 // cookie 还能换 token,连续失败计数清零 if remaining, ok := data["remaining"].(int); ok { meta["cached_quota_remaining"] = remaining // Below the per-generation floor → sink to "限额" so it stops being diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 767243a..c6afe47 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "path/filepath" "sort" @@ -3762,9 +3763,30 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod // grok is intentionally excluded: a grok sso can momentarily 401 while // still valid (upstream blip / proxy / anti-bot), so an auth failure just // fails over for this request without permanently killing the account. - if pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine" { + disable := pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine" + if disable && pool == "leonardo" { + // 两道保险:先重新 get-session 复核(单次失败常是 bearer 轮换竞态),复核 + // 也不过就只记一次连续失败,连续到上限才判死。 + if s.leonardoCookieAlive(ctx, token) { + log.Printf("leonardo %s: auth failure on %s but cookie still authenticates — kept active", token.ID, kind) + disable = false + } else { + meta, strikes, kill := leonardoAuthStrike(&token, "auth failure on "+kind) + patch["meta"] = meta + disable = kill + if kill { + log.Printf("account leonardo/%s disabled after %d consecutive auth failures: %s", token.ID, strikes, kind) + } else { + log.Printf("leonardo %s: auth failure %d/%d on %s — kept active", token.ID, strikes, leonardoAuthStrikeLimit, kind) + } + } + } + if disable { patch["status"] = "disabled" patch["dead"] = true + if pool != "leonardo" { + log.Printf("account %s/%s disabled: auth failure on %s", pool, token.ID, kind) + } } default: // Neither pool is auto-disabled on generic (non-auth / non-quota) failures @@ -3775,6 +3797,24 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod _, _ = s.tokens.Update(ctx, pool, token.ID, patch) } +// leonardoCookieAlive re-checks a Leonardo cookie after an auth failure by +// force-minting a session (bypassing the cached bearer). Only a cookie that +// still fails to authenticate counts as dead; a temporary upstream answer +// (403/429 人机校验) also keeps the account alive. +func (s *V1Service) leonardoCookieAlive(ctx context.Context, token model.TokenAccount) bool { + if s.leonardo == nil || strings.TrimSpace(token.Value) == "" { + return false + } + probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + sess, err := s.leonardo.ProbeSession(probeCtx, token.Value) + if err == nil && sess != nil && strings.TrimSpace(sess.AccessToken) != "" { + s.leonardoPersistCookie(probeCtx, token.ID, token.Value) + return true + } + return !errors.Is(err, leonardo.ErrAuth) +} + // markTokenDead disables an account and marks it dead on a fatal upstream error // (a non-overload temporary Adobe failure that ops policy treats as account death). func (s *V1Service) markTokenDead(ctx context.Context, pool string, token model.TokenAccount, kind string) {