diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index cdec8ddd..ae44cf46 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -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; diff --git a/src/database/db_manager.py b/src/database/db_manager.py index b890377a..9c70a006 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -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" diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index fd5a1c52..696dd660 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -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), diff --git a/web/app.py b/web/app.py index e67a25c9..c7a9028e 100644 --- a/web/app.py +++ b/web/app.py @@ -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