refactor: 重构发送校验逻辑,移除DB轮询与熔断器,改用SSE校验

主要变更:
1. 新增SSEVerifyBus实现基于推送的发送结果校验,替代原DB轮询方案
2. 移除FlowOrchestrator中的DB熔断器,避免雪崩风险
3. 重构SendTextFlow,改用SSE校验流程,删除原DB校验相关代码
4. 新增RoiSpec与ROI搜索支持,优化UI元素匹配精度
5. 更新所有分辨率配置文件,添加ROI配置与调整搜索框坐标
6. 新增WOC_DISABLE_OPENCV环境变量开关,支持纯几何定位模式
7. 清理FlowContext中废弃的before_msg_id字段
This commit is contained in:
Kris 2026-07-17 23:50:03 +08:00
parent c45282f094
commit 054c4676aa
16 changed files with 713 additions and 427 deletions

View File

@ -149,6 +149,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await asyncio.wait_for(_state.message_streamer.stop(), timeout=5.0)
except (asyncio.TimeoutError, Exception):
logger.warning("[lifespan] message_streamer.stop() 超时或异常,强制继续")
# 清理 SSE 发送结果校验总线resolve 所有 pending 等待为 False
if _state.verify_bus is not None:
try:
_state.verify_bus.clear()
except Exception:
logger.warning("[lifespan] verify_bus.clear() 异常,强制继续")
# 停止群发后台协程LIFO先于 send_queue 停止,避免新任务继续入队)
if _state.batch_worker is not None:
try:
@ -380,11 +386,19 @@ def _init_state(cfg: BridgeConfig) -> None:
max_queue_size=cfg.max_queue_size,
)
# SSE 发送结果校验总线send_text 发送后注册等待,
# MessageStreamer 广播消息时 notify 匹配 talker + content。
# 替代 DB 轮询校验,避免熔断器雪崩。
from woc_bridge.messaging.verify_bus import SSEVerifyBus
_state.verify_bus = SSEVerifyBus(default_timeout_sec=2.5)
# 消息流推送器:内部轮询 DB + SSE 广播lifespan 中 start/stop
# 注入 verify_bus广播 messages 事件时 notify 匹配 pending 的发送等待
_state.message_streamer = MessageStreamer(
db_reader=_state.db_reader,
resolve_db_state=_resolve_db_state_tuple,
extract_key=lambda: _auto_extract_with_lock(force=True),
verify_bus=_state.verify_bus,
)
# 读取 WOC_DB_KEY 环境变量,注入 DbReader
@ -461,6 +475,7 @@ def _init_state(cfg: BridgeConfig) -> None:
db_reader=_state.db_reader,
config=cfg,
send_file_flow=send_file_flow,
verify_bus=_state.verify_bus,
)
# 构造 Capabilities

View File

@ -241,6 +241,8 @@ class AppState:
self.send_queue: SendQueue | None = None
self.qr_capture: QrCapture | None = None
self.message_streamer: MessageStreamer | None = None
# SSE 发送结果校验总线send_text 校验,避免 DB 轮询雪崩)
self.verify_bus: Optional[Any] = None # SSEVerifyBus
self.start_time: float = 0.0
# DB 解密密钥缓存env / api / auto_extract / file 注入)
self.key_cache = KeyCache()

View File

@ -97,6 +97,7 @@ class MessageStreamer:
db_reader: "DbReader",
resolve_db_state: DbStateResolver,
extract_key: Optional[Callable[[], Awaitable[Optional[dict[str, str]]]]] = None,
verify_bus: Optional["SSEVerifyBus"] = None,
) -> None:
"""初始化推送器。
@ -107,10 +108,14 @@ class MessageStreamer:
extract_key: 异步回调强制重新提取 DB 密钥
get_messages_since 遇到 DB_ENCRYPTED 时调用成功后重试一次
None 时不做重试仅记日志
verify_bus: SSE 发送结果校验总线可选
注入后广播 messages 事件时会调用 verify_bus.notify
匹配 pending 的发送结果等待实现发送确认
"""
self._db_reader = db_reader
self._resolve_db_state = resolve_db_state
self._extract_key = extract_key
self._verify_bus = verify_bus
# value 为订阅时间戳monotonic用于在超限时剔除最早订阅者
self._subscribers: dict[asyncio.Queue[StreamEvent], float] = {}
self._global_cursor: int = 0
@ -219,10 +224,25 @@ class MessageStreamer:
队列满时丢弃最旧事件再放入避免单客户端消费慢拖垮全局
日志在此处统一打印一次避免在每订阅者生成器循环内重复打印
messages 事件广播前先调用 verify_bus.notify匹配 pending 的发送
结果等待这样 SendTextFlow._wait_verify 能在消息广播时立即收到
通知无需等待 SSE 订阅者消费事件
"""
# sync 事件只发给单个订阅者(在 subscribe 内直接 put不走广播
if event.event != "sync":
self._log_broadcast_once(event)
# messages 事件:先 notify verify_bus再广播给订阅者
if event.event == "messages" and self._verify_bus is not None:
for msg in event.data.get("messages", []) or []:
try:
self._verify_bus.notify(
talker=msg.get("talker", ""),
content=msg.get("content", ""),
is_sender=bool(msg.get("is_sender", False)),
)
except Exception as exc:
logger.warning("verify_bus.notify exception: %s", exc)
for q in list(self._subscribers):
try:
q.put_nowait(event)

View File

@ -0,0 +1,168 @@
"""SSE 发送结果校验总线。
发送文本后发送方注册一个 (to_wxid, content) 等待MessageStreamer
广播新消息时调用 notify匹配到等待即 resolve True超时自动清理
默认 2.5s避免内存泄漏
设计要点
- 完全异步单线程事件循环asyncio.Future
- 精确匹配 talker == to_wxid is_sender=True自己发出的消息
content 包含发送内容微信可能加前缀/后缀
- 多发送并发时每个 (to_wxid, content) 独立 Future互不干扰
- 防泄漏register 后无论是否被 notify超时都会清理并 resolve False
- 不依赖 DB无熔断器无雪崩风险
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Optional
logger = logging.getLogger("woc-bridge")
# 默认等待超时SSE 是推送机制,比 DB 轮询快,
# 但 MessageStreamer 有 1s 轮询间隔 + DB mtime 检测延迟,
# 2.5s 足够覆盖正常场景。
_DEFAULT_TIMEOUT_SEC = 2.5
class SSEVerifyBus:
"""SSE 发送结果校验总线。
线程安全说明asyncio.Future dict 操作都在同一事件循环中
无需加锁notify MessageStreamer._broadcast 调用同一 loop
register/wait SendTextFlow 调用同一 loop
"""
def __init__(self, default_timeout_sec: float = _DEFAULT_TIMEOUT_SEC) -> None:
self._default_timeout = default_timeout_sec
# key: (to_wxid, content_normalized) -> list[(future, expire_at)]
# 用 list 支持同 to_wxid + 同 content 的并发发送(极少见,但需支持)
self._pending: dict[tuple[str, str], list[tuple[asyncio.Future, float]]] = {}
def _normalize(self, content: str) -> str:
"""规范化内容用于匹配:去除首尾空白。
微信消息内容与发送内容可能有微小差异如尾随换行
strip 后的子串匹配更鲁棒
"""
return (content or "").strip()
def register(
self,
to_wxid: str,
content: str,
timeout_sec: Optional[float] = None,
) -> asyncio.Future:
"""注册一个发送结果等待。
Args:
to_wxid: 目标联系人 wxid
content: 发送的消息内容
timeout_sec: 超时秒数None 用默认值
Returns:
asyncio.Future[bool]True 表示收到匹配的 SSE 事件
False 表示超时未收到
"""
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
timeout = timeout_sec if timeout_sec is not None else self._default_timeout
expire_at = time.monotonic() + timeout
key = (to_wxid, self._normalize(content))
self._pending.setdefault(key, []).append((future, expire_at))
# 超时自动清理:超时后 resolve False 并移除条目
def _on_timeout() -> None:
if not future.done():
future.set_result(False)
self._cleanup_done(key)
loop.call_later(timeout, _on_timeout)
return future
async def wait(self, future: asyncio.Future) -> bool:
"""等待 register 返回的 future 完成。
单独提供 wait 方法便于在 async 上下文中 await
"""
return await future
def notify(
self,
talker: str,
content: str,
is_sender: bool,
) -> int:
"""消息广播时调用,匹配 pending 等待并 resolve。
一条 SSE 消息只匹配一个等待FIFO避免同 (to_wxid, content)
并发发送被同一条消息全部 resolve后续相同等待需等下一条 SSE 消息
Args:
talker: 消息的 talker 字段会话对方 wxid
content: 消息内容
is_sender: 是否是自己发出的消息
Returns:
匹配到的等待数量0 1
"""
if not is_sender:
return 0
normalized = self._normalize(content)
# 精确匹配 talker但 content 用子串匹配(微信可能加前缀)
# 遍历所有 pending key找 talker 匹配且 content 包含目标内容的
matched = 0
keys_to_check = [
k for k in self._pending.keys()
if k[0] == talker
]
for key in keys_to_check:
if matched > 0:
break # 一条消息只匹配一个等待
_, target_content = key
# target_content 是发送时规范化的内容
# 检查消息内容是否包含目标内容(微信群消息可能加发送者前缀)
if target_content and target_content in normalized:
waiters = self._pending.get(key, [])
if not waiters:
self._pending.pop(key, None)
continue
# FIFO只 resolve 第一个未完成的等待
future, expire_at = waiters[0]
if not future.done():
future.set_result(True)
matched = 1
# 移除已 resolve 的条目
remaining = waiters[1:]
if remaining:
self._pending[key] = remaining
else:
self._pending.pop(key, None)
return matched
def _cleanup_done(self, key: tuple[str, str]) -> None:
"""清理已完成的等待条目(超时或已 resolve"""
waiters = self._pending.get(key)
if waiters is None:
return
remaining = [(f, t) for f, t in waiters if not f.done()]
if remaining:
self._pending[key] = remaining
else:
self._pending.pop(key, None)
def pending_count(self) -> int:
"""当前等待中的校验数量(供监控/调试)。"""
return sum(len(v) for v in self._pending.values())
def clear(self) -> None:
"""清除所有等待resolve False。供 lifespan 停止时调用。"""
for key, waiters in list(self._pending.items()):
for future, _ in waiters:
if not future.done():
future.set_result(False)
self._pending.clear()

