diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5fc0d9e --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6957eee..84924bf 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/config.py b/app/config.py index 6f9681a..ac3b2af 100644 --- a/app/config.py +++ b/app/config.py @@ -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")), diff --git a/app/dispatcher.py b/app/dispatcher.py index 8c9a048..ecd4814 100644 --- a/app/dispatcher.py +++ b/app/dispatcher.py @@ -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*}}|(? 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 diff --git a/app/server.py b/app/server.py index 81f9936..d98a2ec 100644 --- a/app/server.py +++ b/app/server.py @@ -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"""
Webhook Token{html.escape(token)}
+
Header 方式X-Webhook-Token: {html.escape(token)}
""" + if token + else """

当前未设置 WEBHOOK_TOKEN,任何知道地址的人都可以提交 alert。生产环境建议设置。

""" + ) + webhook_panel = f"""
+

TradingView Webhook 配置

+
Webhook URL{html.escape(webhook_url_with_token)}
+
纯 URL{html.escape(webhook_url)}
+{token_block} +
""" 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"{row['id']}{html.escape(row['symbol'])}{html.escape(row['timeframe'])}{html.escape(row['strategy'])}{html.escape(row['status'])}{row['created_at']}" for row in recent ) - self.send_html("概览", f"

概览

结构化 alert 分发、飞书转发和重试状态。

{cards}

最近 Alert

{rows}
ID品种周期策略状态时间
") + self.send_html("概览", f"

概览

结构化 alert 分发、飞书转发和重试状态。

{webhook_panel}
{cards}

最近 Alert

{rows}
ID品种周期策略状态时间
") def render_targets(self) -> None: targets = self.list_targets() @@ -319,7 +355,8 @@ class Handler(BaseHTTPRequestHandler):
-
+
+ 删除 """ for target in targets ) @@ -330,55 +367,201 @@ class Handler(BaseHTTPRequestHandler): """ - self.send_html("飞书 Webhook", f"

飞书 Webhook

维护所有可分发的飞书机器人地址。

{form}{rows}
ID名称URL状态操作
") + notice = getattr(self, "_target_notice", "") + self.send_html("飞书 Webhook", f"

飞书 Webhook

维护所有可分发的飞书机器人地址。

{notice}{form}{rows}
ID名称URL状态操作
") + + 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"""

删除飞书 Webhook

请确认是否删除这个飞书目标。

+
+

{html.escape(target['name'])}

+

{html.escape(target['webhook_url'])}

+
+ + +取消 +
+
""" + 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'' - for value, label in [("card", "Card"), ("text", "Text")] - ) - selected_targets = target_select_options(targets, rule["target_ids"], placeholder=True) - rows += f""" -{rule['id']} - - - - - - - - - - -
""" - create_target_options = target_select_options(targets, placeholder=True) - form = f"""
-

新增路由规则

+ 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""" +{rule['id']} +{html.escape(rule['name'])} +{'
'.join(item for item in conditions if item) or '-'} +{rule['priority']} +{html.escape(target_name)} +{'启用' if rule['enabled'] else '停用'} +编辑删除 +""" + header = """""" + self.send_html("路由规则", f"{header}{rows}
ID名称匹配条件优先级发送到状态操作
") + + 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'' 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, + ) + body = f"""

{html.escape(title)}

消息统一使用飞书卡片。周期、品种、策略至少填写一个,空字段表示不限。

+ +{hidden_id}
- - - - - + + + + +
-
- - - - + + + + +

规则命中与卡片预览

+ + +
返回列表
+{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"""

删除路由规则

请确认是否删除这条规则。

+
+

{html.escape(rule['name'])}

+

删除后不会再匹配对应 alert,已有日志不受影响。

+
+ + +取消 +
+
""" + 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"""

预览失败

{html.escape(str(exc))}

""" + 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"""
+

预览结果

+
+
当前表单{html.escape(current_text)}
+
系统实际命中{html.escape(matched_text)}
+
规则优先级{rule.get('priority')}
+
消息类型飞书卡片
- - -""" - self.send_html("路由规则", f"

路由规则

每条规则选择一个飞书 Webhook。模板支持 TradingView JSON 字段,例如 {{{{symbol}}}}、{{{{timeframe}}}}、{{{{strategy}}}}、{{{{price}}}},嵌套字段可写 {{{{order.id}}}}。

{form}{rows}
ID名称周期品种策略优先级消息标题模板内容模板发送到状态操作
") +
+
{html.escape(title)}
+
{html.escape(content)}
+
+
""" 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"""

Webhook 测试

{message}

""" + 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, diff --git a/app/static/app.js b/app/static/app.js index ca5b27b..9132e6b 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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}}"; +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); } - 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)); - } - }); }); diff --git a/app/static/styles.css b/app/static/styles.css index 499ee59..958418e 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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; + } } diff --git a/docker-compose.yml b/docker-compose.yml index 08ea6d9..5392bdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index d0c422b..733e9cf 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -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__":