1126 lines
42 KiB
Python
1126 lines
42 KiB
Python
"""
|
||
Settings API - Reading and saving .env configurations
|
||
|
||
Admin-only endpoints for system configuration management.
|
||
"""
|
||
import os
|
||
import re
|
||
import importlib
|
||
from flask import Blueprint, request, jsonify
|
||
from app.utils.logger import get_logger
|
||
from app.utils.config_loader import clear_config_cache
|
||
from app.utils.auth import login_required, admin_required
|
||
from dotenv import load_dotenv
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
settings_bp = Blueprint('settings', __name__)
|
||
|
||
# .env file path
|
||
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
|
||
|
||
|
||
def _reload_runtime_env() -> None:
|
||
"""
|
||
Reload .env into current process so settings take effect immediately.
|
||
Priority keeps backend_api_python/.env over repo-root/.env.
|
||
"""
|
||
backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||
root_dir = os.path.dirname(backend_dir)
|
||
|
||
# Load root first, then backend .env to keep backend file higher priority
|
||
load_dotenv(os.path.join(root_dir, '.env'), override=True)
|
||
load_dotenv(os.path.join(backend_dir, '.env'), override=True)
|
||
|
||
|
||
def _refresh_runtime_services() -> None:
|
||
"""
|
||
Reset singleton services so new env/config is picked up lazily
|
||
on next request without restarting the Python process.
|
||
"""
|
||
# Prefer dedicated reset function where available.
|
||
try:
|
||
search_mod = importlib.import_module('app.services.search')
|
||
if hasattr(search_mod, 'reset_search_service'):
|
||
search_mod.reset_search_service()
|
||
except Exception as e:
|
||
logger.warning(f"reset_search_service skipped: {e}")
|
||
|
||
# Generic singleton fields used across services.
|
||
singleton_fields = [
|
||
('app.services.fast_analysis', '_fast_analysis_service'),
|
||
('app.services.billing_service', '_billing_service'),
|
||
('app.services.security_service', '_security_service'),
|
||
('app.services.oauth_service', '_oauth_service'),
|
||
('app.services.user_service', '_user_service'),
|
||
('app.services.email_service', '_email_service'),
|
||
('app.services.community_service', '_community_service'),
|
||
('app.services.usdt_payment_service', '_svc'),
|
||
('app.services.usdt_payment_service', '_worker'),
|
||
('app.services.analysis_memory', '_memory_instance'),
|
||
]
|
||
|
||
for module_name, field_name in singleton_fields:
|
||
try:
|
||
mod = importlib.import_module(module_name)
|
||
if hasattr(mod, field_name):
|
||
setattr(mod, field_name, None)
|
||
except Exception as e:
|
||
logger.warning(f"Singleton reset skipped: {module_name}.{field_name}: {e}")
|
||
|
||
# Configuration item definition (grouping) - divided by functional modules, each configuration item contains a description
|
||
# ---------------------------------------------------------------
|
||
# Streamlining principle:
|
||
# - Deployment-level configuration (host/port/debug) is not exposed in the UI, users can set it through .env or docker-compose
|
||
# - Internal tuning parameters (timeout/retry/tick interval/vector dimension, etc.) can use default values and are not exposed to ordinary users.
|
||
# - Only keep the function switches and API Keys that users really need to configure
|
||
# ---------------------------------------------------------------
|
||
CONFIG_SCHEMA = {
|
||
|
||
# ==================== 1. Security certification ====================
|
||
'auth': {
|
||
'title': 'Security & Authentication',
|
||
'icon': 'lock',
|
||
'order': 1,
|
||
'items': [
|
||
{
|
||
'key': 'SECRET_KEY',
|
||
'label': 'Secret Key',
|
||
'type': 'password',
|
||
'default': 'quantdinger-secret-key-change-me',
|
||
'description': 'JWT signing secret key. MUST change in production for security'
|
||
},
|
||
{
|
||
'key': 'ADMIN_USER',
|
||
'label': 'Admin Username',
|
||
'type': 'text',
|
||
'default': 'quantdinger',
|
||
'description': 'Administrator login username'
|
||
},
|
||
{
|
||
'key': 'ADMIN_PASSWORD',
|
||
'label': 'Admin Password',
|
||
'type': 'password',
|
||
'default': '123456',
|
||
'description': 'Administrator login password. MUST change in production'
|
||
},
|
||
{
|
||
'key': 'ADMIN_EMAIL',
|
||
'label': 'Admin Email',
|
||
'type': 'text',
|
||
'default': 'admin@example.com',
|
||
'description': 'Administrator email for password reset and notifications'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 2. AI/LLM configuration ====================
|
||
'ai': {
|
||
'title': 'AI / LLM & Search',
|
||
'icon': 'robot',
|
||
'order': 2,
|
||
'items': [
|
||
{
|
||
'key': 'LLM_PROVIDER',
|
||
'label': 'LLM Provider',
|
||
'type': 'select',
|
||
'default': 'openrouter',
|
||
'options': [
|
||
{'value': 'openrouter', 'label': 'OpenRouter (Multi-model gateway)'},
|
||
{'value': 'openai', 'label': 'OpenAI Direct'},
|
||
{'value': 'google', 'label': 'Google Gemini'},
|
||
{'value': 'deepseek', 'label': 'DeepSeek'},
|
||
{'value': 'grok', 'label': 'xAI Grok'},
|
||
],
|
||
'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',
|
||
'label': 'OpenRouter API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://openrouter.ai/keys',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'OpenRouter API key. Supports 100+ models via single API',
|
||
'group': 'openrouter'
|
||
},
|
||
{
|
||
'key': 'OPENROUTER_MODEL',
|
||
'label': 'OpenRouter Model',
|
||
'type': 'text',
|
||
'default': 'openai/gpt-4o',
|
||
'link': 'https://openrouter.ai/models',
|
||
'link_text': 'settings.link.viewModels',
|
||
'description': 'Model ID, e.g. openai/gpt-4o, anthropic/claude-3.5-sonnet',
|
||
'group': 'openrouter'
|
||
},
|
||
# OpenAI Direct
|
||
{
|
||
'key': 'OPENAI_API_KEY',
|
||
'label': 'OpenAI API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://platform.openai.com/api-keys',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'OpenAI official API key',
|
||
'group': 'openai'
|
||
},
|
||
{
|
||
'key': 'OPENAI_MODEL',
|
||
'label': 'OpenAI Model',
|
||
'type': 'text',
|
||
'default': 'gpt-4o',
|
||
'description': 'Model name: gpt-4o, gpt-4o-mini, gpt-4-turbo, etc.',
|
||
'group': 'openai'
|
||
},
|
||
{
|
||
'key': 'OPENAI_BASE_URL',
|
||
'label': 'OpenAI Base URL',
|
||
'type': 'text',
|
||
'default': 'https://api.openai.com/v1',
|
||
'description': 'Custom API endpoint (for proxies or Azure)',
|
||
'group': 'openai'
|
||
},
|
||
# Google Gemini
|
||
{
|
||
'key': 'GOOGLE_API_KEY',
|
||
'label': 'Google API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://aistudio.google.com/apikey',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'Google AI Studio API key for Gemini',
|
||
'group': 'google'
|
||
},
|
||
{
|
||
'key': 'GOOGLE_MODEL',
|
||
'label': 'Gemini Model',
|
||
'type': 'text',
|
||
'default': 'gemini-1.5-flash',
|
||
'description': 'Model: gemini-1.5-flash, gemini-1.5-pro, gemini-2.0-flash-exp',
|
||
'group': 'google'
|
||
},
|
||
# DeepSeek
|
||
{
|
||
'key': 'DEEPSEEK_API_KEY',
|
||
'label': 'DeepSeek API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://platform.deepseek.com/api_keys',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'DeepSeek API key',
|
||
'group': 'deepseek'
|
||
},
|
||
{
|
||
'key': 'DEEPSEEK_MODEL',
|
||
'label': 'DeepSeek Model',
|
||
'type': 'text',
|
||
'default': 'deepseek-chat',
|
||
'description': 'Model: deepseek-chat, deepseek-coder',
|
||
'group': 'deepseek'
|
||
},
|
||
{
|
||
'key': 'DEEPSEEK_BASE_URL',
|
||
'label': 'DeepSeek Base URL',
|
||
'type': 'text',
|
||
'default': 'https://api.deepseek.com/v1',
|
||
'description': 'DeepSeek API endpoint',
|
||
'group': 'deepseek'
|
||
},
|
||
# xAI Grok
|
||
{
|
||
'key': 'GROK_API_KEY',
|
||
'label': 'Grok API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://console.x.ai/',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'xAI Grok API key',
|
||
'group': 'grok'
|
||
},
|
||
{
|
||
'key': 'GROK_MODEL',
|
||
'label': 'Grok Model',
|
||
'type': 'text',
|
||
'default': 'grok-beta',
|
||
'description': 'Model: grok-beta, grok-2',
|
||
'group': 'grok'
|
||
},
|
||
{
|
||
'key': 'GROK_BASE_URL',
|
||
'label': 'Grok Base URL',
|
||
'type': 'text',
|
||
'default': 'https://api.x.ai/v1',
|
||
'description': 'xAI Grok API endpoint',
|
||
'group': 'grok'
|
||
},
|
||
# Common settings
|
||
{
|
||
'key': 'OPENROUTER_TEMPERATURE',
|
||
'label': 'Temperature',
|
||
'type': 'number',
|
||
'default': '0.7',
|
||
'description': 'Model creativity (0-1). Lower = more deterministic'
|
||
},
|
||
{
|
||
'key': 'AI_ANALYSIS_CONSENSUS_TIMEFRAMES',
|
||
'label': 'Consensus Timeframes',
|
||
'type': 'text',
|
||
'default': '1D,4H',
|
||
'required': False,
|
||
'description': 'Multi-timeframe consensus for fast AI analysis. Comma-separated, e.g. "1D,4H"'
|
||
},
|
||
{
|
||
'key': 'SEARCH_PROVIDER',
|
||
'label': 'Search Provider',
|
||
'type': 'select',
|
||
'options': ['tavily', 'google', 'bing', 'none'],
|
||
'default': 'google',
|
||
'description': 'News / web search provider used by AI analysis. Configure both LLM and search to get full AI analysis results'
|
||
},
|
||
{
|
||
'key': 'SEARCH_MAX_RESULTS',
|
||
'label': 'Search Max Results',
|
||
'type': 'number',
|
||
'default': '10',
|
||
'description': 'Maximum number of search/news results returned per AI analysis request'
|
||
},
|
||
{
|
||
'key': 'TAVILY_API_KEYS',
|
||
'label': 'Tavily API Keys',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://tavily.com/',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'Tavily search API keys (comma-separated). Recommended lightweight search source for AI analysis'
|
||
},
|
||
{
|
||
'key': 'SEARCH_GOOGLE_API_KEY',
|
||
'label': 'Google Search API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://console.cloud.google.com/apis/credentials',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'Google Custom Search JSON API key'
|
||
},
|
||
{
|
||
'key': 'SEARCH_GOOGLE_CX',
|
||
'label': 'Google Search Engine ID (CX)',
|
||
'type': 'text',
|
||
'required': False,
|
||
'link': 'https://programmablesearchengine.google.com/',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'Google Programmable Search Engine ID'
|
||
},
|
||
{
|
||
'key': 'SEARCH_BING_API_KEY',
|
||
'label': 'Bing Search API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://portal.azure.com/',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'Microsoft Bing Web Search API key'
|
||
},
|
||
{
|
||
'key': 'SERPAPI_KEYS',
|
||
'label': 'SerpAPI Keys',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://serpapi.com/',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'SerpAPI keys (comma-separated)'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 3. Real offer ====================
|
||
'trading': {
|
||
'title': 'Live Trading',
|
||
'icon': 'stock',
|
||
'order': 3,
|
||
'items': [
|
||
{
|
||
'key': 'ORDER_MODE',
|
||
'label': 'Order Execution Mode',
|
||
'type': 'select',
|
||
'options': ['market', 'maker'],
|
||
'default': 'market',
|
||
'description': 'market: Market order (instant fill, recommended), maker: Limit order first (lower fees but may not fill)'
|
||
},
|
||
{
|
||
'key': 'MAKER_WAIT_SEC',
|
||
'label': 'Limit Order Wait (sec)',
|
||
'type': 'number',
|
||
'default': '10',
|
||
'description': 'Wait time for limit order fill before switching to market order'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 4. Data source configuration ====================
|
||
'data_source': {
|
||
'title': 'Data Sources',
|
||
'icon': 'database',
|
||
'order': 4,
|
||
'items': [
|
||
{
|
||
'key': 'CCXT_DEFAULT_EXCHANGE',
|
||
'label': 'Default Crypto Exchange',
|
||
'type': 'text',
|
||
'default': 'coinbase',
|
||
'link': 'https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets',
|
||
'link_text': 'settings.link.supportedExchanges',
|
||
'description': 'Default exchange for crypto data (binance, coinbase, okx, etc.)'
|
||
},
|
||
{
|
||
'key': 'FINNHUB_API_KEY',
|
||
'label': 'Finnhub API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://finnhub.io/register',
|
||
'link_text': 'settings.link.freeRegister',
|
||
'description': 'Finnhub API key for US stock data (free tier available)'
|
||
},
|
||
{
|
||
'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',
|
||
'description': 'Tiingo API key for Forex/Metals data'
|
||
},
|
||
{
|
||
'key': 'TWELVE_DATA_API_KEY',
|
||
'label': 'Twelve Data API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://twelvedata.com/apikey',
|
||
'link_text': 'settings.link.getApiKey',
|
||
'description': 'Twelve Data API key for CN/HK stock K-lines (free 800 credits/day)'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 5. Email configuration ====================
|
||
'email': {
|
||
'title': 'Email (SMTP)',
|
||
'icon': 'mail',
|
||
'order': 5,
|
||
'items': [
|
||
{
|
||
'key': 'SMTP_HOST',
|
||
'label': 'SMTP Server',
|
||
'type': 'text',
|
||
'required': False,
|
||
'description': 'SMTP server hostname (e.g. smtp.gmail.com)'
|
||
},
|
||
{
|
||
'key': 'SMTP_PORT',
|
||
'label': 'SMTP Port',
|
||
'type': 'number',
|
||
'default': '587',
|
||
'description': 'SMTP port (587 for TLS, 465 for SSL)'
|
||
},
|
||
{
|
||
'key': 'SMTP_USER',
|
||
'label': 'SMTP Username',
|
||
'type': 'text',
|
||
'required': False,
|
||
'description': 'SMTP authentication username (usually email address)'
|
||
},
|
||
{
|
||
'key': 'SMTP_PASSWORD',
|
||
'label': 'SMTP Password',
|
||
'type': 'password',
|
||
'required': False,
|
||
'description': 'SMTP authentication password or app-specific password'
|
||
},
|
||
{
|
||
'key': 'SMTP_FROM',
|
||
'label': 'Sender Address',
|
||
'type': 'text',
|
||
'required': False,
|
||
'description': 'Email sender address (From header)'
|
||
},
|
||
{
|
||
'key': 'SMTP_USE_TLS',
|
||
'label': 'Use TLS',
|
||
'type': 'boolean',
|
||
'default': 'True',
|
||
'description': 'Enable STARTTLS encryption (recommended for port 587)'
|
||
},
|
||
{
|
||
'key': 'SMTP_USE_SSL',
|
||
'label': 'Use SSL',
|
||
'type': 'boolean',
|
||
'default': 'False',
|
||
'description': 'Enable SSL encryption (for port 465)'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 6. SMS configuration ====================
|
||
'sms': {
|
||
'title': 'SMS (Twilio)',
|
||
'icon': 'phone',
|
||
'order': 6,
|
||
'items': [
|
||
{
|
||
'key': 'TWILIO_ACCOUNT_SID',
|
||
'label': 'Account SID',
|
||
'type': 'password',
|
||
'required': False,
|
||
'link': 'https://console.twilio.com/',
|
||
'link_text': 'settings.link.getApi',
|
||
'description': 'Twilio Account SID from console dashboard'
|
||
},
|
||
{
|
||
'key': 'TWILIO_AUTH_TOKEN',
|
||
'label': 'Auth Token',
|
||
'type': 'password',
|
||
'required': False,
|
||
'description': 'Twilio Auth Token from console dashboard'
|
||
},
|
||
{
|
||
'key': 'TWILIO_FROM_NUMBER',
|
||
'label': 'Sender Number',
|
||
'type': 'text',
|
||
'required': False,
|
||
'description': 'Twilio phone number for sending SMS (e.g. +1234567890)'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 7. AI Agent ====================
|
||
'agent': {
|
||
'title': 'AI Agent',
|
||
'icon': 'experiment',
|
||
'order': 7,
|
||
'items': [
|
||
{
|
||
'key': 'ENABLE_REFLECTION_WORKER',
|
||
'label': 'Enable Auto Reflection',
|
||
'type': 'boolean',
|
||
'default': 'False',
|
||
'description': 'Enable background worker for automatic trade reflection and calibration'
|
||
},
|
||
{
|
||
'key': 'REFLECTION_WORKER_INTERVAL_SEC',
|
||
'label': 'Reflection Interval (sec)',
|
||
'type': 'number',
|
||
'default': '86400',
|
||
'description': 'Reflection worker run interval in seconds (86400 = 1 day)'
|
||
},
|
||
{
|
||
'key': 'REFLECTION_MIN_AGE_DAYS',
|
||
'label': 'Min Age for Validation (days)',
|
||
'type': 'number',
|
||
'default': '7',
|
||
'description': 'Only validate analyses older than N days'
|
||
},
|
||
{
|
||
'key': 'REFLECTION_VALIDATE_LIMIT',
|
||
'label': 'Validation Batch Limit',
|
||
'type': 'number',
|
||
'default': '200',
|
||
'description': 'Max records to validate per reflection cycle'
|
||
},
|
||
{
|
||
'key': 'ENABLE_CONFIDENCE_CALIBRATION',
|
||
'label': 'Enable Confidence Calibration',
|
||
'type': 'boolean',
|
||
'default': 'False',
|
||
'description': 'Adjust confidence by historical accuracy in each bucket'
|
||
},
|
||
{
|
||
'key': 'ENABLE_AI_ENSEMBLE',
|
||
'label': 'Enable Multi-Model Voting',
|
||
'type': 'boolean',
|
||
'default': 'False',
|
||
'description': 'Use 2-3 models and majority vote for more stable decisions'
|
||
},
|
||
{
|
||
'key': 'AI_ENSEMBLE_MODELS',
|
||
'label': 'Ensemble Models',
|
||
'type': 'text',
|
||
'default': 'openai/gpt-4o,openai/gpt-4o-mini',
|
||
'description': 'Comma-separated model IDs for ensemble voting'
|
||
},
|
||
{
|
||
'key': 'AI_CALIBRATION_MARKETS',
|
||
'label': 'Calibration Markets',
|
||
'type': 'text',
|
||
'default': 'Crypto',
|
||
'description': 'Comma-separated markets to run threshold calibration'
|
||
},
|
||
{
|
||
'key': 'AI_CALIBRATION_LOOKBACK_DAYS',
|
||
'label': 'Calibration Lookback (days)',
|
||
'type': 'number',
|
||
'default': '30',
|
||
'description': 'Days of validated data for calibration'
|
||
},
|
||
{
|
||
'key': 'AI_CALIBRATION_MIN_SAMPLES',
|
||
'label': 'Calibration Min Samples',
|
||
'type': 'number',
|
||
'default': '80',
|
||
'description': 'Minimum validated samples required for calibration'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 8. Network proxy ====================
|
||
'network': {
|
||
'title': 'Network & Proxy',
|
||
'icon': 'global',
|
||
'order': 8,
|
||
'items': [
|
||
{
|
||
'key': 'PROXY_URL',
|
||
'label': 'Proxy URL',
|
||
'type': 'text',
|
||
'required': False,
|
||
'description': 'Global outbound proxy URL. Used by requests and by crypto data requests when a proxy is needed.'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 10. Registration and OAuth ====================
|
||
'security': {
|
||
'title': 'Registration & OAuth',
|
||
'icon': 'safety',
|
||
'order': 10,
|
||
'items': [
|
||
{
|
||
'key': 'ENABLE_REGISTRATION',
|
||
'label': 'Enable Registration',
|
||
'type': 'boolean',
|
||
'default': 'True',
|
||
'description': 'Allow new users to register accounts'
|
||
},
|
||
{
|
||
'key': 'FRONTEND_URL',
|
||
'label': 'Frontend URL',
|
||
'type': 'text',
|
||
'default': 'http://localhost:8080',
|
||
'description': 'Frontend URL for OAuth redirects'
|
||
},
|
||
{
|
||
'key': 'TURNSTILE_SITE_KEY',
|
||
'label': 'Turnstile Site Key',
|
||
'type': 'text',
|
||
'required': False,
|
||
'link': 'https://dash.cloudflare.com/?to=/:account/turnstile',
|
||
'link_text': 'settings.link.getTurnstileKey',
|
||
'description': 'Cloudflare Turnstile site key for CAPTCHA'
|
||
},
|
||
{
|
||
'key': 'TURNSTILE_SECRET_KEY',
|
||
'label': 'Turnstile Secret Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'description': 'Cloudflare Turnstile secret key'
|
||
},
|
||
{
|
||
'key': 'GOOGLE_CLIENT_ID',
|
||
'label': 'Google OAuth Client ID',
|
||
'type': 'text',
|
||
'required': False,
|
||
'link': 'https://console.cloud.google.com/apis/credentials',
|
||
'link_text': 'settings.link.getGoogleCredentials',
|
||
'description': 'Google OAuth Client ID for Google login'
|
||
},
|
||
{
|
||
'key': 'GOOGLE_CLIENT_SECRET',
|
||
'label': 'Google OAuth Secret',
|
||
'type': 'password',
|
||
'required': False,
|
||
'description': 'Google OAuth Client Secret'
|
||
},
|
||
{
|
||
'key': 'GITHUB_CLIENT_ID',
|
||
'label': 'GitHub OAuth Client ID',
|
||
'type': 'text',
|
||
'required': False,
|
||
'link': 'https://github.com/settings/developers',
|
||
'link_text': 'settings.link.getGithubCredentials',
|
||
'description': 'GitHub OAuth Client ID for GitHub login'
|
||
},
|
||
{
|
||
'key': 'GITHUB_CLIENT_SECRET',
|
||
'label': 'GitHub OAuth Secret',
|
||
'type': 'password',
|
||
'required': False,
|
||
'description': 'GitHub OAuth Client Secret'
|
||
},
|
||
]
|
||
},
|
||
|
||
# ==================== 11. Billing configuration ====================
|
||
'billing': {
|
||
'title': 'Billing & Credits',
|
||
'icon': 'dollar',
|
||
'order': 11,
|
||
'items': [
|
||
{
|
||
'key': 'BILLING_ENABLED',
|
||
'label': 'Enable Billing',
|
||
'type': 'boolean',
|
||
'default': 'False',
|
||
'description': 'Enable billing system. Users need credits to use certain features'
|
||
},
|
||
|
||
# ===== Membership Plans (3 tiers) =====
|
||
{
|
||
'key': 'MEMBERSHIP_MONTHLY_PRICE_USD',
|
||
'label': 'Monthly Membership Price (USD)',
|
||
'type': 'number',
|
||
'default': '19.9',
|
||
'description': 'Monthly membership price in USD (mock payment in current version)'
|
||
},
|
||
{
|
||
'key': 'MEMBERSHIP_MONTHLY_CREDITS',
|
||
'label': 'Monthly Membership Bonus Credits',
|
||
'type': 'number',
|
||
'default': '500',
|
||
'description': 'Credits granted immediately after purchasing monthly membership'
|
||
},
|
||
{
|
||
'key': 'MEMBERSHIP_YEARLY_PRICE_USD',
|
||
'label': 'Yearly Membership Price (USD)',
|
||
'type': 'number',
|
||
'default': '199',
|
||
'description': 'Yearly membership price in USD (mock payment in current version)'
|
||
},
|
||
{
|
||
'key': 'MEMBERSHIP_YEARLY_CREDITS',
|
||
'label': 'Yearly Membership Bonus Credits',
|
||
'type': 'number',
|
||
'default': '8000',
|
||
'description': 'Credits granted immediately after purchasing yearly membership'
|
||
},
|
||
{
|
||
'key': 'MEMBERSHIP_LIFETIME_PRICE_USD',
|
||
'label': 'Lifetime Membership Price (USD)',
|
||
'type': 'number',
|
||
'default': '499',
|
||
'description': 'Lifetime membership price in USD (mock payment in current version)'
|
||
},
|
||
{
|
||
'key': 'MEMBERSHIP_LIFETIME_MONTHLY_CREDITS',
|
||
'label': 'Lifetime Membership Monthly Credits',
|
||
'type': 'number',
|
||
'default': '800',
|
||
'description': 'Credits granted every 30 days for lifetime members'
|
||
},
|
||
|
||
# ===== USDT Pay (Plan B: independent address for each order) =====
|
||
{
|
||
'key': 'USDT_PAY_ENABLED',
|
||
'label': 'Enable USDT Pay',
|
||
'type': 'boolean',
|
||
'default': 'False',
|
||
'description': 'Enable USDT scan-to-pay flow (per-order unique address)'
|
||
},
|
||
{
|
||
'key': 'USDT_PAY_CHAIN',
|
||
'label': 'USDT Chain',
|
||
'type': 'select',
|
||
'default': 'TRC20',
|
||
'options': ['TRC20'],
|
||
'description': 'Currently only TRC20 is supported'
|
||
},
|
||
{
|
||
'key': 'USDT_TRC20_XPUB',
|
||
'label': 'TRC20 XPUB (Watch-only)',
|
||
'type': 'password',
|
||
'required': False,
|
||
'description': 'Watch-only xpub used to derive per-order deposit addresses. Do NOT paste private key.'
|
||
},
|
||
{
|
||
'key': 'USDT_TRC20_CONTRACT',
|
||
'label': 'USDT TRC20 Contract',
|
||
'type': 'text',
|
||
'default': 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj',
|
||
'description': 'USDT contract address on TRON'
|
||
},
|
||
{
|
||
'key': 'TRONGRID_BASE_URL',
|
||
'label': 'TronGrid Base URL',
|
||
'type': 'text',
|
||
'default': 'https://api.trongrid.io',
|
||
'description': 'TronGrid API base URL'
|
||
},
|
||
{
|
||
'key': 'TRONGRID_API_KEY',
|
||
'label': 'TronGrid API Key',
|
||
'type': 'password',
|
||
'required': False,
|
||
'description': 'Optional TronGrid API key for higher rate limits'
|
||
},
|
||
{
|
||
'key': 'USDT_PAY_CONFIRM_SECONDS',
|
||
'label': 'Confirm Delay (sec)',
|
||
'type': 'number',
|
||
'default': '30',
|
||
'description': 'Delay before marking a paid transaction as confirmed (TRC20)'
|
||
},
|
||
{
|
||
'key': 'USDT_PAY_EXPIRE_MINUTES',
|
||
'label': 'Order Expire (min)',
|
||
'type': 'number',
|
||
'default': '30',
|
||
'description': 'USDT payment order expiration time in minutes'
|
||
},
|
||
{
|
||
'key': 'BILLING_COST_AI_ANALYSIS',
|
||
'label': 'AI Analysis Cost (per symbol)',
|
||
'type': 'number',
|
||
'default': '10',
|
||
'description': 'Credits per symbol (instant analysis, AI filter, scheduled tasks all use this price)'
|
||
},
|
||
{
|
||
'key': 'BILLING_COST_AI_CODE_GEN',
|
||
'label': 'AI Code Generation Cost',
|
||
'type': 'number',
|
||
'default': '30',
|
||
'description': 'Credits per AI strategy/indicator code generation (higher token usage)'
|
||
},
|
||
{
|
||
'key': 'CREDITS_REGISTER_BONUS',
|
||
'label': 'Register Bonus',
|
||
'type': 'number',
|
||
'default': '100',
|
||
'description': 'Credits awarded to new users on registration'
|
||
},
|
||
{
|
||
'key': 'CREDITS_REFERRAL_BONUS',
|
||
'label': 'Referral Bonus',
|
||
'type': 'number',
|
||
'default': '50',
|
||
'description': 'Credits awarded to referrer for each signup'
|
||
},
|
||
]
|
||
},
|
||
|
||
}
|
||
|
||
|
||
def read_env_file():
|
||
"""Read .env file"""
|
||
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()
|
||
# Skip empty lines and comments
|
||
if not line or line.startswith('#'):
|
||
continue
|
||
# Parse KEY=VALUE
|
||
if '=' in line:
|
||
key, value = line.split('=', 1)
|
||
key = key.strip()
|
||
value = value.strip()
|
||
# Remove quotes
|
||
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):
|
||
"""Write to .env file, preserving comments and formatting"""
|
||
lines = []
|
||
existing_keys = set()
|
||
|
||
# Read the original file and keep the format
|
||
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()
|
||
|
||
# Keep blank lines and comments
|
||
if not stripped or stripped.startswith('#'):
|
||
lines.append(original_line)
|
||
continue
|
||
|
||
# Update an existing key
|
||
if '=' in stripped:
|
||
key = stripped.split('=', 1)[0].strip()
|
||
if key in env_values:
|
||
existing_keys.add(key)
|
||
value = env_values[key]
|
||
# If the value contains special characters, wrap it in quotes
|
||
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}")
|
||
|
||
# Add new key
|
||
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')
|
||
|
||
# write file
|
||
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'])
|
||
@login_required
|
||
@admin_required
|
||
def get_settings_schema():
|
||
"""Get configuration item definition (admin only)"""
|
||
return jsonify({
|
||
'code': 1,
|
||
'msg': 'success',
|
||
'data': CONFIG_SCHEMA
|
||
})
|
||
|
||
|
||
@settings_bp.route('/values', methods=['GET'])
|
||
@login_required
|
||
@admin_required
|
||
def get_settings_values():
|
||
"""Get current configuration values - including sensitive information (real values) (admin only)"""
|
||
env_values = read_env_file()
|
||
|
||
# Construct return data and return true value
|
||
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
|
||
# Marks whether the password type is configured
|
||
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'])
|
||
@login_required
|
||
@admin_required
|
||
def save_settings():
|
||
"""Save configuration (admin only)"""
|
||
try:
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'code': 0, 'msg': 'Invalid request payload'})
|
||
|
||
# Read current configuration
|
||
current_env = read_env_file()
|
||
|
||
# Update configuration
|
||
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]
|
||
|
||
# Null value handling
|
||
if new_value is None or new_value == '':
|
||
if not item.get('required', True):
|
||
updates[key] = ''
|
||
else:
|
||
updates[key] = str(new_value)
|
||
|
||
# Merge updates
|
||
current_env.update(updates)
|
||
|
||
# write file
|
||
if write_env_file(current_env):
|
||
# Clear configuration cache
|
||
clear_config_cache()
|
||
# Hot reload runtime environment variables (no need to restart the process)
|
||
_reload_runtime_env()
|
||
# Reset the service singleton that depends on the configuration (the next request will be automatically rebuilt according to the new configuration)
|
||
_refresh_runtime_services()
|
||
|
||
return jsonify({
|
||
'code': 1,
|
||
'msg': 'Settings saved successfully',
|
||
'data': {
|
||
'updated_keys': list(updates.keys()),
|
||
'requires_restart': False,
|
||
'hot_reloaded': True,
|
||
'services_refreshed': 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('/openrouter-balance', methods=['GET'])
|
||
@login_required
|
||
@admin_required
|
||
def get_openrouter_balance():
|
||
"""Check OpenRouter account balance (admin only)"""
|
||
try:
|
||
import requests
|
||
from app.config.api_keys import APIKeys
|
||
|
||
api_key = APIKeys.OPENROUTER_API_KEY
|
||
if not api_key:
|
||
return jsonify({
|
||
'code': 0,
|
||
'msg': 'OpenRouter API key is not configured',
|
||
'data': None
|
||
})
|
||
|
||
# Call OpenRouter API to query balance
|
||
# https://openrouter.ai/docs#limits
|
||
resp = requests.get(
|
||
'https://openrouter.ai/api/v1/auth/key',
|
||
headers={
|
||
'Authorization': f'Bearer {api_key}',
|
||
'Content-Type': 'application/json'
|
||
},
|
||
timeout=10
|
||
)
|
||
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
# OpenRouter return format: {"data": {"label": "...", "usage": 0.0, "limit": null, ...}}
|
||
key_data = data.get('data', {})
|
||
usage = key_data.get('usage', 0) # Amount used
|
||
limit = key_data.get('limit') # limit (may be null for no limit)
|
||
limit_remaining = key_data.get('limit_remaining') # remaining balance
|
||
is_free_tier = key_data.get('is_free_tier', False)
|
||
rate_limit = key_data.get('rate_limit', {})
|
||
|
||
return jsonify({
|
||
'code': 1,
|
||
'msg': 'success',
|
||
'data': {
|
||
'usage': round(usage, 4), # Used (USD)
|
||
'limit': limit, # total limit
|
||
'limit_remaining': round(limit_remaining, 4) if limit_remaining is not None else None, # remaining balance
|
||
'is_free_tier': is_free_tier,
|
||
'rate_limit': rate_limit,
|
||
'label': key_data.get('label', '')
|
||
}
|
||
})
|
||
elif resp.status_code == 401:
|
||
return jsonify({
|
||
'code': 0,
|
||
'msg': 'API key is invalid or expired',
|
||
'data': None
|
||
})
|
||
else:
|
||
return jsonify({
|
||
'code': 0,
|
||
'msg': f'Query failed: HTTP {resp.status_code}',
|
||
'data': None
|
||
})
|
||
|
||
except requests.exceptions.Timeout:
|
||
return jsonify({
|
||
'code': 0,
|
||
'msg': 'Request timed out. Please check the network connection.',
|
||
'data': None
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Get OpenRouter balance failed: {e}")
|
||
return jsonify({
|
||
'code': 0,
|
||
'msg': f'Query failed: {str(e)}',
|
||
'data': None
|
||
})
|
||
|
||
|
||
@settings_bp.route('/test-connection', methods=['POST'])
|
||
@login_required
|
||
@admin_required
|
||
def test_connection():
|
||
"""Test API connection (admin only)"""
|
||
try:
|
||
data = request.get_json()
|
||
service = data.get('service')
|
||
|
||
if service == 'openrouter':
|
||
# Test OpenRouter connection
|
||
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':
|
||
# Test Finnhub connection
|
||
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)}'})
|