View File

@ -175,8 +175,8 @@ async def _verify_sent_to_talker(
新消息存在
Note:
P3 阶段该校验将迁移到 SendTextFlow._verify_sent本阶段先在
routes/send.py 修复防串号问题
P3 阶段该校验已迁移到 SendTextFlow._wait_verify基于 SSE 广播
本函数仅 legacy 路径使用
"""
if db_reader is None:
return True
@ -384,7 +384,7 @@ async def _send_text_via_legacy(req: SendTextRequest) -> SendResponse:
async def _send_text_via_flow(req: SendTextRequest) -> SendResponse:
"""新架构发送:经 FlowOrchestrator 编排(幂等/熔断/会话缓存/DB 校验)。"""
"""新架构发送:经 FlowOrchestrator 编排(幂等/会话缓存/SSE 校验)。"""
orchestrator = _state.orchestrator
if orchestrator is None:
raise BridgeError(

View File

@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Optional
@ -24,6 +25,14 @@ from woc_bridge.ui.locators.registry import LocatorRegistry
logger = logging.getLogger("woc-bridge")
# 纯坐标模式开关WOC_DISABLE_OPENCV=1 时跳过 OpenCV 模板匹配,
# find_element 直接走几何兜底by_geom 比例坐标),不截图不匹配。
# 适用于模板图像不可靠的场景,靠窗口几何比例定位元素。
# 启动时读取一次,运行时不再重复读环境变量。
_DISABLE_OPENCV = os.environ.get("WOC_DISABLE_OPENCV", "").strip() in (
"1", "true", "True", "TRUE", "yes",
)
class ElementNotFoundError(Exception):
"""元素未找到(图像匹配失败且 require_image=True或几何规格缺失
@ -90,6 +99,17 @@ class Actions:
"""
sel = self.locator.get(kind)
# 纯坐标模式WOC_DISABLE_OPENCV=1 时直接走几何兜底,
# 跳过截图与 OpenCV 模板匹配,按 Selector.by_geom 比例坐标定位。
# 返回 strategy="geom"confidence=1.0。
if _DISABLE_OPENCV:
logger.debug(
"[action] opencv disabled (WOC_DISABLE_OPENCV=1), "
"use geom directly kind=%s",
kind,
)
return self._geom_find(sel, win_geom)
# 图像路径P2 启用P1 阶段 opencv=None 直接跳过)
if (
sel.by_image
@ -100,7 +120,10 @@ class Actions:
if shot is None:
shot = await self.backend.screenshot()
self._set_cached_screenshot(shot)
roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
# 计算 OpenCV 搜索 ROI优先用 Selector.roi相对窗口的比例矩形
# 无则回退到全窗口。ROI 限制可防止模板误匹配到窗口其他相似元素
# (如 input_box 模板错误时匹配到窗口中部,参见 false-positive 事件)。
roi = self._compute_roi(sel, win_geom)
try:
result = await self.locator.opencv.find_template(
shot,
@ -257,6 +280,42 @@ class Actions:
# ------------------------------------------------------------------
# 几何兜底
# ------------------------------------------------------------------
def _compute_roi(
self, selector: Selector, win_geom: WindowGeometry
) -> tuple[int, int, int, int]:
"""计算 OpenCV 搜索 ROI绝对像素坐标
优先使用 Selector.roi相对窗口的比例矩形无则回退到全窗口
输出格式为 (x, y, w, h) OpenCVBackend.find_template roi 参数一致
边界由 opencv.py 内部裁剪保护这里只做基础 clamp 防负值
Args:
selector: 元素选择器可能含 roi
win_geom: 当前窗口几何
Returns:
(x, y, w, h) 绝对像素 ROI
"""
if selector.roi is None:
return (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
r = selector.roi
abs_x = win_geom.x + int(win_geom.width * r.x_ratio)
abs_y = win_geom.y + int(win_geom.height * r.y_ratio)
abs_w = int(win_geom.width * r.w_ratio)
abs_h = int(win_geom.height * r.h_ratio)
# 基础保护:防止负值或零尺寸
abs_w = max(abs_w, 1)
abs_h = max(abs_h, 1)
logger.debug(
"[action] compute_roi kind=%s win=(%d,%d,%dx%d) "
"roi_ratio=(%.3f,%.3f,%.3f,%.3f) → abs=(%d,%d,%dx%d)",
selector.kind,
win_geom.x, win_geom.y, win_geom.width, win_geom.height,
r.x_ratio, r.y_ratio, r.w_ratio, r.h_ratio,
abs_x, abs_y, abs_w, abs_h,
)
return (abs_x, abs_y, abs_w, abs_h)
def _geom_find(
self, selector: Selector, win_geom: WindowGeometry
) -> ElementHandle:

View File

@ -56,7 +56,6 @@ class FlowContext:
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

View File

@ -5,6 +5,9 @@ focus_input → type_content → send → cleanup。
会话缓存命中时跳过前 4 仅做 _verify_session_still_open
异常时调 _reset_to_idle 恢复 UI 状态
发送结果校验通过 SSEVerifyBus 注册等待MessageStreamer 广播消息时
notify 匹配 talker + content DB 轮询无熔断器无雪崩风险
"""
from __future__ import annotations
@ -13,7 +16,7 @@ import asyncio
import logging
import random
import time
from typing import Awaitable, Callable, Optional
from typing import Optional
from woc_bridge.ui.backends.base import WindowGeometry
from woc_bridge.ui.errors import FlowError, PermanentError
@ -30,25 +33,28 @@ logger = logging.getLogger("woc-bridge")
# 微信窗口标题
_WECHAT_WINDOW_TITLE = "微信"
# DB 校验参数
_DB_VERIFY_TIMEOUT_SEC = 3.0
_DB_VERIFY_INTERVAL_SEC = 0.2
class SendTextFlow(Flow):
"""发送文本消息 Flow。"""
def __init__(self, actions, db_reader=None, session_cache=None) -> None:
def __init__(self, actions, db_reader=None, session_cache=None,
verify_bus=None) -> None:
"""初始化 SendTextFlow。
Args:
actions: L3 Actions 实例
db_reader: 保留参数兼容性不再用于 verify仅供 base
session_cache: 会话缓存SessionCache 实例
verify_bus: SSE 发送结果校验总线SSEVerifyBus 实例
None verified 永远为 False不阻塞发送
"""
super().__init__(actions, db_reader)
self.session_cache = session_cache
# 基线消息的 local_id与 before_msg_idcreate_time配合用于 DB 游标查询
self._before_msg_local_id: int = 0
self.verify_bus = verify_bus
async def run(self, ctx: FlowContext) -> FlowResult:
"""执行发送文本流程。"""
started_at = time.monotonic()
# 后台 task获取 before_msg_id与 _activate 并行)
before_msg_task: Optional[asyncio.Task] = None
logger.info(
"[flow:send_text] START to_wxid=%s display_name=%s content_len=%d client_request_id=%s",
@ -70,9 +76,6 @@ class SendTextFlow(Flow):
# 跳过 click_search_box ~ open_session但仍需激活窗口
# 30s 内用户可能切到其他应用,微信失去焦点)
t0 = time.perf_counter()
before_msg_task = asyncio.create_task(
self._get_latest_create_time(ctx.to_wxid)
)
# 仅激活窗口 + 获取几何,跳过 Esc 清场(会话已打开)
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
ctx.win_geom = self._ensure_valid_geometry(
@ -90,33 +93,17 @@ class SendTextFlow(Flow):
else:
# 完整流程
await self._activate(ctx)
# 并行启动 before_msg_id 获取
before_msg_task = asyncio.create_task(
self._get_latest_create_time(ctx.to_wxid)
)
await self._click_search_box(ctx)
await self._type_query(ctx)
await self._open_session(ctx)
await self._focus_input(ctx)
await self._type_content(ctx)
# 等待 before_msg_task 完成(如果已启动)
if before_msg_task is not None:
ct, lid = await before_msg_task
ctx.before_msg_id = ct
self._before_msg_local_id = lid
logger.info(
"[flow:send_text] before_msg baseline ct=%d local_id=%d",
ct, lid,
)
else:
logger.warning(
"[flow:send_text] before_msg_task is None, baseline unavailable"
)
await self._send(ctx)
await self._cleanup(ctx)
# 成功:更新会话缓存(用 to_wxid 做 key
# SSE verify 通过才缓存,避免发错会话被缓存
if self.session_cache is not None and ctx.verified:
self.session_cache.set(ctx.to_wxid)
@ -182,52 +169,28 @@ class SendTextFlow(Flow):
state=FlowState.FAILED,
duration_ms=(time.monotonic() - started_at) * 1000,
)
finally:
# 兜底:确保 before_msg_task 被取消CancelledError 路径也走这里)
# Python 3.8+ 中 CancelledError 继承自 BaseException
# except Exception 无法捕获finally 块保证清理执行
if before_msg_task is not None and not before_msg_task.done():
before_msg_task.cancel()
try:
await before_msg_task
except (asyncio.CancelledError, Exception):
pass
# ------------------------------------------------------------------
# Transitions
# ------------------------------------------------------------------
def _ensure_valid_geometry(self, geom: Optional[WindowGeometry]) -> WindowGeometry:
"""校验窗口几何有效,防止拿到 1×1 隐藏窗后继续误点 (0,0)。
返回校验后的 WindowGeometry方便调用方获得窄化类型
"""
if geom is None:
raise PermanentError(
"WINDOW_GEOMETRY_INVALID",
"无法获取微信窗口几何,无法执行 UI 操作",
)
if geom.width < 200 or geom.height < 200:
raise PermanentError(
"WINDOW_GEOMETRY_INVALID",
f"微信窗口几何异常 {geom.width}x{geom.height},无法执行 UI 操作",
)
return geom
async def _activate(self, ctx: FlowContext) -> None:
"""激活窗口 + 获取几何 + 重置状态(回到会话列表首页)。"""
"""激活窗口 + Esc 清场(关闭残留搜索框/弹窗)。"""
t0 = time.perf_counter()
logger.info("[flow:send_text] step=activate start")
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
ctx.win_geom = self._ensure_valid_geometry(
await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
)
# 重置状态:点击左侧"会话管理"图标,强制回到会话列表首页,
# 清除可能存在的搜索覆盖层、弹窗或非会话页面。
# 点击 session_manager_icon 强制回到会话列表首页,清除搜索覆盖层
await self.actions.click_element("session_manager_icon", ctx.win_geom)
# Esc 关闭可能的弹窗/搜索框
await self.actions.backend.key_press("Escape")
await asyncio.sleep(0.3)
ctx.current_state = FlowState.WINDOW_ACTIVATED
logger.info(
"[flow:send_text] activated window geom=%dx%d",
"[flow:send_text] activated window geom=%dx%d (%.0fms)",
ctx.win_geom.width, ctx.win_geom.height,
(time.perf_counter() - t0) * 1000,
)
async def _click_search_box(self, ctx: FlowContext) -> None:
@ -236,8 +199,6 @@ class SendTextFlow(Flow):
logger.info("[flow:send_text] step=search_box click start")
await self.actions.click_element("search_box", ctx.win_geom)
ctx.current_state = FlowState.SEARCH_BOX_CLICKED
# 点击后短暂等待 UI 响应,高并发场景下调短以提升吞吐
await asyncio.sleep(0.2)
logger.info(
"[flow:send_text] step=search_box click done (%.0fms)",
(time.perf_counter() - t0) * 1000,
@ -256,47 +217,31 @@ class SendTextFlow(Flow):
await asyncio.sleep(0.2)
await self.actions.backend.type_text(ctx.display_name)
ctx.current_state = FlowState.QUERY_TYPED
# 自适应等待搜索结果渲染P1 阶段无 OpenCV 时 find_element 的 geom 兜底
# 不可信,因此 _check_search_result_appeared 只在 image 命中时返回 True
appeared = await self._wait_for_condition(
lambda: self._check_search_result_appeared(ctx),
timeout=1.5,
interval=0.3,
# 纯坐标模式下无法用 OpenCV 验证搜索结果是否渲染,
# 改为固定等待 0.5s 让微信渲染搜索结果列表
await asyncio.sleep(0.5)
logger.info(
"[flow:send_text] step=type_query done (fixed wait 0.5s, %.0fms)",
(time.perf_counter() - t0) * 1000,
)
if not appeared:
logger.warning(
"[flow:send_text] search result not appeared in 1.5s, continue (%.0fms)",
(time.perf_counter() - t0) * 1000,
)
else:
logger.info(
"[flow:send_text] step=type_query done (search result appeared, %.0fms)",
(time.perf_counter() - t0) * 1000,
)
async def _open_session(self, ctx: FlowContext) -> None:
"""点击搜索结果第一项打开会话"""
"""按 Enter 打开搜索结果第一项(比点击坐标更可靠)。"""
t0 = time.perf_counter()
logger.info("[flow:send_text] step=open_session click search_result_first start")
await self.actions.click_element("search_result_first", ctx.win_geom)
ctx.current_state = FlowState.SESSION_OPENED
# 自适应等待 input_box 出现P1 阶段无 OpenCV 时 find_element 的 geom 兜底
# 不可信,因此 _check_input_box_appeared 只在 image 命中时返回 True。
appeared = await self._wait_for_condition(
lambda: self._check_input_box_appeared(ctx),
timeout=1.5,
interval=0.3,
logger.info(
"[flow:send_text] step=open_session press Enter to open first search result"
)
# 微信 4.x 搜索框输入 display_name 后,直接按 Enter 即可打开第一项搜索结果。
# 这比 click search_result_first 的固定坐标更稳定,不受"搜索网络结果" /
# "最近在搜" / 联系人结果排序变化的影响。
await self.actions.backend.key_press("Return")
ctx.current_state = FlowState.SESSION_OPENED
# 等待会话切换完成、输入框自动聚焦。
await asyncio.sleep(0.5)
logger.info(
"[flow:send_text] step=open_session done (fixed wait 0.5s, %.0fms)",
(time.perf_counter() - t0) * 1000,
)
if not appeared:
logger.warning(
"[flow:send_text] input_box not appeared in 1.5s, continue (%.0fms)",
(time.perf_counter() - t0) * 1000,
)
else:
logger.info(
"[flow:send_text] step=open_session done (input_box appeared, %.0fms)",
(time.perf_counter() - t0) * 1000,
)
async def _focus_input(self, ctx: FlowContext) -> None:
"""聚焦输入框。"""
@ -321,7 +266,7 @@ class SendTextFlow(Flow):
"original_len=%d cleaned_len=%d",
len(ctx.content), len(cleaned_content),
)
ctx.content = cleaned_content
ctx.content = cleaned_content
logger.info(
"[flow:send_text] step=type_content start content_len=%d",
len(ctx.content),
@ -334,13 +279,46 @@ class SendTextFlow(Flow):
)
async def _send(self, ctx: FlowContext) -> None:
"""点击发送按钮 + 校验。"""
"""触发发送 + SSE 校验。
纯坐标模式WOC_DISABLE_OPENCV=1下用 Enter 键发送
避免发送按钮坐标偏移导致点空微信 4.x 默认 Enter 即发送
OpenCV 模式保留点击 send_button坐标+模板双重保障
关键register 必须在 send 之前否则存在竞态条件
- T0: Enter 发送
- T0+0.05: 微信写 DB
- T0+0.05~T0+1.0: MessageStreamer 轮询 + 广播 + notify waiter 丢失
- T0+0.1: register已晚于 notify
先注册 future发送后等待 notify 匹配超时2.5s未匹配则
verified=False不阻塞发送成功语义success=True调用方按需重试
"""
t0 = time.perf_counter()
logger.info("[flow:send_text] step=send click send_button start")
await self.actions.click_element("send_button", ctx.win_geom)
from woc_bridge.ui.actions import _DISABLE_OPENCV
# 1. 先注册 SSE 校验等待,避免 send 后 broadcast 已发生的竞态
verify_future = None
if self.verify_bus is not None:
verify_future = self.verify_bus.register(
to_wxid=ctx.to_wxid,
content=ctx.content,
)
# 2. 触发发送
if _DISABLE_OPENCV:
logger.info("[flow:send_text] step=send press Enter start")
# 短暂等待输入框聚焦稳定,避免 type_text 后焦点未完全落位
await asyncio.sleep(0.1)
await self.actions.backend.key_press("Return")
logger.info("[flow:send_text] step=send press Enter done")
else:
logger.info("[flow:send_text] step=send click send_button start")
await self.actions.click_element("send_button", ctx.win_geom)
ctx.current_state = FlowState.SENT
ctx.local_send_id = f"local_{int(time.time())}_{random.randint(1000,9999)}"
ctx.verified = await self._verify_sent(ctx)
# 3. 等待 SSE 校验结果
ctx.verified = await self._wait_verify(verify_future, ctx)
logger.info(
"[flow:send_text] step=send done local_id=%s verified=%s (%.0fms)",
ctx.local_send_id, ctx.verified,
@ -360,144 +338,43 @@ class SendTextFlow(Flow):
)
# ------------------------------------------------------------------
# 校验
# 校验SSE 推送匹配
# ------------------------------------------------------------------
async def _verify_sent(self, ctx: FlowContext) -> bool:
"""发送结果校验DB 校验优先,失败降级截图校验。"""
t0 = time.perf_counter()
if self.db_reader is None:
logger.warning(
"[flow:send_text] no db_reader, fall back to screenshot verify"
)
result = await self._verify_by_screenshot(ctx)
logger.info(
"[flow:send_text] _verify_sent done (no db_reader) result=%s (%.0fms)",
result, (time.perf_counter() - t0) * 1000,
)
return result
# before_msg_id=0 表示无法建立基线DB 不可用或会话无历史消息),
# 直接降级截图校验,避免 cursor=0 查询返回所有历史消息导致假阳性
if ctx.before_msg_id <= 0:
logger.warning(
"[flow:send_text] before_msg_id=%d, skip DB verify, fall back to screenshot",
ctx.before_msg_id,
)
result = await self._verify_by_screenshot(ctx)
logger.info(
"[flow:send_text] _verify_sent done (no baseline) result=%s (%.0fms)",
result, (time.perf_counter() - t0) * 1000,
)
return result
async def _wait_verify(self, future: Optional[asyncio.Future], ctx: FlowContext) -> bool:
"""等待 SSEVerifyBus 的 future 完成。
deadline = time.monotonic() + _DB_VERIFY_TIMEOUT_SEC
attempts = 0
while time.monotonic() < deadline:
attempts += 1
try:
result = await asyncio.to_thread(
self.db_reader.get_messages_by_session,
ctx.to_wxid,
cursor=ctx.before_msg_id,
cursor_local_id=self._before_msg_local_id,
limit=3,
direction="after",
is_sender=None,
)
messages = result.get("messages", [])
if messages:
# 防串号:校验是否有消息内容匹配发送的 content
# 注意get_messages_by_session 按 talker 分表查询talker 字段恒等于 to_wxid
# 无法通过 talker 字段检测串号。改为内容匹配校验。
for msg in messages:
msg_content = msg.get("content", "")
# 微信消息内容可能包含发送者前缀(群消息),用 in 匹配
if ctx.content and ctx.content in msg_content:
logger.info(
"[flow:send_text] DB verify OK (attempt=%d, content matched, is_sender=%s, %d new msgs)",
attempts, msg.get("is_sender"), len(messages),
)
return True
# 有新消息但内容不匹配 → 可能串号或消息未到达
logger.warning(
"[flow:send_text] DB verify: %d new msgs but content not matched (attempt=%d, contents=%s)",
len(messages), attempts,
[m.get("content", "")[:50] for m in messages],
)
# 继续轮询,可能是 DB 写入延迟
except Exception as exc:
logger.debug(
"[flow:send_text] DB verify attempt=%d exception: %s",
attempts, exc,
)
await asyncio.sleep(_DB_VERIFY_INTERVAL_SEC)
future _send 在触发发送前 register确保 MessageStreamer
广播消息时 notify 能匹配到 waiter匹配到返回 True超时返回 False
logger.warning(
"[flow:send_text] DB verify timeout after %ds/%d attempts, fall back to screenshot",
_DB_VERIFY_TIMEOUT_SEC, attempts,
)
result = await self._verify_by_screenshot(ctx)
logger.info(
"[flow:send_text] _verify_sent done (DB timeout) result=%s (%.0fms)",
result, (time.perf_counter() - t0) * 1000,
)
return result
async def _verify_by_screenshot(self, ctx: FlowContext) -> bool:
"""截图校验:轮询等待 input_box_empty 模板出现。
策略说明
- OpenCV image 命中 可靠校验通过返回 True
- geom 兜底命中 不可靠继续轮询等待 image 命中超时后返回 False
- 超时/异常 返回 False
geom 兜底永远返回坐标无法证明输入框已清空以往乐观返回 True 会掩盖
真实发送失败现改为只有 image 命中才认为校验通过
DB 轮询无截图校验无熔断器避免雪崩
future None verify_bus 未注入直接返回 False不阻塞发送
"""
if ctx.win_geom is None:
logger.warning("[flow:send_text] screenshot verify skipped: win_geom is None")
return False
t0 = time.perf_counter()
deadline = time.monotonic() + 3.0
attempts = 0
last_strategy: Optional[str] = None
last_conf: float = 0.0
while time.monotonic() < deadline:
attempts += 1
try:
elem = await self.actions.find_element(
"input_box_empty", ctx.win_geom
)
if elem is None:
continue
if elem.strategy == "image":
logger.info(
"[flow:send_text] screenshot verify OK (image matched, conf=%.3f, attempts=%d, %.0fms)",
elem.confidence, attempts, (time.perf_counter() - t0) * 1000,
)
return True
# geom 兜底:记录策略,继续轮询看 image 是否能匹配
last_strategy = elem.strategy
last_conf = elem.confidence
except Exception as exc:
logger.debug(
"[flow:send_text] screenshot verify attempt=%d exception: %s",
attempts, exc,
)
await asyncio.sleep(0.3)
# 超时:只有 image 命中才算成功geom 兜底不再乐观返回
if last_strategy == "geom":
if future is None:
logger.warning(
"[flow:send_text] screenshot verify FAIL: only geom fallback matched, "
"image not matched in 3s/%d attempts, %.0fms — cannot prove send success",
attempts, (time.perf_counter() - t0) * 1000,
"[flow:send_text] no verify_bus, skip SSE verify (verified=False)"
)
return False
t0 = time.perf_counter()
try:
result = await self.verify_bus.wait(future)
elapsed_ms = (time.perf_counter() - t0) * 1000
if result:
logger.info(
"[flow:send_text] SSE verify OK (talker=%s content matched, %.0fms)",
ctx.to_wxid, elapsed_ms,
)
else:
logger.warning(
"[flow:send_text] SSE verify timeout (talker=%s no matching broadcast, %.0fms)",
ctx.to_wxid, elapsed_ms,
)
return result
except Exception as exc:
logger.warning(
"[flow:send_text] SSE verify exception: %s (%.0fms)",
exc, (time.perf_counter() - t0) * 1000,
)
return False
logger.warning(
"[flow:send_text] screenshot verify FAIL: input_box_empty not found in 3s "
"(attempts=%d, %.0fms)",
attempts, (time.perf_counter() - t0) * 1000,
)
return False
async def _verify_session_still_open(self, ctx: FlowContext) -> None:
"""会话缓存命中时校验会话仍打开input_box 存在)。"""
@ -517,14 +394,12 @@ class SendTextFlow(Flow):
)
raise PermanentError(
"ELEMENT_NOT_FOUND",
"session cache hit but input_box not found",
"session cache verify failed: input_box not found",
)
logger.info(
"[flow:send_text] session_still_open OK (input_box found, strategy=%s, %.0fms)",
elem.strategy, (time.perf_counter() - t0) * 1000,
"[flow:send_text] session_still_open OK (%.0fms)",
(time.perf_counter() - t0) * 1000,
)
except PermanentError:
raise
except Exception as exc:
logger.warning(
"[flow:send_text] session_still_open exception: %s (%.0fms)",
@ -534,88 +409,3 @@ class SendTextFlow(Flow):
"ELEMENT_NOT_FOUND",
f"session cache verify failed: {exc}",
)
# ------------------------------------------------------------------
# 自适应轮询
# ------------------------------------------------------------------
async def _wait_for_condition(
self,
condition_fn: Callable[[], Awaitable[bool]],
timeout: float,
interval: float,
) -> bool:
"""自适应轮询condition_fn 返回 True 时立即返回,超时返回 False。"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
if await condition_fn():
return True
except Exception:
pass
await asyncio.sleep(interval)
return False
async def _check_search_result_appeared(self, ctx: FlowContext) -> bool:
"""检查搜索结果是否真正出现。
P1 阶段find_element image 匹配失败时会回退到 geom 兜底
geom 兜底只是返回 YAML profile 中写死的比例坐标不能证明
搜索结果已渲染因此只有 image 命中strategy == "image"
才视为可靠出现
"""
try:
elem = await self.actions.find_element(
"search_result_first", ctx.win_geom
)
return elem is not None and elem.strategy == "image"
except Exception:
return False
async def _check_input_box_appeared(self, ctx: FlowContext) -> bool:
"""检查输入框是否真正出现。
_check_search_result_appeared只有 image 命中才视为可靠
"""
try:
elem = await self.actions.find_element("input_box", ctx.win_geom)
return elem is not None and elem.strategy == "image"
except Exception:
return False
# ------------------------------------------------------------------
# DB 辅助
# ------------------------------------------------------------------
async def _get_latest_create_time(self, to_wxid: str) -> tuple[int, int]:
"""获取目标会话当前最新消息的 (create_time, local_id)。"""
if self.db_reader is None:
logger.debug("[flow:send_text] get_latest_create_time: db_reader is None")
return (0, 0)
t0 = time.perf_counter()
try:
result = await asyncio.to_thread(
self.db_reader.get_messages_by_session,
to_wxid,
cursor=0,
limit=1,
direction="before",
is_sender=None,
)
messages = result.get("messages", [])
if messages:
ct = int(messages[0].get("create_time", 0))
lid = int(messages[0].get("local_id", 0))
logger.info(
"[flow:send_text] get_latest_create_time OK to_wxid=%s ct=%d local_id=%d (%.0fms)",
to_wxid, ct, lid, (time.perf_counter() - t0) * 1000,
)
return (ct, lid)
logger.info(
"[flow:send_text] get_latest_create_time: no history msgs for to_wxid=%s (%.0fms)",
to_wxid, (time.perf_counter() - t0) * 1000,
)
except Exception as exc:
logger.warning(
"[flow:send_text] get_latest_create_time exception: %s (%.0fms)",
exc, (time.perf_counter() - t0) * 1000,
)
return (0, 0)

View File

@ -1,10 +1,10 @@
"""L2 Locator 抽象层GeomSpec + Selector + ElementHandle + LocatorRegistry。
"""L2 Locator 抽象层GeomSpec + RoiSpec + Selector + ElementHandle + LocatorRegistry。
LocatorRegistry (app_version, resolution) 加载 YAML profile
返回 Selector L3 ActionsActions 据此调 L1 Backend 执行点击
"""
from woc_bridge.ui.locators.base import ElementHandle, GeomSpec, Selector
from woc_bridge.ui.locators.base import ElementHandle, GeomSpec, RoiSpec, Selector
from woc_bridge.ui.locators.registry import LocatorRegistry
__all__ = ["GeomSpec", "Selector", "ElementHandle", "LocatorRegistry"]
__all__ = ["GeomSpec", "RoiSpec", "Selector", "ElementHandle", "LocatorRegistry"]

View File

@ -1,7 +1,8 @@
"""L2 Locator 抽象层基类GeomSpec + Selector + ElementHandle。
"""L2 Locator 抽象层基类GeomSpec + RoiSpec + Selector + ElementHandle。
GeomSpec几何定位规格relative_to / ratio / offset
SelectorUI 元素选择器kind / by_image / by_geom / threshold / require_image
RoiSpec图像匹配感兴趣区域相对窗口的比例矩形缩小 OpenCV 搜索范围
SelectorUI 元素选择器kind / by_image / by_geom / roi / threshold / require_image
ElementHandle定位结果句柄绝对坐标 + 置信度 + 策略
L3 Actions 通过 Selector 描述要找的元素LocatorRegistry 解析为
@ -31,6 +32,32 @@ class GeomSpec:
y_offset: int = 0
@dataclass
class RoiSpec:
"""图像匹配感兴趣区域ROI相对窗口的比例矩形。
用于将 OpenCV 模板匹配限制在窗口的某个子区域避免误匹配到
窗口内其他视觉相似的 UI 元素例如 input_box 只在聊天区右下角查找
即使模板图像有缺陷也不会匹配到窗口中部
所有字段为相对窗口宽/高的比例0.0~1.0 Actions 按当前窗口几何
转换为绝对像素 ROI 传给 OpenCVBackend.find_template
字段
x_ratio: ROI 左上角相对窗口左上角的 X 比例
y_ratio: ROI 左上角相对窗口左上角的 Y 比例
w_ratio: ROI 宽度相对窗口宽度的比例
h_ratio: ROI 高度相对窗口高度的比例
边界由 Actions/opencv.py 做裁剪保护YAML 中可略微宽松
"""
x_ratio: float = 0.0
y_ratio: float = 0.0
w_ratio: float = 1.0
h_ratio: float = 1.0
@dataclass
class Selector:
"""UI 元素选择器。
@ -38,6 +65,8 @@ class Selector:
kind元素类别键 search_box / send_button / input_box
by_image图像模板名P2 启用非空时优先图像匹配
by_geom几何规格非空时图像失败或 P1 阶段走几何兜底
roi图像匹配感兴趣区域可选非空时限制 OpenCV 搜索范围
为空时使用全窗口用于防止模板误匹配到窗口其他相似元素
threshold图像匹配置信度阈值默认 0.80
require_image高风险操作为 True 图像失败即拒绝执行
description人类可读描述
@ -48,6 +77,7 @@ class Selector:
kind: str = ""
by_image: Optional[str] = None
by_geom: Optional[GeomSpec] = None
roi: Optional[RoiSpec] = None
threshold: float = 0.80
require_image: bool = False
description: str = ""

View File

@ -15,7 +15,7 @@ from typing import Optional
import yaml
from woc_bridge.ui.locators.base import GeomSpec, Selector
from woc_bridge.ui.locators.base import GeomSpec, RoiSpec, Selector
logger = logging.getLogger("woc-bridge")
@ -198,6 +198,7 @@ class LocatorRegistry:
若提供 target_resolution profile_resolution则对 by_geom 中的
绝对偏移 x_offset / y_offset 按分辨率比例缩放宽高比不变时坐标更接近目标
roi 为相对窗口的比例矩形与分辨率无关不做缩放
YAML 结构每个 key 对应一个 Selector
search_box:
@ -208,6 +209,11 @@ class LocatorRegistry:
y_ratio: 0.05
x_offset: 0
y_offset: 0
roi: # dict可省略缺省=全窗口)
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.30
h_ratio: 0.20
threshold: 0.80
require_image: false
description: 搜索框
@ -240,10 +246,20 @@ class LocatorRegistry:
x_offset=int(int(by_geom_raw.get("x_offset", 0)) * scale_x),
y_offset=int(int(by_geom_raw.get("y_offset", 0)) * scale_y),
)
roi_raw = spec.get("roi")
roi: Optional[RoiSpec] = None
if isinstance(roi_raw, dict):
roi = RoiSpec(
x_ratio=float(roi_raw.get("x_ratio", 0.0)),
y_ratio=float(roi_raw.get("y_ratio", 0.0)),
w_ratio=float(roi_raw.get("w_ratio", 1.0)),
h_ratio=float(roi_raw.get("h_ratio", 1.0)),
)
self.selectors[kind] = Selector(
kind=kind,
by_image=by_image,
by_geom=by_geom,
roi=roi,
threshold=float(spec.get("threshold", 0.80)),
require_image=bool(spec.get("require_image", False)),
description=str(spec.get("description", "")),
@ -266,6 +282,9 @@ class LocatorRegistry:
relative_to 字段仅作语义占位实际坐标计算统一用 ratio + offset
offset 已含正负号区分方向详见 Actions._geom_find
roi 字段同步与 YAML profile 保持一致防止无 YAML 时退化为全窗口
匹配全窗口易误匹配到窗口其他相似 UI 元素参见 input_box 误匹配事件
"""
defaults = {
"session_manager_icon": Selector(
@ -277,6 +296,9 @@ class LocatorRegistry:
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.0, y_ratio=0.0, w_ratio=0.10, h_ratio=0.25,
),
description="左侧导航栏顶部微信消息/会话管理图标",
),
"search_box": Selector(
@ -288,6 +310,9 @@ class LocatorRegistry:
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.0, y_ratio=0.0, w_ratio=0.30, h_ratio=0.20,
),
description="左侧顶部搜索框",
),
"send_button": Selector(
@ -299,6 +324,9 @@ class LocatorRegistry:
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.90, y_ratio=0.85, w_ratio=0.10, h_ratio=0.15,
),
description="右下角发送按钮",
),
"input_box": Selector(
@ -310,6 +338,9 @@ class LocatorRegistry:
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22,
),
description="聊天输入框",
),
"input_box_empty": Selector(
@ -321,6 +352,9 @@ class LocatorRegistry:
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22,
),
description="空输入框灰色提示态(发送成功后校验)",
),
"search_result_first": Selector(
@ -332,6 +366,9 @@ class LocatorRegistry:
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.0, y_ratio=0.10, w_ratio=0.30, h_ratio=0.40,
),
description="搜索结果第一项",
),
"main_view": Selector(
@ -343,6 +380,7 @@ class LocatorRegistry:
x_offset=0,
y_offset=0,
),
# main_view 为大区域校验,无需 ROI 限制
description="主聊天视图post_verify 校验用)",
),
}

View File

@ -1,12 +1,14 @@
"""L5 FlowOrchestrator编排幂等/熔断/会话缓存/队列/Flow。
"""L5 FlowOrchestrator编排幂等/会话缓存/队列/Flow。
send_text 流程
1. 幂等检查命中返回 skipped=true
2. DB 校验熔断检查OPEN 时返回 BRIDGE_CIRCUITED
3. 入队自适应 delay_ms同联系人 1000 / 不同 config.send_delay_ms
2. 入队自适应 delay_ms同联系人 1000 / 不同 config.send_delay_ms
3. 执行 SendTextFlow内部用 SSEVerifyBus 校验
4. 成功且 verified 才写幂等缓存
5. DB 校验失败计数 + 熔断
5. verified=False 不阻塞发送不熔断仅记 warning调用方按需重试
6. 失败时 session_cache.invalidate(to_wxid)仅失效当前联系人
熔断器已移除SSE 校验超时不会导致雪崩单次 verify 失败不应阻塞全局发送
"""
from __future__ import annotations
@ -22,8 +24,7 @@ from woc_bridge.models import BridgeError
if TYPE_CHECKING:
from woc_bridge.ui.flows.send_file import SendFileFlow
from woc_bridge.ui.flows.send_text import SendTextFlow
from woc_bridge.ui.circuit_breaker import CircuitBreaker, CircuitState
from woc_bridge.ui.errors import FlowError, is_retryable
from woc_bridge.ui.errors import FlowError
from woc_bridge.ui.flow import FlowContext, FlowResult, FlowState
from woc_bridge.ui.idem_cache import IdemCache
@ -82,7 +83,7 @@ class SessionCache:
class FlowOrchestrator:
"""L5 Flow 编排器。
整合幂等缓存熔断器会话缓存发送队列Flow 执行
整合幂等缓存会话缓存发送队列Flow 执行SSE 发送结果校验
"""
def __init__(
@ -94,6 +95,7 @@ class FlowOrchestrator:
config,
send_text_flow: Optional[SendTextFlow] = None,
send_file_flow: Optional[SendFileFlow] = None,
verify_bus=None,
) -> None:
"""初始化编排器。
@ -106,6 +108,8 @@ class FlowOrchestrator:
send_text_flow: 可选的 SendTextFlow 实例用于测试注入
send_file_flow: 可选的 SendFileFlow 实例必须由 app._init_state 注入
SendFileFlow 依赖 XdotoolDriverorchestrator 无法惰性创建
verify_bus: SSE 发送结果校验总线SSEVerifyBus 实例
惰性创建 SendTextFlow 时注入用于 SSE 推送校验
"""
self.send_queue = send_queue
self.idem_cache = idem_cache
@ -114,12 +118,9 @@ class FlowOrchestrator:
self.config = config
# LRU 多会话缓存:最多 16 个会话30s TTL
self.session_cache = SessionCache(ttl=30.0, max_size=16)
# DB 校验熔断器10 次失败 / 30s 恢复(高并发下更宽容)
self.db_verify_breaker = CircuitBreaker(
"db_verify", failure_threshold=10, recovery_timeout=30.0
)
self._send_text_flow = send_text_flow
self._send_file_flow = send_file_flow
self._verify_bus = verify_bus
def _get_send_text_flow(self):
"""获取 SendTextFlow 实例(惰性创建或使用注入的)。"""
@ -131,6 +132,7 @@ class FlowOrchestrator:
actions=self.actions,
db_reader=self.db_reader,
session_cache=self.session_cache,
verify_bus=self._verify_bus,
)
def _get_send_file_flow(self):
@ -158,10 +160,10 @@ class FlowOrchestrator:
流程
1. 幂等检查
2. DB 校验熔断检查
3. 自适应延时入队
4. 执行 SendTextFlow
5. 成功且 verified 写幂等缓存
2. 自适应延时入队
3. 执行 SendTextFlow内部用 SSEVerifyBus 校验
4. 成功且 verified 写幂等缓存
5. verified=False 不阻塞不熔断调用方按需重试
6. 失败时失效会话缓存
Args:
@ -206,21 +208,7 @@ class FlowOrchestrator:
duration_ms=(time.monotonic() - started_at) * 1000,
)
# 2. DB 校验熔断检查
if not self.db_verify_breaker.allow():
logger.warning(
"[orchestrator] db_verify breaker OPEN, reject send (to_wxid=%s)",
to_wxid,
)
return FlowResult(
success=False,
error="db_verify circuit breaker open",
error_code="BRIDGE_CIRCUITED",
state=FlowState.FAILED,
duration_ms=(time.monotonic() - started_at) * 1000,
)
# 3. 自适应延时:同联系人用短延时
# 2. 自适应延时:同联系人用短延时
is_same_contact = self.session_cache.get(to_wxid)
delay_ms = _SAME_CONTACT_DELAY_MS if is_same_contact else None
# None 表示用 send_queue 的默认 send_delay_ms
@ -229,7 +217,7 @@ class FlowOrchestrator:
is_same_contact, delay_ms, to_wxid,
)
# 4. 构造 FlowContext
# 3. 构造 FlowContext
ctx = FlowContext(
to_wxid=to_wxid,
content=content,
@ -237,7 +225,7 @@ class FlowOrchestrator:
client_request_id=client_request_id,
)
# 5. 入队执行(带队列等待超时,避免无界排队)
# 4. 入队执行(带队列等待超时,避免无界排队)
flow = self._get_send_text_flow()
try:
result: FlowResult = await self.send_queue.enqueue(
@ -270,24 +258,23 @@ class FlowOrchestrator:
details=exc_details,
)
# 6. 结果处理
# 5. 结果处理
if result.success and result.verified:
# 成功且 verified写幂等缓存
self.idem_cache.set(
"send_text", to_wxid, content, result, client_request_id
)
self.db_verify_breaker.record_success()
logger.info(
"[orchestrator] send_text DONE ✓ to_wxid=%s local_id=%s verified=%s (%.0fms)",
to_wxid, result.local_id, result.verified,
(time.monotonic() - started_at) * 1000,
)
elif result.success and not result.verified:
# 发送成功但校验失败DB 校验熔断计数
self.db_verify_breaker.record_failure()
# 发送成功但 SSE 校验未匹配(超时/消息未到/发错会话)
# 不熔断、不阻塞:调用方按需重试或通过 SSE 自行确认
logger.warning(
"[orchestrator] send success but verify failed, breaker fail_count+1 (to_wxid=%s)",
to_wxid,
"[orchestrator] send success but verify unmatched (to_wxid=%s, local_id=%s)",
to_wxid, result.local_id,
)
else:
# 发送失败:仅失效当前联系人会话缓存,避免误伤其它缓存命中
@ -307,15 +294,14 @@ class FlowOrchestrator:
client_request_id: Optional[str] = None,
display_name: Optional[str] = None,
) -> dict:
"""文件发送 Flow 编排:幂等检查 + 熔断 + 入队 + DB 校验。
"""文件发送 Flow 编排:幂等检查 + 入队 + 校验。
流程
1. 幂等检查命中返回 skipped=True
2. DB 校验熔断检查OPEN 时返回 BRIDGE_CIRCUITED
3. 入队执行 SendFileFlow.execute路径白名单 + 文件选择器输入 + post_verify
4. 成功且 verified 写幂等缓存
5. DB 校验失败计数 + 熔断
6. 失败时失效会话缓存
2. 入队执行 SendFileFlow.execute路径白名单 + 文件选择器输入 + post_verify
3. 成功且 verified 写幂等缓存
4. verified=False 不熔断不阻塞仅记 warning
5. 失败时失效会话缓存
幂等缓存键(send_file, to_wxid, file_path, client_request_id)
IdemCache API 仅支持 4 keyfile_path 作为 content 槽位传入
@ -366,23 +352,10 @@ class FlowOrchestrator:
"skipped": True, "error": None, "error_code": None,
}
# 2. DB 校验熔断检查
if not self.db_verify_breaker.allow():
logger.warning(
"[orchestrator] send_file: db_verify breaker OPEN, reject (to_wxid=%s)",
to_wxid,
)
return {
"success": False, "verified": False, "placeholder": False,
"skipped": False,
"error": "db_verify circuit breaker open",
"error_code": "BRIDGE_CIRCUITED",
}
# 3. 获取 SendFileFlow 实例(未注入时抛 BridgeError
# 2. 获取 SendFileFlow 实例(未注入时抛 BridgeError
flow = self._get_send_file_flow()
# 4. 入队执行SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify
# 3. 入队执行SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify
logger.info(
"[orchestrator] send_file enqueue (pending=%d)",
self.send_queue.pending_count(),
@ -420,18 +393,16 @@ class FlowOrchestrator:
"error_code": "SEND_FAILED",
}
# 5. 结果处理
# 4. 结果处理
# SendFileFlow.execute 返回 {"success": bool, "verified": Optional[bool], "error": Optional[str]}
# verified=None 表示无法校验DB 不可用或无基线),不计熔断失败
success = result.get("success", False)
verified = result.get("verified", False)
if success and verified:
# 成功且 verified写幂等缓存 + 熔断成功
# 成功且 verified写幂等缓存
self.idem_cache.set(
"send_file", to_wxid, file_path, result, idem_id
)
self.db_verify_breaker.record_success()
# 更新会话缓存send_file 打开了 to_wxid 会话)
self.session_cache.set(to_wxid)
logger.info(
@ -440,20 +411,17 @@ class FlowOrchestrator:
(time.monotonic() - started_at) * 1000,
)
elif success and verified is False:
# 发送成功但校验失败(真正的超时/未匹配DB 校验熔断计数
self.db_verify_breaker.record_failure()
# 发送成功但校验失败:不熔断、不阻塞
self.session_cache.set(to_wxid)
logger.warning(
"[orchestrator] send_file success but verify failed, breaker fail_count+1 (to_wxid=%s)",
"[orchestrator] send_file success but verify unmatched (to_wxid=%s)",
to_wxid,
)
elif success and verified is None:
# P0 修复发送成功但无法校验DB 不可用或无基线):
# 不计熔断失败,避免 DB 不可用时熔断器雪崩阻塞全部发送。
# 也不写幂等缓存(未校验的结果不应被复用)。
# 发送成功但无法校验:不写幂等缓存,不熔断
self.session_cache.set(to_wxid)
logger.info(
"[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched (to_wxid=%s)",
"[orchestrator] send_file success but verify skipped (to_wxid=%s)",
to_wxid,
)
else:

