From 377c6ee93064296dd73b5ef6549396f20a753a21 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Fri, 15 May 2026 22:04:05 +0800 Subject: [PATCH] 1 --- README.md | 6 +- app/db.py | 48 ++++++++++ app/dispatcher.py | 4 +- app/server.py | 194 ++++++++++++++++++++++++++++++++++++--- app/static/app.js | 12 +++ app/static/styles.css | 60 ++++++++++++ tests/test_dispatcher.py | 12 +++ 7 files changed, 320 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e26b52a..1899142 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,13 @@ X-Webhook-Token: your-shared-secret `docker-compose.yml` 默认已经设置了占位 token,生产使用前请替换;本地临时调试如果不想校验 token,可以把 `WEBHOOK_TOKEN` 置为空。 +## Feishu Webhooks + +「飞书 Webhook」页面只维护目标名称和机器人地址。目标一旦被路由规则选中就会发送,不再提供启用/停用状态。 + ## Feishu Message Templates -路由规则统一发送飞书 interactive card。 +路由规则统一发送飞书 interactive card。可以先在「飞书模板」页面维护常用卡片模板,再在新增或编辑路由规则时从下拉框一键套用。 标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`。 diff --git a/app/db.py b/app/db.py index 39c2562..fde24a3 100644 --- a/app/db.py +++ b/app/db.py @@ -81,6 +81,16 @@ class Database: updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS message_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + card_title_template TEXT NOT NULL, + card_body_template TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_rules_match ON routing_rules(enabled, timeframe, symbol, strategy, priority); @@ -138,6 +148,44 @@ class Database: "ALTER TABLE routing_rules ADD COLUMN card_body_template TEXT NOT NULL DEFAULT '{{symbol}} {{timeframe}} {{strategy}} {{action}} @ {{price}}'" ) now = now_iso() + conn.execute("UPDATE webhook_targets SET enabled = 1 WHERE enabled <> 1") + template_count = conn.execute("SELECT COUNT(*) AS c FROM message_templates").fetchone()["c"] + if template_count == 0: + conn.execute( + """ + INSERT INTO message_templates ( + name, description, card_title_template, card_body_template, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "供需系统通用卡片", + "适合支撑/阻力区域、确认、成交、浮盈保护等供需系统信号。", + "{{title}}", + "**{{symbol}} · {{timeframe}} · {{direction}}**\n\n" + "> {{signal_type}}\n\n" + "**价格区**\n" + "- 试仓位:{{probe_entry}}\n" + "- 优先位:{{priority_entry}}\n" + "- 防守位:{{defense_price}}\n" + "- 止损位:{{stop_loss}}\n" + "- 当前价:{{current_price}}\n\n" + "**确认状态**\n" + "- 区域评分:{{zone_score}}\n" + "- 触碰次数:{{touch_count}}\n" + "- 确认状态:{{confirmation_label}}\n" + "- 交易准备:{{trade_ready}}\n\n" + "**说明**\n" + "{{description}}\n\n" + "**风险提示**\n" + "{{risk_tip}}\n\n" + "---\n" + "{{source}} · {{strategy}} · {{time}}", + now, + now, + ), + ) conn.execute( """ INSERT OR IGNORE INTO admin_settings (id, password_hash, created_at, updated_at) diff --git a/app/dispatcher.py b/app/dispatcher.py index ecd4814..b8cd415 100644 --- a/app/dispatcher.py +++ b/app/dispatcher.py @@ -166,7 +166,7 @@ class Dispatcher: if target_ids: placeholders = ",".join("?" for _ in target_ids) targets = conn.execute( - f"SELECT * FROM webhook_targets WHERE enabled = 1 AND id IN ({placeholders})", + f"SELECT * FROM webhook_targets WHERE id IN ({placeholders})", target_ids, ).fetchall() for target in targets: @@ -194,7 +194,7 @@ class Dispatcher: if rule and not delivery_ids: conn.execute( "UPDATE alerts SET status = ?, error = ? WHERE id = ?", - ("unmatched", "Matched rule has no enabled webhook targets.", alert_id), + ("unmatched", "Matched rule has no webhook targets.", alert_id), ) self.process_due_deliveries() diff --git a/app/server.py b/app/server.py index d08233b..0099a9d 100644 --- a/app/server.py +++ b/app/server.py @@ -75,14 +75,35 @@ def target_checkbox_options( options = [] for target in targets: checked = "checked" if target["id"] in selected_ids else "" - disabled = "" if target["enabled"] else "disabled" - suffix = "" if target["enabled"] else " (停用)" options.append( - f'' + f'' ) return "".join(options) +def template_picker(templates: list[dict[str, Any]]) -> str: + if not templates: + return '

