diff --git a/backend_api_python/app/__init__.py b/backend_api_python/app/__init__.py index e7ca4e3..8016bc2 100644 --- a/backend_api_python/app/__init__.py +++ b/backend_api_python/app/__init__.py @@ -198,72 +198,6 @@ def create_app(config_name='default'): except Exception as e: logger.warning(f"Database initialization note: {e}") - # ===================================================== - # Demo Mode Middleware (Read-Only Mode) - # ===================================================== - import os - from flask import request, jsonify - - # Check environment variable IS_DEMO_MODE - is_demo_mode = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' - - if is_demo_mode: - logger.info("!!! SYSTEM STARTING IN DEMO MODE (READ-ONLY) !!!") - - @app.before_request - def global_demo_mode_check(): - """ - Global interceptor for demo mode. - Blocks all state-changing methods AND access to sensitive GET endpoints. - """ - path = request.path - - # 1. Block access to sensitive settings/config APIs (even if GET) - # These endpoints reveal internal config or allow settings changes - sensitive_endpoints = [ - '/api/settings', # All settings routes - '/api/credentials', # Credentials management - '/api/market/watchlist/add', # Modifying watchlist (POST, already blocked but good to be explicit) - '/api/market/watchlist/remove' - ] - - # Check if path starts with any sensitive prefix - if any(path.startswith(endpoint) for endpoint in sensitive_endpoints): - return jsonify({ - 'code': 403, - 'msg': 'Demo mode: Access to settings and credentials is forbidden.', - 'data': None - }), 403 - - # 2. Allow safe methods (GET, HEAD, OPTIONS) - if request.method in ['GET', 'HEAD', 'OPTIONS']: - return None - - # 2. Allow Authentication (Login/Logout) - # The auth routes are mounted at /api/user (see app/routes/__init__.py) - if request.path.endswith('/login') or request.path.endswith('/logout'): - return None - - # 3. Allow specific read-only POST endpoints (Whitelist) - # Some search/query endpoints use POST for complex payloads but don't modify state. - whitelist_post_endpoints = [ - '/api/indicator/getIndicators', # Search indicators - '/api/market/klines', # Fetch K-lines (sometimes POST) - '/api/ai/chat', # AI Chat (generates response, doesn't mutate system state) - '/api/fast-analysis/analyze', # Fast AI Analysis request - ] - - # Check if current path ends with any whitelist item - if any(request.path.endswith(endpoint) for endpoint in whitelist_post_endpoints): - return None - - # 4. Block everything else - return jsonify({ - 'code': 403, - 'msg': 'Demo mode: Read-only access. Forbidden to modify data.', - 'data': None - }), 403 - from app.routes import register_routes register_routes(app) diff --git a/backend_api_python/app/routes/auth.py b/backend_api_python/app/routes/auth.py index d8bc5e9..af40e66 100644 --- a/backend_api_python/app/routes/auth.py +++ b/backend_api_python/app/routes/auth.py @@ -129,7 +129,6 @@ def login(): if not allowed: return jsonify({'code': 0, 'msg': block_msg, 'data': {'blocked': True}}), 429 - is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' user = None # Step 3: Authenticate @@ -206,9 +205,8 @@ def login(): userinfo = { 'id': user.get('id') or user.get('user_id', 1), 'username': user.get('username', username), - 'nickname': user.get('nickname', 'User') + (' (Demo)' if is_demo else ''), + 'nickname': user.get('nickname', 'User'), 'avatar': user.get('avatar', '/avatar2.jpg'), - 'is_demo': is_demo, 'role': { 'id': user.get('role', 'admin'), 'permissions': _get_permissions(user.get('role', 'admin')) @@ -409,8 +407,6 @@ def login_with_code(): # Log login security.log_security_event('login_via_code', user['id'], ip_address, user_agent) - is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' - return jsonify({ 'code': 1, 'msg': 'Login successful' + (' (new account created)' if is_new_user else ''), @@ -420,10 +416,9 @@ def login_with_code(): 'userinfo': { 'id': user['id'], 'username': user['username'], - 'nickname': user.get('nickname', user['username']) + (' (Demo)' if is_demo else ''), + 'nickname': user.get('nickname', user['username']), 'email': user.get('email'), 'avatar': user.get('avatar', '/avatar2.jpg'), - 'is_demo': is_demo, 'role': { 'id': user.get('role', 'user'), 'permissions': _get_permissions(user.get('role', 'user')) @@ -679,8 +674,6 @@ def register(): token_version=new_token_version ) - is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' - return jsonify({ 'code': 1, 'msg': 'Registration successful', @@ -692,7 +685,6 @@ def register(): 'nickname': username, 'email': email, 'avatar': '/avatar2.jpg', - 'is_demo': is_demo, 'role': { 'id': 'user', 'permissions': _get_permissions('user') @@ -1034,8 +1026,6 @@ def logout(): def get_user_info(): """Get current user info.""" try: - is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true' - user_id = getattr(g, 'user_id', 1) username = getattr(g, 'user', Config.ADMIN_USER) role = getattr(g, 'user_role', 'admin') @@ -1056,10 +1046,9 @@ def get_user_info(): 'data': { 'id': user_data.get('id'), 'username': user_data.get('username'), - 'nickname': user_data.get('nickname', 'User') + (' (Demo)' if is_demo else ''), + 'nickname': user_data.get('nickname', 'User'), 'email': user_data.get('email'), 'avatar': user_data.get('avatar', '/avatar2.jpg'), - 'is_demo': is_demo, 'role': { 'id': user_data.get('role', 'user'), 'permissions': _get_permissions(user_data.get('role', 'user')) @@ -1074,9 +1063,8 @@ def get_user_info(): 'data': { 'id': user_id, 'username': username, - 'nickname': 'Admin' + (' (Demo)' if is_demo else ''), + 'nickname': 'Admin', 'avatar': '/avatar2.jpg', - 'is_demo': is_demo, 'role': { 'id': role, 'permissions': _get_permissions(role) diff --git a/backend_api_python/app/routes/credentials.py b/backend_api_python/app/routes/credentials.py index 20f2c9f..426a520 100644 --- a/backend_api_python/app/routes/credentials.py +++ b/backend_api_python/app/routes/credentials.py @@ -1,18 +1,19 @@ """ -Exchange credentials vault (local-only). +Exchange credentials vault. -Local deployment notes: -- No encryption/decryption is used. -- Credentials are stored as plaintext JSON in DB (encrypted_config column kept for compatibility). +encrypted_config stores Fernet ciphertext derived from SECRET_KEY (see app.utils.credential_crypto). """ import traceback import json from flask import Blueprint, request, jsonify, g +import requests as rq + from app.utils.db import get_db_connection from app.utils.logger import get_logger from app.utils.auth import login_required +from app.utils.credential_crypto import encrypt_credential_blob, decrypt_credential_blob logger = get_logger(__name__) @@ -62,6 +63,42 @@ CRYPTO_EXCHANGES = [ ] +def _egress_ipify(url: str) -> str: + try: + r = rq.get(url, timeout=8) + if r.status_code != 200: + return "" + j = r.json() + if not isinstance(j, dict): + return "" + return str(j.get("ip") or "").strip() + except Exception: + return "" + + +@credentials_bp.route('/egress-ip', methods=['GET']) +@login_required +def get_egress_ip(): + """ + Public egress IPv4/IPv6 of this API server (for exchange API key IP whitelist). + Uses ipify's v4-only / v6-only endpoints so each family is detected independently. + """ + ipv4 = _egress_ipify("https://api4.ipify.org?format=json") + ipv6 = _egress_ipify("https://api6.ipify.org?format=json") + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "ipv4": ipv4 or None, + "ipv6": ipv6 or None, + # 兼容旧前端:优先 IPv4,否则 IPv6 + "ip": ipv4 or ipv6 or None, + }, + } + ) + + @credentials_bp.route('/create', methods=['POST']) @login_required def create_credential(): @@ -121,6 +158,7 @@ def create_credential(): return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400 plaintext_config = json.dumps(config, ensure_ascii=False) + stored_blob = encrypt_credential_blob(plaintext_config) with get_db_connection() as db: cur = db.cursor() @@ -130,7 +168,7 @@ def create_credential(): VALUES (%s, %s, %s, %s, %s, NOW(), NOW()) RETURNING id """, - (user_id, name, exchange_id, hint, plaintext_config) + (user_id, name, exchange_id, hint, stored_blob) ) row = cur.fetchone() new_id = (row or {}).get('id') @@ -198,13 +236,9 @@ def get_credential(): if not row: return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404 - decrypted = {} - raw = row.get('encrypted_config') or '' - if isinstance(raw, str) and raw.strip(): - try: - decrypted = json.loads(raw) - except Exception: - decrypted = {} + raw = row.get('encrypted_config') + plain = decrypt_credential_blob(raw) + decrypted = json.loads(plain) if plain else {} # Ensure exchange_id is present decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id') diff --git a/backend_api_python/app/routes/indicator.py b/backend_api_python/app/routes/indicator.py index 51d4834..1bda0fd 100644 --- a/backend_api_python/app/routes/indicator.py +++ b/backend_api_python/app/routes/indicator.py @@ -651,7 +651,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio # Get provider and model from env config (no frontend override) current_provider = llm.provider - current_model = llm.get_default_model() + current_model = llm.get_code_generation_model() current_api_key = llm.get_api_key() base_url = llm.get_base_url() @@ -682,6 +682,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], + model=current_model, temperature=temperature, use_json_mode=False # Code generation doesn't need JSON mode ) @@ -697,13 +698,15 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio return content.strip() or _template_code() + # Capture user_id before generator runs (generator executes outside request context) + user_id = g.user_id def stream(): from app.services.billing_service import get_billing_service billing = get_billing_service() ok, msg = billing.check_and_consume( - user_id=g.user_id, + user_id=user_id, feature='ai_code_gen', - reference_id=f"ai_code_gen_{g.user_id}_{int(time.time())}" + reference_id=f"ai_code_gen_{user_id}_{int(time.time())}" ) if not ok: yield "data: " + json.dumps({"error": f"积分不足: {msg}"}, ensure_ascii=False) + "\n\n" diff --git a/backend_api_python/app/routes/portfolio.py b/backend_api_python/app/routes/portfolio.py index 2f50100..70d0d71 100644 --- a/backend_api_python/app/routes/portfolio.py +++ b/backend_api_python/app/routes/portfolio.py @@ -576,6 +576,25 @@ def add_monitor(): db.commit() cur.close() + # 创建后立即在后台跑一轮:立刻发通知,并以完成时刻为基准写入 next_run_at(间隔后再次执行) + if is_active and monitor_id: + try: + from app.services.portfolio_monitor import run_single_monitor as _run_single_monitor + + def _initial_run(): + try: + _run_single_monitor(int(monitor_id), user_id=int(user_id)) + except Exception as ex: + logger.error(f"Initial portfolio monitor run failed #{monitor_id}: {ex}") + + threading.Thread( + target=_initial_run, + daemon=True, + name=f"monitor-init-{monitor_id}", + ).start() + except Exception as ex: + logger.error(f"Failed to schedule initial monitor run #{monitor_id}: {ex}") + return jsonify({'code': 1, 'msg': 'success', 'data': {'id': monitor_id}}) except Exception as e: logger.error(f"add_monitor failed: {str(e)}") diff --git a/backend_api_python/app/routes/quick_trade.py b/backend_api_python/app/routes/quick_trade.py index ae44cd2..d9691c1 100644 --- a/backend_api_python/app/routes/quick_trade.py +++ b/backend_api_python/app/routes/quick_trade.py @@ -25,6 +25,7 @@ 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 +from app.utils.credential_crypto import decrypt_credential_blob logger = get_logger(__name__) @@ -149,7 +150,12 @@ def _load_credential(credential_id: int, user_id: int) -> Dict[str, Any]: ) row = cur.fetchone() or {} cur.close() - return _safe_json(row.get("encrypted_config"), {}) + try: + plain = decrypt_credential_blob(row.get("encrypted_config")) + except ValueError as e: + logger.warning(f"decrypt credential_id={credential_id}: {e}") + return {} + return _safe_json(plain, {}) def _build_exchange_config(credential_id: int, user_id: int, overrides: Dict[str, Any] = None) -> Dict[str, Any]: diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 1b39e80..0b8460d 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -134,6 +134,14 @@ CONFIG_SCHEMA = { ], 'description': 'Select your preferred LLM provider' }, + { + 'key': 'AI_CODE_GEN_MODEL', + 'label': 'Code Generation Model', + 'type': 'text', + 'default': '', + 'required': False, + 'description': 'Optional model override for AI code generation. If empty, uses provider default model' + }, # OpenRouter { 'key': 'OPENROUTER_API_KEY', diff --git a/backend_api_python/app/routes/user.py b/backend_api_python/app/routes/user.py index db47e59..2575b9a 100644 --- a/backend_api_python/app/routes/user.py +++ b/backend_api_python/app/routes/user.py @@ -650,6 +650,7 @@ def update_notification_settings(): 'email': str(data.get('email') or '').strip(), 'discord_webhook': str(data.get('discord_webhook') or '').strip(), 'webhook_url': str(data.get('webhook_url') or '').strip(), + 'webhook_token': str(data.get('webhook_token') or '').strip(), 'phone': str(data.get('phone') or '').strip(), } @@ -677,6 +678,85 @@ def update_notification_settings(): return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 +@user_bp.route('/notification-settings/test', methods=['POST']) +@login_required +def test_notification_settings(): + """ + Send a test notification using the current user's saved notification_settings + (save settings first via PUT /notification-settings). + """ + try: + import json + from app.services.signal_notifier import SignalNotifier + from app.utils.db import get_db_connection + + user_id = getattr(g, 'user_id', None) + if not user_id: + return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT notification_settings, email FROM qd_users WHERE id = ?", (user_id,)) + row = cur.fetchone() + cur.close() + + if not row: + return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 + + settings_str = row.get('notification_settings') or '' + account_email = (row.get('email') or '').strip() + settings = {} + if settings_str: + try: + settings = json.loads(settings_str) + except Exception: + settings = {} + + channels = settings.get('default_channels') or ['browser'] + if not isinstance(channels, list) or not channels: + channels = ['browser'] + + notify_email = (settings.get('email') or '').strip() or account_email + targets = { + 'telegram': (settings.get('telegram_chat_id') or '').strip(), + 'telegram_bot_token': (settings.get('telegram_bot_token') or '').strip(), + 'email': notify_email, + 'phone': (settings.get('phone') or '').strip(), + 'discord': (settings.get('discord_webhook') or '').strip(), + 'webhook': (settings.get('webhook_url') or '').strip(), + 'webhook_token': (settings.get('webhook_token') or '').strip(), + } + + accept = (request.headers.get('Accept-Language') or '') + ' ' + (request.headers.get('X-Locale') or '') + language = 'zh-CN' if 'zh' in accept.lower() else 'en-US' + + notifier = SignalNotifier() + results = notifier.send_profile_test_notifications( + user_id=int(user_id), + channels=channels, + targets=targets, + language=language, + ) + + any_ok = any((v or {}).get('ok') for v in results.values()) + failed = [k for k, v in results.items() if not (v or {}).get('ok')] + if failed: + err_detail = {k: (results.get(k) or {}).get('error', '') for k in failed} + logger.warning("notification_settings test: user_id=%s failed_channels=%s errors=%s", user_id, failed, err_detail) + + if not any_ok: + detail = '; '.join(f"{k}: {(results[k] or {}).get('error', '')}" for k in failed) or 'all channels failed' + return jsonify({'code': 0, 'msg': detail, 'data': {'results': results}}) + + msg = 'Test notification sent' + if failed: + msg = f"Sent OK; failed: {', '.join(failed)}" + return jsonify({'code': 1, 'msg': msg, 'data': {'results': results}}) + except Exception as e: + logger.error(f"test_notification_settings failed: {e}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + @user_bp.route('/change-password', methods=['POST']) @login_required def change_password(): diff --git a/backend_api_python/app/services/ai_calibration.py b/backend_api_python/app/services/ai_calibration.py index d7492d6..084b437 100644 --- a/backend_api_python/app/services/ai_calibration.py +++ b/backend_api_python/app/services/ai_calibration.py @@ -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).") diff --git a/backend_api_python/app/services/billing_service.py b/backend_api_python/app/services/billing_service.py index 0d828c1..56e2b16 100644 --- a/backend_api_python/app/services/billing_service.py +++ b/backend_api_python/app/services/billing_service.py @@ -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, diff --git a/backend_api_python/app/services/exchange_execution.py b/backend_api_python/app/services/exchange_execution.py index ed3d87b..8bcb05b 100644 --- a/backend_api_python/app/services/exchange_execution.py +++ b/backend_api_python/app/services/exchange_execution.py @@ -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]: diff --git a/backend_api_python/app/services/live_trading/factory.py b/backend_api_python/app/services/live_trading/factory.py index a8849d2..24a4f5d 100644 --- a/backend_api_python/app/services/live_trading/factory.py +++ b/backend_api_python/app/services/live_trading/factory.py @@ -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) diff --git a/backend_api_python/app/services/live_trading/okx.py b/backend_api_python/app/services/live_trading/okx.py index b56cbd3..ae5e855 100644 --- a/backend_api_python/app/services/live_trading/okx.py +++ b/backend_api_python/app/services/live_trading/okx.py @@ -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, diff --git a/backend_api_python/app/services/llm.py b/backend_api_python/app/services/llm.py index a7d2db5..0d06a77 100644 --- a/backend_api_python/app/services/llm.py +++ b/backend_api_python/app/services/llm.py @@ -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): diff --git a/backend_api_python/app/services/signal_notifier.py b/backend_api_python/app/services/signal_notifier.py index 8f41753..79dac8a 100644 --- a/backend_api_python/app/services/signal_notifier.py +++ b/backend_api_python/app/services/signal_notifier.py @@ -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"
{html.escape(plain)}
" + telegram_html = f"{html.escape(title)}\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 + diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py index 102becc..71440be 100644 --- a/backend_api_python/app/services/strategy.py +++ b/backend_api_python/app/services/strategy.py @@ -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): diff --git a/backend_api_python/app/utils/credential_crypto.py b/backend_api_python/app/utils/credential_crypto.py new file mode 100644 index 0000000..07e042a --- /dev/null +++ b/backend_api_python/app/utils/credential_crypto.py @@ -0,0 +1,49 @@ +""" +Fernet encryption for qd_exchange_credentials.encrypted_config. + +Uses SECRET_KEY from the environment (SHA-256 digest → urlsafe base64 → Fernet key). +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + + +def _fernet_from_secret() -> Fernet: + secret = (os.getenv("SECRET_KEY") or "").strip() + if not secret: + raise ValueError("SECRET_KEY is not set; cannot encrypt or decrypt exchange credentials") + key = base64.urlsafe_b64encode(hashlib.sha256(secret.encode("utf-8")).digest()) + return Fernet(key) + + +def encrypt_credential_blob(plaintext_json: str) -> str: + """Encrypt JSON text for storage in encrypted_config.""" + if plaintext_json is None: + plaintext_json = "" + f = _fernet_from_secret() + return f.encrypt(plaintext_json.encode("utf-8")).decode("ascii") + + +def decrypt_credential_blob(stored: Any) -> str: + """ + Decrypt DB value to JSON text. Empty / None yields empty string. + """ + if stored is None: + return "" + s = stored.decode("utf-8") if isinstance(stored, (bytes, bytearray)) else str(stored) + s = s.strip() + if not s: + return "" + f = _fernet_from_secret() + try: + return f.decrypt(s.encode("ascii")).decode("utf-8") + except InvalidToken as e: + raise ValueError( + "Cannot decrypt exchange credential (wrong SECRET_KEY or data not encrypted with this key)" + ) from e diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 894bf04..b9004e0 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -9,6 +9,7 @@ # ========================= # Auth (required) # ========================= +# Also derives Fernet key for encrypting qd_exchange_credentials.encrypted_config (do not rotate casually). SECRET_KEY=quantdinger-secret-key-change-me ADMIN_USER=quantdinger ADMIN_PASSWORD=123456 @@ -17,7 +18,6 @@ ADMIN_EMAIL= # ========================= # Core app # ========================= -IS_DEMO_MODE=false DATABASE_URL=postgresql://quantdinger:quantdinger123@postgres:5432/quantdinger FRONTEND_URL=http://localhost:8888 ENABLE_REGISTRATION=true @@ -29,6 +29,8 @@ LLM_PROVIDER=openrouter OPENROUTER_API_KEY= OPENROUTER_MODEL=openai/gpt-4o +# Optional: dedicated model for AI code generation (fallback to provider default if empty) +AI_CODE_GEN_MODEL= OPENAI_API_KEY= OPENAI_MODEL=gpt-4o diff --git a/backend_api_python/requirements.txt b/backend_api_python/requirements.txt index e304065..59cbad8 100644 --- a/backend_api_python/requirements.txt +++ b/backend_api_python/requirements.txt @@ -11,6 +11,7 @@ pymysql>=1.0.2 SQLAlchemy>=2.0.0 PyJWT==2.8.0 python-dotenv>=1.0.1 +cryptography>=41.0.0 # HD wallet derivation (xpub -> address) for USDT payments bip-utils>=2.9.0 # PostgreSQL support (multi-user mode) diff --git a/backend_api_python/run.py b/backend_api_python/run.py index 067da0c..22b3c60 100644 --- a/backend_api_python/run.py +++ b/backend_api_python/run.py @@ -91,14 +91,6 @@ def main(): print(msg) raise RuntimeError("Insecure SECRET_KEY configuration: using default example value in non-debug mode") - # Check demo mode status for debugging - demo_status = os.getenv('IS_DEMO_MODE', 'false').lower() - print(f"Status Check: IS_DEMO_MODE={demo_status}") - if demo_status == 'true': - print("!!! RUNNING IN DEMO MODE (READ-ONLY) !!!") - else: - print("Running in FULL ACCESS mode") - print(f"Service starting at: http://{Config.HOST}:{Config.PORT}") # Flask dev server is for local development only.