View File

@ -10,8 +10,9 @@
search_box:
by_geom:
relative_to: window
x_ratio: 0.092
y_ratio: 0.095
# 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052)
x_ratio: 0.080
y_ratio: 0.052
x_offset: 0
y_offset: 0
threshold: 0.80

View File

@ -4,17 +4,43 @@
# 几何兜底按 1280x720 比例计算,与 1920x1080 相同 ratio
# offset 按 2/3 缩放1280/1920 = 720/1080 = 2/3
# 图像模板与 1920x1080 共用(多尺度匹配自适应)。
# roi 为相对窗口的比例矩形,与分辨率无关,与 1435x876/1920x1080 共用相同比例值。
#
# 字段说明:参考 wechat_4.0_1920x1080.yaml 顶部注释。
# 字段说明:参考 wechat_4.0_1435x876.yaml 顶部注释。
# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。
# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。
session_manager_icon:
by_image: session_manager_icon.png
by_geom:
relative_to: window
x_ratio: 0.018
y_ratio: 0.109
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.10
h_ratio: 0.25
threshold: 0.80
require_image: false
description: 左侧导航栏顶部微信消息/会话管理图标
search_box:
by_image: search_box.png
by_geom:
relative_to: window
x_ratio: 0.092
y_ratio: 0.095
# 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052)
x_ratio: 0.080
y_ratio: 0.052
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.30
h_ratio: 0.12
threshold: 0.80
require_image: false
description: 左侧顶部搜索框
@ -27,6 +53,11 @@ send_button:
y_ratio: 0.929
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.90
y_ratio: 0.85
w_ratio: 0.10
h_ratio: 0.15
threshold: 0.80
require_image: false
description: 右下角发送按钮
@ -39,6 +70,11 @@ input_box:
y_ratio: 0.914
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.55
y_ratio: 0.78
w_ratio: 0.45
h_ratio: 0.22
threshold: 0.80
require_image: false
description: 聊天输入框
@ -51,6 +87,11 @@ input_box_empty:
y_ratio: 0.914
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.55
y_ratio: 0.78
w_ratio: 0.45
h_ratio: 0.22
threshold: 0.80
require_image: false
description: 空输入框灰色提示态(发送成功后校验用)
@ -63,6 +104,11 @@ search_result_first:
y_ratio: 0.174
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.10
w_ratio: 0.30
h_ratio: 0.40
threshold: 0.80
require_image: false
description: 搜索结果第一项
@ -76,7 +122,7 @@ main_view:
y_offset: 0
threshold: 0.75
require_image: false
description: 主聊天视图post_verify 校验用,模板匹配不稳定,使用几何兜底
description: 主聊天视图post_verify 校验用,模板匹配不稳定,使用几何兜底;大区域无需 ROI 限制
# 好友申请通过 UI 元素定位experimental坐标为估算值需实测校准
contact_icon:
@ -87,6 +133,11 @@ contact_icon:
y_ratio: 0.171
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.05
w_ratio: 0.10
h_ratio: 0.30
threshold: 0.80
require_image: false
description: 左侧导航栏通讯录图标
@ -99,6 +150,11 @@ new_friend_entry:
y_ratio: 0.138
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.05
y_ratio: 0.10
w_ratio: 0.25
h_ratio: 0.20
threshold: 0.80
require_image: false
description: 通讯录页"新的朋友"入口
@ -110,6 +166,11 @@ friend_request_item:
y_ratio: 0.178
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.05
y_ratio: 0.15
w_ratio: 0.30
h_ratio: 0.30
threshold: 0.75
require_image: false
description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)'
@ -122,6 +183,11 @@ verify_button:
y_ratio: 0.394
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.40
y_ratio: 0.20
w_ratio: 0.40
h_ratio: 0.40
threshold: 0.75
require_image: false
description: 好友详情页"前往验证"按钮OpenCV 实测标定)
@ -133,6 +199,11 @@ confirm_button:
y_ratio: 0.830
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.30
y_ratio: 0.65
w_ratio: 0.40
h_ratio: 0.30
threshold: 0.75
require_image: false
description: '"通过朋友验证"弹窗绿色确定按钮OpenCV 弹窗出现时实测 666/1435, 727/876'

