WechatOnCloud/bridge/woc_bridge/ui/orchestrator.py

425 lines
16 KiB
Python
Raw Normal View History

"""L5 FlowOrchestrator编排幂等/熔断/会话缓存/队列/Flow。
send_text 流程
1. 幂等检查命中返回 skipped=true
2. DB 校验熔断检查OPEN 时返回 BRIDGE_CIRCUITED
3. 入队自适应 delay_ms同联系人 1000 / 不同 config.send_delay_ms
4. 成功且 verified 才写幂等缓存
5. DB 校验失败计数 + 熔断
6. 失败时 session_cache.invalidate()
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Optional
from woc_bridge.models import BridgeError
from woc_bridge.ui.circuit_breaker import CircuitBreaker, CircuitState
from woc_bridge.ui.errors import FlowError, is_retryable
from woc_bridge.ui.flow import FlowContext, FlowResult, FlowState
from woc_bridge.ui.idem_cache import IdemCache
logger = logging.getLogger("woc-bridge")
# 同联系人发送间隔(毫秒),比默认 send_delay_ms 短
_SAME_CONTACT_DELAY_MS = 1000
class SessionCache:
"""会话缓存记录最近打开的会话名30s TTL。
命中时 SendTextFlow 跳过 activate/click_search_box/type_query/open_session 四步
直接校验会话仍打开后进入 focus_input
"""
def __init__(self, ttl: float = 30.0) -> None:
self.ttl = ttl
self._current: Optional[tuple[str, float]] = None # (to_wxid, expire_at)
def get(self) -> Optional[str]:
"""返回当前缓存的 to_wxid过期或无缓存返回 None。"""
if self._current is None:
return None
to_wxid, expire_at = self._current
if time.monotonic() >= expire_at:
self._current = None
return None
return to_wxid
def set(self, to_wxid: str) -> None:
"""更新缓存。"""
self._current = (to_wxid, time.monotonic() + self.ttl)
def invalidate(self) -> None:
"""失效缓存。"""
self._current = None
class FlowOrchestrator:
"""L5 Flow 编排器。
整合幂等缓存熔断器会话缓存发送队列Flow 执行
"""
def __init__(
self,
send_queue,
idem_cache: IdemCache,
actions,
db_reader,
config,
send_text_flow: Optional[SendTextFlow] = None,
send_file_flow: Optional[SendFileFlow] = None,
) -> None:
"""初始化编排器。
Args:
send_queue: SendQueue 实例
idem_cache: IdemCache 实例
actions: L3 Actions 实例
db_reader: DbReader 实例
config: BridgeConfig 实例 send_delay_ms
send_text_flow: 可选的 SendTextFlow 实例用于测试注入
send_file_flow: 可选的 SendFileFlow 实例必须由 app._init_state 注入
SendFileFlow 依赖 XdotoolDriverorchestrator 无法惰性创建
"""
self.send_queue = send_queue
self.idem_cache = idem_cache
self.actions = actions
self.db_reader = db_reader
self.config = config
self.session_cache = SessionCache(ttl=30.0)
# DB 校验熔断器5 次失败 / 30s 恢复
self.db_verify_breaker = CircuitBreaker(
"db_verify", failure_threshold=5, recovery_timeout=30.0
)
self._send_text_flow = send_text_flow
self._send_file_flow = send_file_flow
def _get_send_text_flow(self):
"""获取 SendTextFlow 实例(惰性创建或使用注入的)。"""
if self._send_text_flow is not None:
return self._send_text_flow
# 惰性导入避免循环依赖
from woc_bridge.ui.flows.send_text import SendTextFlow
return SendTextFlow(
actions=self.actions,
db_reader=self.db_reader,
session_cache=self.session_cache,
)
def _get_send_file_flow(self):
"""获取 SendFileFlow 实例(必须由 app._init_state 注入)。
SendFileFlow 依赖 XdotoolDriver用于 xdotool type 路径
orchestrator 未持有 XdotoolDriver 引用无法惰性创建
未注入时抛 BridgeError(BRIDGE_INTERNAL_ERROR)
"""
if self._send_file_flow is not None:
return self._send_file_flow
raise BridgeError(
code="BRIDGE_INTERNAL_ERROR",
message="send_file_flow 未初始化(需在 app._init_state 中注入 SendFileFlow",
)
async def send_text(
self,
to_wxid: str,
content: str,
display_name: str = "",
client_request_id: str = "",
) -> FlowResult:
"""发送文本消息。
流程
1. 幂等检查
2. DB 校验熔断检查
3. 自适应延时入队
4. 执行 SendTextFlow
5. 成功且 verified 写幂等缓存
6. 失败时失效会话缓存
Args:
to_wxid: 目标 wxid
content: 消息内容
display_name: 显示名搜索用
client_request_id: 客户端幂等 ID
Returns:
FlowResult
"""
started_at = time.monotonic()
# 1. 幂等检查
cached = self.idem_cache.get(
"send_text", to_wxid, content, client_request_id
)
if cached is not None:
logger.info(
"[orchestrator] idem cache hit, skip send (to_wxid=%s)",
to_wxid,
)
# 返回缓存的 FlowResult标记 skipped
if isinstance(cached, FlowResult):
return FlowResult(
success=True,
local_id=cached.local_id,
verified=cached.verified,
skipped=True,
state=cached.state,
duration_ms=(time.monotonic() - started_at) * 1000,
)
# 兜底:构造一个 skipped 结果
return FlowResult(
success=True,
skipped=True,
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"
)
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. 自适应延时:同联系人用短延时
cached_session = self.session_cache.get()
is_same_contact = (
cached_session is not None and cached_session == to_wxid
)
delay_ms = _SAME_CONTACT_DELAY_MS if is_same_contact else None
# None 表示用 send_queue 的默认 send_delay_ms
# 4. 构造 FlowContext
ctx = FlowContext(
to_wxid=to_wxid,
content=content,
display_name=display_name,
client_request_id=client_request_id,
)
# 5. 入队执行
flow = self._get_send_text_flow()
try:
result: FlowResult = await self.send_queue.enqueue(
lambda: flow.run(ctx),
delay_ms=delay_ms,
)
except Exception as exc:
logger.error(
"[orchestrator] send_queue exception: %s", exc
)
# 失败时失效会话缓存
self.session_cache.invalidate()
# 保留 BridgeError 的原始错误码(如 RATE_LIMITED
# 避免限流/超时等可重试错误被吞成 SEND_FAILED
error_code = "SEND_FAILED"
if hasattr(exc, "code") and exc.code:
error_code = exc.code
# 传递 details如 retry_after供路由层设置 Retry-After 头
exc_details = None
if hasattr(exc, "details") and exc.details:
exc_details = exc.details
return FlowResult(
success=False,
error=str(exc),
error_code=error_code,
state=FlowState.FAILED,
duration_ms=(time.monotonic() - started_at) * 1000,
details=exc_details,
)
# 6. 结果处理
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()
elif result.success and not result.verified:
# 发送成功但校验失败DB 校验熔断计数
self.db_verify_breaker.record_failure()
logger.warning(
"[orchestrator] send success but verify failed, breaker fail_count+1"
)
else:
# 发送失败:失效会话缓存
self.session_cache.invalidate()
logger.warning(
"[orchestrator] send failed: %s (code=%s)",
result.error, result.error_code,
)
return result
async def send_file(
self,
to_wxid: str,
file_path: str,
file_type: str = "file",
client_request_id: Optional[str] = None,
display_name: Optional[str] = None,
) -> dict:
"""文件发送 Flow 编排:幂等检查 + 熔断 + 入队 + DB 校验。
流程
1. 幂等检查命中返回 skipped=True
2. DB 校验熔断检查OPEN 时返回 BRIDGE_CIRCUITED
3. 入队执行 SendFileFlow.execute路径白名单 + 文件选择器输入 + post_verify
4. 成功且 verified 写幂等缓存
5. DB 校验失败计数 + 熔断
6. 失败时失效会话缓存
幂等缓存键(send_file, to_wxid, file_path, client_request_id)
IdemCache API 仅支持 4 keyfile_path 作为 content 槽位传入
Args:
to_wxid: 目标会话 wxid
file_path: 文件绝对路径必须在白名单目录内
file_type: "image" / "file" / "video"仅用于日志不影响流程
client_request_id: 客户端幂等 IDNone 或空字符串表示不参与幂等
display_name: 用于微信搜索框定位会话的显示名备注/昵称
None SendFileFlow 回退到 to_wxid 搜索
Returns:
{"success": bool, "verified": bool, "placeholder": False,
"skipped": bool, "error": Optional[str], "error_code": Optional[str]}
Raises:
BridgeError(BRIDGE_INTERNAL_ERROR): send_file_flow 未注入
"""
started_at = time.monotonic()
# client_request_id 归一化None -> ""
idem_id = client_request_id or ""
# 1. 幂等检查
cached = self.idem_cache.get("send_file", to_wxid, file_path, idem_id)
if cached is not None:
logger.info(
"[orchestrator] send_file idem cache hit, skip (to_wxid=%s)",
to_wxid,
)
if isinstance(cached, dict):
return {
"success": cached.get("success", True),
"verified": cached.get("verified", False),
"placeholder": False,
"skipped": True,
"error": None,
"error_code": None,
}
# 兜底:构造一个 skipped 结果
return {
"success": True, "verified": False, "placeholder": False,
"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"
)
return {
"success": False, "verified": False, "placeholder": False,
"skipped": False,
"error": "db_verify circuit breaker open",
"error_code": "BRIDGE_CIRCUITED",
}
# 3. 获取 SendFileFlow 实例(未注入时抛 BridgeError
flow = self._get_send_file_flow()
# 4. 入队执行SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify
try:
result = await self.send_queue.enqueue(
lambda: flow.execute(
to_wxid, file_path, file_type, display_name=display_name
),
)
except BridgeError as exc:
logger.error(
"[orchestrator] send_file BridgeError code=%s msg=%s",
exc.code, exc.message,
)
# 失败时失效会话缓存
self.session_cache.invalidate()
return {
"success": False, "verified": False, "placeholder": False,
"skipped": False,
"error": exc.message,
"error_code": exc.code,
}
except Exception as exc:
logger.error(
"[orchestrator] send_file exception: %s: %s",
type(exc).__name__, exc,
)
self.session_cache.invalidate()
return {
"success": False, "verified": False, "placeholder": False,
"skipped": False,
"error": str(exc),
"error_code": "SEND_FAILED",
}
# 5. 结果处理
# 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写幂等缓存 + 熔断成功
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)
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"
)
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"
)
else:
# 发送失败:失效会话缓存
self.session_cache.invalidate()
logger.warning(
"[orchestrator] send_file failed: %s",
result.get("error"),
)
logger.info(
"[orchestrator] send_file done to=%s file=%s success=%s verified=%s (%.0fms)",
to_wxid, file_path, success, verified,
(time.monotonic() - started_at) * 1000,
)
return {
"success": success,
"verified": verified,
"placeholder": False,
"skipped": False,
"error": result.get("error"),
"error_code": None,
}