From 71c2e9e3dfc5ea80ace187c3340ab880b9f38c88 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:42:07 +0800 Subject: [PATCH] fix(storage): retain history until target cleanup is confirmed (#7202) --- app/chain/storage.py | 27 ++- app/chain/transfer/records.py | 33 +++- app/modules/filemanager/module.py | 19 +- app/runtime/extensions/module/contracts.py | 8 + app/schemas/exports.py | 1 + app/schemas/file.py | 10 + docs/architecture-overview.md | 2 +- docs/rules/05-architecture.md | 8 + docs/testing.md | 6 + .../architecture/dependency-baseline.json | 6 +- .../runtime-contract-baseline.json | 20 ++ tests/test_manual_transfer_history.py | 6 +- tests/test_storage_chain_strict.py | 4 +- tests/test_storage_cleanup_receipts.py | 176 ++++++++++++++++++ tests/test_transfer_durable_retry_owner.py | 5 +- 15 files changed, 302 insertions(+), 29 deletions(-) create mode 100644 tests/test_storage_cleanup_receipts.py diff --git a/app/chain/storage.py b/app/chain/storage.py index fe5c9cf8a..3614c92ee 100644 --- a/app/chain/storage.py +++ b/app/chain/storage.py @@ -8,7 +8,9 @@ from typing import Any, Dict, List, Optional, Sequence, cast from app.application.directory import DirectoryHelper from app.chain.base import ChainBase from app.runtime.log import logger +from app.schemas.exception import StorageQueryError from app.schemas.file import FileItem as _SchemaFileItem +from app.schemas.file import FileItemQueryResult class StorageChain(ChainBase): @@ -123,16 +125,23 @@ class StorageChain(ChainBase): path: Path, ) -> Optional[_SchemaFileItem]: """ - 严格查询文件项:确认不存在返回空,能力提供方 或 I/O 失败直接抛出。 + 严格查询文件项,只有实际查询回执中的空值才表示目标不存在。 + + :param storage: 目标存储标识 + :param path: 存储内的文件或目录路径 + :return: 确认存在的文件项或确认不存在时的 None + :raises StorageQueryError: 没有可确认状态的提供方或回执不合法时抛出 """ - return cast( - Optional[_SchemaFileItem], - self.run_module_strict( - "get_file_item", - storage=storage, - path=path, - ), - ) + result = self.run_module_strict("get_file_item_strict", storage=storage, path=path) + if isinstance(result, FileItemQueryResult): + return result.item + if result is not None: + raise StorageQueryError(f"存储 {storage} 的严格查询未返回有效回执") + # 旧插件仍能证明非空命中;其空返回值无法区分“不支持”和“不存在”。 + legacy_item = self.run_module_strict("get_file_item", storage=storage, path=path) + if isinstance(legacy_item, _SchemaFileItem): + return legacy_item + raise StorageQueryError(f"存储 {storage} 没有能够确认目标状态的查询提供方") def get_parent_item(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]: """ diff --git a/app/chain/transfer/records.py b/app/chain/transfer/records.py index a3c1d6f72..3e34da918 100644 --- a/app/chain/transfer/records.py +++ b/app/chain/transfer/records.py @@ -564,6 +564,11 @@ class ManualHistoryMixin(_TransferOwnerBase): 用户已明确重新整理,缺失结算版本或停在重试、人工复核态的旧记录也必须 能放弃;普通重试仍由持久调度器沿原计划继续,有效租约拒绝任何清理。 + 目标状态查询失败时保留历史与失败计数,不把未知状态当作目标已消失。 + + :param history: 用户明确要求重新整理的历史快照 + :param transfer_history_oper: 删除历史所用的仓储端口 + :return: 是否完成旧历史清理及可向用户展示的失败原因 """ task_id = getattr(history, "transfer_task_id", None) if task_id: @@ -582,16 +587,26 @@ class ManualHistoryMixin(_TransferOwnerBase): if not isinstance(history.dest_fileitem, dict): return False, "目标文件历史数据无效,请删除该整理记录后从文件管理重新整理" dest_fileitem = FileItem.model_validate(history.dest_fileitem) + if not dest_fileitem.storage or not dest_fileitem.path: + return False, "目标文件历史数据缺少存储或路径,请检查该整理记录" storage_chain = StorageChain() - if storage_chain.exists(dest_fileitem): - deleted = storage_chain.delete_media_file(dest_fileitem) - # 远端存储可能在删除请求返回前由并发任务或生命周期策略移除文件。 - # 只要读回确认目标已不存在,就按幂等删除继续,不能留下永远无法重整的历史。 - if not deleted and storage_chain.exists(dest_fileitem): - return False, ( - f"旧目标文件 {dest_fileitem.path} 删除失败,请检查媒体库权限," - "或手动删除该文件后重试" - ) + try: + target = storage_chain.get_file_item_strict( + storage=dest_fileitem.storage, path=Path(dest_fileitem.path), + ) + if target: + deleted = storage_chain.delete_media_file(dest_fileitem) + # 删除失败时,只有严格查询确认目标消失,才接受并发移除后的幂等成功。 + if not deleted and storage_chain.get_file_item_strict( + storage=dest_fileitem.storage, path=Path(dest_fileitem.path), + ): + return False, ( + f"旧目标文件 {dest_fileitem.path} 删除失败,请检查媒体库权限," + "或手动删除该文件后重试" + ) + except Exception: # pylint: disable=broad-except + return False, f"无法确认旧目标文件 {dest_fileitem.path} 的状态,请检查存储后重试" + transfer_history_oper.delete(history.id) # 删除记录是用户显式要求重来,失败计数一并清零,否则重整仍会受上一轮次数限制 clear_transfer_failures(history.src, history.src_storage) diff --git a/app/modules/filemanager/module.py b/app/modules/filemanager/module.py index a3c66abed..792f8ef58 100644 --- a/app/modules/filemanager/module.py +++ b/app/modules/filemanager/module.py @@ -22,7 +22,8 @@ from app.modules.filemanager.storages import StorageBase from app.modules.filemanager.transhandler import TransHandler from app.runtime.log import logger from app.runtime.settings import get_runtime_setting -from app.schemas.file import FileItem, StorageUsage +from app.schemas.exception import StorageQueryError +from app.schemas.file import FileItem, FileItemQueryResult, StorageUsage from app.schemas.mediaserver import ExistMediaInfo from app.schemas.system import TransferDirectoryConf from app.schemas.tmdb import TmdbEpisode @@ -477,6 +478,22 @@ class FileManagerModule(_ModuleBase): return None return storage_oper.get_item_strict(path) + def get_file_item_strict(self, storage: str, path: Path) -> Optional[FileItemQueryResult]: + """ + 为支持的存储返回明确查询回执,不支持时让调度器继续寻找其他提供方。 + + :param storage: 目标存储标识 + :param path: 存储内的文件或目录路径 + :return: 已完成查询的回执;不支持该存储时返回 None + :raises StorageQueryError: 已注册存储的适配器无法取得时抛出 + """ + if storage not in self._support_storages: + return None + storage_oper = self.__get_storage_oper(storage) + if storage_oper is None: + raise StorageQueryError(f"存储 {storage} 的文件查询适配器不可用") + return FileItemQueryResult(item=storage_oper.get_item_strict(path)) + def get_parent_item(self, fileitem: FileItem) -> Optional[FileItem]: """ 通过对应存储查询上级目录。 diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index 0b9b88e9f..29e2b1c5d 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -373,6 +373,14 @@ _METHOD_CONTRACTS = { aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path"), ), + "get_file_item_strict": ModuleMethodContract( + family="storage", + input_contract="StorageItemRequest", + result_contract="FileItemQueryResult | None", + result_shape=ModuleResultShape.MODEL, + aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, + required_parameters=("storage", "path"), + ), "get_folder": ModuleMethodContract( family="storage", input_contract="StorageFolderRequest", diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 114fa811b..773adfdd9 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -181,6 +181,7 @@ SCHEMA_EXPORTS = { 'Field': ('app.schemas.mcp', 'Field'), 'FileContextSnapshot': ('app.schemas.event', 'FileContextSnapshot'), 'FileItem': ('app.schemas.workflow', 'FileItem'), + 'FileItemQueryResult': ('app.schemas.file', 'FileItemQueryResult'), 'FileNameData': ('app.schemas.common', 'FileNameData'), 'FileURI': ('app.schemas.file', 'FileURI'), 'FilterRuleGroup': ('app.schemas.system', 'FilterRuleGroup'), diff --git a/app/schemas/file.py b/app/schemas/file.py index a6c44eb50..a39331ed0 100644 --- a/app/schemas/file.py +++ b/app/schemas/file.py @@ -84,6 +84,16 @@ class FileItem(FileURI): return str(value) if value is not None else None +class FileItemQueryResult(BaseModel): + """ + 声明提供方已实际查询目标存储,空文件项表示已确认不存在。 + + 与模块方法返回 None(此提供方不支持目标存储)区分,避免未知态被用于删除决策。 + """ + + item: Optional[FileItem] = Field(..., description="实际查询结果;None 仅表示已确认文件或目录不存在") + + class StorageUsage(BaseModel): """ 存储空间使用情况。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 66f5b2ab9..4832d81e6 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -817,7 +817,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1142 | -| 内部导入边 | 9,837 | +| 内部导入边 | 9,839 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 547893157..ce56e14e7 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -804,6 +804,14 @@ SMB 同存储整理由 `app.modules.filemanager.storages.smb` 持有协议调用 本地文件 ID 使用设备号和 inode,验证不额外读取整文件计算哈希。 外部 legacy transfer provider 继续遵循原有兼容合同。 +严格文件查询使用模块能力 `get_file_item_strict(storage, path)`。提供方不支持目标存储时返回 +`None`,调度继续寻找下一提供方;真正完成查询时返回 `FileItemQueryResult(item=...)`, +其中 `item=None` 才能作为目标不存在的证据。存储不可用或查询失败必须抛出。 +`StorageChain.get_file_item_strict` 仅接受明确回执;旧插件的普通能力非空命中继续兼容, +但空结果没有存储归属证明,不能用于删除历史等破坏性决策。旧插件如需声明“已确认不存在”, +应实现新能力并返回回执;普通 `get_file_item` 的 nullable 查询合同不变。 +显式重新整理在清理旧目标前及删除失败后均使用严格查询,未知态保留历史和失败计数。 + SMB 目录快照不依赖祖先目录 mtime 推断后代变化,而在既有递归深度上限内逐层列举。 存储通过 `snapshot_strict_query` 声明已实现 `get_item_strict` / `list_strict`; 严格快照的任一查询失败必须返回 `None`,由远程轮询器保留旧基线并重试。 diff --git a/docs/testing.md b/docs/testing.md index 4e44dc8b5..60da1358d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -147,6 +147,12 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)): 115 快照不再按目录时间剪枝;扫描间隔、深度和接口限流不变,但每轮可能增加目录列表请求。 这些用例不代表 115 已接入严格查询或验证了查询失败时的快照原子性。 +### 存储严格查询回执回归 + +`tests/test_storage_cleanup_receipts.py` 通过真实 StorageChain、模块调度器和 FileManager, +验证未知存储、未初始化模块、缺失模块和适配器不可用均保留整理历史。自定义存储提供方 +放在插件优先路径或宿主前后顺序时,都须能传递明确的文件项/不存在回执;普通查询仍可返回空值。 + ## 种子下载重试回归 下载选集回归使用真实 qBittorrent/Transmission Module 与离线下载器端口,验证自定义识别词 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index ca245766d..748e0ed38 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -959,8 +959,8 @@ "runtime_only": true } }, - "edge_count": 9837, - "edge_sha256": "d51ff72057e0c293893d968ca72a5f76f33c0c89d4d05a31c87110ba3b2cfc56", + "edge_count": 9839, + "edge_sha256": "72918db401db3ef24d1360aa2f29169d114fa13bebf8e255e807cf38bc5a5082", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -5496,6 +5496,7 @@ "app.chain.storage -> app.runtime", "app.chain.storage -> app.runtime.log", "app.chain.storage -> app.schemas", + "app.chain.storage -> app.schemas.exception", "app.chain.storage -> app.schemas.file", "app.chain.subscribe.calendar -> app.chain", "app.chain.subscribe.calendar -> app.chain.media", @@ -7509,6 +7510,7 @@ "app.modules.filemanager.module -> app.runtime.log", "app.modules.filemanager.module -> app.runtime.settings", "app.modules.filemanager.module -> app.schemas", + "app.modules.filemanager.module -> app.schemas.exception", "app.modules.filemanager.module -> app.schemas.file", "app.modules.filemanager.module -> app.schemas.mediaserver", "app.modules.filemanager.module -> app.schemas.system", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 01222f1a9..91b9b89ed 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -6236,6 +6236,26 @@ "timeout_policy": "caller_budget", "version": 1 }, + "get_file_item_strict": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageItemRequest", + "optional_parameters": [], + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [ + "path", + "storage" + ], + "result_contract": "FileItemQueryResult | None", + "result_shape": "model", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, "get_folder": { "aggregation": "first_non_empty", "error_policy": "isolate_provider", diff --git a/tests/test_manual_transfer_history.py b/tests/test_manual_transfer_history.py index 6ab7a6719..107e266a2 100644 --- a/tests/test_manual_transfer_history.py +++ b/tests/test_manual_transfer_history.py @@ -166,7 +166,7 @@ def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, del ), ) monkeypatch.setattr("app.chain.transfer.records.StorageChain", lambda: SimpleNamespace( - exists=lambda current_fileitem: True, + get_file_item_strict=lambda **_kwargs: fileitem, delete_media_file=lambda current_fileitem: deleted.append( ("target", current_fileitem.path) ) @@ -1020,9 +1020,9 @@ def test_manual_reorganize_accepts_old_target_disappearing_during_delete(monkeyp transfer_task_id=None, download_hash=None, downloader=None, src=None, src_storage=None, ) deleted: list[tuple[str, object]] = [] - states = iter((True, False)) + states = iter((old_dest, None)) monkeypatch.setattr("app.chain.transfer.records.StorageChain", lambda: SimpleNamespace( - exists=lambda _item: next(states), + get_file_item_strict=lambda **_kwargs: next(states), delete_media_file=lambda _item: False, )) state, message = chain._delete_manual_transfer_history( diff --git a/tests/test_storage_chain_strict.py b/tests/test_storage_chain_strict.py index 9e9bbbdc8..164092766 100644 --- a/tests/test_storage_chain_strict.py +++ b/tests/test_storage_chain_strict.py @@ -11,7 +11,7 @@ from app.chain.storage import StorageChain from app.modules.filemanager.module import FileManagerModule from app.modules.filemanager.storages.local import LocalStorage from app.schemas.exception import StorageQueryError -from app.schemas.file import FileItem +from app.schemas.file import FileItem, FileItemQueryResult def test_storage_chain_strict_query_distinguishes_absent_and_failure() -> None: @@ -20,7 +20,7 @@ def test_storage_chain_strict_query_distinguishes_absent_and_failure() -> None: """ chain = object.__new__(StorageChain) chain._module_dispatcher = Mock() - chain._module_dispatcher.dispatch_strict.return_value = None + chain._module_dispatcher.dispatch_strict.return_value = FileItemQueryResult(item=None) assert chain.get_file_item_strict("plugin", Path("/missing.mkv")) is None diff --git a/tests/test_storage_cleanup_receipts.py b/tests/test_storage_cleanup_receipts.py new file mode 100644 index 000000000..d9f6a5ce7 --- /dev/null +++ b/tests/test_storage_cleanup_receipts.py @@ -0,0 +1,176 @@ +""" +验证存储严格查询回执与整理历史清理的未知状态和提供方回退。 +""" + + + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.chain.storage import StorageChain +from app.chain.transfer.records import ManualHistoryMixin +from app.modules.filemanager.module import FileManagerModule +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher +from app.schemas.exception import StorageQueryError +from app.schemas.file import FileItem, FileItemQueryResult + + +@pytest.mark.parametrize("failed_lookup", [0, 1]) +def test_manual_cleanup_preserves_history_when_target_state_is_unknown(tmp_path, monkeypatch, failed_lookup): + """ + 首次或删除失败后的严格查询出错时,不删除仍有目标的历史及失败计数。 + """ + path = tmp_path / "target.mkv" + path.write_bytes(b"existing-media") + item = FileItem(storage="local", path=str(path), type="file") + storage = object.__new__(StorageChain) + calls = [] + + def query(strict): + """ + 在存储模块边界回放普通查询吞错和严格查询抛错的同一故障。 + """ + index = len(calls) + calls.append(index) + if index == failed_lookup: + if strict: + raise OSError("storage unavailable") + return None + return item + + monkeypatch.setattr(storage, "run_module", lambda *_args, **_kwargs: query(False)) + monkeypatch.setattr(storage, "run_module_strict", lambda *_args, **_kwargs: FileItemQueryResult(item=query(True))) + monkeypatch.setattr(storage, "delete_media_file", Mock(return_value=False)) + monkeypatch.setattr("app.chain.transfer.records.StorageChain", lambda: storage) + cleared = Mock() + monkeypatch.setattr("app.chain.transfer.records.clear_transfer_failures", cleared) + repository = SimpleNamespace(delete=Mock()) + history = SimpleNamespace(id=42, transfer_task_id=None, status=True, mode="copy", + dest_fileitem=item.model_dump(), src="/source", src_storage="local") + + success, message = ManualHistoryMixin._delete_manual_transfer_history(SimpleNamespace(), history, repository) + + assert not success + assert message + assert path.read_bytes() == b"existing-media" + repository.delete.assert_not_called() + cleared.assert_not_called() + + +@pytest.mark.parametrize("scenario", ["absent", "delete_success", "delete_failed"]) +def test_manual_cleanup_requires_confirmed_target_cleanup(monkeypatch, scenario): + """ + 明确不存在或删除成功才清历史;删除失败且目标仍在则保留历史和失败计数。 + """ + item = FileItem(storage="local", path="/library/target.mkv", type="file") + storage = object.__new__(StorageChain) + monkeypatch.setattr(storage, "run_module_strict", Mock(return_value=FileItemQueryResult(item=None if scenario == "absent" else item))) + deletion = Mock(return_value=scenario == "delete_success") + monkeypatch.setattr(storage, "delete_media_file", deletion) + monkeypatch.setattr("app.chain.transfer.records.StorageChain", lambda: storage) + cleared = Mock() + monkeypatch.setattr("app.chain.transfer.records.clear_transfer_failures", cleared) + repository = SimpleNamespace(delete=Mock()) + history = SimpleNamespace(id=42, transfer_task_id=None, status=True, mode="copy", + dest_fileitem=item.model_dump(), src="/source", src_storage="local") + + success, _message = ManualHistoryMixin._delete_manual_transfer_history(SimpleNamespace(), history, repository) + + assert success is (scenario != "delete_failed") + assert repository.delete.called is success + assert cleared.called is success + assert deletion.called is (scenario != "absent") + + +@pytest.mark.parametrize("availability", ["unknown", "uninitialized", "no-module", "no-adapter"]) +def test_manual_cleanup_keeps_history_without_available_storage_provider(monkeypatch, availability): + """ + 真实模块调度和 FileManager 对未知或未初始化存储不能伪造“目标不存在”。 + """ + module = object.__new__(FileManagerModule) + module._support_storages = [] if availability == "uninitialized" else ["local"] + monkeypatch.setattr(module, "_FileManagerModule__get_storage_oper", lambda _storage: None) + modules = [] if availability == "no-module" else [module] + def running_modules(method): + """ + 返回实现本次能力的宿主模块快照。 + + :param method: 正在派发的能力名称 + :return: 只包含该能力实现方的模块列表 + """ + return [provider for provider in modules if hasattr(provider, method)] + + catalog = SimpleNamespace(get_running_modules=running_modules) + dispatcher = ModuleInvocationDispatcher(module_catalog=catalog, + plugin_catalog=SimpleNamespace(get_plugin_modules=lambda: {}), plugin_error_handler=Mock(), + system_error_handler=Mock(), rate_limit_handler=Mock()) + storage = object.__new__(StorageChain) + storage._module_dispatcher = dispatcher + monkeypatch.setattr("app.chain.transfer.records.StorageChain", lambda: storage) + cleared = Mock() + monkeypatch.setattr("app.chain.transfer.records.clear_transfer_failures", cleared) + item = FileItem(storage="unknown" if availability == "unknown" else "local", path="/target", type="file") + history = SimpleNamespace(id=42, transfer_task_id=None, status=True, mode="copy", + dest_fileitem=item.model_dump(), src="/source", src_storage="local") + repository = SimpleNamespace(delete=Mock()) + + success, message = ManualHistoryMixin._delete_manual_transfer_history(SimpleNamespace(), history, repository) + + assert not success + assert message + repository.delete.assert_not_called() + cleared.assert_not_called() + # 普通查询继续保留既有 nullable 合同,不把严格用途扩散到普通浏览请求。 + assert storage.get_file_item(item.storage, Path(item.path)) is None + + +@pytest.mark.parametrize("placement", ["plugin", "before-host", "after-host"]) +@pytest.mark.parametrize("found", [False, True]) +def test_strict_storage_receipt_preserves_provider_fallback(monkeypatch, placement, found): + """ + 插件或任意宿主排序的自定义存储回执都能接管,明确不存在不与不适用混淆。 + """ + module = object.__new__(FileManagerModule) + module._support_storages = ["local"] + monkeypatch.setattr(module, "_FileManagerModule__get_storage_oper", lambda _storage: None) + expected = FileItem(storage="custom", path="/target") if found else None + provider = SimpleNamespace(get_priority=lambda: 1 if placement == "before-host" else 9, + get_name=lambda: "custom", get_file_item_strict=lambda **_kwargs: FileItemQueryResult(item=expected)) + modules = [module] if placement == "plugin" else [module, provider] + plugins = {("custom", "custom"): {"get_file_item_strict": provider.get_file_item_strict}} if placement == "plugin" else {} + def running_modules(method): + """ + 返回实现本次能力的宿主模块快照。 + + :param method: 正在派发的能力名称 + :return: 只包含该能力实现方的模块列表 + """ + return [entry for entry in modules if hasattr(entry, method)] + + catalog = SimpleNamespace(get_running_modules=running_modules) + chain = object.__new__(StorageChain) + chain._module_dispatcher = ModuleInvocationDispatcher(module_catalog=catalog, + plugin_catalog=SimpleNamespace(get_plugin_modules=lambda: plugins), plugin_error_handler=Mock(), + system_error_handler=Mock(), rate_limit_handler=Mock()) + + assert chain.get_file_item_strict("custom", Path("/target")) == expected + + +def test_strict_storage_keeps_legacy_nonempty_plugin_lookup(monkeypatch): + """ + 未实现新回执的旧插件仍可返回真实文件项,空结果则必须保守拒绝删除用途。 + """ + expected = FileItem(storage="custom", path="/target") + lookup = Mock(return_value=expected) + catalog = SimpleNamespace(get_running_modules=lambda _method: []) + chain = object.__new__(StorageChain) + chain._module_dispatcher = ModuleInvocationDispatcher(module_catalog=catalog, + plugin_catalog=SimpleNamespace(get_plugin_modules=lambda: {("legacy", "legacy"): {"get_file_item": lookup}}), + plugin_error_handler=Mock(), system_error_handler=Mock(), rate_limit_handler=Mock()) + assert chain.get_file_item_strict("custom", Path("/target")) == expected + lookup.return_value = None + with pytest.raises(StorageQueryError, match="没有能够确认目标状态"): + chain.get_file_item_strict("custom", Path("/target")) diff --git a/tests/test_transfer_durable_retry_owner.py b/tests/test_transfer_durable_retry_owner.py index c77321fe0..5331f19f9 100644 --- a/tests/test_transfer_durable_retry_owner.py +++ b/tests/test_transfer_durable_retry_owner.py @@ -19,6 +19,7 @@ from app.db.base import Base from app.db.models.transferexecutionstep import TransferExecutionStep from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending +from app.schemas.file import FileItem from app.schemas.types import MediaSource, MediaType, NotificationChannel @@ -264,7 +265,7 @@ def test_durable_manual_cleanup_discards_task_and_removes_old_state(monkeypatch) monkeypatch.setattr( "app.chain.transfer.records.StorageChain", lambda: SimpleNamespace( - exists=lambda _fileitem: True, + get_file_item_strict=lambda **kwargs: FileItem(storage=kwargs["storage"], path=str(kwargs["path"])), delete_media_file=lambda fileitem: deleted.append( ("target", fileitem.path) ) or True, @@ -425,7 +426,7 @@ def test_manual_replan_without_settlement_revision_obeys_current_lease( effects.append(("history", history_id)) monkeypatch.setattr("app.chain.transfer.records.StorageChain", lambda: SimpleNamespace( - exists=lambda _fileitem: True, delete_media_file=remove_target, + get_file_item_strict=lambda **kwargs: FileItem(storage=kwargs["storage"], path=str(kwargs["path"])), delete_media_file=remove_target, )) monkeypatch.setattr("app.chain.transfer.records.clear_transfer_failures", lambda *args: effects.append(args)) chain = object.__new__(TransferChain)