WechatOnCloud/bridge/woc_bridge/ui/orchestrator.py
Kris 2348a04163 feat: 完成微信4.0模板UI重构与批量群发、导出功能落地
本次提交包含多项核心更新:
1. 新增微信4.0浅色主题UI模板资源与说明文档,补充了联系人图标、新的朋友入口等四个UI元素的图像匹配模板
2. 重构UI驱动层,将原2375行的单文件拆分为5个职责清晰的子驱动类,提升代码可维护性
3. 新增批量群发消息与聊天记录导出的完整数据模型、路由与后台协程,支持幂等校验与风控配置
4. 为所有业务接口新增post_verify校验逻辑,补充了verified字段返回操作结果
5. 优化媒体文件解密逻辑,修复了导出功能中的数据库错误处理与分辨率硬编码问题
6. 更新版本配置,将媒体发送功能标记为已落地,调整了能力列表与配置项
2026-07-16 16:53:42 +08:00

425 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
}