feat: Multi-user system with PostgreSQL - WIP temporary save
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user