WechatOnCloud/doc/优化方案/03-微信UI自动化架构优化方案.md
Kris 67aab58a00 docs: 新增PRD规范与WechatOnCloud改造、多应用桥接框架文档
新增三份文档:
1. 产品需求文档(PRD)编写规范
2. WechatOnCloud容器化微信改造需求方案
3. 多应用桥接框架整体设计方案
2026-07-18 16:04:25 +08:00

2561 lines
105 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 微信 UI 自动化通用架构优化方案
> 文档版本v3.0(速度优化版)
> 目标:将当前紧耦合于微信 4.x Linux 单一版本的 `xdotool_driver.py` 重构为可扩展、可校验、可恢复、高可用、高性能的通用微信 UI 自动化架构。
> 设计原则:
> - **最小可行实现优先**:每一阶段都可独立交付并产生可见收益,不允许大爆炸式重构
> - **高可用优先**:每一处都考虑并发/竞态/超时/泄漏/降级/熔断/恢复
> - **速度优先**:固定 sleep 改自适应等待、会话缓存、DB 校验调优、并行化
> - **失败可观测**:所有失败都可被监控、分类、定位
---
## 一、背景与现状
### 1.1 当前实现
当前微信 UI 自动化全部集中在 [bridge/woc_bridge/ui/xdotool_driver.py](../bridge/woc_bridge/ui/xdotool_driver.py)(约 2000+ 行单文件),实现方式:
- 通过 `xdotool search --name "微信"` 找窗口([xdotool_driver.py:128](../bridge/woc_bridge/ui/xdotool_driver.py#L128)
- 通过 `getwindowgeometry` 拿窗口 (X, Y, W, H)
- 通过**硬编码像素偏移**计算关键 UI 元素位置([xdotool_driver.py:391-396](../bridge/woc_bridge/ui/xdotool_driver.py#L391)
- 通过 `xdotool click` 点击,`xdotool type` 输入文本
- 每步之间 `sleep 1.0s` 等待 UI 响应
- send_queue 串行化([messaging/send_queue.py](../bridge/woc_bridge/messaging/send_queue.py)),相邻消息最小 3000ms 间隔
### 1.2 已确认的核心问题(含高可用性复核新增)
#### 1.2.1 功能正确性问题
| 问题 | 根因 | 后果 |
|---|---|---|
| 分辨率/DPI 一变即崩 | 像素偏移硬编码([xdotool_driver.py:391-396](../bridge/woc_bridge/ui/xdotool_driver.py#L391) | 1280x720 下点击位置错位 |
| 微信更新布局即崩 | 坐标常量 + 菜单顺序写死 | 撤回可能变成删除,造成数据丢失 |
| 无法校验操作结果 | 只返回本地生成的 `local_<ts>_<rand>` ID | 已记录过"返回 200 但消息未发出" |
| 重试即重发 | 无幂等键 | 网络抖动造成重复消息 |
| 失败后状态不可知 | 线性脚本,无状态机 | 搜索框开着、焦点错位、下一次必败 |
| experimental 功能残留失效代码 | forward/add_friend/set_remark 仍用 `ctrl+f`/`ctrl+a` | 实际跑不通 |
#### 1.2.2 高可用性问题v2.0 新增复核)
| 问题 | 严重程度 | 根因 |
|---|---|---|
| `asyncio.wait_for` 取消导致 xdotool 子进程泄漏 | **严重** | `_step` 的 deadline 先于 `_run` 内 15s 超时触发,取消时 `communicate` 被停但子进程未 kill |
| 调试截图打满磁盘 | **严重** | `_DEBUG_SCREENSHOTS=True` 每次发送写 5 张 PNG ≈ 5-10MB一周打满容器磁盘 |
| L1/L2 超时层级冲突 | **严重** | `max(1.0, remaining)` 在剩余 < 15s 时会先于 `_CMD_TIMEOUT_SEC=15s` 触发导致子进程泄漏 |
| DB 校验失败 发送失败 | **严重** | [routes/send.py:265](../bridge/woc_bridge/routes/send.py#L265) `verified=false` 直接抛 `SEND_FAILED`导致调用方重试即重发 |
| 幂等键缺 `client_request_id` 维度 | **严重** | 用户主动重发相同内容会被误吞 |
| DB 校验未验证 `talker == to_wxid` | **严重** | display_name 串号时幂等会"成功"掩盖发错人 |
| bridge 启动时不做全量清场 | **致命** | 上次崩溃残留脏状态搜索框打开/输入框有内容下一次必败 |
| `_reset_to_idle` post_verify | **严重** | Esc + BackSpace 无法覆盖 webview/多级菜单/输入法候选框等状态 |
| 无错误分类 | **严重** | 所有失败都抛 `SEND_FAILED`调用方无法区分可重试 vs 不可重试 |
| 无崩溃 watchdog | **严重** | 微信/Xvnc 崩溃后 bridge 不主动感知需人工触发 autofix |
| Prometheus metric | **严重** | 纯文本日志无法做趋势分析无法感知模板失效/失败率突增 |
| 无熔断机制 | **严重** | DB 校验/图像匹配连续失败仍每次重试浪费时间且可能造成重复发送 |
#### 1.2.3 速度问题v3.0 新增复核)
通过逐行代码分析 [xdotool_driver.py:455-545](../bridge/woc_bridge/ui/xdotool_driver.py#L455)`_open_session_by_name` [xdotool_driver.py:550-611](../bridge/woc_bridge/ui/xdotool_driver.py#L550)`send_text`实测时序拆解如下
| 阶段 | 步骤 | 耗时 | 类型 |
|---|---|---|---|
| 会话定位 | find_wechat_window | ~50ms | xdotool |
| | _activate_window_fast | ~2s | windowactivate + 轮询 |
| | sleep 1.0s × 7 | **7.0s** | **固定 sleep** |
| | _click_at × 2 | ~200ms | xdotool |
| | _key × 4 | ~200ms | xdotool |
| | _paste_via_xclip (name) | ~200ms | xdotool type |
| | _debug_screenshot × 1 | ~100ms | scrot + 文件 |
| 会话定位小计 | | **~10s** | |
| 发送体 | _debug_screenshot × 4 | ~400ms | scrot + 文件 |
| | _click_input_box | ~100ms | xdotool |
| | _paste_via_xclip (content) | ~200ms | xdotool type |
| | sleep 1.0s × 2 | **2.0s** | **固定 sleep** |
| | _click_send_button | ~100ms | xdotool |
| 发送体小计 | | **~3s** | |
| DB 校验 | _verify_sent_to_talker | **0-10s** | 轮询 0.5s 间隔 |
| 队列延时 | send_delay_ms | **3.0s** | 任务后强制 sleep |
| **总计(典型)** | | **~16s** | |
| **总计(最坏)** | | **~26s** | DB 校验 10s + 全部 sleep |
**核心发现**
1. **固定 sleep 是最大成本**10 `sleep 1.0s` = 10s占会话定位总耗时的 70%
2. **DB 校验超时过长**10s 超时 + 0.5s 轮询间隔实际微信 DB 写入通常 < 500ms
3. **调试截图在关键路径上**5 张截图 × ~100ms = 500ms且写磁盘有 I/O 开销
4. **无会话缓存**每次都重新搜索会话即使连续发给同一人
5. **_click_at 分两条命令**mousemove + click 分两次 fork可合并
6. **DB 查询与 UI 操作串行**发送前查 latest_msg_id 与激活窗口串行可并行
**结论**xdotool 命令本身执行很快毫秒级**慢在固定 sleep 与串行化等待**。优化空间巨大预期可从 16s 降到 3-5s首条/ 1-2s同联系人后续)。
### 1.3 微信 4.x Linux 技术约束(决定方案边界)
通过项目代码与文档调研确认
1. **主聊天 UI = Qt 自绘**[docker/Dockerfile:28](../docker/Dockerfile#L28) 注释"微信原生版是 Qt 程序"
2. **WeChatAppEx = Chromium 内核**[docker/Dockerfile:41](../docker/Dockerfile#L41) 注释承载小程序/公众号文章/朋友圈 webview
3. **RadiumWMPF 不响应 X11 修饰键组合** Ctrl+V只响应单字符事件项目记忆已确认
4. **不暴露 AT-SPI accessibility 树**Qt 自绘 + Chromium 默认懒启用dogtail/pyatspi 全部失效
5. **主进程不吃命令行参数**[docker/autostart:23-25](../docker/autostart#L23) 注释无法直接通过 `--remote-debugging-port` 开启 CDP
6. **send_queue 已实现串行化但无锁、无幂等、无重试**[send_queue.py:117](../bridge/woc_bridge/messaging/send_queue.py#L117)
7. **DB 校验已实现但错误处理不当**[routes/send.py:107](../bridge/woc_bridge/routes/send.py#L107) `_verify_sent_to_talker`10s 轮询
**核心结论**单一方案无法覆盖所有场景必须采用**混合方案**——Qt 自绘 UI 图像识别 + xdotool」,CEF 部分用 CDP若可开启所有操作用状态机编排 + DB 校验兜底**并补齐所有高可用性机制**。
---
## 二、技术路径选型
### 2.1 候选方案对比
| 方案 | 覆盖范围 | 元素识别 | 跨版本 | 实施成本 | 风险 | 决策 |
|---|---|---|---|---|---|---|
| A. xdotool + 像素坐标当前 | 全部 | | | 已实现 | | 作为兜底保留 |
| B. AT-SPI / dogtail | 失效 | - | - | - | - | **不可用**Qt 自绘无 a11y |
| C. 纯图像识别OpenCV | 全部 | 模板匹配 | 需多模板 | | | **采用** |
| D. CDP 协议 | CEF 部分 | 完整 DOM | | 中高 | | **PoC 后决定** |
| E. Frida native hook | 全部 | 函数级 | | 极高 | 封号 | **不采用** |
| F. pyautogui | xdotool | X11 XTest | | | | **不采用**无实质改进 |
### 2.2 选型理由
- **pyautogui xdotool 底层完全相同**都用 X11 XTest 扩展换皮不解决"微信不响应修饰键"的根本问题
- **AT-SPI 路线对 Qt 自绘应用完全失效**自绘控件不实现 `QAccessibleInterface`a11y 树里只有空 panedogtail 拿不到任何可交互节点
- **CDP 路线可行性未验证**主进程不吃命令行参数 LD_PRELOAD 注入或独立启动 WeChatAppExROI 存疑
- **OpenCV 图像识别 + xdotool 执行**是当前最务实路径保留现有执行链路仅在定位层加图像校验最小改动
### 2.3 最终技术栈
```
定位层OpenCV 模板匹配cv2.matchTemplate 多尺度)+ 窗口几何兜底
执行层xdotool保留现有 X11 XTest 链路)
编排层:自写异步状态机(不引入 transitions 库)
校验层DB 回读ground truth+ 截图对比(降级兜底)
去重层:基于 (to_wxid + content_hash + client_request_id) 的 5 分钟幂等窗口
扩展点CDP 后端PoC 验证后再实现)
高可用:熔断器 + watchdog + 错误分类 + Prometheus metric + 资源回收
```
---
## 三、目标架构(六层分层 + 高可用横切)
```
┌─────────────────────────────────────────────────────────────────┐
│ Layer 6: Capability API对外稳定接口
│ send_text / send_image / publish_moment / revoke / forward │
└────────────────────────┬────────────────────────────────────────┘
┌────────────────────────┴────────────────────────────────────────┐
│ Layer 5: Flow Orchestrator流程编排 + 幂等 + 重试 + 熔断) │
│ FlowOrchestrator + IdemCache + CircuitBreaker + SendQueue │
└────────────────────────┬────────────────────────────────────────┘
┌────────────────────────┴────────────────────────────────────────┐
│ Layer 4: Flow State Machine流程状态机 + 回滚 + 启动清场) │
│ Flow 基类 / SendTextFlow / PublishMomentFlow / ... │
│ 每 transition: pre_guard → action → post_verify → on_failure │
└────────────────────────┬────────────────────────────────────────┘
┌────────────────────────┴────────────────────────────────────────┐
│ Layer 3: Action Library原子动作可组合
│ click_element / type_text / key_press / wait_for / screenshot │
└────────────────────────┬────────────────────────────────────────┘
┌────────────────────────┴────────────────────────────────────────┐
│ Layer 2: Locator Strategies多策略定位可插拔
│ ImageLocatorOpenCV/ GeomLocator像素兜底/ CDPLocator │
│ 统一 Selector → ElementHandle 接口 │
└────────────────────────┬────────────────────────────────────────┘
┌────────────────────────┴────────────────────────────────────────┐
│ Layer 1: Backend Adapters执行后端多驱动并存
│ XdotoolBackend / OpenCVBackend / CDPBackend可选
└─────────────────────────────────────────────────────────────────┘
横切关注点(贯穿所有层):
┌─────────────────────────────────────────────────────────────────┐
│ HA: Watchdog微信/Xvnc 崩溃感知) │
│ HA: MetricsPrometheus 端点) │
│ HA: ResourceReaper截图清理 / 模板 LRU / 子进程收尸) │
│ HA: ErrorClassifier错误码分类 + 重试策略) │
│ HA: TraceID贯穿单次请求的所有日志行
└─────────────────────────────────────────────────────────────────┘
```
### 3.1 分层职责
| | 职责 | 不做什么 |
|---|---|---|
| L1 Backend | 执行原子操作click/type/screenshot | 不做元素定位不做流程编排 |
| L2 Locator | 找元素位置返回 ElementHandle | 不执行点击不决定流程顺序 |
| L3 Action | 组合 L1+L2 完成有语义的原子动作"点击并校验" | 不跨步骤 |
| L4 Flow | 编排多步骤完成业务流程"发文本" | 不直接调 xdotool |
| L5 Orchestrator | 幂等检查入队重试策略熔断 | 不关心 UI 细节 |
| L6 Capability | 对外稳定的业务 API | 不关心实现后端 |
### 3.2 依赖方向
```
L6 → L5 → L4 → L3 → L2 → L1
L4 可直接调 L3不绕道 L2Action 内部组合 L1+L2
L5 可直接访问 DB用于幂等与校验不绕道 L4
横切 HA 模块可被任意层调用
```
### 3.3 并发模型(关键决策)
**决策:串行化只在 `send_queue` 一层Flow 内部不再加锁**
**理由**send_queue 的单 worker + asyncio.Queue 已经保证了"同一时刻只有一个 Flow 在跑"[send_queue.py:117](../bridge/woc_bridge/messaging/send_queue.py#L117) `while True` 循环)。如果在 Flow 内再加 `asyncio.Lock` 会形成"双重串行"不死锁但语义混乱排障困难
**只读探测的并发处理**
- `detect_login_state` / `is_wechat_running` / `get_window_geometry` 等只读探测走队列外快速路径
- 但需共享一把**轻量 `asyncio.Lock`** 与发送操作互斥避免探测期间被发送操作打断
- 探测用 `with lock`发送也用 `with lock`但发送额外受队列节流
```python
# bridge/ui/backends/xdotool.py
class XdotoolBackend:
def __init__(self, display: str = ":1"):
self.display = display
self._ui_lock = asyncio.Lock() # UI 单焦点保护
async def click(self, x: int, y: int) -> None:
async with self._ui_lock:
# 实际点击
...
```
**注意**这把锁的粒度是"单次原子操作"不是"整个 Flow"。Flow 的串行性由 send_queue 保证锁只防止探测与发送的并发冲突
### 3.4 多实例隔离MVP 不涉及,但架构必须预留)
**问题**当前 `config.py:126` `display: str = ":1"` 是全局单例两个微信实例共享同一 X server `xdotool search --name "微信"` 跨实例匹配鼠标键盘事件跨实例串扰
**架构预留**
- `XdotoolBackend` 必须接受 `display` 参数当前已做到[xdotool_driver.py:39](../bridge/woc_bridge/ui/xdotool_driver.py#L39)
- `xdotool search` 改用 `--pid <wechat_pid>` 精确匹配本实例进程的窗口
- `AppState` 改为 per-instance 而非全局单例属于阶段 5 多应用框架
- **MVP 阶段单实例不触发此问题**
---
## 四、核心模块详细设计
### 4.1 Layer 1 - Backend Adapters
**职责**把现有的 `xdotool` 调用、`scrot` 截图、`getwindowgeometry` 等封装为统一后端接口。**不做元素定位**。
#### 4.1.1 BackendProtocol 定义
```python
# bridge/ui/backends/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
import asyncio
@dataclass
class WindowGeometry:
x: int; y: int; width: int; height: int
class BackendProtocol(ABC):
"""所有执行后端必须实现的原子能力。"""
@abstractmethod
async def click(self, x: int, y: int) -> None:
"""在屏幕绝对坐标点击。"""
@abstractmethod
async def type_text(self, text: str) -> None:
"""逐字符输入文本xdotool type绕过修饰键问题。"""
@abstractmethod
async def key_press(self, key: str, repeat: int = 1) -> None:
"""按键,支持 repeat。"""
@abstractmethod
async def screenshot(self) -> bytes:
"""截全屏,返回 PNG bytes。"""
@abstractmethod
async def get_window_geometry(self, window_title: str) -> WindowGeometry:
"""按窗口标题找窗口并返回几何。"""
@abstractmethod
async def activate_window(self, window_title: str) -> None:
"""激活指定窗口。"""
@property
def capabilities(self) -> set[str]:
"""该后端支持的能力集合。"""
return {"click", "type", "key", "screenshot", "window"}
```
#### 4.1.2 XdotoolBackend 实现(含超时与取消安全)
**关键高可用性设计**`_run` 改为上下文管理器确保任何取消场景下子进程都被 kill + wait 收尸
```python
# bridge/ui/backends/xdotool.py
import asyncio
import os
import logging
from contextlib import asynccontextmanager
from typing import Optional, AsyncIterator
logger = logging.getLogger("woc-bridge")
# 分层超时(关键决策,解决 L1/L2 冲突)
_CMD_TIMEOUT_SEC = 5.0 # L1: 单命令超时,防子进程挂死(从 15s 降到 5s
_STEP_MIN_TIMEOUT_SEC = 7.0 # L2: 单步骤超时下限 = L1 + 2s避免 L2 先于 L1 触发
_FLOW_TIMEOUT_SEC = 30.0 # L3: 整流程超时
_HTTP_TIMEOUT_SEC = 60.0 # L4: HTTP 请求超时 = L3 + DB 校验 10s + 余量
class XdotoolBackend(BackendProtocol):
def __init__(self, display: str = ":1"):
self.display = display
self._ui_lock = asyncio.Lock() # UI 单焦点保护(与只读探测共享)
def _env(self) -> dict:
env = dict(os.environ)
env["DISPLAY"] = self.display
return env
@asynccontextmanager
async def _run_managed(self, args: list[str]) -> AsyncIterator[tuple[int, bytes, bytes]]:
"""安全的子进程执行上下文管理器。
保证:
- 无论协程如何退出(正常返回/超时/取消/异常),子进程都会被 kill + wait
- 超时不重试,退避交给上层
"""
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
try:
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=_CMD_TIMEOUT_SEC,
)
yield proc.returncode, stdout, stderr
except (asyncio.TimeoutError, asyncio.CancelledError):
# 超时或取消时,显式 kill + wait 收尸,防孤儿进程
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error("[backend] proc.wait() 超时,孤儿进程残留: %s", args[0])
raise
except Exception:
# 兜底:任何异常都确保 kill
if proc.returncode is None:
proc.kill()
try:
await proc.wait()
except Exception:
pass
raise
async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]:
"""执行一条命令并返回 (returncode, stdout, stderr)。"""
async with self._run_managed(args) as result:
return result
async def click(self, x: int, y: int) -> None:
async with self._ui_lock:
await self._run(["xdotool", "mousemove", "--sync", str(x), str(y)])
await self._run(["xdotool", "click", "1"])
async def type_text(self, text: str) -> None:
async with self._ui_lock:
await self._run(["xdotool", "type", "--", text])
async def key_press(self, key: str, repeat: int = 1) -> None:
async with self._ui_lock:
args = ["xdotool", "key"]
if repeat > 1:
args.extend(["--repeat", str(repeat)])
args.append(key)
await self._run(args)
async def screenshot(self) -> bytes:
"""截图并返回 PNG bytes不写文件避免磁盘泄漏。"""
async with self._ui_lock:
proc = await asyncio.create_subprocess_exec(
["scrot", "-o", "-"], # 输出到 stdout
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env=self._env(),
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
return stdout
except (asyncio.TimeoutError, asyncio.CancelledError):
proc.kill()
await proc.wait()
raise
```
**关键改进**
1. `_CMD_TIMEOUT_SEC` 15s 降到 5sxdotool 命令本身是毫秒级5s 足够兜底
2. `_run_managed` 上下文管理器确保任何取消场景都 kill + wait
3. 截图用 `scrot -o -` 输出到 stdout**不写文件**避免磁盘泄漏
4. `_ui_lock` 保护 UI 单焦点
#### 4.1.3 OpenCVBackend图像识别专用
```python
# bridge/ui/backends/opencv.py
import cv2
import numpy as np
from collections import OrderedDict
from typing import Optional
class OpenCVBackend:
"""图像识别后端,只做 find_element不执行点击。"""
def __init__(self, template_dir: str, max_cache_size: int = 20):
self.template_dir = template_dir
# LRU 模板缓存,上限 20 个(避免内存泄漏)
self._cache: OrderedDict[str, np.ndarray] = OrderedDict()
self._max_cache_size = max_cache_size
def _load_template(self, name: str) -> np.ndarray:
if name in self._cache:
self._cache.move_to_end(name) # LRU 更新
return self._cache[name]
path = os.path.join(self.template_dir, name)
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
if img is None:
raise FileNotFoundError(f"模板不存在: {path}")
self._cache[name] = img
if len(self._cache) > self._max_cache_size:
self._cache.popitem(last=False) # LRU 淘汰
return img
async def find_template(
self, screenshot_png: bytes, template_name: str,
threshold: float = 0.80,
scales: list[float] = None,
roi: Optional[tuple[int, int, int, int]] = None,
) -> Optional[tuple[int, int, int, int, float]]:
"""在截图中找模板,返回 (x, y, w, h, confidence) 或 None。
Args:
screenshot_png: PNG bytes
template_name: 模板文件相对路径
threshold: 匹配阈值(默认 0.80
scales: 多尺度匹配列表(默认 [1.0, 0.75, 1.25, 1.5]
roi: (x, y, w, h) 限制搜索区域窗口几何内None 表示全屏
"""
scales = scales or [1.0, 0.75, 1.25, 1.5]
# PNG bytes → np.ndarray灰度
img_arr = np.frombuffer(screenshot_png, np.uint8)
img = cv2.imdecode(img_arr, cv2.IMREAD_GRAYSCALE)
if roi:
x, y, w, h = roi
img = img[y:y+h, x:x+w]
tpl = self._load_template(template_name)
best_val, best_loc, best_size = 0.0, None, (0, 0)
# 逐个尺度处理,避免并行加载多个缩放图占内存
for scale in scales:
scaled = cv2.resize(tpl, None, fx=scale, fy=scale)
res = cv2.matchTemplate(img, scaled, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
if max_val > best_val:
best_val = max_val
best_loc = max_loc
best_size = (scaled.shape[1], scaled.shape[0])
del scaled, res # 显式释放
if best_val < threshold:
return None
# 如果用了 ROI坐标要加上 ROI 偏移
if roi:
best_loc = (best_loc[0] + roi[0], best_loc[1] + roi[1])
return (best_loc[0], best_loc[1], best_size[0], best_size[1], best_val)
```
**关键改进**
1. LRU 模板缓存上限 20 每个约 2MB总上限 40MB
2. 多尺度匹配**逐个处理**显式 `del` 释放避免并行占内存
3. 支持 ROI 限制只在窗口区域内搜性能提升约 4
4. 返回置信度供熔断决策
### 4.2 Layer 2 - Locator Strategies
**职责**"找元素""点元素"分离
```python
# bridge/ui/locators/base.py
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class GeomSpec:
"""窗口几何相对坐标兜底定位。"""
relative_to: str = "window" # window / window_top_left / window_bottom_right
x_ratio: float = 0.0
y_ratio: float = 0.0
x_offset: int = 0
y_offset: int = 0
@dataclass
class Selector:
"""声明式元素选择器,可同时配置多种定位策略。"""
kind: str # 'search_box' / 'send_button' / 'input_box'
by_image: Optional[str] = None # 模板图文件名
by_geom: Optional[GeomSpec] = None # 几何兜底
threshold: float = 0.80 # 图像匹配阈值
require_image: bool = False # 高风险操作设 True图像失败即拒绝
description: str = ""
@dataclass
class ElementHandle:
"""统一的元素句柄,由 Locator 产出。"""
selector: Selector
x: int; y: int
w: int = 0; h: int = 0
confidence: float = 1.0
strategy: str = "geom" # 'image' / 'geom'
@property
def center(self) -> tuple[int, int]:
return (self.x, self.y)
```
**LocatorRegistry** 加载 YAML profile `(app_version, resolution)` 选择
```python
# bridge/ui/locators/registry.py
class LocatorRegistry:
"""加载 UI profile YAML提供 selector 查询。
支持深色/浅色模式切换(通过运行时 set_theme
"""
def __init__(self, profile_dir: str, opencv: OpenCVBackend):
self.profile_dir = profile_dir
self.opencv = opencv
self.selectors: dict[str, Selector] = {}
self.theme: str = "light" # 'light' / 'dark'
self._fail_counts: dict[str, int] = {} # per-kind 失败计数(熔断用)
self._circuit_open_until: dict[str, float] = {} # per-kind 熔断截止时间
def load(self, app_version: str, resolution: tuple[int, int]) -> None:
"""按 (版本, 分辨率) 加载最匹配的 profile。"""
candidates = [
f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml",
f"wechat_{app_version}.yaml",
"default.yaml",
]
for fname in candidates:
path = os.path.join(self.profile_dir, fname)
if os.path.exists(path):
self._load_yaml(path)
logger.info("[locator] 加载 profile: %s", fname)
return
logger.warning("[locator] 无可用 UI profile回退到硬编码默认值")
self._load_defaults()
def set_theme(self, theme: str) -> None:
"""切换深色/浅色模式模板目录。"""
if theme != self.theme:
self.theme = theme
logger.info("[locator] 切换主题: %s", theme)
def get(self, kind: str) -> Selector:
return self.selectors.get(kind) or self._default_selector(kind)
def is_circuited(self, kind: str) -> bool:
"""该 kind 的图像匹配是否被熔断。"""
deadline = self._circuit_open_until.get(kind, 0)
return time.monotonic() < deadline
def record_image_failure(self, kind: str) -> None:
"""记录图像匹配失败,连续 10 次触发熔断 5 分钟。"""
self._fail_counts[kind] = self._fail_counts.get(kind, 0) + 1
if self._fail_counts[kind] >= 10:
self._circuit_open_until[kind] = time.monotonic() + 300
logger.warning("[locator] kind=%s 图像匹配熔断 5 分钟", kind)
def record_image_success(self, kind: str) -> None:
"""记录图像匹配成功,重置失败计数。"""
self._fail_counts[kind] = 0
self._circuit_open_until.pop(kind, None)
```
**Profile YAML 示例**`bridge/ui/profiles/wechat_4.0_1920x1080.yaml`
以下坐标基于用户实际截图估算
- 容器分辨率 `1920x1080`DPI 96浅色模式
- 左侧会话栏宽度约 300px
- 搜索框位于左栏顶部未激活状态为空白输入框
- 发送按钮位于右下角激活态为绿色
```yaml
# 微信 4.0 Linux, 1920x1080 分辨率,浅色模式
# 截图参考bridge/ui/profiles/templates/screenshot/03-主界面.png
search_box:
by_image: "wechat_4.0/light/search_box.png"
# 裁剪建议:从 03-主界面.png 截取 x=40~270, y=32~68
by_geom:
relative_to: window_top_left
x_ratio: 0.08 # 左栏中部,约 150px @ 1920w
y_ratio: 0.05 # 约 55px
width_ratio: 0.12 # 约 230px
height_ratio: 0.04 # 约 40px
threshold: 0.80
description: "左栏顶部搜索框(未激活状态)"
# 截图参考02-聊天界面.png
send_button:
by_image: "wechat_4.0/light/send_button.png"
# 裁剪建议:截取右下角绿色"发送"按钮,约 60x30
by_geom:
relative_to: window_bottom_right
x_offset: -60
y_offset: -30
threshold: 0.85
require_image: false # 发送按钮不强制图像geom 兜底足够)
description: "右下角发送按钮"
# 截图参考02-聊天界面.png
input_box:
by_image: "wechat_4.0/light/input_box.png"
# 裁剪建议:截取底部输入框区域,约 400x50
by_geom:
relative_to: window
x_ratio: 0.65 # 右侧面板中部偏左
y_ratio: 0.93 # 底部上方
threshold: 0.75
description: "聊天输入框"
# 用于发送后校验输入框是否清空
input_box_empty:
by_image: "wechat_4.0/light/input_box_empty.png"
by_geom:
relative_to: window
x_ratio: 0.65
y_ratio: 0.93
threshold: 0.80
description: "空输入框(发送后校验用)"
# 搜索结果第一项,约搜索框下方 70px
search_result_first:
by_image: "wechat_4.0/light/search_result_first.png"
by_geom:
relative_to: window_top_left
x_ratio: 0.08
y_ratio: 0.11 # 约 120px会话项高度约 70px
threshold: 0.80
description: "搜索结果第一项"
# 主界面模板(校验是否回到初始空白态)
# 截图参考03-主界面.png
main_view:
by_image: "wechat_4.0/light/main_view.png"
# 裁剪建议:截取右侧主区域空白+微信 logo约 400x300
by_geom:
relative_to: window
x_ratio: 0.65
y_ratio: 0.50
threshold: 0.75
description: "主界面空白态(校验用)"
# 高风险操作强制图像命中(阶段 2 后补模板)
revoke_menu_item:
by_image: "wechat_4.0/light/revoke_menu_item.png"
threshold: 0.85
require_image: true
```
### 4.3 Layer 3 - Action Library
```python
# bridge/ui/actions.py
class Actions:
def __init__(self, backend: BackendProtocol, locator: LocatorRegistry):
self.backend = backend
self.locator = locator
async def find_element(
self, kind: str, win_geom: WindowGeometry,
) -> ElementHandle:
"""按 selector 找元素。优先图像,失败回退几何。
高风险操作require_image=True图像失败即抛异常。
"""
sel = self.locator.get(kind)
# 策略1图像匹配未熔断时
if sel.by_image and not self.locator.is_circuited(kind):
shot = await self.backend.screenshot()
roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
result = await self.backend.find_template(
shot, sel.by_image, threshold=sel.threshold, roi=roi,
)
if result:
x, y, w, h, conf = result
self.locator.record_image_success(kind)
return ElementHandle(
selector=sel, x=x+w//2, y=y+h//2, w=w, h=h,
confidence=conf, strategy="image",
)
# 图像失败
self.locator.record_image_failure(kind)
logger.warning("[action] 图像匹配失败 kind=%s conf=%.3f", kind, 0.0)
# 高风险操作:图像失败即拒绝
if sel.require_image:
raise ElementNotFoundError(
kind=kind, reason="image_match_failed_require_image",
)
logger.warning("[action] 图像匹配失败 kind=%s,回退几何", kind)
# 策略2几何兜底
return self._geom_find(sel, win_geom)
async def click_element(self, kind: str, win_geom: WindowGeometry) -> ElementHandle:
"""点击元素,返回元素句柄。"""
elem = await self.find_element(kind, win_geom)
await self.backend.click(elem.x, elem.y)
logger.info("[action] click %s at (%d,%d) strategy=%s conf=%.3f",
kind, elem.x, elem.y, elem.strategy, elem.confidence)
return elem
async def type_into(self, kind: str, text: str, win_geom: WindowGeometry,
clear_first: bool = True) -> None:
"""点击元素 + 清空 + 输入文本。"""
await self.click_element(kind, win_geom)
if clear_first:
await self.backend.key_press("BackSpace", repeat=30)
await self.backend.type_text(text)
async def wait_for(self, kind: str, win_geom: WindowGeometry,
timeout: float = 5.0, interval: float = 0.3) -> Optional[ElementHandle]:
"""轮询等待某元素出现。"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
elem = await self.find_element(kind, win_geom)
if elem:
return elem
except ElementNotFoundError:
pass
await asyncio.sleep(interval)
return None
async def screenshot(self) -> bytes:
return await self.backend.screenshot()
```
### 4.4 Layer 4 - Flow State Machine
**最关键的架构升级**含启动清场post_verify安全重置
#### 4.4.1 状态定义
```python
# bridge/ui/flow.py
from enum import Enum
from dataclasses import dataclass, field
from typing import Optional
class FlowState(Enum):
INITIAL = "initial"
WINDOW_ACTIVATED = "window_activated"
SEARCH_BOX_CLICKED = "search_box_clicked"
QUERY_TYPED = "query_typed"
SESSION_OPENED = "session_opened"
INPUT_FOCUSED = "input_focused"
TEXT_TYPED = "text_typed"
SENT = "sent"
CLEANED_UP = "cleaned_up"
FAILED = "failed"
@dataclass
class FlowContext:
"""流程上下文。"""
to_wxid: str
content: str
display_name: str
client_request_id: str = "" # 幂等键维度
win_geom: Optional[WindowGeometry] = None
before_msg_id: int = 0
local_send_id: str = ""
verified: bool = False
image_confidence: float = 0.0 # 供 metric 上报
@dataclass
class FlowResult:
success: bool
local_id: str = ""
verified: bool = False
skipped: bool = False
error: str = ""
error_code: str = ""
state: str = ""
image_confidence: float = 0.0
duration_ms: int = 0
class FlowError(Exception):
"""流程错误基类。"""
def __init__(self, code: str, message: str, retryable: bool = False):
self.code = code
self.message = message
self.retryable = retryable
super().__init__(message)
class TransientError(FlowError):
"""瞬时错误,可重试。"""
def __init__(self, code: str, message: str):
super().__init__(code, message, retryable=True)
class PermanentError(FlowError):
"""永久错误,不可重试。"""
def __init__(self, code: str, message: str):
super().__init__(code, message, retryable=False)
```
#### 4.4.2 Flow 基类(含全量清场 + post_verify
```python
# bridge/ui/flow.py
class Flow:
"""流程基类。"""
def __init__(self, actions: Actions, db_reader=None):
self.actions = actions
self.db = db_reader
# 注意:不加 asyncio.Lock串行性由 send_queue 保证
async def _reset_to_idle(self, win_geom: Optional[WindowGeometry]) -> bool:
"""安全重置到 IDLE 状态。
多策略清场:
1. Esc×3关多级菜单
2. 点击窗口空白区域(退 webview 焦点)
3. BackSpace×30清搜索框
4. Esc×2关残留弹窗
5. 截图校验"主界面",失败返回 False
幂等:重复调用无副作用。
失败不抛异常(吞掉原始错误),返回 False 表示未恢复。
"""
try:
for _ in range(3):
await self.actions.backend.key_press("Escape")
await asyncio.sleep(0.3)
# 点击窗口左上角空白区域(退 webview 焦点)
if win_geom:
await self.actions.backend.click(win_geom.x + 10, win_geom.y + 10)
await asyncio.sleep(0.3)
await self.actions.backend.key_press("BackSpace", repeat=30)
await asyncio.sleep(0.3)
for _ in range(2):
await self.actions.backend.key_press("Escape")
await asyncio.sleep(0.3)
# 截图校验:是否回到主界面
# 如果有 main_view.png 模板,匹配它
return await self._verify_main_view(win_geom)
except Exception as e:
logger.error("[flow] _reset_to_idle 异常: %s", e)
return False
async def _verify_main_view(self, win_geom: Optional[WindowGeometry]) -> bool:
"""校验是否回到主界面(匹配 main_view 模板)。"""
try:
elem = await self.actions.wait_for("main_view", win_geom, timeout=2.0)
return elem is not None
except Exception:
# 无模板时降级为 True不阻塞流程
return True
async def _full_cleanup_on_startup(self) -> bool:
"""bridge 启动时的全量清场。
上次崩溃可能残留脏状态。连续重置 3 次,仍失败则告警。
"""
win_geom = None
try:
win_geom = await self.actions.backend.get_window_geometry("微信")
except Exception:
logger.warning("[flow] 启动清场:微信窗口未找到,跳过")
return True # 窗口都没有,无需清场
for attempt in range(3):
if await self._reset_to_idle(win_geom):
logger.info("[flow] 启动清场成功 (attempt=%d)", attempt + 1)
return True
logger.warning("[flow] 启动清场失败 (attempt=%d)", attempt + 1)
logger.error("[flow] 启动清场 3 次失败,可能需要人工 VNC 接入")
return False
```
#### 4.4.3 SendTextFlow 实现
```python
# bridge/ui/flows/send_text.py
class SendTextFlow(Flow):
"""发文本流程的状态机。"""
async def run(self, ctx: FlowContext) -> FlowResult:
t_start = time.perf_counter()
state = FlowState.INITIAL
try:
# 整流程超时保护L3
async with asyncio.timeout(_FLOW_TIMEOUT_SEC):
state = await self._activate(ctx)
state = await self._click_search_box(ctx)
state = await self._type_query(ctx)
state = await self._open_session(ctx)
state = await self._focus_input(ctx)
state = await self._type_content(ctx)
state = await self._send(ctx)
state = await self._cleanup(ctx)
return FlowResult(
success=True,
local_id=ctx.local_send_id,
verified=ctx.verified,
state=state.value,
image_confidence=ctx.image_confidence,
duration_ms=int((time.perf_counter() - t_start) * 1000),
)
except FlowError as e:
logger.error("[flow] 失败 state=%s code=%s err=%s", state.value, e.code, e.message)
# 失败后重置(不阻塞原始错误传播)
await self._reset_to_idle(ctx.win_geom)
return FlowResult(
success=False,
error=e.message,
error_code=e.code,
state=state.value,
duration_ms=int((time.perf_counter() - t_start) * 1000),
)
except asyncio.TimeoutError:
logger.error("[flow] 整流程超时 state=%s", state.value)
await self._reset_to_idle(ctx.win_geom)
return FlowResult(
success=False,
error="整流程超时",
error_code="SEND_TIMEOUT",
state=state.value,
duration_ms=int((time.perf_counter() - t_start) * 1000),
)
async def _activate(self, ctx: FlowContext) -> FlowState:
await self.actions.backend.activate_window("微信")
await asyncio.sleep(1.0)
ctx.win_geom = await self.actions.backend.get_window_geometry("微信")
await self.actions.backend.key_press("Escape")
await asyncio.sleep(0.5)
return FlowState.WINDOW_ACTIVATED
async def _click_search_box(self, ctx: FlowContext) -> FlowState:
await self.actions.click_element("search_box", ctx.win_geom)
await asyncio.sleep(1.0)
return FlowState.SEARCH_BOX_CLICKED
async def _type_query(self, ctx: FlowContext) -> FlowState:
await self.actions.backend.key_press("BackSpace", repeat=30)
await asyncio.sleep(0.5)
await self.actions.backend.type_text(ctx.display_name)
await asyncio.sleep(1.0)
return FlowState.QUERY_TYPED
async def _open_session(self, ctx: FlowContext) -> FlowState:
# 点击搜索结果第一项(用 geom 兜底,图像匹配不强求)
result_y = ctx.win_geom.y + 120
await self.actions.backend.click(ctx.win_geom.x + 150, result_y)
await asyncio.sleep(1.0)
await self.actions.backend.key_press("Escape")
await asyncio.sleep(0.5)
return FlowState.SESSION_OPENED
async def _focus_input(self, ctx: FlowContext) -> FlowState:
await self.actions.click_element("input_box", ctx.win_geom)
await asyncio.sleep(0.3)
return FlowState.INPUT_FOCUSED
async def _type_content(self, ctx: FlowContext) -> FlowState:
await self.actions.backend.type_text(ctx.content)
await asyncio.sleep(1.0)
return FlowState.TEXT_TYPED
async def _send(self, ctx: FlowContext) -> FlowState:
# 记录发送前的 DB 最新 msg_id
if self.db:
try:
ctx.before_msg_id = await self.db.get_latest_msg_id(ctx.to_wxid)
except Exception:
ctx.before_msg_id = 0
# 点击发送按钮
elem = await self.actions.click_element("send_button", ctx.win_geom)
ctx.image_confidence = max(ctx.image_confidence, elem.confidence)
await asyncio.sleep(1.0)
# 校验DB 是否出现新消息
ctx.verified = await self._verify_sent(ctx)
ctx.local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
return FlowState.SENT
async def _verify_sent(self, ctx: FlowContext) -> bool:
"""发送后校验5 秒窗口内 DB 必须出现新消息。
关键:必须验证 talker == to_wxid防止 display_name 串号。
"""
if not self.db or ctx.before_msg_id == 0:
return await self._verify_by_screenshot(ctx)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
msgs = await self.db.get_messages(
ctx.to_wxid, limit=1, after_local_id=ctx.before_msg_id,
)
if msgs:
msg = msgs[0]
# 关键校验talker 必须匹配 + 内容必须匹配 + is_sender=True
if (msg.talker == ctx.to_wxid
and msg.content == ctx.content
and msg.is_sender):
return True
await asyncio.sleep(0.5)
return False
async def _verify_by_screenshot(self, ctx: FlowContext) -> bool:
"""降级校验:截图匹配空输入框模板。"""
try:
elem = await self.actions.wait_for(
"input_box_empty", ctx.win_geom, timeout=2.0,
)
return elem is not None
except Exception:
return False
async def _cleanup(self, ctx: FlowContext) -> FlowState:
await self.actions.backend.key_press("Escape")
await asyncio.sleep(0.5)
return FlowState.CLEANED_UP
```
### 4.5 Layer 5 - Flow Orchestrator含幂等 + 熔断 + 重试)
#### 4.5.1 幂等缓存(含 client_request_id
```python
# bridge/ui/idem_cache.py
import hashlib
import time
from typing import Any, Optional
from collections import OrderedDict
class IdemCache:
"""内存短时去重TTL=300s5 分钟)。
幂等键 = (flow_name, to_wxid, content_hash, client_request_id)
- client_request_id 非空时:调用方换 id 即可重发
- client_request_id 为空时:纯 to_wxid + content_hash 去重(旧调用方兼容)
"""
def __init__(self, ttl: int = 300, max_size: int = 1000):
self.ttl = ttl
self._cache: OrderedDict[str, tuple[Any, float]] = OrderedDict()
self._max_size = max_size
def _make_key(self, flow_name: str, to_wxid: str, content: str,
client_request_id: str = "") -> str:
# 用完整 SHA256不截断避免短消息碰撞
content_hash = hashlib.sha256(content.encode()).hexdigest()
return f"{flow_name}:{to_wxid}:{content_hash}:{client_request_id}"
def get(self, key: str) -> Optional[Any]:
if key in self._cache:
val, ts = self._cache[key]
if time.monotonic() - ts < self.ttl:
self._cache.move_to_end(key)
return val
del self._cache[key]
return None
def set(self, key: str, val: Any) -> None:
self._cache[key] = (val, time.monotonic())
if len(self._cache) > self._max_size:
self._cache.popitem(last=False) # LRU 淘汰
def clear(self) -> None:
"""bridge 重启时清空(内存缓存不持久化)。"""
self._cache.clear()
```
#### 4.5.2 熔断器
```python
# bridge/ui/circuit_breaker.py
import time
from enum import Enum
class CircuitState(Enum):
CLOSED = "closed" # 正常
OPEN = "open" # 熔断,拒绝请求
HALF_OPEN = "half_open" # 半开,试探
class CircuitBreaker:
"""通用熔断器。
连续失败 N 次开熔断,冷却期后半开试探,成功则关闭。
"""
def __init__(self, name: str, failure_threshold: int = 5,
recovery_timeout: int = 30):
self.name = name
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self._failures = 0
self._state = CircuitState.CLOSED
self._opened_at = 0.0
@property
def state(self) -> CircuitState:
if self._state == CircuitState.OPEN:
if time.monotonic() - self._opened_at >= self.recovery_timeout:
self._state = CircuitState.HALF_OPEN
logger.info("[circuit] %s 进入半开状态", self.name)
return self._state
def allow(self) -> bool:
"""是否允许通过。"""
return self.state in (CircuitState.CLOSED, CircuitState.HALF_OPEN)
def record_success(self) -> None:
if self._state == CircuitState.HALF_OPEN:
logger.info("[circuit] %s 半开试探成功,关闭熔断", self.name)
self._failures = 0
self._state = CircuitState.CLOSED
def record_failure(self) -> None:
self._failures += 1
if self._failures >= self.failure_threshold:
self._state = CircuitState.OPEN
self._opened_at = time.monotonic()
logger.warning("[circuit] %s 熔断开启(失败 %d 次)", self.name, self._failures)
```
#### 4.5.3 FlowOrchestrator
```python
# bridge/ui/orchestrator.py
class FlowOrchestrator:
"""管理所有 UI 流程的编排、幂等、重试、熔断。"""
def __init__(self, send_queue: SendQueue, idem_cache: IdemCache,
actions: Actions, db_reader=None):
self.send_queue = send_queue
self.idem_cache = idem_cache
self.actions = actions
self.db = db_reader
# DB 校验熔断器:连续失败 5 次熔断 30s
self.db_verify_breaker = CircuitBreaker("db_verify", 5, 30)
async def send_text(self, to_wxid: str, content: str,
display_name: str = None,
client_request_id: str = "") -> FlowResult:
# 1. 幂等检查
idem_key = self.idem_cache._make_key(
"send_text", to_wxid, content, client_request_id,
)
cached = self.idem_cache.get(idem_key)
if cached:
logger.info("[orch] 跳过重复发送 idem_key=%s", idem_key[:32])
return FlowResult(
success=True, local_id=cached, verified=True,
skipped=True, state="idempotent_skip",
)
# 2. 入队串行执行
async def _run():
flow = SendTextFlow(self.actions, self.db)
ctx = FlowContext(
to_wxid=to_wxid, content=content,
display_name=display_name or to_wxid,
client_request_id=client_request_id,
)
return await flow.run(ctx)
try:
result = await self.send_queue.enqueue(_run)
except BridgeError as e:
if e.code == "RATE_LIMITED":
return FlowResult(
success=False, error=e.message, error_code="RATE_LIMITED",
state="rate_limited",
)
raise
# 3. 成功才写幂等缓存
if result.success and result.verified:
self.idem_cache.set(idem_key, result.local_id)
# 4. DB 校验熔断
if not result.verified:
self.db_verify_breaker.record_failure()
else:
self.db_verify_breaker.record_success()
return result
```
### 4.6 Layer 6 - Capability API
```python
# bridge/ui/capabilities.py
from dataclasses import dataclass
from typing import Optional
@dataclass
class SendResult:
"""对调用方的稳定响应。"""
success: bool # 发送动作是否完成
local_id: str = ""
verified: bool = False # DB 校验是否通过
skipped: bool = False # 是否因幂等跳过
error: str = ""
error_code: str = "" # 错误分类(见错误码表)
retryable: bool = False # 是否可重试
diagnostics: dict = None # 置信度、耗时等诊断信息
class WeChatCapabilities:
"""对外稳定的业务接口,与底层后端完全解耦。"""
def __init__(self, orchestrator: FlowOrchestrator):
self.orch = orchestrator
async def send_text(self, to_wxid: str, content: str,
display_name: str = None,
client_request_id: str = "") -> SendResult:
result = await self.orch.send_text(
to_wxid, content, display_name, client_request_id,
)
return SendResult(
success=result.success,
local_id=result.local_id,
verified=result.verified,
skipped=result.skipped,
error=result.error,
error_code=result.error_code,
retryable=result.error_code in RETRYABLE_CODES,
diagnostics={
"image_confidence": result.image_confidence,
"duration_ms": result.duration_ms,
"state": result.state,
},
)
```
---
## 五、横切高可用模块
### 5.1 错误码分类表
```python
# bridge/ui/errors.py
# 可重试错误(瞬时性,重试可能成功)
RETRYABLE_CODES = {
"RATE_LIMITED", # 队列限流
"SEND_TIMEOUT", # xdotool 超时
"STATE_DIRTY", # 状态机脏状态,重置后可重试
"X11_TEMP_UNAVAILABLE", # X11 临时不可用
}
# 不可重试错误(永久性,重试无意义)
NON_RETRYABLE_CODES = {
"WECHAT_NOT_LOGGED_IN", # 需人工扫码
"WINDOW_NOT_FOUND", # 微信未运行
"ELEMENT_NOT_FOUND", # 模板失效,需更新模板
"DB_VERIFY_FAILED", # 消息可能已发到错会话,需人工排查
"AMBIGUOUS_CONTACT", # 重名联系人,需指定更精确 display_name
"X11_UNAVAILABLE", # Xvnc 崩溃,需重启容器
"BRIDGE_CIRCUITED", # 熔断中
}
# 特殊DB_VERIFY_FAILED 不可重试
# 理由:消息可能已发出,重试就是重复。调用方应人工排查或等幂等窗口过期。
```
### 5.2 重试策略
```python
# bridge/ui/retry.py
import random
class RetryPolicy:
"""重试策略:指数退避 + 抖动。"""
def __init__(self, max_attempts: int = 2, base_delay: float = 1.0,
max_delay: float = 30.0):
self.max_attempts = max_attempts # UI 自动化最多重试 1 次(不含首次)
self.base_delay = base_delay
self.max_delay = max_delay
def get_delay(self, attempt: int) -> float:
"""指数退避 + 抖动。"""
delay = min(self.base_delay * (2 ** attempt), self.max_delay)
# 抖动 ±25%,避免多调用方同步重试
jitter = delay * 0.25 * random.uniform(-1, 1)
return delay + jitter
def should_retry(self, error_code: str, attempt: int) -> bool:
"""是否应该重试。"""
if attempt >= self.max_attempts:
return False
return error_code in RETRYABLE_CODES
```
**关键决策**
- UI 自动化**最多重试 1 **不是 3 )——重试多了反而容易把状态搞更乱
- 重试前必须 `_reset_to_idle` + 截图校验
- `DB_VERIFY_FAILED` 不可重试避免重复发送
- `RATE_LIMITED` `retry_after` 等待不用指数退避
### 5.3 Watchdog崩溃感知
```python
# bridge/ui/watchdog.py
class WeChatWatchdog:
"""后台监控微信与 Xvnc 进程,崩溃主动恢复。"""
def __init__(self, backend: XdotoolBackend, interval: float = 10.0):
self.backend = backend
self.interval = interval
self._task: Optional[asyncio.Task] = None
self._consecutive_failures = 0
async def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _run(self) -> None:
"""每 10s 检查一次。"""
while True:
try:
await asyncio.sleep(self.interval)
await self._check()
except asyncio.CancelledError:
break
except Exception as e:
logger.error("[watchdog] 检查异常: %s", e)
async def _check(self) -> None:
# 1. 检查微信进程
running = await self._is_wechat_running()
if not running:
self._consecutive_failures += 1
if self._consecutive_failures >= 2:
logger.warning("[watchdog] 微信连续 2 次检测失败,触发 autofix")
await self._autofix_wechat()
else:
self._consecutive_failures = 0
# 2. 检查 X11 serverXvnc
if not await self._is_x11_available():
logger.error("[watchdog] X11 不可用Xvnc 可能崩溃")
# Xvnc 崩溃 bridge 无法自救,告警
# 容器层 s6 会自动重启 Xvnc
async def _is_wechat_running(self) -> bool:
try:
rc, stdout, _ = await self.backend._run(["pgrep", "-x", "wechat"])
return rc == 0 and bool(stdout.decode(errors="ignore").strip())
except Exception:
return False
async def _is_x11_available(self) -> bool:
try:
# 检查 X11 socket + xdpyinfo 探测
rc, _, _ = await self.backend._run(["xdpyinfo", "-display", self.backend.display])
return rc == 0
except Exception:
return False
async def _autofix_wechat(self) -> None:
"""触发微信 autofixpkill → autostart 拉起 → bridge 兜底启动。"""
try:
await self.backend._run(["pkill", "-x", "wechat"])
await asyncio.sleep(2.0)
# autostart 会自动拉起bridge 兜底
# 复用现有 start_wechat 逻辑
except Exception as e:
logger.error("[watchdog] autofix 失败: %s", e)
```
### 5.4 ResourceReaper资源回收
```python
# bridge/ui/resource_reaper.py
import os
import time
import glob
class ResourceReaper:
"""定期清理临时资源。"""
def __init__(self, debug_screenshot_dir: str = "/tmp/woc_debug",
max_age_hours: int = 24, max_total_mb: int = 100):
self.debug_dir = debug_screenshot_dir
self.max_age_hours = max_age_hours
self.max_total_mb = max_total_mb
self._task: Optional[asyncio.Task] = None
async def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run())
async def _run(self) -> None:
"""每小时清理一次。"""
while True:
try:
await asyncio.sleep(3600)
await self.cleanup()
except asyncio.CancelledError:
break
except Exception as e:
logger.error("[reaper] 清理异常: %s", e)
async def cleanup(self) -> None:
"""清理过期调试截图 + 总量超限时 LRU 删除。"""
if not os.path.exists(self.debug_dir):
return
now = time.time()
max_age_sec = self.max_age_hours * 3600
# 1. 删除超龄文件
for f in glob.glob(os.path.join(self.debug_dir, "*.png")):
try:
mtime = os.path.getmtime(f)
if now - mtime > max_age_sec:
os.remove(f)
logger.debug("[reaper] 删除过期文件: %s", f)
except OSError:
pass
# 2. 总量超限时 LRU 删除
files = [(f, os.path.getmtime(f))
for f in glob.glob(os.path.join(self.debug_dir, "*.png"))]
files.sort(key=lambda x: x[1]) # 按修改时间排序
total_mb = sum(os.path.getsize(f) for f, _ in files) / (1024 * 1024)
while total_mb > self.max_total_mb and files:
f, _ = files.pop(0) # 删最老的
try:
os.remove(f)
total_mb -= os.path.getsize(f) / (1024 * 1024)
except OSError:
pass
```
### 5.5 MetricsPrometheus
```python
# bridge/ui/metrics.py
from prometheus_client import Counter, Histogram, Gauge
# 发送计数
send_total = Counter(
"woc_send_total", "Total send operations",
["result"], # success / failed / skipped
)
# 发送耗时
send_duration = Histogram(
"woc_send_duration_seconds", "Send duration",
buckets=[0.5, 1, 2, 5, 10, 15, 30, 60],
)
# 失败状态分布
send_failed_by_state = Counter(
"woc_send_failed_by_state_total", "Failed sends by state",
["state"], # window_activated / search_box_clicked / ...
)
# 图像匹配置信度
image_match_confidence = Histogram(
"woc_image_match_confidence", "Image match confidence",
buckets=[0.5, 0.7, 0.8, 0.85, 0.9, 0.95, 1.0],
)
# DB 校验耗时
db_verify_duration = Histogram(
"woc_db_verify_duration_seconds", "DB verify duration",
buckets=[0.1, 0.5, 1, 2, 5, 10],
)
# 队列深度
send_queue_pending = Gauge(
"woc_send_queue_pending", "Send queue pending count",
)
# 微信运行状态
wechat_running = Gauge(
"woc_wechat_running", "WeChat process running (0/1)",
)
# 熔断状态
circuit_state = Gauge(
"woc_circuit_state", "Circuit breaker state (0=closed, 1=open, 2=half)",
["name"],
)
```
### 5.6 TraceID
```python
# bridge/ui/trace.py
import uuid
from contextvars import ContextVar
# 贯穿单次请求的所有日志行
trace_id_var: ContextVar[str] = ContextVar("trace_id", default="")
def new_trace_id() -> str:
tid = uuid.uuid4().hex[:12]
trace_id_var.set(tid)
return tid
def get_trace_id() -> str:
return trace_id_var.get()
# 日志格式中加入 trace_id
class TraceFilter(logging.Filter):
def filter(self, record):
record.trace_id = get_trace_id()
return True
```
---
## 六、目录结构重构
```
bridge/woc_bridge/
├── ui/
│ ├── __init__.py
│ ├── backends/ # Layer 1
│ │ ├── __init__.py
│ │ ├── base.py # BackendProtocol + WindowGeometry
│ │ ├── xdotool.py # XdotoolBackend含超时/取消安全)
│ │ └── opencv.py # OpenCVBackend含 LRU 缓存)
│ ├── locators/ # Layer 2
│ │ ├── __init__.py
│ │ ├── base.py # Selector + ElementHandle + GeomSpec
│ │ └── registry.py # LocatorRegistry含熔断
│ ├── actions.py # Layer 3
│ ├── flow.py # Layer 4 基类
│ ├── flows/ # Layer 4 具体流程
│ │ ├── __init__.py
│ │ └── send_text.py
│ ├── orchestrator.py # Layer 5
│ ├── idem_cache.py # 幂等缓存
│ ├── circuit_breaker.py # 熔断器
│ ├── retry.py # 重试策略
│ ├── errors.py # 错误码分类
│ ├── capabilities.py # Layer 6 对外接口
│ ├── watchdog.py # HA: 崩溃感知
│ ├── resource_reaper.py # HA: 资源回收
│ ├── metrics.py # HA: Prometheus
│ ├── trace.py # HA: TraceID
│ ├── profiles/ # UI 元素配置
│ │ ├── default.yaml
│ │ ├── wechat_4.0_1920x1080.yaml
│ │ └── templates/
│ │ └── wechat_4.0/
│ │ ├── light/ # 浅色模式模板
│ │ │ ├── search_box.png
│ │ │ ├── send_button.png
│ │ │ ├── input_box.png
│ │ │ ├── input_box_empty.png
│ │ │ └── main_view.png # 主界面模板(校验用)
│ │ └── dark/ # 深色模式模板(阶段 2 补)
│ ├── qr_capture.py # 保留(不变)
│ └── xdotool_driver.py # 旧文件保留作兼容层
└── ...
```
---
## 七、最小可行实现MVP路径
**核心原则**每个阶段都可独立交付每个阶段都有可见收益不允许大爆炸式重构
### 阶段 0PoC 验证(先验证假设)
#### PoC 1OpenCV 模板匹配可行性
- 容器内手动截微信主界面搜索框发送按钮输入框各一张
- **或直接使用用户已提供的截图**`03-主界面.png`搜索框)、`02-聊天界面.png`发送按钮/输入框
- 50 Python 脚本 `cv2.matchTemplate` 验证能否稳定命中
- 测试不同分辨率下的匹配度1280x720 / 1920x1080
- **关键**`search_box.png` 模板必须从未激活搜索框截取不可用带下拉覆盖层的图
- **判定标准**阈值 0.80 时命中率 > 95%,多尺度匹配下命中位置误差 < 5px
#### PoC 2状态机回滚可行性
- 把现有 `send_text` 拆成 5 个状态
- 在中间状态故意失败模拟 `click_element` 返回 None
- 验证 `_reset_to_idle` 能否把微信 UI 恢复到可用状态
- **判定标准**回滚后下一次发送能正常进行无残留弹窗/搜索框/焦点错位
#### PoC 3可选CDP 端口开启可行性
- 容器内 `ss -tlnp | grep -E "9222|DevTools"` 检查
- 尝试独立启动 WeChatAppEx `--remote-debugging-port=9222 --user-data-dir=/tmp/wx-debug`
- `curl http://127.0.0.1:9222/json/version` 看是否返回 JSON
- **判定标准**能拿到 `webSocketDebuggerUrl` CDP 路线可行
### 阶段 1抽象层落地 + P0 高可用修复(行为不变)
**目标**把现有代码包装成六层架构的骨架**同时修复 P0 高可用问题**行为完全不变
**任务清单**
1. 新建 [bridge/ui/backends/base.py](../bridge/woc_bridge/ui) 定义 `BackendProtocol`
2. 新建 `backends/xdotool.py` 移植现有方法**关键改进**
- `_CMD_TIMEOUT_SEC` 15s 降到 5s
- `_run` 改为 `_run_managed` 上下文管理器确保取消时 kill+wait
- 截图用 `scrot -o -` 输出到 stdout不写文件
- `_ui_lock` 保护 UI 单焦点
3. 新建 `locators/base.py` + `registry.py`
4. 新建 `actions.py`
5. 新建 `profiles/default.yaml` 迁移硬编码常量
6. 新建 `capabilities.py`
7. **P0 修复**
- 生产环境关闭 `_DEBUG_SCREENSHOTS`环境变量 `WOC_UI_DEBUG_SHOTS=false`
- 修复 `_step` `max(1.0, remaining)` `max(_STEP_MIN_TIMEOUT_SEC, remaining)`
- 启动时执行 `_full_cleanup_on_startup`
8. **旧 `xdotool_driver.py` 保留**新代码通过 `capabilities.py` 调用
**验收标准**
- 现有 `/api/send/text` 接口行为完全不变
- 调试截图不再写磁盘或写到 `/tmp/woc_debug/` 并自动清理
- 无子进程泄漏取消时 kill+wait
- bridge 启动后微信 UI 处于已知态
### 阶段 2引入图像校验
**目标**在定位层加 OpenCV 图像匹配点击前校验是否命中正确元素
**任务清单**
1. 新建 `backends/opencv.py` 实现 `find_template` LRU 缓存
2. 容器内截取微信关键 UI 元素模板保存到 `profiles/templates/wechat_4.0/light/`
3. 截取 `main_view.png` 主界面模板 `_verify_main_view` 使用
4. 修改 `Actions.find_element`优先图像匹配失败回退几何
5. **高风险操作(撤回/删除)强制要求图像命中**`require_image: true`
6. per-kind 熔断连续 10 次失败熔断 5 分钟
**验收标准**
- 1280x720 分辨率下发送文本成功率 > 90%
- 图像匹配失败时回退到几何定位,不报错
- 撤回操作在图像未命中时拒绝执行,返回 `ELEMENT_NOT_FOUND`
- 图像匹配置信度通过 Prometheus metric 暴露
### 阶段 3引入状态机 + DB 校验 + 幂等
**目标**:把 `send_text` 重构为状态机,加 DB 回读校验与幂等去重。
**任务清单**
1. 新建 `flow.py` 定义 `Flow` 基类 + `FlowState` 枚举 + `FlowContext`
2. 新建 `flows/send_text.py` 实现 `SendTextFlow`(含 post_verify
3. 新建 `orchestrator.py` 编排幂等检查 + 入队 + 重试
4. 新建 `idem_cache.py` 实现内存去重(含 `client_request_id`
5. 新建 `circuit_breaker.py` 实现 DB 校验熔断
6. 新建 `errors.py` 错误码分类
7. 新建 `retry.py` 重试策略(指数退避 + 抖动,最多 1 次)
8.`SendTextFlow._send` 中加 DB 校验:
- **关键**:验证 `msg.talker == to_wxid`(防 display_name 串号)
- DB 校验失败时区分 `success=true, verified=false` vs `success=false`
9. 切换 `/api/send/text` 路由到新实现
10. 新建 `metrics.py` 暴露 Prometheus 指标
11. 新建 `watchdog.py` 微信/Xvnc 崩溃感知
12. 新建 `resource_reaper.py` 资源回收
13. 新建 `trace.py` TraceID
**验收标准**
- 5 分钟内重复发送相同内容(同 client_request_id被跳过返回 `skipped=True`
- 不同 client_request_id 的相同内容可正常发送
- 发送失败DB 校验未通过)时返回 `verified=False`,但不返回 `SEND_FAILED`(调用方不误重试)
- 发错人时 DB 校验失败talker 不匹配),返回 `DB_VERIFY_FAILED`(不可重试)
- 失败后状态机回滚到 IDLE下一次发送正常
- Prometheus `/metrics` 端点可查
- 微信崩溃后 watchdog 10s 内感知并触发 autofix
### 阶段 4可选CDP 后端 PoC
**目标**:若 PoC 3 验证 CDP 可行,实现 `CDPBackend` 用于朋友圈/小程序/公众号文章。
**任务清单**
1. 新建 `backends/cdp.py` 用 Playwright `connect_over_cdp` 连接
2. 实现 `async with` 上下文管理,`__aexit__` 必 `browser.close()`
3. 加健康检查:每次操作前 `browser.is_connected()`,失败重连
4. CDP 与 xdotool 共享同一个 `send_queue` 串行(不并发)
5. page 池上限 5 个,超限拒绝新建
6. 朋友圈发布流程切换到 CDP
**验收标准**
- 朋友圈发布成功率 > 90%
- CDP 连接断开后自动重连
- 无 page 泄漏(`cdp_pages_active` < 5
### 阶段 5多应用桥接框架对齐
**目标** `WechatDriver` 改造为实现 [多应用桥接框架设计.md](./多应用桥接框架设计.md) 定义的 `AppDriver` 抽象
**任务清单**
1. 抽象 `AppDriver` 接口
2. `WechatDriver` 实现该接口
3. 落地 `AppKindRegistry` / `AppInstance` / `BridgeContext`
4. `AppState` 改为 per-instance支持多实例 X server 隔离
5. 新增应用 Telegram/Chromium时复用通用层
---
## 八、关键工程决策汇总
### 8.1 并发模型
- **串行化只在 `send_queue` 一层**Flow 内部不加锁避免双重阻塞
- 只读探测走队列外快速路径但共享 `_ui_lock` 与发送互斥
- 多实例需独立 X server阶段 5
### 8.2 超时层级(解决 L1/L2 冲突)
- L1 单命令`_CMD_TIMEOUT_SEC=5s`(从 15s 降到 5s
- L2 单步骤`max(_STEP_MIN_TIMEOUT_SEC=7s, remaining)`确保 L1+2s
- L3 整流程`_FLOW_TIMEOUT_SEC=30s`
- L4 HTTP 请求`_HTTP_TIMEOUT_SEC=60s`
### 8.3 子进程安全
- `_run_managed` 上下文管理器任何取消/超时/异常都 kill+wait
- 所有 `create_subprocess_exec` 必须显式 `stdout=PIPE/DEVNULL`禁止默认继承
### 8.4 资源回收
- 调试截图生产环境关闭调试模式写 `/tmp/woc_debug/`每小时清理 + 100MB 上限
- 模板缓存LRU 上限 20 40MB
- OpenCV ndarray多尺度逐个处理 + 显式 `del`
- Playwright page`async with` + 池上限 5
### 8.5 熔断策略
- DB 校验熔断连续失败 5 次熔断 30s半开试探
- 图像匹配熔断per-kind 连续 10 次失败熔断 5 分钟
- 熔断后降级DB 截图校验图像 几何坐标高风险操作不降级
### 8.6 幂等设计
- 幂等键 = `(flow_name, to_wxid, content_sha256, client_request_id)`
- 用完整 SHA256不截断避免短消息碰撞
- 5 分钟 TTL
- 内存缓存不持久化bridge 重启丢失可接受重启不频繁
- **关键**DB 校验必须验证 `msg.talker == to_wxid` display_name 串号
### 8.7 状态机恢复
- **启动时全量清场**`_full_cleanup_on_startup` 连续重置 3 + 截图校验
- **失败时重置**`_reset_to_idle` 多策略清场Esc×3 + 点击空白 + BackSpace×30 + Esc×2 + 校验
- **正常路径只做轻清场**Esc×1 + BackSpace×30 1s
- 重置后必须 post_verify匹配 main_view 模板
### 8.8 错误分类与重试
- **可重试**RATE_LIMITED / SEND_TIMEOUT / STATE_DIRTY / X11_TEMP_UNAVAILABLE
- **不可重试**WECHAT_NOT_LOGGED_IN / WINDOW_NOT_FOUND / ELEMENT_NOT_FOUND / DB_VERIFY_FAILED / AMBIGUOUS_CONTACT / X11_UNAVAILABLE / BRIDGE_CIRCUITED
- UI 自动化最多重试 1 重试前必 `_reset_to_idle`
- 指数退避 + 抖动`base=1s, max=30s`
- `DB_VERIFY_FAILED` 不可重试消息可能已发重试即重复
### 8.9 可观测性
- Prometheus `/metrics` 端点
- 关键指标`woc_send_total{result}` / `woc_send_duration_seconds` / `woc_send_failed_by_state_total{state}` / `woc_image_match_confidence` / `woc_db_verify_duration_seconds` / `woc_send_queue_pending` / `woc_wechat_running` / `woc_circuit_state{name}`
- TraceID 贯穿单次请求所有日志行
- 调试截图带 trace_id 文件名
### 8.10 模板维护
- 深色/浅色模式两套模板`light/` `dark/`
- 当前已提供浅色模式截图素材`02-聊天界面.png`、`03-主界面.png`
- `01-搜索框.png` 含下拉覆盖层**不可**作为模板仅作反面参考
- 启动时检测主题截主界面与参考图比对
- 微信版本升级监控`woc_image_match_confidence` 均值跌至 0.7 以下告警
- 多尺度匹配 `[1.0, 0.75, 1.25, 1.5]` 覆盖常见 DPI
- 采集模板时固定 DPI=96
- 模板裁剪坐标参考见 9.2
---
## 九、YAML Profile 规范
### 9.1 文件命名规则
```
profiles/
├── default.yaml # 兜底默认值
├── wechat_4.0_1920x1080.yaml # 精确匹配版本+分辨率
├── wechat_4.0_1280x720.yaml
├── wechat_4.0.yaml # 仅版本匹配
└── templates/
└── wechat_4.0/
├── light/ # 浅色模式
│ ├── search_box.png
│ ├── send_button.png
│ ├── input_box.png
│ ├── input_box_empty.png
│ └── main_view.png # 主界面模板(校验用)
└── dark/ # 深色模式
└── ...(同名)
```
### 9.2 截图素材参考
用户已提供 3 张微信真实截图位于 `bridge/ui/profiles/templates/screenshot/`阶段 2 模板采集可直接基于这些截图裁剪
| 文件名 | 状态 | 用途 | 裁剪建议 |
|---|---|---|---|
| `01-搜索框.png` | 不可用含下拉覆盖层 | 仅作参考不可直接作为 `search_box.png` 模板 | 图中搜索框被点击出现"搜索网络结果"下拉模板必须从未激活状态截取 |
| `02-聊天界面.png` | 可用 | `send_button.png` / `input_box.png` / `input_box_empty.png` | 截取右下角绿色发送按钮底部输入框空输入框 |
| `03-主界面.png` | 可用 | `search_box.png` / `main_view.png` / `search_result_first.png` | 截取左栏顶部未激活搜索框右侧主界面空白+微信 logo |
**主题判断**当前为**浅色模式**白色背景绿色气泡阶段 2 模板保存到 `templates/wechat_4.0/light/`深色模式需等切换后重新采集
**裁剪坐标参考(基于 1920x1080 截图)**
| 模板 | 来源截图 | 建议裁剪区域x, y, w, h | 说明 |
|---|---|---|---|
| `search_box.png` | `03-主界面.png` | (40, 32, 230, 36) | 左栏顶部搜索框不包含网络结果下拉 |
| `main_view.png` | `03-主界面.png` | (600, 300, 400, 300) | 右侧主区域空白+微信 logo |
| `search_result_first.png` | `03-主界面.png` | (40, 90, 230, 60) | 搜索框下方第一条会话结果 |
| `send_button.png` | `02-聊天界面.png` | (右下角 -80, -45, 70, 35) | 绿色激活态"发送"按钮 |
| `input_box.png` | `02-聊天界面.png` | (350, 1010, 500, 40) | 底部输入框 |
| `input_box_empty.png` | `02-聊天界面.png` | (350, 1010, 500, 40) | 空输入框灰色提示文字状态 |
> **注意**01-搜索框.png 是错误示例——包含"搜索网络结果"覆盖层。阶段 2 裁剪模板时必须使用 03-主界面.png 中的未激活搜索框。
### 9.3 YAML 字段规范
```yaml
<element_kind>:
by_image: "wechat_4.0/light/send_button.png"
by_geom:
relative_to: window
x_ratio: 0.0
y_ratio: 0.0
x_offset: 0
y_offset: 0
threshold: 0.80 # 图像匹配阈值
require_image: false # 高风险操作设 true
description: "发送按钮"
```
### 9.4 加载优先级
`LocatorRegistry.load(app_version, resolution)` 按以下顺序查找
1. `wechat_{app_version}_{W}x{H}.yaml` 精确匹配
2. `wechat_{app_version}.yaml` 版本匹配
3. `default.yaml` 默认兜底
---
## 十、迁移与兼容策略
### 10.1 不破坏现有功能
- `xdotool_driver.py` 保留作为兼容层
- 新代码通过 `capabilities.py` 调用
- send_queue 保留
- DB reader 保留
### 10.2 渐进式路由切换
| 路由 | 阶段 1 | 阶段 2 | 阶段 3 |
|---|---|---|---|
| `/api/send/text` | 旧实现 | 旧实现 | **新实现** |
| `/api/send/file` | 旧实现 | 旧实现 | 新实现 |
| `/api/moments/publish` | 旧实现 | 旧实现 | 旧实现CDP 待阶段 4 |
| `/api/messages/revoke` | 旧实现 | **新实现** | 新实现 |
### 10.3 回滚方案
环境变量 `WOC_UI_BACKEND=legacy` 强制走旧实现
```python
# bridge/routes/send.py
if config.ui_backend == "legacy":
result = await state.xdotool.send_text(...)
else:
result = await state.capabilities.send_text(...)
```
---
## 十一、预期收益
| 维度 | 当前 | 阶段 1 | 阶段 3 |
|---|---|---|---|
| 跨分辨率 | 1 个固定布局 | 同左 | profile 自适应 + 图像匹配兜底 |
| 跨版本 | 微信更新即崩 | 同左 | 模板/profile 单独更新代码不动 |
| 发送成功率 | ~70% | 同左 | >95%DB 校验 + 图像校验) |
| **首条消息耗时** | **16s** | **~8s**DB/截图/命令优化) | **3-5s**(自适应等待 + 并行) |
| **同联系人后续耗时** | **16s** | **~8s** | **1-2s**(会话缓存 + 队列自适应) |
| 失败可恢复 | 不可恢复,半残状态 | 启动清场 + 重置 | 状态机重置到 IDLE + 截图校验 |
| 重复发送风险 | 高(重试即重发) | 同左 | 低5 分钟幂等 + client_request_id |
| 子进程泄漏 | 有(取消时不 kill | 无(上下文管理器) | 无 |
| 磁盘泄漏 | 有(调试截图堆积) | 无(生产关闭 + 自动清理) | 无 |
| 崩溃感知 | 无(被动触发) | 同左 | 10s watchdog 主动感知 |
| 可观测性 | 散点日志 | 同左 | Prometheus metric + TraceID |
| 错误分类 | 全部 SEND_FAILED | DB 校验区分 verified | 8 类错误码,区分可重试 |
| 熔断保护 | 无 | 同左 | DB + 图像 双熔断 |
---
## 十二、风险与应对
| 风险 | 概率 | 影响 | 应对 |
|---|---|---|---|
| OpenCV 模板匹配在深色模式下失效 | 中 | 定位失败 | 采集深色/浅色两套模板,启动时检测主题 |
| DB 校验延迟过高 | 低 | 发送后等 5 秒 | 降级到截图校验 |
| 微信版本升级改变 UI 布局 | 中 | 模板失效 | `woc_image_match_confidence` 均值监控 + 告警 |
| 状态机回滚不彻底 | 中 | 下一次发送失败 | 重置后 post_verify + 3 次重试 + 告警 |
| OpenCV 依赖增加镜像体积 | 低 | 镜像变大 ~50MB | 可接受 |
| CDP 端口开启后微信不稳定 | 中 | 微信崩溃 | 阶段 4 PoC 充分验证,失败则放弃 CDP |
| 幂等缓存 bridge 重启丢失 | 低 | 短窗口内重试重发 | 可从 DB 重建(远期) |
| watchdog 误判微信崩溃 | 低 | 误 pkill 微信 | 连续 2 次失败才触发 |
| 模板截图包含临时覆盖层导致匹配失败 | 中 | `search_box` 模板命中 0% | 模板必须从未激活/稳定态截取;`01-搜索框.png` 已标记为不可用 |
| 多 DPI/缩放比例下几何坐标偏移 | 中 | 图像未命中且几何不准 | 多尺度匹配 `[1.0, 0.75, 1.25, 1.5]` + 每分辨率独立 profile |
---
## 十三、验收标准
### 13.1 阶段验收
**阶段 1 验收**
- [ ] `BackendProtocol` 定义完成,`XdotoolBackend` 实现通过所有现有测试
- [ ] YAML profile 加载机制工作,`default.yaml` 与现有硬编码常量一致
- [ ] `capabilities.send_text()` 行为与旧 `xdotool_driver.send_text()` 完全一致
- [ ] 生产环境 `_DEBUG_SCREENSHOTS=false`
- [ ] 无子进程泄漏(压力测试取消 100 次,无僵尸进程)
- [ ] bridge 启动后微信 UI 处于已知态(截图校验通过)
**阶段 2 验收**
- [ ] 5 个 MVP 模板(搜索框/发送按钮/输入框/空输入框/主界面)采集完成
- 已有截图素材:`02-聊天界面.png`、`03-主界面.png`
- `search_box.png` 必须从 `03-主界面.png` 中未激活搜索框裁剪(不可用 `01-搜索框.png`
- [ ] 1280x720 分辨率下发送文本成功率 > 90%
- [ ] 1920x1080 分辨率下图像匹配命中率 > 95%(基于已有截图验证)
- [ ] 图像匹配失败时回退到几何定位
- [ ] 撤回操作在图像未命中时拒绝执行
- [ ] `woc_image_match_confidence` metric 可查
**阶段 3 验收**
- [ ] `SendTextFlow` 状态机实现完成9 个状态全部覆盖
- [ ] 5 分钟内重复发送相同内容(同 client_request_id跳过率 100%
- [ ] 不同 client_request_id 的相同内容可正常发送
- [ ] 发送失败时返回 `verified=False`,不返回 `SEND_FAILED`
- [ ] 发错人时返回 `DB_VERIFY_FAILED`(不可重试)
- [ ] 失败后状态机回滚到 IDLE下一次发送正常
- [ ] Prometheus `/metrics` 端点可查
- [ ] 微信崩溃后 watchdog 10s 内感知
### 13.2 整体验收
- [ ] 单条文本消息端到端耗时 < 10
- [ ] 发送成功率 > 95%(连续 100 次测试)
- [ ] 5 分钟内重复发送跳过率 100%
- [ ] 无子进程泄漏(压力测试)
- [ ] 无磁盘泄漏7 天运行 /tmp/woc_debug < 100MB
- [ ] 状态机日志清晰可读状态耗时置信度trace_id
- [ ] 新增 UI 流程开发时间 < 2 小时
---
## 十四、附录
### 14.1 技术约束备忘
| 约束 | 来源 | 影响 |
|---|---|---|
| 微信主 UI Qt 自绘 | [docker/Dockerfile:28](../docker/Dockerfile#L28) | CDP/AT-SPI 不可用 |
| WeChatAppEx Chromium | [docker/Dockerfile:41](../docker/Dockerfile#L41) | CDP 理论可行 PoC |
| 不响应 X11 修饰键组合 | 项目记忆 | 必须用 `xdotool type` + 鼠标点击 |
| 主进程不吃命令行参数 | [docker/autostart:23-25](../docker/autostart#L23) | CDP 开启需 LD_PRELOAD |
| send_queue 最小 3000ms 间隔 | [config.py:128](../bridge/woc_bridge/config.py#L128) | 单条消息下限 3 |
| DB 密钥在主进程 [heap] | 项目记忆 | key 提取只扫主进程堆 |
| WeChat 4.x 消息分片存储 | 项目记忆 | `Msg_<MD5(username)>` |
| send_queue 已串行但无锁无幂等 | [send_queue.py:117](../bridge/woc_bridge/messaging/send_queue.py#L117) | 需在 L5 补幂等 |
| DB 校验已实现但错误处理不当 | [routes/send.py:265](../bridge/woc_bridge/routes/send.py#L265) | 需区分 verified vs success |
### 14.2 不采用的方案与原因
| 方案 | 不采用原因 |
|---|---|
| pyautogui | xdotool 底层相同X11 XTest不解决核心问题 |
| AT-SPI / dogtail | Qt 自绘应用无 a11y 完全失效 |
| Frida native hook | 需逆向微信符号工作量大封号风险高 |
| SikuliX | JVM 依赖重OpenCV Python 已足够 |
| transitions | 状态数 < 10自写更简单 |
| 逐级状态机回滚 | UI 操作不可逆直接重置到 IDLE 更可靠 |
| Flow 内加 asyncio.Lock | send_queue 双重串行语义混乱 |
| 幂等键用 MD5 截断 8 | 短消息碰撞风险用完整 SHA256 |
| 持久化幂等缓存 | 引入 SQLite/Redis 复杂度收益低 |
### 14.3 参考项目
| 项目 | 方向 | 借鉴点 |
|---|---|---|
| [wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs) | DB 解密 | 手机号定位 key 思路 |
| [wechat-decrypt](https://github.com/ylytdeng/wechat-decrypt) | DB 解析 | 4.0 表结构与消息内容解析 |
| [WeChatFerry](https://github.com/lich0821/WeChatFerry) | Hook 架构 | Windows only仅参考架构设计 |
| [OpenCV 官方教程](https://docs.opencv.org/4.x/d4/dc6/tutorial_py_template_matching.html) | 图像识别 | 模板匹配最佳实践 |
| [Playwright CDP](https://playwright.dev/python/docs/cdp) | CDP 连接 | `connect_over_cdp` 用法 |
| [prometheus-client](https://github.com/prometheus/client_python) | 监控 | Python Prometheus 客户端 |
### 14.4 术语表
| 术语 | 含义 |
|---|---|
| Backend | 执行后端封装 xdotool/OpenCV/CDP |
| Locator | 元素定位策略图像/几何/CDP |
| Selector | 声明式元素选择器YAML 配置 |
| ElementHandle | 元素句柄包含坐标与置信度 |
| Action | 原子动作组合 Backend + Locator |
| Flow | 业务流程状态机编排多个 Action |
| Orchestrator | 流程编排器管理幂等与重试 |
| Capability | 对外业务接口 |
| IdemCache | 幂等缓存5 分钟去重 |
| CircuitBreaker | 熔断器连续失败后熔断 |
| Watchdog | 崩溃感知后台任务 |
| ResourceReaper | 资源回收后台任务 |
| Profile | UI 元素配置文件 |
| TraceID | 贯穿单次请求的日志追踪 ID |
---
## 十五、接口调用速度优化v3.0 新增)
### 15.1 速度瓶颈定位
通过逐行代码分析 1.2.3 当前 `send_text` 端到端时序拆解
```
当前流程(典型 16s / 最坏 26s
├─ 会话定位 _open_session_by_name ~10s
│ ├─ find_wechat_window ~50ms
│ ├─ _activate_window_fast ~2s ← 已用自适应轮询100ms 步进)
│ ├─ sleep 1.0s × 7 处 7.0s ← 【最大瓶颈】固定等待
│ ├─ _click_at × 2 ~200ms ← 两次 fork 可合并
│ ├─ _key × 4 ~200ms
│ ├─ _paste_via_xclip (name) ~200ms ← 方法名误导,实际是 xdotool type
│ └─ _debug_screenshot × 1 ~100ms ← 写磁盘 I/O
├─ 发送体 _send_body ~3s
│ ├─ _debug_screenshot × 4 ~400ms ← 写磁盘 I/O
│ ├─ _click_input_box ~100ms
│ ├─ _paste_via_xclip (content) ~200ms
│ ├─ sleep 1.0s × 2 处 2.0s ← 【瓶颈】
│ └─ _click_send_button ~100ms
├─ DB 校验 _verify_sent_to_talker 0-10s ← 【瓶颈】轮询 0.5s 间隔
└─ send_queue 延时 3.0s ← 任务后强制 sleep
```
**三大瓶颈**占总耗时 80%+
1. **固定 sleep**10 × 1.0s = 10s会话定位 7s + 发送体 2s + 队列 3s 含此延时
2. **DB 校验超时**10s 超时 + 0.5s 轮询间隔实际 DB 写入 < 500ms
3. **无会话缓存**每次重新搜索会话连续发给同一人也重走 10s
### 15.2 优化策略总览
| 策略 | 节省耗时 | 实施成本 | 阶段 |
|---|---|---|---|
| **A. 固定 sleep → 自适应轮询** | 5-7s | | 阶段 3 |
| **B. 会话缓存(同联系人复用)** | 10s后续 | | 阶段 3 |
| **C. DB 校验调优** | 7-9s | | 阶段 1 |
| **D. 调试截图移出关键路径** | 0.5s | 极低 | 阶段 1 |
| **E. 合并 xdotool 命令** | 0.5-1s | | 阶段 1 |
| **F. 并行化 DB 查询与 UI 操作** | 1-2s | | 阶段 3 |
| **G. 队列延时自适应** | 2s同联系人 | | 阶段 3 |
**预期效果**
- 首条消息16s 3-5s 70%
- 同联系人后续16s 1-2s 90%
- 不同联系人切换16s 3-5s
### 15.3 策略 A固定 sleep → 自适应轮询
**问题**当前 10 `sleep 1.0s` "保守等待 UI 响应" UI 实际响应通常 < 200ms
**方案**用条件轮询替代固定等待条件满足即继续超时兜底
```python
# bridge/ui/flows/send_text.py
async def _wait_for_condition(
self, condition_fn, timeout: float = 3.0, interval: float = 0.15,
) -> bool:
"""轮询等待条件成立,满足即返回 True超时返回 False。"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if await condition_fn():
return True
await asyncio.sleep(interval)
return False
async def _click_search_box(self, ctx: FlowContext) -> FlowState:
await self.actions.click_element("search_box", ctx.win_geom)
# 旧await asyncio.sleep(1.0)
# 新:轮询等待搜索框获得焦点(光标出现)
await self._wait_for_condition(
lambda: self._is_search_focused(ctx.win_geom),
timeout=2.0, interval=0.15,
)
return FlowState.SEARCH_BOX_CLICKED
async def _type_query(self, ctx: FlowContext) -> FlowState:
await self.actions.backend.key_press("BackSpace", repeat=30)
await asyncio.sleep(0.2) # BackSpace 无需长等
await self.actions.backend.type_text(ctx.display_name)
# 旧await asyncio.sleep(1.0)
# 新:轮询等待搜索结果出现(图像匹配结果列表)
await self._wait_for_condition(
lambda: self.actions.find_element("search_result_list", ctx.win_geom),
timeout=3.0, interval=0.15,
)
return FlowState.QUERY_TYPED
```
**条件检测方式**
- 搜索框聚焦`xdotool getwindowfocus` + 图像匹配光标
- 搜索结果出现图像匹配搜索结果列表模板
- 会话打开图像匹配右侧聊天面板 / 输入框
- 输入完成无需等待xdotool type 同步返回
- 发送完成DB 校验见策略 C
**效果**7s sleep ~1-2s 自适应等待节省 5-6s
**容错**条件未满足时不报错 timeout 兜底记录到 metric
### 15.4 策略 B会话缓存同联系人复用
**问题**连续发给同一联系人时每次都重新搜索会话10s浪费 9.8s
**方案**维护"当前已打开会话"状态匹配则跳过会话定位
```python
# bridge/ui/orchestrator.py
class SessionCache:
"""当前已打开会话的缓存。
缓存 key = display_namevalue = 打开时间戳。
TTL = 30s30s 内无操作则认为微信可能切走了,缓存失效)。
"""
def __init__(self, ttl: float = 30.0):
self._ttl = ttl
self._current: Optional[tuple[str, float]] = None # (display_name, opened_at)
def get(self) -> Optional[str]:
"""返回当前已打开会话的 display_name过期或无则 None。"""
if self._current is None:
return None
name, ts = self._current
if time.monotonic() - ts > self._ttl:
self._current = None
return None
return name
def set(self, display_name: str) -> None:
self._current = (display_name, time.monotonic())
def invalidate(self) -> None:
"""显式失效(如发送失败、状态机重置时)。"""
self._current = None
class FlowOrchestrator:
def __init__(self, ...):
self.session_cache = SessionCache(ttl=30.0)
async def send_text(self, to_wxid: str, content: str,
display_name: str = None, ...):
# 幂等检查...
async def _run():
flow = SendTextFlow(self.actions, self.db, self.session_cache)
ctx = FlowContext(...)
return await flow.run(ctx)
result = await self.send_queue.enqueue(_run)
# 失败时失效会话缓存
if not result.success:
self.session_cache.invalidate()
return result
# bridge/ui/flows/send_text.py
class SendTextFlow(Flow):
def __init__(self, actions, db, session_cache: SessionCache):
super().__init__(actions, db)
self.session_cache = session_cache
async def run(self, ctx: FlowContext) -> FlowResult:
t_start = time.perf_counter()
# 检查会话缓存
cached_session = self.session_cache.get()
session_cached = (cached_session == ctx.display_name)
if session_cached:
# 跳过会话定位,直接聚焦输入框
logger.info("[flow] 会话缓存命中,跳过定位")
state = FlowState.SESSION_OPENED
# 仅做轻量焦点校验
await self._verify_session_still_open(ctx)
else:
# 完整会话定位流程
state = await self._activate(ctx)
state = await self._click_search_box(ctx)
state = await self._type_query(ctx)
state = await self._open_session(ctx)
self.session_cache.set(ctx.display_name)
# 共同的发送流程
state = await self._focus_input(ctx)
state = await self._type_content(ctx)
state = await self._send(ctx)
state = await self._cleanup(ctx)
# ...
```
**缓存失效条件**
- 发送失败状态机重置
- TTL 过期30s 无操作
- 显式 invalidate如切换联系人Esc 退会话
**注意**缓存命中后仍需轻量校验确认会话还开着图像匹配输入框防止微信后台切走
**效果**同联系人后续消息从 16s 3-4s 10s 会话定位 + 部分 sleep)。
### 15.5 策略 CDB 校验调优
**问题**当前 `_verify_sent_to_talker`[routes/send.py:107](../bridge/woc_bridge/routes/send.py#L107)
- 超时 10s实际 DB 写入 < 500ms
- 轮询间隔 0.5s太慢第一次命中要等 0.5s
- 失败后继续等到 10s浪费时间
**方案**
```python
# bridge/ui/flows/send_text.py
async def _verify_sent(self, ctx: FlowContext) -> bool:
"""DB 校验调优:超时 3s + 轮询 0.2s + 早退出。"""
if not self.db or ctx.before_msg_id == 0:
return await self._verify_by_screenshot(ctx)
deadline = time.monotonic() + 3.0 # 从 10s 降到 3s
while time.monotonic() < deadline:
msgs = await self.db.get_messages(
ctx.to_wxid, limit=1, after_local_id=ctx.before_msg_id,
)
if msgs:
msg = msgs[0]
if (msg.talker == ctx.to_wxid
and msg.content == ctx.content
and msg.is_sender):
return True
# talker 不匹配 = 发错人,立即返回 False不继续等
logger.error("[verify] talker 不匹配: expected=%s actual=%s",
ctx.to_wxid, msg.talker)
return False
await asyncio.sleep(0.2) # 从 0.5s 降到 0.2s
return False
```
**调优依据**
- 微信 DB 写入延迟实测 < 500msSQLCipher 同步写入
- 轮询 0.2s 典型命中只需 1 次轮询0.2-0.4s
- 超时 3s 覆盖 99% 场景P99 < 1s
- talker 不匹配时立即退出不浪费时间
**效果**DB 校验从 0-10s 0.2-3s典型 0.4s)。
### 15.6 策略 D调试截图移出关键路径
**问题**当前 `_DEBUG_SCREENSHOTS=True` 在关键路径上写 5 PNG 500ms含磁盘 I/O 阻塞)。
**方案**
```python
# bridge/ui/backends/xdotool.py
class XdotoolBackend:
def __init__(self, ...):
self._debug_shots_enabled = os.environ.get("WOC_UI_DEBUG_SHOTS", "false").lower() == "true"
self._debug_queue: asyncio.Queue = asyncio.Queue()
self._debug_task: Optional[asyncio.Task] = None
async def _debug_screenshot_async(self, step: str, trace_id: str = "") -> None:
"""异步截图:不阻塞关键路径,丢到后台队列写磁盘。"""
if not self._debug_shots_enabled:
return
# 只入队,不等待写盘
await self._debug_queue.put((step, trace_id, time.time()))
async def _debug_writer(self) -> None:
"""后台 worker从队列取截图写磁盘。"""
while True:
step, trace_id, ts = await self._debug_queue.get()
try:
path = f"/tmp/woc_debug/{trace_id}_{step}_{int(ts*1000)}.png"
await self._run(["scrot", path])
except Exception as e:
logger.warning("[backend] 调试截图失败: %s", e)
```
**关键改进**
1. 生产环境默认关闭`WOC_UI_DEBUG_SHOTS=false`
2. 开启时用后台队列异步写盘不阻塞关键路径
3. 文件名带 trace_id便于排障
4. 配合 ResourceReaper 自动清理
**效果**500ms 0ms生产/ < 5ms调试入队开销)。
### 15.7 策略 E合并 xdotool 命令
**问题**当前 `_click_at`[xdotool_driver.py:399](../bridge/woc_bridge/ui/xdotool_driver.py#L399)分两条命令
```python
await self._run(["xdotool", "mousemove", "--sync", str(x), str(y)]) # fork 1
await self._run(["xdotool", "click", "1"]) # fork 2
```
每次 fork+exec 30-50ms两次 = 60-100ms。
**方案**合并为单条命令
```python
# bridge/ui/backends/xdotool.py
async def click(self, x: int, y: int) -> None:
async with self._ui_lock:
# 合并 mousemove + click 为单条命令
await self._run([
"xdotool", "mousemove", "--sync", str(x), str(y),
"click", "1",
])
```
xdotool 支持链式命令单条命令内顺序执行
**效果**每次点击节省 30-50ms整流程 ~5 次点击 = 节省 150-250ms
### 15.8 策略 F并行化 DB 查询与 UI 操作
**问题**发送前查 `before_msg_id`[routes/send.py:90](../bridge/woc_bridge/routes/send.py#L90) UI 激活窗口串行可并行
**方案**
```python
# bridge/ui/flows/send_text.py
async def _activate_and_prepare(self, ctx: FlowContext) -> FlowState:
"""并行:激活窗口 + 查询发送前 DB 最新 msg_id。"""
# 两个独立任务并行
activate_task = asyncio.create_task(self._activate(ctx))
db_task = asyncio.create_task(self._fetch_before_msg_id(ctx))
await asyncio.gather(activate_task, db_task)
return FlowState.WINDOW_ACTIVATED
async def _fetch_before_msg_id(self, ctx: FlowContext) -> None:
"""查询发送前的 DB 最新 msg_id并行执行不阻塞 UI。"""
if self.db:
try:
ctx.before_msg_id = await self.db.get_latest_msg_id(ctx.to_wxid)
except Exception:
ctx.before_msg_id = 0
```
**效果**节省 DB 查询时间~100-200ms与窗口激活重叠
### 15.9 策略 G队列延时自适应
**问题**当前 `send_delay_ms=3000`[config.py:128](../bridge/woc_bridge/config.py#L128)对所有情况一刀切
**方案**同联系人后续消息延时更短不同联系人保持原延时
```python
# bridge/ui/orchestrator.py
class FlowOrchestrator:
async def send_text(self, ...):
cached_session = self.session_cache.get()
session_cached = (cached_session == display_name)
async def _run():
flow = SendTextFlow(self.actions, self.db, self.session_cache)
ctx = FlowContext(...)
return await flow.run(ctx)
# 自适应延时
if session_cached:
delay = min(self.config.send_delay_ms, 1000) # 同联系人 1s
else:
delay = self.config.send_delay_ms # 不同联系人 3s
result = await self.send_queue.enqueue(_run, delay_ms=delay)
return result
# messaging/send_queue.py
class SendQueue:
async def enqueue(self, coro_factory: CoroFactory,
delay_ms: Optional[int] = None) -> Any:
"""入队,可选自定义延时(覆盖默认 send_delay_ms。"""
...
async def _run(self):
while True:
coro_factory, future, custom_delay = await self._queue.get()
...
finally:
self._queue.task_done()
delay = custom_delay if custom_delay is not None else self.send_delay_ms
await asyncio.sleep(delay / 1000.0)
```
**理由**
- 同联系人会话已打开微信 UI 不需要长缓冲1s 足够
- 不同联系人需要切换会话UI 需要时间稳定保持 3s
**效果**同联系人后续消息队列延时 3s 1s
### 15.10 优化后预期时序
**首条消息(不同联系人)**
```
├─ 激活窗口 + DB 查询(并行) ~2s
├─ 点击搜索框(自适应等待) ~0.3s
├─ 输入搜索词(自适应等待结果) ~0.5s
├─ 点击搜索结果(自适应等待会话打开) ~0.5s
├─ 聚焦输入框 ~0.3s
├─ 输入内容 + 点击发送 ~0.5s
├─ DB 校验3s 超时,典型 0.4s ~0.4s
├─ 清理Esc ~0.3s
└─ 队列延时 ~3s
总计:~4.8s(从 16s 降至 4.8s,省 70%
```
**同联系人后续消息**
```
├─ 会话缓存命中(跳过定位) ~0s
├─ 聚焦输入框 ~0.3s
├─ 输入内容 + 点击发送 ~0.5s
├─ DB 校验 ~0.4s
├─ 清理 ~0.3s
└─ 队列延时(自适应 1s ~1s
总计:~2.5s(从 16s 降至 2.5s,省 84%
```
### 15.11 实施阶段对照
| 策略 | 阶段 1 | 阶段 2 | 阶段 3 |
|---|---|---|---|
| A. 自适应轮询 | - | - | |
| B. 会话缓存 | - | - | |
| C. DB 校验调优 | | | |
| D. 调试截图异步 | | | |
| E. 合并 xdotool | | | |
| F. 并行 DB 查询 | - | - | |
| G. 队列延时自适应 | - | - | |
**阶段 1 即可落地 C/D/E**低成本无需新架构立即节省 ~8sDB 校验 7s + 截图 0.5s + 合并命令 0.2s)。
### 15.12 速度监控指标
```python
# bridge/ui/metrics.py
# 首条 vs 缓存命中耗时分布
send_duration_first = Histogram(
"woc_send_duration_first_seconds", "First send (no cache)",
buckets=[1, 2, 3, 5, 8, 12, 16, 25],
)
send_duration_cached = Histogram(
"woc_send_duration_cached_seconds", "Cached session send",
buckets=[0.5, 1, 1.5, 2, 3, 5],
)
# 会话缓存命中率
session_cache_hit = Counter(
"woc_session_cache_total", "Session cache hit/miss",
["result"], # hit / miss
)
# 自适应等待超时次数
adaptive_wait_timeout = Counter(
"woc_adaptive_wait_timeout_total", "Adaptive wait timeout",
["step"], # search_box / search_result / session_open
)
```
**告警阈值**
- `woc_send_duration_first_seconds` P95 > 8s → 排查自适应等待是否失效
- `woc_session_cache_hit{result="miss"}` 占比 > 80% → 排查缓存 TTL
- `woc_adaptive_wait_timeout_total` 增长率 > 10/min → 模板可能失效
### 15.13 速度与可靠性的平衡
**核心原则**:速度优化不能牺牲可靠性。
| 场景 | 速度优先 | 可靠性优先 | 决策 |
|---|---|---|---|
| 会话缓存命中后是否跳过焦点校验 | 是(省 0.3s | 否(防微信切走) | **否**,必须轻量校验 |
| DB 校验超时设为多短 | 1s | 3s | **3s**,覆盖 P99 |
| 自适应等待超时是否报错 | 否(容错继续) | 是(严格) | **否**timeout 兜底 + 记录 metric |
| 队列延时同联系人设多短 | 500ms | 1s | **1s**,给微信 UI 刷新时间 |
| 合并 xdotool 命令是否影响错误定位 | 是(单条失败难定位) | 否(分步可定位) | **合并**,日志记录命令内容 |
**关键约束**
- 自适应等待的 timeout 不小于 2s防止 UI 偶发慢响应误判)
- 会话缓存 TTL 不超过 30s防止微信自动切走后误用
- DB 校验 talker 不匹配时立即退出(不缩短超时,但早退)
- 队列延时不低于 1s同联系人/ 3s不同联系人
---
## 十六、附录:实施检查清单
### 16.1 阶段 1 检查清单P0 高可用 + 速度初步优化)
- [ ] `_CMD_TIMEOUT_SEC` 从 15s 降到 5s
- [ ] `_run_managed` 上下文管理器实现,取消时 kill+wait
- [ ] `_step``max(1.0, remaining)` 改为 `max(7.0, remaining)`
- [ ] 生产环境 `WOC_UI_DEBUG_SHOTS=false`
- [ ] 调试截图改为异步队列写盘
- [ ] `_click_at` 合并 mousemove + click 为单条命令
- [ ] DB 校验超时从 10s 降到 3s轮询从 0.5s 降到 0.2s
- [ ] DB 校验 talker 不匹配时立即退出
- [ ] bridge 启动时执行 `_full_cleanup_on_startup`
- [ ] 现有 `/api/send/text` 接口行为不变
- [ ] 无子进程泄漏(压力测试)
### 16.2 阶段 3 检查清单(状态机 + 速度全面优化)
- [ ] `SendTextFlow` 状态机实现9 个状态覆盖
- [ ] 固定 sleep 改为自适应轮询10 处)
- [ ] 会话缓存 `SessionCache` 实现TTL 30s
- [ ] 队列延时自适应(同联系人 1s / 不同 3s
- [ ] DB 查询与窗口激活并行
- [ ] 幂等缓存含 `client_request_id`
- [ ] 错误码分类8 类)
- [ ] 熔断器DB + 图像)
- [ ] Watchdog 微信/Xvnc 崩溃感知
- [ ] Prometheus metric 暴露
- [ ] TraceID 贯穿日志
- [ ] 首条消息 P95 < 5s
- [ ] 同联系人后续 P95 < 3s