fix(compat): preserve legacy plugin imports

This commit is contained in:
jxxghp
2026-08-15 07:15:03 +08:00
parent 8a11214a43
commit a2117bafc8
13 changed files with 831 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""只供旧导入映射使用的插件兼容门面;新插件不得直接依赖本包。"""

View File

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

View File

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

View File

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

28
app/sdk/_legacy/user.py Normal file
View File

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

View File

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

View File

@@ -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.<entity>_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

View File

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

View File

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