Fix terminal subscription gate on unknown auth state

This commit is contained in:
2569718930@qq.com
2026-05-30 18:34:38 +08:00
parent 90bc895000
commit 097971f107
8 changed files with 212 additions and 38 deletions
@@ -127,7 +127,7 @@ const TERM = {
logIn: { en: "Log in", zh: "登录" }, logIn: { en: "Log in", zh: "登录" },
createAccount: { en: "Create an account", zh: "注册账号" }, createAccount: { en: "Create an account", zh: "注册账号" },
learnAbout: { en: "Learn about PolyWeather", zh: "了解 PolyWeather" }, learnAbout: { en: "Learn about PolyWeather", zh: "了解 PolyWeather" },
proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" }, proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
proDesc: { proDesc: {
en: "The PolyWeather terminal is a paid product. Subscribe to unlock real-time weather-signal intelligence.", en: "The PolyWeather terminal is a paid product. Subscribe to unlock real-time weather-signal intelligence.",
zh: "PolyWeather 决策台为付费产品。订阅以解锁实时天气信号情报。", zh: "PolyWeather 决策台为付费产品。订阅以解锁实时天气信号情报。",
@@ -137,7 +137,7 @@ const TERM = {
zh: "按月计费,随时可取消。通过 Polygon 链 USDC 支付。", zh: "按月计费,随时可取消。通过 Polygon 链 USDC 支付。",
}, },
month: { en: "/ month", zh: "/ 月" }, month: { en: "/ month", zh: "/ 月" },
subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" }, subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
subscribePrompt: { subscribePrompt: {
en: "You need an active subscription to access the terminal.", en: "You need an active subscription to access the terminal.",
zh: "你需要开通有效订阅才能访问决策台。", zh: "你需要开通有效订阅才能访问决策台。",
@@ -1078,6 +1078,62 @@ function ScanTerminalScreen() {
}; };
}, [loadAuthProfile]); }, [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(() => { useEffect(() => {
setSelectedRegionKey("all"); setSelectedRegionKey("all");
setLocalTimezoneOffsetSeconds(-new Date().getTimezoneOffset() * 60); setLocalTimezoneOffsetSeconds(-new Date().getTimezoneOffset() * 60);
@@ -5,9 +5,9 @@ import { LockKeyhole, CreditCard, LogIn } from "lucide-react";
const ACCESS_TERM = { const ACCESS_TERM = {
signInToContinue: { en: "Sign in to continue", zh: "请先登录" }, signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" }, proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
month: { en: "/ month", zh: "/ " }, month: { en: "/ 30 days", zh: "/ 30 天" },
subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" }, subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" }, backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
} as const; } as const;
@@ -19,16 +19,16 @@ function t(key: keyof typeof ACCESS_TERM, isEn: boolean) {
function SubscriptionGate({ isEn }: { isEn: boolean }) { function SubscriptionGate({ isEn }: { isEn: boolean }) {
const features = isEn const features = isEn
? [ ? [
"Real-time METAR observations across 500+ stations", "Real-time station, METAR, and runway signals",
"DEB forecast blends with 0240h horizon", "DEB forecast curves and model comparison",
"AI decision cards with Poly-score ranking", "Full terminal grid with high-frequency refresh",
"Historical backtesting & weather signals", "API and paid Telegram group access on paid Pro",
] ]
: [ : [
"500+ 气象站实时 METAR 实况", "实时气象站、METAR 与跑道信号",
"DEB 智能融合预测(0240 小时)", "DEB 预测曲线与模型对比",
"AI 决策卡片 + Poly-score 排名", "完整终端网格与高频刷新",
"历史回测与天气信号", "付费 Pro 可进入 API 与付费 Telegram 群",
]; ];
return ( return (
@@ -57,7 +57,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
<div className="p-8"> <div className="p-8">
<div className="mb-6 flex items-baseline gap-1"> <div className="mb-6 flex items-baseline gap-1">
<span className="text-4xl font-black text-slate-900">$10</span> <span className="text-4xl font-black text-slate-900">29.9 USDC</span>
<span className="text-base text-slate-500"> <span className="text-base text-slate-500">
{t("month", isEn)} {t("month", isEn)}
</span> </span>
@@ -65,7 +65,9 @@ export function runTests() {
degraded_auth_profile: true, degraded_auth_profile: true,
}); });
assert( assert(
coldUnknown.subscriptionActive === false && coldUnknown.authenticated === true, coldUnknown.subscriptionActive === false &&
"cold-start unknown subscription state must not fabricate Pro access", coldUnknown.authenticated === true &&
coldUnknown.loading === true,
"cold-start unknown subscription state must keep the terminal gate loading instead of showing a false paywall",
); );
} }
@@ -16,11 +16,21 @@ function queuedDays(value: unknown) {
return Math.max(0, Number(value ?? 0)); 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( export function createAccessStateFromAuthPayload(
payload: AuthProfilePayload, payload: AuthProfilePayload,
): ProAccessState { ): ProAccessState {
const subscriptionUnknown =
Boolean(payload.authenticated) && isSubscriptionStatusUnknown(payload);
return { return {
loading: false, loading: subscriptionUnknown,
authenticated: Boolean(payload.authenticated), authenticated: Boolean(payload.authenticated),
userId: payload.user_id ?? null, userId: payload.user_id ?? null,
subscriptionActive: payload.subscription_active === true, subscriptionActive: payload.subscription_active === true,
@@ -41,10 +51,7 @@ export function mergeAccessStateWithAuthPayload(
payload: AuthProfilePayload, payload: AuthProfilePayload,
): ProAccessState { ): ProAccessState {
const next = createAccessStateFromAuthPayload(payload); const next = createAccessStateFromAuthPayload(payload);
const subscriptionUnknown = const subscriptionUnknown = isSubscriptionStatusUnknown(payload);
payload.subscription_active === null ||
payload.subscription_active === undefined ||
payload.degraded_auth_profile === true;
if (!subscriptionUnknown || !previous.subscriptionActive || !next.authenticated) { if (!subscriptionUnknown || !previous.subscriptionActive || !next.authenticated) {
return next; return next;
@@ -52,6 +59,7 @@ export function mergeAccessStateWithAuthPayload(
return { return {
...next, ...next,
loading: false,
subscriptionActive: true, subscriptionActive: true,
subscriptionPlanCode: previous.subscriptionPlanCode, subscriptionPlanCode: previous.subscriptionPlanCode,
subscriptionExpiresAt: previous.subscriptionExpiresAt, subscriptionExpiresAt: previous.subscriptionExpiresAt,
+36 -10
View File
@@ -7,7 +7,7 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
import requests import requests
from loguru import logger from loguru import logger
@@ -1495,16 +1495,16 @@ class SupabaseEntitlementService:
logger.warning(f"supabase subscription query error user_id={user_id}: {exc}") logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
return None return None
def _query_active_subscription_rows( def _query_active_subscription_rows_result(
self, self,
user_id: str, user_id: str,
bypass_cache: bool = False, bypass_cache: bool = False,
) -> List[Dict[str, object]]: ) -> Tuple[List[Dict[str, object]], bool]:
if not user_id: if not user_id:
return [] return [], True
if not self.service_role_key: if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return [] return [], False
now_ts = time.time() now_ts = time.time()
if not bypass_cache: 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: if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
rows = cached.get("rows") rows = cached.get("rows")
if isinstance(rows, list): 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: try:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -1538,7 +1538,7 @@ class SupabaseEntitlementService:
user_id, user_id,
response.status_code, response.status_code,
) )
rows: List[Dict[str, object]] = [] return [], False
else: else:
data = response.json() if response.content else [] data = response.json() if response.content else []
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else [] rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
@@ -1551,10 +1551,21 @@ class SupabaseEntitlementService:
"rows": rows, "rows": rows,
"ts": now_ts, "ts": now_ts,
} }
return rows return rows, True
except Exception as exc: except Exception as exc:
logger.warning(f"supabase active subscription rows query error user_id={user_id}: {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( def _query_latest_subscription_any_status(
self, self,
@@ -1726,10 +1737,25 @@ class SupabaseEntitlementService:
user_id: str, user_id: str,
respect_requirement: bool = True, respect_requirement: bool = True,
bypass_cache: bool = False, bypass_cache: bool = False,
unknown_on_error: bool = False,
) -> Dict[str, object]: ) -> Dict[str, object]:
if respect_requirement and not self.require_subscription: if respect_requirement and not self.require_subscription:
return {} 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) return self._subscription_window_from_rows(rows)
def _subscription_window_from_rows( def _subscription_window_from_rows(
+23
View File
@@ -164,6 +164,29 @@ def test_subscription_window_query_selects_only_window_fields(monkeypatch):
assert window["current"]["plan_code"] == "pro_monthly" 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): def test_list_subscription_windows_selects_only_batch_window_fields(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
+45
View File
@@ -382,6 +382,51 @@ def test_auth_me_uses_subscription_window_as_required_subscription_gate(monkeypa
assert payload["subscription_queued_days"] == 30 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): 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, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False) monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False)
+21 -7
View File
@@ -57,6 +57,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
user_id, user_id,
respect_requirement=False, respect_requirement=False,
bypass_cache=True, bypass_cache=True,
unknown_on_error=True,
) )
except TypeError: except TypeError:
subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( 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_subscription = None
latest_known_subscription = None latest_known_subscription = None
subscription_window_known = isinstance(subscription_window, dict) subscription_window_unknown = (
if isinstance(subscription_window, dict): 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") current_subscription = subscription_window.get("current")
if isinstance(current_subscription, dict): if isinstance(current_subscription, dict):
latest_subscription = current_subscription latest_subscription = current_subscription
@@ -79,6 +87,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
if ( if (
not latest_subscription not latest_subscription
and not latest_known_subscription and not latest_known_subscription
and not subscription_window_unknown
and not subscription_window_known and not subscription_window_known
and not subscription_required and not subscription_required
): ):
@@ -89,13 +98,18 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
) )
) )
subscription_active = bool(latest_subscription) subscription_active = (
if subscription_required and not 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") 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 latest_known_subscription = latest_subscription
if not latest_known_subscription: if not subscription_window_unknown and not latest_known_subscription:
latest_known_subscription = ( latest_known_subscription = (
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status( legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(
user_id 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_is_trial = _subscription_row_is_trial(latest_known_subscription)
subscription_starts_at = latest_known_subscription.get("starts_at") subscription_starts_at = latest_known_subscription.get("starts_at")
subscription_expires_at = latest_known_subscription.get("expires_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_total_expires_at = subscription_window.get("total_expires_at")
subscription_queued_days = int(subscription_window.get("queued_days") or 0) subscription_queued_days = int(subscription_window.get("queued_days") or 0)
subscription_queued_count = int(subscription_window.get("queued_count") or 0) subscription_queued_count = int(subscription_window.get("queued_count") or 0)