diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 0d8e14be..9b08d3a2 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -127,7 +127,7 @@ const TERM = { logIn: { en: "Log in", zh: "登录" }, createAccount: { en: "Create an account", zh: "注册账号" }, learnAbout: { en: "Learn about PolyWeather", zh: "了解 PolyWeather" }, - proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" }, + proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" }, proDesc: { en: "The PolyWeather terminal is a paid product. Subscribe to unlock real-time weather-signal intelligence.", zh: "PolyWeather 决策台为付费产品。订阅以解锁实时天气信号情报。", @@ -137,7 +137,7 @@ const TERM = { zh: "按月计费,随时可取消。通过 Polygon 链 USDC 支付。", }, month: { en: "/ month", zh: "/ 月" }, - subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" }, + subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" }, subscribePrompt: { en: "You need an active subscription to access the terminal.", zh: "你需要开通有效订阅才能访问决策台。", @@ -1078,6 +1078,62 @@ function ScanTerminalScreen() { }; }, [loadAuthProfile]); + useEffect(() => { + if ( + !hydrated || + canUseLocalFullAccess || + !proAccess.authenticated || + !proAccess.loading || + proAccess.subscriptionActive || + typeof fetch !== "function" + ) { + return; + } + + let cancelled = false; + const supabaseEnabled = hasSupabasePublicEnv(); + const retryAuthProfile = async () => { + try { + const payload = await loadTerminalAuthProfile({ + getSession: () => + supabaseEnabled + ? getSupabaseBrowserClient().auth.getSession() + : Promise.resolve({ data: { session: null } }), + hasSupabasePublicEnv: supabaseEnabled, + loadAuthProfile, + }); + if (cancelled) return; + setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); + } catch (error) { + if (cancelled) return; + setProAccess((prev) => + prev.loading && prev.authenticated && !prev.subscriptionActive + ? { ...prev, error: String(error) } + : prev, + ); + } + }; + + const firstRetry = window.setTimeout(() => { + void retryAuthProfile(); + }, 1500); + const interval = window.setInterval(() => { + void retryAuthProfile(); + }, 5000); + return () => { + cancelled = true; + window.clearTimeout(firstRetry); + window.clearInterval(interval); + }; + }, [ + canUseLocalFullAccess, + hydrated, + loadAuthProfile, + proAccess.authenticated, + proAccess.loading, + proAccess.subscriptionActive, + ]); + useEffect(() => { setSelectedRegionKey("all"); setLocalTimezoneOffsetSeconds(-new Date().getTimezoneOffset() * 60); diff --git a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx index aa6cf5d6..3c5cd7df 100644 --- a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx +++ b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx @@ -5,9 +5,9 @@ import { LockKeyhole, CreditCard, LogIn } from "lucide-react"; const ACCESS_TERM = { signInToContinue: { en: "Sign in to continue", zh: "请先登录" }, - proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" }, - month: { en: "/ month", zh: "/ 月" }, - subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" }, + proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" }, + month: { en: "/ 30 days", zh: "/ 30 天" }, + subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" }, backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" }, } as const; @@ -19,16 +19,16 @@ function t(key: keyof typeof ACCESS_TERM, isEn: boolean) { function SubscriptionGate({ isEn }: { isEn: boolean }) { const features = isEn ? [ - "Real-time METAR observations across 500+ stations", - "DEB forecast blends with 0–240h horizon", - "AI decision cards with Poly-score ranking", - "Historical backtesting & weather signals", + "Real-time station, METAR, and runway signals", + "DEB forecast curves and model comparison", + "Full terminal grid with high-frequency refresh", + "API and paid Telegram group access on paid Pro", ] : [ - "500+ 气象站实时 METAR 实况", - "DEB 智能融合预测(0–240 小时)", - "AI 决策卡片 + Poly-score 排名", - "历史回测与天气信号", + "实时气象站、METAR 与跑道信号", + "DEB 预测曲线与模型对比", + "完整终端网格与高频刷新", + "付费 Pro 可进入 API 与付费 Telegram 群", ]; return ( @@ -57,7 +57,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
- $10 + 29.9 USDC {t("month", isEn)} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts index ac89db31..4487244e 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts @@ -65,7 +65,9 @@ export function runTests() { degraded_auth_profile: true, }); assert( - coldUnknown.subscriptionActive === false && coldUnknown.authenticated === true, - "cold-start unknown subscription state must not fabricate Pro access", + coldUnknown.subscriptionActive === false && + coldUnknown.authenticated === true && + coldUnknown.loading === true, + "cold-start unknown subscription state must keep the terminal gate loading instead of showing a false paywall", ); } diff --git a/frontend/components/dashboard/scan-terminal/terminal-access-state.ts b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts index ee591c95..97293887 100644 --- a/frontend/components/dashboard/scan-terminal/terminal-access-state.ts +++ b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts @@ -16,11 +16,21 @@ function queuedDays(value: unknown) { return Math.max(0, Number(value ?? 0)); } +export function isSubscriptionStatusUnknown(payload: AuthProfilePayload) { + return ( + payload.subscription_active === null || + payload.subscription_active === undefined || + payload.degraded_auth_profile === true + ); +} + export function createAccessStateFromAuthPayload( payload: AuthProfilePayload, ): ProAccessState { + const subscriptionUnknown = + Boolean(payload.authenticated) && isSubscriptionStatusUnknown(payload); return { - loading: false, + loading: subscriptionUnknown, authenticated: Boolean(payload.authenticated), userId: payload.user_id ?? null, subscriptionActive: payload.subscription_active === true, @@ -41,10 +51,7 @@ export function mergeAccessStateWithAuthPayload( payload: AuthProfilePayload, ): ProAccessState { const next = createAccessStateFromAuthPayload(payload); - const subscriptionUnknown = - payload.subscription_active === null || - payload.subscription_active === undefined || - payload.degraded_auth_profile === true; + const subscriptionUnknown = isSubscriptionStatusUnknown(payload); if (!subscriptionUnknown || !previous.subscriptionActive || !next.authenticated) { return next; @@ -52,6 +59,7 @@ export function mergeAccessStateWithAuthPayload( return { ...next, + loading: false, subscriptionActive: true, subscriptionPlanCode: previous.subscriptionPlanCode, subscriptionExpiresAt: previous.subscriptionExpiresAt, diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index b33148be..be34599d 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -7,7 +7,7 @@ import threading import time from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import requests from loguru import logger @@ -1495,16 +1495,16 @@ class SupabaseEntitlementService: logger.warning(f"supabase subscription query error user_id={user_id}: {exc}") return None - def _query_active_subscription_rows( + def _query_active_subscription_rows_result( self, user_id: str, bypass_cache: bool = False, - ) -> List[Dict[str, object]]: + ) -> Tuple[List[Dict[str, object]], bool]: if not user_id: - return [] + return [], True if not self.service_role_key: logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") - return [] + return [], False now_ts = time.time() if not bypass_cache: @@ -1513,7 +1513,7 @@ class SupabaseEntitlementService: if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: rows = cached.get("rows") if isinstance(rows, list): - return [row for row in rows if isinstance(row, dict)] + return [row for row in rows if isinstance(row, dict)], True try: now = datetime.now(timezone.utc) @@ -1538,7 +1538,7 @@ class SupabaseEntitlementService: user_id, response.status_code, ) - rows: List[Dict[str, object]] = [] + return [], False else: data = response.json() if response.content else [] rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else [] @@ -1551,10 +1551,21 @@ class SupabaseEntitlementService: "rows": rows, "ts": now_ts, } - return rows + return rows, True except Exception as exc: logger.warning(f"supabase active subscription rows query error user_id={user_id}: {exc}") - return [] + return [], False + + def _query_active_subscription_rows( + self, + user_id: str, + bypass_cache: bool = False, + ) -> List[Dict[str, object]]: + rows, _ok = self._query_active_subscription_rows_result( + user_id, + bypass_cache=bypass_cache, + ) + return rows def _query_latest_subscription_any_status( self, @@ -1726,10 +1737,25 @@ class SupabaseEntitlementService: user_id: str, respect_requirement: bool = True, bypass_cache: bool = False, + unknown_on_error: bool = False, ) -> Dict[str, object]: if respect_requirement and not self.require_subscription: return {} - rows = self._query_active_subscription_rows(user_id, bypass_cache=bypass_cache) + rows, query_ok = self._query_active_subscription_rows_result( + user_id, + bypass_cache=bypass_cache, + ) + if not query_ok and unknown_on_error: + return { + "unknown": True, + "current": None, + "current_expires_at": None, + "current_starts_at": None, + "total_expires_at": None, + "queued_days": 0, + "queued_count": 0, + "rows": None, + } return self._subscription_window_from_rows(rows) def _subscription_window_from_rows( diff --git a/tests/test_supabase_entitlement.py b/tests/test_supabase_entitlement.py index a7981b5b..a280b4c7 100644 --- a/tests/test_supabase_entitlement.py +++ b/tests/test_supabase_entitlement.py @@ -164,6 +164,29 @@ def test_subscription_window_query_selects_only_window_fields(monkeypatch): assert window["current"]["plan_code"] == "pro_monthly" +def test_subscription_window_can_report_unknown_on_transient_query_failure(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + + def _fake_get(url, headers=None, params=None, timeout=None): + return _Response(503, {"message": "temporarily unavailable"}) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + window = service.get_subscription_window( + "user-1", + respect_requirement=False, + bypass_cache=True, + unknown_on_error=True, + ) + + assert window["unknown"] is True + assert window["rows"] is None + + def test_list_subscription_windows_selects_only_batch_window_fields(monkeypatch): monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 9b38f6e7..06aa9972 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -382,6 +382,51 @@ def test_auth_me_uses_subscription_window_as_required_subscription_gate(monkeypa assert payload["subscription_queued_days"] == 30 +def test_auth_me_preserves_unknown_subscription_window(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False) + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False) + monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None}) + monkeypatch.setattr(routes, "_resolve_auth_points", lambda request: 0) + + def _bind_identity(request): + request.state.auth_user_id = "user-1" + request.state.auth_email = "user@example.com" + + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_bind_optional_supabase_identity", _bind_identity) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_subscription_window", + lambda user_id, respect_requirement=False, bypass_cache=False, unknown_on_error=False: { + "unknown": True, + "rows": None, + }, + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_latest_active_subscription", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("unknown subscription window must not be downgraded to inactive"), + ), + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_latest_subscription_any_status", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("unknown subscription window must not be treated as subscription history"), + ), + ) + + response = client.get("/api/auth/me") + + assert response.status_code == 200 + payload = response.json() + assert payload["authenticated"] is True + assert payload["subscription_active"] is None + assert payload["subscription_plan_code"] is None + + def test_auth_me_uses_window_rows_for_non_required_latest_known_subscription(monkeypatch): monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False) diff --git a/web/services/auth_api.py b/web/services/auth_api.py index e20b3edb..2259c011 100644 --- a/web/services/auth_api.py +++ b/web/services/auth_api.py @@ -57,6 +57,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: user_id, respect_requirement=False, bypass_cache=True, + unknown_on_error=True, ) except TypeError: subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( @@ -65,8 +66,15 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: ) latest_subscription = None latest_known_subscription = None - subscription_window_known = isinstance(subscription_window, dict) - if isinstance(subscription_window, dict): + subscription_window_unknown = ( + isinstance(subscription_window, dict) + and subscription_window.get("unknown") is True + ) + subscription_window_known = ( + isinstance(subscription_window, dict) + and not subscription_window_unknown + ) + if subscription_window_known: current_subscription = subscription_window.get("current") if isinstance(current_subscription, dict): latest_subscription = current_subscription @@ -79,6 +87,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: if ( not latest_subscription and not latest_known_subscription + and not subscription_window_unknown and not subscription_window_known and not subscription_required ): @@ -89,13 +98,18 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: ) ) - subscription_active = bool(latest_subscription) - if subscription_required and not subscription_active: + subscription_active = ( + None if subscription_window_unknown else bool(latest_subscription) + ) + if ( + subscription_required + and subscription_active is False + ): raise HTTPException(status_code=403, detail="Subscription required") - if not latest_known_subscription: + if not subscription_window_unknown and not latest_known_subscription: latest_known_subscription = latest_subscription - if not latest_known_subscription: + if not subscription_window_unknown and not latest_known_subscription: latest_known_subscription = ( legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status( user_id @@ -113,7 +127,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: subscription_is_trial = _subscription_row_is_trial(latest_known_subscription) subscription_starts_at = latest_known_subscription.get("starts_at") subscription_expires_at = latest_known_subscription.get("expires_at") - if isinstance(subscription_window, dict): + if subscription_window_known: subscription_total_expires_at = subscription_window.get("total_expires_at") subscription_queued_days = int(subscription_window.get("queued_days") or 0) subscription_queued_count = int(subscription_window.get("queued_count") or 0)