Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	app/api/endpoints/agent.py
#	app/api/endpoints/anthropic.py
#	app/api/endpoints/openai.py
#	app/chain/__init__.py
#	app/chain/message.py
#	app/chain/site.py
#	app/chain/subscribe.py
#	app/chain/transfer.py
#	app/modules/discord/__init__.py
#	app/modules/qqbot/__init__.py
#	app/modules/slack/__init__.py
#	app/modules/telegram/__init__.py
#	app/modules/wechat/__init__.py
#	app/runtime/extensions/module_manager.py
#	app/runtime/extensions/service_registry.py
#	tests/test_agent_interaction.py
#	tests/test_slash_command_interactions.py
#	tests/test_web_agent_stream.py
This commit is contained in:
jxxghp
2026-08-16 19:44:43 +08:00
232 changed files with 21375 additions and 6683 deletions
@@ -0,0 +1,260 @@
from __future__ import annotations
import importlib
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Any, Mapping
from app.runtime.capabilities.model import (
ActivationPolicy,
AdapterExecutionMode,
CapabilitySpec,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.config import settings
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas.types import (
DownloaderType,
MediaRecognizeType,
MediaServerType,
NotificationChannel,
ModuleType,
OtherModulesType,
StorageSchema,
SystemConfigKey,
)
HOST_MODULE_KIND = "host_module"
_SETTING_SELECTOR = "setting_truthy"
_SERVICE_SELECTOR = "system_config_item"
_MODULE_ROOT = Path(__file__).resolve().parents[2] / "modules"
_SERVICE_CONFIG_GETTERS = MappingProxyType({
SystemConfigKey.Downloaders.value: ServiceConfigHelper.get_downloader_configs,
SystemConfigKey.MediaServers.value: ServiceConfigHelper.get_mediaserver_configs,
SystemConfigKey.Notifications.value: ServiceConfigHelper.get_notification_configs,
})
_SUBTYPE_NAMES = frozenset(
item.name
for enum_type in (
DownloaderType,
MediaServerType,
NotificationChannel,
StorageSchema,
OtherModulesType,
MediaRecognizeType,
)
for item in enum_type
)
@dataclass(frozen=True, slots=True)
class HostModuleConfigSnapshot:
"""一次 reconcile 使用的不可变配置视图,避免每个能力重复查询配置。"""
settings: Mapping[str, Any]
services: Mapping[str, tuple[Any, ...]]
def _validate_setting_selector(config: Mapping[str, Any]) -> None:
"""限制 setting selector 只能读取已声明的应用设置。"""
key = config["key"]
if not isinstance(key, str) or not key or not hasattr(settings, key):
raise ValueError(f"未知应用设置:{key!r}")
def _validate_service_selector(config: Mapping[str, Any]) -> None:
"""限制服务 selector 使用经过 Schema 校验的三个宿主服务配置。"""
key = config["key"]
if key not in _SERVICE_CONFIG_GETTERS:
raise ValueError(f"不支持的服务配置:{key!r}")
if config["match_field"] != "type":
raise ValueError("服务 selector 的 match_field 必须是 type")
if config["enabled_field"] != "enabled":
raise ValueError("服务 selector 的 enabled_field 必须是 enabled")
match_value = config["match_value"]
if not isinstance(match_value, str) or not match_value:
raise ValueError("服务 selector 的 match_value 必须是非空字符串")
HOST_MODULE_SELECTOR_SCHEMAS = MappingProxyType({
_SETTING_SELECTOR: SelectorSchema(
required_fields=frozenset({"key"}),
validator=_validate_setting_selector,
),
_SERVICE_SELECTOR: SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
validator=_validate_service_selector,
),
})
def _validate_manifest_inventory(registry: CapabilityRegistry) -> None:
"""校验一级模块包与 manifest 一一对应,并固定宿主声明合同。"""
module_packages = {
child.name
for child in _MODULE_ROOT.iterdir()
if child.is_dir()
and not child.name.startswith("_")
and (child / "__init__.py").is_file()
}
specs = registry.list_specs()
manifest_packages = {spec.source.parent.name for spec in specs}
if module_packages != manifest_packages:
missing = sorted(module_packages - manifest_packages)
unknown = sorted(manifest_packages - module_packages)
raise ValueError(
f"Host Module manifest inventory 不一致:missing={missing} unknown={unknown}"
)
allowed_metadata = {"name", "type", "subtype", "priority"}
module_type_values = {item.value for item in ModuleType}
for spec in specs:
if spec.source.parent.parent != _MODULE_ROOT:
raise ValueError(f"Host Module manifest 必须位于一级模块包:{spec.source}")
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
expected_module = f"app.modules.{spec.source.parent.name}"
if module_name != expected_module or symbol_name != spec.id:
raise ValueError(
f"{spec.source}: entrypoint 必须指向同包且类名等于 capability id"
)
if set(spec.metadata) != allowed_metadata:
raise ValueError(
f"{spec.source}: metadata 字段必须是 {sorted(allowed_metadata)}"
)
if spec.metadata["type"] not in module_type_values:
raise ValueError(f"{spec.source}: 非法 metadata.type={spec.metadata['type']!r}")
if spec.metadata["subtype"] not in _SUBTYPE_NAMES:
raise ValueError(
f"{spec.source}: 非法 metadata.subtype={spec.metadata['subtype']!r}"
)
priority = spec.metadata["priority"]
if isinstance(priority, bool) or not isinstance(priority, int):
raise ValueError(f"{spec.source}: metadata.priority 必须是整数")
if spec.activation is ActivationPolicy.WHEN_CONFIGURED:
selector_key = str(spec.selector.config["key"])
if selector_key not in spec.watch:
raise ValueError(
f"{spec.source}: activation.watch 必须包含 selector 配置键"
)
def build_host_module_registry() -> CapabilityRegistry:
"""从现有物理模块包构建 import-free Host Module Registry。"""
registry = CapabilityRegistry.discover(
(_MODULE_ROOT,),
kinds={HOST_MODULE_KIND},
selector_schemas=HOST_MODULE_SELECTOR_SCHEMAS,
)
_validate_manifest_inventory(registry)
return registry
def capture_host_module_config(
specs: tuple[CapabilitySpec, ...],
) -> HostModuleConfigSnapshot:
"""对本轮涉及的设置和服务配置各读取一次并冻结容器。"""
setting_keys: set[str] = set()
service_keys: set[str] = set()
for spec in specs:
selector = spec.selector
if selector is None:
continue
key = str(selector.config["key"])
if selector.kind == _SETTING_SELECTOR:
setting_keys.add(key)
elif selector.kind == _SERVICE_SELECTOR:
service_keys.add(key)
setting_values = {
key: getattr(settings, key)
for key in sorted(setting_keys)
}
service_values = {
key: tuple(_SERVICE_CONFIG_GETTERS[key]())
for key in sorted(service_keys)
}
return HostModuleConfigSnapshot(
settings=MappingProxyType(setting_values),
services=MappingProxyType(service_values),
)
def should_run_host_module(
spec: CapabilitySpec,
snapshot: HostModuleConfigSnapshot,
) -> bool:
"""依据有限 selector 语法判断能力是否应拥有运行资源。"""
if spec.activation is ActivationPolicy.BOOTSTRAP:
return True
if spec.activation is ActivationPolicy.ON_FIRST_USE:
return False
selector = spec.selector
if selector is None:
return False
if selector.kind == _SETTING_SELECTOR:
return bool(snapshot.settings[selector.config["key"]])
if selector.kind == _SERVICE_SELECTOR:
config = selector.config
return any(
getattr(item, config["match_field"]) == config["match_value"]
and bool(getattr(item, config["enabled_field"]))
for item in snapshot.services[config["key"]]
)
raise ValueError(f"未支持的 Host Module selector{selector.kind}")
class HostModuleAdapter:
"""把现有模块类接入 Capability Runtime,保持类路径与对象 identity 不变。"""
execution_mode = AdapterExecutionMode.SYNC
@staticmethod
def materialize(spec: CapabilitySpec) -> type:
"""按 manifest entrypoint 导入并返回原始模块类。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
implementation = getattr(importlib.import_module(module_name), symbol_name)
if not isinstance(implementation, type):
raise TypeError(f"{spec.entrypoint} 不是模块类")
return implementation
@staticmethod
def create(
spec: CapabilitySpec,
implementation: type,
generation: int,
previous: Any = None,
) -> Any:
"""首次创建实例;配置重载继续使用原实例以保留既有模块语义。"""
del spec, generation
return previous if previous is not None else implementation()
@staticmethod
def start(spec: CapabilitySpec, candidate: Any, generation: int) -> None:
"""初始化候选实例拥有的连接、线程或客户端资源。"""
del spec, generation
candidate.init_module()
@staticmethod
def stop(spec: CapabilitySpec, instance: Any, generation: int) -> None:
"""停止实例拥有的资源;Runtime 会先撤销其运行态可见性。"""
del spec, generation
instance.stop()
@staticmethod
def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> None:
"""启动失败后尽力回收候选实例已创建的部分资源。"""
del spec, generation, error
candidate.stop()
@@ -0,0 +1,194 @@
"""Managed Resource 的声明发现与 Capability Runtime 适配器。"""
from __future__ import annotations
import asyncio
import importlib
import inspect
from pathlib import Path
from typing import Any, Iterable
from app.runtime.capabilities.errors import CapabilityAdapterContractError
from app.runtime.capabilities.model import (
ActivationPolicy,
AdapterExecutionMode,
CapabilitySpec,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.managed_resources import (
MANAGED_RESOURCE_ASYNC_KIND,
MANAGED_RESOURCE_SYNC_KIND,
)
_DEFAULT_RESOURCE_ROOT = Path(__file__).resolve().parents[2] / "adapters"
_RESOURCE_KINDS = {MANAGED_RESOURCE_SYNC_KIND, MANAGED_RESOURCE_ASYNC_KIND}
def _load_entrypoint(spec: CapabilitySpec) -> Any:
"""解析声明中的 canonical 实现对象,不创建资源实例。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
module = importlib.import_module(module_name)
try:
return getattr(module, symbol_name)
except AttributeError as error:
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 未公开 Managed Resource 实现"
) from error
def _create_candidate(spec: CapabilitySpec, implementation: Any) -> Any:
"""通过零参数工厂创建资源候选,实例在 start 成功前不可见。"""
if not callable(implementation):
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 不是可调用的 Managed Resource 工厂"
)
candidate = implementation()
if candidate is None or inspect.isawaitable(candidate):
close = getattr(candidate, "close", None)
if callable(close):
close()
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 必须同步返回资源候选"
)
return candidate
def _resource_method(spec: CapabilitySpec, candidate: Any, name: str) -> Any:
"""读取必需生命周期方法并生成稳定合同错误。"""
callback = getattr(candidate, name, None)
if not callable(callback):
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 的资源候选缺少 {name}()"
)
return callback
class SyncManagedResourceAdapter:
"""把同步 start/stop 资源接入 Capability Runtime。"""
execution_mode = AdapterExecutionMode.SYNC
@staticmethod
def materialize(spec: CapabilitySpec) -> Any:
"""解析资源工厂。"""
return _load_entrypoint(spec)
@staticmethod
def create(
spec: CapabilitySpec,
implementation: Any,
_generation: int,
_previous: Any = None,
) -> Any:
"""创建尚未发布的同步资源候选。"""
return _create_candidate(spec, implementation)
@staticmethod
def start(spec: CapabilitySpec, candidate: Any, _generation: int) -> None:
"""启动同步候选;同步 kind 不接受 awaitable 返回值。"""
result = _resource_method(spec, candidate, "start")()
if inspect.isawaitable(result):
close = getattr(result, "close", None)
if callable(close):
close()
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.start() 返回 awaitable,与同步 kind 不匹配"
)
@staticmethod
def stop(spec: CapabilitySpec, instance: Any, _generation: int) -> None:
"""停止同步资源,异常交由 Runtime 保留资源所有权并支持重试。"""
result = _resource_method(spec, instance, "stop")()
if inspect.isawaitable(result):
close = getattr(result, "close", None)
if callable(close):
close()
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.stop() 返回 awaitable,与同步 kind 不匹配"
)
@staticmethod
def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
_error: BaseException,
) -> None:
"""启动失败时按同一 stop 合同清理尚未发布的候选。"""
SyncManagedResourceAdapter.stop(spec, candidate, generation)
class AsyncManagedResourceAdapter:
"""把异步 start/stop 资源接入 Capability Runtime。"""
execution_mode = AdapterExecutionMode.ASYNC
@staticmethod
async def materialize(spec: CapabilitySpec) -> Any:
"""在线程中解析资源工厂,避免第三方导入阻塞事件循环。"""
return await asyncio.to_thread(_load_entrypoint, spec)
@staticmethod
async def create(
spec: CapabilitySpec,
implementation: Any,
_generation: int,
_previous: Any = None,
) -> Any:
"""创建尚未发布的异步资源候选。"""
return _create_candidate(spec, implementation)
@staticmethod
async def start(spec: CapabilitySpec, candidate: Any, _generation: int) -> None:
"""等待异步候选完成启动。"""
result = _resource_method(spec, candidate, "start")()
if not inspect.isawaitable(result):
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.start() 必须返回 awaitable"
)
await result
@staticmethod
async def stop(spec: CapabilitySpec, instance: Any, _generation: int) -> None:
"""等待异步资源完成停止。"""
result = _resource_method(spec, instance, "stop")()
if not inspect.isawaitable(result):
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.stop() 必须返回 awaitable"
)
await result
@staticmethod
async def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
_error: BaseException,
) -> None:
"""启动失败时等待同一 stop 合同清理候选。"""
await AsyncManagedResourceAdapter.stop(spec, candidate, generation)
def _validate_registry(registry: CapabilityRegistry) -> None:
"""固定类别级声明合同,资源只能由显式首用触发。"""
for spec in registry.list_specs():
if set(spec.metadata) != {"name"}:
raise ValueError(f"{spec.source}: Managed Resource metadata 只能包含 name")
if spec.activation is not ActivationPolicy.ON_FIRST_USE:
raise ValueError(f"{spec.source}: Managed Resource 必须使用 on_first_use")
if spec.selector is not None or spec.watch:
raise ValueError(f"{spec.source}: Managed Resource 不接受配置 selector 或 watch")
def build_managed_resource_registry(
roots: Iterable[Path | str] | None = None,
) -> CapabilityRegistry:
"""从 data-only manifest 构建不导入资源实现的注册表。"""
registry = CapabilityRegistry.discover(
tuple(roots) if roots is not None else (_DEFAULT_RESOURCE_ROOT,),
kinds=_RESOURCE_KINDS,
selector_schemas={},
)
_validate_registry(registry)
return registry
+268 -138
View File
@@ -1,22 +1,42 @@
import traceback
from typing import Generator, Optional, Tuple, Any, Union, List
from __future__ import annotations
import sys
import threading
from typing import Any, Generator, List, Optional, Tuple, Union
from app.runtime.config import settings
from app.runtime.events import EventHandlerBinding, eventmanager
from app.foundation.reflection import ModuleHelper
from app.runtime.log import logger
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, NotificationChannel, StorageSchema, \
OtherModulesType, MediaRecognizeType
from app.foundation.reflection import ObjectUtils
from app.foundation.singleton import Singleton
from app.runtime.capabilities.model import (
CapabilityLifecycleState,
CapabilityObservation,
CapabilitySpec,
)
from app.runtime.capabilities.runtime import CapabilityRuntime
from app.runtime.config import settings
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions.host_module_adapter import (
HOST_MODULE_KIND,
HostModuleAdapter,
build_host_module_registry,
capture_host_module_config,
should_run_host_module,
)
from app.runtime.log import logger
from app.schemas.types import (
DownloaderType,
EventType,
MediaRecognizeType,
MediaServerType,
NotificationChannel,
ModuleType,
OtherModulesType,
StorageSchema,
)
class ModuleManager(metaclass=Singleton):
"""
模块管理器
"""
"""以 Capability Runtime 管理宿主模块,并保留旧插件同步查询合同。"""
# 子模块类型集合
SubType = Union[
DownloaderType,
MediaServerType,
@@ -26,176 +46,286 @@ class ModuleManager(metaclass=Singleton):
MediaRecognizeType,
]
def __init__(self):
"""初始化模块注册表并装载当前启用的运行模块。"""
# 模块列表
self._modules: dict = {}
# 运行态模块列表
self._running_modules: dict = {}
# 事件总线通过该解析器绑定已启用的模块实例。
def __init__(self) -> None:
"""发现 data-only manifest,并按当前配置激活所需宿主模块。"""
self._lock = threading.RLock()
self._lifecycle_lock = threading.RLock()
self._modules: dict[str, type] = {}
self._running_modules: dict[str, Any] = {}
registry = build_host_module_registry()
self._runtime = CapabilityRuntime(
registry,
adapters={HOST_MODULE_KIND: HostModuleAdapter()},
observer=self._observe_transition,
)
# pkgutil 的既有发现顺序按一级包名稳定排列,兼容视图继续保持该顺序。
self._specs = tuple(
sorted(self._runtime.list_specs(), key=lambda item: item.source.parent.name)
)
eventmanager.register_handler_instance_resolver(
"modules",
self.resolve_event_handler_instance,
)
eventmanager.add_event_listener(
EventType.ConfigChanged,
self.handle_config_changed,
)
self.load_modules()
@staticmethod
def _observe_transition(observation: CapabilityObservation) -> None:
"""把 Runtime 的稳定转换结果接入现有日志面。"""
if observation.outcome == "failed":
logger.error(
"Host Module %s %s 失败:%s",
observation.capability_id,
observation.operation,
observation.error,
)
elif observation.outcome == "succeeded":
logger.debug(
"Host Module %s %s 完成,generation=%s,耗时=%.2fms",
observation.capability_id,
observation.operation,
observation.generation,
observation.duration_ms,
)
@staticmethod
def _event_changed_keys(event: Optional[Event]) -> set[str]:
"""兼容对象和 dict 两种配置事件载荷。"""
if not event:
return set()
event_data = event.event_data
if isinstance(event_data, dict):
keys = event_data.get("key", set())
else:
keys = getattr(event_data, "key", set())
if isinstance(keys, str):
return {keys}
return {str(key) for key in (keys or set())}
def _remember_materialized(self, module_id: str, implementation: type) -> type:
"""更新旧 `_modules` 视图,但不改变能力资源生命周期。"""
with self._lock:
self._modules[module_id] = implementation
return implementation
def _consumer_materialized_class(self, spec: CapabilitySpec) -> Optional[type]:
"""识别插件显式旧导入产生的真实类,不触发新的 Python import。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
module = sys.modules.get(module_name)
namespace = getattr(module, "__dict__", None) if module is not None else None
if not isinstance(namespace, dict):
return None
implementation = namespace.get(symbol_name)
return implementation if isinstance(implementation, type) else None
def _refresh_running_projection(self) -> None:
"""从 Runtime 已发布实例重建插件可见的运行模块字典。"""
running = {
spec.id: instance
for spec in self._specs
if (instance := self._runtime.get_running(spec.id)) is not None
}
with self._lock:
self._running_modules = running
def resolve_event_handler_instance(
self,
owner_class: type,
self,
owner_class: type,
) -> Optional[EventHandlerBinding]:
"""为模块声明的事件方法解析当前运行实例"""
module_id = owner_class.__name__
if module_id not in self._modules:
return None
module = self._running_modules.get(module_id)
owner_name = module_id
if module and callable(getattr(module, "get_name", None)):
owner_name = module.get_name()
return EventHandlerBinding(
instance=module,
owner_name=owner_name,
"""按 canonical class identity 绑定当前 generation,停止态阻断 fallback 构造"""
for spec in self._specs:
with self._lock:
implementation = self._modules.get(spec.id)
if implementation is None:
implementation = self._consumer_materialized_class(spec)
if implementation is not None:
self._remember_materialized(spec.id, implementation)
# 同步 Runtime 的物化观测,但不创建或启动实例。
self._runtime.snapshot(spec.id)
if implementation is not owner_class:
continue
return EventHandlerBinding(
instance=self._runtime.get_running(spec.id),
owner_name=str(spec.metadata["name"]),
)
return None
def _reconcile(
self,
*,
reason: str,
changed_keys: Optional[set[str]] = None,
reload_running: bool = False,
) -> None:
"""以一次配置快照串行协调需要启动、重载或停止的能力。"""
with self._lifecycle_lock:
selected = tuple(
spec
for spec in self._specs
if changed_keys is None or changed_keys.intersection(spec.watch)
)
snapshot = capture_host_module_config(selected)
for spec in selected:
desired = should_run_host_module(spec, snapshot)
running = self._runtime.get_running(spec.id)
try:
if desired and running is None:
instance = self._runtime.activate(
spec.id,
reason=reason,
retry=True,
)
self._remember_materialized(spec.id, type(instance))
elif desired and running is not None and reload_running:
instance = self._runtime.reload(spec.id, reason=reason)
self._remember_materialized(spec.id, type(instance))
elif not desired and running is not None:
self._runtime.stop(spec.id, reason=reason)
except Exception:
# 单能力失败由 Runtime 完整记录;其它无依赖能力继续 reconcile。
continue
self._refresh_running_projection()
def load_modules(self) -> None:
"""按当前配置启动未运行模块;已运行模块保持当前 generation。"""
self._reconcile(reason="module_manager_load")
def handle_config_changed(self, event: Event) -> None:
"""配置变更时仅协调 watch 命中的能力,并保证单一生命周期 writer。"""
changed_keys = self._event_changed_keys(event)
if not changed_keys:
return
self._reconcile(
reason="config_changed",
changed_keys=changed_keys,
reload_running=True,
)
def load_modules(self):
"""
加载所有模块
"""
# 扫描模块目录
modules = ModuleHelper.load(
"app.modules",
filter_func=lambda _, obj: hasattr(obj, 'init_module') and hasattr(obj, 'init_setting')
)
self._running_modules = {}
self._modules = {}
for module in modules:
module_id = module.__name__
self._modules[module_id] = module
try:
# 生成实例
_module = module()
# 初始化模块
if self.check_setting(_module.init_setting()):
# 通过模板开关控制加载
_module.init_module()
self._running_modules[module_id] = _module
logger.debug(f"Moudle Loaded{module_id}")
except Exception as err:
logger.error(f"Load Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
def stop(self):
"""
停止所有模块
"""
def stop(self) -> None:
"""停止全部运行模块但保留 Runtime,使旧插件可随后再次 load。"""
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
with self._lifecycle_lock:
for spec in reversed(self._specs):
snapshot = self._runtime.snapshot(spec.id)
if (
self._runtime.get_running(spec.id) is None
and snapshot.lifecycle is not CapabilityLifecycleState.FAILED
):
continue
try:
self._runtime.stop(spec.id, reason="module_manager_stop")
except Exception:
continue
self._refresh_running_projection()
logger.info("所有模块停止完成")
def reload(self):
"""
重新加载所有模块
"""
self.stop()
self.load_modules()
eventmanager.send_event(etype=EventType.ModuleReload, data={})
def shutdown(self) -> None:
"""进程关闭时不可逆停止 Runtime,阻止并发能力重新发布。"""
logger.info("正在关闭模块运行时...")
with self._lifecycle_lock:
self._runtime.shutdown(reason="application_shutdown")
self._refresh_running_projection()
logger.info("模块运行时关闭完成")
def reload(self) -> None:
"""保留旧插件可观察的 stop、load、ModuleReload 同步顺序。"""
with self._lifecycle_lock:
self.stop()
self.load_modules()
eventmanager.send_event(etype=EventType.ModuleReload, data={})
def test(self, modleid: str) -> Tuple[bool, str]:
"""
测试模块
"""
if modleid not in self._running_modules:
"""测试已运行模块;未启用模块保持旧合同返回 `(False, "")`。"""
module = self.get_running_module(modleid)
if module is None:
return False, ""
module = self._running_modules[modleid]
if hasattr(module, "test") \
and ObjectUtils.check_method(getattr(module, "test")):
if hasattr(module, "test") and ObjectUtils.check_method(module.test):
result = module.test()
if not result:
return False, ""
return result
return result if result else (False, "")
return True, "模块不支持测试"
@staticmethod
def check_setting(setting: Optional[tuple]) -> bool:
"""
检查开关是否己打开,开关使用,分隔多个值,符合其中即代表开启
"""
"""保留旧模块开关的 truthy 与 membership 判定语义。"""
if not setting:
return True
switch, value = setting
option = getattr(settings, switch)
if not option:
return False
if option and value is True:
if value is True:
return True
if value in option:
return True
return False
return value in option
def get_running_module(self, module_id: str) -> Any:
"""
根据模块id获取模块运行实例
"""
if not module_id:
"""根据模块 ID 返回已发布的运行实例,不触发物化。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
if not self._running_modules:
return None
return self._running_modules.get(module_id)
return self._runtime.get_running(module_id)
def _running_snapshot(self) -> tuple[Any, ...]:
"""直接读取 Runtime 发布视图,转换期间不暴露旧或候选实例。"""
return tuple(
instance
for spec in self._specs
if (instance := self._runtime.get_running(spec.id)) is not None
)
def get_running_modules(self, method: str) -> Generator:
"""
获取实现了同一方法的模块列表
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, method) \
and ObjectUtils.check_method(getattr(module, method)):
"""返回实现了指定方法的运行模块快照。"""
for module in self._running_snapshot():
candidate = getattr(module, method, None)
if callable(candidate) and ObjectUtils.check_method(candidate):
yield module
def get_running_type_modules(self, module_type: ModuleType) -> Generator:
"""
获取指定类型的模块列表
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_type') \
and module.get_type() == module_type:
"""返回指定类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_type() == module_type:
yield module
def get_running_subtype_module(self, module_subtype: SubType) -> Generator:
"""
获取指定子类型的模块
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_subtype') \
and module.get_subtype() == module_subtype:
"""返回指定子类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_subtype() == module_subtype:
yield module
def get_module(self, module_id: str) -> Any:
"""
根据模块id获取模块
"""
if not module_id:
"""显式物化并返回 canonical 模块类;失败保持旧合同返回 None。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
if not self._modules:
with self._lock:
implementation = self._modules.get(module_id)
if implementation is not None:
return implementation
try:
implementation = self._runtime.materialize(
module_id,
reason="compat_get_module",
retry=True,
)
except Exception:
return None
return self._modules.get(module_id)
return self._remember_materialized(module_id, implementation)
def get_modules(self) -> dict:
"""
获取模块列表
"""
return self._modules
def get_modules(self) -> dict[str, type]:
"""兼容性显式物化全部真实类;单个失败不阻断其它模块。"""
for spec in self._specs:
self.get_module(spec.id)
with self._lock:
return dict(self._modules)
def get_module_ids(self) -> List[str]:
"""
获取模块id列表
"""
return list(self._modules.keys())
"""从 manifest 返回全部模块 ID,不物化实现。"""
return [spec.id for spec in self._specs]
def list_specs(self) -> tuple[CapabilitySpec, ...]:
"""返回全部轻量模块声明,包含物化或启动失败的能力。"""
return self._specs
def get_specs(self) -> tuple[CapabilitySpec, ...]:
"""兼容内部调用命名,返回与 `list_specs` 相同的声明快照。"""
return self.list_specs()
+23
View File
@@ -37,6 +37,7 @@ from app.schemas.types import EventType, SystemConfigKey
LegacyDiagnosticsConfigurator = Callable[..., None]
LegacyImportScanner = Callable[..., None]
LegacyPluginImportPreparer = Callable[..., None]
PluginInstallReporter = Callable[..., None]
SiteAuthLevelProvider = Callable[[], int]
@@ -45,6 +46,10 @@ def _ignore_legacy_diagnostics(**_kwargs) -> None:
"""在启动组合根尚未注入兼容服务时保持插件加载可用。"""
def _ignore_plugin_resource_imports(**_kwargs) -> None:
"""未进入应用启动组合时不主动创建进程级宿主资源。"""
def _unavailable_site_auth_level() -> int:
"""站点能力尚未装配时返回未认证等级。"""
return 0
@@ -54,6 +59,9 @@ _legacy_diagnostics_configurator: LegacyDiagnosticsConfigurator = (
_ignore_legacy_diagnostics
)
_legacy_import_scanner: LegacyImportScanner = _ignore_legacy_diagnostics
_legacy_plugin_import_preparer: LegacyPluginImportPreparer = (
_ignore_plugin_resource_imports
)
_plugin_install_reporter: PluginInstallReporter = _ignore_legacy_diagnostics
_site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level
@@ -69,6 +77,14 @@ def configure_plugin_legacy_import_services(
_legacy_import_scanner = import_scanner
def configure_plugin_resource_import_preparer(
preparer: LegacyPluginImportPreparer,
) -> None:
"""注入旧插件导入前的宿主资源准备器。"""
global _legacy_plugin_import_preparer
_legacy_plugin_import_preparer = preparer
def configure_plugin_install_reporter(reporter: PluginInstallReporter) -> None:
"""由启动组合根注入插件安装上报器,避免扩展层依赖远程服务。"""
global _plugin_install_reporter
@@ -318,6 +334,13 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
module_name = f"app.plugins.{plugin_dir.name}"
logger.debug(f"正在导入插件模块:{module_name}")
# 旧插件可能直接导入带宿主资源前置条件的第三方包。资源必须在
# Python 执行插件模块顶层代码前就绪,否则导入副作用无法安全回滚。
_legacy_plugin_import_preparer(
plugin_id=plugin_dir.name,
plugin_dir=plugin_dir,
)
_legacy_import_scanner(
plugin_id=plugin_dir.name,
plugin_dir=plugin_dir,
+76
View File
@@ -0,0 +1,76 @@
from typing import List, Optional, Type
from pydantic import ValidationError
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
from app.schemas import (
DownloaderConf,
MediaServerConf,
NotificationConf,
NotificationSwitchConf,
)
from app.schemas.types import MessageType, SystemConfigKey
class ServiceConfigHelper:
"""读取并校验通知、下载器和媒体服务器的宿主配置。"""
@staticmethod
def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:
"""按指定 Schema 过滤单条非法配置,避免影响同组其它服务。"""
config_data = SystemConfigOper().get(config_key)
if not config_data:
return []
configs = []
for conf in config_data:
if not isinstance(conf, dict):
logger.warning(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
continue
try:
configs.append(conf_type(**conf))
except ValidationError as err:
logger.error(
f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{err}"
)
return configs
@staticmethod
def get_downloader_configs() -> List[DownloaderConf]:
"""返回已通过结构校验的下载器配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.Downloaders,
DownloaderConf,
)
@staticmethod
def get_mediaserver_configs() -> List[MediaServerConf]:
"""返回已通过结构校验的媒体服务器配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.MediaServers,
MediaServerConf,
)
@staticmethod
def get_notification_configs() -> List[NotificationConf]:
"""返回已通过结构校验的通知配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.Notifications,
NotificationConf,
)
@staticmethod
def get_notification_switches() -> List[NotificationSwitchConf]:
"""返回已通过结构校验的通知场景开关。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.NotificationSwitchs,
NotificationSwitchConf,
)
@staticmethod
def get_notification_switch(mtype: MessageType) -> Optional[str]:
"""返回指定通知场景的目标范围。"""
for switch in ServiceConfigHelper.get_notification_switches():
if switch.type == mtype.value:
return switch.action
return None
+9 -75
View File
@@ -1,84 +1,18 @@
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
from pydantic import ValidationError
from app.runtime.extensions.module_manager import ModuleManager
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo
from app.schemas.types import MessageType, SystemConfigKey, ModuleType
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas import ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
TConf = TypeVar("TConf")
class ServiceConfigHelper:
"""
配置帮助类,获取不同类型的服务配置
"""
@staticmethod
def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:
"""
通用获取配置的方法,根据 config_key 获取相应的配置并返回指定类型的配置列表
:param config_key: 系统配置的 key
:param conf_type: 用于实例化配置对象的类类型
:return: 配置对象列表
"""
config_data = SystemConfigOper().get(config_key)
if not config_data:
return []
configs = []
for conf in config_data:
if not isinstance(conf, dict):
logger.warn(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
continue
try:
# 直接使用 conf_type 来实例化配置对象
configs.append(conf_type(**conf))
except ValidationError as e:
# 单条配置存在非法值时跳过,避免影响其它服务的初始化
logger.error(f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{e}")
return configs
@staticmethod
def get_downloader_configs() -> List[DownloaderConf]:
"""
获取下载器的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf)
@staticmethod
def get_mediaserver_configs() -> List[MediaServerConf]:
"""
获取媒体服务器的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf)
@staticmethod
def get_notification_configs() -> List[NotificationConf]:
"""
获取消息通知渠道的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf)
@staticmethod
def get_notification_switches() -> List[NotificationSwitchConf]:
"""
获取消息通知场景的开关
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf)
@staticmethod
def get_notification_switch(mtype: MessageType) -> Optional[str]:
"""
获取指定类型的消息通知场景的开关
"""
switchs = ServiceConfigHelper.get_notification_switches()
for switch in switchs:
if switch.type == mtype.value:
return switch.action
return None
__all__ = [
"ServiceBaseHelper",
"ServiceConfigHelper",
"SystemConfigOper",
]
class ServiceBaseHelper(Generic[TConf]):