From 2e056f9bae79015724c1f326452815c583bb5a1b Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Wed, 15 Jul 2026 20:14:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor(bridge+panel):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=8F=91=E9=80=81=E9=80=BB=E8=BE=91=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 调整默认发送延迟从800ms改为3000ms,更新配置默认值与环境变量 fallback 2. 重写Fastify代理路由,修复POST body被提前消费导致的转发超时问题 3. 新增DB校验机制,发送后轮询确认消息是否到达目标会话 4. 重构xdotool操作流程:改用鼠标点击搜索与发送,替代快捷键组合以适配微信4.x自绘UI 5. 优化窗口激活、会话搜索与输入焦点处理逻辑,提升发送稳定性 --- bridge/woc_bridge/config.py | 4 +- bridge/woc_bridge/messaging/send_queue.py | 4 +- bridge/woc_bridge/routes/send.py | 96 ++++++++++- bridge/woc_bridge/ui/xdotool_driver.py | 200 +++++++++++++++------- panel/server/src/index.ts | 44 ++--- 5 files changed, 256 insertions(+), 92 deletions(-) diff --git a/bridge/woc_bridge/config.py b/bridge/woc_bridge/config.py index d0840f1..39fe5e2 100644 --- a/bridge/woc_bridge/config.py +++ b/bridge/woc_bridge/config.py @@ -125,7 +125,7 @@ class BridgeConfig: wechat_db: str = "/config" display: str = ":1" # 环境变量 - send_delay_ms: int = 800 + send_delay_ms: int = 3000 max_calls_per_sec: int = 10 max_batch_size: int = 50 poll_interval_ms: int = 2000 @@ -139,7 +139,7 @@ class BridgeConfig: listen=args.listen, wechat_db=args.wechat_db, display=args.display, - send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "800")), + send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "3000")), max_calls_per_sec=int(os.environ.get("WOC_BRIDGE_MAX_CALLS_PER_SEC", "10")), max_batch_size=int(os.environ.get("WOC_BRIDGE_MAX_BATCH_SIZE", "50")), poll_interval_ms=int(os.environ.get("WOC_BRIDGE_POLL_INTERVAL_MS", "2000")), diff --git a/bridge/woc_bridge/messaging/send_queue.py b/bridge/woc_bridge/messaging/send_queue.py index 1ccb4af..c0e5e18 100644 --- a/bridge/woc_bridge/messaging/send_queue.py +++ b/bridge/woc_bridge/messaging/send_queue.py @@ -24,12 +24,12 @@ class SendQueue: """串行化发送队列 + 限流。 通过 asyncio.Queue 串行执行所有发送任务;执行间隔可配置 - (默认 800ms),单实例每秒调用上限可配置(默认 10)。 + (默认 3000ms),单实例每秒调用上限可配置(默认 10)。 """ def __init__( self, - send_delay_ms: int = 800, + send_delay_ms: int = 3000, max_calls_per_sec: int = 10, ) -> None: """初始化队列配置。 diff --git a/bridge/woc_bridge/routes/send.py b/bridge/woc_bridge/routes/send.py index b3399c5..62f9502 100644 --- a/bridge/woc_bridge/routes/send.py +++ b/bridge/woc_bridge/routes/send.py @@ -80,6 +80,74 @@ async def _resolve_display_name( return resolved +async def _get_latest_create_time_for_talker( + db_reader: Optional[DbReader], + to_wxid: str, +) -> int: + """返回目标 talker 当前最新消息的 create_time,DB 不可用时返回 0。""" + if db_reader is None: + return 0 + try: + result = await asyncio.to_thread( + db_reader.get_messages_by_session, + to_wxid, + cursor=0, + limit=1, + direction="before", + is_sender=None, + ) + messages = result.get("messages", []) + if messages: + return int(messages[0].get("create_time", 0)) + except Exception: + pass + return 0 + + +async def _verify_sent_to_talker( + db_reader: Optional[DbReader], + to_wxid: str, + before_ct: int, + max_wait_sec: float = 10.0, +) -> bool: + """轮询 DB,确认目标 talker 在 before_ct 之后出现了新消息。 + + 用于发送后校验消息是否真的落到了目标会话。DB 不可用时静默跳过。 + 默认最多等待 10 秒,兼容 WeChat 4.x 异步写入 DB 的延迟。 + """ + if db_reader is None: + return True + deadline = time.monotonic() + max_wait_sec + attempts = 0 + while time.monotonic() < deadline: + attempts += 1 + try: + result = await asyncio.to_thread( + db_reader.get_messages_by_session, + to_wxid, + cursor=before_ct, + limit=3, + direction="after", + is_sender=None, + ) + messages = result.get("messages", []) + if messages: + logger.info( + "send/text: DB 校验成功,目标 talker 发现 %d 条新消息 (attempt=%d)", + len(messages), attempts, + ) + return True + logger.debug("send/text: DB 校验第 %d 次轮询未找到新消息", attempts) + except Exception as e: + logger.debug("send/text: DB 校验第 %d 次轮询异常: %s", attempts, e) + await asyncio.sleep(0.5) + logger.warning( + "send/text: DB 校验超时,%ds 内目标 talker 未出现新消息 (attempts=%d)", + max_wait_sec, attempts, + ) + return False + + @router.post("/api/send/text", response_model=SendResponse) @with_db_retry async def send_text(req: SendTextRequest) -> SendResponse: @@ -89,9 +157,11 @@ async def send_text(req: SendTextRequest) -> SendResponse: 1. 校验 to_wxid / content 非空 2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid 3. 检测登录态,非 logged_in 直接拒绝(避免 UI 操作引发不可预期行为) - 4. 经 send_queue 串行执行:activate_window → Ctrl+F 搜索会话(按 display_name) → - 选中并进入 → xclip 粘贴 content → 回车发送 - 5. 返回本地生成的 local_send_id(不等待微信发送回执) + 4. 记录目标会话当前最新 create_time + 5. 经 send_queue 串行执行:activate_window → 鼠标点击左侧搜索框 → + 输入 display_name → 点击第一个搜索结果 → 点击输入框 → 输入 content → 回车发送 + 6. 发送后轮询 DB 校验目标会话是否出现新消息,未出现则抛 SEND_FAILED + 7. 返回本地生成的 local_send_id(不等待微信发送回执) Args: req: SendTextRequest(to_wxid + content + 可选 display_name) @@ -106,12 +176,12 @@ async def send_text(req: SendTextRequest) -> SendResponse: BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500) Notes: - - 发送队列串行化避免 Ctrl+F 搜索框竞态(前一条消息的搜索未关闭时, - 后一条会粘到错误的会话) + - 发送队列串行化避免搜索框/鼠标点击竞态 - local_send_id 由 bridge 本地生成,不对应微信真实 msg_id; 调用方应用它做幂等去重,**禁止用它到 /api/media/{msg_id} 查媒体** - 发送队列的发送间隔与限频由 .env 的 WOC_BRIDGE_SEND_DELAY_MS / WOC_BRIDGE_MAX_CALLS_PER_SEC 控制 + - DB 校验失败时消息可能已经发出(可能到错误会话),调用方需人工排查 """ xdotool = _require_xdotool() send_queue = _require_send_queue() @@ -154,6 +224,10 @@ async def send_text(req: SendTextRequest) -> SendResponse: display_name, (time.perf_counter() - t0) * 1000, ) + # 发送前记录目标会话最新消息时间,用于发送后校验 + before_ct = await _get_latest_create_time_for_talker(_state.db_reader, req.to_wxid) + logger.info("send/text: 目标会话当前最新 create_time=%d", before_ct) + # 经发送队列串行执行 logger.info("send/text: 入队 send_queue (pending=%d)", send_queue.pending_count()) try: @@ -181,6 +255,18 @@ async def send_text(req: SendTextRequest) -> SendResponse: message=f"发送失败: {e}", ) + # 发送后校验:目标会话是否出现了新消息 + verified = await _verify_sent_to_talker(_state.db_reader, req.to_wxid, before_ct) + if not verified: + logger.error( + "send/text: 发送后 DB 校验失败,消息可能未进入目标会话 to=%s before_ct=%d", + req.to_wxid, before_ct, + ) + raise BridgeError( + code="SEND_FAILED", + message="发送后校验失败:消息未到达目标会话", + ) + logger.info( "send/text: ✓ to=%s display_name=%s content_len=%d → local_send_id=%s (总耗时 %.0fms)", req.to_wxid, display_name, len(req.content), local_send_id, diff --git a/bridge/woc_bridge/ui/xdotool_driver.py b/bridge/woc_bridge/ui/xdotool_driver.py index e8f3287..85803d8 100644 --- a/bridge/woc_bridge/ui/xdotool_driver.py +++ b/bridge/woc_bridge/ui/xdotool_driver.py @@ -95,10 +95,19 @@ class XdotoolDriver: ) from exc return proc.returncode, stdout, stderr - async def _key(self, key: str) -> None: - """执行 xdotool key 。""" - logger.info("[ui] key: %s", key) - rc, stdout, stderr = await self._run(["xdotool", "key", key]) + async def _key(self, key: str, repeat: int = 1) -> None: + """执行 xdotool key [--repeat N] 。 + + Args: + key: 按键名,如 "Escape" / "Return" / "BackSpace" + repeat: 重复次数,默认 1 + """ + logger.info("[ui] key: %s repeat=%d", key, repeat) + args = ["xdotool", "key"] + if repeat > 1: + args.extend(["--repeat", str(repeat)]) + args.append(key) + rc, _, _ = await self._run(args) logger.info("[ui] key %s -> rc=%s", key, rc) async def _sleep(self, seconds: float) -> None: @@ -163,7 +172,13 @@ class XdotoolDriver: await self._run(["xdotool", "windowactivate", "--sync", str(window_id)]) async def _activate_window_fast(self, window_id: Optional[int] = None) -> None: - """非阻塞激活微信窗口,避免 --sync 在 VNC 无人操作时死等。""" + """激活微信窗口并校验焦点是否真正落到该窗口上。 + + 先用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等, + 然后轮询 getactivewindow 最多 2 秒确认目标窗口已获得焦点。 + 校验失败不阻塞流程(部分窗口管理器 getactivewindow 不可靠), + 但会打 warning 便于排查。 + """ if window_id is None: window_id = await self.find_wechat_window() if window_id is None: @@ -173,6 +188,22 @@ class XdotoolDriver: ) await self._run(["xdotool", "windowactivate", str(window_id)]) + # 轮询校验焦点(最多 2 秒,步进 100ms) + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + rc, stdout, _ = await self._run(["xdotool", "getactivewindow"]) + if rc == 0: + active_text = stdout.decode(errors="ignore").strip() + try: + if int(active_text) == window_id: + return + except ValueError: + pass + await self._sleep(0.1) + logger.warning( + "[ui] 窗口激活校验失败: window_id=%s 未在 2s 内成为活动窗口", window_id + ) + async def restart_wechat(self, timeout_sec: int = 30) -> int: """重启微信进程:发 SIGTERM 让微信优雅退出,等待 autostart 拉起新进程。 @@ -353,59 +384,90 @@ class XdotoolDriver: logger.info("[ui] type done rc=%s", rc) # ------------------------------------------------------------------ - # 鼠标点击聊天输入框(强制焦点) + # 微信 4.x 自绘 UI 布局常量(基于窗口几何的相对坐标) # ------------------------------------------------------------------ - # 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。 - # 实测点击窗口底部中间区域 (800, 800) 能稳定聚焦到聊天输入框。 - # 坐标基于窗口几何 1435x876,若窗口尺寸变化需重新校准。 - _INPUT_BOX_X = 800 - _INPUT_BOX_Y = 800 + # 截图显示:左侧会话列表宽度约 300px,顶部搜索框在左侧栏上方,输入框在右下面板底部。 + # 所有坐标通过 getwindowgeometry 获取窗口 (X, Y, WIDTH, HEIGHT) 后动态计算。 + _LEFT_PANEL_WIDTH = 300 # 左侧会话列表宽度(像素) + _SEARCH_BOX_Y_OFFSET = 55 # 搜索框中心相对窗口顶部的 Y 偏移 + _SEARCH_RESULT_Y_OFFSET = 120 # 搜索结果/会话列表第一项中心相对窗口顶部的 Y 偏移 + _INPUT_BOX_Y_OFFSET = 50 # 输入框中心相对窗口底部的 Y 偏移(向上) + _SEND_BUTTON_X_OFFSET = 60 # 发送按钮中心相对窗口右边缘的 X 偏移(向左) + _SEND_BUTTON_Y_OFFSET = 30 # 发送按钮中心相对窗口底部的 Y 偏移(向上) + _DEBUG_SCREENSHOTS = True # 是否在关键步骤保存调试截图到 /tmp - async def _click_input_box(self) -> None: - """鼠标点击聊天输入框,强制焦点落到输入框。 - - 微信 4.x 自绘 UI 通过搜索进入会话后,焦点不在聊天输入框, - 直接 xdotool type 会导致文字打到错误位置。必须先鼠标点击 - 输入框区域强制聚焦。 - """ - logger.info("[ui] click input box (%d, %d)", self._INPUT_BOX_X, self._INPUT_BOX_Y) - rc, _, stderr = await self._run([ - "xdotool", "mousemove", str(self._INPUT_BOX_X), str(self._INPUT_BOX_Y), - ]) + async def _click_at(self, x: int, y: int, desc: str = "") -> None: + """移动鼠标到窗口相对/绝对坐标 (x, y) 并左键单击。""" + logger.info("[ui] click at (%d, %d) %s", x, y, desc) + rc, _, stderr = await self._run( + ["xdotool", "mousemove", "--sync", str(x), str(y)] + ) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" raise BridgeError( code="SEND_FAILED", - message=f"鼠标移动到输入框失败 (code={rc}): {err}", + message=f"移动鼠标到 ({x},{y}) 失败: {err}", ) rc, _, stderr = await self._run(["xdotool", "click", "1"]) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" raise BridgeError( code="SEND_FAILED", - message=f"点击输入框失败 (code={rc}): {err}", + message=f"点击 ({x},{y}) 失败: {err}", ) - logger.info("[ui] click input box done rc=%s", rc) + logger.info("[ui] click done rc=%s", rc) + + async def _debug_screenshot(self, step: str) -> None: + """保存调试截图到 /tmp,文件名包含步骤名和时间戳。""" + if not self._DEBUG_SCREENSHOTS: + return + path = f"/tmp/woc_send_debug_{step}_{int(time.time() * 1000)}.png" + rc, _, stderr = await self._run(["scrot", path]) + if rc == 0: + logger.info("[ui] debug screenshot saved: %s", path) + else: + err = stderr.decode(errors="ignore").strip() if stderr else "unknown" + logger.warning("[ui] debug screenshot failed (%s): %s", step, err) + + async def _click_input_box(self) -> None: + """鼠标点击聊天输入框,强制焦点落到输入框。 + + 微信 4.x 自绘 UI 通过搜索进入会话后,焦点不在聊天输入框, + 直接 xdotool type 会导致文字打到错误位置。先获取窗口几何, + 点击右下面板底部区域强制聚焦。 + """ + win_x, win_y, win_w, win_h = await self._get_window_geometry() + input_x = win_x + self._LEFT_PANEL_WIDTH + (win_w - self._LEFT_PANEL_WIDTH) // 2 + input_y = win_y + win_h - self._INPUT_BOX_Y_OFFSET + await self._click_at(input_x, input_y, desc="input box") await self._sleep(0.3) + async def _click_send_button(self) -> None: + """鼠标点击右下角发送按钮,替代可能失效的 Return 键。""" + win_x, win_y, win_w, win_h = await self._get_window_geometry() + send_x = win_x + win_w - self._SEND_BUTTON_X_OFFSET + send_y = win_y + win_h - self._SEND_BUTTON_Y_OFFSET + await self._click_at(send_x, send_y, desc="send button") + # ------------------------------------------------------------------ - # 会话定位(Ctrl+F 搜索) + # 会话定位(鼠标点击搜索框 + 点击结果) # ------------------------------------------------------------------ async def _open_session_by_name( self, name: str, - timeout_sec: float = 10.0, + timeout_sec: float = 20.0, ) -> None: - """通过微信搜索框定位并进入指定联系人的会话。 + """通过微信左侧搜索框定位并进入指定联系人的会话。 - 流程: - 1. 非阻塞激活微信窗口 + 截图显示顶部左侧有独立搜索框。WeChat 4.x 自绘 UI 对 Ctrl+F 等 + X11 修饰键组合不响应,因此全部改用鼠标点击: + 1. 激活窗口并校验焦点 2. 按 Esc 关闭可能存在的搜索框/弹窗 - 3. Ctrl+F 打开搜索 - 4. Ctrl+A 全选旧内容,粘贴 display_name + 3. 点击左侧顶部搜索框 + 4. 输入 display_name 5. 等待搜索结果渲染 - 6. 按 ↓ 选中第一个结果,回车进入会话 - 7. 再按 Esc 确保退出搜索模式,焦点落在聊天输入框 + 6. 点击第一个搜索结果进入会话 + 7. 按 Esc 退出搜索覆盖层 Args: name: 用于搜索的显示名(备注/昵称/微信号) @@ -413,7 +475,7 @@ class XdotoolDriver: Raises: BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口 - BridgeError(SEND_FAILED): 超时或 xdotool/xclip 操作失败 + BridgeError(SEND_FAILED): 超时或 xdotool 操作失败 """ if not name: raise BridgeError( @@ -442,7 +504,7 @@ class XdotoolDriver: ) from exc logger.info("[ui] step done: %s", desc) - # 1. 激活窗口(非阻塞,避免 --sync 死等) + # 1. 查找并激活窗口 window_id = await self.find_wechat_window() logger.info("[ui] found window_id=%s", window_id) if window_id is None: @@ -451,33 +513,36 @@ class XdotoolDriver: message="未找到微信窗口,无法定位会话", ) await _step(self._activate_window_fast(window_id), "激活窗口") - await _step(self._sleep(0.3), "等待窗口激活") + await _step(self._sleep(1.0), "等待窗口激活") # 2. 关闭可能存在的搜索框/弹窗 - await _step(self._key("Escape"), "关闭搜索框") - await _step(self._key("Escape"), "再按一次 Esc") - await _step(self._key("Escape"), "第三次 Esc 确保退出") - await _step(self._sleep(0.2), "等待 Esc 生效") + await _step(self._key("Escape"), "关闭搜索框/弹窗") + await _step(self._sleep(1.0), "等待 Esc 生效") - # 3. 打开搜索 - await _step(self._key("ctrl+f"), "打开搜索") - await _step(self._sleep(0.4), "等待搜索框打开") + # 3. 获取窗口几何,计算搜索框/结果坐标 + win_x, win_y, win_w, win_h = await self._get_window_geometry() + search_x = win_x + self._LEFT_PANEL_WIDTH // 2 + search_y = win_y + self._SEARCH_BOX_Y_OFFSET + result_x = win_x + self._LEFT_PANEL_WIDTH // 2 + result_y = win_y + self._SEARCH_RESULT_Y_OFFSET - # 4. 清空并输入搜索关键词 - await _step(self._key("ctrl+a"), "全选搜索框内容") - await _step(self._sleep(0.1), "等待全选") - await _step(self._paste_via_xclip(name), "粘贴搜索关键词") - await _step(self._sleep(0.8), "等待搜索结果") + # 4. 点击搜索框并输入关键词 + await _step(self._click_at(search_x, search_y, desc="search box"), "点击搜索框") + await _step(self._sleep(1.0), "等待搜索框聚焦") + # 清空可能存在的旧内容:用多次 Backspace 规避 Ctrl+A 可能失效的问题 + await _step(self._key("BackSpace", repeat=30), "清空搜索框旧内容") + await _step(self._sleep(1.0), "等待清空") + await _step(self._paste_via_xclip(name), "输入搜索关键词") + await _step(self._sleep(1.0), "等待搜索结果") - # 5. 选中第一个结果并进入会话 - await _step(self._key("Down"), "选中搜索结果") - await _step(self._sleep(0.3), "等待选中") - await _step(self._key("Return"), "进入会话") - await _step(self._sleep(0.6), "等待会话打开") + # 5. 点击第一个搜索结果进入会话 + await _step(self._click_at(result_x, result_y, desc="first search result"), "点击第一个搜索结果") + await _step(self._sleep(1.0), "等待会话打开") + await self._debug_screenshot("after_open_session") - # 6. 退出搜索模式,确保焦点在输入框 + # 6. 退出搜索模式,确保焦点在会话区域 await _step(self._key("Escape"), "退出搜索模式") - await _step(self._sleep(0.2), "等待焦点稳定") + await _step(self._sleep(1.0), "等待焦点稳定") # ------------------------------------------------------------------ # 发送文本 @@ -491,8 +556,8 @@ class XdotoolDriver: """发送文本消息。 流程: - 1. 通过微信搜索框定位会话(使用 display_name,未提供时回退到 to_wxid) - 2. 粘贴文本内容并回车发送 + 1. 通过微信左侧搜索框定位会话(使用 display_name,未提供时回退到 to_wxid) + 2. 输入文本内容并回车发送 3. 返回本地生成的 channel_msg_id Args: @@ -510,27 +575,36 @@ class XdotoolDriver: t0 = time.perf_counter() await self._open_session_by_name(name) logger.info("[send_text] 会话定位完成 (%.0fms)", (time.perf_counter() - t0) * 1000) + await self._debug_screenshot("after_session") # 1.5 鼠标点击聊天输入框强制焦点 # 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。 - # 实测点击 (800, 800) 能稳定聚焦到输入框(窗口几何 1435x876)。 await self._click_input_box() + await self._debug_screenshot("after_click_input") # 2. 输入内容并发送(三步整体超时保护,与定位会话的 10s 合计最坏 25s) async def _send_body() -> None: await self._paste_via_xclip(content) logger.info("[send_text] 文本已输入") - await self._sleep(0.2) - await self._key("Return") + await self._debug_screenshot("after_type") + await self._sleep(1.0) + # 优先用鼠标点击右下角发送按钮,规避 Return 键可能不触发发送的问题 + await self._click_send_button() try: t0 = time.perf_counter() await asyncio.wait_for(_send_body(), timeout=_SEND_BODY_TIMEOUT_SEC) - logger.info("[send_text] 回车已发送 (%.0fms)", (time.perf_counter() - t0) * 1000) + logger.info("[send_text] 发送按钮已点击 (%.0fms)", (time.perf_counter() - t0) * 1000) except asyncio.TimeoutError as exc: logger.error("[send_text] 输入/发送步骤超时") raise BridgeError( code="SEND_FAILED", message="文本输入/发送步骤超时", ) from exc - # 3. 生成 local_send_id(本地 ID,非微信原生 msg_id) + await self._sleep(1.0) + await self._debug_screenshot("after_send") + # 3. 按 Esc 退出当前聊天界面,回到会话列表,避免影响下一次发送 + await self._key("Escape") + await self._sleep(1.0) + await self._debug_screenshot("after_escape") + # 4. 生成 local_send_id(本地 ID,非微信原生 msg_id) local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}" logger.info("[send_text] 完成 local_send_id=%s (总耗时 %.0fms)", local_send_id, (time.perf_counter() - t_total) * 1000) diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 1c5f47c..f2321b1 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -1291,7 +1291,11 @@ function timingSafeEqual(a: string, b: string): boolean { return diff === 0; } -const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => { +// 在 onRequest 阶段执行代理:此时 Fastify 尚未通过 content-type parser 消费 req.raw, +// 请求体仍完整可读,http-proxy 能正确转发 POST body。 +// 原方案在 handler 中 reply.hijack() + proxy.web(req.raw, ...) 时 body 已被 Fastify +// 在 preValidation 阶段读取完毕,req.raw 已 end,导致上游收到 Content-Length 头但 body 为空 → 30s 超时。 +const bridgeOnRequest = async (req: FastifyRequest, reply: FastifyReply) => { // 鉴权 1:Bearer token(M2M 调用) // 仅当面板配置了 WOC_BRIDGE_API_TOKEN 时启用,避免空 token 误放行 let tokenAuthorized = false; @@ -1317,28 +1321,28 @@ const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => { } reply.hijack(); req.raw.url = parsed.rest; - proxy.web(req.raw, reply.raw, { - target: `http://${inst.containerName}:8088`, - onError: (err: any) => { - try { - reply.raw.writeHead(502, { 'content-type': 'application/json' }); - reply.raw.end(JSON.stringify({ - success: false, - error: { - code: 'BRIDGE_UNAVAILABLE', - message: 'bridge 服务暂时不可用', - details: String(err?.message || err || ''), - }, - })); - } catch { - // 响应已部分写出,无法再写 JSON,仅记日志 - } - }, + proxy.web(req.raw, reply.raw, { target: `http://${inst.containerName}:8088` }, (err: any) => { + try { + reply.raw.writeHead(502, { 'content-type': 'application/json' }); + reply.raw.end(JSON.stringify({ + success: false, + error: { + code: 'BRIDGE_UNAVAILABLE', + message: 'bridge 服务暂时不可用', + details: String(err?.message || err || ''), + }, + })); + } catch { + // 响应已部分写出,无法再写 JSON + } }); }; -app.all('/api/bridge/:id', bridgeHandler); -app.all('/api/bridge/:id/*', bridgeHandler); +// handler 不会被触发:onRequest 中 reply.hijack() 后 Fastify 不再走 body parser / handler +const bridgeHandler = async (_req: FastifyRequest, _reply: FastifyReply) => {}; + +app.all('/api/bridge/:id', { onRequest: bridgeOnRequest }, bridgeHandler); +app.all('/api/bridge/:id/*', { onRequest: bridgeOnRequest }, bridgeHandler); // ---------- 静态 SPA + 前端路由回退 ---------- await app.register(fstatic, { root: STATIC_DIR, wildcard: false, index: ['index.html'] });