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
This commit is contained in:
TIANHE
2026-02-27 19:57:23 +08:00
parent ffdd2ffbae
commit abbad97bdd
250 changed files with 1895 additions and 91621 deletions
@@ -61,7 +61,7 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
cur = db.cursor()
cur.execute(
"""
SELECT id, exchange_config, trading_config, market_type, leverage, execution_mode, market_category
SELECT id, user_id, exchange_config, trading_config, market_type, leverage, execution_mode, market_category
FROM qd_strategies_trading
WHERE id = %s
""",
@@ -77,9 +77,11 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
leverage = float(row.get("leverage") or trading_config.get("leverage") or exchange_config.get("leverage") or 1.0)
execution_mode = (row.get("execution_mode") or "signal").strip().lower()
market_category = (row.get("market_category") or "Crypto").strip()
user_id = int(row.get("user_id") or 1)
return {
"strategy_id": int(strategy_id),
"user_id": user_id,
"exchange_config": exchange_config if isinstance(exchange_config, dict) else {},
"trading_config": trading_config if isinstance(trading_config, dict) else {},
"market_type": market_type,
@@ -207,7 +207,8 @@ class PendingOrderWorker:
if exec_mode != "live":
logger.debug(f"[PositionSync] Strategy {sid} skipped: execution_mode='{exec_mode}' (needs 'live')")
continue
exchange_config = resolve_exchange_config(sc.get("exchange_config") or {})
sync_user_id = int(sc.get("user_id") or 1)
exchange_config = resolve_exchange_config(sc.get("exchange_config") or {}, user_id=sync_user_id)
safe_cfg = safe_exchange_config_for_log(exchange_config)
market_type = (sc.get("market_type") or exchange_config.get("market_type") or "swap")
market_type = str(market_type or "swap").strip().lower()
@@ -832,7 +833,8 @@ class PendingOrderWorker:
return
cfg = load_strategy_configs(strategy_id)
exchange_config = resolve_exchange_config(cfg.get("exchange_config") or {})
strategy_user_id = int(cfg.get("user_id") or 1)
exchange_config = resolve_exchange_config(cfg.get("exchange_config") or {}, user_id=strategy_user_id)
safe_cfg = safe_exchange_config_for_log(exchange_config)
exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower()
market_category = str(cfg.get("market_category") or "Crypto").strip()
+13 -1
View File
@@ -549,6 +549,12 @@ class StrategyService:
trading_config = payload.get('trading_config') or {}
exchange_config = payload.get('exchange_config') or {}
# When credential_id is present, strip raw API keys to avoid
# storing secrets in the strategy record — they live in qd_exchange_credentials.
if isinstance(exchange_config, dict) and exchange_config.get('credential_id'):
for _secret_key in ('api_key', 'secret_key', 'passphrase', 'apiKey', 'secret', 'password'):
exchange_config.pop(_secret_key, None)
# Strategy group fields
strategy_group_id = payload.get('strategy_group_id') or ''
group_base_name = payload.get('group_base_name') or ''
@@ -779,7 +785,13 @@ class StrategyService:
trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {})
exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {})
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
# When credential_id is present, strip raw API keys to avoid
# storing secrets in the strategy record — they live in qd_exchange_credentials.
if isinstance(exchange_config, dict) and exchange_config.get('credential_id'):
for _secret_key in ('api_key', 'secret_key', 'passphrase', 'apiKey', 'secret', 'password'):
exchange_config.pop(_secret_key, None)
# Handle cross-sectional strategy config updates
if payload.get('cs_strategy_type') is not None:
trading_config['cs_strategy_type'] = payload.get('cs_strategy_type')
@@ -4,14 +4,15 @@ USDT Payment Service (方案B:每单独立地址 + 自动对账)
MVP:
- 只支持 USDT-TRC20
- 使用 XPUB 派生地址服务端只保存 xpub不保存私钥
- 通过 TronGrid API 轮询到账前端轮询订单状态时触发刷新
- 后台 Worker 线程自动轮询链上到账 + 前端轮询双保险
"""
import os
import threading
import time
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
import requests
@@ -233,10 +234,13 @@ class UsdtPaymentService:
# -------------------- 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):
@@ -244,7 +248,7 @@ class UsdtPaymentService:
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"],))
cur.execute("UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (order_id,))
return
if chain != "TRC20":
@@ -257,6 +261,12 @@ class UsdtPaymentService:
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
@@ -265,37 +275,60 @@ class UsdtPaymentService:
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"]),
(tx_hash, paid_at, order_id),
)
# Confirm after a short delay to reduce reorg/uncle risk (TRON usually stable)
# If already old enough, confirm now.
# Try to confirm immediately if delay is satisfied
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)
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:
# do not block
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 confirmed if not already
"""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 (idempotent-ish: billing_service stacks vip)
# Activate membership
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:
@@ -340,13 +373,9 @@ class UsdtPaymentService:
if min_ts and int(it.get("block_timestamp") or 0) < min_ts:
continue
val = int(it.get("value") or 0)
if val != target:
# Accept payments >= order amount (tolerance for overpayment)
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
@@ -354,8 +383,114 @@ class UsdtPaymentService:
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:
@@ -364,3 +499,10 @@ def get_usdt_payment_service() -> UsdtPaymentService:
_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