还没有飞书内容模板,可以直接填写标题和正文。

' + options = [''] + for template in templates: + options.append( + "" + ) + return ( + '
' + '' + '新增模板' + '
' + ) + + class Handler(BaseHTTPRequestHandler): context: AppContext @@ -108,6 +129,14 @@ class Handler(BaseHTTPRequestHandler): self.render_targets() elif parsed.path == "/targets/delete": self.render_target_delete(parsed) + elif parsed.path == "/templates": + self.render_templates() + elif parsed.path == "/templates/new": + self.render_template_new() + elif parsed.path == "/templates/edit": + self.render_template_edit(parsed) + elif parsed.path == "/templates/delete": + self.render_template_delete(parsed) elif parsed.path == "/rules": self.render_rules() elif parsed.path == "/rules/new": @@ -146,6 +175,9 @@ class Handler(BaseHTTPRequestHandler): "/targets/update": self.update_target, "/targets/delete": self.delete_target, "/targets/test": self.test_target, + "/templates/create": self.create_template, + "/templates/update": self.update_template, + "/templates/delete": self.delete_template, "/rules/create": self.create_rule, "/rules/update": self.update_rule, "/rules/delete": self.delete_rule, @@ -170,6 +202,7 @@ class Handler(BaseHTTPRequestHandler): nav = [ ("/dashboard", "概览"), ("/rules", "路由规则"), + ("/templates", "飞书模板"), ("/targets", "飞书 Webhook"), ("/logs", "日志"), ("/test", "测试发送"), @@ -301,6 +334,11 @@ class Handler(BaseHTTPRequestHandler): rules.append(item) return rules + def list_templates(self) -> list[dict[str, Any]]: + with self.context.db.connect() as conn: + rows = conn.execute("SELECT * FROM message_templates ORDER BY id DESC").fetchall() + return [dict(row) for row in rows] + def list_logs(self) -> dict[str, list[dict[str, Any]]]: with self.context.db.connect() as conn: alerts = conn.execute("SELECT * FROM alerts ORDER BY id DESC LIMIT 100").fetchall() @@ -353,7 +391,6 @@ class Handler(BaseHTTPRequestHandler): {target['id']} -
删除 @@ -364,11 +401,10 @@ class Handler(BaseHTTPRequestHandler):

新增飞书 Webhook