View File

@ -10,6 +10,12 @@
# relative_to: window / window_bottom_right当前统一按 window 处理)
# x_ratio / y_ratio: 相对窗口宽高的比例位置
# x_offset / y_offset: 绝对像素偏移(含正负号)
# roi: 图像匹配感兴趣区域(可选,缺省=全窗口)
# 将 OpenCV 模板匹配限制在窗口的某个子区域,避免误匹配到窗口其他
# 视觉相似的 UI 元素。所有字段为相对窗口宽/高的比例0.0~1.0
# 与分辨率无关3 个 profile 共用相同比例值。
# x_ratio / y_ratio: ROI 左上角相对窗口左上角的比例
# w_ratio / h_ratio: ROI 宽/高相对窗口宽/高的比例
# threshold: 图像匹配置信度阈值
# require_image: true 时图像失败拒绝执行(高风险元素)
# description: 元素描述
@ -24,6 +30,11 @@ session_manager_icon:
y_ratio: 0.109
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.10
h_ratio: 0.25
threshold: 0.80
require_image: false
description: 左侧导航栏顶部微信消息/会话管理图标
@ -32,10 +43,16 @@ search_box:
by_image: search_box.png
by_geom:
relative_to: window
x_ratio: 0.092
y_ratio: 0.095
# 基于 1435x876 实测截图标定:搜索框中心约 (115,45)
x_ratio: 0.080
y_ratio: 0.052
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.30
h_ratio: 0.12
threshold: 0.80
require_image: false
description: 左侧顶部搜索框
@ -48,6 +65,11 @@ send_button:
y_ratio: 0.929
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.90
y_ratio: 0.85
w_ratio: 0.10
h_ratio: 0.15
threshold: 0.80
require_image: false
description: 右下角发送按钮
@ -60,6 +82,11 @@ input_box:
y_ratio: 0.914
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.55
y_ratio: 0.78
w_ratio: 0.45
h_ratio: 0.22
threshold: 0.80
require_image: false
description: 聊天输入框
@ -72,6 +99,11 @@ input_box_empty:
y_ratio: 0.914
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.55
y_ratio: 0.78
w_ratio: 0.45
h_ratio: 0.22
threshold: 0.80
require_image: false
description: 空输入框灰色提示态(发送成功后校验用)
@ -84,6 +116,11 @@ search_result_first:
y_ratio: 0.174
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.10
w_ratio: 0.30
h_ratio: 0.40
threshold: 0.80
require_image: false
description: 搜索结果第一项
@ -98,7 +135,7 @@ main_view:
y_offset: 0
threshold: 0.75
require_image: false
description: 主聊天视图post_verify 校验用,阈值略低因区域大
description: 主聊天视图post_verify 校验用,阈值略低因区域大;大区域无需 ROI 限制
# 好友申请通过 UI 元素定位(基于 1435x876 实测标定)
contact_icon:
@ -109,6 +146,11 @@ contact_icon:
y_ratio: 0.171
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.05
w_ratio: 0.10
h_ratio: 0.30
threshold: 0.80
require_image: false
description: 左侧导航栏通讯录图标
@ -121,6 +163,11 @@ new_friend_entry:
y_ratio: 0.138
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.05
y_ratio: 0.10
w_ratio: 0.25
h_ratio: 0.20
threshold: 0.80
require_image: false
description: 通讯录页"新的朋友"入口
@ -133,6 +180,11 @@ friend_request_item:
y_ratio: 0.178
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.05
y_ratio: 0.15
w_ratio: 0.30
h_ratio: 0.30
threshold: 0.75
require_image: false
description: '"新的朋友"列表第一条申请项'
@ -145,6 +197,11 @@ verify_button:
y_ratio: 0.394
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.40
y_ratio: 0.20
w_ratio: 0.40
h_ratio: 0.40
threshold: 0.75
require_image: false
description: 好友详情页"前往验证"按钮OpenCV 实测标定)
@ -156,6 +213,11 @@ confirm_button:
y_ratio: 0.830
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.30
y_ratio: 0.65
w_ratio: 0.40
h_ratio: 0.30
threshold: 0.75
require_image: false
description: '"通过朋友验证"弹窗绿色确定按钮OpenCV 弹窗出现时实测 666/1435, 727/876'

