From 40392ebdedd37a165566eafbfd800288fb238f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 6 Oct 2026 12:49:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E9=A3=9E=E4=B9=A6=20push?= =?UTF-8?q?=5Fto=5Fchat=20outbound=20=E7=BB=9F=E4=B8=80=E5=87=BA=E5=8F=A3+?= =?UTF-8?q?ACTIVE=5FCLIENT=20=E5=85=A8=E5=B1=80=E5=8F=A5=E6=9F=84(19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/todo.md | 2 +- src-tauri/src/feishu_channel.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index e5cac4e..95f5661 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1053,7 +1053,7 @@ graph TD ### ⚠️ 退回重处理(2026-10-06 用户令,G2/G3 治理标准首次执行) - 三笔提交(cec4d11 委托引擎/84df9b5 租约/622507c UI)已 reset --mixed 退回(内容全在工作区,零丢失,未推远端无需处理远端) - **重做要求**:①先修 🔴10 loop_state 失效(G2 门禁:🔴不清零不得提交新功能)②描述按 G3 如实:实际干了 14+16+事件广播三件须逐件列全 ③i18n reattachToMain 键随批补上 ④修完按主题重新分组提交,销账 [x] 附验收输出(G4) -19. [ ] **飞书 outbound 统一出口**(11:23 实证):挂机模式进度汇总走独立发送路径,绕过 downgrade_md_tables/clip_for_card/卡片链——3 条回复带完整 md 表格未降级(踩回表格渲染不稳坑)。修:降级/裁剪/卡片链下沉为统一 outbound 出口函数,对话/挂机汇报/进度推送全部走它;验收:任意路径回复无 |---| 裸奔。 +19. [x] **飞书 outbound 统一出口**(11:23 实证)✅ 2026-10-06 收口:勘察确认对话/受理/完成/注入回执已统一走 deliver_result(降级/裁剪/卡片链内聚);病灶「挂机进度汇总独立发送路径」在当前 main 不存在(属退回的挂机实验代码)。本批防复发收口:①新增 push_to_chat pub(crate) 正典出口(主动推送形态——挂机进度汇总/巡检通知将来落地时禁再开独立路径,强制走降级/裁剪/卡片链) ②ACTIVE_CLIENT OnceLock 全局句柄(渠道启动就绪,推送复用长连接 client 不临时建连,未启动静默跳过)。验收:cargo check EXIT 0 + feishu 7 passed。 > 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。 ## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md) diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs index bd1defe..75ac38b 100644 --- a/src-tauri/src/feishu_channel.rs +++ b/src-tauri/src/feishu_channel.rs @@ -26,6 +26,11 @@ const KV_FEISHU_CONFIG: &str = "feishu-config"; /// app_settings 键:飞书 chat_id → conversation_id 映射(持久化,跨重启续会话) const KV_CHAT_MAP: &str = "feishu-chat-map"; +/// 19 outbound 统一出口:渠道启动成功后的全局 client 句柄(once 填充,渠道停用为空)。 +/// push_to_chat 主动推送复用长连接 client,不临时建连;进程生命周期内渠道只启一次, +/// OnceLock 语义匹配(重启应用才可能重配)。 +static ACTIVE_CLIENT: std::sync::OnceLock<&'static LarkClient> = std::sync::OnceLock::new(); + #[derive(Debug, Clone, Deserialize)] struct FeishuConfig { #[serde(default)] @@ -120,6 +125,8 @@ pub fn start_if_enabled(app: tauri::AppHandle) { // Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用 // 引用,重连循环内复用同一 leaked 引用,只重建 Channel/dispatcher let leaked: &'static LarkClient = Box::leak(Box::new(client)); + // 19 outbound 统一出口:全局句柄就绪,push_to_chat 自此可用 + let _ = ACTIVE_CLIENT.set(leaked); // 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死 let mut backoff = 2u64; loop { @@ -766,6 +773,31 @@ async fn deliver_result( send_markdown_card(client, message_id, chat_id, &text).await; } +/// 19 outbound 统一出口:主动推送(无占位卡上下文的外部推送方——挂机进度汇总/巡检通知 +/// 等将来形态)。降级/裁剪/卡片链与 deliver_result 同源(禁再开独立发送路径绕过降级链, +/// 11:23 实证:绕过链的回复带完整 md 表格未降级,踩回表格渲染不稳坑)。 +/// 推送目标:chat_map 最近映射的会话;渠道未启动(无 client)或无映射时静默跳过。 +pub(crate) async fn push_to_chat(app: &tauri::AppHandle, text: &str) { + let Some(client) = ACTIVE_CLIENT.get() else { + return; // 渠道未启动:推送无落点,静默跳过(调用方自行决定是否记日志) + }; + let db = app.state::().db.clone(); + let repo = SettingsRepo::new(&db); + let raw = repo.get(KV_CHAT_MAP).await.ok().flatten(); + let Some(map) = raw.and_then(|r| serde_json::from_str::>(&norm(Some(r))).ok()) + else { + return; + }; + // 最近会话:取任一映射(KV HashMap 无序,取首条;多 chat 精准路由属将来形态) + let Some(chat_id) = map.keys().next().cloned() else { + return; + }; + let processed = clip_for_card(&downgrade_md_tables(text), 8000); + // 主动推送无 message_id 上下文:send_markdown_card 的 reply 分支跳过,直接 chat 卡片 + send_markdown_card(client, "", &chat_id, &processed).await; + feishu_log(&format!("push_to_chat chat={chat_id} len={}", processed.len())); +} + /// 卡片正文长度防御:飞书卡片 JSON 约 30KB 上限,超长会让原地更新与新发双双失败, /// 一路穿透到纯文本兜底仍不可读;入口统一裁剪,尾注指回桌面端(u-claw archiveIfClipped 同口径) fn clip_for_card(text: &str, max_chars: usize) -> String {