mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor(workflow): isolate legacy execution writes
This commit is contained in:
+5
-5
@@ -211,6 +211,7 @@ def configure_plugin_system_services():
|
||||
from app.application.site.query import SiteQueryService, configure_site_query_service
|
||||
from app.application.workflow import (
|
||||
WorkflowQueryService,
|
||||
configure_workflow_execution,
|
||||
configure_workflow_query,
|
||||
configure_workflow_runtime,
|
||||
)
|
||||
@@ -243,7 +244,7 @@ def configure_plugin_system_services():
|
||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||
from app.db.oper.transferhistory import TransferHistoryOper
|
||||
from app.db.oper.user import UserOper
|
||||
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
|
||||
from app.db.oper.workflow import WorkflowOper
|
||||
|
||||
def create_sync_session() -> Session:
|
||||
"""为无显式会话的 Oper 测试入口创建独占同步 Session。"""
|
||||
@@ -258,9 +259,8 @@ def configure_plugin_system_services():
|
||||
async_=transaction_runner.async_,
|
||||
)
|
||||
|
||||
configure_workflow_legacy_writer(
|
||||
TransactionalWorkflowExecutionService(SessionFactory)
|
||||
)
|
||||
workflow_execution = TransactionalWorkflowExecutionService(SessionFactory)
|
||||
configure_workflow_execution(workflow_execution)
|
||||
|
||||
configure_api_data_ports(
|
||||
sync_session=get_db,
|
||||
@@ -304,7 +304,7 @@ def configure_plugin_system_services():
|
||||
configure_chain_data_ports(
|
||||
site=site_repository,
|
||||
subscribe=lambda: SubscribeOper(),
|
||||
workflow=lambda: WorkflowOper(),
|
||||
workflow=lambda: workflow_execution,
|
||||
download_history=lambda: DownloadHistoryOper(),
|
||||
transfer_history=lambda: TransferHistoryOper(),
|
||||
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
|
||||
|
||||
+10
-3
@@ -1441,8 +1441,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 6901,
|
||||
"edge_sha256": "860590c25bd889096c9faa04f35ad3e3312e2c28ab415e57dce1d669d490e0a1",
|
||||
"edge_count": 6907,
|
||||
"edge_sha256": "8b3bb489cfc9573e377d0ee21337633b842b9969fc69fe99b86f547613b86a2a",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -3988,6 +3988,7 @@
|
||||
"app.application.chain.data -> app.application.transfer",
|
||||
"app.application.chain.data -> app.application.transfer.execution",
|
||||
"app.application.chain.data -> app.application.transfer.workflow",
|
||||
"app.application.chain.data -> app.application.workflow",
|
||||
"app.application.chain.events -> app.application",
|
||||
"app.application.chain.events -> app.application.history",
|
||||
"app.application.chain.events -> app.application.transfer",
|
||||
@@ -7700,6 +7701,11 @@
|
||||
"app.sdk._legacy.user -> app.db",
|
||||
"app.sdk._legacy.user -> app.db.oper",
|
||||
"app.sdk._legacy.user -> app.db.oper.user",
|
||||
"app.sdk._legacy.workflow -> app.application",
|
||||
"app.sdk._legacy.workflow -> app.application.workflow",
|
||||
"app.sdk._legacy.workflow -> app.db",
|
||||
"app.sdk._legacy.workflow -> app.db.oper",
|
||||
"app.sdk._legacy.workflow -> app.db.oper.workflow",
|
||||
"app.sdk.browser -> app.adapters",
|
||||
"app.sdk.browser -> app.adapters.network",
|
||||
"app.sdk.browser -> app.adapters.network.browser",
|
||||
@@ -8346,7 +8352,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 844,
|
||||
"module_count": 845,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9136,6 +9142,7 @@
|
||||
"app.sdk._legacy.transfer",
|
||||
"app.sdk._legacy.transferpending",
|
||||
"app.sdk._legacy.user",
|
||||
"app.sdk._legacy.workflow",
|
||||
"app.sdk.browser",
|
||||
"app.sdk.cache",
|
||||
"app.sdk.config",
|
||||
|
||||
@@ -479,9 +479,6 @@
|
||||
"app/db/oper/userconfig.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/workflow.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/session.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1263,9 +1260,6 @@
|
||||
"tests/test_db_lazy_engine.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_db_oper_layer.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_db_oper_layer_extra.py": {
|
||||
"I001": 1
|
||||
},
|
||||
|
||||
+10
-3
@@ -284,9 +284,9 @@
|
||||
"app.db.workflow_oper": {
|
||||
"introduced": "v3.0.0",
|
||||
"is_package": false,
|
||||
"owner": "db",
|
||||
"replacement": "app.db.oper.workflow",
|
||||
"target": "app.db.oper.workflow"
|
||||
"owner": "sdk",
|
||||
"replacement": "app.application.workflow.WorkflowExecutionPort",
|
||||
"target": "app.sdk._legacy.workflow"
|
||||
},
|
||||
"app.domain.string": {
|
||||
"introduced": "v3.0.0",
|
||||
@@ -926,6 +926,13 @@
|
||||
"target_name": "MediaInteractionChain"
|
||||
}
|
||||
},
|
||||
"app.db.oper": {
|
||||
"WorkflowOper": {
|
||||
"replacement": "app.application.workflow.WorkflowExecutionPort",
|
||||
"target_module": "app.sdk._legacy.workflow",
|
||||
"target_name": "WorkflowOper"
|
||||
}
|
||||
},
|
||||
"app.domain.media": {
|
||||
"MEDIA_SOURCE_ALIASES": {
|
||||
"replacement": "app.schemas.media.MEDIA_SOURCE_ALIASES",
|
||||
|
||||
@@ -347,6 +347,94 @@ def test_workflow_query_adapter_owns_projection_sessions():
|
||||
assert "_project_workflow(record)" in source
|
||||
|
||||
|
||||
def test_workflow_execution_chain_uses_typed_transaction_port():
|
||||
"""工作流 Chain 写端不得再经过 raw Oper 或重复获取全局端口。"""
|
||||
contract_path = APP_ROOT / "application" / "workflow.py"
|
||||
contract_tree = ast.parse(
|
||||
contract_path.read_text(encoding="utf-8"),
|
||||
filename=str(contract_path),
|
||||
)
|
||||
contract = next(
|
||||
node
|
||||
for node in contract_tree.body
|
||||
if isinstance(node, ast.ClassDef)
|
||||
and node.name == "WorkflowExecutionPort"
|
||||
)
|
||||
methods = {
|
||||
node.name: ast.unparse(node.returns)
|
||||
for node in contract.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.returns is not None
|
||||
}
|
||||
assert methods == {
|
||||
"start": "bool",
|
||||
"success": "bool",
|
||||
"fail": "bool",
|
||||
"step": "bool",
|
||||
"reset": "bool",
|
||||
}
|
||||
|
||||
data_path = APP_ROOT / "application" / "chain" / "data.py"
|
||||
data_tree = ast.parse(
|
||||
data_path.read_text(encoding="utf-8"),
|
||||
filename=str(data_path),
|
||||
)
|
||||
data_class = next(
|
||||
node
|
||||
for node in data_tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ChainDataPorts"
|
||||
)
|
||||
workflow_field = next(
|
||||
node
|
||||
for node in data_class.body
|
||||
if isinstance(node, ast.AnnAssign)
|
||||
and isinstance(node.target, ast.Name)
|
||||
and node.target.id == "workflow"
|
||||
)
|
||||
workflow_getter = next(
|
||||
node
|
||||
for node in data_tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "get_chain_workflow_port"
|
||||
)
|
||||
assert ast.unparse(workflow_field.annotation) == "WorkflowExecutionPortFactory"
|
||||
assert ast.unparse(workflow_getter.returns) == "WorkflowExecutionPort"
|
||||
|
||||
chain_source = (APP_ROOT / "chain" / "workflow.py").read_text(encoding="utf-8")
|
||||
startup_source = (
|
||||
APP_ROOT / "startup" / "initializers" / "modules.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert chain_source.count("get_chain_workflow_port()") == 1
|
||||
assert "workflow=lambda: workflow_execution" in startup_source
|
||||
assert "workflow=lambda: WorkflowOper()" not in startup_source
|
||||
|
||||
|
||||
def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports():
|
||||
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
|
||||
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
|
||||
oper_tree = ast.parse(
|
||||
oper_path.read_text(encoding="utf-8"),
|
||||
filename=str(oper_path),
|
||||
)
|
||||
oper_class = next(
|
||||
node
|
||||
for node in oper_tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "WorkflowOper"
|
||||
)
|
||||
method_names = {
|
||||
node.name
|
||||
for node in oper_class.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
assert {"start", "success", "fail", "step", "reset"}.isdisjoint(method_names)
|
||||
assert "legacy" not in oper_path.read_text(encoding="utf-8").lower()
|
||||
|
||||
package_source = (APP_ROOT / "db" / "oper" / "__init__.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '"WorkflowOper"' not in package_source
|
||||
|
||||
|
||||
def test_agent_data_ports_do_not_duplicate_workflow_query_capability():
|
||||
"""Agent 数据聚合器不得重新暴露无类型工作流读取入口。"""
|
||||
source = (APP_ROOT / "application" / "agentdata.py").read_text(
|
||||
|
||||
@@ -6,12 +6,11 @@ Oper 层大多是模型方法的薄封装,但薄封装恰恰是最容易出错
|
||||
验证 Oper 的对外契约,而不是验证它调了哪个模型方法。
|
||||
"""
|
||||
import asyncio
|
||||
import importlib
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.oper.mediaserver import MediaServerOper
|
||||
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
||||
from app.db.models.mediaserver import MediaServerItem
|
||||
from app.db.models.plugindata import PluginData
|
||||
@@ -22,6 +21,8 @@ from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.models.user import User
|
||||
from app.db.models.userconfig import UserConfig
|
||||
from app.db.models.workflow import Workflow
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.oper.mediaserver import MediaServerOper
|
||||
from app.db.oper.plugindata import PluginDataOper
|
||||
from app.db.oper.site import SiteOper
|
||||
from app.db.oper.user import UserOper
|
||||
@@ -374,7 +375,7 @@ def test_workflow_oper_add_rejects_duplicate_name(db):
|
||||
assert oper.add(**_workflow_kwargs("op-wf")) == (False, "工作流已存在")
|
||||
|
||||
|
||||
def test_workflow_oper_exposes_lists_and_lifecycle(db):
|
||||
def test_workflow_oper_exposes_lists_and_staged_lifecycle(db):
|
||||
"""
|
||||
列表入口与生命周期方法都应透传到模型并落库。
|
||||
"""
|
||||
@@ -387,23 +388,24 @@ def test_workflow_oper_exposes_lists_and_lifecycle(db):
|
||||
assert {w.name for w in oper.list_enabled()} >= {"op-wf-life"}
|
||||
assert {w.name for w in oper.get_timer_triggered_workflows()} >= {"op-wf-life"}
|
||||
|
||||
oper.start(flow.id)
|
||||
oper.stage_start(flow.id)
|
||||
assert oper.get(flow.id).state == "R"
|
||||
oper.step(flow.id, "a1", {"n": 1})
|
||||
oper.stage_step(flow.id, "a1", {"n": 1})
|
||||
assert oper.get(flow.id).current_action == "a1"
|
||||
oper.success(flow.id, "完成")
|
||||
oper.stage_success(flow.id, "完成")
|
||||
assert oper.get(flow.id).state == "S"
|
||||
oper.fail(flow.id, "出错")
|
||||
oper.stage_fail(flow.id, "出错")
|
||||
assert oper.get(flow.id).state == "F"
|
||||
oper.reset(flow.id, reset_count=True)
|
||||
oper.stage_execution_reset(flow.id, reset_count=True)
|
||||
assert (oper.get(flow.id).state, oper.get(flow.id).run_count) == ("W", 0)
|
||||
|
||||
|
||||
def test_workflow_oper_no_session_uses_configured_uow_writer(db):
|
||||
"""旧的无 Session Oper 写入口仍可用,但事务由组合根服务持有。"""
|
||||
flow = db.add(Workflow(**_workflow_kwargs("op-wf-legacy")))
|
||||
legacy = importlib.import_module("app.db.workflow_oper")
|
||||
|
||||
assert WorkflowOper().start(flow.id) is True
|
||||
assert legacy.WorkflowOper().start(flow.id) is True
|
||||
|
||||
db.session.expire_all()
|
||||
assert WorkflowOper(db=db.session).get(flow.id).state == "R"
|
||||
|
||||
@@ -7,6 +7,62 @@ from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas.file import FileItem
|
||||
|
||||
|
||||
def test_legacy_workflow_writes_delegate_to_configured_execution_port(monkeypatch):
|
||||
"""旧 WorkflowOper 无 Session 写入必须完整委托类型化事务端口。"""
|
||||
legacy = importlib.import_module("app.db.workflow_oper")
|
||||
calls = []
|
||||
|
||||
class ExecutionPort:
|
||||
"""记录五种旧工作流写入调用。"""
|
||||
|
||||
def start(self, workflow_id):
|
||||
"""记录启动。"""
|
||||
calls.append(("start", workflow_id))
|
||||
return True
|
||||
|
||||
def success(self, workflow_id, result=None):
|
||||
"""记录成功。"""
|
||||
calls.append(("success", workflow_id, result))
|
||||
return True
|
||||
|
||||
def fail(self, workflow_id, result):
|
||||
"""记录失败。"""
|
||||
calls.append(("fail", workflow_id, result))
|
||||
return True
|
||||
|
||||
def step(self, workflow_id, action_id, context, execution_state=None):
|
||||
"""记录步骤。"""
|
||||
calls.append(
|
||||
("step", workflow_id, action_id, context, execution_state)
|
||||
)
|
||||
return True
|
||||
|
||||
def reset(self, workflow_id, reset_count=False):
|
||||
"""记录重置。"""
|
||||
calls.append(("reset", workflow_id, reset_count))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
legacy,
|
||||
"get_configured_workflow_execution",
|
||||
lambda: ExecutionPort(),
|
||||
)
|
||||
oper = legacy.WorkflowOper()
|
||||
|
||||
assert oper.start(7) is True
|
||||
assert oper.success(7, "done") is True
|
||||
assert oper.fail(7, "failed") is True
|
||||
assert oper.step(7, "A", {"value": 1}, {"runtime": {}}) is True
|
||||
assert oper.reset(7, reset_count=True) is True
|
||||
assert calls == [
|
||||
("start", 7),
|
||||
("success", 7, "done"),
|
||||
("fail", 7, "failed"),
|
||||
("step", 7, "A", {"value": 1}, {"runtime": {}}),
|
||||
("reset", 7, True),
|
||||
]
|
||||
|
||||
|
||||
def test_legacy_subscribe_add_delegates_to_application_service(monkeypatch):
|
||||
"""旧 SubscribeOper.add 应保留 mediainfo 写入签名。"""
|
||||
legacy = importlib.import_module("app.db.subscribe_oper")
|
||||
|
||||
@@ -314,6 +314,19 @@ def test_db_refactor_legacy_modules_are_all_registered():
|
||||
assert expected <= set(MODULE_ALIASES)
|
||||
|
||||
|
||||
def test_workflow_oper_compatibility_is_only_exposed_by_overlay():
|
||||
"""旧工作流写入口只由 Legacy facade 和精确符号映射提供。"""
|
||||
legacy = importlib.import_module("app.db.workflow_oper")
|
||||
canonical = importlib.import_module("app.db.oper.workflow")
|
||||
oper_package = importlib.import_module("app.db.oper")
|
||||
|
||||
assert MODULE_ALIASES["app.db.workflow_oper"].target == "app.sdk._legacy.workflow"
|
||||
assert issubclass(legacy.WorkflowOper, canonical.WorkflowOper)
|
||||
assert legacy.WorkflowOper is not canonical.WorkflowOper
|
||||
assert oper_package.WorkflowOper is legacy.WorkflowOper
|
||||
assert "WorkflowOper" not in oper_package.__all__
|
||||
|
||||
|
||||
def test_split_user_oper_facade_exports_data_and_auth_contracts():
|
||||
"""旧 user_oper 同时提供 UserOper 与八个认证依赖。"""
|
||||
legacy = importlib.import_module("app.db.user_oper")
|
||||
@@ -469,6 +482,7 @@ def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path):
|
||||
|
||||
def test_symbol_alias_manifest_covers_all_moved_public_symbols():
|
||||
"""符号级映射清单应覆盖媒体身份、整理工作项、刮削拆分与消息/通知命名统一的旧入口。"""
|
||||
assert set(SYMBOL_ALIASES["app.db.oper"]) == {"WorkflowOper"}
|
||||
assert set(SYMBOL_ALIASES["app.workflow"]) == {"WorkFlowManager"}
|
||||
assert set(SYMBOL_ALIASES["app.domain.media"]) == {
|
||||
"MEDIA_SOURCE_ALIASES",
|
||||
|
||||
@@ -635,9 +635,15 @@ def test_workflow_chain_process_serializes_circular_context(monkeypatch):
|
||||
flows=[{"id": "flow-end", "source": "A", "target": "END", "animated": True}],
|
||||
)
|
||||
fake_oper = _FakeWorkflowOper(workflow)
|
||||
port_calls = []
|
||||
|
||||
def get_execution_port():
|
||||
"""记录单次执行获取事务端口的次数。"""
|
||||
port_calls.append(True)
|
||||
return fake_oper
|
||||
|
||||
monkeypatch.setattr(workflow_module, "get_workflow_manager", lambda: fake_manager)
|
||||
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", lambda: fake_oper)
|
||||
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", get_execution_port)
|
||||
monkeypatch.setattr(
|
||||
workflow_module,
|
||||
"get_configured_workflow_query",
|
||||
@@ -650,6 +656,7 @@ def test_workflow_chain_process_serializes_circular_context(monkeypatch):
|
||||
|
||||
assert success is True
|
||||
assert message == ""
|
||||
assert port_calls == [True]
|
||||
assert fake_oper.succeeded is True
|
||||
saved_workflow_context = fake_oper.steps[-1]["context"]["workflow_context"]
|
||||
saved_self = saved_workflow_context["self"]
|
||||
|
||||
Reference in New Issue
Block a user