ForcePilot/backend/package/yuxi/channels/application/extension/event_bus.py
Kris 0d067d61d3 chore: 批量更新契约与实现,修复多处细节问题
本次提交包含多类代码优化与修复:
1. 重命名适配器协议类:WebhookTestable→WebhookTestAdapter、AttachmentUploadable→AttachmentUploadAdapter,并同步更新所有引用
2. 为飞书/企业微信插件添加凭证克隆能力开关配置
3. 修复飞书入站适配器URL解析错误,使用hostname替代host属性
4. 新增批量发送消息DTO与已读状态DTO
5. 新增插件目录列表接口与插件能力校验规则
6. 修复权限阶段配置,添加analytics权限映射
7. 优化健康检查、限流模块的环境变量配置支持
8. 修复配对过期扫描器参数名不匹配问题
9. 优化日志调用、文档注释与代码可读性
10. 移除废弃的AuditExportTask聚合根与相关导入
2026-07-02 18:27:06 +08:00

225 lines
9.5 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.

"""进程内事件总线。
实现 ``PluginHost.publishEvent``,按事件类型分发领域事件到订阅者。仅
进程内分发,不引入消息中间件。订阅管理采用读无锁、写加锁策略,与
``core/registry`` 一致。
"""
from __future__ import annotations
import asyncio
import threading
import uuid
from dataclasses import dataclass
from yuxi.channels.contract.dtos.event import OutboxStateChangedEvent
from yuxi.channels.contract.dtos.plugin import DomainEvent
from yuxi.channels.contract.plugin.extension_point import EventSubscription
from yuxi.channels.contract.plugin.manifest import FailurePolicy
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.core.service.degradation_manager import DegradationManager
__all__ = ["EventBus"]
@dataclass(frozen=True)
class _SubscriptionEntry:
"""内部订阅条目。
关联插件 ID 与事件订阅,用于注销时按插件 ID 过滤。``EventSubscription``
契约不含插件 ID 字段,故在总线内部维护关联关系。
字段:
plugin_id: 插件 ID。
subscription: 事件订阅值对象。
"""
plugin_id: str
subscription: EventSubscription
class EventBus:
"""进程内事件总线。
实现 ``PluginHost.publishEvent``,按事件类型查找订阅者并按优先级调用
handler分发 21 类领域事件。不引入消息中间件,仅进程内分发。
线程安全:订阅管理采用读无锁、写加锁策略(与 ``core/registry`` 一致),
读操作直接访问内存数据结构,写操作通过 ``threading.Lock`` 保护。
关键约束:
- handler 异常不得拖垮宿主FR-36捕获异常并记录日志不中断
其他订阅者。
- 订阅按 ``priority`` 升序排列(越小越先执行,与扩展点优先级规则
一致)。
- handler 超时保护:单个 handler 调用施加 ``_HANDLER_TIMEOUT``
超时控制,防止恶意/故障 handler 阻塞分发。
- 失败策略联动handler 抛错时按 ``subscription.failure_policy``
处理,``CIRCUIT_BREAK`` / ``ISOLATE`` 触发 ``DegradationManager``
降级,``DEGRADE`` 仅记录告警。
依赖:
logger: 日志被驱动端口,记录 handler 失败等告警。
degradation_manager: 降级管理服务,用于在 handler 失败时按策略
触发插件降级/熔断/隔离。
关联 FRFR-36。
"""
#: 单个事件 handler 调用超时(秒),防止恶意/故障 handler 阻塞分发
_HANDLER_TIMEOUT: float = 5.0
def __init__(
self,
logger: LoggerPort,
degradation_manager: DegradationManager,
) -> None:
"""初始化事件总线。
参数:
logger: 日志被驱动端口,用于记录 handler 失败等告警。
degradation_manager: 降级管理服务,用于在 handler 失败时按
``failure_policy`` 触发插件降级/熔断/隔离。
"""
self._logger = logger
self._degradation = degradation_manager
self._lock = threading.Lock()
# 按事件类型索引订阅条目列表
self._subscriptions: dict[str, list[_SubscriptionEntry]] = {}
async def publish(self, event: DomainEvent) -> None:
"""发布事件,按优先级分发到所有订阅者。
按 ``event.event_type`` 查找订阅者,按 ``priority`` 升序调用 handler。
每个 handler 调用施加 ``_HANDLER_TIMEOUT`` 超时控制,超时或异常
按 ``subscription.failure_policy`` 处理FR-36
- DEGRADE记录告警跳过本次调用继续其他订阅者。
- CIRCUIT_BREAK / ISOLATE调用 ``DegradationManager.onPluginFailed``
触发插件降级,然后继续其他订阅者。
参数:
event: 领域事件 DTO。
"""
# 读无锁:取事件类型对应的订阅条目副本后遍历,避免并发写影响迭代
entries = list(self._subscriptions.get(event.event_type, []))
for entry in entries:
try:
await asyncio.wait_for(
entry.subscription.handler.handle(event),
timeout=self._HANDLER_TIMEOUT,
)
except TimeoutError:
strategy = entry.subscription.failure_policy
if strategy in (FailurePolicy.CIRCUIT_BREAK, FailurePolicy.ISOLATE):
# 按策略触发插件降级/熔断/隔离,降级管理器自身异常不得
# 拖垮宿主,记录告警后继续分发其他订阅者
try:
await self._degradation.onPluginFailed(
entry.plugin_id,
f"handler timeout: {self._HANDLER_TIMEOUT}s",
)
except Exception as degrade_err:
await self._logger.warn(
f"降级管理器调用失败: {degrade_err}",
plugin_id=entry.plugin_id,
error=str(degrade_err),
)
# handler 超时不得拖垮宿主,记录告警后继续分发其他订阅者
await self._logger.warn(
f"事件订阅 handler 超时: event_type={event.event_type}, strategy={strategy}",
trace_id=event.trace_id,
plugin_id=entry.plugin_id,
timeout=self._HANDLER_TIMEOUT,
)
except Exception as e:
strategy = entry.subscription.failure_policy
if strategy in (FailurePolicy.CIRCUIT_BREAK, FailurePolicy.ISOLATE):
# 按策略触发插件降级/熔断/隔离,降级管理器自身异常不得
# 拖垮宿主,记录告警后继续分发其他订阅者
try:
await self._degradation.onPluginFailed(
entry.plugin_id,
str(e),
)
except Exception as degrade_err:
await self._logger.warn(
f"降级管理器调用失败: {degrade_err}",
plugin_id=entry.plugin_id,
error=str(degrade_err),
)
# 插件 handler 失败不得拖垮宿主,记录日志后继续分发其他订阅者
await self._logger.warn(
f"事件订阅 handler 失败: event_type={event.event_type}, strategy={strategy.value}",
trace_id=event.trace_id,
plugin_id=entry.plugin_id,
error=str(e),
)
async def publishOutboxStateChanged(
self,
event: OutboxStateChangedEvent,
) -> None:
"""发布发件箱状态变更事件FR-22 / FR-34
将 ``OutboxStateChangedEvent`` 转换为 ``DomainEvent``event_type=
``"OutboxStateChanged"``),复用 ``publish`` 分发给已注册的订阅者
(如 ``OutboxStateAuditHandler``。payload 对齐
``OutboxStateChanged`` 领域事件的序列化格式(``outbox_id`` /
``old_status`` / ``new_status``),供 ``OutboxStateAuditHandler``
提取;并额外携带 ``reason`` 与 ``occurred_at`` 供下游消费。
handler 异常由 ``publish`` 捕获并记录警告日志,不中断其他订阅者
FR-36故本方法不添加 try/except 包裹 ``publish`` 调用,禁止
静默吞没异常。
参数:
event: 发件箱状态变更事件 DTO携带 entry_id / old_state /
new_state / reason / occurred_at。
"""
domain_event = DomainEvent(
event_id=str(uuid.uuid4()),
event_type="OutboxStateChanged",
payload={
"outbox_id": event.entry_id,
"old_status": event.old_state,
"new_status": event.new_state,
"reason": event.reason,
"occurred_at": event.occurred_at.isoformat(),
},
timestamp=event.occurred_at,
trace_id=None,
)
await self.publish(domain_event)
def register(self, plugin_id: str, subscription: EventSubscription) -> None:
"""注册事件订阅。
将订阅条目追加到对应事件类型的列表,并按 ``priority`` 升序排序
(越小越先执行)。
参数:
plugin_id: 插件 ID。
subscription: 事件订阅值对象。
"""
entry = _SubscriptionEntry(plugin_id=plugin_id, subscription=subscription)
with self._lock:
self._subscriptions.setdefault(subscription.event_type, []).append(entry)
self._subscriptions[subscription.event_type].sort(
key=lambda e: e.subscription.priority,
)
def unregister(self, plugin_id: str) -> None:
"""注销插件的所有事件订阅。
遍历所有事件类型的订阅列表,移除属于该插件 ID 的订阅条目。
参数:
plugin_id: 插件 ID。
"""
with self._lock:
for event_type in self._subscriptions:
self._subscriptions[event_type] = [
e for e in self._subscriptions[event_type] if e.plugin_id != plugin_id
]