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;
|
||||
email?: string | null;
|
||||
points?: number;
|
||||
weekly_points?: number;
|
||||
weekly_rank?: number | string | null;
|
||||
entitlement_mode?: string | null;
|
||||
auth_required?: boolean;
|
||||
subscription_required?: boolean;
|
||||
@@ -126,7 +128,12 @@ type InfoRowProps = {
|
||||
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 gap-3">
|
||||
<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)
|
||||
? Math.max(backendPointsRaw, metadataPointsSafe)
|
||||
: metadataPointsSafe;
|
||||
const weeklyPointsRaw = Number(user?.user_metadata?.weekly_points ?? 0);
|
||||
const weeklyRankRaw = user?.user_metadata?.weekly_rank;
|
||||
const backendWeeklyPointsRaw = Number(backend?.weekly_points);
|
||||
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 weeklyPoints = Number.isFinite(weeklyPointsRaw)
|
||||
? Math.max(0, weeklyPointsRaw)
|
||||
@@ -421,7 +432,8 @@ export function AccountCenter() {
|
||||
: 0;
|
||||
const discountUnits = Math.floor(actualRedeem / 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;
|
||||
|
||||
return {
|
||||
@@ -595,7 +607,8 @@ export function AccountCenter() {
|
||||
payment_mode: "strict",
|
||||
allowed_wallet: payingWallet,
|
||||
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" },
|
||||
}),
|
||||
});
|
||||
@@ -749,9 +762,6 @@ export function AccountCenter() {
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
账户中心
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm">
|
||||
积分体系 v4.3 · 管理身份与订阅计划
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -969,7 +979,9 @@ export function AccountCenter() {
|
||||
onPay={() => void handleOverlayCheckout()}
|
||||
onClose={() => setShowOverlay(false)}
|
||||
payBusy={paymentBusy}
|
||||
payLabel={hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付"}
|
||||
payLabel={
|
||||
hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付"
|
||||
}
|
||||
errorText={paymentError || undefined}
|
||||
infoText={paymentInfo || undefined}
|
||||
faqHref="/account"
|
||||
@@ -990,7 +1002,7 @@ export function AccountCenter() {
|
||||
<Bot size={22} /> Telegram Bot 绑定
|
||||
</h3>
|
||||
<p className="text-slate-400 text-sm mb-6">
|
||||
将下方命令发送给 Bot,实现全平台气象推送与权限同步。
|
||||
将下方命令发送给到群聊,实现全平台气象推送与权限同步。
|
||||
</p>
|
||||
<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">
|
||||
|
||||
@@ -392,3 +392,43 @@ class DBManager:
|
||||
(week_key, limit),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
if SUPABASE_ENTITLEMENT.enabled:
|
||||
if _legacy_service_token_valid(request):
|
||||
@@ -1117,12 +1132,15 @@ async def auth_me(request: Request):
|
||||
subscription_active = None
|
||||
|
||||
points = _resolve_auth_points(request)
|
||||
weekly_profile = _resolve_weekly_profile(request)
|
||||
|
||||
return {
|
||||
"authenticated": bool(user_id),
|
||||
"user_id": user_id,
|
||||
"email": getattr(request.state, "auth_email", None),
|
||||
"points": points,
|
||||
"weekly_points": weekly_profile["weekly_points"],
|
||||
"weekly_rank": weekly_profile["weekly_rank"],
|
||||
"entitlement_mode": (
|
||||
"supabase_required"
|
||||
if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED
|
||||
|
||||
Reference in New Issue
Block a user