2026-07-16 01:19:47 +08:00
|
|
|
|
"""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 校验失败计数 + 熔断
|
2026-07-17 18:10:31 +08:00
|
|
|
|
6. 失败时 session_cache.invalidate(to_wxid)(仅失效当前联系人)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import time
|
2026-07-17 18:10:31 +08:00
|
|
|
|
from collections import OrderedDict
|
|
|
|
|
|
from typing import Optional, TYPE_CHECKING
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
2026-07-16 16:53:42 +08:00
|
|
|
|
from woc_bridge.models import BridgeError
|
2026-07-17 18:10:31 +08:00
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
|
from woc_bridge.ui.flows.send_file import SendFileFlow
|
|
|
|
|
|
from woc_bridge.ui.flows.send_text import SendTextFlow
|
2026-07-16 01:19:47 +08:00
|
|
|
|
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:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"""会话缓存:LRU 多会话,30s TTL。
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
命中时 SendTextFlow 跳过 activate/click_search_box/type_query/open_session 四步,
|
|
|
|
|
|
直接校验会话仍打开后进入 focus_input。
|
2026-07-17 18:10:31 +08:00
|
|
|
|
多会话缓存提升 30-100 并发人群中重复联系人的命中率。
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
def __init__(self, ttl: float = 30.0, max_size: int = 16) -> None:
|
2026-07-16 01:19:47 +08:00
|
|
|
|
self.ttl = ttl
|
2026-07-17 18:10:31 +08:00
|
|
|
|
self.max_size = max_size
|
|
|
|
|
|
# wxid -> expire_at,按最近使用排序
|
|
|
|
|
|
self._cache: OrderedDict[str, float] = OrderedDict()
|
|
|
|
|
|
|
|
|
|
|
|
def get(self, to_wxid: str) -> bool:
|
|
|
|
|
|
"""查询指定 wxid 是否在缓存且未过期。命中时移到队尾(最近使用)。"""
|
|
|
|
|
|
expire_at = self._cache.get(to_wxid)
|
|
|
|
|
|
if expire_at is None:
|
|
|
|
|
|
return False
|
2026-07-16 01:19:47 +08:00
|
|
|
|
if time.monotonic() >= expire_at:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
self._cache.pop(to_wxid, None)
|
|
|
|
|
|
return False
|
|
|
|
|
|
self._cache.move_to_end(to_wxid)
|
|
|
|
|
|
return True
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
def set(self, to_wxid: str) -> None:
|
|
|
|
|
|
"""更新缓存。"""
|
2026-07-17 18:10:31 +08:00
|
|
|
|
self._cache[to_wxid] = time.monotonic() + self.ttl
|
|
|
|
|
|
self._cache.move_to_end(to_wxid)
|
|
|
|
|
|
# 超限淘汰最久未使用
|
|
|
|
|
|
while len(self._cache) > self.max_size:
|
|
|
|
|
|
self._cache.popitem(last=False)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
def invalidate(self, to_wxid: Optional[str] = None) -> None:
|
|
|
|
|
|
"""失效缓存。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
to_wxid: 为 None 时清除全部缓存;为具体 wxid 时仅清除该条目
|
|
|
|
|
|
(传空字符串不会误清全部)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if to_wxid is None:
|
|
|
|
|
|
self._cache.clear()
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._cache.pop(to_wxid, None)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FlowOrchestrator:
|
|
|
|
|
|
"""L5 Flow 编排器。
|
|
|
|
|
|
|
|
|
|
|
|
整合幂等缓存、熔断器、会话缓存、发送队列、Flow 执行。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
send_queue,
|
|
|
|
|
|
idem_cache: IdemCache,
|
|
|
|
|
|
actions,
|
|
|
|
|
|
db_reader,
|
|
|
|
|
|
config,
|
|
|
|
|
|
send_text_flow: Optional[SendTextFlow] = None,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
send_file_flow: Optional[SendFileFlow] = None,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""初始化编排器。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
send_queue: SendQueue 实例
|
|
|
|
|
|
idem_cache: IdemCache 实例
|
|
|
|
|
|
actions: L3 Actions 实例
|
|
|
|
|
|
db_reader: DbReader 实例
|
|
|
|
|
|
config: BridgeConfig 实例(含 send_delay_ms 等)
|
|
|
|
|
|
send_text_flow: 可选的 SendTextFlow 实例(用于测试注入)
|
2026-07-16 16:53:42 +08:00
|
|
|
|
send_file_flow: 可选的 SendFileFlow 实例(必须由 app._init_state 注入,
|
|
|
|
|
|
因 SendFileFlow 依赖 XdotoolDriver,orchestrator 无法惰性创建)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"""
|
|
|
|
|
|
self.send_queue = send_queue
|
|
|
|
|
|
self.idem_cache = idem_cache
|
|
|
|
|
|
self.actions = actions
|
|
|
|
|
|
self.db_reader = db_reader
|
|
|
|
|
|
self.config = config
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# LRU 多会话缓存:最多 16 个会话,30s TTL
|
|
|
|
|
|
self.session_cache = SessionCache(ttl=30.0, max_size=16)
|
|
|
|
|
|
# DB 校验熔断器:10 次失败 / 30s 恢复(高并发下更宽容)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
self.db_verify_breaker = CircuitBreaker(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"db_verify", failure_threshold=10, recovery_timeout=30.0
|
2026-07-16 01:19:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._send_text_flow = send_text_flow
|
2026-07-16 16:53:42 +08:00
|
|
|
|
self._send_file_flow = send_file_flow
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-16 16:53:42 +08:00
|
|
|
|
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)",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-16 01:19:47 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[orchestrator] send_text START to_wxid=%s content_len=%d display_name=%s client_request_id=%s",
|
|
|
|
|
|
to_wxid, len(content), display_name or "(empty)", client_request_id or "(none)",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# 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(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] db_verify breaker OPEN, reject send (to_wxid=%s)",
|
|
|
|
|
|
to_wxid,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
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. 自适应延时:同联系人用短延时
|
2026-07-17 18:10:31 +08:00
|
|
|
|
is_same_contact = self.session_cache.get(to_wxid)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
delay_ms = _SAME_CONTACT_DELAY_MS if is_same_contact else None
|
|
|
|
|
|
# None 表示用 send_queue 的默认 send_delay_ms
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[orchestrator] send_text enqueue: same_contact=%s delay_ms=%s to_wxid=%s",
|
|
|
|
|
|
is_same_contact, delay_ms, to_wxid,
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
# 4. 构造 FlowContext
|
|
|
|
|
|
ctx = FlowContext(
|
|
|
|
|
|
to_wxid=to_wxid,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
display_name=display_name,
|
|
|
|
|
|
client_request_id=client_request_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 5. 入队执行(带队列等待超时,避免无界排队)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
flow = self._get_send_text_flow()
|
|
|
|
|
|
try:
|
|
|
|
|
|
result: FlowResult = await self.send_queue.enqueue(
|
|
|
|
|
|
lambda: flow.run(ctx),
|
|
|
|
|
|
delay_ms=delay_ms,
|
2026-07-17 18:10:31 +08:00
|
|
|
|
wait_timeout_ms=self.config.send_queue_wait_timeout_ms,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.error(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send_queue exception: %s: %s (to_wxid=%s)",
|
|
|
|
|
|
type(exc).__name__, exc, to_wxid,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 失败时仅失效当前联系人会话缓存,避免误伤其它缓存命中
|
|
|
|
|
|
self.session_cache.invalidate(to_wxid)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# 保留 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()
|
2026-07-17 18:10:31 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
elif result.success and not result.verified:
|
|
|
|
|
|
# 发送成功但校验失败:DB 校验熔断计数
|
|
|
|
|
|
self.db_verify_breaker.record_failure()
|
|
|
|
|
|
logger.warning(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send success but verify failed, breaker fail_count+1 (to_wxid=%s)",
|
|
|
|
|
|
to_wxid,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 发送失败:仅失效当前联系人会话缓存,避免误伤其它缓存命中
|
|
|
|
|
|
self.session_cache.invalidate(to_wxid)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
logger.warning(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send failed: %s (code=%s, to_wxid=%s)",
|
|
|
|
|
|
result.error, result.error_code, to_wxid,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
2026-07-16 16:53:42 +08:00
|
|
|
|
|
|
|
|
|
|
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 段 key,file_path 作为 content 槽位传入)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
to_wxid: 目标会话 wxid
|
|
|
|
|
|
file_path: 文件绝对路径(必须在白名单目录内)
|
|
|
|
|
|
file_type: "image" / "file" / "video"(仅用于日志,不影响流程)
|
|
|
|
|
|
client_request_id: 客户端幂等 ID(None 或空字符串表示不参与幂等)
|
|
|
|
|
|
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 ""
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[orchestrator] send_file START to_wxid=%s file=%s type=%s display_name=%s client_request_id=%s",
|
|
|
|
|
|
to_wxid, file_path, file_type, display_name or "(none)", idem_id or "(none)",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-16 16:53:42 +08:00
|
|
|
|
# 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(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send_file: db_verify breaker OPEN, reject (to_wxid=%s)",
|
|
|
|
|
|
to_wxid,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
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)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[orchestrator] send_file enqueue (pending=%d)",
|
|
|
|
|
|
self.send_queue.pending_count(),
|
|
|
|
|
|
)
|
2026-07-16 16:53:42 +08:00
|
|
|
|
try:
|
|
|
|
|
|
result = await self.send_queue.enqueue(
|
|
|
|
|
|
lambda: flow.execute(
|
|
|
|
|
|
to_wxid, file_path, file_type, display_name=display_name
|
|
|
|
|
|
),
|
2026-07-17 18:10:31 +08:00
|
|
|
|
wait_timeout_ms=self.config.send_queue_wait_timeout_ms,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
except BridgeError as exc:
|
|
|
|
|
|
logger.error(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send_file BridgeError code=%s msg=%s (to_wxid=%s)",
|
|
|
|
|
|
exc.code, exc.message, to_wxid,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 失败时失效当前联系人会话缓存
|
|
|
|
|
|
self.session_cache.invalidate(to_wxid)
|
2026-07-16 16:53:42 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"success": False, "verified": False, "placeholder": False,
|
|
|
|
|
|
"skipped": False,
|
|
|
|
|
|
"error": exc.message,
|
|
|
|
|
|
"error_code": exc.code,
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.error(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send_file exception: %s: %s (to_wxid=%s)",
|
|
|
|
|
|
type(exc).__name__, exc, to_wxid,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
self.session_cache.invalidate(to_wxid)
|
2026-07-16 16:53:42 +08:00
|
|
|
|
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)
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[orchestrator] send_file DONE ✓ to=%s file=%s verified=%s (%.0fms)",
|
|
|
|
|
|
to_wxid, file_path, verified,
|
|
|
|
|
|
(time.monotonic() - started_at) * 1000,
|
|
|
|
|
|
)
|
2026-07-16 16:53:42 +08:00
|
|
|
|
elif success and verified is False:
|
|
|
|
|
|
# 发送成功但校验失败(真正的超时/未匹配):DB 校验熔断计数
|
|
|
|
|
|
self.db_verify_breaker.record_failure()
|
|
|
|
|
|
self.session_cache.set(to_wxid)
|
|
|
|
|
|
logger.warning(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send_file success but verify failed, breaker fail_count+1 (to_wxid=%s)",
|
|
|
|
|
|
to_wxid,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
elif success and verified is None:
|
|
|
|
|
|
# P0 修复:发送成功但无法校验(DB 不可用或无基线):
|
|
|
|
|
|
# 不计熔断失败,避免 DB 不可用时熔断器雪崩阻塞全部发送。
|
|
|
|
|
|
# 也不写幂等缓存(未校验的结果不应被复用)。
|
|
|
|
|
|
self.session_cache.set(to_wxid)
|
|
|
|
|
|
logger.info(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched (to_wxid=%s)",
|
|
|
|
|
|
to_wxid,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 发送失败:仅失效当前联系人会话缓存
|
|
|
|
|
|
self.session_cache.invalidate(to_wxid)
|
2026-07-16 16:53:42 +08:00
|
|
|
|
logger.warning(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
"[orchestrator] send_file failed: %s (to_wxid=%s)",
|
|
|
|
|
|
result.get("error"), to_wxid,
|
2026-07-16 16:53:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": success,
|
|
|
|
|
|
"verified": verified,
|
|
|
|
|
|
"placeholder": False,
|
|
|
|
|
|
"skipped": False,
|
|
|
|
|
|
"error": result.get("error"),
|
|
|
|
|
|
"error_code": None,
|
|
|
|
|
|
}
|