From b3fe1158cc391c8b5066df1b7d00042c7fcf6106 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 29 Aug 2026 17:25:25 +0800 Subject: [PATCH] refactor(startup): isolate server service composition --- app/startup/composition/server.py | 50 ++++++ app/startup/initializers/modules.py | 55 +------ docs/architecture-optimization-checklist.md | 4 +- docs/architecture-overview.md | 4 +- docs/rules/05-architecture.md | 4 + .../architecture/dependency-baseline.json | 26 ++- tests/test_architecture_dependencies.py | 40 +++++ tests/test_server_composition.py | 153 ++++++++++++++++++ 8 files changed, 276 insertions(+), 60 deletions(-) create mode 100644 app/startup/composition/server.py create mode 100644 tests/test_server_composition.py diff --git a/app/startup/composition/server.py b/app/startup/composition/server.py new file mode 100644 index 000000000..df1dc0a56 --- /dev/null +++ b/app/startup/composition/server.py @@ -0,0 +1,50 @@ +"""MoviePilot Server 应用服务的宿主组合根。""" + +from app.adapters.external.server import ( + MoviePilotServerHelper, + configure_server_application_services, +) +from app.application.configuration import get_configured_system_config +from app.application.server.report import ServerReportService +from app.application.server.share import ServerSharingService +from app.application.subscription.contract import SubscriptionRepository +from app.application.workflow import WorkflowQueryService +from app.schemas.types import SystemConfigKey + + +def configure_server_services( + workflow_query: WorkflowQueryService, + subscription_repository: SubscriptionRepository, +) -> None: + """构造并登记中心服务上报与分享用例,构造阶段不执行外部请求。""" + configure_server_application_services( + report_service=ServerReportService( + config_reader=lambda key: get_configured_system_config().get(key), + config_writer=lambda key, value: get_configured_system_config().set(key, value), + async_config_writer=lambda key, value: get_configured_system_config().async_set(key, value), + installed_plugins_provider=lambda: ( + get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] + ), + subscribes_provider=subscription_repository.list, + async_subscribes_provider=subscription_repository.async_list, + plugin_report_sender=MoviePilotServerHelper.plugin_install_report, + async_plugin_report_sender=MoviePilotServerHelper.async_plugin_install_report, + subscribe_report_sender=MoviePilotServerHelper.subscribe_report, + async_subscribe_report_sender=MoviePilotServerHelper.async_subscribe_report, + repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url, + ), + sharing_service=ServerSharingService( + subscribe_provider=subscription_repository.get, + async_subscribe_provider=subscription_repository.async_get, + workflow_provider=workflow_query.get_sync, + async_workflow_provider=workflow_query.get, + user_uuid_provider=MoviePilotServerHelper.get_user_uuid, + subscribe_sender=MoviePilotServerHelper.subscribe_share, + async_subscribe_sender=MoviePilotServerHelper.async_subscribe_share, + workflow_sender=MoviePilotServerHelper.workflow_share, + async_workflow_sender=MoviePilotServerHelper.async_workflow_share, + response_handler=MoviePilotServerHelper._handle_response, + subscribe_cache_clearer=MoviePilotServerHelper._clear_subscribe_share_cache, + workflow_cache_clearer=MoviePilotServerHelper._clear_workflow_share_cache, + ), + ) diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 75f1af82d..6ddcd77b1 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -18,10 +18,7 @@ except ImportError as e: print(error_message, file=sys.stderr) sys.exit(1) -from app.adapters.external.server import ( - MoviePilotServerHelper, - configure_server_application_services, -) +from app.adapters.external.server import MoviePilotServerHelper from app.adapters.network.doh import DohHelper from app.adapters.system.host import SystemUtils from app.adapters.system.resource import ( @@ -64,16 +61,10 @@ from app.application.messaging.message import ( from app.application.module import configure_module_runtime from app.application.outbox import configure_outbox_dispatcher from app.application.security.url import close_image_proxy_block_log_coalescer -from app.application.server.report import ServerReportService -from app.application.server.share import ServerSharingService from app.application.service import configure_service_directory from app.application.site.health import SiteHealthService, configure_site_health_service from app.application.site.query import SiteQueryService, configure_site_query_service -from app.application.subscription.contract import SubscriptionRepository -from app.application.workflow import ( - WorkflowQueryService, - configure_workflow_execution, -) +from app.application.workflow import configure_workflow_execution from app.command import CommandChain from app.db.adapters.agent import ( SessionAgentTaskRepository, @@ -178,6 +169,7 @@ from app.startup.composition.security import ( configure_security_access, configure_security_services, ) +from app.startup.composition.server import configure_server_services from app.startup.composition.subscription import ( async_rule_group_mutation_scope, build_subscription_batch_writer, @@ -254,48 +246,14 @@ def _build_chain_runtime_context( ) -def configure_runtime_data_providers( - workflow_query: WorkflowQueryService, - subscription_repository: SubscriptionRepository, -) -> None: - """在启动组合层装配运行时和外部服务所需的数据库读取能力。""" +def configure_runtime_data_providers() -> None: + """在启动组合层装配模块和配置目录的运行时读取能力。""" configure_service_config_reader(lambda key: get_configured_system_config().get(key)) configure_module_runtime(lambda: ModuleManager()) configure_service_directory( configs=ServiceConfigHelper.get_configs, modules=lambda module_type: ModuleManager().get_running_type_modules(module_type), ) - configure_server_application_services( - report_service=ServerReportService( - config_reader=lambda key: get_configured_system_config().get(key), - config_writer=lambda key, value: get_configured_system_config().set(key, value), - async_config_writer=lambda key, value: get_configured_system_config().async_set(key, value), - installed_plugins_provider=lambda: ( - get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] - ), - subscribes_provider=subscription_repository.list, - async_subscribes_provider=subscription_repository.async_list, - plugin_report_sender=MoviePilotServerHelper.plugin_install_report, - async_plugin_report_sender=(MoviePilotServerHelper.async_plugin_install_report), - subscribe_report_sender=MoviePilotServerHelper.subscribe_report, - async_subscribe_report_sender=MoviePilotServerHelper.async_subscribe_report, - repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url, - ), - sharing_service=ServerSharingService( - subscribe_provider=subscription_repository.get, - async_subscribe_provider=subscription_repository.async_get, - workflow_provider=workflow_query.get_sync, - async_workflow_provider=workflow_query.get, - user_uuid_provider=MoviePilotServerHelper.get_user_uuid, - subscribe_sender=MoviePilotServerHelper.subscribe_share, - async_subscribe_sender=MoviePilotServerHelper.async_subscribe_share, - workflow_sender=MoviePilotServerHelper.workflow_share, - async_workflow_sender=MoviePilotServerHelper.async_workflow_share, - response_handler=MoviePilotServerHelper._handle_response, - subscribe_cache_clearer=(MoviePilotServerHelper._clear_subscribe_share_cache), - workflow_cache_clearer=(MoviePilotServerHelper._clear_workflow_share_cache), - ), - ) def configure_wallpaper_services() -> None: @@ -714,7 +672,8 @@ async def _initialize_modules() -> HostRuntime: tasks=get_task_registry(), ) publish_database_services(database_services) - configure_runtime_data_providers(workflow_query, subscription_repository) + configure_runtime_data_providers() + configure_server_services(workflow_query, subscription_repository) workflow_execution = TransactionalWorkflowExecutionService(SessionFactory) configure_workflow_execution(workflow_execution) configure_outbox_dispatcher(build_outbox_dispatcher) diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 18928ec1d..da438515e 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 968 / 8,248 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 969 / 8,257 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 | | 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 10,347 / 593 文件 | strict frontier 当前覆盖 41 个文件,Domain projection 与既有 owner 拆分的受益调用方低水位已固化 | +| 全量 mypy 历史债务 | 10,336 / 593 文件 | strict frontier 当前覆盖 41 个文件,Domain projection 与既有 owner 拆分的受益调用方低水位已固化 | | Ruff 历史诊断 | 648 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 7b44e82f3..e52b6dd94 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -745,8 +745,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 968 | -| 内部导入边 | 8,248 | +| Python 模块 | 969 | +| 内部导入边 | 8,257 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 229c96500..2c25c616a 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -152,6 +152,10 @@ startup order and must not recreate their concrete construction. owns durable handler validation, lazy event/notification replay binding and the short-transaction dispatcher factory. Initializers call this owner in startup order and must not recreate their concrete construction. Database runtime +`startup/composition/server.py` owns MoviePilot Server report/share service +construction and registration, including lazy local-data readers and external +transport callbacks; `initializers/modules.py` retains only its startup-order call +and the later explicit network warmup. Database runtime ownership rejects overlapping lifespans; after a successful worker shutdown, the configuration/database composition owners must revoke every provider they published before releasing database connections. Other domain providers remain owned by their diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 405ff3ffd..2448cfd62 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8248, - "edge_sha256": "224c93fde383ba0fa2e5f5fba873b06a53bf9eaa17c28f18b285a5048dd071a8", + "edge_count": 8257, + "edge_sha256": "914b0e25fb7aaad53720fb92b6d9523344d7a909af7cd081a4669259253825d8", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8754,6 +8754,19 @@ "app.startup.composition.security -> app.startup.composition", "app.startup.composition.security -> app.startup.composition.context", "app.startup.composition.security -> app.startup.composition.database", + "app.startup.composition.server -> app.adapters", + "app.startup.composition.server -> app.adapters.external", + "app.startup.composition.server -> app.adapters.external.server", + "app.startup.composition.server -> app.application", + "app.startup.composition.server -> app.application.configuration", + "app.startup.composition.server -> app.application.server", + "app.startup.composition.server -> app.application.server.report", + "app.startup.composition.server -> app.application.server.share", + "app.startup.composition.server -> app.application.subscription", + "app.startup.composition.server -> app.application.subscription.contract", + "app.startup.composition.server -> app.application.workflow", + "app.startup.composition.server -> app.schemas", + "app.startup.composition.server -> app.schemas.types", "app.startup.composition.subscription -> app.adapters", "app.startup.composition.subscription -> app.adapters.external", "app.startup.composition.subscription -> app.adapters.external.server", @@ -8898,15 +8911,10 @@ "app.startup.initializers.modules -> app.application.outbox", "app.startup.initializers.modules -> app.application.security", "app.startup.initializers.modules -> app.application.security.url", - "app.startup.initializers.modules -> app.application.server", - "app.startup.initializers.modules -> app.application.server.report", - "app.startup.initializers.modules -> app.application.server.share", "app.startup.initializers.modules -> app.application.service", "app.startup.initializers.modules -> app.application.site", "app.startup.initializers.modules -> app.application.site.health", "app.startup.initializers.modules -> app.application.site.query", - "app.startup.initializers.modules -> app.application.subscription", - "app.startup.initializers.modules -> app.application.subscription.contract", "app.startup.initializers.modules -> app.application.workflow", "app.startup.initializers.modules -> app.chain", "app.startup.initializers.modules -> app.chain.download", @@ -8975,6 +8983,7 @@ "app.startup.initializers.modules -> app.startup.composition.network", "app.startup.initializers.modules -> app.startup.composition.outbox", "app.startup.initializers.modules -> app.startup.composition.security", + "app.startup.initializers.modules -> app.startup.composition.server", "app.startup.initializers.modules -> app.startup.composition.subscription", "app.startup.initializers.modules -> app.startup.initializers", "app.startup.initializers.modules -> app.startup.initializers.agent", @@ -9341,7 +9350,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 968, + "module_count": 969, "modules": [ "app", "app.adapters", @@ -10270,6 +10279,7 @@ "app.startup.composition.network", "app.startup.composition.outbox", "app.startup.composition.security", + "app.startup.composition.server", "app.startup.composition.subscription", "app.startup.initializers", "app.startup.initializers.agent", diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index e8dd53535..dd6b7a400 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -848,6 +848,46 @@ def test_modules_initializer_delegates_outbox_composition(): assert "build_outbox_dispatcher" not in package_source +def test_modules_initializer_delegates_server_service_composition(): + """中心服务对象图只由单词型 composition owner 构造,initializer 仅保留调用顺序。""" + initializer_source = ( + APP_ROOT / "startup" / "initializers" / "modules.py" + ).read_text(encoding="utf-8") + composition_path = APP_ROOT / "startup" / "composition" / "server.py" + composition_source = composition_path.read_text(encoding="utf-8") + architecture_source = ( + PROJECT_ROOT / "docs" / "rules" / "05-architecture.md" + ).read_text(encoding="utf-8") + package_tree = ast.parse( + (APP_ROOT / "startup" / "composition" / "__init__.py").read_text( + encoding="utf-8" + ) + ) + + assert composition_path.is_file() + assert not list(composition_path.parent.glob("server_*.py")) + assert "from app.startup.composition.server import configure_server_services" in ( + initializer_source + ) + assert "configure_server_services(workflow_query, subscription_repository)" in ( + initializer_source + ) + assert "`startup/composition/server.py` owns MoviePilot Server" in ( + architecture_source + ) + for detail in ( + "ServerReportService(", + "ServerSharingService(", + "configure_server_application_services(", + ): + assert detail not in initializer_source + assert detail in composition_source + assert not any( + isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) + for node in package_tree.body + ) + + def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports(): """工作流旧写入口只能存在于 SDK Legacy facade。""" oper_path = APP_ROOT / "db" / "oper" / "workflow.py" diff --git a/tests/test_server_composition.py b/tests/test_server_composition.py new file mode 100644 index 000000000..19f118d5a --- /dev/null +++ b/tests/test_server_composition.py @@ -0,0 +1,153 @@ +"""MoviePilot Server 生产组合根测试。""" + +from types import SimpleNamespace + +import pytest + +import app.startup.composition.server as server_composition +from app.schemas.types import SystemConfigKey + + +class _SystemConfig: + """记录中心服务组合回调触发的配置读写。""" + + def __init__(self) -> None: + """初始化插件清单和读写调用记录。""" + self.values = {SystemConfigKey.UserInstalledPlugins: ["TestPlugin"]} + self.calls: list[tuple[str, object, object]] = [] + + def get(self, key): + """读取测试配置并记录访问。""" + self.calls.append(("get", key, None)) + return self.values.get(key) + + def set(self, key, value): + """同步写入测试配置并记录访问。""" + self.calls.append(("set", key, value)) + self.values[key] = value + return True + + async def async_set(self, key, value): + """异步写入测试配置并记录访问。""" + self.calls.append(("async_set", key, value)) + self.values[key] = value + return True + + +class _SubscriptionRepository: + """提供同步和异步订阅读取端口的测试仓储。""" + + def list(self): + """返回同步订阅快照。""" + return ["sync-list"] + + async def async_list(self): + """返回异步订阅快照。""" + return ["async-list"] + + def get(self, subscribe_id): + """返回同步订阅标记。""" + return ("sync-subscribe", subscribe_id) + + async def async_get(self, subscribe_id): + """返回异步订阅标记。""" + return ("async-subscribe", subscribe_id) + + +class _WorkflowQuery: + """提供同步和异步工作流读取端口。""" + + def get_sync(self, workflow_id): + """返回同步工作流标记。""" + return ("sync-workflow", workflow_id) + + async def get(self, workflow_id): + """返回异步工作流标记。""" + return ("async-workflow", workflow_id) + + +@pytest.mark.asyncio +async def test_server_composition_wires_lazy_local_and_transport_ports( + monkeypatch, +) -> None: + """生产组合仅保存本地与传输回调,构造阶段不得读取配置或发起请求。""" + configured = {} + system_config = _SystemConfig() + configuration_resolutions = [] + transport_calls = [] + + def get_system_config(): + """记录配置服务只在回调执行时解析。""" + configuration_resolutions.append(True) + return system_config + + def capture_services(*, report_service, sharing_service): + """捕获生产组合登记的两个应用服务。""" + configured["report"] = report_service + configured["sharing"] = sharing_service + + def sync_transport(*args, **kwargs): + """记录同步中心服务传输调用。""" + transport_calls.append(("sync", args, kwargs)) + return SimpleNamespace(status_code=200) + + async def async_transport(*args, **kwargs): + """记录异步中心服务传输调用。""" + transport_calls.append(("async", args, kwargs)) + return SimpleNamespace(status_code=200) + + monkeypatch.setattr( + server_composition, + "get_configured_system_config", + get_system_config, + ) + monkeypatch.setattr( + server_composition, + "configure_server_application_services", + capture_services, + ) + helper = server_composition.MoviePilotServerHelper + for name in ( + "plugin_install_report", + "subscribe_report", + "subscribe_share", + "workflow_share", + ): + monkeypatch.setattr(helper, name, sync_transport) + for name in ( + "async_plugin_install_report", + "async_subscribe_report", + "async_subscribe_share", + "async_workflow_share", + ): + monkeypatch.setattr(helper, name, async_transport) + monkeypatch.setattr(helper, "sanitize_plugin_repo_url", lambda value: value) + monkeypatch.setattr(helper, "get_user_uuid", lambda: "user-uuid") + monkeypatch.setattr(helper, "_handle_response", lambda _response, _clear: (True, "ok")) + monkeypatch.setattr(helper, "_clear_subscribe_share_cache", lambda: None) + monkeypatch.setattr(helper, "_clear_workflow_share_cache", lambda: None) + + subscriptions = _SubscriptionRepository() + workflows = _WorkflowQuery() + server_composition.configure_server_services(workflows, subscriptions) + + assert configuration_resolutions == [] + assert system_config.calls == [] + assert transport_calls == [] + + report = configured["report"] + sharing = configured["sharing"] + config_key = SystemConfigKey.UserInstalledPlugins + assert report._config_reader(config_key) == ["TestPlugin"] + assert report._config_writer(config_key, ["NextPlugin"]) is True + assert await report._async_config_writer(config_key, ["AsyncPlugin"]) is True + assert report._installed_plugins_provider() == ["AsyncPlugin"] + assert report._subscribes_provider() == ["sync-list"] + assert await report._async_subscribes_provider() == ["async-list"] + assert sharing._subscribe_provider(7) == ("sync-subscribe", 7) + assert await sharing._async_subscribe_provider(8) == ("async-subscribe", 8) + assert sharing._workflow_provider(9) == ("sync-workflow", 9) + assert await sharing._async_workflow_provider(10) == ("async-workflow", 10) + assert sharing._user_uuid_provider() == "user-uuid" + assert len(configuration_resolutions) == 4 + assert transport_calls == []