feat: Introduce web application with Supabase authentication, entitlement, and account management.
This commit is contained in:
@@ -55,6 +55,9 @@ type AuthMeResponse = {
|
||||
auth_required?: boolean;
|
||||
subscription_required?: boolean;
|
||||
subscription_active?: boolean | null;
|
||||
subscription_plan_code?: string | null;
|
||||
subscription_starts_at?: string | null;
|
||||
subscription_expires_at?: string | null;
|
||||
};
|
||||
|
||||
type PaymentPlan = {
|
||||
@@ -236,7 +239,8 @@ function buildBalanceOfCalldata(owner: string) {
|
||||
}
|
||||
|
||||
function formatTokenUnits(amount: bigint, decimals: number) {
|
||||
const safeDecimals = Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : 6;
|
||||
const safeDecimals =
|
||||
Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : 6;
|
||||
const base = 10n ** BigInt(safeDecimals);
|
||||
const whole = amount / base;
|
||||
const fraction = amount % base;
|
||||
@@ -303,7 +307,9 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
||||
}
|
||||
|
||||
const insufficientGas =
|
||||
(code === -32000 && /insufficient funds/.test(lower) && /(gas|fee|native|pol|matic)/.test(lower)) ||
|
||||
(code === -32000 &&
|
||||
/insufficient funds/.test(lower) &&
|
||||
/(gas|fee|native|pol|matic)/.test(lower)) ||
|
||||
/not enough pol|insufficient (pol|matic)|insufficient funds for gas|network fee|网络费|手续费/.test(
|
||||
lower,
|
||||
);
|
||||
@@ -548,7 +554,15 @@ export function AccountCenter() {
|
||||
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
||||
const joinedAt = formatTime(user?.created_at, "zh-CN");
|
||||
const isSubscribed = Boolean(backend?.subscription_active);
|
||||
const proExpiry = user?.user_metadata?.pro_expiry || "暂无 Pro 订阅";
|
||||
const expiryRaw = String(
|
||||
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
||||
).trim();
|
||||
const expiryFormatted = formatTime(expiryRaw, "zh-CN");
|
||||
const proExpiry = isSubscribed
|
||||
? expiryFormatted !== "--"
|
||||
? expiryFormatted
|
||||
: expiryRaw || "已开通(待同步)"
|
||||
: "暂无 Pro 订阅";
|
||||
|
||||
// Points Logic
|
||||
const backendPointsRaw = Number(backend?.points);
|
||||
@@ -609,7 +623,9 @@ export function AccountCenter() {
|
||||
: Number(paymentConfig?.token_decimals ?? 6),
|
||||
}));
|
||||
if (clean.length) return clean;
|
||||
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
|
||||
const fallbackAddress = String(
|
||||
paymentConfig?.token_address || "",
|
||||
).toLowerCase();
|
||||
if (!fallbackAddress.startsWith("0x")) return [];
|
||||
return [
|
||||
{
|
||||
@@ -632,11 +648,14 @@ export function AccountCenter() {
|
||||
"",
|
||||
).toLowerCase();
|
||||
const selectedPaymentToken =
|
||||
availableTokenList.find((row) => row.address === resolvedSelectedTokenAddress) ||
|
||||
availableTokenList[0];
|
||||
availableTokenList.find(
|
||||
(row) => row.address === resolvedSelectedTokenAddress,
|
||||
) || availableTokenList[0];
|
||||
const selectedTokenLabel =
|
||||
selectedPaymentToken?.symbol ||
|
||||
(resolvedSelectedTokenAddress.startsWith("0x") ? shortAddress(resolvedSelectedTokenAddress) : "USDC");
|
||||
(resolvedSelectedTokenAddress.startsWith("0x")
|
||||
? shortAddress(resolvedSelectedTokenAddress)
|
||||
: "USDC");
|
||||
const paymentFeatureReady = Boolean(
|
||||
paymentConfig?.enabled && paymentConfig?.configured,
|
||||
);
|
||||
@@ -1295,17 +1314,24 @@ export function AccountCenter() {
|
||||
会员权限详情
|
||||
</h3>
|
||||
<InfoRow icon={ShieldCheck} label="鉴权模式" value="Supabase" />
|
||||
<InfoRow icon={BarChart3} label="气象引擎" value="Premium AI" />
|
||||
<InfoRow icon={BarChart3} label="气象引擎" value="DEB + 多模型" />
|
||||
<InfoRow
|
||||
icon={Zap}
|
||||
label="实时雷达"
|
||||
label="今日内分析"
|
||||
value={isSubscribed ? "深度版(含高温时段)" : "简版可见"}
|
||||
isPrimary={isSubscribed}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Clock}
|
||||
label="历史对账 + 未来日期分析"
|
||||
value={isSubscribed ? "已开启" : "锁定"}
|
||||
isPrimary={isSubscribed}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Sparkles}
|
||||
label="平台广告"
|
||||
value={isSubscribed ? "已移除" : "可见"}
|
||||
icon={Bot}
|
||||
label="全平台智能气象推送"
|
||||
value={isSubscribed ? "已开启" : "锁定"}
|
||||
isPrimary={isSubscribed}
|
||||
/>
|
||||
</section>
|
||||
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
||||
@@ -1423,7 +1449,9 @@ export function AccountCenter() {
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-bold">{token.symbol}</div>
|
||||
<div className="text-xs font-bold">
|
||||
{token.symbol}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-80 truncate">
|
||||
{token.name}
|
||||
</div>
|
||||
|
||||
@@ -144,23 +144,29 @@ class SupabaseEntitlementService:
|
||||
logger.warning(f"supabase auth user check failed: {exc}")
|
||||
return None
|
||||
|
||||
def _query_active_subscription(self, user_id: str) -> bool:
|
||||
def _query_latest_active_subscription(
|
||||
self,
|
||||
user_id: str,
|
||||
) -> Optional[Dict[str, object]]:
|
||||
if not user_id:
|
||||
return False
|
||||
return None
|
||||
if not self.service_role_key:
|
||||
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
|
||||
return False
|
||||
return None
|
||||
|
||||
now_ts = time.time()
|
||||
with self._sub_cache_lock:
|
||||
cached = self._sub_cache.get(user_id)
|
||||
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
return bool(cached.get("active"))
|
||||
row = cached.get("row")
|
||||
if isinstance(row, dict):
|
||||
return row
|
||||
return None
|
||||
|
||||
try:
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
params = {
|
||||
"select": "id,user_id,status,expires_at",
|
||||
"select": "id,user_id,status,plan_code,starts_at,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
@@ -179,20 +185,35 @@ class SupabaseEntitlementService:
|
||||
user_id,
|
||||
response.status_code,
|
||||
)
|
||||
active = False
|
||||
row = None
|
||||
else:
|
||||
data = response.json() if response.content else []
|
||||
active = isinstance(data, list) and len(data) > 0
|
||||
row = data[0] if isinstance(data, list) and data else None
|
||||
if not isinstance(row, dict):
|
||||
row = None
|
||||
|
||||
with self._sub_cache_lock:
|
||||
self._sub_cache[user_id] = {
|
||||
"active": active,
|
||||
"active": bool(row),
|
||||
"row": row,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return active
|
||||
return row
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
|
||||
return False
|
||||
return None
|
||||
|
||||
def _query_active_subscription(self, user_id: str) -> bool:
|
||||
return self._query_latest_active_subscription(user_id) is not None
|
||||
|
||||
def get_latest_active_subscription(
|
||||
self,
|
||||
user_id: str,
|
||||
respect_requirement: bool = True,
|
||||
) -> Optional[Dict[str, object]]:
|
||||
if respect_requirement and not self.require_subscription:
|
||||
return None
|
||||
return self._query_latest_active_subscription(user_id)
|
||||
|
||||
def has_active_subscription(
|
||||
self,
|
||||
|
||||
+15
-1
@@ -1155,14 +1155,25 @@ async def auth_me(request: Request):
|
||||
and SUPABASE_ENTITLEMENT.require_subscription
|
||||
)
|
||||
subscription_active = None
|
||||
subscription_plan_code = None
|
||||
subscription_starts_at = None
|
||||
subscription_expires_at = None
|
||||
if SUPABASE_ENTITLEMENT.enabled and user_id:
|
||||
try:
|
||||
subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription(
|
||||
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
subscription_active = bool(latest_subscription)
|
||||
if isinstance(latest_subscription, dict):
|
||||
subscription_plan_code = latest_subscription.get("plan_code")
|
||||
subscription_starts_at = latest_subscription.get("starts_at")
|
||||
subscription_expires_at = latest_subscription.get("expires_at")
|
||||
except Exception:
|
||||
subscription_active = None
|
||||
subscription_plan_code = None
|
||||
subscription_starts_at = None
|
||||
subscription_expires_at = None
|
||||
|
||||
points = _resolve_auth_points(request)
|
||||
weekly_profile = _resolve_weekly_profile(request)
|
||||
@@ -1186,6 +1197,9 @@ async def auth_me(request: Request):
|
||||
"auth_required": bool(SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED),
|
||||
"subscription_required": subscription_required,
|
||||
"subscription_active": subscription_active,
|
||||
"subscription_plan_code": subscription_plan_code,
|
||||
"subscription_starts_at": subscription_starts_at,
|
||||
"subscription_expires_at": subscription_expires_at,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user