This commit is contained in:
aaron 2026-05-15 17:09:15 +08:00
parent 17a5828266
commit 829905842a
4 changed files with 36 additions and 5 deletions

View File

@ -68,7 +68,7 @@ X-Webhook-Token: your-shared-secret
标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}` 标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`
每条路由规则通过「发送到」下拉框选择一个飞书 Webhook。`timeframe`、`symbol`、`strategy` 至少填写一个,空字段表示不限。例如只填 `symbol=BTCUSDT` 会匹配所有 BTCUSDT 信号。需要同一个信号发到多个群时,可以建多条匹配条件相同、目标不同的规则,并用优先级控制命中顺序;当前默认路由逻辑只发送最高优先级命中的规则 每条路由规则通过「发送到」多选框选择一个或多个飞书 Webhook。`timeframe`、`symbol`、`strategy` 至少填写一个,空字段表示不限。例如只填 `symbol=BTCUSDT` 会匹配所有 BTCUSDT 信号。当前默认路由逻辑只匹配最高优先级的规则,但这条规则可以同时分发到多个飞书目标
示例正文模板: 示例正文模板:

View File

@ -400,7 +400,9 @@ class Handler(BaseHTTPRequestHandler):
f"品种={html.escape(rule['symbol'])}" if rule["symbol"] else "", f"品种={html.escape(rule['symbol'])}" if rule["symbol"] else "",
f"策略={html.escape(rule['strategy'])}" if rule["strategy"] else "", f"策略={html.escape(rule['strategy'])}" if rule["strategy"] else "",
] ]
target_name = target_names.get(rule["target_ids"][0], "-") if rule["target_ids"] else "-" target_name = "<br>".join(
html.escape(target_names.get(target_id, f"#{target_id}")) for target_id in rule["target_ids"]
) or "-"
rows += f"""<tr> rows += f"""<tr>
<td>{rule['id']}</td> <td>{rule['id']}</td>
<td>{html.escape(rule['name'])}</td> <td>{html.escape(rule['name'])}</td>
@ -434,7 +436,7 @@ class Handler(BaseHTTPRequestHandler):
"target_ids": [], "target_ids": [],
"enabled": 1, "enabled": 1,
} }
selected_targets = target_select_options(targets, rule.get("target_ids", []), placeholder=True) selected_targets = target_select_options(targets, rule.get("target_ids", []))
hidden_id = f'<input type="hidden" name="id" value="{rule["id"]}">' if rule.get("id") else "" hidden_id = f'<input type="hidden" name="id" value="{rule["id"]}">' if rule.get("id") else ""
button_text = "保存修改" if rule.get("id") else "创建规则" button_text = "保存修改" if rule.get("id") else "创建规则"
sample_payload = sample_payload or json.dumps( sample_payload = sample_payload or json.dumps(
@ -454,7 +456,7 @@ class Handler(BaseHTTPRequestHandler):
</div> </div>
<label>卡片标题模板<input name="card_title_template" value="{html.escape(str(rule['card_title_template']))}" required></label> <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> <label>卡片正文模板<textarea name="card_body_template" rows="6">{html.escape(str(rule['card_body_template']))}</textarea></label>
<label class="field-target">发送到<select class="select-target" name="target_ids" required>{selected_targets}</select></label> <label class="field-target">发送到<select class="select-target multi" name="target_ids" multiple required size="5">{selected_targets}</select></label>
<label class="check"><input name="enabled" type="checkbox" {'checked' if rule.get('enabled') else ''}> 启用</label> <label class="check"><input name="enabled" type="checkbox" {'checked' if rule.get('enabled') else ''}> 启用</label>
<h2>规则命中与卡片预览</h2> <h2>规则命中与卡片预览</h2>
<input type="hidden" name="source_action" value="{html.escape(action)}"> <input type="hidden" name="source_action" value="{html.escape(action)}">

View File

@ -197,6 +197,10 @@ td textarea {
width: clamp(220px, 34vw, 360px); width: clamp(220px, 34vw, 360px);
} }
.select-target.multi {
min-height: 138px;
}
.check { .check {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -42,6 +42,17 @@ class DispatcherTest(unittest.TestCase):
timeframe: str = "5m", timeframe: str = "5m",
symbol: str = "BTCUSDT", symbol: str = "BTCUSDT",
strategy: str = "breakout", strategy: str = "breakout",
) -> int:
return self.add_rule_with_targets([target_id], priority, name, timeframe, symbol, strategy)
def add_rule_with_targets(
self,
target_ids: list[int],
priority: int = 100,
name: str = "rule",
timeframe: str = "5m",
symbol: str = "BTCUSDT",
strategy: str = "breakout",
) -> int: ) -> int:
now = now_iso() now = now_iso()
with self.db.connect() as conn: with self.db.connect() as conn:
@ -55,7 +66,7 @@ class DispatcherTest(unittest.TestCase):
) )
VALUES (?, ?, ?, ?, ?, 'card', 'Signal {{symbol}}', 'Price {{price}}', 1, ?, ?, ?) VALUES (?, ?, ?, ?, ?, 'card', 'Signal {{symbol}}', 'Price {{price}}', 1, ?, ?, ?)
""", """,
(name, timeframe, symbol, strategy, priority, to_json([target_id]), now, now), (name, timeframe, symbol, strategy, priority, to_json(target_ids), now, now),
) )
return int(cur.lastrowid) return int(cur.lastrowid)
@ -125,6 +136,20 @@ class DispatcherTest(unittest.TestCase):
self.assertEqual(delivery["attempts"], 1) self.assertEqual(delivery["attempts"], 1)
self.assertIsNotNone(delivery["error"]) self.assertIsNotNone(delivery["error"])
def test_rule_can_dispatch_to_multiple_targets(self) -> None:
target_a = self.add_target("ops-a")
target_b = self.add_target("ops-b")
self.add_rule_with_targets([target_a, target_b])
result = self.dispatcher.receive_alert(
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy"}
)
self.assertEqual(len(result["delivery_ids"]), 2)
with self.db.connect() as conn:
count = conn.execute("SELECT COUNT(*) AS c FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()["c"]
self.assertEqual(count, 2)
def test_card_template_uses_alert_fields(self) -> None: def test_card_template_uses_alert_fields(self) -> None:
message = build_feishu_message( message = build_feishu_message(
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000}, {"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},