2561 lines
105 KiB
Markdown
2561 lines
105 KiB
Markdown
# 微信 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 树里只有空 pane,dogtail 拿不到任何可交互节点
|
||
- **CDP 路线可行性未验证**:主进程不吃命令行参数,需 LD_PRELOAD 注入或独立启动 WeChatAppEx,ROI 存疑
|
||
- **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(多策略定位,可插拔) │
|
||
│ ImageLocator(OpenCV)/ GeomLocator(像素兜底)/ CDPLocator │
|
||
│ 统一 Selector → ElementHandle 接口 │
|
||
└────────────────────────┬────────────────────────────────────────┘
|
||
│
|
||
┌────────────────────────┴────────────────────────────────────────┐
|
||
│ Layer 1: Backend Adapters(执行后端,多驱动并存) │
|
||
│ XdotoolBackend / OpenCVBackend / CDPBackend(可选) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
|
||
横切关注点(贯穿所有层):
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ HA: Watchdog(微信/Xvnc 崩溃感知) │
|
||
│ HA: Metrics(Prometheus 端点) │
|
||
│ 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,不绕道 L2(Action 内部组合 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 降到 5s(xdotool 命令本身是毫秒级,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=300s(5 分钟)。
|
||
|
||
幂等键 = (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 server(Xvnc)
|
||
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:
|
||
"""触发微信 autofix:pkill → 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 Metrics(Prometheus)
|
||
|
||
```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)路径
|
||
|
||
**核心原则**:每个阶段都可独立交付,每个阶段都有可见收益,不允许大爆炸式重构。
|
||
|
||
### 阶段 0:PoC 验证(先验证假设)
|
||
|
||
#### PoC 1:OpenCV 模板匹配可行性
|
||
- 容器内手动截微信主界面(搜索框、发送按钮、输入框各一张)
|
||
- **或直接使用用户已提供的截图**:`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_name,value = 打开时间戳。
|
||
TTL = 30s(30s 内无操作则认为微信可能切走了,缓存失效)。
|
||
"""
|
||
|
||
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 策略 C:DB 校验调优
|
||
|
||
**问题**:当前 `_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 写入延迟实测 < 500ms(SQLCipher 同步写入)
|
||
- 轮询 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**(低成本,无需新架构),立即节省 ~8s(DB 校验 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
|