feat: implement user account center with authentication, payments, and subscriptions.
This commit is contained in:
@@ -49,6 +49,8 @@ type AuthMeResponse = {
|
|||||||
user_id?: string | null;
|
user_id?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
points?: number;
|
points?: number;
|
||||||
|
weekly_points?: number;
|
||||||
|
weekly_rank?: number | string | null;
|
||||||
entitlement_mode?: string | null;
|
entitlement_mode?: string | null;
|
||||||
auth_required?: boolean;
|
auth_required?: boolean;
|
||||||
subscription_required?: boolean;
|
subscription_required?: boolean;
|
||||||
@@ -126,7 +128,12 @@ type InfoRowProps = {
|
|||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const InfoRow = ({ icon: Icon, label, value, isPrimary = false }: InfoRowProps) => (
|
const InfoRow = ({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
isPrimary = false,
|
||||||
|
}: InfoRowProps) => (
|
||||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group">
|
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
<div className="p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||||
@@ -363,8 +370,12 @@ export function AccountCenter() {
|
|||||||
const pointsRaw = Number.isFinite(backendPointsRaw)
|
const pointsRaw = Number.isFinite(backendPointsRaw)
|
||||||
? Math.max(backendPointsRaw, metadataPointsSafe)
|
? Math.max(backendPointsRaw, metadataPointsSafe)
|
||||||
: metadataPointsSafe;
|
: metadataPointsSafe;
|
||||||
const weeklyPointsRaw = Number(user?.user_metadata?.weekly_points ?? 0);
|
const backendWeeklyPointsRaw = Number(backend?.weekly_points);
|
||||||
const weeklyRankRaw = user?.user_metadata?.weekly_rank;
|
const metadataWeeklyPointsRaw = Number(user?.user_metadata?.weekly_points ?? 0);
|
||||||
|
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw)
|
||||||
|
? backendWeeklyPointsRaw
|
||||||
|
: metadataWeeklyPointsRaw;
|
||||||
|
const weeklyRankRaw = backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
|
||||||
const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0;
|
const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0;
|
||||||
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
|
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
|
||||||
? Math.max(0, weeklyPointsRaw)
|
? Math.max(0, weeklyPointsRaw)
|
||||||
@@ -421,7 +432,8 @@ export function AccountCenter() {
|
|||||||
: 0;
|
: 0;
|
||||||
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
|
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
|
||||||
const pointsUsed = discountUnits * pointsPerUsdc;
|
const pointsUsed = discountUnits * pointsPerUsdc;
|
||||||
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
const canRedeem =
|
||||||
|
pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
||||||
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
|
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -595,7 +607,8 @@ export function AccountCenter() {
|
|||||||
payment_mode: "strict",
|
payment_mode: "strict",
|
||||||
allowed_wallet: payingWallet,
|
allowed_wallet: payingWallet,
|
||||||
use_points: billing.canRedeem && usePoints,
|
use_points: billing.canRedeem && usePoints,
|
||||||
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
points_to_consume:
|
||||||
|
billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
||||||
metadata: { source: "account_center" },
|
metadata: { source: "account_center" },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -749,9 +762,6 @@ export function AccountCenter() {
|
|||||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||||
账户中心
|
账户中心
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-500 text-sm">
|
|
||||||
积分体系 v4.3 · 管理身份与订阅计划
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -969,7 +979,9 @@ export function AccountCenter() {
|
|||||||
onPay={() => void handleOverlayCheckout()}
|
onPay={() => void handleOverlayCheckout()}
|
||||||
onClose={() => setShowOverlay(false)}
|
onClose={() => setShowOverlay(false)}
|
||||||
payBusy={paymentBusy}
|
payBusy={paymentBusy}
|
||||||
payLabel={hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付"}
|
payLabel={
|
||||||
|
hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付"
|
||||||
|
}
|
||||||
errorText={paymentError || undefined}
|
errorText={paymentError || undefined}
|
||||||
infoText={paymentInfo || undefined}
|
infoText={paymentInfo || undefined}
|
||||||
faqHref="/account"
|
faqHref="/account"
|
||||||
@@ -990,7 +1002,7 @@ export function AccountCenter() {
|
|||||||
<Bot size={22} /> Telegram Bot 绑定
|
<Bot size={22} /> Telegram Bot 绑定
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-400 text-sm mb-6">
|
<p className="text-slate-400 text-sm mb-6">
|
||||||
将下方命令发送给 Bot,实现全平台气象推送与权限同步。
|
将下方命令发送给到群聊,实现全平台气象推送与权限同步。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<code className="flex-grow bg-black/40 border border-white/10 p-4 rounded-xl font-mono text-xs text-blue-300 overflow-hidden text-ellipsis whitespace-nowrap">
|
<code className="flex-grow bg-black/40 border border-white/10 p-4 rounded-xl font-mono text-xs text-blue-300 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
|||||||
@@ -392,3 +392,43 @@ class DBManager:
|
|||||||
(week_key, limit),
|
(week_key, limit),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in cursor.fetchall()]
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def get_weekly_profile_by_supabase_user_id(self, supabase_user_id: str) -> Dict[str, Any]:
|
||||||
|
key = str(supabase_user_id or "").strip().lower()
|
||||||
|
if not key:
|
||||||
|
return {"weekly_points": 0, "weekly_rank": None, "total_ranked": 0}
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
iso_year, iso_week, _ = now.isocalendar()
|
||||||
|
week_key = f"{iso_year}-W{iso_week:02d}"
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
telegram_id,
|
||||||
|
lower(trim(COALESCE(supabase_user_id, ''))) AS supabase_key,
|
||||||
|
COALESCE(points, 0) AS points,
|
||||||
|
COALESCE(message_count, 0) AS message_count,
|
||||||
|
CASE
|
||||||
|
WHEN weekly_points_week = ? THEN COALESCE(weekly_points, 0)
|
||||||
|
ELSE 0
|
||||||
|
END AS weekly_points
|
||||||
|
FROM users
|
||||||
|
ORDER BY weekly_points DESC, points DESC, message_count DESC, telegram_id ASC
|
||||||
|
""",
|
||||||
|
(week_key,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
weekly_rank: Optional[int] = None
|
||||||
|
weekly_points = 0
|
||||||
|
for idx, row in enumerate(rows, start=1):
|
||||||
|
if str(row["supabase_key"] or "") == key:
|
||||||
|
weekly_rank = idx
|
||||||
|
weekly_points = int(row["weekly_points"] or 0)
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"weekly_points": max(0, int(weekly_points or 0)),
|
||||||
|
"weekly_rank": weekly_rank,
|
||||||
|
"total_ranked": len(rows),
|
||||||
|
}
|
||||||
|
|||||||
+18
@@ -144,6 +144,21 @@ def _resolve_auth_points(request: Request) -> int:
|
|||||||
return points
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_weekly_profile(request: Request) -> Dict[str, Any]:
|
||||||
|
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
||||||
|
if not user_id:
|
||||||
|
return {"weekly_points": 0, "weekly_rank": None}
|
||||||
|
try:
|
||||||
|
profile = _account_db.get_weekly_profile_by_supabase_user_id(user_id)
|
||||||
|
return {
|
||||||
|
"weekly_points": int(profile.get("weekly_points") or 0),
|
||||||
|
"weekly_rank": profile.get("weekly_rank"),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"auth weekly profile fallback failed user_id={user_id}: {exc}")
|
||||||
|
return {"weekly_points": 0, "weekly_rank": None}
|
||||||
|
|
||||||
|
|
||||||
def _assert_entitlement(request: Request) -> None:
|
def _assert_entitlement(request: Request) -> None:
|
||||||
if SUPABASE_ENTITLEMENT.enabled:
|
if SUPABASE_ENTITLEMENT.enabled:
|
||||||
if _legacy_service_token_valid(request):
|
if _legacy_service_token_valid(request):
|
||||||
@@ -1117,12 +1132,15 @@ async def auth_me(request: Request):
|
|||||||
subscription_active = None
|
subscription_active = None
|
||||||
|
|
||||||
points = _resolve_auth_points(request)
|
points = _resolve_auth_points(request)
|
||||||
|
weekly_profile = _resolve_weekly_profile(request)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": bool(user_id),
|
"authenticated": bool(user_id),
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"email": getattr(request.state, "auth_email", None),
|
"email": getattr(request.state, "auth_email", None),
|
||||||
"points": points,
|
"points": points,
|
||||||
|
"weekly_points": weekly_profile["weekly_points"],
|
||||||
|
"weekly_rank": weekly_profile["weekly_rank"],
|
||||||
"entitlement_mode": (
|
"entitlement_mode": (
|
||||||
"supabase_required"
|
"supabase_required"
|
||||||
if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED
|
if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED
|
||||||
|
|||||||
Reference in New Issue
Block a user