feat: Implement account center for user authentication, subscription, and payment management.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user