diff --git a/README.md b/README.md
index 5c7436b..e26b52a 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ X-Webhook-Token: your-shared-secret
标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`。
-每条路由规则通过「发送到」下拉框选择一个飞书 Webhook。`timeframe`、`symbol`、`strategy` 至少填写一个,空字段表示不限。例如只填 `symbol=BTCUSDT` 会匹配所有 BTCUSDT 信号。需要同一个信号发到多个群时,可以建多条匹配条件相同、目标不同的规则,并用优先级控制命中顺序;当前默认路由逻辑只发送最高优先级命中的规则。
+每条路由规则通过「发送到」多选框选择一个或多个飞书 Webhook。`timeframe`、`symbol`、`strategy` 至少填写一个,空字段表示不限。例如只填 `symbol=BTCUSDT` 会匹配所有 BTCUSDT 信号。当前默认路由逻辑只匹配最高优先级的规则,但这条规则可以同时分发到多个飞书目标。
示例正文模板:
diff --git a/app/server.py b/app/server.py
index 73c471b..fdc0d38 100644
--- a/app/server.py
+++ b/app/server.py
@@ -400,7 +400,9 @@ class Handler(BaseHTTPRequestHandler):
f"品种={html.escape(rule['symbol'])}" if rule["symbol"] 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 = "
".join(
+ 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'])} |
@@ -434,7 +436,7 @@ class Handler(BaseHTTPRequestHandler):
"target_ids": [],
"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'' if rule.get("id") else ""
button_text = "保存修改" if rule.get("id") else "创建规则"
sample_payload = sample_payload or json.dumps(
@@ -454,7 +456,7 @@ class Handler(BaseHTTPRequestHandler):
-
+
规则命中与卡片预览
diff --git a/app/static/styles.css b/app/static/styles.css
index 68ae4f4..e9ccadf 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -197,6 +197,10 @@ td textarea {
width: clamp(220px, 34vw, 360px);
}
+.select-target.multi {
+ min-height: 138px;
+}
+
.check {
display: inline-flex;
align-items: center;
diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py
index 733e9cf..1eca97b 100644
--- a/tests/test_dispatcher.py
+++ b/tests/test_dispatcher.py
@@ -42,6 +42,17 @@ class DispatcherTest(unittest.TestCase):
timeframe: str = "5m",
symbol: str = "BTCUSDT",
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:
now = now_iso()
with self.db.connect() as conn:
@@ -55,7 +66,7 @@ class DispatcherTest(unittest.TestCase):
)
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)
@@ -125,6 +136,20 @@ class DispatcherTest(unittest.TestCase):
self.assertEqual(delivery["attempts"], 1)
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:
message = build_feishu_message(
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},