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: "登录" },
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);
@@ -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 0240h 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 智能融合预测(0240 小时)",
"AI 决策卡片 + Poly-score 排名",
"历史回测与天气信号",
"实时气象站、METAR 与跑道信号",
"DEB 预测曲线与模型对比",
"完整终端网格与高频刷新",
"付费 Pro 可进入 API 与付费 Telegram 群",
];
return (
@@ -57,7 +57,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
<div className="p-8">
<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">
{t("month", isEn)}
</span>
@@ -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",
);
}
@@ -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,
+36 -10
View File
@@ -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(
+23
View File
@@ -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")
+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
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)
+21 -7
View File
@@ -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)