From 37934091ace85eaebf2e05cacd12802d3703842a Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Mon, 29 Jun 2026 17:17:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(notify):=20=E6=8E=A8=E9=80=81=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=8E=A5=E5=85=A5=E9=A3=9E=E4=B9=A6=20Webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 webhook_adapter 飞书群机器人推送 (含签名校验);preferences/settings 增加 feishu_webhook_url/secret 与 webhook_enabled_default (新建规则默认推送) 配置与端点;Monitoring 设置页重构为「推送通知」卡片 (渠道列表+飞书地址折叠 +默认渠道勾选);RuleEditor 规则编辑器支持单条规则勾选飞书。 QMT/ptrade 渠道文案统一改为「待定」。 --- backend/app/api/monitor_rules.py | 2 +- backend/app/api/settings.py | 47 +++++ backend/app/services/preferences.py | 37 ++++ backend/app/services/webhook_adapter.py | 106 ++++++++++ .../src/components/monitor/RuleEditor.tsx | 70 +++++-- frontend/src/pages/settings/Monitoring.tsx | 182 +++++++++++++++--- 6 files changed, 403 insertions(+), 41 deletions(-) create mode 100644 backend/app/services/webhook_adapter.py diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index e0856af..475d685 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -47,7 +47,7 @@ class RuleModel(BaseModel): logic: str = "and" # and | or cooldown_seconds: int = 3600 severity: str = "info" # info | warn | critical - webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 开发中) + webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定) webhook_enabled: bool = False message: str = "" diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 9af897a..8612846 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -307,6 +307,9 @@ def get_preferences() -> dict: "strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(), "strategy_monitor_ids": preferences.get_strategy_monitor_ids(), "system_notify_enabled": preferences.get_system_notify_enabled(), + "feishu_webhook_url": preferences.get_feishu_webhook_url(), + "feishu_webhook_secret": preferences.get_feishu_webhook_secret(), + "webhook_enabled_default": preferences.get_webhook_enabled_default(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "nav_order": preferences.get_nav_order(), "nav_hidden": preferences.get_nav_hidden(), @@ -554,6 +557,50 @@ def update_system_notify(req: SystemNotifyPrefsIn) -> dict: return {"system_notify_enabled": saved} +class FeishuWebhookPrefsIn(BaseModel): + url: str + secret: str = "" + + +@router.put("/preferences/feishu-webhook") +def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict: + """飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。 + + - url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。 + - secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。 + """ + from app.services import preferences + from app.services import webhook_adapter + + url = (req.url or "").strip() + if url and not webhook_adapter.is_valid_feishu_url(url): + raise HTTPException( + status_code=400, + detail="Webhook 地址非法, 需为飞书自定义机器人地址 " + "(https://open.feishu.cn/open-apis/bot/v2/hook/...)", + ) + saved_url = preferences.set_feishu_webhook_url(url) + saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip()) + return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret} + + +class WebhookEnabledDefaultIn(BaseModel): + enabled: bool + + +@router.put("/preferences/webhook-enabled-default") +def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict: + """新建监控规则时是否默认勾选「飞书推送」。 + + 数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。 + 单条规则仍可在规则编辑页独立修改此项。 + """ + from app.services import preferences + + saved = preferences.set_webhook_enabled_default(req.enabled) + return {"webhook_enabled_default": saved} + + @router.put("/preferences/quote-interval") def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict: """更新行情轮询间隔。按档位自动 clamp。""" diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 7853dfe..9f06a2e 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -397,6 +397,43 @@ def set_system_notify_enabled(enabled: bool) -> bool: return bool(enabled) +def get_feishu_webhook_url() -> str: + """飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。""" + return load().get("feishu_webhook_url", "") + + +def get_feishu_webhook_secret() -> str: + """飞书自定义机器人签名密钥 — 机器人启用「签名校验」时必填, 留空表示不验签。""" + return load().get("feishu_webhook_secret", "") + + +def set_feishu_webhook_url(url: str) -> str: + """保存飞书 Webhook 地址。传入空串表示清空配置。""" + save({"feishu_webhook_url": str(url or "").strip()}) + return get_feishu_webhook_url() + + +def set_feishu_webhook_secret(secret: str) -> str: + """保存飞书签名密钥。传入空串表示不验签。""" + save({"feishu_webhook_secret": str(secret or "").strip()}) + return get_feishu_webhook_secret() + + +def get_webhook_enabled_default() -> bool: + """新建监控规则时是否默认勾选「飞书推送」。 + + 数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。 + 此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。 + """ + return load().get("webhook_enabled_default", False) + + +def set_webhook_enabled_default(enabled: bool) -> bool: + """保存飞书推送默认勾选态。""" + save({"webhook_enabled_default": bool(enabled)}) + return get_webhook_enabled_default() + + def get_screener_auto_run() -> bool: """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" return load().get("screener_auto_run", True) diff --git a/backend/app/services/webhook_adapter.py b/backend/app/services/webhook_adapter.py new file mode 100644 index 0000000..28c356f --- /dev/null +++ b/backend/app/services/webhook_adapter.py @@ -0,0 +1,106 @@ +"""Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。 + +职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。 + 目前支持飞书群机器人; QMT / ptrade 等量化通道为待定。 + +飞书自定义机器人接入: + 1. 飞书群 → 群设置 → 群机器人 → 添加「自定义机器人」 + 2. 复制生成的 Webhook 地址 (形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx) + 3. (可选) 安全设置 → 启用「签名校验」, 记录签名密钥(secret) + 4. 填入设置页「飞书 Webhook」配置 + +设计: 失败静默降级, 绝不因推送失败阻断告警主流程 (落盘 / SSE 推送)。 + 去重不在本层做, 复用 MonitorRuleEngine 的 cooldown。 +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import time + +logger = logging.getLogger(__name__) + +# 单次推送最长字符 (飞书单条文本消息上限 30KB, 这里保守截断避免刷屏) +_MAX_LEN = 500 + +# 飞书自定义机器人 Webhook 前缀 (用于 URL 合法性校验) +FEISHU_HOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/" + + +def _truncate(text: str) -> str: + """截断超长文本。""" + text = (text or "").strip() + return text[:_MAX_LEN] + ("…" if len(text) > _MAX_LEN else "") + + +def is_valid_feishu_url(url: str) -> bool: + """校验是否为合法的飞书自定义机器人 Webhook 地址。""" + return bool(url) and url.startswith(FEISHU_HOOK_PREFIX) + + +def _gen_sign(timestamp: str, secret: str) -> str: + """计算飞书自定义机器人签名。 + + 算法 (官方): 把 `timestamp + "\\n" + secret` 作为签名字符串 (key), + 用 HmacSHA256 计算空字符串的签名结果, 再 Base64 编码。 + """ + string_to_sign = f"{timestamp}\n{secret}" + hmac_code = hmac.new( + string_to_sign.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + return base64.b64encode(hmac_code).decode("utf-8") + + +def send_feishu(webhook_url: str, title: str, body: str, secret: str = "") -> bool: + """推送一条文本消息到飞书群机器人。 + + Args: + webhook_url: 飞书自定义机器人 Webhook 地址 + title: 消息标题 (与正文拼接为一条文本) + body: 消息正文 + secret: 签名密钥 (机器人启用了「签名校验」时必填; 留空则不带签名) + + Returns: + True=成功送达, False=失败或 URL 非法。 + 失败静默, 不抛异常 (Webhook 是辅助通道, 不能阻断告警主流程)。 + """ + if not is_valid_feishu_url(webhook_url): + return False + + text = _truncate(f"{title}\n{body}".strip()) + if not text: + return False + + try: + import httpx + + payload: dict = {"msg_type": "text", "content": {"text": text}} + # 启用签名校验时, 请求体须带 timestamp + sign (秒级时间戳) + if secret: + timestamp = str(int(time.time())) + payload["timestamp"] = timestamp + payload["sign"] = _gen_sign(timestamp, secret) + + resp = httpx.post(webhook_url, json=payload, timeout=5.0) + # 飞书成功响应: {"code":0,"msg":"success"} (或 StatusCode 200 + Extra) + if resp.status_code == 200: + try: + data = resp.json() + # code=0 表示飞书业务侧成功; 部分版本无 code 字段则按 msg 判断 + if isinstance(data, dict): + code = data.get("code", data.get("StatusCode", 0)) + if code == 0: + return True + logger.debug("飞书推送业务失败: %s", data) + return False + except ValueError: + # 非 JSON 响应但 HTTP 200, 视为成功 + return True + logger.debug("飞书推送 HTTP %s: %s", resp.status_code, resp.text[:200]) + return False + except Exception as e: # noqa: BLE001 + logger.debug("飞书 Webhook 推送失败: %s", e) + return False diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 59c5782..0c19864 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' +import { Link } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Save, X, Plus, Search } from 'lucide-react' import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { SignalPicker } from '@/components/screener/SignalPicker' +import { usePreferences } from '@/lib/useSharedQueries' interface Props { /** 编辑现有规则;null=新建 */ @@ -42,9 +44,15 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { const qc = useQueryClient() const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions }) const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies }) + const { data: prefs } = usePreferences() + const feishuConfigured = !!(prefs?.feishu_webhook_url) const [editing] = useState(!!rule) + // 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。 + // 编辑规则: 完全沿用规则自身配置, 不受默认值影响。 const [draft, setDraft] = useState( - rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } : emptyRule(preset), + rule + ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } + : { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) }, ) const [error, setError] = useState('') const [symbolQuery, setSymbolQuery] = useState('') @@ -368,21 +376,59 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { - {/* Webhook 推送 (占位, 后续开发) */} + {/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */}
-
-
- Webhook 推送 - 开发中 -
-
{error &&
{error}
} diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 469c4bb..339795f 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -3,12 +3,12 @@ import { Link } from 'react-router-dom' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { Activity, - Shield, Wifi, BarChart3, Flame, Zap, - Bell, + Webhook, + ChevronDown, } from 'lucide-react' import { usePreferences, @@ -53,8 +53,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false const refreshPages = prefs?.sse_refresh_pages ?? {} const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false - const systemNotify = prefs?.system_notify_enabled ?? false const hasDepth = !!caps?.capabilities?.['depth5.batch'] + // 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改) + const webhookDefault = prefs?.webhook_enabled_default ?? false const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol) const indicesPinned = prefs?.indices_nav_pinned ?? true const isRunning = quoteStatus?.running ?? false @@ -63,6 +64,17 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const minInterval = intervalData?.min_interval ?? 5 const maxInterval = intervalData?.max_interval ?? 60 const [intervalDraft, setIntervalDraft] = useState(interval) + const feishuWebhookUrl = prefs?.feishu_webhook_url ?? '' + const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? '' + const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl) + const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret) + const [feishuError, setFeishuError] = useState('') + // 飞书渠道配置区展开态 (推送通知卡片内) + const [channelOpen, setChannelOpen] = useState(false) + useEffect(() => { + setFeishuDraft(feishuWebhookUrl) + setFeishuSecretDraft(feishuWebhookSecret) + }, [feishuWebhookUrl, feishuWebhookSecret]) const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? [] const watchlist = useQuery({ queryKey: QK.watchlist, @@ -107,11 +119,31 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = qc.invalidateQueries({ queryKey: QK.preferences }) }, [qc]) - const toggleSystemNotify = useCallback(async (enabled: boolean) => { - await api.updateSystemNotify(enabled) + const toggleWebhookDefault = useCallback(async (enabled: boolean) => { + await api.updateWebhookDefault(enabled) qc.invalidateQueries({ queryKey: QK.preferences }) }, [qc]) + const saveFeishuWebhook = useMutation({ + mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret), + onSuccess: () => { + setFeishuError('') + toast('飞书 Webhook 已保存', 'success') + qc.invalidateQueries({ queryKey: QK.preferences }) + }, + onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')), + }) + const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/' + const submitFeishu = useCallback(() => { + const url = feishuDraft.trim() + const secret = feishuSecretDraft.trim() + if (url && !url.startsWith(FEISHU_PREFIX)) { + setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头') + return + } + saveFeishuWebhook.mutate({ url, secret }) + }, [feishuDraft, feishuSecretDraft, saveFeishuWebhook]) + const runFix = useMutation({ mutationFn: () => api.runLimitLadderFix(), onSuccess: (data) => { @@ -300,29 +332,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {/* ========== 右列 ========== */}
- {/* 策略监控已迁移至监控中心 */} - -

- 策略监控、个股信号监控、价格监控已统一到「监控中心」页面,支持灵活配置触发条件、冷却期和作用范围。 -

- - 前往监控中心配置 → - -
- -
-
- - {/* 连板梯队降级修正 */} + {/* 连板梯队降级修正 (移至右列顶部) */}
+ + {/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。 + 飞书已实现; 微信开发中, QMT/ptrade 待定。 + 每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */} + +

+ 监控规则命中后,可把告警推送到外部。勾选渠道作为新建规则的默认推送, + 单条规则仍可在编辑页独立修改。 +

+ + {/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */} +
+ {/* 飞书 (可用): 勾选默认 + 展开地址配置 */} +
+
setChannelOpen(o => !o)} + className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" + > + { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }} + onClick={e => e.stopPropagation()} + title="作为新建规则的默认推送渠道" + className="h-3 w-3 accent-accent cursor-pointer" + /> + 飞书 + 群机器人 + {webhookDefault && ( + 默认 + )} + + {feishuWebhookUrl ? '已配置' : '未配置'} + + +
+ + {/* 飞书地址配置 — 行内展开 */} + {channelOpen && ( +
+ + + + + {feishuError && ( +
{feishuError}
+ )} + +
+ + {feishuWebhookUrl && ( + ● 已配置 + )} +
+ +
+ 如何获取飞书 Webhook 地址? +
    +
  1. 打开飞书,进入目标群聊 → 群设置 → 群机器人
  2. +
  3. 点击「添加机器人」→ 选择「自定义机器人
  4. +
  5. 填写机器人名称后添加,复制生成的 Webhook 地址
  6. +
  7. 安全设置若启用了「签名校验」,把密钥一并复制填到「签名密钥」框
  8. +
  9. 粘贴到上方输入框并保存
  10. +
+

+ 📖 官方文档: + + 自定义机器人使用指南 ↗ + +

+
+
+ )} +
+ + {/* 占位渠道 — 不可点 */} + {[ + { name: '微信', hint: '公众号/企业微信', status: '开发中' }, + { name: 'QMT', hint: '量化交易终端', status: '待定' }, + { name: 'ptrade', hint: '量化交易终端', status: '待定' }, + ].map(ch => ( +
+ + {ch.name} + {ch.hint} + {ch.status} +
+ ))} +
+
)