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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user