This commit is contained in:
aaron 2026-05-14 22:12:31 +08:00
parent d01c0d49cd
commit 4f025c3736
9 changed files with 495 additions and 141 deletions

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
APP_HOST=0.0.0.0
APP_PORT=8030
DATABASE_PATH=data/dispatcher.db
ADMIN_USERNAME=admin
ADMIN_PASSWORD=12345678
SESSION_SECRET=DBUwycvdxjSUZX4LMvUKa0xMzWKzFJmg
# Optional but recommended. When set, TradingView must call:
# /webhook/tradingview?token=your-shared-secret
# or send X-Webhook-Token: your-shared-secret
WEBHOOK_TOKEN=vvyVmc33aC0I85LkH4yrd6ojvkqmyrb1
RETENTION_DAYS=30
MAX_DELIVERY_ATTEMPTS=3
RETRY_BACKOFF_SECONDS=60
FEISHU_TIMEOUT_SECONDS=10
WORKER_INTERVAL_SECONDS=15

View File

@ -1,6 +1,6 @@
# TradingView Alert Dispatcher
接收 TradingView webhook alert`timeframe + symbol + strategy` 路由到飞书 webhook并提供管理控制台。
接收 TradingView webhook alert`timeframe / symbol / strategy` 等条件路由到飞书 webhook并提供管理控制台。
## Run Locally
@ -44,16 +44,22 @@ POST /webhook/tradingview
Content-Type: application/json
```
如果设置了 `WEBHOOK_TOKEN`TradingView 需要使用以下任一方式携带 token
```text
POST /webhook/tradingview?token=your-shared-secret
X-Webhook-Token: your-shared-secret
```
`docker-compose.yml` 默认已经设置了占位 token生产使用前请替换本地临时调试如果不想校验 token可以把 `WEBHOOK_TOKEN` 置为空。
## Feishu Message Templates
路由规则支持两种消息类型:
- `Card`:默认,发送飞书 interactive card。
- `Text`:发送普通文本消息。
路由规则统一发送飞书 interactive card。
标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`
每条路由规则通过「发送到」下拉框选择一个飞书 Webhook。需要同一个信号发到多个群时可以建多条匹配条件相同、目标不同的规则并用优先级控制命中顺序当前默认路由逻辑只发送最高优先级命中的规则。
每条路由规则通过「发送到」下拉框选择一个飞书 Webhook。`timeframe`、`symbol`、`strategy` 至少填写一个,空字段表示不限。例如只填 `symbol=BTCUSDT` 会匹配所有 BTCUSDT 信号。需要同一个信号发到多个群时,可以建多条匹配条件相同、目标不同的规则,并用优先级控制命中顺序;当前默认路由逻辑只发送最高优先级命中的规则。
示例正文模板:
@ -70,6 +76,7 @@ Content-Type: application/json
- `ADMIN_USERNAME`
- `ADMIN_PASSWORD`
- `SESSION_SECRET`
- `WEBHOOK_TOKEN`
- `DATABASE_PATH`
- `RETENTION_DAYS`
- `MAX_DELIVERY_ATTEMPTS`

View File

@ -13,6 +13,7 @@ class Settings:
admin_username: str = "admin"
admin_password: str = "change-me-now"
session_secret: str = "change-this-session-secret"
webhook_token: str = ""
retention_days: int = 30
max_delivery_attempts: int = 3
retry_backoff_seconds: int = 60
@ -27,6 +28,7 @@ def get_settings() -> Settings:
admin_username=os.getenv("ADMIN_USERNAME", "admin"),
admin_password=os.getenv("ADMIN_PASSWORD", "change-me-now"),
session_secret=os.getenv("SESSION_SECRET", "change-this-session-secret"),
webhook_token=os.getenv("WEBHOOK_TOKEN", ""),
retention_days=int(os.getenv("RETENTION_DAYS", "30")),
max_delivery_attempts=int(os.getenv("MAX_DELIVERY_ATTEMPTS", "3")),
retry_backoff_seconds=int(os.getenv("RETRY_BACKOFF_SECONDS", "60")),

View File

