mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
refactor: isolate transfer and download history queries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
查询某时间之后的转移历史
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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_*` 方法只修改当前会话。插件数据重置从
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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]
|
||||
Reference in New Issue
Block a user