feat(backend): encrypt exchange credentials, remove IS_DEMO_MODE

- Fernet encrypt qd_exchange_credentials via SECRET_KEY (cryptography)

- Remove global read-only demo middleware; drop is_demo from auth payloads

- Egress whitelist: /api/credentials/egress-ip returns ipv4 + ipv6 (ipify)

- Exchange factory: demo/testnet URLs and OKX simulated-trading header

- Bitget spot connection test; misc route/service fixes

Made-with: Cursor
This commit is contained in:
Dinger
2026-03-24 19:10:18 +08:00
parent 2e9c7cd69e
commit 7626328c9c
20 changed files with 429 additions and 150 deletions
@@ -314,11 +314,6 @@ def start_ai_calibration_worker() -> None:
"""
Run offline calibration once on service startup (best-effort).
"""
is_demo_mode = os.getenv("IS_DEMO_MODE", "false").lower() == "true"
if is_demo_mode:
logger.info("AI calibration worker skipped in demo mode.")
return
enabled = os.getenv("ENABLE_OFFLINE_AI_CALIBRATION", "true").lower() == "true"
if not enabled:
logger.info("AI calibration worker disabled (ENABLE_OFFLINE_AI_CALIBRATION=false).")
@@ -491,15 +491,16 @@ class BillingService:
(float(new_balance), user_id)
)
# 记录日志
# 记录日志 - 使用 UTC 时间确保跨时区显示正确
feature_name = FEATURE_NAMES.get(feature, feature)
created_at_utc = datetime.now(timezone.utc)
cur.execute(
"""
INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
VALUES (?, 'consume', ?, ?, ?, ?, ?, NOW())
VALUES (?, 'consume', ?, ?, ?, ?, ?, ?)
""",
(user_id, -cost, float(new_balance), feature, reference_id, f'Consume: {feature_name}')
(user_id, -cost, float(new_balance), feature, reference_id, f'Consume: {feature_name}', created_at_utc)
)
db.commit()
@@ -686,8 +687,22 @@ class BillingService:
""",
(user_id, page_size, offset)
)
logs = cur.fetchall() or []
rows = cur.fetchall() or []
cur.close()
# Format created_at as ISO 8601 with Z (UTC) for correct frontend display
logs = []
for r in rows:
d = dict(r)
if d.get('created_at'):
dt = d['created_at']
if hasattr(dt, 'isoformat'):
if getattr(dt, 'tzinfo', None) is not None:
d['created_at'] = dt.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S') + 'Z'
else:
# 无时区:新记录用 UTC 写入,旧记录可能为服务器本地时间,统一按 UTC 返回以便前端正确转换
d['created_at'] = dt.strftime('%Y-%m-%dT%H:%M:%S') + 'Z'
logs.append(d)
return {
'items': logs,
@@ -15,6 +15,7 @@ from typing import Any, Dict
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.utils.credential_crypto import decrypt_credential_blob
logger = get_logger(__name__)
@@ -92,7 +93,7 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
def _load_credential_config(credential_id: int, user_id: int = 1) -> Dict[str, Any]:
"""Load credential JSON from qd_exchange_credentials (plaintext in local mode)."""
"""Load credential JSON from qd_exchange_credentials (Fernet via SECRET_KEY)."""
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
@@ -105,7 +106,13 @@ def _load_credential_config(credential_id: int, user_id: int = 1) -> Dict[str, A
)
row = cur.fetchone() or {}
cur.close()
return _safe_json_loads(row.get("encrypted_config"), {}) or {}
raw = row.get("encrypted_config")
try:
plain = decrypt_credential_blob(raw)
except ValueError as e:
logger.warning(f"decrypt credential_id={credential_id}: {e}")
return {}
return _safe_json_loads(plain, {}) or {}
def resolve_exchange_config(exchange_config: Dict[str, Any], user_id: int = 1) -> Dict[str, Any]:
@@ -46,6 +46,13 @@ def _get(cfg: Dict[str, Any], *keys: str) -> str:
return ""
def _demo_enabled(cfg: Dict[str, Any]) -> bool:
v = cfg.get("enable_demo_trading") or cfg.get("enableDemoTrading")
if isinstance(v, bool):
return v
return str(v or "").strip().lower() in ("true", "1", "yes")
def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") -> BaseRestClient:
if not isinstance(exchange_config, dict):
raise LiveTradingError("Invalid exchange_config")
@@ -58,16 +65,14 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
if mt in ("futures", "future", "perp", "perpetual"):
mt = "swap"
is_demo = _demo_enabled(exchange_config)
if exchange_id == "binance":
# 检查是否启用模拟交易,支持布尔值和字符串
enable_demo = exchange_config.get("enable_demo_trading") or exchange_config.get("enableDemoTrading")
is_demo = bool(enable_demo) if isinstance(enable_demo, bool) else str(enable_demo).lower() in ("true", "1", "yes")
if mt == "spot":
default_url = "https://demo-api.binance.com" if is_demo else "https://api.binance.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_url
return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo)
# Default to USDT-M futures
# Default to USDT-M futures
default_url = "https://demo-fapi.binance.com" if is_demo else "https://fapi.binance.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_url
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo)
@@ -79,9 +84,11 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
secret_key=secret_key,
passphrase=passphrase,
base_url=base_url,
broker_code=broker_code
broker_code=broker_code,
simulated_trading=is_demo,
)
if exchange_id == "bitget":
# Bitget simulated trading uses the same REST host; keys must be created in Bitget demo trading.
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
if mt == "spot":
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
@@ -90,7 +97,8 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
if exchange_id == "bybit":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bybit.com"
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
category = "spot" if mt == "spot" else "linear"
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
return BybitClient(
@@ -102,7 +110,8 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
)
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.exchange.coinbase.com"
default_cb = "https://api-public.sandbox.exchange.coinbase.com" if is_demo else "https://api.exchange.coinbase.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_cb
if mt != "spot":
raise LiveTradingError("CoinbaseExchange only supports spot market_type in this project")
return CoinbaseExchangeClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
@@ -110,26 +119,32 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
if exchange_id == "kraken":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.kraken.com"
if mt == "spot":
# Kraken spot REST has no separate public sandbox URL; use demo keys on production API if offered by Kraken.
return KrakenClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
# Futures/perp
fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://futures.kraken.com"
fut_default = "https://demo-futures.kraken.com" if is_demo else "https://futures.kraken.com"
fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or fut_default
return KrakenFuturesClient(api_key=api_key, secret_key=secret_key, base_url=fut_url)
if exchange_id == "kucoin":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.kucoin.com"
default_spot = "https://openapi-sandbox.kucoin.com" if is_demo else "https://api.kucoin.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_spot
if mt == "spot":
return KucoinSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://api-futures.kucoin.com"
fut_default = "https://api-sandbox-futures.kucoin.com" if is_demo else "https://api-futures.kucoin.com"
fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or fut_default
return KucoinFuturesClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=fut_url)
if exchange_id == "gate":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.gateio.ws"
if mt == "spot":
default_gate = "https://api-testnet.gateio.ws" if is_demo else "https://api.gateio.ws"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_gate
return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
# Default to USDT futures for swap
default_fut = "https://fx-api-testnet.gateio.ws" if is_demo else "https://api.gateio.ws"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_fut
return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
if exchange_id == "bitfinex":
# Same REST host; use keys from Bitfinex paper/sub-account where applicable.
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitfinex.com"
if mt == "spot":
return BitfinexClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
@@ -31,11 +31,13 @@ class OkxClient(BaseRestClient):
base_url: str = "https://www.okx.com",
timeout_sec: float = 15.0,
broker_code: Optional[str] = None,
simulated_trading: bool = False,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
self.simulated_trading = bool(simulated_trading)
effective_broker = broker_code or self._DEFAULT_BROKER_CODE
self.broker_code = str(effective_broker).strip() if effective_broker else None
if not self.api_key or not self.secret_key or not self.passphrase:
@@ -270,13 +272,16 @@ class OkxClient(BaseRestClient):
return base64.b64encode(mac).decode("utf-8")
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
return {
h: Dict[str, str] = {
"OK-ACCESS-KEY": self.api_key,
"OK-ACCESS-SIGN": sign,
"OK-ACCESS-TIMESTAMP": ts,
"OK-ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
if self.simulated_trading:
h["x-simulated-trading"] = "1"
return h
def _signed_request(
self,
+7
View File
@@ -147,6 +147,13 @@ class LLMService:
return PROVIDER_CONFIGS[p]["default_model"]
def get_code_generation_model(self, provider: LLMProvider = None) -> str:
"""Get model for AI code generation; fallback to provider default when unset."""
model = os.getenv('AI_CODE_GEN_MODEL', '').strip()
if model:
return model
return self.get_default_model(provider)
# Legacy properties for backward compatibility
@property
def api_key(self):
@@ -24,7 +24,6 @@ import json
import os
import smtplib
import time
import traceback
from datetime import datetime, timezone
from email.message import EmailMessage
from typing import Any, Dict, List, Optional, Tuple
@@ -437,7 +436,7 @@ class SignalNotifier:
def _notify_browser(
self,
*,
strategy_id: int,
strategy_id: Optional[int] = None,
symbol: str,
signal_type: str,
channels: List[str],
@@ -450,15 +449,19 @@ class SignalNotifier:
now = int(time.time())
# Get user_id from strategy if not provided
if user_id is None:
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
row = cur.fetchone()
cur.close()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
if strategy_id is not None:
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (int(strategy_id),))
row = cur.fetchone()
cur.close()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
user_id = 1
else:
user_id = 1
sid = None if strategy_id is None else int(strategy_id)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
@@ -469,7 +472,7 @@ class SignalNotifier:
""",
(
int(user_id),
int(strategy_id),
sid,
str(symbol or ""),
str(signal_type or ""),
",".join([str(c) for c in (channels or [])]),
@@ -483,7 +486,7 @@ class SignalNotifier:
return True, ""
except Exception as e:
logger.warning(f"browser notify persist failed: {e}")
logger.error('browser.error', traceback=traceback.format_exc())
logger.exception("browser.error")
return False, str(e)
def _notify_webhook(
@@ -573,7 +576,7 @@ class SignalNotifier:
return False, f"http_{resp2.status_code}:{(resp2.text or '')[:300]}"
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e:
logger.error('webhook.error', traceback=traceback.format_exc())
logger.exception("webhook.error")
return False, str(e)
def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]:
@@ -649,7 +652,7 @@ class SignalNotifier:
pass
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e:
logger.error('discord.error', traceback=traceback.format_exc())
logger.exception("discord.error")
return False, str(e)
def _notify_telegram(
@@ -684,15 +687,21 @@ class SignalNotifier:
return True, ""
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e:
logger.error('telegram.error', traceback=traceback.format_exc())
logger.exception("telegram.error")
return False, str(e)
def _notify_email(self, *, to_email: str, subject: str, body_text: str, body_html: str = "") -> Tuple[bool, str]:
if not to_email:
logger.warning("email.skip: missing recipient (to_email empty)")
return False, "missing_email_target"
if not self.smtp_host:
logger.warning(
"email.skip: SMTP_HOST not configured (set in system env / admin Email settings); "
"test notification and all outbound mail require it"
)
return False, "missing_SMTP_HOST"
if not self.smtp_from:
logger.warning("email.skip: SMTP_FROM not configured (usually same as SMTP_USER or a verified sender)")
return False, "missing_SMTP_FROM"
msg = EmailMessage()
@@ -723,7 +732,7 @@ class SignalNotifier:
server.send_message(msg)
return True, ""
except Exception as e:
logger.error('email.error', traceback=traceback.format_exc())
logger.exception("email.error")
return False, str(e)
def _notify_phone(self, *, to_phone: str, body: str) -> Tuple[bool, str]:
@@ -740,7 +749,114 @@ class SignalNotifier:
return True, ""
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e:
logger.error('phone.error', traceback=traceback.format_exc())
logger.exception("phone.error")
return False, str(e)
def send_profile_test_notifications(
self,
*,
user_id: int,
channels: List[str],
targets: Dict[str, Any],
language: str = "en-US",
) -> Dict[str, Dict[str, Any]]:
"""
Send a short test message to each selected channel (profile / notification settings).
Used by POST /api/users/notification-settings/test.
"""
lang = (language or "en-US").strip().lower()
zh = lang.startswith("zh")
title = "QuantDinger 通知测试" if zh else "QuantDinger notification test"
plain = (
"这是一条来自 QuantDinger 个人中心「通知设置」的测试消息。若您收到本条消息,说明该渠道配置正确。"
if zh
else "This is a test message from QuantDinger profile notification settings. "
"If you received this, the channel is configured correctly."
)
html_body = f"<p>{html.escape(plain)}</p>"
telegram_html = f"<b>{html.escape(title)}</b>\n\n{html.escape(plain)}"
now = int(time.time())
iso = datetime.now(timezone.utc).isoformat()
test_payload: Dict[str, Any] = {
"event": "qd.profile_test",
"version": 1,
"timestamp": now,
"timestamp_iso": iso,
"strategy": {"id": 0, "name": "Profile Test"},
"instrument": {"symbol": "TEST"},
"signal": {"type": "profile_test", "action": "test", "side": ""},
"order": {"ref_price": 0.0, "stake_amount": 0.0},
"trace": {},
"extra": {"kind": "profile_test"},
}
results: Dict[str, Dict[str, Any]] = {}
ch_list = _as_list(channels)
if not ch_list:
ch_list = ["browser"]
for ch in ch_list:
c = (ch or "").strip().lower()
if not c:
continue
ok, err = False, ""
try:
if c == "browser":
ok, err = self._notify_browser(
strategy_id=None,
symbol="TEST",
signal_type="profile_test",
channels=ch_list,
title=title,
message=html_body,
payload=test_payload,
user_id=int(user_id),
)
elif c == "telegram":
chat_id = str((targets or {}).get("telegram") or "").strip()
token_override = str(
(targets or {}).get("telegram_bot_token")
or (targets or {}).get("telegram_token")
or ""
).strip()
ok, err = self._notify_telegram(
chat_id=chat_id,
text=telegram_html,
token_override=token_override,
parse_mode="HTML",
)
elif c == "email":
to_email = str((targets or {}).get("email") or "").strip()
ok, err = self._notify_email(
to_email=to_email,
subject=title,
body_text=plain,
body_html=html_body,
)
elif c == "phone":
to_phone = str((targets or {}).get("phone") or "").strip()
ok, err = self._notify_phone(to_phone=to_phone, body=f"{title}\n\n{plain}")
elif c == "discord":
url = str((targets or {}).get("discord") or "").strip()
ok, err = self._notify_discord(url=url, payload=test_payload, fallback_text=f"{title}\n\n{plain}")
elif c == "webhook":
url = str((targets or {}).get("webhook") or "").strip()
tok = str((targets or {}).get("webhook_token") or "").strip()
wh_payload = {
"event": "qd.profile_test",
"title": title,
"message": plain,
"timestamp": now,
"timestamp_iso": iso,
}
ok, err = self._notify_webhook(url=url, payload=wh_payload, token_override=tok or None)
else:
ok, err = False, f"unsupported_channel:{c}"
except Exception as e:
ok, err = False, str(e)
results[c] = {"ok": bool(ok), "error": (err or "")}
return results
@@ -274,6 +274,7 @@ class StrategyService:
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.bitget_spot import BitgetSpotClient
from app.services.live_trading.bybit import BybitClient
from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient
from app.services.live_trading.kraken import KrakenClient
@@ -409,6 +410,8 @@ class StrategyService:
elif isinstance(client, BitgetMixClient):
product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES")
priv_data = client.get_accounts(product_type=product_type)
elif isinstance(client, BitgetSpotClient):
priv_data = client.get_assets()
elif isinstance(client, BybitClient):
priv_data = client.get_wallet_balance()
elif isinstance(client, CoinbaseExchangeClient):