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)