- """ notice = getattr(self, "_target_notice", "") - self.send_html("飞书 Webhook", f"

飞书 Webhook

维护所有可分发的飞书机器人地址。

{notice}{form}{rows}
ID名称URL状态操作
") + self.send_html("飞书 Webhook", f"

飞书 Webhook

维护所有可分发的飞书机器人地址。

{notice}{form}{rows}
ID名称URL操作
") def render_target_delete(self, parsed: Any) -> None: target_id = parse_qs(parsed.query).get("id", [""])[-1] @@ -389,6 +425,78 @@ class Handler(BaseHTTPRequestHandler): """ self.send_html("删除飞书 Webhook", body) + def render_templates(self) -> None: + templates = self.list_templates() + rows = "".join( + f""" +{template['id']} +{html.escape(template['name'])} +{html.escape(template['description'] or '-')} +{html.escape(template['card_title_template'])} +编辑删除 +""" + for template in templates + ) + header = """""" + self.send_html("飞书内容模板", f"{header}{rows}
ID名称说明标题模板操作
") + + def render_template_form( + self, + title: str, + action: str, + template: dict[str, Any] | None = None, + ) -> None: + template = template or { + "id": "", + "name": "", + "description": "", + "card_title_template": "{{title}}", + "card_body_template": "**{{symbol}} · {{timeframe}} · {{direction}}**\n\n> {{signal_type}}\n\n**价格区**\n- 试仓位:{{probe_entry}}\n- 优先位:{{priority_entry}}\n- 防守位:{{defense_price}}\n- 止损位:{{stop_loss}}\n- 当前价:{{current_price}}\n\n**说明**\n{{description}}\n\n**风险提示**\n{{risk_tip}}", + } + hidden_id = f'' if template.get("id") else "" + button_text = "保存修改" if template.get("id") else "创建模板" + body = f"""

{html.escape(title)}

模板支持 {{{{field}}}}{{field}} 占位符,字段来自 TradingView JSON。

+
+{hidden_id} + + + + +
返回列表
+
""" + self.send_html(title, body) + + def render_template_new(self) -> None: + self.render_template_form("新增飞书内容模板", "/templates/create") + + def render_template_edit(self, parsed: Any) -> None: + template_id = parse_qs(parsed.query).get("id", [""])[-1] + with self.context.db.connect() as conn: + template = conn.execute("SELECT * FROM message_templates WHERE id = ?", (template_id,)).fetchone() + if not template: + self.send_error(404) + return + self.render_template_form("编辑飞书内容模板", "/templates/update", dict(template)) + + def render_template_delete(self, parsed: Any) -> None: + template_id = parse_qs(parsed.query).get("id", [""])[-1] + with self.context.db.connect() as conn: + template = conn.execute("SELECT * FROM message_templates WHERE id = ?", (template_id,)).fetchone() + if not template: + self.send_error(404) + return + body = f"""

删除飞书内容模板

请确认是否删除这个模板。

+
+

{html.escape(template['name'])}

+

删除模板不会影响已经创建的路由规则,只是后续不能再套用它。

+
+ + +取消 +
+
""" + self.send_html("删除飞书内容模板", body) + def render_rules(self) -> None: targets = self.list_targets() rules = self.list_rules() @@ -400,15 +508,16 @@ class Handler(BaseHTTPRequestHandler): f"品种={html.escape(rule['symbol'])}" if rule["symbol"] else "", f"策略={html.escape(rule['strategy'])}" if rule["strategy"] else "", ] - target_name = "
".join( - html.escape(target_names.get(target_id, f"#{target_id}")) for target_id in rule["target_ids"] + target_badges = "".join( + f'{html.escape(target_names.get(target_id, f"#{target_id}"))}' + for target_id in rule["target_ids"] ) or "-" rows += f""" {rule['id']} {html.escape(rule['name'])} {'
'.join(item for item in conditions if item) or '-'} {rule['priority']} -{html.escape(target_name)} +
{target_badges}
{'启用' if rule['enabled'] else '停用'} 编辑删除 """ @@ -422,6 +531,7 @@ class Handler(BaseHTTPRequestHandler): rule: dict[str, Any] | None = None, ) -> None: targets = self.list_targets() + templates = self.list_templates() rule = rule or { "id": "", "name": "", @@ -435,6 +545,7 @@ class Handler(BaseHTTPRequestHandler): "enabled": 1, } selected_targets = target_checkbox_options(targets, rule.get("target_ids", [])) + picker = template_picker(templates) hidden_id = f'' if rule.get("id") else "" button_text = "保存修改" if rule.get("id") else "创建规则" body = f"""

{html.escape(title)}

消息统一使用飞书卡片。周期、品种、策略至少填写一个,空字段表示不限。

@@ -447,8 +558,9 @@ class Handler(BaseHTTPRequestHandler): - - +{picker} + +
发送到
{selected_targets}
返回列表
@@ -546,7 +658,7 @@ class Handler(BaseHTTPRequestHandler): with self.context.db.connect() as conn: conn.execute( "INSERT INTO webhook_targets (name, webhook_url, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", - (form["name"].strip(), form["webhook_url"].strip(), 1 if form.get("enabled") == "on" else 0, now, now), + (form["name"].strip(), form["webhook_url"].strip(), 1, now, now), ) redirect(self, "/targets") @@ -555,7 +667,7 @@ class Handler(BaseHTTPRequestHandler): with self.context.db.connect() as conn: conn.execute( "UPDATE webhook_targets SET name = ?, webhook_url = ?, enabled = ?, updated_at = ? WHERE id = ?", - (form["name"].strip(), form["webhook_url"].strip(), 1 if form.get("enabled") == "on" else 0, now_iso(), form["id"]), + (form["name"].strip(), form["webhook_url"].strip(), 1, now_iso(), form["id"]), ) redirect(self, "/targets") @@ -599,6 +711,56 @@ class Handler(BaseHTTPRequestHandler): self._target_notice = f"""

Webhook 测试

{message}

""" self.render_targets() + def create_template(self) -> None: + form = parse_form(self) + now = now_iso() + with self.context.db.connect() as conn: + conn.execute( + """ + INSERT INTO message_templates ( + name, description, card_title_template, card_body_template, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + form["name"].strip(), + form.get("description", "").strip(), + form["card_title_template"].strip(), + form["card_body_template"].strip(), + now, + now, + ), + ) + redirect(self, "/templates") + + def update_template(self) -> None: + form = parse_form(self) + with self.context.db.connect() as conn: + conn.execute( + """ + UPDATE message_templates + SET name = ?, description = ?, card_title_template = ?, + card_body_template = ?, updated_at = ? + WHERE id = ? + """, + ( + form["name"].strip(), + form.get("description", "").strip(), + form["card_title_template"].strip(), + form["card_body_template"].strip(), + now_iso(), + form["id"], + ), + ) + redirect(self, "/templates") + + def delete_template(self) -> None: + form = parse_form(self) + with self.context.db.connect() as conn: + conn.execute("DELETE FROM message_templates WHERE id = ?", (form["id"],)) + redirect(self, "/templates") + def create_rule(self) -> None: form = parse_form_multi(self) target_ids = [int(value) for value in form.get("target_ids", [])] @@ -608,6 +770,9 @@ class Handler(BaseHTTPRequestHandler): if not any((timeframe, symbol, strategy)): self.send_error(400, "周期、品种、策略至少填写一个") return + if not target_ids: + self.send_error(400, "至少选择一个飞书 Webhook") + return now = now_iso() with self.context.db.connect() as conn: conn.execute( @@ -651,6 +816,9 @@ class Handler(BaseHTTPRequestHandler): if not any((timeframe, symbol, strategy)): self.send_error(400, "周期、品种、策略至少填写一个") return + if not target_ids: + self.send_error(400, "至少选择一个飞书 Webhook") + return with self.context.db.connect() as conn: conn.execute( """ diff --git a/app/static/app.js b/app/static/app.js index 9132e6b..f2f6ee1 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -13,3 +13,15 @@ document.addEventListener("click", async (event) => { window.prompt("复制下面的内容", value); } }); + +document.addEventListener("change", (event) => { + const picker = event.target.closest("[data-template-picker]"); + if (!picker) return; + const option = picker.selectedOptions[0]; + if (!option || !option.value) return; + const form = picker.closest("form"); + const titleInput = form?.querySelector("[data-title-template-input]"); + const bodyInput = form?.querySelector("[data-body-template-input]"); + if (titleInput) titleInput.value = option.dataset.title || ""; + if (bodyInput) bodyInput.value = option.dataset.body || ""; +}); diff --git a/app/static/styles.css b/app/static/styles.css index a2c7886..4ab3da7 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -105,6 +105,10 @@ p { font-family: ui-sans-serif, system-ui, sans-serif; } +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + .metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -227,6 +231,58 @@ td textarea { background: #eef8f5; } +.template-picker { + display: grid; + grid-template-columns: minmax(260px, 420px) auto; + align-items: end; + gap: 12px; + margin: 6px 0 14px; +} + +.template-picker label { + margin-bottom: 0; +} + +.button-link.compact { + width: fit-content; + margin-bottom: 0; +} + +.muted-note { + margin: 4px 0 14px; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-width: 360px; +} + +.tag { + display: inline-flex; + align-items: center; + max-width: 180px; + padding: 4px 9px; + border: 1px solid #bdd8d2; + border-radius: 999px; + background: #eef8f5; + color: #0b534d; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.template-sample { + display: block; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .check { display: inline-flex; align-items: center; @@ -543,4 +599,8 @@ th { .copy-row { grid-template-columns: 1fr; } + + .template-picker { + grid-template-columns: 1fr; + } } diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 1eca97b..b867013 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -150,6 +150,18 @@ class DispatcherTest(unittest.TestCase): count = conn.execute("SELECT COUNT(*) AS c FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()["c"] self.assertEqual(count, 2) + def test_legacy_disabled_target_is_still_dispatchable(self) -> None: + target_id = self.add_target() + with self.db.connect() as conn: + conn.execute("UPDATE webhook_targets SET enabled = 0 WHERE id = ?", (target_id,)) + self.add_rule(target_id) + + result = self.dispatcher.receive_alert( + {"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy"} + ) + + self.assertEqual(len(result["delivery_ids"]), 1) + def test_card_template_uses_alert_fields(self) -> None: message = build_feishu_message( {"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},