# 微信 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
