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
@@ -97,6 +97,227 @@ class AgentCoordinator:
self.neutral_analyst = NeutralAnalyst()
self.safe_analyst = SafeAnalyst()
def run_analysis_stream(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D", on_progress=None):
"""
Run the full multi-agent analysis workflow with progress callbacks.
Args:
on_progress: Callback function that receives (agent_name: str, status: str, result: Optional[dict])
status can be: 'started', 'completed', 'error'
Yields:
Progress events as dicts: { 'agent': str, 'status': str, 'result': dict or None }
"""
logger.info(f"Multi-agent stream analysis start: {market}:{symbol}, model={model}, language={language}")
def emit_progress(agent: str, status: str, result: dict = None):
"""Emit progress event."""
if on_progress:
on_progress(agent, status, result)
# Build base context
from .tools import AgentTools
tools = AgentTools()
emit_progress('data_collection', 'started', None)
# 1) Base data
current_price = tools.get_current_price(market, symbol)
company_data = tools.get_company_data(market, symbol, language=language)
# Normalize timeframe
tf = (timeframe or "1D").strip()
# 2) Kline + fundamentals
kline_data = tools.get_stock_data(market, symbol, days=30, timeframe=tf)
fundamental_data = tools.get_fundamental_data(market, symbol)
indicators = tools.calculate_technical_indicators(kline_data or [])
# 3) News (Finnhub + web search)
company_name = company_data.get('name', symbol) if company_data else symbol
news_data = tools.get_news(market, symbol, days=7, company_name=company_name)
base_data = {
"market": market,
"symbol": symbol,
"current_price": current_price,
"kline_data": kline_data,
"fundamental_data": fundamental_data,
"company_data": company_data,
"news_data": news_data,
"indicators": indicators,
}
context = {
"market": market,
"symbol": symbol,
"language": language,
"model": model,
"timeframe": tf,
"memory_features": {
"timeframe": tf,
"price": (current_price or {}).get("price"),
"changePercent": (current_price or {}).get("changePercent"),
"indicators": indicators,
},
"base_data": base_data
}
emit_progress('data_collection', 'completed', None)
# Phase 1: Analysts (parallel but report individually)
logger.info("Phase 1: Analyst team")
# Fundamental Analyst
emit_progress('fundamental', 'started', None)
fundamental_report = self.fundamental_analyst.analyze(context)
emit_progress('fundamental', 'completed', fundamental_report.get('data', {}))
# Technical Analyst (market_analyst)
emit_progress('technical', 'started', None)
market_report = self.market_analyst.analyze(context)
emit_progress('technical', 'completed', market_report.get('data', {}))
# News Analyst
emit_progress('news', 'started', None)
news_report = self.news_analyst.analyze(context)
emit_progress('news', 'completed', news_report.get('data', {}))
# Sentiment Analyst
emit_progress('sentiment', 'started', None)
sentiment_report = self.sentiment_analyst.analyze(context)
emit_progress('sentiment', 'completed', sentiment_report.get('data', {}))
# Risk Analyst
emit_progress('risk', 'started', None)
risk_report = self.risk_analyst.analyze(context)
emit_progress('risk', 'completed', risk_report.get('data', {}))
# Update context with analyst outputs
context.update({
"market_report": market_report,
"fundamental_report": fundamental_report,
"news_report": news_report,
"sentiment_report": sentiment_report,
"risk_report": risk_report,
})
# Phase 2: Bull/Bear debate
logger.info("Phase 2: Research debate")
emit_progress('debate_bull', 'started', None)
bull_argument = self.bull_researcher.analyze(context)
emit_progress('debate_bull', 'completed', bull_argument.get('data', {}))
emit_progress('debate_bear', 'started', None)
bear_argument = self.bear_researcher.analyze(context)
emit_progress('debate_bear', 'completed', bear_argument.get('data', {}))
context["bull_argument"] = bull_argument
context["bear_argument"] = bear_argument
# Research manager decision
emit_progress('debate_research', 'started', None)
research_decision = self._make_research_decision(bull_argument, bear_argument, context)
context["research_decision"] = research_decision
emit_progress('debate_research', 'completed', {'research_decision': research_decision})
# Phase 3: Trader decision
logger.info("Phase 3: Trader decision")
emit_progress('trader_decision', 'started', None)
trader_result = self.trader_agent.analyze(context)
trader_plan = trader_result.get('data', {}).get('trading_plan', {})
context["trader_plan"] = trader_plan
emit_progress('trader_decision', 'completed', trader_result.get('data', {}))
# Phase 4: Risk debate
logger.info("Phase 4: Risk debate")
emit_progress('risk_debate_risky', 'started', None)
risky_result = self.risky_analyst.analyze(context)
emit_progress('risk_debate_risky', 'completed', risky_result.get('data', {}))
emit_progress('risk_debate_neutral', 'started', None)
neutral_result = self.neutral_analyst.analyze(context)
emit_progress('risk_debate_neutral', 'completed', neutral_result.get('data', {}))
emit_progress('risk_debate_safe', 'started', None)
safe_result = self.safe_analyst.analyze(context)
emit_progress('risk_debate_safe', 'completed', safe_result.get('data', {}))
# Final decision
logger.info("Phase 5: Final decision")
emit_progress('final_decision', 'started', None)
final_decision = self._make_risk_decision(risky_result, neutral_result, safe_result, trader_result, context)
emit_progress('final_decision', 'completed', final_decision)
# Generate overview
emit_progress('overview', 'started', None)
overview = self._generate_overview(context, final_decision)
emit_progress('overview', 'completed', overview)
# Record for reflection
if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']:
try:
self.reflection_service.record_analysis(
market=market,
symbol=symbol,
price=base_data.get('current_price', {}).get('price'),
decision=final_decision.get('decision'),
confidence=final_decision.get('confidence', 50),
reasoning=final_decision.get('reasoning', ''),
check_days=7
)
except Exception as e:
logger.warning(f"Record reflection failed: {e}")
# Build final result
debate_data = {
"bull": bull_argument.get('data', {}) if bull_argument.get('data') else {},
"bear": bear_argument.get('data', {}) if bear_argument.get('data') else {},
"research_decision": research_decision if research_decision else "Analyzing..."
}
trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else {
"decision": "HOLD",
"confidence": 50,
"reasoning": "Analyzing...",
"trading_plan": {},
"report": "Analyzing..."
}
risk_debate_data = {
"risky": risky_result.get('data', {}) if risky_result.get('data') else {},
"neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {},
"safe": safe_result.get('data', {}) if safe_result.get('data') else {}
}
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
final_decision = {
"decision": "HOLD",
"confidence": 50,
"reasoning": "Analyzing...",
"risk_summary": {},
"recommendation": "Analyzing..."
}
result = {
"overview": overview,
"fundamental": fundamental_report.get('data', {}),
"technical": market_report.get('data', {}),
"news": news_report.get('data', {}),
"sentiment": sentiment_report.get('data', {}),
"risk": risk_report.get('data', {}),
"debate": debate_data,
"trader_decision": trader_decision_data,
"risk_debate": risk_debate_data,
"final_decision": final_decision,
"error": None
}
logger.info(f"Multi-agent stream analysis completed: {market}:{symbol}")
return result
def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""
Run the full multi-agent analysis workflow.
+118 -147
View File
@@ -1,7 +1,7 @@
"""
Agent memory system (local-only).
Agent memory system (PostgreSQL).
This module stores agent experiences in SQLite and retrieves relevant past cases
This module stores agent experiences in PostgreSQL and retrieves relevant past cases
to inject into prompts (RAG-style). It does NOT finetune model weights.
Retrieval (configurable):
@@ -14,7 +14,6 @@ Ranking combines:
- optional returns weight
"""
import sqlite3
import json
import os
import math
@@ -23,31 +22,24 @@ from datetime import datetime, timezone
import difflib
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from .embedding import EmbeddingService, cosine_sim
logger = get_logger(__name__)
class AgentMemory:
"""智能体记忆系统"""
"""Agent memory system using PostgreSQL"""
def __init__(self, agent_name: str, db_path: Optional[str] = None):
"""
初始化记忆系统
Initialize memory system.
Args:
agent_name: 智能体名称
db_path: 数据库路径(可选)
agent_name: Agent identifier (e.g., 'trader_agent', 'risk_analyst')
db_path: Deprecated parameter, kept for backward compatibility
"""
self.agent_name = agent_name
if db_path is None:
# 默认数据库路径
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
os.makedirs(db_dir, exist_ok=True)
db_path = os.path.join(db_dir, f'{agent_name}_memory.db')
self.db_path = db_path
self.embedder = EmbeddingService()
self.enable_vector = os.getenv("AGENT_MEMORY_ENABLE_VECTOR", "true").lower() == "true"
self.candidate_limit = int(os.getenv("AGENT_MEMORY_CANDIDATE_LIMIT", "500") or 500)
@@ -55,58 +47,6 @@ class AgentMemory:
self.w_sim = float(os.getenv("AGENT_MEMORY_W_SIM", "0.75") or 0.75)
self.w_recency = float(os.getenv("AGENT_MEMORY_W_RECENCY", "0.20") or 0.20)
self.w_returns = float(os.getenv("AGENT_MEMORY_W_RETURNS", "0.05") or 0.05)
self._init_database()
def _init_database(self):
"""初始化数据库表"""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
situation TEXT NOT NULL,
recommendation TEXT NOT NULL,
result TEXT,
returns REAL,
market TEXT,
symbol TEXT,
timeframe TEXT,
features_json TEXT,
embedding BLOB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Best-effort migration for older DBs
# NOTE: For existing tables, we must add missing columns BEFORE creating indexes
# that reference them (otherwise we'll hit: "no such column: market").
cursor.execute("PRAGMA table_info(memories)")
existing_cols = {row[1] for row in cursor.fetchall() or []}
for col, ddl in {
"market": "TEXT",
"symbol": "TEXT",
"timeframe": "TEXT",
"features_json": "TEXT",
"embedding": "BLOB",
}.items():
if col not in existing_cols:
cursor.execute(f"ALTER TABLE memories ADD COLUMN {col} {ddl}")
# 创建索引(放在迁移之后,兼容旧库)
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_market_symbol ON memories(market, symbol)
''')
conn.commit()
conn.close()
except Exception as e:
logger.error(f"初始化记忆数据库失败: {e}")
def _now_utc(self) -> datetime:
return datetime.now(timezone.utc)
@@ -156,13 +96,13 @@ class AgentMemory:
metadata: Optional[Dict[str, Any]] = None,
):
"""
添加记忆
Add a memory entry.
Args:
situation: 情况描述
recommendation: 建议/决策
result: 结果描述(可选)
returns: 收益(可选)
situation: Situation description
recommendation: Decision/recommendation made
result: Outcome description (optional)
returns: Return percentage (optional)
metadata: Optional structured metadata (market/symbol/timeframe/features...)
"""
try:
@@ -182,45 +122,50 @@ class AgentMemory:
vec = self.embedder.embed(text)
embedding_blob = self.embedder.to_bytes(vec)
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO memories (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob))
conn.commit()
conn.close()
logger.info(f"{self.agent_name} 添加新记忆")
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO qd_agent_memories
(agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(self.agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob)
)
conn.commit()
cur.close()
logger.info(f"{self.agent_name} added new memory")
except Exception as e:
logger.error(f"添加记忆失败: {e}")
logger.error(f"Failed to add memory: {e}")
def get_memories(self, current_situation: str, n_matches: int = 5, metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""
检索相似记忆
Retrieve similar memories.
Args:
current_situation: 当前情况描述
n_matches: 返回的匹配数量
current_situation: Current situation description
n_matches: Number of matches to return
metadata: Optional metadata for filtering/weighting
Returns:
匹配的记忆列表
List of matching memory entries
"""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 获取所有记忆
cursor.execute('''
SELECT id, situation, recommendation, result, returns, created_at, market, symbol, timeframe, features_json, embedding
FROM memories
ORDER BY created_at DESC
LIMIT ?
''', (int(self.candidate_limit),))
all_memories = cursor.fetchall()
conn.close()
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT id, situation, recommendation, result, returns, created_at,
market, symbol, timeframe, features_json, embedding
FROM qd_agent_memories
WHERE agent_name = ?
ORDER BY created_at DESC
LIMIT ?
""",
(self.agent_name, int(self.candidate_limit))
)
all_memories = cur.fetchall() or []
cur.close()
if not all_memories:
return []
@@ -240,23 +185,24 @@ class AgentMemory:
ranked = []
for row in all_memories:
(
mem_id,
situation,
recommendation,
result,
returns,
created_at,
market,
symbol,
timeframe,
features_json,
embedding_blob,
) = row
mem_id = row['id']
situation = row['situation']
recommendation = row['recommendation']
result = row['result']
returns = row['returns']
created_at = row['created_at']
market = row['market']
symbol = row['symbol']
timeframe = row['timeframe']
features_json = row['features_json']
embedding_blob = row['embedding']
sim = 0.0
if self.enable_vector and embedding_blob:
try:
# Handle memoryview/bytes from PostgreSQL
if isinstance(embedding_blob, memoryview):
embedding_blob = bytes(embedding_blob)
mem_vec = self.embedder.from_bytes(embedding_blob)
sim = cosine_sim(query_vec, mem_vec)
except Exception:
@@ -292,50 +238,60 @@ class AgentMemory:
return ranked[: max(0, int(n_matches or 0))]
except Exception as e:
logger.error(f"检索记忆失败: {e}")
logger.error(f"Failed to retrieve memories: {e}")
return []
def update_memory_result(self, memory_id: int, result: str, returns: Optional[float] = None):
"""
更新记忆的结果
Update memory result.
Args:
memory_id: 记忆ID
result: 结果描述
returns: 收益
memory_id: Memory ID
result: Outcome description
returns: Return percentage
"""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
UPDATE memories
SET result = ?, returns = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (result, returns, memory_id))
conn.commit()
conn.close()
logger.info(f"{self.agent_name} 更新记忆 {memory_id}")
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
UPDATE qd_agent_memories
SET result = ?, returns = ?, updated_at = NOW()
WHERE id = ? AND agent_name = ?
""",
(result, returns, memory_id, self.agent_name)
)
conn.commit()
cur.close()
logger.info(f"{self.agent_name} updated memory {memory_id}")
except Exception as e:
logger.error(f"更新记忆失败: {e}")
logger.error(f"Failed to update memory: {e}")
def get_statistics(self) -> Dict[str, Any]:
"""获取记忆统计信息"""
"""Get memory statistics for this agent."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM memories')
total = cursor.fetchone()[0]
cursor.execute('SELECT AVG(returns) FROM memories WHERE returns IS NOT NULL')
avg_returns = cursor.fetchone()[0] or 0
cursor.execute('SELECT COUNT(*) FROM memories WHERE returns > 0')
positive = cursor.fetchone()[0]
conn.close()
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ?',
(self.agent_name,)
)
total = cur.fetchone()['cnt']
cur.execute(
'SELECT AVG(returns) as avg_ret FROM qd_agent_memories WHERE agent_name = ? AND returns IS NOT NULL',
(self.agent_name,)
)
avg_returns = cur.fetchone()['avg_ret'] or 0
cur.execute(
'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ? AND returns > 0',
(self.agent_name,)
)
positive = cur.fetchone()['cnt']
cur.close()
return {
'total_memories': total,
@@ -344,5 +300,20 @@ class AgentMemory:
'success_rate': round(positive / total * 100, 2) if total > 0 else 0
}
except Exception as e:
logger.error(f"获取统计信息失败: {e}")
logger.error(f"Failed to get statistics: {e}")
return {}
def clear_memories(self):
"""Clear all memories for this agent (use with caution)."""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
'DELETE FROM qd_agent_memories WHERE agent_name = ?',
(self.agent_name,)
)
conn.commit()
cur.close()
logger.warning(f"{self.agent_name} cleared all memories")
except Exception as e:
logger.error(f"Failed to clear memories: {e}")
@@ -1,220 +1,249 @@
"""
自动反思与验证服务
用于记录分析预测,并在未来自动验证结果,实现闭环学习
Auto-reflection and verification service (PostgreSQL).
Records analysis predictions and auto-verifies results in the future
to achieve closed-loop learning for AI agents.
"""
import sqlite3
import os
import json
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from .memory import AgentMemory
from .tools import AgentTools
logger = get_logger(__name__)
class ReflectionService:
"""反思服务:管理分析记录的存储和验证"""
"""Reflection service: manages storage and verification of analysis records."""
def __init__(self, db_path: Optional[str] = None):
if db_path is None:
# 默认数据库路径
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
os.makedirs(db_dir, exist_ok=True)
db_path = os.path.join(db_dir, 'reflection_records.db')
self.db_path = db_path
self.tools = AgentTools()
self._init_database()
def _init_database(self):
"""初始化数据库表"""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 创建分析记录表
cursor.execute('''
CREATE TABLE IF NOT EXISTS analysis_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
market TEXT NOT NULL,
symbol TEXT NOT NULL,
initial_price REAL,
decision TEXT,
confidence INTEGER,
reasoning TEXT,
analysis_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
target_check_date TIMESTAMP,
status TEXT DEFAULT 'PENDING', -- PENDING, COMPLETED, FAILED
final_price REAL,
actual_return REAL,
check_result TEXT
)
''')
# 创建索引
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_status_date ON analysis_records(status, target_check_date)
''')
conn.commit()
conn.close()
except Exception as e:
logger.error(f"初始化反思数据库失败: {e}")
def record_analysis(self, market: str, symbol: str, price: float,
decision: str, confidence: int, reasoning: str,
check_days: int = 7):
"""
记录一次分析,以便未来验证
Initialize reflection service.
Args:
market: 市场
symbol: 代码
price: 当前价格
decision: 决策 (BUY/SELL/HOLD)
confidence: 置信度
reasoning: 理由
check_days: 几天后验证 (默认7天)
db_path: Deprecated parameter, kept for backward compatibility
"""
self.tools = AgentTools()
def record_analysis(
self,
market: str,
symbol: str,
price: float,
decision: str,
confidence: int,
reasoning: str,
check_days: int = 7
):
"""
Record an analysis for future verification.
Args:
market: Market type
symbol: Symbol code
price: Current price
decision: Decision (BUY/SELL/HOLD)
confidence: Confidence level (0-100)
reasoning: Reasoning text
check_days: Days until verification (default 7)
"""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
target_date = datetime.now() + timedelta(days=check_days)
cursor.execute('''
INSERT INTO analysis_records
(market, symbol, initial_price, decision, confidence, reasoning, target_check_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (market, symbol, price, decision, confidence, reasoning, target_date))
conn.commit()
conn.close()
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO qd_reflection_records
(market, symbol, initial_price, decision, confidence, reasoning, target_check_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(market, symbol, price, decision, confidence, reasoning, target_date)
)
conn.commit()
cur.close()
logger.info(f"Recorded analysis for reflection: {market}:{symbol}, will verify after {check_days} day(s)")
except Exception as e:
logger.error(f"记录分析失败: {e}")
logger.error(f"Failed to record analysis: {e}")
def run_verification_cycle(self):
"""
执行验证周期:检查到期的记录,验证结果,并写入记忆
Execute verification cycle: check due records, verify results, and write to memory.
"""
logger.info("开始执行自动反思验证周期...")
logger.info("Starting auto-reflection verification cycle...")
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 1. 查找所有已到期且未处理的记录
cursor.execute('''
SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date
FROM analysis_records
WHERE status = 'PENDING' AND target_check_date <= CURRENT_TIMESTAMP
''')
records = cursor.fetchall()
if not records:
logger.info("没有需要验证的记录")
conn.close()
return
logger.info(f"发现 {len(records)} 条待验证记录")
# 初始化记忆系统(用于写入验证结果)
trader_memory = AgentMemory('trader_agent')
for record in records:
record_id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date = record
with get_db_connection() as conn:
cur = conn.cursor()
try:
# 2. 获取当前最新价格
current_price_data = self.tools.get_current_price(market, symbol)
current_price = current_price_data.get('price')
# 1. Find all due and pending records
cur.execute(
"""
SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date
FROM qd_reflection_records
WHERE status = 'PENDING' AND target_check_date <= NOW()
"""
)
records = cur.fetchall() or []
if not records:
logger.info("No records to verify")
cur.close()
return
logger.info(f"Found {len(records)} records to verify")
# Initialize memory system for writing verification results
trader_memory = AgentMemory('trader_agent')
for record in records:
record_id = record['id']
market = record['market']
symbol = record['symbol']
initial_price = record['initial_price']
decision = record['decision']
confidence = record['confidence']
reasoning = record['reasoning']
analysis_date = record['analysis_date']
if not current_price:
logger.warning(f"无法获取 {market}:{symbol} 的当前价格,跳过")
continue
try:
# 2. Get current price
current_price_data = self.tools.get_current_price(market, symbol)
current_price = current_price_data.get('price')
# 3. 计算收益和结果
if not initial_price or initial_price == 0:
actual_return = 0.0
else:
actual_return = (current_price - initial_price) / initial_price * 100
# 评估结果
result_desc = ""
is_good_prediction = False
if decision == "BUY":
if actual_return > 2.0:
result_desc = "Correct: price rose after BUY"
is_good_prediction = True
elif actual_return < -2.0:
result_desc = "Wrong: price fell after BUY"
if not current_price:
logger.warning(f"Cannot get current price for {market}:{symbol}, skipping")
continue
# 3. Calculate return and result
if not initial_price or initial_price == 0:
actual_return = 0.0
else:
result_desc = "Neutral: limited price movement"
elif decision == "SELL":
if actual_return < -2.0:
result_desc = "Correct: price fell after SELL"
is_good_prediction = True
elif actual_return > 2.0:
result_desc = "Wrong: price rose after SELL"
else:
result_desc = "Neutral: limited price movement"
else: # HOLD
if -2.0 <= actual_return <= 2.0:
result_desc = "Correct: limited movement during HOLD"
is_good_prediction = True
else:
result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)"
actual_return = (current_price - initial_price) / initial_price * 100
# Evaluate result
result_desc = ""
is_good_prediction = False
if decision == "BUY":
if actual_return > 2.0:
result_desc = "Correct: price rose after BUY"
is_good_prediction = True
elif actual_return < -2.0:
result_desc = "Wrong: price fell after BUY"
else:
result_desc = "Neutral: limited price movement"
elif decision == "SELL":
if actual_return < -2.0:
result_desc = "Correct: price fell after SELL"
is_good_prediction = True
elif actual_return > 2.0:
result_desc = "Wrong: price rose after SELL"
else:
result_desc = "Neutral: limited price movement"
else: # HOLD
if -2.0 <= actual_return <= 2.0:
result_desc = "Correct: limited movement during HOLD"
is_good_prediction = True
else:
result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)"
# 4. 写入记忆系统 (Let the agent learn)
memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})"
memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}"
memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})"
trader_memory.add_memory(
memory_situation,
memory_recommendation,
memory_result,
actual_return,
metadata={
"market": market,
"symbol": symbol,
"timeframe": "1D",
"features": {
"source": "auto_verify",
"decision": decision,
"confidence": confidence,
"initial_price": initial_price,
"final_price": current_price,
"analysis_date": str(analysis_date),
"result_desc": result_desc,
"is_good_prediction": bool(is_good_prediction),
},
}
)
# 5. 更新记录状态
cursor.execute('''
UPDATE analysis_records
SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ?
WHERE id = ?
''', (current_price, actual_return, result_desc, record_id))
conn.commit()
logger.info(f"验证完成 {market}:{symbol}: {result_desc}")
except Exception as inner_e:
logger.error(f"处理记录 {record_id} 失败: {inner_e}")
# 标记为失败,避免重复处理
# cursor.execute("UPDATE analysis_records SET status = 'FAILED' WHERE id = ?", (record_id,))
# conn.commit()
conn.close()
logger.info("反思验证周期结束")
# 4. Write to memory system (agent learning)
memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})"
memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}"
memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})"
trader_memory.add_memory(
memory_situation,
memory_recommendation,
memory_result,
actual_return,
metadata={
"market": market,
"symbol": symbol,
"timeframe": "1D",
"features": {
"source": "auto_verify",
"decision": decision,
"confidence": confidence,
"initial_price": initial_price,
"final_price": current_price,
"analysis_date": str(analysis_date),
"result_desc": result_desc,
"is_good_prediction": bool(is_good_prediction),
},
}
)
# 5. Update record status
cur.execute(
"""
UPDATE qd_reflection_records
SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ?
WHERE id = ?
""",
(current_price, actual_return, result_desc, record_id)
)
conn.commit()
logger.info(f"Verification completed {market}:{symbol}: {result_desc}")
except Exception as inner_e:
logger.error(f"Failed to process record {record_id}: {inner_e}")
# Optionally mark as failed to avoid repeated processing
# cur.execute("UPDATE qd_reflection_records SET status = 'FAILED' WHERE id = ?", (record_id,))
# conn.commit()
cur.close()
logger.info("Reflection verification cycle completed")
except Exception as e:
logger.error(f"执行验证周期失败: {e}")
logger.error(f"Failed to execute verification cycle: {e}")
def get_pending_count(self) -> int:
"""Get count of pending verification records."""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'")
count = cur.fetchone()['cnt']
cur.close()
return count
except Exception as e:
logger.error(f"Failed to get pending count: {e}")
return 0
def get_statistics(self) -> Dict[str, Any]:
"""Get reflection statistics."""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records")
total = cur.fetchone()['cnt']
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'")
pending = cur.fetchone()['cnt']
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'COMPLETED'")
completed = cur.fetchone()['cnt']
cur.execute(
"SELECT AVG(actual_return) as avg_ret FROM qd_reflection_records WHERE status = 'COMPLETED' AND actual_return IS NOT NULL"
)
avg_return = cur.fetchone()['avg_ret'] or 0
cur.close()
return {
'total_records': total,
'pending_records': pending,
'completed_records': completed,
'average_return': round(avg_return, 2)
}
except Exception as e:
logger.error(f"Failed to get statistics: {e}")
return {}
@@ -14,6 +14,19 @@ from typing import Any, Dict, Optional, Tuple
from app.utils.db import get_db_connection
def _get_user_id_from_strategy(strategy_id: int) -> int:
"""Get user_id from strategy table. Defaults to 1 if not found."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cur.fetchone()
cur.close()
return int((row or {}).get('user_id') or 1)
except Exception:
return 1
def record_trade(
*,
strategy_id: int,
@@ -24,19 +37,23 @@ def record_trade(
commission: float = 0.0,
commission_ccy: str = "",
profit: Optional[float] = None,
user_id: int = None,
) -> None:
now = int(time.time())
value = float(amount or 0.0) * float(price or 0.0)
if user_id is None:
user_id = _get_user_id_from_strategy(strategy_id)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_strategy_trades
(strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at)
(user_id, strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
int(user_id),
int(strategy_id),
str(symbol),
str(trade_type),
@@ -86,25 +103,28 @@ def upsert_position(
current_price: float,
highest_price: float = 0.0,
lowest_price: float = 0.0,
user_id: int = None,
) -> None:
now = int(time.time())
if user_id is None:
user_id = _get_user_id_from_strategy(strategy_id)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_strategy_positions
(strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at)
(user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s)
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET
size = excluded.size,
entry_price = excluded.entry_price,
current_price = excluded.current_price,
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END,
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END,
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE qd_strategy_positions.highest_price END,
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END,
updated_at = excluded.updated_at
""",
(int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now),
(int(user_id), int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now),
)
db.commit()
cur.close()
@@ -417,9 +417,8 @@ class PendingOrderWorker:
cur = db.cursor()
for rid in to_delete_ids:
cur.execute("DELETE FROM qd_strategy_positions WHERE id = %s", (int(rid),))
now_ts = int(time.time())
for u in to_update:
cur.execute("UPDATE qd_strategy_positions SET size = %s, updated_at = %s WHERE id = %s", (float(u["size"]), now_ts, int(u["id"])))
cur.execute("UPDATE qd_strategy_positions SET size = %s, updated_at = NOW() WHERE id = %s", (float(u["size"]), int(u["id"])))
db.commit()
cur.close()
@@ -436,24 +435,22 @@ class PendingOrderWorker:
except Exception:
stale_sec = 0
if stale_sec > 0:
now = int(time.time())
cutoff = now - stale_sec
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
UPDATE pending_orders
SET status = 'pending',
updated_at = %s,
updated_at = NOW(),
dispatch_note = CASE
WHEN dispatch_note IS NULL OR dispatch_note = '' THEN 'requeued_stale_processing'
ELSE dispatch_note
END
WHERE status = 'processing'
AND (updated_at IS NULL OR updated_at < %s)
AND (updated_at IS NULL OR updated_at < NOW() - INTERVAL '%s seconds')
AND (attempts < max_attempts)
""",
(now, cutoff),
(stale_sec,),
)
db.commit()
cur.close()
@@ -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:
@@ -447,18 +447,31 @@ class SignalNotifier:
title: str,
message: str,
payload: Dict[str, Any],
user_id: int = None,
) -> Tuple[bool, str]:
try:
now = int(time.time())
# Get user_id from strategy if not provided
if user_id is None:
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
row = cur.fetchone()
cur.close()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
user_id = 1
with get_db_connection() as db:
cur = db.cursor()
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(user_id),
int(strategy_id),
str(symbol or ""),
str(signal_type or ""),
+57 -39
View File
@@ -427,16 +427,21 @@ class StrategyService:
except Exception:
return 'IndicatorStrategy'
def update_strategy_status(self, strategy_id: int, status: str) -> bool:
"""Update strategy status."""
def update_strategy_status(self, strategy_id: int, status: str, user_id: int = None) -> bool:
"""Update strategy status. If user_id is provided, verify ownership."""
try:
now = int(time.time())
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_strategies_trading SET status = ?, updated_at = ? WHERE id = ?",
(status, now, strategy_id)
)
if user_id is not None:
cur.execute(
"UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ? AND user_id = ?",
(status, strategy_id, user_id)
)
else:
cur.execute(
"UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ?",
(status, strategy_id)
)
db.commit()
cur.close()
return True
@@ -467,7 +472,7 @@ class StrategyService:
return json.dumps(obj, ensure_ascii=False)
def list_strategies(self, user_id: int = 1) -> List[Dict[str, Any]]:
"""List strategies for local single-user."""
"""List strategies for the specified user."""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -475,8 +480,10 @@ class StrategyService:
"""
SELECT *
FROM qd_strategies_trading
WHERE user_id = ?
ORDER BY id DESC
"""
""",
(user_id,)
)
rows = cur.fetchall() or []
cur.close()
@@ -501,11 +508,15 @@ class StrategyService:
logger.error(f"list_strategies failed: {e}")
return []
def get_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]:
def get_strategy(self, strategy_id: int, user_id: int = None) -> Optional[Dict[str, Any]]:
"""Get strategy by ID. If user_id is provided, verify ownership."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
if user_id is not None:
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id))
else:
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
r = cur.fetchone()
cur.close()
if not r:
@@ -521,11 +532,11 @@ class StrategyService:
return None
def create_strategy(self, payload: Dict[str, Any]) -> int:
now = int(time.time())
name = (payload.get('strategy_name') or '').strip()
if not name:
raise ValueError("strategy_name is required")
user_id = payload.get('user_id') or 1
strategy_type = payload.get('strategy_type') or 'IndicatorStrategy'
market_category = payload.get('market_category') or 'Crypto'
execution_mode = payload.get('execution_mode') or 'signal'
@@ -551,14 +562,15 @@ class StrategyService:
cur.execute(
"""
INSERT INTO qd_strategies_trading
(strategy_name, strategy_type, market_category, execution_mode, notification_config,
(user_id, strategy_name, strategy_type, market_category, execution_mode, notification_config,
status, symbol, timeframe, initial_capital, leverage, market_type,
exchange_config, indicator_config, trading_config, ai_model_config, decide_interval,
strategy_group_id, group_base_name,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(
user_id,
name,
strategy_type,
market_category,
@@ -576,9 +588,7 @@ class StrategyService:
self._dump_json_or_encrypt(payload.get('ai_model_config') or {}, encrypt=False),
int(payload.get('decide_interval') or 300),
strategy_group_id,
group_base_name,
now,
now
group_base_name
)
)
new_id = cur.lastrowid
@@ -657,14 +667,14 @@ class StrategyService:
'total_failed': len(failed_symbols)
}
def batch_start_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]:
"""Batch start strategies"""
def batch_start_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]:
"""Batch start strategies. If user_id is provided, verify ownership."""
success_ids = []
failed_ids = []
for sid in strategy_ids:
try:
self.update_strategy_status(sid, 'running')
self.update_strategy_status(sid, 'running', user_id=user_id)
success_ids.append(sid)
except Exception as e:
logger.error(f"Failed to start strategy {sid}: {e}")
@@ -676,14 +686,14 @@ class StrategyService:
'failed_ids': failed_ids
}
def batch_stop_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]:
"""Batch stop strategies"""
def batch_stop_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]:
"""Batch stop strategies. If user_id is provided, verify ownership."""
success_ids = []
failed_ids = []
for sid in strategy_ids:
try:
self.update_strategy_status(sid, 'stopped')
self.update_strategy_status(sid, 'stopped', user_id=user_id)
success_ids.append(sid)
except Exception as e:
logger.error(f"Failed to stop strategy {sid}: {e}")
@@ -695,14 +705,14 @@ class StrategyService:
'failed_ids': failed_ids
}
def batch_delete_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]:
"""Batch delete strategies"""
def batch_delete_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]:
"""Batch delete strategies. If user_id is provided, verify ownership."""
success_ids = []
failed_ids = []
for sid in strategy_ids:
try:
self.delete_strategy(sid)
self.delete_strategy(sid, user_id=user_id)
success_ids.append(sid)
except Exception as e:
logger.error(f"Failed to delete strategy {sid}: {e}")
@@ -714,15 +724,21 @@ class StrategyService:
'failed_ids': failed_ids
}
def get_strategies_by_group(self, strategy_group_id: str) -> List[Dict[str, Any]]:
"""Get all strategies in a group"""
def get_strategies_by_group(self, strategy_group_id: str, user_id: int = None) -> List[Dict[str, Any]]:
"""Get all strategies in a group. If user_id is provided, filter by user."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?",
(strategy_group_id,)
)
if user_id is not None:
cur.execute(
"SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ? AND user_id = ?",
(strategy_group_id, user_id)
)
else:
cur.execute(
"SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?",
(strategy_group_id,)
)
rows = cur.fetchall() or []
cur.close()
return [row['id'] for row in rows]
@@ -730,9 +746,8 @@ class StrategyService:
logger.error(f"get_strategies_by_group failed: {e}")
return []
def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool:
now = int(time.time())
existing = self.get_strategy(strategy_id)
def update_strategy(self, strategy_id: int, payload: Dict[str, Any], user_id: int = None) -> bool:
existing = self.get_strategy(strategy_id, user_id=user_id)
if not existing:
return False
@@ -771,7 +786,7 @@ class StrategyService:
indicator_config = ?,
trading_config = ?,
ai_model_config = ?,
updated_at = ?
updated_at = NOW()
WHERE id = ?
""",
(
@@ -788,7 +803,6 @@ class StrategyService:
self._dump_json_or_encrypt(indicator_config, encrypt=False),
self._dump_json_or_encrypt(trading_config, encrypt=False),
self._dump_json_or_encrypt(ai_model_config, encrypt=False),
now,
strategy_id
)
)
@@ -796,11 +810,15 @@ class StrategyService:
cur.close()
return True
def delete_strategy(self, strategy_id: int) -> bool:
def delete_strategy(self, strategy_id: int, user_id: int = None) -> bool:
"""Delete strategy. If user_id is provided, verify ownership."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
if user_id is not None:
cur.execute("DELETE FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id))
else:
cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
db.commit()
cur.close()
return True
@@ -2191,19 +2191,32 @@ class TradingExecutor:
title: str,
message: str,
payload: Optional[Dict[str, Any]] = None,
user_id: int = None,
) -> None:
"""Best-effort persist notification row for the frontend '通知' panel (browser channel)."""
try:
now = int(time.time())
# Get user_id from strategy if not provided
if user_id is None:
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
row = cur.fetchone()
cur.close()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
user_id = 1
with get_db_connection() as db:
cur = db.cursor()
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(user_id),
int(strategy_id),
str(symbol or ""),
str(signal_type or ""),
@@ -2419,18 +2432,28 @@ class TradingExecutor:
# Best-effort only; do not block enqueue on dedup query errors.
pass
# Get user_id from strategy
user_id = 1
try:
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cur.fetchone()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
pass
cur.execute(
"""
INSERT INTO pending_orders
(strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price,
(user_id, strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price,
execution_mode, status, priority, attempts, max_attempts, last_error, payload_json,
created_at, updated_at, processed_at, sent_at)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s,
(%s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s)
""",
(
int(user_id),
int(strategy_id),
symbol,
signal_type,
@@ -2473,16 +2496,24 @@ class TradingExecutor:
def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None):
"""记录交易到数据库"""
try:
# Get user_id from strategy
user_id = 1
with get_db_connection() as db:
cursor = db.cursor()
try:
cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cursor.fetchone()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
pass
query = """
INSERT INTO qd_strategy_trades (
strategy_id, symbol, type, price, amount, value, commission, profit, created_at
user_id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
"""
cursor.execute(query, (strategy_id, symbol, type, price, amount, value, commission or 0, profit, int(time.time())))
cursor.execute(query, (user_id, strategy_id, symbol, type, price, amount, value, commission or 0, profit, int(time.time())))
db.commit()
cursor.close()
except Exception as e:
@@ -2501,24 +2532,32 @@ class TradingExecutor:
):
"""更新持仓状态"""
try:
# Get user_id from strategy
user_id = 1
with get_db_connection() as db:
cursor = db.cursor()
try:
cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cursor.fetchone()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
pass
# 简化:直接 Update 或 Insert
upsert_query = """
INSERT INTO qd_strategy_positions (
strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at
user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s
) ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET
size = excluded.size,
entry_price = excluded.entry_price,
current_price = excluded.current_price,
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END,
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END,
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE qd_strategy_positions.highest_price END,
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END,
updated_at = excluded.updated_at
"""
cursor.execute(upsert_query, (
strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, int(time.time())
user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, int(time.time())
))
db.commit()
cursor.close()
@@ -0,0 +1,386 @@
"""
User Service - Multi-user management
Handles user CRUD operations, password hashing, and role management.
"""
import hashlib
import time
import os
from typing import Optional, Dict, Any, List
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
# Try to import bcrypt for secure password hashing
try:
import bcrypt
HAS_BCRYPT = True
except ImportError:
HAS_BCRYPT = False
logger.warning("bcrypt not installed. Using SHA256 for password hashing (less secure).")
class UserService:
"""User management service"""
# Available roles (ordered by privilege level)
ROLES = ['viewer', 'user', 'manager', 'admin']
# Role permissions mapping
ROLE_PERMISSIONS = {
'viewer': ['dashboard', 'view'],
'user': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'],
'manager': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings'],
'admin': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings', 'user_manage', 'credentials'],
}
def hash_password(self, password: str) -> str:
"""Hash password using bcrypt (preferred) or SHA256 (fallback)"""
if HAS_BCRYPT:
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
else:
# Fallback to SHA256 with salt
salt = os.urandom(16).hex()
hashed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest()
return f"sha256${salt}${hashed}"
def verify_password(self, password: str, password_hash: str) -> bool:
"""Verify password against hash"""
if password_hash.startswith('$2b$') or password_hash.startswith('$2a$'):
# bcrypt hash
if HAS_BCRYPT:
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception:
return False
return False
elif password_hash.startswith('sha256$'):
# SHA256 fallback hash
parts = password_hash.split('$')
if len(parts) != 3:
return False
salt = parts[1]
stored_hash = parts[2]
computed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest()
return computed == stored_hash
return False
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Get user by ID"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, username, email, nickname, avatar, status, role,
last_login_at, created_at, updated_at
FROM qd_users WHERE id = ?
""",
(user_id,)
)
row = cur.fetchone()
cur.close()
return row
except Exception as e:
logger.error(f"get_user_by_id failed: {e}")
return None
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user by username (includes password_hash for auth)"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, username, password_hash, email, nickname, avatar,
status, role, last_login_at, created_at, updated_at
FROM qd_users WHERE username = ?
""",
(username,)
)
row = cur.fetchone()
cur.close()
return row
except Exception as e:
logger.error(f"get_user_by_username failed: {e}")
return None
def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]:
"""
Authenticate user with username and password.
Returns user info (without password_hash) if successful, None otherwise.
"""
user = self.get_user_by_username(username)
if not user:
return None
if user.get('status') != 'active':
logger.warning(f"Login attempt for disabled user: {username}")
return None
if not self.verify_password(password, user.get('password_hash', '')):
return None
# Update last login time
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_users SET last_login_at = NOW() WHERE id = ?",
(user['id'],)
)
db.commit()
cur.close()
except Exception as e:
logger.warning(f"Failed to update last_login_at: {e}")
# Remove password_hash from return value
user.pop('password_hash', None)
return user
def create_user(self, data: Dict[str, Any]) -> Optional[int]:
"""
Create a new user.
Args:
data: {
username: str (required),
password: str (required),
email: str (optional),
nickname: str (optional),
role: str (optional, default 'user'),
status: str (optional, default 'active')
}
Returns:
New user ID or None if failed
"""
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
raise ValueError("Username and password are required")
if len(username) < 3 or len(username) > 50:
raise ValueError("Username must be 3-50 characters")
if len(password) < 6:
raise ValueError("Password must be at least 6 characters")
# Check if username already exists
existing = self.get_user_by_username(username)
if existing:
raise ValueError("Username already exists")
password_hash = self.hash_password(password)
email = (data.get('email') or '').strip() or None
nickname = (data.get('nickname') or '').strip() or username
role = data.get('role', 'user')
status = data.get('status', 'active')
if role not in self.ROLES:
role = 'user'
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_users
(username, password_hash, email, nickname, role, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(username, password_hash, email, nickname, role, status)
)
db.commit()
user_id = cur.lastrowid
cur.close()
# For PostgreSQL, get the ID differently
if user_id is None:
cur = db.cursor()
cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,))
row = cur.fetchone()
user_id = row['id'] if row else None
cur.close()
logger.info(f"Created user: {username} (id={user_id})")
return user_id
except Exception as e:
logger.error(f"create_user failed: {e}")
raise
def update_user(self, user_id: int, data: Dict[str, Any]) -> bool:
"""
Update user information.
Args:
user_id: User ID
data: Fields to update (email, nickname, avatar, role, status)
"""
allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status']
updates = []
values = []
for field in allowed_fields:
if field in data:
value = data[field]
if field == 'role' and value not in self.ROLES:
continue
updates.append(f"{field} = ?")
values.append(value)
if not updates:
return False
updates.append("updated_at = NOW()")
values.append(user_id)
try:
with get_db_connection() as db:
cur = db.cursor()
sql = f"UPDATE qd_users SET {', '.join(updates)} WHERE id = ?"
cur.execute(sql, tuple(values))
db.commit()
cur.close()
return True
except Exception as e:
logger.error(f"update_user failed: {e}")
return False
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
"""Change user password (requires old password verification)"""
user = self.get_user_by_id(user_id)
if not user:
return False
# Get full user with password_hash
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
if not row:
return False
if not self.verify_password(old_password, row['password_hash']):
return False
return self.reset_password(user_id, new_password)
def reset_password(self, user_id: int, new_password: str) -> bool:
"""Reset user password (admin operation, no old password required)"""
if len(new_password) < 6:
raise ValueError("Password must be at least 6 characters")
password_hash = self.hash_password(new_password)
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?",
(password_hash, user_id)
)
db.commit()
cur.close()
return True
except Exception as e:
logger.error(f"reset_password failed: {e}")
return False
def delete_user(self, user_id: int) -> bool:
"""Delete a user"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("DELETE FROM qd_users WHERE id = ?", (user_id,))
db.commit()
cur.close()
return True
except Exception as e:
logger.error(f"delete_user failed: {e}")
return False
def list_users(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""List all users with pagination"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# Get total count
cur.execute("SELECT COUNT(*) as count FROM qd_users")
total = cur.fetchone()['count']
# Get users
cur.execute(
"""
SELECT id, username, email, nickname, avatar, status, role,
last_login_at, created_at, updated_at
FROM qd_users
ORDER BY id DESC
LIMIT ? OFFSET ?
""",
(page_size, offset)
)
users = cur.fetchall()
cur.close()
return {
'items': users,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size
}
except Exception as e:
logger.error(f"list_users failed: {e}")
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def get_user_permissions(self, role: str) -> List[str]:
"""Get permissions for a role"""
return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS['viewer'])
def ensure_admin_exists(self):
"""
Ensure at least one admin user exists.
Creates admin using ADMIN_USER/ADMIN_PASSWORD from env if no users exist.
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT COUNT(*) as count FROM qd_users")
count = cur.fetchone()['count']
cur.close()
if count == 0:
# Create admin using env credentials
admin_user = os.getenv('ADMIN_USER', 'admin')
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
self.create_user({
'username': admin_user,
'password': admin_password,
'nickname': 'Administrator',
'role': 'admin',
'status': 'active'
})
logger.info(f"Created admin user: {admin_user}")
except Exception as e:
logger.error(f"ensure_admin_exists failed: {e}")
# Global singleton
_user_service = None
def get_user_service() -> UserService:
"""Get UserService singleton"""
global _user_service
if _user_service is None:
_user_service = UserService()
return _user_service