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
+26
View File
@@ -74,6 +74,31 @@ def start_pending_order_worker():
logger.error(f"Failed to start pending order worker: {e}")
def start_usdt_order_worker():
"""Start the USDT order background worker.
Periodically scans pending/paid USDT orders and checks on-chain status.
Ensures orders are confirmed even if the user closes the browser after payment.
Only starts if USDT_PAY_ENABLED=true.
"""
import os
if str(os.getenv("USDT_PAY_ENABLED", "False")).lower() not in ("1", "true", "yes"):
logger.info("USDT order worker not started (USDT_PAY_ENABLED is not true).")
return
# Avoid running twice with Flask reloader
debug = os.getenv("PYTHON_API_DEBUG", "false").lower() == "true"
if debug:
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
return
try:
from app.services.usdt_payment_service import get_usdt_order_worker
get_usdt_order_worker().start()
except Exception as e:
logger.error(f"Failed to start USDT order worker: {e}")
def restore_running_strategies():
"""
Restore running strategies on startup.
@@ -231,6 +256,7 @@ def create_app(config_name='default'):
with app.app_context():
start_pending_order_worker()
start_portfolio_monitor()
start_usdt_order_worker()
restore_running_strategies()
return app
+3 -1
View File
@@ -25,6 +25,7 @@ def register_routes(app: Flask):
from app.routes.community import community_bp
from app.routes.fast_analysis import fast_analysis_bp
from app.routes.billing import billing_bp
from app.routes.quick_trade import quick_trade_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
@@ -44,4 +45,5 @@ def register_routes(app: Flask):
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
app.register_blueprint(community_bp, url_prefix='/api/community')
app.register_blueprint(fast_analysis_bp, url_prefix='/api/fast-analysis')
app.register_blueprint(billing_bp, url_prefix='/api/billing')
app.register_blueprint(billing_bp, url_prefix='/api/billing')
app.register_blueprint(quick_trade_bp, url_prefix='/api/quick-trade')
+62 -20
View File
@@ -6,7 +6,6 @@ Local deployment notes:
- Credentials are stored as plaintext JSON in DB (encrypted_config column kept for compatibility).
"""
import time
import traceback
import json
from flask import Blueprint, request, jsonify, g
@@ -42,7 +41,7 @@ def list_credentials():
"""
SELECT id, user_id, name, exchange_id, api_key_hint, created_at, updated_at
FROM qd_exchange_credentials
WHERE user_id = ?
WHERE user_id = %s
ORDER BY id DESC
""",
(user_id,)
@@ -57,41 +56,84 @@ def list_credentials():
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
CRYPTO_EXCHANGES = [
'binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange',
'kraken', 'kucoin', 'gate', 'bitfinex', 'deepcoin'
]
@credentials_bp.route('/create', methods=['POST'])
@login_required
def create_credential():
"""Create a new credential for the current user."""
"""Create a new credential for the current user.
Supports crypto exchanges, IBKR (US stocks) and MT5 (Forex).
"""
try:
user_id = g.user_id
data = request.get_json() or {}
name = (data.get('name') or '').strip()
exchange_id = (data.get('exchange_id') or '').strip()
api_key = (data.get('api_key') or '').strip()
secret_key = (data.get('secret_key') or '').strip()
passphrase = (data.get('passphrase') or '').strip()
exchange_id = (data.get('exchange_id') or '').strip().lower()
if not exchange_id:
return jsonify({'code': 0, 'msg': 'Missing exchange_id', 'data': None}), 400
if not api_key or not secret_key:
return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400
plaintext_config = json.dumps({
'exchange_id': exchange_id,
'api_key': api_key,
'secret_key': secret_key,
'passphrase': passphrase
}, ensure_ascii=False)
config = {'exchange_id': exchange_id}
hint = ''
if exchange_id == 'ibkr':
# Interactive Brokers (US stocks)
config.update({
'ibkr_host': (data.get('ibkr_host') or '127.0.0.1').strip(),
'ibkr_port': int(data.get('ibkr_port') or 7497),
'ibkr_client_id': int(data.get('ibkr_client_id') or 1),
'ibkr_account': (data.get('ibkr_account') or '').strip()
})
hint = f"{config['ibkr_host']}:{config['ibkr_port']}"
elif exchange_id == 'mt5':
# MetaTrader 5 (Forex)
mt5_server = (data.get('mt5_server') or '').strip()
mt5_login = str(data.get('mt5_login') or '').strip()
mt5_password = (data.get('mt5_password') or '').strip()
if not mt5_server or not mt5_login or not mt5_password:
return jsonify({'code': 0, 'msg': 'Missing mt5_server/mt5_login/mt5_password', 'data': None}), 400
config.update({
'mt5_server': mt5_server,
'mt5_login': mt5_login,
'mt5_password': mt5_password,
'mt5_terminal_path': (data.get('mt5_terminal_path') or '').strip()
})
hint = f"{mt5_server}/{mt5_login}"
elif exchange_id in CRYPTO_EXCHANGES:
# Crypto exchanges
api_key = (data.get('api_key') or '').strip()
secret_key = (data.get('secret_key') or '').strip()
if not api_key or not secret_key:
return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400
config.update({
'api_key': api_key,
'secret_key': secret_key,
'passphrase': (data.get('passphrase') or '').strip(),
'enable_demo_trading': bool(data.get('enable_demo_trading', False))
})
hint = _api_key_hint(api_key)
else:
return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400
plaintext_config = json.dumps(config, ensure_ascii=False)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
RETURNING id
""",
(user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config)
(user_id, name, exchange_id, hint, plaintext_config)
)
new_id = cur.lastrowid
row = cur.fetchone()
new_id = (row or {}).get('id')
db.commit()
cur.close()
@@ -115,7 +157,7 @@ def delete_credential():
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_exchange_credentials WHERE id = ? AND user_id = ?",
"DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s",
(cred_id, user_id)
)
db.commit()
@@ -146,7 +188,7 @@ def get_credential():
"""
SELECT id, user_id, name, exchange_id, encrypted_config, api_key_hint, created_at, updated_at
FROM qd_exchange_credentials
WHERE id = ? AND user_id = ?
WHERE id = %s AND user_id = %s
""",
(cred_id, user_id)
)
@@ -0,0 +1,555 @@
"""
Quick Trade API — manual / discretionary order placement.
Allows users to place market or limit orders directly from AI analysis
or indicator analysis pages, without creating a strategy first.
Endpoints:
POST /api/quick-trade/place-order — Place a quick order
GET /api/quick-trade/balance — Get available balance
GET /api/quick-trade/position — Get current position for symbol
GET /api/quick-trade/history — Get quick trade history
"""
from __future__ import annotations
import json
import time
import traceback
import uuid
from typing import Any, Dict
from flask import Blueprint, g, jsonify, request
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.utils.auth import login_required
logger = get_logger(__name__)
quick_trade_bp = Blueprint('quick_trade', __name__)
# ────────── helpers ──────────
def _safe_json(v, default=None):
if v is None:
return default
if isinstance(v, (dict, list)):
return v
try:
return json.loads(v) if isinstance(v, str) else default
except Exception:
return default
def _load_credential(credential_id: int, user_id: int) -> Dict[str, Any]:
"""Load exchange credential JSON for the given user."""
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT encrypted_config FROM qd_exchange_credentials WHERE id = %s AND user_id = %s",
(int(credential_id), int(user_id)),
)
row = cur.fetchone() or {}
cur.close()
return _safe_json(row.get("encrypted_config"), {})
def _build_exchange_config(credential_id: int, user_id: int, overrides: Dict[str, Any] = None) -> Dict[str, Any]:
"""Build exchange config from saved credential + overrides."""
base = _load_credential(credential_id, user_id)
if not base:
raise ValueError("Credential not found or access denied")
if overrides:
for k, v in overrides.items():
if v is not None and (not isinstance(v, str) or v.strip()):
base[k] = v
return base
def _create_client(exchange_config: Dict[str, Any], market_type: str = "swap"):
"""Create exchange client from config."""
from app.services.live_trading.factory import create_client
return create_client(exchange_config, market_type=market_type)
def _record_quick_trade(
user_id: int,
credential_id: int,
exchange_id: str,
symbol: str,
side: str,
order_type: str,
amount: float,
price: float,
leverage: int,
market_type: str,
tp_price: float,
sl_price: float,
status: str,
exchange_order_id: str,
filled: float,
avg_price: float,
error_msg: str,
source: str,
raw_result: Dict[str, Any],
):
"""Insert a quick trade record into the database."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_quick_trades
(user_id, credential_id, exchange_id, symbol, side, order_type,
amount, price, leverage, market_type, tp_price, sl_price,
status, exchange_order_id, filled_amount, avg_fill_price,
error_msg, source, raw_result, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(
user_id, credential_id, exchange_id, symbol, side, order_type,
amount, price, leverage, market_type, tp_price, sl_price,
status, exchange_order_id, filled, avg_price,
error_msg, source, json.dumps(raw_result or {}),
),
)
row = cur.fetchone()
db.commit()
cur.close()
return (row or {}).get("id")
except Exception as e:
logger.error(f"Failed to record quick trade: {e}")
return None
# ────────── endpoints ──────────
@quick_trade_bp.route('/place-order', methods=['POST'])
@login_required
def place_order():
"""
Place a quick market or limit order.
Body JSON:
credential_id (int) — saved exchange credential ID
symbol (str) — e.g. "BTC/USDT"
side (str) — "buy" or "sell"
order_type (str) — "market" or "limit" (default: market)
amount (float) — order size (USDT quote amount for market buy, or base qty)
price (float) — limit price (required for limit orders)
leverage (int) — leverage multiplier (default: 1)
market_type (str) — "swap" / "spot" (default: swap)
tp_price (float) — take-profit price (optional, for record only)
sl_price (float) — stop-loss price (optional, for record only)
source (str) — "ai_radar" / "ai_analysis" / "indicator" / "manual"
"""
try:
user_id = g.user_id
body = request.get_json(force=True, silent=True) or {}
credential_id = int(body.get("credential_id") or 0)
symbol = str(body.get("symbol") or "").strip()
side = str(body.get("side") or "").strip().lower()
order_type = str(body.get("order_type") or "market").strip().lower()
amount = float(body.get("amount") or 0)
price = float(body.get("price") or 0)
leverage = int(body.get("leverage") or 1)
market_type = str(body.get("market_type") or "swap").strip().lower()
tp_price = float(body.get("tp_price") or 0)
sl_price = float(body.get("sl_price") or 0)
source = str(body.get("source") or "manual").strip()
# ---- validation ----
if not credential_id:
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
if not symbol:
return jsonify({"code": 0, "msg": "Missing symbol"}), 400
if side not in ("buy", "sell"):
return jsonify({"code": 0, "msg": "side must be 'buy' or 'sell'"}), 400
if amount <= 0:
return jsonify({"code": 0, "msg": "amount must be > 0"}), 400
if order_type == "limit" and price <= 0:
return jsonify({"code": 0, "msg": "price required for limit orders"}), 400
if market_type in ("futures", "future", "perp", "perpetual"):
market_type = "swap"
# ---- build exchange client ----
exchange_config = _build_exchange_config(credential_id, user_id, {
"market_type": market_type,
})
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
if not exchange_id:
return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400
client = _create_client(exchange_config, market_type=market_type)
# ---- set leverage (futures only) ----
if market_type != "spot" and leverage > 1:
try:
if hasattr(client, "set_leverage"):
client.set_leverage(symbol=symbol, leverage=leverage)
elif hasattr(client, "set_leverage") and callable(getattr(client, "set_leverage", None)):
client.set_leverage(symbol=symbol, lever=leverage)
except Exception as le:
logger.warning(f"set_leverage failed (non-fatal): {le}")
# ---- place order ----
client_order_id = f"qt_{int(time.time())}_{uuid.uuid4().hex[:8]}"
result = None
if order_type == "market":
result = client.place_market_order(
symbol=symbol,
side=side.upper() if "binance" in exchange_id else side,
**_market_order_kwargs(client, symbol, amount, side, market_type, client_order_id),
)
else:
result = client.place_limit_order(
symbol=symbol,
side=side.upper() if "binance" in exchange_id else side,
**_limit_order_kwargs(client, symbol, amount, price, side, market_type, client_order_id),
)
# ---- extract result ----
exchange_order_id = str(getattr(result, "exchange_order_id", "") or "")
filled = float(getattr(result, "filled", 0) or 0)
avg_fill = float(getattr(result, "avg_price", 0) or 0)
raw = getattr(result, "raw", {}) or {}
# ---- record trade ----
trade_id = _record_quick_trade(
user_id=user_id,
credential_id=credential_id,
exchange_id=exchange_id,
symbol=symbol,
side=side,
order_type=order_type,
amount=amount,
price=price if order_type == "limit" else avg_fill,
leverage=leverage,
market_type=market_type,
tp_price=tp_price,
sl_price=sl_price,
status="filled" if filled > 0 else "submitted",
exchange_order_id=exchange_order_id,
filled=filled,
avg_price=avg_fill,
error_msg="",
source=source,
raw_result=raw,
)
return jsonify({
"code": 1,
"msg": "Order placed successfully",
"data": {
"trade_id": trade_id,
"exchange_order_id": exchange_order_id,
"filled": filled,
"avg_price": avg_fill,
"status": "filled" if filled > 0 else "submitted",
},
})
except Exception as e:
logger.error(f"quick trade failed: {e}")
logger.error(traceback.format_exc())
# Try to record the failure
try:
_record_quick_trade(
user_id=g.user_id,
credential_id=int(body.get("credential_id") or 0),
exchange_id="",
symbol=str(body.get("symbol") or ""),
side=str(body.get("side") or ""),
order_type=str(body.get("order_type") or "market"),
amount=float(body.get("amount") or 0),
price=0,
leverage=int(body.get("leverage") or 1),
market_type=str(body.get("market_type") or "swap"),
tp_price=0,
sl_price=0,
status="failed",
exchange_order_id="",
filled=0,
avg_price=0,
error_msg=str(e)[:500],
source=str(body.get("source") or "manual"),
raw_result={},
)
except Exception:
pass
return jsonify({"code": 0, "msg": str(e)}), 500
def _market_order_kwargs(client, symbol, amount, side, market_type, client_order_id):
"""Build kwargs compatible with any exchange client's place_market_order."""
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.bitget import BitgetMixClient
from app.services.live_trading.bybit import BybitClient
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
return {"quantity": amount, "client_order_id": client_order_id}
if isinstance(client, OkxClient):
return {"size": amount, "client_order_id": client_order_id}
if isinstance(client, BitgetMixClient):
return {"size": amount, "client_order_id": client_order_id}
if isinstance(client, BybitClient):
return {"qty": amount, "client_order_id": client_order_id}
# Generic fallback
return {"size": amount, "client_order_id": client_order_id}
def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client_order_id):
"""Build kwargs compatible with any exchange client's place_limit_order."""
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
return {"quantity": amount, "price": price, "client_order_id": client_order_id}
# Generic fallback
return {"size": amount, "price": price, "client_order_id": client_order_id}
@quick_trade_bp.route('/balance', methods=['GET'])
@login_required
def get_balance():
"""
Get available balance from exchange.
Query: credential_id (int), market_type (str, default "swap")
"""
try:
user_id = g.user_id
credential_id = request.args.get("credential_id", type=int)
market_type = request.args.get("market_type", "swap").strip().lower()
if not credential_id:
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
exchange_config = _build_exchange_config(credential_id, user_id, {"market_type": market_type})
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
client = _create_client(exchange_config, market_type=market_type)
balance_data = {"available": 0, "total": 0, "currency": "USDT"}
try:
if hasattr(client, "get_balance"):
raw = client.get_balance()
balance_data = _parse_balance(raw, exchange_id, market_type)
elif hasattr(client, "get_account"):
raw = client.get_account()
balance_data = _parse_balance(raw, exchange_id, market_type)
elif hasattr(client, "get_accounts"):
raw = client.get_accounts()
balance_data = _parse_balance(raw, exchange_id, market_type)
except Exception as be:
logger.warning(f"Balance fetch failed: {be}")
balance_data["error"] = str(be)
return jsonify({"code": 1, "msg": "success", "data": balance_data})
except Exception as e:
logger.error(f"get_balance failed: {e}")
return jsonify({"code": 0, "msg": str(e)}), 500
def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, Any]:
"""Best-effort parse balance from various exchange responses."""
result = {"available": 0, "total": 0, "currency": "USDT"}
if not raw:
return result
try:
if isinstance(raw, dict):
# Binance futures
if "availableBalance" in raw:
result["available"] = float(raw.get("availableBalance") or 0)
result["total"] = float(raw.get("totalWalletBalance") or raw.get("totalMarginBalance") or 0)
return result
# Binance spot
if "balances" in raw:
for b in raw.get("balances", []):
if str(b.get("asset") or "").upper() == "USDT":
result["available"] = float(b.get("free") or 0)
result["total"] = float(b.get("free") or 0) + float(b.get("locked") or 0)
return result
return result
# OKX
data = raw.get("data")
if isinstance(data, list) and data:
first = data[0] if isinstance(data[0], dict) else {}
# Account balance
details = first.get("details", [])
if isinstance(details, list):
for d in details:
if str(d.get("ccy") or "").upper() == "USDT":
result["available"] = float(d.get("availBal") or d.get("availEq") or 0)
result["total"] = float(d.get("eq") or d.get("cashBal") or 0)
return result
# Fallback
result["available"] = float(first.get("availBal") or first.get("totalEq") or 0)
result["total"] = float(first.get("totalEq") or 0)
return result
# Bybit
if "result" in raw:
res = raw["result"]
if isinstance(res, dict):
coin_list = res.get("list", [])
if isinstance(coin_list, list):
for acc in coin_list:
coins = acc.get("coin", []) if isinstance(acc, dict) else []
for c in coins:
if str(c.get("coin") or "").upper() == "USDT":
result["available"] = float(c.get("availableToWithdraw") or c.get("walletBalance") or 0)
result["total"] = float(c.get("walletBalance") or 0)
return result
# Fallback: try to find any USDT-like values
if isinstance(raw, dict):
for k, v in raw.items():
if "avail" in str(k).lower() and isinstance(v, (int, float)):
result["available"] = float(v)
if "total" in str(k).lower() and isinstance(v, (int, float)):
result["total"] = float(v)
except Exception as e:
logger.warning(f"_parse_balance error: {e}")
return result
@quick_trade_bp.route('/position', methods=['GET'])
@login_required
def get_position():
"""
Get current position for a symbol from exchange.
Query: credential_id (int), symbol (str), market_type (str)
"""
try:
user_id = g.user_id
credential_id = request.args.get("credential_id", type=int)
symbol = request.args.get("symbol", "").strip()
market_type = request.args.get("market_type", "swap").strip().lower()
if not credential_id or not symbol:
return jsonify({"code": 0, "msg": "Missing credential_id or symbol"}), 400
exchange_config = _build_exchange_config(credential_id, user_id, {"market_type": market_type})
client = _create_client(exchange_config, market_type=market_type)
positions = []
try:
if hasattr(client, "get_positions"):
raw = client.get_positions(symbol=symbol)
positions = _parse_positions(raw)
elif hasattr(client, "get_position"):
raw = client.get_position(symbol=symbol)
positions = _parse_positions(raw)
except Exception as pe:
logger.warning(f"Position fetch failed: {pe}")
return jsonify({"code": 1, "msg": "success", "data": {"positions": positions}})
except Exception as e:
logger.error(f"get_position failed: {e}")
return jsonify({"code": 0, "msg": str(e)}), 500
def _parse_positions(raw: Any) -> list:
"""Best-effort parse positions from exchange response."""
result = []
if not raw:
return result
try:
items = []
if isinstance(raw, list):
items = raw
elif isinstance(raw, dict):
data = raw.get("data") or raw.get("result") or raw.get("positions") or []
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("list", []) if "list" in data else [data]
for item in items:
if not isinstance(item, dict):
continue
size = float(item.get("posAmt") or item.get("pos") or item.get("size") or item.get("contracts") or 0)
if abs(size) < 1e-10:
continue
result.append({
"symbol": item.get("symbol") or item.get("instId") or "",
"side": "long" if size > 0 else "short",
"size": abs(size),
"entry_price": float(item.get("entryPrice") or item.get("avgCost") or item.get("avgPx") or 0),
"unrealized_pnl": float(item.get("unRealizedProfit") or item.get("upl") or item.get("unrealisedPnl") or 0),
"leverage": float(item.get("leverage") or 1),
"mark_price": float(item.get("markPrice") or item.get("markPx") or 0),
})
except Exception as e:
logger.warning(f"_parse_positions error: {e}")
return result
@quick_trade_bp.route('/history', methods=['GET'])
@login_required
def get_history():
"""
Get quick trade history for the current user.
Query: limit (int, default 50), offset (int, default 0)
"""
try:
user_id = g.user_id
limit = min(int(request.args.get("limit") or 50), 200)
offset = int(request.args.get("offset") or 0)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, exchange_id, symbol, side, order_type, amount, price,
leverage, market_type, tp_price, sl_price, status,
exchange_order_id, filled_amount, avg_fill_price,
error_msg, source, created_at
FROM qd_quick_trades
WHERE user_id = %s
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""",
(user_id, limit, offset),
)
rows = cur.fetchall() or []
cur.close()
trades = []
for r in rows:
trades.append({
"id": r.get("id"),
"exchange_id": r.get("exchange_id") or "",
"symbol": r.get("symbol") or "",
"side": r.get("side") or "",
"order_type": r.get("order_type") or "market",
"amount": float(r.get("amount") or 0),
"price": float(r.get("price") or 0),
"leverage": int(r.get("leverage") or 1),
"market_type": r.get("market_type") or "swap",
"tp_price": float(r.get("tp_price") or 0),
"sl_price": float(r.get("sl_price") or 0),
"status": r.get("status") or "",
"exchange_order_id": r.get("exchange_order_id") or "",
"filled_amount": float(r.get("filled_amount") or 0),
"avg_fill_price": float(r.get("avg_fill_price") or 0),
"error_msg": r.get("error_msg") or "",
"source": r.get("source") or "",
"created_at": str(r.get("created_at") or ""),
})
return jsonify({"code": 1, "msg": "success", "data": {"trades": trades}})
except Exception as e:
logger.error(f"get_history failed: {e}")
return jsonify({"code": 0, "msg": str(e)}), 500
+21 -12
View File
@@ -640,7 +640,7 @@ def test_connection():
Test exchange connection.
Request body:
exchange_config: Exchange configuration
exchange_config: Exchange configuration (may contain credential_id or inline keys)
"""
try:
data = request.get_json() or {}
@@ -664,29 +664,38 @@ def test_connection():
logger.error(f"Invalid exchange_config type: {type(exchange_config)}, data: {str(exchange_config)[:200]}")
# Frontend expects HTTP 200 with {code:0} for business failures.
return jsonify({'code': 0, 'msg': 'Invalid exchange config format; please check your payload', 'data': None})
# 验证必要字段
if not exchange_config.get('exchange_id'):
# Resolve credential_id → full config (merges credential keys with any overrides).
# This allows the frontend to send just {credential_id: 5} without raw api_key/secret_key.
from app.services.exchange_execution import resolve_exchange_config
user_id = g.user_id if hasattr(g, 'user_id') else 1
resolved = resolve_exchange_config(exchange_config, user_id=user_id)
# 验证必要字段 (check resolved config after credential merge)
if not resolved.get('exchange_id'):
return jsonify({'code': 0, 'msg': 'Please select an exchange', 'data': None})
api_key = exchange_config.get('api_key', '')
secret_key = exchange_config.get('secret_key', '')
api_key = resolved.get('api_key', '')
secret_key = resolved.get('secret_key', '')
# 详细日志排查
logger.info(f"Testing connection: exchange_id={exchange_config.get('exchange_id')}")
logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})")
logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})")
logger.info(f"Testing connection: exchange_id={resolved.get('exchange_id')}")
if api_key:
logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})")
if secret_key:
logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})")
# 检查是否有特殊字符
if api_key.strip() != api_key:
if api_key and api_key.strip() != api_key:
logger.warning("API key contains leading/trailing whitespace")
if secret_key.strip() != secret_key:
if secret_key and secret_key.strip() != secret_key:
logger.warning("Secret key contains leading/trailing whitespace")
if not api_key or not secret_key:
return jsonify({'code': 0, 'msg': 'Please provide API key and secret key', 'data': None})
result = get_strategy_service().test_exchange_connection(exchange_config)
# Pass the resolved config (with actual keys) to the service
result = get_strategy_service().test_exchange_connection(resolved)
if result['success']:
return jsonify({'code': 1, 'msg': result.get('message') or 'Connection successful', 'data': result.get('data')})
+447
View File
@@ -1071,3 +1071,450 @@ def get_system_strategies():
import traceback
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== Admin Orders ====================
@user_bp.route('/admin-orders', methods=['GET'])
@login_required
@admin_required
def get_admin_orders():
"""
Get all orders across the system (admin only).
Merges qd_membership_orders and qd_usdt_orders into a unified list.
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
status: str (optional, filter by status: paid/pending/confirmed/expired/all)
search: str (optional, search by username/email)
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
status_filter = request.args.get('status', '', type=str).strip().lower()
search = request.args.get('search', '', type=str).strip()
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# --- USDT Orders (primary) ---
usdt_conditions = []
usdt_params = []
if status_filter and status_filter != 'all':
usdt_conditions.append("o.status = ?")
usdt_params.append(status_filter)
if search:
usdt_conditions.append("(u.username ILIKE ? OR u.email ILIKE ? OR u.nickname ILIKE ?)")
like_val = f"%{search}%"
usdt_params.extend([like_val, like_val, like_val])
usdt_where = ""
if usdt_conditions:
usdt_where = "WHERE " + " AND ".join(usdt_conditions)
# Count
cur.execute(
f"SELECT COUNT(*) as cnt FROM qd_usdt_orders o LEFT JOIN qd_users u ON u.id = o.user_id {usdt_where}",
tuple(usdt_params)
)
usdt_total = cur.fetchone()['cnt']
# --- Membership Orders (mock) ---
mock_conditions = []
mock_params = []
if status_filter and status_filter != 'all':
mock_conditions.append("m.status = ?")
mock_params.append(status_filter)
if search:
mock_conditions.append("(u.username ILIKE ? OR u.email ILIKE ? OR u.nickname ILIKE ?)")
like_val = f"%{search}%"
mock_params.extend([like_val, like_val, like_val])
mock_where = ""
if mock_conditions:
mock_where = "WHERE " + " AND ".join(mock_conditions)
cur.execute(
f"SELECT COUNT(*) as cnt FROM qd_membership_orders m LEFT JOIN qd_users u ON u.id = m.user_id {mock_where}",
tuple(mock_params)
)
mock_total = cur.fetchone()['cnt']
total = usdt_total + mock_total
# Use UNION ALL to merge both tables into one sorted list
# We select a unified schema
union_sql = f"""
SELECT * FROM (
SELECT
o.id,
'usdt' AS order_type,
o.user_id,
u.username,
u.nickname,
u.email AS user_email,
o.plan,
o.amount_usdt AS amount,
'USDT' AS currency,
o.chain,
o.address,
o.tx_hash,
o.status,
o.created_at,
o.paid_at,
o.confirmed_at,
o.expires_at
FROM qd_usdt_orders o
LEFT JOIN qd_users u ON u.id = o.user_id
{usdt_where}
UNION ALL
SELECT
m.id,
'mock' AS order_type,
m.user_id,
u.username,
u.nickname,
u.email AS user_email,
m.plan,
m.price_usd AS amount,
'USD' AS currency,
'' AS chain,
'' AS address,
'' AS tx_hash,
m.status,
m.created_at,
m.paid_at,
NULL AS confirmed_at,
NULL AS expires_at
FROM qd_membership_orders m
LEFT JOIN qd_users u ON u.id = m.user_id
{mock_where}
) AS combined
ORDER BY combined.created_at DESC
LIMIT ? OFFSET ?
"""
all_params = list(usdt_params) + list(mock_params) + [page_size, offset]
cur.execute(union_sql, tuple(all_params))
rows = cur.fetchall() or []
# Summary stats
cur.execute(
f"""SELECT
COUNT(*) AS total_orders,
COALESCE(SUM(CASE WHEN status IN ('paid','confirmed') THEN 1 ELSE 0 END), 0) AS paid_orders,
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_orders,
COALESCE(SUM(CASE WHEN status IN ('expired','cancelled','failed') THEN 1 ELSE 0 END), 0) AS failed_orders,
COALESCE(SUM(CASE WHEN status IN ('paid','confirmed') THEN amount_usdt ELSE 0 END), 0) AS total_revenue
FROM qd_usdt_orders"""
)
summary_row = cur.fetchone() or {}
cur.close()
items = []
for row in rows:
created_at = row.get('created_at')
paid_at = row.get('paid_at')
confirmed_at = row.get('confirmed_at')
expires_at = row.get('expires_at')
if hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if hasattr(paid_at, 'isoformat'):
paid_at = paid_at.isoformat()
if hasattr(confirmed_at, 'isoformat'):
confirmed_at = confirmed_at.isoformat()
if hasattr(expires_at, 'isoformat'):
expires_at = expires_at.isoformat()
items.append({
'id': row['id'],
'order_type': row.get('order_type') or '',
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'user_email': row.get('user_email') or '',
'plan': row.get('plan') or '',
'amount': float(row.get('amount') or 0),
'currency': row.get('currency') or '',
'chain': row.get('chain') or '',
'address': row.get('address') or '',
'tx_hash': row.get('tx_hash') or '',
'status': row.get('status') or '',
'created_at': created_at,
'paid_at': paid_at,
'confirmed_at': confirmed_at,
'expires_at': expires_at
})
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'summary': {
'total_orders': int(summary_row.get('total_orders') or 0),
'paid_orders': int(summary_row.get('paid_orders') or 0),
'pending_orders': int(summary_row.get('pending_orders') or 0),
'failed_orders': int(summary_row.get('failed_orders') or 0),
'total_revenue': round(float(summary_row.get('total_revenue') or 0), 2)
}
}
})
except Exception as e:
logger.error(f"get_admin_orders failed: {e}")
import traceback
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== Admin AI Analysis Stats ====================
@user_bp.route('/admin-ai-stats', methods=['GET'])
@login_required
@admin_required
def get_admin_ai_stats():
"""
Get AI analysis usage statistics across the system (admin only).
Does NOT expose analysis results, only aggregated counts/stats.
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
search: str (optional, search by username)
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
search = request.args.get('search', '', type=str).strip()
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# --- Overall summary (from qd_analysis_tasks + qd_analysis_memory) ---
cur.execute("""
SELECT
COUNT(*) AS total_tasks,
COUNT(DISTINCT user_id) AS unique_users,
COUNT(DISTINCT symbol) AS unique_symbols,
COUNT(DISTINCT market) AS unique_markets
FROM qd_analysis_tasks
""")
task_summary = cur.fetchone() or {}
memory_summary = {}
try:
cur.execute("""
SELECT
COUNT(*) AS total_memory,
COALESCE(SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END), 0) AS correct_count,
COALESCE(SUM(CASE WHEN was_correct = false THEN 1 ELSE 0 END), 0) AS incorrect_count,
COALESCE(SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END), 0) AS helpful_count,
COALESCE(SUM(CASE WHEN user_feedback = 'not_helpful' THEN 1 ELSE 0 END), 0) AS not_helpful_count
FROM qd_analysis_memory
""")
memory_summary = cur.fetchone() or {}
except Exception as mem_err:
logger.warning(f"qd_analysis_memory query failed (table/column may not exist): {mem_err}")
db.rollback()
cur = db.cursor() # re-create cursor after rollback
memory_summary = {}
# --- Per-user stats ---
user_conditions = []
user_params = []
if search:
user_conditions.append("(u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)")
like_val = f"%{search}%"
user_params.extend([like_val, like_val, like_val])
user_where = ""
if user_conditions:
user_where = "WHERE " + " AND ".join(user_conditions)
# Count distinct users who have analysis records
cur.execute(
f"""
SELECT COUNT(DISTINCT t.user_id) AS cnt
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
{user_where}
""",
tuple(user_params)
)
user_total = cur.fetchone()['cnt']
# Get per-user aggregated stats
cur.execute(
f"""
SELECT
t.user_id,
u.username,
u.nickname,
u.email,
COUNT(*) AS analysis_count,
COUNT(DISTINCT t.symbol) AS symbol_count,
COUNT(DISTINCT t.market) AS market_count,
MAX(t.created_at) AS last_analysis_at,
MIN(t.created_at) AS first_analysis_at
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
{user_where}
GROUP BY t.user_id, u.username, u.nickname, u.email
ORDER BY analysis_count DESC
LIMIT ? OFFSET ?
""",
tuple(user_params) + (page_size, offset)
)
user_rows = cur.fetchall() or []
# Get per-user analysis_memory stats (correct/helpful counts)
user_ids = [r['user_id'] for r in user_rows if r.get('user_id')]
memory_stats_map = {}
if user_ids:
try:
placeholders = ','.join(['?'] * len(user_ids))
cur.execute(
f"""
SELECT
user_id,
COUNT(*) AS memory_count,
COALESCE(SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END), 0) AS correct,
COALESCE(SUM(CASE WHEN was_correct = false THEN 1 ELSE 0 END), 0) AS incorrect,
COALESCE(SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END), 0) AS helpful,
COALESCE(SUM(CASE WHEN user_feedback = 'not_helpful' THEN 1 ELSE 0 END), 0) AS not_helpful
FROM qd_analysis_memory
WHERE user_id IN ({placeholders})
GROUP BY user_id
""",
tuple(user_ids)
)
for row in (cur.fetchall() or []):
memory_stats_map[row['user_id']] = {
'memory_count': row['memory_count'],
'correct': row['correct'],
'incorrect': row['incorrect'],
'helpful': row['helpful'],
'not_helpful': row['not_helpful']
}
except Exception as mem_err:
logger.warning(f"qd_analysis_memory per-user query failed: {mem_err}")
db.rollback()
cur = db.cursor() # re-create cursor after rollback
memory_stats_map = {}
# Get recent analysis records (last 50)
cur.execute(
"""
SELECT
t.id,
t.user_id,
u.username,
u.nickname,
t.market,
t.symbol,
t.model,
t.status,
t.created_at,
t.completed_at
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
ORDER BY t.created_at DESC
LIMIT 50
"""
)
recent_rows = cur.fetchall() or []
cur.close()
# Build per-user items
user_items = []
for row in user_rows:
uid = row.get('user_id')
ms = memory_stats_map.get(uid, {})
last_at = row.get('last_analysis_at')
first_at = row.get('first_analysis_at')
if hasattr(last_at, 'isoformat'):
last_at = last_at.isoformat()
if hasattr(first_at, 'isoformat'):
first_at = first_at.isoformat()
user_items.append({
'user_id': uid,
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'email': row.get('email') or '',
'analysis_count': row.get('analysis_count') or 0,
'symbol_count': row.get('symbol_count') or 0,
'market_count': row.get('market_count') or 0,
'correct': ms.get('correct', 0),
'incorrect': ms.get('incorrect', 0),
'helpful': ms.get('helpful', 0),
'not_helpful': ms.get('not_helpful', 0),
'last_analysis_at': last_at,
'first_analysis_at': first_at
})
# Build recent records
recent_items = []
for row in recent_rows:
created_at = row.get('created_at')
completed_at = row.get('completed_at')
if hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if hasattr(completed_at, 'isoformat'):
completed_at = completed_at.isoformat()
recent_items.append({
'id': row['id'],
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'market': row.get('market') or '',
'symbol': row.get('symbol') or '',
'model': row.get('model') or '',
'status': row.get('status') or '',
'created_at': created_at,
'completed_at': completed_at
})
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'user_stats': user_items,
'user_total': user_total,
'page': page,
'page_size': page_size,
'recent': recent_items,
'summary': {
'total_analyses': int(task_summary.get('total_tasks') or 0),
'unique_users': int(task_summary.get('unique_users') or 0),
'unique_symbols': int(task_summary.get('unique_symbols') or 0),
'unique_markets': int(task_summary.get('unique_markets') or 0),
'total_memory': int(memory_summary.get('total_memory') or 0),
'correct_count': int(memory_summary.get('correct_count') or 0),
'incorrect_count': int(memory_summary.get('incorrect_count') or 0),
'helpful_count': int(memory_summary.get('helpful_count') or 0),
'not_helpful_count': int(memory_summary.get('not_helpful_count') or 0)
}
}
})
except Exception as e:
logger.error(f"get_admin_ai_stats failed: {e}")
import traceback
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@@ -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
+2
View File
@@ -347,6 +347,8 @@ TRONGRID_API_KEY=
# Order confirmation delay and expiration
USDT_PAY_CONFIRM_SECONDS=30
USDT_PAY_EXPIRE_MINUTES=30
# Background worker poll interval (seconds) for checking pending USDT orders
USDT_WORKER_POLL_INTERVAL=30
# New user registration bonus credits (新用户注册赠送积分)
CREDITS_REGISTER_BONUS=100
+30
View File
@@ -802,6 +802,36 @@ BEGIN
END IF;
END $$;
-- =============================================================================
-- Quick Trades (manual / discretionary orders from Quick Trade Panel)
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_quick_trades (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
credential_id INTEGER DEFAULT 0,
exchange_id VARCHAR(40) NOT NULL DEFAULT '',
symbol VARCHAR(60) NOT NULL DEFAULT '',
side VARCHAR(10) NOT NULL DEFAULT '', -- buy / sell
order_type VARCHAR(20) NOT NULL DEFAULT 'market', -- market / limit
amount DECIMAL(24, 8) DEFAULT 0,
price DECIMAL(24, 8) DEFAULT 0,
leverage INTEGER DEFAULT 1,
market_type VARCHAR(20) DEFAULT 'swap', -- swap / spot
tp_price DECIMAL(24, 8) DEFAULT 0,
sl_price DECIMAL(24, 8) DEFAULT 0,
status VARCHAR(20) DEFAULT 'submitted', -- submitted / filled / failed / cancelled
exchange_order_id VARCHAR(120) DEFAULT '',
filled_amount DECIMAL(24, 8) DEFAULT 0,
avg_fill_price DECIMAL(24, 8) DEFAULT 0,
error_msg TEXT DEFAULT '',
source VARCHAR(40) DEFAULT 'manual', -- ai_radar / ai_analysis / indicator / manual
raw_result JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quick_trades_user ON qd_quick_trades(user_id);
CREATE INDEX IF NOT EXISTS idx_quick_trades_created ON qd_quick_trades(created_at DESC);
-- =============================================================================
-- Completion Notice
-- =============================================================================