Files
DinQuant/backend_api_python/app/services/usdt_payment_service.py
T
TIANHE abbad97bdd v2.2.1: frontend closed-source + Docker one-click deploy
- Remove frontend source code (now in private repo)
- Add pre-built frontend/dist/ with Nginx serving
- Simplify docker-compose.yml (no Node.js build needed)
- Update README with docs index and Docker deploy guide
- Add admin order list and AI analysis stats tabs
- Add quick trade API routes
- Clean up redundant files (package-lock.json, yarn.lock, .iml)
- Add GitHub Actions workflow for frontend update automation
2026-02-27 19:57:23 +08:00

509 lines
20 KiB
Python

"""
USDT Payment Service (方案B:每单独立地址 + 自动对账)
MVP:
- 只支持 USDT-TRC20
- 使用 XPUB 派生地址(服务端只保存 xpub,不保存私钥)
- 后台 Worker 线程自动轮询链上到账 + 前端轮询双保险
"""
import os
import threading
import time
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from typing import Any, Dict, List, 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:
"""Check chain status for a single order and update in the current transaction."""
cfg = self._get_cfg()
status = (row.get("status") or "").lower()
chain = (row.get("chain") or "").upper()
order_id = row.get("id")
# --- Expiry check (only for pending; paid orders should still be confirmed) ---
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 = ?", (order_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
# --- For 'paid' status, skip chain query and just check confirm delay ---
if status == "paid":
self._try_confirm_paid_order(cur, row, cfg, now)
return
# --- For 'pending' status, query chain for incoming transfer ---
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, order_id),
)
# Try to confirm immediately if delay is satisfied
confirm_sec = int(cfg.get("confirm_seconds") or 30)
try:
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, order_id, row.get("user_id"), row.get("plan"), tx_hash)
elif confirm_sec <= 0:
self._confirm_and_activate_in_tx(cur, order_id, row.get("user_id"), row.get("plan"), tx_hash)
except Exception:
pass
def _try_confirm_paid_order(self, cur, row: Dict[str, Any], cfg: Dict[str, Any], now: datetime) -> None:
"""For orders already in 'paid' status, check if confirm delay is met and activate."""
confirm_sec = int(cfg.get("confirm_seconds") or 30)
paid_at = row.get("paid_at")
if paid_at:
if isinstance(paid_at, str):
try:
paid_at = datetime.fromisoformat(paid_at.replace("Z", "+00:00"))
except Exception:
paid_at = None
if paid_at and paid_at.tzinfo is None:
paid_at = paid_at.replace(tzinfo=timezone.utc)
if paid_at and (now - paid_at).total_seconds() >= confirm_sec:
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "")
return
# Fallback: if paid_at missing but confirm_sec <= 0, confirm now
if confirm_sec <= 0:
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "")
def _confirm_and_activate_in_tx(self, cur, order_id: int, user_id: int, plan: str, tx_hash: str) -> None:
"""Mark order as confirmed and activate membership. Idempotent: skips if already confirmed."""
# --- Idempotency check: re-read current status ---
try:
cur.execute("SELECT status FROM qd_usdt_orders WHERE id = ?", (order_id,))
current = cur.fetchone()
if current and (current.get("status") or "").lower() == "confirmed":
logger.debug(f"USDT order {order_id} already confirmed, skipping activation.")
return
except Exception:
pass
# Mark confirmed
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
try:
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)
# Accept payments >= order amount (tolerance for overpayment)
if val < target:
continue
return it
except Exception:
continue
except Exception:
return None
return None
# -------------------- Batch refresh (for worker) --------------------
def refresh_all_active_orders(self) -> int:
"""
Scan all pending/paid USDT orders and refresh their chain status.
Called by the background UsdtOrderWorker.
Returns the number of orders that were updated to 'confirmed' or 'expired'.
"""
updated = 0
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 status IN ('pending', 'paid')
ORDER BY created_at ASC
LIMIT 100
"""
)
rows = cur.fetchall() or []
for row in rows:
old_status = (row.get("status") or "").lower()
try:
self._refresh_order_in_tx(cur, row)
except Exception as e:
logger.debug(f"refresh_all: order {row.get('id')} error: {e}")
continue
# Check if status changed
try:
cur.execute("SELECT status FROM qd_usdt_orders WHERE id = ?", (row["id"],))
new_row = cur.fetchone()
new_status = (new_row.get("status") or "").lower() if new_row else old_status
if new_status != old_status:
updated += 1
logger.info(f"USDT order {row['id']}: {old_status} -> {new_status}")
except Exception:
pass
db.commit()
cur.close()
except Exception as e:
logger.error(f"refresh_all_active_orders error: {e}", exc_info=True)
return updated
# ==================== Background Worker ====================
class UsdtOrderWorker:
"""
Background thread that periodically scans pending/paid USDT orders
and checks on-chain status via TronGrid API.
This ensures that even if the user closes the browser after payment,
the order will still be confirmed and membership activated.
"""
def __init__(self, poll_interval_sec: float = 30.0):
self.poll_interval_sec = float(poll_interval_sec)
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def start(self) -> bool:
with self._lock:
if self._thread and self._thread.is_alive():
return True
self._stop_event.clear()
self._thread = threading.Thread(target=self._run_loop, name="UsdtOrderWorker", daemon=True)
self._thread.start()
logger.info("UsdtOrderWorker started (interval=%ss)", self.poll_interval_sec)
return True
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
logger.info("UsdtOrderWorker stopped")
def _run_loop(self):
# Wait a bit on startup to let the app fully initialize
self._stop_event.wait(timeout=10)
while not self._stop_event.is_set():
try:
svc = get_usdt_payment_service()
cfg = svc._get_cfg()
if cfg["enabled"]:
updated = svc.refresh_all_active_orders()
if updated > 0:
logger.info(f"UsdtOrderWorker: refreshed {updated} orders")
except Exception as e:
logger.error(f"UsdtOrderWorker loop error: {e}", exc_info=True)
self._stop_event.wait(timeout=self.poll_interval_sec)
# ==================== Singletons ====================
_svc = None
_worker = None
def get_usdt_payment_service() -> UsdtPaymentService:
global _svc
if _svc is None:
_svc = UsdtPaymentService()
return _svc
def get_usdt_order_worker() -> UsdtOrderWorker:
global _worker
if _worker is None:
interval = float(os.getenv("USDT_WORKER_POLL_INTERVAL", "30"))
_worker = UsdtOrderWorker(poll_interval_sec=interval)
return _worker