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
+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