refactor: isolate transfer and download history queries

This commit is contained in:
jxxghp
2026-08-23 13:59:12 +08:00
parent 03c03fa27d
commit 4074fa4e42
10 changed files with 505 additions and 348 deletions
+78
View File
@@ -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
+17 -17
View File
@@ -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())
+91 -47
View File
@@ -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):
"""
查询某时间之后的转移历史
+94 -38
View File
@@ -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):
"""
+92 -50
View File
@@ -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)
)
+3 -2
View File
@@ -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 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从
@@ -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 降到 168Oper 仍不创建 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` 均为 0Oper 自建 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 |
+5 -1
View File
@@ -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
+3 -188
View File
@@ -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",
@@ -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]