From 4074fa4e421bfb22a3a1dde3d82170dca1f2d29d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 23 Aug 2026 13:59:12 +0800 Subject: [PATCH] refactor: isolate transfer and download history queries --- app/db/decorators.py | 78 +++++++ app/db/models/downloadhistory.py | 34 ++-- app/db/models/transferhistory.py | 138 ++++++++----- app/db/oper/downloadhistory.py | 132 ++++++++---- app/db/oper/transferhistory.py | 142 ++++++++----- docs/architecture-overview.md | 5 +- .../backend-architecture-next-stage.md | 16 +- docs/rules/10-data-and-persistent.md | 6 +- .../transaction-debt-baseline.json | 191 +----------------- ..._transfer_download_history_query_compat.py | 111 ++++++++++ 10 files changed, 505 insertions(+), 348 deletions(-) create mode 100644 tests/test_transfer_download_history_query_compat.py diff --git a/app/db/decorators.py b/app/db/decorators.py index 95c7266a0..168f497e7 100644 --- a/app/db/decorators.py +++ b/app/db/decorators.py @@ -16,6 +16,8 @@ SQLAlchemy 归还连接时已在池层吞掉异常并 invalidate 坏连接,再把释放故障升级成调用方 的异常,只会让一次已经落库的写入看起来像失败,诱发重复提交。 """ +from functools import wraps +from inspect import Parameter, signature from typing import Any, Awaitable, Callable, Optional, TypeVar from sqlalchemy.ext.asyncio import AsyncSession @@ -296,3 +298,79 @@ def async_db_query(func: Callable[..., Awaitable[_R]]) -> Callable[..., Awaitabl return result return wrapper + + +def legacy_db_query(func: Callable[..., _R]) -> Callable[..., _R]: + """保留旧 Model 查询 ABI,同时让新调用方复用显式 Session。 + + 旧插件通常省略 ``db``,直接把业务参数放在第一个位置;通用 ``db_query`` + 装饰器只适用于固定的 ``(db, ...)`` 形状,不能把这类位置参数直接套进去。 + 这里按签名插入会话,避免丢失旧插件传入的第一个业务参数。 + """ + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> _R: + db = _get_args_db(args, kwargs) + if db is not None: + return func(*args, **kwargs) + + session = ScopedSession() + call_args, call_kwargs = _inject_legacy_db(func, args, kwargs, session) + try: + return func(*call_args, **call_kwargs) + finally: + try: + session.close() + except Exception as close_err: # noqa: BLE001 释放故障不得改变旧 ABI 返回值 + logger.error(f"释放数据库会话失败:{close_err}") + + return wrapper + + +def legacy_async_db_query( + func: Callable[..., Awaitable[_R]], +) -> Callable[..., Awaitable[_R]]: + """保留旧 Model 异步查询 ABI,同时让新调用方复用显式 AsyncSession。""" + + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> _R: + db = _get_args_async_db(args, kwargs) + if db is not None: + return await func(*args, **kwargs) + + async with async_session_scope() as session: + call_args, call_kwargs = _inject_legacy_db(func, args, kwargs, session) + return await func(*call_args, **call_kwargs) + + return wrapper + + +def _inject_legacy_db( + func: Callable[..., _R], + args: tuple[Any, ...], + kwargs: dict[str, Any], + db: Any, +) -> tuple[tuple[Any, ...], dict[str, Any]]: + """按旧 Model 方法签名注入兼容会话,不吞掉位置业务参数。""" + call_args = list(args) + call_kwargs = dict(kwargs) + parameters = list(signature(func).parameters.values()) + db_index = next( + (index for index, parameter in enumerate(parameters) if parameter.name == "db"), + None, + ) + if "db" in call_kwargs: + call_kwargs["db"] = db + return tuple(call_args), call_kwargs + if db_index is None: + # 兼容没有显式 db 参数的极旧函数,保持调用失败方式与普通 Python 一致。 + return tuple(call_args), {"db": db, **call_kwargs} + if db_index < len(call_args) and call_args[db_index] is None: + call_args[db_index] = db + elif db_index < len(parameters) and parameters[db_index].kind is Parameter.POSITIONAL_ONLY: + call_args.insert(db_index, db) + elif db_index <= len(call_args): + call_args.insert(db_index, db) + else: + call_kwargs["db"] = db + return tuple(call_args), call_kwargs diff --git a/app/db/models/downloadhistory.py b/app/db/models/downloadhistory.py index da875424e..5e5fdf14f 100644 --- a/app/db/models/downloadhistory.py +++ b/app/db/models/downloadhistory.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column from app.db.base import Base, execute_dml, get_id_column -from app.db.decorators import async_db_query, db_query +from app.db.decorators import legacy_async_db_query, legacy_db_query from app.db.models._constraints import media_identity_constraint from app.schemas.types import MediaSource @@ -77,7 +77,7 @@ class DownloadHistory(Base): ) @classmethod - @db_query + @legacy_db_query def get_by_hash(cls, db: Session, download_hash: str): return db.execute( select(DownloadHistory) @@ -86,7 +86,7 @@ class DownloadHistory(Base): ).scalars().first() @classmethod - @db_query + @legacy_db_query def get_by_hashes(cls, db: Session, download_hashes: List[str]): """ 批量查询多个下载任务的最新历史记录,避免在上层形成 N+1 查询。 @@ -119,7 +119,7 @@ class DownloadHistory(Base): ] @classmethod - @db_query + @legacy_db_query def get_by_media_identity( cls, db: Session, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, @@ -136,7 +136,7 @@ class DownloadHistory(Base): return list(db.execute(statement).scalars().all()) @classmethod - @db_query + @legacy_db_query def list_by_page( cls, db: Session, page: int = 1, count: int = 30 ): @@ -148,7 +148,7 @@ class DownloadHistory(Base): ).scalars().all()) @classmethod - @async_db_query + @legacy_async_db_query async def async_list_by_page( cls, db: AsyncSession, page: int = 1, count: int = 30 ): @@ -161,7 +161,7 @@ class DownloadHistory(Base): return list(result.scalars().all()) @classmethod - @async_db_query + @legacy_async_db_query async def async_list_by_title( cls, db: AsyncSession, @@ -177,13 +177,13 @@ class DownloadHistory(Base): return list(result.scalars().all()) @classmethod - @async_db_query + @legacy_async_db_query async def async_count(cls, db: AsyncSession): result = await db.execute(select(func.count(cls.id))) return result.scalar() @classmethod - @async_db_query + @legacy_async_db_query async def async_count_by_title(cls, db: AsyncSession, title: str): result = await db.execute( select(func.count(cls.id)).filter(_title_like(cls.title, title)) @@ -191,14 +191,14 @@ class DownloadHistory(Base): return result.scalar() @classmethod - @db_query + @legacy_db_query def get_by_path(cls, db: Session, path: str): return db.execute( select(DownloadHistory).where(DownloadHistory.path == path) ).scalars().first() @classmethod - @db_query + @legacy_db_query def get_last_by( cls, db: Session, @@ -237,7 +237,7 @@ class DownloadHistory(Base): @classmethod - @db_query + @legacy_db_query def list_by_user_date(cls, db: Session, date: str, username: Optional[str] = None): """ 查询某用户某时间之前的下载历史。 @@ -256,7 +256,7 @@ class DownloadHistory(Base): ).scalars().all()) @classmethod - @db_query + @legacy_db_query def list_by_date( cls, db: Session, @@ -282,7 +282,7 @@ class DownloadHistory(Base): ).scalars().all()) @classmethod - @db_query + @legacy_db_query def list_by_type(cls, db: Session, mtype: str, days: int): return list(db.execute( select(DownloadHistory).where( @@ -345,7 +345,7 @@ class DownloadFiles(Base): ) @classmethod - @db_query + @legacy_db_query def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None): statement = select(cls).where(cls.download_hash == download_hash) if state is not None: @@ -353,7 +353,7 @@ class DownloadFiles(Base): return list(db.execute(statement).scalars().all()) @classmethod - @db_query + @legacy_db_query def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False): result = db.execute( select(cls).where(cls.fullpath == fullpath).order_by(cls.id.desc()) @@ -361,7 +361,7 @@ class DownloadFiles(Base): return list(result.all()) if all_files else result.first() @classmethod - @db_query + @legacy_db_query def get_by_savepath(cls, db: Session, savepath: str): return list(db.execute(select(cls).where(cls.savepath == savepath)).scalars().all()) diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 271b0b41a..fbcde0361 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -8,7 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column from app.db.base import Base, execute_dml, get_id_column -from app.db.decorators import async_db_query, db_query +from app.db.decorators import ( + legacy_async_db_query, + legacy_db_query, + run_legacy_sync_query, +) from app.db.models._constraints import media_identity_constraint from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType @@ -94,7 +98,7 @@ class TransferHistory(Base): ) @classmethod - @db_query + @legacy_db_query def list_by_title(cls, db: Session, title: str, page: int = 1, count: int = 30, status: Optional[bool] = None, wildcard: bool = False): if wildcard: @@ -121,7 +125,7 @@ class TransferHistory(Base): return list(db.execute(statement).scalars().all()) @classmethod - @async_db_query + @legacy_async_db_query async def async_list_by_title(cls, db: AsyncSession, title: str, page: int = 1, count: int = 30, status: Optional[bool] = None, wildcard: bool = False): if wildcard: @@ -149,7 +153,7 @@ class TransferHistory(Base): return list(result.scalars().all()) @classmethod - @db_query + @legacy_db_query def list_by_page(cls, db: Session, page: int = 1, count: int = 30, status: Optional[bool] = None): statement = select(cls) if status is not None: @@ -163,7 +167,7 @@ class TransferHistory(Base): return list(db.execute(statement).scalars().all()) @classmethod - @async_db_query + @legacy_async_db_query async def async_list_by_page(cls, db: AsyncSession, page: int = 1, count: int = 30, status: Optional[bool] = None): if status is not None: @@ -185,16 +189,29 @@ class TransferHistory(Base): return list(result.scalars().all()) @classmethod - @db_query - def get_by_hash(cls, db: Session, download_hash: str): - return db.execute( - select(cls).where(cls.download_hash == download_hash) - ).scalars().first() + def get_by_hash( + cls, + db: Session | str | None = None, + download_hash: str | None = None, + ): + """按下载哈希查询最新记录,兼容旧插件无会话调用。""" + if download_hash is None and isinstance(db, str): + download_hash, db = db, None + if download_hash is None: + raise TypeError("download_hash is required") + + def query(session: Session): + """在调用方提供的同步会话中执行哈希查询。""" + return session.execute( + select(cls).where(cls.download_hash == download_hash) + ).scalars().first() + + return query(db) if isinstance(db, Session) else run_legacy_sync_query(query) @classmethod - @db_query def get_by_src( - cls, db: Session, src: str, storage: Optional[str] = None + cls, db: Session | str | None = None, src: str | None = None, + storage: Optional[str] = None ) -> Optional["TransferHistory"]: """ 按源路径和存储查询单条整理记录。 @@ -204,17 +221,26 @@ class TransferHistory(Base): :param storage: 源存储类型 :return: 命中的整理记录,未命中时返回 None """ - statement = select(cls).where(cls.src == src) - if storage: - statement = statement.where(cls.src_storage == storage) - return db.execute( - statement.order_by(cls.id.desc()) - ).scalars().first() + if src is None and isinstance(db, str): + src, db = db, None + if src is None: + raise TypeError("src is required") + + def query(session: Session): + """在调用方提供的同步会话中执行源路径查询。""" + statement = select(cls).where(cls.src == src) + if storage: + statement = statement.where(cls.src_storage == storage) + return session.execute( + statement.order_by(cls.id.desc()) + ).scalars().first() + + return query(db) if isinstance(db, Session) else run_legacy_sync_query(query) @classmethod - @db_query def get_success_by_src( - cls, db: Session, src: str, storage: Optional[str] = None + cls, db: Session | str | None = None, src: str | None = None, + storage: Optional[str] = None ) -> Optional["TransferHistory"]: """ 按源路径和存储查询成功的整理记录,源路径原样精确匹配。 @@ -226,17 +252,26 @@ class TransferHistory(Base): :param storage: 源存储类型 :return: 命中的成功整理记录,未命中时返回 None """ - statement = select(cls).where(cls.src == src, cls.status.is_(True)) - if storage: - statement = statement.where(cls.src_storage == storage) - return db.execute( - statement.order_by(cls.id.desc()) - ).scalars().first() + if src is None and isinstance(db, str): + src, db = db, None + if src is None: + raise TypeError("src is required") + + def query(session: Session): + """在调用方提供的同步会话中执行成功源路径查询。""" + statement = select(cls).where(cls.src == src, cls.status.is_(True)) + if storage: + statement = statement.where(cls.src_storage == storage) + return session.execute( + statement.order_by(cls.id.desc()) + ).scalars().first() + + return query(db) if isinstance(db, Session) else run_legacy_sync_query(query) @classmethod - @db_query def get_by_dest( - cls, db: Session, dest: str, storage: Optional[str] = None + cls, db: Session | str | None = None, dest: str | None = None, + storage: Optional[str] = None ) -> Optional["TransferHistory"]: """ 按目标路径和存储查询单条整理记录。 @@ -246,15 +281,24 @@ class TransferHistory(Base): :param storage: 目标存储类型 :return: 命中的整理记录,未命中时返回 None """ - statement = select(cls).where(cls.dest == dest) - if storage: - statement = statement.where(cls.dest_storage == storage) - return db.execute( - statement.order_by(cls.id.desc()) - ).scalars().first() + if dest is None and isinstance(db, str): + dest, db = db, None + if dest is None: + raise TypeError("dest is required") + + def query(session: Session): + """在调用方提供的同步会话中执行目标路径查询。""" + statement = select(cls).where(cls.dest == dest) + if storage: + statement = statement.where(cls.dest_storage == storage) + return session.execute( + statement.order_by(cls.id.desc()) + ).scalars().first() + + return query(db) if isinstance(db, Session) else run_legacy_sync_query(query) @classmethod - @db_query + @legacy_db_query def list_success_by_src( cls, db: Session, @@ -294,7 +338,7 @@ class TransferHistory(Base): return list(db.execute(statement).scalars().all()) @classmethod - @db_query + @legacy_db_query def list_success_move_by_dest( cls, db: Session, @@ -337,14 +381,14 @@ class TransferHistory(Base): return list(db.execute(statement).scalars().all()) @classmethod - @db_query + @legacy_db_query def list_by_hash(cls, db: Session, download_hash: str): return list(db.execute( select(cls).where(cls.download_hash == download_hash) ).scalars().all()) @classmethod - @db_query + @legacy_db_query def statistic(cls, db: Session, days: int = 7): """ 统计最近days天的下载历史数量,按日期分组返回每日数量 @@ -361,7 +405,7 @@ class TransferHistory(Base): ).all()) @classmethod - @db_query + @legacy_db_query def monthly_media_statistics(cls, db: Session): """ 统计当月成功整理的电影、电视剧、剧集和音乐数量。 @@ -427,7 +471,7 @@ class TransferHistory(Base): return 1 @classmethod - @async_db_query + @legacy_async_db_query async def async_statistic(cls, db: AsyncSession, days: int = 7): """ 统计最近days天的下载历史数量,按日期分组返回每日数量 @@ -442,7 +486,7 @@ class TransferHistory(Base): return result.all() @classmethod - @db_query + @legacy_db_query def count(cls, db: Session, status: Optional[bool] = None): statement = select(func.count(cls.id)) if status is not None: @@ -450,7 +494,7 @@ class TransferHistory(Base): return db.execute(statement).scalar() @classmethod - @async_db_query + @legacy_async_db_query async def async_count(cls, db: AsyncSession, status: Optional[bool] = None): if status is not None: result = await db.execute( @@ -463,7 +507,7 @@ class TransferHistory(Base): return result.scalar() @classmethod - @db_query + @legacy_db_query def count_by_title(cls, db: Session, title: str, status: Optional[bool] = None, wildcard: bool = False): if wildcard: text_filter = or_( @@ -483,7 +527,7 @@ class TransferHistory(Base): return db.execute(statement).scalar() @classmethod - @async_db_query + @legacy_async_db_query async def async_count_by_title(cls, db: AsyncSession, title: str, status: Optional[bool] = None, wildcard: bool = False): if wildcard: text_filter = or_( @@ -504,7 +548,7 @@ class TransferHistory(Base): return result.scalar() @classmethod - @db_query + @legacy_db_query def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, @@ -542,7 +586,7 @@ class TransferHistory(Base): return list(db.execute(statement).scalars().all()) @classmethod - @db_query + @legacy_db_query def get_by_media_identity( cls, db: Session, media_source: MediaSource, media_id: str, mtype: Optional[str] = None, @@ -589,7 +633,7 @@ class TransferHistory(Base): return history @classmethod - @db_query + @legacy_db_query def list_by_date(cls, db: Session, date: str): """ 查询某时间之后的转移历史 diff --git a/app/db/oper/downloadhistory.py b/app/db/oper/downloadhistory.py index d9be14c85..08f9902df 100644 --- a/app/db/oper/downloadhistory.py +++ b/app/db/oper/downloadhistory.py @@ -18,20 +18,28 @@ class DownloadHistoryOper(DbOper): 按路径查询下载记录 :param path: 数据key """ - return DownloadHistory.get_by_path(self._db, path) + return self._execute_sync_query( + lambda session: DownloadHistory.get_by_path(session, path) + ) def get_by_hash(self, download_hash: str) -> Optional[DownloadHistory]: """ 按Hash查询下载记录 :param download_hash: 数据key """ - return DownloadHistory.get_by_hash(self._db, download_hash) + return self._execute_sync_query( + lambda session: DownloadHistory.get_by_hash(session, download_hash) + ) def get_by_hashes(self, download_hashes: List[str]) -> Dict[str, DownloadHistory]: """ 批量按 Hash 查询下载记录,并返回以 Hash 为键的映射。 """ - histories = DownloadHistory.get_by_hashes(self._db, download_hashes) + histories = self._execute_sync_query( + lambda session: DownloadHistory.get_by_hashes( + session, download_hashes + ) + ) return { history.download_hash: history for history in histories @@ -48,11 +56,13 @@ class DownloadHistoryOper(DbOper): :param media_id: 数据源原生 ID :param music_type: 音乐实体类型 """ - return DownloadHistory.get_by_media_identity( - self._db, - media_source=media_source, - media_id=media_id, - music_type=music_type, + return self._execute_sync_query( + lambda session: DownloadHistory.get_by_media_identity( + session, + media_source=media_source, + media_id=media_id, + music_type=music_type, + ) ) def add(self, **kwargs): @@ -97,30 +107,48 @@ class DownloadHistoryOper(DbOper): :param download_hash: 数据key :param state: 删除状态 """ - return DownloadFiles.get_by_hash(self._db, download_hash, state) + return self._execute_sync_query( + lambda session: DownloadFiles.get_by_hash( + session, download_hash, state + ) + ) def get_file_by_fullpath(self, fullpath: str) -> Optional[DownloadFiles]: """ 按fullpath查询下载文件记录 :param fullpath: 数据key """ - return cast(Optional[DownloadFiles], - DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)) + return self._execute_sync_query( + lambda session: cast( + Optional[DownloadFiles], + DownloadFiles.get_by_fullpath( + session, fullpath=fullpath, all_files=False + ), + ) + ) def get_files_by_fullpath(self, fullpath: str) -> List[DownloadFiles]: """ 按fullpath查询下载文件记录 :param fullpath: 数据key """ - return cast(List[DownloadFiles], - DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=True)) + return self._execute_sync_query( + lambda session: cast( + List[DownloadFiles], + DownloadFiles.get_by_fullpath( + session, fullpath=fullpath, all_files=True + ), + ) + ) def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]: """ 按savepath查询下载文件记录 :param fullpath: 数据key """ - return DownloadFiles.get_by_savepath(self._db, fullpath) + return self._execute_sync_query( + lambda session: DownloadFiles.get_by_savepath(session, fullpath) + ) def delete_file_by_fullpath(self, fullpath: str): """ @@ -147,8 +175,14 @@ class DownloadHistoryOper(DbOper): 按fullpath查询下载文件记录hash :param fullpath: 数据key """ - fileinfo = cast(Optional[DownloadFiles], - DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)) + fileinfo = self._execute_sync_query( + lambda session: cast( + Optional[DownloadFiles], + DownloadFiles.get_by_fullpath( + session, fullpath=fullpath, all_files=False + ), + ) + ) if fileinfo: return fileinfo.download_hash return "" @@ -157,7 +191,9 @@ class DownloadHistoryOper(DbOper): """ 分页查询下载历史 """ - return DownloadHistory.list_by_page(self._db, page, count) + return self._execute_sync_query( + lambda session: DownloadHistory.list_by_page(session, page, count) + ) async def async_list_by_page( self, @@ -165,7 +201,11 @@ class DownloadHistoryOper(DbOper): count: int = 30, ) -> List[DownloadHistory]: """异步分页查询下载历史。""" - return await DownloadHistory.async_list_by_page(self._db, page, count) + return await self._execute_async_query( + lambda session: DownloadHistory.async_list_by_page( + session, page, count + ) + ) async def async_delete_history(self, historyid: int): """ @@ -187,22 +227,30 @@ class DownloadHistoryOper(DbOper): 按类型、标题、年份、季集查询下载记录 媒体身份 + mtype 或 title + year """ - return DownloadHistory.get_last_by(db=self._db, - mtype=mtype, - title=title, - year=year, - season=season, - episode=episode, - media_source=media_source, - media_id=media_id) + return self._execute_sync_query( + lambda session: DownloadHistory.get_last_by( + db=session, + mtype=mtype, + title=title, + year=year, + season=season, + episode=episode, + media_source=media_source, + media_id=media_id, + ) + ) def list_by_user_date(self, date: str, username: Optional[str] = None) -> List[DownloadHistory]: """ 查询某用户某时间之前的下载历史 """ - return DownloadHistory.list_by_user_date(db=self._db, - date=date, - username=username) + return self._execute_sync_query( + lambda session: DownloadHistory.list_by_user_date( + db=session, + date=date, + username=username, + ) + ) def list_by_date( self, date: str, type: str, media_source: MediaSource, media_id: str, @@ -211,20 +259,28 @@ class DownloadHistoryOper(DbOper): """ 查询某时间之后的下载历史 """ - return DownloadHistory.list_by_date(db=self._db, - date=date, - type=type, - media_source=media_source, - media_id=media_id, - seasons=seasons) + return self._execute_sync_query( + lambda session: DownloadHistory.list_by_date( + db=session, + date=date, + type=type, + media_source=media_source, + media_id=media_id, + seasons=seasons, + ) + ) def list_by_type(self, mtype: str, days: int = 7) -> List[DownloadHistory]: """ 获取指定类型的下载历史 """ - return DownloadHistory.list_by_type(db=self._db, - mtype=mtype, - days=days) + return self._execute_sync_query( + lambda session: DownloadHistory.list_by_type( + db=session, + mtype=mtype, + days=days, + ) + ) def delete_history(self, historyid): """ diff --git a/app/db/oper/transferhistory.py b/app/db/oper/transferhistory.py index e559e9bc0..9edcfcab6 100644 --- a/app/db/oper/transferhistory.py +++ b/app/db/oper/transferhistory.py @@ -19,13 +19,17 @@ class TransferHistoryOper(DbOper): 获取转移历史 :param historyid: 转移历史id """ - return TransferHistory.get(self._db, historyid) + return self._execute_sync_query( + lambda session: TransferHistory.get(session, historyid) + ) async def async_get(self, historyid: int) -> Optional[TransferHistory]: """ 异步获取转移历史。 """ - return await TransferHistory.async_get(self._db, historyid) + return await self._execute_async_query( + lambda session: TransferHistory.async_get(session, historyid) + ) async def async_list_by_title( self, @@ -38,13 +42,15 @@ class TransferHistoryOper(DbOper): """ 异步按标题分页查询转移记录。 """ - return await TransferHistory.async_list_by_title( - self._db, - title=title, - page=page, - count=count, - status=status, - wildcard=wildcard, + return await self._execute_async_query( + lambda session: TransferHistory.async_list_by_title( + session, + title=title, + page=page, + count=count, + status=status, + wildcard=wildcard, + ) ) async def async_list_by_page( @@ -56,15 +62,19 @@ class TransferHistoryOper(DbOper): """ 异步分页查询转移记录。 """ - return await TransferHistory.async_list_by_page( - self._db, page=page, count=count, status=status + return await self._execute_async_query( + lambda session: TransferHistory.async_list_by_page( + session, page=page, count=count, status=status + ) ) async def async_count(self, status: Optional[bool] = None) -> Optional[int]: """ 异步统计转移记录数量。 """ - return await TransferHistory.async_count(self._db, status=status) + return await self._execute_async_query( + lambda session: TransferHistory.async_count(session, status=status) + ) async def async_count_by_title( self, @@ -75,11 +85,13 @@ class TransferHistoryOper(DbOper): """ 异步按标题统计转移记录数量。 """ - return await TransferHistory.async_count_by_title( - self._db, - title=title, - status=status, - wildcard=wildcard, + return await self._execute_async_query( + lambda session: TransferHistory.async_count_by_title( + session, + title=title, + status=status, + wildcard=wildcard, + ) ) def get_by_title(self, title: str) -> List[TransferHistory]: @@ -87,7 +99,9 @@ class TransferHistoryOper(DbOper): 按标题查询转移记录 :param title: 数据key """ - return TransferHistory.list_by_title(self._db, title) + return self._execute_sync_query( + lambda session: TransferHistory.list_by_title(session, title) + ) def get_by_src( self, src: str, storage: Optional[str] = None @@ -98,7 +112,9 @@ class TransferHistoryOper(DbOper): :param storage: 存储类型 :return: 命中的整理记录,未命中时返回 None """ - return TransferHistory.get_by_src(self._db, src, storage) + return self._execute_sync_query( + lambda session: TransferHistory.get_by_src(session, src, storage) + ) def get_success_by_src( self, src: str, storage: Optional[str] = None @@ -109,7 +125,11 @@ class TransferHistoryOper(DbOper): :param storage: 存储类型 :return: 命中的成功整理记录,未命中时返回 None """ - return TransferHistory.get_success_by_src(self._db, src, storage) + return self._execute_sync_query( + lambda session: TransferHistory.get_success_by_src( + session, src, storage + ) + ) def get_by_dest( self, dest: str, storage: Optional[str] = None @@ -119,7 +139,9 @@ class TransferHistoryOper(DbOper): :param dest: 数据key :param storage: 存储类型 """ - return TransferHistory.get_by_dest(self._db, dest, storage) + return self._execute_sync_query( + lambda session: TransferHistory.get_by_dest(session, dest, storage) + ) def list_success_by_src( self, @@ -135,11 +157,13 @@ class TransferHistoryOper(DbOper): :param recursive: 是否递归匹配目录子项 :return: 命中的成功整理记录 """ - return TransferHistory.list_success_by_src( - self._db, - src=src, - storage=storage, - recursive=recursive, + return self._execute_sync_query( + lambda session: TransferHistory.list_success_by_src( + session, + src=src, + storage=storage, + recursive=recursive, + ) ) def list_success_move_by_dest( @@ -156,11 +180,13 @@ class TransferHistoryOper(DbOper): :param recursive: 是否递归匹配目录子项 :return: 命中的成功移动记录 """ - return TransferHistory.list_success_move_by_dest( - self._db, - dest=dest, - storage=storage, - recursive=recursive, + return self._execute_sync_query( + lambda session: TransferHistory.list_success_move_by_dest( + session, + dest=dest, + storage=storage, + recursive=recursive, + ) ) def list_by_hash(self, download_hash: str) -> List[TransferHistory]: @@ -168,7 +194,9 @@ class TransferHistoryOper(DbOper): 按种子hash查询转移记录 :param download_hash: 种子hash """ - return TransferHistory.list_by_hash(self._db, download_hash) + return self._execute_sync_query( + lambda session: TransferHistory.list_by_hash(session, download_hash) + ) def add(self, **kwargs): """ @@ -183,15 +211,21 @@ class TransferHistoryOper(DbOper): """ 统计最近days天的下载历史数量 """ - return TransferHistory.statistic(self._db, days) + return self._execute_sync_query( + lambda session: TransferHistory.statistic(session, days) + ) async def async_statistic(self, days: int = 7) -> List[Any]: """异步统计最近若干天的整理历史数量。""" - return await TransferHistory.async_statistic(self._db, days) + return await self._execute_async_query( + lambda session: TransferHistory.async_statistic(session, days) + ) def monthly_media_statistics(self) -> tuple[int, int, int, int]: """统计本月成功整理的电影、剧集、单集和音乐数量。""" - return TransferHistory.monthly_media_statistics(self._db) + return self._execute_sync_query( + TransferHistory.monthly_media_statistics + ) def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, @@ -200,26 +234,32 @@ class TransferHistoryOper(DbOper): """ 按类型、标题、年份、季集查询转移记录 """ - return TransferHistory.list_by(db=self._db, - mtype=mtype, - title=title, - dest=dest, - year=year, - season=season, - episode=episode, - media_source=media_source, - media_id=media_id) + return self._execute_sync_query( + lambda session: TransferHistory.list_by( + db=session, + mtype=mtype, + title=title, + dest=dest, + year=year, + season=season, + episode=episode, + media_source=media_source, + media_id=media_id, + ) + ) def get_by_media_identity( self, media_source: MediaSource, media_id: str, mtype: Optional[str] = None, ) -> Optional[TransferHistory]: """按规范媒体身份和类型查询整理记录。""" - return TransferHistory.get_by_media_identity( - db=self._db, - media_source=media_source, - media_id=media_id, - mtype=mtype, + return self._execute_sync_query( + lambda session: TransferHistory.get_by_media_identity( + db=session, + media_source=media_source, + media_id=media_id, + mtype=mtype, + ) ) def delete(self, historyid): @@ -312,4 +352,6 @@ class TransferHistoryOper(DbOper): 查询某时间之后的转移历史 :param date: 日期 """ - return TransferHistory.list_by_date(self._db, date) + return self._execute_sync_query( + lambda session: TransferHistory.list_by_date(session, date) + ) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 7e72cb5e2..027c05ea5 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -378,8 +378,9 @@ flowchart LR 成功后执行。订阅新增样板由 `startup/subscription.py` 创建独占 Session, `application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()` 只查重、`add` 和 `flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。 - `transaction-debt-baseline.json` 当前冻结 123 个只读查询装饰器;原有 45 个同步/异步写装饰器 - 已全部移除,`db_update` 与 `async_db_update` 必须持续保持为 0。宿主 Oper 也不得调用 Base 保留的 + `transaction-debt-baseline.json` 当前冻结 38 个正式只读查询装饰器;原有同步/异步写装饰器 + 已全部移除,`db_update` 与 `async_db_update` 必须持续保持为 0。下载/整理历史的旧插件 Model + 调用由 `legacy_*` 兼容外壳承接,宿主 Oper 必须显式传递 Session。宿主 Oper 也不得调用 Base 保留的 `create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。 - 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application Command/Service 持有 UoW,Oper 的 `stage_*` 方法只修改当前会话。插件数据重置从 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 38f6a9c59..fdceed1b0 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -28,7 +28,7 @@ 1. **后台任务的统一所有权已覆盖 API 入口,但仍有更深层任务机制待分级。** `app/runtime/tasks.py` 已建立 lifespan 级 TaskRegistry,启动收尾、插件 Release 刷新、Webhook E0 广播、CookieCloud E1 手工调度、消息入口、Seerr 订阅和 WebAgent 断线后执行/快照保存均不再维护端点模块级任务集合或 Starlette 回调,shutdown 会停止接收、取消并有限等待,且生命周期清单明确登记其顺序。主仓 `app/` 已无裸 FastAPI `BackgroundTasks`;当前仍有约 `50` 个更底层 `create_task`/等价任务创建点,与线程池和 APScheduler 并存,后续需逐项确认 owner、取消、等待、重试、幂等和是否 durable,关键业务副作用优先接入已有 Outbox/恢复表。 2. **动态模块契约仍以 legacy 聚合语义为主。** 当前登记 `212` 个模块方法,其中 `194` 个仍使用 `legacy` aggregation,只有 `14` 个 `first_non_empty`、`4` 个 `ordered_list_merge`。`app/runtime/extensions/module/contracts.py:422-455` 已能登记 family、输入/结果标签和基础签名诊断,但 `193` 个方法没有 required parameters,调度器 `app/runtime/extensions/module/dispatcher.py:109-260` 仍主要依赖运行时反射、返回值形状和短路规则。未知第三方方法保留 legacy fallback 是兼容要求,不应删除;宿主高频能力则应逐族补齐可执行的输入校验、结果校验、超时和错误语义。 -3. **查询侧数据库兼容 ABI 仍未完全收口。** 写事务装饰器已降为 `0`,事务所有权已经明显改善;但 `app/db/models` 仍有 `75` 个 `db_query/async_db_query`(`48` 个同步、`27` 个异步)。这些装饰器会在调用方未传 Session 时隐式创建并关闭会话(见 `app/db/decorators.py:224-298`),查询返回的 ORM 对象仍可能跨层流转,导致事务组合、对象生命周期和懒加载行为需要依赖隐含约定。站点、消息、用户和订阅高频查询已迁到对应 Oper 显式 Session 路径,后续继续按历史等风险切片迁移,不一次性全仓改写。 +3. **查询侧数据库兼容 ABI 仍未完全收口。** 写事务装饰器已降为 `0`,正式 `db_query/async_db_query` 已降至 `38` 个(`20` 个同步、`18` 个异步)。站点、消息、用户、订阅以及下载/整理历史的宿主 Oper 已迁到显式 Session 路径;下载/整理历史的旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。剩余正式装饰器仍会隐式创建会话,查询返回的 ORM 对象也可能跨层流转,后续继续按 Workflow、MediaServer 等风险切片迁移。 4. **组合根和全局状态仍形成复杂的隐式运行时图。** Singleton 实例、模块级 provider、`configure_*` 注册函数和兼容 Facade 同时存在;它们解决了旧 ABI 和启动顺序问题,但增加测试污染、重复装配、实例身份和初始化顺序风险。`app/startup/lifecycle/__init__.py:161-376` 已有声明式生命周期,`app/startup/modules_initializer.py:505-530` 也有分阶段关闭,但尚未做到所有进程级资源都只通过 typed HostRuntime 访问。后续应以“新代码禁止新增 Service Locator/Singleton 依赖、旧入口有命中观测”为 ratchet。 ### P2:中长期可演进性债务 @@ -55,7 +55,7 @@ 1. 未知第三方插件自定义模块方法继续走 `legacy` fallback,不能因宿主契约收口而拒绝加载旧插件。 2. `PluginManager`、`PluginHelper`、`MoviePilotServerHelper` 等 Facade 继续保留旧公开/私有调用面,并通过 `compat.facade.hit` 统计迁移命中。 3. `app/runtime/compat` 的精确旧导入映射、`app.sdk._legacy` 薄门面和插件 V1/V2/V3 三代索引继续存在,直到命中数据和发行策略支持删除。 -4. 查询装饰器保留为只读兼容入口,迁移以高频路径和可观测收益为依据,不以“全仓零装饰器”作为短期目标。 +4. 既有查询 Model 方法保留只读兼容入口;宿主 Oper 必须走显式 Session,`legacy_*` 只服务旧插件 ABI,不得成为新 Model 方法的默认模式。 ### 建议的后续治理顺序 @@ -904,7 +904,7 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas 事务低水位从 174 降到 168,Oper 仍不创建 Session、也不直接 commit/rollback。 - 剩余同步/异步 Model 写装饰器已全部迁移:AgentTask、PassKey、User、消息、历史清理、 站点快照、媒体服务器、插件数据、TransferPending 等写入由调用方 Session 和 UoW 收口;无 Session - 的旧 Oper ABI 委托 Startup 注入的短事务执行器。当前 Model 装饰器仅剩 75 个查询装饰器(同步 48、异步 27), + 的旧 Oper ABI 委托 Startup 注入的短事务执行器。当前 Model 正式查询装饰器仅剩 38 个(同步 20、异步 18), `db_update` 与 `async_db_update` 均为 0,Oper 自建 Session/直接提交仍为 0。 - 数据清理按批次显式提交 UoW,单表失败先回滚会话再继续汇总后续表;不再依赖删除 Model 的隐式提交。 - 收尾批次进一步移除宿主 Oper 对 `Base.create/update/delete/truncate` 八个兼容包装器的调用:显式 @@ -1174,9 +1174,15 @@ Settings 读取作为基础设施边界,架构基线已明确记录该例外 `model_dump` 旧 Settings ABI,并由应用组合根注入服务对象,低层 runtime 不再反向导入 `app.application`; `SkillHelper` 的技能市场写入继续经过兼容代理,旧插件/测试的模块级替换语义保持。`UserConfigOper` 的 无 Session 查询改为一次性兼容查询会话,显式 Session 仍由调用方持有。配置债务稳定为 8 个文件,Model -查询装饰器在消息、用户和订阅查询切片后进一步降至 75 个且写装饰器为 0;四分片全量测试 `5492 passed, 3 skipped`,mypy、复杂度、异步阻塞、 +查询装饰器在消息、用户和订阅查询切片后曾降至 75 个且写装饰器为 0;四分片全量测试 `5492 passed, 3 skipped`,mypy、复杂度、异步阻塞、 host/plugin 架构基线均通过。 +2026-08-23 完成下载/整理历史查询切片:`TransferHistoryOper`、`DownloadHistoryOper` 的正式入口统一 +通过 `_execute_sync_query` / `_execute_async_query` 复用调用方 Session,正式查询装饰器由 75 降至 +38 个且写装饰器保持 0。旧插件仍可直接调用 Model 方法;`legacy_db_query` / `legacy_async_db_query` +按签名插入一次性会话,兼容无 Session 的位置参数和关键字参数,同时显式 Session 不创建额外会话。 +历史查询、删除工具、类型门禁和插件架构专项共 `101 passed`,host/plugin 架构基线通过。 + #### ARCH-272:异步阻塞检测 **目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。 @@ -1354,7 +1360,7 @@ rollback: | 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope | | 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 | | 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 | -| Model 事务装饰器 | 当前 75 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 | +| Model 事务装饰器 | 当前 38 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 | | 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW | | 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 | | Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict | diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md index fae3c5c7c..228736d64 100644 --- a/docs/rules/10-data-and-persistent.md +++ b/docs/rules/10-data-and-persistent.md @@ -84,9 +84,13 @@ Oper classes accept and return persistence values. Turning a `MediaInfo` or ### Transaction ownership ratchet - `tests/fixtures/architecture/transaction-debt-baseline.json` records the - existing Model transaction decorators. The current 75 decorators are query-only + existing Model transaction decorators. The current 38 decorators are query-only migration debt: they may decrease but must never increase or move to a new Model method. Both `db_update` and `async_db_update` must remain at zero. +- `legacy_db_query` / `legacy_async_db_query` are compatibility-only shells for + existing plugin-facing Model methods. Host Oper code must pass an explicit + Session through `_execute_sync_query` / `_execute_async_query`; new Model + methods must not add either legacy decorator. - New Model methods must not use `db_query`, `db_update`, `async_db_query`, or `async_db_update`, create a Session, or call `commit()` / `rollback()`. - Oper receives a caller-owned Session and may query, add, update, delete, or diff --git a/tests/fixtures/architecture/transaction-debt-baseline.json b/tests/fixtures/architecture/transaction-debt-baseline.json index 5f4a624c7..bf8e5d106 100644 --- a/tests/fixtures/architecture/transaction-debt-baseline.json +++ b/tests/fixtures/architecture/transaction-debt-baseline.json @@ -1,12 +1,12 @@ { "model_decorators": { "by_kind": { - "async_db_query": 27, + "async_db_query": 18, "async_db_update": 0, - "db_query": 48, + "db_query": 20, "db_update": 0 }, - "count": 75, + "count": 38, "methods": [ { "decorator": "async_db_query", @@ -38,86 +38,6 @@ "file": "app/db/models/agenttaskrun.py", "method": "AgentTaskRun.list_for_task" }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadFiles.get_by_fullpath" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadFiles.get_by_hash" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadFiles.get_by_savepath" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.async_count" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.async_count_by_title" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.async_list_by_page" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.async_list_by_title" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.get_by_hash" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.get_by_hashes" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.get_by_media_identity" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.get_by_path" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.get_last_by" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.list_by_date" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.list_by_page" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.list_by_type" - }, - { - "decorator": "db_query", - "file": "app/db/models/downloadhistory.py", - "method": "DownloadHistory.list_by_user_date" - }, { "decorator": "async_db_query", "file": "app/db/models/mediaserver.py", @@ -233,111 +153,6 @@ "file": "app/db/models/systemconfig.py", "method": "SystemConfig.get_by_key" }, - { - "decorator": "async_db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.async_count" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.async_count_by_title" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.async_list_by_page" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.async_list_by_title" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.async_statistic" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.count" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.count_by_title" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.get_by_dest" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.get_by_hash" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.get_by_media_identity" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.get_by_src" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.get_success_by_src" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.list_by" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.list_by_date" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.list_by_hash" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.list_by_page" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.list_by_title" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.list_success_by_src" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.list_success_move_by_dest" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.monthly_media_statistics" - }, - { - "decorator": "db_query", - "file": "app/db/models/transferhistory.py", - "method": "TransferHistory.statistic" - }, { "decorator": "db_query", "file": "app/db/models/transferpending.py", diff --git a/tests/test_transfer_download_history_query_compat.py b/tests/test_transfer_download_history_query_compat.py new file mode 100644 index 000000000..a85eda5f2 --- /dev/null +++ b/tests/test_transfer_download_history_query_compat.py @@ -0,0 +1,111 @@ +"""Transfer/Download History 查询兼容层的会话与旧插件 ABI 验证。""" + +import asyncio + +from app.db import decorators +from app.db.models.downloadhistory import DownloadHistory +from app.db.models.transferhistory import TransferHistory +from app.db.oper.downloadhistory import DownloadHistoryOper +from app.db.oper.transferhistory import TransferHistoryOper +from app.db.session import SessionFactory, async_session_scope + + +def test_oper_reuses_explicit_sync_session(db, monkeypatch): + """显式同步会话绑定到 Oper 后,查询不能再创建兼容会话。""" + row = db.add(TransferHistory(src="/compat/transfer.mkv", src_storage="local")) + monkeypatch.setattr( + decorators, + "ScopedSession", + lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), + ) + + assert TransferHistoryOper(db.session).get_by_src("/compat/transfer.mkv").id == row.id + assert DownloadHistoryOper(db.session).get_by_hash("missing") is None + + +def test_model_legacy_sync_calls_preserve_business_arguments(db, monkeypatch): + """旧插件省略 db 时,第一个位置参数仍须作为业务参数传入。""" + row = db.add(TransferHistory(src="/compat/legacy.mkv", src_storage="local")) + created = [] + monkeypatch.setattr( + decorators, + "ScopedSession", + lambda: (created.append(True) or SessionFactory()), + ) + + assert TransferHistory.get_by_src("/compat/legacy.mkv").id == row.id + assert created == [True] + + +def test_download_model_legacy_sync_call_preserves_keyword_arguments(db, monkeypatch): + """旧插件使用关键字查询时,兼容层仍须自动补入 db。""" + row = db.add( + DownloadHistory( + path="/compat/download", + type="电视剧", + download_hash="compat-hash", + title="兼容", + ) + ) + created = [] + monkeypatch.setattr( + decorators, + "ScopedSession", + lambda: (created.append(True) or SessionFactory()), + ) + + assert DownloadHistory.get_by_hash(download_hash="compat-hash").id == row.id + assert created == [True] + + +def test_oper_reuses_explicit_async_session(db, monkeypatch): + """显式异步会话绑定到 Oper 后,异步查询不能再创建兼容作用域。""" + db.add( + DownloadHistory( + path="/compat/async-download", + type="电视剧", + title="异步兼容", + download_hash="async-compat", + ) + ) + + async def check() -> None: + async with async_session_scope() as session: + monkeypatch.setattr( + decorators, + "async_session_scope", + lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), + ) + result = await DownloadHistoryOper(session).async_list_by_page(count=10) + assert any(item.download_hash == "async-compat" for item in result) + + asyncio.run(check()) + + +def test_model_legacy_async_calls_support_explicit_and_implicit_sessions(db, monkeypatch): + """异步 Model 查询同时保留显式会话调用与旧插件无会话调用。""" + db.add( + DownloadHistory( + path="/compat/async-legacy", + type="电视剧", + title="异步旧 ABI", + download_hash="async-legacy", + ) + ) + original_scope = decorators.async_session_scope + created = [] + + def tracked_scope(): + """记录兼容层是否创建了异步会话作用域。""" + created.append(True) + return original_scope() + + async def check() -> None: + async with original_scope() as session: + assert await DownloadHistory.async_count(session) >= 1 + monkeypatch.setattr(decorators, "async_session_scope", tracked_scope) + result = await DownloadHistory.async_list_by_title(title="异步旧 ABI") + assert result[0].download_hash == "async-legacy" + + asyncio.run(check()) + assert created == [True]