@@ -0,0 +1,9 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.cache import CacheManager
|
||||
from app.utils.http import get_retry_session
|
||||
|
||||
__all__ = ['get_logger', 'CacheManager', 'get_retry_session']
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
|
||||
import jwt
|
||||
import datetime
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
from app.config.settings import Config
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def generate_token(username):
|
||||
"""Generate JWT token."""
|
||||
try:
|
||||
payload = {
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
'iat': datetime.datetime.utcnow(),
|
||||
'sub': username
|
||||
}
|
||||
return jwt.encode(
|
||||
payload,
|
||||
Config.SECRET_KEY,
|
||||
algorithm='HS256'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Token generation failed: {e}")
|
||||
return None
|
||||
|
||||
def verify_token(token):
|
||||
"""Verify JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256'])
|
||||
return payload['sub']
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator that enforces Bearer token auth."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = None
|
||||
|
||||
# Read token from Authorization: Bearer <token>
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header:
|
||||
parts = auth_header.split()
|
||||
if len(parts) == 2 and parts[0].lower() == 'bearer':
|
||||
token = parts[1]
|
||||
|
||||
if not token:
|
||||
return jsonify({'code': 401, 'msg': 'Token missing', 'data': None}), 401
|
||||
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401
|
||||
|
||||
# Store user in flask.g
|
||||
g.user = username
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Cache utilities.
|
||||
Local-first behavior: use in-memory cache by default.
|
||||
Redis is only used when explicitly enabled via environment variables.
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional, Any
|
||||
import json
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.config import CacheConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MemoryCache:
|
||||
"""内存缓存(Redis 不可用时的备选方案)"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
data, expiry = self._cache[key]
|
||||
if expiry > time.time():
|
||||
return data
|
||||
else:
|
||||
del self._cache[key]
|
||||
return None
|
||||
|
||||
def setex(self, key: str, ttl: int, value: str):
|
||||
with self._lock:
|
||||
expiry = time.time() + ttl
|
||||
self._cache[key] = (value, expiry)
|
||||
|
||||
def delete(self, key: str):
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""缓存管理器"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._client = None
|
||||
self._use_redis = False
|
||||
|
||||
# Local-first: do NOT touch Redis unless explicitly enabled.
|
||||
if not CacheConfig.ENABLED:
|
||||
self._client = MemoryCache()
|
||||
self._use_redis = False
|
||||
return
|
||||
|
||||
# Try Redis only when enabled.
|
||||
try:
|
||||
import redis
|
||||
from app.config import RedisConfig
|
||||
|
||||
self._client = redis.Redis(
|
||||
host=RedisConfig.HOST,
|
||||
port=RedisConfig.PORT,
|
||||
db=RedisConfig.DB,
|
||||
password=RedisConfig.PASSWORD,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=RedisConfig.CONNECT_TIMEOUT,
|
||||
socket_timeout=RedisConfig.SOCKET_TIMEOUT
|
||||
)
|
||||
self._client.ping()
|
||||
self._use_redis = True
|
||||
logger.info("Redis cache connected")
|
||||
except Exception as e:
|
||||
# Fall back silently (keep startup logs clean in local mode).
|
||||
logger.info(f"Redis is enabled but unavailable; using in-memory cache instead: {e}")
|
||||
self._client = MemoryCache()
|
||||
self._use_redis = False
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""获取缓存"""
|
||||
try:
|
||||
data = self._client.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Cache read failed: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int = 300):
|
||||
"""设置缓存"""
|
||||
try:
|
||||
self._client.setex(key, ttl, json.dumps(value))
|
||||
except Exception as e:
|
||||
logger.error(f"Cache write failed: {e}")
|
||||
|
||||
def delete(self, key: str):
|
||||
"""删除缓存"""
|
||||
try:
|
||||
self._client.delete(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Cache delete failed: {e}")
|
||||
|
||||
@property
|
||||
def is_redis(self) -> bool:
|
||||
return self._use_redis
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Config loader (local-only).
|
||||
|
||||
This project is fully localized: all sensitive configuration should come from
|
||||
`backend_api_python/.env` (or OS environment variables).
|
||||
|
||||
We keep the return shape compatible with the old PHP `loadConfig`:
|
||||
flat keys like `openrouter.api_key` become nested dicts like:
|
||||
{
|
||||
"openrouter": {"api_key": "..."}
|
||||
}
|
||||
"""
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import os
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 配置缓存
|
||||
_config_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def load_addon_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Build config from environment variables (.env / OS env).
|
||||
|
||||
NOTE: We intentionally do NOT load secrets from the database.
|
||||
|
||||
Returns:
|
||||
Nested config dict (PHP-compatible shape)
|
||||
"""
|
||||
global _config_cache
|
||||
|
||||
# 如果缓存存在,直接返回
|
||||
if _config_cache is not None:
|
||||
return _config_cache
|
||||
|
||||
config: Dict[str, Any] = {}
|
||||
|
||||
def set_nested(cfg: Dict[str, Any], dotted_key: str, value: Any) -> None:
|
||||
keys = dotted_key.split('.')
|
||||
ref = cfg
|
||||
for i, k in enumerate(keys):
|
||||
if i == len(keys) - 1:
|
||||
ref[k] = value
|
||||
else:
|
||||
if k not in ref or not isinstance(ref[k], dict):
|
||||
ref[k] = {}
|
||||
ref = ref[k]
|
||||
|
||||
def env_get(name: str) -> Optional[str]:
|
||||
val = os.getenv(name)
|
||||
if val is None:
|
||||
return None
|
||||
val = str(val).strip()
|
||||
return val if val != '' else None
|
||||
|
||||
# Map env vars to PHP-style dotted keys.
|
||||
mappings: List[Tuple[str, str, str]] = [
|
||||
# internal
|
||||
('INTERNAL_API_KEY', 'internal_api.key', 'string'),
|
||||
|
||||
# OpenRouter / LLM
|
||||
('OPENROUTER_API_KEY', 'openrouter.api_key', 'string'),
|
||||
('OPENROUTER_API_URL', 'openrouter.api_url', 'string'),
|
||||
('OPENROUTER_MODEL', 'openrouter.model', 'string'),
|
||||
('OPENROUTER_TEMPERATURE', 'openrouter.temperature', 'float'),
|
||||
('OPENROUTER_MAX_TOKENS', 'openrouter.max_tokens', 'int'),
|
||||
('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'),
|
||||
('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'),
|
||||
('AI_MODELS_JSON', 'ai.models', 'json'),
|
||||
|
||||
# Market
|
||||
('MARKET_TYPES_JSON', 'market.types', 'json'),
|
||||
('TRADING_SUPPORTED_SYMBOLS_JSON', 'trading.supported_symbols', 'json'),
|
||||
|
||||
# App
|
||||
('CORS_ORIGINS', 'app.cors_origins', 'string'),
|
||||
('RATE_LIMIT', 'app.rate_limit', 'int'),
|
||||
('ENABLE_CACHE', 'app.enable_cache', 'bool'),
|
||||
('ENABLE_REQUEST_LOG', 'app.enable_request_log', 'bool'),
|
||||
('ENABLE_AI_ANALYSIS', 'app.enable_ai_analysis', 'bool'),
|
||||
|
||||
# Data source common
|
||||
('DATA_SOURCE_TIMEOUT', 'data_source.timeout', 'int'),
|
||||
('DATA_SOURCE_RETRY', 'data_source.retry_count', 'int'),
|
||||
('DATA_SOURCE_RETRY_BACKOFF', 'data_source.retry_backoff', 'float'),
|
||||
|
||||
# Finnhub
|
||||
('FINNHUB_API_KEY', 'finnhub.api_key', 'string'),
|
||||
('FINNHUB_TIMEOUT', 'finnhub.timeout', 'int'),
|
||||
('FINNHUB_RATE_LIMIT', 'finnhub.rate_limit', 'int'),
|
||||
|
||||
# CCXT
|
||||
('CCXT_DEFAULT_EXCHANGE', 'ccxt.default_exchange', 'string'),
|
||||
('CCXT_TIMEOUT', 'ccxt.timeout', 'int'),
|
||||
('CCXT_PROXY', 'ccxt.proxy', 'string'),
|
||||
|
||||
# Other sources
|
||||
('YFINANCE_TIMEOUT', 'yfinance.timeout', 'int'),
|
||||
('AKSHARE_TIMEOUT', 'akshare.timeout', 'int'),
|
||||
('TIINGO_API_KEY', 'tiingo.api_key', 'string'),
|
||||
('TIINGO_TIMEOUT', 'tiingo.timeout', 'int'),
|
||||
|
||||
# Search (Google CSE / Bing)
|
||||
('SEARCH_PROVIDER', 'search.provider', 'string'),
|
||||
('SEARCH_MAX_RESULTS', 'search.max_results', 'int'),
|
||||
('SEARCH_GOOGLE_API_KEY', 'search.google.api_key', 'string'),
|
||||
('SEARCH_GOOGLE_CX', 'search.google.cx', 'string'),
|
||||
('SEARCH_BING_API_KEY', 'search.bing.api_key', 'string'),
|
||||
]
|
||||
|
||||
for env_name, dotted_key, value_type in mappings:
|
||||
raw = env_get(env_name)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
value = _convert_config_value(raw, value_type)
|
||||
set_nested(config, dotted_key, value)
|
||||
except Exception as e:
|
||||
logger.warning(f"Config env parse failed: {env_name} -> {dotted_key}: {e}")
|
||||
|
||||
_config_cache = config
|
||||
return config
|
||||
|
||||
|
||||
def _convert_config_value(value: str, value_type: str) -> Any:
|
||||
"""
|
||||
根据类型转换配置值(与PHP端convertConfigValue方法保持一致)
|
||||
|
||||
Args:
|
||||
value: 配置值字符串(可能为None)
|
||||
value_type: 配置类型
|
||||
|
||||
Returns:
|
||||
转换后的配置值
|
||||
"""
|
||||
# 处理 None 或空值
|
||||
if value is None or value == '':
|
||||
if value_type == 'int':
|
||||
return 0
|
||||
elif value_type == 'float':
|
||||
return 0.0
|
||||
elif value_type == 'bool':
|
||||
return False
|
||||
elif value_type == 'json':
|
||||
return {}
|
||||
else:
|
||||
return ''
|
||||
|
||||
try:
|
||||
if value_type == 'int':
|
||||
return int(value)
|
||||
elif value_type == 'float':
|
||||
return float(value)
|
||||
elif value_type == 'bool':
|
||||
return bool(value) or value == '1' or value == 'true' or value == 'True'
|
||||
elif value_type == 'json':
|
||||
import json
|
||||
try:
|
||||
return json.loads(value) if value else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
else:
|
||||
return str(value) if value is not None else ''
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Config value type conversion failed: value={value}, type={value_type}, error={str(e)}")
|
||||
# 转换失败时返回默认值
|
||||
if value_type == 'int':
|
||||
return 0
|
||||
elif value_type == 'float':
|
||||
return 0.0
|
||||
elif value_type == 'bool':
|
||||
return False
|
||||
elif value_type == 'json':
|
||||
return {}
|
||||
else:
|
||||
return str(value) if value is not None else ''
|
||||
|
||||
|
||||
def get_internal_api_key() -> Optional[str]:
|
||||
"""
|
||||
获取内部API密钥(优先从环境变量读取)
|
||||
|
||||
Returns:
|
||||
内部API密钥,如果未配置则返回None
|
||||
"""
|
||||
try:
|
||||
env_val = os.getenv('INTERNAL_API_KEY', '').strip()
|
||||
if env_val:
|
||||
return env_val
|
||||
|
||||
config = load_addon_config()
|
||||
api_key = config.get('internal_api', {}).get('key')
|
||||
|
||||
if api_key:
|
||||
logger.debug(f"Loaded INTERNAL_API_KEY from env-config shape, length: {len(api_key)}")
|
||||
else:
|
||||
logger.warning("Missing INTERNAL_API_KEY (env).")
|
||||
|
||||
return api_key
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load internal API key: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def clear_config_cache():
|
||||
"""
|
||||
清除配置缓存(配置更新后调用)
|
||||
"""
|
||||
global _config_cache
|
||||
_config_cache = None
|
||||
logger.debug("Addon config cache cleared")
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
|
||||
"""
|
||||
SQLite 数据库连接工具 (本地化适配版)
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional, Any, List, Dict
|
||||
from contextlib import contextmanager
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 数据库文件路径
|
||||
DB_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'quantdinger.db')
|
||||
|
||||
# 线程锁,用于简单的并发控制(SQLite 对写操作有限制)
|
||||
_db_lock = threading.Lock()
|
||||
|
||||
def _init_db_schema(conn):
|
||||
"""初始化数据库表结构"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
def ensure_columns(table: str, columns: Dict[str, str]) -> None:
|
||||
"""
|
||||
Ensure columns exist for an existing SQLite table (simple migration).
|
||||
columns: {column_name: "TYPE DEFAULT ..."}
|
||||
"""
|
||||
try:
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
existing = {row[1] for row in cursor.fetchall() or []} # row[1] is column name
|
||||
for col, ddl in columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
except Exception as e:
|
||||
logger.warning(f"ensure_columns failed for table={table}: {e}")
|
||||
|
||||
# 1. 策略表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategies_trading (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_name TEXT NOT NULL,
|
||||
strategy_type TEXT DEFAULT 'IndicatorStrategy',
|
||||
market_category TEXT DEFAULT 'Crypto',
|
||||
execution_mode TEXT DEFAULT 'signal',
|
||||
notification_config TEXT DEFAULT '', -- JSON string
|
||||
status TEXT DEFAULT 'stopped',
|
||||
symbol TEXT,
|
||||
timeframe TEXT,
|
||||
initial_capital REAL DEFAULT 1000,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
market_type TEXT DEFAULT 'swap',
|
||||
exchange_config TEXT, -- JSON string
|
||||
indicator_config TEXT, -- JSON string
|
||||
trading_config TEXT, -- JSON string
|
||||
ai_model_config TEXT, -- JSON string
|
||||
decide_interval INTEGER DEFAULT 300,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategies_trading", {
|
||||
"market_category": "TEXT DEFAULT 'Crypto'",
|
||||
"execution_mode": "TEXT DEFAULT 'signal'",
|
||||
"notification_config": "TEXT DEFAULT ''"
|
||||
})
|
||||
|
||||
# 2. 持仓表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT,
|
||||
side TEXT, -- long/short
|
||||
size REAL,
|
||||
entry_price REAL,
|
||||
current_price REAL,
|
||||
highest_price REAL DEFAULT 0,
|
||||
lowest_price REAL DEFAULT 0,
|
||||
unrealized_pnl REAL DEFAULT 0,
|
||||
pnl_percent REAL DEFAULT 0,
|
||||
equity REAL DEFAULT 0,
|
||||
updated_at INTEGER,
|
||||
UNIQUE(strategy_id, symbol, side)
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_positions", {
|
||||
"highest_price": "REAL DEFAULT 0",
|
||||
"lowest_price": "REAL DEFAULT 0",
|
||||
})
|
||||
|
||||
# 3. 交易记录表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT,
|
||||
type TEXT, -- open_long, close_short, etc.
|
||||
price REAL,
|
||||
amount REAL,
|
||||
value REAL,
|
||||
commission REAL DEFAULT 0,
|
||||
commission_ccy TEXT DEFAULT '',
|
||||
profit REAL DEFAULT 0,
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_trades", {
|
||||
"commission_ccy": "TEXT DEFAULT ''",
|
||||
})
|
||||
|
||||
# NOTE:
|
||||
# We intentionally do not persist runtime logs in DB for local deployments.
|
||||
# Use console logs / stdout prints instead.
|
||||
|
||||
# 3.1 Pending orders queue (signal dispatch / live execution)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pending_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT NOT NULL,
|
||||
signal_type TEXT NOT NULL, -- open_long/close_long/open_short/close_short/add_long/add_short
|
||||
signal_ts INTEGER, -- candle timestamp (seconds). used for strict de-dup per candle
|
||||
market_type TEXT DEFAULT 'swap',
|
||||
order_type TEXT DEFAULT 'market',
|
||||
amount REAL DEFAULT 0, -- base amount (or stake amount depending on execution)
|
||||
price REAL DEFAULT 0, -- reference price at enqueue time
|
||||
execution_mode TEXT DEFAULT 'signal', -- signal/live
|
||||
status TEXT DEFAULT 'pending', -- pending/processing/sent/failed/deferred
|
||||
priority INTEGER DEFAULT 0,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
max_attempts INTEGER DEFAULT 10,
|
||||
last_error TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT '', -- JSON string for dispatcher
|
||||
-- Live execution result fields (best-effort)
|
||||
dispatch_note TEXT DEFAULT '',
|
||||
exchange_id TEXT DEFAULT '',
|
||||
exchange_order_id TEXT DEFAULT '',
|
||||
exchange_response_json TEXT DEFAULT '',
|
||||
filled REAL DEFAULT 0,
|
||||
avg_price REAL DEFAULT 0,
|
||||
executed_at INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
processed_at INTEGER,
|
||||
sent_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("pending_orders", {
|
||||
"signal_ts": "INTEGER",
|
||||
"market_type": "TEXT DEFAULT 'swap'",
|
||||
"order_type": "TEXT DEFAULT 'market'",
|
||||
"price": "REAL DEFAULT 0",
|
||||
"execution_mode": "TEXT DEFAULT 'signal'",
|
||||
"status": "TEXT DEFAULT 'pending'",
|
||||
"priority": "INTEGER DEFAULT 0",
|
||||
"attempts": "INTEGER DEFAULT 0",
|
||||
"max_attempts": "INTEGER DEFAULT 10",
|
||||
"last_error": "TEXT DEFAULT ''",
|
||||
"payload_json": "TEXT DEFAULT ''",
|
||||
"dispatch_note": "TEXT DEFAULT ''",
|
||||
"exchange_id": "TEXT DEFAULT ''",
|
||||
"exchange_order_id": "TEXT DEFAULT ''",
|
||||
"exchange_response_json": "TEXT DEFAULT ''",
|
||||
"filled": "REAL DEFAULT 0",
|
||||
"avg_price": "REAL DEFAULT 0",
|
||||
"executed_at": "INTEGER",
|
||||
"created_at": "INTEGER",
|
||||
"updated_at": "INTEGER",
|
||||
"processed_at": "INTEGER",
|
||||
"sent_at": "INTEGER",
|
||||
})
|
||||
|
||||
# 3.2 Strategy notifications (browser polling / audit trail)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT DEFAULT '',
|
||||
signal_type TEXT DEFAULT '',
|
||||
channels TEXT DEFAULT '',
|
||||
title TEXT DEFAULT '',
|
||||
message TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT '',
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_notifications", {
|
||||
"strategy_id": "INTEGER",
|
||||
"symbol": "TEXT DEFAULT ''",
|
||||
"signal_type": "TEXT DEFAULT ''",
|
||||
"channels": "TEXT DEFAULT ''",
|
||||
"title": "TEXT DEFAULT ''",
|
||||
"message": "TEXT DEFAULT ''",
|
||||
"payload_json": "TEXT DEFAULT ''",
|
||||
"created_at": "INTEGER",
|
||||
})
|
||||
|
||||
# 4. 指标代码表(参考 MySQL: qd_indicator_codes)
|
||||
# 说明:
|
||||
# - 本地化后统一使用 SQLite,但字段保持与 MySQL 结构接近,便于前端/业务复用。
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_indicator_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
is_buy INTEGER NOT NULL DEFAULT 0,
|
||||
end_time INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
code TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
publish_to_community INTEGER NOT NULL DEFAULT 0,
|
||||
pricing_type TEXT NOT NULL DEFAULT 'free',
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
is_encrypted INTEGER NOT NULL DEFAULT 0,
|
||||
preview_image TEXT DEFAULT '',
|
||||
createtime INTEGER,
|
||||
updatetime INTEGER,
|
||||
-- legacy local columns (kept for backward compatibility)
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# Migrate older local DBs (missing columns) to the new schema shape.
|
||||
ensure_columns("qd_indicator_codes", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"is_buy": "INTEGER NOT NULL DEFAULT 0",
|
||||
"end_time": "INTEGER NOT NULL DEFAULT 1",
|
||||
"publish_to_community": "INTEGER NOT NULL DEFAULT 0",
|
||||
"pricing_type": "TEXT NOT NULL DEFAULT 'free'",
|
||||
"price": "REAL NOT NULL DEFAULT 0",
|
||||
"is_encrypted": "INTEGER NOT NULL DEFAULT 0",
|
||||
"preview_image": "TEXT DEFAULT ''",
|
||||
"createtime": "INTEGER",
|
||||
"updatetime": "INTEGER"
|
||||
})
|
||||
|
||||
# 4.1 策略代码表(indicator-analysis 本地策略;与交易执行器的 qd_strategies_trading 区分)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
code TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
createtime INTEGER,
|
||||
updatetime INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_codes", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"name": "TEXT NOT NULL DEFAULT ''",
|
||||
"code": "TEXT",
|
||||
"description": "TEXT DEFAULT ''",
|
||||
"createtime": "INTEGER",
|
||||
"updatetime": "INTEGER"
|
||||
})
|
||||
|
||||
# 5. AI决策记录表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_ai_decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
decision_data TEXT, -- JSON
|
||||
context_data TEXT, -- JSON
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# 6. 插件/系统配置表(原来由 MySQL 提供,这里用 SQLite 本地化)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_addon_config (
|
||||
config_key TEXT PRIMARY KEY,
|
||||
config_value TEXT,
|
||||
type TEXT DEFAULT 'string'
|
||||
)
|
||||
""")
|
||||
|
||||
# 7. Watchlist (local-only, single-user by default)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
UNIQUE(user_id, market, symbol)
|
||||
)
|
||||
""")
|
||||
|
||||
# 8. Analysis tasks / history (local-only)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_analysis_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
model TEXT DEFAULT '',
|
||||
language TEXT DEFAULT 'en-US',
|
||||
status TEXT DEFAULT 'completed', -- completed/failed/processing/pending
|
||||
result_json TEXT DEFAULT '',
|
||||
error_message TEXT DEFAULT '',
|
||||
created_at INTEGER,
|
||||
completed_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# 9. Backtest runs (for AI optimization / history)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_backtest_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
indicator_id INTEGER,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
end_date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
initial_capital REAL DEFAULT 10000,
|
||||
commission REAL DEFAULT 0.001,
|
||||
slippage REAL DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
trade_direction TEXT DEFAULT 'long',
|
||||
strategy_config TEXT DEFAULT '', -- JSON string
|
||||
status TEXT DEFAULT 'success', -- success/failed
|
||||
error_message TEXT DEFAULT '',
|
||||
result_json TEXT DEFAULT '', -- JSON string
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_backtest_runs", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"indicator_id": "INTEGER",
|
||||
"market": "TEXT NOT NULL DEFAULT ''",
|
||||
"symbol": "TEXT NOT NULL DEFAULT ''",
|
||||
"timeframe": "TEXT NOT NULL DEFAULT ''",
|
||||
"start_date": "TEXT NOT NULL DEFAULT ''",
|
||||
"end_date": "TEXT NOT NULL DEFAULT ''",
|
||||
"initial_capital": "REAL DEFAULT 10000",
|
||||
"commission": "REAL DEFAULT 0.001",
|
||||
"slippage": "REAL DEFAULT 0",
|
||||
"leverage": "INTEGER DEFAULT 1",
|
||||
"trade_direction": "TEXT DEFAULT 'long'",
|
||||
"strategy_config": "TEXT DEFAULT ''",
|
||||
"status": "TEXT DEFAULT 'success'",
|
||||
"error_message": "TEXT DEFAULT ''",
|
||||
"result_json": "TEXT DEFAULT ''",
|
||||
"created_at": "INTEGER"
|
||||
})
|
||||
|
||||
# 10. Exchange credentials vault (local-only)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_exchange_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT DEFAULT '',
|
||||
exchange_id TEXT NOT NULL,
|
||||
api_key_hint TEXT DEFAULT '',
|
||||
encrypted_config TEXT NOT NULL, -- encrypted JSON string
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_exchange_credentials", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"name": "TEXT DEFAULT ''",
|
||||
"exchange_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"api_key_hint": "TEXT DEFAULT ''",
|
||||
"encrypted_config": "TEXT NOT NULL DEFAULT ''",
|
||||
"created_at": "INTEGER",
|
||||
"updated_at": "INTEGER"
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database schema initialized (SQLite)")
|
||||
|
||||
# 初始化一次
|
||||
_has_initialized = False
|
||||
|
||||
class SQLiteCursor:
|
||||
"""模拟 pymysql DictCursor"""
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def execute(self, query: str, args: Any = None):
|
||||
# 适配 MySQL -> SQLite 语法
|
||||
# 1. 替换占位符: %s -> ?
|
||||
query = query.replace('%s', '?')
|
||||
# 2. 替换 INSERT IGNORE -> INSERT OR IGNORE
|
||||
query = query.replace('INSERT IGNORE', 'INSERT OR IGNORE')
|
||||
# 3. 替换 ON DUPLICATE KEY UPDATE -> 简化为 UPSERT (SQLite 3.24+)
|
||||
# 注意:复杂的 ON DUPLICATE KEY UPDATE 很难自动转换,建议业务代码改写
|
||||
# 这里做一个简单的替换尝试,如果失败则需要人工介入代码
|
||||
if 'ON DUPLICATE KEY UPDATE' in query:
|
||||
# 简单的正则替换很难完美,这里记录日志提醒
|
||||
logger.warning(f"Complex SQL may require manual SQLite adaptation: {query}")
|
||||
# 尝试转换为 SQLite 的 ON CONFLICT (id) DO UPDATE SET ...
|
||||
# 但由于不知道主键冲突列,很难自动转换。
|
||||
# 临时方案:如果遇到这种 SQL,可能报错。我们假设主要业务逻辑已经重构。
|
||||
pass
|
||||
|
||||
if args:
|
||||
return self._cursor.execute(query, args)
|
||||
return self._cursor.execute(query)
|
||||
|
||||
def fetchone(self):
|
||||
row = self._cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
# Convert sqlite3.Row to dict
|
||||
return dict(row)
|
||||
|
||||
def fetchall(self):
|
||||
rows = self._cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def close(self):
|
||||
self._cursor.close()
|
||||
|
||||
@property
|
||||
def lastrowid(self):
|
||||
return self._cursor.lastrowid
|
||||
|
||||
class SQLiteConnection:
|
||||
"""数据库连接包装类"""
|
||||
def __init__(self, db_path):
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30.0)
|
||||
# 设置 Row factory 以支持字段名访问
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
|
||||
def cursor(self):
|
||||
return SQLiteCursor(self._conn.cursor())
|
||||
|
||||
def commit(self):
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
"""
|
||||
获取数据库连接 (Context Manager)
|
||||
"""
|
||||
global _has_initialized
|
||||
|
||||
# 简单的连接创建,不使用连接池(SQLite 文件锁机制决定了连接池意义不大)
|
||||
# 使用线程锁防止写冲突(虽然 SQLite 有 WAL 模式,但稳妥起见)
|
||||
# 注意:这里加锁粒度较大,如果是高并发场景可能会慢,但对于个人量化系统足够。
|
||||
|
||||
# 初始化表结构
|
||||
if not _has_initialized:
|
||||
try:
|
||||
conn_init = sqlite3.connect(DB_FILE)
|
||||
_init_db_schema(conn_init)
|
||||
conn_init.close()
|
||||
_has_initialized = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
|
||||
conn = SQLiteConnection(DB_FILE)
|
||||
try:
|
||||
# with _db_lock: # SQLite 内部有锁,这里如果不跨线程共享连接其实不用强加锁
|
||||
yield conn
|
||||
except Exception as e:
|
||||
logger.error(f"Database operation error: {e}")
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_db_connection_sync():
|
||||
"""兼容旧接口"""
|
||||
global _has_initialized
|
||||
if not _has_initialized:
|
||||
try:
|
||||
conn_init = sqlite3.connect(DB_FILE)
|
||||
_init_db_schema(conn_init)
|
||||
conn_init.close()
|
||||
_has_initialized = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
|
||||
return SQLiteConnection(DB_FILE)
|
||||
|
||||
def close_db_connection():
|
||||
pass
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
HTTP 工具模块
|
||||
"""
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
def get_retry_session(
|
||||
retries: int = 3,
|
||||
backoff_factor: float = 0.5,
|
||||
status_forcelist: tuple = (500, 502, 503, 504)
|
||||
) -> requests.Session:
|
||||
"""
|
||||
获取带重试机制的 HTTP Session
|
||||
|
||||
Args:
|
||||
retries: 重试次数
|
||||
backoff_factor: 重试间隔因子
|
||||
status_forcelist: 需要重试的 HTTP 状态码
|
||||
|
||||
Returns:
|
||||
配置好的 Session 实例
|
||||
"""
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=retries,
|
||||
read=retries,
|
||||
connect=retries,
|
||||
backoff_factor=backoff_factor,
|
||||
status_forcelist=status_forcelist,
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount('http://', adapter)
|
||||
session.mount('https://', adapter)
|
||||
return session
|
||||
|
||||
|
||||
# 全局共享 Session
|
||||
global_session = get_retry_session()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Language helpers (local-only).
|
||||
|
||||
We want AI analysis output language to follow the frontend UI language.
|
||||
Frontend sends `X-App-Lang` (and also `Accept-Language`) on each request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
SUPPORTED_LANGS = {
|
||||
"en-US",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"ja-JP",
|
||||
"ko-KR",
|
||||
"vi-VN",
|
||||
"th-TH",
|
||||
"ar-SA",
|
||||
"fr-FR",
|
||||
"de-DE",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_lang(raw: Optional[str]) -> Optional[str]:
|
||||
if not raw:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
|
||||
# Accept-Language can be like: "en-US,en;q=0.9"
|
||||
if "," in s:
|
||||
s = s.split(",", 1)[0].strip()
|
||||
if ";" in s:
|
||||
s = s.split(";", 1)[0].strip()
|
||||
|
||||
# Normalize short tags
|
||||
lower = s.lower()
|
||||
if lower in ("en", "en-us"):
|
||||
return "en-US"
|
||||
if lower in ("zh", "zh-cn", "zh-hans"):
|
||||
return "zh-CN"
|
||||
if lower in ("zh-tw", "zh-hant"):
|
||||
return "zh-TW"
|
||||
|
||||
# Keep canonical casing if already supported
|
||||
for lang in SUPPORTED_LANGS:
|
||||
if lang.lower() == lower:
|
||||
return lang
|
||||
return None
|
||||
|
||||
|
||||
def detect_request_language(flask_request, body: Optional[dict] = None, default: str = "en-US") -> str:
|
||||
"""
|
||||
Detect language for the current request.
|
||||
|
||||
Priority:
|
||||
1) Header X-App-Lang (frontend UI language)
|
||||
2) body["language"] or query ?language=
|
||||
3) Header Accept-Language
|
||||
"""
|
||||
# 1) Custom header
|
||||
lang = _normalize_lang(flask_request.headers.get("X-App-Lang"))
|
||||
if lang:
|
||||
return lang
|
||||
|
||||
# 2) Explicit parameter
|
||||
if body and isinstance(body, dict):
|
||||
lang = _normalize_lang(body.get("language"))
|
||||
if lang:
|
||||
return lang
|
||||
lang = _normalize_lang(flask_request.args.get("language"))
|
||||
if lang:
|
||||
return lang
|
||||
|
||||
# 3) Browser default
|
||||
lang = _normalize_lang(flask_request.headers.get("Accept-Language"))
|
||||
if lang:
|
||||
return lang
|
||||
|
||||
return default
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Logging utilities (local-only friendly).
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
|
||||
def setup_logger():
|
||||
"""配置全局日志"""
|
||||
log_level = os.getenv('LOG_LEVEL', 'INFO')
|
||||
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format=log_format
|
||||
)
|
||||
|
||||
# 创建日志目录
|
||||
log_dir = 'logs'
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
# 添加文件处理器
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(log_format))
|
||||
logging.getLogger().addHandler(file_handler)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
获取指定名称的日志记录器
|
||||
|
||||
Args:
|
||||
name: 日志记录器名称
|
||||
|
||||
Returns:
|
||||
Logger 实例
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
安全的代码执行工具
|
||||
提供超时、资源限制和沙箱环境
|
||||
"""
|
||||
import signal
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from contextlib import contextmanager
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TimeoutError(Exception):
|
||||
"""代码执行超时异常"""
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def timeout_context(seconds: int):
|
||||
"""
|
||||
代码执行超时上下文管理器
|
||||
|
||||
注意:
|
||||
- 仅在Unix/Linux系统上有效
|
||||
- 仅在主线程中有效,非主线程会降级为不限制超时
|
||||
- Windows上会降级为不限制超时
|
||||
|
||||
Args:
|
||||
seconds: 超时时间(秒)
|
||||
"""
|
||||
# 检查是否在主线程中
|
||||
is_main_thread = threading.current_thread() is threading.main_thread()
|
||||
|
||||
if sys.platform == 'win32':
|
||||
# Windows不支持signal.alarm,只能记录警告
|
||||
logger.warning("Windows does not support signal-based timeouts; execution time limits may not work")
|
||||
yield
|
||||
return
|
||||
|
||||
if not is_main_thread:
|
||||
# 非主线程不能使用signal,记录警告但不限制超时
|
||||
# logger.warning(f"当前在非主线程中运行(线程: {threading.current_thread().name}),"
|
||||
# f"signal超时不可用,代码执行可能无法限制时间")
|
||||
yield
|
||||
return
|
||||
|
||||
def timeout_handler(signum, frame):
|
||||
raise TimeoutError(f"代码执行超时(超过{seconds}秒)")
|
||||
|
||||
try:
|
||||
# 设置信号处理器
|
||||
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
||||
signal.alarm(seconds)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 恢复原来的信号处理器
|
||||
signal.alarm(0)
|
||||
signal.signal(signal.SIGALRM, old_handler)
|
||||
except ValueError as e:
|
||||
# 如果signal设置失败(比如在某些环境中),记录警告但不中断执行
|
||||
logger.warning(f"Failed to set signal timeout: {str(e)}; execution will continue without timeout enforcement")
|
||||
yield
|
||||
|
||||
|
||||
def safe_exec_code(
|
||||
code: str,
|
||||
exec_globals: Dict[str, Any],
|
||||
exec_locals: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
max_memory_mb: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
安全执行Python代码
|
||||
|
||||
Args:
|
||||
code: 要执行的Python代码
|
||||
exec_globals: 全局变量字典
|
||||
exec_locals: 局部变量字典(如果为None,则使用exec_globals)
|
||||
timeout: 超时时间(秒),默认30秒
|
||||
max_memory_mb: 最大内存限制(MB),默认500MB
|
||||
|
||||
Returns:
|
||||
执行结果字典,包含:
|
||||
- success: bool,是否执行成功
|
||||
- error: str,错误信息(如果失败)
|
||||
- result: Any,执行结果(如果有)
|
||||
|
||||
Raises:
|
||||
TimeoutError: 如果代码执行超时
|
||||
"""
|
||||
if exec_locals is None:
|
||||
exec_locals = exec_globals
|
||||
|
||||
# 设置内存限制(如果支持)
|
||||
if max_memory_mb is None:
|
||||
max_memory_mb = 500 # 默认500MB
|
||||
|
||||
try:
|
||||
# 注意:resource.setrlimit 是进程级别,会影响整个 API 进程。
|
||||
# 之前全局限制为 500MB 可能导致并行策略/线程无法分配内存。
|
||||
# 仅当显式开启 SAFE_EXEC_ENABLE_RLIMIT 时才设置。
|
||||
if sys.platform != 'win32' and os.getenv('SAFE_EXEC_ENABLE_RLIMIT', 'false').lower() == 'true':
|
||||
try:
|
||||
import resource
|
||||
max_memory_bytes = max_memory_mb * 1024 * 1024
|
||||
resource.setrlimit(resource.RLIMIT_AS, (max_memory_bytes, max_memory_bytes))
|
||||
logger.debug(f"Memory limit set: {max_memory_mb}MB (SAFE_EXEC_ENABLE_RLIMIT enabled)")
|
||||
except (ImportError, ValueError, OSError) as e:
|
||||
logger.warning(f"Failed to set memory limit: {str(e)}")
|
||||
else:
|
||||
logger.debug("No resource memory limit (SAFE_EXEC_ENABLE_RLIMIT disabled or unsupported platform)")
|
||||
|
||||
# 在Windows上,timeout_context不会真正限制时间
|
||||
# 但会记录警告
|
||||
with timeout_context(timeout):
|
||||
exec(code, exec_globals, exec_locals)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'error': None,
|
||||
'result': None
|
||||
}
|
||||
|
||||
except MemoryError as e:
|
||||
error_msg = f"代码执行内存不足(超过{max_memory_mb}MB限制)"
|
||||
logger.error(f"Code execution out of memory (limit={max_memory_mb}MB)")
|
||||
return {
|
||||
'success': False,
|
||||
'error': error_msg,
|
||||
'result': None
|
||||
}
|
||||
except TimeoutError as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"Code execution timed out (timeout={timeout}s)")
|
||||
return {
|
||||
'success': False,
|
||||
'error': error_msg,
|
||||
'result': None
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = f"代码执行错误: {str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(f"Code execution error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return {
|
||||
'success': False,
|
||||
'error': error_msg,
|
||||
'result': None
|
||||
}
|
||||
|
||||
|
||||
def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证代码安全性(基本检查)
|
||||
|
||||
检查代码中是否包含危险的函数调用或导入
|
||||
|
||||
Args:
|
||||
code: 要检查的Python代码
|
||||
|
||||
Returns:
|
||||
(is_safe: bool, error_message: Optional[str])
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
|
||||
# 危险的关键字和函数名
|
||||
dangerous_patterns = [
|
||||
# 系统命令执行
|
||||
r'\bos\.system\b',
|
||||
r'\bos\.popen\b',
|
||||
r'\bos\.spawn\b',
|
||||
r'\bos\.exec\b',
|
||||
r'\bos\.fork\b',
|
||||
r'\bsubprocess\b',
|
||||
r'\bcommands\b',
|
||||
# 代码执行
|
||||
r'\b__import__\s*\(',
|
||||
r'\beval\s*\(',
|
||||
r'\bexec\s*\(',
|
||||
r'\bcompile\s*\(',
|
||||
# 文件操作
|
||||
r'\bopen\s*\(',
|
||||
r'\bfile\s*\(',
|
||||
r'\b__builtins__\b',
|
||||
# 模块导入
|
||||
r'\bimport\s+os\b',
|
||||
r'\bimport\s+sys\b',
|
||||
r'\bimport\s+subprocess\b',
|
||||
r'\bimport\s+pymysql\b',
|
||||
r'\bimport\s+sqlite3\b',
|
||||
r'\bimport\s+requests\b',
|
||||
r'\bimport\s+urllib\b',
|
||||
r'\bimport\s+http\b',
|
||||
r'\bimport\s+socket\b',
|
||||
r'\bimport\s+ftplib\b',
|
||||
r'\bimport\s+telnetlib\b',
|
||||
r'\bimport\s+pickle\b',
|
||||
r'\bimport\s+cpickle\b',
|
||||
r'\bimport\s+marshal\b',
|
||||
r'\bimport\s+ctypes\b',
|
||||
r'\bimport\s+multiprocessing\b',
|
||||
r'\bimport\s+threading\b',
|
||||
r'\bimport\s+concurrent\b',
|
||||
# 反射和元编程(可能用于绕过限制)
|
||||
r'\bgetattr\s*\(.*__import__',
|
||||
r'\bgetattr\s*\(.*eval',
|
||||
r'\bgetattr\s*\(.*exec',
|
||||
r'\bsetattr\s*\(',
|
||||
r'\b__getattribute__\b',
|
||||
r'\b__setattr__\b',
|
||||
r'\b__dict__\b',
|
||||
r'\bglobals\s*\(',
|
||||
r'\blocals\s*\(',
|
||||
r'\bdir\s*\(',
|
||||
r'\btype\s*\(.*\)\s*\(', # type() 可能用于创建新类型
|
||||
r'\b__class__\b',
|
||||
r'\b__bases__\b',
|
||||
r'\b__subclasses__\b',
|
||||
r'\b__mro__\b',
|
||||
r'\b__init__\b.*__import__',
|
||||
r'\b__new__\b.*__import__',
|
||||
# 其他危险操作
|
||||
r'\b__builtins__\s*\[',
|
||||
r'\b__builtins__\s*\.',
|
||||
r'\b__import__\s*\(',
|
||||
r'\bimportlib\b',
|
||||
r'\bimp\b',
|
||||
]
|
||||
|
||||
# 检查代码中是否包含危险模式
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, code):
|
||||
return False, f"检测到危险代码模式: {pattern}"
|
||||
|
||||
# 尝试解析AST,检查是否有危险的节点
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
|
||||
# 危险模块列表(扩展)
|
||||
dangerous_modules = [
|
||||
'os', 'sys', 'subprocess', 'pymysql', 'sqlite3',
|
||||
'requests', 'urllib', 'http', 'socket', 'ftplib', 'telnetlib',
|
||||
'pickle', 'cpickle', 'marshal', 'ctypes',
|
||||
'multiprocessing', 'threading', 'concurrent',
|
||||
'importlib', 'imp', 'builtins'
|
||||
]
|
||||
|
||||
# 危险函数列表(扩展)
|
||||
# 注意:hasattr 是安全的,只用于检查属性,不用于访问
|
||||
dangerous_functions = [
|
||||
'eval', 'exec', 'compile', '__import__',
|
||||
'getattr', 'setattr', 'delattr', # hasattr 已移除,它是安全的
|
||||
'globals', 'locals', 'vars', 'dir', 'type'
|
||||
]
|
||||
|
||||
# 检查是否有危险的函数调用
|
||||
for node in ast.walk(tree):
|
||||
# 检查是否有对危险函数的调用
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name):
|
||||
func_name = node.func.id
|
||||
if func_name in dangerous_functions:
|
||||
return False, f"检测到危险函数调用: {func_name}()"
|
||||
|
||||
# 检查是否有os.system等调用
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
if isinstance(node.func.value, ast.Name):
|
||||
if node.func.value.id in dangerous_modules:
|
||||
return False, f"检测到危险模块调用: {node.func.value.id}.{node.func.attr}"
|
||||
|
||||
# 检查是否有 getattr(builtins, '__import__') 等绕过方式
|
||||
if isinstance(node.func, ast.Name) and node.func.id == 'getattr':
|
||||
# 检查 getattr 的参数
|
||||
if len(node.args) >= 2:
|
||||
if isinstance(node.args[0], ast.Name) and node.args[0].id in ['builtins', '__builtins__']:
|
||||
if isinstance(node.args[1], ast.Constant) and node.args[1].value in dangerous_functions:
|
||||
return False, f"检测到通过 getattr 绕过限制: getattr({node.args[0].id}, '{node.args[1].value}')"
|
||||
|
||||
# 检查导入语句
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name in dangerous_modules:
|
||||
return False, f"检测到危险模块导入: {alias.name}"
|
||||
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.split('.')[0] in dangerous_modules:
|
||||
return False, f"检测到危险模块导入: {node.module}"
|
||||
|
||||
# 检查是否有访问 __builtins__ 的尝试
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute):
|
||||
if isinstance(node.attr, str) and node.attr.startswith('__') and node.attr.endswith('__'):
|
||||
if node.attr in ['__builtins__', '__import__', '__class__', '__bases__', '__subclasses__', '__mro__']:
|
||||
# 检查是否在危险上下文中使用
|
||||
if isinstance(node.value, ast.Name) and node.value.id in ['builtins', '__builtins__']:
|
||||
return False, f"检测到访问危险属性: {node.value.id}.{node.attr}"
|
||||
|
||||
except SyntaxError as e:
|
||||
return False, f"代码语法错误: {str(e)}"
|
||||
except Exception as e:
|
||||
# 如果AST解析失败,记录警告但允许继续(可能是代码不完整)
|
||||
logger.warning(f"AST parse failed; skipping safety checks: {str(e)}")
|
||||
|
||||
return True, None
|
||||
Reference in New Issue
Block a user