@ -14,7 +14,6 @@ from app.db import Database, from_json, now_iso, to_json
UTC = timezone.utc
REQUIRED_ALERT_FIELDS = ("timeframe", "symbol", "strategy")
TEMPLATE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}|(?<!{){\s*([a-zA-Z0-9_.-]+)\s*}(?!})")
@ -23,13 +22,10 @@ class ValidationError(ValueError):
def normalize_alert(payload: dict[str, Any]) -> dict[str, Any]:
missing = [field for field in REQUIRED_ALERT_FIELDS if not str(payload.get(field, "")).strip()]
if missing:
raise ValidationError(f"Missing required fields: {', '.join(missing)}")
normalized = dict(payload)
normalized["timeframe"] = str(payload["timeframe"]).strip()
normalized["symbol"] = str(payload["symbol"]).strip().upper()
normalized["strategy"] = str(payload["strategy"]).strip()
normalized["timeframe"] = str(payload.get("timeframe", "")).strip()
normalized["symbol"] = str(payload.get("symbol", "")).strip().upper()
normalized["strategy"] = str(payload.get("strategy", "")).strip()
if "price" in normalized and normalized["price"] not in (None, ""):
try:
normalized["price"] = float(normalized["price"])
@ -59,9 +55,9 @@ def render_template(template: str, alert: dict[str, Any]) -> str:
def default_body(alert: dict[str, Any]) -> str:
action = alert.get("action") or alert.get("signal") or "alert"
lines = [
f"TradingView 信号: {alert['symbol']}",
f"周期: {alert['timeframe']}",
f"策略: {alert['strategy']}",
f"TradingView 信号: {alert.get('symbol') or '-'}",
f"周期: {alert.get('timeframe') or '-'}",
f"策略: {alert.get('strategy') or '-'}",
f"动作: {action}",
]
if alert.get("price") is not None:
@ -75,12 +71,9 @@ def build_feishu_message(alert: dict[str, Any], rule: dict[str, Any] | None = No
rule = rule or {}
title_template = rule.get("card_title_template") or "TradingView {{symbol}} {{action}}"
body_template = rule.get("card_body_template") or default_body(alert)
title = render_template(title_template, alert).strip() or f"TradingView {alert['symbol']}"
title = render_template(title_template, alert).strip() or f"TradingView {alert.get('symbol') or 'Alert'}"
body = render_template(body_template, alert).strip() or default_body(alert)
if rule.get("message_type") == "text":
return {"msg_type": "text", "content": {"text": f"{title}\n{body}"}}
return {
"msg_type": "interactive",
"card": {
@ -99,7 +92,7 @@ def build_feishu_message(alert: dict[str, Any], rule: dict[str, Any] | None = No
"elements": [
{
"tag": "plain_text",
"content": f"{alert['symbol']} · {alert['timeframe']} · {alert['strategy']}",
"content": f"{alert.get('symbol') or '-'} · {alert.get('timeframe') or '-'} · {alert.get('strategy') or '-'}",
}
],
},
@ -113,22 +106,35 @@ class Dispatcher:
self.db = db
self.settings = settings
def find_matching_rule(self, alert: dict[str, Any]) -> dict[str, Any] | None:
normalized = normalize_alert(alert)
with self.db.connect() as conn:
row = conn.execute(
"""
SELECT * FROM routing_rules
WHERE enabled = 1
AND (timeframe = '' OR timeframe = ?)
AND (symbol = '' OR upper(symbol) = ?)
AND (strategy = '' OR strategy = ?)
AND (timeframe <> '' OR symbol <> '' OR strategy <> '')
ORDER BY priority ASC,
(
CASE WHEN timeframe <> '' THEN 1 ELSE 0 END +
CASE WHEN symbol <> '' THEN 1 ELSE 0 END +
CASE WHEN strategy <> '' THEN 1 ELSE 0 END
) DESC,
id ASC
LIMIT 1
""",
(normalized["timeframe"], normalized["symbol"], normalized["strategy"]),
).fetchone()
return dict(row) if row else None
def receive_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
alert = normalize_alert(payload)
created_at = now_iso()
with self.db.connect() as conn:
rule = conn.execute(
"""
SELECT * FROM routing_rules
WHERE enabled = 1
AND timeframe = ?
AND upper(symbol) = ?
AND strategy = ?
ORDER BY priority ASC, id ASC
LIMIT 1
""",
(alert["timeframe"], alert["symbol"], alert["strategy"]),
).fetchone()
rule = self.find_matching_rule(alert)
status = "matched" if rule else "unmatched"
cur = conn.execute(
@ -204,8 +210,7 @@ class Dispatcher:
with self.db.connect() as conn:
rows = conn.execute(
"""
SELECT d.*, a.payload
, r.message_type, r.card_title_template, r.card_body_template
SELECT d.*, a.payload, r.card_title_template, r.card_body_template
FROM deliveries d
JOIN alerts a ON a.id = d.alert_id
LEFT JOIN routing_rules r ON r.id = d.rule_id

View File

@ -4,6 +4,8 @@ import html
import json
import mimetypes
import os
import urllib.error
import urllib.request
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -13,7 +15,7 @@ from urllib.parse import parse_qs, urlparse
from app.auth import COOKIE_NAME, check_credentials, hash_password, is_valid_session, make_session_cookie
from app.config import Settings, get_settings
from app.db import Database, from_json, now_iso, to_json
from app.dispatcher import Dispatcher, ValidationError
from app.dispatcher import Dispatcher, ValidationError, build_feishu_message, normalize_alert
class AppContext:
@ -103,8 +105,16 @@ class Handler(BaseHTTPRequestHandler):
self.render_dashboard()
elif parsed.path == "/targets":
self.render_targets()
elif parsed.path == "/targets/delete":
self.render_target_delete(parsed)
elif parsed.path == "/rules":
self.render_rules()
elif parsed.path == "/rules/new":
self.render_rule_new()
elif parsed.path == "/rules/edit":
self.render_rule_edit(parsed)
elif parsed.path == "/rules/delete":
self.render_rule_delete(parsed)
elif parsed.path == "/logs":
self.render_logs()
elif parsed.path == "/test":
@ -134,9 +144,11 @@ class Handler(BaseHTTPRequestHandler):
"/targets/create": self.create_target,
"/targets/update": self.update_target,
"/targets/delete": self.delete_target,
"/targets/test": self.test_target,
"/rules/create": self.create_rule,
"/rules/update": self.update_rule,
"/rules/delete": self.delete_rule,
"/rules/preview": self.preview_rule,
"/test/send": self.send_test,
"/account/password": self.change_password,
"/deliveries/retry": self.retry_deliveries,
@ -261,6 +273,12 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(content)
def handle_tradingview_webhook(self) -> None:
if self.context.settings.webhook_token:
query = parse_qs(urlparse(self.path).query)
token = self.headers.get("X-Webhook-Token") or query.get("token", [""])[-1]
if token != self.context.settings.webhook_token:
json_response(self, 401, {"error": "Invalid webhook token"})
return
try:
payload = parse_json_body(self)
result = self.context.dispatcher.receive_alert(payload)
@ -290,6 +308,24 @@ class Handler(BaseHTTPRequestHandler):
return {"alerts": [dict(row) for row in alerts], "deliveries": [dict(row) for row in deliveries]}
def render_dashboard(self) -> None:
host = self.headers.get("Host", f"localhost:{self.context.settings.port}")
scheme = self.headers.get("X-Forwarded-Proto", "http")
base_url = f"{scheme}://{host}"
webhook_url = f"{base_url}/webhook/tradingview"
token = self.context.settings.webhook_token
webhook_url_with_token = f"{webhook_url}?token={token}" if token else webhook_url
token_block = (
f"""<div class="copy-row"><span>Webhook Token</span><code>{html.escape(token)}</code><button type="button" data-copy="{html.escape(token)}">复制</button></div>
<div class="copy-row"><span>Header 方式</span><code>X-Webhook-Token: {html.escape(token)}</code><button type="button" data-copy="X-Webhook-Token: {html.escape(token)}">复制</button></div>"""
if token
else """<p class="warning">当前未设置 WEBHOOK_TOKEN任何知道地址的人都可以提交 alert。生产环境建议设置。</p>"""
)
webhook_panel = f"""<section class="panel">
<h2>TradingView Webhook 配置</h2>
<div class="copy-row"><span>Webhook URL</span><code>{html.escape(webhook_url_with_token)}</code><button type="button" data-copy="{html.escape(webhook_url_with_token)}">复制</button></div>
<div class="copy-row"><span> URL</span><code>{html.escape(webhook_url)}</code><button type="button" data-copy="{html.escape(webhook_url)}">复制</button></div>
{token_block}
</section>"""
with self.context.db.connect() as conn:
counts = {
"alerts": conn.execute("SELECT COUNT(*) AS c FROM alerts").fetchone()["c"],
@ -308,7 +344,7 @@ class Handler(BaseHTTPRequestHandler):
f"<tr><td>{row['id']}</td><td>{html.escape(row['symbol'])}</td><td>{html.escape(row['timeframe'])}</td><td>{html.escape(row['strategy'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{row['created_at']}</td></tr>"
for row in recent
)
self.send_html("概览", f"<header><h1>概览</h1><p>结构化 alert 分发、飞书转发和重试状态。</p></header><section class='metrics'>{cards}</section><section><h2>最近 Alert</h2><table><thead><tr><th>ID</th><th>品种</th><th>周期</th><th>策略</th><th>状态</th><th>时间</th></tr></thead><tbody>{rows}</tbody></table></section>")
self.send_html("概览", f"<header><h1>概览</h1><p>结构化 alert 分发、飞书转发和重试状态。</p></header>{webhook_panel}<section class='metrics'>{cards}</section><section><h2>最近 Alert</h2><table><thead><tr><th>ID</th><th>品种</th><th>周期</th><th>策略</th><th>状态</th><th>时间</th></tr></thead><tbody>{rows}</tbody></table></section>")
def render_targets(self) -> None:
targets = self.list_targets()
@ -319,7 +355,8 @@ class Handler(BaseHTTPRequestHandler):
<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/delete"><input type="hidden" name="id" value="{target['id']}"><button class="danger" type="submit">删除</button></form>
<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>
</td></tr>"""
for target in targets
)
@ -330,55 +367,201 @@ class Handler(BaseHTTPRequestHandler):
<label class="check"><input name="enabled" type="checkbox" checked> 启用</label>
<button type="submit">保存目标</button>
</form>"""
self.send_html("飞书 Webhook", f"<header><h1>飞书 Webhook</h1><p>维护所有可分发的飞书机器人地址。</p></header>{form}<table><thead><tr><th>ID</th><th>名称</th><th>URL</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
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>")
def render_target_delete(self, parsed: Any) -> None:
target_id = parse_qs(parsed.query).get("id", [""])[-1]
with self.context.db.connect() as conn:
target = conn.execute("SELECT * FROM webhook_targets WHERE id = ?", (target_id,)).fetchone()
if not target:
self.send_error(404)
return
body = f"""<header><h1>删除飞书 Webhook</h1><p>请确认是否删除这个飞书目标。</p></header>
<section class="panel narrow">
<h2>{html.escape(target['name'])}</h2>
<p class="url">{html.escape(target['webhook_url'])}</p>
<form method="post" action="/targets/delete" class="actions">
<input type="hidden" name="id" value="{target['id']}">
<button class="danger" type="submit">确认删除</button>
<a class="button-link secondary" href="/targets">取消</a>
</form>
</section>"""
self.send_html("删除飞书 Webhook", body)
def render_rules(self) -> None:
targets = self.list_targets()
rules = self.list_rules()
target_names = {target["id"]: target["name"] for target in targets}
rows = ""
for rule in rules:
message_type_options = "".join(
f'<option value="{value}" {"selected" if rule["message_type"] == value else ""}>{label}</option>'
for value, label in [("card", "Card"), ("text", "Text")]
conditions = [
f"周期={html.escape(rule['timeframe'])}" if rule["timeframe"] else "",
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 "-"
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><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>"""
header = """<header class="page-header"><div><h1>路由规则</h1><p>周期、品种、策略至少填写一个;空字段表示不限。消息统一用飞书卡片发送。</p></div><a class="button-link" href="/rules/new">新增规则</a></header>"""
self.send_html("路由规则", f"{header}<table><thead><tr><th>ID</th><th>名称</th><th>匹配条件</th><th>优先级</th><th>发送到</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
def render_rule_form(
self,
title: str,
action: str,
rule: dict[str, Any] | None = None,
preview_html: str = "",
sample_payload: str | None = None,
) -> None:
targets = self.list_targets()
rule = rule or {
"id": "",
"name": "",
"timeframe": "",
"symbol": "",
"strategy": "",
"priority": 100,
"card_title_template": "TradingView {{symbol}} {{action}}",
"card_body_template": "**品种**: {{symbol}}\n**周期**: {{timeframe}}\n**策略**: {{strategy}}\n**动作**: {{action}}\n**价格**: {{price}}",
"target_ids": [],
"enabled": 1,
}
selected_targets = target_select_options(targets, rule.get("target_ids", []), placeholder=True)
hidden_id = f'<input type="hidden" name="id" value="{rule["id"]}">' if rule.get("id") else ""
button_text = "保存修改" if rule.get("id") else "创建规则"
sample_payload = sample_payload or json.dumps(
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
ensure_ascii=False,
indent=2,
)
selected_targets = target_select_options(targets, rule["target_ids"], placeholder=True)
rows += f"""<tr data-message-form>
<td>{rule['id']}<input form="rule-update-{rule['id']}" type="hidden" name="id" value="{rule['id']}"></td>
<td><input form="rule-update-{rule['id']}" name="name" value="{html.escape(rule['name'])}" required></td>
<td><input form="rule-update-{rule['id']}" name="timeframe" value="{html.escape(rule['timeframe'])}" required></td>
<td><input form="rule-update-{rule['id']}" name="symbol" value="{html.escape(rule['symbol'])}" required></td>
<td><input form="rule-update-{rule['id']}" name="strategy" value="{html.escape(rule['strategy'])}" required></td>
<td><input form="rule-update-{rule['id']}" name="priority" type="number" value="{rule['priority']}" required></td>
<td><select class="select-compact" form="rule-update-{rule['id']}" name="message_type" data-message-type>{message_type_options}</select></td>
<td><textarea form="rule-update-{rule['id']}" name="card_title_template" rows="2" data-title-template>{html.escape(rule['card_title_template'])}</textarea></td>
<td><textarea form="rule-update-{rule['id']}" name="card_body_template" rows="4" data-body-template>{html.escape(rule['card_body_template'])}</textarea></td>
<td><select class="select-target" form="rule-update-{rule['id']}" name="target_ids" required>{selected_targets}</select></td>
<td><label class="check"><input form="rule-update-{rule['id']}" name="enabled" type="checkbox" {'checked' if rule['enabled'] else ''}> 启用</label></td>
<td><form id="rule-update-{rule['id']}" class="inline" method="post" action="/rules/update"></form><button form="rule-update-{rule['id']}" type="submit">更新</button><form class="inline" method="post" action="/rules/delete"><input type="hidden" name="id" value="{rule['id']}"><button class="danger" type="submit">删除</button></form></td></tr>"""
create_target_options = target_select_options(targets, placeholder=True)
form = f"""<form class="panel" method="post" action="/rules/create" data-message-form>
<h2>新增路由规则</h2>
body = f"""<header><h1>{html.escape(title)}</h1><p>消息统一使用飞书卡片。周期、品种、策略至少填写一个,空字段表示不限。</p></header>
<form class="panel rule-form" method="post" action="{action}">
{hidden_id}
<div class="grid">
<label>规则名<input name="name" required></label>
<label>周期<input name="timeframe" placeholder="5m" required></label>
<label>品种<input name="symbol" placeholder="BTCUSDT" required></label>
<label>策略<input name="strategy" placeholder="breakout" required></label>
<label>优先级<input name="priority" type="number" value="100" required></label>
<label>规则名<input name="name" value="{html.escape(str(rule['name']))}" required></label>
<label>周期<input name="timeframe" value="{html.escape(str(rule['timeframe']))}" placeholder="5m空=不限"></label>
<label>品种<input name="symbol" value="{html.escape(str(rule['symbol']))}" placeholder="BTCUSDT空=不限"></label>
<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>
<div>
<label class="field-compact">消息类型<select class="select-compact" name="message_type" data-message-type><option value="card" selected>Card</option><option value="text">Text</option></select></label>
<label><span data-title-label>卡片标题模板</span><input name="card_title_template" value="TradingView {{{{symbol}}}} {{{{action}}}}" data-title-template required></label>
<label><span data-body-label>卡片正文模板</span><textarea name="card_body_template" rows="5" data-body-template>**品种**: {{{{symbol}}}}
**周期**: {{{{timeframe}}}}
**策略**: {{{{strategy}}}}
**动作**: {{{{action}}}}
**价格**: {{{{price}}}}</textarea></label>
<label class="field-target">发送到<select class="select-target" name="target_ids" required>{create_target_options}</select></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 class="field-target">发送到<select class="select-target" name="target_ids" required>{selected_targets}</select></label>
<label class="check"><input name="enabled" type="checkbox" {'checked' if rule.get('enabled') else ''}> 启用</label>
<h2>规则命中与卡片预览</h2>
<input type="hidden" name="source_action" value="{html.escape(action)}">
<label>样例 Alert JSON<textarea name="sample_payload" rows="9">{html.escape(sample_payload)}</textarea></label>
<div class="actions"><button type="submit">{button_text}</button><button type="submit" formaction="/rules/preview">预览命中和卡片</button><a class="button-link secondary" href="/rules">返回列表</a></div>
</form>{preview_html}"""
self.send_html(title, body)
def render_rule_new(self) -> None:
self.render_rule_form("新增路由规则", "/rules/create")
def render_rule_edit(self, parsed: Any) -> None:
query = parse_qs(parsed.query)
rule_id = query.get("id", [""])[-1]
with self.context.db.connect() as conn:
rule = conn.execute("SELECT * FROM routing_rules WHERE id = ?", (rule_id,)).fetchone()
if not rule:
self.send_error(404)
return
rule_dict = dict(rule)
rule_dict["target_ids"] = from_json(rule_dict["target_ids"], [])
self.render_rule_form("编辑路由规则", "/rules/update", rule_dict)
def render_rule_delete(self, parsed: Any) -> None:
rule_id = parse_qs(parsed.query).get("id", [""])[-1]
with self.context.db.connect() as conn:
rule = conn.execute("SELECT * FROM routing_rules WHERE id = ?", (rule_id,)).fetchone()
if not rule:
self.send_error(404)
return
body = f"""<header><h1>删除路由规则</h1><p>请确认是否删除这条规则。</p></header>
<section class="panel narrow">
<h2>{html.escape(rule['name'])}</h2>
<p>删除后不会再匹配对应 alert已有日志不受影响</p>
<form method="post" action="/rules/delete" class="actions">
<input type="hidden" name="id" value="{rule['id']}">
<button class="danger" type="submit">确认删除</button>
<a class="button-link secondary" href="/rules">取消</a>
</form>
</section>"""
self.send_html("删除路由规则", body)
def build_rule_from_form(self, form: dict[str, list[str]]) -> dict[str, Any]:
return {
"id": form.get("id", [""])[-1],
"name": form.get("name", [""])[-1].strip(),
"timeframe": form.get("timeframe", [""])[-1].strip(),
"symbol": form.get("symbol", [""])[-1].strip().upper(),
"strategy": form.get("strategy", [""])[-1].strip(),
"priority": int(form.get("priority", ["100"])[-1] or 100),
"card_title_template": form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
"card_body_template": form.get("card_body_template", [""])[-1].strip(),
"target_ids": [int(value) for value in form.get("target_ids", []) if value],
"enabled": 1 if form.get("enabled", [""])[-1] == "on" else 0,
}
def preview_rule(self) -> None:
form = parse_form_multi(self)
rule = self.build_rule_from_form(form)
sample_payload = form.get("sample_payload", ["{}"])[-1]
source_action = form.get("source_action", ["/rules/create"])[-1]
title = "编辑路由规则" if rule.get("id") else "新增路由规则"
try:
alert = normalize_alert(json.loads(sample_payload))
message = build_feishu_message(alert, rule)
matched = self.context.dispatcher.find_matching_rule(alert)
current_matches = self.rule_matches_alert(rule, alert)
preview_html = self.render_preview_result(rule, message, matched, current_matches)
except (json.JSONDecodeError, ValidationError, ValueError) as exc:
preview_html = f"""<section class="result-panel error"><h2>预览失败</h2><p>{html.escape(str(exc))}</p></section>"""
self.render_rule_form(title, source_action, rule, preview_html, sample_payload)
def rule_matches_alert(self, rule: dict[str, Any], alert: dict[str, Any]) -> bool:
if not any((rule.get("timeframe"), rule.get("symbol"), rule.get("strategy"))):
return False
if rule.get("timeframe") and rule["timeframe"] != alert.get("timeframe"):
return False
if rule.get("symbol") and rule["symbol"].upper() != alert.get("symbol"):
return False
if rule.get("strategy") and rule["strategy"] != alert.get("strategy"):
return False
return True
def render_preview_result(
self,
rule: dict[str, Any],
message: dict[str, Any],
matched: dict[str, Any] | None,
current_matches: bool,
) -> str:
title = message["card"]["header"]["title"]["content"]
content = message["card"]["elements"][0]["text"]["content"]
matched_text = f"当前已保存规则 #{matched['id']} {matched['name']}" if matched else "没有已保存规则会命中"
current_text = "当前表单会匹配样例 Alert" if current_matches else "当前表单不会匹配样例 Alert"
return f"""<section class="result-panel success">
<h2>预览结果</h2>
<div class="result-grid">
<div><span>当前表单</span><strong>{html.escape(current_text)}</strong></div>
<div><span>系统实际命中</span><strong>{html.escape(matched_text)}</strong></div>
<div><span>规则优先级</span><strong>{rule.get('priority')}</strong></div>
<div><span>消息类型</span><strong>飞书卡片</strong></div>
</div>
<label class="check"><input name="enabled" type="checkbox" checked> 启用</label>
<button type="submit">保存规则</button>
</form>"""
self.send_html("路由规则", f"<header><h1>路由规则</h1><p>每条规则选择一个飞书 Webhook。模板支持 TradingView JSON 字段,例如 {{{{symbol}}}}{{{{timeframe}}}}{{{{strategy}}}}{{{{price}}}},嵌套字段可写 {{{{order.id}}}}。</p></header>{form}<table><thead><tr><th>ID</th><th>名称</th><th>周期</th><th>品种</th><th>策略</th><th>优先级</th><th>消息</th><th>标题模板</th><th>内容模板</th><th>发送到</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
<div class="feishu-preview">
<div class="feishu-preview-header">{html.escape(title)}</div>
<pre>{html.escape(content)}</pre>
</div>
</section>"""
def render_logs(self) -> None:
logs = self.list_logs()
@ -444,9 +627,49 @@ class Handler(BaseHTTPRequestHandler):
conn.execute("DELETE FROM webhook_targets WHERE id = ?", (form["id"],))
redirect(self, "/targets")
def test_target(self) -> None:
form = parse_form(self)
with self.context.db.connect() as conn:
target = conn.execute("SELECT * FROM webhook_targets WHERE id = ?", (form.get("id"),)).fetchone()
if not target:
self.send_error(404)
return
message = build_feishu_message(
{"symbol": "TEST", "timeframe": "5m", "strategy": "webhook-test", "action": "test"},
{
"card_title_template": "TV Dispatch 测试消息",
"card_body_template": "**目标**: {{symbol}}\n**动作**: {{action}}\n这是一条飞书 Webhook 连通性测试。",
},
)
try:
data = json.dumps(message, ensure_ascii=False).encode()
request = urllib.request.Request(
target["webhook_url"],
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=self.context.settings.feishu_timeout_seconds) as response:
status = response.getcode()
self.render_targets_with_notice(f"测试消息已发送到 {html.escape(target['name'])}HTTP {status}", success=True)
except urllib.error.HTTPError as exc:
self.render_targets_with_notice(f"测试发送失败HTTP {exc.code}", success=False)
except Exception as exc:
self.render_targets_with_notice(f"测试发送失败:{html.escape(str(exc))}", success=False)
def render_targets_with_notice(self, message: str, success: bool) -> None:
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_rule(self) -> None:
form = parse_form_multi(self)
target_ids = [int(value) for value in form.get("target_ids", [])]
timeframe = form.get("timeframe", [""])[-1].strip()
symbol = form.get("symbol", [""])[-1].strip().upper()
strategy = form.get("strategy", [""])[-1].strip()
if not any((timeframe, symbol, strategy)):
self.send_error(400, "周期、品种、策略至少填写一个")
return
now = now_iso()
with self.context.db.connect() as conn:
conn.execute(
@ -460,11 +683,11 @@ class Handler(BaseHTTPRequestHandler):
""",
(
form.get("name", [""])[-1].strip(),
form.get("timeframe", [""])[-1].strip(),
form.get("symbol", [""])[-1].strip().upper(),
form.get("strategy", [""])[-1].strip(),
timeframe,
symbol,
strategy,
int(form.get("priority", ["100"])[-1]),
form.get("message_type", ["card"])[-1],
"card",
form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
form.get("card_body_template", [""])[-1].strip(),
1 if form.get("enabled", [""])[-1] == "on" else 0,
@ -484,6 +707,12 @@ class Handler(BaseHTTPRequestHandler):
def update_rule(self) -> None:
form = parse_form_multi(self)
target_ids = [int(value) for value in form.get("target_ids", [])]
timeframe = form.get("timeframe", [""])[-1].strip()
symbol = form.get("symbol", [""])[-1].strip().upper()
strategy = form.get("strategy", [""])[-1].strip()
if not any((timeframe, symbol, strategy)):
self.send_error(400, "周期、品种、策略至少填写一个")
return
with self.context.db.connect() as conn:
conn.execute(
"""
@ -495,11 +724,11 @@ class Handler(BaseHTTPRequestHandler):
""",
(
form.get("name", [""])[-1].strip(),
form.get("timeframe", [""])[-1].strip(),
form.get("symbol", [""])[-1].strip().upper(),
form.get("strategy", [""])[-1].strip(),
timeframe,
symbol,
strategy,
int(form.get("priority", ["100"])[-1]),
form.get("message_type", ["card"])[-1],
"card",
form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
form.get("card_body_template", [""])[-1].strip(),
1 if form.get("enabled", [""])[-1] == "on" else 0,

View File

@ -1,29 +1,15 @@
function updateMessageForm(scope) {
const typeSelect = scope.querySelector("[data-message-type]");
if (!typeSelect) return;
const isText = typeSelect.value === "text";
const titleLabel = scope.querySelector("[data-title-label]");
const bodyLabel = scope.querySelector("[data-body-label]");
const titleTemplate = scope.querySelector("[data-title-template]");
const bodyTemplate = scope.querySelector("[data-body-template]");
scope.classList.toggle("text-message", isText);
if (titleLabel) titleLabel.textContent = isText ? "文本标题模板" : "卡片标题模板";
if (bodyLabel) bodyLabel.textContent = isText ? "文本内容模板" : "卡片正文模板";
if (titleTemplate) {
titleTemplate.placeholder = isText ? "例如TradingView {{symbol}}" : "例如TradingView {{symbol}} {{action}}";
}
if (bodyTemplate) {
bodyTemplate.placeholder = isText ? "{{symbol}} {{timeframe}} {{strategy}} {{action}}" : "**品种**: {{symbol}}";
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-message-form]").forEach((scope) => {
updateMessageForm(scope);
const typeSelect = scope.querySelector("[data-message-type]");
if (typeSelect) {
typeSelect.addEventListener("change", () => updateMessageForm(scope));
document.addEventListener("click", async (event) => {
const button = event.target.closest("[data-copy]");
if (!button) return;
const value = button.getAttribute("data-copy") || "";
try {
await navigator.clipboard.writeText(value);
const original = button.textContent;
button.textContent = "已复制";
setTimeout(() => {
button.textContent = original;
}, 1200);
} catch {
window.prompt("复制下面的内容", value);
}
});
});

View File

@ -77,6 +77,13 @@ header {
margin-bottom: 24px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
h1,
h2 {
margin: 0 0 8px;
@ -181,33 +188,15 @@ td textarea {
max-width: 560px;
}
.field-compact,
.field-target {
width: fit-content;
max-width: 100%;
}
.select-compact {
width: 140px;
}
.select-target {
width: clamp(220px, 34vw, 360px);
}
td .select-compact {
min-width: 110px;
}
td .select-target {
min-width: 180px;
max-width: 260px;
}
.text-message [data-title-template] {
background: #f7f3e8;
}
.check {
display: inline-flex;
align-items: center;
@ -247,6 +236,57 @@ button:hover {
background: var(--danger);
}
.button-link {
display: inline-block;
border-radius: 6px;
padding: 10px 14px;
background: var(--accent);
color: white;
font: 800 14px ui-sans-serif, system-ui, sans-serif;
text-decoration: none;
margin-right: 8px;
}
.button-link.secondary {
background: #ece7da;
color: var(--ink);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.copy-row {
display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
margin-top: 12px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.copy-row span {
color: var(--muted);
font-weight: 700;
}
.copy-row code {
display: block;
overflow-wrap: anywhere;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px 12px;
background: #fff;
}
.warning {
margin-top: 12px;
color: var(--danger);
}
.result-panel {
border-radius: 8px;
border: 1px solid var(--line);
@ -264,6 +304,28 @@ button:hover {
border-left: 5px solid var(--danger);
}
.feishu-preview {
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #fff;
margin-top: 16px;
}
.feishu-preview-header {
background: #1d4ed8;
color: #fff;
padding: 12px 14px;
font: 800 15px ui-sans-serif, system-ui, sans-serif;
}
.feishu-preview pre {
margin: 0;
background: #fff;
color: var(--ink);
border-radius: 0;
}
.result-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@ -372,6 +434,10 @@ th {
width: auto;
}
.page-header {
display: grid;
}
.shell {
margin-left: 0;
padding: 22px;
@ -382,4 +448,8 @@ th {
.result-grid {
grid-template-columns: 1fr;
}
.copy-row {
grid-template-columns: 1fr;
}
}

View File

@ -2,11 +2,12 @@ services:
dispatcher:
build: .
ports:
- "8000:8000"
- "8030:8000"
environment:
ADMIN_USERNAME: admin
ADMIN_PASSWORD: change-me-now
SESSION_SECRET: replace-with-a-long-random-secret
WEBHOOK_TOKEN: replace-with-a-shared-webhook-secret
RETENTION_DAYS: 30
MAX_DELIVERY_ATTEMPTS: 3
RETRY_BACKOFF_SECONDS: 60
@ -20,6 +21,7 @@ services:
ADMIN_USERNAME: admin
ADMIN_PASSWORD: change-me-now
SESSION_SECRET: replace-with-a-long-random-secret
WEBHOOK_TOKEN: replace-with-a-shared-webhook-secret
RETENTION_DAYS: 30
MAX_DELIVERY_ATTEMPTS: 3
RETRY_BACKOFF_SECONDS: 60

View File

@ -34,7 +34,15 @@ class DispatcherTest(unittest.TestCase):
)
return int(cur.lastrowid)
def add_rule(self, target_id: int, priority: int = 100, name: str = "rule") -> int:
def add_rule(
self,
target_id: 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:
cur = conn.execute(
@ -45,16 +53,12 @@ class DispatcherTest(unittest.TestCase):
card_title_template, card_body_template, enabled, target_ids,
created_at, updated_at
)
VALUES (?, '5m', 'BTCUSDT', 'breakout', ?, 'card', 'Signal {{symbol}}', 'Price {{price}}', 1, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, 'card', 'Signal {{symbol}}', 'Price {{price}}', 1, ?, ?, ?)
""",
(name, priority, to_json([target_id]), now, now),
(name, timeframe, symbol, strategy, priority, to_json([target_id]), now, now),
)
return int(cur.lastrowid)
def test_missing_required_fields_are_rejected(self) -> None:
with self.assertRaises(ValidationError):
self.dispatcher.receive_alert({"symbol": "BTCUSDT"})
def test_unmatched_alert_is_stored(self) -> None:
result = self.dispatcher.receive_alert({"timeframe": "15m", "symbol": "ETHUSDT", "strategy": "trend"})
self.assertEqual(result["status"], "unmatched")
@ -78,6 +82,35 @@ class DispatcherTest(unittest.TestCase):
delivery = conn.execute("SELECT * FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()
self.assertEqual(delivery["target_id"], fast_target)
def test_single_dimension_rule_matches(self) -> None:
target_id = self.add_target()
rule_id = self.add_rule(target_id, timeframe="", symbol="BTCUSDT", strategy="")
result = self.dispatcher.receive_alert({"symbol": "btcusdt", "action": "buy"})
self.assertEqual(result["status"], "matched")
self.assertEqual(result["matched_rule_id"], rule_id)
def test_more_specific_rule_wins_when_priority_ties(self) -> None:
broad_target = self.add_target("broad")
specific_target = self.add_target("specific")
broad_rule = self.add_rule(broad_target, priority=10, name="broad", timeframe="", symbol="BTCUSDT", strategy="")
specific_rule = self.add_rule(specific_target, priority=10, name="specific")
result = self.dispatcher.receive_alert({"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout"})
self.assertEqual(result["matched_rule_id"], specific_rule)
self.assertNotEqual(result["matched_rule_id"], broad_rule)
def test_find_matching_rule_preview(self) -> None:
target_id = self.add_target()
rule_id = self.add_rule(target_id, timeframe="", symbol="BTCUSDT", strategy="")
rule = self.dispatcher.find_matching_rule({"symbol": "btcusdt"})
self.assertIsNotNone(rule)
self.assertEqual(rule["id"], rule_id)
def test_failed_delivery_is_marked_for_retry(self) -> None:
target_id = self.add_target()
self.add_rule(target_id)
@ -106,7 +139,7 @@ class DispatcherTest(unittest.TestCase):
self.assertEqual(message["card"]["header"]["title"]["content"], "BTCUSDT buy")
self.assertEqual(message["card"]["elements"][0]["text"]["content"], "**价格** 68000")
def test_template_accepts_legacy_single_braces(self) -> None:
def test_template_accepts_legacy_single_braces_and_still_sends_card(self) -> None:
message = build_feishu_message(
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
{
@ -116,7 +149,9 @@ class DispatcherTest(unittest.TestCase):
},
)
self.assertEqual(message["content"]["text"], "TradingView BTCUSDT buy\n价格 68000")
self.assertEqual(message["msg_type"], "interactive")
self.assertEqual(message["card"]["header"]["title"]["content"], "TradingView BTCUSDT buy")
self.assertEqual(message["card"]["elements"][0]["text"]["content"], "价格 68000")
if __name__ == "__main__":