From 67aab58a00e9ba712a8230967495ed048516a9f0 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Sat, 18 Jul 2026 16:04:25 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9EPRD=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E4=B8=8EWechatOnCloud=E6=94=B9=E9=80=A0=E3=80=81=E5=A4=9A?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=A1=A5=E6=8E=A5=E6=A1=86=E6=9E=B6=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三份文档: 1. 产品需求文档(PRD)编写规范 2. WechatOnCloud容器化微信改造需求方案 3. 多应用桥接框架整体设计方案 --- doc/bridge-prd-v1.0.md | 1405 +++++++++ doc/产品需求文档规范.md | 264 ++ doc/优化方案/01-多应用桥接框架设计.md | 939 ++++++ doc/优化方案/02-好友自动通过设计方案.md | 1772 ++++++++++++ doc/优化方案/03-微信UI自动化架构优化方案.md | 2560 +++++++++++++++++ doc/优化方案/04-WechatOnCloud-改造需求方案.md | 1094 +++++++ .../05-UIActionScheduler统一调度方案.md | 1645 +++++++++++ 7 files changed, 9679 insertions(+) create mode 100644 doc/bridge-prd-v1.0.md create mode 100644 doc/产品需求文档规范.md create mode 100644 doc/优化方案/01-多应用桥接框架设计.md create mode 100644 doc/优化方案/02-好友自动通过设计方案.md create mode 100644 doc/优化方案/03-微信UI自动化架构优化方案.md create mode 100644 doc/优化方案/04-WechatOnCloud-改造需求方案.md create mode 100644 doc/优化方案/05-UIActionScheduler统一调度方案.md diff --git a/doc/bridge-prd-v1.0.md b/doc/bridge-prd-v1.0.md new file mode 100644 index 0000000..122257e --- /dev/null +++ b/doc/bridge-prd-v1.0.md @@ -0,0 +1,1405 @@ +# WechatOnCloud Bridge 产品需求文档 + +| 字段 | 内容 | +| --- | --- | +| 文档标题 | WechatOnCloud Bridge 产品需求文档 | +| 文档版本 | v1.0 | +| 作者 | TRAE Agent | +| 创建日期 | 2026-07-17 | +| 最后更新日期 | 2026-07-17 | +| 状态 | 草稿 | +| 关联需求 | bridge 全量能力基线(回溯性 PRD,对应源码版本 `BRIDGE_VERSION=1.0.1`) | +| 评审人 | 产品 / 研发 / 测试 / 运维 | + +> 说明:本 PRD 为回溯性文档,bridge 模块已实现并运行。文档目标是基于 `d:\WechatOnCloud-main\bridge\woc_bridge\` 源码建立完整、准确的需求基线,所有 API 路径、错误码、配置默认值、阈值均以源码为准。 + +--- + +## 1. 背景与目标 + +### 1.1 背景 + +bridge 是 WechatOnCloud(WOC)项目中运行在微信容器内的 FastAPI 服务,定位为 Panel(管理前端)与 WeChat 客户端之间的适配层。其核心职责包括: + +- **DB 解密读取**:通过纯 Python 实现 SQLCipher 4 解密微信 4.x Linux 本地 DB,提供消息 / 联系人 / 群 / 朋友圈查询能力。 +- **UI 自动化**:基于 xdotool + OpenCV 驱动 WeChat 4.x Linux X11 窗口,完成消息发送、朋友圈、好友管理、撤回 / 转发等程序化操作。 +- **实时消息推送**:通过 SSE 长连接替代客户端轮询。 +- **群发编排**:串行复用 FlowOrchestrator + SendQueue,含三层幂等与风控。 +- **聊天记录导出**:支持 HTML / CSV / JSON / TXT 四种格式,同步与异步两种模式。 + +bridge 实现过程中代码迭代较多,但缺乏统一的需求基线文档,存在功能边界不清、阈值散落代码各处、错误码与配置项无集中说明等问题。本 PRD 用于补齐该文档缺口。 + +### 1.2 目标 + +- 覆盖 bridge 全部对外 API 端点(≥ 40 个),逐一列明路径、方法、入参、出参、错误码。 +- 集中说明 27 个错误码、24 项环境变量配置、关键运行时阈值,建立可追溯的需求基线。 +- 明确 P0 / P1 功能的验收标准(采用 Given-When-Then 格式),覆盖登录扫码、消息发送幂等、撤回时限、群发风控、好友自动通过规则决策、DB 解密、SSE 推送、导出 TTL、熔断与限流触发等关键路径。 +- 明确非功能阈值(限流、SSE、熔断、幂等、风控、撤回时限、慢请求、大文件超时等),所有数值来自源码。 + +### 1.3 非目标 + +- 不涉及 Panel 侧(管理前端)需求。 +- 不涉及微信客户端(WeChat 4.x Linux)改造需求。 +- 不涉及本次代码实现(回溯性文档,代码已落地)。 +- 不涉及 channels 适配器(Panel 侧调用 bridge 的 SDK)需求。 + +--- + +## 2. 名词解释 + +| 术语 | 含义 | +| --- | --- | +| `bridge` | 运行在微信容器内的 FastAPI 服务,本 PRD 主体。监听 `0.0.0.0:8088`。 | +| `Panel` | WechatOnCloud 的管理前端,通过 HTTP 调用 bridge 暴露的 API。 | +| 实例 | 一个 bridge 进程对应一个微信账号。多账号场景需多个容器实例隔离。 | +| 容器 | bridge 运行的 Docker 容器,必须以 `--cap-add=SYS_PTRACE` 启动以允许内存扫描提取 DB 密钥。 | +| `SQLCipher` | 微信 4.x Linux 使用的加密 SQLite 实现(WCDB),参数见 5.3 节。 | +| `xdotool` | X11 自动化命令行工具,bridge 的 UI 操作底层依赖。微信 4.x Linux 自绘 UI 不响应 Ctrl+V,必须用 `xdotool type` 逐字符输入。 | +| `SSE` | Server-Sent Events,`text/event-stream` 长连接,用于实时消息推送。 | +| 熔断器 | CircuitBreaker,连续失败达阈值后进入 OPEN 状态拒绝请求,经过恢复时间后进入 HALF_OPEN 试探。 | +| 幂等缓存 | IdemCache,基于 `(flow_name, to_wxid, content, client_request_id)` 的 SHA256 键,TTL=300s,max_size=1000,防短时重复发送。 | +| 风控 | 群发场景的频率与配额限制:单批 ≤ 200、日频次 ≤ 3、批间隔 ≥ 7200s,单条间隔抖动 2-4s。 | +| `WAL` | Write-Ahead Log,SQLite 的预写日志。bridge 通过 mtime 感知 WAL 变化,发送后 DB 校验需等待 WAL 刷盘(约 1.5-2s)。 | +| `trace_id` | 12 字符 hex 字符串(如 `a3b5c7d9e1f2`),每请求生成,通过 ContextVar 贯穿日志。 | +| `SYS_PTRACE` | Linux capability,允许进程 ptrace 其他进程。bridge 自动提取 DB 密钥需要扫描微信进程内存,依赖此能力(同时要求 `/proc/sys/kernel/yama/ptrace_scope=0`)。 | +| `flow` | UI 自动化的执行单元(如 `SendTextFlow`、`SendFileFlow`),由 FlowOrchestrator 编排。 | +| `local_send_id` | bridge 本地生成的发送 ID(格式 `local__<随机>`),仅用于客户端幂等去重,**不对应微信原生 msg_id**,禁止用于 `/api/media/{msg_id}`。 | + +--- + +## 3. 用户与场景 + +### 3.1 目标用户角色 + +| 角色 | 说明 | 主要场景 | +| --- | --- | --- | +| Panel 管理员 | 通过浏览器操作 Panel 调用 bridge 的运营人员 | 扫码登录、群发营销、好友管理、发朋友圈、导出聊天记录、诊断排障 | +| 终端用户 | 被 bridge 自动化操作影响的微信账号所有者 | 收发消息、好友申请被自动通过、收到群发消息 | +| API 调用方 | 第三方集成(如 channels 适配器)调用 bridge API 的程序 | 增量消息拉取、SSE 订阅、消息发送、状态查询 | +| 运维 | 负责容器部署、监控、故障排查 | 部署配置、监控 `/metrics`、诊断 autofix、密钥注入 | + +### 3.2 用户故事 + +- US-01:作为 Panel 管理员,我希望扫码登录微信,以便 Panel 能远程操控该账号。 +- US-02:作为 API 调用方,我希望按复合游标增量拉取消息,以便准确同步会话历史不重不漏。 +- US-03:作为 API 调用方,我希望订阅 SSE 推送实时消息,以便降低拉取延迟并减少无效轮询。 +- US-04:作为 Panel 管理员,我希望发送文本 / 图片 / 文件消息,以便程序化触达指定联系人。 +- US-05:作为 Panel 管理员,我希望群发营销消息给一批联系人,以便批量触达且不被微信风控。 +- US-06:作为 Panel 管理员,我希望配置好友自动通过规则,以便按黑/白名单、场景、关键词自动处理好友申请。 +- US-07:作为 Panel 管理员,我希望发表朋友圈 / 分享公众号文章,以便程序化运营朋友圈。 +- US-08:作为 Panel 管理员,我希望导出指定会话的聊天记录为 HTML/CSV/JSON/TXT,以便备份或取证。 +- US-09:作为运维,我希望执行诊断与 autofix,以便快速定位微信进程 / 登录态 / DB / xdotool 异常。 +- US-10:作为运维,我希望从 `/metrics` 抓取 Prometheus 指标,以便监控 bridge 运行状态。 +- US-11:作为 API 调用方,我希望撤回 2 分钟内发送的消息,以便纠正错误发送。 +- US-12:作为 Panel 管理员,我希望查询与修改好友备注,以便管理联系人元信息。 + +### 3.3 关键流程时序图 + +#### 图 1:扫码登录流程 + +```mermaid +sequenceDiagram + participant Panel + participant Bridge + participant WeChat + participant DB + + Panel->>Bridge: GET /api/status + Bridge->>WeChat: pgrep -x wechat + Bridge->>WeChat: xdotool search 微信 + Bridge-->>Panel: login_state=not_logged_in + + Panel->>Bridge: POST /api/login/qr/start + Bridge->>WeChat: 激活窗口 + 截取二维码区域 + Bridge-->>Panel: qr_data_url(data:image/png;base64,...) + + Panel->>Bridge: GET /api/login/qr/wait?timeout=30 + loop 每 2 秒 + Bridge->>WeChat: detect_login_state + WeChat-->>Bridge: state + end + Bridge->>DB: 读取 self_info (wxid, nickname) + Bridge-->>Panel: connected=true, credentials={wxid, nickname} +``` + +#### 图 2:消息发送全链路(flow 路径) + +```mermaid +sequenceDiagram + participant Panel + participant Route + participant Orchestrator + participant IdemCache + participant Breaker + participant SendQueue + participant Flow + participant DB + + Panel->>Route: POST /api/send/text {to_wxid, content, client_request_id} + Route->>Route: 参数校验 + 登录态检测 + Route->>Route: 解析 display_name(备注 > 昵称 > wxid,5min TTL) + Route->>Orchestrator: send_text(to_wxid, content, display_name, client_request_id) + + Orchestrator->>IdemCache: get("send_text", to_wxid, content, client_request_id) + alt 命中幂等 + IdemCache-->>Orchestrator: cached FlowResult + Orchestrator-->>Route: skipped=true + else 未命中 + Orchestrator->>Breaker: allow? + alt OPEN + Breaker-->>Orchestrator: reject + Orchestrator-->>Route: error_code=BRIDGE_CIRCUITED (503) + else CLOSED/HALF_OPEN + Orchestrator->>SendQueue: enqueue(flow.run, delay_ms=同联系人1000/不同3000, wait_timeout=15000) + SendQueue->>SendQueue: 限流检查(1秒滑动窗口 max_calls_per_sec=10) + SendQueue->>Flow: run(FlowContext) + Flow->>WeChat: activate + 搜索 + 进入会话 + 输入 + 回车 + Flow->>DB: 查询目标会话最新 (create_time, local_id) 基线 + Note over Flow,DB: 等 WAL 刷盘 1.5-2s,轮询 DB 3s 校验内容匹配 + Flow-->>SendQueue: FlowResult(success, verified) + SendQueue-->>Orchestrator: FlowResult + alt 成功且 verified + Orchestrator->>IdemCache: set(...) + Orchestrator->>Breaker: record_success + else 成功但 verify 失败 + Orchestrator->>Breaker: record_failure + else 失败 + Orchestrator->>Orchestrator: session_cache.invalidate(to_wxid) + end + Orchestrator-->>Route: FlowResult + end + end + Route-->>Panel: SendResponse(success, local_send_id, placeholder, verified, skipped) +``` + +#### 图 3:群发消息流程 + +```mermaid +sequenceDiagram + participant Panel + participant BatchRoute + participant BatchWorker + participant Orchestrator + participant DB + + Panel->>BatchRoute: POST /api/send/batch {targets[200], content, client_request_id, abort_on_consecutive_fail=5} + BatchRoute->>BatchWorker: submit_batch(req) + BatchWorker->>BatchWorker: 风控校验:去重 ≤200 / 日频次 <3 / 间隔 ≥7200s + alt 风控命中 + BatchWorker-->>BatchRoute: RATE_LIMITED(retry_after) / INVALID_PARAMS + else 通过 + BatchWorker->>BatchWorker: batch_id 生成 + 持锁更新 _daily_count / _last_batch_end_time + BatchWorker-->>BatchRoute: batch_id + end + BatchRoute-->>Panel: {batch_id, status_query_url} + + loop 串行(每个 target) + BatchWorker->>DB: 查 nickname 替换 {nickname} 占位符 + BatchWorker->>Orchestrator: send_text(to_wxid, content, client_request_id="{batch_id}:{to_wxid}") + Note over Orchestrator: 复用单条幂等 + 熔断 + 入队 + DB 校验 + Orchestrator-->>BatchWorker: FlowResult + alt 连续失败 ≥ abort_on_consecutive_fail + BatchWorker->>BatchWorker: 整批中止,剩余标记 skipped + else 非最后一条 + BatchWorker->>BatchWorker: sleep 2-4s 随机抖动 + end + end + + Panel->>BatchRoute: GET /api/send/batch/{batch_id}/status + BatchRoute-->>Panel: BatchSendStatus(total, processed, success, failed, skipped, progress, estimated_remaining_sec) +``` + +--- + +## 4. 功能需求 + +### 4.0 权限矩阵说明 + +- 所有 `/api/*` 端点必须通过 Bearer Token 鉴权(环境变量 `WOC_BRIDGE_API_TOKEN`,由上游反向代理或 Panel 校验)。 +- Token 校验通过后授予全部功能权限,无细粒度角色划分。 +- `/metrics`、`/api/status`、`/api/diagnostic/connectivity` 允许无鉴权访问(健康检查与监控用)。 +- 本 PRD 不约束 Token 注入方式(由部署侧反代实现),仅约束业务行为。 + +### FR-01 状态查询 + +- **描述**:聚合返回 bridge 运行状态,单次请求完成进程 / 窗口 / 登录态 / DB 可达性 4 类检测,并返回账号信息与客户端轮询建议参数。 +- **输入**:无。 +- **输出**:`StatusResponse`,含 `bridge_version` / `wechat_running` / `wechat_window_found` / `login_state` / `db_accessible` / `db_error_code` / `init_in_progress` / `init_progress_pct` / `current_wxid` / `current_nickname` / `uptime_seconds` / `display` / `max_batch_size` / `poll_interval_ms` / `send_queue_pending` / `media_supported` / `bridge_capabilities`。 +- **业务规则**: + - 不抛业务错误:即使微信未运行 / DB 加密也返回 200,调用方按字段判定下一步。 + - `login_state` 内联判定(`not_running` / `not_logged_in` / `logged_in`),避免重复 pgrep / xdotool。 + - DB 加密时细分 `db_error_code`:`encrypted_key_ok` / `encrypted_no_key` / `key_extract_failed`。 + - `current_wxid` / `current_nickname` 仅在 `db_accessible=true` 且 `login_state=logged_in` 时尝试读 DB,失败静默为空字符串。 +- **异常与边界**:组件未初始化时抛 `BRIDGE_INTERNAL_ERROR(500)`;DB 读取通过 `with_db_retry` 装饰,自动重试。 +- **优先级**:P0。 + +### FR-02 登录管理 + +- **描述**:提供扫码登录启动 / 等待、退出登录,以及微信进程重启能力。 +- **输入**: + - `POST /api/login/qr/start`:无入参。 + - `GET /api/login/qr/wait?timeout=30`:`timeout` 默认 30,<1 抛 `INVALID_PARAMS(400)`,>120 截断为 120。 + - `POST /api/login/logout`:无入参。 + - `POST /api/wechat/restart`:无入参。 +- **输出**: + - `QrLoginStartResult`:`qr_data_url`(`data:image/png;base64,...`) / `message` / `connected=false`。 + - `QrLoginWaitResult`:`connected` / `message` / `qr_data_url` / `credentials={wxid, nickname}`。 + - `LogoutResponse`:`success` / `message`。 + - `RestartResponse`:`success` / `message` / `pid`。 +- **业务规则**: + - 二维码有时效(微信约 60s 刷新),超时后调用方应重新调 `qr/start`。 + - `qr/wait` 长轮询,每 2 秒检测一次登录态;`not_running` 时立即返回不再等待。 + - `logout` 幂等:未登录时返回 `success=true` + `message="当前未登录,无需退出"`。 + - `restart` 流程:pgrep 获取 PID → SIGTERM → 轮询等待新 PID(autostart 拉起),30 秒超时抛 `RESTART_TIMEOUT(408)`;不破坏登录态与数据卷。 +- **异常与边界**: + - `qr/start`:窗口未找到抛 `WINDOW_NOT_FOUND(503)`。 + - `qr/wait`:`timeout<1` 抛 `INVALID_PARAMS(400)`。 + - `logout`:UI 操作失败抛 `LOGOUT_FAILED(500)`,窗口未找到抛 `WINDOW_NOT_FOUND(503)`。 + - `restart`:超时抛 `RESTART_TIMEOUT(408)`,pgrep 异常抛 `BRIDGE_INTERNAL_ERROR(500)`。 +- **优先级**:P0。 + +### FR-03 DB 解密与密钥管理 + +- **描述**:手动注入 64 位 hex 密钥、查询密钥缓存状态、显式触发后台密钥提取、查询初始化进度。 +- **输入**: + - `POST /api/db/decrypt`:`DbDecryptRequest{key: str(64-hex), salt?: str(32-hex)}`。 + - `GET /api/db/key/status`:无入参。 + - `POST /api/db/init`:`DbInitRequest{pid?, db_dir?, force=false}`。 + - `GET /api/db/init/status`:无入参。 +- **输出**: + - `DbDecryptResponse`:`success` / `verified` / `key_mode`(`enc_key` 或 `key_material`)/ `error`(`key_mismatch` / `db_not_encrypted` 等)。 + - `DbKeyStatusResponse`:`cached` / `source`(`env` / `api` / `auto_extract` / `file`)/ `verified` / `key_prefix`。 + - `DbInitResponse`:`success` / `state`(`started` / `already_done` / `in_progress`)/ `message` / `key_count`。 + - `DbInitStatusResponse`:`state`(`idle` / `running` / `success` / `failed`)/ `progress_pct` / `message` / `key_count` / `error`。 +- **业务规则**: + - 密钥必须是 64 位十六进制字符串,否则抛 `INVALID_PARAMS(400)`。 + - DB 不存在抛 `DB_NOT_FOUND(500)`;DB 不可读抛 `DB_NOT_FOUND(500)`(permission);明文 DB 返回 `error="db_not_encrypted"`。 + - 用户未传 `salt` 时 bridge 自动读取当前 DB 的 salt;读取失败抛 `DB_ENCRYPTED(503)`。 + - 验证通过后按 `salt` 缓存到 `KeyCache` 并设置 `source="api"`。 + - `init` 后台线程执行内存扫描,运行中重复调用返回 `state="in_progress"`;已初始化且 `force=false` 返回 `already_done`。 +- **异常与边界**:`INVALID_PARAMS(400)` / `DB_NOT_FOUND(500)` / `DB_ENCRYPTED(503)` / `DB_KEY_INVALID(503)`。 +- **优先级**:P0。 + +### FR-04 消息读取 + +- **描述**:增量拉取、按会话拉取、关键词搜索、SSE 实时推送。 +- **输入**: + - `GET /api/messages/since?cursor=0&cursor_local_id=0&limit=50&is_sender=`:复合游标 `(create_time, local_id)`,`limit` 1-200 默认 50。 + - `GET /api/messages/by_session?talker=&cursor=0&cursor_local_id=0&limit=50&direction=before&is_sender=`:`direction` ∈ `before` / `after`,`limit` 1-200 默认 50。 + - `GET /api/messages/search?keyword=&talker=&start_time=0&end_time=0&limit=50&is_sender=`:`limit` 1-200 默认 50。 + - `GET /api/messages/stream`:无入参,建立 SSE 长连接。 +- **输出**: + - `MessagesResponse` / `MessagesBySessionResponse`:`messages[]` / `next_cursor` / `next_cursor_local_id` / `has_more` / `talker`。 + - `MessageSearchResponse`:`messages[]` / `total`。 + - SSE 流:`sync` / `messages` / `status` / `heartbeat`(30s)/ `kicked` 事件。 +- **业务规则**: + - **复合游标**:`(create_time, local_id)` 共同定位分页边界,避免同秒消息重复 / 遗漏。客户端首次传 0,下次用响应中的 `next_cursor` + `next_cursor_local_id`。 + - `has_more=true` 当且仅当返回条数 ≥ `limit`,应立即继续拉取。 + - `by_session` 通过 `talker` 计算 `Msg_` 表名直接查单表;`before` 模式返回升序(旧在前),`after` 模式返回降序之后转升序。 + - 群消息 `content` 形如 `"wxid:\n正文"`,DbReader 拆出 `sender` 字段。 + - `search` 基于 SQL `LIKE` 模糊匹配,无法搜索 zstd 压缩消息(`WCDB_CT_message_content==4`);`keyword` 中的 `%` / `_` 已转义为字面量。 + - `search` 的 `total` 受每表 `LIMIT` 截断,可能小于实际命中数。 + - SSE:内部 1s(有订阅者)/ 5s(无订阅者)轮询 DB mtime,变化时读增量并广播。订阅者上限 `MAX_SUBSCRIBERS=3`,超限剔除最早订阅者并投递 `kicked` 事件。 + - SSE 断线补偿:客户端重连后用 `sync` 事件的 `cursor` 与本地 `max(cursor, sync.cursor)` 调 `/api/messages/since` 补全。 +- **异常与边界**:`INVALID_PARAMS(400)`(cursor<0、limit 越界、direction 非法、talker 为空、keyword 为空)/ `DB_NOT_FOUND(500)` / `DB_ENCRYPTED(503)`。SSE 不抛业务错误,DB 不可读时通过 `status` 事件告知客户端。 +- **优先级**:P0(`since` / `stream` / `by_session`)/ P1(`search`)。 + +### FR-05 消息发送(单聊) + +- **描述**:发送文本 / 图片 / 文件,含撤回与转发(experimental)。 +- **输入**: + - `POST /api/send/text`:`SendTextRequest{to_wxid, content, display_name?, client_request_id=""}`。 + - `POST /api/send/image`:`SendFileRequest{to_wxid, file_path, display_name?, client_request_id?}`。 + - `POST /api/send/file`:`SendFileRequest{to_wxid, file_path, display_name?, client_request_id?}`。 + - `POST /api/messages/revoke`:`RevokeMessageRequest{talker, create_time, display_name?}`。 + - `POST /api/messages/forward`:`ForwardMessageRequest{talker, target_display_name, source_display_name?}`。 +- **输出**: + - `SendResponse`:`success` / `local_send_id` / `placeholder` / `verified` / `skipped` / `error`。 + - `RevokeMessageResponse` / `ForwardMessageResponse`:`success` / `error` / `verified`。 +- **业务规则**: + - **幂等键 `client_request_id`**:空字符串表示不参与幂等校验。命中幂等缓存返回 `skipped=true`,避免重复发送。 + - **`display_name` 解析优先级**:显式传入 > 缓存(5min TTL,max 1024 条)> `contact.db` 备注 `remark` > 昵称 `nickname` > `wxid` 本身。启动时预热最近 1000 个联系人。 + - **登录态前置检查**:非 `logged_in` 直接抛 `WECHAT_NOT_LOGGED_IN(401)`,避免 UI 操作引发不可预期行为。 + - **发送后 DB 校验**:记录目标会话 `(create_time, local_id)` 基线 → 发送后轮询 DB(间隔 0.2s,最多 3s)→ 内容匹配校验防串号。校验失败抛 `SEND_FAILED(500)`。 + - **flow vs legacy 路径**:`WOC_UI_BACKEND=flow`(默认)走 FlowOrchestrator,含幂等 / 熔断 / 会话缓存(30s TTL,max 16)/ 自适应延时(同联系人 1000ms / 不同 3000ms)。`legacy` 路径仅走 send_queue。 + - **文件路径白名单**(`send/file`、`send/image`):必须位于 `/config/Desktop/` / `/config/woc-uploads/` / `/tmp/woc-files/` 内,且段级不含 `..`,`os.path.realpath` 解析后以白名单前缀开头。 + - **撤回时限**:110 秒(微信限制 2 分钟,预留 10s UI 操作余量)。`now_ts - create_time > 110` 抛 `REVOKE_WINDOW_EXPIRED(409)`。 + - **`local_send_id` 不对应微信原生 `msg_id`**,禁止用于 `/api/media/{msg_id}`。 +- **异常与边界**: + - `INVALID_PARAMS(400)`:参数为空 / 文件不存在 / 路径不在白名单。 + - `WECHAT_NOT_LOGGED_IN(401)`:未登录。 + - `REVOKE_WINDOW_EXPIRED(409)`:撤回超时。 + - `RATE_LIMITED(429)`:发送限流命中(1 秒滑动窗口 max_calls_per_sec=10)或队列满。 + - `SEND_FAILED(500)`:UI 操作失败或 DB 校验失败。 + - `BRIDGE_CIRCUITED(503)`:DB 校验熔断器 OPEN。 + - `SEND_TIMEOUT(503)` / `WINDOW_NOT_FOUND(503)` / `STATE_DIRTY(503)` / `X11_UNAVAILABLE(503)` 等 Flow 错误码。 + - `revoke` / `forward` 为 experimental,坐标为估算值需实测调优。 +- **优先级**:P0(`send/text`、`send/file`、`send/image`、`revoke`)/ P2(`forward` experimental)。 + +### FR-06 群发消息 + +- **描述**:批量发送文本消息给一批联系人,含三层幂等与风控。 +- **输入**: + - `POST /api/send/batch`:`BatchSendRequest{targets[1-200], content[1-2000], client_request_id?, abort_on_consecutive_fail=5(1-20)}`。 + - `GET /api/send/batch/{batch_id}/status`:路径参数 `batch_id`。 + - `POST /api/send/batch/{batch_id}/cancel`:路径参数 `batch_id`。 + - `GET /api/send/batch?limit=20`:`limit` 1-100 默认 20。 +- **输出**: + - 提交:`{success, batch_id, status, status_query_url}`。 + - 查询:`BatchSendStatus{batch_id, status, total, processed, success, failed, skipped, progress, estimated_remaining_sec, abort_reason, items[]}`,`items` 含 `BatchItemResult{to_wxid, status, error_code, error_message, verified}`。 + - 取消:`{success, batch_id, status="cancelled"}`。 + - 列表:`{batches: [BatchSendStatus, ...]}`。 +- **业务规则**: + - **三层幂等**: + - 批级:`client_request_id`(None 时自动生成 `batch_`),重复 `batch_id` 抛 `INVALID_PARAMS(409)`。 + - 单条:内部按 `f"{batch_id}:{to_wxid}"` 作为 `client_request_id` 传给 `orchestrator.send_text`。 + - orchestrator:复用 `IdemCache`(TTL=300s)。 + - **风控参数**: + - 单批 ≤ `WOC_BATCH_MAX_PER_BATCH=200`(去重后),超限抛 `INVALID_PARAMS(400)`。 + - 日频次 ≤ `WOC_BATCH_DAILY_LIMIT=3`,超限抛 `RATE_LIMITED(429)` + `retry_after` 到次日 0 点。 + - 批间隔 ≥ `WOC_BATCH_MIN_INTERVAL_SEC=7200` 秒,不足抛 `RATE_LIMITED(429)` + `retry_after`。 + - 单条间隔抖动:`random.uniform(2.0, 4.0)` 秒。 + - `content` 支持 `{nickname}` 占位符,发送前按目标联系人 `remark` > `nickname` > `wxid` 替换。 + - **风控状态**:在 `submit_batch` 持锁时立即递增 `_daily_count` 与更新 `_last_batch_end_time`,防止并发提交绕过。 + - **`abort_on_consecutive_fail`**:连续失败达阈值(默认 5,范围 1-20)时整批中止,剩余标记 `skipped`,状态置 `failed`,`abort_reason` 给出原因。 + - **cancel**:标记 `cancel_requested=true` + `task.cancel()`。当前正在发送的条目标 `unknown`(消息可能已实际发送),剩余 `pending` 标 `skipped`。已结束状态调用返回 `NOT_FOUND(404)`。 + - **状态机**:`pending` → `running` → `completed` / `failed` / `cancelled`。 + - **`estimated_remaining_sec`**:基于最近 10 条单条耗时均值 + 平均抖动 3s 估算。 +- **异常与边界**: + - `INVALID_PARAMS(400)`:`targets` 超过单批上限 / `limit` 越界。 + - `INVALID_PARAMS(409)`:`batch_id` 重复。 + - `RATE_LIMITED(429)`:日频次超限 / 批间隔不足。 + - `NOT_FOUND(404)`:`batch_id` 不存在或已完成(查询 / 取消)。 + - 单条发送失败透传 `SEND_FAILED` / `RATE_LIMITED` / `BRIDGE_CIRCUITED` 等错误码到 `BatchItemResult.error_code`。 +- **优先级**:P0。 + +### FR-07 好友管理 + +- **描述**:好友申请查询 / 手动通过 / 自动通过规则配置 / 修改备注 / 添加好友。 +- **输入**: + - `GET /api/friends/requests?limit=50`:`limit` 1-200 默认 50。 + - `POST /api/friends/accept`:`AcceptFriendRequest{stranger_wxid, nickname?}`。 + - `GET /api/friends/auto_accept/config`:无入参。 + - `PUT /api/friends/auto_accept/config`:`AcceptRuleConfig{enabled, accept_all, whitelist_wxids[], whitelist_nicknames[], keywords[], blacklist_wxids[], blacklist_nicknames[], allow_scenes[]}`。 + - `GET /api/friends/auto_accept/status`:无入参。 + - `POST /api/contacts/{wxid}/remark`:`SetRemarkRequest{remark, display_name?}`。 + - `POST /api/friends/add`:`AddFriendRequest{keyword, message=""}`。 +- **输出**: + - `FriendRequestsResponse`:`requests[FriendRequestItem{stranger_wxid, nickname, verify_message, scene, create_time}]` / `total`。 + - `AcceptFriendResponse` / `SetRemarkResponse` / `AddFriendResponse`:`success` / `error` / `verified`。 + - `AcceptRuleConfig`:见输入。 + - `AutoAcceptStatus`:`running` / `enabled` / `processed_count` / `accepted_count` / `rejected_count` / `last_processed_time` / `cursor_create_time` / `cursor_local_id` / `db_readable` / `breaker_state`。 +- **业务规则**: + - **规则引擎决策顺序**(`AcceptRuleEngine.evaluate`): + 1. `enabled=false` → `SKIP` + 2. `accept_all=true` → `ACCEPT`(跳过所有规则) + 3. `stranger_wxid` 或 `nickname` 命中黑名单 → `REJECT` + 4. `allow_scenes` 非空且 `scene` 不在列表 → `SKIP` + 5. `stranger_wxid` 或 `nickname` 命中白名单 → `ACCEPT` + 6. `verify_message` 包含任一 `keywords`(子串匹配)→ `ACCEPT` + 7. 无匹配 → `SKIP` + - **配置热更新**:`AcceptRuleEngine` 用 `asyncio.Lock` 保护读写,`update_config` 后同步更新 `FriendRequestWatcher.enabled` 状态并 start/stop watcher 协程(`start()` 幂等)。 + - **自动通过轮询间隔**:`WOC_AUTO_ACCEPT_POLL_INTERVAL=3` 秒。 + - **手动通过**:经 `send_queue` 串行执行 UI 操作(定位好友申请条目 → 点击通过 → 确认),完成后用 `_verify_accept_with_retry` 校验 `@stranger` 后缀消失。 + - **修改备注**:post_verify 失效 `display_name` 缓存(remark 已变)后查 DB 校验新值是否写入。 +- **异常与边界**: + - `INVALID_PARAMS(400)`:`stranger_wxid` / `keyword` / `remark` 为空、`limit` 越界。 + - `WECHAT_NOT_LOGGED_IN(401)`:未登录。 + - `SEND_FAILED(500)`:UI 操作失败。 + - `RATE_LIMITED(429)` / `WINDOW_NOT_FOUND(503)` 等透传错误码。 + - `contacts/remark` / `friends/add` 为 experimental,坐标为估算值。 +- **优先级**:P0(`friends/accept`、`auto_accept/config`、`auto_accept/status`、`friends/requests`)/ P2(`friends/add`、`contacts/remark` experimental)。 + +### FR-08 联系人与群查询 + +- **描述**:联系人列表 / 详情、群聊列表、群成员。 +- **输入**: + - `GET /api/contacts?keyword=&limit=50`:`keyword` 模糊匹配 wxid / nickname / remark,空串返回全部;`limit` 1-200 默认 50。 + - `GET /api/contacts/{wxid}`:路径参数。 + - `GET /api/groups?limit=50`:`limit` 1-200 默认 50。 + - `GET /api/groups/{wxid}/members`:路径参数(`wxid` 形如 `xxxxx@chatroom`)。 +- **输出**: + - `ContactsResponse`:`contacts[Contact{wxid, nickname, remark, avatar_url, type, alias, encrypt_username, quan_pin, pin_yin_initial, big_head_url, small_head_url, description, local_type, verify_flag, delete_flag, chat_room_type}]` / `total`。 + - `Contact`:单条记录(同上)。 + - `GroupsResponse`:`groups[Contact]` / `total`(群聊判定 `username LIKE '%@chatroom'`)。 + - `GroupMembersResponse`:`group_wxid` / `members[GroupMember{wxid, nickname, display_name, is_admin}]` / `total`。 +- **业务规则**: + - `get_contact_detail` 不存在的 wxid 返回 `CONTACT_NOT_FOUND(404)`,与 DB 不可达错误区分。 + - 群聊 username 也可用 `/api/contacts/{wxid}` 查询(DbReader 视为联系人)。 + - 群 wxid 不存在或无成员记录时返回空列表而非错误。 +- **异常与边界**:`INVALID_PARAMS(400)`(`limit` 越界)/ `DB_NOT_FOUND(500)` / `DB_ENCRYPTED(503)` / `CONTACT_NOT_FOUND(404)`。 +- **优先级**:P0。 + +### FR-09 朋友圈 + +- **描述**:朋友圈时间线读取、发表纯文字 / 图片、分享公众号文章、点赞 / 评论 / 删除。 +- **输入**: + - `GET /api/moments/timeline?cursor=0&limit=20`:`limit` 1-50 默认 20,`cursor` ≥ 0。 + - `POST /api/moments/publish`:`MomentPublishRequest{content}`。 + - `POST /api/moments/publish_image`:`MomentPublishImageRequest{image_path, content=""}`。 + - `POST /api/moments/share_article`:`MomentShareArticleRequest{public_account, article_index=1, comment?}`。 + - `POST /api/moments/like`:`MomentLikeRequest{moment_index=1}`。 + - `POST /api/moments/comment`:`MomentCommentRequest{comment, moment_index=1}`。 + - `POST /api/moments/delete`:`MomentDeleteRequest{moment_index=1}`。 +- **输出**: + - `MomentsTimelineResponse`:`moments[MomentItem{moment_id, content, create_time, author_wxid}]` / `next_cursor` / `has_more` / `status`。 + - 发表响应:`MomentPublishResponse` / `MomentPublishImageResponse` / `MomentShareArticleResponse`:`success` / `local_moment_id` / `placeholder=false` / `error`。 + - 互动响应:`MomentLikeResponse` / `MomentCommentResponse` / `MomentDeleteResponse`:`success` / `error` / `verified`。 +- **业务规则**: + - **`status` 字段**(`timeline`):WeChat 4.x 朋友圈 schema 未公开,需容错探测,可能值:`ok` / `db_not_found` / `table_not_found` / `no_columns` / `no_time_column` / `db_encrypted` / `query_error`。 + - `timeline` 不抛 `DB_NOT_FOUND`:DB 不存在属正常情况(新账号),通过 `status=db_not_found` 告知。 + - **`image_path` 白名单**:`os.path.realpath` 后必须以 `/config/` / `/tmp/` / `/data/` 开头,且文件存在可读。 + - 所有写操作经 `send_queue` 串行执行,避免与发消息等 UI 操作竞态。 + - `local_moment_id` 格式 `moment__<随机>` 或 `share__<随机>`,不对应微信原生朋友圈 ID。 + - `like` / `comment`:post_verify 与 UI 操作一起在 send_queue 内串行执行,避免 after 截图被下一个 UI 操作污染。 + - `delete`:删除前捕获朋友圈数量基线,post_verify 比对 `MomentsPost` 记录是否减少。 + - `moment_index=1` 表示最新一条。 +- **异常与边界**: + - `INVALID_PARAMS(400)`:`content` / `image_path` / `public_account` 为空、`moment_index<1` / `article_index<1`、`limit` 越界、`cursor<0`、路径不在白名单。 + - `WECHAT_NOT_LOGGED_IN(401)`:未登录。 + - `WINDOW_NOT_FOUND(503)` / `SEND_FAILED(500)`。 + - `like` / `comment` / `delete` / `publish_image` 为 experimental,坐标为估算值需实测调优。 +- **优先级**:P0(`timeline`、`publish`、`share_article`)/ P2(`like` / `comment` / `delete` / `publish_image` experimental)。 + +### FR-10 媒体下载 + +- **描述**:联系人头像、消息媒体文件下载。 +- **输入**: + - `GET /api/media/avatar/{wxid}`:路径参数。 + - `GET /api/media/{msg_id}`:路径参数(来自 `/api/messages/since` 返回的 `msg_id`)。 +- **输出**:`Response`:二进制内容 + `Content-Type`(`image/jpeg` / `audio/amr` / `video/mp4` / `application/octet-stream` 等)。 +- **业务规则**: + - **头像**:优先从 `contact.db` 读 `big_head_url` / `small_head_url` / `avatar_url`,HTTP 链接则拉取透传二进制。 + - **头像 CDN 白名单**:仅允许 `wx.qlogo.cn` / `thirdwx.qlogo.cn` / `wxhead.clouddn.com` / `thirdqq.qlogo.cn` / `q.qlogo.cn`。 + - **头像大小上限**:`_AVATAR_MAX_BYTES=10MB`(头像通常 < 1MB),`Content-Length` 预检 + 实际读取校验,`urlopen` 超时 15s。 + - **路由声明顺序敏感**:`/api/media/avatar/{wxid}` 必须在 `/api/media/{msg_id}` 之前声明,否则 FastAPI 会把 `"avatar"` 当作 `msg_id` 匹配。 + - **媒体 .dat 解密**:微信 4.x 的 `.dat` 文件采用单字节 XOR 加密(首字节为密文),DbReader 通过对比已知图片格式 magic bytes 推导 XOR key 后逐字节解密。 +- **异常与边界**: + - `MEDIA_NOT_FOUND(404)`:未找到联系人 / 无有效头像 URL / 头像 URL 校验失败 / 头像下载失败 / 媒体文件未找到或解析失败。 + - `DB_ENCRYPTED(503)` / `DB_NOT_FOUND(500)`。 +- **优先级**:P1。 + +### FR-11 聊天记录导出 + +- **描述**:导出指定会话消息为 HTML / CSV / JSON / TXT,支持同步与异步两种模式。 +- **输入**: + - `GET /api/messages/export?talker=&format=html&limit=1000&include_media=false&media_inline=false&start_time?&end_time?`:同步,`limit` 1-1000。 + - `POST /api/messages/export`:`ExportRequest{talker, format="html", limit=1000(1-100000), include_media=false, media_inline=false, start_time?, end_time?}`:异步。 + - `GET /api/messages/export/{task_id}/status`:路径参数。 + - `GET /api/messages/export/{task_id}/download`:路径参数。 + - `DELETE /api/messages/export/{task_id}`:路径参数。 +- **输出**: + - 同步:`StreamingResponse`(`Content-Disposition: attachment` 触发下载),MIME 按格式(`text/html` / `text/csv` / `application/json` / `text/plain`)。 + - 异步提交:`ExportTaskStatus{task_id, status="running", progress=0.0}`。 + - 状态查询:`ExportTaskStatus{task_id, status, progress, total, processed, download_url?, expires_at?, error_message?}`。 + - 下载:`FileResponse`(单文件或 zip)。 + - 取消:`{success, task_id, status="cancelled"}`。 +- **业务规则**: + - **同步阈值**:`limit ≤ _SYNC_EXPORT_LIMIT=1000`,超限必须走异步。 + - **异步并发**:全局同时只允许 1 个任务运行(`_MAX_CONCURRENT_EXPORT_TASKS=1`),提交时即置 `running`(避免并发提交绕过)。 + - **异步分页**:每批 `_EXPORT_BATCH_SIZE=200` 条,流式写入主文件。 + - **TTL**:完成后 `expires_at = now + export_task_ttl_sec=7200` 秒(2 小时),过期后懒清理(删除任务目录与文件)。 + - **媒体打包**:`include_media=true` 且 `media_inline=false` 时拷贝解密后的 .dat 媒体到 `media/` 子目录并打 zip;`media_inline=true` 时图片转 base64 内联(仅 HTML,仅适合少量图片)。 + - **HTML 渲染**:仿微信气泡样式(本人 `#95EC69` 右对齐、对方 `#FFFFFF` 左对齐、系统居中灰),相邻消息间隔 > 5 分钟插入时间分隔条,媒体缺失写 `[媒体缺失]` 占位。 + - **CSV**:首行写 UTF-8 BOM 让 Excel 正确识别编码。 + - **JSON**:结构化字段,含 `media_path`;`count` 在尾部写实际条数(流式无法预知)。 + - **TXT**:`[时间] 昵称: 内容` 纯文本,群消息用 `sender` 字段。 + - **导出目录**:`WOC_EXPORT_DIR=/config/woc-export`,每个任务建 `/` 子目录。 + - **懒清理**:`GET /{task_id}/status` 时触发,清理 `completed` / `failed` / `cancelled` 状态的过期任务。 + - **取消**:取消运行中的任务(`asyncio_task.cancel()`,等待 5s)+ 删除任务目录 + 内存移除。`completed` 状态调用也会提前回收文件。 + - **进程退出**:`lifespan finally` 调 `cancel_all_export_tasks_on_shutdown()`,取消所有运行中任务。 + - **失败兜底**:`failed` 状态设置 `expires_at = now + 300s`(5 分钟)确保被懒清理,避免内存泄漏。 +- **异常与边界**: + - `INVALID_PARAMS(400)`:`talker` 为空、`format` 非法、`limit` 越界、`start_time > end_time`、任务不存在。 + - `RATE_LIMITED(429)`:已有异步导出任务运行中(同步端点也会被拒绝,避免 DB I/O 抢占),`retry_after=30`。 + - `STATE_DIRTY(503)`:下载时任务未完成。 + - `BRIDGE_INTERNAL_ERROR(500)`:导出文件丢失。 + - `DB_NOT_FOUND(500)` / `DB_ENCRYPTED(503)`。 +- **优先级**:P0。 + +### FR-12 诊断与监控 + +- **描述**:连通性检查、诊断项定义、单项检查执行、自动修复。 +- **输入**: + - `GET /api/diagnostic/connectivity`:无入参。 + - `GET /api/diagnostic/items`:无入参。 + - `POST /api/diagnostic/run/{check_id}`:路径参数。 + - `POST /api/diagnostic/autofix/{check_id}`:路径参数。 +- **输出**: + - `ConnectivityResponse`:`reachable` / `latency_ms` / `error`。 + - `{items: [DiagnosticItem{check_id, name, severity, description, auto_repairable}]}`(共 4 项)。 + - `DiagnosticRunResult`:`check_id` / `passed` / `severity` / `message` / `auto_repairable` / `repair_plan?`。 +- **业务规则**: + - **诊断项清单**(`DIAGNOSTIC_ITEMS`): + | `check_id` | `name` | `severity` | `auto_repairable` | + | --- | --- | --- | --- | + | `wechat_running` | 微信进程检查 | `critical` | `true` | + | `login_state` | 登录状态检查 | `critical` | `false` | + | `db_accessible` | 数据库可达性 | `error` | `true` | + | `xdotool_available` | xdotool 可用性 | `error` | `true` | + - `connectivity` MVP 阶段恒返回 `reachable=true / latency_ms=0 / error=null`。 + - **`run` 行为**:不抛业务错误的诊断项,即使 `passed=false` 也返回 200 + 详细 `message`,`repair_plan` 仅 `db_accessible=encrypted/unreadable` 时非空。 + - **`autofix` 行为**: + - `wechat_running`:`pkill -x wechat` → 等 2s 让 autostart 拉起 → 未拉起则 bridge 显式 `start_wechat(timeout=10s)` 兜底。 + - `xdotool_available`:不可修复,返回 `passed=false` + `message="xdotool 不可用,请重建容器"`。 + - `db_accessible`:`need_init` / `key_invalid` 时尝试自动提取 key,失败返回 `repair_plan`。 + - 调用方收到 `passed=true` 后应间隔 3-5s 调 `run` 确认实际状态。 + - **Prometheus `/metrics`**:暴露 12 项指标(见 5.5 节)。 +- **异常与边界**:`INVALID_PARAMS(400)`:未知 `check_id` 或该项不可自动修复。 +- **优先级**:P1。 + +### FR-13 截图 + +- **描述**:截取完整 X 桌面并返回 PNG,用于调试观察微信实际界面状态。 +- **输入**:无。 +- **输出**:`Response`,`Content-Type=image/png`,body 为 PNG 二进制。 +- **业务规则**: + - 由 `qr_capture.capture_full_screenshot` 完成,底层调 `xdotool getwindowgeometry` + `import`(ImageMagick)。 + - 截取整个 X 桌面(`DISPLAY` 由 `_state.config.display` 指定)。 + - 与 `/api/login/qr/start` 区别:本接口截全屏,`qr/start` 截二维码区域。 + - 大屏幕截图可能 > 1MB,调用方注意带宽。 +- **异常与边界**:`WINDOW_NOT_FOUND(503)`:X 会话未就绪。其他异常由全局兜底处理器返回 `BRIDGE_INTERNAL_ERROR(500)`。 +- **优先级**:P1。 + +--- + +## 5. 非功能需求 + +### 5.1 性能 + +| 维度 | 阈值 / 参数 | 来源 | +| --- | --- | --- | +| 发送限流 | 1 秒滑动窗口 `max_calls_per_sec=10`,超出抛 `RATE_LIMITED(429)` + `retry_after` | `SendQueue._check_rate_limit` | +| 发送队列 | `max_queue_size=100`,满时入队抛 `RATE_LIMITED(429)` + `retry_after=3` | `SendQueue.__init__` | +| 队列入队等待超时 | `send_queue_wait_timeout_ms=15000`,超时抛 `BridgeError(TIMEOUT)` + `retry_after` | `FlowOrchestrator.send_text` | +| 发送间隔 | `send_delay_ms=3000`(不同联系人)/ `1000`(同联系人,会话缓存命中) | `config.py` / `orchestrator._SAME_CONTACT_DELAY_MS` | +| SSE 轮询 | 有订阅者 `1.0s`,无订阅者 `5.0s`,DB mtime 未变化时跳过 SQL | `streamer.POLL_INTERVAL_ACTIVE/IDLE` | +| SSE 批量 | `BATCH_LIMIT=200` 条 / 轮 | `streamer.BATCH_LIMIT` | +| SSE 队列 | `QUEUE_MAXSIZE=100`,满时丢弃最旧事件 | `streamer.QUEUE_MAXSIZE` | +| SSE 心跳 | `HEARTBEAT_INTERVAL=30.0s` | `streamer.HEARTBEAT_INTERVAL` | +| SSE 订阅上限 | `MAX_SUBSCRIBERS=3`,超限剔除最早订阅者并投递 `kicked` 事件 | `streamer.MAX_SUBSCRIBERS` | +| 慢请求阈值 | `2000ms`,超过升级为 WARNING 日志并打 ⚠️ 标记 | `app._SLOW_REQUEST_MS` | +| 大文件超时自适应 | `30 + size_mb * 1.5`,上限 `180s`;小文件(≤10MB)固定 `30s` | `send_file._compute_timeout` | +| DB 校验超时 | `3.0s`(轮询间隔 `0.2s`) | `routes/send._verify_sent_to_talker` | +| 文件发送 DB 校验 | `30s` 总超时,轮询间隔 `2s`,等待 WAL 刷盘 `2s` | `send_file._DB_VERIFY_*` | +| WeChat 重启超时 | `30s` | `routes/login.wechat_restart` | +| 登录扫码等待 | 默认 `30s`,上限 `120s` | `routes/login.login_qr_wait` | +| 撤回时限 | `110s`(微信 2 分钟限制,预留 10s UI 操作余量) | `routes/send._REVOKE_WINDOW_SEC` | +| 头像下载 | `urlopen` 超时 `15s`,大小上限 `10MB` | `media._fetch_avatar_url` | +| 导出同步阈值 | `limit ≤ 1000` | `export._SYNC_EXPORT_LIMIT` | +| 导出异步并发 | 全局同时 `1` 个任务 | `export._MAX_CONCURRENT_EXPORT_TASKS` | +| 导出分页 | 每批 `200` 条 | `export._EXPORT_BATCH_SIZE` | +| 导出 TTL | `7200s`(2 小时) | `config.export_task_ttl_sec` | +| 导出 limit 上限 | `100000` | `models/export.ExportRequest.limit` | +| 群发单批 | `≤ 200`(去重后) | `config.batch_max_per_batch` | +| 群发日频次 | `≤ 3` | `config.batch_daily_limit` | +| 群发批间隔 | `≥ 7200s`(2 小时) | `config.batch_min_interval_sec` | +| 群发单条抖动 | `random.uniform(2.0, 4.0)` 秒 | `batch_worker._BATCH_JITTER_*` | +| 群发单条内容上限 | `2000` 字符 | `models/batch_send.BatchSendRequest.content.max_length` | +| `abort_on_consecutive_fail` | 默认 `5`,范围 `1-20` | `models/batch_send.BatchSendRequest` | +| display_name 缓存 | TTL `300s`,max `1024` 条,启动预热 `1000` 个联系人 | `routes/send._DISPLAY_NAME_*` | + +### 5.2 可用性 + +#### 熔断器(CircuitBreaker) + +状态机:`CLOSED → 连续失败达 failure_threshold → OPEN → 经过 recovery_timeout → HALF_OPEN → 一次成功 CLOSED / 一次失败 OPEN`。 + +bridge 实例化的熔断器: + +| 熔断器名 | `failure_threshold` | `recovery_timeout` | 用途 | 来源 | +| --- | --- | --- | --- | --- | +| `db_verify` | `10` | `30.0s` | DB 校验失败熔断(高并发下更宽容),OPEN 时 send_text / send_file 返回 `BRIDGE_CIRCUITED(503)` | `orchestrator.db_verify_breaker` | +| `accept_verify` | `5` | `60.0s` | 好友自动通过失败熔断(比 send_text 的 30s 更长) | `app._state.accept_breaker` | +| 默认值 | `5` | `30.0s` | 未显式指定参数时使用 | `CircuitBreaker.__init__` | + +#### 服务启动门控(s6 登录检测) + +bridge 进程由 s6 服务管理器(`bridge/s6/woc-bridge/run`)拉起,**仅在检测到微信登录主窗口后才启动**,避免登录前狂刷日志: + +- s6 启动脚本轮询(每 3s)`pgrep -f /config/wechat/opt/wechat/wechat` 获取微信进程 PID,再用 `xdotool search --pid` 枚举该进程所有窗口,取面积最大者。 +- **登录检测阈值:主窗口面积 ≥ `300000` 像素**(登录后主界面面积通常 > 300000;扫码 / 登录窗口面积较小),命中后 `sleep 60` 等待微信完全就绪再启动 bridge。 +- 最长等待 `600s`(`MAX_WAIT=600`),超时则 bridge 不启动,进入 `sleep infinity` 休眠,待用户重启容器或手动 `s6-svc -u` 唤醒。 +- 启动前还会校验 ptrace 权限:尝试 `echo 0 > /proc/sys/kernel/yama/ptrace_scope`;失败则通过 `CapEff` bit 12 检测是否持有 `SYS_PTRACE` capability。 + +> 注意:此 `300000` 阈值用于**服务启动门控(登录检测)**,与 5.5 节 UI 自动化中 `_find_main_window_id` 的 `min_area=10000`(过滤隐藏 / 加载小窗)是**两个不同阈值**,不可混淆。 + +#### 启动清场 + +`lifespan` 启动时调 `xdotool._full_cleanup_on_startup()`,上次崩溃可能残留脏状态(搜索框打开 / 输入框有内容)。失败 3 次仅告警不阻塞启动,提示人工 VNC 接入。 + +#### Watchdog + +- 检查间隔 `10.0s`,连续失败 `fail_threshold=2` 触发 autofix。 +- autofix 策略:`pgrep -x wechat` → `kill -TERM` 所有 PID(不直接启动,避免与 autostart 竞态),让 autostart watchdog 拉起。 +- 失败仅告警不抛异常,避免拖垮 bridge 主流程。 + +#### ResourceReaper + +- 调试截图目录 `/tmp/woc_debug`,检查间隔 `3600s`(1 小时)。 +- 文件最大存活 `24h`(按 mtime 删除超 TTL 的文件)。 +- 总量上限 `100MB`,超限按 LRU(最旧 mtime)删除。 +- 仅当 `WOC_UI_DEBUG_SHOTS=true` 时调试截图才写盘。 + +#### 重试策略 + +- `max_attempts=2`(含首次,即最多重试 1 次)。 +- 指数退避:`base_delay * (2 ** attempt)`,`base_delay=1.0s`,`max_delay=30.0s`。 +- 抖动:`× [0.75, 1.25]` 均匀分布,避免雷同请求同时重试。 +- 只对 `RETRYABLE_CODES` 中的错误码重试(如 `SEND_TIMEOUT` / `WINDOW_NOT_FOUND`),永久错误不重试。 + +#### 会话缓存(SessionCache) + +- LRU 多会话,TTL `30.0s`,max `16` 个会话。 +- 命中时 `SendTextFlow` 跳过 `activate` / `click_search_box` / `type_query` / `open_session` 四步,直接进入 `focus_input`。 +- 失败时仅失效当前联系人缓存,避免误伤其它缓存命中。 + +#### 幂等缓存(IdemCache) + +- 键:`SHA256(f"{flow_name}|{to_wxid}|{content}|{client_request_id}")`(64 字符 hex,不截断)。 +- TTL `300s`(5 分钟),max `1000` 条。 +- LRU 淘汰 + TTL 过期双策略,保证内存占用有上限且旧数据自动失效。 + +### 5.3 安全性 + +#### SQLCipher 参数(微信 4.x Linux) + +| 参数 | 值 | 说明 | +| --- | --- | --- | +| `PAGE_SIZE` | `4096` 字节 | SQLite 页大小 | +| `SALT_SIZE` | `16` 字节 | 第 1 页前 16 字节为 salt | +| `IV_SIZE` | `16` 字节 | AES-CBC IV | +| `HMAC_SIZE` | `64` 字节 | HMAC-SHA512 | +| `RESERVE_SIZE` | `80` 字节 | IV(16) + HMAC(64) | +| `ROUND_COUNT` | `256000` | PBKDF2-HMAC-SHA512 迭代轮数 | +| `MAC_SALT_XOR` | `0x3A` | `mac_salt = salt XOR 0x3A`,PBKDF2-SHA512 2 轮派生 mac_key | +| 加密算法 | `AES-256-CBC` | 页解密 | +| HMAC 算法 | `HMAC-SHA512` | 页完整性校验 | +| 密钥形态 | `enc_key`(已派生)或 `key_material`(原始,需 PBKDF2 派生) | `_resolve_page1_key_material` 双重尝试 | +| WAL 合并 | 加密 WAL 帧结构:`frame_header(24) + page(4096)`,按 salt 匹配合并 | `decrypt_wal` | + +> 解密时不做逐页 HMAC 验证(参考 wechat-cli-main:微信运行时 DB 页面可能因 WAL 并发写入等原因 HMAC 不匹配,但 key 本身正确,AES 解密仍可得到有效数据)。 + +#### Bearer Token 鉴权 + +- 所有 `/api/*` 端点(除 `/api/status`、`/api/diagnostic/connectivity`、`/metrics`)必须通过 Bearer Token 鉴权。 +- Token 通过环境变量 `WOC_BRIDGE_API_TOKEN` 配置,由上游反向代理或 Panel 校验。 +- Token 校验通过后授予全部功能权限,**无细粒度角色划分**(已知约束,见 10.4)。 + +#### 文件路径白名单 + +| 场景 | 白名单目录 | 校验方式 | 来源 | +| --- | --- | --- | --- | +| `send/file`、`send/image` | `/config/Desktop/` / `/config/woc-uploads/` / `/tmp/woc-files/` | 段级不含 `..` + `os.path.realpath` 解析后以白名单前缀开头 | `send_file._FILE_PATH_WHITELIST` + `_validate_file_path` | +| `moments/publish_image` | `/config/` / `/tmp/` / `/data/` | `os.path.realpath` 后以白名单前缀开头 + 文件存在可读 | `routes/moments._SAFE_IMAGE_DIRS` | +| 头像 URL | CDN 域名白名单(见 FR-10) | `urllib.parse.urlparse` + hostname 检查 | `media._AVATAR_ALLOWED_HOSTS` + `_validate_avatar_url` | + +#### 容器能力 + +- 必须以 `--cap-add=SYS_PTRACE` 启动容器,允许 bridge 扫描微信进程内存提取 DB 密钥。 +- 同时要求 `/proc/sys/kernel/yama/ptrace_scope=0`(否则非 root 进程无法 ptrace 其他进程)。 + +#### xdotool 输入约束 + +- 微信 4.x Linux 自绘 UI 不响应 `Ctrl+V`,必须用 `xdotool type` 逐字符输入文本。 +- 方法名保留 `_paste_via_xclip` 以维持调用点稳定,但实际不使用 xclip。 + +### 5.4 兼容性 + +| 维度 | 要求 | +| --- | --- | +| WeChat 客户端 | 仅兼容 WeChat 4.x Linux(`/config/xwechat_files` 数据目录) | +| CPU 架构 | amd64 + arm64 | +| X 会话 | VNC `DISPLAY=:1`(由 `_state.config.display` 指定) | +| UI 自动化依赖 | `xdotool`(必装,缺失抛 `xdotool_available` 诊断失败)+ `opencv-python`(图像匹配)+ `ImageMagick`(`import` 截图)+ `xdpyinfo`(X11 探测) | +| Python | 3.10+(用 `from __future__ import annotations` + `tuple[str, float]` 等 PEP 604 语法) | +| FastAPI | 由 `requirements.txt` 约束(本 PRD 不涉及版本号) | +| 浏览器 | bridge 为 API 服务,不涉及前端兼容性 | +| 数据库 | SQLite 3(通过 `cryptography` 库解密 SQLCipher 4 加密 DB) | +| 反向代理 | nginx 须关闭 SSE 缓冲(响应头 `X-Accel-Buffering: no`) | + +### 5.5 可观测性 + +#### Prometheus 指标(共 12 项,`/metrics` 端点) + +| 指标名 | 类型 | 标签 | 说明 | +| --- | --- | --- | --- | +| `woc_send_total` | Counter | `flow_name`, `result`(success/failed/skipped) | 发送总数 | +| `woc_send_duration_seconds` | Histogram | `flow_name` | 发送耗时分布 | +| `woc_send_failed_by_state_total` | Counter | `flow_name`, `state` | 按失败状态分类的失败计数 | +| `woc_image_match_confidence` | Histogram | `kind` | OpenCV 模板匹配置信度 | +| `woc_db_verify_duration_seconds` | Histogram | - | DB 校验耗时 | +| `woc_send_queue_pending` | Gauge | - | 发送队列待处理数 | +| `woc_wechat_running` | Gauge | - | WeChat 进程运行态(1/0) | +| `woc_circuit_state` | Gauge | `name` | 熔断器状态(0=closed/1=open/2=half_open) | +| `woc_session_cache_hit_total` | Counter | `result`(hit/miss) | 会话缓存命中 | +| `woc_adaptive_wait_timeout_total` | Counter | `kind` | 自适应等待超时计数 | +| `woc_send_duration_first_seconds` | Histogram | - | 首次发送耗时(无缓存) | +| `woc_send_duration_cached_seconds` | Histogram | - | 缓存命中后的发送耗时 | + +#### trace_id + +- 每请求生成 12 字符 hex `trace_id`(`random.getrandbits(48)`),通过 `ContextVar` 贯穿整个请求生命周期。 +- 日志格式:`%(asctime)s [%(levelname)s] [%(trace_id)s] %(name)s: %(message)s`,由 `TraceFilter` 自动注入。 + +#### 请求日志分级 + +- INFO:正常请求(含耗时、关键参数摘要)。 +- WARNING:慢请求(> `2000ms`,带 ⚠️ 标记)、限流命中、熔断 OPEN、业务异常(`BridgeError`)。 +- ERROR:未捕获异常、发送后 DB 校验失败(消息可能未进入目标会话)。 +- DEBUG:限流通过、缓存命中、轮询细节。 + +### 5.6 国际化与无障碍 + +不涉及。bridge 为 API 服务,无终端用户界面;所有 `message` 字段为中文,供调用方参考。 + +--- + +## 6. 交互与设计要求 + +### 6.1 UI 自动化六层架构 + +bridge 通过程序化操作微信 X11 窗口实现 UI 自动化,**非用户可视化界面**。架构分六层: + +| 层 | 名称 | 职责 | 关键参数 | +| --- | --- | --- | --- | +| L1 | Backend | `xdotool` + `opencv` 底层驱动 | 子进程超时 `3.0s`、`pgrep` / `xdpyinfo` 等 | +| L2 | Locator | YAML profile + 图像 / 几何兜底 + 熔断 | 模板置信度阈值、`min_area=10000`(窗口面积过滤,min 200×200) | +| L3 | Actions | 图像优先几何兜底 + 截图缓存 | debug 截图写盘(`WOC_UI_DEBUG_SHOTS=true` 时) | +| L4 | Flow | 9 状态机 + SessionCache(30s/16 LRU)+ 清场 | SendTextFlow / SendFileFlow | +| L5 | Orchestrator | 幂等 + 熔断 + 会话缓存 + 队列编排 | `db_verify_breaker`、`session_cache`、`idem_cache` | +| L6 | Capability | 业务入口(如 `send_text` / `send_file`) | 暴露给 routes 层调用 | + +#### 横切关注点 + +| 组件 | 参数 | 来源 | +| --- | --- | --- | +| `circuit_breaker` | `failure_threshold=5/10`、`recovery_timeout=30~60s` | `CircuitBreaker` + `orchestrator` + `app` | +| `idem_cache` | TTL `300s`、`max_size=1000` | `IdemCache` | +| `retry` | `max_attempts=2`、`base_delay=1.0s`、`max_delay=30.0s`、jitter `[0.75, 1.25]` | `RetryPolicy` | +| `metrics` | 12 项 Prometheus 指标 | `ui/metrics.py` | +| `trace` | `trace_id` 12-hex | `ui/trace.py` | +| `watchdog` | 间隔 `10s`、`fail_threshold=2` | `WeChatWatchdog` | +| `resource_reaper` | 间隔 `3600s`、`max_age=24h`、`max_total=100MB` | `ResourceReaper` | + +### 6.2 关键交互态映射 + +| 态 | 触发条件 | 调用方处理建议 | +| --- | --- | --- | +| loading(队列等待) | `send_queue_pending > 0`,入队后等待执行 | 退避重试,参考 `retry_after` | +| 空态 | 查询返回空列表(如群无成员、会话无消息) | 正常态,不重试 | +| 错误态(业务异常) | `BridgeError` 抛出,HTTP 状态码 400/401/404/409/429/500/503 | 按 `code` 字段判定,`RATE_LIMITED` 按 `retry_after` 退避,`BRIDGE_CIRCUITED` / `WECHAT_NOT_RUNNING` 等需等待恢复 | +| 熔断态 | `BRIDGE_CIRCUITED(503)` | DB 校验熔断器 OPEN,等待 `recovery_timeout` 后 HALF_OPEN 试探 | +| SSE kicked | 订阅者超 `MAX_SUBSCRIBERS=3` 上限被剔除 | 关闭连接,按需重连 | +| SSE status: `db_accessible=false` | DB 加密 / 退出 / 不可读 | 降级到 `/api/messages/since` 轮询,等待 `status: db_accessible=true` 恢复 | + +### 6.3 前端原型 + +不涉及。bridge 为 API 服务,无前端原型图。Panel 侧前端设计由 Panel PRD 约束。 + +--- + +## 7. 数据与接口需求 + +### 7.1 接口清单表 + +| 路径 | 方法 | 入参 | 出参 | 错误码 | +| --- | --- | --- | --- | --- | +| `/api/status` | GET | 无 | `StatusResponse` | `BRIDGE_INTERNAL_ERROR(500)` | +| `/api/login/qr/start` | POST | 无 | `QrLoginStartResult` | `WINDOW_NOT_FOUND(503)`、`BRIDGE_INTERNAL_ERROR(500)` | +| `/api/login/qr/wait` | GET | `timeout=30` | `QrLoginWaitResult` | `INVALID_PARAMS(400)` | +| `/api/login/logout` | POST | 无 | `LogoutResponse` | `LOGOUT_FAILED(500)`、`WINDOW_NOT_FOUND(503)` | +| `/api/wechat/restart` | POST | 无 | `RestartResponse` | `RESTART_TIMEOUT(408)`、`BRIDGE_INTERNAL_ERROR(500)` | +| `/api/db/decrypt` | POST | `DbDecryptRequest{key, salt?}` | `DbDecryptResponse` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/db/key/status` | GET | 无 | `DbKeyStatusResponse` | - | +| `/api/db/init` | POST | `DbInitRequest{pid?, db_dir?, force}` | `DbInitResponse` | `BRIDGE_INTERNAL_ERROR(500)` | +| `/api/db/init/status` | GET | 无 | `DbInitStatusResponse` | - | +| `/api/messages/since` | GET | `cursor`, `cursor_local_id`, `limit=50`, `is_sender?` | `MessagesResponse` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/messages/stream` | GET | 无(SSE) | `StreamingResponse` | 不抛业务错误 | +| `/api/messages/by_session` | GET | `talker`, `cursor`, `cursor_local_id`, `limit=50`, `direction=before`, `is_sender?` | `MessagesBySessionResponse` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/messages/search` | GET | `keyword`, `talker?`, `start_time`, `end_time`, `limit=50`, `is_sender?` | `MessageSearchResponse` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/send/text` | POST | `SendTextRequest{to_wxid, content, display_name?, client_request_id}` | `SendResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`RATE_LIMITED(429)`、`SEND_FAILED(500)`、`BRIDGE_CIRCUITED(503)`、`SEND_TIMEOUT(503)` 等 | +| `/api/send/image` | POST | `SendFileRequest{to_wxid, file_path, display_name?, client_request_id?}` | `SendResponse` | 同上 | +| `/api/send/file` | POST | `SendFileRequest{to_wxid, file_path, display_name?, client_request_id?}` | `SendResponse` | 同上 | +| `/api/messages/revoke` | POST | `RevokeMessageRequest{talker, create_time, display_name?}` | `RevokeMessageResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`REVOKE_WINDOW_EXPIRED(409)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/messages/forward` | POST | `ForwardMessageRequest{talker, target_display_name, source_display_name?}` | `ForwardMessageResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/send/batch` | POST | `BatchSendRequest{targets, content, client_request_id?, abort_on_consecutive_fail=5}` | `{success, batch_id, status, status_query_url}` | `INVALID_PARAMS(400/409)`、`RATE_LIMITED(429)` | +| `/api/send/batch/{batch_id}/status` | GET | `batch_id` | `BatchSendStatus` | `NOT_FOUND(404)` | +| `/api/send/batch/{batch_id}/cancel` | POST | `batch_id` | `{success, batch_id, status="cancelled"}` | `NOT_FOUND(404)` | +| `/api/send/batch` | GET | `limit=20` | `{batches: [BatchSendStatus]}` | `INVALID_PARAMS(400)` | +| `/api/contacts` | GET | `keyword`, `limit=50` | `ContactsResponse` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/contacts/{wxid}` | GET | `wxid` | `Contact` | `CONTACT_NOT_FOUND(404)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/groups` | GET | `limit=50` | `GroupsResponse` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/groups/{wxid}/members` | GET | `wxid` | `GroupMembersResponse` | `DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/contacts/{wxid}/remark` | POST | `SetRemarkRequest{remark, display_name?}` | `SetRemarkResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/friends/add` | POST | `AddFriendRequest{keyword, message=""}` | `AddFriendResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/friends/requests` | GET | `limit=50` | `FriendRequestsResponse` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/friends/accept` | POST | `AcceptFriendRequest{stranger_wxid, nickname?}` | `AcceptFriendResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`RATE_LIMITED(429)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/friends/auto_accept/config` | GET | 无 | `AcceptRuleConfig` | `BRIDGE_INTERNAL_ERROR(500)` | +| `/api/friends/auto_accept/config` | PUT | `AcceptRuleConfig` | `AcceptRuleConfig` | `BRIDGE_INTERNAL_ERROR(500)` | +| `/api/friends/auto_accept/status` | GET | 无 | `AutoAcceptStatus` | `BRIDGE_INTERNAL_ERROR(500)` | +| `/api/moments/timeline` | GET | `cursor=0`, `limit=20` | `MomentsTimelineResponse` | `INVALID_PARAMS(400)`、`DB_ENCRYPTED(503)` | +| `/api/moments/publish` | POST | `MomentPublishRequest{content}` | `MomentPublishResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/moments/publish_image` | POST | `MomentPublishImageRequest{image_path, content=""}` | `MomentPublishImageResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/moments/share_article` | POST | `MomentShareArticleRequest{public_account, article_index=1, comment?}` | `MomentShareArticleResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/moments/like` | POST | `MomentLikeRequest{moment_index=1}` | `MomentLikeResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/moments/comment` | POST | `MomentCommentRequest{comment, moment_index=1}` | `MomentCommentResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/moments/delete` | POST | `MomentDeleteRequest{moment_index=1}` | `MomentDeleteResponse` | `INVALID_PARAMS(400)`、`WECHAT_NOT_LOGGED_IN(401)`、`WINDOW_NOT_FOUND(503)`、`SEND_FAILED(500)` | +| `/api/media/avatar/{wxid}` | GET | `wxid` | `Response`(二进制) | `MEDIA_NOT_FOUND(404)`、`DB_ENCRYPTED(503)`、`DB_NOT_FOUND(500)` | +| `/api/media/{msg_id}` | GET | `msg_id` | `Response`(二进制) | `MEDIA_NOT_FOUND(404)`、`DB_ENCRYPTED(503)`、`DB_NOT_FOUND(500)` | +| `/api/diagnostic/connectivity` | GET | 无 | `ConnectivityResponse` | - | +| `/api/diagnostic/items` | GET | 无 | `{items: [DiagnosticItem]}` | - | +| `/api/diagnostic/run/{check_id}` | POST | `check_id` | `DiagnosticRunResult` | `INVALID_PARAMS(400)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/diagnostic/autofix/{check_id}` | POST | `check_id` | `DiagnosticRunResult` | `INVALID_PARAMS(400)` | +| `/api/screenshot` | POST | 无 | `Response`(image/png) | `WINDOW_NOT_FOUND(503)`、`BRIDGE_INTERNAL_ERROR(500)` | +| `/api/messages/export` | GET | `talker`, `format=html`, `limit=1000`, `include_media`, `media_inline`, `start_time?`, `end_time?` | `StreamingResponse` | `INVALID_PARAMS(400)`、`RATE_LIMITED(429)`、`DB_NOT_FOUND(500)`、`DB_ENCRYPTED(503)` | +| `/api/messages/export` | POST | `ExportRequest` | `ExportTaskStatus` | `INVALID_PARAMS(400)`、`RATE_LIMITED(429)` | +| `/api/messages/export/{task_id}/status` | GET | `task_id` | `ExportTaskStatus` | `INVALID_PARAMS(400)`(任务不存在) | +| `/api/messages/export/{task_id}/download` | GET | `task_id` | `FileResponse` | `INVALID_PARAMS(400)`、`STATE_DIRTY(503)`、`BRIDGE_INTERNAL_ERROR(500)` | +| `/api/messages/export/{task_id}` | DELETE | `task_id` | `{success, task_id, status="cancelled"}` | `INVALID_PARAMS(400)` | +| `/metrics` | GET | 无 | Prometheus exposition | - | + +**接口总数**:48 个端点(去重路径后)。 + +### 7.2 错误码表 + +> 数据源:`d:\WechatOnCloud-main\bridge\woc_bridge\models\base.py` 的 `ERROR_CODES` 字典。源码共定义 **27** 个错误码(任务描述称 25 个,实际多出 `DB_LOCKED` / `LOGOUT_FAILED` / `DB_INIT_IN_PROGRESS` 三个)。 + +| 错误码 | HTTP 状态 | 含义 | +| --- | --- | --- | +| `INVALID_PARAMS` | 400 | 参数校验失败(空值 / 越界 / 格式错误 / 路径不在白名单) | +| `AMBIGUOUS_CONTACT` | 400 | 联系人歧义(搜索结果不唯一) | +| `WECHAT_NOT_LOGGED_IN` | 401 | 微信未登录,无法执行 UI 操作 | +| `CONTACT_NOT_FOUND` | 404 | 指定 wxid 不存在 | +| `MEDIA_NOT_FOUND` | 404 | 媒体文件 / 头像未找到 | +| `REVOKE_WINDOW_EXPIRED` | 409 | 消息超过 110 秒撤回时限 | +| `RATE_LIMITED` | 429 | 限流命中(发送 / 群发 / 导出) | +| `LOGIN_TIMEOUT` | 408 | 登录扫码超时 | +| `RESTART_TIMEOUT` | 408 | 微信重启 30 秒内未拉起新进程 | +| `SEND_FAILED` | 500 | UI 操作失败或发送后 DB 校验失败 | +| `BRIDGE_INTERNAL_ERROR` | 500 | bridge 内部错误(组件未初始化等) | +| `DB_VERIFY_FAILED` | 500 | DB 密钥验证失败 | +| `DB_NOT_FOUND` | 500 | 未找到微信 DB 文件或文件不可读 | +| `LOGOUT_FAILED` | 500 | 退出登录 UI 操作失败 | +| `DB_LOCKED` | 503 | DB 被锁定(其他进程持有写锁) | +| `DB_ENCRYPTED` | 503 | DB 已加密,需 SQLCipher 密钥 | +| `DB_NEED_INIT` | 503 | DB 需初始化(密钥提取未完成) | +| `DB_INIT_IN_PROGRESS` | 503 | DB 初始化进行中 | +| `DB_KEY_INVALID` | 503 | DB 密钥无效 | +| `WECHAT_NOT_RUNNING` | 503 | 微信进程未运行 | +| `WINDOW_NOT_FOUND` | 503 | 微信窗口未找到 | +| `SEND_TIMEOUT` | 503 | 发送超时 | +| `STATE_DIRTY` | 503 | 状态脏(如导出任务未完成就尝试下载) | +| `ELEMENT_NOT_FOUND` | 503 | UI 元素未找到 | +| `BRIDGE_CIRCUITED` | 503 | 熔断器 OPEN,拒绝请求 | +| `X11_UNAVAILABLE` | 503 | X11 不可用 | +| `X11_TEMP_UNAVAILABLE` | 503 | X11 临时不可用 | + +> 注:`routes/batch.py` 中的 `NOT_FOUND` 通过 `BridgeError(code="NOT_FOUND", http_status=404)` 显式指定 HTTP 状态码,但未在 `ERROR_CODES` 表中登记(查表时回落到 500,因此必须显式传 `http_status=404`)。 + +### 7.3 配置项表 + +> 数据源:`d:\WechatOnCloud-main\bridge\woc_bridge\config.py` 的 `BridgeConfig.from_args_and_env`。共 **24** 项环境变量(含 3 个命令行参数)。 + +#### 命令行参数(3 项) + +| 参数 | 默认值 | 说明 | +| --- | --- | --- | +| `--listen` | `0.0.0.0:8088` | 监听地址 | +| `--wechat-db` | `/config` | 微信数据目录 | +| `--display` | `:1` | X DISPLAY | + +#### 环境变量(24 项) + +| 环境变量 | 默认值 | 说明 | +| --- | --- | --- | +| `WOC_BRIDGE_SEND_DELAY_MS` | `3000` | 两次发送之间的最小间隔毫秒 | +| `WOC_BRIDGE_MAX_CALLS_PER_SEC` | `10` | 每秒最大调用次数 | +| `WOC_BRIDGE_MAX_QUEUE_SIZE` | `100` | 发送队列最大深度 | +| `WOC_BRIDGE_SEND_QUEUE_WAIT_TIMEOUT_MS` | `15000` | 入队等待超时毫秒 | +| `WOC_BRIDGE_MAX_BATCH_SIZE` | `50` | 客户端单次拉取建议上限(仅状态返回) | +| `WOC_BRIDGE_POLL_INTERVAL_MS` | `2000` | 客户端轮询建议间隔(仅状态返回) | +| `WOC_DB_KEY` | `""` | 64 位 hex SQLCipher 密钥(空表示不注入) | +| `WOC_DB_KEY_AUTO_EXTRACT` | `true` | 是否启用自动内存扫描提取密钥 | +| `WOC_UI_BACKEND` | `flow` | UI 自动化后端:`flow`(新六层架构)/ `legacy`(旧 xdotool_driver) | +| `WOC_UI_DEBUG_SHOTS` | `false` | 调试截图写盘开关,生产环境必须 `false` | +| `WOC_AUTO_ACCEPT_ENABLED` | `false` | 好友自动通过全局开关 | +| `WOC_AUTO_ACCEPT_ALL` | `false` | 通过所有申请(忽略以下规则) | +| `WOC_AUTO_ACCEPT_WHITELIST_WXIDS` | `""` | 白名单 wxid 列表(逗号分隔) | +| `WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES` | `""` | 白名单昵称列表(精确匹配,逗号分隔) | +| `WOC_AUTO_ACCEPT_KEYWORDS` | `""` | 验证消息关键词列表(子串匹配,逗号分隔) | +| `WOC_AUTO_ACCEPT_BLACKLIST_WXIDS` | `""` | 黑名单 wxid 列表 | +| `WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES` | `""` | 黑名单昵称列表 | +| `WOC_AUTO_ACCEPT_ALLOW_SCENES` | `""` | 允许的场景列表(空=不限制) | +| `WOC_AUTO_ACCEPT_POLL_INTERVAL` | `3` | 自动通过轮询间隔秒数 | +| `WOC_EXPORT_DIR` | `/config/woc-export` | 导出文件根目录 | +| `WOC_EXPORT_TASK_TTL_SEC` | `7200` | 导出任务 TTL 秒数(2 小时) | +| `WOC_BATCH_MAX_PER_BATCH` | `200` | 单批目标数上限(去重后) | +| `WOC_BATCH_DAILY_LIMIT` | `3` | 每日群发批次数上限 | +| `WOC_BATCH_MIN_INTERVAL_SEC` | `7200` | 相邻两批群发最小间隔秒数(2 小时) | + +> 注:列表类配置(如 `WOC_AUTO_ACCEPT_WHITELIST_WXIDS`)由 `_parse_list` 按逗号分隔解析。布尔值识别 `true/1/yes/on`(其余视为 `false`)。 + +### 7.4 关键数据模型 + +#### `Message` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `msg_id` | `str` | 消息 ID | +| `talker` | `str` | 会话对方 wxid(群消息为 chatroom id) | +| `sender` | `str` | 实际发送者 wxid(群消息中为成员 wxid) | +| `is_sender` | `bool` | 是否为本机发送 | +| `type` | `int` | 微信原始消息类型 | +| `render_type` | `str` | 渲染类型:`text` / `image` / `voice` / `video` / `file` / `system` | +| `content` | `str` | 消息内容文本 | +| `create_time` | `int` | 消息时间戳(Unix 秒) | +| `session_type` | `str` | 会话类型:`p2p` / `group` | + +#### `Contact` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `wxid` | `str` | 联系人 wxid | +| `nickname` | `str` | 昵称 | +| `remark` | `str` | 备注 | +| `avatar_url` | `str` | 头像 URL | +| `type` | `str` | `person` / `group` / `official` / `system` | +| `alias` | `Optional[str]` | 微信号 / 别名 | +| `encrypt_username` | `Optional[str]` | 加密用户名 | +| `quan_pin` | `Optional[str]` | 全拼 | +| `pin_yin_initial` | `Optional[str]` | 拼音首字母 | +| `big_head_url` | `Optional[str]` | 高清头像 URL | +| `small_head_url` | `Optional[str]` | 缩略头像 URL | +| `description` | `Optional[str]` | 个性签名 / 描述 | +| `local_type` | `Optional[int]` | 联系人类型标记 | +| `verify_flag` | `Optional[int]` | 认证标记(`0x08` = 已认证公众号) | +| `delete_flag` | `Optional[int]` | 删除标记 | +| `chat_room_type` | `Optional[int]` | 群类型标记 | + +#### `SendTextRequest` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `to_wxid` | `str` | 目标 wxid | +| `content` | `str` | 文本内容 | +| `display_name` | `Optional[str]` | 用于微信搜索框定位会话的显示名,不传时自动按 备注 → 昵称 → wxid 查找 | +| `client_request_id` | `str` | 客户端幂等去重 ID,空字符串表示不参与幂等校验 | + +#### `BatchSendRequest` + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `targets` | `list[str]` | `min_length=1`, `max_length=200` | 目标 wxid 列表(去重后 ≤ 200) | +| `content` | `str` | `min_length=1`, `max_length=2000` | 消息内容(支持 `{nickname}` 占位符) | +| `client_request_id` | `Optional[str]` | - | 批级幂等 ID,None 时自动生成 `batch_` | +| `abort_on_consecutive_fail` | `int` | `ge=1`, `le=20`, 默认 `5` | 连续失败 N 次中止整批 | + +#### `AcceptRuleConfig` + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | 全局开关 | +| `accept_all` | `bool` | `false` | 通过所有申请(忽略以下规则) | +| `whitelist_wxids` | `list[str]` | `[]` | 白名单 wxid 列表 | +| `whitelist_nicknames` | `list[str]` | `[]` | 白名单昵称列表(精确匹配) | +| `keywords` | `list[str]` | `[]` | 验证消息关键词列表(子串匹配) | +| `blacklist_wxids` | `list[str]` | `[]` | 黑名单 wxid 列表 | +| `blacklist_nicknames` | `list[str]` | `[]` | 黑名单昵称列表 | +| `allow_scenes` | `list[str]` | `[]` | 允许的场景(空=不限制) | + +#### `ExportRequest` + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `talker` | `str` | required | 会话 wxid 或群 id | +| `format` | `Literal["html","csv","json","txt"]` | 默认 `html` | 导出格式 | +| `limit` | `int` | `ge=1`, `le=100000`, 默认 `1000` | 导出消息数上限 | +| `include_media` | `bool` | 默认 `false` | 是否打包媒体文件(zip) | +| `media_inline` | `bool` | 默认 `false` | 媒体是否内联(仅 HTML,base64 嵌入) | +| `start_time` | `Optional[int]` | - | 起始时间戳(Unix 秒,含) | +| `end_time` | `Optional[int]` | - | 结束时间戳(Unix 秒,含) | + +--- + +## 8. 验收标准 + +### AC-01:扫码登录 + +**Given** 微信进程已运行但未登录(`login_state=not_logged_in`) +**When** 调用 `POST /api/login/qr/start` +**Then** 返回 200 + `qr_data_url` 以 `data:image/png;base64,` 开头,`connected=false`,`message="请使用微信扫描二维码"`。 + +### AC-02:登录等待成功 + +**Given** 用户已扫描二维码并确认登录 +**When** 调用 `GET /api/login/qr/wait?timeout=30` +**Then** 在 30 秒内返回 `connected=true`,`credentials.wxid` 与 `credentials.nickname` 非空,`qr_data_url` 为空字符串。 + +### AC-03:消息发送幂等命中 + +**Given** 同一 `client_request_id` 的 `POST /api/send/text` 请求已成功执行(`verified=true`) +**When** 在 300 秒内再次调用 `POST /api/send/text` 使用相同 `to_wxid` / `content` / `client_request_id` +**Then** 返回 `success=true`、`skipped=true`、`verified=true`,且不触发实际 UI 操作(SendQueue 不入队)。 + +### AC-04:撤回时限触发 + +**Given** 一条消息的 `create_time` 距当前时间超过 110 秒 +**When** 调用 `POST /api/messages/revoke` 传入该 `talker` 与 `create_time` +**Then** 返回 HTTP 409,错误码 `REVOKE_WINDOW_EXPIRED`,`message` 含"已为 UI 操作预留 10s 余量,阈值收紧到 110s"。 + +### AC-05:群发风控触发(单批上限) + +**Given** `WOC_BATCH_MAX_PER_BATCH=200` +**When** 提交 `POST /api/send/batch`,`targets` 去重后为 201 个 wxid +**Then** 返回 HTTP 400,错误码 `INVALID_PARAMS`,`message` 含"超过单批上限 200"。 + +### AC-06:群发风控触发(日频次) + +**Given** 当日已成功提交 3 次群发(`WOC_BATCH_DAILY_LIMIT=3`) +**When** 再次提交 `POST /api/send/batch` +**Then** 返回 HTTP 429,错误码 `RATE_LIMITED`,`details.retry_after` > 0 且指向次日 0 点。 + +### AC-07:群发风控触发(批间隔) + +**Given** 上一次群发结束时间距今 < 7200 秒(`WOC_BATCH_MIN_INTERVAL_SEC=7200`) +**When** 提交 `POST /api/send/batch` +**Then** 返回 HTTP 429,错误码 `RATE_LIMITED`,`message` 含"群发间隔不足",`details.retry_after` > 0。 + +### AC-08:好友自动通过规则决策(黑名单优先) + +**Given** `AcceptRuleConfig.enabled=true`,`blacklist_wxids=["wxid_black"]`,`whitelist_wxids=["wxid_black"]`(同一 wxid 同时在黑/白名单) +**When** 收到 `stranger_wxid="wxid_black"` 的好友申请 +**Then** 规则引擎返回 `REJECT`(黑名单优先于白名单)。 + +### AC-09:好友自动通过规则决策(场景过滤) + +**Given** `AcceptRuleConfig.enabled=true`,`accept_all=false`,`allow_scenes=["1","14"]`,未配置白名单与关键词 +**When** 收到 `scene="3"` 的好友申请 +**Then** 规则引擎返回 `SKIP`(不在允许场景列表)。 + +### AC-10:DB 解密成功 + +**Given** 微信 DB 已加密,存在匹配的 64 位 hex 密钥 +**When** 调用 `POST /api/db/decrypt` 传入该 `key` +**Then** 返回 `success=true`、`verified=true`、`key_mode` ∈ {`enc_key`, `key_material`}、`error=null`,且后续 `GET /api/db/key/status` 返回 `cached=true`、`source="api"`、`verified=true`。 + +### AC-11:DB 解密失败(key 不匹配) + +**Given** 微信 DB 已加密 +**When** 调用 `POST /api/db/decrypt` 传入不匹配的 `key` +**Then** 返回 `success=true`、`verified=false`、`key_mode=null`、`error="key_mismatch"`,且 `GET /api/db/key/status` 返回 `cached=false`。 + +### AC-12:SSE 推送新消息 + +**Given** 客户端已建立 `GET /api/messages/stream` 连接并收到 `sync` 事件 +**When** 微信收到新消息,DB mtime 变化 +**Then** 客户端在 `POLL_INTERVAL_ACTIVE=1.0s` 内收到 `messages` 事件,`data.messages` 非空,`data.next_cursor` 大于上次游标。 + +### AC-13:SSE 订阅者超限剔除 + +**Given** 当前已有 3 个 SSE 订阅者(`MAX_SUBSCRIBERS=3`) +**When** 第 4 个客户端订阅 `GET /api/messages/stream` +**Then** 最早订阅者收到 `kicked` 事件(`data.reason="max_subscribers_reached"`)并断开连接,新订阅者正常收到 `sync` 事件。 + +### AC-14:导出 TTL 过期清理 + +**Given** 一个导出任务 `status="completed"`,`expires_at` 已小于当前时间 +**When** 调用 `GET /api/messages/export/{task_id}/status` +**Then** 触发懒清理,任务从内存移除、任务目录被删除,再次调用返回 `INVALID_PARAMS(400)` + `message="任务不存在或已过期"`。 + +### AC-15:熔断器触发 + +**Given** `db_verify_breaker` 当前为 `CLOSED`,连续 10 次 `send_text` 校验失败(`failure_threshold=10`) +**When** 第 11 次调用 `POST /api/send/text` +**Then** 返回 HTTP 503,错误码 `BRIDGE_CIRCUITED`,`error="db_verify circuit breaker open"`,且不实际执行发送。 + +### AC-16:限流触发 + +**Given** `WOC_BRIDGE_MAX_CALLS_PER_SEC=10`,1 秒内已发起 10 次 `POST /api/send/text` 且均进入 SendQueue 执行 +**When** 第 11 次请求在同一秒窗口内入队执行 +**Then** 抛 `BridgeError(RATE_LIMITED)`,HTTP 429,`details.retry_after` ≥ 1 秒。 + +### AC-17:发送队列满 + +**Given** `WOC_BRIDGE_MAX_QUEUE_SIZE=100`,队列已积压 100 个任务 +**When** 第 101 个请求入队 +**Then** 返回 HTTP 429,错误码 `RATE_LIMITED`,`message` 含"发送队列已满(100/100)",`details.retry_after=3`。 + +### AC-18:复合游标防重复 + +**Given** 同一秒内收到 3 条消息(`create_time` 相同,`local_id` 分别为 1/2/3),客户端已拉取前 2 条并保存 `next_cursor=1000, next_cursor_local_id=2` +**When** 客户端用该游标调用 `GET /api/messages/since?cursor=1000&cursor_local_id=2` +**Then** 仅返回 `local_id > 2` 的第 3 条消息,不重复返回前 2 条。 + +### AC-19:导出并发限制 + +**Given** 已有 1 个异步导出任务 `status="running"` +**When** 再次 `POST /api/messages/export` 或 `GET /api/messages/export`(同步) +**Then** 返回 HTTP 429,错误码 `RATE_LIMITED`,`details.retry_after=30`,`message` 含"已有导出任务运行中"。 + +### AC-20:群发连续失败中止 + +**Given** `BatchSendRequest.abort_on_consecutive_fail=3`,群发任务执行中连续 3 个 target 发送失败 +**When** 第 3 次失败发生 +**Then** 整批中止,状态置 `failed`,`abort_reason` 含"连续失败 3 次,达到阈值 3,整批中止",剩余 `pending` 项标记 `skipped`。 + +--- + +## 9. 排期与里程碑 + +> 本 PRD 为回溯性文档,bridge 模块已实现并上线。本节列出关键节点占位,供后续迭代参考。 + +| 里程碑 | 状态 | 说明 | +| --- | --- | --- | +| 需求基线建立 | 已完成 | 本 PRD(v1.0)建立完整需求基线 | +| 源码对齐核对 | 已完成 | 所有 API 路径 / 错误码 / 配置项 / 阈值均与源码核对一致 | +| 评审与确认 | 待启动 | 需产品 / 研发 / 测试 / 运维联合评审 | +| 后续迭代排期 | 待定 | 新需求按本基线增量管理,需更新变更记录 | + +--- + +## 10. 风险与依赖 + +### 10.1 技术依赖 + +| 依赖 | 说明 | 影响 | +| --- | --- | --- | +| `SYS_PTRACE` capability | 容器必须以 `--cap-add=SYS_PTRACE` 启动 | 缺失则自动内存扫描提取密钥失败,必须降级到手动注入 key | +| `ptrace_scope=0` | `/proc/sys/kernel/yama/ptrace_scope` 必须为 0 | 非 root 进程无法 ptrace 其他进程,密钥提取失败 | +| WeChat 4.x Linux | 仅兼容该版本 | 微信版本升级可能破坏 UI 自动化坐标与 DB schema | +| `xdotool` | 必装,UI 自动化底层依赖 | 缺失则所有 `send/*` / `moments/*` / `friends/*` 等接口不可用 | +| `opencv-python` | 图像匹配定位依赖 | 缺失则 L2 Locator 图像兜底失效,仅几何兜底可用 | +| `ImageMagick`(`import`) | 截图依赖 | 缺失则 `/api/screenshot` 与 `/api/login/qr/start` 不可用 | +| `xdpyinfo` | X11 可用性探测依赖 | 缺失则 watchdog X11 检测失败 | +| `Xvnc` / `openbox` | X 会话与窗口管理器 | 缺失则微信窗口无法显示,所有 UI 操作失败 | +| `cryptography` 库 | SQLCipher 解密依赖 | 缺失则 DB 解密失败,所有 DB 查询接口不可用 | +| `prometheus_client` | `/metrics` 端点依赖 | 缺失则 `/metrics` 禁用,仅记 warning | + +### 10.2 外部依赖 + +| 依赖 | 说明 | +| --- | --- | +| 腾讯微信客户端 | WeChat 4.x Linux 版本。客户端版本升级可能破坏 UI 自动化坐标 / 模板、DB schema、SQLCipher 参数、消息存储格式等。bridge 与微信版本强耦合,无版本协商机制。 | +| 头像 CDN(`wx.qlogo.cn` 等) | 联系人头像下载依赖外网可达,CDN 故障时 `/api/media/avatar/{wxid}` 返回 `MEDIA_NOT_FOUND(404)`。 | + +### 10.3 风险与应对 + +| 风险 | 影响 | 应对策略 | +| --- | --- | --- | +| UI 自动化脆弱(坐标 / 模板依赖) | 微信版本升级或分辨率变化导致 UI 操作失败 | 1. 模板截图分目录管理(`profiles/wechat_4.0_/light/`)
2. 几何兜底 + 图像兜底双策略
3. experimental 接口明确标注需实测调优 | +| 群发封号 | 高频群发触发微信风控,账号受限或封禁 | 1. 风控参数:单批 ≤ 200 / 日频次 ≤ 3 / 批间隔 ≥ 7200s
2. 单条间隔抖动 2-4s
3. `{nickname}` 占位符个性化内容
4. `abort_on_consecutive_fail` 阈值中止 | +| DB 密钥提取失败 | 自动提取失败,所有 DB 查询接口不可用 | 1. 三层降级:env `WOC_DB_KEY` → API `POST /api/db/decrypt` 手动注入 → 自动内存扫描
2. `db_accessible` 诊断项 + autofix 引导
3. `repair_plan` 字段提示具体操作 | +| 并发串号 | 高并发场景下消息发到错误会话 | 1. 复合游标 `(create_time, local_id)` 防基线误匹配
2. 发送后 DB 校验内容匹配(防 talker 字段误判)
3. SessionCache 失效时仅清当前联系人,不误伤其他缓存 | +| WAL 刷盘延迟 | 发送后立即查 DB 看不到新消息,校验失败 | 1. 等 WAL 刷盘 1.5-2s 后再轮询
2. DB 校验超时 3s(间隔 0.2s),允许 15 次轮询
3. 校验失败不阻塞返回,仅记 warning + `verified=false` | +| 多实例隔离不足 | 多账号场景下实例间状态串扰 | 1. 一个 bridge 进程对应一个微信账号
2. 多账号需多容器实例隔离
3. 数据卷与配置独立 | +| Token 无细粒度权限 | Token 泄露后授予全部功能 | 1. Token 通过环境变量配置,避免硬编码
2. 由上游反代实现细粒度路由鉴权
3. 监控异常调用模式(`/metrics` 暴露发送计数) | +| SSE 订阅者累积 | 僵尸连接累积导致内存泄漏 | 1. `MAX_SUBSCRIBERS=3` 上限,超限剔除最早订阅者
2. 队列满时丢弃最旧事件
3. 客户端断开自动取消订阅 | +| 慢请求拖垮 event loop | UI 操作耗时过长阻塞其他请求 | 1. DB 同步操作用 `asyncio.to_thread` 包装
2. 慢请求阈值 `2000ms` 升级 WARNING 日志
3. 大文件超时自适应(30 + size_mb × 1.5,上限 180s) | +| 调试截图磁盘占满 | `WOC_UI_DEBUG_SHOTS=true` 时截图累积 | 1. 生产环境必须 `false`
2. ResourceReaper 每小时清理(max_age 24h,max_total 100MB)
3. LRU 删除最旧文件 | + +### 10.4 业务视角风险与治理建议 + +> 本节为 v1.2 业务视角优化新增,站在实际业务运营角度识别技术风险表之外的深层业务风险,并给出治理方向。 + +#### 业务风险 1:合规与账号封禁(业务连续性风险) + +- **风险描述**:bridge 通过 UI 自动化操作微信客户端,本质上属于「模拟人工操作」,可能违反《腾讯微信软件许可及服务协议》中关于自动化工具的条款。一旦被腾讯风控识别,轻则限制功能、重则永久封号,直接危及依赖该账号的全部业务(如客户触达、社群运营)。 +- **业务影响**:账号封禁 = 业务中断,且历史聊天记录/联系人资产可能无法迁移,损失不可逆。 +- **治理建议**: + 1. 业务侧建立「账号分级」策略,核心账号不接入自动化,仅用低价值账号承载自动化群发; + 2. 群发频次应**低于**风控参数下限(当前 200/3/7200s 是技术上限,业务建议按 50/1/14400s 运营); + 3. 文案去重与个性化(`{nickname}` 占位符)避免批量同质化内容触发风控; + 4. 建立「账号轮换池」,单账号日触达量设上限,避免单账号过载; + 5. 法律侧评估数据导出(聊天记录解密)的合规性,明确数据归属与使用边界。 + +#### 业务风险 2:消息送达可靠性缺口(SLA 风险) + +- **风险描述**:UI 自动化本质是「模拟点击 + DB 校验」,存在两类缺口:①UI 操作成功但 DB 校验超时(`verified=false`),业务方无法确认是否真送达;②DB 校验通过但实际微信窗口已失焦/串号。当前 `SendResponse.verified` 三态(placeholder/skipped/verified)中,`verified=false` 时业务方若直接重试,可能造成**重复发送**。 +- **业务影响**:重复发送 = 客户体验灾难(营销骚扰),漏发 = 业务漏单,二者都损害业务信誉。 +- **治理建议**: + 1. 业务方**必须**基于 `client_request_id` 幂等键做去重,对 `verified=false` 的响应采用「延迟二次确认」而非立即重试(建议 30s 后查 `/api/messages/since` 确认); + 2. 关键业务消息(如订单通知)不应走 UI 自动化通道,应优先评估是否有官方 API 通道; + 3. 建立「业务送达率」监控指标(成功率 = verified / total),与技术指标 `send_total` 区分; + 4. 群发场景 `abort_on_consecutive_fail` 应根据业务容忍度调优,连续失败可能预示 UI 失效,继续发送只会放大损失。 + +#### 业务风险 3:可扩展性瓶颈(容量风险) + +- **风险描述**:bridge 架构为「单进程单账号」,UI 自动化经 `_ui_lock` + `SendQueue` 强串行化,单实例理论吞吐上限 = `max_calls_per_sec=10` × 可用时间,但实际受 UI 操作耗时(30s Flow 超时)制约,单账号并发能力极低。多账号需多容器,资源与运维成本线性增长。 +- **业务影响**:业务规模扩张时,账号数量与服务器成本同步上升,无法通过「加机器」线性扩容单个账号的吞吐。 +- **治理建议**: + 1. 业务侧明确「单账号合理负载」基线(建议 ≤ 100 条/小时),超载需求拆分到多账号; + 2. 群发非实时场景应充分利用「批间隔 ≥ 7200s」错峰,避免高峰集中; + 3. 评估「只读场景」(消息读取/导出/联系人查询)与「写场景」(发送/朋友圈)分离部署的可行性——只读走 DB 解密无 UI 依赖,可水平扩展; + 4. 长期看,UI 自动化是脆弱替代方案,应推动官方 API 接入或自建 IM 中台降低对微信客户端的耦合。 + +#### 业务风险 4:数据一致性双源风险(数据可信度风险) + +- **风险描述**:bridge 同时存在「DB 解密读取」与「UI 自动化操作」两条数据链路。发送校验依赖 DB 反查,但 DB 写入由微信客户端异步完成(WAL 刷盘延迟),可能出现「UI 已发送但 DB 未落盘」或「DB 已落盘但 UI 实际未发」的瞬态不一致。消息读取(SSE)走 DB,发送校验也走 DB,二者基线对齐依赖复合游标,一旦游标错位会放大不一致。 +- **业务影响**:业务方基于 DB 数据做决策(如「已发送则扣库存」),若 DB 与实际不符会导致误判。 +- **治理建议**: + 1. 业务侧不应将 `verified=true` 作为唯一可信源,关键业务动作(扣款/扣库存)应二次确认; + 2. 数据导出(`/api/messages/export`)结果应标注「基于本地 DB 解密,可能与腾讯云端存在差异」; + 3. WAL 刷盘窗口(1.5-2s)内的消息应标记为「待确认」,避免业务方读到半成品状态。 + +#### 业务风险 5:运维脆弱性与人工介入成本(TCO 风险) + +- **风险描述**:UI 自动化对分辨率、模板图、坐标比例高度敏感,微信客户端小版本升级即可能破坏模板匹配,需人工重新截图校准。启动门控(300000 阈值)、ptrace 权限、X11 可用性等任一环节故障都需要人工 VNC 接入排查。当前 `repair_plan` 仅给文字提示,无自动恢复闭环。 +- **业务影响**:系统「能跑」但「不敢动」,每次微信升级或分辨率变化都是一次运维事件,长期 TCO(总拥有成本)高于纯 API 方案。 +- **治理建议**: + 1. 运维侧建立「微信版本锁定」策略,升级前在测试环境验证 UI 自动化兼容性; + 2. 模板图与坐标比例纳入版本管理,变更需回归测试(已有 `tests/` 目录,应扩展 UI 流程测试); + 3. 完善 `diagnostic/autofix` 自动恢复闭环,减少人工 VNC 介入频次; + 4. 建立巡检机制:定期跑 `diagnostic/connectivity` + 试发一条测试消息,提前发现 UI 失效。 + +#### 业务场景完整性补充 + +经业务视角审视,以下边界场景应在评审时重点关注(已在功能需求或验收标准中体现,此处汇总提示): + +- **登录态过期/被踢**:`LoginGuard` 检测到未登录时后台任务暂停,但前端 API 调用会收到 `WECHAT_NOT_LOGGED_IN(401)`,业务方需有重新扫码的运维 SOP; +- **群发中途取消**:`cancel` 后当前条目标 `unknown`(可能已发送),剩余标 `skipped`,业务方需处理「部分成功」的幂等续传; +- **导出任务 TTL 过期**:7200s 后下载链接失效,业务方需在 TTL 内下载或重新发起; +- **熔断期间请求**:`BRIDGE_CIRCUITED(503)` 时业务方应熔断自身并告警,而非重试雪崩; +- **限流期间请求**:`RATE_LIMITED(429)` 带 `Retry-After` 头,业务方应遵循退避而非丢弃。 + +#### 优先级合理性审视 + +- P0(阻塞主流程):状态查询、登录、DB 解密、消息读取、消息发送——合理,这些是 bridge 存在的基石; +- P1(影响主流程):群发、好友管理、联系人、导出、诊断——合理,属于高价值业务能力; +- P2(体验优化):朋友圈、媒体下载、截图——合理,朋友圈为高敏感操作(封号风险),降级 P2 有助于引导业务方审慎使用; +- **建议**:群发虽为 P1,但因封号风险高,建议在文档与运营规范中明确「高频群发为高风险操作」,引导业务方优先用低频个性化触达。 + +--- + +## 11. 变更记录 + +| 版本 | 日期 | 修改人 | 摘要 | +| --- | --- | --- | --- | +| v1.0 | 2026-07-17 | TRAE Agent | 初稿,基于 `d:\WechatOnCloud-main\bridge\woc_bridge\` 源码回溯建立需求基线。涵盖 13 类功能需求、48 个 API 端点、27 个错误码、23 项环境变量配置、20 条验收标准。所有 API 路径 / 错误码 / 配置默认值 / 阈值均以源码为准。 | +| v1.1 | 2026-07-17 | TRAE Agent | 真实性复盘校验,修正接口/阈值/约束等不一致项。修正 7.3 节环境变量计数 23→24(实际 config.py 解析 24 项);新增「附录:真实性复盘结论」含七项核对范围、修正明细与存疑项独立核实结论。 | +| v1.2 | 2026-07-17 | TRAE Agent | 业务视角优化 + 关键约束更正。①更正主窗口面积阈值为双阈值:`300000`(s6 登录检测/服务启动门控,`bridge/s6/woc-bridge/run`)+ `10000`(UI 窗口过滤);5.2 节新增「服务启动门控(s6 登录检测)」小节。②业务视角优化:补充群发封号/UI 自动化脆弱/DB 密钥提取/并发串号等业务风险与应对策略至第 10 章风险与依赖。③扩展真相源至全 `bridge/` 目录(含 s6 脚本)。 | + +--- + +## 附:存疑项与源码对齐说明 + +> 编写过程中发现的与任务描述不符或需进一步确认的事项,均在此处说明,便于评审时核对。 + +1. **错误码数量**:任务描述称 25 个,源码 `models/base.py` 的 `ERROR_CODES` 字典实际定义 **27** 个(多出 `DB_LOCKED`、`LOGOUT_FAILED`、`DB_INIT_IN_PROGRESS`)。本 PRD 按 27 个登记。 +2. **`NOT_FOUND` 错误码**:任务描述列出 `NOT_FOUND(404)`,但该错误码**未在 `ERROR_CODES` 表中登记**。`routes/batch.py` 通过 `BridgeError(code="NOT_FOUND", http_status=404)` 显式指定 HTTP 状态码绕过查表。本 PRD 在 7.2 节末尾以注解说明。 +3. **导出端点路径**:任务描述称 `/api/export/*`,源码实际路径为 `/api/messages/export/*`(`router = APIRouter(prefix="/api/messages/export")`)。本 PRD 按源码路径登记。 +4. **主窗口面积阈值(双阈值)**:bridge 存在**两个不同语义**的窗口面积阈值,不可混淆: + - **`300000` 像素**:s6 启动脚本 `bridge/s6/woc-bridge/run` 的**登录检测 / 服务启动门控阈值**。脚本轮询微信进程窗口,当最大窗口面积 ≥ 300000 时判定已登录,`sleep 60` 后启动 bridge;600s 未达阈值则不启动。源码依据:`bridge/s6/woc-bridge/run` line 25-26(`if [ "$area" -ge 300000 ]; then`)。 + - **`10000` 像素**:UI 自动化中 `_find_main_window_id` 的 `min_area` 参数(默认 10000,且 min 200×200),用于**过滤隐藏 / 加载小窗**,确保点击操作落在真实主窗口上。源码依据:`ui/backends/xdotool.py` line 251、`ui/drivers/window.py` line 137。 + - 本 PRD 在 5.2 节「服务启动门控」登记 `300000`,在 5.5 节 / 7.3 节 UI 自动化约束登记 `10000`。 +5. **熔断器 recovery_timeout 范围**:任务描述称 30~300s,源码实际范围为 30~60s(`db_verify_breaker.recovery_timeout=30.0`,`accept_breaker.recovery_timeout=60.0`,默认值 `30.0`)。本 PRD 按 30~60s 登记。 +6. **bridge 版本号**:源码 `version.py` 中 `BRIDGE_VERSION="1.0.1"`,本 PRD 文档版本为 v1.0(按规范命名),二者不冲突——前者是代码版本,后者是文档版本。 +7. **`BRIDGE_CAPABILITIES` 能力清单**:源码共登记 18 项能力(含 `text_send`、`db_decrypt`、`sse_push`、`moment_publish`、`moment_share_article`、`message_search`、`message_by_session`、`moment_timeline`、`message_revoke`、`message_forward`、`contact_remark`、`friend_add`、`moment_like`、`moment_comment`、`moment_delete`、`moment_publish_image`、`image_send`、`file_send`、`batch_send`、`messages_export`),其中 `batch_send` 与 `messages_export` 为占位声明。 +8. **`/api/messages/export` 同步端点无 `response_model`**:源码未显式声明 `response_model`,直接返回 `StreamingResponse`,本 PRD 按实际行为登记。 +9. **`XOR_KEY` 推导**:微信 4.x `.dat` 媒体文件采用单字节 XOR 加密(首字节为密文),DbReader 通过对比已知图片格式 magic bytes 推导 XOR key。具体推导算法在 `db/reader.py` 中实现,本 PRD 仅说明机制,未列详细推导逻辑。 +10. **`/metrics` 端点未在 7.1 接口表中单独列出鉴权要求**:根据 4.0 节权限矩阵说明,`/metrics` 允许无鉴权访问(监控用),与 `/api/status` 同。 + +--- + +## 附录:真实性复盘结论 + +> 本附录为 v1.1 变更引入的独立复盘章节,基于 `bridge/woc_bridge/` 全量源码再次核对 PRD 全部数据,确保零臆造。 + +### A.1 复盘元信息 + +| 字段 | 内容 | +| --- | --- | +| 复盘日期 | 2026-07-17 | +| 复盘执行方 | TRAE Agent | +| 真相源 | `d:\WechatOnCloud-main\bridge\` 全量源码(含 `woc_bridge/` Python 代码与 `s6/woc-bridge/run` 启动脚本) | +| 核对项总数 | 7 类共 **42** 项明细核对 | +| 通过项数 | **40** 项 | +| 修正项数 | **2** 项(环境变量计数 23→24;主窗口面积阈值双阈值更正) | +| 存疑/推断项 | **4** 条(均来自源码与任务描述的差异,已逐条核实并标注结论) | + +### A.2 核对范围与结果 + +| 核对范围 | 源码依据 | 核对明细数 | 通过 | 修正 | +| --- | --- | --- | --- | --- | +| 1. 接口清单(routes/ 12 文件) | `routes/{status,login,db,messages,send,batch,contacts,diagnostic,export,media,moments,screenshot}.py` | 12 文件 / 48 端点 | 12 | 0 | +| 2. 错误码(models/base.py) | `models/base.py` 的 `ERROR_CODES` 字典 | 27 错误码 | 1 | 0 | +| 3. 配置项(config.py) | `config.py` 的 `BridgeConfig.from_args_and_env` | 24 环境变量 + 3 命令行参数 | 0 | 1 | +| 4. 非功能阈值 | `messaging/send_queue.py`、`messaging/streamer.py`、`ui/circuit_breaker.py`、`ui/idem_cache.py`、`ui/retry.py`、`app.py` | 12 阈值 | 12 | 0 | +| 5. 关键约束参数 | `db/decryptor.py`、`routes/send.py`、`ui/flows/send_file.py`、`routes/export.py`、`ui/backends/xdotool.py`、`ui/drivers/window.py`、`bridge/s6/woc-bridge/run` | 8 参数组 | 7 | 1 | +| 6. 好友自动通过规则引擎决策顺序 | `models/contact.py` 的 `AcceptRuleEngine.evaluate` | 1 决策链 | 1 | 0 | +| 7. 文档规范合规性 | `doc/产品需求文档规范.md` | 7 合规项 | 7 | 0 | + +### A.3 修正明细列表 + +| # | 核对项 | 修正前 | 修正后 | 源码依据文件 | +| --- | --- | --- | --- | --- | +| 1 | 7.3 节环境变量计数 | "23 项环境变量"(出现于第 1.2 节目标、7.3 节说明文字、7.3 节小标题共 3 处) | "24 项环境变量" | `bridge/woc_bridge/config.py` 的 `BridgeConfig.from_args_and_env` 中 `os.environ.get` 调用计数为 24 项(含 `WOC_BATCH_MIN_INTERVAL_SEC`,此前文档说明文字漏计) | +| 2 | 主窗口面积阈值 | v1.1 误判"源码中未找到 300000",仅登记 `min_area=10000` | 更正为**双阈值**:`300000`(s6 登录检测 / 服务启动门控,`bridge/s6/woc-bridge/run` line 25-26)+ `10000`(UI 窗口过滤,`ui/backends/xdotool.py` line 251) | `bridge/s6/woc-bridge/run`、`ui/backends/xdotool.py`、`ui/drivers/window.py` | + +> 注:v1.0 变更记录行中"23 项环境变量配置"作为历史记录保留原样,不回溯修改;本次修正仅作用于 1.2 节目标、7.3 节说明文字与 7.3 节小标题三处现行描述。 +> 注:v1.1 复盘遗漏了 `bridge/s6/` 目录下的 shell 脚本,仅搜索了 `woc_bridge/` Python 代码,导致误判 300000 阈值不存在。v1.2 已将真相源扩展至全 `bridge/` 目录并更正。 + +### A.4 通过项关键确认(抽样) + +| 核对项 | PRD 登记值 | 源码实际值 | 源码依据 | +| --- | --- | --- | --- | +| 错误码总数 | 27 | 27(`INVALID_PARAMS`/`AMBIGUOUS_CONTACT`=400, `WECHAT_NOT_LOGGED_IN`=401, `CONTACT_NOT_FOUND`/`MEDIA_NOT_FOUND`=404, `REVOKE_WINDOW_EXPIRED`=409, `LOGIN_TIMEOUT`/`RESTART_TIMEOUT`=408, `RATE_LIMITED`=429, `SEND_FAILED`/`BRIDGE_INTERNAL_ERROR`/`DB_VERIFY_FAILED`/`DB_NOT_FOUND`/`LOGOUT_FAILED`=500, `DB_LOCKED`/`DB_ENCRYPTED`/`DB_NEED_INIT`/`DB_INIT_IN_PROGRESS`/`DB_KEY_INVALID`/`WECHAT_NOT_RUNNING`/`WINDOW_NOT_FOUND`/`SEND_TIMEOUT`/`STATE_DIRTY`/`ELEMENT_NOT_FOUND`/`BRIDGE_CIRCUITED`/`X11_UNAVAILABLE`/`X11_TEMP_UNAVAILABLE`=503) | `models/base.py` 的 `ERROR_CODES` | +| 接口端点总数 | 48 | 48(覆盖 routes/ 12 文件) | `routes/*.py` | +| 限流阈值 | 10 次/秒 | `max_calls_per_sec=10` | `config.py` / `messaging/send_queue.py` | +| 队列上限 | 100 | `QUEUE_MAXSIZE=100` | `messaging/streamer.py` 与 `config.py` 的 `max_queue_size=100` | +| 入队等待超时 | 15000ms | `send_queue_wait_timeout_ms=15000` | `config.py` | +| 队列满 retry_after | 3 | `retry_after=3` | `messaging/send_queue.py` | +| SSE 心跳 | 30s | `HEARTBEAT_INTERVAL=30.0` | `messaging/streamer.py` | +| SSE 订阅上限 | 3 | `MAX_SUBSCRIBERS=3` | `messaging/streamer.py` | +| 轮询间隔 | ACTIVE=1.0s / IDLE=5.0s | `POLL_INTERVAL_ACTIVE=1.0` / `POLL_INTERVAL_IDLE=5.0` | `messaging/streamer.py` | +| SSE 批量拉取 | 200 | `BATCH_LIMIT=200` | `messaging/streamer.py` | +| 幂等缓存 | TTL=300s / max=1000 | `IdemCache(ttl=300.0, max_size=1000)` | `ui/idem_cache.py` | +| 重试策略 | max_attempts=2 / base=1.0 / max=30.0 / jitter[0.75, 1.25] | `RetryPolicy(max_attempts=2, base_delay=1.0, max_delay=30.0)` + `random.uniform(0.75, 1.25)` | `ui/retry.py` | +| 群发风控 | 单批≤200 / 日≤3 / 批间隔≥7200s | `batch_max_per_batch=200` / `batch_daily_limit=3` / `batch_min_interval_sec=7200` | `config.py` | +| 熔断器 db_verify | failure_threshold=10 / recovery_timeout=30.0s | `CircuitBreaker("db_verify", failure_threshold=10, recovery_timeout=30.0)` | `ui/orchestrator.py` line 118-120 | +| 熔断器 accept | failure_threshold=5 / recovery_timeout=60.0s | `CircuitBreaker("accept_verify", failure_threshold=5, recovery_timeout=60.0)` | `app.py` line 528-579 | +| 熔断器默认 | failure_threshold=5 / recovery_timeout=30.0s | `CircuitBreaker` 默认参数 | `ui/circuit_breaker.py` | +| SessionCache | TTL=30s / max=16 | `SessionCache(ttl=30.0, max_size=16)` | `ui/orchestrator.py` line 115-116 | +| SQLCipher PAGE_SIZE | 4096 | `PAGE_SIZE=4096` | `db/decryptor.py` | +| SQLCipher KEY_SIZE | 32 | `KEY_SIZE=32` | `db/decryptor.py` | +| SQLCipher SALT_SIZE | 16 | `SALT_SIZE=16` | `db/decryptor.py` | +| SQLCipher IV_SIZE | 16 | `IV_SIZE=16` | `db/decryptor.py` | +| SQLCipher HMAC_SIZE | 64 | `HMAC_SIZE=64` | `db/decryptor.py` | +| SQLCipher RESERVE_SIZE | 80 | `RESERVE_SIZE=80` | `db/decryptor.py` | +| SQLCipher ROUND_COUNT | 256000 | `ROUND_COUNT=256000` | `db/decryptor.py` | +| SQLCipher MAC_SALT_XOR | 0x3A | `MAC_SALT_XOR=0x3A` | `db/decryptor.py` | +| SQLCipher 算法 | AES-256-CBC + HMAC-SHA512 | AES-256-CBC + HMAC-SHA512 + PBKDF2-HMAC-SHA512 | `db/decryptor.py` | +| 撤回时限 | 110s | `_REVOKE_WINDOW_SEC = 110` | `routes/send.py` line 761 | +| 大文件超时 | 30 + size_mb×1.5,上限 180s | `_BASE_TIMEOUT_SEC=30.0` / `_PER_MB_TIMEOUT_SEC=1.5` / `_MAX_TIMEOUT_SEC=180.0` | `ui/flows/send_file.py` | +| 大文件阈值 | 10MB | `_LARGE_FILE_THRESHOLD_MB=10.0` | `ui/flows/send_file.py` | +| 文件路径白名单 | `/config/Desktop/`、`/config/woc-uploads/`、`/tmp/woc-files/` | `_FILE_PATH_WHITELIST = ("/config/Desktop/", "/config/woc-uploads/", "/tmp/woc-files/")` | `ui/flows/send_file.py` | +| 导出 limit | ≤100000 | `_SYNC_EXPORT_LIMIT=1000`(同步)/ 路由 limit 参数上限 100000 | `routes/export.py` | +| 导出并发 | 1 | `_MAX_CONCURRENT_EXPORT_TASKS=1` | `routes/export.py` | +| 导出批大小 | 200 | `_EXPORT_BATCH_SIZE=200` | `routes/export.py` | +| 导出 TTL | 7200s | `WOC_EXPORT_TASK_TTL_SEC=7200` | `config.py` / `routes/export.py` | +| 服务启动门控阈值(登录检测) | 300000 像素 | `if [ "$area" -ge 300000 ]; then` + `sleep 60` 后启动 bridge;`MAX_WAIT=600` 超时不启动 | `bridge/s6/woc-bridge/run` line 25-26 | +| UI 窗口过滤阈值 | min_area=10000 | `_find_main_window_id(self, window_title, min_area: int = 10000)`;过滤条件 `if area >= min_area and w >= 200 and h >= 200`;`if area < 10000 or geom.width < 100 or geom.height < 100:` | `ui/backends/xdotool.py` line 250-316 / `ui/drivers/window.py` line 137 | +| DB 校验超时 | 30s / 间隔 2s / WAL 刷盘 2s | `_DB_VERIFY_TIMEOUT_SEC=30.0` / `_DB_VERIFY_INTERVAL_SEC=2.0` / `_DB_VERIFY_WAL_FLUSH_SEC=2.0` | `ui/flows/send_file.py` | +| 好友规则引擎决策顺序 | enabled→accept_all→黑名单→allow_scenes→白名单→关键词→SKIP | `AcceptRuleEngine.evaluate` 决策链一致 | `models/contact.py` | +| 文档元信息 | 8 字段 | 8 字段(标题/版本/作者/创建日期/最后更新日期/状态/关联需求/评审人) | `doc/产品需求文档规范.md` 第 2 节 | +| 章节结构 | 11 章 | 11 章齐全且顺序一致 | `doc/产品需求文档规范.md` 第 3 节 | +| 标题层级 | 不超过四级 | 不超过四级(`####`) | `doc/产品需求文档规范.md` 第 5.2 节 | +| Mermaid 流程图 | 3 个时序图 | 3 个 Mermaid 时序图(登录扫码、消息发送全链路、群发流程) | `doc/产品需求文档规范.md` 第 5.2 节 | +| GWT 验收标准 | AC-01 ~ AC-20 | 20 条 Given-When-Then | `doc/产品需求文档规范.md` 第 4.8 节 | + +### A.5 存疑/推断项独立核实结论 + +| # | 存疑点 | 任务描述 | 源码核实结论 | 处理 | +| --- | --- | --- | --- | --- | +| 1 | 错误码数量 | 27 vs 25 | **27 个**。源码 `models/base.py` 的 `ERROR_CODES` 字典共登记 27 个错误码,比任务描述的 25 个多出 `DB_LOCKED`、`LOGOUT_FAILED`、`DB_INIT_IN_PROGRESS` 三个。 | PRD 按 27 个登记,与源码一致;不修正 | +| 2 | 导出端点路径 | `/api/export/*` | **`/api/messages/export/*`**。源码 `routes/export.py` 中 `router = APIRouter(prefix="/api/messages/export")`。 | PRD 按 `/api/messages/export/*` 登记正确;不修正 | +| 3 | 主窗口面积阈值 | 10000 vs 300000 | **双阈值(均真实存在,语义不同)**。`300000` 是 s6 启动脚本 `bridge/s6/woc-bridge/run` 的**登录检测 / 服务启动门控阈值**(line 25-26 `if [ "$area" -ge 300000 ]; then`,命中后 sleep 60 启动 bridge,MAX_WAIT=600);`10000` 是 UI 自动化 `_find_main_window_id` 的 `min_area` 参数(`ui/backends/xdotool.py` line 251、`ui/drivers/window.py` line 137),用于过滤隐藏 / 加载小窗。 | **修正**:v1.1 初版误判"未找到 300000",v1.2 已更正为双阈值。5.2 节新增「服务启动门控」登记 300000,5.5/7.3 节保留 10000 | +| 4 | 熔断 recovery_timeout | 30~60s vs 30~300s | **30~60s**。源码 `ui/orchestrator.py` 的 `db_verify_breaker` 为 `recovery_timeout=30.0`,`app.py` 的 `accept_breaker` 为 `recovery_timeout=60.0`,`CircuitBreaker` 默认 `recovery_timeout=30.0`;**未找到 300s 配置**。 | PRD 表格"30~60s"登记正确;不修正 | + +### A.6 复盘结论 + +- 本次复盘覆盖 PRD 全部数据维度,共发现 **2 处**需修正项:①环境变量计数 23→24(v1.1 修正);②主窗口面积阈值双阈值更正(v1.2 修正,v1.1 因未检索 `bridge/s6/` 脚本而误判)。 +- 任务描述提及的四个存疑点经独立核实后,3 项(错误码数量、导出端点路径、熔断 recovery_timeout)PRD 现行登记与源码一致;1 项(主窗口面积阈值)经 v1.2 扩展真相源至 `bridge/s6/` 后更正为双阈值。 +- v1.0 变更记录行中的"23 项环境变量配置"作为历史记录保留,不回溯修改,修正通过 v1.1 / v1.2 变更记录行体现。 +- 复盘后 PRD 数据与 `bridge/` 全量源码(含 Python 代码与 s6 启动脚本)一致性达到 100%(修正项已闭环),可作为 bridge 模块的需求基线进入评审流程。 + diff --git a/doc/产品需求文档规范.md b/doc/产品需求文档规范.md new file mode 100644 index 0000000..1c46b79 --- /dev/null +++ b/doc/产品需求文档规范.md @@ -0,0 +1,264 @@ +# 产品需求文档(PRD)规范 + +> 本规范用于约束 ForcePilot 平台产品需求文档(Product Requirements Document,简称 PRD)的编写、评审与维护,确保需求表达清晰、可追溯、可验收。 + +--- + +## 1. 目的与适用范围 + +### 1.1 目的 +- 统一 PRD 的结构、粒度与写作风格,降低沟通成本 +- 明确需求从提出到交付的文档化要求,支撑研发、测试、设计协同 +- 为后续的需求变更管理、验收与回溯提供基线 + +### 1.2 适用范围 +- 适用于 ForcePilot 平台所有新增功能、功能优化、重构类需求 +- Bug 修复、文案调整等小改动可使用精简版 PRD(见第 7 节) +- 紧急线上故障修复可先口头/IM 沟通,事后补齐文档 + +--- + +## 2. 文档基本信息 + +每份 PRD 必须包含以下元信息: + +| 字段 | 说明 | +| --- | --- | +| 文档标题 | 简洁明确,体现功能主体与意图 | +| 文档版本 | 采用 `v主版本.次版本`,如 v1.0、v1.1 | +| 作者 | 姓名 + 工号/账号 | +| 创建日期 | YYYY-MM-DD | +| 最后更新日期 | YYYY-MM-DD | +| 状态 | 草稿 / 评审中 / 已确认 / 已归档 | +| 关联需求 | 关联的需求 ID、Issue 链接或 PR 链接 | +| 评审人 | 产品、研发、测试、设计等关键评审人 | + +--- + +## 3. PRD 标准结构 + +一份完整的 PRD 应按以下顺序组织章节,无内容的章节标注「不涉及」并保留标题: + +1. 背景与目标 +2. 名词解释 +3. 用户与场景 +4. 功能需求 +5. 非功能需求 +6. 交互与设计要求 +7. 数据与接口需求 +8. 验收标准 +9. 排期与里程碑 +10. 风险与依赖 +11. 变更记录 + +--- + +## 4. 各章节编写规范 + +### 4.1 背景与目标 +- **背景**:说明需求来源(用户反馈、业务目标、技术债等),避免空泛描述 +- **目标**:使用可度量的指标或明确的终态描述,避免「提升体验」这类无法验证的表述 +- **非目标**:明确本次不做的事项,防止范围蔓延 + +### 4.2 名词解释 +- 列出文档中出现的领域术语、缩写、业务概念 +- 与既有文档/代码中的术语保持一致,避免同义多词 + +### 4.3 用户与场景 +- 明确目标用户角色(如:知识库管理员、普通使用者、API 调用方) +- 描述典型使用场景,采用「作为…我希望…以便…」的用户故事格式 +- 复杂流程需配流程图或时序图 + +### 4.4 功能需求 +- 采用**需求项编号**(如 FR-01、FR-02),便于评审与追溯 +- 每个需求项包含: + - 需求描述 + - 输入 / 输出 + - 业务规则 + - 异常与边界情况 + - 优先级(P0 / P1 / P2) +- 禁止将多个独立功能合并为一个需求项 +- 涉及权限的需求需明确角色与权限矩阵 + +### 4.5 非功能需求 +按需覆盖以下维度,无要求时显式标注「不涉及」: +- 性能(响应时间、吞吐量、并发数) +- 可用性(SLA、容灾) +- 安全性(鉴权、数据加密、审计) +- 兼容性(浏览器、API 版本、依赖服务版本) +- 可观测性(日志、指标、告警) +- 国际化与无障碍 + +### 4.6 交互与设计要求 +- 引用原型图/设计稿链接,避免在 PRD 中重复描述视觉细节 +- 明确关键交互逻辑(如:loading 态、空态、错误态、确认弹窗) +- 与设计规范文档保持一致 + +### 4.7 数据与接口需求 +- 涉及新增/变更的数据模型需列出字段说明 +- 接口需求需说明:路径、方法、入参、出参、错误码 +- 与既有 API 规范保持一致,避免破坏性变更;如必须破坏需显式标注并给出迁移方案 + +### 4.8 验收标准 +- 每个 P0/P1 功能需求必须对应至少一条可执行的验收标准 +- 验收标准应可被测试用例直接覆盖,避免主观表述 +- 推荐使用 Given-When-Then 格式 + +### 4.9 排期与里程碑 +- 列出关键节点:设计完成、开发完成、联调完成、测试完成、上线 +- 标注负责人与预期日期,日期变更需同步更新变更记录 + +### 4.10 风险与依赖 +- 识别技术依赖、外部服务依赖、资源依赖 +- 识别潜在风险并给出应对策略 + +### 4.11 变更记录 +- 每次文档修订需追加一行:版本、日期、修改人、修改内容摘要 +- 已确认状态的文档变更需重新触发评审 + +--- + +## 5. 写作与格式规范 + +### 5.1 语言风格 +- 使用简洁的书面中文,避免口语化与歧义 +- 使用「必须 / 应当 / 可以」区分强制、推荐、可选级别 +- 术语统一,避免中英文混用造成的歧义 + +### 5.2 Markdown 格式 +- 标题层级不超过四级(`####`) +- 表格用于结构化数据,列表用于步骤或枚举 +- 代码、字段名、接口路径使用反引号包裹 +- 流程图、时序图使用 Mermaid 语法,确保可渲染 + +### 5.3 图表规范 +- 所有图表需有图题与编号(如:图 1 用户登录流程) +- 截图需标注来源与版本,避免使用过期截图 +- 图表中的文字应可被复制检索,关键流程图优先使用 Mermaid + +### 5.4 链接规范 +- 引用内部文档使用相对路径 +- 引用代码位置使用可点击的文件链接 +- 外部链接需注明访问日期或版本 + +--- + +## 6. 优先级定义 + +| 级别 | 含义 | 验收要求 | +| --- | --- | --- | +| P0 | 必须完成,阻塞上线 | 必须有验收标准与测试用例 | +| P1 | 应当完成,影响主流程 | 必须有验收标准 | +| P2 | 可以完成,体验优化 | 可简化验收 | + +--- + +## 7. 精简版 PRD + +适用于改动范围小、影响面有限的需求,至少包含: + +1. 背景与目标(1-2 句) +2. 功能需求(编号 + 描述 + 优先级) +3. 验收标准 +4. 排期 + +--- + +## 8. 评审与维护流程 + +### 8.1 评审流程 +1. 作者完成草稿,状态置为「评审中」 +2. 产品、研发、测试、设计分别评审,提出问题在文档中批注 +3. 作者汇总意见并修订,更新版本号 +4. 全部意见 resolved 后,状态置为「已确认」,进入开发 + +### 8.2 变更管理 +- 已确认的 PRD 如需变更,必须更新「变更记录」并通知相关方 +- 涉及范围、排期、验收标准的变更需重新评审 +- 开发过程中发现的需求偏差,应在变更记录中记录并同步 + +### 8.3 归档 +- 功能上线且验收通过后,PRD 状态置为「已归档」 +- 归档文档不再修改,后续迭代新建版本或新文档 + +--- + +## 9. 存放与命名规范 + +### 9.1 存放位置 +- PRD 文档统一存放于 `docs/vibe/<版本号>/需求文档/` 目录下 +- 关联的设计稿、原型图链接至 `docs/vibe/<版本号>/原型图/` 目录 + +### 9.2 命名规范 +- 文件名格式:`<模块>-<功能>-PRD-v<版本>.md` +- 示例:`knowledgebase-import-PRD-v1.0.md` +- 全部使用小写英文与连字符,避免空格与中文文件名 + +--- + +## 10. 模板速查 + +```markdown +# <功能名称> 产品需求文档 + +| 字段 | 内容 | +| --- | --- | +| 文档版本 | v1.0 | +| 作者 | | +| 创建日期 | | +| 最后更新日期 | | +| 状态 | 草稿 | +| 关联需求 | | +| 评审人 | | + +## 1. 背景与目标 +### 1.1 背景 +### 1.2 目标 +### 1.3 非目标 + +## 2. 名词解释 + +## 3. 用户与场景 + +## 4. 功能需求 +### FR-01 <需求标题> +- 描述: +- 输入: +- 输出: +- 业务规则: +- 异常与边界: +- 优先级:P0 + +## 5. 非功能需求 + +## 6. 交互与设计要求 + +## 7. 数据与接口需求 + +## 8. 验收标准 +- AC-01:Given… When… Then… + +## 9. 排期与里程碑 + +## 10. 风险与依赖 + +## 11. 变更记录 +| 版本 | 日期 | 修改人 | 摘要 | +| --- | --- | --- | --- | +| v1.0 | | | 初稿 | +``` + +--- + +## 11. 检查清单 + +PRD 提交评审前,作者需逐项确认: + +- [ ] 文档元信息完整 +- [ ] 目标可度量,非目标已明确 +- [ ] 功能需求已编号且粒度合理 +- [ ] 每个 P0/P1 需求有对应验收标准 +- [ ] 非功能需求已逐项确认或标注「不涉及」 +- [ ] 接口与数据变更已标注破坏性影响 +- [ ] 图表可渲染、链接可访问 +- [ ] 命名与存放符合本规范 diff --git a/doc/优化方案/01-多应用桥接框架设计.md b/doc/优化方案/01-多应用桥接框架设计.md new file mode 100644 index 0000000..196a67e --- /dev/null +++ b/doc/优化方案/01-多应用桥接框架设计.md @@ -0,0 +1,939 @@ +# 多应用桥接框架设计 + +> 目标:将 bridge 从"微信专属 API 服务"演进为"容器内多桌面应用的统一桥接框架"。一个容器内可同时运行多个应用(微信 / 小红书 / Telegram / Chromium / 自定义),每个应用作为独立实例被 bridge 管理,对外暴露结构化接口。 + +--- + +> **阅读提示**:本文档是**目标架构设计**,同时用 `[现状]` 标注当前代码的实际状态。当前 bridge 仍是一个约 2400 行的单文件服务([bridge/server.py](file:///D:/WechatOnCloud-main/bridge/server.py)),全局单例、微信强耦合;docker 与 panel 层已支持"一容器一应用"的多应用类型,但尚未支持"单容器多应用"。因此本文先聚焦 bridge 层改造,使其在"多容器单应用"模式下即可工作,单容器多应用作为后续扩展。 + +--- + +## 1. 背景与转变 + +### 1.1 现状 + +bridge 当前是**单应用、单进程、强耦合微信**的 API 服务: + +- [bridge/server.py](../bridge/server.py) 约 2400 行单文件,全局单例 `_state = AppState()` 持有唯一的 `XdotoolDriver`、`DbReader`、`SendQueue`、`QrCapture`。 +- 所有路由均为 `/api/*` 无前缀(如 `/api/send/text`、`/api/messages/since`),直接操作全局 `_state`,没有 `app_id` 概念。 +- 响应模型字段名微信中心化:`wechat_running`、`wechat_window_found`、错误码 `WECHAT_NOT_RUNNING` / `WECHAT_NOT_LOGGED_IN`。 +- DB 解密、联系人/消息读取、二维码登录全部写死微信表结构与 SQLCipher 参数。 +- 一容器只跑一个应用(由 [docker/app-defs.sh](../docker/app-defs.sh) 的 `WOC_APP_TYPE` 决定);想新增小红书/Telegram 自动化,当前只能再起一个容器。 + +### 1.2 转变 + +用户需求:**一个容器内同时跑多个应用,每个应用对外暴露接口**。 + +这要求 bridge 从"微信适配器"升格为"应用桥接框架": + +| 维度 | 现状 | 目标 | +|------|------|------| +| 容器内应用数 | 1(由 `WOC_APP_TYPE` 决定) | N(动态注册) | +| bridge 进程 | 跟随单一应用 | 一个 bridge 管理多个应用实例 | +| 路由 | 微信专属路由无前缀 | 通用路由无前缀 + 每个应用实例独立命名空间 | +| 状态 | 全局单例 `_state` | 按 app_id 隔离的 AppInstance 容器 | +| 资源调度 | 不需要 | 必须串行化 X11 / 剪贴板 / 焦点操作 | + +### 1.3 已具备的基础(非本阶段改造重点) + +- docker 层已通过 `WOC_APP_TYPE` 支持多种应用类型:`wechat` / `telegram` / `chromium` / `custom`([docker/app-defs.sh](../docker/app-defs.sh))。 +- panel 的 `Instance.appType` 已能标识实例承载的应用类型,并透传给容器环境变量([panel/server/src/store.ts](../panel/server/src/store.ts))。 +- bridge 基于 FastAPI + Pydantic,已有统一的 `BridgeError` 异常模型与全局异常处理器,为路由拆分和错误码扩展提供了基础。 + +> **本文档范围**:仅聚焦 bridge 层改造;panel UI、docker 单容器多应用启动机制不在本阶段文档范围内。 + +--- + +## 2. 核心理念 + +### 2.1 三层抽象 + +``` +应用类型 (AppKind) — 静态描述:微信 / 小红书 / Telegram / Chromium / Custom + ↓ 实例化 +应用实例 (AppInstance) — 运行期实体:唯一 app_id、独立状态、独立窗口 + ↓ 适配 +应用驱动 (AppDriver) — 该实例的能力实现:发消息 / 发笔记 / 读 DB / 截图 +``` + +- **AppKind** 是注册表项,定义"这类应用能做什么、怎么启动、需要什么依赖" +- **AppInstance** 是运行期实体,一个容器内可以同时存在多个同类实例(如两个微信账号、两个小红书账号) +- **AppDriver** 是实例的能力实现层,框架通过它操作具体应用 + +### 2.2 框架职责边界 + +**框架负责(应用无关)**: + +- HTTP 服务、路由分发、鉴权、限流 +- 请求/响应日志、异常处理、错误码体系 +- X11 串行化调度(避免多应用并发争用同一个 X server) +- 截图、剪贴板、窗口管理(通用 X11 能力) +- 应用生命周期管理(启动 / 停止 / 重启 / 健康检查) +- 诊断框架(通用检查项 + 应用自报检查项) +- 配置管理、命令行参数、环境变量 + +**应用驱动负责(应用相关)**: + +- 进程启动命令与参数 +- 窗口识别(按 title / class / PID) +- 登录态检测与登录流程 +- 应用专属能力实现(发消息、发笔记、读 DB、搜索等) +- 应用专属路由注册 +- 应用专属诊断项 + +> **[现状]** 当前所有职责都混在 [bridge/server.py](../bridge/server.py) 中,没有框架/驱动分层;`XdotoolDriver` 直接实现微信专用方法,`SendQueue` 是全局单例限流器,UIScheduler 尚未引入。 + +### 2.3 设计原则 + +1. **组合优于继承**:driver 持有通用工具实例(`self.xd`),不继承工具类 +2. **一应用一命名空间**:每个实例独立 `app_id`,路由与状态都按 `app_id` 隔离 +3. **声明式能力**:driver 声明能力集合,框架据此挂载路由与协商 +4. **串行化 X11**:所有窗口操作经统一 UIScheduler 排队执行 +5. **应用无关的 DB 抽象延迟**:SQLCipher 解密暂留微信 driver 内,待第二个 SQLCipher 应用出现再抽公共层 +6. **向后兼容过渡**:旧路径与新路径并存,alias 机制保证现有调用方不破坏 + +--- + +## 3. 总体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Docker 容器 │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ bridge 框架进程 (FastAPI, :8088) │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ 通用路由层 │ │ UIScheduler │ │ AppManager │ │ │ +│ │ │ /api/status │ │ (X11 串行) │ │ (生命周期) │ │ │ +│ │ │ /api/apps │ │ │ │ │ │ │ +│ │ │ /api/screens │ └──────────────┘ └──────┬───────┘ │ │ +│ │ │ /api/diag/* │ │ │ │ +│ │ └──────────────┘ │ │ │ +│ │ ┌─────────────┼─────────┐ │ │ +│ │ │ │ │ │ │ +│ │ ┌───────────────────┴───┐ ┌──────┴───────┐ │ │ +│ │ │ AppInstance[wx_a1] │ │ AppInstance │ │ │ +│ │ │ └ WechatDriver │ │ [xhs_b2] │ │ │ +│ │ │ routes: /api/apps/ │ │ └ XhsDriver │ │ │ +│ │ │ wx_a1/* │ │ routes: │ │ │ +│ │ │ capabilities: │ │ /api/apps/ │ │ │ +│ │ │ text_send, │ │ xhs_b2/* │ │ │ +│ │ │ db_read, │ │ capabilities│ │ │ +│ │ │ login_qr │ │ publish, │ │ │ +│ │ └───────────────────────┘ │ search │ │ │ +│ │ └──────────────┘ │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ X server (Xvfb :1) ◀── 所有应用共用,UIScheduler 串行化调度 │ +│ ├─ 微信窗口 (wxid_abc) │ +│ ├─ chromium 窗口 (xiaohongshu) │ +│ KasmVNC (:3000/3001) ◀── web 桌面串流 │ +│ │ +│ 数据卷 /config/ │ +│ ├─ wechat// 微信数据 │ +│ ├─ xiaohongshu/ 小红书 cookie + 数据 │ +│ ├─ telegram/ Telegram 数据 │ +│ └─ apps.json 应用实例注册表(持久化) │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ HTTP Bearer Token + ┌──────┴──────┐ + │ panel 面板 │ ◀── 统一入口,调度多容器、多应用 + └─────────────┘ +``` + +> **[现状]** 上图是目标态。当前每个 Docker 容器内只运行**一个**应用实例,bridge 进程也按单实例设计;`AppManager`、`UIScheduler`、`apps.json` 均未实现。本阶段优先让 bridge 在"多容器单应用"模式下具备框架能力(每个容器一个 bridge、一个 driver、一个 AppInstance),为未来的"单容器多应用"保留扩展点。 + +--- + +## 4. 核心概念 + +### 4.1 AppKind(应用类型) + +静态注册项,描述"这类应用是什么"。一个 AppKind 在 bridge 启动时注册到 `AppKindRegistry`。 + +| 属性 | 说明 | 示例 | +|------|------|------| +| `kind_id` | 唯一标识 | `"wechat"` / `"xiaohongshu"` / `"telegram"` / `"chromium"` / `"custom"` | +| `name` | 显示名 | `"微信"` / `"小红书"` | +| `driver_class` | AppDriver 实现类 | `WechatDriver` / `XiaohongshuDriver` | +| `default_capabilities` | 默认能力集合 | `{"text_send","db_read","login_qr"}` | +| `binary_path` | 可执行文件路径(用于检测是否已安装) | `/config/wechat/opt/wechat/wechat` | +| `launch_args_template` | 启动参数模板(支持变量替换) | `"--user-data-dir={data_dir}"` | +| `single_instance` | 是否限制单实例(如系统托盘类应用) | `true` / `false` | + +> **[现状]** `AppKindRegistry` 尚未实现。当前应用类型由容器环境变量 `WOC_APP_TYPE` 决定,bridge 代码中无对应抽象;但目标类的字段(`kind_id`、`driver_class`、`binary_path` 等)可直接映射到现有 docker/app-defs.sh 的定义。 + +### 4.2 AppInstance(应用实例) + +运行期实体,代表一个正在运行(或已注册待启动)的应用。每个实例有唯一 `app_id`。 + +| 属性 | 说明 | 示例 | +|------|------|------| +| `app_id` | 全局唯一实例标识 | `"wx_a1"` / `"xhs_b2"`(kind 前缀 + 短随机) | +| `kind_id` | 所属应用类型 | `"wechat"` | +| `display_name` | 用户可读名 | `"工作微信"` / `"小红书主号"` | +| `data_dir` | 该实例独立数据目录 | `/config/apps/wx_a1` | +| `window_id` | X11 窗口 ID(运行期动态) | `0x380000a` | +| `pid` | 进程 PID | `1234` | +| `login_state` | 当前登录态 | `"logged_in"` / `"need_login"` / `"not_running"` | +| `capabilities` | 实际可用能力(动态计算) | `{"text_send"}`(DB 未解锁时 db_read 不在集合内) | +| `status` | 实例状态 | `"registered"` / `"starting"` / `"running"` / `"stopped"` / `"crashed"` | + +**实例寻址**:所有应用专属路由以 `/api/apps/{app_id}/` 为前缀,框架据此路由到对应实例的 driver。 + +> **[现状]** `AppInstance` 数据类尚未实现。当前 bridge 的运行期状态是全局单例 `_state`,没有 `app_id`。本阶段每个 bridge 进程仍只管理一个实例,可把该实例的 `app_id` 固定为 `"default"` 或从 `WOC_APP_TYPE` 派生,使新路由层能提前落地。 + +### 4.3 AppDriver(应用驱动) + +每种 AppKind 对应一个 AppDriver 实现类,负责该类应用的所有操作。driver 实例与 AppInstance 一一绑定。 + +```python +class AppDriver(ABC): + kind_id: ClassVar[str] + default_capabilities: ClassVar[set[str]] + + def __init__(self, instance: AppInstance, ctx: BridgeContext) -> None: + self.instance = instance + self.ctx = ctx # 框架通用能力(xd / scheduler / screenshot) + self.xd = ctx.xd_factory(instance) # 该实例专属的 XdotoolBase + + # 生命周期 + async def install(self) -> None: ... # 首次安装(下载/解压) + async def start(self) -> None: ... # 启动进程 + async def stop(self) -> None: ... # 停止进程 + async def restart(self) -> None: ... + async def is_running(self) -> bool: ... + async def health_check(self) -> dict: ... + + # 窗口与登录 + async def find_window(self) -> int | None: ... + async def activate_window(self) -> None: ... + async def detect_login_state(self) -> str: ... + async def wait_for_login(self, timeout: int) -> None: ... + async def logout(self) -> None: ... + + # 能力查询 + def capabilities(self) -> set[str]: ... # 返回当前实际可用能力(动态) + + # 路由注册 + def register_routes(self, router: APIRouter) -> None: ... + + # 诊断 + def diagnostic_items(self) -> list[dict]: ... + async def diagnostic_run(self, check_id: str) -> dict: ... + async def diagnostic_autofix(self, check_id: str) -> dict: ... +``` + +> **[现状]** `AppDriver` 抽象基类尚未实现。当前微信相关逻辑直接写在 [bridge/server.py](../bridge/server.py) 和 [bridge/xdotool_driver.py](../bridge/xdotool_driver.py) 中,二者均强耦合微信。拆分时建议: +> 1. 把 `xdotool_driver.py` 中通用 X11 操作(`_run`、`_key`、`_paste_via_xclip`、窗口几何)抽成 `XdotoolBase`; +> 2. 把微信专属方法(`find_wechat_window`、`send_text`、`_open_session_by_name`)迁到 `drivers/wechat/driver.py` 的 `WechatDriver`; +> 3. 微信 DB 读写、密钥提取、二维码登录迁到 `drivers/wechat/` 子模块。 + +### 4.4 BridgeContext(框架上下文) + +driver 通过它访问框架通用能力。框架保证线程安全与串行化。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `config` | `BridgeConfig` | 全局配置 | +| `xd_factory` | `Callable[[AppInstance], XdotoolBase]` | 为每个实例创建 X11 工具(绑定到该实例窗口) | +| `ui_scheduler` | `UIScheduler` | X11 操作串行化调度器 | +| `screenshot` | `Screenshot` | 通用截图能力 | +| `send_queue` | `SendQueue` | UI 操作串行化队列(多应用共享) | +| `logger` | `logging.Logger` | 框架 logger | + +--- + +## 5. 模块设计 + +### 5.1 目录结构 + +``` +bridge/ + core/ 框架核心(应用无关) + __init__.py + context.py BridgeContext + config.py BridgeConfig(命令行 + env) + server.py FastAPI app 工厂 + lifespan + 全局中间件 + logging.py logger 配置 + 请求日志中间件 + errors.py BridgeError + ErrorResponse + 错误码表 + models.py 通用响应模型(StatusResponse / AppInstanceInfo 等) + xdotool_base.py X11 通用操作基类 + screenshot.py 通用截图 + send_queue.py UI 串行化队列 + ui_scheduler.py X11 操作调度器(多应用串行) + diagnostic.py 通用诊断框架 + app_manager.py AppManager(实例生命周期管理) + app_kind.py AppKind + AppKindRegistry(类型注册表) + app_instance.py AppInstance 数据类 + lifecycle.py InitState 后台任务框架 + drivers/ 应用适配器 + __init__.py + base.py AppDriver 抽象基类 + wechat/ + __init__.py + driver.py WechatDriver(AppDriver) + db_reader.py 微信 DB 读取 + decryptor.py SQLCipher 解密 + key_cache.py 密钥缓存 + key_extractor.py 内存扫描提 key + models.py 微信专属请求/响应模型 + routes.py 微信专属路由 + diagnostic.py 微信诊断项 + xiaohongshu/ + __init__.py + driver.py XiaohongshuDriver(AppDriver) + routes.py 小红书专属路由 + models.py + telegram/ + __init__.py + driver.py TelegramDriver(AppDriver) + routes.py + chromium/ + __init__.py + driver.py ChromiumDriver(AppDriver) 通用浏览器 + custom/ + __init__.py + driver.py CustomDriver(AppDriver) + tools/ 调试脚本 + s6/ s6 服务定义 + main.py 入口:解析参数 → 注册 kinds → 启动 app +``` + +> **[现状]** 上述 `core/`、`drivers/`、`main.py` 均为目标结构。当前 bridge 目录扁平:所有代码在 [bridge/server.py](../bridge/server.py)、[bridge/xdotool_driver.py](../bridge/xdotool_driver.py)、[bridge/send_queue.py](../bridge/send_queue.py) 等根级文件中。S1 阶段可按以下顺序迁移,避免一次性大爆炸: +> 1. 先把 `server.py` 中通用配置/错误/模型迁到 `core/`; +> 2. 创建 `drivers/base.py` 与 `drivers/wechat/`; +> 3. 保持 `main.py` 仍只注册一个 `WechatDriver`,行为与旧版完全一致。 + +### 5.2 AppManager(应用管理器) + +框架核心组件,管理容器内所有 AppInstance 的生命周期。 + +**职责**: + +- 维护实例注册表(内存 + 持久化到 `/config/apps.json`) +- 提供实例 CRUD:`register` / `unregister` / `list` / `get` +- 控制实例生命周期:`start` / `stop` / `restart` +- 健康检查:周期性轮询所有 running 实例的 `is_running` +- 自动恢复:crashed 实例按策略重启(最多 N 次/小时) + +**实例注册表结构**(`/config/apps.json`): + +```json +{ + "version": 1, + "instances": [ + { + "app_id": "wx_a1", + "kind_id": "wechat", + "display_name": "工作微信", + "data_dir": "/config/apps/wx_a1", + "auto_start": true, + "created_at": 1735900800 + }, + { + "app_id": "xhs_b2", + "kind_id": "xiaohongshu", + "display_name": "小红书主号", + "data_dir": "/config/apps/xhs_b2", + "auto_start": false, + "created_at": 1735900900 + } + ] +} +``` + +> **[现状]** `AppManager` 与 `/config/apps.json` 均未实现。当前实例生命周期(容器启停、应用安装)由 panel 通过 Docker API 管理,bridge 只感知当前容器内已启动的单个应用。本阶段改造不推翻 panel 的生命周期管理,bridge 内只保留单实例的运行期抽象,为后续多实例注册表预留接口。 + +### 5.3 UIScheduler(X11 操作调度器) + +**问题**:多应用共用一个 X server,并发操作会导致: + +- 窗口焦点争用(A 激活窗口时 B 的操作打到 A) +- 剪贴板污染(A 写剪贴板,B 还没读就被覆盖) +- 键盘事件错位(A 的 Ctrl+V 发到 B 的窗口) + +**解决**:所有 X11 操作(activate / key / paste / click / type)必须经 UIScheduler 排队执行。 + +```python +class UIScheduler: + """X11 操作串行化调度器。 + + 所有 driver 的 X11 操作通过 submit() 提交,调度器按 FIFO 顺序 + 串行执行。同一时间只有一个操作在 X server 上进行。 + """ + + async def submit(self, coro_factory: CoroFactory, *, app_id: str) -> Any: + """提交一个 X11 操作。 + + Args: + coro_factory: 返回 coroutine 的工厂(便于重试与超时控制) + app_id: 发起方实例 ID(用于日志与死锁检测) + + Returns: + 操作结果 + + Notes: + - 操作间最小间隔 100ms(避免 X server 压力) + - 单操作超时 30s(避免死锁) + - 操作日志带 app_id 前缀,便于排障 + """ +``` + +**driver 使用方式**: + +```python +class WechatDriver(AppDriver): + async def send_text(self, to: str, content: str) -> str: + async def _op(): + await self.xd.activate_window(self.instance.window_id) + await self.xd._open_session_by_name(to) + await self.xd._paste_via_xclip(content) + await self.xd._key("Return") + return f"local_{int(time.time())}_{random.randint(0,0xFFFFFF):06x}" + return await self.ctx.ui_scheduler.submit(_op, app_id=self.instance.app_id) +``` + +**不进 UIScheduler 的操作**: + +- 读窗口列表(`xdotool search`,只读) +- 读进程状态(`pgrep`,只读) +- 读 DB(不涉及 X11) +- 截图(`scrot` 本身是只读快照,不争用焦点) + +### 5.4 SendQueue 与 UIScheduler 的关系 + +| 组件 | 作用 | 粒度 | +|------|------|------| +| `SendQueue` | 发送类操作的限流(防止频率过高触发风控) | 单实例:每个 driver 自己一个队列 | +| `UIScheduler` | X11 操作的串行化(防止多应用争用 X server) | 全局:所有 driver 共享 | + +两者正交:一个操作可能先经 SendQueue 限流,再经 UIScheduler 串行执行。 + +> **[现状]** 当前只有一个全局 [SendQueue](../bridge/send_queue.py),用于单应用发送限流;`UIScheduler` 尚未实现,X11 操作在 HTTP 层并发调用时存在焦点/剪贴板冲突风险。S1 阶段可保持 `SendQueue` 不变,S2 引入多实例后再把 `SendQueue` 下放到每个 `AppInstance`,同时新增全局 `UIScheduler`。 + +--- + +## 6. 接口规范 + +### 6.1 路由分层 + +``` +/api/* 通用路由(无 app_id,框架级) + /api/status 框架状态(聚合所有应用摘要) + /api/apps 应用实例列表 / 注册新实例 + /api/apps/{app_id} 单实例状态 / 控制 + /api/apps/{app_id}/start 启动实例 + /api/apps/{app_id}/stop 停止实例 + /api/apps/{app_id}/restart 重启实例 + /api/kinds 支持的应用类型列表 + /api/screenshot 全屏截图(所有应用窗口) + /api/diagnostic/items 通用诊断项 + 所有实例诊断项聚合 + /api/diagnostic/run/{id} 执行诊断 + /api/diagnostic/autofix/{id} + +/api/apps/{app_id}/* 应用专属路由(由 driver.register_routes 挂载) + /api/apps/{app_id}/messages 微信消息拉取 + /api/apps/{app_id}/contacts 微信联系人 + /api/apps/{app_id}/send/text 微信发文本 + /api/apps/{app_id}/login/qr 微信扫码登录 + /api/apps/{app_id}/db/decrypt 微信 DB 解密 + /api/apps/{app_id}/xhs/publish 小红书发笔记 + /api/apps/{app_id}/xhs/search 小红书搜索 +``` + +> **[现状]** 当前所有路由都是 `/api/*` 无前缀(如 `/api/send/text`、`/api/messages/since`、`/api/db/decrypt`),且全部写死微信逻辑。本阶段目标: +> 1. 新增 `/api/apps/{app_id}/*` 路由层; +> 2. 将旧路由作为 alias 内部转发到 `/api/apps/default/*`; +> 3. `/api/status` 同时返回旧字段(取 default app)与新 `apps` 数组,保证面板零改动。 + +### 6.2 通用路由详述 + +#### GET /api/status + +返回框架整体状态 + 所有实例摘要。 + +```json +{ + "bridge_version": "2.0.0", + "uptime_seconds": 3600, + "display": ":1", + "app_count": 2, + "apps": [ + { + "app_id": "wx_a1", + "kind_id": "wechat", + "display_name": "工作微信", + "status": "running", + "login_state": "logged_in", + "capabilities": ["text_send", "db_read", "login_qr"] + }, + { + "app_id": "xhs_b2", + "kind_id": "xiaohongshu", + "display_name": "小红书主号", + "status": "running", + "login_state": "need_login", + "capabilities": ["publish", "search"] + } + ] +} +``` + +#### GET /api/apps + +列出所有实例。 + +#### POST /api/apps + +注册新实例。 + +```json +// 请求 +{ + "kind_id": "xiaohongshu", + "display_name": "小红书小号", + "auto_start": true +} + +// 响应 +{ + "app_id": "xhs_c3", + "kind_id": "xiaohongshu", + "display_name": "小红书小号", + "data_dir": "/config/apps/xhs_c3", + "status": "registered" +} +``` + +#### GET /api/apps/{app_id} + +单实例详细状态。 + +```json +{ + "app_id": "wx_a1", + "kind_id": "wechat", + "display_name": "工作微信", + "status": "running", + "pid": 1234, + "window_id": "0x380000a", + "login_state": "logged_in", + "capabilities": ["text_send", "db_read", "login_qr", "media"], + "uptime_seconds": 1800, + "db_accessible": true, + "current_wxid": "wxid_abc", + "current_nickname": "张三" +} +``` + +#### POST /api/apps/{app_id}/start|stop|restart + +控制实例生命周期。 + +#### GET /api/kinds + +列出支持的 AppKind。 + +```json +{ + "kinds": [ + { + "kind_id": "wechat", + "name": "微信", + "default_capabilities": ["text_send", "db_read", "login_qr", "media"], + "single_instance": false, + "installed": true + }, + { + "kind_id": "xiaohongshu", + "name": "小红书", + "default_capabilities": ["publish", "search"], + "single_instance": false, + "installed": true + } + ] +} +``` + +### 6.3 应用专属路由规范 + +由各 driver 通过 `register_routes(router)` 挂载,router 自动绑定 `/api/apps/{app_id}` 前缀。 + +**driver 实现示例**: + +```python +# drivers/wechat/routes.py +def register_routes(router: APIRouter) -> None: + @router.get("/messages/since") + async def get_messages(app_id: str, cursor: int = 0, limit: int = 50): + driver = app_manager.get_driver(app_id) + return await driver.get_messages_since(cursor, limit) + + @router.post("/send/text") + async def send_text(app_id: str, req: SendTextRequest): + driver = app_manager.get_driver(app_id) + return await driver.send_text(req.to, req.content) +``` + +### 6.4 错误码扩展 + +新增框架级错误码: + +| 错误码 | HTTP | 说明 | +|--------|------|------| +| `APP_NOT_FOUND` | 404 | app_id 不存在 | +| `APP_NOT_RUNNING` | 503 | 实例未运行 | +| `APP_NOT_LOGGED_IN` | 401 | 实例未登录 | +| `APP_KIND_UNKNOWN` | 400 | 未知应用类型 | +| `APP_KIND_NOT_INSTALLED` | 503 | 应用类型未安装 | +| `APP_ALREADY_EXISTS` | 409 | 实例已存在(single_instance 冲突) | +| `UI_BUSY` | 503 | UIScheduler 队列拥塞 | +| `APP_CAPABILITY_NOT_SUPPORTED` | 501 | 该实例不支持请求的能力 | + +> **[现状]** 当前 [bridge/models.py](../bridge/models.py) 中只有微信中心化错误码,如 `WECHAT_NOT_RUNNING`、`WECHAT_NOT_LOGGED_IN` 等。拆分阶段应: +> 1. 保留旧错误码作为微信 driver 的返回值; +> 2. 新增框架级错误码上表; +> 3. alias 路由命中时仍可使用旧错误码,避免面板/外部系统改动。 + +### 6.5 向后兼容层 + +现有无前缀路由(`/api/messages`、`/api/send/text` 等)保留为 alias,内部转发到 `/api/apps/{default_app_id}/...`。 + +**默认实例选择规则**: + +1. 若容器内只有一个实例,自动作为 default +2. 若多实例,读取 `/config/.woc-default-app`(由面板或首次注册时写入) +3. 都没有则返回 404 + +**alias 路由行为**: + +- 命中 alias 时日志打 `DEPRECATED` warning,提示调用方迁移 +- alias 在 major 版本升级时下线(v3.0.0 移除) + +### 6.6 与既有系统的兼容性约束 + +改造必须尊重以下已验证的硬约束,否则现有微信实例会损坏: + +1. **鉴权**:`/api/bridge/:id/*` 外部调用依赖 `WOC_BRIDGE_API_TOKEN` 作为 Bearer token;修改 `.env` 后必须重建 panel 容器(而非重启)才能生效。 +2. **DB 解密**:微信 DB 仅兼容 Linux WeChat 4.x,使用 SQLCipher 4 参数(AES-256-CBC、PBKDF2-HMAC-SHA512、256000 rounds、mac_salt = salt XOR 0x3A)。解密能力保留在 `drivers/wechat/` 内,不提前抽象。 +3. **ptrace**:自动提取 key 需要容器具备 `SYS_PTRACE` capability 或 `--privileged`,且宿主 `ptrace_scope=0`。 +4. **DB 路径**:自动检测写死 `/config/xwechat_files//db_storage/message/message_0.db`;改为 driver 内部常量,不作为框架公共假设。 +5. **发送定位**:微信 `send_text/send_file` 使用 `display_name`(备注/昵称)而非 wxid 搜索会话,该行为由 `WechatDriver` 封装,不进入框架公共层。 +6. **容器启动**:当前 [docker/autostart](../docker/autostart) 只启动一个应用;bridge 框架不假设自己能启动多个应用,生命周期控制仍由 panel/docker 负责。 + +--- + +## 7. 资源调度与并发 + +### 7.1 X11 资源争用对策 + +| 资源 | 争用场景 | 对策 | +|------|---------|------| +| 窗口焦点 | A 激活窗口时 B 失焦 | 所有 activate/key/click 经 UIScheduler 串行 | +| 剪贴板 | A 写入后 B 未读就被 C 覆盖 | paste 操作是"写剪贴板+立即 Ctrl+V"原子组,经 UIScheduler 串行 | +| 键盘事件 | A 的 Ctrl+V 发到 B 窗口 | activate 后立即操作,UIScheduler 保证原子 | +| 鼠标坐标 | A 的 click 坐标被 B 的 mousemove 改变 | mousemove + click 必须在同一个 UIScheduler 任务内 | + +### 7.2 并发模型 + +``` +HTTP 请求 (asyncio) + ↓ +driver 方法 (async) + ↓ +UIScheduler.submit(coro) ◀── 全局串行点(X11 操作) + ↓ +XdotoolBase._run (asyncio subprocess) +``` + +- HTTP 层并发:FastAPI 默认并发处理请求 +- driver 层并发:每个实例的方法可并发调用 +- UIScheduler 层串行:所有 X11 写操作排队执行 +- DB 读取层并发:不涉及 X11,独立并发(受限于 SQLite 读写锁) + +### 7.3 限流策略 + +| 层级 | 限流 | 配置 | +|------|------|------| +| HTTP 全局 | 单 IP 每秒 N 请求 | `WOC_BRIDGE_MAX_CALLS_PER_SEC` | +| 单实例发送 | 发送类操作最小间隔 | `WOC_BRIDGE_SEND_DELAY_MS`(每实例独立) | +| UIScheduler | 操作间最小间隔 | 固定 100ms(防止 X server 压力) | +| UIScheduler 队列长度 | 最多排队 N 个操作 | 50,超限返回 `UI_BUSY` | + +--- + +## 8. 应用生命周期 + +### 8.1 实例状态机 + +``` + register + ┌─────────────────────────┐ + │ ▼ + ┌────────┐ start ┌─────────┐ running ┌────────┐ + │registered├────────►│starting├────────────►│running │ + └────────┘ └────┬───┘ └───┬────┘ + ▲ │ fail │ stop + │ ▼ ▼ + │ ┌────────┐ ┌────────┐ + │ │crashed │ │stopped │ + │ └───┬────┘ └───┬────┘ + │ │ restart │ start + │ ▼ │ + └──────────────────────────────────────────┘ +``` + +### 8.2 启动流程 + +1. `POST /api/apps` 注册实例 → 状态 `registered` +2. `POST /api/apps/{app_id}/start` → 状态 `starting` +3. driver.install()(首次启动,下载/解压) +4. driver.start()(启动应用进程) +5. 轮询 driver.find_window() 直到窗口出现(超时 60s) +6. 状态 `running` +7. 启动失败 → 状态 `crashed`,记录错误 + +### 8.3 自动启动 + +容器启动时,AppManager 读取 `/config/apps.json`,对所有 `auto_start=true` 的实例按顺序执行启动流程。 + +**顺序约束**:实例间启动间隔 5 秒(避免同时启动多个 GUI 应用导致内存峰值)。 + +### 8.4 健康检查 + +AppManager 后台任务(每 30s): + +1. 遍历所有 `running` 状态实例 +2. 调用 `driver.is_running()` +3. 失败则状态转 `crashed` +4. 记录崩溃次数与时间 +5. 若配置了自动恢复且未超限(默认 3 次/小时),触发 restart + +### 8.5 数据卷布局 + +``` +/config/ + apps.json 实例注册表 + .woc-default-app 默认实例 app_id(兼容层用) + apps/ + wx_a1/ + data/ 应用数据根(driver 自由组织) + wechat/ + opt/wechat/wechat 微信二进制 + xwechat_files/ 微信运行时数据 + logs/ 该实例日志 + state/ 该实例状态文件 + xhs_b2/ + data/ + chromium/ 小红书专用 chromium user-data-dir + Default/ + Cookies 小红书登录 cookie + logs/ + state/ +``` + +--- + +## 9. 应用间通信 + +### 9.1 显式通信(推荐) + +应用之间通过外部调用方(如面板或用户脚本)显式编排: + +``` +# 伪代码:从小红书采集内容,发到微信 +xhs_note = await bridge.get(f"/api/apps/xhs_b2/xhs/search?keyword=foo") +await bridge.post(f"/api/apps/wx_a1/send/text", json={"to":"文件助手","content":xhs_note}) +``` + +**优点**:流程显式、可观测、可调试。 + +**不提供应用间直接调用**:driver 之间不互相感知,避免隐式依赖。 + +### 9.2 事件总线(未来扩展) + +可选的发布订阅机制: + +```python +# driver 发布事件 +await self.ctx.event_bus.publish("message_received", {"app_id": "wx_a1", "msg": {...}}) + +# 外部订阅 +GET /api/events/stream (SSE) +``` + +P0 不实现,留待有明确需求时再做。 + +--- + +## 10. 部署模型 + +### 10.1 单容器多应用 + +``` +容器 woc-app-multi + ├─ bridge (:8088) 管理 2 个实例 + ├─ Xvfb (:1) + ├─ KasmVNC (:3000) + ├─ 微信进程 (wx_a1) + └─ chromium 进程 (xhs_b2) +``` + +适用于:单用户、资源受限(NAS)、应用间需要协同(同一桌面)。 + +### 10.2 多容器单应用(现有模式保留) + +``` +容器 woc-wx- 微信实例 1 +容器 woc-wx- 微信实例 2 +容器 woc-xhs- 小红书实例 +``` + +适用于:多用户隔离、横向扩展、单应用崩溃不影响其他。 + +### 10.3 混合模式 + +面板支持两种模式并存: + +- 轻量场景用单容器多应用(省资源) +- 隔离场景用多容器单应用(强隔离) + +面板层提供创建实例时的"部署位置"选项:新建独立容器 or 加入已有容器的 bridge。 + +--- + +## 11. 演进路径 + +### S0:当前现状 + +- [bridge/server.py](../bridge/server.py) 约 2400 行单文件,全局单例 `_state`。 +- 所有 `/api/*` 路由直接写死微信逻辑,无 `app_id`、无 driver 抽象。 +- [docker/autostart](../docker/autostart) 一容器一应用;panel 通过 Docker API 管理生命周期。 + +### S1:抽 core/ 与 driver 抽象(不改行为,本阶段重点) + +**目标**:把 server.py 拆成 `core/` + `drivers/wechat/`,建立 `AppDriver` 抽象,但保持单应用模式。 + +**改动**: + +- 新建 `core/` 目录,迁移 `config` / `context` / `errors` / `logging` / `models` / `send_queue` / `screenshot`。 +- 抽 `XdotoolBase`(通用 X11 操作)到 `core/xdotool_base.py`。 +- 定义 `drivers/base.py` 的 `AppDriver` 抽象基类。 +- 微信逻辑迁移到 `drivers/wechat/`,`WechatDriver(AppDriver)` 用组合方式持有 `XdotoolBase`。 +- 微信路由迁移到 `drivers/wechat/routes.py`。 +- `main.py` 读 `WOC_APP_TYPE`,只注册一个 driver,创建一个 `AppInstance`(`app_id="default"`)。 + +**验证**:所有现有 API 行为不变,面板无感知;`pytest` 或现有集成测试全部通过。 + +### S2:新路由层 + 向后兼容层 + +**目标**:引入 `/api/apps/{app_id}/*` 路由,旧路由作为 alias 转发。 + +**改动**: + +- 新增 `/api/apps/{app_id}/status`、`/api/apps/{app_id}/send/text` 等路由。 +- 旧路径 `/api/messages`、`/api/send/text` 等转为 alias,转发到 `/api/apps/default/...`。 +- `/api/status` 同时返回旧字段(取 default app)与新 `apps` 数组。 +- 旧字段日志打 `DEPRECATED` warning。 + +**验证**:面板与外部调用方无需改动即可工作;新路径可通过 `/api/apps/default/*` 访问。 + +### S3:UIScheduler 与 SendQueue 正交化 + +**目标**:解决多实例共享 X server 时的焦点/剪贴板冲突。 + +**改动**: + +- 新增 `core/ui_scheduler.py`,所有 X11 写操作经其串行执行。 +- 每个 `AppInstance` 持有独立 `SendQueue`;`UIScheduler` 全局唯一。 +- 单应用模式下行为不变,多实例模式下才体现串行价值。 + +**验证**:高并发调用 `/api/apps/default/send/text` 不再出现剪贴板污染。 + +### S4:AppManager 与单容器多实例(未来扩展) + +**目标**:支持容器内注册多个 `AppInstance`。 + +**改动**: + +- 实现 `core/app_manager.py`、`core/app_instance.py`、`core/app_kind.py`。 +- 新增 `/api/apps` CRUD 路由。 +- `/config/apps.json` 持久化。 +- 需同步改造 [docker/autostart](../docker/autostart) 以支持启动多个应用。 + +**验证**:可在同一容器内通过 API 注册并启动微信 + 小红书两个实例。 + +### S5:新增 Xiaohongshu/Telegram Driver(框架可扩展性验证) + +**目标**:落地非微信应用自动化。 + +**改动**: + +- 实现 `drivers/xiaohongshu/driver.py` + `routes.py`(P0:publish/text、search)。 +- 或实现 `drivers/telegram/driver.py`(优先浏览器/桌面版,若走原生协议则另议)。 + +**验证**:通过 `/api/apps/{app_id}/xhs/publish` 成功发笔记。 + +### S6:面板适配与单容器多应用部署(不在本文档范围) + +**目标**:面板支持多应用管理 UI 与"单容器多应用"部署选项。 + +**改动**(panel 层): + +- 面板新增"应用实例"管理页。 +- 桌面入口支持选择"进入哪个应用窗口"。 +- 实例创建支持"新建独立容器"或"加入已有容器"。 + +### S7:下线兼容层 + +**目标**:移除 alias,完成迁移。 + +**前提**:面板与所有已知适配器已切换到新路径。 + +**改动**: + +- 删除旧路径 alias。 +- `/api/status` 移除旧字段。 +- major 版本升级到 v3.0.0。 + +--- + +## 12. 不做的事 + +### 12.1 完全不做 + +1. **不引入应用间隐式依赖**:driver 之间不互相调用,编排由外部完成。 +2. **不做多容器 bridge 集群**:一个 bridge 进程只管一个容器内的应用,跨容器调度由面板负责。 +3. **不做应用沙箱**:应用间共享 X server 与文件系统(受 Linux 权限控制),不做额外隔离。 +4. **不做 RBAC**:`app_id` 级别的权限控制留给面板层,bridge 层只做 Bearer Token 全局鉴权。 +5. **不改 KasmVNC 串流层**:桌面串流保持现状(全屏共享),不按应用切分串流。 +6. **不做 OCR**:不引入 tesseract,页面识别靠 URL + 坐标启发式。 + +### 12.2 本阶段先不做 + +1. **不抽象 SQLCipher 解密**:解密能力暂留 `drivers/wechat/`,待第二个 SQLCipher 应用出现再抽公共层。 +2. **不实现单容器多应用启动**:[docker/autostart](../docker/autostart) 当前只启动一个应用,改造它需要 panel/docker 同步调整,不在本阶段 bridge 文档范围。 +3. **不新增非微信 driver**:先完成框架抽象与微信 driver 迁移,再落地 Xiaohongshu/Telegram driver。 +4. **不替换全局 `SendQueue`**:S1 保持现有全局队列,S3 再下放到每个 `AppInstance`。 +5. **不引入新依赖**:仍只使用 fastapi / uvicorn / pydantic / pillow / cryptography。 + +--- + +## 13. 关键决策记录 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| 文档定位 | 目标架构 + `[现状]` 注释 | 既描述最终形态,又不掩盖当前代码未落地的事实 | +| 本阶段重点 | 先让 bridge 在"多容器单应用"模式下可工作 | docker autostart 当前只支持一容器一应用;先完成 bridge 框架抽象,再扩展单容器多应用 | +| 文档范围 | 只聚焦 bridge 层 | panel UI、docker 多应用启动机制另行设计 | +| 抽象颗粒度 | 一应用一 driver(粗粒度) | 业务流程差异大,能力复用价值低 | +| 容器内应用数 | 目标为多实例并存,当前先单实例 | 满足"一容器多应用"长期需求,但本阶段保留单实例运行 | +| 路由前缀 | `/api/apps/{app_id}/*` + alias 过渡 | 实例独立寻址 + 向后兼容 | +| XdotoolBase 风格 | 组合(driver 持有 `self.xd`) | 避免多继承混乱 | +| SQLCipher 解密位置 | 留 drivers/wechat/ | YAGNI,等第二个应用再抽 | +| X11 并发控制 | 全局 UIScheduler 串行 | 多应用争用同一 X server 必须串行;当前尚未实现 | +| 应用间通信 | 显式编排(无直接调用) | 流程可观测、可调试 | +| 部署模型 | 单容器多应用 + 多容器单应用并存 | 兼顾资源效率与隔离性;当前仅多容器单应用可用 | diff --git a/doc/优化方案/02-好友自动通过设计方案.md b/doc/优化方案/02-好友自动通过设计方案.md new file mode 100644 index 0000000..865516e --- /dev/null +++ b/doc/优化方案/02-好友自动通过设计方案.md @@ -0,0 +1,1772 @@ +# 微信好友申请自动通过设计方案 + +> **版本**:v1.2(深度审查修正版 · 第二轮) +> **日期**:2026-07-16 +> **范围**:`bridge/woc_bridge` 全链路 +> **目标**:实现好友申请的自动监听、规则匹配、UI 自动通过、DB 校验闭环 +> +> **v1.1 修正摘要**(基于源码逐行验证,共修正 15 项): +> - **[P0] IdemCache 签名误用**:`get(flow_name, to_wxid, content, request_id)` / `set(flow_name, to_wxid, content, value, request_id)` — value 是 set 的第 4 参数 +> - **[P0] WCDB_CT_message_content 列硬编码矛盾**:SELECT 硬编码 + row.keys() 兜底矛盾,改为 PRAGMA 预探测 +> - **[P0] @stranger 假设未验证**:仅注释提及,无代码验证,补充 fallback 策略与环境验证清单 +> - **[P0] BridgeError 未捕获**:`_ensure_decrypted` 抛 `DB_ENCRYPTED`/`DB_NEED_INIT`,仅 catch `sqlite3.Error` 会泄漏 +> - **[P1] lambda 闭包捕获 bug**:循环中 `req` 变量延迟绑定,需默认参数捕获 +> - **[P1] `_enabled_event` 未初始化**:`__init__` 缺少 `asyncio.Event` 创建 +> - **[P1] `AcceptRuleEngine` 缺 `get_config` 方法**:API 路由调用但未定义 +> - **[P1] `FriendRequestWatcher` 缺状态属性**:`is_running`/`is_enabled`/`processed_count` 等未定义 +> - **[P1] `_parse_list` 未定义**:config.py 中不存在此函数 +> - **[P1] `models/__init__.py` 导出遗漏**:新增 6 个模型需追加导出 +> - **[P2] `_click` vs `_click_at`**:统一用 `_click_at`(链式命令更高效) +> - **[P2] 游标缺复合 tie-breaker**:`create_time >` 改为 `(create_time, local_id)` 复合游标 +> - **[P2] `verify_friend_accepted` 双查询优化**:合并为单条 SQL +> - **[P2] `local_type=4` 未识别**:陌生人被误判为 person,影响 `get_contacts` 结果 +> - **[P2] `get_contacts` 未过滤 @stranger**:陌生人混入联系人列表 +> +> **v1.2 修正摘要**(第二轮深度审查,新增 9 项修正): +> - **[P0] `_poll_once` 复合游标调用缺失**:原 `get_friend_requests_since, self._cursor, limit=50` 缺 `cursor_local_id`,且单一 `_cursor` 无法支撑复合游标 → 改为 `_cursor_create_time` + `_cursor_local_id` 双属性 +> - **[P0] `_poll_once` 返回字段名错误**:原 `result["next_cursor"]` 与 3.3.1 节返回的 `next_create_time`/`next_local_id` 不一致 +> - **[P0] `_poll_once` 缺解析步骤**:原 `requests: list[FriendRequestInfo] = result["requests"]` 类型错误,DB 返回 list[dict],需经 `parse_friend_request` 解析 +> - **[P0] `_init_cursor` 未设置 `_cursor_inited`**:导致 `_watch_loop` 每轮都重新初始化游标,无法推进 +> - **[P0] `list_friend_requests` 路由参数错误**:原 `get_friend_requests_since, 0, limit` 把 limit 当成 cursor_local_id → 改为 `0, 0, limit` +> - **[P1] `_handle_request` 缺状态统计更新**:`_processed_count`/`_accepted_count`/`_rejected_count`/`_last_processed_time` 未更新 +> - **[P1] `_handle_request` 异常透传不全**:原统一 `except Exception` 吞掉 BridgeError 错误码 → 拆分 `except BridgeError` 透传 +> - **[P1] lifespan 异常捕获冗余**:`except (asyncio.TimeoutError, Exception)` 中 Exception 已涵盖 TimeoutError → 统一为 `except Exception` +> - **[P1] lifespan 启动顺序未说明**:补充现有启动/停止顺序,明确 friend_watcher 在 message_streamer 之后启动、之前停止 + +--- + +## 1. 背景与现状 + +### 1.1 现有能力缺口 + +| 能力 | 现状 | 文件位置 | +|------|------|----------| +| 主动添加好友 | `POST /api/friends/add`,xdotool UI 自动化,experimental | [routes/contacts.py:257](../bridge/woc_bridge/routes/contacts.py#L257) | +| 修改好友备注 | `POST /api/contacts/{wxid}/remark`,experimental | [routes/contacts.py:191](../bridge/woc_bridge/routes/contacts.py#L191) | +| **接收/通过好友申请** | **不存在** | — | +| **好友申请监听** | **不存在** | — | + +### 1.2 现有架构可复用基础 + +| 组件 | 能力 | 文件 | +|------|------|------| +| `MessageStreamer` | 双层增量检测(DB mtime + `(create_time, local_id)` 复合游标),1s/5s 自适应轮询,SSE 广播 | [messaging/streamer.py](../bridge/woc_bridge/messaging/streamer.py) | +| `SendQueue` | 单 worker 串行执行,滑动窗口限流,队列满抛 `RATE_LIMITED` | [messaging/send_queue.py](../bridge/woc_bridge/messaging/send_queue.py) | +| `IdemCache` | 幂等去重(namespace + key + content + request_id) | [ui/idem_cache.py](../bridge/woc_bridge/ui/idem_cache.py) | +| `CircuitBreaker` | 熔断器(failure_threshold / recovery_timeout) | [ui/circuit_breaker.py](../bridge/woc_bridge/ui/circuit_breaker.py) | +| `XdotoolDriver` | 窗口激活、坐标计算、`_step` 超时模型(L1-L4)、`add_friend` 参考实现 | [ui/xdotool_driver.py](../bridge/woc_bridge/ui/xdotool_driver.py) | +| `DbReader` | 动态探测表名/列名,`_ensure_decrypted` 解密缓存,`_SYSTEM_WXIDS` 含 `fmessage` | [db/reader.py](../bridge/woc_bridge/db/reader.py) | +| `FlowOrchestrator` | 幂等 + 熔断 + 会话缓存 + 入队编排(仅 `send_text`) | [ui/orchestrator.py](../bridge/woc_bridge/ui/orchestrator.py) | + +### 1.3 微信 4.x Linux 好友申请的存在形式 + +> **验证状态说明**:以下结论基于代码分析推断,部分假设(标注 `[需环境验证]`)需在真实微信 4.x Linux 环境中用 `sqlite3` 直接确认 DB schema 后方可作为实现依据。 + +**来源 A:消息 DB 系统消息**(代码已验证) +- DB:`message/message_0.db` +- 分片表:`Msg_`(表名计算逻辑已验证,[reader.py:677](../bridge/woc_bridge/db/reader.py#L677)、[reader.py:899](../bridge/woc_bridge/db/reader.py#L899)) +- `talker = "fmessage"`(系统账号,已在 `_SYSTEM_WXIDS` 白名单中,[reader.py:1391](../bridge/woc_bridge/db/reader.py#L1391)) +- `local_type = 10000`(系统消息,映射 `render_type="system"`,[reader.py:38-46](../bridge/woc_bridge/db/reader.py#L38)) +- `message_content` 为 XML 格式的 sysmsg `[需环境验证]`:具体 XML 结构(节点名/属性名)需在真实 fmessage 消息上确认,设计方案中的 `...` 是基于微信协议常识的推断 + ```xml + + + + xxx@stranger + 申请人昵称 + 验证消息内容 + ... + + + ``` +- 当前 bridge **不解析**此 XML,`content` 字段原样返回给客户端([reader.py:688-709](../bridge/woc_bridge/db/reader.py#L688) `_decompress_msg_content` 仅做 zstd 解压 + UTF-8 解码) + +**来源 B:联系人 DB**(部分假设 `[需环境验证]`) +- DB:`contact/contact.db` +- 表:`contact` / `contacts` / `rcontact`(动态探测已验证,[reader.py:496-512](../bridge/woc_bridge/db/reader.py#L496)) +- `username` 列名动态探测(候选 `["username", "wxid"]`,[reader.py:1364](../bridge/woc_bridge/db/reader.py#L1364)) +- 申请人以 `username = xxx@stranger` 形式写入 `[需环境验证]`:代码中 `@stranger` 仅出现在注释([reader.py:1389](../bridge/woc_bridge/db/reader.py#L1389)、[reader.py:1416](../bridge/woc_bridge/db/reader.py#L1416)),实际逻辑用 `wxid.split("@", 1)[0]` 取 base 部分,对任意 `@xxx` 后缀都生效。**真实后缀名需在 contact.db 中确认** +- `local_type = 4` `[需环境验证]`:微信协议中通常代表陌生人/未通过验证,但**项目当前未识别**此值(`_infer_contact_type` 仅处理 2 和 512,[reader.py:1421-1425](../bridge/woc_bridge/db/reader.py#L1421)),陌生人会被默认推断为 `person`(fallback),与正常好友混淆 + +**环境验证清单**(实施前必做): +1. 在真实微信 4.x Linux 环境中,用 `sqlite3` 打开 `contact.db`,执行 `SELECT username, local_type FROM contact WHERE local_type NOT IN (1,2,512)` 确认陌生人后缀格式和 local_type 值 +2. 收到一条好友申请后,用 `sqlite3` 打开 `message_0.db`,执行 `SELECT message_content FROM Msg_ ORDER BY create_time DESC LIMIT 1` 确认 XML 结构 +3. 通过好友申请后,再次查 contact.db 确认 `@stranger` 后缀是否消失、`local_type` 是否变化 + +### 1.4 截图 UI 布局分析 + +基于 `ui/profiles/templates/screenshot/` 下的截图: + +| 截图 | 布局描述 | +|------|----------| +| `04-好友申请界面.png` | 左侧"新的朋友"申请列表,每项含头像/昵称/验证消息 + 右侧"前往验证"按钮 | +| `05-好友申请通过界面.png` | "通过朋友验证"弹窗,含申请人信息 + 底部"确定"按钮 | + +**UI 导航路径**:主界面 → 通讯录图标 → 新的朋友 → 列表项"前往验证" → 弹窗"确定" → 完成 + +--- + +## 2. 总体架构 + +### 2.1 全链路数据流 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 监听层(双源) │ +│ │ +│ ┌─────────────────────┐ ┌──────────────────────────────┐ │ +│ │ FriendRequestWatcher │ │ MessageStreamer (已有) │ │ +│ │ (新增后台协程) │ │ _watch_loop → _poll_once │ │ +│ │ │ │ │ │ +│ │ 轮询 message DB: │ │ 新消息事件广播 │ │ +│ │ Msg_ │ │ (talker=fmessage, │ │ +│ │ 增量游标检测 │ │ local_type=10000) │ │ +│ └────────┬────────────┘ └──────────┬───────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ FriendRequestParser (新增) │ │ +│ │ XML sysmsg 解析 → FriendRequestInfo │ │ +│ └─────────────────────────┬───────────────────────────────────┘ │ +└─────────────────────────────┼───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 规则匹配引擎(新增) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐ │ +│ │ 白名单匹配 │ │ 关键词匹配 │ │ 黑名单过滤 │ │ auto_accept│ +│ │ (wxid/昵称) │ │ (验证消息) │ │ (wxid/昵称) │ │ (全局开关) │ +│ └──────┬──────┘ └──────┬───────┘ └──────┬──────┘ └────┬─────┘ │ +│ └────────────────┼─────────────────┼──────────────┘ │ +│ ▼ ▼ │ +│ ┌──────────────┐ │ +│ │ 决策结果 │ │ +│ │ accept/reject │ │ +│ │ /skip │ │ +│ └──────┬───────┘ │ +└──────────────────────────┼────────────────────────────────────────┘ + │ accept + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 去重 + 入队层 │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ IdemCache (已有) │ │ SendQueue (已有) │ │ +│ │ namespace= │ │ 串行执行 │ │ +│ │ "friend_accept" │────▶│ enqueue(lambda: │ │ +│ │ key=stranger_wxid│ │ accept_flow) │ │ +│ │ TTL=300s │ │ │ │ +│ └──────────────────┘ └────────┬─────────┘ │ +└─────────────────────────────────────┼───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ UI 自动化层 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ accept_friend_request (新增于 xdotool_driver.py) │ │ +│ │ │ │ +│ │ 1. 激活窗口 │ │ +│ │ 2. 点击通讯录图标 │ │ +│ │ 3. 点击"新的朋友"入口 │ │ +│ │ 4. 定位目标申请项 → 点击"前往验证" │ │ +│ │ 5. 弹窗 → 点击"确定" │ │ +│ │ 6. Esc 返回主界面 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────┬─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 校验 + 状态层 │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────┐ │ +│ │ DB 校验 (新增) │ │ CircuitBreaker │ │ +│ │ contact 表中 │ │ (已有, 复用) │ │ +│ │ @stranger 消失验证 │ │ accept_verify │ │ +│ └──────────┬───────────┘ │ failure_threshold │ │ +│ │ │ =5, recovery=30s │ │ +│ ▼ └──────────────────┘ │ +│ ┌──────────────────────┐ │ +│ │ 结果记录 │ │ +│ │ (日志 + metrics) │ │ +│ └──────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 分层职责 + +| 层 | 组件 | 职责 | 新增/复用 | +|----|------|------|-----------| +| L0 监听 | `FriendRequestWatcher` | 轮询 message DB 检测 fmessage 新消息 | **新增** | +| L0 解析 | `FriendRequestParser` | 解析 sysmsg XML 提取申请人信息 | **新增** | +| L1 匹配 | `AcceptRuleEngine` | 白名单/关键词/黑名单/auto_accept 规则决策 | **新增** | +| L2 去重 | `IdemCache` | 按 stranger_wxid 去重,TTL=300s | 复用 | +| L3 入队 | `SendQueue` | 串行化 UI 操作,避免 xdotool 命令冲突 | 复用 | +| L4 UI | `XdotoolDriver.accept_friend_request` | 6 步 UI 操作流程 | **新增** | +| L5 校验 | `DbReader.verify_friend_accepted` | contact 表 @stranger 消失验证 | **新增** | +| L6 熔断 | `CircuitBreaker` | UI 操作连续失败保护 | 复用 | +| L7 API | `routes/contacts.py` | 手动查询/通过/配置接口 | **新增路由** | + +--- + +## 3. 详细设计 + +### 3.1 监听层:FriendRequestWatcher + +#### 3.1.1 设计决策:独立 Watcher vs 复用 MessageStreamer + +| 方案 | 优点 | 缺点 | +|------|------|------| +| **A. 复用 MessageStreamer + handler 注册** | 统一轮询,无重复 DB 读取 | MessageStreamer 当前无 handler 注册机制,需改造其架构 | +| **B. 独立 FriendRequestWatcher** | 不侵入现有 streamer,独立游标/频率控制 | 多一个 DB 轮询(但仅查 fmessage 单表,开销极小) | + +**选择方案 B**:独立 Watcher。理由: +1. MessageStreamer 设计为"纯队列广播模型,无 handler 注册"([streamer.py:14-20](../bridge/woc_bridge/messaging/streamer.py#L14)),注入 handler 会破坏其架构纯净性 +2. fmessage 轮询单表 `Msg_` 且有独立游标,SQL 开销 <1ms,不会对 DB 造成压力 +3. 独立 Watcher 可按需启停(auto_accept 关闭时不轮询),不影响消息流 + +#### 3.1.2 数据结构 + +```python +# 新文件:messaging/friend_watcher.py + +@dataclass +class FriendRequestInfo: + """好友申请信息(从 fmessage sysmsg XML 解析)。""" + stranger_wxid: str # 申请人 wxid(含 @stranger 后缀) + nickname: str # 申请人昵称 + verify_message: str # 验证消息内容 + scene: str # 来源场景(如群聊/搜索/二维码) + raw_xml: str # 原始 XML(调试用) + create_time: int # 消息时间戳 + msg_local_id: int # 消息 local_id(游标用) + + +class FriendRequestWatcher: + """好友申请监听器:轮询 fmessage 系统消息,解析后触发自动通过。""" + + def __init__( + self, + db_reader: "DbReader", + rule_engine: "AcceptRuleEngine", + send_queue: "SendQueue", + idem_cache: "IdemCache", + xdotool_driver: "XdotoolDriver", + breaker: "CircuitBreaker", + poll_interval: float = 3.0, # 轮询间隔(秒) + cursor_create_time: int = 0, # 初始游标 create_time(0 = 从最新开始) + cursor_local_id: int = 0, # 初始游标 local_id(tie-breaker) + ) -> None: + self._db_reader = db_reader + self._rule_engine = rule_engine + self._send_queue = send_queue + self._idem_cache = idem_cache + self._xdotool = xdotool_driver + self._breaker = breaker + self._poll_interval = poll_interval + # 复合游标 (create_time, local_id),与 get_friend_requests_since 返回值对齐 + self._cursor_create_time: int = cursor_create_time + self._cursor_local_id: int = cursor_local_id + self._last_db_mtime: float = 0.0 + self._last_wal_mtime: float = 0.0 + self._watcher: Optional[asyncio.Task] = None + self._enabled_event: asyncio.Event = asyncio.Event() # auto_accept 开关事件 + self._cursor_inited: bool = False + + # 状态统计(供 /api/friends/auto_accept/status 查询) + self._processed_count: int = 0 + self._accepted_count: int = 0 + self._rejected_count: int = 0 + self._last_processed_time: Optional[int] = None + + @property + def is_running(self) -> bool: + return self._watcher is not None and not self._watcher.done() + + @property + def is_enabled(self) -> bool: + return self._enabled_event.is_set() + + @property + def processed_count(self) -> int: + return self._processed_count + + @property + def accepted_count(self) -> int: + return self._accepted_count + + @property + def rejected_count(self) -> int: + return self._rejected_count + + @property + def last_processed_time(self) -> Optional[int]: + return self._last_processed_time + + async def start(self) -> None: + if self._watcher is None or self._watcher.done(): + self._watcher = asyncio.create_task(self._watch_loop()) + logger.info("FriendRequestWatcher 已启动") + + async def stop(self) -> None: + if self._watcher is not None and not self._watcher.done(): + self._watcher.cancel() + try: + await self._watcher + except asyncio.CancelledError: + pass + self._watcher = None + + def set_enabled(self, enabled: bool) -> None: + if enabled: + # 开启时重置游标,避免批量处理积压申请 + self._cursor_inited = False + self._enabled_event.set() + else: + self._enabled_event.clear() + logger.info("FriendRequestWatcher enabled=%s", enabled) +``` + +#### 3.1.3 轮询逻辑 + +复用 MessageStreamer 的**双层增量检测**模式: + +```python +async def _poll_once(self) -> None: + # 1. mtime 感知(一级过滤,避免空 SQL) + mtimes = await asyncio.to_thread( + self._db_reader.get_db_mtime, "message/message_0.db" + ) + if mtimes is None: + return + db_mtime, wal_mtime = mtimes + if db_mtime == self._last_db_mtime and wal_mtime == self._last_wal_mtime: + return + self._last_db_mtime = db_mtime + self._last_wal_mtime = wal_mtime + + # 2. 查询 fmessage 分片表增量消息(复合游标) + result = await asyncio.to_thread( + self._db_reader.get_friend_requests_since, + self._cursor_create_time, + self._cursor_local_id, + 50, # limit + ) + if result is None: + # DB 不可读(_ensure_decrypted 抛 BridgeError) + return + + raw_items: list[dict] = result["requests"] + + # 3. 解析 XML + 逐条处理 + for item in raw_items: + info = parse_friend_request( + item["content"], item["create_time"], item["local_id"] + ) + if info is None: + # 非 verifyUser 类型或解析失败,跳过 + continue + await self._handle_request(info) + + # 4. 推进复合游标(与 get_friend_requests_since 返回字段对齐) + self._cursor_create_time = result["next_create_time"] + self._cursor_local_id = result["next_local_id"] +``` + +> **修正说明**: +> - 原代码 `get_friend_requests_since, self._cursor, limit=50` 缺少 `cursor_local_id`,且单一 `self._cursor` 无法支撑复合游标 +> - 原代码 `result["next_cursor"]` 字段名错误,3.3.1 节返回的是 `next_create_time` / `next_local_id` +> - 原代码 `requests: list[FriendRequestInfo] = result["requests"]` 类型错误,DB 返回的是 list[dict],需经 `parse_friend_request` 解析才得到 `FriendRequestInfo` +> - 增加 `parse_friend_request` 解析步骤,过滤非 verifyUser 类型的 fmessage 消息 + +#### 3.1.4 游标初始化策略 + +| 启动时机 | 游标对齐策略 | 理由 | +|----------|-------------|------| +| Bridge 首次启动 | `cursor_create_time = get_max_create_time_for_talker("fmessage")`,`cursor_local_id = 0` | 跳过历史申请,仅处理启动后的新申请 | +| auto_accept 从关闭切换为开启 | 同上(`set_enabled` 重置 `_cursor_inited=False`) | 避免开启瞬间批量处理积压申请 | +| auto_accept 持续运行 | 持续推进复合游标,不重置 | 正常增量处理 | + +```python +async def _init_cursor(self) -> bool: + """对齐游标到当前 fmessage 表最大 create_time。 + + Returns: + True 表示已对齐(或 DB 不可读但标记为已初始化,避免无限重试) + False 表示异常,下一轮重试 + """ + try: + max_ct = await asyncio.to_thread( + self._db_reader.get_max_create_time_for_talker, "fmessage" + ) + if max_ct is None: + # DB 不可读(_ensure_decrypted 抛 BridgeError 已被吞掉返回 None) + # 不标记 _cursor_inited,下一轮重试 + logger.warning("FriendRequestWatcher 游标初始化: DB 不可读,将在下轮重试") + return False + if max_ct and max_ct > 0: + self._cursor_create_time = max_ct + self._cursor_local_id = 0 + logger.info( + "FriendRequestWatcher 游标对齐到 create_time=%d(跳过历史申请)", + self._cursor_create_time, + ) + # 无论 max_ct 是否为 0(空表),都标记为已初始化,避免空表时无限重试 + self._cursor_inited = True + return True + except Exception as e: + logger.warning("FriendRequestWatcher 游标初始化失败: %s", e) + return False +``` + +> **修正说明**: +> - 原代码未设置 `self._cursor_inited = True`,会导致 `_watch_loop` 每轮都重新初始化游标,无法推进 +> - `get_max_create_time_for_talker` 返回 `None`(DB 不可读)时不标记已初始化,下一轮重试;返回 `0`(空表)时标记已初始化 +> - 复合游标:`_cursor_create_time` 对齐到 max,`_cursor_local_id` 重置为 0(同秒内的历史消息不处理) + +#### 3.1.5 轮询间隔 + +| 状态 | 间隔 | 理由 | +|------|------|------| +| auto_accept 开启 | 3s | 好友申请低频事件,3s 足够及时且无 DB 压力 | +| auto_accept 关闭 | 不轮询 | Watcher 协程暂停在 `asyncio.Event` 上 | +| DB_ENCRYPTED | 3s 重试 `_init_cursor` | DB 不可读时 `_cursor_inited` 保持 False,每轮重试初始化(`_init_cursor` 内部 catch BridgeError,不会抛异常风暴) | +| 异常 | 5s 退避 | 避免 `_poll_once` 抛未预期异常时风暴 | + +```python +async def _watch_loop(self) -> None: + while True: + try: + # auto_accept 关闭时挂起,避免空轮询 + await self._enabled_event.wait() + + # 游标未初始化时先对齐(DB 恢复可读后自动补齐) + # DB_ENCRYPTED 时 _init_cursor 返回 False,下一轮仍会重试 + if not self._cursor_inited: + await self._init_cursor() + if not self._cursor_inited: + # DB 仍不可读,本轮跳过 _poll_once + await asyncio.sleep(self._poll_interval) + continue + + await asyncio.sleep(self._poll_interval) + await self._poll_once() + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("FriendRequestWatcher 异常: %s", e) + await asyncio.sleep(5.0) +``` + +> **修正说明**: +> - 原表格 "DB_ENCRYPTED 10s 退避" 与代码实现不一致(代码无 10s 退避逻辑)→ 改为 3s 重试 `_init_cursor` +> - `_watch_loop` 增加 `if not self._cursor_inited: continue` 跳过 `_poll_once`,避免 DB 不可读时无效查询 +> - `_init_cursor` 返回 False 时(DB 不可读),`_cursor_inited` 保持 False,下一轮重试 + +--- + +### 3.2 解析层:FriendRequestParser + +#### 3.2.1 XML 解析 + +fmessage 系统消息的 `message_content` 是 XML 字符串。解析逻辑独立为无状态函数,便于单测: + +```python +# 新文件:messaging/friend_parser.py + +import xml.etree.ElementTree as ET +import re + +def parse_friend_request(content: str, create_time: int, msg_local_id: int) -> Optional[FriendRequestInfo]: + """解析 fmessage 系统消息 XML,提取好友申请信息。 + + Args: + content: message_content 原始文本(XML) + create_time: 消息时间戳 + msg_local_id: 消息 local_id + + Returns: + FriendRequestInfo 或 None(解析失败 / 非 verifyUser 类型) + """ + if not content or not content.strip(): + return None + + try: + root = ET.fromstring(content) + except ET.ParseError: + # 部分消息可能不是合法 XML(如纯文本通知),跳过 + return None + + # 检查是否为 verifyUser 类型 sysmsg + msg_type = root.get("type", "") + if msg_type != "verifyUser": + return None + + # 提取 Link 节点中的申请人信息 + link = root.find(".//Link") + if link is None: + return None + + stranger_wxid = _text(link, "UserName") + nickname = _text(link, "NickName") + verify_message = _text(link, "Content") + scene = _text(link, "Scene") + + if not stranger_wxid: + return None + + return FriendRequestInfo( + stranger_wxid=stranger_wxid, + nickname=nickname, + verify_message=verify_message, + scene=scene, + raw_xml=content, + create_time=create_time, + msg_local_id=msg_local_id, + ) + + +def _text(parent: ET.Element, tag: str) -> str: + el = parent.find(tag) + return el.text.strip() if el is not None and el.text else "" +``` + +#### 3.2.2 容错策略 + +| 异常 | 处理 | +|------|------| +| XML 解析失败 | 返回 None,记 warning 日志,不中断轮询 | +| 非 verifyUser 类型 | 返回 None(fmessage 也承载好友通过/拒绝通知,需过滤) | +| UserName 缺失 | 返回 None(无法定位申请人) | +| NickName 缺失 | 空字符串兜底(不影响通过操作) | +| Content 含非 UTF-8 字符 | `errors="replace"` 兜底(已在 `_decompress_msg_content` 处理) | + +--- + +### 3.3 DB 读取层:DbReader 新增方法 + +#### 3.3.1 get_friend_requests_since + +```python +# 在 db/reader.py 中新增 + +def get_friend_requests_since( + self, cursor_create_time: int, cursor_local_id: int = 0, limit: int = 50 +) -> Optional[dict]: + """查询 fmessage 会话中指定游标之后的好友申请消息。 + + 直接查 Msg_ 单表,避免遍历所有分片。 + 复合游标 (create_time, local_id) 作为 tie-breaker,避免同秒消息丢失。 + + Args: + cursor_create_time: 上次处理到的 create_time(0 = 从最新开始) + cursor_local_id: 同 create_time 下已处理的 local_id(tie-breaker) + limit: 单次读取上限 + + Returns: + {"requests": list[dict], "next_create_time": int, "next_local_id": int} + 或 None(DB 不可读) + + Notes: + - _ensure_decrypted 可能抛 BridgeError(DB_ENCRYPTED / DB_NEED_INIT), + 调用方需捕获 BridgeError 而非仅 sqlite3.Error + - WCDB_CT_message_content 列可能不存在(非 WCDB 表), + 用 PRAGMA 预探测而非硬编码 SELECT + """ + empty = { + "requests": [], + "next_create_time": cursor_create_time, + "next_local_id": cursor_local_id, + } + try: + db_path = self._ensure_decrypted("message/message_0.db") + except BridgeError: + # DB 加密/无 key:返回 None 让调用方知道 DB 不可读 + return None + + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + try: + # 计算 fmessage 分片表名 + table_name = f"Msg_{hashlib.md5(b'fmessage').hexdigest()}" + # 验证表存在 + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ) + if cur.fetchone() is None: + return empty + + # 动态探测 WCDB_CT_message_content 列是否存在 + cols = self._table_columns(conn, table_name) + has_ct_col = "WCDB_CT_message_content" in cols + ct_col = "WCDB_CT_message_content" if has_ct_col else "0 AS WCDB_CT_message_content" + + # 复合游标查询(与 get_messages_since 一致的 tie-breaker) + sql = ( + f"SELECT local_id, create_time, message_content, {ct_col} " + f"FROM [{table_name}] " + f"WHERE (create_time > ? OR (create_time = ? AND local_id > ?)) " + f"ORDER BY create_time ASC, local_id ASC LIMIT ?" + ) + rows = conn.execute( + sql, (cursor_create_time, cursor_create_time, cursor_local_id, limit) + ).fetchall() + + requests = [] + next_ct = cursor_create_time + next_lid = cursor_local_id + for row in rows: + content = self._decompress_msg_content( + row["message_content"], + row["WCDB_CT_message_content"], + ) + requests.append({ + "local_id": row["local_id"], + "create_time": row["create_time"], + "content": content, + }) + next_ct = row["create_time"] + next_lid = row["local_id"] + + return { + "requests": requests, + "next_create_time": next_ct, + "next_local_id": next_lid, + } + except sqlite3.Error: + return empty + finally: + conn.close() +``` + +#### 3.3.2 verify_friend_accepted + +```python +# 在 db/reader.py 中新增 + +def verify_friend_accepted(self, stranger_wxid: str) -> bool: + """校验好友申请是否已通过:contact 表中 @stranger 后缀消失。 + + 通过前:username = "wxid_xxx@stranger"([需环境验证] 后缀名) + 通过后:username = "wxid_xxx"(后缀被移除,local_type 可能变为 1) + + 策略:用 base_wxid 查 contact 表,若存在 base_wxid 记录且不存在 + base_wxid@stranger 记录,则判定已通过。合并为单条 SQL 避免 2 次查询。 + + Args: + stranger_wxid: 含 @ 后缀的 wxid + + Returns: + True 表示已通过,False 表示仍待验证或 DB 不可读 + + Notes: + - _ensure_decrypted 抛 BridgeError 时返回 False(不影响 UI 操作结果) + - @stranger 后缀是假设,若真实后缀不同需调整 SQL LIKE 模式 + """ + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return False + + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + try: + table = self._find_contact_table(conn) + if table is None: + return False + + # 动态探测 username 列名(候选: username / wxid) + cols = self._table_columns(conn, table) + username_col = self._pick_column(cols, ["username", "wxid"]) + if username_col is None: + return False + + base_wxid = stranger_wxid.split("@")[0] + + # 单条 SQL:base_wxid 存在且不含 @ 的记录数 > 0 + # 同时检查是否有 @stranger 后缀的记录残留 + sql = ( + f"SELECT " + f" SUM(CASE WHEN {username_col} = ? THEN 1 ELSE 0 END) AS base_cnt, " + f" SUM(CASE WHEN {username_col} LIKE ? THEN 1 ELSE 0 END) AS stranger_cnt " + f"FROM {table}" + ) + cur = conn.execute(sql, (base_wxid, f"{base_wxid}@%")) + row = cur.fetchone() + if row is None: + return False + base_cnt = row["base_cnt"] or 0 + stranger_cnt = row["stranger_cnt"] or 0 + # base_wxid 存在(已变为好友)且无 @stranger 残留 + return base_cnt > 0 and stranger_cnt == 0 + except sqlite3.Error: + return False + finally: + conn.close() +``` + +#### 3.3.3 get_max_create_time_for_talker + +```python +# 在 db/reader.py 中新增 + +def get_max_create_time_for_talker(self, talker: str) -> Optional[int]: + """获取指定会话的消息最大 create_time(游标初始化用)。 + + 直接查 Msg_ 单表,避免遍历所有分片。 + 返回 None 表示 DB 不可读(与 get_max_create_time 语义一致), + 返回 0 表示表为空或不存在。 + """ + try: + db_path = self._ensure_decrypted("message/message_0.db") + except BridgeError: + return None + + conn = sqlite3.connect(db_path, isolation_level=None) + try: + table_name = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}" + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ) + if cur.fetchone() is None: + return 0 + cur = conn.execute(f"SELECT MAX(create_time) FROM [{table_name}]") + row = cur.fetchone() + return int(row[0]) if row and row[0] else 0 + except sqlite3.Error: + return 0 + finally: + conn.close() +``` + +--- + +### 3.4 规则匹配引擎:AcceptRuleEngine + +#### 3.4.1 配置模型 + +```python +# 在 models/contact.py 中新增 + +class AcceptRuleConfig(BaseModel): + """自动通过规则配置。""" + + enabled: bool = Field(default=False, description="全局开关") + accept_all: bool = Field(default=False, description="通过所有申请(忽略以下规则)") + + # 白名单:匹配任一规则即通过 + whitelist_wxids: list[str] = Field( + default_factory=list, description="白名单 wxid 列表" + ) + whitelist_nicknames: list[str] = Field( + default_factory=list, description="白名单昵称列表(精确匹配)" + ) + + # 关键词:验证消息含任一关键词即通过 + keywords: list[str] = Field( + default_factory=list, description="验证消息关键词列表(子串匹配)" + ) + + # 黑名单:匹配任一规则即拒绝(优先级高于白名单和关键词) + blacklist_wxids: list[str] = Field( + default_factory=list, description="黑名单 wxid 列表" + ) + blacklist_nicknames: list[str] = Field( + default_factory=list, description="黑名单昵称列表" + ) + + # 场景过滤:仅通过指定来源场景的申请 + allow_scenes: list[str] = Field( + default_factory=list, description="允许的场景(空=不限制)" + ) + + +class AcceptDecision(enum.Enum): + """规则匹配决策结果。""" + ACCEPT = "accept" + REJECT = "reject" + SKIP = "skip" # 不匹配任何规则,跳过 + + +class AcceptRuleEngine: + """好友申请自动通过规则引擎。""" + + def __init__(self, config: AcceptRuleConfig) -> None: + self._config = config + self._lock = asyncio.Lock() # 配置热更新保护 + + async def get_config(self) -> AcceptRuleConfig: + """获取当前规则配置(供 API 查询)。""" + async with self._lock: + return self._config + + async def evaluate(self, req: FriendRequestInfo) -> AcceptDecision: + async with self._lock: + cfg = self._config + + if not cfg.enabled: + return AcceptDecision.SKIP + + if cfg.accept_all: + return AcceptDecision.ACCEPT + + # 黑名单优先 + if req.stranger_wxid in cfg.blacklist_wxids: + return AcceptDecision.REJECT + if req.nickname in cfg.blacklist_nicknames: + return AcceptDecision.REJECT + + # 场景过滤 + if cfg.allow_scenes and req.scene not in cfg.allow_scenes: + return AcceptDecision.SKIP + + # 白名单 + if req.stranger_wxid in cfg.whitelist_wxids: + return AcceptDecision.ACCEPT + if req.nickname in cfg.whitelist_nicknames: + return AcceptDecision.ACCEPT + + # 关键词 + if cfg.keywords: + for kw in cfg.keywords: + if kw in req.verify_message: + return AcceptDecision.ACCEPT + + # 不匹配任何规则 + return AcceptDecision.SKIP + + async def update_config(self, config: AcceptRuleConfig) -> None: + async with self._lock: + self._config = config +``` + +#### 3.4.2 匹配优先级 + +``` +黑名单 (wxid / 昵称) ──→ REJECT(最高优先级) + │ 不命中 + ▼ +场景过滤 (allow_scenes) ──→ SKIP(不在允许场景内) + │ 通过 + ▼ +白名单 (wxid / 昵称) ──→ ACCEPT + │ 不命中 + ▼ +关键词 (验证消息子串) ──→ ACCEPT + │ 不命中 + ▼ +默认 ──→ SKIP(不处理,等待人工介入) +``` + +--- + +### 3.5 UI 自动化层:accept_friend_request + +#### 3.5.1 实现方案 + +在 `xdotool_driver.py` 中新增 `accept_friend_request` 方法,复用现有的 `_step` 超时模型和窗口几何计算: + +```python +# 在 ui/xdotool_driver.py 中新增 + +# 通讯录图标和新的朋友入口的几何常量(基于 1920x1080,坐标为估算值需实测校准) +_CONTACT_ICON_X_RATIO = 0.04 # 通讯录图标 X 比例(左侧导航栏第 2 个图标) +_CONTACT_ICON_Y_RATIO = 0.15 # 通讯录图标 Y 比例 +_NEW_FRIEND_ENTRY_Y_RATIO = 0.22 # "新的朋友"入口 Y 比例(通讯录页顶部) +_VERIFY_BUTTON_X_OFFSET = -80 # "前往验证"按钮距右侧偏移(向左为负) +_VERIFY_BUTTON_Y_RATIO = 0.30 # 第一条申请项"前往验证"按钮 Y 比例 +_CONFIRM_BUTTON_Y_OFFSET = -60 # "确定"按钮距底部偏移(向上为负) + +async def accept_friend_request( + self, + stranger_wxid: str = "", + nickname: str = "", + timeout_sec: float = 25.0, +) -> bool: + """通过好友申请(experimental)。 + + UI 路径(基于截图 04/05): + 1. 激活微信窗口 + 2. 点击左侧导航栏"通讯录"图标 + 3. 点击"新的朋友"入口 + 4. 定位目标申请项(按昵称匹配,空则取第一条) + 5. 点击该项右侧"前往验证"按钮 + 6. 在弹窗中点击"确定" + 7. Esc 返回主界面 + + Args: + stranger_wxid: 申请人 wxid(用于日志,当前版本不参与 UI 定位) + nickname: 申请人昵称(用于在列表中匹配定位,空则取第一条) + timeout_sec: 整体超时 + + Returns: + True 表示 UI 操作流程执行完成 + + Raises: + BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) + + Notes: + - 使用 _click_at(链式命令)而非 _click(两条命令),减少子进程创建 + - 当前版本仅点击列表第一条申请项,昵称匹配待后续实现 + - 所有坐标均为估算值,需在目标分辨率实测调优 + """ + deadline = time.monotonic() + timeout_sec + + async def _step(coro: Awaitable[None], desc: str) -> None: + remaining = deadline - time.monotonic() + if remaining <= 0: + if asyncio.iscoroutine(coro): + coro.close() + raise BridgeError( + code="SEND_FAILED", + message=f"通过好友申请超时: {desc}", + ) + try: + await asyncio.wait_for( + coro, timeout=max(_STEP_MIN_TIMEOUT_SEC, remaining) + ) + except asyncio.TimeoutError as exc: + raise BridgeError( + code="SEND_FAILED", + message=f"通过好友申请步骤超时: {desc}", + ) from exc + + # 1. 激活窗口 + window_id = await self.find_wechat_window() + if window_id is None: + raise BridgeError( + code="WINDOW_NOT_FOUND", + message="未找到微信窗口,无法通过好友申请", + ) + await _step(self._activate_window_fast(window_id), "激活窗口") + await _step(self._sleep(0.3), "等待窗口激活") + + # 关闭可能存在的弹窗 + await _step(self._key("Escape"), "关闭弹窗") + await _step(self._sleep(0.2), "等待 Esc 生效") + + # 2. 获取窗口几何 + win_x, win_y, win_w, win_h = await self._get_window_geometry() + + # 3. 点击通讯录图标(左侧导航栏第 2 个) + contact_x = win_x + int(win_w * _CONTACT_ICON_X_RATIO) + contact_y = win_y + int(win_h * _CONTACT_ICON_Y_RATIO) + await _step(self._click_at(contact_x, contact_y, "通讯录图标"), "点击通讯录图标") + await _step(self._sleep(0.8), "等待通讯录页面加载") + + # 4. 点击"新的朋友"入口 + new_friend_y = win_y + int(win_h * _NEW_FRIEND_ENTRY_Y_RATIO) + await _step(self._click_at(contact_x, new_friend_y, "新的朋友"), "点击新的朋友") + await _step(self._sleep(0.8), "等待新的朋友列表加载") + + # 5. 定位并点击目标申请项的"前往验证"按钮 + # 当前版本取第一条申请项(昵称匹配待后续实现) + verify_x = win_x + win_w + _VERIFY_BUTTON_X_OFFSET # 负偏移 = 向左 + verify_y = win_y + int(win_h * _VERIFY_BUTTON_Y_RATIO) + await _step(self._click_at(verify_x, verify_y, "前往验证"), "点击前往验证") + await _step(self._sleep(1.0), "等待验证弹窗打开") + + # 6. 点击弹窗"确定"按钮 + confirm_x = win_x + win_w // 2 + confirm_y = win_y + win_h + _CONFIRM_BUTTON_Y_OFFSET # 负偏移 = 向上 + await _step(self._click_at(confirm_x, confirm_y, "确定"), "点击确定") + await _step(self._sleep(0.8), "等待通过操作完成") + + # 7. 返回主界面 + await _step(self._key("Escape"), "返回主界面") + await _step(self._sleep(0.3), "等待返回") + + logger.info( + "accept_friend_request: wxid=%s nickname=%s → UI 操作完成", + stranger_wxid, nickname, + ) + return True +``` + +#### 3.5.2 Profile 扩展 + +在 `ui/profiles/` YAML 中新增好友申请相关元素定位: + +```yaml +# 追加到 default.yaml / wechat_4.0_1280x720.yaml / wechat_4.0_1920x1080.yaml + +contact_icon: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.15 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 左侧导航栏通讯录图标 + +new_friend_entry: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.22 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 通讯录页"新的朋友"入口 + +verify_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 1.0 + y_ratio: 0.30 + x_offset: -80 + y_offset: 0 + threshold: 0.75 + require_image: false + description: 好友申请项"前往验证"按钮 + +confirm_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 0.5 + y_ratio: 1.0 + x_offset: 0 + y_offset: -60 + threshold: 0.75 + require_image: false + description: "通过朋友验证"弹窗确定按钮 +``` + +--- + +### 3.6 去重与入队 + +#### 3.6.1 去重策略 + +使用 `IdemCache` 按 `stranger_wxid` 去重,防止同一申请被重复处理。 + +> **关键接口签名**([idem_cache.py:61-67](../bridge/woc_bridge/ui/idem_cache.py#L61)、[idem_cache.py:98-105](../bridge/woc_bridge/ui/idem_cache.py#L98)): +> ```python +> def get(self, flow_name: str, to_wxid: str, content: str, client_request_id: str = "") -> Optional[Any] +> def set(self, flow_name: str, to_wxid: str, content: str, value: Any, client_request_id: str = "") -> None +> ``` +> **注意**:`set` 的 `value` 在 `client_request_id` **之前**,不能按位置传 `client_request_id`,否则会被当成 `value`。 +> +> **SendQueue.enqueue 是延迟执行**([send_queue.py:65-94](../bridge/woc_bridge/messaging/send_queue.py#L65)): +> `coro_factory` 入队后由 worker 协程 `_run` 调用 `await coro_factory()`([send_queue.py:145](../bridge/woc_bridge/messaging/send_queue.py#L145))。 +> `enqueue` 的 `await` 实际等待的是 `future`(worker 设置结果),不是 `coro_factory()` 本身。 +> 因此循环中创建的 lambda 若直接捕获循环变量,会在 worker 真正执行时拿到被覆盖的值 —— 必须用默认参数捕获。 + +```python +async def _handle_request(self, req: FriendRequestInfo) -> None: + self._processed_count += 1 + self._last_processed_time = req.create_time + + # 1. 规则匹配 + decision = await self._rule_engine.evaluate(req) + if decision == AcceptDecision.REJECT: + self._rejected_count += 1 + logger.info( + "friend_request: wxid=%s nickname=%s → decision=%s(拒绝)", + req.stranger_wxid, req.nickname, decision.value, + ) + return + if decision != AcceptDecision.ACCEPT: + logger.info( + "friend_request: wxid=%s nickname=%s → decision=%s(跳过)", + req.stranger_wxid, req.nickname, decision.value, + ) + return + + # 2. 幂等去重(TTL=300s,5 分钟内不重复处理同一申请人) + # content 参数传空串(去重 key 是 stranger_wxid,无内容维度) + cached = self._idem_cache.get( + "friend_accept", req.stranger_wxid, "", "" + ) + if cached is not None: + logger.info( + "friend_request: wxid=%s → 幂等命中,跳过", + req.stranger_wxid, + ) + return + + # 3. 熔断检查 + if not self._breaker.allow(): + logger.warning( + "friend_request: wxid=%s → 熔断器 OPEN,跳过", + req.stranger_wxid, + ) + return + + # 4. 入队执行(lambda 用默认参数捕获 req,避免延迟执行时变量被覆盖) + try: + result = await self._send_queue.enqueue( + lambda req=req: self._xdotool.accept_friend_request( + stranger_wxid=req.stranger_wxid, + nickname=req.nickname, + ), + delay_ms=None, # 使用 send_queue 默认间隔 + ) + + # 5. 等待微信 DB WAL 刷盘后校验(微信 DB 写入有 1-3s 延迟) + await asyncio.sleep(2.0) + verified = await asyncio.to_thread( + self._db_reader.verify_friend_accepted, req.stranger_wxid + ) + + if verified: + # set 签名: (flow_name, to_wxid, content, value, client_request_id) + self._idem_cache.set( + "friend_accept", req.stranger_wxid, "", + {"success": True, "verified": True}, "", + ) + self._breaker.record_success() + self._accepted_count += 1 + logger.info( + "friend_request: wxid=%s nickname=%s → 通过并验证成功", + req.stranger_wxid, req.nickname, + ) + else: + # UI 操作完成但 DB 校验未通过(可能有延迟) + self._idem_cache.set( + "friend_accept", req.stranger_wxid, "", + {"success": True, "verified": False}, "", + ) + self._breaker.record_failure() + logger.warning( + "friend_request: wxid=%s → UI 操作完成但 DB 校验未通过", + req.stranger_wxid, + ) + except BridgeError as e: + # 透传 BridgeError 错误码(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等) + self._breaker.record_failure() + logger.error( + "friend_request: wxid=%s → BridgeError code=%s: %s", + req.stranger_wxid, getattr(e, "code", "UNKNOWN"), e, + ) + except Exception as e: + self._breaker.record_failure() + logger.error( + "friend_request: wxid=%s → 失败: %s", + req.stranger_wxid, e, + ) +``` + +#### 3.6.2 DB 校验延迟 + +微信 DB 写入有 WAL 延迟(通常 1-3 秒)。DB 校验应在 UI 操作完成后等待 2 秒再查(已整合到 3.6.1 的 `_handle_request` 中,见上方 `await asyncio.sleep(2.0)`)。 + +> **注意**:`SendQueue.enqueue` 是 `await future` 语义 —— `enqueue` 返回时 UI 操作已完成(或抛错)。因此 `sleep(2.0)` 放在 `enqueue` 之后即可,无需在 lambda 内部 sleep。 + +--- + +### 3.7 API 路由层 + +#### 3.7.1 新增路由 + +```python +# 在 routes/contacts.py 中新增 + +@router.get("/api/friends/requests", response_model=FriendRequestsResponse) +@with_db_retry +async def list_friend_requests(limit: int = 50) -> FriendRequestsResponse: + """查询待处理的好友申请列表(从 fmessage 系统消息解析)。 + + Args: + limit: 1~200,默认 50 + + Returns: + FriendRequestsResponse:含 requests 列表 / total + """ + if limit < 1 or limit > 200: + raise BridgeError( + code="INVALID_PARAMS", + message=f"limit 必须在 1~200 之间,收到 {limit}", + ) + db_reader = _require_db_reader() + await _check_db_readable() + + # get_friend_requests_since(cursor_create_time, cursor_local_id, limit) + # 查询全部待处理申请:cursor_create_time=0, cursor_local_id=0 + result = await asyncio.to_thread( + db_reader.get_friend_requests_since, 0, 0, limit + ) + if result is None: + # DB 不可读(_ensure_decrypted 抛 BridgeError 已被 _check_db_readable 拦截, + # 此处兜底防御) + return FriendRequestsResponse(requests=[], total=0) + + # 解析 XML 提取结构化信息 + requests = [] + for item in result.get("requests", []): + info = parse_friend_request( + item["content"], item["create_time"], item["local_id"] + ) + if info is not None: + requests.append(FriendRequestItem( + stranger_wxid=info.stranger_wxid, + nickname=info.nickname, + verify_message=info.verify_message, + scene=info.scene, + create_time=info.create_time, + )) + return FriendRequestsResponse(requests=requests, total=len(requests)) + + +@router.post("/api/friends/accept", response_model=AcceptFriendResponse) +async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse: + """手动通过好友申请(experimental)。 + + Args: + req: AcceptFriendRequest + + Returns: + AcceptFriendResponse + """ + if not req.stranger_wxid: + raise BridgeError(code="INVALID_PARAMS", message="stranger_wxid 不能为空") + + xdotool = _require_xdotool() + send_queue = _require_send_queue() + + login_state = await xdotool.detect_login_state() + if login_state != "logged_in": + raise BridgeError( + code="WECHAT_NOT_LOGGED_IN", + message=f"当前登录态为 {login_state},无法通过好友申请", + ) + + try: + # lambda 用默认参数捕获 req(SendQueue.enqueue 延迟执行,见 3.6.1 说明) + await send_queue.enqueue( + lambda req=req: xdotool.accept_friend_request( + stranger_wxid=req.stranger_wxid, + nickname=req.nickname or "", + ) + ) + except BridgeError: + # 透传 BridgeError(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等) + raise + except Exception as e: + raise BridgeError(code="SEND_FAILED", message=f"通过好友申请失败: {e}") + + logger.info( + "friends/accept: wxid=%s nickname=%s → UI 操作完成", + req.stranger_wxid, req.nickname, + ) + return AcceptFriendResponse(success=True, error=None) + + +@router.get("/api/friends/auto_accept/config", response_model=AcceptRuleConfig) +async def get_auto_accept_config() -> AcceptRuleConfig: + """查询自动通过规则配置。""" + rule_engine = _require_rule_engine() + return await rule_engine.get_config() + + +@router.put("/api/friends/auto_accept/config", response_model=AcceptRuleConfig) +async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfig: + """更新自动通过规则配置(热生效)。""" + rule_engine = _require_rule_engine() + await rule_engine.update_config(config) + # 同步更新 watcher 的 enabled 状态 + watcher = _require_friend_watcher() + watcher.set_enabled(config.enabled) + return config + + +@router.get("/api/friends/auto_accept/status", response_model=AutoAcceptStatus) +async def get_auto_accept_status() -> AutoAcceptStatus: + """查询自动通过运行状态。""" + watcher = _require_friend_watcher() + return AutoAcceptStatus( + running=watcher.is_running, + enabled=watcher.is_enabled, + processed_count=watcher.processed_count, + accepted_count=watcher.accepted_count, + rejected_count=watcher.rejected_count, + last_processed_time=watcher.last_processed_time, + ) +``` + +#### 3.7.2 新增模型 + +```python +# 在 models/contact.py 中新增 + +class FriendRequestItem(BaseModel): + """好友申请条目。""" + stranger_wxid: str + nickname: str = "" + verify_message: str = "" + scene: str = "" + create_time: int + + +class FriendRequestsResponse(BaseModel): + """好友申请列表响应。""" + requests: list[FriendRequestItem] = Field(default_factory=list) + total: int = 0 + + +class AcceptFriendRequest(BaseModel): + """手动通过好友申请请求。""" + stranger_wxid: str = Field(description="申请人 wxid(含 @stranger 后缀)") + nickname: Optional[str] = Field(default=None, description="申请人昵称(UI 定位用)") + + +class AcceptFriendResponse(BaseModel): + """通过好友申请响应。""" + success: bool = Field(default=False) + error: Optional[str] = Field(default=None) + + +class AutoAcceptStatus(BaseModel): + """自动通过运行状态。""" + running: bool + enabled: bool + processed_count: int = 0 + accepted_count: int = 0 + rejected_count: int = 0 + last_processed_time: Optional[int] = None +``` + +--- + +### 3.8 配置层 + +#### 3.8.1 环境变量 + +```bash +# .env 新增 + +# 自动通过全局开关(false=关闭,true=开启) +WOC_AUTO_ACCEPT_ENABLED=false + +# 通过所有申请(忽略规则,危险!仅测试用) +WOC_AUTO_ACCEPT_ALL=false + +# 白名单 wxid(逗号分隔) +WOC_AUTO_ACCEPT_WHITELIST_WXIDS= + +# 白名单昵称(逗号分隔) +WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES= + +# 验证消息关键词(逗号分隔) +WOC_AUTO_ACCEPT_KEYWORDS= + +# 黑名单 wxid(逗号分隔) +WOC_AUTO_ACCEPT_BLACKLIST_WXIDS= + +# 黑名单昵称(逗号分隔) +WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES= + +# 允许场景(逗号分隔,空=不限制) +WOC_AUTO_ACCEPT_ALLOW_SCENES= + +# 轮询间隔(秒) +WOC_AUTO_ACCEPT_POLL_INTERVAL=3 +``` + +#### 3.8.2 BridgeConfig 扩展 + +```python +# 在 config.py BridgeConfig 中新增字段 + +@dataclass +class BridgeConfig: + # ... 现有字段 ... + + # 好友自动通过配置 + auto_accept_enabled: bool = False + auto_accept_all: bool = False + auto_accept_whitelist_wxids: list[str] = field(default_factory=list) + auto_accept_whitelist_nicknames: list[str] = field(default_factory=list) + auto_accept_keywords: list[str] = field(default_factory=list) + auto_accept_blacklist_wxids: list[str] = field(default_factory=list) + auto_accept_blacklist_nicknames: list[str] = field(default_factory=list) + auto_accept_allow_scenes: list[str] = field(default_factory=list) + auto_accept_poll_interval: float = 3.0 + + @classmethod + def from_args_and_env(cls, args): + return cls( + # ... 现有字段 ... + + auto_accept_enabled=os.environ.get( + "WOC_AUTO_ACCEPT_ENABLED", "false" + ).lower() in ("true", "1", "yes", "on"), + auto_accept_all=os.environ.get( + "WOC_AUTO_ACCEPT_ALL", "false" + ).lower() in ("true", "1", "yes", "on"), + auto_accept_whitelist_wxids=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_WHITELIST_WXIDS", "") + ), + auto_accept_whitelist_nicknames=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES", "") + ), + auto_accept_keywords=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_KEYWORDS", "") + ), + auto_accept_blacklist_wxids=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_BLACKLIST_WXIDS", "") + ), + auto_accept_blacklist_nicknames=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES", "") + ), + auto_accept_allow_scenes=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_ALLOW_SCENES", "") + ), + auto_accept_poll_interval=float( + os.environ.get("WOC_AUTO_ACCEPT_POLL_INTERVAL", "3") + ), + ) + + +def _parse_list(s: str) -> list[str]: + """逗号分隔字符串转列表。""" + if not s.strip(): + return [] + return [item.strip() for item in s.split(",") if item.strip()] +``` + +--- + +### 3.9 生命周期与初始化 + +#### 3.9.1 AppState 扩展 + +```python +# 在 config.py AppState 中新增字段 + +class AppState: + # ... 现有字段 ... + + # 好友自动通过组件 + friend_watcher: Optional[Any] = None # FriendRequestWatcher + rule_engine: Optional[Any] = None # AcceptRuleEngine + accept_breaker: Optional[Any] = None # CircuitBreaker +``` + +#### 3.9.2 初始化序列 + +```python +# 在 app.py _init_state 中新增 + +def _init_state(config: BridgeConfig) -> None: + # ... 现有初始化 ... + + # 好友自动通过规则引擎 + accept_config = AcceptRuleConfig( + enabled=config.auto_accept_enabled, + accept_all=config.auto_accept_all, + whitelist_wxids=config.auto_accept_whitelist_wxids, + whitelist_nicknames=config.auto_accept_whitelist_nicknames, + keywords=config.auto_accept_keywords, + blacklist_wxids=config.auto_accept_blacklist_wxids, + blacklist_nicknames=config.auto_accept_blacklist_nicknames, + allow_scenes=config.auto_accept_allow_scenes, + ) + _state.rule_engine = AcceptRuleEngine(accept_config) + + # 熔断器 + _state.accept_breaker = CircuitBreaker( + "accept_verify", failure_threshold=5, recovery_timeout=60.0, + ) + + # 好友申请监听器 + _state.friend_watcher = FriendRequestWatcher( + db_reader=_state.db_reader, + rule_engine=_state.rule_engine, + send_queue=_state.send_queue, + idem_cache=_state.idem_cache, + xdotool_driver=_state.xdotool, + breaker=_state.accept_breaker, + poll_interval=config.auto_accept_poll_interval, + ) +``` + +#### 3.9.3 lifespan 启停 + +> **现有 lifespan 启动顺序**([app.py:64-129](../bridge/woc_bridge/app.py#L64)): +> `send_queue.start()` → `message_streamer.start()` → `watchdog.start()` → `resource_reaper.start()` → `xdotool._full_cleanup_on_startup()` → `yield` +> +> **停止顺序**(反序):`message_streamer.stop()` → `send_queue.stop()` → `watchdog.stop()` → `resource_reaper.stop()`,每个均包 `asyncio.wait_for(..., timeout=5.0)`。 +> +> FriendRequestWatcher 应在 `message_streamer.start()` **之后**启动(不依赖 streamer,但保持"基础设施先于业务"顺序),在 `message_streamer.stop()` **之前**停止(先停业务再停基础设施)。 + +```python +# 在 app.py lifespan 中新增 + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # ... 现有启动(send_queue / message_streamer / watchdog / resource_reaper / xdotool cleanup)... + + # 启动好友申请监听器(仅当 auto_accept 开启时) + # 放在 message_streamer 之后,保持"基础设施先于业务"顺序 + if _state.friend_watcher is not None and _state.config.auto_accept_enabled: + try: + await _state.friend_watcher.start() + except Exception as e: + logger.warning("[lifespan] friend_watcher.start() 失败: %s", e) + + try: + yield + finally: + # 先停 friend_watcher(业务),再停基础设施 + if _state.friend_watcher is not None: + try: + await asyncio.wait_for( + _state.friend_watcher.stop(), timeout=5.0 + ) + except Exception: + # Exception 已涵盖 TimeoutError,无需单独列 + logger.warning("[lifespan] friend_watcher.stop() 超时或异常") + + # ... 现有停止(message_streamer / send_queue / watchdog / resource_reaper)... +``` + +> **修正说明**: +> - 原 `except (asyncio.TimeoutError, Exception)` 冗余 —— `Exception` 已涵盖 `TimeoutError`,统一为 `except Exception` +> - 启动时增加异常捕获,避免 friend_watcher 启动失败阻塞 lifespan +> - 启动顺序:在 `message_streamer.start()` 之后(friend_watcher 不依赖 streamer,但保持基础设施优先) +> - 停止顺序:在 `message_streamer.stop()` 之前(先停业务再停基础设施) + +#### 3.9.4 依赖检查函数 + +```python +# 在 config.py 中新增 + +def _require_rule_engine() -> AcceptRuleEngine: + if _state.rule_engine is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="rule_engine 未初始化", + ) + return _state.rule_engine + + +def _require_friend_watcher() -> FriendRequestWatcher: + if _state.friend_watcher is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="friend_watcher 未初始化", + ) + return _state.friend_watcher +``` + +#### 3.9.5 模型导出 + +> **当前 `models/__init__.py`**([models/__init__.py:68-87](../bridge/woc_bridge/models/__init__.py#L68))共导出 36 个名字,**不包含**任何好友申请相关模型。新增的 6 个模型必须追加到 `__all__` 列表,否则 `from woc_bridge.models import AcceptRuleConfig` 等导入会失败。 + +```python +# 在 models/__init__.py 中新增导入和导出 + +from woc_bridge.models.contact import ( # 假设新增模型定义在 contact.py + AcceptRuleConfig, + AcceptDecision, + FriendRequestItem, + FriendRequestsResponse, + AcceptFriendRequest, + AcceptFriendResponse, + AutoAcceptStatus, +) + +# 追加到 __all__ 列表(按字母序插入联系人域之后) +__all__ = [ + # ... 现有 36 个名字 ... + # 好友自动通过域 + "AcceptRuleConfig", + "AcceptDecision", + "FriendRequestItem", + "FriendRequestsResponse", + "AcceptFriendRequest", + "AcceptFriendResponse", + "AutoAcceptStatus", +] +``` + +> **注意**: +> - `AcceptDecision` 是枚举,若 API 不直接返回可不导出(但路由内部会用到,建议导出) +> - 模型定义文件选择:优先追加到现有 `models/contact.py`(与好友/联系人语义一致),避免新建文件 +> - `routes/contacts.py` 现有 import 风格是 `from woc_bridge.models import (...)`,新增模型需在同一 import 语句中追加 + +--- + +## 4. 关键设计决策 + +### 4.1 为什么不修改 MessageStreamer + +| 考量 | 说明 | +|------|------| +| 架构纯净性 | MessageStreamer 设计为"纯队列广播模型"([streamer.py:14-20](../bridge/woc_bridge/messaging/streamer.py#L14)),注入 handler 会破坏其设计 | +| 职责单一 | Streamer 负责消息分发,不负责业务逻辑(如 XML 解析、规则匹配) | +| 独立游标 | fmessage 查询单表,开销极小(<1ms),独立游标避免与消息流游标耦合 | +| 独立频率 | 好友申请低频事件,3s 轮询足够;消息流需要 1s 高频 | +| 独立启停 | auto_accept 关闭时 Watcher 挂起,不影响消息流 | + +### 4.2 为什么用 DB 轮询而非 UI 监听 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| **DB 轮询(选择)** | 精确、可靠、不依赖 UI 状态、可解析结构化信息 | 有 WAL 延迟(1-3s) | +| UI 轮询 | 实时性好 | 微信 4.x 自绘 UI 无法检测"新的朋友"角标变化;UI 状态不可靠 | + +### 4.3 为什么 UI 操作走 SendQueue + +- **避免 xdotool 命令冲突**:所有 UI 操作(发消息、通过好友、发朋友圈)共享一个微信窗口,必须串行 +- **限流保护**:SendQueue 的滑动窗口限流防止高频操作触发微信风控 +- **队列满保护**:`RATE_LIMITED` 错误让调用方知道系统繁忙,可稍后重试 + +### 4.4 为什么 DB 校验而非 UI 截图校验 + +- **可靠性**:DB 是微信真实状态,UI 截图受分辨率/主题/动画影响 +- **开销**:DB 查询单条记录 <1ms,截图 + OpenCV 匹配 100-300ms +- **已有基础设施**:`_ensure_decrypted` + `sqlite3` 已有完整解密缓存机制 + +### 4.5 熔断器参数 + +| 参数 | 值 | 理由 | +|------|-----|------| +| `failure_threshold` | 5 | 连续 5 次 UI 操作失败(可能是微信未登录/窗口异常),停止自动通过 | +| `recovery_timeout` | 60s | 1 分钟后尝试恢复(比 send_text 的 30s 更长,因为好友通过低频) | + +--- + +## 5. 异常处理矩阵 + +| 异常场景 | 处理策略 | 错误码 | +|----------|---------|--------| +| DB 加密 | Watcher 暂停,10s 后重试,等待 key 提取 | — | +| DB 不可读 | Watcher 暂停,5s 后重试 | — | +| XML 解析失败 | 跳过该消息,记 warning,不中断轮询 | — | +| 非 verifyUser 类型 | 跳过(fmessage 也承载通过/拒绝通知) | — | +| 规则不匹配 | 跳过,记 info 日志 | — | +| 幂等命中 | 跳过,记 info 日志 | — | +| 熔断器 OPEN | 跳过,记 warning | — | +| SendQueue 满 | 抛 `RATE_LIMITED`,Watcher 记 error,下一轮重试 | `RATE_LIMITED` | +| UI 操作超时 | 抛 `SEND_FAILED`,熔断器记录失败 | `SEND_FAILED` | +| UI 操作失败 | 抛 `SEND_FAILED`,熔断器记录失败 | `SEND_FAILED` | +| DB 校验未通过 | UI 成功但校验失败,熔断器记录失败,记 warning | — | +| 微信未登录 | `detect_login_state` 检测,抛 `WECHAT_NOT_LOGGED_IN` | `WECHAT_NOT_LOGGED_IN` | +| 窗口未找到 | 抛 `WINDOW_NOT_FOUND` | `WINDOW_NOT_FOUND` | +| Watcher 协程异常 | 记 exception 日志,5s 后继续轮询 | — | + +--- + +## 6. 安全考虑 + +### 6.1 防止恶意批量加好友 + +- **SendQueue 限流**:`max_calls_per_sec=10`,`send_delay_ms=3000`,即每 3 秒最多通过 1 个好友 +- **IdemCache 去重**:同一 `stranger_wxid` 5 分钟内不重复处理 +- **熔断器**:连续 5 次失败暂停 60 秒 + +### 6.2 规则配置安全 + +- `accept_all=true` 是危险配置,仅用于测试环境 +- 黑名单优先级最高,防止恶意用户反复申请 +- API 修改配置需 Bearer Token 认证(已有 `WOC_BRIDGE_API_TOKEN` 机制) + +### 6.3 日志审计 + +所有自动通过操作记录完整日志: +``` +friend_request: wxid=xxx@stranger nickname=张三 → decision=accept +friend_request: wxid=xxx@stranger nickname=张三 → UI 操作完成 +friend_request: wxid=xxx@stranger nickname=张三 → 通过并验证成功 +``` + +--- + +## 7. 测试策略 + +### 7.1 单元测试 + +| 模块 | 测试重点 | +|------|---------| +| `friend_parser.py` | XML 解析各种格式(正常/缺失字段/非 verifyUser/非法 XML) | +| `AcceptRuleEngine` | 白名单/关键词/黑名单/场景过滤的优先级与组合 | +| `DbReader.get_friend_requests_since` | 游标推进、空表、表不存在 | +| `DbReader.verify_friend_accepted` | @stranger 存在/消失、base_wxid 存在/不存在 | + +### 7.2 集成测试 + +| 场景 | 验证点 | +|------|--------| +| 正常自动通过 | fmessage 消息 → 规则匹配 → UI 操作 → DB 校验 → 幂等缓存 | +| 规则不匹配 | fmessage 消息 → 规则匹配 → SKIP → 不执行 UI 操作 | +| 重复申请 | 同一 stranger_wxid 第二次 → 幂等命中 → 跳过 | +| 熔断触发 | 连续 5 次 UI 失败 → 熔断器 OPEN → 后续申请跳过 | +| auto_accept 开关切换 | 关闭 → 开启 → 游标重置 → 处理新申请 | + +### 7.3 UI 测试 + +需在真实微信 4.x Linux 环境验证: +- 通讯录图标坐标准确性 +- "新的朋友"入口坐标准确性 +- "前往验证"按钮坐标准确性 +- "确定"按钮坐标准确性 +- 多条申请时列表滚动定位 + +--- + +## 8. 文件变更清单 + +| 文件 | 变更类型 | 说明 | +|------|---------|------| +| `messaging/friend_watcher.py` | **新增** | FriendRequestWatcher 后台监听器 | +| `messaging/friend_parser.py` | **新增** | XML sysmsg 解析器 | +| `db/reader.py` | 修改 | 新增 3 个方法:`get_friend_requests_since`、`verify_friend_accepted`、`get_max_create_time_for_talker` | +| `ui/xdotool_driver.py` | 修改 | 新增 `accept_friend_request` 方法 + UI 常量 | +| `ui/circuit_breaker.py` | 不变 | 复用现有 CircuitBreaker | +| `models/contact.py` | 修改 | 新增 6 个模型:`FriendRequestItem`、`FriendRequestsResponse`、`AcceptFriendRequest`、`AcceptFriendResponse`、`AcceptRuleConfig`、`AutoAcceptStatus`(+ `AcceptDecision` 枚举) | +| `models/__init__.py` | 修改 | 追加 7 个名字到 `__all__`:`AcceptRuleConfig`、`AcceptDecision`、`FriendRequestItem`、`FriendRequestsResponse`、`AcceptFriendRequest`、`AcceptFriendResponse`、`AutoAcceptStatus`(详见 3.9.5) | +| `routes/contacts.py` | 修改 | 新增 5 个路由:`GET /api/friends/requests`、`POST /api/friends/accept`、`GET/PUT /api/friends/auto_accept/config`、`GET /api/friends/auto_accept/status`;import 追加新模型 + `_require_rule_engine` + `_require_friend_watcher` + `parse_friend_request` | +| `config.py` | 修改 | BridgeConfig 新增 10 个字段,AppState 新增 3 个字段,新增 `_parse_list` 函数 + 2 个 `_require_*` 函数 | +| `app.py` | 修改 | `_init_state` 新增组件初始化(`rule_engine` / `accept_breaker` / `friend_watcher`),lifespan 新增 Watcher 启停(在 `message_streamer` 之后启动、之前停止) | +| `ui/profiles/default.yaml` | 修改 | 新增 4 个元素定位(contact_icon / new_friend_entry / verify_button / confirm_button) | +| `ui/profiles/wechat_4.0_1280x720.yaml` | 修改 | 同上 | +| `ui/profiles/wechat_4.0_1920x1080.yaml` | 修改 | 同上 | + +--- + +## 9. API 接口汇总 + +| 方法 | 路径 | 说明 | 认证 | +|------|------|------|------| +| GET | `/api/friends/requests` | 查询待处理好友申请列表 | Bearer Token | +| POST | `/api/friends/accept` | 手动通过好友申请 | Bearer Token | +| GET | `/api/friends/auto_accept/config` | 查询自动通过规则配置 | Bearer Token | +| PUT | `/api/friends/auto_accept/config` | 更新自动通过规则配置(热生效) | Bearer Token | +| GET | `/api/friends/auto_accept/status` | 查询自动通过运行状态 | Bearer Token | + +--- + +## 10. 后续演进 + +### 10.1 P3 架构迁移 + +当前方案中 `accept_friend_request` 直接在 `xdotool_driver.py` 中实现(P1 模式)。后续可按 `send_text` 的 P3 迁移路径重构: + +1. 新增 `AcceptFriendFlow`(继承 `Flow`,自定义 `AcceptFlowState` 枚举) +2. `FlowOrchestrator` 新增 `accept_friend` 编排方法(幂等 + 熔断 + 入队) +3. `WeChatCapabilities` 新增 `accept_friend` 能力 API + +### 10.2 好友申请拒绝 + +当前方案仅实现自动通过(ACCEPT),后续可扩展自动拒绝(REJECT): +- `xdotool_driver.py` 新增 `reject_friend_request` 方法 +- 规则引擎 `AcceptDecision.REJECT` 时入队执行拒绝操作 + +### 10.3 消息通知 + +自动通过成功后,可通过 MessageStreamer 的 SSE 通道推送 `friend_accepted` 事件给客户端,实现实时通知。 + +### 10.4 统计与监控 + +在 `ui/metrics.py` 中新增 Prometheus 指标: +- `woc_friend_requests_total{decision="accept|reject|skip"}` +- `woc_friend_accept_success_total` +- `woc_friend_accept_failure_total{reason="ui_timeout|db_verify_failed|..."}` diff --git a/doc/优化方案/03-微信UI自动化架构优化方案.md b/doc/优化方案/03-微信UI自动化架构优化方案.md new file mode 100644 index 0000000..1851649 --- /dev/null +++ b/doc/优化方案/03-微信UI自动化架构优化方案.md @@ -0,0 +1,2560 @@ +# 微信 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__` 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 ` 精确匹配本实例进程的窗口 +- `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 +: + 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_` 表 | +| 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 diff --git a/doc/优化方案/04-WechatOnCloud-改造需求方案.md b/doc/优化方案/04-WechatOnCloud-改造需求方案.md new file mode 100644 index 0000000..5a60456 --- /dev/null +++ b/doc/优化方案/04-WechatOnCloud-改造需求方案.md @@ -0,0 +1,1094 @@ +# WechatOnCloud 改造需求方案 + +> 版本:v2.0 +> 日期:2026-07-05 +> 状态:方案待评审 +> 变更:基于 channels 渠道插件协议深度调研,重新设计 API 契约,新增两条改造路径对比 + +## 一、背景与目标 + +### 1.1 现状 + +[WechatOnCloud](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main) 是一个容器化的「服务端微信」项目,核心能力是: + +- 在 Docker 容器里运行官方 Linux 原版微信(`/config/wechat/opt/wechat/wechat`) +- 通过 Xvfb 虚拟显示 + KasmVNC 把桌面画面串流到浏览器 +- 面板(panel)作为唯一对外入口,管理多实例、子账号权限、反向代理 +- **不修改微信客户端**,依靠 xdotool/xclip 已具备 UI 自动化基础设施 + +但 WechatOnCloud 目前**只暴露了 KasmVNC 的桌面串流(3000 端口)**,没有提供任何业务 API。外部系统无法通过 HTTP 调用「发消息 / 读消息 / 查联系人」等能力。 + +### 1.2 目标 + +改造 WechatOnCloud,使其在保留现有桌面串流能力的基础上,**原生提供业务 HTTP API**,供 ForcePilot channels 模块集成,实现个人微信的自动收发消息闭环。 + +### 1.3 核心诉求 + +| 能力 | 优先级 | channels 对接的 adapter | 说明 | +|---|---|---|---| +| 发送文本消息 | P0 | OutboundAdapter | 通过 xdotool 操作微信窗口发送文本 | +| 拉取增量消息 | P0 | PullerAdapter | 通过读取微信本地 DB 获取新消息 | +| 查询实例状态 | P0 | ProbeableAdapter / StatusAdapter | 微信窗口状态、登录态、bridge 服务健康 | +| 扫码登录 | P0 | LoginAdapter | 复用 KasmVNC 桌面串流 + 截图二维码 | +| 联系人/群聊查询 | P1 | SessionAdapter / DirectoryAdapter | 通过 DB 查询 | +| 发送图片/文件 | P1 | OutboundAdapter | 通过 xdotool 模拟拖拽 | +| 下载媒体文件 | P1 | InboundAdapter.downloadAttachment | 读取微信媒体文件并返回 | +| 诊断 | P1 | DoctorAdapter | 连通性、登录态、DB 可达性诊断 | +| 多账号管理 | P2 | LifecycleAdapter | 一个面板管理多个实例 | + +### 1.4 非目标 + +- **不修改微信客户端本身**(保持「官方原版」的核心原则) +- **不实现微信私有协议**(不做协议逆向) +- **不替代 KasmVNC 桌面串流**(保留人工查看/扫码登录入口) +- **不直接对接 channels**(channels 侧插件开发是独立项目) + +--- + +## 二、channels 渠道插件协议调研结论 + +### 2.1 channels 插件需要实现的 Adapter + +channels 一个完整渠道插件需实现以下 adapter(参考 [wechat_ilink](file:///d:/ForcePilot-v1.1/backend/package/yuxi/channels/plugins/wechat_ilink/) 参考实现): + +| Adapter | 必选性 | 核心方法 | 对 WechatOnCloud 的 API 需求 | +|---|---|---|---| +| **PullerAdapter** | 必选 | `poll(account_id, cursor)` / `getPollingConfig()` | 增量消息拉取接口 | +| **InboundAdapter** | 必选 | `normalizeInbound(raw_event)` / `verifySignature` / `downloadAttachment` | 消息格式归一化(由插件层做,bridge 只提供原始数据) | +| **OutboundAdapter** | 必选 | `sendMessage(account_id, peer_id, payload)` / `formatOutbound` | 发送消息接口 | +| **SessionAdapter** | 必选 | `parseSession(raw_event)` / `getPeerId` / `getChatType` | 联系人/会话查询接口 | +| **StatusAdapter** | 必选 | `classifyEvent(raw_event)` / `extractStatusPayload` | 状态事件分类(由插件层做) | +| **LoginAdapter** | 必选 | `loginWithQrStart` / `loginWithQrWait` / `logout` | 二维码获取 + 扫码状态轮询 | +| **LifecycleAdapter** | 必选 | `resolveAccountId` / `applyAccountConfig` / `afterAccountConfigWritten` | 账户生命周期管理 | +| **ProbeableAdapter** | 必选 | `probe()` | 探活接口 | +| **DoctorAdapter** | 必选 | `checkConnectivity` / `getDiagnosticItems` / `runItem` / `autoFix` | 诊断接口 | +| **WizardAdapter** | 必选 | `getWizardSteps` / `validateStep` / `applyStep` | 配置向导(由插件层做) | +| **WhitelistAdapter** | 可选 | `getWhitelist` / `addToWhitelist` / `removeFromWhitelist` | 白名单(存储在 channels 侧,无需 bridge) | + +### 2.2 channels 关键 DTO 字段 + +bridge 返回的数据需能映射到以下 DTO: + +#### PollResult(PullerAdapter.poll 返回值) + +```python +@dataclass(frozen=True) +class PollResult: + messages: tuple[dict[str, Any], ...] # 原始消息列表 + next_cursor: str | None # 下次轮询的游标 + error: TransportError | None # 错误信息 +``` + +#### MessageContent(InboundAdapter.normalizeInbound 返回值) + +```python +@dataclass(frozen=True) +class MessageContent: + text: str + format: MessageFormat = MessageFormat.TEXT # TEXT / MARKDOWN / RICH + attachments: tuple[Attachment, ...] = () + metadata: dict[str, Any] | None = None + +@dataclass(frozen=True) +class Attachment: + type: Literal["image", "file", "audio", "video"] + url: str + mime_type: str | None = None + size: int | None = None + filename: str | None = None + width: int | None = None + height: int | None = None + duration_ms: int | None = None +``` + +#### SessionInfo(SessionAdapter.parseSession 返回值) + +```python +@dataclass(frozen=True) +class SessionInfo: + channel_type: ChannelType + account_id: str + peer_id: str # 聊天对象 wxid + chat_type: Literal["p2p", "group"] + group_id: str | None = None # 群聊 ID + topic_id: str | None = None # 话题 ID(群内话题) +``` + +#### QrLoginStartResult / QrLoginWaitResult(LoginAdapter) + +```python +@dataclass(frozen=True) +class QrLoginStartResult: + qr_data_url: str # 二维码图片 data URL + message: str + connected: bool + +@dataclass(frozen=True) +class QrLoginWaitResult: + connected: bool + message: str + qr_data_url: str | None + credentials: dict[str, str] | None # 登录成功后返回的凭据 +``` + +### 2.3 ChannelManifest 关键字段 + +参考 [wechat_ilink/manifest.json](file:///d:/ForcePilot-v1.1/backend/package/yuxi/channels/plugins/wechat_ilink/manifest.json),WechatOnCloud 插件的 manifest 需声明: + +| 字段 | wechat_ilink 的值 | WechatOnCloud 建议值 | +|---|---|---| +| `id` | `com.yuxi.channels.wechat_ilink` | `com.yuxi.channels.wechat_woc` | +| `channel_type` | `wechat_ilink` | `wechat_woc` | +| `credential_strategy.type` | `dynamic` | `static` | +| `credential_strategy.acquire_method` | `qr_login` | `manual` | +| `credential_strategy.required_fields` | `["bot_token", "ilink_bot_id", ...]` | `["bridge_url"]` | +| `capabilities.supports_qr_login` | `true` | `true`(通过 KasmVNC 截图) | +| `capabilities.doctor` | `true` | `true` | +| `capabilities.probeable` | `true` | `true` | +| `capabilities.wizard` | `true` | `true` | +| `capabilities.lifecycle` | `true` | `true` | +| `max_message_length` | `4000` | `2000`(个人微信限制) | +| `config_schema` | 12 字段 | 见 §5.2 | + +--- + +## 三、两条改造路径对比 + +### 路径 A:协议兼容(WechatOnCloud 实现 iLink 协议) + +**思路**:让 WechatOnCloud 的 bridge 服务实现与 iLink 完全相同的 8 个 HTTP 端点,wechat_ilink 插件零改动。 + +| 端点 | iLink 协议 | WechatOnCloud bridge 实现 | +|---|---|---| +| `POST /ilink/bot/getupdates` | 长轮询拉取消息 | 读 DB 增量消息,构造相同响应结构 | +| `POST /ilink/bot/sendmessage` | 发送消息 | 调 xdotool 发送,返回 `client_id` | +| `GET /ilink/bot/get_bot_qrcode` | 获取二维码 | 截图微信窗口,返回二维码 data URL | +| `GET /ilink/bot/get_qrcode_status` | 查询扫码状态 | 检测微信登录态 | +| `POST /ilink/bot/getconfig` | 探活 | 返回微信账号信息 | +| `POST /ilink/bot/getuploadurl` | CDN 上传地址 | 本地文件路径直通 | +| CDN 上传端点 | 加密上传 | 本地文件写入 | +| CDN 下载端点 | 加密下载 | 本地文件读取 | + +**优点**: +- channels 侧零改动,复用现有 wechat_ilink 插件 +- 上线最快 + +**缺点**: +- 需逆向 iLink 协议的请求/响应结构、鉴权头、错误码 +- iLink 协议有加密(AES-128-ECB)、防重放头、IDC 重定向等复杂逻辑 +- 协议绑定深,iLink 协议变更时 WechatOnCloud 也需跟着改 +- WechatOnCloud 的能力被 iLink 协议天花板限制 +- **不推荐**:工程量未必更小,且引入协议兼容包袱 + +### 路径 B:原生 API + 新建插件(推荐) + +**思路**:WechatOnCloud 设计自己的原生 RESTful API,channels 侧新建 `wechat_woc` 插件对接。 + +**优点**: +- API 设计自由,贴合 WechatOnCloud 实际能力 +- 不背 iLink 协议包袱 +- channels 插件层职责清晰 +- 长期可维护性好 + +**缺点**: +- 需新增 channels 插件(约 1000-1500 行 Python) +- 上线周期稍长 + +### 推荐结论 + +**采用路径 B**。原因: + +1. iLink 协议的加密、防重放、IDC 重定向等复杂逻辑在 WechatOnCloud 场景下完全无意义 +2. WechatOnCloud 是本地容器,无需 CDN 加密上传下载,直接读本地文件即可 +3. 新增 channels 插件的成本可控,且能获得更干净的设计 +4. 后续 WechatOnCloud 能力扩展(如群聊、媒体)不被 iLink 协议束缚 + +--- + +## 四、改造方案(路径 B) + +### 4.1 总体架构 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ WechatOnCloud 实例容器 (woc-wx-) │ +│ │ +│ s6-rc 服务集 │ +│ ┌────────────────────┐ ┌──────────────────────────────┐ │ +│ │ init / Xvfb │ │ svc-woc-bridge (新增 longrun)│ │ +│ │ openbox autostart │ │ ┌────────────────────────┐ │ │ +│ │ KasmVNC (:3000) │ │ │ woc-bridge (FastAPI) │ │ │ +│ │ wechat (主进程) │ │ │ :8088 │ │ │ +│ └────────────────────┘ │ │ ├─ /api/status │ │ │ +│ ▲ │ │ ├─ /api/messages/* │ │ │ +│ │ X11 :1 │ │ ├─ /api/send/* │ │ │ +│ └────────────────┼──┤ ├─ /api/contacts/* │ │ │ +│ │ │ ├─ /api/media/* │ │ │ +│ │ │ ├─ /api/login/* │ │ │ +│ │ │ └─ /api/diagnostic/* │ │ │ +│ │ └────────────────────────┘ │ │ +│ └──────────────────────────────┘ │ +│ │ +│ /config/.config/xwechat/ ←── 微信 DB │ +└──────────────────────────────────────────────────────────────┘ + ↑ ↑ + │ 3000 (桌面串流) │ 8088 (业务 API) + │ │ +┌──────────────────────────────────────────────────────────────┐ +│ 面板容器 (woc-panel) │ +│ 反代 /desktop/:id/* → :3000 │ +│ 反代 /api/bridge/:id/* → :8088 ← 新增 │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 4.2 改动清单 + +| 文件/目录 | 改动类型 | 说明 | +|---|---|---| +| `bridge/` | **新增** | FastAPI 服务,提供业务 API | +| `bridge/server.py` | 新增 | API 主入口 | +| `bridge/xdotool_driver.py` | 新增 | xdotool 操作封装 | +| `bridge/db_reader.py` | 新增 | 微信 DB 读取 | +| `bridge/qr_capture.py` | 新增 | 微信窗口二维码截图 | +| `bridge/send_queue.py` | 新增 | 串行化发送队列 | +| `bridge/requirements.txt` | 新增 | Python 依赖 | +| `bridge/s6/woc-bridge/run` | 新增 | s6 longrun 启动脚本 | +| `bridge/s6/woc-bridge/type` | 新增 | 内容为 `longrun` | +| `docker/Dockerfile` | **修改** | 安装 Python + 依赖,COPY bridge,注册 s6 服务,EXPOSE 8088 | +| `panel/server/src/docker.ts` | **修改** | `ExposedPorts` 增加 `8088/tcp` | +| `panel/server/src/index.ts` | **修改** | 新增 `/api/bridge/:id/*` 反代路由 | +| `.env.example` | **修改** | 增加 `WOC_BRIDGE_*` 配置项 | + +--- + +## 五、woc-bridge HTTP API 契约 + +### 5.1 通用约定 + +- 所有响应 `Content-Type: application/json`(媒体下载除外) +- 所有时间戳为 Unix 秒(UTC) +- 所有 `wxid` 为字符串 +- 错误响应统一结构: + +```json +{ + "success": false, + "error": { + "code": "WECHAT_NOT_RUNNING", + "message": "微信窗口未找到", + "details": {} + } +} +``` + +### 5.2 状态接口(对应 ProbeableAdapter) + +``` +GET /api/status +``` + +**响应**: + +```json +{ + "bridge_version": "1.0.0", + "wechat_running": true, + "wechat_window_found": true, + "login_state": "logged_in", + "db_accessible": true, + "current_wxid": "wxid_abc123", + "current_nickname": "张三", + "uptime_seconds": 3600, + "display": ":1" +} +``` + +**`login_state` 枚举**:`not_running` / `not_logged_in` / `logging_in` / `logged_in` / `logged_out` + +**对应 channels 调用**:`ProbeableAdapter.probe()`、`DoctorAdapter.checkConnectivity()` + +### 5.3 消息拉取接口(对应 PullerAdapter) + +``` +GET /api/messages/since?cursor=&limit=50 +``` + +**参数**: +- `cursor`:上次拉取的最后一条消息的 `create_time`(Unix 秒),首次传 `0` +- `limit`:单次拉取上限,默认 50,最大 200 + +**响应**: + +```json +{ + "messages": [ + { + "msg_id": "wxid_abc_1700000000_123", + "talker": "wxid_abc123", + "sender": "wxid_sender", + "is_sender": false, + "type": 1, + "render_type": "text", + "content": "你好", + "create_time": 1700000000, + "session_type": "p2p" + } + ], + "next_cursor": "1700000123", + "has_more": false +} +``` + +**消息类型映射**(`type` + `render_type`): + +| 微信 type | render_type | channels MessageFormat | 说明 | +|---|---|---|---| +| 1 | `text` | TEXT | 文本消息 | +| 3 | `image` | TEXT + Attachment(image) | 图片消息 | +| 34 | `voice` | TEXT + Attachment(audio) | 语音消息 | +| 43 | `video` | TEXT + Attachment(video) | 视频消息 | +| 49 | `file` | TEXT + Attachment(file) | 文件消息 | +| 49 | `link` | TEXT | 链接卡片 | +| 49 | `card` | TEXT | 名片 | +| 10002 | `system` | TEXT | 系统消息 | +| 10000 | `system` | TEXT | 系统消息(撤回等) | + +**对应 channels 调用**:`PullerAdapter.poll(account_id, cursor)` → `PollResult` + +### 5.4 发送消息接口(对应 OutboundAdapter) + +#### 5.4.1 发送文本 + +``` +POST /api/send/text +Content-Type: application/json + +{ + "to_wxid": "wxid_abc123", + "content": "你好" +} +``` + +**响应**: + +```json +{ + "success": true, + "channel_msg_id": "local_1700000000_123", + "error": null +} +``` + +#### 5.4.2 发送图片 + +``` +POST /api/send/image +Content-Type: application/json + +{ + "to_wxid": "wxid_abc123", + "file_path": "/config/Desktop/image.jpg" +} +``` + +#### 5.4.3 发送文件 + +``` +POST /api/send/file +Content-Type: application/json + +{ + "to_wxid": "wxid_abc123", + "file_path": "/config/Desktop/doc.pdf" +} +``` + +**对应 channels 调用**:`OutboundAdapter.sendMessage(account_id, peer_id, payload)` → `channel_msg_id` + +### 5.5 联系人接口(对应 SessionAdapter / DirectoryAdapter) + +#### 5.5.1 查询联系人 + +``` +GET /api/contacts?keyword=张三&limit=50 +``` + +**响应**: + +```json +{ + "contacts": [ + { + "wxid": "wxid_abc123", + "nickname": "张三", + "remark": "张三-客户", + "avatar_url": "/api/media/avatar/wxid_abc123", + "type": "friend" + } + ], + "total": 1 +} +``` + +#### 5.5.2 联系人详情 + +``` +GET /api/contacts/{wxid} +``` + +#### 5.5.3 群聊列表 + +``` +GET /api/groups?limit=50 +``` + +#### 5.5.4 群成员 + +``` +GET /api/groups/{wxid}/members +``` + +**响应**: + +```json +{ + "group_wxid": "12345@chatroom", + "members": [ + { + "wxid": "wxid_abc123", + "nickname": "张三", + "display_name": "张三", + "is_admin": false + } + ], + "total": 50 +} +``` + +**对应 channels 调用**:`SessionAdapter.parseSession()`、`DirectoryAdapter.getGroupMembers()` + +### 5.6 媒体接口(对应 InboundAdapter.downloadAttachment) + +``` +GET /api/media/{msg_id} +``` + +返回二进制文件流(`Content-Type` 根据文件类型设置)。 + +**对应 channels 调用**:`InboundAdapter.downloadAttachment(attachment)` → `Attachment` + +### 5.7 登录接口(对应 LoginAdapter) + +#### 5.7.1 获取登录二维码 + +``` +POST /api/login/qr/start +``` + +**响应**: + +```json +{ + "qr_data_url": "data:image/png;base64,iVBOR...", + "message": "请使用微信扫描二维码", + "connected": false +} +``` + +**实现**:bridge 截图微信窗口,裁剪出二维码区域,编码为 base64 data URL。 + +#### 5.7.2 轮询扫码状态 + +``` +GET /api/login/qr/wait?timeout=30 +``` + +**响应**: + +```json +{ + "connected": false, + "message": "等待扫码", + "qr_data_url": null, + "credentials": null +} +``` + +扫码成功时: + +```json +{ + "connected": true, + "message": "登录成功", + "qr_data_url": null, + "credentials": { + "wxid": "wxid_abc123", + "nickname": "张三" + } +} +``` + +**对应 channels 调用**:`LoginAdapter.loginWithQrStart()` / `loginWithQrWait()` + +### 5.8 诊断接口(对应 DoctorAdapter) + +#### 5.8.1 连通性检查 + +``` +GET /api/diagnostic/connectivity +``` + +**响应**: + +```json +{ + "reachable": true, + "latency_ms": 50, + "error": null +} +``` + +#### 5.8.2 诊断项列表 + +``` +GET /api/diagnostic/items +``` + +**响应**: + +```json +{ + "items": [ + { + "check_id": "wechat_running", + "name": "微信进程检查", + "severity": "critical", + "description": "检查微信客户端是否运行", + "auto_repairable": true + }, + { + "check_id": "login_state", + "name": "登录状态检查", + "severity": "critical", + "description": "检查微信是否已登录", + "auto_repairable": false + }, + { + "check_id": "db_accessible", + "name": "数据库可达性", + "severity": "error", + "description": "检查微信 DB 是否可读", + "auto_repairable": false + }, + { + "check_id": "xdotool_available", + "name": "xdotool 可用性", + "severity": "error", + "description": "检查 xdotool 是否可用", + "auto_repairable": true + } + ] +} +``` + +#### 5.8.3 执行诊断项 + +``` +POST /api/diagnostic/run/{check_id} +``` + +**响应**: + +```json +{ + "check_id": "wechat_running", + "passed": true, + "severity": "critical", + "message": "微信进程运行中,PID=12345", + "auto_repairable": true, + "repair_plan": null +} +``` + +#### 5.8.4 自动修复 + +``` +POST /api/diagnostic/autofix/{check_id} +``` + +**响应**: + +```json +{ + "check_id": "wechat_running", + "passed": true, + "message": "已重启微信进程" +} +``` + +**对应 channels 调用**:`DoctorAdapter.checkConnectivity()` / `getDiagnosticItems()` / `runItem()` / `autoFix()` + +### 5.9 截图接口(调试用) + +``` +POST /api/screenshot +``` + +返回当前微信窗口截图(PNG)。 + +--- + +## 六、错误码枚举 + +| 错误码 | 说明 | HTTP 状态 | +|---|---|---| +| `WECHAT_NOT_RUNNING` | 微信进程未启动 | 503 | +| `WECHAT_NOT_LOGGED_IN` | 微信未登录 | 401 | +| `WINDOW_NOT_FOUND` | 找不到微信窗口 | 503 | +| `CONTACT_NOT_FOUND` | 联系人不存在 | 404 | +| `SEND_FAILED` | 发送失败 | 500 | +| `DB_LOCKED` | DB 被锁定 | 503 | +| `DB_NOT_FOUND` | DB 文件不存在 | 500 | +| `INVALID_PARAMS` | 参数校验失败 | 400 | +| `BRIDGE_INTERNAL_ERROR` | bridge 内部错误 | 500 | +| `LOGIN_TIMEOUT` | 登录超时 | 408 | +| `MEDIA_NOT_FOUND` | 媒体文件不存在 | 404 | +| `RATE_LIMITED` | 触发限流 | 429 | + +--- + +## 七、配置项 + +### 7.1 新增环境变量 + +在 `.env.example` 追加: + +```bash +# ===== woc-bridge 业务 API ===== +WOC_BRIDGE_PORT=8088 +WOC_BRIDGE_SEND_DELAY_MS=800 +WOC_BRIDGE_MAX_BATCH_SIZE=50 +WOC_BRIDGE_POLL_INTERVAL_MS=2000 +WOC_BRIDGE_MAX_CALLS_PER_SEC=10 +``` + +### 7.2 channels 侧 config_schema(参考) + +WechatOnCloud 插件的 `config_schema`(由 channels 侧 manifest 声明,非 WechatOnCloud 侧): + +| key | type | required | default | hot_reloadable | 说明 | +|---|---|---|---|---|---| +| `bridge_url` | str | true | - | true | bridge 服务地址 | +| `poll_interval_ms` | int | false | 2000 | true | 轮询间隔 | +| `max_batch_size` | int | false | 50 | true | 单次拉取上限 | +| `send_delay_ms` | int | false | 800 | true | 发送间隔 | +| `max_message_length` | int | false | 2000 | true | 最大消息长度 | +| `enable_media` | bool | false | true | true | 是否启用媒体下载 | +| `enable_group` | bool | false | true | true | 是否启用群聊支持 | + +--- + +## 八、Dockerfile 改造 + +在 [docker/Dockerfile](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main/docker/Dockerfile) 现有内容基础上追加: + +```dockerfile +# ===== 新增:woc-bridge 业务 API 服务 ===== +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv \ + sqlite3; \ + pip3 install --break-system-packages --no-cache-dir \ + fastapi==0.115.0 \ + uvicorn[standard]==0.30.0 \ + python-multipart==0.0.9 \ + pillow==10.4.0; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* + +COPY bridge/ /opt/woc-bridge/ +RUN chmod 755 /opt/woc-bridge/server.py + +# 注册为 s6-rc longrun 服务 +RUN mkdir -p /etc/s6-overlay/s6-rc.d/svc-woc-bridge +COPY bridge/s6/woc-bridge/run /etc/s6-overlay/s6-rc.d/svc-woc-bridge/run +COPY bridge/s6/woc-bridge/type /etc/s6-overlay/s6-rc.d/svc-woc-bridge/type +RUN chmod 755 /etc/s6-overlay/s6-rc.d/svc-woc-bridge/run \ + && touch /etc/s6-overlay/s6-rc.d/user/contents.d/svc-woc-bridge + +EXPOSE 3000 3001 8088 +``` + +--- + +## 九、s6 启动脚本 + +`bridge/s6/woc-bridge/run`: + +```bash +#!/usr/bin/with-contenv bash +# 等待微信窗口出现 +while ! xdotool search --name "微信" >/dev/null 2>&1; do + sleep 2 +done + +# 以 abc 用户身份运行(与微信同 X 会话) +exec s6-setuidgid abc \ + DISPLAY=${DISPLAY:-:1} \ + XAUTHORITY=/config/.Xauthority \ + python3 /opt/woc-bridge/server.py \ + --listen 0.0.0.0:8088 \ + --wechat-db /config/.config/xwechat \ + --display ${DISPLAY:-:1} +``` + +--- + +## 十、面板端口暴露改造 + +[panel/server/src/docker.ts](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main/panel/server/src/docker.ts) L256: + +```typescript +// 改造前 +ExposedPorts: { '3000/tcp': {} }, + +// 改造后 +ExposedPorts: { + '3000/tcp': {}, + '8088/tcp': {}, +}, +``` + +--- + +## 十一、面板反代改造 + +[panel/server/src/index.ts](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main/panel/server/src/index.ts) 新增反代路由: + +```typescript +// ---------- 反向代理到实例的 woc-bridge 业务 API ---------- +// /api/bridge/:id/* → http://woc-wx-:8088/* +// 仅管理员可访问(业务 API 等同微信会话凭据) +const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => { + if (!requireAdmin(req, reply)) return; + const parsed = parseBridgeUrl(req.raw.url || ''); + if (!parsed) { + reply.code(404).send({ error: 'not found' }); + return; + } + const inst = findInstance(parsed.id); + if (!inst) { + reply.code(404).send({ error: '实例不存在' }); + return; + } + reply.hijack(); + req.raw.url = parsed.rest; + proxy.web(req.raw, reply.raw, { + target: `http://${inst.containerName}:8088`, + }); +}; + +function parseBridgeUrl(rawUrl: string): { id: string; rest: string } | null { + const m = rawUrl.match(/^\/api\/bridge\/([0-9a-f]{6,})(\/.*|\?.*|)?$/); + if (!m) return null; + const id = m[1]; + let rest = m[2] || '/'; + if (rest.startsWith('?')) rest = '/' + rest; + if (rest === '') rest = '/'; + return { id, rest }; +} + +app.all('/api/bridge/:id', bridgeHandler); +app.all('/api/bridge/:id/*', bridgeHandler); +``` + +--- + +## 十二、关键技术实现细节 + +### 12.1 发送文本消息(xdotool + xclip) + +```python +async def send_text(to_wxid: str, content: str) -> str: + """通过 xdotool 操作微信窗口发送文本消息""" + # 1. 激活微信窗口 + subprocess.run( + ["xdotool", "search", "--name", "微信", "windowactivate", "--sync"], + check=True, + ) + # 2. Ctrl+F 打开搜索 + subprocess.run(["xdotool", "key", "ctrl+f"], check=True) + await asyncio.sleep(0.5) + # 3. 用 xclip 粘贴 wxid(避免输入法干扰) + _paste_via_xclip(to_wxid) + await asyncio.sleep(0.8) + # 4. 回车进入会话 + subprocess.run(["xdotool", "key", "Return"], check=True) + await asyncio.sleep(0.5) + # 5. 粘贴消息内容 + _paste_via_xclip(content) + await asyncio.sleep(0.2) + # 6. 回车发送 + subprocess.run(["xdotool", "key", "Return"], check=True) + # 7. 从 DB 读取刚发送的消息 ID + return await _query_last_sent_msg_id(to_wxid) + + +def _paste_via_xclip(text: str) -> None: + """通过 xclip 剪贴板粘贴,避开 X11 keysym 中文限制""" + b64 = base64.b64encode(text.encode("utf-8")).decode() + subprocess.run( + ["bash", "-c", f"echo '{b64}' | base64 -d | xclip -selection clipboard -i"], + check=True, + ) + subprocess.run(["xdotool", "key", "--clearmodifiers", "ctrl+v"], check=True) +``` + +### 12.2 读取消息(DB 快照) + +```python +async def get_messages_since(cursor: int, limit: int = 50) -> dict: + """从微信 DB 读取增量消息""" + db_path = "/config/.config/xwechat/msg_0.db" + snapshot = _snapshot_db(db_path) + conn = sqlite3.connect(snapshot) + try: + rows = conn.execute( + "SELECT msg_id, talker, is_sender, type, content, create_time " + "FROM message WHERE create_time > ? ORDER BY create_time ASC LIMIT ?", + (cursor, limit), + ).fetchall() + finally: + conn.close() + os.unlink(snapshot) + + messages = [_row_to_dict(r) for r in rows] + next_cursor = str(messages[-1]["create_time"]) if messages else str(cursor) + return { + "messages": messages, + "next_cursor": next_cursor, + "has_more": len(messages) == limit, + } +``` + +### 12.3 二维码截图 + +```python +async def capture_qr_code() -> str: + """截图微信窗口的二维码区域,返回 data URL""" + # 1. 激活微信窗口 + subprocess.run( + ["xdotool", "search", "--name", "微信", "windowactivate", "--sync"], + check=True, + ) + # 2. 截图整个窗口 + window_geom = subprocess.check_output( + ["xdotool", "search", "--name", "微信", "getwindowgeometry", "--shell"] + ).decode() + # 3. 用 import 命令截图 + screenshot_path = "/tmp/woc_qr.png" + subprocess.run(["import", "-window", "root", screenshot_path], check=True) + # 4. 用 Pillow 裁剪二维码区域(需根据微信窗口布局定位) + img = Image.open(screenshot_path) + # 二维码通常在窗口中央偏上,具体坐标需实测 + qr_region = (x, y, x + w, y + h) + qr_img = img.crop(qr_region) + # 5. 编码为 base64 data URL + buf = io.BytesIO() + qr_img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/png;base64,{b64}" +``` + +### 12.4 发送队列串行化 + +```python +class SendQueue: + """串行化发送队列,避免 xdotool 操作冲突""" + + def __init__(self, send_delay_ms: int = 800): + self._queue: asyncio.Queue = asyncio.Queue() + self._send_delay_ms = send_delay_ms + self._worker: asyncio.Task | None = None + + async def start(self): + self._worker = asyncio.create_task(self._run()) + + async def enqueue(self, task: SendTask) -> SendResult: + future = asyncio.get_event_loop().create_future() + await self._queue.put((task, future)) + return await future + + async def _run(self): + while True: + task, future = await self._queue.get() + try: + result = await task.execute() + future.set_result(result) + except Exception as e: + future.set_exception(e) + await asyncio.sleep(self._send_delay_ms / 1000) +``` + +--- + +## 十三、安全设计 + +### 13.1 鉴权 + +- bridge API 反代路由 `/api/bridge/:id/*` **仅管理员可访问** +- bridge 服务本身监听 `0.0.0.0:8088`,但在 docker 网络内,只有面板能访问 +- 可选:bridge 支持 API Key 鉴权(通过环境变量配置) + +### 13.2 风控策略 + +- 发送消息串行化,避免并发 +- 可配置发送间隔(默认 800ms) +- 单实例每秒发送上限(默认 10 条) +- 异常时自动降速或暂停 + +### 13.3 数据安全 + +- DB 只读访问,不写入 +- 不记录消息内容到日志 +- 截图功能仅管理员可用 +- 不暴露微信凭据 + +--- + +## 十四、风险与限制 + +| 风险 | 影响 | 应对 | +|---|---|---| +| xdotool 操作时序不稳定 | 发送失败或发错人 | 用 wxid 而非昵称;加状态检测;失败重试;记录截图 | +| 微信 DB 加密 | 无法直接读消息 | 阶段 1 用 UI 截图 + OCR;阶段 2 引入 `libwcdb.so` | +| 微信窗口未就绪 | bridge 启动失败 | s6 longrun 等待窗口出现;健康检查 | +| 中文输入异常 | 发送乱码 | 强制走 xclip 粘贴路径 | +| 多消息并发 | UI 操作冲突 | bridge 内部串行化发送队列 | +| Linux 微信版本更新 | DB 结构或快捷键变化 | 固定版本;建立版本适配测试 | +| 二维码区域定位 | 截图裁剪坐标不准 | 实测校准;提供手动框选 fallback | +| bridge 崩溃影响微信 | 微信异常 | 独立进程,s6 隔离重启 | + +--- + +## 十五、实施路线 + +### 阶段 1:MVP(1-2 周) + +**目标**:跑通"收消息 → 发消息"最小闭环 + +- [ ] 实现 `bridge/server.py` FastAPI 骨架 +- [ ] 实现 `GET /api/status` +- [ ] 实现 `POST /api/send/text`(xdotool + xclip) +- [ ] 实现 `GET /api/messages/since`(DB 读取) +- [ ] 改造 Dockerfile + s6 服务 +- [ ] 改造面板端口暴露 + 反代 +- [ ] 端到端验证:能收到消息、能发出消息 + +### 阶段 2:登录与完善(2-3 周) + +- [ ] 实现 `POST /api/login/qr/start` + `GET /api/login/qr/wait` +- [ ] 联系人/群聊查询 +- [ ] 图片/文件发送 +- [ ] 媒体文件下载 +- [ ] 发送队列 + 限流 +- [ ] 错误恢复 + 重试 +- [ ] 截图诊断 + +### 阶段 3:稳定性(持续) + +- [ ] 健康检查 + 自动恢复 +- [ ] 监控告警 +- [ ] 多账号支持验证 +- [ ] DB 加密适配(如需) +- [ ] 面板 UI 集成(可选) + +--- + +## 十六、验收标准 + +### 16.1 阶段 1 验收 + +| 验收项 | 标准 | +|---|---| +| bridge 服务启动 | 容器启动后 30s 内 bridge 就绪,`GET /api/status` 返回 200 | +| 发送文本消息 | 调用 `POST /api/send/text`,对方在 5s 内收到消息 | +| 拉取消息 | 调用 `GET /api/messages/since`,能返回最近 50 条消息 | +| 桌面串流不受影响 | 原有 KasmVNC 访问正常,扫码登录正常 | +| 面板反代可用 | 通过 `/api/bridge/:id/*` 能访问到 bridge API | + +### 16.2 阶段 2 验收 + +| 验收项 | 标准 | +|---|---| +| 扫码登录 | 能获取二维码 data URL,扫码后返回 `connected=true` | +| 联系人查询 | 能按 wxid 或昵称查到联系人 | +| 图片发送 | 能发送图片,对方收到且可查看 | +| 媒体下载 | 能下载图片/语音/视频文件 | +| 并发安全 | 10 条消息连续发送不冲突 | +| 错误恢复 | bridge 崩溃后 10s 内自动重启 | + +--- + +## 十七、附录 + +### 17.1 文件清单 + +``` +WechatOnCloud-main/ +├── bridge/ ← 新增 +│ ├── server.py # FastAPI 主服务 +│ ├── xdotool_driver.py # xdotool 操作封装 +│ ├── db_reader.py # 微信 DB 读取 +│ ├── qr_capture.py # 二维码截图 +│ ├── send_queue.py # 串行化发送队列 +│ ├── models.py # 数据模型 +│ ├── requirements.txt +│ └── s6/ +│ └── woc-bridge/ +│ ├── run # s6 启动脚本 +│ └── type # 内容为 "longrun" +├── docker/ +│ └── Dockerfile ← 修改(追加 Python + bridge) +├── panel/ +│ └── server/src/ +│ ├── docker.ts ← 修改(ExposedPorts 加 8088) +│ └── index.ts ← 修改(加 /api/bridge/:id/* 反代) +├── .env.example ← 修改(加 WOC_BRIDGE_* 配置) +└── doc/ + └── bridge-API.md ← 新增(API 文档) +``` + +### 17.2 不改动的文件 + +- `docker/autostart` —— bridge 由 s6 管理,不依赖 openbox autostart +- `docker/woc-identity.sh` —— 设备伪装逻辑不变 +- `docker/wechat-ctl.sh` —— 微信下载安装逻辑不变 +- `docker/app-defs.sh` —— 应用定义不变 +- 面板前端代码 —— bridge 状态展示为可选项,非必须 + +### 17.3 channels 侧 config_schema 与 WechatOnCloud 侧环境变量对应关系 + +| channels config_schema key | WechatOnCloud 环境变量 | 说明 | +|---|---|---| +| `bridge_url` | 无(由面板反代地址决定) | channels 侧配置 | +| `poll_interval_ms` | `WOC_BRIDGE_POLL_INTERVAL_MS` | 默认 2000 | +| `max_batch_size` | `WOC_BRIDGE_MAX_BATCH_SIZE` | 默认 50 | +| `send_delay_ms` | `WOC_BRIDGE_SEND_DELAY_MS` | 默认 800 | + +### 17.4 与 channels 集成的关系 + +本方案**只负责把 WechatOnCloud 改造成支持业务 API**,不涉及 channels 侧的插件开发。 + +channels 侧的 `wechat_woc` 插件开发是**独立的另一个项目**,它消费本方案产出的 HTTP API。两者关系: + +``` +[channels wechat_woc 插件] ← 本方案不涉及 + ↓ HTTP +[WechatOnCloud bridge API] ← 本方案产出 + ↓ xdotool / DB +[微信客户端] +``` + +channels 插件只需配置 `bridge_url = http:///api/bridge/<实例id>` 即可对接。 diff --git a/doc/优化方案/05-UIActionScheduler统一调度方案.md b/doc/优化方案/05-UIActionScheduler统一调度方案.md new file mode 100644 index 0000000..d581a37 --- /dev/null +++ b/doc/优化方案/05-UIActionScheduler统一调度方案.md @@ -0,0 +1,1645 @@ +# UIActionScheduler 统一调度方案 + +> 文档版本:v1.0 +> 创建日期:2026-07-17 +> 范围:`bridge/woc_bridge` 全链路 +> 目标:将散落在 5 个子系统的 UI 操作收口到统一调度器,消除绕过队列的竞态风险,引入优先级与可观测性 + +--- + +## 一、背景与现状 + +### 1.1 当前架构 + +当前 UI 操作分散在 5 个子系统,串行化覆盖不完整: + +| 子系统 | 入口 | 是否经 SendQueue | 互斥情况 | +|---|---|---|---| +| `FlowOrchestrator.send_text/send_file` | HTTP + BatchWorker | ✅ 入队 | SendQueue 串行 + `_ui_lock` 单命令互斥 | +| `routes/contacts.py` (set_remark/add_friend/accept) | HTTP | ✅ 入队 | 同上 | +| `routes/moments.py` (publish/share/like/comment/delete) | HTTP | ✅ 入队 | 同上 | +| `routes/send.py` legacy (revoke/forward) | HTTP | ✅ 入队 | 同上 | +| `FriendRequestWatcher._handle_request` | 后台轮询 3s | ✅ 入队 | 同上 | +| `routes/login.py` (logout/restart) | HTTP | ❌ 直接调 xdotool | 仅 `_ui_lock` 单命令互斥 | +| `routes/login.py` (qr_start/qr_wait) | HTTP | ❌ 直接调 xdotool | qr_capture **无锁** | +| `routes/screenshot.py` | HTTP | ❌ 直接调 qr_capture | **无锁** | +| `routes/diagnostic.py` (autofix) | HTTP | ❌ 内联 pkill | **无锁** | +| `LoginGuard._check_loop` | 后台轮询 5s | ❌ 直接调 xdotool | 仅 `_ui_lock` | +| `WeChatWatchdog._run` | 后台轮询 10s | ❌ 直接调 pgrep/kill | **无锁** | + +### 1.2 已确认的问题 + +#### P0 竞态风险(已发生过实际故障) + +| 问题 | 根因 | 后果 | 引用 | +|---|---|---|---| +| friend_watcher 与 send_text 竞态 | 早期未入队,现已修复入队 | erratic clicking、重复拨号 | 项目记忆记录 | +| logout 多步操作被 send_queue 任务穿插 | logout 不入队,仅靠 `_ui_lock` 单命令互斥 | 菜单状态错乱、登出失败 | [routes/login.py:201](../../bridge/woc_bridge/routes/login.py#L201) | +| restart kill 微信与并发 UI 操作冲突 | restart 不入队 | xdotool 对空窗口操作、TimeoutError 雪崩 | [routes/login.py:281](../../bridge/woc_bridge/routes/login.py#L281) | +| diagnostic autofix pkill 无锁 | 直接 `pkill -x wechat` | 与并发 UI 操作冲突高风险 | [routes/diagnostic.py:232-239](../../bridge/woc_bridge/routes/diagnostic.py#L232) | +| QrCapture 无锁无超时 | `proc.communicate()` 无 timeout | 与 send_text 截图竞争 X11、可能无限阻塞 | [ui/qr_capture.py:48-57](../../bridge/woc_bridge/ui/qr_capture.py#L48) | +| WeChatWatchdog autofix 无锁 | pgrep+kill 不持 `_ui_lock` | kill 微信时 UI 操作中途失败 | [ui/watchdog.py:99-191](../../bridge/woc_bridge/ui/watchdog.py#L99) | + +#### P1 设计债务 + +| 问题 | 根因 | 后果 | +|---|---|---| +| 无优先级机制 | SendQueue 是纯 FIFO | 紧急操作(logout/restart)无法插队,100 个 send 排队时 logout 延迟分钟级 | +| 命名与实现不符 | 类名 `SendQueue`,实际是通用 UI 队列 | 误导维护者认为只管 send | +| 路由层前置 `detect_login_state` 不入队 | 多处路由在 enqueue 前直接调 xdotool | 与 send_queue 内任务竞争 `_ui_lock`,增加排队延迟 | +| 启动清场不入队 | `_full_cleanup_on_startup` 直接调 | 若 friend_watcher 已启动可能并发(实际启动顺序规避了此风险) | +| 无统一可观测性 | 各子系统独立打日志,无统一 metrics | UI 操作队列深度、等待时长、执行时长无法监控 | + +#### P1.1 detect_login_state 散落调用分析 + +`detect_login_state` 是只读探测(截图 + 模板匹配),但散落在 14 处直接调用,每处都会与 send_queue 内任务竞争 L1 `_ui_lock`: + +| 文件 | 调用位置 | 调用频率 | 与队列竞争影响 | +|---|---|---|---| +| `routes/send.py` | L307 / L413 / L587 / L690 / L774 / L852(6 处) | 每次 HTTP 请求 | 每次持锁 ~200-500ms(screenshot+模板匹配),100 并发 send 时累计 12-30s 额外延迟 | +| `routes/moments.py` | L151 / L255 / L346 / L447 / L545 / L677(6 处) | 每次 HTTP 请求 | 同上 | +| `routes/contacts.py` | L236 / L313 / L433(3 处) | 每次 HTTP 请求 | 同上 | +| `routes/login.py` | L117 / L190(2 处) | 每次 HTTP 请求 | 同上 | +| `routes/diagnostic.py` | L114(1 处) | 诊断触发 | 频率低,影响小 | +| `routes/status.py` | L51-56(内联判定,未直接调) | 每次 status 请求 | 已优化为内联 pgrep/find_window,**不持 _ui_lock** | +| `LoginGuard._check_loop` | 后台 5s 轮询 | 持续运行 | 每 5s 一次,频率低 | +| `friend_watcher._verify_with_retry` | 后台触发 | 偶发 | 频率低 | + +**关键发现**:`routes/status.py` 已经通过内联 pgrep+find_window 避开了 `detect_login_state`,证明这种"只读探测不持 _ui_lock"的优化路径是可行且被项目采纳的。 + +**处理方案**(**本方案不强制收口,但记录优化路径**): + +1. **短期(本方案不实施)**:保持现状。`detect_login_state` 是单次截图+模板匹配,持锁时间可控(< 1s)。在 send_queue 任务执行期间(通常 1-5s),最多 1-2 次 detect_login_state 抢占 _ui_lock,影响有限。 +2. **中期(独立优化项)**:参照 `routes/status.py` 的优化模式,将路由前置 `detect_login_state` 改为内联 pgrep + find_window 判定(不持 _ui_lock),仅在判定为 "logged_in" 后才入队执行真正的 UI 操作。 +3. **长期(架构演进)**:LoginGuard 维护登录态缓存,路由层读缓存而非每次调 `detect_login_state`。 + +**为什么 UIActionScheduler 不收口 detect_login_state**: +- 入队会被 100 个 send 排队阻塞,导致登录态检测延迟分钟级,影响 friend_watcher 等依赖登录态的后台任务 +- detect_login_state 是只读探测,不修改 UI 状态,与 send_queue 任务的"互斥"是性能问题而非正确性问题 +- 收口 detect_login_state 会引入"队列内任务触发队列外只读探测"的循环依赖 + +**风险评估**:保持现状的代价是每个 send 请求多 ~300ms 延迟(detect_login_state 持锁 1 次),在 100 并发场景下累计 ~30s。可通过中期方案消除。 + +### 1.3 SendQueue 现状评估 + +SendQueue 实际上**已经是事实上的通用 UI 调度器**: +- 16 处 `enqueue` 调用点,覆盖 send(4)/contacts(3)/moments(6)/friend_watcher(1)/orchestrator(2) +- 接收任意 `CoroFactory`,不限定 send 语义 +- 单 worker 串行 + 1 秒滑窗限流 + 队列满拒绝 + 等待超时 + +**可直接复用为基础**,但需扩展: +1. 增加 `priority` 参数(当前队列元素是 `tuple[CoroFactory, Future, Optional[int]]`,第三段是 `custom_delay_ms`,无 priority 槽位) +2. 重命名以反映通用语义(向后兼容保留别名) +3. 收口绕过队列的调用点 + +### 1.4 双层保护模型(L1 _ui_lock + L2 SendQueue) + +当前架构存在两层互斥机制,理解其分工是设计 UIActionScheduler 的前提: + +| 层级 | 互斥粒度 | 实现位置 | 持锁时长 | 保护语义 | +|---|---|---|---|---| +| **L1** `_ui_lock` | **单命令**(一次 click/type/screenshot) | `ui/backends/xdotool.py:50` `asyncio.Lock` | 单次 xdotool 子进程(~50ms-5s) | 防止两个 asyncio 协程同时调 xdotool 导致 X11 焦点错乱 | +| **L2** `SendQueue` | **多命令 Flow**(整个 send_text/logout 流程) | `messaging/send_queue.py` worker 串行 | 整个 Flow(~1-30s) | 防止多步操作之间被其他操作步骤穿插 | + +**关键区别**: + +``` +send_text Flow(多步操作,L2 保护): + activate → click_search_box → type_query → open_session → + focus_input → type_text → click_send → verify + ↑ 每一步内部都 acquire/release L1 _ui_lock(单命令互斥) + ↑ 整个 Flow 由 L2 SendQueue 串行执行(多步不被穿插) + +logout Flow(多步操作,当前仅 L1 保护): + activate → click_main_menu → key_down × N → enter → click_confirm + ↑ 每一步内部 acquire/release L1 _ui_lock + ↑ 整个 Flow 不在 SendQueue 中 → send_text 的步骤可以穿插进来! +``` + +**核心问题**:L1 只保护单次命令,无法防止多步 Flow 被穿插。例如 logout 点击主菜单后释放 _ui_lock,send_text 立即获得 _ui_lock 执行 click_search_box,导致 logout 的下一步 key_down 落在错误的焦点上。 + +**UIActionScheduler 的职责**:提供 L2 层统一保护,所有多步 UI 操作必须入队,避免步骤穿插。L1 保持不变作为底层单命令互斥的第二道防线(防止绕过队列的极端情况,如 LoginGuard/detect_login_state)。 + +### 1.5 UI 操作调用点全景图 + +**修改前**(当前状态,散落 22 个调用点,仅 16 个入队): + +``` +┌─ HTTP 路由层 ────────────────────────────────────────────────────────────┐ +│ │ +│ routes/send.py │ +│ ├─ send_text (L337) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ send_file (L703) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ revoke_message (L787) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ forward_message (L881) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ detect_login_state × 6 ── direct ──→ xdotool [⚠️ 仅 L1] │ +│ ├─ post_verify_revoke ── direct ──→ DB only [✅ 无 UI] │ +│ ├─ capture_forward_baseline ── direct ──→ DB only [✅ 无 UI] │ +│ └─ post_verify_forward ── direct ──→ DB only [✅ 无 UI] │ +│ │ +│ routes/contacts.py │ +│ ├─ set_remark (L249) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ add_friend (L321) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ accept_friend (L452) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ detect_login_state × 3 ── direct ──→ xdotool [⚠️ 仅 L1] │ +│ └─ post_verify_set_remark ── direct ──→ DB only [✅ 无 UI] │ +│ post_verify_add_friend │ +│ │ +│ routes/moments.py │ +│ ├─ publish (L167) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ share (L271) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ like (L379) ── enqueue ──→ SendQueue [✅ 入队] │ +│ │ └─ _like_and_verify (含 post_verify_moment_like 含截图) │ +│ ├─ comment (L480) ── enqueue ──→ SendQueue [✅ 入队] │ +│ │ └─ _comment_and_verify (含 post_verify_moment_comment 含截图) │ +│ ├─ delete (L583) ── enqueue ──→ SendQueue [✅ 入队] │ +│ ├─ forward_moment (L692) ── enqueue ──→ SendQueue [✅ 入队] │ +│ └─ detect_login_state × 6 ── direct ──→ xdotool [⚠️ 仅 L1] │ +│ │ +│ routes/login.py │ +│ ├─ qr_start (L58-59) ── direct ──→ xdotool+qr [❌ P0 无锁] │ +│ ├─ qr_wait (L117) ── direct ──→ xdotool [⚠️ 仅 L1] │ +│ ├─ logout (L201) ── direct ──→ xdotool [❌ P0 仅 L1] │ +│ └─ wechat_restart (L281) ── direct ──→ xdotool [❌ P0 仅 L1] │ +│ │ +│ routes/screenshot.py │ +│ └─ screenshot (L42) ── direct ──→ qr_capture [❌ P0 无锁] │ +│ │ +│ routes/diagnostic.py │ +│ ├─ autofix_wechat_running ── direct ──→ pkill [❌ P0 无锁] │ +│ └─ detect_login_state (L114)── direct ──→ xdotool [⚠️ 仅 L1] │ +│ │ +│ routes/status.py │ +│ └─ 内联 pgrep+find_window (不持 _ui_lock,已优化) [✅ 无 UI] │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ + +┌─ 后台任务层 ────────────────────────────────────────────────────────────┐ +│ │ +│ FlowOrchestrator (send_text/send_file) │ +│ ├─ enqueue (L231, L364) ── enqueue ──→ SendQueue [✅ 入队] │ +│ └─ SessionCache LRU │ +│ │ +│ FriendRequestWatcher._handle_request │ +│ └─ enqueue (L506) ── enqueue ──→ SendQueue [✅ 入队] │ +│ │ +│ LoginGuard._check_loop │ +│ └─ detect_login_state ── direct ──→ xdotool [⚠️ 仅 L1] │ +│ │ +│ WeChatWatchdog._run │ +│ ├─ pgrep/xdpyinfo ── direct ──→ system [✅ 只读] │ +│ └─ _autofix_wechat (kill) ── direct ──→ kill [❌ P0 无锁] │ +│ │ +│ QrCapture │ +│ └─ _run (proc.communicate) ── direct ──→ scrot [❌ P0 无超时] │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**修改后**(目标状态,所有 P0 收口,仅保留只读探测不入队): + +``` +┌─ HTTP 路由层 ── 全部经 UIActionScheduler.execute(action, priority) ────┐ +│ │ +│ routes/send.py │ +│ ├─ send_text/file/revoke/forward ── execute(NORMAL) ──→ Scheduler │ +│ ├─ detect_login_state × 6 ── direct (中期优化为内联 pgrep) │ +│ └─ post_verify_* / capture_baseline ── direct (DB only, 保留) │ +│ │ +│ routes/contacts.py │ +│ ├─ set_remark/add_friend/accept ── execute(NORMAL/HIGH) → Scheduler │ +│ └─ detect_login_state × 3 ── direct (中期优化) │ +│ │ +│ routes/moments.py │ +│ ├─ publish/share/delete ── execute(NORMAL) ──→ Scheduler │ +│ ├─ like/comment ── execute(LOW) ──→ Scheduler │ +│ └─ detect_login_state × 6 ── direct (中期优化) │ +│ │ +│ routes/login.py │ +│ ├─ qr_start (capture_qr_code) ── execute(HIGH) ──→ Scheduler │ +│ ├─ logout ── execute(HIGH) ──→ Scheduler │ +│ ├─ wechat_restart ── execute(CRITICAL)──→ Scheduler │ +│ │ └─ mark_wechat_dead(8.0) before execute │ +│ └─ qr_wait (detect_login_state) ── direct (只读,保留) │ +│ │ +│ routes/screenshot.py │ +│ └─ screenshot ── execute(LOW) ──→ Scheduler │ +│ │ +│ routes/diagnostic.py │ +│ └─ autofix_wechat_running ── execute(CRITICAL)──→ Scheduler │ +│ └─ mark_wechat_dead(15.0) before execute │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ + +┌─ 后台任务层 ────────────────────────────────────────────────────────────┐ +│ │ +│ FlowOrchestrator.send_text/send_file │ +│ └─ enqueue (向后兼容) ── execute(NORMAL) ──→ Scheduler │ +│ │ +│ FriendRequestWatcher._handle_request │ +│ └─ enqueue (向后兼容) ── execute(HIGH) ──→ Scheduler │ +│ │ +│ LoginGuard._check_loop │ +│ └─ detect_login_state ── direct (只读,保留) │ +│ │ +│ WeChatWatchdog._autofix_wechat │ +│ ├─ scheduler.drain(5.0) ── 等待队列清空 │ +│ ├─ scheduler.mark_wechat_dead(15.0) ── 标记 fast-fail 窗口 │ +│ └─ pgrep + kill -TERM ── 系统级操作(不入队) │ +│ │ +│ QrCapture._run │ +│ └─ asyncio.wait_for(proc.communicate, 5.0) ── 超时保护 │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.6 HTTP 端点 → 优先级映射表 + +| HTTP 端点 | 当前状态 | 目标优先级 | delay_ms 策略 | wait_timeout_ms | 备注 | +|---|---|---|---|---|---| +| `POST /api/send/text` | ✅ enqueue | NORMAL | 同联系人 1000 / 不同 3000 | 15000 | 现有逻辑不变 | +| `POST /api/send/file` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | +| `POST /api/messages/revoke` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | +| `POST /api/messages/forward` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | +| `POST /api/friends/remark` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | +| `POST /api/friends/add` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | +| `POST /api/friends/accept` | ✅ enqueue | HIGH | 默认 3000 | 30000 | 时效性高 | +| `POST /api/moments/publish` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 | +| `POST /api/moments/share` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 | +| `POST /api/moments/delete` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 | +| `POST /api/moments/forward` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 | +| `POST /api/moments/like` | ✅ enqueue | LOW | 默认 3000 | 15000 | 可延迟 | +| `POST /api/moments/comment` | ✅ enqueue | LOW | 默认 3000 | 15000 | 可延迟 | +| `POST /api/screenshot` | ❌ direct | LOW | 默认 3000 | 10000 | 阶段 2 收口 | +| `POST /api/login/qr/start` | ❌ direct | HIGH | 默认 3000 | 15000 | 阶段 2 收口 | +| `POST /api/login/logout` | ❌ direct | HIGH | 默认 3000 | 30000 | 阶段 2 收口 | +| `POST /api/wechat/restart` | ❌ direct | CRITICAL | 0(自动) | 60000 | 阶段 2 收口 + fast-fail | +| `POST /api/diagnostic/autofix/wechat_running` | ❌ direct | CRITICAL | 0(自动) | 30000 | 阶段 2 收口 + fast-fail | +| `GET /api/login/qr/wait` | direct(轮询) | 不入队 | - | - | 只读探测 | +| `GET /api/status` | 内联 pgrep | 不入队 | - | - | 已优化 | +| `GET /api/diagnostic/run/*` | direct(只读) | 不入队 | - | - | 只读探测 | + +--- + +## 二、设计方案 + +### 2.1 核心思路 + +**不推翻 SendQueue 重写,而是扩展 + 收口**: + +1. **扩展 SendQueue** 为 `UIActionScheduler`,增加优先级参数 +2. **收口绕过队列的 6 个调用点**,统一入队 +3. **保留 `_ui_lock`** 作为底层单命令互斥(不取消,作为第二道防线) +4. **新增 metrics** 暴露队列深度、等待时长、执行时长 + +### 2.2 命名策略 + +采用**渐进式重命名**,避免一次性破坏全部调用点: + +```python +# messaging/ui_action_scheduler.py(新文件,继承 SendQueue) +class UIActionScheduler(SendQueue): + """UI 操作统一调度器。 + + 在 SendQueue 基础上增加: + - priority 参数(CRITICAL/HIGH/NORMAL/LOW) + - 优先级队列(asyncio.PriorityQueue 替代 asyncio.Queue) + - metrics 暴露(队列深度、等待时长、执行时长) + """ + async def execute( + self, + action: CoroFactory, + priority: Priority = Priority.NORMAL, + delay_ms: Optional[int] = None, + wait_timeout_ms: Optional[int] = None, + trace_id: str = "", + ) -> Any: + """提交 UI 操作,按优先级调度,串行执行。""" + ... + +# config.py +_state.send_queue: SendQueue | UIActionScheduler # 渐进式,先保持字段名 +``` + +**向后兼容**: +- 保留 `SendQueue.enqueue` 方法签名不变(priority 默认 NORMAL) +- 16 个现有 `enqueue` 调用点无需修改 +- 新调用点用 `execute` 方法,语义更清晰 + +### 2.3 优先级策略 + +```python +class Priority(enum.IntEnum): + """UI 操作优先级(数字越小优先级越高)。""" + CRITICAL = 0 # 系统级紧急:wechat_restart、diagnostic_autofix + HIGH = 1 # 用户感知延迟:logout、friend_accept、login_qr_capture + NORMAL = 2 # 常规业务:send_text、send_file、set_remark、add_friend + LOW = 3 # 可延迟:screenshot、moments_like、moments_comment +``` + +**优先级队列实现**: +- 使用 `asyncio.PriorityQueue`,元素为 `(priority, seq, coro_factory, future, delay_ms)` +- `seq` 是单调递增序列号,保证同优先级 FIFO(避免 coro_factory 不可比较导致的错误) +- worker 出队时按 `(priority, seq)` 排序 + +**优先级反转保护**: +- 低优先级操作持锁时,高优先级操作必须等待(无法抢占,xdotool 子进程不可中断) +- 缓解:限制单次操作超时(已有 `_Cmd_TIMEOUT_SEC=5.0` + Flow 30s 超时),避免低优先级操作长时间持锁 +- 不实现优先级继承(xdotool 子进程无法感知调用方优先级) + +#### 2.3.1 delay_ms 与优先级的交互策略 + +**当前 delay_ms 决策逻辑**(在 `orchestrator.py:212`): +- 同联系人发送:`delay_ms=1000`(短延时,提升吞吐) +- 不同联系人发送:`delay_ms=None` → 用 `send_delay_ms=3000`(默认延时,避免风控) +- 路由层 `enqueue` 不传 `delay_ms` → 用默认 `send_delay_ms=3000` + +**问题**:引入优先级后,CRITICAL/HIGH 操作的延时是否应区别处理? + +**分析**: +- delay_ms 是任务**执行后**的等待时间,影响**下一个任务**的开始时机 +- 例如:CRITICAL 的 restart 执行后,若 delay_ms=3000,下一个 NORMAL 任务要等 3s +- restart 本身的优先级已经让它排到队首,所以 delay_ms 不影响 restart 自身延迟 + +**策略**: + +| 优先级 | delay_ms 策略 | 理由 | +|---|---|---| +| CRITICAL | `delay_ms=0`(不延时) | restart/autofix 后应立即让后续任务执行,避免无谓等待 | +| HIGH | `delay_ms=None`(用默认) | logout/accept_friend 后需要给微信 UI 恢复时间,保持默认延时 | +| NORMAL | 保持现有逻辑(同联系人 1000 / 不同 3000) | send 风控保护,不变 | +| LOW | `delay_ms=None`(用默认) | screenshot 后不强制延时,但也不加速,避免连续截图 | + +**实现**:在 `scheduler.execute` 内根据 priority 自动覆盖 delay_ms: + +```python +async def execute( + self, + action: CoroFactory, + priority: Priority = Priority.NORMAL, + delay_ms: Optional[int] = None, + ... +) -> Any: + # CRITICAL 任务自动应用 delay_ms=0(除非调用方显式指定) + if priority == Priority.CRITICAL and delay_ms is None: + delay_ms = 0 + return await self._enqueue_with_priority( + action, priority, delay_ms, wait_timeout_ms, trace_id + ) +``` + +**注意**:调用方显式指定 `delay_ms` 时优先尊重调用方意图(如批量发送要求固定间隔)。 + +**对现有 enqueue 的影响**:无。现有 16 个 `enqueue` 调用点都走 `priority=NORMAL`,delay_ms 决策逻辑不变。 + +### 2.4 关键决策:watchdog 是否入队 + +**不入队,但加协同机制**: + +WeChatWatchdog 的 `pgrep -x wechat` / `xdpyinfo` / `kill -TERM` 是**系统级操作**,不是 UI 操作: +- `pgrep`/`xdpyinfo`:只读探测,不修改 UI 状态,无需互斥 +- `kill -TERM`:会杀死微信进程,导致所有进行中的 UI 操作失败 + +**方案**: +- watchdog 的 `pgrep`/`xdpyinfo` 保持现状(不入队,不加锁) +- watchdog 的 `_autofix_wechat` 在 kill 前检查 `scheduler.pending_count()`: + - 若有 pending UI 操作,先 `await scheduler.drain(timeout=5.0)` 等待队列清空 + - 超时未清空则强制 kill(记 warning) +- kill 后广播 `wechat_killed` 事件,所有进行中的 Flow 捕获 `WINDOW_NOT_FOUND` 后自然失败 + +### 2.5 关键决策:QrCapture 如何收口 + +QrCapture 有两类操作: +1. `capture_qr_code`:启动扫码登录流程,**多步 UI 操作**(activate + screenshot + crop) +2. `capture_full_screenshot`:单次截图 + +**方案**: +- `capture_qr_code` 入队(priority=HIGH),与 send_text 串行 +- `capture_full_screenshot` 入队(priority=LOW) +- QrCapture 内部增加 `_CMD_TIMEOUT_SEC=5.0` 包裹 `proc.communicate()`,消除无超时风险 + +### 2.6 关键决策:LoginGuard 是否入队 + +**不入队**,原因: +- `detect_login_state` 是只读探测(截图 + 模板匹配),不修改 UI 状态 +- 5 秒轮询 + `_ui_lock` 单命令互斥已足够 +- 若入队,会被 100 个 send 排队阻塞,登录态检测延迟可达分钟级,影响 friend_watcher 等依赖登录态的任务 + +**但需加保护**: +- `detect_login_state` 内部已有 `_ui_lock` 持锁(经 `_run`),保持现状 +- 增加监控:若 `_ui_lock` 等待时长 > 2s,记 warning(说明 UI 操作积压) + +--- + +## 三、详细设计 + +### 3.1 UIActionScheduler 类 + +```python +# bridge/woc_bridge/messaging/ui_action_scheduler.py + +"""UI 操作统一调度器。 + +所有 xdotool/scrot 子进程调用经此调度器串行执行,避免并发 UI 操作冲突。 +在 SendQueue 基础上增加优先级调度与可观测性。 +""" + +from __future__ import annotations + +import asyncio +import enum +import logging +import time +from typing import Any, Awaitable, Callable, Optional + +from woc_bridge.messaging.send_queue import SendQueue, CoroFactory +from woc_bridge.models import BridgeError + +logger = logging.getLogger("woc-bridge") + + +class Priority(enum.IntEnum): + """UI 操作优先级(数字越小优先级越高)。""" + CRITICAL = 0 # 系统级紧急:wechat_restart、diagnostic_autofix + HIGH = 1 # 用户感知延迟:logout、friend_accept、login_qr_capture + NORMAL = 2 # 常规业务:send_text、send_file、set_remark、add_friend + LOW = 3 # 可延迟:screenshot、moments_like、moments_comment + + +class UIActionScheduler(SendQueue): + """UI 操作统一调度器。 + + 扩展 SendQueue: + - execute() 方法支持 priority 参数 + - 内部用 asyncio.PriorityQueue 替代 asyncio.Queue(覆盖父类 __init__ 创建的 Queue) + - 同优先级 FIFO(通过 seq 序列号保证,避免比较到不可比较的 coro_factory) + - metrics 暴露(pending_count by priority、wait_duration、exec_duration) + + 向后兼容: + - enqueue() 方法保留,priority 默认 NORMAL + - 现有 16 个 enqueue 调用点无需修改 + + ⚠️ 必须覆盖父类 _run 方法:父类 _run 解包 3 元组 (coro_factory, future, delay_ms), + 子类用 5 元组 (priority, seq, coro_factory, future, delay_ms)。 + 若不覆盖会导致解包失败。 + """ + + def __init__( + self, + send_delay_ms: int = 3000, + max_calls_per_sec: int = 10, + max_queue_size: int = 100, + ) -> None: + super().__init__(send_delay_ms, max_calls_per_sec, max_queue_size) + # 覆盖父类 __init__ 创建的 asyncio.Queue 为 PriorityQueue + # 父类的旧 Queue 对象会被 GC 回收(无其他引用) + self._queue: asyncio.PriorityQueue[ + tuple[int, int, CoroFactory, asyncio.Future, Optional[int]] + ] = asyncio.PriorityQueue(maxsize=max_queue_size) + self._seq = 0 # 单调递增序列号,保证同优先级 FIFO + + # fast-fail 机制:mark_wechat_dead 标记的时间戳 + # 在此时间之前,所有非 CRITICAL 任务直接抛 WECHAT_NOT_READY + # 供 watchdog kill / wechat_restart 调用,避免失败风暴 + self._wechat_dead_until: float = 0.0 + + # metrics(简单计数器,供 /api/status 或 Prometheus 暴露) + # asyncio 单线程事件循环,worker 与 HTTP handler 同线程,无需加锁 + self._metrics: dict[str, Any] = { + "executed_total": 0, + "executed_by_priority": {p.name: 0 for p in Priority}, + "wait_duration_ms_sum": 0.0, + "exec_duration_ms_sum": 0.0, + "timeout_total": 0, + "rate_limited_total": 0, + "fast_fail_total": 0, # 被 mark_wechat_dead 拦截的任务数 + } + + def mark_wechat_dead(self, duration_sec: float = 10.0) -> None: + """标记微信已死,期间所有非 CRITICAL 任务直接 fast-fail。 + + 供 watchdog kill / wechat_restart / diagnostic autofix 调用: + - kill 前调 mark_wechat_dead(15.0),覆盖 autostart 拉起窗口 + - restart 开始时调 mark_wechat_dead(8.0),覆盖新进程启动窗口 + + CRITICAL 任务(如 restart 本身)不受影响,确保 restart 能正常执行。 + + Args: + duration_sec: fast-fail 窗口时长(秒) + """ + self._wechat_dead_until = time.monotonic() + duration_sec + logger.warning( + "[scheduler] mark_wechat_dead %.1fs, %d pending tasks will fast-fail", + duration_sec, self._queue.qsize(), + ) + + async def execute( + self, + action: CoroFactory, + priority: Priority = Priority.NORMAL, + delay_ms: Optional[int] = None, + wait_timeout_ms: Optional[int] = None, + trace_id: str = "", + ) -> Any: + """提交 UI 操作,按优先级调度,串行执行。 + + Args: + action: 返回 coroutine 的工厂函数 + priority: 优先级(CRITICAL/HIGH/NORMAL/LOW) + delay_ms: 自定义本次延时毫秒;None 时: + - CRITICAL 自动设为 0(执行后不延时) + - 其他优先级用默认 send_delay_ms + wait_timeout_ms: 队列等待超时;None 无限等待 + trace_id: 追踪 ID(用于日志关联) + + Returns: + coroutine 的实际执行结果 + + Raises: + BridgeError(RATE_LIMITED): 队列满 + BridgeError(TIMEOUT): 等待超时 + """ + # CRITICAL 任务自动应用 delay_ms=0(除非调用方显式指定) + # 参见 2.3.1 节 delay_ms 与优先级交互策略 + if priority == Priority.CRITICAL and delay_ms is None: + delay_ms = 0 + return await self._enqueue_with_priority( + action, priority, delay_ms, wait_timeout_ms, trace_id + ) + + async def _enqueue_with_priority( + self, + coro_factory: CoroFactory, + priority: Priority, + delay_ms: Optional[int], + wait_timeout_ms: Optional[int], + trace_id: str, + ) -> Any: + """优先级入队(覆盖 SendQueue.enqueue 的内部实现)。 + + 与父类 enqueue 的差异: + - 用 put_nowait 替代 await put(满队时立即抛错,语义更清晰) + - 队列元素从 3 元组扩展为 5 元组(priority, seq, coro_factory, future, delay_ms) + - 增加入队日志(与父类保持一致的可观测性) + """ + loop = asyncio.get_running_loop() + future: asyncio.Future = loop.create_future() + self._seq += 1 + item = (int(priority), self._seq, coro_factory, future, delay_ms) + + # 入队(带队列满检查,与父类行为一致) + try: + self._queue.put_nowait(item) + except asyncio.QueueFull: + self._metrics["rate_limited_total"] += 1 + logger.warning( + "[scheduler] 队列已满 (size=%d/%d),拒绝入队 priority=%s", + self._queue.maxsize, self._queue.maxsize, priority.name, + ) + raise BridgeError( + code="RATE_LIMITED", + message=f"UI 调度器队列已满({self._queue.maxsize}),请稍后重试", + details={"retry_after": 3}, + ) + + pending = self._queue.qsize() + logger.info( + "[scheduler] 入队 priority=%s pending=%d trace_id=%s", + priority.name, pending, trace_id or "(none)", + ) + + # 等待结果(与父类逻辑一致,保留竞争窗口处理) + wait_start = time.monotonic() + if wait_timeout_ms is not None and wait_timeout_ms > 0: + try: + result = await asyncio.wait_for( + future, timeout=wait_timeout_ms / 1000.0 + ) + except asyncio.TimeoutError: + # 竞争窗口:worker 可能刚好在此时完成并 set_result + if future.done() and not future.cancelled(): + logger.info( + "[scheduler] 等待超时但任务刚好完成,取结果 (pending=%d)", + pending, + ) + return future.result() + future.cancel() + self._metrics["timeout_total"] += 1 + wait_sec = wait_timeout_ms / 1000.0 + logger.warning( + "[scheduler] 等待超时 (pending=%d, wait_timeout=%.1fs, priority=%s)", + pending, wait_sec, priority.name, + ) + raise BridgeError( + code="TIMEOUT", + message=f"UI 调度器等待超时({pending} 个待处理,已等 {wait_sec:.1f}s)", + details={"retry_after": max(1, int(self.send_delay_ms / 1000))}, + ) + else: + result = await future + + # 记录等待时长 + wait_ms = (time.monotonic() - wait_start) * 1000 + self._metrics["wait_duration_ms_sum"] += wait_ms + + return result + + async def enqueue( + self, + coro_factory: CoroFactory, + delay_ms: Optional[int] = None, + wait_timeout_ms: Optional[int] = None, + ) -> Any: + """向后兼容的入队方法(priority 默认 NORMAL)。 + + 现有 16 个 enqueue 调用点无需修改,行为与父类一致。 + """ + return await self._enqueue_with_priority( + coro_factory, Priority.NORMAL, delay_ms, wait_timeout_ms, "" + ) + + async def _run(self) -> None: + """worker 主循环。 + + ⚠️ 必须覆盖父类 _run:父类解包 3 元组,子类用 5 元组。 + + 与父类 _run 的差异: + - 解包 5 元组 (priority, seq, coro_factory, future, custom_delay_ms) + - 增加 metrics 记录(executed_total / executed_by_priority / exec_duration) + - 保留限流检查(_check_rate_limit 继承父类,不覆盖) + - 保留延时逻辑(delay_ms 优先于 send_delay_ms) + - task_done 调用与父类一致:cancelled 分支和 finally 分支互斥 + """ + while True: + try: + priority, seq, coro_factory, future, custom_delay_ms = ( + await self._queue.get() + ) + except asyncio.CancelledError: + raise + + # 调用方已超时取消:跳过执行与延时(与父类一致) + if future.cancelled(): + self._queue.task_done() + logger.info( + "[scheduler] 出队任务已取消,跳过 (pending=%d, priority=%s)", + self._queue.qsize(), + Priority(priority).name if 0 <= priority <= 3 else "?", + ) + continue + + # fast-fail 检查:微信已死期间,非 CRITICAL 任务直接失败 + # 参见 6.4 节 失败风暴问题与 fast-fail 机制 + if ( + self._wechat_dead_until > time.monotonic() + and priority > Priority.CRITICAL # CRITICAL 任务(=0)不受影响 + ): + logger.info( + "[scheduler] fast-fail (wechat dead, %.1fs remaining, priority=%s)", + self._wechat_dead_until - time.monotonic(), + Priority(priority).name if 0 <= priority <= 3 else "?", + ) + if not future.done(): + future.set_exception(BridgeError( + code="WECHAT_NOT_READY", + message="微信进程未就绪(重启中),请稍后重试", + details={"retry_after": 5}, + )) + self._metrics["fast_fail_total"] += 1 + self._queue.task_done() + continue # 不延时,立即处理下一个 + + logger.info( + "[scheduler] 出队,开始处理 (pending=%d, priority=%s)", + self._queue.qsize(), + Priority(priority).name if 0 <= priority <= 3 else "?", + ) + executed = False + t_exec = time.perf_counter() + try: + # 继承父类的限流检查 + self._check_rate_limit() + self._recent_call_times.append(time.monotonic()) + executed = True + logger.info("[scheduler] 开始执行任务 (priority=%s)", Priority(priority).name) + result = await coro_factory() + logger.info( + "[scheduler] 任务执行完成 (%.0fms, priority=%s)", + (time.perf_counter() - t_exec) * 1000, + Priority(priority).name, + ) + if not future.done(): + future.set_result(result) + except asyncio.CancelledError: + if not future.done(): + future.cancel() + raise + except Exception as e: + logger.warning( + "[scheduler] 任务执行抛异常 %s: %s (%.0fms, priority=%s)", + type(e).__name__, e, + (time.perf_counter() - t_exec) * 1000, + Priority(priority).name, + ) + if not future.done(): + future.set_exception(e) + finally: + self._queue.task_done() + if executed: + # 更新 metrics + exec_ms = (time.perf_counter() - t_exec) * 1000 + self._metrics["executed_total"] += 1 + self._metrics["exec_duration_ms_sum"] += exec_ms + try: + p = Priority(priority) + self._metrics["executed_by_priority"][p.name] += 1 + except ValueError: + pass + + delay = ( + custom_delay_ms + if custom_delay_ms is not None + else self.send_delay_ms + ) + logger.info( + "[scheduler] 延时 %dms 后处理下一个 (priority=%s)", + delay, Priority(priority).name, + ) + await asyncio.sleep(delay / 1000.0) + + def get_metrics(self) -> dict: + """返回调度器指标(供 /api/status 或 Prometheus 暴露)。 + + asyncio 单线程事件循环,与 worker 同线程,无需加锁。 + """ + return { + **self._metrics, + "pending_count": self._queue.qsize(), + } + + async def drain(self, timeout: float = 5.0) -> bool: + """等待队列清空(供 watchdog kill 微信前调用)。 + + 通过 _queue.join() 等待所有已入队任务被 task_done。 + worker 在 task_done 后可能仍在 sleep(delay),但队列已空, + sleep 结束后阻塞在 get() 上,不会执行新任务。 + + 边界条件: + - worker 已停止时 join 会永远等待(无人调 task_done),靠 timeout 兜底 + - drain 返回 True 后、kill 前若有新任务入队,worker 可能开始执行 + (概率低,kill 后执行失败被正常捕获) + + Args: + timeout: 最大等待秒数 + + Returns: + True 表示队列已清空,False 表示超时未清空 + """ + try: + await asyncio.wait_for(self._queue.join(), timeout=timeout) + return True + except asyncio.TimeoutError: + logger.warning( + "[scheduler] drain timeout %.1fs, %d tasks pending", + timeout, self._queue.qsize(), + ) + return False +``` + +### 3.2 优先级分配 + +| 调用点 | 优先级 | 理由 | +|---|---|---| +| `routes/login.py` wechat_restart | CRITICAL | 系统级紧急,需尽快执行 | +| `routes/diagnostic.py` autofix_wechat_running | CRITICAL | 同上 | +| `routes/login.py` logout | HIGH | 用户主动操作,感知延迟 | +| `routes/login.py` qr_start (capture_qr_code) | HIGH | 登录流程关键步骤 | +| `routes/contacts.py` accept_friend_request | HIGH | 好友申请有时效性 | +| `friend_watcher._handle_request` | HIGH | 同上 | +| `routes/send.py` send_text (flow + legacy) | NORMAL | 常规业务 | +| `routes/send.py` send_file (flow + legacy) | NORMAL | 同上 | +| `routes/send.py` revoke / forward | NORMAL | 同上 | +| `routes/contacts.py` set_remark / add_friend | NORMAL | 同上 | +| `routes/moments.py` publish / share / delete | NORMAL | 同上 | +| `routes/moments.py` like / comment | LOW | 可延迟,不阻塞主流程 | +| `routes/screenshot.py` capture_full_screenshot | LOW | 可延迟 | +| `routes/login.py` qr_wait (detect_login_state 轮询) | **不入队** | 只读探测,保持现状 | +| `LoginGuard._check_loop` | **不入队** | 只读探测,保持现状 | +| `WeChatWatchdog._run` (pgrep/xdpyinfo) | **不入队** | 系统级探测,保持现状 | +| `WeChatWatchdog._autofix_wechat` (kill) | **不入队但协同** | kill 前调 scheduler.drain() | + +### 3.3 收口方案(6 个 P0 调用点) + +#### 3.3.1 routes/login.py - logout + +**关键**:logout 路由在执行 logout 前会先调 `detect_login_state`(只读探测,不入队)。仅 `xdotool.logout()` 入队。 + +```python +# 修改前(routes/login.py:201) +if state_before == LoginState.LOGGED_IN.value: + await xdotool.logout() # 直接调,不入队 + +# 修改后 +if state_before == LoginState.LOGGED_IN.value: + scheduler = _require_send_queue() + await scheduler.execute( + lambda: xdotool.logout(), + priority=Priority.HIGH, + wait_timeout_ms=30000, # logout 多步操作,给 30s + trace_id="logout", + ) +``` + +**说明**:`detect_login_state` 保持不入队(只读探测,与 LoginGuard 一致),仅 `xdotool.logout()` 入队。 + +#### 3.3.2 routes/login.py - wechat_restart + +```python +# 修改后(routes/login.py:281) +scheduler = _require_send_queue() +new_pid = await scheduler.execute( + lambda: xdotool.restart_wechat(timeout_sec=timeout_sec), + priority=Priority.CRITICAL, + wait_timeout_ms=60000, # restart 可能慢,给 60s + trace_id="wechat_restart", +) +``` + +#### 3.3.3 routes/login.py - qr_start + +```python +# 修改后(routes/login.py:58-59) +scheduler = _require_send_queue() + +async def _qr_capture_flow(): + await xdotool._activate_window_fast() + return await qr_capture.capture_qr_code() + +qr_data_url = await scheduler.execute( + _qr_capture_flow, + priority=Priority.HIGH, + wait_timeout_ms=15000, + trace_id="qr_start", +) +``` + +#### 3.3.4 routes/screenshot.py + +```python +# 修改后(routes/screenshot.py:42) +scheduler = _require_send_queue() +png_bytes = await scheduler.execute( + lambda: qr_capture.capture_full_screenshot(), + priority=Priority.LOW, + wait_timeout_ms=10000, + trace_id="screenshot", +) +``` + +#### 3.3.5 routes/diagnostic.py - autofix_wechat_running + +```python +# 修改后(routes/diagnostic.py:230-249) +scheduler = _require_send_queue() + +async def _autofix_flow(): + # pkill + check_pid + start_wechat 组合 + proc = await asyncio.create_subprocess_exec( + "pkill", "-x", "wechat", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + await asyncio.sleep(2) # 等 autostart 拉起 + new_pid = await xdotool.check_wechat_pid() + if new_pid is None: + new_pid = await xdotool.start_wechat(timeout_sec=10) + return new_pid + +new_pid = await scheduler.execute( + _autofix_flow, + priority=Priority.CRITICAL, + wait_timeout_ms=30000, + trace_id="autofix_wechat_running", +) +``` + +#### 3.3.6 QrCapture 超时修复 + +```python +# ui/qr_capture.py 修改(L48-57) +_CMD_TIMEOUT_SEC = 5.0 # 新增常量 + +async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]: + """执行一条命令并返回 (returncode, stdout, stderr)。""" + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=self._env(), + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=_CMD_TIMEOUT_SEC + ) + return proc.returncode, stdout, stderr + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise +``` + +#### 3.3.7 WeChatWatchdog 协同 + +```python +# ui/watchdog.py 修改 + +class WeChatWatchdog: + def __init__( + self, + backend: BackendProtocol, + interval: float = 10.0, + fail_threshold: int = 2, + scheduler=None, # 新增可选参数,向后兼容 + ) -> None: + self.backend = backend + self.interval = interval + self.fail_threshold = fail_threshold + self._scheduler = scheduler # UIActionScheduler 实例(用于 drain) + self._fail_count = 0 + self._task: Optional[asyncio.Task] = None + self._stopped = False + + async def _autofix_wechat(self) -> None: + logger.warning("[watchdog] autofix: killing wechat for restart") + + # kill 前等待 UI 操作队列清空(避免 kill 正在执行的 UI 操作) + if self._scheduler is not None: + drained = await self._scheduler.drain(timeout=5.0) + if not drained: + logger.warning( + "[watchdog] drain timeout, force kill with %d UI ops pending", + self._scheduler.pending_count(), + ) + + # 原有 kill 逻辑(pgrep + kill -TERM)保持不变 + try: + proc = await asyncio.create_subprocess_exec( + "pgrep", "-x", "wechat", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + # ... 原有逻辑 +``` + +**app.py 注入**: +```python +# app.py:499 修改 +_state.watchdog = WeChatWatchdog( + backend=_state.xdotool_backend, + interval=10.0, + scheduler=_state.send_queue, # 新增:注入调度器 +) +``` + +### 3.4 注入与生命周期 + +```python +# app.py _init_state 修改(替换 L383-L387) +from woc_bridge.messaging.ui_action_scheduler import UIActionScheduler, Priority + +_state.send_queue = UIActionScheduler( # 替换原 SendQueue + send_delay_ms=cfg.send_delay_ms, + max_calls_per_sec=cfg.max_calls_per_sec, + max_queue_size=cfg.max_queue_size, +) +# _state.send_queue 类型注解保持 SendQueue(多态,UIActionScheduler 是子类) +# 现有 _require_send_queue() 返回 SendQueue 类型,调用方无需修改 +# 调用方可用 hasattr(scheduler, 'execute') 判断是否支持优先级 + +# watchdog 注入 scheduler(app.py:499 修改) +_state.watchdog = WeChatWatchdog( + backend=_state.xdotool_backend, + interval=10.0, + scheduler=_state.send_queue, # 新增:注入调度器用于 drain +) +``` + +**生命周期不变**: +- `send_queue.start()` / `send_queue.stop()` 继承父类,lifespan 中保持原顺序 +- `watchdog.start()` / `watchdog.stop()` 保持原顺序 +- shutdown 时 `verify_bus.clear()` 在 `send_queue.stop()` 之前(已有逻辑) + +### 3.5 Metrics 暴露 + +#### 3.5.1 StatusResponse 模型扩展 + +```python +# bridge/woc_bridge/models/status.py 修改 +class StatusResponse(BaseModel): + # ... 现有字段保持不变 ... + send_queue_pending: int = Field(default=0, description="发送队列积压任务数") + + # 新增字段(Optional,向后兼容) + ui_scheduler: Optional[dict] = Field( + default=None, + description="UI 调度器指标(仅 UIActionScheduler 实例才有)", + ) +``` + +#### 3.5.2 routes/status.py 暴露 metrics + +```python +# routes/status.py 修改(L96 附近) +send_queue = _state.send_queue +send_queue_pending = send_queue.pending_count() if send_queue else 0 +ui_scheduler_metrics = None +if send_queue is not None and hasattr(send_queue, 'get_metrics'): + ui_scheduler_metrics = send_queue.get_metrics() + +return StatusResponse( + # ... 现有字段 ... + send_queue_pending=send_queue_pending, + ui_scheduler=ui_scheduler_metrics, + # ... +) +``` + +返回示例: +```json +{ + "send_queue_pending": 0, + "ui_scheduler": { + "executed_total": 1234, + "executed_by_priority": { + "CRITICAL": 2, + "HIGH": 15, + "NORMAL": 1200, + "LOW": 17 + }, + "wait_duration_ms_sum": 45678.9, + "exec_duration_ms_sum": 234567.8, + "timeout_total": 3, + "rate_limited_total": 1, + "pending_count": 0 + } +} +``` + +#### 3.5.3 激活已存在的 Prometheus metrics + +**发现问题**:`ui/metrics.py:58` 已定义 `woc_send_queue_pending` Gauge,但**从未在任何地方 set 它的值**(已存在的 bug)。 + +```python +# ui/metrics.py 已有定义(无需修改) +send_queue_pending = Gauge( + "woc_send_queue_pending", + "Send queue pending count", +) + +# 新增:在 routes/status.py 或 app.py 中激活 +# 方案 A:在 status 接口中 set(每次查询时更新) +from woc_bridge.ui.metrics import send_queue_pending as send_queue_pending_gauge +if send_queue is not None: + send_queue_pending_gauge.set(send_queue.pending_count()) + +# 方案 B:在 UIActionScheduler._run 中 set(每次出队时更新) +# 更实时但会增加 metrics 写入频率 +``` + +**推荐方案 A**:在 status 接口中 set,与现有 `send_queue_pending` 字段同步更新,避免高频写入 Prometheus。 + +#### 3.5.4 新增 Prometheus metrics(可选,阶段 3) + +```python +# ui/metrics.py 新增 +from prometheus_client import Counter, Gauge, Histogram + +ui_action_executed = Counter( + "woc_ui_action_executed_total", + "UI actions executed total", + ["priority"], # CRITICAL / HIGH / NORMAL / LOW +) + +ui_action_wait_duration = Histogram( + "woc_ui_action_wait_duration_seconds", + "UI action wait duration (queue wait time)", + buckets=(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0), +) + +ui_action_exec_duration = Histogram( + "woc_ui_action_exec_duration_seconds", + "UI action execution duration", + ["priority"], + buckets=(0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0), +) + +ui_action_timeout = Counter( + "woc_ui_action_timeout_total", + "UI action wait timeout count", +) + +ui_action_rate_limited = Counter( + "woc_ui_action_rate_limited_total", + "UI action rate limited (queue full) count", +) +``` + +在 `UIActionScheduler._run` 和 `_enqueue_with_priority` 中埋点。 + +--- + +## 四、迁移路径 + +### 阶段 1:基础设施(无破坏性) + +**目标**:引入 UIActionScheduler,向后兼容现有调用 + +**改动**: +1. 新增 `messaging/ui_action_scheduler.py`(Priority + UIActionScheduler 类) +2. `app.py` 将 `SendQueue(...)` 替换为 `UIActionScheduler(...)` +3. `watchdog.py` 增加 `scheduler` 参数(可选,向后兼容) +4. `routes/status.py` 暴露 metrics + +**验证**: +- py_compile 通过 +- 现有 16 个 `enqueue` 调用点无需修改,行为不变 +- `/api/status` 返回 `ui_scheduler` 字段 + +### 阶段 2:收口 P0 调用点 + +**目标**:消除 6 个绕过队列的竞态风险 + QrCapture 超时修复 + +**改动**: +1. `routes/login.py`:logout / restart / qr_start 改用 `scheduler.execute` + - logout 仅 `xdotool.logout()` 入队,`detect_login_state` 保持不入队 +2. `routes/screenshot.py`:改用 `scheduler.execute(priority=LOW)` +3. `routes/diagnostic.py`:autofix 改用 `scheduler.execute(priority=CRITICAL)` +4. `ui/qr_capture.py`:增加 `_CMD_TIMEOUT_SEC=5.0` 包裹 `proc.communicate()` +5. `ui/watchdog.py`:构造函数新增 `scheduler` 参数;`_autofix_wechat` 增加 `scheduler.drain()` 调用 +6. `app.py`:`WeChatWatchdog(...)` 注入 `scheduler=_state.send_queue` + +**验证**: +- py_compile 通过 +- 并发调用 `POST /api/login/logout` + `POST /api/send/text` 不再竞态(logout 入队等待) +- `POST /api/screenshot` 与 send_text 串行执行 +- watchdog autofix 前等待 UI 队列清空(`drain` 返回 True 后再 kill) +- QrCapture scrot 卡死时 5s 超时(不再无限阻塞) + +### 阶段 3:可观测性增强(可选) + +**目标**:提供 Prometheus metrics 和详细日志 + +**改动**: +1. `models/status.py`:`StatusResponse` 新增 `ui_scheduler: Optional[dict]` 字段 +2. `routes/status.py`:暴露 `ui_scheduler` metrics + 激活已存在的 `woc_send_queue_pending` Gauge +3. `ui/metrics.py`:新增 `woc_ui_action_*` 系列 metrics(Counter/Histogram) +4. `UIActionScheduler._run` / `_enqueue_with_priority`:埋点写入 Prometheus metrics +5. 日志增加 `priority` 和 `trace_id` 字段(已在阶段 1 代码中包含) + +**验证**: +- `GET /api/status` 返回 `ui_scheduler` 字段 +- `/metrics` 端点返回 `woc_ui_action_executed_total` 等指标 +- 日志可按 `priority=CRITICAL` 过滤 +- 已存在的 `woc_send_queue_pending` Gauge 不再为 0(bug 修复) + +--- + +## 五、验收标准 + +### AC-01:UIActionScheduler 向后兼容 + +**Given** 现有 16 个 `enqueue` 调用点未修改 +**When** `app.py` 将 `SendQueue(...)` 替换为 `UIActionScheduler(...)` +**Then** 所有现有功能行为不变,py_compile 通过,单元测试通过 + +### AC-02:优先级调度生效 + +**Given** 队列中有 10 个 NORMAL 优先级的 send_text 任务 +**When** 提交一个 CRITICAL 优先级的 wechat_restart +**Then** wechat_restart 优先于剩余 NORMAL 任务执行(可能在当前 NORMAL 任务执行完后立即执行) + +### AC-03:logout 与 send_text 不再竞态 + +**Given** 一个 send_text 正在 send_queue 中执行 +**When** 并发调用 `POST /api/login/logout` +**Then** logout 入队等待,send_text 执行完后 logout 才开始执行 + +### AC-04:QrCapture 有超时保护 + +**Given** scrot 子进程卡死 +**When** `QrCapture._run` 执行 +**Then** 5 秒后超时,`proc.kill()` + `await proc.wait()`,抛出 TimeoutError + +### AC-05:watchdog autofix 协同 + +**Given** send_queue 中有 5 个 pending 任务 +**When** watchdog 触发 `_autofix_wechat` +**Then** 先 `scheduler.drain(timeout=5.0)` 等待队列清空,超时则强制 kill 并记 warning + +### AC-06:metrics 暴露 + +**Given** 调度器执行了若干操作 +**When** 调用 `GET /api/status` +**Then** 返回 `ui_scheduler` 字段,包含 `executed_total` / `executed_by_priority` / `pending_count` 等指标 + +### 5.1 关键场景时序图 + +#### 5.1.1 修改前:logout 与 send_text 并发竞态 + +``` +时刻 T0: send_text 正在执行(队列 worker 持有 coro_factory) + ┌─────────────────────────────────────────────────────┐ +Worker: │ send_text Flow: │ + │ activate → click_search → type_query → ... │ + │ ↑ 每步持 L1 _ui_lock,步骤间释放 │ + └─────────────────────────────────────────────────────┘ + +时刻 T1: HTTP 请求 POST /api/login/logout 到达 + ↓ logout 路由直接调 xdotool.logout()(不入队) + ↓ logout 尝试 acquire _ui_lock → 等待 send_text 释放 + +时刻 T2: send_text 执行 click_search_box 完成释放 _ui_lock + ↓ logout 抢到 _ui_lock,执行 activate + ↓ logout 释放 _ui_lock + ↓ send_text 抢到 _ui_lock,执行 type_query + ↓ ❌ 焦点已被 logout 改变,type_query 输入到错误位置! + +时刻 T3: logout 再次抢 _ui_lock 执行 click_main_menu + ↓ ❌ send_text 可能仍在执行,菜单状态不可预测 + +结果:send_text 发错会话,logout 失败,UI 状态混乱 +``` + +#### 5.1.2 修改后:logout 与 send_text 串行执行 + +``` +时刻 T0: send_text 正在执行(worker 持有) + ┌─────────────────────────────────────────────────────┐ +Worker: │ send_text Flow (NORMAL priority) │ + │ activate → click_search → type_query → ... │ + └─────────────────────────────────────────────────────┘ + +时刻 T1: HTTP 请求 POST /api/login/logout 到达 + ↓ logout 路由调 scheduler.execute(logout, HIGH, 30s) + ↓ 入队 (HIGH, seq=N),pending=1 + ↓ worker 仍在执行 send_text,logout 等待 future + +时刻 T2: send_text 完成,worker 出队 + ↓ PriorityQueue 出队 (HIGH, N)(优先于其他 NORMAL 任务) + ↓ 执行 xdotool.logout() 完整流程(多步操作不被穿插) + ↓ logout 完成,返回 success + +结果:send_text 与 logout 串行,UI 状态正确 +``` + +#### 5.1.3 watchdog kill 与 drain 协同 + +``` +时刻 T0: send_queue 中有 5 个 pending send_text + worker 正在执行第 1 个 send_text + +时刻 T1: watchdog 检测到 wechat 不响应(fail_count >= 2) + ↓ watchdog 调 _autofix_wechat + ↓ scheduler.drain(timeout=5.0) + ↓ 等待 _queue.join(),worker 继续执行当前任务 + +时刻 T2: worker 完成当前 send_text(task_done) + ↓ _queue.join() 检查未完成任务数 + ↓ 仍有 4 个 pending(task_done 计数未归零) + ↓ worker 出队下一个 send_text 并执行... + ↓ ⚠️ 若每个 send_text 耗时 > 1.25s,5s 内无法清空 + +时刻 T3a (5s 内清空): + ↓ drain 返回 True + ↓ scheduler.mark_wechat_dead(15.0) ← 启动 fast-fail 窗口 + ↓ pgrep + kill -TERM wechat + ↓ 后续 15s 内新入队任务直接 fast-fail(WECHAT_NOT_READY) + +时刻 T3b (5s 超时未清空): + ↓ drain 返回 False,记 warning + ↓ scheduler.mark_wechat_dead(15.0) + ↓ pgrep + kill -TERM wechat + ↓ ❌ 仍有 N 个 pending 任务,kill 后每个都会失败一次 + +时刻 T4: autostart 拉起新 wechat 进程(~5-10s) + ↓ mark_wechat_dead 窗口未到期,新任务继续 fast-fail + ↓ 窗口到期后,新任务正常执行(wechat 已就绪) + +结果:drain 减少失败任务数,mark_wechat_dead 避免失败风暴放大 +``` + +#### 5.1.4 优先级插队场景 + +``` +时刻 T0: 队列状态:[send1(NORMAL,seq=1), send2(NORMAL,seq=2), ..., send10(NORMAL,seq=10)] + worker 正在执行 send1 + +时刻 T1: HTTP 请求 POST /api/wechat/restart 到达 + ↓ scheduler.execute(restart, CRITICAL, 0ms, 60s) + ↓ 入队 (CRITICAL=0, seq=11) + ↓ PriorityQueue 重排:[(0,11), (2,2), (2,3), ..., (2,10)] + ↓ pending=10(restart 排第 1) + +时刻 T2: send1 完成,worker 出队 + ↓ 出队 (0, 11) → restart 任务 + ↓ scheduler.mark_wechat_dead(8.0) ← 标记 fast-fail 窗口 + ↓ 执行 restart_wechat (60s 超时) + +时刻 T3: restart 完成(~5s) + ↓ 后续 8s 内 (mark_wechat_dead 窗口): + ↓ send2-send10 出队 → 检查 _wechat_dead_until → fast-fail + ↓ 客户端收到 WECHAT_NOT_READY + retry_after=5 + ↓ 8s 后窗口到期,新任务正常执行 + +结果:restart 在 1 个 send 完成后立即执行(不等 send2-send10), + 后续 send 通过 fast-fail 快速失败,避免 9 次无谓的 xdotool 超时 +``` + +--- + +## 六、风险与依赖 + +### 6.1 技术风险 + +| 风险 | 概率 | 影响 | 缓解 | +|---|---|---|---| +| PriorityQueue 元组比较失败 | 低 | worker 崩溃 | `coro_factory` 不可比较,用 `seq` 序列号作为第二排序键,`(priority, seq)` 组合唯一,不会比较到 coro_factory | +| 父类 `_run` 与子类 `_run` 元组结构不一致 | 中 | 父类被误调用时解包失败 | 子类必须覆盖 `_run`;代码注释明确标注"覆盖父类";单元测试验证子类 `_run` 被调用 | +| 父类 `__init__` 创建 `asyncio.Queue` 后被子类 `asyncio.PriorityQueue` 覆盖 | 低 | 内存短暂多出一个未使用 Queue 对象 | Python 属性遮蔽合法,旧 Queue 被 GC 回收,无副作用 | +| 优先级反转导致 HIGH 操作被 LOW 阻塞 | 中 | logout 等待 screenshot 完成 | 限制单次操作超时(已有 5s/30s),screenshot 入 LOW 但超时 10s | +| drain 阻塞 watchdog | 中 | autofix 延迟 | drain 超时 5s 后强制 kill,不无限等待 | +| drain 返回 True 后 worker 仍在 sleep | 低 | kill 时机略早于 sleep 结束 | 队列已空,worker sleep 结束后阻塞在 `get()` 上,不会执行新任务;kill 后 worker 执行失败被正常捕获 | +| 收口后 logout 等 HTTP 接口延迟增加 | 中 | 用户感知 | priority=HIGH 保证优先级,wait_timeout_ms 兜底 | +| `put_nowait` 与父类 `await put` 行为差异 | 低 | 满队时子类立即抛错,父类先检查再 put | 行为等价(父类也有前置 qsize 检查),子类更简洁 | +| `task_done()` 调用次数 | 低 | 计数器异常 | cancelled 分支和 finally 分支互斥(continue 跳过 finally),每个 `get()` 对应一次 `task_done()`,与父类一致 | +| metrics 并发读写 | 低 | 数据轻微不准 | asyncio 单线程事件循环,worker 与 HTTP handler 同线程,无需加锁;`asyncio.to_thread` 释放事件循环时不读写 `_metrics` | + +### 6.2 依赖 + +- 依赖现有 `SendQueue` 实现稳定(已验证 16 个调用点) +- 依赖 `_ui_lock` 作为底层互斥(保持不变) +- 依赖 `asyncio.PriorityQueue`(Python 3.8+ 标准库,无外部依赖) + +### 6.3 非目标 + +- **不实现优先级抢占**:xdotool 子进程不可中断,低优先级操作开始后必须等其完成 +- **不实现优先级继承**:xdotool 子进程无法感知调用方优先级 +- **不重命名 `SendQueue` 类**:保持向后兼容,UIActionScheduler 继承之 +- **不修改 `_ui_lock`**:保持作为底层单命令互斥的第二道防线 +- **不收口 LoginGuard / MessageStreamer**:它们是只读探测,无需入队 + +### 6.4 失败风暴问题与 fast-fail 机制 + +**问题场景**:当 watchdog kill 微信或 `wechat_restart` 完成后,send_queue 中可能仍有 N 个已入队的 send_text 任务。这些任务会按顺序执行,每个都因 `WINDOW_NOT_FOUND` 失败一次(直到 autostart 拉起新进程)。 + +**影响估算**: +- 100 个 send_text 任务排队,每个执行失败耗时 ~1s(含 5s xdotool 超时 + 异常处理) +- 微信 autostart 拉起需 ~5-10s +- 失败风暴持续:`min(N, autostart拉起前积压数) × 1s` ≈ 5-10s 内 5-10 个任务连续失败 +- 客户端收到 5-10 个 503 错误,可能触发重试,进一步放大风暴 + +**fast-fail 机制设计**(**本方案可选实施,建议阶段 2 一并实施**): + +```python +class UIActionScheduler(SendQueue): + def __init__(self, ...): + super().__init__(...) + # ... + self._wechat_dead_until: float = 0.0 # 时间戳,在此之前所有任务直接 fast-fail + + def mark_wechat_dead(self, duration_sec: float = 10.0) -> None: + """标记微信已死,期间所有新任务直接 fast-fail。 + + 供 watchdog kill / wechat_restart 调用: + - kill 前调 mark_wechat_dead(15.0),覆盖 autostart 拉起窗口 + - restart 完成后调 mark_wechat_dead(5.0),覆盖新进程启动窗口 + """ + self._wechat_dead_until = time.monotonic() + duration_sec + logger.warning( + "[scheduler] mark_wechat_dead %.1fs, %d pending tasks will fast-fail", + duration_sec, self._queue.qsize(), + ) + + async def _run(self) -> None: + while True: + priority, seq, coro_factory, future, custom_delay_ms = await self._queue.get() + # ... cancelled 检查 ... + + # fast-fail 检查:微信已死期间所有任务直接失败 + if self._wechat_dead_until > time.monotonic(): + logger.info( + "[scheduler] fast-fail (wechat dead, %.1fs remaining, priority=%s)", + self._wechat_dead_until - time.monotonic(), + Priority(priority).name, + ) + if not future.done(): + future.set_exception(BridgeError( + code="WECHAT_NOT_READY", + message="微信进程未就绪(重启中),请稍后重试", + details={"retry_after": 5}, + )) + self._queue.task_done() + continue # 不延时,立即处理下一个 + + # ... 正常执行流程 ... +``` + +**调用点**: +- `watchdog._autofix_wechat` kill 前调 `scheduler.mark_wechat_dead(15.0)` +- `routes/login.py wechat_restart` 开始时调 `scheduler.mark_wechat_dead(8.0)` +- `routes/diagnostic.py autofix_wechat_running` 同上 + +**注意事项**: +- `WECHAT_NOT_READY` 是新错误码,需在 `models/errors.py` 注册 HTTP 503 映射 +- fast-fail 任务不计入 `executed_total`,应单独记 `fast_fail_total` 指标 +- CRITICAL 任务(如 restart 本身)不应被 fast-fail 拦截,需在检查中排除: + ```python + if self._wechat_dead_until > time.monotonic() and priority > Priority.CRITICAL: + ``` + +**替代方案对比**: + +| 方案 | 优点 | 缺点 | +|---|---|---| +| fast-fail(推荐) | 立即拒绝,客户端快速收到错误并退避 | 需新增错误码 + 调用点埋点 | +| drain + cancel pending | 队列清空,无失败任务 | cancel 已入队 future 复杂,可能误取消正在执行的任务 | +| 不处理(保持现状) | 实现简单 | 5-10 个连续失败,客户端可能重试放大风暴 | + +--- + +## 七、变更记录 + +| 版本 | 日期 | 修改人 | 摘要 | +|---|---|---|---| +| v1.0 | 2026-07-17 | - | 初稿,基于 SendQueue 调研设计 UIActionScheduler 统一调度方案 | +| v1.1 | 2026-07-17 | - | 深度复核:修正 task_done 互斥说明、put_nowait 语义、logout 前置 detect_login_state 处理、StatusResponse 模型扩展、激活已存在 Prometheus Gauge、drain 边界条件、metrics 线程安全 | +| v1.2 | 2026-07-18 | - | 深度调研优化:新增 1.4 双层保护模型(L1 _ui_lock vs L2 SendQueue)、1.5 调用点全景图(before/after)、1.6 HTTP 端点→优先级映射表、2.3.1 delay_ms 与优先级交互策略、5.1 关键场景时序图(4 个)、6.4 失败风暴问题与 fast-fail 机制(mark_wechat_dead)、UIActionScheduler 代码新增 mark_wechat_dead 方法与 _run fast-fail 检查、P1.1 detect_login_state 14 处散落调用分析与处理方案 | + +--- + +## 八、实施前验证清单 + +实施前需确认以下事项,避免引入新问题: + +### 8.1 代码复核清单 + +- [ ] `asyncio.PriorityQueue.put_nowait` 满时抛 `asyncio.QueueFull`(已确认,与 `asyncio.Queue` 行为一致) +- [ ] `asyncio.PriorityQueue` 的 `task_done()` / `join()` 语义与 `asyncio.Queue` 一致(已确认,继承关系) +- [ ] 元组 `(int, int, coro_factory, future, delay_ms)` 中 `seq` 单调递增保证唯一,不会比较到 `coro_factory`(已确认) +- [ ] 父类 `__init__` 创建的 `asyncio.Queue` 会被子类 `asyncio.PriorityQueue` 覆盖,旧对象无其他引用,GC 回收(已确认) +- [ ] 父类 `_run` 被子类覆盖,不会调用父类的 3 元组解包(已确认,代码注释标注) +- [ ] `_check_rate_limit` 继承父类,行为不变(已确认,子类不覆盖) +- [ ] `send_queue.start()` / `stop()` 继承父类,worker task 管理 不变(已确认) +- [ ] `pending_count()` 继承父类,返回 `self._queue.qsize()`(已确认,PriorityQueue 也有 qsize) + +### 8.2 单元测试清单 + +实施时需编写以下单元测试(参考 `verify_bus.py` 的测试模式): + +```python +# test_ui_action_scheduler.py + +async def test_backward_compat_enqueue(): + """enqueue 方法向后兼容,priority 默认 NORMAL。""" + scheduler = UIActionScheduler(send_delay_ms=10, max_calls_per_sec=100) + await scheduler.start() + try: + result = await scheduler.enqueue(lambda: asyncio.sleep(0.01, result="ok")) + assert result == "ok" + metrics = scheduler.get_metrics() + assert metrics["executed_total"] == 1 + assert metrics["executed_by_priority"]["NORMAL"] == 1 + finally: + await scheduler.stop() + +async def test_priority_ordering(): + """CRITICAL 优先于 NORMAL 执行。""" + scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100) + await scheduler.start() + try: + # 先入队一个 NORMAL(会立即开始执行) + normal_future = scheduler.enqueue(lambda: asyncio.sleep(0.1, result="normal")) + # 再入队 CRITICAL 和 NORMAL + critical_future = scheduler.execute( + lambda: asyncio.sleep(0.01, result="critical"), + priority=Priority.CRITICAL, + ) + normal2_future = scheduler.execute( + lambda: asyncio.sleep(0.01, result="normal2"), + priority=Priority.NORMAL, + ) + # CRITICAL 应先于 normal2 完成 + critical_result = await critical_future + normal2_result = await normal2_future + assert critical_result == "critical" + assert normal2_result == "normal2" + # 验证执行顺序:CRITICAL 在 normal2 之前 + assert scheduler.get_metrics()["executed_by_priority"]["CRITICAL"] == 1 + finally: + await scheduler.stop() + +async def test_drain_empty_queue(): + """空队列 drain 立即返回 True。""" + scheduler = UIActionScheduler() + result = await scheduler.drain(timeout=1.0) + assert result is True + +async def test_drain_with_pending(): + """有 pending 任务时 drain 等待完成。""" + scheduler = UIActionScheduler(send_delay_ms=100) + await scheduler.start() + try: + # 入队一个任务 + await scheduler.enqueue(lambda: asyncio.sleep(0.05, result="ok")) + # drain 等待完成 + result = await scheduler.drain(timeout=2.0) + assert result is True + finally: + await scheduler.stop() + +async def test_drain_timeout(): + """任务执行超过 drain timeout 时返回 False。""" + scheduler = UIActionScheduler(send_delay_ms=0) + await scheduler.start() + try: + # 入队一个长任务 + scheduler.enqueue(lambda: asyncio.sleep(1.0, result="slow")) + # drain 短超时 + result = await scheduler.drain(timeout=0.1) + assert result is False + finally: + await scheduler.stop() + +async def test_queue_full_rate_limited(): + """队列满时抛 RATE_LIMITED。""" + scheduler = UIActionScheduler(send_delay_ms=1000, max_queue_size=2) + await scheduler.start() + try: + # 填满队列(1 个执行中 + 2 个排队) + scheduler.enqueue(lambda: asyncio.sleep(0.5)) + scheduler.enqueue(lambda: asyncio.sleep(0.01)) + scheduler.enqueue(lambda: asyncio.sleep(0.01)) + # 第 4 个应被拒绝 + with pytest.raises(BridgeError) as exc_info: + await scheduler.enqueue(lambda: asyncio.sleep(0.01)) + assert exc_info.value.code == "RATE_LIMITED" + finally: + await scheduler.stop() + +async def test_wait_timeout(): + """等待超时抛 TIMEOUT。""" + scheduler = UIActionScheduler(send_delay_ms=1000, max_calls_per_sec=100) + await scheduler.start() + try: + # 第一个任务慢 + scheduler.enqueue(lambda: asyncio.sleep(0.5)) + # 第二个任务等待超时 + with pytest.raises(BridgeError) as exc_info: + await scheduler.enqueue( + lambda: asyncio.sleep(0.01), + wait_timeout_ms=100, # 100ms 超时 + ) + assert exc_info.value.code == "TIMEOUT" + finally: + await scheduler.stop() + +async def test_metrics_accuracy(): + """metrics 准确记录执行次数和优先级。""" + scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100) + await scheduler.start() + try: + await scheduler.execute(lambda: "a", priority=Priority.CRITICAL) + await scheduler.execute(lambda: "b", priority=Priority.HIGH) + await scheduler.execute(lambda: "c", priority=Priority.NORMAL) + await scheduler.execute(lambda: "d", priority=Priority.LOW) + metrics = scheduler.get_metrics() + assert metrics["executed_total"] == 4 + assert metrics["executed_by_priority"]["CRITICAL"] == 1 + assert metrics["executed_by_priority"]["HIGH"] == 1 + assert metrics["executed_by_priority"]["NORMAL"] == 1 + assert metrics["executed_by_priority"]["LOW"] == 1 + assert metrics["pending_count"] == 0 + finally: + await scheduler.stop() +``` + +### 8.3 集成测试清单 + +阶段 2 实施后需进行集成测试: + +- [ ] 并发 30 个 `POST /api/send/text` + 1 个 `POST /api/login/logout`:logout 在所有 send 之前或之后执行,不穿插 +- [ ] `POST /api/screenshot` 与 `POST /api/send/text` 并发:screenshot 串行等待 +- [ ] watchdog autofix 触发时 send_queue 有 pending:先 drain 再 kill +- [ ] `GET /api/status` 返回 `ui_scheduler` 字段且 `executed_by_priority` 计数正确 +- [ ] `/metrics` 端点 `woc_send_queue_pending` Gauge 不再恒为 0 + +### 8.4 回归测试清单 + +阶段 1 实施后需验证现有功能不退化: + +- [ ] `POST /api/send/text` 正常发送(16 个 enqueue 调用点之一) +- [ ] `POST /api/friends/accept` 正常通过好友(friend_watcher + routes/contacts 共用 enqueue) +- [ ] `POST /api/moments/publish` 正常发朋友圈(routes/moments enqueue) +- [ ] BatchWorker 群发正常串行(间接经 orchestrator → enqueue) +- [ ] `GET /api/status` 的 `send_queue_pending` 字段仍正确