first commit
This commit is contained in:
commit
d01c0d49cd
28
.dockerignore
Normal file
28
.dockerignore
Normal file
@ -0,0 +1,28 @@
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
data/
|
||||
*.log
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Local runtime data
|
||||
data/*.db
|
||||
data/*.sqlite
|
||||
data/*.sqlite3
|
||||
*.log
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Docker / build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY app ./app
|
||||
COPY requirements.txt .
|
||||
|
||||
ENV APP_HOST=0.0.0.0
|
||||
ENV APP_PORT=8000
|
||||
ENV DATABASE_PATH=/data/dispatcher.db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "app"]
|
||||
78
README.md
Normal file
78
README.md
Normal file
@ -0,0 +1,78 @@
|
||||
# TradingView Alert Dispatcher
|
||||
|
||||
接收 TradingView webhook alert,按 `timeframe + symbol + strategy` 路由到飞书 webhook,并提供管理控制台。
|
||||
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
python3 -m app
|
||||
```
|
||||
|
||||
默认地址:`http://localhost:8000`
|
||||
|
||||
默认登录:
|
||||
|
||||
- 用户名:`admin`
|
||||
- 密码:`change-me-now`
|
||||
|
||||
首次启动会把 `ADMIN_PASSWORD` 写入数据库并保存为哈希。之后请在管理台的「账号安全」页面修改密码;修改后环境变量不会覆盖数据库中的新密码。
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Compose 会启动两个服务:`dispatcher` 负责 Web/API/管理台,`worker` 负责周期性处理失败重试。
|
||||
|
||||
## TradingView Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"timeframe": "5m",
|
||||
"symbol": "BTCUSDT",
|
||||
"strategy": "breakout",
|
||||
"action": "buy",
|
||||
"price": 68000
|
||||
}
|
||||
```
|
||||
|
||||
发送到:
|
||||
|
||||
```text
|
||||
POST /webhook/tradingview
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
## Feishu Message Templates
|
||||
|
||||
路由规则支持两种消息类型:
|
||||
|
||||
- `Card`:默认,发送飞书 interactive card。
|
||||
- `Text`:发送普通文本消息。
|
||||
|
||||
标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`。
|
||||
|
||||
每条路由规则通过「发送到」下拉框选择一个飞书 Webhook。需要同一个信号发到多个群时,可以建多条匹配条件相同、目标不同的规则,并用优先级控制命中顺序;当前默认路由逻辑只发送最高优先级命中的规则。
|
||||
|
||||
示例正文模板:
|
||||
|
||||
```text
|
||||
**品种**: {{symbol}}
|
||||
**周期**: {{timeframe}}
|
||||
**策略**: {{strategy}}
|
||||
**动作**: {{action}}
|
||||
**价格**: {{price}}
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
- `ADMIN_USERNAME`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `SESSION_SECRET`
|
||||
- `DATABASE_PATH`
|
||||
- `RETENTION_DAYS`
|
||||
- `MAX_DELIVERY_ATTEMPTS`
|
||||
- `RETRY_BACKOFF_SECONDS`
|
||||
- `FEISHU_TIMEOUT_SECONDS`
|
||||
- `WORKER_INTERVAL_SECONDS`
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""TradingView alert dispatcher."""
|
||||
4
app/__main__.py
Normal file
4
app/__main__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from app.server import run
|
||||
|
||||
|
||||
run()
|
||||
69
app/auth.py
Normal file
69
app/auth.py
Normal file
@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
from http import cookies
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
COOKIE_NAME = "tv_dispatcher_session"
|
||||
SESSION_TTL_SECONDS = 60 * 60 * 12
|
||||
PASSWORD_ITERATIONS = 200_000
|
||||
|
||||
|
||||
def _sign(secret: str, value: str) -> str:
|
||||
return hmac.new(secret.encode(), value.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def make_session_cookie(settings: Settings) -> str:
|
||||
payload = f"{settings.admin_username}:{int(time.time())}"
|
||||
encoded = base64.urlsafe_b64encode(payload.encode()).decode()
|
||||
return f"{encoded}.{_sign(settings.session_secret, encoded)}"
|
||||
|
||||
|
||||
def is_valid_session(settings: Settings, cookie_header: str | None) -> bool:
|
||||
if not cookie_header:
|
||||
return False
|
||||
jar = cookies.SimpleCookie(cookie_header)
|
||||
morsel = jar.get(COOKIE_NAME)
|
||||
if not morsel:
|
||||
return False
|
||||
try:
|
||||
encoded, signature = morsel.value.split(".", 1)
|
||||
if not hmac.compare_digest(signature, _sign(settings.session_secret, encoded)):
|
||||
return False
|
||||
raw = base64.urlsafe_b64decode(encoded.encode()).decode()
|
||||
username, issued = raw.rsplit(":", 1)
|
||||
return username == settings.admin_username and time.time() - int(issued) <= SESSION_TTL_SECONDS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PASSWORD_ITERATIONS)
|
||||
return f"pbkdf2_sha256${PASSWORD_ITERATIONS}${base64.b64encode(salt).decode()}${base64.b64encode(digest).decode()}"
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt, digest = password_hash.split("$", 3)
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
expected = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode(),
|
||||
base64.b64decode(salt.encode()),
|
||||
int(iterations),
|
||||
)
|
||||
return hmac.compare_digest(base64.b64encode(expected).decode(), digest)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_credentials(settings: Settings, username: str, password: str, password_hash: str) -> bool:
|
||||
return hmac.compare_digest(username, settings.admin_username) and verify_password(password, password_hash)
|
||||
34
app/config.py
Normal file
34
app/config.py
Normal file
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
app_name: str = "TradingView Alert Dispatcher"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
database_path: str = "data/dispatcher.db"
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change-me-now"
|
||||
session_secret: str = "change-this-session-secret"
|
||||
retention_days: int = 30
|
||||
max_delivery_attempts: int = 3
|
||||
retry_backoff_seconds: int = 60
|
||||
feishu_timeout_seconds: int = 10
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings(
|
||||
host=os.getenv("APP_HOST", "0.0.0.0"),
|
||||
port=int(os.getenv("APP_PORT", "8000")),
|
||||
database_path=os.getenv("DATABASE_PATH", "data/dispatcher.db"),
|
||||
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"),
|
||||
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")),
|
||||
feishu_timeout_seconds=int(os.getenv("FEISHU_TIMEOUT_SECONDS", "10")),
|
||||
)
|
||||
151
app/db.py
Normal file
151
app/db.py
Normal file
@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterator
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def to_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def from_json(value: str | None, default: Any = None) -> Any:
|
||||
if not value:
|
||||
return default
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, settings: Settings):
|
||||
self.path = settings.database_path
|
||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[sqlite3.Connection]:
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def migrate(self, settings: Settings) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
webhook_url TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routing_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
strategy TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
target_ids 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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timeframe TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
strategy TEXT NOT NULL,
|
||||
action TEXT,
|
||||
price REAL,
|
||||
payload TEXT NOT NULL,
|
||||
matched_rule_id INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(matched_rule_id) REFERENCES routing_rules(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alert_id INTEGER NOT NULL,
|
||||
rule_id INTEGER,
|
||||
target_id INTEGER,
|
||||
target_name TEXT NOT NULL,
|
||||
webhook_url TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT,
|
||||
last_attempt_at TEXT,
|
||||
response_code INTEGER,
|
||||
response_body TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(alert_id) REFERENCES alerts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(rule_id) REFERENCES routing_rules(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(target_id) REFERENCES webhook_targets(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_retry
|
||||
ON deliveries(status, next_attempt_at);
|
||||
"""
|
||||
)
|
||||
existing_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(routing_rules)").fetchall()
|
||||
}
|
||||
if "message_type" not in existing_columns:
|
||||
conn.execute("ALTER TABLE routing_rules ADD COLUMN message_type TEXT NOT NULL DEFAULT 'card'")
|
||||
if "card_title_template" not in existing_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE routing_rules ADD COLUMN card_title_template TEXT NOT NULL DEFAULT 'TradingView {{symbol}} {{action}}'"
|
||||
)
|
||||
if "card_body_template" not in existing_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE routing_rules ADD COLUMN card_body_template TEXT NOT NULL DEFAULT '{{symbol}} {{timeframe}} {{strategy}} {{action}} @ {{price}}'"
|
||||
)
|
||||
admin = conn.execute("SELECT id FROM admin_settings WHERE id = 1").fetchone()
|
||||
if not admin:
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"INSERT INTO admin_settings (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)",
|
||||
(hash_password(settings.admin_password), now, now),
|
||||
)
|
||||
|
||||
def cleanup_old_logs(self, retention_days: int) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).replace(microsecond=0).isoformat()
|
||||
with self.connect() as conn:
|
||||
cur = conn.execute("DELETE FROM alerts WHERE created_at < ?", (cutoff,))
|
||||
return cur.rowcount
|
||||
296
app/dispatcher.py
Normal file
296
app/dispatcher.py
Normal file
@ -0,0 +1,296 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
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*}(?!})")
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
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()
|
||||
if "price" in normalized and normalized["price"] not in (None, ""):
|
||||
try:
|
||||
normalized["price"] = float(normalized["price"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("price must be numeric") from exc
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_template_value(alert: dict[str, Any], field: str) -> str:
|
||||
value: Any = alert
|
||||
for part in field.split("."):
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
return ""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value)
|
||||
|
||||
|
||||
def render_template(template: str, alert: dict[str, Any]) -> str:
|
||||
return TEMPLATE_PATTERN.sub(lambda match: resolve_template_value(alert, match.group(1) or match.group(2)), template)
|
||||
|
||||
|
||||
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"动作: {action}",
|
||||
]
|
||||
if alert.get("price") is not None:
|
||||
lines.append(f"价格: {alert['price']}")
|
||||
if alert.get("time"):
|
||||
lines.append(f"时间: {alert['time']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_feishu_message(alert: dict[str, Any], rule: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
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']}"
|
||||
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": {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": body}},
|
||||
{
|
||||
"tag": "hr",
|
||||
},
|
||||
{
|
||||
"tag": "note",
|
||||
"elements": [
|
||||
{
|
||||
"tag": "plain_text",
|
||||
"content": f"{alert['symbol']} · {alert['timeframe']} · {alert['strategy']}",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(self, db: Database, settings: Settings):
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
|
||||
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()
|
||||
|
||||
status = "matched" if rule else "unmatched"
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO alerts (
|
||||
timeframe, symbol, strategy, action, price, payload,
|
||||
matched_rule_id, status, error, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
alert["timeframe"],
|
||||
alert["symbol"],
|
||||
alert["strategy"],
|
||||
alert.get("action") or alert.get("signal"),
|
||||
alert.get("price"),
|
||||
to_json(alert),
|
||||
rule["id"] if rule else None,
|
||||
status,
|
||||
None if rule else "No enabled routing rule matched this alert.",
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
alert_id = int(cur.lastrowid)
|
||||
|
||||
delivery_ids: list[int] = []
|
||||
if rule:
|
||||
target_ids = from_json(rule["target_ids"], [])
|
||||
if target_ids:
|
||||
placeholders = ",".join("?" for _ in target_ids)
|
||||
targets = conn.execute(
|
||||
f"SELECT * FROM webhook_targets WHERE enabled = 1 AND id IN ({placeholders})",
|
||||
target_ids,
|
||||
).fetchall()
|
||||
for target in targets:
|
||||
delivery = conn.execute(
|
||||
"""
|
||||
INSERT INTO deliveries (
|
||||
alert_id, rule_id, target_id, target_name, webhook_url,
|
||||
status, attempts, next_attempt_at, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
alert_id,
|
||||
rule["id"],
|
||||
target["id"],
|
||||
target["name"],
|
||||
target["webhook_url"],
|
||||
created_at,
|
||||
created_at,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
delivery_ids.append(int(delivery.lastrowid))
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
self.process_due_deliveries()
|
||||
return {
|
||||
"alert_id": alert_id,
|
||||
"status": status,
|
||||
"matched_rule_id": rule["id"] if rule else None,
|
||||
"delivery_ids": delivery_ids,
|
||||
}
|
||||
|
||||
def process_due_deliveries(self, limit: int = 25) -> int:
|
||||
now = now_iso()
|
||||
with self.db.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT d.*, a.payload
|
||||
, r.message_type, 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
|
||||
WHERE d.status IN ('pending', 'retry')
|
||||
AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?)
|
||||
ORDER BY d.created_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(now, limit),
|
||||
).fetchall()
|
||||
|
||||
processed = 0
|
||||
for row in rows:
|
||||
delivery = dict(row)
|
||||
payload = from_json(delivery["payload"], {})
|
||||
self._send_delivery(delivery, payload)
|
||||
processed += 1
|
||||
return processed
|
||||
|
||||
def _send_delivery(self, delivery: dict[str, Any], alert: dict[str, Any]) -> None:
|
||||
attempts = int(delivery["attempts"]) + 1
|
||||
message = build_feishu_message(alert, delivery)
|
||||
encoded = json.dumps(message, ensure_ascii=False).encode()
|
||||
request = urllib.request.Request(
|
||||
delivery["webhook_url"],
|
||||
data=encoded,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
response_code: int | None = None
|
||||
response_body: str | None = None
|
||||
error: str | None = None
|
||||
status = "sent"
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.settings.feishu_timeout_seconds) as response:
|
||||
response_code = response.getcode()
|
||||
response_body = response.read(2048).decode(errors="replace")
|
||||
if response_code >= 400:
|
||||
status = "failed"
|
||||
error = f"Feishu webhook returned HTTP {response_code}"
|
||||
except urllib.error.HTTPError as exc:
|
||||
response_code = exc.code
|
||||
response_body = exc.read(2048).decode(errors="replace")
|
||||
status = "failed"
|
||||
error = f"Feishu webhook returned HTTP {exc.code}"
|
||||
except Exception as exc:
|
||||
status = "failed"
|
||||
error = str(exc)
|
||||
|
||||
next_attempt_at = None
|
||||
if status == "failed" and attempts < self.settings.max_delivery_attempts:
|
||||
status = "retry"
|
||||
next_time = datetime.now(UTC) + timedelta(seconds=self.settings.retry_backoff_seconds * attempts)
|
||||
next_attempt_at = next_time.replace(microsecond=0).isoformat()
|
||||
|
||||
with self.db.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE deliveries
|
||||
SET status = ?, attempts = ?, next_attempt_at = ?, last_attempt_at = ?,
|
||||
response_code = ?, response_body = ?, error = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
attempts,
|
||||
next_attempt_at,
|
||||
now_iso(),
|
||||
response_code,
|
||||
response_body,
|
||||
error,
|
||||
now_iso(),
|
||||
delivery["id"],
|
||||
),
|
||||
)
|
||||
failed_open = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM deliveries
|
||||
WHERE alert_id = ? AND status IN ('pending', 'retry', 'failed')
|
||||
""",
|
||||
(delivery["alert_id"],),
|
||||
).fetchone()["count"]
|
||||
conn.execute(
|
||||
"UPDATE alerts SET status = ? WHERE id = ?",
|
||||
("delivered" if failed_open == 0 else "partial", delivery["alert_id"]),
|
||||
)
|
||||
587
app/server.py
Normal file
587
app/server.py
Normal file
@ -0,0 +1,587 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
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
|
||||
|
||||
|
||||
class AppContext:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.db = Database(settings)
|
||||
self.db.migrate(settings)
|
||||
self.dispatcher = Dispatcher(self.db, settings)
|
||||
|
||||
|
||||
def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any] | list[Any]) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode()
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def redirect(handler: BaseHTTPRequestHandler, location: str) -> None:
|
||||
handler.send_response(HTTPStatus.SEE_OTHER)
|
||||
handler.send_header("Location", location)
|
||||
handler.end_headers()
|
||||
|
||||
|
||||
def read_body(handler: BaseHTTPRequestHandler) -> bytes:
|
||||
length = int(handler.headers.get("Content-Length", "0") or "0")
|
||||
return handler.rfile.read(length)
|
||||
|
||||
|
||||
def parse_form(handler: BaseHTTPRequestHandler) -> dict[str, str]:
|
||||
data = read_body(handler).decode()
|
||||
return {key: values[-1] for key, values in parse_qs(data).items()}
|
||||
|
||||
|
||||
def parse_form_multi(handler: BaseHTTPRequestHandler) -> dict[str, list[str]]:
|
||||
return parse_qs(read_body(handler).decode())
|
||||
|
||||
|
||||
def parse_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(read_body(handler).decode() or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValidationError("Request body must be valid JSON") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError("Request body must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def target_select_options(
|
||||
targets: list[dict[str, Any]],
|
||||
selected_ids: list[int] | None = None,
|
||||
placeholder: bool = False,
|
||||
) -> str:
|
||||
selected_ids = selected_ids or []
|
||||
options = ['<option value="">请选择飞书 Webhook</option>'] if placeholder else []
|
||||
for target in targets:
|
||||
selected = "selected" if target["id"] in selected_ids else ""
|
||||
disabled = "" if target["enabled"] else "disabled"
|
||||
suffix = "" if target["enabled"] else " (停用)"
|
||||
options.append(
|
||||
f'<option value="{target["id"]}" {selected} {disabled}>{html.escape(target["name"])}{suffix}</option>'
|
||||
)
|
||||
return "".join(options)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
context: AppContext
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), format % args))
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/health":
|
||||
json_response(self, 200, {"ok": True})
|
||||
return
|
||||
if parsed.path == "/login":
|
||||
self.render_login()
|
||||
return
|
||||
if parsed.path.startswith("/static/"):
|
||||
self.serve_static(parsed.path)
|
||||
return
|
||||
if not self.require_auth():
|
||||
return
|
||||
if parsed.path in ("/", "/dashboard"):
|
||||
self.render_dashboard()
|
||||
elif parsed.path == "/targets":
|
||||
self.render_targets()
|
||||
elif parsed.path == "/rules":
|
||||
self.render_rules()
|
||||
elif parsed.path == "/logs":
|
||||
self.render_logs()
|
||||
elif parsed.path == "/test":
|
||||
self.render_test()
|
||||
elif parsed.path == "/account":
|
||||
self.render_account()
|
||||
elif parsed.path == "/api/targets":
|
||||
json_response(self, 200, self.list_targets())
|
||||
elif parsed.path == "/api/rules":
|
||||
json_response(self, 200, self.list_rules())
|
||||
elif parsed.path == "/api/logs":
|
||||
json_response(self, 200, self.list_logs())
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/webhook/tradingview":
|
||||
self.handle_tradingview_webhook()
|
||||
return
|
||||
if parsed.path == "/login":
|
||||
self.handle_login()
|
||||
return
|
||||
if not self.require_auth():
|
||||
return
|
||||
routes = {
|
||||
"/targets/create": self.create_target,
|
||||
"/targets/update": self.update_target,
|
||||
"/targets/delete": self.delete_target,
|
||||
"/rules/create": self.create_rule,
|
||||
"/rules/update": self.update_rule,
|
||||
"/rules/delete": self.delete_rule,
|
||||
"/test/send": self.send_test,
|
||||
"/account/password": self.change_password,
|
||||
"/deliveries/retry": self.retry_deliveries,
|
||||
"/logout": self.logout,
|
||||
}
|
||||
handler = routes.get(parsed.path)
|
||||
if not handler:
|
||||
self.send_error(404)
|
||||
return
|
||||
handler()
|
||||
|
||||
def require_auth(self) -> bool:
|
||||
if is_valid_session(self.context.settings, self.headers.get("Cookie")):
|
||||
return True
|
||||
redirect(self, "/login")
|
||||
return False
|
||||
|
||||
def layout(self, title: str, body: str) -> bytes:
|
||||
nav = [
|
||||
("/dashboard", "概览"),
|
||||
("/rules", "路由规则"),
|
||||
("/targets", "飞书 Webhook"),
|
||||
("/logs", "日志"),
|
||||
("/test", "测试发送"),
|
||||
("/account", "账号安全"),
|
||||
]
|
||||
items = "".join(f'<a href="{href}">{label}</a>' for href, label in nav)
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">TV Dispatch</div>
|
||||
<nav>{items}</nav>
|
||||
<form method="post" action="/logout"><button class="ghost" type="submit">退出</button></form>
|
||||
</aside>
|
||||
<main class="shell">{body}</main>
|
||||
</body>
|
||||
</html>""".encode()
|
||||
|
||||
def send_html(self, title: str, body: str) -> None:
|
||||
content = self.layout(title, body)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def render_login(self) -> None:
|
||||
content = """<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<h1>TV Dispatch</h1>
|
||||
<p>TradingView alert routing console</p>
|
||||
<label>用户名<input name="username" autocomplete="username" required></label>
|
||||
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>""".encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def handle_login(self) -> None:
|
||||
form = parse_form(self)
|
||||
if not check_credentials(
|
||||
self.context.settings,
|
||||
form.get("username", ""),
|
||||
form.get("password", ""),
|
||||
self.get_admin_password_hash(),
|
||||
):
|
||||
redirect(self, "/login")
|
||||
return
|
||||
cookie = SimpleCookie()
|
||||
cookie[COOKIE_NAME] = make_session_cookie(self.context.settings)
|
||||
cookie[COOKIE_NAME]["path"] = "/"
|
||||
cookie[COOKIE_NAME]["httponly"] = True
|
||||
cookie[COOKIE_NAME]["samesite"] = "Lax"
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", "/dashboard")
|
||||
self.send_header("Set-Cookie", cookie.output(header="").strip())
|
||||
self.end_headers()
|
||||
|
||||
def get_admin_password_hash(self) -> str:
|
||||
with self.context.db.connect() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM admin_settings WHERE id = 1").fetchone()
|
||||
return row["password_hash"]
|
||||
|
||||
def logout(self) -> None:
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", "/login")
|
||||
self.send_header("Set-Cookie", f"{COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax")
|
||||
self.end_headers()
|
||||
|
||||
def serve_static(self, path: str) -> None:
|
||||
local_path = os.path.join(os.path.dirname(__file__), "static", os.path.basename(path))
|
||||
if not os.path.exists(local_path):
|
||||
self.send_error(404)
|
||||
return
|
||||
with open(local_path, "rb") as file:
|
||||
content = file.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mimetypes.guess_type(local_path)[0] or "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def handle_tradingview_webhook(self) -> None:
|
||||
try:
|
||||
payload = parse_json_body(self)
|
||||
result = self.context.dispatcher.receive_alert(payload)
|
||||
json_response(self, 202, result)
|
||||
except ValidationError as exc:
|
||||
json_response(self, 400, {"error": str(exc)})
|
||||
|
||||
def list_targets(self) -> list[dict[str, Any]]:
|
||||
with self.context.db.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_targets ORDER BY id DESC").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_rules(self) -> list[dict[str, Any]]:
|
||||
with self.context.db.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM routing_rules ORDER BY priority ASC, id DESC").fetchall()
|
||||
rules = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["target_ids"] = from_json(item["target_ids"], [])
|
||||
rules.append(item)
|
||||
return rules
|
||||
|
||||
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()
|
||||
deliveries = conn.execute("SELECT * FROM deliveries ORDER BY id DESC LIMIT 200").fetchall()
|
||||
return {"alerts": [dict(row) for row in alerts], "deliveries": [dict(row) for row in deliveries]}
|
||||
|
||||
def render_dashboard(self) -> None:
|
||||
with self.context.db.connect() as conn:
|
||||
counts = {
|
||||
"alerts": conn.execute("SELECT COUNT(*) AS c FROM alerts").fetchone()["c"],
|
||||
"rules": conn.execute("SELECT COUNT(*) AS c FROM routing_rules").fetchone()["c"],
|
||||
"targets": conn.execute("SELECT COUNT(*) AS c FROM webhook_targets").fetchone()["c"],
|
||||
"pending": conn.execute("SELECT COUNT(*) AS c FROM deliveries WHERE status IN ('pending','retry')").fetchone()["c"],
|
||||
}
|
||||
recent = conn.execute("SELECT * FROM alerts ORDER BY id DESC LIMIT 8").fetchall()
|
||||
cards = "".join(f'<div class="metric"><span>{label}</span><strong>{value}</strong></div>' for label, value in [
|
||||
("Alerts", counts["alerts"]),
|
||||
("Rules", counts["rules"]),
|
||||
("Targets", counts["targets"]),
|
||||
("Pending", counts["pending"]),
|
||||
])
|
||||
rows = "".join(
|
||||
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>")
|
||||
|
||||
def render_targets(self) -> None:
|
||||
targets = self.list_targets()
|
||||
rows = "".join(
|
||||
f"""<tr>
|
||||
<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/delete"><input type="hidden" name="id" value="{target['id']}"><button class="danger" type="submit">删除</button></form>
|
||||
</td></tr>"""
|
||||
for target in targets
|
||||
)
|
||||
form = """<form class="panel" method="post" action="/targets/create">
|
||||
<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>"""
|
||||
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>")
|
||||
|
||||
def render_rules(self) -> None:
|
||||
targets = self.list_targets()
|
||||
rules = self.list_rules()
|
||||
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")]
|
||||
)
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
</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>")
|
||||
|
||||
def render_logs(self) -> None:
|
||||
logs = self.list_logs()
|
||||
alert_rows = "".join(
|
||||
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>{html.escape(row['error'] or '')}</td><td>{row['created_at']}</td></tr>"
|
||||
for row in logs["alerts"]
|
||||
)
|
||||
delivery_rows = "".join(
|
||||
f"<tr><td>{row['id']}</td><td>{row['alert_id']}</td><td>{html.escape(row['target_name'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{row['attempts']}</td><td>{html.escape(str(row['response_code'] or ''))}</td><td>{html.escape(row['error'] or '')}</td><td>{html.escape(row['next_attempt_at'] or '')}</td></tr>"
|
||||
for row in logs["deliveries"]
|
||||
)
|
||||
body = f"""<header><h1>日志</h1><p>最近 100 条 alert 和 200 条分发任务。</p></header>
|
||||
<form method="post" action="/deliveries/retry"><button type="submit">处理到期重试</button></form>
|
||||
<section><h2>Alert 日志</h2><table><thead><tr><th>ID</th><th>品种</th><th>周期</th><th>策略</th><th>状态</th><th>错误</th><th>时间</th></tr></thead><tbody>{alert_rows}</tbody></table></section>
|
||||
<section><h2>Delivery 日志</h2><table><thead><tr><th>ID</th><th>Alert</th><th>目标</th><th>状态</th><th>次数</th><th>HTTP</th><th>错误</th><th>下次重试</th></tr></thead><tbody>{delivery_rows}</tbody></table></section>"""
|
||||
self.send_html("日志", body)
|
||||
|
||||
def render_test(self) -> None:
|
||||
sample = html.escape(json.dumps({"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000}, indent=2))
|
||||
result = getattr(self, "_test_result_html", "")
|
||||
body = f"""<header><h1>测试发送</h1><p>提交一条模拟 TradingView alert,走完整匹配和飞书转发流程。</p></header>
|
||||
<form class="panel" method="post" action="/test/send">
|
||||
<label>Alert JSON<textarea name="payload" rows="12">{sample}</textarea></label>
|
||||
<button type="submit">发送测试 Alert</button>
|
||||
</form>"""
|
||||
if result:
|
||||
body += result
|
||||
self.send_html("测试发送", body)
|
||||
|
||||
def render_account(self) -> None:
|
||||
body = """<header><h1>账号安全</h1><p>修改当前管理员密码,修改成功后会退出登录。</p></header>
|
||||
<form class="panel narrow" method="post" action="/account/password">
|
||||
<h2>修改密码</h2>
|
||||
<label>当前密码<input name="current_password" type="password" autocomplete="current-password" required></label>
|
||||
<label>新密码<input name="new_password" type="password" autocomplete="new-password" minlength="8" required></label>
|
||||
<label>确认新密码<input name="confirm_password" type="password" autocomplete="new-password" minlength="8" required></label>
|
||||
<button type="submit">更新密码</button>
|
||||
</form>"""
|
||||
self.send_html("账号安全", body)
|
||||
|
||||
def create_target(self) -> None:
|
||||
form = parse_form(self)
|
||||
now = now_iso()
|
||||
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),
|
||||
)
|
||||
redirect(self, "/targets")
|
||||
|
||||
def update_target(self) -> None:
|
||||
form = parse_form(self)
|
||||
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"]),
|
||||
)
|
||||
redirect(self, "/targets")
|
||||
|
||||
def delete_target(self) -> None:
|
||||
form = parse_form(self)
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute("DELETE FROM webhook_targets WHERE id = ?", (form["id"],))
|
||||
redirect(self, "/targets")
|
||||
|
||||
def create_rule(self) -> None:
|
||||
form = parse_form_multi(self)
|
||||
target_ids = [int(value) for value in form.get("target_ids", [])]
|
||||
now = now_iso()
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO routing_rules (
|
||||
name, timeframe, symbol, strategy, priority, message_type,
|
||||
card_title_template, card_body_template, enabled, target_ids,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
form.get("name", [""])[-1].strip(),
|
||||
form.get("timeframe", [""])[-1].strip(),
|
||||
form.get("symbol", [""])[-1].strip().upper(),
|
||||
form.get("strategy", [""])[-1].strip(),
|
||||
int(form.get("priority", ["100"])[-1]),
|
||||
form.get("message_type", ["card"])[-1],
|
||||
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,
|
||||
to_json(target_ids),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
redirect(self, "/rules")
|
||||
|
||||
def delete_rule(self) -> None:
|
||||
form = parse_form(self)
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute("DELETE FROM routing_rules WHERE id = ?", (form["id"],))
|
||||
redirect(self, "/rules")
|
||||
|
||||
def update_rule(self) -> None:
|
||||
form = parse_form_multi(self)
|
||||
target_ids = [int(value) for value in form.get("target_ids", [])]
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE routing_rules
|
||||
SET name = ?, timeframe = ?, symbol = ?, strategy = ?, priority = ?,
|
||||
message_type = ?, card_title_template = ?, card_body_template = ?,
|
||||
enabled = ?, target_ids = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
form.get("name", [""])[-1].strip(),
|
||||
form.get("timeframe", [""])[-1].strip(),
|
||||
form.get("symbol", [""])[-1].strip().upper(),
|
||||
form.get("strategy", [""])[-1].strip(),
|
||||
int(form.get("priority", ["100"])[-1]),
|
||||
form.get("message_type", ["card"])[-1],
|
||||
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,
|
||||
to_json(target_ids),
|
||||
now_iso(),
|
||||
form.get("id", [""])[-1],
|
||||
),
|
||||
)
|
||||
redirect(self, "/rules")
|
||||
|
||||
def send_test(self) -> None:
|
||||
form = parse_form(self)
|
||||
payload_text = form.get("payload", "{}")
|
||||
try:
|
||||
payload = json.loads(payload_text)
|
||||
result = self.context.dispatcher.receive_alert(payload)
|
||||
delivery_text = ", ".join(str(item) for item in result.get("delivery_ids", [])) or "-"
|
||||
self._test_result_html = f"""<section class="result-panel success">
|
||||
<h2>测试结果</h2>
|
||||
<div class="result-grid">
|
||||
<div><span>Alert ID</span><strong>{result.get("alert_id")}</strong></div>
|
||||
<div><span>状态</span><strong>{html.escape(str(result.get("status")))}</strong></div>
|
||||
<div><span>命中规则</span><strong>{html.escape(str(result.get("matched_rule_id") or "-"))}</strong></div>
|
||||
<div><span>Delivery</span><strong>{html.escape(delivery_text)}</strong></div>
|
||||
</div>
|
||||
<details><summary>查看响应 JSON</summary><pre>{html.escape(json.dumps(result, ensure_ascii=False, indent=2))}</pre></details>
|
||||
</section>"""
|
||||
self.render_test()
|
||||
except (json.JSONDecodeError, ValidationError) as exc:
|
||||
self._test_result_html = f"""<section class="result-panel error">
|
||||
<h2>测试失败</h2>
|
||||
<p>{html.escape(str(exc))}</p>
|
||||
</section>"""
|
||||
self.render_test()
|
||||
|
||||
def change_password(self) -> None:
|
||||
form = parse_form(self)
|
||||
current_password = form.get("current_password", "")
|
||||
new_password = form.get("new_password", "")
|
||||
confirm_password = form.get("confirm_password", "")
|
||||
if not check_credentials(
|
||||
self.context.settings,
|
||||
self.context.settings.admin_username,
|
||||
current_password,
|
||||
self.get_admin_password_hash(),
|
||||
):
|
||||
json_response(self, 400, {"error": "当前密码不正确"})
|
||||
return
|
||||
if len(new_password) < 8:
|
||||
json_response(self, 400, {"error": "新密码至少需要 8 位"})
|
||||
return
|
||||
if new_password != confirm_password:
|
||||
json_response(self, 400, {"error": "两次输入的新密码不一致"})
|
||||
return
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE admin_settings SET password_hash = ?, updated_at = ? WHERE id = 1",
|
||||
(hash_password(new_password), now_iso()),
|
||||
)
|
||||
self.logout()
|
||||
|
||||
def retry_deliveries(self) -> None:
|
||||
self.context.dispatcher.process_due_deliveries(limit=100)
|
||||
redirect(self, "/logs")
|
||||
|
||||
|
||||
def make_handler(context: AppContext) -> type[Handler]:
|
||||
class BoundHandler(Handler):
|
||||
pass
|
||||
|
||||
BoundHandler.context = context
|
||||
return BoundHandler
|
||||
|
||||
|
||||
def run() -> None:
|
||||
settings = get_settings()
|
||||
context = AppContext(settings)
|
||||
context.db.cleanup_old_logs(settings.retention_days)
|
||||
server = ThreadingHTTPServer((settings.host, settings.port), make_handler(context))
|
||||
print(f"Serving {settings.app_name} on http://{settings.host}:{settings.port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
29
app/static/app.js
Normal file
29
app/static/app.js
Normal file
@ -0,0 +1,29 @@
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
385
app/static/styles.css
Normal file
385
app/static/styles.css
Normal file
@ -0,0 +1,385 @@
|
||||
:root {
|
||||
--bg: #f5f3ec;
|
||||
--ink: #1e2528;
|
||||
--muted: #667071;
|
||||
--line: #d9d4c8;
|
||||
--panel: #fffdf8;
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #0b534d;
|
||||
--danger: #b42318;
|
||||
--shadow: 0 18px 45px rgba(40, 34, 23, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: ui-serif, Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(rgba(30, 37, 40, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(30, 37, 40, 0.03) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: 236px;
|
||||
padding: 28px 20px;
|
||||
background: #182326;
|
||||
color: #f7f1e4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #dfe8e4;
|
||||
text-decoration: none;
|
||||
padding: 11px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
margin-left: 236px;
|
||||
min-height: 100vh;
|
||||
padding: 36px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.panel,
|
||||
table {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric {
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 8px;
|
||||
padding: 22px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font: 600 13px ui-sans-serif, system-ui, sans-serif;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 11px 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: 15px ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
td input,
|
||||
td select,
|
||||
td textarea {
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
td textarea {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.narrow {
|
||||
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;
|
||||
gap: 8px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.checks {
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 11px 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 22px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.result-panel.success {
|
||||
border-left: 5px solid var(--accent);
|
||||
}
|
||||
|
||||
.result-panel.error {
|
||||
border-left: 5px solid var(--danger);
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.result-grid div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.result-grid span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font: 12px ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.result-grid strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font: 700 18px ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
details {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
background: #182326;
|
||||
color: #f7f1e4;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 12px 13px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #ece7da;
|
||||
color: #3b4445;
|
||||
}
|
||||
|
||||
.url {
|
||||
max-width: 460px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
background: #f7f3e8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.sidebar {
|
||||
position: static;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.shell {
|
||||
margin-left: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.grid,
|
||||
.result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
26
app/worker.py
Normal file
26
app/worker.py
Normal file
@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import Database
|
||||
from app.dispatcher import Dispatcher
|
||||
|
||||
|
||||
def run() -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings)
|
||||
db.migrate(settings)
|
||||
dispatcher = Dispatcher(db, settings)
|
||||
interval = int(os.getenv("WORKER_INTERVAL_SECONDS", "15"))
|
||||
print(f"Retry worker running every {interval}s")
|
||||
while True:
|
||||
processed = dispatcher.process_due_deliveries(limit=100)
|
||||
if processed:
|
||||
print(f"processed {processed} due deliveries")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
||||
services:
|
||||
dispatcher:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: change-me-now
|
||||
SESSION_SECRET: replace-with-a-long-random-secret
|
||||
RETENTION_DAYS: 30
|
||||
MAX_DELIVERY_ATTEMPTS: 3
|
||||
RETRY_BACKOFF_SECONDS: 60
|
||||
volumes:
|
||||
- dispatcher-data:/data
|
||||
|
||||
worker:
|
||||
build: .
|
||||
command: ["python", "-m", "app.worker"]
|
||||
environment:
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: change-me-now
|
||||
SESSION_SECRET: replace-with-a-long-random-secret
|
||||
RETENTION_DAYS: 30
|
||||
MAX_DELIVERY_ATTEMPTS: 3
|
||||
RETRY_BACKOFF_SECONDS: 60
|
||||
WORKER_INTERVAL_SECONDS: 15
|
||||
volumes:
|
||||
- dispatcher-data:/data
|
||||
|
||||
volumes:
|
||||
dispatcher-data:
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
# The runtime intentionally uses only Python standard library modules.
|
||||
123
tests/test_dispatcher.py
Normal file
123
tests/test_dispatcher.py
Normal file
@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import Database, now_iso, to_json
|
||||
from app.dispatcher import Dispatcher, ValidationError, build_feishu_message
|
||||
|
||||
|
||||
class DispatcherTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.settings = Settings(
|
||||
database_path=os.path.join(self.tmpdir.name, "test.db"),
|
||||
max_delivery_attempts=2,
|
||||
retry_backoff_seconds=1,
|
||||
feishu_timeout_seconds=1,
|
||||
)
|
||||
self.db = Database(self.settings)
|
||||
self.db.migrate(self.settings)
|
||||
self.dispatcher = Dispatcher(self.db, self.settings)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def add_target(self, name: str = "ops", url: str = "http://127.0.0.1:9/hook") -> int:
|
||||
now = now_iso()
|
||||
with self.db.connect() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_targets (name, webhook_url, enabled, created_at, updated_at) VALUES (?, ?, 1, ?, ?)",
|
||||
(name, url, now, now),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def add_rule(self, target_id: int, priority: int = 100, name: str = "rule") -> int:
|
||||
now = now_iso()
|
||||
with self.db.connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO routing_rules
|
||||
(
|
||||
name, timeframe, symbol, strategy, priority, message_type,
|
||||
card_title_template, card_body_template, enabled, target_ids,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?, '5m', 'BTCUSDT', 'breakout', ?, 'card', 'Signal {{symbol}}', 'Price {{price}}', 1, ?, ?, ?)
|
||||
""",
|
||||
(name, 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")
|
||||
with self.db.connect() as conn:
|
||||
alert = conn.execute("SELECT * FROM alerts WHERE id = ?", (result["alert_id"],)).fetchone()
|
||||
self.assertEqual(alert["status"], "unmatched")
|
||||
|
||||
def test_highest_priority_rule_wins(self) -> None:
|
||||
slow_target = self.add_target("slow")
|
||||
fast_target = self.add_target("fast")
|
||||
slow_rule = self.add_rule(slow_target, priority=100, name="slow")
|
||||
fast_rule = self.add_rule(fast_target, priority=1, name="fast")
|
||||
|
||||
result = self.dispatcher.receive_alert(
|
||||
{"timeframe": "5m", "symbol": "btcusdt", "strategy": "breakout", "action": "buy"}
|
||||
)
|
||||
|
||||
self.assertEqual(result["matched_rule_id"], fast_rule)
|
||||
self.assertNotEqual(result["matched_rule_id"], slow_rule)
|
||||
with self.db.connect() as conn:
|
||||
delivery = conn.execute("SELECT * FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()
|
||||
self.assertEqual(delivery["target_id"], fast_target)
|
||||
|
||||
def test_failed_delivery_is_marked_for_retry(self) -> None:
|
||||
target_id = self.add_target()
|
||||
self.add_rule(target_id)
|
||||
|
||||
result = self.dispatcher.receive_alert(
|
||||
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy"}
|
||||
)
|
||||
|
||||
with self.db.connect() as conn:
|
||||
delivery = conn.execute("SELECT * FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()
|
||||
self.assertEqual(delivery["status"], "retry")
|
||||
self.assertEqual(delivery["attempts"], 1)
|
||||
self.assertIsNotNone(delivery["error"])
|
||||
|
||||
def test_card_template_uses_alert_fields(self) -> None:
|
||||
message = build_feishu_message(
|
||||
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
|
||||
{
|
||||
"message_type": "card",
|
||||
"card_title_template": "{{symbol}} {{action}}",
|
||||
"card_body_template": "**价格** {{price}}",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(message["msg_type"], "interactive")
|
||||
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:
|
||||
message = build_feishu_message(
|
||||
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
|
||||
{
|
||||
"message_type": "text",
|
||||
"card_title_template": "TradingView {symbol} {action}",
|
||||
"card_body_template": "价格 {price}",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(message["content"]["text"], "TradingView BTCUSDT buy\n价格 68000")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue
Block a user