87f2845483
- Cleaned up whitespace and formatting in various files including http.py, language.py, logger.py, safe_exec.py, and SQL migration scripts. - Consolidated import statements and removed unnecessary blank lines. - Updated logging configuration for better clarity. - Enhanced the safe execution code with improved error handling and logging. - Removed commented-out code and unnecessary variables in backfill_zero_trades.py and other scripts. - Added a pyproject.toml for Ruff and Vulture configuration. - Introduced requirements-dev.txt for development dependencies. - Removed commented-out stock entries in init.sql for cleaner migration scripts.
1083 lines
41 KiB
Python
1083 lines
41 KiB
Python
"""
|
||
Settings API - Reading and saving .env configurations
|
||
|
||
Admin-only endpoints for system configuration management.
|
||
"""
|
||
|
||
import importlib
|
||
import os
|
||
|
||
from dotenv import load_dotenv
|
||
from flask import Blueprint, jsonify, request
|
||
|
||
from app.utils.auth import admin_required, login_required
|
||
from app.utils.config_loader import clear_config_cache
|
||
from app.utils.logger import get_logger
|
||
|
||
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",
|
||
},
|
||
],
|
||
},
|
||
# ==================== 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)}"})
|