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}")
+ self.send_html("飞书 Webhook", f"飞书 Webhook维护所有可分发的飞书机器人地址。 {notice}{form}")
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}")
+
+ 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"""
+"""
+ 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"""
@@ -447,8 +558,9 @@ class Handler(BaseHTTPRequestHandler):
-
-
+{picker}
+
+
@@ -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""""""
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},