diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index f92322f..d5e6ad6 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -103,7 +103,7 @@ class RuleModel(BaseModel): severity: str = "info" # info | warn | critical webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定) webhook_enabled: bool = False # 兼容老规则 (已由 webhook_channels 取代, 仅做向后兼容读) - webhook_channels: list[str] = [] # 命中时推送的外部渠道 (合法值 'feishu' | 'wecom') + webhook_channels: list[str] = [] # 合法值: feishu | wecom | custom | email message: str = "" # abnormal 专属 (异动边缘监控): any | 3d | 10d | 30d abnormal_window: str = "any" diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 88209e2..4831dc9 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -537,6 +537,10 @@ def get_preferences() -> dict: "feishu_webhook_url": preferences.get_feishu_webhook_url(), "feishu_webhook_secret": preferences.get_feishu_webhook_secret(), "wecom_webhook_url": preferences.get_wecom_webhook_url(), + "custom_webhook_url": preferences.get_custom_webhook_url(), + "custom_webhook_secret_set": bool(secrets_store.get_custom_webhook_secret()), + "email_smtp_config": preferences.get_email_smtp_config(), + "email_smtp_password_set": bool(secrets_store.get_email_smtp_password()), "wecom_bot_id": preferences.get_wecom_bot_id(), "wecom_bot_secret": preferences.get_wecom_bot_secret(), "wecom_bot_enabled": preferences.get_wecom_bot_enabled(), @@ -1232,8 +1236,88 @@ def update_wecom_webhook(req: WecomWebhookPrefsIn) -> dict: return {"wecom_webhook_url": saved_url} +class CustomWebhookPrefsIn(BaseModel): + url: str + # None preserves the stored secret; an explicit empty string clears it. + secret: str | None = None + + +@router.put("/preferences/custom-webhook") +def update_custom_webhook(req: CustomWebhookPrefsIn) -> dict: + """Configure the generic third-party JSON webhook and optional HMAC secret.""" + from app.services import preferences, webhook_adapter + + url = (req.url or "").strip() + if url and not webhook_adapter.is_valid_custom_url(url): + raise HTTPException(status_code=400, detail="Webhook 地址必须是完整的 HTTP(S) URL") + saved_url = preferences.set_custom_webhook_url(url) + if not saved_url: + secrets_store.set_custom_webhook_secret("") + elif req.secret is not None: + secrets_store.set_custom_webhook_secret(req.secret) + return { + "custom_webhook_url": saved_url, + "custom_webhook_secret_set": bool(secrets_store.get_custom_webhook_secret()), + } + + +class EmailSmtpPrefsIn(BaseModel): + host: str + port: int = Field(default=465, ge=1, le=65535) + security: Literal["ssl", "starttls", "none"] = "ssl" + username: str = "" + # None preserves the stored password; an explicit empty string clears it. + password: str | None = None + from_address: str = "" + to_addresses: list[str] = Field(default_factory=list) + + +@router.put("/preferences/email-smtp") +def update_email_smtp(req: EmailSmtpPrefsIn) -> dict: + """Configure the SMTP transport shared by monitor alerts and review reports.""" + from app.services import email_adapter, preferences + + host = (req.host or "").strip() + username = (req.username or "").strip() + from_address = (req.from_address or username).strip() + recipients = list(dict.fromkeys(item.strip() for item in req.to_addresses if item.strip())) + if host: + if not from_address or not email_adapter.is_valid_email(from_address): + raise HTTPException(status_code=400, detail="请填写有效的发件人邮箱") + if not recipients or any(not email_adapter.is_valid_email(item) for item in recipients): + raise HTTPException(status_code=400, detail="请至少填写一个有效的收件人邮箱") + effective_password = ( + secrets_store.get_email_smtp_password() + if req.password is None + else req.password + ) + if username and not effective_password: + raise HTTPException(status_code=400, detail="已填写 SMTP 登录用户名,请同时填写密码或授权码") + else: + username = "" + from_address = "" + recipients = [] + + config = preferences.set_email_smtp_config({ + "host": host, + "port": req.port, + "security": req.security, + "username": username, + "from_address": from_address, + "to_addresses": recipients, + }) + if not host or not username: + secrets_store.set_email_smtp_password("") + elif req.password is not None: + secrets_store.set_email_smtp_password(req.password) + return { + "email_smtp_config": config, + "email_smtp_password_set": bool(secrets_store.get_email_smtp_password()), + } + + class WebhookTestIn(BaseModel): - channel: Literal["feishu", "wecom"] + channel: Literal["feishu", "wecom", "custom", "email"] @router.post("/preferences/webhook-test") @@ -1259,16 +1343,43 @@ def test_webhook(req: WebhookTestIn) -> dict: secret = preferences.get_feishu_webhook_secret() # 诊断用途单次尝试: 失败即返回, 不等生产退避重试 (~17s) ok = webhook_adapter.send_feishu(url, title, body, secret, max_attempts=1) - else: # wecom + elif req.channel == "wecom": url = preferences.get_wecom_webhook_url() if not url: return {"ok": False, "detail": "尚未配置企业微信 Webhook,请先保存"} if not webhook_adapter.is_valid_wecom_url(url): return {"ok": False, "detail": "已保存的企业微信 Webhook 地址非法,请重新保存"} ok = webhook_adapter.send_wecom(url, title, body) + elif req.channel == "custom": + url = preferences.get_custom_webhook_url() + if not url: + return {"ok": False, "detail": "尚未配置第三方 Webhook,请先保存"} + if not webhook_adapter.is_valid_custom_url(url): + return {"ok": False, "detail": "已保存的第三方 Webhook 地址非法,请重新保存"} + ok = webhook_adapter.send_custom( + url, + title, + body, + event_type="test", + secret=secrets_store.get_custom_webhook_secret(), + max_attempts=1, + ) + else: # email + from app.services import email_adapter + + config = preferences.get_email_smtp_config() + if not email_adapter.is_configured(config): + return {"ok": False, "detail": "尚未完整配置邮件 SMTP,请先保存"} + ok = email_adapter.send_email( + config, + secrets_store.get_email_smtp_password(), + title, + body, + max_attempts=1, + ) if ok: - return {"ok": True, "detail": "测试消息已发送,请到群内查收"} + return {"ok": True, "detail": "测试消息已发送,请检查对应接收端"} return {"ok": False, "detail": "推送失败:网络不可达或地址/密钥不正确,详情见后端日志"} @@ -1353,7 +1464,7 @@ def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict: class WebhookDefaultChannelsIn(BaseModel): - channels: list[str] # 多选: ['feishu','wecom'] 等; 空数组=默认不推送 + channels: list[str] # 多选: feishu / wecom / custom / email; 空数组=不推送 @router.put("/preferences/webhook-default-channels") @@ -1796,7 +1907,7 @@ def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict: class ReviewPushIn(BaseModel): - channels: list[str] # 多选: ['feishu'] 等; 空数组=不推送。微信等开发中 + channels: list[str] # 多选: feishu / wecom / custom / email; 空数组=不推送 @router.put("/preferences/review-push") diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index f50b87f..bac85a2 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -979,11 +979,12 @@ def _maybe_push_review(content: str, meta: dict) -> None: """复盘报告归档后, 按 review_push_channels 选定的外部工具逐个推送完整报告。 定时生成与手动生成共用本函数 (手动归档端点 POST /api/market-recap/reports 也会调用)。 - channels 为空则不推送; 'feishu' 复用监控中心的全局飞书 Webhook 通道。 + channels 为空则不推送; 复用监控中心的全局外部渠道配置。 推送失败静默降级 (Webhook 是辅助通道), 不影响已归档的报告。 """ try: - from app.services import preferences, webhook_adapter + from app import secrets_store + from app.services import email_adapter, preferences, webhook_adapter channels = preferences.get_review_push_channels() if not channels: @@ -1015,6 +1016,33 @@ def _maybe_push_review(content: str, meta: dict) -> None: url, "每日复盘", full_body ) logger.info("review push(wecom) %s", "sent" if ok else "failed") + elif ch == "custom": + url = preferences.get_custom_webhook_url() + if not url: + logger.info("review push(custom) skipped: webhook not configured") + continue + ok = webhook_adapter.send_custom( + url, + "每日复盘", + content, + "market_review", + meta, + secrets_store.get_custom_webhook_secret(), + ) + logger.info("review push(custom) %s", "sent" if ok else "failed") + elif ch == "email": + config = preferences.get_email_smtp_config() + if not email_adapter.is_configured(config): + logger.info("review push(email) skipped: SMTP not configured") + continue + email_body = (f"{subtitle}\n\n{content}" if subtitle else content) + ok = email_adapter.send_email( + config, + secrets_store.get_email_smtp_password(), + "每日复盘", + email_body, + ) + logger.info("review push(email) %s", "sent" if ok else "failed") # 未来更多渠道在此追加分支 except Exception as e: # noqa: BLE001 logger.warning("review push error: %s", e) diff --git a/backend/app/secrets_store.py b/backend/app/secrets_store.py index e0de664..b773996 100644 --- a/backend/app/secrets_store.py +++ b/backend/app/secrets_store.py @@ -99,6 +99,36 @@ def get_ai_config_int(key: str, default: int) -> int: return int(getattr(settings, key, default) or default) +def get_custom_webhook_secret() -> str: + """Return the optional HMAC secret for the generic outbound webhook.""" + return str(load().get("custom_webhook_secret") or "") + + +def set_custom_webhook_secret(secret: str) -> str: + """Persist or clear the generic outbound webhook HMAC secret.""" + value = (secret or "").strip() + if value: + save({"custom_webhook_secret": value}) + else: + clear("custom_webhook_secret") + return value + + +def get_email_smtp_password() -> str: + """Return the SMTP password used by the email notification channel.""" + return str(load().get("email_smtp_password") or "") + + +def set_email_smtp_password(password: str) -> str: + """Persist or clear the SMTP password used by email notifications.""" + value = password or "" + if value: + save({"email_smtp_password": value}) + else: + clear("email_smtp_password") + return value + + def get_env_backed_secret(field: str, env_name: str) -> str: """取环境变量后备的密钥(插件 API Key 等):secrets.json 优先,否则环境变量。 diff --git a/backend/app/services/email_adapter.py b/backend/app/services/email_adapter.py new file mode 100644 index 0000000..e405c2b --- /dev/null +++ b/backend/app/services/email_adapter.py @@ -0,0 +1,104 @@ +"""SMTP email notification adapter. + +Transport failures are isolated from alert persistence and SSE delivery. SMTP credentials +are supplied by the caller from ``secrets_store`` and never logged here. +""" +from __future__ import annotations + +import logging +import smtplib +import time +from contextlib import suppress +from email.message import EmailMessage +from email.utils import parseaddr + +logger = logging.getLogger(__name__) + +SECURITY_MODES = {"ssl", "starttls", "none"} +_MAX_ATTEMPTS = 2 + + +def is_valid_email(address: str) -> bool: + """Small dependency-free mailbox validation suitable for configuration checks.""" + parsed = parseaddr((address or "").strip())[1] + if parsed != (address or "").strip() or parsed.count("@") != 1: + return False + local, domain = parsed.rsplit("@", 1) + return bool(local and domain and "." in domain and " " not in parsed) + + +def is_configured(config: dict) -> bool: + """Return whether the non-secret fields are sufficient to attempt delivery.""" + sender = str(config.get("from_address") or config.get("username") or "").strip() + recipients = config.get("to_addresses") or [] + return bool(config.get("host") and sender and recipients) + + +def send_email( + config: dict, + password: str, + subject: str, + body: str, + *, + max_attempts: int = _MAX_ATTEMPTS, +) -> bool: + """Send one UTF-8 plain-text email through SSL, STARTTLS, or plain SMTP.""" + if not is_configured(config): + return False + + host = str(config.get("host") or "").strip() + try: + port = int(config.get("port", 465)) + except (TypeError, ValueError): + return False + security = str(config.get("security") or "ssl") + username = str(config.get("username") or "").strip() + sender = str(config.get("from_address") or username).strip() + recipients = [str(item).strip() for item in config.get("to_addresses", [])] + if ( + not 1 <= port <= 65535 + or security not in SECURITY_MODES + or not is_valid_email(sender) + or not recipients + or any(not is_valid_email(item) for item in recipients) + ): + return False + + message = EmailMessage() + message["Subject"] = str(subject or "TickFlow 通知") + message["From"] = sender + message["To"] = ", ".join(recipients) + message.set_content(str(body or "")) + + last_err = "" + for attempt in range(1, max_attempts + 1): + smtp = None + try: + if security == "ssl": + smtp = smtplib.SMTP_SSL(host, port, timeout=10) + else: + smtp = smtplib.SMTP(host, port, timeout=10) + if security == "starttls": + smtp.ehlo() + smtp.starttls() + smtp.ehlo() + if username: + smtp.login(username, password) + smtp.send_message(message) + # Delivery already succeeded; a failed QUIT must not retry and duplicate the email. + try: + smtp.quit() + except Exception: + with suppress(Exception): + smtp.close() + return True + except Exception as exc: # SMTP/network errors must not escape + last_err = str(exc) + if smtp is not None: + with suppress(Exception): + smtp.close() + if attempt < max_attempts: + time.sleep(1) + + logger.warning("邮件推送最终失败(已尝试 %d 次): %s", max_attempts, last_err) + return False diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 10bda6b..4ff13f4 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -580,9 +580,9 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict: return {"hour": h, "minute": m} -# 复盘推送可选渠道白名单 (企业微信已实现, 与飞书并列) +# 监控与复盘共用的外部推送渠道白名单。 # 多选: 不推送 = 空数组, 而非 'none' -REVIEW_PUSH_CHANNELS = {"feishu", "wecom"} +PUSH_CHANNELS = {"feishu", "wecom", "custom", "email"} def get_review_schedule() -> dict: @@ -662,7 +662,7 @@ def get_review_push_channels() -> list[str]: d = load() raw = d.get("review_push_channels") if isinstance(raw, list): - return [c for c in raw if c in REVIEW_PUSH_CHANNELS] + return [c for c in raw if c in PUSH_CHANNELS] # 兼容老单选字符串 if d.get("review_push_channel") == "feishu": return ["feishu"] @@ -677,7 +677,7 @@ def set_review_push_channels(channels: list[str]) -> list[str]: seen: set[str] = set() cleaned: list[str] = [] for c in channels or []: - if c in REVIEW_PUSH_CHANNELS and c not in seen: + if c in PUSH_CHANNELS and c not in seen: seen.add(c) cleaned.append(c) save({"review_push_channels": cleaned}) @@ -797,6 +797,71 @@ def set_wecom_webhook_url(url: str) -> str: return get_wecom_webhook_url() +def get_custom_webhook_url() -> str: + """Generic third-party JSON Webhook URL shared by enabled rules and reviews.""" + return str(load().get("custom_webhook_url") or "") + + +def set_custom_webhook_url(url: str) -> str: + """Persist or clear the generic third-party JSON Webhook URL.""" + value = str(url or "").strip() + save({"custom_webhook_url": value}) + return value + + +_EMAIL_SMTP_DEFAULTS = { + "host": "", + "port": 465, + "security": "ssl", + "username": "", + "from_address": "", + "to_addresses": [], +} + + +def get_email_smtp_config() -> dict: + """Return non-secret SMTP settings for the email notification channel.""" + raw = load().get("email_smtp_config") + if not isinstance(raw, dict): + raw = {} + security = raw.get("security", _EMAIL_SMTP_DEFAULTS["security"]) + if security not in {"ssl", "starttls", "none"}: + security = _EMAIL_SMTP_DEFAULTS["security"] + try: + port = int(raw.get("port", _EMAIL_SMTP_DEFAULTS["port"])) + except (TypeError, ValueError): + port = _EMAIL_SMTP_DEFAULTS["port"] + if not 1 <= port <= 65535: + port = _EMAIL_SMTP_DEFAULTS["port"] + recipients = raw.get("to_addresses") + if not isinstance(recipients, list): + recipients = [] + return { + "host": str(raw.get("host") or "").strip(), + "port": port, + "security": security, + "username": str(raw.get("username") or "").strip(), + "from_address": str(raw.get("from_address") or "").strip(), + "to_addresses": [str(item).strip() for item in recipients if str(item).strip()], + } + + +def set_email_smtp_config(config: dict) -> dict: + """Atomically persist the non-secret SMTP configuration group.""" + normalized = { + "host": str(config.get("host") or "").strip(), + "port": int(config.get("port", 465)), + "security": str(config.get("security") or "ssl"), + "username": str(config.get("username") or "").strip(), + "from_address": str(config.get("from_address") or "").strip(), + "to_addresses": [ + str(item).strip() for item in config.get("to_addresses", []) if str(item).strip() + ], + } + save({"email_smtp_config": normalized}) + return get_email_smtp_config() + + # ===== 企业微信智能机器人 (API 模式 / 长连接) ===== @@ -863,7 +928,7 @@ def get_webhook_default_channels() -> list[str]: d = load() raw = d.get("webhook_default_channels") if isinstance(raw, list): - return [c for c in raw if c in REVIEW_PUSH_CHANNELS] + return [c for c in raw if c in PUSH_CHANNELS] # 兼容老布尔开关 (勾选即双推) if d.get("webhook_enabled_default") is True: return ["feishu", "wecom"] @@ -875,7 +940,7 @@ def set_webhook_default_channels(channels: list[str]) -> list[str]: seen: set[str] = set() cleaned: list[str] = [] for c in channels or []: - if c in REVIEW_PUSH_CHANNELS and c not in seen: + if c in PUSH_CHANNELS and c not in seen: seen.add(c) cleaned.append(c) save({"webhook_default_channels": cleaned}) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 16a05db..c7f281d 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -1454,7 +1454,7 @@ class QuoteService: def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None: """把告警通过 Webhook 推送到外部 IM (由规则 webhook_channels 指定渠道)。 - - 飞书 / 企业微信任一已配置即生效 (两个都没配才跳过) + - 飞书 / 企业微信 / 第三方 Webhook / 邮件均按规则独立选择 - 仅推送 webhook_channels 非空的规则触发的告警, 且只投递被勾选的渠道 - 失败静默, 不阻断主流程 - 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重 @@ -1463,14 +1463,17 @@ class QuoteService: 以便反查引擎规则判断是否启用推送。 """ try: - from app.services import preferences - from app.services import webhook_adapter + from app import secrets_store + from app.services import email_adapter, preferences, webhook_adapter feishu_url = preferences.get_feishu_webhook_url() feishu_secret = preferences.get_feishu_webhook_secret() wecom_url = preferences.get_wecom_webhook_url() - # 两个通道都没配置才跳过 - if not feishu_url and not wecom_url: + custom_url = preferences.get_custom_webhook_url() + custom_secret = secrets_store.get_custom_webhook_secret() + email_config = preferences.get_email_smtp_config() + email_password = secrets_store.get_email_smtp_password() + if not any((feishu_url, wecom_url, custom_url, email_adapter.is_configured(email_config))): return # 反查规则, 过滤出启用推送的事件 @@ -1478,7 +1481,7 @@ class QuoteService: enqueued = 0 for ev in rule_events: rule = rules.get(ev.get("rule_id")) - # webhook_channels 指定命中的渠道 (['feishu'] / ['wecom'] / ['feishu','wecom'] / []). + # webhook_channels 指定本规则需要投递的外部渠道。 # 空列表 = 该规则不推送。仅推送「渠道已选 + 对应地址已配置」的组合。 channels = rule.get("webhook_channels") if rule else None if not channels: @@ -1493,7 +1496,7 @@ class QuoteService: # 补上触发时的现价/涨跌幅, 让推送可执行 (止损到底触发在哪个价位) body = _body_with_quote(body, ev) # 提交到独立线程池, 不阻塞行情轮询线程 (webhook 慢/重试不拖累实时行情+告警)。 - # 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。 + # 按渠道独立投递: 只投递同时“已勾选 + 已配置”的渠道。 # 应用内 alerts.jsonl 记录与 SSE 已在前面完成, 不依赖 webhook 成败, # 失败由 webhook_adapter 记 WARNING(可见)。 if feishu_url and "feishu" in channels: @@ -1502,6 +1505,26 @@ class QuoteService: if wecom_url and "wecom" in channels: _WEBHOOK_EXECUTOR.submit(webhook_adapter.send_wecom, wecom_url, title, body) enqueued += 1 + if custom_url and "custom" in channels: + _WEBHOOK_EXECUTOR.submit( + webhook_adapter.send_custom, + custom_url, + title, + body, + "monitor_alert", + ev, + custom_secret, + ) + enqueued += 1 + if email_adapter.is_configured(email_config) and "email" in channels: + _WEBHOOK_EXECUTOR.submit( + email_adapter.send_email, + email_config, + email_password, + title, + body, + ) + enqueued += 1 if enqueued: logger.info("Webhook 已提交 %d 条 (异步投递, 按渠道独立投递, 失败记 WARNING)", enqueued) except Exception as e: # noqa: BLE001 diff --git a/backend/app/services/webhook_adapter.py b/backend/app/services/webhook_adapter.py index 77cbfd8..8d6fa7b 100644 --- a/backend/app/services/webhook_adapter.py +++ b/backend/app/services/webhook_adapter.py @@ -1,7 +1,7 @@ """Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。 职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。 - 目前支持飞书群推送 Webhook; QMT / ptrade 等量化通道为待定。 + 目前支持飞书、企业微信和通用第三方 JSON Webhook。 飞书自定义机器人接入: 1. 飞书群 → 群设置 → 群推送 Webhook → 添加「自定义机器人」 @@ -17,8 +17,10 @@ from __future__ import annotations import base64 import hashlib import hmac +import json import logging import time +from urllib.parse import urlparse logger = logging.getLogger(__name__) @@ -344,3 +346,76 @@ def send_wecom_markdown(webhook_url: str, title: str, body_md: str) -> bool: payload: dict = {"msgtype": "markdown", "markdown": {"content": content}} return _post_wecom(webhook_url, payload) + +# ================================================================ +# 通用第三方 JSON Webhook +# ================================================================ + +_CUSTOM_MAX_ATTEMPTS = 3 + + +def is_valid_custom_url(url: str) -> bool: + """Accept absolute HTTP(S) URLs, including LAN endpoints used by local deployments.""" + try: + parsed = urlparse((url or "").strip()) + except ValueError: + return False + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username + + +def send_custom( + webhook_url: str, + title: str, + body: str, + event_type: str, + data: dict | None = None, + secret: str = "", + max_attempts: int = _CUSTOM_MAX_ATTEMPTS, +) -> bool: + """POST a stable JSON envelope to a user-configured third-party system. + + When ``secret`` is configured the raw request body is signed with HMAC-SHA256. + The receiver can validate ``X-TickFlow-Timestamp`` and + ``X-TickFlow-Signature: sha256=`` before accepting the event. + """ + if not is_valid_custom_url(webhook_url): + return False + + timestamp = str(int(time.time())) + payload = { + "event": str(event_type or "notification"), + "timestamp": int(timestamp), + "title": str(title or ""), + "body": str(body or ""), + "data": data or {}, + } + encoded = json.dumps( + payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True, default=str, + ).encode("utf-8") + headers = {"Content-Type": "application/json", "User-Agent": "TickFlow-Webhook/1.0"} + if secret: + digest = hmac.new(secret.encode("utf-8"), encoded, hashlib.sha256).hexdigest() + headers["X-TickFlow-Timestamp"] = timestamp + headers["X-TickFlow-Signature"] = f"sha256={digest}" + + import httpx + + last_err = "" + for attempt in range(1, max_attempts + 1): + try: + response = httpx.post( + webhook_url, content=encoded, headers=headers, timeout=5.0, + ) + if 200 <= response.status_code < 300: + return True + last_err = f"HTTP {response.status_code}: {response.text[:200]}" + if response.status_code < 500: + logger.warning("第三方 Webhook 推送失败(不重试): %s", last_err) + return False + except Exception as exc: # Network failures are retryable and must not escape. + last_err = str(exc) + if attempt < max_attempts: + time.sleep(min(2 ** (attempt - 1), 3)) + + logger.warning("第三方 Webhook 推送最终失败(已重试 %d 次): %s", max_attempts, last_err) + return False diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index 787d313..52914f4 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -394,7 +394,7 @@ def normalize(rule: dict) -> dict: r.setdefault("message", "") r.setdefault("webhook_url", "") r.setdefault("webhook_enabled", False) - # webhook_channels: 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')。 + # webhook_channels: 命中时推送的外部渠道。 # 向后兼容: 老规则只有 webhook_enabled 布尔 (当时勾选即飞书+企业微信双推), # 这里把 webhook_enabled=True 但未带 webhook_channels 的老规则迁移为 ['feishu','wecom'], # 还原其当时的实际行为, 用户无感知。 @@ -402,7 +402,9 @@ def normalize(rule: dict) -> dict: r["webhook_channels"] = ["feishu", "wecom"] if r.get("webhook_enabled") else [] else: # 防御性过滤, 只保留合法渠道 - r["webhook_channels"] = [c for c in r["webhook_channels"] if c in ("feishu", "wecom")] + r["webhook_channels"] = [ + c for c in r["webhook_channels"] if c in ("feishu", "wecom", "custom", "email") + ] r.setdefault("created_at", datetime.now(timezone.utc).isoformat()) return r diff --git a/backend/tests/test_notification_adapters.py b/backend/tests/test_notification_adapters.py new file mode 100644 index 0000000..7f08507 --- /dev/null +++ b/backend/tests/test_notification_adapters.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import hashlib +import hmac +import json + +from app.services import email_adapter, webhook_adapter + + +def test_custom_webhook_posts_stable_signed_json(monkeypatch): + captured = {} + + class Response: + status_code = 204 + text = "" + + def fake_post(url, *, content, headers, timeout): + captured.update(url=url, content=content, headers=headers, timeout=timeout) + return Response() + + monkeypatch.setattr("httpx.post", fake_post) + monkeypatch.setattr(webhook_adapter.time, "time", lambda: 1_700_000_000) + + assert webhook_adapter.send_custom( + "https://example.com/tickflow", + "价格预警", + "600000.SH 触发", + "monitor_alert", + {"symbol": "600000.SH"}, + "shared-secret", + ) + payload = json.loads(captured["content"]) + assert payload == { + "event": "monitor_alert", + "timestamp": 1_700_000_000, + "title": "价格预警", + "body": "600000.SH 触发", + "data": {"symbol": "600000.SH"}, + } + expected = hmac.new(b"shared-secret", captured["content"], hashlib.sha256).hexdigest() + assert captured["headers"]["X-TickFlow-Signature"] == f"sha256={expected}" + assert captured["headers"]["X-TickFlow-Timestamp"] == "1700000000" + + +def test_custom_webhook_rejects_non_http_urls(monkeypatch): + monkeypatch.setattr("httpx.post", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError)) + assert not webhook_adapter.send_custom("file:///tmp/hook", "title", "body", "test") + + +def test_email_adapter_uses_starttls_login_and_multiple_recipients(monkeypatch): + instances = [] + + class FakeSmtp: + def __init__(self, host, port, timeout): + self.host = host + self.port = port + self.timeout = timeout + self.calls = [] + instances.append(self) + + def ehlo(self): + self.calls.append(("ehlo",)) + + def starttls(self): + self.calls.append(("starttls",)) + + def login(self, username, password): + self.calls.append(("login", username, password)) + + def send_message(self, message): + self.calls.append(("send", message)) + + def quit(self): + self.calls.append(("quit",)) + + def close(self): + self.calls.append(("close",)) + + monkeypatch.setattr(email_adapter.smtplib, "SMTP", FakeSmtp) + config = { + "host": "smtp.example.com", + "port": 587, + "security": "starttls", + "username": "bot@example.com", + "from_address": "bot@example.com", + "to_addresses": ["one@example.com", "two@example.com"], + } + + assert email_adapter.send_email(config, "smtp-password", "监控告警", "正文") + smtp = instances[0] + assert smtp.calls[:4] == [ + ("ehlo",), + ("starttls",), + ("ehlo",), + ("login", "bot@example.com", "smtp-password"), + ] + message = next(call[1] for call in smtp.calls if call[0] == "send") + assert message["To"] == "one@example.com, two@example.com" + assert message["Subject"] == "监控告警" + assert smtp.calls[-1] == ("quit",) diff --git a/backend/tests/test_notification_settings.py b/backend/tests/test_notification_settings.py new file mode 100644 index 0000000..612c4e7 --- /dev/null +++ b/backend/tests/test_notification_settings.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from app.api.settings import ( + CustomWebhookPrefsIn, + EmailSmtpPrefsIn, + update_custom_webhook, + update_email_smtp, +) + + +def test_custom_webhook_settings_validate_url_and_store_secret(monkeypatch): + saved = {} + monkeypatch.setattr( + "app.services.preferences.set_custom_webhook_url", + lambda url: saved.update(url=url) or url, + ) + monkeypatch.setattr( + "app.secrets_store.set_custom_webhook_secret", + lambda secret: saved.update(secret=secret) or secret, + ) + monkeypatch.setattr( + "app.secrets_store.get_custom_webhook_secret", + lambda: saved.get("secret", ""), + ) + + result = update_custom_webhook(CustomWebhookPrefsIn( + url="https://hooks.example.com/tickflow", + secret="shared-secret", + )) + + assert saved == { + "url": "https://hooks.example.com/tickflow", + "secret": "shared-secret", + } + assert result["custom_webhook_secret_set"] is True + + with pytest.raises(HTTPException, match=r"HTTP\(S\)"): + update_custom_webhook(CustomWebhookPrefsIn(url="file:///tmp/hook")) + + +def test_email_settings_validate_and_store_password_separately(monkeypatch): + saved = {} + monkeypatch.setattr( + "app.services.preferences.set_email_smtp_config", + lambda config: saved.update(config=config) or config, + ) + monkeypatch.setattr( + "app.secrets_store.set_email_smtp_password", + lambda password: saved.update(password=password) or password, + ) + monkeypatch.setattr( + "app.secrets_store.get_email_smtp_password", + lambda: saved.get("password", ""), + ) + + result = update_email_smtp(EmailSmtpPrefsIn( + host="smtp.example.com", + port=587, + security="starttls", + username="bot@example.com", + password="smtp-password", + from_address="bot@example.com", + to_addresses=["alerts@example.com", "alerts@example.com"], + )) + + assert saved["password"] == "smtp-password" + assert saved["config"]["to_addresses"] == ["alerts@example.com"] + assert result["email_smtp_password_set"] is True + + with pytest.raises(HTTPException, match="收件人"): + update_email_smtp(EmailSmtpPrefsIn( + host="smtp.example.com", + from_address="bot@example.com", + to_addresses=["not-an-email"], + )) + + saved.clear() + with pytest.raises(HTTPException, match="密码或授权码"): + update_email_smtp(EmailSmtpPrefsIn( + host="smtp.example.com", + username="bot@example.com", + from_address="bot@example.com", + to_addresses=["alerts@example.com"], + )) diff --git a/backend/tests/test_pipeline_and_monitor_fixes.py b/backend/tests/test_pipeline_and_monitor_fixes.py index 3b3615c..b320d51 100644 --- a/backend/tests/test_pipeline_and_monitor_fixes.py +++ b/backend/tests/test_pipeline_and_monitor_fixes.py @@ -115,6 +115,10 @@ def test_ladder_webhook_uses_chinese_title_without_brand(monkeypatch): monkeypatch.setattr("app.services.preferences.get_feishu_webhook_url", lambda: "https://open.feishu.cn/open-apis/bot/v2/hook/test") monkeypatch.setattr("app.services.preferences.get_feishu_webhook_secret", lambda: "secret") monkeypatch.setattr("app.services.preferences.get_wecom_webhook_url", lambda: "wecom-key") + monkeypatch.setattr("app.services.preferences.get_custom_webhook_url", lambda: "") + monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: {}) + monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "") + monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "") engine = type("Engine", (), { "rules": {"r_ladder": {"webhook_channels": ["feishu", "wecom"]}}, @@ -135,6 +139,45 @@ def test_ladder_webhook_uses_chinese_title_without_brand(monkeypatch): assert all("TickFlow" not in args[1] for _, args in calls) +def test_ladder_dispatches_custom_webhook_and_email(monkeypatch): + calls = [] + + class CaptureExecutor: + def submit(self, fn, *args): + calls.append((fn.__name__, args)) + + email_config = { + "host": "smtp.example.com", + "username": "bot@example.com", + "from_address": "bot@example.com", + "to_addresses": ["alerts@example.com"], + } + monkeypatch.setattr(quote_service, "_WEBHOOK_EXECUTOR", CaptureExecutor()) + monkeypatch.setattr("app.services.preferences.get_feishu_webhook_url", lambda: "") + monkeypatch.setattr("app.services.preferences.get_feishu_webhook_secret", lambda: "") + monkeypatch.setattr("app.services.preferences.get_wecom_webhook_url", lambda: "") + monkeypatch.setattr("app.services.preferences.get_custom_webhook_url", lambda: "https://example.com/hook") + monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: email_config) + monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "hook-secret") + monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "smtp-password") + + engine = type("Engine", (), { + "rules": {"r_ladder": {"webhook_channels": ["custom", "email"]}}, + })() + event = { + "rule_id": "r_ladder", + "source": "ladder", + "symbol": "600000.SH", + "name": "浦发银行", + "message": "炸板预警", + } + QuoteService._maybe_send_webhook(object.__new__(QuoteService), [event], engine) + + assert [name for name, _ in calls] == ["send_custom", "send_email"] + assert calls[0][1][3:] == ("monitor_alert", event, "hook-secret") + assert calls[1][1][:2] == (email_config, "smtp-password") + + def test_review_webhooks_use_title_without_brand(monkeypatch): calls = [] monkeypatch.setattr("app.services.preferences.get_review_push_channels", lambda: ["feishu", "wecom"]) @@ -154,3 +197,32 @@ def test_review_webhooks_use_title_without_brand(monkeypatch): assert [args[1] for _, args in calls] == ["每日复盘", "每日复盘"] assert all("TickFlow" not in args[1] for _, args in calls) + + +def test_review_pushes_custom_webhook_and_email(monkeypatch): + calls = [] + email_config = { + "host": "smtp.example.com", + "username": "bot@example.com", + "from_address": "bot@example.com", + "to_addresses": ["alerts@example.com"], + } + monkeypatch.setattr("app.services.preferences.get_review_push_channels", lambda: ["custom", "email"]) + monkeypatch.setattr("app.services.preferences.get_custom_webhook_url", lambda: "https://example.com/hook") + monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: email_config) + monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "hook-secret") + monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "smtp-password") + monkeypatch.setattr( + "app.services.webhook_adapter.send_custom", + lambda *args: calls.append(("custom", args)) or True, + ) + monkeypatch.setattr( + "app.services.email_adapter.send_email", + lambda *args: calls.append(("email", args)) or True, + ) + + daily_pipeline._maybe_push_review("复盘正文", {"as_of": "2026-07-18"}) + + assert [channel for channel, _ in calls] == ["custom", "email"] + assert calls[0][1][3] == "market_review" + assert calls[1][1][2] == "每日复盘" diff --git a/backend/tests/test_preferences_cache.py b/backend/tests/test_preferences_cache.py index 634a42d..f6c31b2 100644 --- a/backend/tests/test_preferences_cache.py +++ b/backend/tests/test_preferences_cache.py @@ -121,3 +121,32 @@ def test_mining_schedule_setter_rejects_invalid_weekday(weekday): def test_mining_schedule_setter_rejects_invalid_profile(): with pytest.raises(ValueError, match="profile"): preferences.set_mining_schedule(True, 4, "exploratory") + + +def test_external_push_channel_whitelists_include_custom_and_email(_isolated): + assert preferences.set_webhook_default_channels([ + "custom", "email", "custom", "unsupported", + ]) == ["custom", "email"] + assert preferences.set_review_push_channels([ + "email", "wecom", "unsupported", + ]) == ["email", "wecom"] + + +def test_email_smtp_config_falls_back_from_malformed_stored_values(_isolated): + preferences.save({ + "email_smtp_config": { + "host": " smtp.example.com ", + "port": "invalid", + "security": "invalid", + "to_addresses": "alerts@example.com", + }, + }) + + assert preferences.get_email_smtp_config() == { + "host": "smtp.example.com", + "port": 465, + "security": "ssl", + "username": "", + "from_address": "", + "to_addresses": [], + } diff --git a/backend/tests/test_strategy_monitor_events.py b/backend/tests/test_strategy_monitor_events.py index 4c917d2..53443b6 100644 --- a/backend/tests/test_strategy_monitor_events.py +++ b/backend/tests/test_strategy_monitor_events.py @@ -109,6 +109,12 @@ def test_strategy_rule_compatibility_and_validation(tmp_path): monitor_rules.validate(_rule("pool_entry", score_min=90, score_max=70)) monitor_rules.validate(_rule("buy_signal", "pool_exit")) + normalized = monitor_rules.normalize(_rule( + "pool_entry", + webhook_channels=["feishu", "custom", "email", "unsupported"], + )) + assert normalized["webhook_channels"] == ["feishu", "custom", "email"] + def test_strategy_score_range_filters_pool_and_buy_signals_but_not_sell_signals(): day = date(2026, 7, 24) diff --git a/backend/tests/test_webhook_test.py b/backend/tests/test_webhook_test.py index 2b4d4e9..ffa5f93 100644 --- a/backend/tests/test_webhook_test.py +++ b/backend/tests/test_webhook_test.py @@ -89,6 +89,61 @@ def test_wecom_sends_saved_url(monkeypatch): assert "推送配置正确" in calls["body"] +def test_custom_webhook_sends_saved_url_and_secret(monkeypatch): + calls = {} + monkeypatch.setattr( + "app.services.preferences.get_custom_webhook_url", + lambda: "https://example.com/tickflow", + ) + monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "secret") + monkeypatch.setattr( + "app.services.webhook_adapter.send_custom", + lambda url, title, body, event_type, data=None, secret="", max_attempts=3: + calls.update( + url=url, + event_type=event_type, + secret=secret, + max_attempts=max_attempts, + ) or True, + ) + + result = run_webhook_test(WebhookTestIn(channel="custom")) + + assert result["ok"] is True + assert calls == { + "url": "https://example.com/tickflow", + "event_type": "test", + "secret": "secret", + "max_attempts": 1, + } + + +def test_email_sends_with_saved_smtp_config(monkeypatch): + calls = {} + config = { + "host": "smtp.example.com", + "port": 465, + "security": "ssl", + "username": "bot@example.com", + "from_address": "bot@example.com", + "to_addresses": ["alerts@example.com"], + } + monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: config) + monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "password") + monkeypatch.setattr( + "app.services.email_adapter.send_email", + lambda cfg, password, subject, body, max_attempts=2: + calls.update(config=cfg, password=password, subject=subject, max_attempts=max_attempts) or True, + ) + + result = run_webhook_test(WebhookTestIn(channel="email")) + + assert result["ok"] is True + assert calls["config"] == config + assert calls["password"] == "password" + assert calls["max_attempts"] == 1 + + def test_unknown_channel_rejected_by_pydantic(): with pytest.raises(ValidationError): - WebhookTestIn(channel="wecom-bot") + WebhookTestIn(channel="sms") diff --git a/docs/features.md b/docs/features.md index d417550..68d6ff1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -86,9 +86,9 @@ - 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标 - **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发 -### 飞书 Webhook 推送 +### 外部推送渠道 -全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验)。可在设置页设「默认推送渠道」,新建规则自动预填。 +全局配置飞书群 Webhook、企业微信群 Webhook、通用第三方 JSON Webhook 或 SMTP 邮件。每条监控规则可独立多选渠道,每日复盘也共用同一套渠道配置。第三方 Webhook 使用稳定 JSON 信封,并支持可选 HMAC-SHA256 签名;邮件支持 SSL、STARTTLS 和无加密 SMTP。设置页可选择新建规则的默认渠道并对各渠道发送测试消息。 --- diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 4bbb12e..9a3e609 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -86,6 +86,13 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { const quoteInterval = quoteStatus?.interval_s const feishuConfigured = !!(prefs?.feishu_webhook_url) const wecomConfigured = !!(prefs?.wecom_webhook_url) + const customConfigured = !!(prefs?.custom_webhook_url) + const emailConfigured = !!( + prefs?.email_smtp_config?.host + && prefs.email_smtp_config.from_address + && prefs.email_smtp_config.to_addresses.length + && (!prefs.email_smtp_config.username || prefs.email_smtp_password_set) + ) const [editing] = useState(!!rule) // 新建规则: 预填全局「默认推送渠道」(多选数组), preset 显式指定时以 preset 为准。 // 编辑规则: 完全沿用规则自身配置, 不受默认值影响。 @@ -369,7 +376,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { }) } - // 勾选/取消勾选某个推送渠道 (飞书 / 企业微信 各自独立) + // 勾选/取消勾选某个外部推送渠道。 const toggleChannel = (ch: string) => setDraft(d => { const cur = d.webhook_channels ?? [] @@ -1495,10 +1502,10 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { - {/* Webhook 推送 — 飞书 / 企业微信 */} + {/* 外部推送 */}
- Webhook 推送 + 外部推送 触发时推送告警到外部
@@ -1521,6 +1528,38 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { )} + + + + {/* 企业微信 (可用) */}
- {/* 推送渠道: 胶囊标签 (飞书 / 企业微信 各自独立勾选), 选中带强调色 */} -
+ {/* 推送渠道: 多选胶囊标签 */} +
推送 - {([ - { key: 'feishu', label: '飞书' }, - { key: 'wecom', label: '企业微信' }, - ] as const).map(ch => { - const on = pushChannels.includes(ch.key) - return ( - - ) - })} +
+ {([ + { key: 'feishu', label: '飞书' }, + { key: 'wecom', label: '企微' }, + { key: 'custom', label: '第三方' }, + { key: 'email', label: '邮件' }, + ] as const).map(ch => { + const on = pushChannels.includes(ch.key) + return ( + + ) + })} +
{/* 权限提示 (免费用户) */} diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx index 894f179..518b80c 100644 --- a/frontend/src/pages/Review.tsx +++ b/frontend/src/pages/Review.tsx @@ -108,8 +108,15 @@ export function Review() { const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 } const feishuConfigured = !!(prefs.data?.feishu_webhook_url) const wecomConfigured = !!(prefs.data?.wecom_webhook_url) + const customConfigured = !!(prefs.data?.custom_webhook_url) + const emailConfigured = !!( + prefs.data?.email_smtp_config?.host + && prefs.data.email_smtp_config.from_address + && prefs.data.email_smtp_config.to_addresses.length + && (!prefs.data.email_smtp_config.username || prefs.data.email_smtp_password_set) + ) // 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置 - // []=不推送, ['feishu']=飞书, ['wecom']=企业微信 + // []=不推送; 可多选飞书、企微、第三方 Webhook 和邮件。 const reviewPushChannels = prefs.data?.review_push_channels ?? [] // 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗) const [draft, setDraft] = useState(reviewSched) @@ -475,10 +482,55 @@ export function Review() { {wecomConfigured ? '已配置' : '未配置'} + +

