# 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_<unix秒>_<随机>`），仅用于客户端幂等去重，**不对应微信原生 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_<MD5(talker)>` 表名直接查单表；`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_<uuid.hex[:16]>`），重复 `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_<unix秒>_<随机>` 或 `share_<unix秒>_<随机>`，不对应微信原生朋友圈 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`，每个任务建 `<task_id>/` 子目录。
  - **懒清理**：`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_<uuid.hex[:16]>` |
| `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_<resolution>/light/`）<br>2. 几何兜底 + 图像兜底双策略<br>3. experimental 接口明确标注需实测调优 |
| 群发封号 | 高频群发触发微信风控，账号受限或封禁 | 1. 风控参数：单批 ≤ 200 / 日频次 ≤ 3 / 批间隔 ≥ 7200s<br>2. 单条间隔抖动 2-4s<br>3. `{nickname}` 占位符个性化内容<br>4. `abort_on_consecutive_fail` 阈值中止 |
| DB 密钥提取失败 | 自动提取失败，所有 DB 查询接口不可用 | 1. 三层降级：env `WOC_DB_KEY` → API `POST /api/db/decrypt` 手动注入 → 自动内存扫描<br>2. `db_accessible` 诊断项 + autofix 引导<br>3. `repair_plan` 字段提示具体操作 |
| 并发串号 | 高并发场景下消息发到错误会话 | 1. 复合游标 `(create_time, local_id)` 防基线误匹配<br>2. 发送后 DB 校验内容匹配（防 talker 字段误判）<br>3. SessionCache 失效时仅清当前联系人，不误伤其他缓存 |
| WAL 刷盘延迟 | 发送后立即查 DB 看不到新消息，校验失败 | 1. 等 WAL 刷盘 1.5-2s 后再轮询<br>2. DB 校验超时 3s（间隔 0.2s），允许 15 次轮询<br>3. 校验失败不阻塞返回，仅记 warning + `verified=false` |
| 多实例隔离不足 | 多账号场景下实例间状态串扰 | 1. 一个 bridge 进程对应一个微信账号<br>2. 多账号需多容器实例隔离<br>3. 数据卷与配置独立 |
| Token 无细粒度权限 | Token 泄露后授予全部功能 | 1. Token 通过环境变量配置，避免硬编码<br>2. 由上游反代实现细粒度路由鉴权<br>3. 监控异常调用模式（`/metrics` 暴露发送计数） |
| SSE 订阅者累积 | 僵尸连接累积导致内存泄漏 | 1. `MAX_SUBSCRIBERS=3` 上限，超限剔除最早订阅者<br>2. 队列满时丢弃最旧事件<br>3. 客户端断开自动取消订阅 |
| 慢请求拖垮 event loop | UI 操作耗时过长阻塞其他请求 | 1. DB 同步操作用 `asyncio.to_thread` 包装<br>2. 慢请求阈值 `2000ms` 升级 WARNING 日志<br>3. 大文件超时自适应（30 + size_mb × 1.5，上限 180s） |
| 调试截图磁盘占满 | `WOC_UI_DEBUG_SHOTS=true` 时截图累积 | 1. 生产环境必须 `false`<br>2. ResourceReaper 每小时清理（max_age 24h，max_total 100MB）<br>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 模块的需求基线进入评审流程。