View File

@ -2,25 +2,43 @@
#
# 适用场景:微信窗口在 1920x1080 桌面环境下运行。
# 图像模板位于 templates/wechat_4.0/light/,几何兜底基于 1920x1080 比例计算。
# roi 为相对窗口的比例矩形,与分辨率无关,与 1435x876/1280x720 共用相同比例值。
#
# 字段说明:
# by_image: 模板文件名(相对 templates/wechat_4.0/light/
# by_geom: 几何兜底规格(图像匹配失败或熔断时使用)
# relative_to: 语义占位window / window_bottom_right
# x_ratio / y_ratio: 相对窗口宽高的比例位置
# x_offset / y_offset: 绝对像素偏移(含正负号)
# threshold: 图像匹配置信度阈值
# require_image: true 时图像失败拒绝执行(高风险元素)
# description: 元素描述
# 字段说明:参考 wechat_4.0_1435x876.yaml 顶部注释。
# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。
# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。
session_manager_icon:
by_image: session_manager_icon.png
by_geom:
relative_to: window
x_ratio: 0.018
y_ratio: 0.109
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.10
h_ratio: 0.25
threshold: 0.80
require_image: false
description: 左侧导航栏顶部微信消息/会话管理图标
search_box:
by_image: search_box.png
by_geom:
relative_to: window
x_ratio: 0.092
y_ratio: 0.095
# 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052)
x_ratio: 0.080
y_ratio: 0.052
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.30
h_ratio: 0.12
threshold: 0.80
require_image: false
description: 左侧顶部搜索框
@ -33,6 +51,11 @@ send_button:
y_ratio: 0.929
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.90
y_ratio: 0.85
w_ratio: 0.10
h_ratio: 0.15
threshold: 0.80
require_image: false
description: 右下角发送按钮
@ -45,6 +68,11 @@ input_box:
y_ratio: 0.914
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.55
y_ratio: 0.78
w_ratio: 0.45
h_ratio: 0.22
threshold: 0.80
require_image: false
description: 聊天输入框
@ -57,6 +85,11 @@ input_box_empty:
y_ratio: 0.914
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.55
y_ratio: 0.78
w_ratio: 0.45
h_ratio: 0.22
threshold: 0.80
require_image: false
description: 空输入框灰色提示态(发送成功后校验用)
@ -69,6 +102,11 @@ search_result_first:
y_ratio: 0.174
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.10
w_ratio: 0.30
h_ratio: 0.40
threshold: 0.80
require_image: false
description: 搜索结果第一项
@ -82,7 +120,7 @@ main_view:
y_offset: 0
threshold: 0.75
require_image: false
description: 主聊天视图post_verify 校验用,模板匹配不稳定,使用几何兜底
description: 主聊天视图post_verify 校验用,模板匹配不稳定,使用几何兜底;大区域无需 ROI 限制
# 好友申请通过 UI 元素定位experimental坐标为估算值需实测校准
contact_icon:
@ -93,6 +131,11 @@ contact_icon:
y_ratio: 0.171
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.0
y_ratio: 0.05
w_ratio: 0.10
h_ratio: 0.30
threshold: 0.80
require_image: false
description: 左侧导航栏通讯录图标
@ -105,6 +148,11 @@ new_friend_entry:
y_ratio: 0.138
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.05
y_ratio: 0.10
w_ratio: 0.25
h_ratio: 0.20
threshold: 0.80
require_image: false
description: 通讯录页"新的朋友"入口
@ -116,6 +164,11 @@ friend_request_item:
y_ratio: 0.178
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.05
y_ratio: 0.15
w_ratio: 0.30
h_ratio: 0.30
threshold: 0.75
require_image: false
description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)'
@ -128,6 +181,11 @@ verify_button:
y_ratio: 0.394
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.40
y_ratio: 0.20
w_ratio: 0.40
h_ratio: 0.40
threshold: 0.75
require_image: false
description: 好友详情页"前往验证"按钮OpenCV 实测标定)
@ -139,6 +197,11 @@ confirm_button:
y_ratio: 0.830
x_offset: 0
y_offset: 0
roi:
x_ratio: 0.30
y_ratio: 0.65
w_ratio: 0.40
h_ratio: 0.30
threshold: 0.75
require_image: false
description: '"通过朋友验证"弹窗绿色确定按钮OpenCV 弹窗出现时实测 666/1435, 727/876'