This commit is contained in:
aaron 2026-05-15 22:04:05 +08:00
parent 6721cfddf1
commit 377c6ee930
7 changed files with 320 additions and 16 deletions

View File

@ -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}}`

View File

@ -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)

View File

@ -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()

View File

@ -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'<label class="target-choice"><input type="checkbox" name="target_ids" value="{target["id"]}" {checked} {disabled}> <span>{html.escape(target["name"])}{suffix}</span></label>'
f'<label class="target-choice"><input type="checkbox" name="target_ids" value="{target["id"]}" {checked}> <span>{html.escape(target["name"])}</span></label>'
)
return "".join(options)
def template_picker(templates: list[dict[str, Any]]) -> str:
if not templates:
return '<p class="muted-note">还没有飞书内容模板,可以直接填写标题和正文。</p>'
options = ['<option value="">不套用模板</option>']
for template in templates:
options.append(
"<option "
f'value="{template["id"]}" '
f'data-title="{html.escape(template["card_title_template"], quote=True)}" '
f'data-body="{html.escape(template["card_body_template"], quote=True)}">'
f'{html.escape(template["name"])}'
"</option>"
)
return (
'<div class="template-picker">'
'<label>套用飞书内容模板'
f'<select data-template-picker>{"".join(options)}</select>'
'</label>'
'<a class="button-link secondary compact" href="/templates/new">新增模板</a>'
'</div>'
)
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):
<td>{target['id']}<input form="target-update-{target['id']}" type="hidden" name="id" value="{target['id']}"></td>
<td><input form="target-update-{target['id']}" name="name" value="{html.escape(target['name'])}" required></td>
<td class="url"><input form="target-update-{target['id']}" name="webhook_url" value="{html.escape(target['webhook_url'])}" type="url" required></td>
<td><label class="check"><input form="target-update-{target['id']}" name="enabled" type="checkbox" {'checked' if target['enabled'] else ''}> 启用</label></td>
<td><form id="target-update-{target['id']}" class="inline" method="post" action="/targets/update"></form><button form="target-update-{target['id']}" type="submit">更新</button>
<form class="inline" method="post" action="/targets/test"><input type="hidden" name="id" value="{target['id']}"><button type="submit">测试</button></form>
<a class="button-link danger-link" href="/targets/delete?id={target['id']}">删除</a>
@ -364,11 +401,10 @@ class Handler(BaseHTTPRequestHandler):
<h2>新增飞书 Webhook</h2>
<label>名称<input name="name" required></label>
<label>Webhook URL<input name="webhook_url" type="url" required></label>
<label class="check"><input name="enabled" type="checkbox" checked> 启用</label>
<button type="submit">保存目标</button>
</form>"""
notice = getattr(self, "_target_notice", "")
self.send_html("飞书 Webhook", f"<header><h1>飞书 Webhook</h1><p>维护所有可分发的飞书机器人地址。</p></header>{notice}{form}<table><thead><tr><th>ID</th><th>名称</th><th>URL</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
self.send_html("飞书 Webhook", f"<header><h1>飞书 Webhook</h1><p>维护所有可分发的飞书机器人地址。</p></header>{notice}{form}<table><thead><tr><th>ID</th><th>名称</th><th>URL</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
def render_target_delete(self, parsed: Any) -> None:
target_id = parse_qs(parsed.query).get("id", [""])[-1]
@ -389,6 +425,78 @@ class Handler(BaseHTTPRequestHandler):
</section>"""
self.send_html("删除飞书 Webhook", body)
def render_templates(self) -> None:
templates = self.list_templates()
rows = "".join(
f"""<tr>
<td>{template['id']}</td>
<td>{html.escape(template['name'])}</td>
<td>{html.escape(template['description'] or '-')}</td>
<td><span class="template-sample">{html.escape(template['card_title_template'])}</span></td>
<td><a class="button-link" href="/templates/edit?id={template['id']}">编辑</a><a class="button-link danger-link" href="/templates/delete?id={template['id']}">删除</a></td>
</tr>"""
for template in templates
)
header = """<header class="page-header"><div><h1>飞书内容模板</h1><p>把常用卡片标题和正文保存成模板,新建或编辑路由规则时可以一键套用。</p></div><a class="button-link" href="/templates/new">新增模板</a></header>"""
self.send_html("飞书内容模板", f"{header}<table><thead><tr><th>ID</th><th>名称</th><th>说明</th><th>标题模板</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
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'<input type="hidden" name="id" value="{template["id"]}">' if template.get("id") else ""
button_text = "保存修改" if template.get("id") else "创建模板"
body = f"""<header><h1>{html.escape(title)}</h1><p>模板支持 <code>{{{{field}}}}</code> 或 <code>{{field}}</code> 占位符,字段来自 TradingView JSON。</p></header>
<form class="panel template-form" method="post" action="{action}">
{hidden_id}
<label>模板名称<input name="name" value="{html.escape(str(template['name']))}" required></label>
<label>说明<input name="description" value="{html.escape(str(template['description']))}" placeholder="例如:供需系统阻力/支撑区域通用"></label>
<label>卡片标题模板<input name="card_title_template" value="{html.escape(str(template['card_title_template']))}" required></label>
<label>卡片正文模板<textarea name="card_body_template" rows="16" required>{html.escape(str(template['card_body_template']))}</textarea></label>
<div class="actions"><button type="submit">{button_text}</button><a class="button-link secondary" href="/templates">返回列表</a></div>
</form>"""
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"""<header><h1>删除飞书内容模板</h1><p>请确认是否删除这个模板。</p></header>
<section class="panel narrow">
<h2>{html.escape(template['name'])}</h2>
<p>删除模板不会影响已经创建的路由规则只是后续不能再套用它</p>
<form method="post" action="/templates/delete" class="actions">
<input type="hidden" name="id" value="{template['id']}">
<button class="danger" type="submit">确认删除</button>
<a class="button-link secondary" href="/templates">取消</a>
</form>
</section>"""
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 = "<br>".join(
html.escape(target_names.get(target_id, f"#{target_id}")) for target_id in rule["target_ids"]
target_badges = "".join(
f'<span class="tag">{html.escape(target_names.get(target_id, f"#{target_id}"))}</span>'
for target_id in rule["target_ids"]
) or "-"
rows += f"""<tr>
<td>{rule['id']}</td>
<td>{html.escape(rule['name'])}</td>
<td>{'<br>'.join(item for item in conditions if item) or '-'}</td>
<td>{rule['priority']}</td>
<td>{html.escape(target_name)}</td>
<td><div class="tag-list">{target_badges}</div></td>
<td><span class="status">{'启用' if rule['enabled'] else '停用'}</span></td>
<td><a class="button-link" href="/rules/edit?id={rule['id']}">编辑</a><a class="button-link danger-link" href="/rules/delete?id={rule['id']}">删除</a></td>
</tr>"""
@ -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'<input type="hidden" name="id" value="{rule["id"]}">' if rule.get("id") else ""
button_text = "保存修改" if rule.get("id") else "创建规则"
body = f"""<header><h1>{html.escape(title)}</h1><p>消息统一使用飞书卡片。周期、品种、策略至少填写一个,空字段表示不限。</p></header>
@ -447,8 +558,9 @@ class Handler(BaseHTTPRequestHandler):
<label>策略<input name="strategy" value="{html.escape(str(rule['strategy']))}" placeholder="breakout空=不限"></label>
<label>优先级<input name="priority" type="number" value="{rule['priority']}" required></label>
</div>
<label>卡片标题模板<input name="card_title_template" value="{html.escape(str(rule['card_title_template']))}" required></label>
<label>卡片正文模板<textarea name="card_body_template" rows="6">{html.escape(str(rule['card_body_template']))}</textarea></label>
{picker}
<label>卡片标题模板<input data-title-template-input name="card_title_template" value="{html.escape(str(rule['card_title_template']))}" required></label>
<label>卡片正文模板<textarea data-body-template-input name="card_body_template" rows="10">{html.escape(str(rule['card_body_template']))}</textarea></label>
<div class="field-target"><span class="field-label">发送到</span><div class="target-choices">{selected_targets}</div></div>
<label class="switch"><input name="enabled" type="checkbox" {'checked' if rule.get('enabled') else ''}><span></span><strong>启用规则</strong></label>
<div class="actions"><button type="submit">{button_text}</button><a class="button-link secondary" href="/rules">返回列表</a></div>
@ -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"""<section class="result-panel {'success' if success else 'error'}"><h2>Webhook 测试</h2><p>{message}</p></section>"""
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(
"""

View File

@ -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 || "";
});

View File

@ -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;
}
}

View File

@ -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},