V2.2.1: Membership & Billing, USDT TRC20 payment, VIP free indicators, AI Trading Radar, simplified strategy creation, system settings simplification, bug fixes and UI improvements
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
USDT Payment Service (方案B:每单独立地址 + 自动对账)
|
||||
|
||||
MVP:
|
||||
- 只支持 USDT-TRC20
|
||||
- 使用 XPUB 派生地址(服务端只保存 xpub,不保存私钥)
|
||||
- 通过 TronGrid API 轮询到账(前端轮询订单状态时触发刷新)
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.services.billing_service import get_billing_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UsdtPaymentService:
|
||||
def __init__(self):
|
||||
self.billing = get_billing_service()
|
||||
|
||||
# -------------------- Config --------------------
|
||||
|
||||
def _get_cfg(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": str(os.getenv("USDT_PAY_ENABLED", "False")).lower() in ("1", "true", "yes"),
|
||||
"chain": (os.getenv("USDT_PAY_CHAIN", "TRC20") or "TRC20").upper(),
|
||||
"xpub_trc20": (os.getenv("USDT_TRC20_XPUB", "") or "").strip(),
|
||||
"trongrid_base": (os.getenv("TRONGRID_BASE_URL", "https://api.trongrid.io") or "").strip().rstrip("/"),
|
||||
"trongrid_key": (os.getenv("TRONGRID_API_KEY", "") or "").strip(),
|
||||
"usdt_trc20_contract": (os.getenv("USDT_TRC20_CONTRACT", "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj") or "").strip(),
|
||||
"confirm_seconds": int(float(os.getenv("USDT_PAY_CONFIRM_SECONDS", "30") or 30)),
|
||||
"order_expire_minutes": int(float(os.getenv("USDT_PAY_EXPIRE_MINUTES", "30") or 30)),
|
||||
}
|
||||
|
||||
# -------------------- Schema --------------------
|
||||
|
||||
def _ensure_schema_best_effort(self, cur):
|
||||
"""Best-effort create table/columns for old databases."""
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS qd_usdt_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
plan VARCHAR(20) NOT NULL,
|
||||
chain VARCHAR(20) NOT NULL DEFAULT 'TRC20',
|
||||
amount_usdt DECIMAL(20,6) NOT NULL DEFAULT 0,
|
||||
address_index INTEGER NOT NULL DEFAULT 0,
|
||||
address VARCHAR(80) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
tx_hash VARCHAR(120) DEFAULT '',
|
||||
paid_at TIMESTAMP,
|
||||
confirmed_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_address_unique ON qd_usdt_orders(chain, address)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_user_id ON qd_usdt_orders(user_id)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_status ON qd_usdt_orders(status)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------- Address derivation --------------------
|
||||
|
||||
def _derive_trc20_address_from_xpub(self, xpub: str, index: int) -> str:
|
||||
"""
|
||||
Derive TRON address from xpub.
|
||||
|
||||
Requires bip_utils.
|
||||
NOTE:
|
||||
- Some wallets export account-level xpub at m/44'/195'/0' (level=3).
|
||||
- Some export change-level xpub at m/44'/195'/0'/0 (level=4, external chain).
|
||||
This function supports both by normalizing to change-level before AddressIndex().
|
||||
"""
|
||||
try:
|
||||
from bip_utils import Bip44, Bip44Coins, Bip44Changes
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"bip_utils_missing:{e}")
|
||||
|
||||
if not xpub:
|
||||
raise RuntimeError("missing_xpub")
|
||||
if index < 0:
|
||||
raise RuntimeError("invalid_index")
|
||||
|
||||
ctx = Bip44.FromExtendedKey(xpub, Bip44Coins.TRON)
|
||||
lvl = int(ctx.Level())
|
||||
# Normalize to change-level (external chain) so we can derive addresses by index
|
||||
if lvl == 3:
|
||||
# account-level xpub: m/44'/195'/0'
|
||||
ctx = ctx.Change(Bip44Changes.CHAIN_EXT)
|
||||
elif lvl == 4:
|
||||
# change-level xpub: m/44'/195'/0'/0
|
||||
pass
|
||||
elif lvl == 5:
|
||||
# address-level xpub: cannot derive other indexes
|
||||
if index != 0:
|
||||
raise RuntimeError("xpub_is_address_level")
|
||||
return ctx.PublicKey().ToAddress()
|
||||
else:
|
||||
raise RuntimeError(f"unsupported_xpub_level:{lvl}")
|
||||
|
||||
addr = ctx.AddressIndex(index).PublicKey().ToAddress()
|
||||
return addr
|
||||
|
||||
# -------------------- Orders --------------------
|
||||
|
||||
def create_order(self, user_id: int, plan: str) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
cfg = self._get_cfg()
|
||||
if not cfg["enabled"]:
|
||||
return False, "usdt_pay_disabled", {}
|
||||
if cfg["chain"] != "TRC20":
|
||||
return False, "unsupported_chain", {}
|
||||
plan = (plan or "").strip().lower()
|
||||
if plan not in ("monthly", "yearly", "lifetime"):
|
||||
return False, "invalid_plan", {}
|
||||
|
||||
plans = self.billing.get_membership_plans()
|
||||
amount = Decimal(str(plans.get(plan, {}).get("price_usd") or 0))
|
||||
if amount <= 0:
|
||||
return False, "invalid_amount", {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(minutes=cfg["order_expire_minutes"])
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
self._ensure_schema_best_effort(cur)
|
||||
|
||||
# allocate next address index (simple monotonic)
|
||||
cur.execute(
|
||||
"SELECT COALESCE(MAX(address_index), -1) as max_idx FROM qd_usdt_orders WHERE chain = 'TRC20'"
|
||||
)
|
||||
max_idx = cur.fetchone().get("max_idx")
|
||||
next_idx = int(max_idx) + 1
|
||||
|
||||
address = self._derive_trc20_address_from_xpub(cfg["xpub_trc20"], next_idx)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_usdt_orders
|
||||
(user_id, plan, chain, amount_usdt, address_index, address, status, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'TRC20', ?, ?, ?, 'pending', ?, NOW(), NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(user_id, plan, float(amount), next_idx, address, expires_at),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
order_id = row.get("id")
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return True, "success", {
|
||||
"order_id": order_id,
|
||||
"plan": plan,
|
||||
"chain": "TRC20",
|
||||
"amount_usdt": str(amount),
|
||||
"address": address,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"create_order failed: {e}", exc_info=True)
|
||||
return False, f"error:{str(e)}", {}
|
||||
|
||||
def get_order(self, user_id: int, order_id: int, refresh: bool = True) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
self._ensure_schema_best_effort(cur)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash,
|
||||
paid_at, confirmed_at, expires_at, created_at, updated_at
|
||||
FROM qd_usdt_orders
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(order_id, user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return False, "order_not_found", {}
|
||||
|
||||
if refresh:
|
||||
self._refresh_order_in_tx(cur, row)
|
||||
db.commit()
|
||||
# re-read
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash,
|
||||
paid_at, confirmed_at, expires_at, created_at, updated_at
|
||||
FROM qd_usdt_orders
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(order_id, user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
cur.close()
|
||||
|
||||
return True, "success", self._row_to_dict(row)
|
||||
except Exception as e:
|
||||
logger.error(f"get_order failed: {e}", exc_info=True)
|
||||
return False, f"error:{str(e)}", {}
|
||||
|
||||
def _row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"order_id": row.get("id"),
|
||||
"plan": row.get("plan"),
|
||||
"chain": row.get("chain"),
|
||||
"amount_usdt": str(row.get("amount_usdt") or 0),
|
||||
"address": row.get("address") or "",
|
||||
"status": row.get("status") or "",
|
||||
"tx_hash": row.get("tx_hash") or "",
|
||||
"paid_at": row.get("paid_at").isoformat() if row.get("paid_at") else None,
|
||||
"confirmed_at": row.get("confirmed_at").isoformat() if row.get("confirmed_at") else None,
|
||||
"expires_at": row.get("expires_at").isoformat() if row.get("expires_at") else None,
|
||||
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
||||
}
|
||||
|
||||
# -------------------- Chain check --------------------
|
||||
|
||||
def _refresh_order_in_tx(self, cur, row: Dict[str, Any]) -> None:
|
||||
cfg = self._get_cfg()
|
||||
status = (row.get("status") or "").lower()
|
||||
chain = (row.get("chain") or "").upper()
|
||||
|
||||
expires_at = row.get("expires_at")
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_at and isinstance(expires_at, datetime):
|
||||
exp = expires_at
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if status == "pending" and exp <= now:
|
||||
cur.execute("UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (row["id"],))
|
||||
return
|
||||
|
||||
if chain != "TRC20":
|
||||
return
|
||||
if status not in ("pending", "paid"):
|
||||
return
|
||||
|
||||
address = row.get("address") or ""
|
||||
amount = Decimal(str(row.get("amount_usdt") or 0))
|
||||
if not address or amount <= 0:
|
||||
return
|
||||
|
||||
tx = self._find_trc20_usdt_incoming(address, amount, row.get("created_at"))
|
||||
if not tx:
|
||||
return
|
||||
|
||||
tx_hash = tx.get("transaction_id") or ""
|
||||
paid_at = datetime.now(timezone.utc)
|
||||
cur.execute(
|
||||
"UPDATE qd_usdt_orders SET status = 'paid', tx_hash = ?, paid_at = ?, updated_at = NOW() WHERE id = ? AND status = 'pending'",
|
||||
(tx_hash, paid_at, row["id"]),
|
||||
)
|
||||
|
||||
# Confirm after a short delay to reduce reorg/uncle risk (TRON usually stable)
|
||||
# If already old enough, confirm now.
|
||||
confirm_sec = int(cfg.get("confirm_seconds") or 30)
|
||||
try:
|
||||
if confirm_sec <= 0:
|
||||
confirm_sec = 0
|
||||
# If transaction timestamp is available, use it
|
||||
tx_ts = tx.get("block_timestamp")
|
||||
if tx_ts:
|
||||
tx_time = datetime.fromtimestamp(int(tx_ts) / 1000.0, tz=timezone.utc)
|
||||
if (now - tx_time).total_seconds() >= confirm_sec:
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
|
||||
else:
|
||||
# no timestamp -> confirm immediately
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
|
||||
except Exception:
|
||||
# do not block
|
||||
pass
|
||||
|
||||
def _confirm_and_activate_in_tx(self, cur, order_id: int, user_id: int, plan: str, tx_hash: str) -> None:
|
||||
# Mark confirmed if not already
|
||||
cur.execute(
|
||||
"UPDATE qd_usdt_orders SET status='confirmed', confirmed_at = NOW(), updated_at = NOW() WHERE id = ? AND status IN ('paid','pending')",
|
||||
(order_id,),
|
||||
)
|
||||
# Activate membership (idempotent-ish: billing_service stacks vip)
|
||||
try:
|
||||
# We use existing membership activation (writes qd_membership_orders + credits logs).
|
||||
ok, msg, data = self.billing.purchase_membership(int(user_id), str(plan))
|
||||
logger.info(f"USDT activate membership: order={order_id} user={user_id} plan={plan} ok={ok} msg={msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"USDT activate membership failed: order={order_id} err={e}", exc_info=True)
|
||||
|
||||
def _find_trc20_usdt_incoming(self, address: str, amount_usdt: Decimal, created_at: Optional[datetime]) -> Optional[Dict[str, Any]]:
|
||||
cfg = self._get_cfg()
|
||||
base = cfg["trongrid_base"]
|
||||
contract = cfg["usdt_trc20_contract"]
|
||||
|
||||
url = f"{base}/v1/accounts/{address}/transactions/trc20"
|
||||
headers = {}
|
||||
if cfg["trongrid_key"]:
|
||||
headers["TRON-PRO-API-KEY"] = cfg["trongrid_key"]
|
||||
|
||||
params = {
|
||||
"only_to": "true",
|
||||
"limit": 50,
|
||||
"contract_address": contract,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json() or {}
|
||||
items = data.get("data") or []
|
||||
# TRC20 USDT has 6 decimals
|
||||
target = int((amount_usdt * Decimal("1000000")).to_integral_value())
|
||||
|
||||
min_ts = None
|
||||
if created_at and isinstance(created_at, datetime):
|
||||
ct = created_at
|
||||
if ct.tzinfo is None:
|
||||
ct = ct.replace(tzinfo=timezone.utc)
|
||||
min_ts = int(ct.timestamp() * 1000) - 60_000
|
||||
|
||||
for it in items:
|
||||
try:
|
||||
if it.get("to") != address:
|
||||
continue
|
||||
if min_ts and int(it.get("block_timestamp") or 0) < min_ts:
|
||||
continue
|
||||
val = int(it.get("value") or 0)
|
||||
if val != target:
|
||||
continue
|
||||
# basic checks
|
||||
token = it.get("token_info") or {}
|
||||
if str(token.get("symbol") or "").upper() != "USDT":
|
||||
# some APIs omit symbol; contract filter should already ensure
|
||||
pass
|
||||
return it
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_svc = None
|
||||
|
||||
|
||||
def get_usdt_payment_service() -> UsdtPaymentService:
|
||||
global _svc
|
||||
if _svc is None:
|
||||
_svc = UsdtPaymentService()
|
||||
return _svc
|
||||
|
||||
Reference in New Issue
Block a user