From fb7e27d9edd7056380944d0bcb913dbf98cb893f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 27 May 2026 12:14:07 +0800 Subject: [PATCH] fix: recover account subscription state --- frontend/components/account/AccountCenter.tsx | 74 ++++++++++++++++--- .../account/__tests__/paymentShell.test.ts | 14 ++++ frontend/components/account/account-copy.ts | 4 + .../components/account/useAccountPayment.ts | 33 ++++++++- tests/test_web_observability.py | 26 +++++++ web/core.py | 18 +++++ 6 files changed, 155 insertions(+), 14 deletions(-) diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 06942056..2b574d40 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -286,7 +286,22 @@ export function AccountCenter() { copy.guestUser; const initials = (displayName.slice(0, 2) || "PW").toUpperCase(); const joinedAt = formatTime(user?.created_at, locale); - const isSubscribed = Boolean(backend?.subscription_active); + const backendAuthenticated = backend?.authenticated === true; + const localAuthenticated = Boolean(user?.id); + const isSubscriptionUnknown = Boolean( + localAuthenticated && + (!backend || + backend.subscription_active == null || + backend.authenticated === false), + ); + const isSubscribed = backend?.subscription_active === true; + const subscriptionStatusLabel = isSubscribed + ? copy.proMember + : isSubscriptionUnknown + ? copy.subscriptionChecking + : isEn + ? "UNSUBSCRIBED" + : "未订阅"; const planCode = String(backend?.subscription_plan_code || "").trim(); const currentExpiryRaw = String( backend?.subscription_expires_at || @@ -323,6 +338,8 @@ export function AccountCenter() { ? expiryFormatted !== "--" ? expiryFormatted : displayExpiryRaw || copy.proPendingSync + : isSubscriptionUnknown + ? copy.subscriptionUnknown : copy.noProSubscription; const showExpiringSoon = Boolean( isSubscribed && @@ -337,6 +354,7 @@ export function AccountCenter() { const paymentFeatureReady = paymentReadyForRecovery; const canOpenCheckoutOverlay = Boolean( paymentFeatureReady && + !isSubscriptionUnknown && (!isSubscribed || showExpiringSoon || showExpiredReminder), ); const subscriptionStatusTitle = showExpiredReminder @@ -533,7 +551,13 @@ export function AccountCenter() { {initials}
@@ -542,13 +566,15 @@ export function AccountCenter() {

{displayName}

- {isSubscribed - ? copy.proMember - : isEn - ? "UNSUBSCRIBED" - : "未订阅"} + {subscriptionStatusLabel}

@@ -685,19 +711,37 @@ export function AccountCenter() { @@ -724,7 +768,13 @@ export function AccountCenter() { {queuedExtensionSummary ? (

diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index cf590466..283db44e 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -139,6 +139,20 @@ export function runTests() { accountCenterSource.includes('trackAppEvent("dashboard_active"'), "account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data", ); + assert( + accountCenterSource.includes("isSubscriptionUnknown") && + accountCenterSource.includes("subscriptionStatusLabel") && + !accountCenterSource.includes( + 'backend?.subscription_active);', + ), + "account center must distinguish unknown subscription sync state from a confirmed unsubscribed account", + ); + assert( + hookSource.includes("backendJson.authenticated === false") && + hookSource.includes("refreshSession()") && + hookSource.includes("retriedBackendJson"), + "account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated", + ); assert( subscriptionsPageSource.includes("getSupabaseBrowserClient") && subscriptionsPageSource.includes("refreshSession") && diff --git a/frontend/components/account/account-copy.ts b/frontend/components/account/account-copy.ts index c88ef856..44528324 100644 --- a/frontend/components/account/account-copy.ts +++ b/frontend/components/account/account-copy.ts @@ -130,6 +130,10 @@ export function createAccountCopy(isEn: boolean): Record { : "钱包已绑定,正在创建订单并发起支付...", proMember: "PRO MEMBER", proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)", + subscriptionChecking: isEn ? "Checking subscription" : "订阅同步中", + subscriptionUnknown: isEn + ? "Subscription status is syncing. Please refresh shortly." + : "订阅状态正在同步,请稍后刷新。", noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期", proEndsSoonBody: isEn diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index 012303bd..8f44ed28 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -167,7 +167,36 @@ export function useAccountPayment(params: UseAccountPaymentParams) { const raw = (await backendResult.text()).slice(0, 260); throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw)); } - const backendJson = (await backendResult.json()) as AuthMeResponse; + let backendJson = (await backendResult.json()) as AuthMeResponse; + if ( + retry && + supabaseReady && + userResult.data?.user && + backendJson.authenticated === false + ) { + try { + const { + data: { session: refreshedSession }, + } = await getSupabaseBrowserClient().auth.refreshSession(); + const refreshedToken = String( + refreshedSession?.access_token || "", + ).trim(); + if (refreshedToken) { + const retriedBackendResult = await fetch("/api/auth/me", { + cache: "no-store", + headers: { Authorization: `Bearer ${refreshedToken}` }, + }); + if (retriedBackendResult.ok) { + const retriedBackendJson = + (await retriedBackendResult.json()) as AuthMeResponse; + backendJson = retriedBackendJson; + } + } + } catch { + // Keep the first response; the UI treats a logged-in local user with + // an unauthenticated backend snapshot as a temporary sync state. + } + } setBackend(backendJson); setUpdatedAt(new Date().toISOString()); }; @@ -467,4 +496,4 @@ export function useAccountPayment(params: UseAccountPaymentParams) { handleOverlayCheckout: paymentFlow.handleOverlayCheckout, openTelegramBotBindLink: billing.openTelegramBotBindLink, }; -} \ No newline at end of file +} diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 20dc0cc2..0852cdd5 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -1,6 +1,8 @@ from fastapi.testclient import TestClient +from starlette.requests import Request +import web.core as web_core from web.app import app import web.routes as routes import web.scan_terminal_cache as scan_terminal_cache @@ -141,6 +143,30 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch): assert reconcile_calls["count"] == 0 +def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True) + monkeypatch.setattr(web_core, "_ENTITLEMENT_TOKEN", "backend-token") + + request = Request( + { + "type": "http", + "headers": [ + (b"x-polyweather-entitlement", b"backend-token"), + (b"x-polyweather-auth-user-id", b"user-1"), + (b"x-polyweather-auth-email", b"user@example.com"), + ], + } + ) + + web_core._assert_entitlement(request) + + assert request.state.auth_user_id == "user-1" + assert request.state.auth_email == "user@example.com" + + def test_ops_memberships_prefers_supabase_auth_email(monkeypatch): monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) diff --git a/web/core.py b/web/core.py index 3b4f7f5c..fea65ef6 100644 --- a/web/core.py +++ b/web/core.py @@ -217,14 +217,31 @@ def _legacy_service_token_valid(request: Request) -> bool: return bool(_ENTITLEMENT_TOKEN and token == _ENTITLEMENT_TOKEN) +def _bind_forwarded_supabase_identity(request: Request) -> bool: + if not _legacy_service_token_valid(request): + return False + forwarded_user_id = str( + request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or "" + ).strip() + if not forwarded_user_id: + return False + request.state.auth_user_id = forwarded_user_id + request.state.auth_email = str( + request.headers.get(_FORWARDED_SUPABASE_EMAIL_HEADER) or "" + ).strip() + return True + + def _bind_optional_supabase_identity(request: Request) -> None: if not SUPABASE_ENTITLEMENT.configured: return access_token = extract_bearer_token(request.headers.get("authorization")) if not access_token: + _bind_forwarded_supabase_identity(request) return identity = SUPABASE_ENTITLEMENT.get_identity(access_token) if not identity: + _bind_forwarded_supabase_identity(request) return request.state.auth_user_id = identity.user_id request.state.auth_email = identity.email @@ -286,6 +303,7 @@ def _resolve_weekly_profile(request: Request) -> Dict[str, Any]: def _assert_entitlement(request: Request) -> None: if SUPABASE_ENTITLEMENT.enabled: if _legacy_service_token_valid(request): + _bind_forwarded_supabase_identity(request) return if not _SUPABASE_AUTH_REQUIRED: _bind_optional_supabase_identity(request)