Files
MoviePilot/app/runtime/event/contracts.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

342 lines
13 KiB
Python
Raw Permalink 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.
"""
事件 payload、可见性、投递与可靠性契约清单。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ValidationError
import app.schemas.event as event_schemas
from app.schemas.mediaserver import WebhookEventInfo
from app.schemas.types import ChainEventType, EventType
from app.schemas.workflow import ActionContext
class EventDelivery(StrEnum):
"""
描述事件当前的交付保证。
"""
# 进程内即时交付,不保证重启后重放
EPHEMERAL = "ephemeral"
# 发布前要求由持久化发件箱保障交付
DURABLE_REQUIRED = "durable_required"
class EventVisibility(StrEnum):
"""
描述事件是否属于插件公开扩展面。
"""
# 仅供宿主内部消费者使用
HOST_ONLY = "host_only"
# 允许插件订阅的公开事件
PLUGIN_PUBLIC = "plugin_public"
# 仅投递给指定插件实例
TARGET_PLUGIN = "target_plugin"
class EventErrorBehavior(StrEnum):
"""
描述 handler 异常后的传播规则。
"""
# 隔离当前处理器异常,继续其他处理器
ISOLATE = "isolate"
# 遇到处理器异常时中断当前调用链
STOP_CHAIN = "stop_chain"
# 将处理器失败交给通知策略处理
NOTIFY = "notify"
class EventPayloadMode(StrEnum):
"""
描述事件 payload 是运行时对象还是稳定快照。
"""
# 保留进程内运行时对象语义
RUNTIME = "runtime"
# 使用可稳定校验的载荷快照
SNAPSHOT = "snapshot"
# 保留扩展方定义的载荷结构
EXTENSION = "extension"
class EventValidationMode(StrEnum):
"""
描述契约校验失败后的兼容策略。
"""
# 校验失败只记录诊断,保留兼容调用
DIAGNOSTIC = "diagnostic"
# 校验失败即拒绝继续投递
STRICT = "strict"
@dataclass(frozen=True, slots=True)
class EventContract:
"""
冻结一个事件的 payload 与运行语义。
:param event_name: 事件注册名称
:param payload_model: 完整载荷的校验模型;为 None 时不执行该模型校验
:param payload_contract: 面向消费者描述的载荷协议名称
:param mode: 事件的分发模式
:param visibility: 事件面向宿主、全部插件或指定插件的可见范围
:param delivery: 事件需要的交付保证
:param error_behavior: 处理器异常后的传播或隔离策略
:param ordering: 处理器调用顺序约束
:param input_model: 链式事件输入模型;未声明时为 None
:param output_model: 链式事件输出模型;未声明时为 None
:param schema_version: 载荷结构版本,默认从 1 开始
:param payload_mode: 载荷保留运行时对象、冻结快照或扩展对象的策略
:param validation_mode: 校验失败时记录诊断或严格拒绝的策略
:param sensitive_fields: 输出诊断时需要脱敏的字段路径集合
:param legacy_reason: 保留历史契约的原因;没有历史兼容说明时为 None
"""
event_name: str
payload_model: type[BaseModel] | None
payload_contract: str
mode: str
visibility: EventVisibility
delivery: EventDelivery
error_behavior: EventErrorBehavior
ordering: str
input_model: type[BaseModel] | None = None
output_model: type[BaseModel] | None = None
schema_version: int = 1
payload_mode: EventPayloadMode = EventPayloadMode.RUNTIME
validation_mode: EventValidationMode = EventValidationMode.DIAGNOSTIC
sensitive_fields: tuple[str, ...] = ()
legacy_reason: str | None = None
_PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
EventType.ConfigChanged: event_schemas.ConfigChangeEventData,
EventType.PluginReload: event_schemas.PluginReloadEventData,
EventType.PluginAction: event_schemas.PluginActionEventData,
EventType.PluginTriggered: event_schemas.PluginTriggeredEventData,
EventType.CommandExcute: event_schemas.CommandExecuteEventData,
EventType.SiteDeleted: event_schemas.SiteEventData,
EventType.SiteUpdated: event_schemas.SiteEventData,
EventType.SiteRefreshed: event_schemas.SiteEventData,
EventType.AgentTokensUsage: event_schemas.AgentTokensUsageEventData,
EventType.SubscribeAdded: event_schemas.SubscribeAddedEventData,
EventType.SubscribeDeleted: event_schemas.SubscribeDeletedEventData,
EventType.SubscribeModified: event_schemas.SubscribeModifiedEventData,
EventType.DownloadAdded: event_schemas.DownloadAddedContractData,
EventType.TransferComplete: event_schemas.TransferResultContractData,
EventType.TransferFailed: event_schemas.TransferResultContractData,
EventType.SubtitleTransferComplete: event_schemas.TransferResultContractData,
EventType.SubtitleTransferFailed: event_schemas.TransferResultContractData,
EventType.AudioTransferComplete: event_schemas.TransferResultContractData,
EventType.AudioTransferFailed: event_schemas.TransferResultContractData,
EventType.HistoryDeleted: event_schemas.HistoryDeletedEventData,
EventType.DownloadFileDeleted: event_schemas.DownloadFileDeletedEventData,
EventType.DownloadDeleted: event_schemas.DownloadDeletedEventData,
EventType.UserMessage: event_schemas.UserMessageEventData,
EventType.WebhookMessage: WebhookEventInfo,
EventType.NoticeMessage: event_schemas.NoticeMessageEventData,
EventType.SubscribeComplete: event_schemas.SubscribeCompleteEventData,
EventType.SystemError: event_schemas.SystemErrorEventData,
EventType.MetadataScrape: event_schemas.MetadataScrapeContractData,
EventType.ModuleReload: event_schemas.EmptyEventData,
EventType.MessageAction: event_schemas.MessageActionEventData,
EventType.WorkflowExecute: event_schemas.WorkflowExecuteEventData,
ChainEventType.PluginDataReset: event_schemas.PluginDataResetEventData,
ChainEventType.AuthVerification: event_schemas.AuthCredentials,
ChainEventType.AuthIntercept: event_schemas.AuthInterceptCredentials,
ChainEventType.CommandRegister: event_schemas.CommandRegisterEventData,
ChainEventType.TransferRename: event_schemas.TransferRenameEventData,
ChainEventType.TransferRenameBuild: event_schemas.TransferRenameBuildEventData,
ChainEventType.TransferIntercept: event_schemas.TransferInterceptEventData,
ChainEventType.TransferOverwriteCheck: event_schemas.TransferOverwriteCheckEventData,
ChainEventType.ResourceSelection: event_schemas.ResourceSelectionContractData,
ChainEventType.ResourceDownload: event_schemas.ResourceDownloadContractData,
ChainEventType.DiscoverSource: event_schemas.DiscoverSourceEventData,
ChainEventType.MediaRecognizeConvert: event_schemas.MediaRecognizeConvertEventData,
ChainEventType.RecommendSource: event_schemas.RecommendSourceEventData,
ChainEventType.StorageOperSelection: event_schemas.StorageOperSelectionEventData,
ChainEventType.AgentLLMProvider: event_schemas.AgentLLMProviderEventData,
ChainEventType.SubscribeEpisodesRefresh: event_schemas.SubscribeEpisodesRefreshEventData,
ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckContractData,
ChainEventType.NameRecognize: event_schemas.NameRecognizeEventData,
ChainEventType.MusicNameRecognize: event_schemas.MusicNameRecognizeEventData,
ChainEventType.MediaRecognize: event_schemas.MediaRecognizeEventData,
ChainEventType.MusicMediaRecognize: event_schemas.MusicMediaRecognizeEventData,
ChainEventType.WorkflowExecution: ActionContext,
}
_SNAPSHOT_EVENTS = {
EventType.DownloadAdded,
EventType.TransferComplete,
EventType.TransferFailed,
EventType.SubtitleTransferComplete,
EventType.SubtitleTransferFailed,
EventType.AudioTransferComplete,
EventType.AudioTransferFailed,
EventType.MetadataScrape,
ChainEventType.ResourceSelection,
ChainEventType.ResourceDownload,
ChainEventType.SubscribeCompletionCheck,
}
_INPUT_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
ChainEventType.ResourceSelection: event_schemas.ResourceSelectionInputContractData,
ChainEventType.ResourceDownload: event_schemas.ResourceDownloadInputContractData,
ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckInputContractData,
}
_OUTPUT_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
ChainEventType.ResourceSelection: event_schemas.ResourceSelectionOutputContractData,
ChainEventType.ResourceDownload: event_schemas.ResourceDownloadOutputContractData,
ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckOutputContractData,
}
_DURABLE_REQUIRED = {
EventType.SubscribeAdded,
EventType.SubscribeModified,
EventType.SubscribeDeleted,
EventType.SubscribeComplete,
EventType.DownloadAdded,
EventType.TransferComplete,
EventType.TransferFailed,
EventType.SubtitleTransferComplete,
EventType.SubtitleTransferFailed,
EventType.AudioTransferComplete,
EventType.AudioTransferFailed,
}
_TARGET_PLUGIN = {EventType.PluginAction, EventType.PluginTriggered}
_HOST_ONLY = {EventType.SystemError, EventType.ConfigChanged, EventType.ModuleReload}
_SENSITIVE_FIELDS: dict[EventType | ChainEventType, tuple[str, ...]] = {
ChainEventType.AuthVerification: ("password", "token", "mfa_code"),
ChainEventType.AuthIntercept: ("token",),
ChainEventType.AgentLLMProvider: ("api_key",),
}
def _build_contract(event_type: EventType | ChainEventType) -> EventContract:
"""
按 enum 类别和首批 model 映射构造完整契约。
"""
payload_model = _PAYLOAD_MODELS.get(event_type)
is_chain = isinstance(event_type, ChainEventType)
visibility = EventVisibility.PLUGIN_PUBLIC
if event_type in _TARGET_PLUGIN:
visibility = EventVisibility.TARGET_PLUGIN
elif event_type in _HOST_ONLY:
visibility = EventVisibility.HOST_ONLY
return EventContract(
event_name=f"{event_type.__class__.__name__}.{event_type.name}",
payload_model=payload_model,
payload_contract=payload_model.__name__ if payload_model else "legacy_dict",
mode="chain" if is_chain else "broadcast",
visibility=visibility,
delivery=(
EventDelivery.DURABLE_REQUIRED
if event_type in _DURABLE_REQUIRED
else EventDelivery.EPHEMERAL
),
error_behavior=(
EventErrorBehavior.STOP_CHAIN if is_chain else EventErrorBehavior.NOTIFY
),
ordering="priority_serial" if is_chain else "priority_queue",
input_model=_INPUT_MODELS.get(event_type, payload_model),
output_model=_OUTPUT_MODELS.get(event_type),
payload_mode=(
EventPayloadMode.SNAPSHOT
if event_type in _SNAPSHOT_EVENTS
else EventPayloadMode.RUNTIME
),
validation_mode=EventValidationMode.DIAGNOSTIC,
sensitive_fields=_SENSITIVE_FIELDS.get(event_type, ()),
legacy_reason=(
None
if payload_model
else "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI"
),
)
_ALL_EVENT_TYPES: tuple[EventType | ChainEventType, ...] = (
*tuple(EventType),
*tuple(ChainEventType),
)
EVENT_CONTRACTS: dict[EventType | ChainEventType, EventContract] = {
event_type: _build_contract(event_type)
for event_type in _ALL_EVENT_TYPES
}
def normalize_event_type(
event_type: EventType | ChainEventType | str,
) -> EventType | ChainEventType | str:
"""
把旧 SDK 传入的已知字符串恢复为 enum,未知扩展值保持原样。
"""
if not isinstance(event_type, str):
return event_type
for enum_type in (EventType, ChainEventType):
try:
return enum_type(event_type)
except ValueError:
continue
return event_type
def get_event_contract(
event_type: EventType | ChainEventType | str,
) -> EventContract:
"""
返回已登记事件的完整契约,兼容传入 enum value 字符串。
"""
normalized = normalize_event_type(event_type)
if isinstance(normalized, str):
raise KeyError(normalized)
return EVENT_CONTRACTS[normalized]
def describe_event_validation_error(model: type[BaseModel], error: Exception) -> str:
"""
只输出模型名和错误数量,避免校验输入、异常文本或动态字段名泄露凭据。
"""
if isinstance(error, ValidationError):
return f"{model.__name__}: {error.error_count()} validation error(s)"
return f"{model.__name__}: {type(error).__name__}"
def validate_event_payload(
event_type: EventType | ChainEventType | str,
payload: Any,
) -> tuple[str, ...]:
"""
在发送边界诊断首批 typed payload,保持原对象和插件 dict 形状不变。
"""
try:
contract = get_event_contract(event_type)
models = tuple(
model
for model in (contract.input_model, contract.output_model)
if model is not None
)
except KeyError:
# 动态插件在旧 ABI 下可能使用宿主枚举之外的字符串事件。
return ()
if not models or payload is None:
return ()
problems: list[str] = []
for model in models:
if isinstance(payload, model):
continue
try:
model.model_validate(payload)
except (ValidationError, TypeError, ValueError) as error:
problems.append(describe_event_validation_error(model, error))
return tuple(problems)