Merge branch 'brokermr810:main' into main
This commit is contained in:
@@ -354,8 +354,16 @@ def summary():
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
recent_trades = cur.fetchall() or []
|
||||
recent_trades_raw = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
# Convert datetime to timestamp for frontend compatibility
|
||||
recent_trades = []
|
||||
for t in recent_trades_raw:
|
||||
trade = dict(t)
|
||||
if trade.get('created_at') and hasattr(trade['created_at'], 'timestamp'):
|
||||
trade['created_at'] = int(trade['created_at'].timestamp())
|
||||
recent_trades.append(trade)
|
||||
|
||||
# Compute performance statistics
|
||||
perf_stats = _compute_performance_stats(recent_trades)
|
||||
|
||||
@@ -228,22 +228,6 @@ CONFIG_SCHEMA = {
|
||||
'default': '10',
|
||||
'description': 'Time-to-live for cached price data in seconds'
|
||||
},
|
||||
{
|
||||
'key': 'MARKET_TYPES_JSON',
|
||||
'label': 'Market Types (JSON)',
|
||||
'type': 'text',
|
||||
'default': '[]',
|
||||
'required': False,
|
||||
'description': 'Custom market type definitions in JSON format'
|
||||
},
|
||||
{
|
||||
'key': 'TRADING_SUPPORTED_SYMBOLS_JSON',
|
||||
'label': 'Supported Symbols (JSON)',
|
||||
'type': 'text',
|
||||
'default': '[]',
|
||||
'required': False,
|
||||
'description': 'List of supported trading symbols in JSON format'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -353,50 +337,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 7. 通知推送 ====================
|
||||
'notification': {
|
||||
'title': 'Notifications',
|
||||
'icon': 'notification',
|
||||
'order': 7,
|
||||
'items': [
|
||||
{
|
||||
'key': 'SIGNAL_WEBHOOK_URL',
|
||||
'label': 'Webhook URL',
|
||||
'type': 'text',
|
||||
'required': False,
|
||||
'description': 'Custom webhook URL for signal notifications (POST JSON)'
|
||||
},
|
||||
{
|
||||
'key': 'SIGNAL_WEBHOOK_TOKEN',
|
||||
'label': 'Webhook Token',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'description': 'Authentication token sent in webhook header'
|
||||
},
|
||||
{
|
||||
'key': 'SIGNAL_NOTIFY_TIMEOUT_SEC',
|
||||
'label': 'Notify Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '6',
|
||||
'description': 'Notification request timeout'
|
||||
},
|
||||
{
|
||||
'key': 'TELEGRAM_BOT_TOKEN',
|
||||
'label': 'Telegram Bot Token',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://t.me/BotFather',
|
||||
'link_text': 'settings.link.createBot',
|
||||
'description': 'Telegram bot token from @BotFather for signal notifications'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 8. 邮件配置 ====================
|
||||
# ==================== 7. 邮件配置 (公共 SMTP) ====================
|
||||
'email': {
|
||||
'title': 'Email (SMTP)',
|
||||
'icon': 'mail',
|
||||
'order': 8,
|
||||
'order': 7,
|
||||
'items': [
|
||||
{
|
||||
'key': 'SMTP_HOST',
|
||||
@@ -454,7 +399,7 @@ CONFIG_SCHEMA = {
|
||||
'sms': {
|
||||
'title': 'SMS (Twilio)',
|
||||
'icon': 'phone',
|
||||
'order': 9,
|
||||
'order': 8,
|
||||
'items': [
|
||||
{
|
||||
'key': 'TWILIO_ACCOUNT_SID',
|
||||
@@ -482,11 +427,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 10. AI Agent 配置 ====================
|
||||
# ==================== 9. AI Agent 配置 ====================
|
||||
'agent': {
|
||||
'title': 'AI Agent',
|
||||
'icon': 'experiment',
|
||||
'order': 10,
|
||||
'order': 9,
|
||||
'items': [
|
||||
{
|
||||
'key': 'ENABLE_AGENT_MEMORY',
|
||||
@@ -568,11 +513,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 11. 网络代理 ====================
|
||||
# ==================== 10. 网络代理 ====================
|
||||
'network': {
|
||||
'title': 'Network & Proxy',
|
||||
'icon': 'global',
|
||||
'order': 11,
|
||||
'order': 10,
|
||||
'items': [
|
||||
{
|
||||
'key': 'PROXY_HOST',
|
||||
@@ -606,11 +551,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 12. 搜索配置 ====================
|
||||
# ==================== 11. 搜索配置 ====================
|
||||
'search': {
|
||||
'title': 'Web Search',
|
||||
'icon': 'search',
|
||||
'order': 12,
|
||||
'order': 11,
|
||||
'items': [
|
||||
{
|
||||
'key': 'SEARCH_PROVIDER',
|
||||
@@ -664,11 +609,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 13. 注册与安全 ====================
|
||||
# ==================== 12. 注册与安全 ====================
|
||||
'security': {
|
||||
'title': 'Registration & Security',
|
||||
'icon': 'safety',
|
||||
'order': 13,
|
||||
'order': 12,
|
||||
'items': [
|
||||
{
|
||||
'key': 'ENABLE_REGISTRATION',
|
||||
@@ -826,11 +771,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 14. 计费配置 ====================
|
||||
# ==================== 13. 计费配置 ====================
|
||||
'billing': {
|
||||
'title': 'Billing & Credits',
|
||||
'icon': 'dollar',
|
||||
'order': 14,
|
||||
'order': 13,
|
||||
'items': [
|
||||
{
|
||||
'key': 'BILLING_ENABLED',
|
||||
@@ -898,11 +843,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 15. 应用配置 ====================
|
||||
# ==================== 14. 应用配置 ====================
|
||||
'app': {
|
||||
'title': 'Application',
|
||||
'icon': 'appstore',
|
||||
'order': 15,
|
||||
'order': 14,
|
||||
'items': [
|
||||
{
|
||||
'key': 'CORS_ORIGINS',
|
||||
|
||||
@@ -471,7 +471,13 @@ def get_equity_curve():
|
||||
equity += float(r.get('profit') or 0)
|
||||
except Exception:
|
||||
pass
|
||||
ts = int(r.get('created_at').timestamp() or time.time())
|
||||
created_at = r.get('created_at')
|
||||
if created_at and hasattr(created_at, 'timestamp'):
|
||||
ts = int(created_at.timestamp())
|
||||
elif created_at:
|
||||
ts = int(created_at)
|
||||
else:
|
||||
ts = int(time.time())
|
||||
curve.append({'time': ts, 'equity': equity})
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': curve})
|
||||
|
||||
@@ -348,9 +348,11 @@ def get_user_credits_log():
|
||||
@user_bp.route('/profile', methods=['GET'])
|
||||
@login_required
|
||||
def get_profile():
|
||||
"""Get current user's profile with billing info"""
|
||||
"""Get current user's profile with billing info and notification settings"""
|
||||
try:
|
||||
import json
|
||||
from app.services.billing_service import get_billing_service
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
@@ -367,6 +369,27 @@ def get_profile():
|
||||
billing_info = get_billing_service().get_user_billing_info(user_id)
|
||||
user['billing'] = billing_info
|
||||
|
||||
# Add notification settings
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT notification_settings FROM qd_users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
settings_str = (row.get('notification_settings') if row else '') or ''
|
||||
notification_settings = {}
|
||||
if settings_str:
|
||||
try:
|
||||
notification_settings = json.loads(settings_str)
|
||||
except Exception:
|
||||
notification_settings = {}
|
||||
|
||||
# Default values
|
||||
if 'default_channels' not in notification_settings:
|
||||
notification_settings['default_channels'] = ['browser']
|
||||
|
||||
user['notification_settings'] = notification_settings
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
@@ -529,6 +552,129 @@ def get_my_referrals():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/notification-settings', methods=['GET'])
|
||||
@login_required
|
||||
def get_notification_settings():
|
||||
"""
|
||||
Get current user's notification settings.
|
||||
|
||||
Returns:
|
||||
notification_settings: {
|
||||
default_channels: ['browser', 'telegram', ...],
|
||||
telegram_chat_id: str,
|
||||
email: str (optional, override for notifications),
|
||||
discord_webhook: str (optional)
|
||||
}
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT notification_settings, email FROM qd_users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
|
||||
|
||||
# Parse notification_settings JSON
|
||||
settings_str = row.get('notification_settings') or ''
|
||||
settings = {}
|
||||
if settings_str:
|
||||
try:
|
||||
settings = json.loads(settings_str)
|
||||
except Exception:
|
||||
settings = {}
|
||||
|
||||
# Default values
|
||||
if 'default_channels' not in settings:
|
||||
settings['default_channels'] = ['browser']
|
||||
if 'email' not in settings:
|
||||
settings['email'] = row.get('email') or ''
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': settings
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_notification_settings failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/notification-settings', methods=['PUT'])
|
||||
@login_required
|
||||
def update_notification_settings():
|
||||
"""
|
||||
Update current user's notification settings.
|
||||
|
||||
Request body:
|
||||
default_channels: list of str (optional, e.g. ['browser', 'telegram'])
|
||||
telegram_bot_token: str (optional, user's own Telegram bot token)
|
||||
telegram_chat_id: str (optional)
|
||||
email: str (optional, for notification override)
|
||||
discord_webhook: str (optional)
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate channels
|
||||
valid_channels = ['browser', 'email', 'telegram', 'discord', 'webhook', 'phone']
|
||||
default_channels = data.get('default_channels', [])
|
||||
if not isinstance(default_channels, list):
|
||||
default_channels = ['browser']
|
||||
default_channels = [c for c in default_channels if c in valid_channels]
|
||||
if not default_channels:
|
||||
default_channels = ['browser']
|
||||
|
||||
# Build settings object
|
||||
settings = {
|
||||
'default_channels': default_channels,
|
||||
'telegram_bot_token': str(data.get('telegram_bot_token') or '').strip(),
|
||||
'telegram_chat_id': str(data.get('telegram_chat_id') or '').strip(),
|
||||
'email': str(data.get('email') or '').strip(),
|
||||
'discord_webhook': str(data.get('discord_webhook') or '').strip(),
|
||||
'webhook_url': str(data.get('webhook_url') or '').strip(),
|
||||
'phone': str(data.get('phone') or '').strip(),
|
||||
}
|
||||
|
||||
# Remove empty values (but keep default_channels and telegram_bot_token even if partially filled)
|
||||
settings = {k: v for k, v in settings.items() if v or k == 'default_channels'}
|
||||
|
||||
settings_json = json.dumps(settings, ensure_ascii=False)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET notification_settings = ?, updated_at = NOW() WHERE id = ?",
|
||||
(settings_json, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Notification settings updated',
|
||||
'data': settings
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"update_notification_settings failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
|
||||
@@ -92,18 +92,18 @@ class SignalNotifier:
|
||||
"""
|
||||
Notify signal events across channels.
|
||||
|
||||
Provider environment variables:
|
||||
- SIGNAL_NOTIFY_TIMEOUT_SEC: HTTP timeout (default: 6)
|
||||
|
||||
- SIGNAL_WEBHOOK_TOKEN: optional bearer token for generic webhook channel
|
||||
|
||||
- TELEGRAM_BOT_TOKEN: Telegram bot token (required for telegram channel)
|
||||
通知配置说明:
|
||||
- 用户在个人中心配置自己的通知设置(telegram_bot_token, telegram_chat_id, email 等)
|
||||
- 创建策略/监控时,系统自动使用用户配置的通知目标
|
||||
|
||||
公共服务配置(管理员在系统设置中配置):
|
||||
- SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_USE_TLS
|
||||
(required for email channel)
|
||||
|
||||
(邮件服务,所有用户共用)
|
||||
- TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER
|
||||
(optional for phone/SMS channel)
|
||||
(短信服务,所有用户共用)
|
||||
|
||||
可选的环境变量:
|
||||
- SIGNAL_NOTIFY_TIMEOUT_SEC: HTTP timeout (default: 6)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -112,10 +112,7 @@ class SignalNotifier:
|
||||
except Exception:
|
||||
self.timeout_sec = 6.0
|
||||
|
||||
self.webhook_token = (os.getenv("SIGNAL_WEBHOOK_TOKEN") or "").strip()
|
||||
|
||||
self.telegram_token = (os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
|
||||
# 公共 SMTP 配置(管理员在系统设置中配置)
|
||||
self.smtp_host = (os.getenv("SMTP_HOST") or "").strip()
|
||||
try:
|
||||
self.smtp_port = int(os.getenv("SMTP_PORT") or "587")
|
||||
@@ -184,7 +181,7 @@ class SignalNotifier:
|
||||
payload=payload,
|
||||
)
|
||||
elif c == "webhook":
|
||||
url = (targets.get("webhook") or os.getenv("SIGNAL_WEBHOOK_URL") or "").strip()
|
||||
url = (targets.get("webhook") or "").strip()
|
||||
ok, err = self._notify_webhook(
|
||||
url=url,
|
||||
payload=payload,
|
||||
@@ -201,19 +198,18 @@ class SignalNotifier:
|
||||
ok, err = self._notify_discord(url=url, payload=payload, fallback_text=message_plain)
|
||||
elif c == "telegram":
|
||||
chat_id = (targets.get("telegram") or "").strip()
|
||||
# Support per-strategy token override (local mode). Falls back to env TELEGRAM_BOT_TOKEN.
|
||||
# User's token takes priority, then falls back to env TELEGRAM_BOT_TOKEN.
|
||||
token_override = ""
|
||||
if not self.telegram_token:
|
||||
try:
|
||||
token_override = str(
|
||||
targets.get("telegram_bot_token")
|
||||
or targets.get("telegram_token")
|
||||
or cfg.get("telegram_bot_token")
|
||||
or cfg.get("telegram_token")
|
||||
or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
token_override = ""
|
||||
try:
|
||||
token_override = str(
|
||||
targets.get("telegram_bot_token")
|
||||
or targets.get("telegram_token")
|
||||
or cfg.get("telegram_bot_token")
|
||||
or cfg.get("telegram_token")
|
||||
or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
token_override = ""
|
||||
ok, err = self._notify_telegram(
|
||||
chat_id=chat_id,
|
||||
text=rendered.get("telegram_html") or message_plain,
|
||||
@@ -500,15 +496,15 @@ class SignalNotifier:
|
||||
"""
|
||||
Generic webhook delivery.
|
||||
|
||||
Supports (best-effort):
|
||||
- per-strategy headers: notification_config.targets.webhook_headers (dict or JSON string)
|
||||
- per-strategy bearer token: notification_config.targets.webhook_token
|
||||
- global bearer token: SIGNAL_WEBHOOK_TOKEN
|
||||
- optional signing secret: notification_config.targets.webhook_signing_secret or env SIGNAL_WEBHOOK_SIGNING_SECRET
|
||||
Adds headers:
|
||||
- X-QD-Timestamp: unix seconds
|
||||
- X-QD-Signature: hex(HMAC_SHA256("{ts}.{body}", secret))
|
||||
- retry once on 429/5xx
|
||||
用户在个人中心配置:
|
||||
- webhook_url: Webhook 地址
|
||||
- webhook_token: Bearer Token(可选)
|
||||
|
||||
支持功能:
|
||||
- 自定义 headers: notification_config.targets.webhook_headers
|
||||
- Bearer Token: notification_config.targets.webhook_token
|
||||
- 签名验证: notification_config.targets.webhook_signing_secret
|
||||
- 自动重试: 429/5xx 时重试一次
|
||||
"""
|
||||
if not url:
|
||||
return False, "missing_webhook_url"
|
||||
@@ -535,10 +531,8 @@ class SignalNotifier:
|
||||
continue
|
||||
headers[kk] = str(v if v is not None else "")
|
||||
|
||||
# Auth (per-strategy token first, fallback to global token)
|
||||
# Auth (user's token from notification_config.targets.webhook_token)
|
||||
tok = str(token_override or "").strip()
|
||||
if not tok:
|
||||
tok = self.webhook_token
|
||||
if tok and "Authorization" not in headers:
|
||||
headers["Authorization"] = f"Bearer {tok}"
|
||||
|
||||
@@ -662,9 +656,10 @@ class SignalNotifier:
|
||||
token_override: str = "",
|
||||
parse_mode: str = "",
|
||||
) -> Tuple[bool, str]:
|
||||
token = (token_override or "").strip() or (self.telegram_token or "").strip()
|
||||
# 用户必须在个人中心配置自己的 telegram_bot_token
|
||||
token = (token_override or "").strip()
|
||||
if not token:
|
||||
return False, "missing_TELEGRAM_BOT_TOKEN"
|
||||
return False, "missing_telegram_bot_token (请在个人中心配置 Telegram Bot Token)"
|
||||
if not chat_id:
|
||||
return False, "missing_telegram_chat_id"
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
@@ -71,10 +71,6 @@ def load_addon_config() -> Dict[str, Any]:
|
||||
('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'),
|
||||
|
||||
Reference in New Issue
Block a user