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
-66
View File
@@ -198,72 +198,6 @@ def create_app(config_name='default'):
except Exception as e: except Exception as e:
logger.warning(f"Database initialization note: {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 from app.routes import register_routes
register_routes(app) register_routes(app)
+4 -16
View File
@@ -129,7 +129,6 @@ def login():
if not allowed: if not allowed:
return jsonify({'code': 0, 'msg': block_msg, 'data': {'blocked': True}}), 429 return jsonify({'code': 0, 'msg': block_msg, 'data': {'blocked': True}}), 429
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
user = None user = None
# Step 3: Authenticate # Step 3: Authenticate
@@ -206,9 +205,8 @@ def login():
userinfo = { userinfo = {
'id': user.get('id') or user.get('user_id', 1), 'id': user.get('id') or user.get('user_id', 1),
'username': user.get('username', username), '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'), 'avatar': user.get('avatar', '/avatar2.jpg'),
'is_demo': is_demo,
'role': { 'role': {
'id': user.get('role', 'admin'), 'id': user.get('role', 'admin'),
'permissions': _get_permissions(user.get('role', 'admin')) 'permissions': _get_permissions(user.get('role', 'admin'))
@@ -409,8 +407,6 @@ def login_with_code():
# Log login # Log login
security.log_security_event('login_via_code', user['id'], ip_address, user_agent) 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({ return jsonify({
'code': 1, 'code': 1,
'msg': 'Login successful' + (' (new account created)' if is_new_user else ''), 'msg': 'Login successful' + (' (new account created)' if is_new_user else ''),
@@ -420,10 +416,9 @@ def login_with_code():
'userinfo': { 'userinfo': {
'id': user['id'], 'id': user['id'],
'username': user['username'], 'username': user['username'],
'nickname': user.get('nickname', user['username']) + (' (Demo)' if is_demo else ''), 'nickname': user.get('nickname', user['username']),
'email': user.get('email'), 'email': user.get('email'),
'avatar': user.get('avatar', '/avatar2.jpg'), 'avatar': user.get('avatar', '/avatar2.jpg'),
'is_demo': is_demo,
'role': { 'role': {
'id': user.get('role', 'user'), 'id': user.get('role', 'user'),
'permissions': _get_permissions(user.get('role', 'user')) 'permissions': _get_permissions(user.get('role', 'user'))
@@ -679,8 +674,6 @@ def register():
token_version=new_token_version token_version=new_token_version
) )
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
return jsonify({ return jsonify({
'code': 1, 'code': 1,
'msg': 'Registration successful', 'msg': 'Registration successful',
@@ -692,7 +685,6 @@ def register():
'nickname': username, 'nickname': username,
'email': email, 'email': email,
'avatar': '/avatar2.jpg', 'avatar': '/avatar2.jpg',
'is_demo': is_demo,
'role': { 'role': {
'id': 'user', 'id': 'user',
'permissions': _get_permissions('user') 'permissions': _get_permissions('user')
@@ -1034,8 +1026,6 @@ def logout():
def get_user_info(): def get_user_info():
"""Get current user info.""" """Get current user info."""
try: try:
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
user_id = getattr(g, 'user_id', 1) user_id = getattr(g, 'user_id', 1)
username = getattr(g, 'user', Config.ADMIN_USER) username = getattr(g, 'user', Config.ADMIN_USER)
role = getattr(g, 'user_role', 'admin') role = getattr(g, 'user_role', 'admin')
@@ -1056,10 +1046,9 @@ def get_user_info():
'data': { 'data': {
'id': user_data.get('id'), 'id': user_data.get('id'),
'username': user_data.get('username'), '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'), 'email': user_data.get('email'),
'avatar': user_data.get('avatar', '/avatar2.jpg'), 'avatar': user_data.get('avatar', '/avatar2.jpg'),
'is_demo': is_demo,
'role': { 'role': {
'id': user_data.get('role', 'user'), 'id': user_data.get('role', 'user'),
'permissions': _get_permissions(user_data.get('role', 'user')) 'permissions': _get_permissions(user_data.get('role', 'user'))
@@ -1074,9 +1063,8 @@ def get_user_info():
'data': { 'data': {
'id': user_id, 'id': user_id,
'username': username, 'username': username,
'nickname': 'Admin' + (' (Demo)' if is_demo else ''), 'nickname': 'Admin',
'avatar': '/avatar2.jpg', 'avatar': '/avatar2.jpg',
'is_demo': is_demo,
'role': { 'role': {
'id': role, 'id': role,
'permissions': _get_permissions(role) 'permissions': _get_permissions(role)
+46 -12
View File
@@ -1,18 +1,19 @@
""" """
Exchange credentials vault (local-only). Exchange credentials vault.
Local deployment notes: encrypted_config stores Fernet ciphertext derived from SECRET_KEY (see app.utils.credential_crypto).
- No encryption/decryption is used.
- Credentials are stored as plaintext JSON in DB (encrypted_config column kept for compatibility).
""" """
import traceback import traceback
import json import json
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
import requests as rq
from app.utils.db import get_db_connection from app.utils.db import get_db_connection
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.auth import login_required from app.utils.auth import login_required
from app.utils.credential_crypto import encrypt_credential_blob, decrypt_credential_blob
logger = get_logger(__name__) 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']) @credentials_bp.route('/create', methods=['POST'])
@login_required @login_required
def create_credential(): def create_credential():
@@ -121,6 +158,7 @@ def create_credential():
return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400 return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400
plaintext_config = json.dumps(config, ensure_ascii=False) plaintext_config = json.dumps(config, ensure_ascii=False)
stored_blob = encrypt_credential_blob(plaintext_config)
with get_db_connection() as db: with get_db_connection() as db:
cur = db.cursor() cur = db.cursor()
@@ -130,7 +168,7 @@ def create_credential():
VALUES (%s, %s, %s, %s, %s, NOW(), NOW()) VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
RETURNING id RETURNING id
""", """,
(user_id, name, exchange_id, hint, plaintext_config) (user_id, name, exchange_id, hint, stored_blob)
) )
row = cur.fetchone() row = cur.fetchone()
new_id = (row or {}).get('id') new_id = (row or {}).get('id')
@@ -198,13 +236,9 @@ def get_credential():
if not row: if not row:
return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404 return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404
decrypted = {} raw = row.get('encrypted_config')
raw = row.get('encrypted_config') or '' plain = decrypt_credential_blob(raw)
if isinstance(raw, str) and raw.strip(): decrypted = json.loads(plain) if plain else {}
try:
decrypted = json.loads(raw)
except Exception:
decrypted = {}
# Ensure exchange_id is present # Ensure exchange_id is present
decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id') decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id')
+6 -3
View File
@@ -651,7 +651,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
# Get provider and model from env config (no frontend override) # Get provider and model from env config (no frontend override)
current_provider = llm.provider current_provider = llm.provider
current_model = llm.get_default_model() current_model = llm.get_code_generation_model()
current_api_key = llm.get_api_key() current_api_key = llm.get_api_key()
base_url = llm.get_base_url() 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": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
], ],
model=current_model,
temperature=temperature, temperature=temperature,
use_json_mode=False # Code generation doesn't need JSON mode 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() return content.strip() or _template_code()
# Capture user_id before generator runs (generator executes outside request context)
user_id = g.user_id
def stream(): def stream():
from app.services.billing_service import get_billing_service from app.services.billing_service import get_billing_service
billing = get_billing_service() billing = get_billing_service()
ok, msg = billing.check_and_consume( ok, msg = billing.check_and_consume(
user_id=g.user_id, user_id=user_id,
feature='ai_code_gen', 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: if not ok:
yield "data: " + json.dumps({"error": f"积分不足: {msg}"}, ensure_ascii=False) + "\n\n" yield "data: " + json.dumps({"error": f"积分不足: {msg}"}, ensure_ascii=False) + "\n\n"
@@ -576,6 +576,25 @@ def add_monitor():
db.commit() db.commit()
cur.close() 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}}) return jsonify({'code': 1, 'msg': 'success', 'data': {'id': monitor_id}})
except Exception as e: except Exception as e:
logger.error(f"add_monitor failed: {str(e)}") logger.error(f"add_monitor failed: {str(e)}")
+7 -1
View File
@@ -25,6 +25,7 @@ from flask import Blueprint, g, jsonify, request
from app.utils.db import get_db_connection from app.utils.db import get_db_connection
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.auth import login_required from app.utils.auth import login_required
from app.utils.credential_crypto import decrypt_credential_blob
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -149,7 +150,12 @@ def _load_credential(credential_id: int, user_id: int) -> Dict[str, Any]:
) )
row = cur.fetchone() or {} row = cur.fetchone() or {}
cur.close() 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]: 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' '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 # OpenRouter
{ {
'key': 'OPENROUTER_API_KEY', 'key': 'OPENROUTER_API_KEY',
+80
View File
@@ -650,6 +650,7 @@ def update_notification_settings():
'email': str(data.get('email') or '').strip(), 'email': str(data.get('email') or '').strip(),
'discord_webhook': str(data.get('discord_webhook') or '').strip(), 'discord_webhook': str(data.get('discord_webhook') or '').strip(),
'webhook_url': str(data.get('webhook_url') 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(), '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 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']) @user_bp.route('/change-password', methods=['POST'])
@login_required @login_required
def change_password(): def change_password():
@@ -314,11 +314,6 @@ def start_ai_calibration_worker() -> None:
""" """
Run offline calibration once on service startup (best-effort). 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" enabled = os.getenv("ENABLE_OFFLINE_AI_CALIBRATION", "true").lower() == "true"
if not enabled: if not enabled:
logger.info("AI calibration worker disabled (ENABLE_OFFLINE_AI_CALIBRATION=false).") logger.info("AI calibration worker disabled (ENABLE_OFFLINE_AI_CALIBRATION=false).")
@@ -491,15 +491,16 @@ class BillingService:
(float(new_balance), user_id) (float(new_balance), user_id)
) )
# 记录日志 # 记录日志 - 使用 UTC 时间确保跨时区显示正确
feature_name = FEATURE_NAMES.get(feature, feature) feature_name = FEATURE_NAMES.get(feature, feature)
created_at_utc = datetime.now(timezone.utc)
cur.execute( cur.execute(
""" """
INSERT INTO qd_credits_log INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at) (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() db.commit()
@@ -686,9 +687,23 @@ class BillingService:
""", """,
(user_id, page_size, offset) (user_id, page_size, offset)
) )
logs = cur.fetchall() or [] rows = cur.fetchall() or []
cur.close() 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 { return {
'items': logs, 'items': logs,
'total': total, 'total': total,
@@ -15,6 +15,7 @@ from typing import Any, Dict
from app.utils.db import get_db_connection from app.utils.db import get_db_connection
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.credential_crypto import decrypt_credential_blob
logger = get_logger(__name__) 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]: 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: with get_db_connection() as db:
cur = db.cursor() cur = db.cursor()
cur.execute( cur.execute(
@@ -105,7 +106,13 @@ def _load_credential_config(credential_id: int, user_id: int = 1) -> Dict[str, A
) )
row = cur.fetchone() or {} row = cur.fetchone() or {}
cur.close() 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]: 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 "" 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: def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") -> BaseRestClient:
if not isinstance(exchange_config, dict): if not isinstance(exchange_config, dict):
raise LiveTradingError("Invalid exchange_config") raise LiveTradingError("Invalid exchange_config")
@@ -58,11 +65,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
if mt in ("futures", "future", "perp", "perpetual"): if mt in ("futures", "future", "perp", "perpetual"):
mt = "swap" mt = "swap"
if exchange_id == "binance": is_demo = _demo_enabled(exchange_config)
# 检查是否启用模拟交易,支持布尔值和字符串
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 exchange_id == "binance":
if mt == "spot": if mt == "spot":
default_url = "https://demo-api.binance.com" if is_demo else "https://api.binance.com" 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 base_url = _get(exchange_config, "base_url", "baseUrl") or default_url
@@ -79,9 +84,11 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
secret_key=secret_key, secret_key=secret_key,
passphrase=passphrase, passphrase=passphrase,
base_url=base_url, base_url=base_url,
broker_code=broker_code broker_code=broker_code,
simulated_trading=is_demo,
) )
if exchange_id == "bitget": 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" base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
if mt == "spot": if mt == "spot":
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x" 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) 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": 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" category = "spot" if mt == "spot" else "linear"
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000) recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
return BybitClient( 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"): 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": if mt != "spot":
raise LiveTradingError("CoinbaseExchange only supports spot market_type in this project") 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) 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": if exchange_id == "kraken":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.kraken.com" base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.kraken.com"
if mt == "spot": 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) return KrakenClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
# Futures/perp 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 "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) return KrakenFuturesClient(api_key=api_key, secret_key=secret_key, base_url=fut_url)
if exchange_id == "kucoin": 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": if mt == "spot":
return KucoinSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) 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) return KucoinFuturesClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=fut_url)
if exchange_id == "gate": if exchange_id == "gate":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.gateio.ws"
if mt == "spot": 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) 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) return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
if exchange_id == "bitfinex": 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" base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitfinex.com"
if mt == "spot": if mt == "spot":
return BitfinexClient(api_key=api_key, secret_key=secret_key, base_url=base_url) 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", base_url: str = "https://www.okx.com",
timeout_sec: float = 15.0, timeout_sec: float = 15.0,
broker_code: Optional[str] = None, broker_code: Optional[str] = None,
simulated_trading: bool = False,
): ):
super().__init__(base_url=base_url, timeout_sec=timeout_sec) super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip() self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip() self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip() self.passphrase = (passphrase or "").strip()
self.simulated_trading = bool(simulated_trading)
effective_broker = broker_code or self._DEFAULT_BROKER_CODE effective_broker = broker_code or self._DEFAULT_BROKER_CODE
self.broker_code = str(effective_broker).strip() if effective_broker else None 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: 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") return base64.b64encode(mac).decode("utf-8")
def _headers(self, ts: str, sign: str) -> Dict[str, str]: def _headers(self, ts: str, sign: str) -> Dict[str, str]:
return { h: Dict[str, str] = {
"OK-ACCESS-KEY": self.api_key, "OK-ACCESS-KEY": self.api_key,
"OK-ACCESS-SIGN": sign, "OK-ACCESS-SIGN": sign,
"OK-ACCESS-TIMESTAMP": ts, "OK-ACCESS-TIMESTAMP": ts,
"OK-ACCESS-PASSPHRASE": self.passphrase, "OK-ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json", "Content-Type": "application/json",
} }
if self.simulated_trading:
h["x-simulated-trading"] = "1"
return h
def _signed_request( def _signed_request(
self, self,
+7
View File
@@ -147,6 +147,13 @@ class LLMService:
return PROVIDER_CONFIGS[p]["default_model"] 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 # Legacy properties for backward compatibility
@property @property
def api_key(self): def api_key(self):
@@ -24,7 +24,6 @@ import json
import os import os
import smtplib import smtplib
import time import time
import traceback
from datetime import datetime, timezone from datetime import datetime, timezone
from email.message import EmailMessage from email.message import EmailMessage
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -437,7 +436,7 @@ class SignalNotifier:
def _notify_browser( def _notify_browser(
self, self,
*, *,
strategy_id: int, strategy_id: Optional[int] = None,
symbol: str, symbol: str,
signal_type: str, signal_type: str,
channels: List[str], channels: List[str],
@@ -450,15 +449,19 @@ class SignalNotifier:
now = int(time.time()) now = int(time.time())
# Get user_id from strategy if not provided # Get user_id from strategy if not provided
if user_id is None: if user_id is None:
try: if strategy_id is not None:
with get_db_connection() as db: try:
cur = db.cursor() with get_db_connection() as db:
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) cur = db.cursor()
row = cur.fetchone() cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (int(strategy_id),))
cur.close() row = cur.fetchone()
user_id = int((row or {}).get('user_id') or 1) cur.close()
except Exception: user_id = int((row or {}).get('user_id') or 1)
except Exception:
user_id = 1
else:
user_id = 1 user_id = 1
sid = None if strategy_id is None else int(strategy_id)
with get_db_connection() as db: with get_db_connection() as db:
cur = db.cursor() cur = db.cursor()
cur.execute( cur.execute(
@@ -469,7 +472,7 @@ class SignalNotifier:
""", """,
( (
int(user_id), int(user_id),
int(strategy_id), sid,
str(symbol or ""), str(symbol or ""),
str(signal_type or ""), str(signal_type or ""),
",".join([str(c) for c in (channels or [])]), ",".join([str(c) for c in (channels or [])]),
@@ -483,7 +486,7 @@ class SignalNotifier:
return True, "" return True, ""
except Exception as e: except Exception as e:
logger.warning(f"browser notify persist failed: {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) return False, str(e)
def _notify_webhook( def _notify_webhook(
@@ -573,7 +576,7 @@ class SignalNotifier:
return False, f"http_{resp2.status_code}:{(resp2.text or '')[:300]}" return False, f"http_{resp2.status_code}:{(resp2.text or '')[:300]}"
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e: except Exception as e:
logger.error('webhook.error', traceback=traceback.format_exc()) logger.exception("webhook.error")
return False, str(e) return False, str(e)
def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]: def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]:
@@ -649,7 +652,7 @@ class SignalNotifier:
pass pass
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e: except Exception as e:
logger.error('discord.error', traceback=traceback.format_exc()) logger.exception("discord.error")
return False, str(e) return False, str(e)
def _notify_telegram( def _notify_telegram(
@@ -684,15 +687,21 @@ class SignalNotifier:
return True, "" return True, ""
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e: except Exception as e:
logger.error('telegram.error', traceback=traceback.format_exc()) logger.exception("telegram.error")
return False, str(e) return False, str(e)
def _notify_email(self, *, to_email: str, subject: str, body_text: str, body_html: str = "") -> Tuple[bool, str]: def _notify_email(self, *, to_email: str, subject: str, body_text: str, body_html: str = "") -> Tuple[bool, str]:
if not to_email: if not to_email:
logger.warning("email.skip: missing recipient (to_email empty)")
return False, "missing_email_target" return False, "missing_email_target"
if not self.smtp_host: 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" return False, "missing_SMTP_HOST"
if not self.smtp_from: 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" return False, "missing_SMTP_FROM"
msg = EmailMessage() msg = EmailMessage()
@@ -723,7 +732,7 @@ class SignalNotifier:
server.send_message(msg) server.send_message(msg)
return True, "" return True, ""
except Exception as e: except Exception as e:
logger.error('email.error', traceback=traceback.format_exc()) logger.exception("email.error")
return False, str(e) return False, str(e)
def _notify_phone(self, *, to_phone: str, body: str) -> Tuple[bool, str]: def _notify_phone(self, *, to_phone: str, body: str) -> Tuple[bool, str]:
@@ -740,7 +749,114 @@ class SignalNotifier:
return True, "" return True, ""
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
except Exception as e: except Exception as e:
logger.error('phone.error', traceback=traceback.format_exc()) logger.exception("phone.error")
return False, str(e) 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.binance_spot import BinanceSpotClient
from app.services.live_trading.okx import OkxClient from app.services.live_trading.okx import OkxClient
from app.services.live_trading.bitget import BitgetMixClient 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.bybit import BybitClient
from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient
from app.services.live_trading.kraken import KrakenClient from app.services.live_trading.kraken import KrakenClient
@@ -409,6 +410,8 @@ class StrategyService:
elif isinstance(client, BitgetMixClient): elif isinstance(client, BitgetMixClient):
product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES") product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES")
priv_data = client.get_accounts(product_type=product_type) priv_data = client.get_accounts(product_type=product_type)
elif isinstance(client, BitgetSpotClient):
priv_data = client.get_assets()
elif isinstance(client, BybitClient): elif isinstance(client, BybitClient):
priv_data = client.get_wallet_balance() priv_data = client.get_wallet_balance()
elif isinstance(client, CoinbaseExchangeClient): 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
+3 -1
View File
@@ -9,6 +9,7 @@
# ========================= # =========================
# Auth (required) # Auth (required)
# ========================= # =========================
# Also derives Fernet key for encrypting qd_exchange_credentials.encrypted_config (do not rotate casually).
SECRET_KEY=quantdinger-secret-key-change-me SECRET_KEY=quantdinger-secret-key-change-me
ADMIN_USER=quantdinger ADMIN_USER=quantdinger
ADMIN_PASSWORD=123456 ADMIN_PASSWORD=123456
@@ -17,7 +18,6 @@ ADMIN_EMAIL=
# ========================= # =========================
# Core app # Core app
# ========================= # =========================
IS_DEMO_MODE=false
DATABASE_URL=postgresql://quantdinger:quantdinger123@postgres:5432/quantdinger DATABASE_URL=postgresql://quantdinger:quantdinger123@postgres:5432/quantdinger
FRONTEND_URL=http://localhost:8888 FRONTEND_URL=http://localhost:8888
ENABLE_REGISTRATION=true ENABLE_REGISTRATION=true
@@ -29,6 +29,8 @@ LLM_PROVIDER=openrouter
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL=openai/gpt-4o 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_API_KEY=
OPENAI_MODEL=gpt-4o OPENAI_MODEL=gpt-4o
+1
View File
@@ -11,6 +11,7 @@ pymysql>=1.0.2
SQLAlchemy>=2.0.0 SQLAlchemy>=2.0.0
PyJWT==2.8.0 PyJWT==2.8.0
python-dotenv>=1.0.1 python-dotenv>=1.0.1
cryptography>=41.0.0
# HD wallet derivation (xpub -> address) for USDT payments # HD wallet derivation (xpub -> address) for USDT payments
bip-utils>=2.9.0 bip-utils>=2.9.0
# PostgreSQL support (multi-user mode) # PostgreSQL support (multi-user mode)
-8
View File
@@ -91,14 +91,6 @@ def main():
print(msg) print(msg)
raise RuntimeError("Insecure SECRET_KEY configuration: using default example value in non-debug mode") 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}") print(f"Service starting at: http://{Config.HOST}:{Config.PORT}")
# Flask dev server is for local development only. # Flask dev server is for local development only.