From cb3c5f008fa461b0cad1a443f0c4eb86c1c2cedf Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 05:12:21 +0800 Subject: [PATCH] refactor: unify service configuration boundary --- app/agent/llm/capability.py | 5 +- app/agent/tools/base.py | 4 +- app/agent/tools/impl/query_library_latest.py | 5 +- app/api/endpoints/message.py | 4 +- app/application/mediaserver.py | 9 ++++ app/application/notification.py | 26 ++++++++-- app/application/service.py | 10 +++- app/chain/_messaging.py | 6 +-- app/chain/mediaserver.py | 4 +- app/scheduler.py | 4 +- .../backend-architecture-next-stage.md | 15 +++++- docs/rules/05-architecture.md | 5 ++ .../architecture/dependency-baseline.json | 27 +++++----- tests/test_agent_llm_capability.py | 2 +- tests/test_agent_resource_flow_permissions.py | 8 +-- tests/test_application_service_directory.py | 49 +++++++++++++++++++ tests/test_architecture_dependencies.py | 26 ++++++++++ tests/test_mediaserver_sync_incremental.py | 14 +++--- tests/test_scheduler_cache_expiry.py | 4 +- 19 files changed, 174 insertions(+), 53 deletions(-) create mode 100644 tests/test_application_service_directory.py diff --git a/app/agent/llm/capability.py b/app/agent/llm/capability.py index bc8b8e650..a75f6a500 100644 --- a/app/agent/llm/capability.py +++ b/app/agent/llm/capability.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any, Dict, Optional from uuid import uuid4 +from app.application.notification import get_notification_configs from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() @@ -801,9 +802,7 @@ class AgentCapabilityManager: if not source: return False - from app.runtime.extensions.service_config import ServiceConfigHelper - - for config in ServiceConfigHelper.get_notification_configs(): + for config in get_notification_configs(include_disabled=True): if config.name != source: continue return (config.config or {}).get("WECHAT_MODE", "app") != "bot" diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index dd6c272c9..9a59988d2 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -23,7 +23,7 @@ from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() from app.application.messaging.agent import matches_channel_admin -from app.runtime.extensions.service_config import ServiceConfigHelper +from app.application.notification import get_notification_configs from app.runtime.log import logger from app.schemas.message import Message from app.schemas.types import NotificationChannel, MessageType @@ -719,7 +719,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): return False try: - configs = ServiceConfigHelper.get_notification_configs() + configs = get_notification_configs(include_disabled=True) for config in configs: if config.name == self._source and config.config: return matches_channel_admin( diff --git a/app/agent/tools/impl/query_library_latest.py b/app/agent/tools/impl/query_library_latest.py index 93242c50a..aa91c27b4 100644 --- a/app/agent/tools/impl/query_library_latest.py +++ b/app/agent/tools/impl/query_library_latest.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.mediaserver import MediaServerChain -from app.runtime.extensions.service_config import ServiceConfigHelper +from app.application.mediaserver import get_mediaserver_configs from app.runtime.log import logger PAGE_SIZE = 20 @@ -61,8 +61,7 @@ class QueryLibraryLatestTool(MoviePilotTool): @staticmethod def _get_enabled_servers() -> list[str]: """同步读取启用的媒体服务器列表。""" - mediaservers = ServiceConfigHelper.get_mediaserver_configs() - return [ms.name for ms in mediaservers if ms.enabled] + return [config.name for config in get_mediaserver_configs()] @staticmethod def _load_latest_items( diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 6182fbbd3..0fa6624ef 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -28,7 +28,7 @@ from app.application.configuration import ( from app.api.dependencies.agent import get_message_query_service from app.api.dependencies.auth import get_current_active_superuser from app.application.messaging.message import MessageQueryService -from app.runtime.extensions.service_config import ServiceConfigHelper +from app.application.notification import get_notification_configs from app.runtime.log import logger from app.adapters.external.wechat_crypt import WXBizMsgCrypt from app.schemas.types import NotificationChannel, SystemConfigKey @@ -261,7 +261,7 @@ def wechat_verify( 微信验证响应 """ # 获取服务配置 - client_configs = ServiceConfigHelper.get_notification_configs() + client_configs = get_notification_configs(include_disabled=True) if not client_configs: return "未找到对应的消息配置" client_config = next( diff --git a/app/application/mediaserver.py b/app/application/mediaserver.py index 6fce86933..26a1afded 100644 --- a/app/application/mediaserver.py +++ b/app/application/mediaserver.py @@ -304,3 +304,12 @@ class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): """判断给定服务或服务名称是否属于指定媒体服务器类型。""" service = service or self.get_service(name=name) return bool(service and service.type == service_type) + + +def get_mediaserver_configs( + include_disabled: bool = False, +) -> list[MediaServerConf]: + """返回媒体服务器配置列表,并按调用方需要决定是否包含禁用项。""" + return list( + MediaServerHelper().get_configs(include_disabled=include_disabled).values() + ) diff --git a/app/application/notification.py b/app/application/notification.py index e62f980ed..bdd4876d3 100644 --- a/app/application/notification.py +++ b/app/application/notification.py @@ -1,9 +1,9 @@ from typing import Optional -from app.application.service import ServiceBaseHelper -from app.schemas.system import NotificationConf +from app.application.service import ServiceBaseHelper, get_service_configs +from app.schemas.system import NotificationConf, NotificationSwitchConf from app.schemas.system import ServiceInfo -from app.schemas.types import ModuleType, SystemConfigKey +from app.schemas.types import MessageType, ModuleType, SystemConfigKey class NotificationHelper(ServiceBaseHelper[NotificationConf]): @@ -33,3 +33,23 @@ class NotificationHelper(ServiceBaseHelper[NotificationConf]): """ service = service or self.get_service(name=name) return bool(service and service.type == service_type) + + +def get_notification_configs( + include_disabled: bool = False, +) -> list[NotificationConf]: + """返回通知配置列表,并按调用方需要决定是否包含禁用项。""" + return list( + NotificationHelper().get_configs(include_disabled=include_disabled).values() + ) + + +def get_notification_switch(mtype: MessageType) -> Optional[str]: + """返回指定通知场景的目标范围。""" + for switch in get_service_configs( + SystemConfigKey.NotificationSwitchs, + NotificationSwitchConf, + ): + if switch.type == mtype.value: + return switch.action + return None diff --git a/app/application/service.py b/app/application/service.py index e902b9f7e..8fd755063 100644 --- a/app/application/service.py +++ b/app/application/service.py @@ -41,6 +41,14 @@ def configure_service_directory( _module_loader = modules +def get_service_configs( + config_key: SystemConfigKey, + conf_type: Type[TConf], +) -> list[TConf]: + """通过组合根登记的读取器返回已校验服务配置。""" + return _config_loader(config_key, conf_type) + + class ServiceBaseHelper(Generic[TConf]): """通过应用端口查询服务配置和对应运行实例。""" @@ -57,7 +65,7 @@ class ServiceBaseHelper(Generic[TConf]): def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: """返回按名称索引的有效服务配置。""" - configs = _config_loader(self.config_key, self.conf_type) + configs = get_service_configs(self.config_key, self.conf_type) return { config.name: config for config in configs diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py index 64df327ed..486861728 100644 --- a/app/chain/_messaging.py +++ b/app/chain/_messaging.py @@ -13,7 +13,7 @@ from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.foundation.identity import normalize_internal_user_id from app.application.messaging.message import MessageTemplateHelper -from app.runtime.extensions.service_config import ServiceConfigHelper +from app.application.notification import get_notification_switch from app.runtime.log import logger from app.schemas.message import MessageResponse from app.schemas.message import Message @@ -159,7 +159,7 @@ class NotificationMixin: # 发送消息按设置隔离 if not dispatch_message.userid and dispatch_message.mtype: # 消息隔离设置 - notify_action = ServiceConfigHelper.get_notification_switch( + notify_action = get_notification_switch( dispatch_message.mtype ) if notify_action: @@ -277,7 +277,7 @@ class NotificationMixin: # 发送消息按设置隔离 if not dispatch_message.userid and dispatch_message.mtype: # 消息隔离设置 - notify_action = ServiceConfigHelper.get_notification_switch( + notify_action = get_notification_switch( dispatch_message.mtype ) if notify_action: diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py index 4a84e9a32..2d7cbd5de 100644 --- a/app/chain/mediaserver.py +++ b/app/chain/mediaserver.py @@ -5,7 +5,7 @@ from typing import Callable, Dict, List, Union, Optional, Generator, Any, Tuple from app.chain import ChainBase from app.runtime.config import global_vars from app.application.chain.data import get_chain_media_server_port -from app.runtime.extensions.service_config import ServiceConfigHelper +from app.application.mediaserver import get_mediaserver_configs from app.runtime.log import logger from app.schemas.mediaserver import MediaServerLibrary from app.schemas.mediaserver import MediaServerItem @@ -458,7 +458,7 @@ class MediaServerChain(ChainBase): :param server: 指定媒体服务器名称,为空时同步全部已启用服务器 """ # 设置的媒体服务器 - mediaservers = ServiceConfigHelper.get_mediaserver_configs() + mediaservers = get_mediaserver_configs(include_disabled=True) if not mediaservers: if progress_callback: progress_callback(value=100, text="未配置媒体服务器,跳过同步") diff --git a/app/scheduler.py b/app/scheduler.py index 5fe0bd287..73ececbb6 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -38,10 +38,10 @@ from app.application.configuration import ( get_scheduler_runtime_config, ) from app.application.image import WallpaperHelper +from app.application.mediaserver import get_mediaserver_configs from app.application.messaging.message import MessageHelper from app.runtime.progress import AsyncProgressHelper, ProgressHelper from app.adapters.external.server import MoviePilotServerHelper -from app.runtime.extensions.service_config import ServiceConfigHelper from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.runtime.log import logger from app.schemas.message import Message @@ -603,7 +603,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): # 按媒体服务器分别注册自动同步任务 mediaserver_schedules = self._build_mediaserver_sync_schedules( - mediaservers=ServiceConfigHelper.get_mediaserver_configs(), + mediaservers=get_mediaserver_configs(include_disabled=True), default_interval=config.mediaserver_sync_interval, ) for mediaserver_schedule in mediaserver_schedules: diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index f33f9f5d9..719e9fbbb 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -6,7 +6,7 @@ > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` -> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口。 +> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界。 ## 当前复核结论(2026-08-24) @@ -229,13 +229,24 @@ - 兼容边界不变:`TransferHistoryPort`、DB Oper、监控候选判定、历史查重、失败重试和整理触发语义均 未修改。 +### 长期整改阶段 22:服务配置应用边界统一(2026-08-24) + +- 启动组合根已注入应用层服务目录,但 Chain、消息 API、Scheduler 和 Agent 仍直接读取 runtime + `ServiceConfigHelper`,形成两个应用入口;现在统一通过通知与媒体服务器应用模块的命名函数读取。 +- 命名函数显式区分仅启用配置和包含禁用配置,保持定向同步、定时注册、企业微信模式和渠道管理员判断 + 的原语义;架构门禁覆盖七个生产消费者。 +- 依赖基线经语义诊断后从 `6544` 条边降为 `6539`:七个消费者移除 runtime service-config 边并改为 + Application 边,12 组禁止边和唯一隔离 TMDB SCC 均未变化。 +- `ServiceConfigHelper` 继续保留在 startup、runtime module adapter、模块实例初始化和 `app.sdk.services` + 插件兼容出口;V2/V3 插件导入、配置 Schema 和热更新读取器均未修改。 + ### 总体判断 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: - 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。 - `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。 -- 依赖图当前为 `806` 个 Python 模块、`6544` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 +- 依赖图当前为 `806` 个 Python 模块、`6539` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 - 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。 综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 4612a0063..93f5fbca6 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -143,6 +143,11 @@ must not be reintroduced as Oper aliases in canonical Agent modules. Monitor history checks use `get_transfer_history_port()` from `app/application/history.py`; the constructible `TransferHistoryPort` facade is retained only for compatibility and is not a canonical Oper substitute. +Canonical Chain, API, Scheduler and Agent consumers read notification and media +server configuration through the named helpers in `app/application/notification.py` +and `app/application/mediaserver.py`. `ServiceConfigHelper` remains the parser at +the startup/runtime module boundary and a plugin SDK compatibility export; it is +not a second application-facing service directory. ### Adapter boundaries diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 57fea6ce5..c72afe014 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6544, - "edge_sha256": "562471ed16680ca403b6cdb7661b20b1200c53b75da5e162efe4e7400cde174b", + "edge_count": 6539, + "edge_sha256": "28c7ab8544ede290e6dacf5f126ca1aad95e09627d874a0d23c6476108a88e1b", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -202,9 +202,9 @@ "app.agent.llm.capability -> app.agent", "app.agent.llm.capability -> app.agent.llm", "app.agent.llm.capability -> app.agent.llm.helper", + "app.agent.llm.capability -> app.application", + "app.agent.llm.capability -> app.application.notification", "app.agent.llm.capability -> app.runtime", - "app.agent.llm.capability -> app.runtime.extensions", - "app.agent.llm.capability -> app.runtime.extensions.service_config", "app.agent.llm.capability -> app.runtime.log", "app.agent.llm.capability -> app.runtime.settings", "app.agent.llm.capability -> app.schemas", @@ -432,10 +432,9 @@ "app.agent.tools.base -> app.application", "app.agent.tools.base -> app.application.messaging", "app.agent.tools.base -> app.application.messaging.agent", + "app.agent.tools.base -> app.application.notification", "app.agent.tools.base -> app.chain", "app.agent.tools.base -> app.runtime", - "app.agent.tools.base -> app.runtime.extensions", - "app.agent.tools.base -> app.runtime.extensions.service_config", "app.agent.tools.base -> app.runtime.log", "app.agent.tools.base -> app.runtime.settings", "app.agent.tools.base -> app.schemas", @@ -952,11 +951,11 @@ "app.agent.tools.impl.query_library_latest -> app.agent.tools", "app.agent.tools.impl.query_library_latest -> app.agent.tools.base", "app.agent.tools.impl.query_library_latest -> app.agent.tools.tags", + "app.agent.tools.impl.query_library_latest -> app.application", + "app.agent.tools.impl.query_library_latest -> app.application.mediaserver", "app.agent.tools.impl.query_library_latest -> app.chain", "app.agent.tools.impl.query_library_latest -> app.chain.mediaserver", "app.agent.tools.impl.query_library_latest -> app.runtime", - "app.agent.tools.impl.query_library_latest -> app.runtime.extensions", - "app.agent.tools.impl.query_library_latest -> app.runtime.extensions.service_config", "app.agent.tools.impl.query_library_latest -> app.runtime.log", "app.agent.tools.impl.query_market_plugins -> app.agent", "app.agent.tools.impl.query_market_plugins -> app.agent.tools", @@ -1983,12 +1982,11 @@ "app.api.endpoints.message -> app.application.configuration", "app.api.endpoints.message -> app.application.messaging", "app.api.endpoints.message -> app.application.messaging.message", + "app.api.endpoints.message -> app.application.notification", "app.api.endpoints.message -> app.chain", "app.api.endpoints.message -> app.chain.message", "app.api.endpoints.message -> app.runtime", "app.api.endpoints.message -> app.runtime.config", - "app.api.endpoints.message -> app.runtime.extensions", - "app.api.endpoints.message -> app.runtime.extensions.service_config", "app.api.endpoints.message -> app.runtime.log", "app.api.endpoints.message -> app.runtime.tasks", "app.api.endpoints.message -> app.schemas", @@ -2941,6 +2939,7 @@ "app.chain._messaging -> app.application.messaging", "app.chain._messaging -> app.application.messaging.agent", "app.chain._messaging -> app.application.messaging.message", + "app.chain._messaging -> app.application.notification", "app.chain._messaging -> app.domain", "app.chain._messaging -> app.domain.context", "app.chain._messaging -> app.domain.meta", @@ -2948,8 +2947,6 @@ "app.chain._messaging -> app.foundation", "app.chain._messaging -> app.foundation.identity", "app.chain._messaging -> app.runtime", - "app.chain._messaging -> app.runtime.extensions", - "app.chain._messaging -> app.runtime.extensions.service_config", "app.chain._messaging -> app.runtime.log", "app.chain._messaging -> app.schemas", "app.chain._messaging -> app.schemas.message", @@ -3166,13 +3163,12 @@ "app.chain.mediaserver -> app.application", "app.chain.mediaserver -> app.application.chain", "app.chain.mediaserver -> app.application.chain.data", + "app.chain.mediaserver -> app.application.mediaserver", "app.chain.mediaserver -> app.application.security", "app.chain.mediaserver -> app.application.security.url", "app.chain.mediaserver -> app.chain", "app.chain.mediaserver -> app.runtime", "app.chain.mediaserver -> app.runtime.config", - "app.chain.mediaserver -> app.runtime.extensions", - "app.chain.mediaserver -> app.runtime.extensions.service_config", "app.chain.mediaserver -> app.runtime.log", "app.chain.mediaserver -> app.schemas", "app.chain.mediaserver -> app.schemas.mediaserver", @@ -5801,6 +5797,7 @@ "app.scheduler -> app.application.configuration", "app.scheduler -> app.application.database", "app.scheduler -> app.application.image", + "app.scheduler -> app.application.mediaserver", "app.scheduler -> app.application.messaging", "app.scheduler -> app.application.messaging.message", "app.scheduler -> app.application.outbox", @@ -5824,8 +5821,6 @@ "app.scheduler -> app.runtime.config", "app.scheduler -> app.runtime.correlation", "app.scheduler -> app.runtime.events", - "app.scheduler -> app.runtime.extensions", - "app.scheduler -> app.runtime.extensions.service_config", "app.scheduler -> app.runtime.gc", "app.scheduler -> app.runtime.log", "app.scheduler -> app.runtime.observability", diff --git a/tests/test_agent_llm_capability.py b/tests/test_agent_llm_capability.py index f786fe3ad..a6c19ccee 100644 --- a/tests/test_agent_llm_capability.py +++ b/tests/test_agent_llm_capability.py @@ -199,7 +199,7 @@ class AgentCapabilityManagerTest(unittest.TestCase): ] with patch( - "app.runtime.extensions.service_config.ServiceConfigHelper.get_notification_configs", + "app.agent.llm.capability.get_notification_configs", return_value=configs, ): self.assertTrue( diff --git a/tests/test_agent_resource_flow_permissions.py b/tests/test_agent_resource_flow_permissions.py index b217af076..a78238571 100644 --- a/tests/test_agent_resource_flow_permissions.py +++ b/tests/test_agent_resource_flow_permissions.py @@ -454,7 +454,7 @@ def test_channel_primary_id_defaults_to_admin_without_admin_list(): ) with patch( - "app.agent.tools.base.ServiceConfigHelper.get_notification_configs", + "app.agent.tools.base.get_notification_configs", return_value=[ SimpleNamespace( name="telegram-main", @@ -477,7 +477,7 @@ def test_channel_primary_id_mismatch_remains_non_admin(): ) with patch( - "app.agent.tools.base.ServiceConfigHelper.get_notification_configs", + "app.agent.tools.base.get_notification_configs", return_value=[ SimpleNamespace( name="telegram-main", @@ -500,7 +500,7 @@ def test_feishu_primary_open_id_defaults_to_admin(): ) with patch( - "app.agent.tools.base.ServiceConfigHelper.get_notification_configs", + "app.agent.tools.base.get_notification_configs", return_value=[ SimpleNamespace( name="feishu-main", @@ -523,7 +523,7 @@ def test_channel_primary_id_still_prefers_admin_list(): ) with patch( - "app.agent.tools.base.ServiceConfigHelper.get_notification_configs", + "app.agent.tools.base.get_notification_configs", return_value=[ SimpleNamespace( name="telegram-main", diff --git a/tests/test_application_service_directory.py b/tests/test_application_service_directory.py new file mode 100644 index 000000000..6becf7040 --- /dev/null +++ b/tests/test_application_service_directory.py @@ -0,0 +1,49 @@ +"""应用层服务配置目录测试。""" + +from app.application import service +from app.application.mediaserver import get_mediaserver_configs +from app.application.notification import ( + get_notification_configs, + get_notification_switch, +) +from app.schemas.system import ( + MediaServerConf, + NotificationConf, + NotificationSwitchConf, +) +from app.schemas.types import MessageType, SystemConfigKey + + +def test_named_service_config_helpers_preserve_enabled_policy(monkeypatch) -> None: + """命名应用函数应复用同一配置目录,并显式控制禁用项可见性。""" + configs = { + SystemConfigKey.MediaServers: [ + MediaServerConf(name="enabled-media", type="plex", enabled=True), + MediaServerConf(name="disabled-media", type="emby", enabled=False), + ], + SystemConfigKey.Notifications: [ + NotificationConf(name="enabled-channel", type="telegram", enabled=True), + NotificationConf(name="disabled-channel", type="wechat", enabled=False), + ], + SystemConfigKey.NotificationSwitchs: [ + NotificationSwitchConf( + type=MessageType.Download.value, + action="admin", + ) + ], + } + monkeypatch.setattr( + service, + "_config_loader", + lambda config_key, _conf_type: configs.get(config_key, []), + ) + + assert [item.name for item in get_mediaserver_configs()] == ["enabled-media"] + assert [ + item.name for item in get_mediaserver_configs(include_disabled=True) + ] == ["enabled-media", "disabled-media"] + assert [item.name for item in get_notification_configs()] == ["enabled-channel"] + assert [ + item.name for item in get_notification_configs(include_disabled=True) + ] == ["enabled-channel", "disabled-channel"] + assert get_notification_switch(MessageType.Download) == "admin" diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 7a4b524c3..ec40d8675 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -614,6 +614,32 @@ def test_monitor_dispatcher_uses_explicit_history_port_getter(): assert violations == [] +def test_canonical_service_config_consumers_use_application_directory(): + """Chain、API、Scheduler 与 Agent 不得绕过应用目录读取运行时配置 Helper。""" + paths = [ + APP_ROOT / "chain" / "_messaging.py", + APP_ROOT / "chain" / "mediaserver.py", + APP_ROOT / "api" / "endpoints" / "message.py", + APP_ROOT / "scheduler.py", + APP_ROOT / "agent" / "llm" / "capability.py", + APP_ROOT / "agent" / "tools" / "base.py", + APP_ROOT / "agent" / "tools" / "impl" / "query_library_latest.py", + ] + violations: list[str] = [] + for path in paths: + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + for node in ast.walk(tree): + if ( + isinstance(node, ast.ImportFrom) + and node.module == "app.runtime.extensions.service_config" + ): + violations.append( + f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}" + ) + + assert violations == [] + + def test_plugin_components_do_not_reexport_legacy_abi_names(): """新插件组件只提供 canonical 能力,不得复制旧 Helper、Manager 或 Oper 导出。""" violations: list[str] = [] diff --git a/tests/test_mediaserver_sync_incremental.py b/tests/test_mediaserver_sync_incremental.py index a6e71dee6..ceb221a5a 100644 --- a/tests/test_mediaserver_sync_incremental.py +++ b/tests/test_mediaserver_sync_incremental.py @@ -106,7 +106,7 @@ def test_sync_persists_music_without_querying_tv_episodes(database): "get_chain_media_server_port", lambda: MediaServerOper(session), ), patch.object( - MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, + MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])], ): @@ -199,7 +199,7 @@ def test_sync_updates_rows_and_removes_stale_entries(database): "get_chain_media_server_port", lambda: MediaServerOper(session), ), patch.object( - MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, + MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])], ): @@ -283,7 +283,7 @@ def test_sync_queries_counts_before_items_and_reports_media_progress(database): "get_chain_media_server_port", lambda: MediaServerOper(session), ), patch.object( - MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, + MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=[ SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]), @@ -340,9 +340,9 @@ def test_sync_targets_one_server_without_excluding_other_enabled_servers(monkeyp FakeMediaServerOper, ) monkeypatch.setattr( - MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, + MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", - lambda: [ + lambda **_kwargs: [ SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]), SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]), ], @@ -383,9 +383,9 @@ def test_sync_stops_without_emitting_completion_after_stop_signal(monkeypatch): ) monkeypatch.setattr(chain, "_sync_server_libraries", stop_during_sync) monkeypatch.setattr( - MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, + MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", - lambda: [server], + lambda **_kwargs: [server], ) global_vars.STOP_EVENT.clear() try: diff --git a/tests/test_scheduler_cache_expiry.py b/tests/test_scheduler_cache_expiry.py index 3dfe66e94..7727acf9f 100644 --- a/tests/test_scheduler_cache_expiry.py +++ b/tests/test_scheduler_cache_expiry.py @@ -90,9 +90,9 @@ def test_clear_cache_is_manual_only(monkeypatch): ]: monkeypatch.setattr(scheduler_module, name, lambda: generic_chain) monkeypatch.setattr( - scheduler_module.ServiceConfigHelper, + scheduler_module, "get_mediaserver_configs", - lambda: [], + lambda **_kwargs: [], ) monkeypatch.setattr( scheduler_module,