feat(broadcast): 播报系统Tab+API+历史查询+消息分类
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""broadcast.py — 播报系统核心模块
|
||||
消息分类 + DB写入 + API查询接口
|
||||
"""
|
||||
import os, re, sqlite3, json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB = "/home/hmo/MoFin/data/mofin.db"
|
||||
|
||||
# === 消息分类规则 ===
|
||||
CATEGORIES = {
|
||||
"trading": ["买入", "卖出", "止损", "止盈", "加仓", "减仓", "调仓", "持仓异动", "推荐", "操作建议", "区间触发", "价格监控"],
|
||||
"system_error": ["LLM端点故障", "API错误", "连接失败", "超时", "异常", "error", "失败", "Error", "Exception"],
|
||||
"health": ["健康检查", "系统体检", "数据完整性", "cron", "部署"],
|
||||
"market": ["大盘", "市场", "板块", "行业", "指数", "行情", "涨跌"],
|
||||
"news": ["新闻", "消息面", "资讯", "公告", "政策"],
|
||||
"strategy": ["策略", "重评", "评估", "温区", "regime"],
|
||||
"general": [],
|
||||
}
|
||||
|
||||
def classify(title, content):
|
||||
"""根据标题和内容分类消息"""
|
||||
text = (title + " " + content).lower()
|
||||
for cat, keywords in CATEGORIES.items():
|
||||
if cat == "general":
|
||||
continue
|
||||
for kw in keywords:
|
||||
if kw.lower() in text:
|
||||
return cat
|
||||
return "general"
|
||||
|
||||
def save_message(ts, title, content, source="xmpp"):
|
||||
"""保存消息到DB"""
|
||||
category = classify(title, content)
|
||||
conn = sqlite3.connect(DB, timeout=30)
|
||||
conn.execute(
|
||||
"INSERT INTO broadcast_messages (ts, category, title, content, source) VALUES (?,?,?,?,?)",
|
||||
(ts, category, title, content, source))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return category
|
||||
|
||||
def get_recent(hours=72, category=None, limit=200):
|
||||
"""获取最近N小时的消息"""
|
||||
conn = sqlite3.connect(DB, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
since = (datetime.now() - timedelta(hours=hours)).isoformat()
|
||||
query = "SELECT * FROM broadcast_messages WHERE ts >= ?"
|
||||
params = [since]
|
||||
if category:
|
||||
query += " AND category=?"
|
||||
params.append(category)
|
||||
query += " ORDER BY ts DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def search_history(start_date=None, end_date=None, keyword=None, category=None, limit=100):
|
||||
"""历史消息搜索"""
|
||||
conn = sqlite3.connect(DB, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
query = "SELECT * FROM broadcast_messages WHERE 1=1"
|
||||
params = []
|
||||
if start_date:
|
||||
query += " AND ts >= ?"
|
||||
params.append(start_date)
|
||||
if end_date:
|
||||
query += " AND ts <= ?"
|
||||
params.append(end_date + " 23:59:59")
|
||||
if keyword:
|
||||
query += " AND (title LIKE ? OR content LIKE ?)"
|
||||
params.extend([f"%{keyword}%", f"%{keyword}%"])
|
||||
if category:
|
||||
query += " AND category=?"
|
||||
params.append(category)
|
||||
query += " ORDER BY ts DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def archive_old(days=7):
|
||||
"""归档超过N天的消息"""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
conn = sqlite3.connect(DB, timeout=30)
|
||||
conn.execute("UPDATE broadcast_messages SET archived=1 WHERE ts < ? AND archived=0", (cutoff,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("broadcast 模块已加载")
|
||||
print(f" 当前消息数: {len(get_recent(hours=9999))}")
|
||||
@@ -2445,6 +2445,33 @@ def api_research_execution_log():
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
# ── Broadcast System API ──
|
||||
@app.route("/api/broadcast/recent")
|
||||
def api_broadcast_recent():
|
||||
from broadcast import get_recent
|
||||
hours = int(request.args.get("hours", "72"))
|
||||
category = request.args.get("category")
|
||||
limit = int(request.args.get("limit", "200"))
|
||||
return jsonify(get_recent(hours=hours, category=category, limit=limit))
|
||||
|
||||
@app.route("/api/broadcast/search")
|
||||
def api_broadcast_search():
|
||||
from broadcast import search_history
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
keyword = request.args.get("keyword")
|
||||
category = request.args.get("category")
|
||||
limit = int(request.args.get("limit", "100"))
|
||||
return jsonify(search_history(start_date=start, end_date=end, keyword=keyword, category=category, limit=limit))
|
||||
|
||||
@app.route("/api/broadcast/archive", methods=["POST"])
|
||||
def api_broadcast_archive():
|
||||
from broadcast import archive_old
|
||||
days = int(request.args.get("days", "7"))
|
||||
archive_old(days=days)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8899))
|
||||
print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Broadcast History - MoFin</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e0e0e0;padding:20px}
|
||||
h1{color:#fff;font-size:1.5em;margin-bottom:16px}
|
||||
.filters{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.filters select,.filters input{background:#1a1a2e;color:#e0e0e0;border:1px solid #333;padding:6px 12px;border-radius:4px}
|
||||
.filters button{background:#3b82f6;color:#fff;padding:6px 16px;border-radius:4px;border:none;cursor:pointer}
|
||||
.filters button:hover{background:#2563eb}
|
||||
.table{width:100%;border-collapse:collapse;margin-top:12px}
|
||||
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid #333}
|
||||
th{color:#888;font-size:0.85em}
|
||||
tr:hover{background:#1a1a2e}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8em}
|
||||
a{color:#3b82f6;text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.back{display:inline-block;margin-bottom:16px;color:#3b82f6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="back">← Back to Dashboard</a>
|
||||
<h1>Broadcast History</h1>
|
||||
<div class="filters">
|
||||
<input type="date" id="startDate" placeholder="Start Date">
|
||||
<input type="date" id="endDate" placeholder="End Date">
|
||||
<input type="text" id="keyword" placeholder="Keyword..." style="min-width:200px">
|
||||
<select id="filterCat">
|
||||
<option value="">All Types</option>
|
||||
<option value="trading">Trading</option>
|
||||
<option value="system_error">Error</option>
|
||||
<option value="health">Health</option>
|
||||
<option value="market">Market</option>
|
||||
<option value="news">News</option>
|
||||
<option value="strategy">Strategy</option>
|
||||
<option value="general">System</option>
|
||||
</select>
|
||||
<button onclick="searchHistory()">Search</button>
|
||||
</div>
|
||||
<div id="results"></div>
|
||||
<div id="detail-modal" style="display:none" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div style="background:#1a1a2e;border:1px solid #444;border-radius:12px;padding:24px;max-width:700px;max-height:80vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.5)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h3 id="detail-title" style="color:#fff;font-size:1.1em"></h3>
|
||||
<button onclick="document.getElementById('detail-modal').style.display='none'" style="background:none;border:none;color:#888;font-size:20px;cursor:pointer">×</button>
|
||||
</div>
|
||||
<pre id="detail-content" style="color:#ccc;font-size:13px;white-space:pre-wrap;line-height:1.6"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function searchHistory() {
|
||||
var params = new URLSearchParams();
|
||||
var s = document.getElementById('startDate').value;
|
||||
var e = document.getElementById('endDate').value;
|
||||
var k = document.getElementById('keyword').value;
|
||||
var c = document.getElementById('filterCat').value;
|
||||
if (s) params.set('start', s);
|
||||
if (e) params.set('end', e);
|
||||
if (k) params.set('keyword', k);
|
||||
if (c) params.set('category', c);
|
||||
params.set('limit', '200');
|
||||
fetch('/api/broadcast/search?' + params).then(r => r.json()).then(data => {
|
||||
var el = document.getElementById('results');
|
||||
if (!data.length) { el.innerHTML = '<div style="color:#888;padding:20px">No results</div>'; return; }
|
||||
var cat_colors = {trading:'#22c55e',system_error:'#ef4444',health:'#3b82f6',market:'#f59e0b',news:'#8b5cf6',strategy:'#06b6d4',general:'#6b7280'};
|
||||
var cat_labels = {trading:'Trading',system_error:'Error',health:'Health',market:'Market',news:'News',strategy:'Strategy',general:'System'};
|
||||
var h = '<table class="table"><tr><th>Time</th><th>Type</th><th>Title</th><th>Content</th></tr>';
|
||||
data.forEach(m => {
|
||||
var color = cat_colors[m.category]||'#6b7280';
|
||||
var label = cat_labels[m.category]||m.category;
|
||||
var preview = m.content.length>80 ? m.content.slice(0,80)+'...' : m.content;
|
||||
var time = (m.ts||'').slice(0,16).replace('T',' ');
|
||||
h += '<tr onclick="showDetail(\'' + m.content.replace(/'/g,"\\'").replace(/\n/g,"\\n") + '\',\'' + (m.title||'').replace(/'/g,"\\'") + '\')" style="cursor:pointer">';
|
||||
h += '<td style="white-space:nowrap">'+time+'</td>';
|
||||
h += '<td><span class="tag" style="background:'+color+'22;color:'+color+'">'+label+'</span></td>';
|
||||
h += '<td style="font-weight:600;color:#e0e0e0">'+(m.title||'-')+'</td>';
|
||||
h += '<td style="color:#aaa">'+preview+'</td>';
|
||||
h += '</tr>';
|
||||
});
|
||||
h += '</table>';
|
||||
el.innerHTML = h;
|
||||
}).catch(e => { document.getElementById('results').innerHTML = '<div style="color:red">'+e.message+'</div>'; });
|
||||
}
|
||||
function showDetail(content, title) {
|
||||
document.getElementById('detail-title').textContent = title || 'Detail';
|
||||
document.getElementById('detail-content').textContent = content;
|
||||
document.getElementById('detail-modal').style.display = 'flex';
|
||||
}
|
||||
searchHistory();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user