diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 0879afb..7b9c2aa 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -49,7 +49,8 @@ class RuleModel(BaseModel): cooldown_seconds: int = 3600 severity: str = "info" # info | warn | critical webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定) - webhook_enabled: bool = False + webhook_enabled: bool = False # 兼容老规则 (已由 webhook_channels 取代, 仅做向后兼容读) + webhook_channels: list[str] = [] # 命中时推送的外部渠道 (合法值 'feishu' | 'wecom') message: str = "" # ladder 专属 (连板梯队封单监控) metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 6f36c4e..ea239b7 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -400,6 +400,7 @@ def get_preferences() -> dict: "feishu_webhook_secret": preferences.get_feishu_webhook_secret(), "wecom_webhook_url": preferences.get_wecom_webhook_url(), "webhook_enabled_default": preferences.get_webhook_enabled_default(), + "webhook_default_channels": preferences.get_webhook_default_channels(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "nav_order": preferences.get_nav_order(), "nav_hidden": preferences.get_nav_hidden(), @@ -853,10 +854,10 @@ class WebhookEnabledDefaultIn(BaseModel): @router.put("/preferences/webhook-enabled-default") def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict: - """新建监控规则时是否默认勾选「飞书推送」。 + """新建监控规则时是否默认勾选推送 (老布尔接口, 兼容旧前端)。 - 数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。 - 单条规则仍可在规则编辑页独立修改此项。 + 新数据模型为渠道数组 (webhook_default_channels); 此处转译为 + True→['feishu','wecom'], False→[]。新前端请改用 webhook-default-channels 接口。 """ from app.services import preferences @@ -864,6 +865,23 @@ def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict: return {"webhook_enabled_default": saved} +class WebhookDefaultChannelsIn(BaseModel): + channels: list[str] # 多选: ['feishu','wecom'] 等; 空数组=默认不推送 + + +@router.put("/preferences/webhook-default-channels") +def update_webhook_default_channels(req: WebhookDefaultChannelsIn) -> dict: + """新建监控规则时默认勾选的推送渠道 (多选)。 + + 作为新建规则的默认推送渠道预填, 单条规则仍可在规则编辑页独立修改。 + 空数组=默认不推送。白名单外的渠道会被过滤掉。 + """ + from app.services import preferences + + saved = preferences.set_webhook_default_channels(req.channels) + return {"webhook_default_channels": 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 adb9c46..bb98d34 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -496,20 +496,53 @@ def set_wecom_webhook_url(url: str) -> str: def get_webhook_enabled_default() -> bool: - """新建监控规则时是否默认勾选「飞书推送」。 + """新建监控规则时是否默认勾选推送 (老布尔, 已由 webhook_default_channels 取代)。 - 数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。 - 此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。 + 保留向后兼容: 读取 webhook_default_channels 非空时返回 True。 """ - return load().get("webhook_enabled_default", False) + return bool(get_webhook_default_channels()) def set_webhook_enabled_default(enabled: bool) -> bool: - """保存飞书推送默认勾选态。""" - save({"webhook_enabled_default": bool(enabled)}) + """保存推送默认勾选态 (老布尔兼容入口)。 + + 新数据模型为渠道数组; 此处把老布尔转译: True→['feishu','wecom'], False→[]。 + """ + set_webhook_default_channels(["feishu", "wecom"] if enabled else []) return get_webhook_enabled_default() +def get_webhook_default_channels() -> list[str]: + """新建监控规则时默认勾选的推送渠道 (多选)。 + + 空列表 = 新建规则默认不推送; ['feishu'] = 默认推飞书。 + 此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。 + + 向后兼容: 老版本只有布尔 webhook_enabled_default (勾选即飞书+企业微信双推), + 这里把 True 迁移为 ['feishu','wecom'], 还原当时的实际行为。 + """ + d = load() + raw = d.get("webhook_default_channels") + if isinstance(raw, list): + return [c for c in raw if c in REVIEW_PUSH_CHANNELS] + # 兼容老布尔开关 (勾选即双推) + if d.get("webhook_enabled_default") is True: + return ["feishu", "wecom"] + return [] + + +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: + seen.add(c) + cleaned.append(c) + save({"webhook_default_channels": cleaned}) + return cleaned + + def get_screener_auto_run() -> bool: """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" return load().get("screener_auto_run", True) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index bc500f1..d2b3946 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -895,7 +895,7 @@ class QuoteService: # cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。 self._maybe_send_system_notifications(all_alerts) - # Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。 + # Webhook 推送 (飞书等外部 IM, 由规则 webhook_channels 指定渠道)。 # 紧随系统通知, 同样静默降级不阻断主流程。 if rule_events: self._maybe_send_webhook(rule_events, engine) @@ -940,10 +940,10 @@ class QuoteService: return enriched_today def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None: - """把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。 + """把告警通过 Webhook 推送到外部 IM (由规则 webhook_channels 指定渠道)。 - 飞书 / 企业微信任一已配置即生效 (两个都没配才跳过) - - 仅推送 webhook_enabled=True 的规则触发的告警 + - 仅推送 webhook_channels 非空的规则触发的告警, 且只投递被勾选的渠道 - 失败静默, 不阻断主流程 - 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重 @@ -970,7 +970,10 @@ class QuoteService: enqueued = 0 for ev in rule_events: rule = rules.get(ev.get("rule_id")) - if not rule or not rule.get("webhook_enabled"): + # webhook_channels 指定命中的渠道 (['feishu'] / ['wecom'] / ['feishu','wecom'] / []). + # 空列表 = 该规则不推送。仅推送「渠道已选 + 对应地址已配置」的组合。 + channels = rule.get("webhook_channels") if rule else None + if not channels: continue source = ev.get("source", "") source_label = source_labels.get(source, source or "通知") @@ -980,16 +983,17 @@ class QuoteService: title = f"TickFlow · {source_label}" body = f"{symbol} {name} {message}".strip() if symbol else (message or name) # 提交到独立线程池, 不阻塞行情轮询线程 (webhook 慢/重试不拖累实时行情+告警)。 - # 飞书 + 企业微信双通道; 应用内 alerts.jsonl 记录与 SSE 已在前面完成, 不依赖 - # webhook 成败, 失败由 webhook_adapter 记 WARNING(可见)。 - if feishu_url: + # 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。 + # 应用内 alerts.jsonl 记录与 SSE 已在前面完成, 不依赖 webhook 成败, + # 失败由 webhook_adapter 记 WARNING(可见)。 + if feishu_url and "feishu" in channels: _WEBHOOK_EXECUTOR.submit(webhook_adapter.send_feishu, feishu_url, title, body, feishu_secret) enqueued += 1 - if wecom_url: + if wecom_url and "wecom" in channels: _WEBHOOK_EXECUTOR.submit(webhook_adapter.send_wecom, wecom_url, title, body) enqueued += 1 if enqueued: - logger.info("Webhook 已提交 %d 条 (异步投递, 飞书+企业微信, 失败记 WARNING)", enqueued) + logger.info("Webhook 已提交 %d 条 (异步投递, 按渠道独立投递, 失败记 WARNING)", enqueued) except Exception as e: # noqa: BLE001 logger.warning("Webhook 提交异常 (不影响告警主流程): %s", e) diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index f8c0162..3405bb4 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -189,6 +189,15 @@ def normalize(rule: dict) -> dict: r.setdefault("message", "") r.setdefault("webhook_url", "") r.setdefault("webhook_enabled", False) + # webhook_channels: 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')。 + # 向后兼容: 老规则只有 webhook_enabled 布尔 (当时勾选即飞书+企业微信双推), + # 这里把 webhook_enabled=True 但未带 webhook_channels 的老规则迁移为 ['feishu','wecom'], + # 还原其当时的实际行为, 用户无感知。 + if r.get("webhook_channels") is None: + 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.setdefault("created_at", datetime.now(timezone.utc).isoformat()) return r diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 3fffb78..ad0d1fb 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -46,13 +46,17 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions }) const { data: prefs } = usePreferences() const feishuConfigured = !!(prefs?.feishu_webhook_url) + const wecomConfigured = !!(prefs?.wecom_webhook_url) const [editing] = useState(!!rule) - // 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。 + // 新建规则: 预填全局「默认推送渠道」(多选数组), preset 显式指定时以 preset 为准。 // 编辑规则: 完全沿用规则自身配置, 不受默认值影响。 const [draft, setDraft] = useState( rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } - : { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) }, + : { + ...emptyRule(preset), + webhook_channels: preset?.webhook_channels ?? (prefs?.webhook_default_channels ?? []), + }, ) const assetType = draft.asset_type ?? 'stock' // 策略列表跟随资产类型: ETF 只列技术类策略。 @@ -121,6 +125,13 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { setSymbolQuery('') } + // 勾选/取消勾选某个推送渠道 (飞书 / 企业微信 各自独立) + const toggleChannel = (ch: string) => + setDraft(d => { + const cur = d.webhook_channels ?? [] + return { ...d, webhook_channels: cur.includes(ch) ? cur.filter(c => c !== ch) : [...cur, ch] } + }) + const thresholdFields = options.data?.threshold_fields ?? [] const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!='] const selectedSignals = draft.conditions.filter(c => c.op === 'truth').map(c => c.field) @@ -404,7 +415,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { - {/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */} + {/* Webhook 推送 — 飞书 / 企业微信 可用, QMT/ptrade 待定 */}
Webhook 推送 @@ -417,19 +428,36 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { + {/* 企业微信 (可用) */} + + {/* QMT (待定) */}
- {/* 飞书勾选但全局未配置 → 提示前往设置 */} - {draft.webhook_enabled && !feishuConfigured && ( -

- 飞书 Webhook 地址尚未配置, - 前往设置页配置 → -

- )} - {draft.webhook_enabled && feishuConfigured && ( -

- 命中本规则时,告警将推送到设置页配置的飞书群。 -

- )} + {/* 勾选了某渠道但该渠道地址未配置 → 提示前往设置 */} + {(draft.webhook_channels ?? []).length > 0 && (() => { + const selected = draft.webhook_channels ?? [] + const unconfigured: string[] = [] + if (selected.includes('feishu') && !feishuConfigured) unconfigured.push('飞书') + if (selected.includes('wecom') && !wecomConfigured) unconfigured.push('企业微信') + if (unconfigured.length === 0) return null + return ( +

+ {unconfigured.join('、')}尚未配置, + 前往设置页配置 → +

+ ) + })()} + {(draft.webhook_channels ?? []).length > 0 && (() => { + const selected = draft.webhook_channels ?? [] + const ready: string[] = [] + if (selected.includes('feishu') && feishuConfigured) ready.push('飞书') + if (selected.includes('wecom') && wecomConfigured) ready.push('企业微信') + if (ready.length === 0) return null + return ( +

+ 命中本规则时,告警将推送到已配置的{ready.join(' + ')}。 +

+ ) + })()}
{error &&
{error}
} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d576982..8e8183b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -440,7 +440,8 @@ export interface MonitorRule { severity: 'info' | 'warn' | 'critical' message: string webhook_url?: string - webhook_enabled?: boolean + webhook_enabled?: boolean // 兼容老规则, 已由 webhook_channels 取代 + webhook_channels?: string[] // 命中时推送的外部渠道 (合法值 'feishu' | 'wecom') created_at?: string // ladder 专属: 封单监控 metric?: 'sealed_vol' | 'sealed_amount' // 量(手) / 额(元) @@ -792,6 +793,7 @@ export interface Preferences { feishu_webhook_secret?: string wecom_webhook_url?: string webhook_enabled_default?: boolean + webhook_default_channels?: string[] sidebar_index_symbols: string[] nav_order: string[] nav_hidden: string[] @@ -998,6 +1000,11 @@ export const api = { method: 'PUT', body: JSON.stringify({ enabled }), }), + updateWebhookDefaultChannels: (channels: string[]) => + request<{ webhook_default_channels: string[] }>('/api/settings/preferences/webhook-default-channels', { + method: 'PUT', + body: JSON.stringify({ channels }), + }), updatePipelineSchedule: (hour: number, minute: number) => request<{ hour: number; minute: number }>('/api/settings/preferences/pipeline-schedule', { method: 'PUT', diff --git a/frontend/src/pages/LimitUpLadder.tsx b/frontend/src/pages/LimitUpLadder.tsx index f924c0a..1d8ae20 100644 --- a/frontend/src/pages/LimitUpLadder.tsx +++ b/frontend/src/pages/LimitUpLadder.tsx @@ -374,9 +374,9 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD const ruleId = `mr_ladder_${stock.symbol.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}` const existing = monitorRule - // 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值) + // 推送渠道默认值: 取偏好设置中的全局默认 (已有规则沿用其值) const { data: prefs } = usePreferences() - const webhookDefault = prefs?.webhook_enabled_default ?? false + const webhookDefaultChannels = prefs?.webhook_default_channels ?? [] // 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元) const VOL_UNITS = [ @@ -404,7 +404,12 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD const mult = units.find(u => u.key === initUnit)?.mult ?? 1 return String(existing.threshold / mult) }) - const [pushExternal, setPushExternal] = useState(existing?.webhook_enabled ?? webhookDefault) + // 推送渠道 (多选): 新建取全局默认, 已有规则沿用其 webhook_channels + const [pushChannels, setPushChannels] = useState( + existing?.webhook_channels ?? webhookDefaultChannels, + ) + const togglePushChannel = (ch: string) => + setPushChannels(cur => cur.includes(ch) ? cur.filter(c => c !== ch) : [...cur, ch]) const [saving, setSaving] = useState(false) const warnLabel = direction === 'down' ? '翘板预警' : '炸板预警' @@ -440,7 +445,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD cooldown_seconds: existing?.cooldown_seconds ?? 600, severity: 'warn', message: '', - webhook_enabled: pushExternal, + webhook_channels: pushChannels, } as MonitorRule) onChanged() onClose() @@ -531,21 +536,30 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD - {/* 推送渠道: 胶囊标签 (后续可扩展钉钉/企微等), 选中带强调色 */} + {/* 推送渠道: 胶囊标签 (飞书 / 企业微信 各自独立勾选), 选中带强调色 */}
推送 - + {([ + { key: 'feishu', label: '飞书' }, + { key: 'wecom', label: '企业微信' }, + ] as const).map(ch => { + const on = pushChannels.includes(ch.key) + return ( + + ) + })}
{/* 权限提示 (免费用户) */} diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 779e5e2..6b935a3 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -54,8 +54,8 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const refreshPages = prefs?.sse_refresh_pages ?? {} const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false const hasDepth = !!caps?.capabilities?.['depth5.batch'] - // 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改) - const webhookDefault = prefs?.webhook_enabled_default ?? false + // 新建监控规则时默认勾选的推送渠道 (全局默认值数组, 单条规则可独立修改) + const webhookDefaultChannels = prefs?.webhook_default_channels ?? [] 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 @@ -128,10 +128,13 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = qc.invalidateQueries({ queryKey: QK.preferences }) }, [qc]) - const toggleWebhookDefault = useCallback(async (enabled: boolean) => { - await api.updateWebhookDefault(enabled) + // 勾选/取消勾选某个默认推送渠道 (飞书 / 企业微信 各自独立) + const toggleDefaultChannel = useCallback(async (ch: string, enabled: boolean) => { + const cur = prefs?.webhook_default_channels ?? [] + const next = enabled ? [...cur, ch] : cur.filter(c => c !== ch) + await api.updateWebhookDefaultChannels(next) qc.invalidateQueries({ queryKey: QK.preferences }) - }, [qc]) + }, [qc, prefs]) const saveFeishuWebhook = useMutation({ mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret), @@ -437,15 +440,15 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = > { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }} + checked={webhookDefaultChannels.includes('feishu')} + onChange={e => { e.stopPropagation(); toggleDefaultChannel('feishu', e.target.checked) }} onClick={e => e.stopPropagation()} title="作为新建规则的默认推送渠道" className="h-3 w-3 accent-accent cursor-pointer" /> 飞书 群机器人 - {webhookDefault && ( + {webhookDefaultChannels.includes('feishu') && ( 默认 )} @@ -523,15 +526,15 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = > { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }} + checked={webhookDefaultChannels.includes('wecom')} + onChange={e => { e.stopPropagation(); toggleDefaultChannel('wecom', e.target.checked) }} onClick={e => e.stopPropagation()} title="作为新建规则的默认推送渠道" className="h-3 w-3 accent-accent cursor-pointer" /> 企业微信 群机器人 - {webhookDefault && ( + {webhookDefaultChannels.includes('wecom') && ( 默认 )}