feat: Multi-user system with PostgreSQL - WIP temporary save

This commit is contained in:
TIANHE
2026-01-14 05:29:55 +08:00
parent 996e3b38fe
commit 61a5e5e6aa
68 changed files with 91057 additions and 1920 deletions
@@ -79,10 +79,11 @@ def _safe_json_loads(value, default=None):
return default
def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str, Any]]:
"""Get positions, optionally filtered by IDs."""
def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = None) -> List[Dict[str, Any]]:
"""Get positions, optionally filtered by IDs and user_id."""
try:
kline_service = KlineService()
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
with get_db_connection() as db:
cur = db.cursor()
@@ -94,7 +95,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str,
FROM qd_manual_positions
WHERE user_id = ? AND id IN ({placeholders})
""",
[DEFAULT_USER_ID] + list(position_ids)
[effective_user_id] + list(position_ids)
)
else:
cur.execute(
@@ -103,7 +104,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str,
FROM qd_manual_positions
WHERE user_id = ?
""",
(DEFAULT_USER_ID,)
(effective_user_id,)
)
rows = cur.fetchall() or []
cur.close()
@@ -726,15 +727,17 @@ def _send_monitor_notification(
positions: List[Dict[str, Any]] = None,
position_analyses: List[Dict[str, Any]] = None,
language: str = 'en-US',
custom_prompt: str = ''
custom_prompt: str = '',
user_id: int = None
) -> None:
"""Send notification with analysis result using appropriate format for each channel."""
try:
notifier = SignalNotifier()
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
channels = notification_config.get('channels', ['browser'])
targets = notification_config.get('targets', {})
title = f"📊 资产监测: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}"
if not result.get('success'):
@@ -751,10 +754,10 @@ def _send_monitor_notification(
cur.execute(
"""
INSERT INTO qd_strategy_notifications
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg,
(effective_user_id, 0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg,
json.dumps(result, ensure_ascii=False), now)
)
db.commit()
@@ -798,10 +801,10 @@ def _send_monitor_notification(
cur.execute(
"""
INSERT INTO qd_strategy_notifications
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
(effective_user_id, 0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
json.dumps(result, ensure_ascii=False), now)
)
db.commit()
@@ -848,24 +851,28 @@ def _send_monitor_notification(
logger.error(f"_send_monitor_notification failed: {e}")
def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[str, Any]:
def run_single_monitor(monitor_id: int, override_language: str = None, user_id: int = None) -> Dict[str, Any]:
"""Run a single monitor and return the result.
Args:
monitor_id: The monitor ID to run
override_language: Optional language override (e.g., 'zh-CN', 'en-US')
If provided, will override the language in monitor config
user_id: Optional user ID for user isolation
"""
try:
# Use provided user_id or default
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, name, position_ids, monitor_type, config, notification_config
SELECT id, user_id, name, position_ids, monitor_type, config, notification_config
FROM qd_position_monitors
WHERE id = ? AND user_id = ?
""",
(monitor_id, DEFAULT_USER_ID)
(monitor_id, effective_user_id)
)
row = cur.fetchone()
cur.close()
@@ -873,6 +880,7 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
if not row:
return {'success': False, 'error': 'Monitor not found'}
monitor_user_id = int(row.get('user_id') or effective_user_id)
name = row.get('name') or f'Monitor #{monitor_id}'
position_ids = _safe_json_loads(row.get('position_ids'), [])
monitor_type = row.get('monitor_type') or 'ai'
@@ -883,8 +891,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
if override_language:
config['language'] = override_language
# Get positions
positions = _get_positions_for_monitor(position_ids if position_ids else None)
# Get positions for this user
positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id)
if not positions:
return {'success': False, 'error': 'No positions to analyze'}
@@ -897,19 +905,21 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
# Update monitor record
now = _now_ts()
interval_minutes = int(config.get('interval_minutes') or 60)
next_run_at = now + (interval_minutes * 60)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
UPDATE qd_position_monitors
SET last_run_at = ?, next_run_at = ?, last_result = ?, run_count = run_count + 1, updated_at = ?
SET last_run_at = NOW(),
next_run_at = NOW() + INTERVAL '%s minutes',
last_result = ?,
run_count = run_count + 1,
updated_at = NOW()
WHERE id = ?
""",
(now, next_run_at, json.dumps(result, ensure_ascii=False), now, monitor_id)
(interval_minutes, json.dumps(result, ensure_ascii=False), monitor_id)
)
db.commit()
cur.close()
@@ -926,7 +936,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
positions=positions,
position_analyses=position_analyses,
language=language,
custom_prompt=custom_prompt
custom_prompt=custom_prompt,
user_id=monitor_user_id
)
return result
@@ -938,24 +949,24 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
def _check_position_alerts():
"""Check all active alerts and trigger notifications if conditions are met."""
from datetime import datetime, timezone
try:
kline_service = KlineService()
notifier = SignalNotifier()
now = _now_ts()
now = datetime.now(timezone.utc)
with get_db_connection() as db:
cur = db.cursor()
# Get active alerts that haven't been triggered (or can repeat)
# Get active alerts for all users that haven't been triggered (or can repeat)
cur.execute(
"""
SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
SELECT a.id, a.user_id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
a.notification_config, a.is_triggered, a.last_triggered_at, a.repeat_interval,
p.entry_price, p.quantity, p.side, p.name as position_name
FROM qd_position_alerts a
LEFT JOIN qd_manual_positions p ON a.position_id = p.id
WHERE a.user_id = ? AND a.is_active = 1
""",
(DEFAULT_USER_ID,)
WHERE a.is_active = 1
"""
)
alerts = cur.fetchall() or []
cur.close()
@@ -963,19 +974,24 @@ def _check_position_alerts():
for alert in alerts:
try:
alert_id = alert.get('id')
alert_user_id = int(alert.get('user_id') or 1)
alert_type = alert.get('alert_type')
threshold = float(alert.get('threshold') or 0)
market = alert.get('market')
symbol = alert.get('symbol')
is_triggered = bool(alert.get('is_triggered'))
last_triggered_at = alert.get('last_triggered_at') or 0
last_triggered_at = alert.get('last_triggered_at') # datetime or None
repeat_interval = int(alert.get('repeat_interval') or 0)
notification_config = _safe_json_loads(alert.get('notification_config'), {})
# Check if we can trigger (not triggered yet, or repeat interval passed)
can_trigger = not is_triggered
if is_triggered and repeat_interval > 0:
if now - last_triggered_at >= repeat_interval:
if is_triggered and repeat_interval > 0 and last_triggered_at:
# Convert last_triggered_at to timezone-aware if needed
if last_triggered_at.tzinfo is None:
last_triggered_at = last_triggered_at.replace(tzinfo=timezone.utc)
elapsed_seconds = (now - last_triggered_at).total_seconds()
if elapsed_seconds >= repeat_interval:
can_trigger = True
if not can_trigger:
@@ -1048,10 +1064,10 @@ def _check_position_alerts():
cur.execute(
"""
UPDATE qd_position_alerts
SET is_triggered = 1, last_triggered_at = ?, trigger_count = trigger_count + 1, updated_at = ?
SET is_triggered = 1, last_triggered_at = NOW(), trigger_count = trigger_count + 1, updated_at = NOW()
WHERE id = ?
""",
(now, now, alert_id)
(alert_id,)
)
db.commit()
cur.close()
@@ -1070,10 +1086,10 @@ def _check_position_alerts():
cur.execute(
"""
INSERT INTO qd_strategy_notifications
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(0, symbol, 'price_alert', 'browser', alert_title, alert_message,
(alert_user_id, 0, symbol, 'price_alert', 'browser', alert_title, alert_message,
json.dumps({'alert_id': alert_id, 'alert_type': alert_type}, ensure_ascii=False), now)
)
db.commit()
@@ -1096,7 +1112,7 @@ def _check_position_alerts():
logger.error(f"_check_position_alerts failed: {e}")
def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str):
def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str, user_id: int = None):
"""
Called when a strategy signal is triggered.
Check if user has manual positions in this symbol and send notification.
@@ -1108,14 +1124,25 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, market, symbol, name, side, quantity, entry_price, group_name
FROM qd_manual_positions
WHERE user_id = ? AND symbol = ?
""",
(DEFAULT_USER_ID, symbol)
)
# Query positions for all users or specific user
if user_id is not None:
cur.execute(
"""
SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name
FROM qd_manual_positions
WHERE user_id = ? AND symbol = ?
""",
(user_id, symbol)
)
else:
cur.execute(
"""
SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name
FROM qd_manual_positions
WHERE symbol = ?
""",
(symbol,)
)
positions = cur.fetchall() or []
cur.close()
@@ -1127,6 +1154,7 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
now = _now_ts()
for pos in positions:
pos_user_id = int(pos.get('user_id') or 1)
pos_name = pos.get('name') or symbol
pos_side = pos.get('side') or 'long'
quantity = float(pos.get('quantity') or 0)
@@ -1149,10 +1177,10 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
cur.execute(
"""
INSERT INTO qd_strategy_notifications
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(0, symbol, 'strategy_linkage', 'browser', title, message,
(pos_user_id, 0, symbol, 'strategy_linkage', 'browser', title, message,
json.dumps({'signal_type': signal_type}, ensure_ascii=False), now)
)
db.commit()
@@ -1170,22 +1198,19 @@ def _monitor_loop():
while not _stop_event.is_set():
try:
now = _now_ts()
# 1. Check position alerts (price/pnl alerts)
# 1. Check position alerts (price/pnl alerts) for all users
_check_position_alerts()
# 2. Find AI monitors that are due
# 2. Find AI monitors that are due for all users
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id FROM qd_position_monitors
WHERE user_id = ? AND is_active = 1 AND next_run_at <= ?
SELECT id, user_id FROM qd_position_monitors
WHERE is_active = 1 AND next_run_at <= NOW()
ORDER BY next_run_at ASC
LIMIT 10
""",
(DEFAULT_USER_ID, now)
"""
)
rows = cur.fetchall() or []
cur.close()
@@ -1194,10 +1219,11 @@ def _monitor_loop():
if _stop_event.is_set():
break
monitor_id = row.get('id')
monitor_user_id = int(row.get('user_id') or 1)
if monitor_id:
logger.info(f"Running due monitor #{monitor_id}")
logger.info(f"Running due monitor #{monitor_id} for user #{monitor_user_id}")
try:
run_single_monitor(monitor_id)
run_single_monitor(monitor_id, user_id=monitor_user_id)
except Exception as e:
logger.error(f"Monitor #{monitor_id} execution failed: {e}")
except Exception as e: