mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
Merge pull request #244 from yushenglin01/feat/notification-channels
feat(notifications): add custom webhook and email channels
This commit is contained in:
@@ -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"
|
||||
|
||||
+116
-5
@@ -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")
|
||||
|
||||
@@ -1026,11 +1026,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:
|
||||
@@ -1062,6 +1063,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)
|
||||
|
||||
@@ -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 优先,否则环境变量。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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})
|
||||
|
||||
@@ -1564,7 +1564,7 @@ class QuoteService:
|
||||
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
|
||||
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_channels 指定渠道)。
|
||||
|
||||
- 飞书 / 企业微信任一已配置即生效 (两个都没配才跳过)
|
||||
- 飞书 / 企业微信 / 第三方 Webhook / 邮件均按规则独立选择
|
||||
- 仅推送 webhook_channels 非空的规则触发的告警, 且只投递被勾选的渠道
|
||||
- 失败静默, 不阻断主流程
|
||||
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
|
||||
@@ -1573,14 +1573,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
|
||||
|
||||
# 反查规则, 过滤出启用推送的事件
|
||||
@@ -1588,7 +1591,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:
|
||||
@@ -1603,7 +1606,7 @@ class QuoteService:
|
||||
# 补上触发时的现价/涨跌幅, 让推送可执行 (止损到底触发在哪个价位)
|
||||
body = _body_with_quote(body, ev)
|
||||
# 提交到独立线程池, 不阻塞行情轮询线程 (webhook 慢/重试不拖累实时行情+告警)。
|
||||
# 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。
|
||||
# 按渠道独立投递: 只投递同时“已勾选 + 已配置”的渠道。
|
||||
# 应用内 alerts.jsonl 记录与 SSE 已在前面完成, 不依赖 webhook 成败,
|
||||
# 失败由 webhook_adapter 记 WARNING(可见)。
|
||||
if feishu_url and "feishu" in channels:
|
||||
@@ -1612,6 +1615,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
|
||||
|
||||
@@ -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=<hex>`` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",)
|
||||
@@ -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"],
|
||||
))
|
||||
@@ -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] == "每日复盘"
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-2
@@ -109,9 +109,9 @@
|
||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
||||
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
||||
|
||||
### 飞书 Webhook 推送
|
||||
### 外部推送渠道
|
||||
|
||||
全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验)。可在设置页设「默认推送渠道」,新建规则自动预填。
|
||||
全局配置飞书群 Webhook、企业微信群 Webhook、通用第三方 JSON Webhook 或 SMTP 邮件。每条监控规则可独立多选渠道,每日复盘也共用同一套渠道配置。第三方 Webhook 使用稳定 JSON 信封,并支持可选 HMAC-SHA256 签名;邮件支持 SSL、STARTTLS 和无加密 SMTP。设置页可选择新建规则的默认渠道并对各渠道发送测试消息。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Webhook 推送 — 飞书 / 企业微信 */}
|
||||
{/* 外部推送 */}
|
||||
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-foreground">Webhook 推送</span>
|
||||
<span className="text-[11px] font-medium text-foreground">外部推送</span>
|
||||
<span className="text-[9px] text-muted">触发时推送告警到外部</span>
|
||||
</div>
|
||||
|
||||
@@ -1521,6 +1528,38 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(draft.webhook_channels ?? []).includes('custom')}
|
||||
onChange={() => toggleChannel('custom')}
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] text-foreground">第三方系统</span>
|
||||
<span className="text-[9px] text-muted">JSON Webhook</span>
|
||||
{(draft.webhook_channels ?? []).includes('custom') && (
|
||||
<span className={`ml-auto text-[9px] ${customConfigured ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{customConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(draft.webhook_channels ?? []).includes('email')}
|
||||
onChange={() => toggleChannel('email')}
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] text-foreground">邮件</span>
|
||||
<span className="text-[9px] text-muted">SMTP</span>
|
||||
{(draft.webhook_channels ?? []).includes('email') && (
|
||||
<span className={`ml-auto text-[9px] ${emailConfigured ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{emailConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* 企业微信 (可用) */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -1546,6 +1585,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
const unconfigured: string[] = []
|
||||
if (selected.includes('feishu') && !feishuConfigured) unconfigured.push('飞书')
|
||||
if (selected.includes('wecom') && !wecomConfigured) unconfigured.push('企业微信')
|
||||
if (selected.includes('custom') && !customConfigured) unconfigured.push('第三方系统')
|
||||
if (selected.includes('email') && !emailConfigured) unconfigured.push('邮件')
|
||||
if (unconfigured.length === 0) return null
|
||||
return (
|
||||
<p className="text-[10px] leading-relaxed text-warning/80">
|
||||
@@ -1559,6 +1600,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
const ready: string[] = []
|
||||
if (selected.includes('feishu') && feishuConfigured) ready.push('飞书')
|
||||
if (selected.includes('wecom') && wecomConfigured) ready.push('企业微信')
|
||||
if (selected.includes('custom') && customConfigured) ready.push('第三方系统')
|
||||
if (selected.includes('email') && emailConfigured) ready.push('邮件')
|
||||
if (ready.length === 0) return null
|
||||
return (
|
||||
<p className="text-[10px] leading-relaxed text-muted">
|
||||
|
||||
@@ -106,6 +106,8 @@ export function PriceAlertDialog({
|
||||
const configured = new Set<string>()
|
||||
if (prefs.feishu_webhook_url) configured.add('feishu')
|
||||
if (prefs.wecom_webhook_url) configured.add('wecom')
|
||||
if (prefs.custom_webhook_url) configured.add('custom')
|
||||
if (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)) configured.add('email')
|
||||
setChannels((prefs.webhook_default_channels ?? []).filter(channel => configured.has(channel)))
|
||||
}, [prefs])
|
||||
|
||||
@@ -330,6 +332,8 @@ export function PriceAlertDialog({
|
||||
{([
|
||||
{ key: 'feishu', label: '飞书', configured: !!prefs?.feishu_webhook_url },
|
||||
{ key: 'wecom', label: '企业微信', configured: !!prefs?.wecom_webhook_url },
|
||||
{ key: 'custom', label: '第三方系统', configured: !!prefs?.custom_webhook_url },
|
||||
{ key: 'email', label: '邮件', configured: !!(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)) },
|
||||
]).map(channel => (
|
||||
<label key={channel.key} className={`inline-flex items-center gap-2 text-xs ${channel.configured ? 'text-foreground' : 'text-muted/60'}`}>
|
||||
<input type="checkbox" checked={channels.includes(channel.key)} disabled={!channel.configured} onChange={() => toggleChannel(channel.key)} className="h-3.5 w-3.5 accent-sky-500" />
|
||||
|
||||
+50
-3
@@ -12,6 +12,16 @@ type RequestOptions = RequestInit & {
|
||||
quiet?: boolean
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestOptions): Promise<T> {
|
||||
const { quiet, ...fetchInit } = init ?? {}
|
||||
const isFormData = fetchInit.body instanceof FormData
|
||||
@@ -37,7 +47,7 @@ async function request<T>(path: string, init?: RequestOptions): Promise<T> {
|
||||
const msg = detail || `${res.status} ${res.statusText}`
|
||||
// 401 (未登录/会话过期) 不弹 toast — 由全局认证拦截器统一跳登录页, 避免刷屏
|
||||
if (res.status !== 401 && !quiet) toast(msg, 'error')
|
||||
throw new Error(msg)
|
||||
throw new ApiError(msg, res.status)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
@@ -167,6 +177,18 @@ export interface LevelSeries {
|
||||
atr?: { stop_loss: (number | null)[]; take_profit: (number | null)[] }
|
||||
}
|
||||
|
||||
export interface MainCostEstimate {
|
||||
estimated_main_cost: number | null
|
||||
main_cost_peak: number | null
|
||||
main_core_cost_low: number | null
|
||||
main_core_cost_high: number | null
|
||||
/** 小数制:0.10 表示估算浮盈 10% */
|
||||
main_profit_rate: number | null
|
||||
main_net_amount: number
|
||||
/** 0~1,表示输入数据完整度,不是结果真实准确率 */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface StockLevels {
|
||||
levels: Record<LevelType, PriceLevel[]>
|
||||
close: number | null
|
||||
@@ -175,6 +197,8 @@ export interface StockLevels {
|
||||
/** dates 与 series 对齐;前端按自身 rows 的日期映射,缺失填 null */
|
||||
dates?: string[]
|
||||
series?: LevelSeries
|
||||
/** 疑似主力成本统计估算;旧后端响应可能不存在该字段 */
|
||||
main_cost?: MainCostEstimate
|
||||
}
|
||||
|
||||
export interface AiStockReport {
|
||||
@@ -908,7 +932,7 @@ export interface MonitorRule {
|
||||
message: string
|
||||
webhook_url?: string
|
||||
webhook_enabled?: boolean // 兼容老规则, 已由 webhook_channels 取代
|
||||
webhook_channels?: string[] // 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')
|
||||
webhook_channels?: string[] // 合法值: feishu | wecom | custom | email
|
||||
created_at?: string
|
||||
runtime_warning?: string
|
||||
// ladder 专属: 封单监控; volume_delta 复用 metric 表示阈值口径 (volume=手数, amount=金额)
|
||||
@@ -1766,6 +1790,10 @@ export interface Preferences {
|
||||
feishu_webhook_url?: string
|
||||
feishu_webhook_secret?: string
|
||||
wecom_webhook_url?: string
|
||||
custom_webhook_url?: string
|
||||
custom_webhook_secret_set?: boolean
|
||||
email_smtp_config?: EmailSmtpConfig
|
||||
email_smtp_password_set?: boolean
|
||||
wecom_bot_id?: string
|
||||
wecom_bot_secret?: string
|
||||
wecom_bot_enabled?: boolean
|
||||
@@ -1779,6 +1807,15 @@ export interface Preferences {
|
||||
monitor_ext_fields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
|
||||
}
|
||||
|
||||
export interface EmailSmtpConfig {
|
||||
host: string
|
||||
port: number
|
||||
security: 'ssl' | 'starttls' | 'none'
|
||||
username: string
|
||||
from_address: string
|
||||
to_addresses: string[]
|
||||
}
|
||||
|
||||
/** 监控中心 ext 字段单项配置 (行业/概念标签的来源 + 显示裁剪) */
|
||||
export interface MonitorExtFieldItem {
|
||||
/** "configId.fieldName" */
|
||||
@@ -2075,7 +2112,17 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
sendTestWebhook: (channel: 'feishu' | 'wecom') =>
|
||||
updateCustomWebhook: (url: string, secret?: string) =>
|
||||
request<{ custom_webhook_url: string; custom_webhook_secret_set: boolean }>('/api/settings/preferences/custom-webhook', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url, ...(secret !== undefined ? { secret } : {}) }),
|
||||
}),
|
||||
updateEmailSmtp: (config: EmailSmtpConfig, password?: string) =>
|
||||
request<{ email_smtp_config: EmailSmtpConfig; email_smtp_password_set: boolean }>('/api/settings/preferences/email-smtp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...config, ...(password !== undefined ? { password } : {}) }),
|
||||
}),
|
||||
sendTestWebhook: (channel: 'feishu' | 'wecom' | 'custom' | 'email') =>
|
||||
request<{ ok: boolean; detail: string }>('/api/settings/preferences/webhook-test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel }),
|
||||
|
||||
@@ -28,7 +28,7 @@ import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
* 偏移异动计算量可控: 主开关默认关闭, 开启后才发起轮询 (每 60s 一次); 关闭后
|
||||
* 保留展示上次计算结果 (含计算时间, 取自 localStorage)。规则口径通过工具栏「?」
|
||||
* 展开查看。告警走系统监控体系: 在「监控中心」创建异动监控规则后由后端持续评估,
|
||||
* 统一触发记录/站内通知/飞书·企微推送。
|
||||
* 统一触发记录/站内通知/外部渠道推送。
|
||||
*/
|
||||
|
||||
const WINDOW_KEYS = ['3d', '10d', '30d'] as const
|
||||
@@ -86,7 +86,7 @@ export function AbnormalMoves() {
|
||||
<Link
|
||||
to="/monitor"
|
||||
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
|
||||
title="在监控中心创建「异动监控」规则: 后台持续评估, 触发时统一走触发记录/站内通知/飞书·企微推送, 无需保持本页打开"
|
||||
title="在监控中心创建「异动监控」规则: 后台持续评估, 触发时统一走触发记录/站内通知/外部渠道推送, 无需保持本页打开"
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
告警规则
|
||||
@@ -683,7 +683,7 @@ function DeviationView({ onPreview }: {
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-lg text-[11px] leading-relaxed text-muted/80">
|
||||
需要告警推送时, 在<Link to="/monitor?new=abnormal" className="text-accent hover:underline">监控中心</Link>
|
||||
新建「异动监控」规则 —— 后台持续评估, 触发时统一走触发记录 / 站内通知 / 飞书·企微推送,
|
||||
新建「异动监控」规则 —— 后台持续评估, 触发时统一走触发记录 / 站内通知 / 外部渠道推送,
|
||||
与本页开关互不影响。
|
||||
</p>
|
||||
<button
|
||||
|
||||
@@ -109,7 +109,7 @@ function getExtTags(stock: LimitLadderStock, item?: ExtFieldItem): string[] {
|
||||
const sep = cfg?.separator?.trim() || null
|
||||
const tags = sep
|
||||
? str.split(sep).map(s => s.trim()).filter(Boolean)
|
||||
: str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean)
|
||||
: str.split(/[、,,;;-]/).map(s => s.trim()).filter(Boolean)
|
||||
|
||||
const maxTags = cfg?.maxTags ?? 0
|
||||
const sliced = maxTags > 0 ? tags.slice(0, maxTags) : tags
|
||||
@@ -501,7 +501,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
|
||||
|
||||
// 基于齿轮按钮位置算菜单坐标 (fixed 定位, 脱离父级 overflow-hidden 裁剪)
|
||||
const MENU_W = 240 // w-60 = 15rem = 240px
|
||||
const MENU_H = 340 // 预估高度 (含标题栏 + 4 行设置 + 权限提示 + 按钮区)
|
||||
const MENU_H = 370 // 预估高度 (推送渠道可换行)
|
||||
const anchorRight = anchorRect.right
|
||||
const anchorBottom = anchorRect.bottom
|
||||
// 水平: 默认右对齐齿轮; 超出右边则左移
|
||||
@@ -572,30 +572,34 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 推送渠道: 胶囊标签 (飞书 / 企业微信 各自独立勾选), 选中带强调色 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 推送渠道: 多选胶囊标签 */}
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[10px] text-muted shrink-0 w-8">推送</span>
|
||||
{([
|
||||
{ key: 'feishu', label: '飞书' },
|
||||
{ key: 'wecom', label: '企业微信' },
|
||||
] as const).map(ch => {
|
||||
const on = pushChannels.includes(ch.key)
|
||||
return (
|
||||
<button
|
||||
key={ch.key}
|
||||
type="button"
|
||||
onClick={() => togglePushChannel(ch.key)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-medium transition-colors border cursor-pointer ${
|
||||
on
|
||||
? 'bg-accent/15 text-accent border-accent/40'
|
||||
: 'bg-elevated/40 text-muted border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${on ? 'bg-accent' : 'bg-muted/50'}`} />
|
||||
{ch.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ key: 'feishu', label: '飞书' },
|
||||
{ key: 'wecom', label: '企微' },
|
||||
{ key: 'custom', label: '第三方' },
|
||||
{ key: 'email', label: '邮件' },
|
||||
] as const).map(ch => {
|
||||
const on = pushChannels.includes(ch.key)
|
||||
return (
|
||||
<button
|
||||
key={ch.key}
|
||||
type="button"
|
||||
onClick={() => togglePushChannel(ch.key)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-medium transition-colors border cursor-pointer ${
|
||||
on
|
||||
? 'bg-accent/15 text-accent border-accent/40'
|
||||
: 'bg-elevated/40 text-muted border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${on ? 'bg-accent' : 'bg-muted/50'}`} />
|
||||
{ch.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 权限提示 (免费用户) */}
|
||||
|
||||
@@ -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 ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pushMut.isPending}
|
||||
onClick={() => togglePushChannel('custom')}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
|
||||
reviewPushChannels.includes('custom')
|
||||
? 'border-accent/40 bg-accent/10'
|
||||
: 'border-border/60 bg-base/40 hover:bg-base/60',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('custom') ? 'border-accent bg-accent text-white' : 'border-border')}>
|
||||
{reviewPushChannels.includes('custom') && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground">第三方系统</span>
|
||||
<span className="text-[9px] text-muted">JSON Webhook</span>
|
||||
<span className={cn('ml-auto text-[9px]', customConfigured ? 'text-emerald-500' : 'text-warning')}>
|
||||
{customConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pushMut.isPending}
|
||||
onClick={() => togglePushChannel('email')}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
|
||||
reviewPushChannels.includes('email')
|
||||
? 'border-accent/40 bg-accent/10'
|
||||
: 'border-border/60 bg-base/40 hover:bg-base/60',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('email') ? 'border-accent bg-accent text-white' : 'border-border')}>
|
||||
{reviewPushChannels.includes('email') && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground">邮件</span>
|
||||
<span className="text-[9px] text-muted">SMTP</span>
|
||||
<span className={cn('ml-auto text-[9px]', emailConfigured ? 'text-emerald-500' : 'text-warning')}>
|
||||
{emailConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
|
||||
手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的 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)
|
||||
) && (
|
||||
<Link to="/settings?tab=monitoring&highlight=webhooks" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
|
||||
前往配置 →
|
||||
</Link>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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>(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 } =
|
||||
</Card>
|
||||
|
||||
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
|
||||
飞书 / 企业微信。
|
||||
飞书 / 企业微信 / 第三方 Webhook / 邮件。
|
||||
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
|
||||
<Card icon={Webhook} title="推送通知" anchor="webhooks">
|
||||
<p className="text-xs text-secondary mb-3">
|
||||
@@ -642,6 +731,150 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 通用第三方 JSON Webhook */}
|
||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
||||
<div
|
||||
onClick={() => setCustomOpen(o => !o)}
|
||||
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webhookDefaultChannels.includes('custom')}
|
||||
onChange={e => { e.stopPropagation(); toggleDefaultChannel('custom', e.target.checked) }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title="作为新建规则的默认推送渠道"
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground">第三方系统</span>
|
||||
<span className="text-[9px] text-muted">JSON Webhook</span>
|
||||
{webhookDefaultChannels.includes('custom') && (
|
||||
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
||||
)}
|
||||
<span className={`ml-auto text-[9px] ${customWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{customWebhookUrl ? '已配置' : '未配置'}
|
||||
</span>
|
||||
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${customOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{customOpen && (
|
||||
<div className="border-t border-border/60 bg-base/30 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-muted">接收端 URL</span>
|
||||
<input
|
||||
value={customDraft}
|
||||
onChange={e => { setCustomDraft(e.target.value); if (!testCustom.isPending) testCustom.reset() }}
|
||||
placeholder="https://example.com/webhooks/tickflow"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
<label className="block mt-2 space-y-1.5">
|
||||
<span className="text-[11px] text-muted">签名密钥 (可选 · HMAC-SHA256)</span>
|
||||
<input
|
||||
type="password"
|
||||
value={customSecretDraft}
|
||||
onChange={e => setCustomSecretDraft(e.target.value)}
|
||||
placeholder={customWebhookSecretSet ? '已保存;留空保持不变' : '留空则不签名'}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
{customError && <div className="mt-2 text-[11px] text-danger">{customError}</div>}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={submitCustom}
|
||||
disabled={saveCustomWebhook.isPending || (customDraft.trim() === customWebhookUrl && !customSecretDraft)}
|
||||
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{saveCustomWebhook.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<TestSendButton test={testCustom} configured={!!customWebhookUrl} />
|
||||
<TestResult test={testCustom} />
|
||||
</div>
|
||||
<details className="mt-3 text-[10px] text-muted">
|
||||
<summary className="cursor-pointer hover:text-secondary">请求格式</summary>
|
||||
<p className="mt-1.5 leading-relaxed">
|
||||
POST JSON 包含 event、timestamp、title、body、data。配置密钥后会附带
|
||||
X-TickFlow-Timestamp 和 X-TickFlow-Signature 请求头。
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SMTP 邮件推送 */}
|
||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
||||
<div
|
||||
onClick={() => setEmailOpen(o => !o)}
|
||||
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webhookDefaultChannels.includes('email')}
|
||||
onChange={e => { e.stopPropagation(); toggleDefaultChannel('email', e.target.checked) }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title="作为新建规则的默认推送渠道"
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground">邮件</span>
|
||||
<span className="text-[9px] text-muted">SMTP</span>
|
||||
{webhookDefaultChannels.includes('email') && (
|
||||
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
||||
)}
|
||||
<span className={`ml-auto text-[9px] ${emailConfigured ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{emailConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${emailOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{emailOpen && (
|
||||
<div className="border-t border-border/60 bg-base/30 p-3">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_6rem_8rem]">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">SMTP 主机</span>
|
||||
<input value={emailDraft.host} onChange={e => setEmailDraft(d => ({ ...d, host: e.target.value }))} placeholder="smtp.example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">端口</span>
|
||||
<input type="number" min={1} max={65535} value={emailDraft.port} onChange={e => setEmailDraft(d => ({ ...d, port: Number(e.target.value) }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">加密</span>
|
||||
<select value={emailDraft.security} onChange={e => setEmailDraft(d => ({ ...d, security: e.target.value as EmailSmtpConfig['security'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-2 text-xs text-foreground">
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="none">无</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">登录用户名 (可选)</span>
|
||||
<input value={emailDraft.username} onChange={e => setEmailDraft(d => ({ ...d, username: e.target.value }))} placeholder="bot@example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">密码 / 授权码 (可选)</span>
|
||||
<input type="password" value={emailPasswordDraft} onChange={e => setEmailPasswordDraft(e.target.value)} placeholder={emailSmtpPasswordSet ? '已保存;留空保持不变' : '无认证服务器可留空'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">发件人</span>
|
||||
<input value={emailDraft.from_address} onChange={e => setEmailDraft(d => ({ ...d, from_address: e.target.value }))} placeholder="bot@example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">收件人 (多个用逗号分隔)</span>
|
||||
<input value={emailDraft.to_addresses.join(', ')} onChange={e => setEmailDraft(d => ({ ...d, to_addresses: e.target.value.split(/[,;,;\n]/) }))} placeholder="alerts@example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
|
||||
</label>
|
||||
</div>
|
||||
{emailError && <div className="mt-2 text-[11px] text-danger">{emailError}</div>}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button onClick={submitEmail} disabled={saveEmailSmtp.isPending} className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors">
|
||||
{saveEmailSmtp.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<TestSendButton test={testEmail} configured={emailConfigured} />
|
||||
<TestResult test={testEmail} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 企业微信智能机器人 (BotID + Secret): 长连接通道, 与群推送 Webhook 并列 */}
|
||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
||||
<div
|
||||
@@ -755,7 +988,7 @@ function TestSendButton({ test, configured }: {
|
||||
<button
|
||||
onClick={() => 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 ? '测试中…' : '测试'}
|
||||
|
||||
Reference in New Issue
Block a user