abbad97bdd
- 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
509 lines
20 KiB
Python
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
|