- 手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的 Webhook 配置。 - {((reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)) && ( + 手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的渠道配置。 + {( + (reviewPushChannels.includes('feishu') && !feishuConfigured) + || (reviewPushChannels.includes('wecom') && !wecomConfigured) + || (reviewPushChannels.includes('custom') && !customConfigured) + || (reviewPushChannels.includes('email') && !emailConfigured) + ) && ( setShowSchedule(false)}> 前往配置 → diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index dd0afc9..d564be0 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -15,7 +15,7 @@ import { useCapabilities, } from '@/lib/useSharedQueries' import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations' -import { api } from '@/lib/api' +import { api, type EmailSmtpConfig } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { useCardFlash, cardFlashCls } from '@/lib/useCardFlash' import { toast } from '@/components/Toast' @@ -32,6 +32,15 @@ const PAGE_LABELS: Record = { 'limit-ladder': '连板梯队', } +const EMPTY_EMAIL_SMTP: EmailSmtpConfig = { + host: '', + port: 465, + security: 'ssl', + username: '', + from_address: '', + to_addresses: [], +} + // ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) ===== export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) { @@ -83,6 +92,24 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const wecomWebhookUrl = prefs?.wecom_webhook_url ?? '' const [wecomDraft, setWecomDraft] = useState(wecomWebhookUrl) const [wecomError, setWecomError] = useState('') + // 通用第三方 JSON webhook + const customWebhookUrl = prefs?.custom_webhook_url ?? '' + const customWebhookSecretSet = prefs?.custom_webhook_secret_set ?? false + const [customDraft, setCustomDraft] = useState(customWebhookUrl) + const [customSecretDraft, setCustomSecretDraft] = useState('') + const [customError, setCustomError] = useState('') + // SMTP 邮件通道 + const emailSmtpConfig = prefs?.email_smtp_config ?? EMPTY_EMAIL_SMTP + const emailSmtpPasswordSet = prefs?.email_smtp_password_set ?? false + const emailConfigured = !!( + emailSmtpConfig.host + && emailSmtpConfig.from_address + && emailSmtpConfig.to_addresses.length + && (!emailSmtpConfig.username || emailSmtpPasswordSet) + ) + const [emailDraft, setEmailDraft] = useState(emailSmtpConfig) + const [emailPasswordDraft, setEmailPasswordDraft] = useState('') + const [emailError, setEmailError] = useState('') // 企业微信智能机器人 (BotID + Secret, 长连接通道) const wecomBotId = prefs?.wecom_bot_id ?? '' const wecomBotSecret = prefs?.wecom_bot_secret ?? '' @@ -95,6 +122,8 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const [channelOpen, setChannelOpen] = useState(false) // 企业微信渠道配置区展开态 const [wecomOpen, setWecomOpen] = useState(false) + const [customOpen, setCustomOpen] = useState(false) + const [emailOpen, setEmailOpen] = useState(false) // 智能机器人配置区展开态 const [botOpen, setBotOpen] = useState(false) useEffect(() => { @@ -104,6 +133,14 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = useEffect(() => { setWecomDraft(wecomWebhookUrl) }, [wecomWebhookUrl]) + useEffect(() => { + setCustomDraft(customWebhookUrl) + setCustomSecretDraft('') + }, [customWebhookUrl, customWebhookSecretSet]) + useEffect(() => { + setEmailDraft(emailSmtpConfig) + setEmailPasswordDraft('') + }, [emailSmtpConfig, emailSmtpPasswordSet]) useEffect(() => { setBotIdDraft(wecomBotId) setBotSecretDraft(wecomBotSecret) @@ -129,7 +166,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = qc.invalidateQueries({ queryKey: QK.preferences }) }, [qc]) - // 勾选/取消勾选某个默认推送渠道 (飞书 / 企业微信 各自独立) + // 勾选/取消勾选某个默认推送渠道。 const toggleDefaultChannel = useCallback(async (ch: string, enabled: boolean) => { const cur = prefs?.webhook_default_channels ?? [] const next = enabled ? [...cur, ch] : cur.filter(c => c !== ch) @@ -184,6 +221,58 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = mutationFn: () => api.sendTestWebhook('wecom'), }) + const saveCustomWebhook = useMutation({ + mutationFn: ({ url, secret }: { url: string; secret?: string }) => api.updateCustomWebhook(url, secret), + onSuccess: () => { + setCustomError('') + setCustomSecretDraft('') + toast('第三方 Webhook 已保存', 'success') + qc.invalidateQueries({ queryKey: QK.preferences }) + }, + onError: (err: any) => setCustomError(String(err?.message ?? '保存失败')), + }) + const submitCustom = useCallback(() => { + const url = customDraft.trim() + if (url && !/^https?:\/\//i.test(url)) { + setCustomError('请输入完整的 HTTP(S) URL') + return + } + saveCustomWebhook.mutate({ + url, + ...(customSecretDraft ? { secret: customSecretDraft } : {}), + }) + }, [customDraft, customSecretDraft, saveCustomWebhook]) + const testCustom = useMutation({ + mutationFn: () => api.sendTestWebhook('custom'), + }) + + const saveEmailSmtp = useMutation({ + mutationFn: ({ config, password }: { config: EmailSmtpConfig; password?: string }) => + api.updateEmailSmtp(config, password), + onSuccess: () => { + setEmailError('') + setEmailPasswordDraft('') + toast('邮件推送配置已保存', 'success') + qc.invalidateQueries({ queryKey: QK.preferences }) + }, + onError: (err: any) => setEmailError(String(err?.message ?? '保存失败')), + }) + const submitEmail = useCallback(() => { + saveEmailSmtp.mutate({ + config: { + ...emailDraft, + host: emailDraft.host.trim(), + username: emailDraft.username.trim(), + from_address: emailDraft.from_address.trim(), + to_addresses: emailDraft.to_addresses.map(item => item.trim()).filter(Boolean), + }, + ...(emailPasswordDraft ? { password: emailPasswordDraft } : {}), + }) + }, [emailDraft, emailPasswordDraft, saveEmailSmtp]) + const testEmail = useMutation({ + mutationFn: () => api.sendTestWebhook('email'), + }) + // 智能机器人 (BotID + Secret) 保存 → 后端立即重建连接 const saveWecomBot = useMutation({ mutationFn: ({ botId, secret }: { botId: string; secret: string }) => @@ -468,7 +557,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。 - 飞书 / 企业微信。 + 飞书 / 企业微信 / 第三方 Webhook / 邮件。 每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}

@@ -642,6 +731,150 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = )} + {/* 通用第三方 JSON Webhook */} +

+
setCustomOpen(o => !o)} + className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" + > + { e.stopPropagation(); toggleDefaultChannel('custom', e.target.checked) }} + onClick={e => e.stopPropagation()} + title="作为新建规则的默认推送渠道" + className="h-3 w-3 accent-accent cursor-pointer" + /> + 第三方系统 + JSON Webhook + {webhookDefaultChannels.includes('custom') && ( + 默认 + )} + + {customWebhookUrl ? '已配置' : '未配置'} + + +
+ + {customOpen && ( +
+ + + {customError &&
{customError}
} +
+ + + +
+
+ 请求格式 +

+ POST JSON 包含 event、timestamp、title、body、data。配置密钥后会附带 + X-TickFlow-Timestamp 和 X-TickFlow-Signature 请求头。 +

+
+
+ )} +
+ + {/* SMTP 邮件推送 */} +
+
setEmailOpen(o => !o)} + className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" + > + { e.stopPropagation(); toggleDefaultChannel('email', e.target.checked) }} + onClick={e => e.stopPropagation()} + title="作为新建规则的默认推送渠道" + className="h-3 w-3 accent-accent cursor-pointer" + /> + 邮件 + SMTP + {webhookDefaultChannels.includes('email') && ( + 默认 + )} + + {emailConfigured ? '已配置' : '未配置'} + + +
+ + {emailOpen && ( +
+
+ + + +
+
+ + + + +
+ {emailError &&
{emailError}
} +
+ + + +
+
+ )} +
+ {/* 企业微信智能机器人 (BotID + Secret): 长连接通道, 与群推送 Webhook 并列 */}
test.mutate()} disabled={test.isPending || !configured} - title={!configured ? '请先保存 Webhook 地址' : '向已保存的地址发送测试消息'} + title={!configured ? '请先完成该渠道配置' : '发送测试消息'} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:text-foreground text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors" > {test.isPending ? '测试中…' : '测试'}