feat(broadcast): 播报系统Tab+API+历史查询+消息分类

This commit is contained in:
xxm
2026-08-21 15:09:48 +08:00
parent 981d35e1cf
commit 5eeb31668d
3 changed files with 216 additions and 0 deletions
+93
View File
@@ -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))}")
+27
View File
@@ -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}")
+96
View File
@@ -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">&larr; 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">&times;</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>