diff --git a/app/db/oper/user.py b/app/db/oper/user.py index f620af491..c61ecf3d6 100644 --- a/app/db/oper/user.py +++ b/app/db/oper/user.py @@ -4,11 +4,10 @@ 认证依赖(get_current_user 等八个)已迁至 app/api/deps.py——那是 HTTP 层的关注点, 产出 403/400 而非数据。本模块只保留 UserOper。 -这里不为那八个名字留惰性转发。转发曾是给仓外插件备的软着陆,代价是把 -app.db.oper.user -> app.api.deps -> app.application.security 这条边永久焊进依赖图: -数据访问模块从此在静态分析里牵着整个鉴权栈,而仓内没有任何调用方需要它。插件生态 -既已确定迭代,就让旧名字直接以 AttributeError 报错——指向明确、当场可改,好过一条 -悄悄成立的反向依赖。 +这里不为那八个名字留惰性转发,否则会把 +app.db.oper.user -> app.api.deps -> app.application.security 这条边永久焊进依赖图, +让数据访问模块在静态分析里牵着整个鉴权栈。仓外插件的旧 ``app.db.user_oper`` 路径由 +runtime 兼容映射指向 SDK 薄门面;canonical 数据访问模块仍只依赖模型,不承担兼容职责。 """ from typing import List, Optional diff --git a/app/runtime/compat/diagnostics.py b/app/runtime/compat/diagnostics.py index 2bc45da3c..f83d1b809 100644 --- a/app/runtime/compat/diagnostics.py +++ b/app/runtime/compat/diagnostics.py @@ -3,9 +3,15 @@ import inspect import threading from dataclasses import dataclass from pathlib import Path -from typing import Callable, Dict, List, Optional, Set, Tuple +from typing import Callable, Dict, List, Optional, Set, Tuple, Union -from app.runtime.compat.manifest import MODULE_ALIASES, PACKAGE_ALIASES, ModuleAlias +from app.runtime.compat.manifest import ( + MODULE_ALIASES, + PACKAGE_ALIASES, + SYMBOL_ALIASES, + ModuleAlias, + SymbolAlias, +) WarningEmitter = Callable[[str], object] @@ -47,7 +53,23 @@ def _find_import_consumer() -> str: return "unknown" -def _format_warning(usage: LegacyImportUsage, alias: ModuleAlias) -> str: +def _find_alias(legacy_path: str) -> Optional[Union[ModuleAlias, SymbolAlias]]: + """查找旧模块或旧符号对应的精确兼容规则。""" + module_alias = MODULE_ALIASES.get(legacy_path) or PACKAGE_ALIASES.get( + legacy_path + ) + if module_alias: + return module_alias + module_name, separator, symbol_name = legacy_path.rpartition(".") + if not separator: + return None + return SYMBOL_ALIASES.get(module_name, {}).get(symbol_name) + + +def _format_warning( + usage: LegacyImportUsage, + alias: Union[ModuleAlias, SymbolAlias], +) -> str: """生成包含实际目标和推荐 SDK 路径的单行兼容警告。""" consumer = usage.consumer if consumer.startswith("app.plugins."): @@ -56,17 +78,20 @@ def _format_warning(usage: LegacyImportUsage, alias: ModuleAlias) -> str: else: source = f"模块 {consumer}" origin = f"({usage.origin})" if usage.origin else "" + target = ( + alias.target + if isinstance(alias, ModuleAlias) + else f"{alias.target_module}.{alias.target_name}" + ) return ( f"[兼容导入] {source}{origin} 使用旧路径 {usage.legacy_module}," - f"已映射到 {alias.target};请迁移到 {alias.replacement}" + f"已映射到 {target};请迁移到 {alias.replacement}" ) def _emit_usage(usage: LegacyImportUsage) -> None: """按调用方和旧路径去重后输出兼容警告。""" - alias = MODULE_ALIASES.get(usage.legacy_module) or PACKAGE_ALIASES.get( - usage.legacy_module - ) + alias = _find_alias(usage.legacy_module) if not alias: return key = (usage.consumer, usage.legacy_module) @@ -129,7 +154,11 @@ def _extract_legacy_imports(tree: ast.AST) -> Tuple[Tuple[str, int], ...]: matches.add((node.module, node.lineno)) for imported in node.names: candidate = f"{node.module}.{imported.name}" - if candidate in MODULE_ALIASES or candidate in PACKAGE_ALIASES: + if ( + candidate in MODULE_ALIASES + or candidate in PACKAGE_ALIASES + or imported.name in SYMBOL_ALIASES.get(node.module, {}) + ): matches.add((candidate, node.lineno)) elif isinstance(node, ast.Call) and node.args: function_name = "" diff --git a/app/runtime/compat/imports.py b/app/runtime/compat/imports.py index 9905069c1..32f34305d 100644 --- a/app/runtime/compat/imports.py +++ b/app/runtime/compat/imports.py @@ -12,6 +12,7 @@ from app.runtime.compat.manifest import ( MODULE_ALIASES, PACKAGE_ALIASES, PACKAGE_EXPORTS, + SYMBOL_ALIASES, VIRTUAL_PACKAGES, ModuleAlias, ) @@ -115,6 +116,91 @@ class VirtualLegacyPackageLoader(importlib.abc.Loader): record_legacy_import(self.package_name) +class LegacySymbolOverlayLoader(importlib.abc.Loader): + """在标准物理模块执行后叠加旧符号的惰性解析,不修改 canonical 源码。""" + + _STATE_KEY = "__legacy_symbol_overlay_state__" + + def __init__(self, module_name: str, original_loader: importlib.abc.Loader): + """保存物理模块名称和 PathFinder 已选择的原始 Loader。""" + self.module_name = module_name + self.original_loader = original_loader + + def __getattr__(self, name: str): + """把资源读取等非核心 Loader 能力转交给原始 Loader。""" + return getattr(self.original_loader, name) + + def create_module(self, spec): + """沿用原始 Loader 的模块创建逻辑。""" + creator = getattr(self.original_loader, "create_module", None) + return creator(spec) if creator else None + + @classmethod + def _restore_previous_overlay(cls, module: ModuleType) -> None: + """reload 前恢复物理模块原有的动态属性和 __all__ 状态。""" + state = module.__dict__.pop(cls._STATE_KEY, None) + if not state: + return + for name in ("__getattr__", "__dir__"): + previous = state.get(name) + if previous is None: + module.__dict__.pop(name, None) + else: + module.__dict__[name] = previous + if state.get("had_all"): + module.__dict__["__all__"] = state.get("all") + else: + module.__dict__.pop("__all__", None) + + def exec_module(self, module: ModuleType) -> None: + """执行真实模块后安装只对已登记旧符号生效的 __getattr__。""" + self._restore_previous_overlay(module) + executor = getattr(self.original_loader, "exec_module", None) + if not executor: + raise ImportError(f"模块 {self.module_name} 的原始 Loader 不支持 exec_module") + executor(module) + + exports = SYMBOL_ALIASES[self.module_name] + previous_getattr = module.__dict__.get("__getattr__") + previous_dir = module.__dict__.get("__dir__") + had_all = "__all__" in module.__dict__ + previous_all = module.__dict__.get("__all__") + + def resolve_export(name: str): + """惰性解析物理模块中已经迁走的旧符号。""" + symbol = exports.get(name) + if symbol: + record_legacy_import(f"{self.module_name}.{name}") + target = importlib.import_module(symbol.target_module) + return getattr(target, symbol.target_name) + if previous_getattr: + return previous_getattr(name) + raise AttributeError( + f"module {self.module_name!r} has no attribute {name!r}" + ) + + def list_exports(): + """返回物理模块原有名称与兼容符号的并集。""" + names = set(module.__dict__) | set(exports) + if previous_dir: + names.update(previous_dir()) + return sorted(names) + + module.__getattr__ = resolve_export + module.__dir__ = list_exports + public_names = { + name for name in module.__dict__ if not name.startswith("_") + } + declared_exports = set(previous_all or ()) if had_all else public_names + module.__all__ = sorted(declared_exports | set(exports)) + module.__dict__[self._STATE_KEY] = { + "__getattr__": previous_getattr, + "__dir__": previous_dir, + "had_all": had_all, + "all": previous_all, + } + + class BlockedLegacyModuleLoader(importlib.abc.Loader): """阻止合成旧包从其他 Finder 泄漏未登记的新内部模块。""" @@ -163,6 +249,12 @@ class LegacyImportFinder(importlib.abc.MetaPathFinder): is_package=alias.is_package, ) + if fullname in SYMBOL_ALIASES: + spec = importlib.machinery.PathFinder.find_spec(fullname, path, target) + if spec and spec.loader: + spec.loader = LegacySymbolOverlayLoader(fullname, spec.loader) + return spec + virtual_packages = self._virtual_package_names() if fullname in virtual_packages: loader = VirtualLegacyPackageLoader(fullname) diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index 72e2b93d0..b79fe2804 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -30,6 +30,108 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = { introduced="v3.0.0", owner="sdk", ), + "app.db.agentchat_oper": ModuleAlias( + target="app.db.oper.agentchat", + replacement="app.db.oper.agentchat", + introduced="v3.0.0", + owner="db", + ), + "app.db.agenttask_oper": ModuleAlias( + target="app.db.oper.agenttask", + replacement="app.db.oper.agenttask", + introduced="v3.0.0", + owner="db", + ), + "app.db.downloadfailure_oper": ModuleAlias( + target="app.db.oper.downloadfailure", + replacement="app.db.oper.downloadfailure", + introduced="v3.0.0", + owner="db", + ), + "app.db.downloadhistory_oper": ModuleAlias( + target="app.db.oper.downloadhistory", + replacement="app.db.oper.downloadhistory", + introduced="v3.0.0", + owner="db", + ), + "app.db.init": ModuleAlias( + target="app.startup.database_initializer", + replacement="app.startup.database_initializer", + introduced="v3.0.0", + owner="startup", + ), + "app.db.mediaserver_oper": ModuleAlias( + target="app.db.oper.mediaserver", + replacement="app.db.oper.mediaserver", + introduced="v3.0.0", + owner="db", + ), + "app.db.message_oper": ModuleAlias( + target="app.db.oper.message", + replacement="app.db.oper.message", + introduced="v3.0.0", + owner="db", + ), + "app.db.plugindata_oper": ModuleAlias( + target="app.db.oper.plugindata", + replacement="app.db.oper.plugindata", + introduced="v3.0.0", + owner="db", + ), + "app.db.site_oper": ModuleAlias( + target="app.db.oper.site", + replacement="app.db.oper.site", + introduced="v3.0.0", + owner="db", + ), + "app.db.subscribe_oper": ModuleAlias( + target="app.sdk._legacy.subscribe", + replacement="app.application.subscribe.add_subscribe", + introduced="v3.0.0", + owner="sdk", + ), + "app.db.subscribehistory_oper": ModuleAlias( + target="app.db.oper.subscribehistory", + replacement="app.db.oper.subscribehistory", + introduced="v3.0.0", + owner="db", + ), + "app.db.systemconfig_oper": ModuleAlias( + target="app.db.oper.systemconfig", + replacement="app.db.oper.systemconfig", + introduced="v3.0.0", + owner="db", + ), + "app.db.transferhistory_oper": ModuleAlias( + target="app.sdk._legacy.history", + replacement="app.application.history", + introduced="v3.0.0", + owner="sdk", + ), + "app.db.transferpending_oper": ModuleAlias( + target="app.db.oper.transferpending", + replacement="app.db.oper.transferpending", + introduced="v3.0.0", + owner="db", + ), + "app.db.user_oper": ModuleAlias( + target="app.sdk._legacy.user", + replacement="app.db.oper.user 或 app.api.deps", + introduced="v3.0.0", + owner="sdk", + ), + "app.db.userconfig_oper": ModuleAlias( + target="app.db.oper.userconfig", + replacement="app.db.oper.userconfig", + introduced="v3.0.0", + owner="db", + ), + "app.db.workflow_oper": ModuleAlias( + target="app.db.oper.workflow", + replacement="app.db.oper.workflow", + introduced="v3.0.0", + owner="db", + ), "app.utils.crypto": ModuleAlias( target="app.foundation.crypto", replacement="app.sdk.utilities", @@ -151,10 +253,10 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = { owner="runtime", ), "app.utils.media": ModuleAlias( - target="app.domain.media", - replacement="app.domain.media", + target="app.sdk.media", + replacement="app.sdk.media", introduced="v3.0.0", - owner="domain", + owner="sdk", ), "app.utils.mixins": ModuleAlias( target="app.runtime.reload", @@ -562,3 +664,40 @@ PACKAGE_EXPORTS: Dict[str, Dict[str, SymbolAlias]] = { ), }, } + +# 物理模块仍存在、仅部分公开符号迁走时,由导入器在标准 Loader 执行后叠加惰性符号路由。 +# canonical 源码不反向依赖兼容层,目标符号也只在旧调用方真正取用时加载。 +SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { + "app.domain.media": { + name: SymbolAlias( + target_module="app.schemas.media", + target_name=name, + replacement=f"app.schemas.media.{name}", + ) + for name in ( + "MEDIA_SOURCE_ALIASES", + "MEDIA_SOURCE_PREFIXES", + "normalize_media_source", + "parse_media_key", + "resolve_media_identity", + "normalize_media_identity_payload", + "build_media_key", + ) + }, + "app.schemas": { + name: SymbolAlias( + target_module="app.sdk._legacy.transfer", + target_name=name, + replacement=f"app.application.transfer.{name}", + ) + for name in ("TransferTask", "TransferQueue") + }, + "app.schemas.transfer": { + name: SymbolAlias( + target_module="app.sdk._legacy.transfer", + target_name=name, + replacement=f"app.application.transfer.{name}", + ) + for name in ("TransferTask", "TransferQueue") + }, +} diff --git a/app/sdk/_legacy/__init__.py b/app/sdk/_legacy/__init__.py new file mode 100644 index 000000000..aed8fe231 --- /dev/null +++ b/app/sdk/_legacy/__init__.py @@ -0,0 +1 @@ +"""只供旧导入映射使用的插件兼容门面;新插件不得直接依赖本包。""" diff --git a/app/sdk/_legacy/history.py b/app/sdk/_legacy/history.py new file mode 100644 index 000000000..e8c993315 --- /dev/null +++ b/app/sdk/_legacy/history.py @@ -0,0 +1,70 @@ +"""把旧整理历史 Oper 的业务写入方法转交给应用服务。""" + +from typing import Optional + +from app.application.history import add_transfer_fail, add_transfer_success +from app.db.models.transferhistory import TransferHistory +from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper +from app.domain.context import MediaInfo, MusicInfo +from app.domain.meta.metabase import MetaBase +from app.schemas.file import FileItem +from app.schemas.transfer import TransferInfo + + +class TransferHistoryOper(CanonicalTransferHistoryOper): + """继承新的查询接口,并保留旧的成功/失败历史写入方法。""" + + def add_success( + self, + fileitem: FileItem, + mode: str, + meta: MetaBase, + mediainfo: MediaInfo | MusicInfo, + transferinfo: TransferInfo, + downloader: Optional[str] = None, + download_hash: Optional[str] = None, + ) -> Optional[TransferHistory]: + """ + 按旧签名新增整理成功历史。 + + :return: 落库后的整理记录 + """ + return add_transfer_success( + fileitem=fileitem, + mode=mode, + meta=meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + downloader=downloader, + download_hash=download_hash, + transfer_history_oper=self, + ) + + def add_fail( + self, + fileitem: FileItem, + mode: str, + meta: MetaBase, + mediainfo: Optional[MediaInfo | MusicInfo] = None, + transferinfo: Optional[TransferInfo] = None, + downloader: Optional[str] = None, + download_hash: Optional[str] = None, + ) -> Optional[TransferHistory]: + """ + 按旧签名新增整理失败历史。 + + :return: 落库后的整理记录 + """ + return add_transfer_fail( + fileitem=fileitem, + mode=mode, + meta=meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + downloader=downloader, + download_hash=download_hash, + transfer_history_oper=self, + ) + + +__all__ = ["TransferHistoryOper"] diff --git a/app/sdk/_legacy/subscribe.py b/app/sdk/_legacy/subscribe.py new file mode 100644 index 000000000..866f868ba --- /dev/null +++ b/app/sdk/_legacy/subscribe.py @@ -0,0 +1,75 @@ +"""把旧订阅 Oper 写入调用转交给新的应用服务。""" + +from typing import Any, Optional + +from app.application.subscribe import add_subscribe, async_add_subscribe +from app.db.models.subscribe import Subscribe +from app.db.oper.subscribe import SubscribeOper as CanonicalSubscribeOper +from app.domain.context import MediaInfo, MusicInfo + + +class SubscribeOper(CanonicalSubscribeOper): + """保留旧 ``mediainfo`` 写入签名,同时继承新的查询接口。""" + + def add( + self, + mediainfo: Optional[MediaInfo | MusicInfo] = None, + **kwargs: Any, + ): + """ + 兼容旧订阅写入;应用服务回调的新字典签名直接交给 canonical Oper。 + + :param mediainfo: 旧调用传入的媒体识别结果 + :param kwargs: 旧订阅配置,或新 Oper 的 identity/payload/username + :return: 订阅 ID 与结果说明 + """ + if mediainfo is None and "identity" in kwargs and "payload" in kwargs: + identity = kwargs.pop("identity") + payload = kwargs.pop("payload") + username = kwargs.pop("username", None) + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"SubscribeOper.add 收到未知参数:{unexpected}") + return super().add( + identity=identity, + payload=payload, + username=username, + ) + return add_subscribe( + mediainfo=mediainfo, + subscribe_oper=self, + **kwargs, + ) + + async def async_add( + self, + mediainfo: Optional[MediaInfo | MusicInfo] = None, + **kwargs: Any, + ): + """ + 异步兼容旧订阅写入;新字典签名直接交给 canonical Oper。 + + :param mediainfo: 旧调用传入的媒体识别结果 + :param kwargs: 旧订阅配置,或新 Oper 的 identity/payload/username + :return: 订阅 ID 与结果说明 + """ + if mediainfo is None and "identity" in kwargs and "payload" in kwargs: + identity = kwargs.pop("identity") + payload = kwargs.pop("payload") + username = kwargs.pop("username", None) + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"SubscribeOper.async_add 收到未知参数:{unexpected}") + return await super().async_add( + identity=identity, + payload=payload, + username=username, + ) + return await async_add_subscribe( + mediainfo=mediainfo, + subscribe_oper=self, + **kwargs, + ) + + +__all__ = ["Subscribe", "SubscribeOper"] diff --git a/app/sdk/_legacy/transfer.py b/app/sdk/_legacy/transfer.py new file mode 100644 index 000000000..e617209d5 --- /dev/null +++ b/app/sdk/_legacy/transfer.py @@ -0,0 +1,44 @@ +"""保留旧 schemas 整理工作项的宽松构造契约。""" + +from typing import Any, Optional + +from app.application.transfer import ( + TransferQueue as CanonicalTransferQueue, + TransferTask as CanonicalTransferTask, +) + + +def _serialize_legacy_value(value: Any) -> Any: + """同时支持旧 Pydantic 对象与新的领域对象序列化接口。""" + if value is None: + return None + if callable(getattr(value, "to_dict", None)): + return value.to_dict() + if callable(getattr(value, "model_dump", None)): + return value.model_dump() + return value + + +class TransferTask(CanonicalTransferTask): + """兼容旧任务允许插件传入自定义 meta/mediainfo 对象的行为。""" + + meta: Optional[Any] = None + mediainfo: Optional[Any] = None + + def to_dict(self): + """返回兼容领域对象和旧 Pydantic 对象的任务字典。""" + values = vars(self).copy() + values["fileitem"] = _serialize_legacy_value(self.fileitem) + values["meta"] = _serialize_legacy_value(self.meta) + values["mediainfo"] = _serialize_legacy_value(self.mediainfo) + values["target_directory"] = _serialize_legacy_value(self.target_directory) + return values + + +class TransferQueue(CanonicalTransferQueue): + """让旧宽松任务可以继续进入新的整理队列。""" + + task: Optional[TransferTask] = None + + +__all__ = ["TransferQueue", "TransferTask"] diff --git a/app/sdk/_legacy/user.py b/app/sdk/_legacy/user.py new file mode 100644 index 000000000..c8cc11b4f --- /dev/null +++ b/app/sdk/_legacy/user.py @@ -0,0 +1,28 @@ +"""兼容旧 ``app.db.user_oper`` 中混合的数据访问与认证依赖。""" + +from app.api.deps import ( + get_current_active_manage_user, + get_current_active_manage_user_async, + get_current_active_superuser, + get_current_active_superuser_async, + get_current_active_user, + get_current_active_user_async, + get_current_user, + get_current_user_async, +) +from app.db.oper.user import UserOper +from app.db.models.user import User + + +__all__ = [ + "UserOper", + "User", + "get_current_active_manage_user", + "get_current_active_manage_user_async", + "get_current_active_superuser", + "get_current_active_superuser_async", + "get_current_active_user", + "get_current_active_user_async", + "get_current_user", + "get_current_user_async", +] diff --git a/app/sdk/media.py b/app/sdk/media.py index 82c824d81..4036c3880 100644 --- a/app/sdk/media.py +++ b/app/sdk/media.py @@ -1,4 +1,4 @@ -"""插件使用的媒体上下文、标题解析和识别类型。""" +"""插件使用的媒体上下文、标题解析、识别类型和媒体身份规则。""" from app.domain.context import Context, MediaInfo, TorrentInfo from app.domain.meta.metaanime import MetaAnime @@ -17,10 +17,33 @@ from app.domain.meta.words import WordsMatcher from app.domain.metainfo import MetaInfo, MetaInfoPath from app.domain.scraper import NfoReader from app.domain.tokens import Tokens +from app.domain.media import ( + MUSIC_MEDIA_SOURCE_ORDER, + MUSIC_MEDIA_SOURCES, + configure_search_source_provider, + is_media_source_enabled, + is_media_source_selected, + is_music_media_source, + normalize_music_type, + parse_media_source_selection, +) +from app.schemas.media import ( + MEDIA_SOURCE_ALIASES, + MEDIA_SOURCE_PREFIXES, + build_media_key, + normalize_media_identity_payload, + normalize_media_source, + parse_media_key, + resolve_media_identity, +) __all__ = [ "Context", + "MEDIA_SOURCE_ALIASES", + "MEDIA_SOURCE_PREFIXES", + "MUSIC_MEDIA_SOURCE_ORDER", + "MUSIC_MEDIA_SOURCES", "MediaInfo", "MetaAnime", "MetaBase", @@ -38,4 +61,15 @@ __all__ = [ "MusicNameRegistry", "TorrentInfo", "WordsMatcher", + "build_media_key", + "configure_search_source_provider", + "is_media_source_enabled", + "is_media_source_selected", + "is_music_media_source", + "normalize_media_identity_payload", + "normalize_media_source", + "normalize_music_type", + "parse_media_key", + "parse_media_source_selection", + "resolve_media_identity", ] diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 1a4eb7c1e..3b5dfbf31 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -243,6 +243,12 @@ policy. `app/db` therefore has no dependency on `app/domain`. behavior stays in `app/api/endpoints/message.py`. - `app/runtime/compat` stores string mappings and resolves aliases lazily. It may not eagerly import canonical MoviePilot modules. +- 已删除的 `app.db._oper` 路径继续由精确模块映射提供给旧插件;其中订阅写入、 + 整理历史写入和拆分后的用户认证依赖通过 `app.sdk._legacy` 薄门面委托 canonical + Application/Oper,不把领域对象或 HTTP 依赖重新引回 DB 层。 +- 物理模块仍存在但公开符号已经迁走时(例如 `app.domain.media` 的身份原语、 + `app.schemas` 的整理工作项),兼容 Finder 在标准 Loader 执行后叠加白名单符号路由; + canonical 模块不得为兼容而反向 import `app.runtime.compat`。 - Canonical implementation packages may not import `app/runtime/compat` or `app/sdk`. - Host code uses canonical paths. Only `app/plugins/` and compatibility tests diff --git a/tests/test_legacy_db_behavior_compat.py b/tests/test_legacy_db_behavior_compat.py new file mode 100644 index 000000000..d451f3348 --- /dev/null +++ b/tests/test_legacy_db_behavior_compat.py @@ -0,0 +1,166 @@ +import importlib + +import pytest + +from app.application.transfer import TransferTask as CanonicalTransferTask +from app.schemas.file import FileItem + + +def test_legacy_subscribe_add_delegates_to_application_service(monkeypatch): + """旧 SubscribeOper.add 应保留 mediainfo 写入签名。""" + legacy = importlib.import_module("app.db.subscribe_oper") + oper = object.__new__(legacy.SubscribeOper) + mediainfo = object() + captured = {} + + def fake_add_subscribe(*, mediainfo, subscribe_oper, **kwargs): + """记录同步兼容门面转交的参数。""" + captured.update({ + "mediainfo": mediainfo, + "subscribe_oper": subscribe_oper, + "kwargs": kwargs, + }) + return 7, "新增订阅成功" + + monkeypatch.setattr(legacy, "add_subscribe", fake_add_subscribe) + + assert oper.add(mediainfo=mediainfo, season=1) == (7, "新增订阅成功") + assert captured == { + "mediainfo": mediainfo, + "subscribe_oper": oper, + "kwargs": {"season": 1}, + } + + +def test_legacy_subscribe_facade_accepts_application_dictionary_callback( + monkeypatch, +): + """应用服务回调兼容 Oper 时应进入新字典签名,不能再次转回应用服务。""" + legacy = importlib.import_module("app.db.subscribe_oper") + canonical = importlib.import_module("app.db.oper.subscribe") + oper = object.__new__(legacy.SubscribeOper) + captured = {} + + def fake_canonical_add(self, identity, payload, username=None): + """记录兼容类转交给 canonical Oper 的持久化参数。""" + captured.update({ + "self": self, + "identity": identity, + "payload": payload, + "username": username, + }) + return 9, "新增订阅成功" + + monkeypatch.setattr(canonical.SubscribeOper, "add", fake_canonical_add) + + result = oper.add( + identity={"media_source": "themoviedb", "media_id": "1"}, + payload={"name": "Test"}, + username="admin", + ) + + assert result == (9, "新增订阅成功") + assert captured == { + "self": oper, + "identity": {"media_source": "themoviedb", "media_id": "1"}, + "payload": {"name": "Test"}, + "username": "admin", + } + + +@pytest.mark.asyncio +async def test_legacy_subscribe_async_add_delegates_to_application_service( + monkeypatch, +): + """旧 SubscribeOper.async_add 应保留异步 mediainfo 写入签名。""" + legacy = importlib.import_module("app.db.subscribe_oper") + oper = object.__new__(legacy.SubscribeOper) + mediainfo = object() + captured = {} + + async def fake_async_add_subscribe(*, mediainfo, subscribe_oper, **kwargs): + """记录异步兼容门面转交的参数。""" + captured.update({ + "mediainfo": mediainfo, + "subscribe_oper": subscribe_oper, + "kwargs": kwargs, + }) + return 8, "新增订阅成功" + + monkeypatch.setattr(legacy, "async_add_subscribe", fake_async_add_subscribe) + + result = await oper.async_add(mediainfo=mediainfo, season=2) + + assert result == (8, "新增订阅成功") + assert captured == { + "mediainfo": mediainfo, + "subscribe_oper": oper, + "kwargs": {"season": 2}, + } + + +@pytest.mark.parametrize( + ("method_name", "service_name"), + [ + ("add_success", "add_transfer_success"), + ("add_fail", "add_transfer_fail"), + ], +) +def test_legacy_transfer_history_writes_delegate_to_application_service( + monkeypatch, + method_name, + service_name, +): + """旧整理历史写入方法应只做代理,不把业务逻辑搬回 Oper。""" + legacy = importlib.import_module("app.db.transferhistory_oper") + oper = object.__new__(legacy.TransferHistoryOper) + arguments = { + "fileitem": object(), + "mode": "copy", + "meta": object(), + "mediainfo": object(), + "transferinfo": object(), + "downloader": "qb", + "download_hash": "hash", + } + captured = {} + + def fake_service(**kwargs): + """记录整理历史兼容门面转交的参数。""" + captured.update(kwargs) + return "history" + + monkeypatch.setattr(legacy, service_name, fake_service) + + assert getattr(oper, method_name)(**arguments) == "history" + assert captured == {**arguments, "transfer_history_oper": oper} + + +class LegacyPydanticValue: + """模拟旧插件放进 TransferTask 的 Pydantic 风格对象。""" + + def model_dump(self): + """返回测试用序列化结果。""" + return {"kind": "pydantic"} + + +class LegacyDomainValue: + """模拟新领域对象的 to_dict 序列化接口。""" + + def to_dict(self): + """返回测试用序列化结果。""" + return {"kind": "domain"} + + +def test_legacy_transfer_task_keeps_wide_plugin_input_contract(): + """旧 schemas.TransferTask 应接受自定义对象且仍可进入新整理链。""" + schemas_package = importlib.import_module("app.schemas") + task = schemas_package.TransferTask( + fileitem=FileItem(path="/downloads/test.mkv", storage="local"), + meta=LegacyPydanticValue(), + mediainfo=LegacyDomainValue(), + ) + + assert isinstance(task, CanonicalTransferTask) + assert task.to_dict()["meta"] == {"kind": "pydantic"} + assert task.to_dict()["mediainfo"] == {"kind": "domain"} diff --git a/tests/test_legacy_import_compat.py b/tests/test_legacy_import_compat.py index 63eb4aca7..d7e19fbeb 100644 --- a/tests/test_legacy_import_compat.py +++ b/tests/test_legacy_import_compat.py @@ -17,6 +17,7 @@ from app.runtime.compat.manifest import ( MODULE_ALIASES, PACKAGE_ALIASES, PACKAGE_EXPORTS, + SYMBOL_ALIASES, VIRTUAL_PACKAGES, ModuleAlias, ) @@ -203,3 +204,133 @@ def test_virtual_package_exports_resolve_exact_manifest_symbols(): ).MetaBase assert PACKAGE_ALIASES[legacy_package].replacement in messages[0] reset_legacy_import_diagnostics() + + +def test_db_refactor_legacy_modules_are_all_registered(): + """DB 分层迁移删除的旧模块必须全部有精确兼容入口。""" + expected = { + "app.db.agentchat_oper", + "app.db.agenttask_oper", + "app.db.downloadfailure_oper", + "app.db.downloadhistory_oper", + "app.db.init", + "app.db.mediaserver_oper", + "app.db.message_oper", + "app.db.plugindata_oper", + "app.db.site_oper", + "app.db.subscribe_oper", + "app.db.subscribehistory_oper", + "app.db.systemconfig_oper", + "app.db.transferhistory_oper", + "app.db.transferpending_oper", + "app.db.user_oper", + "app.db.userconfig_oper", + "app.db.workflow_oper", + } + + assert expected <= set(MODULE_ALIASES) + + +def test_split_user_oper_facade_exports_data_and_auth_contracts(): + """旧 user_oper 同时提供 UserOper 与八个认证依赖。""" + legacy = importlib.import_module("app.db.user_oper") + canonical_user = importlib.import_module("app.db.oper.user") + canonical_deps = importlib.import_module("app.api.deps") + + assert legacy.UserOper is canonical_user.UserOper + for name in ( + "get_current_user", + "get_current_user_async", + "get_current_active_user", + "get_current_active_user_async", + "get_current_active_manage_user", + "get_current_active_manage_user_async", + "get_current_active_superuser", + "get_current_active_superuser_async", + ): + assert getattr(legacy, name) is getattr(canonical_deps, name) + + +def test_legacy_utils_media_facade_combines_strategy_and_identity_symbols(): + """旧 utils.media 同时保留领域策略和迁至 schemas 的身份原语。""" + legacy = importlib.import_module("app.utils.media") + domain_media = importlib.import_module("app.domain.media") + schema_media = importlib.import_module("app.schemas.media") + + assert legacy.is_music_media_source is domain_media.is_music_media_source + assert legacy.resolve_media_identity is schema_media.resolve_media_identity + assert legacy.build_media_key is schema_media.build_media_key + assert legacy.MEDIA_SOURCE_ALIASES is schema_media.MEDIA_SOURCE_ALIASES + + +def test_physical_modules_resolve_moved_symbols_without_reverse_imports(): + """仍存在的旧物理模块应通过 Loader 叠加迁走的符号。""" + domain_media = importlib.import_module("app.domain.media") + schema_media = importlib.import_module("app.schemas.media") + transfer_schema = importlib.import_module("app.schemas.transfer") + legacy_transfer = importlib.import_module("app.sdk._legacy.transfer") + schemas_package = importlib.import_module("app.schemas") + + assert domain_media.build_media_key is schema_media.build_media_key + assert domain_media.resolve_media_identity is schema_media.resolve_media_identity + assert transfer_schema.TransferTask is legacy_transfer.TransferTask + assert transfer_schema.TransferQueue is legacy_transfer.TransferQueue + assert schemas_package.TransferTask is legacy_transfer.TransferTask + assert schemas_package.TransferQueue is legacy_transfer.TransferQueue + + +def test_debug_diagnostics_reports_moved_symbol_path(): + """DEBUG 模式应对物理模块中的旧符号路径给出一次迁移提示。""" + messages = [] + reset_legacy_import_diagnostics() + configure_legacy_import_diagnostics(enabled=True, emitter=messages.append) + + domain_media = importlib.import_module("app.domain.media") + domain_media.build_media_key + domain_media.build_media_key + + assert len(messages) == 1 + assert "app.domain.media.build_media_key" in messages[0] + assert "app.schemas.media.build_media_key" in messages[0] + reset_legacy_import_diagnostics() + + +def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path): + """插件静态扫描应识别仍存在模块中的旧符号导入。""" + messages = [] + reset_legacy_import_diagnostics() + configure_legacy_import_diagnostics(enabled=True, emitter=messages.append) + plugin_dir = tmp_path / "symbolplugin" + plugin_dir.mkdir() + (plugin_dir / "__init__.py").write_text( + "from app.domain.media import build_media_key\n", + encoding="utf-8", + ) + + scan_plugin_legacy_imports("SymbolPlugin", plugin_dir) + + assert len(messages) == 1 + assert "app.domain.media.build_media_key" in messages[0] + assert "__init__.py:1" in messages[0] + reset_legacy_import_diagnostics() + + +def test_symbol_alias_manifest_covers_all_moved_public_symbols(): + """符号级映射清单应覆盖媒体身份与整理工作项的旧入口。""" + assert set(SYMBOL_ALIASES["app.domain.media"]) == { + "MEDIA_SOURCE_ALIASES", + "MEDIA_SOURCE_PREFIXES", + "normalize_media_source", + "parse_media_key", + "resolve_media_identity", + "normalize_media_identity_payload", + "build_media_key", + } + assert set(SYMBOL_ALIASES["app.schemas"]) == { + "TransferTask", + "TransferQueue", + } + assert set(SYMBOL_ALIASES["app.schemas.transfer"]) == { + "TransferTask", + "TransferQueue", + }