refactor(workflow): isolate legacy execution writes

This commit is contained in:
jxxghp
2026-08-28 02:10:40 +08:00
parent 9a7e87dbe0
commit 17d8be2af2
21 changed files with 385 additions and 173 deletions
+5 -5
View File
@@ -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
View File
@@ -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",
-6
View File
@@ -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
View File
@@ -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",
+88
View File
@@ -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(
+11 -9
View File
@@ -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"
+56
View File
@@ -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")
+14
View File
@@ -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",
+8 -1
View File
@@ -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"]