50939212be
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
385 lines
18 KiB
Python
385 lines
18 KiB
Python
"""
|
|
Settings API - 读取和保存 .env 配置
|
|
"""
|
|
import os
|
|
import re
|
|
from flask import Blueprint, request, jsonify
|
|
from app.utils.logger import get_logger
|
|
from app.utils.config_loader import clear_config_cache
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
settings_bp = Blueprint('settings', __name__)
|
|
|
|
# .env 文件路径
|
|
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
|
|
|
|
# 配置项定义(分组)- 完整对照 env.example
|
|
CONFIG_SCHEMA = {
|
|
'auth': {
|
|
'title': '认证配置',
|
|
'items': [
|
|
{'key': 'SECRET_KEY', 'label': 'Secret Key', 'type': 'password', 'default': 'quantdinger-secret-key-change-me'},
|
|
{'key': 'ADMIN_USER', 'label': '管理员用户名', 'type': 'text', 'default': 'quantdinger'},
|
|
{'key': 'ADMIN_PASSWORD', 'label': '管理员密码', 'type': 'password', 'default': '123456'},
|
|
]
|
|
},
|
|
'server': {
|
|
'title': '服务器配置',
|
|
'items': [
|
|
{'key': 'PYTHON_API_HOST', 'label': '监听地址', 'type': 'text', 'default': '0.0.0.0'},
|
|
{'key': 'PYTHON_API_PORT', 'label': '端口', 'type': 'number', 'default': '5000'},
|
|
{'key': 'PYTHON_API_DEBUG', 'label': '调试模式', 'type': 'boolean', 'default': 'False'},
|
|
]
|
|
},
|
|
'worker': {
|
|
'title': '订单处理配置',
|
|
'items': [
|
|
{'key': 'ENABLE_PENDING_ORDER_WORKER', 'label': '启用订单处理Worker', 'type': 'boolean', 'default': 'True'},
|
|
{'key': 'PENDING_ORDER_STALE_SEC', 'label': '订单超时时间(秒)', 'type': 'number', 'default': '90'},
|
|
]
|
|
},
|
|
'notification': {
|
|
'title': '信号通知配置',
|
|
'items': [
|
|
{'key': 'SIGNAL_WEBHOOK_URL', 'label': 'Webhook URL', 'type': 'text', 'required': False},
|
|
{'key': 'SIGNAL_WEBHOOK_TOKEN', 'label': 'Webhook Token', 'type': 'password', 'required': False},
|
|
{'key': 'SIGNAL_NOTIFY_TIMEOUT_SEC', 'label': '通知超时(秒)', 'type': 'number', 'default': '6'},
|
|
{'key': 'TELEGRAM_BOT_TOKEN', 'label': 'Telegram Bot Token', 'type': 'password', 'required': False, 'link': 'https://t.me/BotFather', 'link_text': 'settings.link.createBot'},
|
|
]
|
|
},
|
|
'smtp': {
|
|
'title': '邮件SMTP配置',
|
|
'items': [
|
|
{'key': 'SMTP_HOST', 'label': 'SMTP服务器', 'type': 'text', 'required': False},
|
|
{'key': 'SMTP_PORT', 'label': 'SMTP端口', 'type': 'number', 'default': '587'},
|
|
{'key': 'SMTP_USER', 'label': 'SMTP用户名', 'type': 'text', 'required': False},
|
|
{'key': 'SMTP_PASSWORD', 'label': 'SMTP密码', 'type': 'password', 'required': False},
|
|
{'key': 'SMTP_FROM', 'label': '发件人地址', 'type': 'text', 'required': False},
|
|
{'key': 'SMTP_USE_TLS', 'label': '使用TLS', 'type': 'boolean', 'default': 'True'},
|
|
{'key': 'SMTP_USE_SSL', 'label': '使用SSL', 'type': 'boolean', 'default': 'False'},
|
|
]
|
|
},
|
|
'twilio': {
|
|
'title': 'Twilio短信配置',
|
|
'items': [
|
|
{'key': 'TWILIO_ACCOUNT_SID', 'label': 'Account SID', 'type': 'password', 'required': False, 'link': 'https://console.twilio.com/', 'link_text': 'settings.link.getApi'},
|
|
{'key': 'TWILIO_AUTH_TOKEN', 'label': 'Auth Token', 'type': 'password', 'required': False},
|
|
{'key': 'TWILIO_FROM_NUMBER', 'label': '发送号码', 'type': 'text', 'required': False},
|
|
]
|
|
},
|
|
'strategy': {
|
|
'title': '策略执行配置',
|
|
'items': [
|
|
{'key': 'DISABLE_RESTORE_RUNNING_STRATEGIES', 'label': '禁用自动恢复策略', 'type': 'boolean', 'default': 'False'},
|
|
{'key': 'STRATEGY_TICK_INTERVAL_SEC', 'label': '策略Tick间隔(秒)', 'type': 'number', 'default': '10'},
|
|
{'key': 'PRICE_CACHE_TTL_SEC', 'label': '价格缓存TTL(秒)', 'type': 'number', 'default': '10'},
|
|
]
|
|
},
|
|
'proxy': {
|
|
'title': '代理配置',
|
|
'items': [
|
|
{'key': 'PROXY_PORT', 'label': '代理端口', 'type': 'text', 'required': False},
|
|
{'key': 'PROXY_HOST', 'label': '代理主机', 'type': 'text', 'default': '127.0.0.1'},
|
|
{'key': 'PROXY_SCHEME', 'label': '代理协议', 'type': 'select', 'options': ['socks5h', 'socks5', 'http', 'https'], 'default': 'socks5h'},
|
|
{'key': 'PROXY_URL', 'label': '完整代理URL', 'type': 'text', 'required': False},
|
|
]
|
|
},
|
|
'app': {
|
|
'title': '应用配置',
|
|
'items': [
|
|
{'key': 'CORS_ORIGINS', 'label': 'CORS来源', 'type': 'text', 'default': '*'},
|
|
{'key': 'RATE_LIMIT', 'label': '速率限制(每分钟)', 'type': 'number', 'default': '100'},
|
|
{'key': 'ENABLE_CACHE', 'label': '启用缓存', 'type': 'boolean', 'default': 'False'},
|
|
{'key': 'ENABLE_REQUEST_LOG', 'label': '启用请求日志', 'type': 'boolean', 'default': 'True'},
|
|
{'key': 'ENABLE_AI_ANALYSIS', 'label': '启用AI分析', 'type': 'boolean', 'default': 'True'},
|
|
]
|
|
},
|
|
'agent_memory': {
|
|
'title': '记忆/反思配置',
|
|
'items': [
|
|
{'key': 'ENABLE_AGENT_MEMORY', 'label': '启用Agent记忆', 'type': 'boolean', 'default': 'True'},
|
|
{'key': 'AGENT_MEMORY_ENABLE_VECTOR', 'label': '启用向量检索(本地)', 'type': 'boolean', 'default': 'True'},
|
|
{'key': 'AGENT_MEMORY_EMBEDDING_DIM', 'label': 'Embedding维度', 'type': 'number', 'default': '256'},
|
|
{'key': 'AGENT_MEMORY_TOP_K', 'label': '召回数量TopK', 'type': 'number', 'default': '5'},
|
|
{'key': 'AGENT_MEMORY_CANDIDATE_LIMIT', 'label': '候选窗口大小', 'type': 'number', 'default': '500'},
|
|
{'key': 'AGENT_MEMORY_HALF_LIFE_DAYS', 'label': '时间衰减半衰期(天)', 'type': 'number', 'default': '30'},
|
|
{'key': 'AGENT_MEMORY_W_SIM', 'label': '相似度权重', 'type': 'number', 'default': '0.75'},
|
|
{'key': 'AGENT_MEMORY_W_RECENCY', 'label': '时间权重', 'type': 'number', 'default': '0.20'},
|
|
{'key': 'AGENT_MEMORY_W_RETURNS', 'label': '收益权重', 'type': 'number', 'default': '0.05'},
|
|
]
|
|
},
|
|
'reflection_worker': {
|
|
'title': '自动反思验证Worker',
|
|
'items': [
|
|
{'key': 'ENABLE_REFLECTION_WORKER', 'label': '启用自动验证', 'type': 'boolean', 'default': 'False'},
|
|
{'key': 'REFLECTION_WORKER_INTERVAL_SEC', 'label': '验证周期间隔(秒)', 'type': 'number', 'default': '86400'},
|
|
]
|
|
},
|
|
'ai': {
|
|
'title': 'AI/LLM配置',
|
|
'items': [
|
|
{'key': 'OPENROUTER_API_KEY', 'label': 'OpenRouter API Key', 'type': 'password', 'required': False, 'link': 'https://openrouter.ai/keys', 'link_text': 'settings.link.getApiKey'},
|
|
{'key': 'OPENROUTER_API_URL', 'label': 'OpenRouter API URL', 'type': 'text', 'default': 'https://openrouter.ai/api/v1/chat/completions'},
|
|
{'key': 'OPENROUTER_MODEL', 'label': '默认模型', 'type': 'text', 'default': 'openai/gpt-4o', 'link': 'https://openrouter.ai/models', 'link_text': 'settings.link.viewModels'},
|
|
{'key': 'OPENROUTER_TEMPERATURE', 'label': 'Temperature', 'type': 'number', 'default': '0.7'},
|
|
{'key': 'OPENROUTER_MAX_TOKENS', 'label': 'Max Tokens', 'type': 'number', 'default': '4000'},
|
|
{'key': 'OPENROUTER_TIMEOUT', 'label': '超时时间(秒)', 'type': 'number', 'default': '300'},
|
|
{'key': 'OPENROUTER_CONNECT_TIMEOUT', 'label': '连接超时(秒)', 'type': 'number', 'default': '30'},
|
|
{'key': 'AI_MODELS_JSON', 'label': '模型列表(JSON)', 'type': 'text', 'default': '{}', 'required': False},
|
|
]
|
|
},
|
|
'market': {
|
|
'title': '市场预设',
|
|
'items': [
|
|
{'key': 'MARKET_TYPES_JSON', 'label': '市场类型(JSON)', 'type': 'text', 'default': '[]', 'required': False},
|
|
{'key': 'TRADING_SUPPORTED_SYMBOLS_JSON', 'label': '支持的交易对(JSON)', 'type': 'text', 'default': '[]', 'required': False},
|
|
]
|
|
},
|
|
'data_source': {
|
|
'title': '数据源配置',
|
|
'items': [
|
|
{'key': 'DATA_SOURCE_TIMEOUT', 'label': '数据源超时(秒)', 'type': 'number', 'default': '30'},
|
|
{'key': 'DATA_SOURCE_RETRY', 'label': '重试次数', 'type': 'number', 'default': '3'},
|
|
{'key': 'DATA_SOURCE_RETRY_BACKOFF', 'label': '重试退避(秒)', 'type': 'number', 'default': '0.5'},
|
|
{'key': 'FINNHUB_API_KEY', 'label': 'Finnhub API Key', 'type': 'password', 'required': False, 'link': 'https://finnhub.io/register', 'link_text': 'settings.link.freeRegister'},
|
|
{'key': 'FINNHUB_TIMEOUT', 'label': 'Finnhub超时(秒)', 'type': 'number', 'default': '10'},
|
|
{'key': 'FINNHUB_RATE_LIMIT', 'label': 'Finnhub速率限制', 'type': 'number', 'default': '60'},
|
|
{'key': 'CCXT_DEFAULT_EXCHANGE', 'label': 'CCXT默认交易所', 'type': 'text', 'default': 'coinbase', 'link': 'https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets', 'link_text': 'settings.link.supportedExchanges'},
|
|
{'key': 'CCXT_TIMEOUT', 'label': 'CCXT超时(ms)', 'type': 'number', 'default': '10000'},
|
|
{'key': 'CCXT_PROXY', 'label': 'CCXT代理', 'type': 'text', 'required': False},
|
|
{'key': 'AKSHARE_TIMEOUT', 'label': 'Akshare超时(秒)', 'type': 'number', 'default': '30'},
|
|
{'key': 'YFINANCE_TIMEOUT', 'label': 'YFinance超时(秒)', 'type': 'number', 'default': '30'},
|
|
{'key': 'TIINGO_API_KEY', 'label': 'Tiingo API Key', 'type': 'password', 'required': False, 'link': 'https://www.tiingo.com/account/api/token', 'link_text': 'settings.link.getToken'},
|
|
{'key': 'TIINGO_TIMEOUT', 'label': 'Tiingo超时(秒)', 'type': 'number', 'default': '10'},
|
|
]
|
|
},
|
|
'search': {
|
|
'title': '搜索配置',
|
|
'items': [
|
|
{'key': 'SEARCH_PROVIDER', 'label': '搜索提供商', 'type': 'select', 'options': ['google', 'bing', 'none'], 'default': 'google'},
|
|
{'key': 'SEARCH_MAX_RESULTS', 'label': '最大结果数', 'type': 'number', 'default': '10'},
|
|
{'key': 'SEARCH_GOOGLE_API_KEY', 'label': 'Google API Key', 'type': 'password', 'required': False, 'link': 'https://developers.google.com/custom-search/v1/introduction', 'link_text': 'settings.link.applyApi'},
|
|
{'key': 'SEARCH_GOOGLE_CX', 'label': 'Google CX', 'type': 'text', 'required': False, 'link': 'https://programmablesearchengine.google.com/controlpanel/all', 'link_text': 'settings.link.createSearchEngine'},
|
|
{'key': 'SEARCH_BING_API_KEY', 'label': 'Bing API Key', 'type': 'password', 'required': False, 'link': 'https://www.microsoft.com/en-us/bing/apis/bing-web-search-api', 'link_text': 'settings.link.applyApi'},
|
|
{'key': 'INTERNAL_API_KEY', 'label': '内部API Key', 'type': 'password', 'required': False},
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
def read_env_file():
|
|
"""读取 .env 文件"""
|
|
env_values = {}
|
|
|
|
if not os.path.exists(ENV_FILE_PATH):
|
|
logger.warning(f".env file not found at {ENV_FILE_PATH}")
|
|
return env_values
|
|
|
|
try:
|
|
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
# 跳过空行和注释
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
# 解析 KEY=VALUE
|
|
if '=' in line:
|
|
key, value = line.split('=', 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
# 移除引号
|
|
if (value.startswith('"') and value.endswith('"')) or \
|
|
(value.startswith("'") and value.endswith("'")):
|
|
value = value[1:-1]
|
|
env_values[key] = value
|
|
except Exception as e:
|
|
logger.error(f"Failed to read .env file: {e}")
|
|
|
|
return env_values
|
|
|
|
|
|
def write_env_file(env_values):
|
|
"""写入 .env 文件,保留注释和格式"""
|
|
lines = []
|
|
existing_keys = set()
|
|
|
|
# 读取原文件保留格式
|
|
if os.path.exists(ENV_FILE_PATH):
|
|
try:
|
|
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
original_line = line
|
|
stripped = line.strip()
|
|
|
|
# 保留空行和注释
|
|
if not stripped or stripped.startswith('#'):
|
|
lines.append(original_line)
|
|
continue
|
|
|
|
# 更新已存在的键
|
|
if '=' in stripped:
|
|
key = stripped.split('=', 1)[0].strip()
|
|
if key in env_values:
|
|
existing_keys.add(key)
|
|
value = env_values[key]
|
|
# 如果值包含特殊字符,用引号包裹
|
|
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
|
|
lines.append(f'{key}="{value}"\n')
|
|
else:
|
|
lines.append(f'{key}={value}\n')
|
|
else:
|
|
lines.append(original_line)
|
|
else:
|
|
lines.append(original_line)
|
|
except Exception as e:
|
|
logger.error(f"Failed to read .env file for update: {e}")
|
|
|
|
# 添加新的键
|
|
new_keys = set(env_values.keys()) - existing_keys
|
|
if new_keys:
|
|
if lines and not lines[-1].endswith('\n'):
|
|
lines.append('\n')
|
|
lines.append('\n# Added by Settings UI\n')
|
|
for key in sorted(new_keys):
|
|
value = env_values[key]
|
|
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
|
|
lines.append(f'{key}="{value}"\n')
|
|
else:
|
|
lines.append(f'{key}={value}\n')
|
|
|
|
# 写入文件
|
|
try:
|
|
with open(ENV_FILE_PATH, 'w', encoding='utf-8') as f:
|
|
f.writelines(lines)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to write .env file: {e}")
|
|
return False
|
|
|
|
|
|
@settings_bp.route('/schema', methods=['GET'])
|
|
def get_settings_schema():
|
|
"""获取配置项定义"""
|
|
return jsonify({
|
|
'code': 1,
|
|
'msg': 'success',
|
|
'data': CONFIG_SCHEMA
|
|
})
|
|
|
|
|
|
@settings_bp.route('/values', methods=['GET'])
|
|
def get_settings_values():
|
|
"""获取当前配置值 - 包括敏感信息(真实值)"""
|
|
env_values = read_env_file()
|
|
|
|
# 构建返回数据,返回真实值
|
|
result = {}
|
|
for group_key, group in CONFIG_SCHEMA.items():
|
|
result[group_key] = {}
|
|
for item in group['items']:
|
|
key = item['key']
|
|
value = env_values.get(key, item.get('default', ''))
|
|
result[group_key][key] = value
|
|
# 标记密码类型是否已配置
|
|
if item['type'] == 'password':
|
|
result[group_key][f'{key}_configured'] = bool(value)
|
|
|
|
return jsonify({
|
|
'code': 1,
|
|
'msg': 'success',
|
|
'data': result
|
|
})
|
|
|
|
|
|
@settings_bp.route('/save', methods=['POST'])
|
|
def save_settings():
|
|
"""保存配置"""
|
|
try:
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'code': 0, 'msg': 'Invalid request payload'})
|
|
|
|
# 读取当前配置
|
|
current_env = read_env_file()
|
|
|
|
# 更新配置
|
|
updates = {}
|
|
for group_key, group_values in data.items():
|
|
if group_key not in CONFIG_SCHEMA:
|
|
continue
|
|
|
|
for item in CONFIG_SCHEMA[group_key]['items']:
|
|
key = item['key']
|
|
if key in group_values:
|
|
new_value = group_values[key]
|
|
|
|
# 空值处理
|
|
if new_value is None or new_value == '':
|
|
if not item.get('required', True):
|
|
updates[key] = ''
|
|
else:
|
|
updates[key] = str(new_value)
|
|
|
|
# 合并更新
|
|
current_env.update(updates)
|
|
|
|
# 写入文件
|
|
if write_env_file(current_env):
|
|
# 清除配置缓存
|
|
clear_config_cache()
|
|
|
|
return jsonify({
|
|
'code': 1,
|
|
'msg': 'Settings saved successfully',
|
|
'data': {
|
|
'updated_keys': list(updates.keys()),
|
|
'requires_restart': True # 标记需要重启
|
|
}
|
|
})
|
|
else:
|
|
return jsonify({'code': 0, 'msg': 'Failed to save settings'})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to save settings: {e}")
|
|
return jsonify({'code': 0, 'msg': f'Save failed: {str(e)}'})
|
|
|
|
|
|
@settings_bp.route('/test-connection', methods=['POST'])
|
|
def test_connection():
|
|
"""测试API连接"""
|
|
try:
|
|
data = request.get_json()
|
|
service = data.get('service')
|
|
|
|
if service == 'openrouter':
|
|
# 测试 OpenRouter 连接
|
|
from app.services.llm import LLMService
|
|
llm = LLMService()
|
|
result = llm.test_connection()
|
|
if result:
|
|
return jsonify({'code': 1, 'msg': 'OpenRouter connection successful'})
|
|
else:
|
|
return jsonify({'code': 0, 'msg': 'OpenRouter connection failed'})
|
|
|
|
elif service == 'finnhub':
|
|
# 测试 Finnhub 连接
|
|
import requests
|
|
api_key = data.get('api_key') or os.getenv('FINNHUB_API_KEY')
|
|
if not api_key:
|
|
return jsonify({'code': 0, 'msg': 'API key is not configured'})
|
|
resp = requests.get(
|
|
f'https://finnhub.io/api/v1/quote?symbol=AAPL&token={api_key}',
|
|
timeout=10
|
|
)
|
|
if resp.status_code == 200:
|
|
return jsonify({'code': 1, 'msg': 'Finnhub connection successful'})
|
|
else:
|
|
return jsonify({'code': 0, 'msg': f'Finnhub connection failed: {resp.status_code}'})
|
|
|
|
return jsonify({'code': 0, 'msg': 'Unknown service'})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Connection test failed: {e}")
|
|
return jsonify({'code': 0, 'msg': f'Test failed: {str(e)}'})
|