refactor(startup): isolate server service composition

This commit is contained in:
jxxghp
2026-08-29 17:25:25 +08:00
parent 999c9f5f63
commit b3fe1158cc
8 changed files with 276 additions and 60 deletions

View File

@@ -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,
),
)

View File

@@ -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)

View File

@@ -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 未进入包级覆盖率门禁 |

View File

@@ -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) |

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 == []