feat: Implement account center for user authentication, subscription, and payment management.

This commit is contained in:
2569718930@qq.com
2026-03-13 09:50:04 +08:00
parent 120f4b1261
commit 990182e089
4 changed files with 139 additions and 5 deletions
@@ -48,6 +48,7 @@ type AuthMeResponse = {
authenticated?: boolean;
user_id?: string | null;
email?: string | null;
points?: number;
entitlement_mode?: string | null;
auth_required?: boolean;
subscription_required?: boolean;
@@ -352,9 +353,13 @@ export function AccountCenter() {
const proExpiry = user?.user_metadata?.pro_expiry || "暂无 Pro 订阅";
// Points Logic
const pointsRaw = Number(
const backendPointsRaw = Number(backend?.points);
const metadataPointsRaw = Number(
user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0,
);
const pointsRaw = Number.isFinite(backendPointsRaw)
? backendPointsRaw
: metadataPointsRaw;
const weeklyPointsRaw = Number(user?.user_metadata?.weekly_points ?? 0);
const weeklyRankRaw = user?.user_metadata?.weekly_rank;
const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0;
+62
View File
@@ -89,6 +89,34 @@ class DBManager:
return user
return None
def get_user_by_supabase_user_id(self, supabase_user_id: str) -> Optional[Dict[str, Any]]:
key = str(supabase_user_id or "").strip().lower()
if not key:
return None
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT *
FROM users
WHERE lower(trim(COALESCE(supabase_user_id, ''))) = ?
LIMIT 1
""",
(key,),
).fetchone()
if row:
return dict(row)
return None
def get_points_by_supabase_user_id(self, supabase_user_id: str) -> int:
user = self.get_user_by_supabase_user_id(supabase_user_id)
if not user:
return 0
try:
return max(0, int(user.get("points") or 0))
except Exception:
return 0
def upsert_user(self, telegram_id: int, username: str):
with self._get_connection() as conn:
conn.execute("""
@@ -283,6 +311,40 @@ class DBManager:
conn.commit()
return {"ok": True, "balance": new_balance, "spent": amount}
def spend_points_by_supabase_user_id(self, supabase_user_id: str, amount: int) -> Dict[str, Any]:
key = str(supabase_user_id or "").strip().lower()
if not key:
return {"ok": False, "reason": "invalid_supabase_user_id", "balance": 0, "required": amount}
if amount <= 0:
return {"ok": True, "balance": self.get_points_by_supabase_user_id(key)}
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT telegram_id, points
FROM users
WHERE lower(trim(COALESCE(supabase_user_id, ''))) = ?
LIMIT 1
""",
(key,),
).fetchone()
if not row:
return {"ok": False, "reason": "user_missing", "balance": 0, "required": amount}
telegram_id = int(row["telegram_id"])
balance = int(row["points"] or 0)
if balance < amount:
return {"ok": False, "reason": "insufficient_points", "balance": balance, "required": amount}
new_balance = balance - amount
conn.execute(
"UPDATE users SET points = ? WHERE telegram_id = ?",
(new_balance, telegram_id),
)
conn.commit()
return {"ok": True, "balance": new_balance, "spent": amount}
def set_premium(self, telegram_id: int, plan: str, months: int = 1):
expiry = datetime.now() + timedelta(days=30 * months)
col_is = f"is_{plan}_premium"
+44 -3
View File
@@ -14,6 +14,8 @@ from eth_account import Account
from eth_account.messages import encode_defunct
from web3 import Web3
from src.database.db_manager import DBManager
DEFAULT_POLYGON_CHAIN_ID = 137
DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
@@ -265,6 +267,7 @@ class PaymentContractCheckoutService:
self._event_topic = Web3.keccak(
text="OrderPaid(bytes32,address,uint256,address,uint256)"
).hex()
self._db = DBManager()
@property
def configured(self) -> bool:
@@ -400,6 +403,20 @@ class PaymentContractCheckoutService:
continue
return 0
def _resolve_points_balance(self, user_id: str) -> Dict[str, Any]:
db_user = self._db.get_user_by_supabase_user_id(user_id)
if db_user is not None:
try:
balance = max(0, int(db_user.get("points") or 0))
except Exception:
balance = 0
return {"source": "bot_db", "balance": balance}
user_obj = self._auth_admin_get_user(user_id)
metadata = self._extract_user_metadata(user_obj)
balance = self._extract_points_from_metadata(metadata)
return {"source": "supabase_metadata", "balance": balance, "metadata": metadata}
def _auth_admin_get_user(self, user_id: str) -> Dict[str, Any]:
user_id_text = str(user_id or "").strip()
if not user_id_text:
@@ -451,6 +468,7 @@ class PaymentContractCheckoutService:
"applied": False,
"points_per_usdc": int(self.points_per_usdc),
"max_discount_usdc": int(self.points_max_discount_usdc),
"points_source": "supabase_metadata",
"points_balance_snapshot": 0,
"points_to_consume": 0,
"discount_usdc": "0",
@@ -462,9 +480,9 @@ class PaymentContractCheckoutService:
return base
if plan_amount_usdc <= 0:
return base
user_obj = self._auth_admin_get_user(user_id)
metadata = self._extract_user_metadata(user_obj)
balance = self._extract_points_from_metadata(metadata)
points_ctx = self._resolve_points_balance(user_id)
balance = int(points_ctx.get("balance") or 0)
base["points_source"] = str(points_ctx.get("source") or "supabase_metadata")
base["points_balance_snapshot"] = balance
if balance <= 0:
return base
@@ -539,9 +557,30 @@ class PaymentContractCheckoutService:
return result
planned_points = int(redemption.get("points_to_consume") or 0)
points_source = str(redemption.get("points_source") or "").strip().lower()
if planned_points <= 0:
return result
if points_source == "bot_db":
points_before = self._db.get_points_by_supabase_user_id(user_id)
if points_before <= 0:
return result
redeemable = min(points_before, planned_points)
redeemable = (redeemable // int(self.points_per_usdc)) * int(self.points_per_usdc)
if redeemable <= 0:
return result
spend_result = self._db.spend_points_by_supabase_user_id(user_id, redeemable)
if not bool(spend_result.get("ok")):
return result
points_after = int(spend_result.get("balance") or 0)
discount_usdc = Decimal(redeemable // int(self.points_per_usdc))
result["applied"] = True
result["points_redeemed"] = int(redeemable)
result["points_before"] = int(points_before)
result["points_after"] = int(points_after)
result["discount_usdc"] = _format_decimal(discount_usdc)
return result
user_obj = self._auth_admin_get_user(user_id)
user_metadata = self._extract_user_metadata(user_obj)
points_before = self._extract_points_from_metadata(user_metadata)
@@ -938,6 +977,7 @@ class PaymentContractCheckoutService:
"applied": bool(redemption.get("applied")),
"points_per_usdc": int(redemption.get("points_per_usdc") or self.points_per_usdc),
"max_discount_usdc": int(redemption.get("max_discount_usdc") or self.points_max_discount_usdc),
"points_source": str(redemption.get("points_source") or "supabase_metadata"),
"points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0),
"points_to_consume": int(redemption.get("points_to_consume") or 0),
"discount_usdc": str(redemption.get("discount_usdc") or "0"),
@@ -983,6 +1023,7 @@ class PaymentContractCheckoutService:
},
"points_redemption": {
"applied": bool(redemption.get("applied")),
"points_source": str(redemption.get("points_source") or "supabase_metadata"),
"points_to_consume": int(redemption.get("points_to_consume") or 0),
"discount_usdc": str(redemption.get("discount_usdc") or "0"),
"points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0),
+27 -1
View File
@@ -36,6 +36,7 @@ from src.auth.supabase_entitlement import (
SUPABASE_ENTITLEMENT,
extract_bearer_token,
)
from src.database.db_manager import DBManager
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
# ──────────────────────────────────────────────────────────
@@ -58,6 +59,7 @@ app.add_middleware(
_config = load_config()
_weather = WeatherDataCollector(_config)
_market_layer = PolymarketReadOnlyLayer()
_account_db = DBManager()
from src.data_collection.city_registry import CITY_REGISTRY, ALIASES
@@ -120,6 +122,28 @@ def _bind_optional_supabase_identity(request: Request) -> None:
request.state.auth_points = identity.points
def _resolve_auth_points(request: Request) -> int:
raw_points = getattr(request.state, "auth_points", 0)
try:
points = max(0, int(raw_points or 0))
except Exception:
points = 0
if points > 0:
return points
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
if not user_id:
return points
try:
db_points = _account_db.get_points_by_supabase_user_id(user_id)
if db_points > points:
request.state.auth_points = db_points
return db_points
except Exception as exc:
logger.warning(f"auth points fallback failed user_id={user_id}: {exc}")
return points
def _assert_entitlement(request: Request) -> None:
if SUPABASE_ENTITLEMENT.enabled:
if _legacy_service_token_valid(request):
@@ -1092,11 +1116,13 @@ async def auth_me(request: Request):
except Exception:
subscription_active = None
points = _resolve_auth_points(request)
return {
"authenticated": bool(user_id),
"user_id": user_id,
"email": getattr(request.state, "auth_email", None),
"points": getattr(request.state, "auth_points", 0),
"points": points,
"entitlement_mode": (
"supabase_required"
if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED