refactor: make model sessions explicit

This commit is contained in:
jxxghp
2026-08-23 23:33:07 +08:00
parent 820582ab12
commit 6e69258e3c
65 changed files with 1299 additions and 2010 deletions
+24 -42
View File
@@ -1,7 +1,7 @@
""" """
ORM 基类与数据访问基类。 ORM 基类与数据访问基类。
Base 提供声明式基类与兼容行为(字典转换、旧增删改查便利方法) Base 提供声明式基类与显式会话增删改查原语
DbOper 是各业务 Oper 的基类,持有一个可注入的会话。 DbOper 是各业务 Oper 的基类,持有一个可注入的会话。
""" """
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
@@ -12,12 +12,6 @@ from sqlalchemy import (CursorResult, Executable, Identity, Integer, Sequence,
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, declared_attr, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, Session, declared_attr, mapped_column
from app.db.decorators import (
legacy_async_db_query,
legacy_async_db_update,
legacy_db_query,
legacy_db_update,
)
from app.db.uow import run_async_transaction, run_sync_transaction from app.db.uow import run_async_transaction, run_sync_transaction
from app.runtime.config import settings from app.runtime.config import settings
@@ -70,98 +64,87 @@ class Base(DeclarativeBase): # type: ignore[misc] # SQLAlchemy 无 py.typed
继承本类的模型一律使用 mapped_column() + Mapped[] 注解;确需非映射的类级属性时 继承本类的模型一律使用 mapped_column() + Mapped[] 注解;确需非映射的类级属性时
用 ClassVar 显式声明,而不是把这个标志加回来。 用 ClassVar 显式声明,而不是把这个标志加回来。
create/get/update/delete/list/truncate 及其异步版本仅保留旧插件 ABI。宿主新代码应由 create/get/update/delete/list/truncate 及其异步版本都是显式会话原语:只在调用方
Application Command 定义事务边界,经显式 Session 调用 Oper,不得新增对这些方法的依赖。 Session 中暂存或查询,不自行创建、提交、回滚或关闭事务。宿主业务代码应通过 Oper
或 Application Command 使用这些能力,插件不得直接依赖宿主模型。
""" """
# 由 get_id_column() 在各模型中提供实际的列定义,这里只声明类型供 IDE 使用 # 由 get_id_column() 在各模型中提供实际的列定义,这里只声明类型供 IDE 使用
id: Mapped[int] id: Mapped[int]
@legacy_db_update
def create(self, db: Session) -> None: def create(self, db: Session) -> None:
"""兼容旧插件调用:新增当前模型并提交""" """在调用方同步事务中暂存当前模型。"""
db.add(self) db.add(self)
@legacy_async_db_update
async def async_create(self, db: AsyncSession) -> Self: async def async_create(self, db: AsyncSession) -> Self:
"""兼容旧插件调用异步新增当前模型刷新主键并提交""" """调用异步事务中暂存当前模型刷新主键。"""
db.add(self) db.add(self)
await db.flush() await db.flush()
return self return self
@classmethod @classmethod
@legacy_db_query
def get(cls, db: Session, rid: int) -> Optional[Self]: def get(cls, db: Session, rid: int) -> Optional[Self]:
"""兼容旧插件调用:按主键查询当前模型。""" """在调用方同步会话中按主键查询当前模型。"""
return cast( return cast(
Optional[Self], Optional[Self],
db.execute(select(cls).where(and_(cls.id == rid))).scalars().first(), db.execute(select(cls).where(and_(cls.id == rid))).scalars().first(),
) )
@classmethod @classmethod
@legacy_async_db_query
async def async_get(cls, db: AsyncSession, rid: int) -> Optional[Self]: async def async_get(cls, db: AsyncSession, rid: int) -> Optional[Self]:
"""兼容旧插件调用异步按主键查询当前模型。""" """调用异步会话中按主键查询当前模型。"""
result = await db.execute(select(cls).where(and_(cls.id == rid))) result = await db.execute(select(cls).where(and_(cls.id == rid)))
return cast(Optional[Self], result.scalars().first()) return cast(Optional[Self], result.scalars().first())
@legacy_db_update
def update(self, db: Session, payload: dict[str, Any]) -> None: def update(self, db: Session, payload: dict[str, Any]) -> None:
"""兼容旧插件调用:更新当前模型字段并提交""" """在调用方同步事务中更新当前模型字段。"""
for key, value in payload.items(): for key, value in payload.items():
setattr(self, key, value) setattr(self, key, value)
if inspect(self).detached: if inspect(self).detached:
db.add(self) db.add(self)
@legacy_async_db_update
async def async_update( async def async_update(
self, self,
db: AsyncSession, db: AsyncSession,
payload: dict[str, Any], payload: dict[str, Any],
) -> None: ) -> None:
"""兼容旧插件调用异步更新当前模型字段并提交""" """调用异步事务中更新当前模型字段。"""
for key, value in payload.items(): for key, value in payload.items():
setattr(self, key, value) setattr(self, key, value)
if inspect(self).detached: if inspect(self).detached:
db.add(self) db.add(self)
@classmethod @classmethod
@legacy_db_update
def delete(cls, db: Session, rid: Any) -> None: def delete(cls, db: Session, rid: Any) -> None:
"""兼容旧插件调用:按主键删除当前模型并提交""" """在调用方同步事务中按主键删除当前模型。"""
db.execute(delete(cls).where(and_(cls.id == rid))) db.execute(delete(cls).where(and_(cls.id == rid)))
@classmethod @classmethod
@legacy_async_db_update
async def async_delete(cls, db: AsyncSession, rid: Any) -> None: async def async_delete(cls, db: AsyncSession, rid: Any) -> None:
"""兼容旧插件调用异步按主键删除当前模型并提交""" """调用异步事务中按主键删除当前模型。"""
result = await db.execute(select(cls).where(and_(cls.id == rid))) result = await db.execute(select(cls).where(and_(cls.id == rid)))
user = result.scalars().first() user = result.scalars().first()
if user: if user:
await db.delete(user) await db.delete(user)
@classmethod @classmethod
@legacy_db_update
def truncate(cls, db: Session) -> None: def truncate(cls, db: Session) -> None:
"""兼容旧插件调用:清空当前模型表并提交""" """在调用方同步事务中清空当前模型表。"""
db.execute(delete(cls)) db.execute(delete(cls))
@classmethod @classmethod
@legacy_async_db_update
async def async_truncate(cls, db: AsyncSession) -> None: async def async_truncate(cls, db: AsyncSession) -> None:
"""兼容旧插件调用异步清空当前模型表并提交""" """调用异步事务中清空当前模型表。"""
await db.execute(delete(cls)) await db.execute(delete(cls))
@classmethod @classmethod
@legacy_db_query
def list(cls, db: Session) -> List[Self]: def list(cls, db: Session) -> List[Self]:
"""兼容旧插件调用:查询当前模型的全部记录。""" """在调用方同步会话中查询当前模型的全部记录。"""
return list(db.execute(select(cls)).scalars().all()) return list(db.execute(select(cls)).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list(cls, db: AsyncSession) -> List[Self]: async def async_list(cls, db: AsyncSession) -> List[Self]:
"""兼容旧插件调用异步查询当前模型的全部记录。""" """调用异步会话中查询当前模型的全部记录。"""
result = await db.execute(select(cls)) result = await db.execute(select(cls))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -183,19 +166,19 @@ class DbOper:
""" """
def __init__(self, db: Optional[Union[Session, AsyncSession]] = None): def __init__(self, db: Optional[Union[Session, AsyncSession]] = None):
"""保存调用方会话;无会话写入由组合根兼容事务执行器承接。""" """保存调用方会话;无会话调用由组合根事务执行器承接。"""
self._db = db self._db = db
def _execute_sync_write(self, operation: Callable[[Session], T]) -> T: def _execute_sync_write(self, operation: Callable[[Session], T]) -> T:
"""在当前同步会话暂存,或委托组合根创建兼容事务。""" """在当前同步会话暂存,或委托组合根创建事务。"""
if self._db is None or isinstance(self._db, AsyncSession): if self._db is None or isinstance(self._db, AsyncSession):
# 旧调用可能在同一 Oper 上混用同步/异步方法;跨会话类型时使用匹配的 # 旧调用可能在同一 Oper 上混用同步/异步方法;跨会话类型时使用匹配的
# 兼容事务,不能把 AsyncSession 交给同步 SQLAlchemy API。 # 独立事务,不能把 AsyncSession 交给同步 SQLAlchemy API。
return run_sync_transaction(operation) return run_sync_transaction(operation)
return operation(self._db) return operation(self._db)
def _execute_sync_query(self, operation: Callable[[Session], T]) -> T: def _execute_sync_query(self, operation: Callable[[Session], T]) -> T:
"""在当前同步会话查询,或委托组合根创建一次性兼容会话。""" """在当前同步会话查询,或委托组合根创建一次性会话。"""
if self._db is None or isinstance(self._db, AsyncSession): if self._db is None or isinstance(self._db, AsyncSession):
return run_sync_transaction(operation) return run_sync_transaction(operation)
return operation(self._db) return operation(self._db)
@@ -204,10 +187,9 @@ class DbOper:
self, self,
operation: Callable[[AsyncSession], Awaitable[T]], operation: Callable[[AsyncSession], Awaitable[T]],
) -> T: ) -> T:
"""在当前异步会话暂存,或委托组合根创建兼容事务。""" """在当前异步会话暂存,或委托组合根创建事务。"""
if self._db is None or isinstance(self._db, Session): if self._db is None or isinstance(self._db, Session):
# 与查询装饰器的历史行为一致:同步会话不会被错误传入异步模型写入, # 同步会话不会被错误传入异步模型写入,而是由组合根另开匹配的异步事务。
# 而是由组合根另开匹配的异步事务。
return await run_async_transaction(operation) return await run_async_transaction(operation)
return await operation(self._db) return await operation(self._db)
@@ -215,13 +197,13 @@ class DbOper:
self, self,
operation: Callable[[AsyncSession], Awaitable[T]], operation: Callable[[AsyncSession], Awaitable[T]],
) -> T: ) -> T:
"""在当前异步会话查询,或委托组合根创建一次性兼容会话。""" """在当前异步会话查询,或委托组合根创建一次性会话。"""
if self._db is None or isinstance(self._db, Session): if self._db is None or isinstance(self._db, Session):
return await run_async_transaction(operation) return await run_async_transaction(operation)
return await operation(self._db) return await operation(self._db)
def _stage_create(self, model: TModel) -> TModel: def _stage_create(self, model: TModel) -> TModel:
"""显式同步事务中暂存新模型,不触发 Base 的兼容提交装饰器""" """调用方或组合根持有的同步事务中暂存新模型"""
def stage(session: Session) -> TModel: def stage(session: Session) -> TModel:
"""把模型加入当前同步会话。""" """把模型加入当前同步会话。"""
session.add(model) session.add(model)
+2 -151
View File
@@ -5,8 +5,8 @@
未显式传入会话时自动创建,并在结束时归还——异步路径经 async_session_scope 收口, 未显式传入会话时自动创建,并在结束时归还——异步路径经 async_session_scope 收口,
连接池与配额都在那里生效。 连接池与配额都在那里生效。
收尾故障(rollback / close / __aexit__ 自身抛异常)一律只记日志、不上抛,正式装饰器 收尾故障(rollback / close / __aexit__ 自身抛异常)一律只记日志、不上抛。理由与代价
和 legacy 兼容壳的处理一致。理由与代价都要写明,别当成漏写的 raise 都要写明,别当成漏写的 raise
- 连接断开、事务已失效这类故障恰恰最容易发生在「出错之后」的收尾阶段。裸写收尾语句时 - 连接断开、事务已失效这类故障恰恰最容易发生在「出错之后」的收尾阶段。裸写收尾语句时
它一抛错就顶替掉原始异常,调用方看到的只剩「connection reset」,业务异常连类型都被 它一抛错就顶替掉原始异常,调用方看到的只剩「connection reset」,业务异常连类型都被
@@ -16,8 +16,6 @@
SQLAlchemy 归还连接时已在池层吞掉异常并 invalidate 坏连接,再把释放故障升级成调用方 SQLAlchemy 归还连接时已在池层吞掉异常并 invalidate 坏连接,再把释放故障升级成调用方
的异常,只会让一次已经落库的写入看起来像失败,诱发重复提交。 的异常,只会让一次已经落库的写入看起来像失败,诱发重复提交。
""" """
from functools import wraps
from inspect import Parameter, signature
from typing import Any, Awaitable, Callable, Optional, TypeVar from typing import Any, Awaitable, Callable, Optional, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -279,150 +277,3 @@ def async_db_query(func: Callable[..., Awaitable[_R]]) -> Callable[..., Awaitabl
return result return result
return wrapper 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 legacy_db_update(func: Callable[..., _R]) -> Callable[..., _R]:
"""保留旧 Model 同步写 ABI,并维持历史自动提交语义。
该装饰器只供已经公开的 Model/Base 方法兼容仓外插件。宿主新写路径必须
通过 Application Command、显式 Session 和 UnitOfWork 完成事务收口。
"""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> _R:
db = _get_args_db(args, kwargs)
owns_session = db is None
if db is None:
db = ScopedSession()
args, kwargs = _inject_legacy_db(func, args, kwargs, db)
try:
result = func(*args, **kwargs)
db.commit()
return result
except Exception:
try:
db.rollback()
except Exception as rollback_err: # noqa: BLE001 回滚失败不能掩盖原始异常
logger.error(f"事务回滚失败,原始异常将原样上抛:{rollback_err}")
raise
finally:
if owns_session:
try:
db.close()
except Exception as close_err: # noqa: BLE001 释放故障不得改变旧 ABI 结果
logger.error(f"释放数据库会话失败:{close_err}")
return wrapper
def legacy_async_db_update(
func: Callable[..., Awaitable[_R]],
) -> Callable[..., Awaitable[_R]]:
"""保留旧 Model 异步写 ABI,并维持历史自动提交语义。
该装饰器只承接既有兼容面;新宿主代码不得用它创建隐式事务。
"""
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> _R:
db = _get_args_async_db(args, kwargs)
owns_session = db is None
scope = None
if db is None:
scope = async_session_scope()
db = await scope.__aenter__()
args, kwargs = _inject_legacy_db(func, args, kwargs, db)
try:
result = await func(*args, **kwargs)
await db.commit()
return result
except Exception:
try:
await db.rollback()
except Exception as rollback_err: # noqa: BLE001 回滚失败不能掩盖原始异常
logger.error(f"事务回滚失败,原始异常将原样上抛:{rollback_err}")
raise
finally:
if owns_session and scope is not None:
try:
await scope.__aexit__(None, None, None)
except Exception as close_err: # noqa: BLE001 释放故障不得改变旧 ABI 结果
logger.error(f"释放数据库会话失败:{close_err}")
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
-5
View File
@@ -5,7 +5,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
class AgentChat(Base): class AgentChat(Base):
@@ -50,7 +49,6 @@ class AgentChat(Base):
) )
@classmethod @classmethod
@legacy_db_query
def get_by_session( def get_by_session(
cls, db: Session, session_id: str, user_id: Optional[str] = None cls, db: Session, session_id: str, user_id: Optional[str] = None
) -> Optional["AgentChat"]: ) -> Optional["AgentChat"]:
@@ -63,7 +61,6 @@ class AgentChat(Base):
return db.execute(statement.order_by(cls.id.desc())).scalars().first() return db.execute(statement.order_by(cls.id.desc())).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_session( async def async_get_by_session(
cls, db: AsyncSession, session_id: str, user_id: Optional[str] = None cls, db: AsyncSession, session_id: str, user_id: Optional[str] = None
) -> Optional["AgentChat"]: ) -> Optional["AgentChat"]:
@@ -77,7 +74,6 @@ class AgentChat(Base):
return result.scalars().first() return result.scalars().first()
@classmethod @classmethod
@legacy_db_query
def list_by_page( def list_by_page(
cls, cls,
db: Session, db: Session,
@@ -103,7 +99,6 @@ class AgentChat(Base):
).scalars().all()) ).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_page( async def async_list_by_page(
cls, cls,
db: AsyncSession, db: AsyncSession,
+11 -31
View File
@@ -4,7 +4,6 @@ from sqlalchemy import Boolean, Index, Integer, String, Text, select, update
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import legacy_db_query
def _get_for_user_statement( def _get_for_user_statement(
@@ -85,47 +84,28 @@ class AgentTask(Base):
return task.id return task.id
@classmethod @classmethod
@legacy_db_query
def get_for_user( def get_for_user(
cls, cls,
db: Session | int | None = None, db: Session,
task_id: int | None = None, task_id: int,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> Optional["AgentTask"]: ) -> Optional["AgentTask"]:
""" """在调用方会话中按任务 ID 和可选用户 ID 查询。"""
按任务 ID 和可选用户 ID 查询,并保留无 Session 的旧插件调用方式。 return db.execute(
""" _get_for_user_statement(cls, task_id=task_id, user_id=user_id)
if task_id is None and isinstance(db, int): ).scalars().first()
task_id, db = db, None
if task_id is None:
raise TypeError("task_id is required")
def query(session: Session) -> Optional["AgentTask"]:
"""在给定会话中读取单个 Agent 任务。"""
return session.execute(
_get_for_user_statement(cls, task_id=task_id, user_id=user_id)
).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_db_query
def list_for_user( def list_for_user(
cls, cls,
db: Session | None = None, db: Session,
user_id: Optional[str] = None, user_id: Optional[str] = None,
enabled: Optional[bool] = None, enabled: Optional[bool] = None,
) -> list["AgentTask"]: ) -> list["AgentTask"]:
""" """在调用方会话中按用户和启用状态查询。"""
按用户和启用状态查询,并保留无 Session 的旧插件调用方式。 return list(db.execute(
""" _list_for_user_statement(cls, user_id=user_id, enabled=enabled)
def query(session: Session) -> list["AgentTask"]: ).scalars().all())
"""在给定会话中读取 Agent 任务列表。"""
return list(session.execute(
_list_for_user_statement(cls, user_id=user_id, enabled=enabled)
).scalars().all())
return query(db)
@classmethod @classmethod
def update_task( def update_task(
-3
View File
@@ -4,7 +4,6 @@ from sqlalchemy import Index, Integer, String, Text, delete, select, update
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import legacy_db_query
from app.db.models.agenttask import AgentTask from app.db.models.agenttask import AgentTask
@@ -249,7 +248,6 @@ class AgentTaskRun(Base):
return True return True
@classmethod @classmethod
@legacy_db_query
def get_by_run_id( def get_by_run_id(
cls, cls,
db: Session, db: Session,
@@ -261,7 +259,6 @@ class AgentTaskRun(Base):
).scalars().first() ).scalars().first()
@classmethod @classmethod
@legacy_db_query
def list_for_task( def list_for_task(
cls, cls,
db: Session, db: Session,
-17
View File
@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
from app.db.models._constraints import media_identity_constraint from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MediaSource from app.schemas.types import MediaSource
@@ -77,7 +76,6 @@ class DownloadHistory(Base):
) )
@classmethod @classmethod
@legacy_db_query
def get_by_hash(cls, db: Session, download_hash: str): def get_by_hash(cls, db: Session, download_hash: str):
return db.execute( return db.execute(
select(DownloadHistory) select(DownloadHistory)
@@ -86,7 +84,6 @@ class DownloadHistory(Base):
).scalars().first() ).scalars().first()
@classmethod @classmethod
@legacy_db_query
def get_by_hashes(cls, db: Session, download_hashes: List[str]): def get_by_hashes(cls, db: Session, download_hashes: List[str]):
""" """
批量查询多个下载任务的最新历史记录,避免在上层形成 N+1 查询。 批量查询多个下载任务的最新历史记录,避免在上层形成 N+1 查询。
@@ -119,7 +116,6 @@ class DownloadHistory(Base):
] ]
@classmethod @classmethod
@legacy_db_query
def get_by_media_identity( def get_by_media_identity(
cls, db: Session, media_source: MediaSource, media_id: str, cls, db: Session, media_source: MediaSource, media_id: str,
music_type: Optional[str] = None, music_type: Optional[str] = None,
@@ -136,7 +132,6 @@ class DownloadHistory(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_db_query
def list_by_page( def list_by_page(
cls, db: Session, page: int = 1, count: int = 30 cls, db: Session, page: int = 1, count: int = 30
): ):
@@ -148,7 +143,6 @@ class DownloadHistory(Base):
).scalars().all()) ).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_page( async def async_list_by_page(
cls, db: AsyncSession, page: int = 1, count: int = 30 cls, db: AsyncSession, page: int = 1, count: int = 30
): ):
@@ -161,7 +155,6 @@ class DownloadHistory(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_title( async def async_list_by_title(
cls, cls,
db: AsyncSession, db: AsyncSession,
@@ -177,13 +170,11 @@ class DownloadHistory(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_count(cls, db: AsyncSession): async def async_count(cls, db: AsyncSession):
result = await db.execute(select(func.count(cls.id))) result = await db.execute(select(func.count(cls.id)))
return result.scalar() return result.scalar()
@classmethod @classmethod
@legacy_async_db_query
async def async_count_by_title(cls, db: AsyncSession, title: str): async def async_count_by_title(cls, db: AsyncSession, title: str):
result = await db.execute( result = await db.execute(
select(func.count(cls.id)).filter(_title_like(cls.title, title)) select(func.count(cls.id)).filter(_title_like(cls.title, title))
@@ -191,14 +182,12 @@ class DownloadHistory(Base):
return result.scalar() return result.scalar()
@classmethod @classmethod
@legacy_db_query
def get_by_path(cls, db: Session, path: str): def get_by_path(cls, db: Session, path: str):
return db.execute( return db.execute(
select(DownloadHistory).where(DownloadHistory.path == path) select(DownloadHistory).where(DownloadHistory.path == path)
).scalars().first() ).scalars().first()
@classmethod @classmethod
@legacy_db_query
def get_last_by( def get_last_by(
cls, cls,
db: Session, db: Session,
@@ -237,7 +226,6 @@ class DownloadHistory(Base):
@classmethod @classmethod
@legacy_db_query
def list_by_user_date(cls, db: Session, date: str, username: Optional[str] = None): def list_by_user_date(cls, db: Session, date: str, username: Optional[str] = None):
""" """
查询某用户某时间之前的下载历史。 查询某用户某时间之前的下载历史。
@@ -256,7 +244,6 @@ class DownloadHistory(Base):
).scalars().all()) ).scalars().all())
@classmethod @classmethod
@legacy_db_query
def list_by_date( def list_by_date(
cls, cls,
db: Session, db: Session,
@@ -282,7 +269,6 @@ class DownloadHistory(Base):
).scalars().all()) ).scalars().all())
@classmethod @classmethod
@legacy_db_query
def list_by_type(cls, db: Session, mtype: str, days: int): def list_by_type(cls, db: Session, mtype: str, days: int):
return list(db.execute( return list(db.execute(
select(DownloadHistory).where( select(DownloadHistory).where(
@@ -345,7 +331,6 @@ class DownloadFiles(Base):
) )
@classmethod @classmethod
@legacy_db_query
def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None): def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None):
statement = select(cls).where(cls.download_hash == download_hash) statement = select(cls).where(cls.download_hash == download_hash)
if state is not None: if state is not None:
@@ -353,7 +338,6 @@ class DownloadFiles(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False): def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False):
result = db.execute( result = db.execute(
select(cls).where(cls.fullpath == fullpath).order_by(cls.id.desc()) select(cls).where(cls.fullpath == fullpath).order_by(cls.id.desc())
@@ -361,7 +345,6 @@ class DownloadFiles(Base):
return list(result.all()) if all_files else result.first() return list(result.all()) if all_files else result.first()
@classmethod @classmethod
@legacy_db_query
def get_by_savepath(cls, db: Session, savepath: str): def get_by_savepath(cls, db: Session, savepath: str):
return list(db.execute(select(cls).where(cls.savepath == savepath)).scalars().all()) return list(db.execute(select(cls).where(cls.savepath == savepath)).scalars().all())
-8
View File
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
from app.db.models._constraints import media_identity_constraint from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MediaSource from app.schemas.types import MediaSource
@@ -53,12 +52,10 @@ class MediaServerItem(Base):
) )
@classmethod @classmethod
@legacy_db_query
def get_by_itemid(cls, db: Session, item_id: str): def get_by_itemid(cls, db: Session, item_id: str):
return db.execute(select(cls).where(cls.item_id == item_id)).scalars().first() return db.execute(select(cls).where(cls.item_id == item_id)).scalars().first()
@classmethod @classmethod
@legacy_db_query
def get_by_server_itemid(cls, db: Session, server: str, item_id: str): def get_by_server_itemid(cls, db: Session, server: str, item_id: str):
return db.execute( return db.execute(
select(cls).where(cls.server == server, cls.item_id == item_id) select(cls).where(cls.server == server, cls.item_id == item_id)
@@ -97,7 +94,6 @@ class MediaServerItem(Base):
) )
@classmethod @classmethod
@legacy_db_query
def exist_by_media_identity( def exist_by_media_identity(
cls, db: Session, media_source: MediaSource, media_id: str, mtype: str, cls, db: Session, media_source: MediaSource, media_id: str, mtype: str,
): ):
@@ -109,7 +105,6 @@ class MediaServerItem(Base):
)).scalars().first() )).scalars().first()
@classmethod @classmethod
@legacy_db_query
def exists_by_title(cls, db: Session, title: str, mtype: str, year: str): def exists_by_title(cls, db: Session, title: str, mtype: str, year: str):
statement = select(cls).where(cls.title == title) statement = select(cls).where(cls.title == title)
if mtype: if mtype:
@@ -119,13 +114,11 @@ class MediaServerItem(Base):
return db.execute(statement).scalars().first() return db.execute(statement).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_itemid(cls, db: AsyncSession, item_id: str): async def async_get_by_itemid(cls, db: AsyncSession, item_id: str):
result = await db.execute(select(cls).filter(cls.item_id == item_id)) result = await db.execute(select(cls).filter(cls.item_id == item_id))
return result.scalars().first() return result.scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_exist_by_media_identity( async def async_exist_by_media_identity(
cls, db: AsyncSession, media_source: MediaSource, media_id: str, mtype: str, cls, db: AsyncSession, media_source: MediaSource, media_id: str, mtype: str,
): ):
@@ -138,7 +131,6 @@ class MediaServerItem(Base):
return result.scalars().first() return result.scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_exists_by_title(cls, db: AsyncSession, title: str, mtype: str, year: str): async def async_exists_by_title(cls, db: AsyncSession, title: str, mtype: str, year: str):
if not mtype and not year: if not mtype and not year:
result = await db.execute(select(cls).filter(cls.title == title)) result = await db.execute(select(cls).filter(cls.title == title))
+45 -73
View File
@@ -5,7 +5,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
class Message(Base): class Message(Base):
@@ -49,33 +48,25 @@ class Message(Base):
return self.to_dict() return self.to_dict()
@classmethod @classmethod
@legacy_db_query
def list_by_page( def list_by_page(
cls, cls,
db: Session | None = None, db: Session,
page: int = 1, page: int = 1,
count: int = 30, count: int = 30,
) -> List["Message"]: ) -> List["Message"]:
""" """在调用方同步会话中分页获取消息记录。"""
分页获取消息记录,兼容显式会话和旧插件无会话调用。 return list(db.execute(
""" select(cls)
def query(session: Session) -> List["Message"]: .order_by(cls.reg_time.desc(), cls.id.desc())
"""在给定同步会话中执行消息分页查询。""" .offset((page - 1) * count)
return list(session.execute( .limit(count)
select(cls) ).scalars().all())
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_db_query
def exists_by_source( def exists_by_source(
cls, cls,
db: Session | str | None = None, db: Session,
source: str | None = None, source: str,
) -> bool: ) -> bool:
""" """
判断指定来源标识的消息记录是否存在。 判断指定来源标识的消息记录是否存在。
@@ -84,44 +75,29 @@ class Message(Base):
:param source: 消息来源唯一标识 :param source: 消息来源唯一标识
:return: 是否存在匹配记录 :return: 是否存在匹配记录
""" """
if source is None and isinstance(db, str): return db.execute(
source, db = db, None select(cls.id).where(cls.source == source).limit(1)
if source is None: ).scalars().first() is not None
raise TypeError("source is required")
def query(session: Session) -> bool:
"""在给定同步会话中执行来源存在性查询。"""
return session.execute(
select(cls.id).where(cls.source == source).limit(1)
).scalars().first() is not None
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_page( async def async_list_by_page(
cls, db: AsyncSession | None = None, page: int = 1, count: int = 30 cls, db: AsyncSession, page: int = 1, count: int = 30
) -> List["Message"]: ) -> List["Message"]:
""" """
异步分页获取消息记录。 异步分页获取消息记录。
""" """
async def query(session: AsyncSession) -> List["Message"]: result = await db.execute(
"""在给定异步会话中执行消息分页查询。""" select(cls)
result = await session.execute( .order_by(cls.reg_time.desc(), cls.id.desc())
select(cls) .offset((page - 1) * count)
.order_by(cls.reg_time.desc(), cls.id.desc()) .limit(count)
.offset((page - 1) * count) )
.limit(count) return list(result.scalars().all())
)
return list(result.scalars().all())
return await query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_list_sent_by_page( async def async_list_sent_by_page(
cls, cls,
db: AsyncSession | None = None, db: AsyncSession,
page: int = 1, page: int = 1,
count: int = 30, count: int = 30,
all_clear_before: Optional[str] = None, all_clear_before: Optional[str] = None,
@@ -131,35 +107,31 @@ class Message(Base):
""" """
分页获取系统发送的通知消息。 分页获取系统发送的通知消息。
""" """
async def query(session: AsyncSession) -> List["Message"]: statement = select(cls).where(cls.action == 1)
"""在给定异步会话中执行通知消息分页查询。""" if all_clear_before:
statement = select(cls).where(cls.action == 1) statement = statement.where(cls.reg_time > all_clear_before)
if all_clear_before: if system_clear_before:
statement = statement.where(cls.reg_time > all_clear_before) statement = statement.where(
if system_clear_before: or_(
statement = statement.where( and_(cls.image.isnot(None), cls.image != ""),
or_( cls.reg_time > system_clear_before,
and_(cls.image.isnot(None), cls.image != ""),
cls.reg_time > system_clear_before,
)
) )
if media_clear_before:
statement = statement.where(
or_(
cls.image.is_(None),
cls.image == "",
cls.reg_time > media_clear_before,
)
)
result = await session.execute(
statement
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
) )
return list(result.scalars().all()) if media_clear_before:
statement = statement.where(
return await query(db) or_(
cls.image.is_(None),
cls.image == "",
cls.reg_time > media_clear_before,
)
)
result = await db.execute(
statement
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return list(result.scalars().all())
@classmethod @classmethod
def delete_before( def delete_before(
+16 -44
View File
@@ -5,10 +5,6 @@ from sqlalchemy.orm import Mapped, Session, mapped_column
from datetime import datetime from datetime import datetime
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import (
legacy_async_db_query,
legacy_db_query,
)
def _get_by_user_id_statement(model: type["PassKey"], user_id: int): def _get_by_user_id_statement(model: type["PassKey"], user_id: int):
@@ -54,75 +50,51 @@ class PassKey(Base):
transports: Mapped[Optional[str]] = mapped_column(String, nullable=True) transports: Mapped[Optional[str]] = mapped_column(String, nullable=True)
@classmethod @classmethod
@legacy_db_query
def get_by_user_id( def get_by_user_id(
cls, cls,
db: Session | int | None = None, db: Session,
user_id: int | None = None, user_id: int,
): ):
"""获取用户的所有 PassKey,并保留无 Session 的旧插件调用方式""" """在调用方 Session 中获取用户的所有启用 PassKey。"""
if user_id is None and isinstance(db, int): return list(db.execute(
user_id, db = db, None _get_by_user_id_statement(cls, user_id)
if user_id is None: ).scalars().all())
raise TypeError("user_id is required")
def query(session: Session):
"""在给定会话中执行启用凭证查询。"""
return list(session.execute(
_get_by_user_id_statement(cls, user_id)
).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_user_id(cls, db: AsyncSession, user_id: int): async def async_get_by_user_id(cls, db: AsyncSession, user_id: int):
"""异步获取用户的所有 PassKey,并保留旧插件无 Session 调用""" """在调用方 AsyncSession 中获取用户的所有启用 PassKey。"""
result = await db.execute( result = await db.execute(
_get_by_user_id_statement(cls, user_id) _get_by_user_id_statement(cls, user_id)
) )
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_by_credential_id( def get_by_credential_id(
cls, cls,
db: Session | str | None = None, db: Session,
credential_id: str | None = None, credential_id: str,
): ):
"""按凭证 ID 获取 PassKey,并保留无 Session 的旧插件调用方式""" """在调用方 Session 中按凭证 ID 获取启用 PassKey。"""
if credential_id is None and isinstance(db, str): return db.execute(
credential_id, db = db, None _get_by_credential_id_statement(cls, credential_id)
if credential_id is None: ).scalars().first()
raise TypeError("credential_id is required")
def query(session: Session):
"""在给定会话中执行启用凭证查询。"""
return session.execute(
_get_by_credential_id_statement(cls, credential_id)
).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str): async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str):
"""异步根据凭证 ID 获取 PassKey,并保留旧插件无 Session 调用""" """在调用方 AsyncSession 中根据凭证 ID 获取启用 PassKey。"""
result = await db.execute( result = await db.execute(
_get_by_credential_id_statement(cls, credential_id) _get_by_credential_id_statement(cls, credential_id)
) )
return result.scalars().first() return result.scalars().first()
@classmethod @classmethod
@legacy_db_query
def get_by_id(cls, db: Session, passkey_id: int): def get_by_id(cls, db: Session, passkey_id: int):
"""根据 ID 获取 PassKey,并保留旧插件无 Session 调用""" """在调用方 Session 中根据 ID 获取 PassKey。"""
return db.execute(select(cls).where(cls.id == passkey_id)).scalars().first() return db.execute(select(cls).where(cls.id == passkey_id)).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_id(cls, db: AsyncSession, passkey_id: int): async def async_get_by_id(cls, db: AsyncSession, passkey_id: int):
"""异步根据 ID 获取 PassKey,并保留旧插件无 Session 调用""" """在调用方 AsyncSession 中根据 ID 获取 PassKey"""
result = await db.execute( result = await db.execute(
select(cls).filter(cls.id == passkey_id) select(cls).filter(cls.id == passkey_id)
) )
+12 -31
View File
@@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base from app.db.base import get_id_column, Base
from app.db.decorators import legacy_async_db_query, legacy_db_query
class PluginData(Base): class PluginData(Base):
@@ -21,44 +20,32 @@ class PluginData(Base):
) )
@classmethod @classmethod
@legacy_db_query def get_plugin_data(cls, db: Session, plugin_id: str):
def get_plugin_data(cls, db: Session | None = None, plugin_id: str | None = None): """在调用方 Session 中读取插件全部数据。"""
"""在调用方 Session 中读取插件全部数据,并兼容旧无会话入口。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
return list(db.execute(select(cls).where(cls.plugin_id == plugin_id)).scalars().all()) return list(db.execute(select(cls).where(cls.plugin_id == plugin_id)).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_get_plugin_data( async def async_get_plugin_data(
cls, db: AsyncSession | None = None, plugin_id: str | None = None cls, db: AsyncSession, plugin_id: str
): ):
"""在调用方 AsyncSession 中读取插件全部数据,并兼容旧无会话入口""" """在调用方 AsyncSession 中读取插件全部数据。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
result = await db.execute(select(cls).where(cls.plugin_id == plugin_id)) result = await db.execute(select(cls).where(cls.plugin_id == plugin_id))
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_plugin_data_by_key( def get_plugin_data_by_key(
cls, db: Session | None = None, plugin_id: str | None = None, key: str | None = None cls, db: Session, plugin_id: str, key: str
): ):
"""在调用方 Session 中按键读取插件数据,并兼容旧无会话入口""" """在调用方 Session 中按键读取插件数据。"""
if plugin_id is None or key is None:
raise TypeError("plugin_id and key are required")
return db.execute( return db.execute(
select(cls).where(cls.plugin_id == plugin_id, cls.key == key) select(cls).where(cls.plugin_id == plugin_id, cls.key == key)
).scalars().first() ).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_plugin_data_by_key( async def async_get_plugin_data_by_key(
cls, db: AsyncSession | None = None, plugin_id: str | None = None, key: str | None = None cls, db: AsyncSession, plugin_id: str, key: str
): ):
"""在调用方 AsyncSession 中按键读取插件数据,并兼容旧无会话入口""" """在调用方 AsyncSession 中按键读取插件数据。"""
if plugin_id is None or key is None:
raise TypeError("plugin_id and key are required")
result = await db.execute( result = await db.execute(
select(cls).where(cls.plugin_id == plugin_id, cls.key == key) select(cls).where(cls.plugin_id == plugin_id, cls.key == key)
) )
@@ -75,22 +62,16 @@ class PluginData(Base):
db.execute(delete(cls).where(cls.plugin_id == plugin_id)) db.execute(delete(cls).where(cls.plugin_id == plugin_id))
@classmethod @classmethod
@legacy_db_query
def get_plugin_data_by_plugin_id( def get_plugin_data_by_plugin_id(
cls, db: Session | None = None, plugin_id: str | None = None cls, db: Session, plugin_id: str
): ):
"""在调用方 Session 中按插件 ID 读取数据,并兼容旧无会话入口""" """在调用方 Session 中按插件 ID 读取数据。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
return list(db.execute(select(cls).where(cls.plugin_id == plugin_id)).scalars().all()) return list(db.execute(select(cls).where(cls.plugin_id == plugin_id)).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_get_plugin_data_by_plugin_id( async def async_get_plugin_data_by_plugin_id(
cls, db: AsyncSession | None = None, plugin_id: str | None = None cls, db: AsyncSession, plugin_id: str
): ):
"""在调用方 AsyncSession 中按插件 ID 读取数据,并兼容旧无会话入口""" """在调用方 AsyncSession 中按插件 ID 读取数据。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
result = await db.execute(select(cls).where(cls.plugin_id == plugin_id)) result = await db.execute(select(cls).where(cls.plugin_id == plugin_id))
return list(result.scalars().all()) return list(result.scalars().all())
+35 -92
View File
@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
class Site(Base): class Site(Base):
@@ -58,122 +57,66 @@ class Site(Base):
downloader: Mapped[Optional[str]] = mapped_column(String) downloader: Mapped[Optional[str]] = mapped_column(String)
@classmethod @classmethod
@legacy_db_query def get_by_domain(cls, db: Session, domain: str):
def get_by_domain(cls, db: Session | str | None = None, domain: str | None = None): """在调用方 Session 中按域名查询站点。"""
"""按域名查询站点,兼容显式会话和旧插件无会话调用。""" return db.execute(select(cls).where(cls.domain == domain)).scalars().first()
if domain is None and isinstance(db, str):
domain, db = db, None
if domain is None:
raise TypeError("domain is required")
def query(session: Session):
"""在给定同步会话中执行域名查询。"""
return session.execute(select(cls).where(cls.domain == domain)).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_domain( async def async_get_by_domain(
cls, cls,
db: AsyncSession | str | None = None, db: AsyncSession,
domain: str | None = None, domain: str,
): ):
"""异步按域名查询站点,兼容显式会话和旧插件无会话调用""" """在调用方 AsyncSession 中按域名查询站点"""
if domain is None and isinstance(db, str): result = await db.execute(select(cls).where(cls.domain == domain))
domain, db = db, None return result.scalar_one_or_none()
if domain is None:
raise TypeError("domain is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行域名查询。"""
result = await session.execute(select(cls).where(cls.domain == domain))
return result.scalar_one_or_none()
return await query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_name( async def async_get_by_name(
cls, cls,
db: AsyncSession | str | None = None, db: AsyncSession,
name: str | None = None, name: str,
): ):
"""异步按站点名称查询,兼容显式会话和旧插件无会话调用""" """在调用方 AsyncSession 中按站点名称查询"""
if name is None and isinstance(db, str): result = await db.execute(select(cls).where(cls.name == name))
name, db = db, None return result.scalar_one_or_none()
if name is None:
raise TypeError("name is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行名称查询。"""
result = await session.execute(select(cls).where(cls.name == name))
return result.scalar_one_or_none()
return await query(db)
@classmethod @classmethod
@legacy_db_query def get_actives(cls, db: Session):
def get_actives(cls, db: Session | None = None): """在调用方 Session 中查询启用站点。"""
"""查询启用站点,兼容显式会话和旧插件无会话调用。""" return list(db.execute(
def query(session: Session): select(cls).where(cls.is_active.is_(True))
"""在给定同步会话中执行启用站点查询。""" ).scalars().all())
return list(session.execute(select(cls).where(cls.is_active.is_(True))).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_async_db_query async def async_get_actives(cls, db: AsyncSession):
async def async_get_actives(cls, db: AsyncSession | None = None): """在调用方 AsyncSession 中查询启用站点。"""
"""异步查询启用站点,兼容显式会话和旧插件无会话调用。""" result = await db.execute(select(cls).where(cls.is_active.is_(True)))
async def query(session: AsyncSession): return list(result.scalars().all())
"""在给定异步会话中执行启用站点查询。"""
result = await session.execute(select(cls).where(cls.is_active.is_(True)))
return list(result.scalars().all())
return await query(db)
@classmethod @classmethod
@legacy_db_query def list_order_by_pri(cls, db: Session):
def list_order_by_pri(cls, db: Session | None = None): """在调用方 Session 中按优先级升序查询站点。"""
"""按优先级升序查询站点,兼容显式会话和旧插件无会话调用。""" return list(db.execute(select(cls).order_by(cls.pri)).scalars().all())
def query(session: Session):
"""在给定同步会话中执行优先级查询。"""
return list(session.execute(select(cls).order_by(cls.pri)).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_async_db_query async def async_list_order_by_pri(cls, db: AsyncSession):
async def async_list_order_by_pri(cls, db: AsyncSession | None = None): """在调用方 AsyncSession 中按优先级升序查询站点。"""
"""异步按优先级升序查询站点,兼容显式会话和旧插件无会话调用。""" result = await db.execute(select(cls).order_by(cls.pri))
async def query(session: AsyncSession): return list(result.scalars().all())
"""在给定异步会话中执行优先级查询。"""
result = await session.execute(select(cls).order_by(cls.pri))
return list(result.scalars().all())
return await query(db)
@classmethod @classmethod
@legacy_db_query
def get_domains_by_ids( def get_domains_by_ids(
cls, cls,
db: Session | list[int] | None = None, db: Session,
ids: list[int] | None = None, ids: list[int],
): ):
"""按 ID 查询域名,兼容显式会话和旧插件无会话调用""" """在调用方 Session 中按 ID 查询域名。"""
if ids is None and isinstance(db, list):
ids, db = db, None
if ids is None:
raise TypeError("ids is required")
if not ids: if not ids:
return [] return []
return list(db.execute(
def query(session: Session): select(cls.domain).where(cls.id.in_(ids))
"""在给定同步会话中执行域名投影查询。""" ).scalars().all())
return list(session.execute(select(cls.domain).where(cls.id.in_(ids))).scalars().all())
return query(db)
@classmethod @classmethod
def reset(cls, db: Session): def reset(cls, db: Session):
+4 -13
View File
@@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import legacy_async_db_query
class SiteIcon(Base): class SiteIcon(Base):
@@ -27,19 +26,11 @@ class SiteIcon(Base):
return db.execute(select(cls).where(cls.domain == domain)).scalars().first() return db.execute(select(cls).where(cls.domain == domain)).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_domain( async def async_get_by_domain(
cls, cls,
db: AsyncSession | None = None, db: AsyncSession,
domain: str | None = None, domain: str,
): ):
"""在调用方 AsyncSession 中查询站点图标。""" """在调用方 AsyncSession 中查询站点图标。"""
if domain is None: result = await db.execute(select(cls).where(cls.domain == domain))
raise TypeError("domain is required") return result.scalar_one_or_none()
async def query(session: AsyncSession):
"""在给定异步会话中执行站点图标查询。"""
result = await session.execute(select(cls).where(cls.domain == domain))
return result.scalar_one_or_none()
return await query(db)
+5 -14
View File
@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base from app.db.base import get_id_column, Base
from app.db.decorators import legacy_async_db_query
class SiteStatistic(Base): class SiteStatistic(Base):
@@ -35,22 +34,14 @@ class SiteStatistic(Base):
return db.execute(select(cls).where(cls.domain == domain)).scalars().first() return db.execute(select(cls).where(cls.domain == domain)).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_domain( async def async_get_by_domain(
cls, cls,
db: AsyncSession | None = None, db: AsyncSession,
domain: str | None = None, domain: str,
): ):
"""在调用方 AsyncSession 中查询站点统计,并兼容旧无会话调用""" """在调用方 AsyncSession 中查询站点统计。"""
if domain is None: result = await db.execute(select(cls).where(cls.domain == domain))
raise TypeError("domain is required") return result.scalar_one_or_none()
async def query(session: AsyncSession):
"""在给定异步会话中执行站点统计查询。"""
result = await session.execute(select(cls).where(cls.domain == domain))
return result.scalar_one_or_none()
return await query(db)
@classmethod @classmethod
def reset(cls, db: Session): def reset(cls, db: Session):
-6
View File
@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
class SiteUserData(Base): class SiteUserData(Base):
@@ -61,7 +60,6 @@ class SiteUserData(Base):
) )
@classmethod @classmethod
@legacy_db_query
def get_by_domain(cls, db: Session, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None): def get_by_domain(cls, db: Session, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None):
statement = select(cls).where(cls.domain == domain) statement = select(cls).where(cls.domain == domain)
if workdate and worktime: if workdate and worktime:
@@ -72,7 +70,6 @@ class SiteUserData(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_domain(cls, db: AsyncSession, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None): async def async_get_by_domain(cls, db: AsyncSession, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None):
query = select(cls).filter(cls.domain == domain) query = select(cls).filter(cls.domain == domain)
if workdate and worktime: if workdate and worktime:
@@ -83,12 +80,10 @@ class SiteUserData(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_by_date(cls, db: Session, date: str): def get_by_date(cls, db: Session, date: str):
return list(db.execute(select(cls).where(cls.updated_day == date)).scalars().all()) return list(db.execute(select(cls).where(cls.updated_day == date)).scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_latest(cls, db: Session): def get_latest(cls, db: Session):
""" """
获取各站点最新一天的数据 获取各站点最新一天的数据
@@ -113,7 +108,6 @@ class SiteUserData(Base):
).scalars().all()) ).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_get_latest(cls, db: AsyncSession): async def async_get_latest(cls, db: AsyncSession):
""" """
异步获取各站点最新一天的数据 异步获取各站点最新一天的数据
+107 -210
View File
@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base from app.db.base import get_id_column, Base
from app.db.decorators import legacy_async_db_query, legacy_db_query
from app.db.models._constraints import media_identity_constraint from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
@@ -140,9 +139,8 @@ class Subscribe(Base):
return condition return condition
@classmethod @classmethod
@legacy_db_query
def exists( def exists(
cls, db: Session | MediaSource | None = None, cls, db: Session,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, media_id: str | None = None,
season: Optional[int] = None, season: Optional[int] = None,
@@ -150,27 +148,21 @@ class Subscribe(Base):
music_type: Optional[str] = None, music_type: Optional[str] = None,
): ):
"""按媒体身份、季号与剧集组查询已有订阅。""" """按媒体身份、季号与剧集组查询已有订阅。"""
if db is not None and not isinstance(db, Session):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, music_type media_source, media_id, music_type
) )
if condition is None: if condition is None:
return None return None
def query(session: Session): statement = select(cls).where(condition)
"""在给定会话中执行订阅身份查询。""" if season is not None:
statement = select(cls).where(condition) statement = statement.where(cls.season == season)
if season is not None: return db.execute(
statement = statement.where(cls.season == season) statement.where(cls.episode_group == episode_group)
return session.execute( ).scalars().first()
statement.where(cls.episode_group == episode_group)
).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_exists( async def async_exists(
cls, db: AsyncSession | MediaSource | None = None, cls, db: AsyncSession,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, media_id: str | None = None,
season: Optional[int] = None, season: Optional[int] = None,
@@ -178,28 +170,22 @@ class Subscribe(Base):
music_type: Optional[str] = None, music_type: Optional[str] = None,
): ):
"""异步按媒体身份、季号与剧集组查询已有订阅。""" """异步按媒体身份、季号与剧集组查询已有订阅。"""
if db is not None and not isinstance(db, AsyncSession):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, music_type media_source, media_id, music_type
) )
if condition is None: if condition is None:
return None return None
async def query(session: AsyncSession): statement = select(cls).where(condition)
"""在给定异步会话中执行订阅身份查询。""" if season is not None:
statement = select(cls).where(condition) statement = statement.where(cls.season == season)
if season is not None: result = await db.execute(
statement = statement.where(cls.season == season) statement.where(cls.episode_group == episode_group)
result = await session.execute( )
statement.where(cls.episode_group == episode_group) return result.scalars().first()
)
return result.scalars().first()
return await query(db)
@classmethod @classmethod
@legacy_db_query
def exists_by_username( def exists_by_username(
cls, db: Session | str | None = None, cls, db: Session,
username: str | MediaSource | None = None, username: str | MediaSource | None = None,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, media_id: str | None = None,
@@ -210,8 +196,6 @@ class Subscribe(Base):
""" """
按订阅 owner、媒体身份、季号与剧集组查询订阅行。 按订阅 owner、媒体身份、季号与剧集组查询订阅行。
""" """
if db is not None and not isinstance(db, Session):
username, media_source, media_id, db = db, username, media_source, None
if not username: if not username:
return None return None
condition = cls._identity_condition( condition = cls._identity_condition(
@@ -219,20 +203,16 @@ class Subscribe(Base):
) )
if condition is None: if condition is None:
return None return None
def query(session: Session): statement = select(cls).where(cls.username == username, condition)
"""在给定会话中执行订阅 owner 查询。""" if season is not None:
statement = select(cls).where(cls.username == username, condition) statement = statement.where(cls.season == season)
if season is not None: return db.execute(
statement = statement.where(cls.season == season) statement.where(cls.episode_group == episode_group)
return session.execute( ).scalars().first()
statement.where(cls.episode_group == episode_group)
).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_exists_by_username( async def async_exists_by_username(
cls, db: AsyncSession | str | None = None, cls, db: AsyncSession,
username: str | MediaSource | None = None, username: str | MediaSource | None = None,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, season: Optional[int] = None, media_id: str | None = None, season: Optional[int] = None,
@@ -242,8 +222,6 @@ class Subscribe(Base):
""" """
异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。 异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。
""" """
if db is not None and not isinstance(db, AsyncSession):
username, media_source, media_id, db = db, username, media_source, None
if not username: if not username:
return None return None
condition = cls._identity_condition( condition = cls._identity_condition(
@@ -251,112 +229,76 @@ class Subscribe(Base):
) )
if condition is None: if condition is None:
return None return None
async def query(session: AsyncSession): statement = select(cls).where(cls.username == username, condition)
"""在给定异步会话中执行订阅 owner 查询。""" if season is not None:
statement = select(cls).where(cls.username == username, condition) statement = statement.where(cls.season == season)
if season is not None: result = await db.execute(
statement = statement.where(cls.season == season) statement.where(cls.episode_group == episode_group)
result = await session.execute( )
statement.where(cls.episode_group == episode_group) return result.scalars().first()
)
return result.scalars().first()
return await query(db)
@classmethod @classmethod
@legacy_db_query def get_by_state(cls, db: Session, state: str | None = None):
def get_by_state(cls, db: Session | str | None = None, state: str | None = None): """在调用方 Session 中按状态列表查询订阅。"""
"""按状态列表查询订阅,兼容显式会话和旧插件无会话调用。""" statement = select(cls)
if not isinstance(db, Session): if state:
state, db = db if state is None else state, None statement = statement.where(cls.state.in_(state.split(',')))
def query(session: Session): return list(db.execute(statement).scalars().all())
"""在给定会话中执行状态查询。"""
statement = select(cls)
if state:
statement = statement.where(cls.state.in_(state.split(',')))
return list(session.execute(statement).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_state( async def async_get_by_state(
cls, db: AsyncSession | str | None = None, state: str | None = None cls, db: AsyncSession, state: str | None = None
): ):
"""异步按状态列表查询订阅,兼容显式会话和旧插件无会话调用""" """在调用方 AsyncSession 中按状态列表查询订阅"""
if not isinstance(db, AsyncSession): statement = select(cls)
state, db = db if state is None else state, None if state:
async def query(session: AsyncSession): statement = statement.where(cls.state.in_(state.split(',')))
"""在给定异步会话中执行状态查询。""" result = await db.execute(statement)
statement = select(cls) return list(result.scalars().all())
if state:
statement = statement.where(cls.state.in_(state.split(',')))
result = await session.execute(statement)
return list(result.scalars().all())
return await query(db)
@classmethod @classmethod
@legacy_db_query
def get_by_title( def get_by_title(
cls, db: Session | str | None = None, title: str | None = None, cls, db: Session, title: str,
season: Optional[int] = None, season: Optional[int] = None,
): ):
"""按标题查询订阅,兼容显式会话和旧插件无会话调用""" """在调用方 Session 中按标题查询订阅。"""
if not isinstance(db, Session): statement = select(cls).where(cls.name == title)
title, db = db if title is None else title, None if season is not None:
def query(session: Session): statement = statement.where(cls.season == season)
"""在给定会话中执行标题查询。""" return db.execute(statement).scalars().first()
statement = select(cls).where(cls.name == title)
if season is not None:
statement = statement.where(cls.season == season)
return session.execute(statement).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_title( async def async_get_by_title(
cls, db: AsyncSession | str | None = None, title: str | None = None, cls, db: AsyncSession, title: str,
season: Optional[int] = None, season: Optional[int] = None,
): ):
"""异步按标题查询订阅,兼容显式会话和旧插件无会话调用""" """在调用方 AsyncSession 中按标题查询订阅"""
if not isinstance(db, AsyncSession): statement = select(cls).where(cls.name == title)
title, db = db if title is None else title, None if season is not None:
async def query(session: AsyncSession): statement = statement.where(cls.season == season)
"""在给定异步会话中执行标题查询。""" result = await db.execute(statement)
statement = select(cls).where(cls.name == title) return result.scalars().first()
if season is not None:
statement = statement.where(cls.season == season)
result = await session.execute(statement)
return result.scalars().first()
return await query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_title( async def async_list_by_title(
cls, db: AsyncSession | str | None = None, title: str | None = None, cls, db: AsyncSession, title: str,
season: Optional[int] = None, season: Optional[int] = None,
): ):
"""异步按标题查询候选订阅列表,兼容显式会话和旧插件无会话调用""" """在调用方 AsyncSession 中按标题查询候选订阅列表"""
if not isinstance(db, AsyncSession): statement = select(cls).where(cls.name == title)
title, db = db if title is None else title, None if season is not None:
async def query(session: AsyncSession): statement = statement.where(cls.season == season)
"""在给定异步会话中执行标题列表查询。""" result = await db.execute(statement)
statement = select(cls).where(cls.name == title) return list(result.scalars().all())
if season is not None:
statement = statement.where(cls.season == season)
result = await session.execute(statement)
return list(result.scalars().all())
return await query(db)
@classmethod @classmethod
@legacy_db_query
def list_by_media_identity( def list_by_media_identity(
cls, db: Session | MediaSource | None = None, cls, db: Session,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, media_id: str | None = None,
music_type: Optional[str] = None, music_type: Optional[str] = None,
): ):
"""同步按统一媒体身份查询候选订阅列表。""" """同步按统一媒体身份查询候选订阅列表。"""
if db is not None and not isinstance(db, Session):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source=media_source, media_source=media_source,
media_id=media_id, media_id=media_id,
@@ -364,22 +306,16 @@ class Subscribe(Base):
) )
if condition is None: if condition is None:
return [] return []
def query(session: Session): return list(db.execute(select(cls).where(condition)).scalars().all())
"""在给定会话中执行媒体身份列表查询。"""
return list(session.execute(select(cls).where(condition)).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_media_identity( async def async_list_by_media_identity(
cls, db: AsyncSession | MediaSource | None = None, cls, db: AsyncSession,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, media_id: str | None = None,
music_type: Optional[str] = None, music_type: Optional[str] = None,
): ):
"""异步按统一媒体身份查询候选订阅列表。""" """异步按统一媒体身份查询候选订阅列表。"""
if db is not None and not isinstance(db, AsyncSession):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source=media_source, media_source=media_source,
media_id=media_id, media_id=media_id,
@@ -387,16 +323,12 @@ class Subscribe(Base):
) )
if condition is None: if condition is None:
return [] return []
async def query(session: AsyncSession): result = await db.execute(select(cls).where(condition))
"""在给定异步会话中执行媒体身份列表查询。""" return list(result.scalars().all())
result = await session.execute(select(cls).where(condition))
return list(result.scalars().all())
return await query(db)
@classmethod @classmethod
@legacy_db_query
def get_by( def get_by(
cls, db: Session | str | None = None, cls, db: Session,
type: str | MediaSource | None = None, type: str | MediaSource | None = None,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, media_id: str | None = None,
@@ -406,8 +338,6 @@ class Subscribe(Base):
""" """
根据条件查询订阅 根据条件查询订阅
""" """
if db is not None and not isinstance(db, Session):
type, media_source, media_id, db = db, type, media_source, None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, music_type media_source, media_id, music_type
) )
@@ -416,15 +346,11 @@ class Subscribe(Base):
statement = select(cls).where(condition, cls.type == type) statement = select(cls).where(condition, cls.type == type)
if season is not None: if season is not None:
statement = statement.where(cls.season == season) statement = statement.where(cls.season == season)
def query(session: Session): return db.execute(statement).scalars().first()
"""在给定会话中执行类型媒体查询。"""
return session.execute(statement).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by( async def async_get_by(
cls, db: AsyncSession | str | None = None, cls, db: AsyncSession,
type: str | MediaSource | None = None, type: str | MediaSource | None = None,
media_source: MediaSource | str | None = None, media_source: MediaSource | str | None = None,
media_id: str | None = None, media_id: str | None = None,
@@ -434,8 +360,6 @@ class Subscribe(Base):
""" """
根据条件查询订阅 根据条件查询订阅
""" """
if db is not None and not isinstance(db, AsyncSession):
type, media_source, media_id, db = db, type, media_source, None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, music_type media_source, media_id, music_type
) )
@@ -444,76 +368,49 @@ class Subscribe(Base):
query = select(cls).filter(condition, cls.type == type) query = select(cls).filter(condition, cls.type == type)
if season is not None: if season is not None:
query = query.filter(cls.season == season) query = query.filter(cls.season == season)
async def execute_query(session: AsyncSession): result = await db.execute(query)
"""在给定异步会话中执行类型媒体查询。""" return result.scalars().first()
result = await session.execute(query)
return result.scalars().first()
return await execute_query(db)
@classmethod @classmethod
@legacy_db_query def list_by_username(cls, db: Session, username: str,
def list_by_username(cls, db: Session | str | None = None, username: str | None = None,
state: Optional[str] = None, mtype: Optional[str] = None): state: Optional[str] = None, mtype: Optional[str] = None):
"""按用户筛选订阅,兼容显式会话和旧插件无会话调用""" """在调用方 Session 中按用户筛选订阅。"""
if not isinstance(db, Session): statement = select(cls).where(cls.username == username)
username, db = db if username is None else username, None if state:
def query(session: Session): statement = statement.where(cls.state == state)
"""在给定会话中执行用户筛选查询。""" if mtype:
statement = select(cls).where(cls.username == username) statement = statement.where(cls.type == mtype)
if state: return list(db.execute(statement).scalars().all())
statement = statement.where(cls.state == state)
if mtype:
statement = statement.where(cls.type == mtype)
return list(session.execute(statement).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_async_db_query async def async_list_by_username(cls, db: AsyncSession,
async def async_list_by_username(cls, db: AsyncSession | str | None = None, username: str, state: Optional[str] = None,
username: str | None = None, state: Optional[str] = None,
mtype: Optional[str] = None): mtype: Optional[str] = None):
"""异步按用户筛选订阅,兼容显式会话和旧插件无会话调用""" """在调用方 AsyncSession 中按用户筛选订阅"""
if not isinstance(db, AsyncSession): statement = select(cls).where(cls.username == username)
username, db = db if username is None else username, None if state:
async def query(session: AsyncSession): statement = statement.where(cls.state == state)
"""在给定异步会话中执行用户筛选查询。""" if mtype:
statement = select(cls).where(cls.username == username) statement = statement.where(cls.type == mtype)
if state: result = await db.execute(statement)
statement = statement.where(cls.state == state) return list(result.scalars().all())
if mtype:
statement = statement.where(cls.type == mtype)
result = await session.execute(statement)
return list(result.scalars().all())
return await query(db)
@classmethod @classmethod
@legacy_db_query def list_by_type(cls, db: Session, mtype: str, days: int = 7):
def list_by_type(cls, db: Session | str | None = None, mtype: str | None = None, days: int = 7): """在调用方 Session 中按类型查询最近时间窗内的订阅。"""
"""按类型查询最近时间窗内的订阅,兼容显式会话和旧插件无会话调用。""" return list(db.execute(select(cls).where(
if not isinstance(db, Session): cls.type == mtype,
mtype, db = db if mtype is None else mtype, None cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
def query(session: Session): time.localtime(time.time() - 86400 * int(days)))
"""在给定会话中执行时间窗订阅查询。""" )).scalars().all())
return list(session.execute(select(cls).where(
cls.type == mtype,
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
)).scalars().all())
return query(db)
@classmethod @classmethod
@legacy_async_db_query async def async_list_by_type(cls, db: AsyncSession,
async def async_list_by_type(cls, db: AsyncSession | str | None = None, mtype: str, days: int = 7):
mtype: str | None = None, days: int = 7): """在调用方 AsyncSession 中按类型查询最近时间窗内的订阅。"""
"""异步按类型查询最近时间窗内的订阅,兼容显式会话和旧插件无会话调用。""" result = await db.execute(select(cls).where(
if not isinstance(db, AsyncSession): cls.type == mtype,
mtype, db = db if mtype is None else mtype, None cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
async def query(session: AsyncSession): time.localtime(time.time() - 86400 * int(days)))
"""在给定异步会话中执行时间窗订阅查询。""" ))
result = await session.execute(select(cls).where( return list(result.scalars().all())
cls.type == mtype,
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
))
return list(result.scalars().all())
return await query(db)
+2 -8
View File
@@ -5,7 +5,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
from app.db.models._constraints import media_identity_constraint from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
@@ -107,9 +106,8 @@ class SubscribeHistory(Base):
) )
@classmethod @classmethod
@legacy_db_query
def list_by_type(cls, db: Session, mtype: str, page: int = 1, count: int = 30): def list_by_type(cls, db: Session, mtype: str, page: int = 1, count: int = 30):
"""按媒体类型分页查询订阅历史,并保留旧插件无 Session 调用""" """在调用方 Session 中按媒体类型分页查询订阅历史。"""
return list(db.execute( return list(db.execute(
select(cls).where( select(cls).where(
cls.type == mtype cls.type == mtype
@@ -119,9 +117,8 @@ class SubscribeHistory(Base):
).scalars().all()) ).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_type(cls, db: AsyncSession, mtype: str, page: int = 1, count: int = 30): async def async_list_by_type(cls, db: AsyncSession, mtype: str, page: int = 1, count: int = 30):
"""异步按媒体类型分页查询订阅历史,并保留旧插件无 Session 调用""" """在调用方 AsyncSession 中按媒体类型分页查询订阅历史"""
result = await db.execute( result = await db.execute(
select(cls).filter( select(cls).filter(
cls.type == mtype cls.type == mtype
@@ -132,7 +129,6 @@ class SubscribeHistory(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_type_and_username( async def async_list_by_type_and_username(
cls, cls,
db: AsyncSession, db: AsyncSession,
@@ -177,7 +173,6 @@ class SubscribeHistory(Base):
return condition return condition
@classmethod @classmethod
@legacy_db_query
def exists( def exists(
cls, db: Session, media_source: MediaSource, media_id: str, cls, db: Session, media_source: MediaSource, media_id: str,
season: Optional[int] = None, season: Optional[int] = None,
@@ -197,7 +192,6 @@ class SubscribeHistory(Base):
return db.execute(statement).scalars().first() return db.execute(statement).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_exists( async def async_exists(
cls, db: AsyncSession, media_source: MediaSource, media_id: str, cls, db: AsyncSession, media_source: MediaSource, media_id: str,
season: Optional[int] = None, season: Optional[int] = None,
+2 -5
View File
@@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
class SystemConfig(Base): class SystemConfig(Base):
@@ -18,15 +17,13 @@ class SystemConfig(Base):
value: Mapped[Optional[Any]] = mapped_column(JSON) value: Mapped[Optional[Any]] = mapped_column(JSON)
@classmethod @classmethod
@legacy_db_query
def get_by_key(cls, db: Session, key: str): def get_by_key(cls, db: Session, key: str):
"""按配置键查询系统配置,并保留旧插件无 Session 调用""" """在调用方 Session 中按配置键查询系统配置。"""
return db.execute(select(cls).where(cls.key == key)).scalars().first() return db.execute(select(cls).where(cls.key == key)).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_key(cls, db: AsyncSession, key: str): async def async_get_by_key(cls, db: AsyncSession, key: str):
"""异步按配置键查询系统配置,并保留旧插件无 Session 调用""" """在调用方 AsyncSession 中按配置键查询系统配置"""
result = await db.execute(select(cls).where(cls.key == key)) result = await db.execute(select(cls).where(cls.key == key))
return result.scalar_one_or_none() return result.scalar_one_or_none()
+21 -88
View File
@@ -8,10 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import (
legacy_async_db_query,
legacy_db_query,
)
from app.db.models._constraints import media_identity_constraint from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
@@ -97,7 +93,6 @@ class TransferHistory(Base):
) )
@classmethod @classmethod
@legacy_db_query
def list_by_title(cls, db: Session, title: str, page: int = 1, count: int = 30, def list_by_title(cls, db: Session, title: str, page: int = 1, count: int = 30,
status: Optional[bool] = None, wildcard: bool = False): status: Optional[bool] = None, wildcard: bool = False):
if wildcard: if wildcard:
@@ -124,7 +119,6 @@ class TransferHistory(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_title(cls, db: AsyncSession, title: str, page: int = 1, count: int = 30, async def async_list_by_title(cls, db: AsyncSession, title: str, page: int = 1, count: int = 30,
status: Optional[bool] = None, wildcard: bool = False): status: Optional[bool] = None, wildcard: bool = False):
if wildcard: if wildcard:
@@ -152,7 +146,6 @@ class TransferHistory(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def list_by_page(cls, db: Session, page: int = 1, count: int = 30, status: Optional[bool] = None): def list_by_page(cls, db: Session, page: int = 1, count: int = 30, status: Optional[bool] = None):
statement = select(cls) statement = select(cls)
if status is not None: if status is not None:
@@ -166,7 +159,6 @@ class TransferHistory(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_list_by_page(cls, db: AsyncSession, page: int = 1, count: int = 30, async def async_list_by_page(cls, db: AsyncSession, page: int = 1, count: int = 30,
status: Optional[bool] = None): status: Optional[bool] = None):
if status is not None: if status is not None:
@@ -188,30 +180,19 @@ class TransferHistory(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_by_hash( def get_by_hash(
cls, cls,
db: Session | str | None = None, db: Session,
download_hash: str | None = None, download_hash: str,
): ):
"""按下载哈希查询最新记录,兼容旧插件无会话调用""" """在调用方 Session 中按下载哈希查询最新记录。"""
if download_hash is None and isinstance(db, str): return db.execute(
download_hash, db = db, None select(cls).where(cls.download_hash == download_hash)
if download_hash is None: ).scalars().first()
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)
@classmethod @classmethod
@legacy_db_query
def get_by_src( def get_by_src(
cls, db: Session | str | None = None, src: str | None = None, cls, db: Session, src: str,
storage: Optional[str] = None storage: Optional[str] = None
) -> Optional["TransferHistory"]: ) -> Optional["TransferHistory"]:
""" """
@@ -222,26 +203,14 @@ class TransferHistory(Base):
:param storage: 源存储类型 :param storage: 源存储类型
:return: 命中的整理记录,未命中时返回 None :return: 命中的整理记录,未命中时返回 None
""" """
if src is None and isinstance(db, str): statement = select(cls).where(cls.src == src)
src, db = db, None if storage:
if src is None: statement = statement.where(cls.src_storage == storage)
raise TypeError("src is required") return db.execute(statement.order_by(cls.id.desc())).scalars().first()
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)
@classmethod @classmethod
@legacy_db_query
def get_success_by_src( def get_success_by_src(
cls, db: Session | str | None = None, src: str | None = None, cls, db: Session, src: str,
storage: Optional[str] = None storage: Optional[str] = None
) -> Optional["TransferHistory"]: ) -> Optional["TransferHistory"]:
""" """
@@ -254,26 +223,14 @@ class TransferHistory(Base):
:param storage: 源存储类型 :param storage: 源存储类型
:return: 命中的成功整理记录,未命中时返回 None :return: 命中的成功整理记录,未命中时返回 None
""" """
if src is None and isinstance(db, str): statement = select(cls).where(cls.src == src, cls.status.is_(True))
src, db = db, None if storage:
if src is None: statement = statement.where(cls.src_storage == storage)
raise TypeError("src is required") return db.execute(statement.order_by(cls.id.desc())).scalars().first()
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)
@classmethod @classmethod
@legacy_db_query
def get_by_dest( def get_by_dest(
cls, db: Session | str | None = None, dest: str | None = None, cls, db: Session, dest: str,
storage: Optional[str] = None storage: Optional[str] = None
) -> Optional["TransferHistory"]: ) -> Optional["TransferHistory"]:
""" """
@@ -284,24 +241,12 @@ class TransferHistory(Base):
:param storage: 目标存储类型 :param storage: 目标存储类型
:return: 命中的整理记录,未命中时返回 None :return: 命中的整理记录,未命中时返回 None
""" """
if dest is None and isinstance(db, str): statement = select(cls).where(cls.dest == dest)
dest, db = db, None if storage:
if dest is None: statement = statement.where(cls.dest_storage == storage)
raise TypeError("dest is required") return db.execute(statement.order_by(cls.id.desc())).scalars().first()
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)
@classmethod @classmethod
@legacy_db_query
def list_success_by_src( def list_success_by_src(
cls, cls,
db: Session, db: Session,
@@ -341,7 +286,6 @@ class TransferHistory(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_db_query
def list_success_move_by_dest( def list_success_move_by_dest(
cls, cls,
db: Session, db: Session,
@@ -384,14 +328,12 @@ class TransferHistory(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_db_query
def list_by_hash(cls, db: Session, download_hash: str): def list_by_hash(cls, db: Session, download_hash: str):
return list(db.execute( return list(db.execute(
select(cls).where(cls.download_hash == download_hash) select(cls).where(cls.download_hash == download_hash)
).scalars().all()) ).scalars().all())
@classmethod @classmethod
@legacy_db_query
def statistic(cls, db: Session, days: int = 7): def statistic(cls, db: Session, days: int = 7):
""" """
统计最近days天的下载历史数量,按日期分组返回每日数量 统计最近days天的下载历史数量,按日期分组返回每日数量
@@ -408,7 +350,6 @@ class TransferHistory(Base):
).all()) ).all())
@classmethod @classmethod
@legacy_db_query
def monthly_media_statistics(cls, db: Session): def monthly_media_statistics(cls, db: Session):
""" """
统计当月成功整理的电影、电视剧、剧集和音乐数量。 统计当月成功整理的电影、电视剧、剧集和音乐数量。
@@ -474,7 +415,6 @@ class TransferHistory(Base):
return 1 return 1
@classmethod @classmethod
@legacy_async_db_query
async def async_statistic(cls, db: AsyncSession, days: int = 7): async def async_statistic(cls, db: AsyncSession, days: int = 7):
""" """
统计最近days天的下载历史数量,按日期分组返回每日数量 统计最近days天的下载历史数量,按日期分组返回每日数量
@@ -489,7 +429,6 @@ class TransferHistory(Base):
return result.all() return result.all()
@classmethod @classmethod
@legacy_db_query
def count(cls, db: Session, status: Optional[bool] = None): def count(cls, db: Session, status: Optional[bool] = None):
statement = select(func.count(cls.id)) statement = select(func.count(cls.id))
if status is not None: if status is not None:
@@ -497,7 +436,6 @@ class TransferHistory(Base):
return db.execute(statement).scalar() return db.execute(statement).scalar()
@classmethod @classmethod
@legacy_async_db_query
async def async_count(cls, db: AsyncSession, status: Optional[bool] = None): async def async_count(cls, db: AsyncSession, status: Optional[bool] = None):
if status is not None: if status is not None:
result = await db.execute( result = await db.execute(
@@ -510,7 +448,6 @@ class TransferHistory(Base):
return result.scalar() return result.scalar()
@classmethod @classmethod
@legacy_db_query
def count_by_title(cls, db: Session, title: str, status: Optional[bool] = None, wildcard: bool = False): def count_by_title(cls, db: Session, title: str, status: Optional[bool] = None, wildcard: bool = False):
if wildcard: if wildcard:
text_filter = or_( text_filter = or_(
@@ -530,7 +467,6 @@ class TransferHistory(Base):
return db.execute(statement).scalar() return db.execute(statement).scalar()
@classmethod @classmethod
@legacy_async_db_query
async def async_count_by_title(cls, db: AsyncSession, title: str, status: Optional[bool] = None, wildcard: bool = False): async def async_count_by_title(cls, db: AsyncSession, title: str, status: Optional[bool] = None, wildcard: bool = False):
if wildcard: if wildcard:
text_filter = or_( text_filter = or_(
@@ -551,7 +487,6 @@ class TransferHistory(Base):
return result.scalar() return result.scalar()
@classmethod @classmethod
@legacy_db_query
def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None,
season: Optional[str] = None, season: Optional[str] = None,
episode: Optional[str] = None, episode: Optional[str] = None,
@@ -589,7 +524,6 @@ class TransferHistory(Base):
return list(db.execute(statement).scalars().all()) return list(db.execute(statement).scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_by_media_identity( def get_by_media_identity(
cls, db: Session, media_source: MediaSource, media_id: str, cls, db: Session, media_source: MediaSource, media_id: str,
mtype: Optional[str] = None, mtype: Optional[str] = None,
@@ -636,7 +570,6 @@ class TransferHistory(Base):
return history return history
@classmethod @classmethod
@legacy_db_query
def list_by_date(cls, db: Session, date: str): def list_by_date(cls, db: Session, date: str):
""" """
查询某时间之后的转移历史 查询某时间之后的转移历史
-2
View File
@@ -4,7 +4,6 @@ from sqlalchemy import Index, String, delete, select
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import legacy_db_query
class TransferPending(Base): class TransferPending(Base):
@@ -73,7 +72,6 @@ class TransferPending(Base):
) )
@classmethod @classmethod
@legacy_db_query
def list_all(cls, db: Session, limit: Optional[int] = 5000) -> List["TransferPending"]: def list_all(cls, db: Session, limit: Optional[int] = 5000) -> List["TransferPending"]:
""" """
列出全部待整理登记,供启动回放使用。 列出全部待整理登记,供启动回放使用。
+17 -58
View File
@@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
class User(Base): class User(Base):
@@ -35,78 +34,38 @@ class User(Base):
settings: Mapped[Optional[Any]] = mapped_column(JSON, default=dict) settings: Mapped[Optional[Any]] = mapped_column(JSON, default=dict)
@classmethod @classmethod
@legacy_db_query
def get_by_name( def get_by_name(
cls, cls,
db: Session | str | None = None, db: Session,
name: str | None = None, name: str,
): ):
"""按用户名查询用户,兼容显式会话和旧插件无会话调用""" """在调用方同步会话中按用户名查询用户。"""
if name is None and isinstance(db, str): return db.execute(select(cls).where(cls.name == name)).scalars().first()
name, db = db, None
if name is None:
raise TypeError("name is required")
def query(session: Session):
"""在给定会话中执行用户名查询。"""
return session.execute(select(cls).where(cls.name == name)).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_name( async def async_get_by_name(
cls, cls,
db: AsyncSession | str | None = None, db: AsyncSession,
name: str | None = None, name: str,
): ):
"""异步按用户名查询,兼容显式会话和旧插件无会话调用""" """在调用方异步会话中按用户名查询用户"""
if name is None and isinstance(db, str): result = await db.execute(select(cls).filter(cls.name == name))
name, db = db, None return result.scalars().first()
if name is None:
raise TypeError("name is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行用户名查询。"""
result = await session.execute(select(cls).filter(cls.name == name))
return result.scalars().first()
return await query(db)
@classmethod @classmethod
@legacy_db_query def get_by_id(cls, db: Session, user_id: int):
def get_by_id(cls, db: Session | int | None = None, user_id: int | None = None): """在调用方同步会话中按用户 ID 查询用户。"""
"""按用户 ID 查询用户,兼容显式会话和旧插件无会话调用。""" return db.execute(select(cls).where(cls.id == user_id)).scalars().first()
if user_id is None and isinstance(db, int):
user_id, db = db, None
if user_id is None:
raise TypeError("user_id is required")
def query(session: Session):
"""在给定会话中执行用户 ID 查询。"""
return session.execute(select(cls).where(cls.id == user_id)).scalars().first()
return query(db)
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_id( async def async_get_by_id(
cls, cls,
db: AsyncSession | int | None = None, db: AsyncSession,
user_id: int | None = None, user_id: int,
): ):
"""异步按用户 ID 查询,兼容显式会话和旧插件无会话调用""" """在调用方异步会话中按用户 ID 查询用户"""
if user_id is None and isinstance(db, int): result = await db.execute(select(cls).filter(cls.id == user_id))
user_id, db = db, None return result.scalars().first()
if user_id is None:
raise TypeError("user_id is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行用户 ID 查询。"""
result = await session.execute(select(cls).filter(cls.id == user_id))
return result.scalars().first()
return await query(db)
def delete_by_name(self, db: Session, name: str): def delete_by_name(self, db: Session, name: str):
user = self.get_by_name(db, name) user = self.get_by_name(db, name)
-9
View File
@@ -7,7 +7,6 @@ from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import Base, get_id_column from app.db.base import Base, get_id_column
from app.db.decorators import legacy_async_db_query, legacy_db_query
class Workflow(Base): class Workflow(Base):
@@ -56,18 +55,15 @@ class Workflow(Base):
) )
@classmethod @classmethod
@legacy_db_query
def get_enabled_workflows(cls, db): def get_enabled_workflows(cls, db):
return list(db.execute(select(cls).where(cls.state != 'P')).scalars().all()) return list(db.execute(select(cls).where(cls.state != 'P')).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_get_enabled_workflows(cls, db: AsyncSession): async def async_get_enabled_workflows(cls, db: AsyncSession):
result = await db.execute(select(cls).where(cls.state != 'P')) result = await db.execute(select(cls).where(cls.state != 'P'))
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_timer_triggered_workflows(cls, db): def get_timer_triggered_workflows(cls, db):
"""获取定时触发的工作流""" """获取定时触发的工作流"""
return list(db.execute(select(cls).where( return list(db.execute(select(cls).where(
@@ -81,7 +77,6 @@ class Workflow(Base):
)).scalars().all()) )).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_get_timer_triggered_workflows(cls, db: AsyncSession): async def async_get_timer_triggered_workflows(cls, db: AsyncSession):
"""异步获取定时触发的工作流""" """异步获取定时触发的工作流"""
result = await db.execute(select(cls).where( result = await db.execute(select(cls).where(
@@ -96,7 +91,6 @@ class Workflow(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_event_triggered_workflows(cls, db): def get_event_triggered_workflows(cls, db):
"""获取事件触发的工作流""" """获取事件触发的工作流"""
return list(db.execute(select(cls).where( return list(db.execute(select(cls).where(
@@ -107,7 +101,6 @@ class Workflow(Base):
)).scalars().all()) )).scalars().all())
@classmethod @classmethod
@legacy_async_db_query
async def async_get_event_triggered_workflows(cls, db: AsyncSession): async def async_get_event_triggered_workflows(cls, db: AsyncSession):
"""异步获取事件触发的工作流""" """异步获取事件触发的工作流"""
result = await db.execute(select(cls).where( result = await db.execute(select(cls).where(
@@ -119,12 +112,10 @@ class Workflow(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@legacy_db_query
def get_by_name(cls, db, name: str): def get_by_name(cls, db, name: str):
return db.execute(select(cls).where(cls.name == name)).scalars().first() return db.execute(select(cls).where(cls.name == name)).scalars().first()
@classmethod @classmethod
@legacy_async_db_query
async def async_get_by_name(cls, db: AsyncSession, name: str): async def async_get_by_name(cls, db: AsyncSession, name: str):
result = await db.execute(select(cls).where(cls.name == name)) result = await db.execute(select(cls).where(cls.name == name))
return result.scalars().first() return result.scalars().first()
+4
View File
@@ -321,6 +321,10 @@ class AgentChatOper(DbOper):
await self._stage_async_delete(AgentChat, chat.id) await self._stage_async_delete(AgentChat, chat.id)
return True return True
def delete_by_id(self, chat_id: int) -> None:
"""在 Oper 事务边界内按主键删除 Agent 会话。"""
self._stage_delete(AgentChat, chat_id)
async def async_stage_delete( async def async_stage_delete(
self, self,
session_id: str, session_id: str,
+14 -10
View File
@@ -17,10 +17,12 @@ class DownloadFailureOper(DbOper):
""" """
批量按指纹查询仍在冷却期的失败记录。 批量按指纹查询仍在冷却期的失败记录。
""" """
failures = DownloadFailure.get_active_by_fingerprints( failures = self._execute_sync_query(
self._db, lambda session: DownloadFailure.get_active_by_fingerprints(
fingerprints=fingerprints, session,
now_time=now_time, fingerprints=fingerprints,
now_time=now_time,
)
) )
return { return {
failure.fingerprint: failure failure.fingerprint: failure
@@ -38,12 +40,14 @@ class DownloadFailureOper(DbOper):
""" """
新增或更新资源失败记录。 新增或更新资源失败记录。
""" """
return DownloadFailure.record_failure( return self._execute_sync_write(
self._db, lambda session: DownloadFailure.record_failure(
fingerprint=fingerprint, session,
now_time=now_time, fingerprint=fingerprint,
next_retry_at=next_retry_at, now_time=now_time,
**kwargs, next_retry_at=next_retry_at,
**kwargs,
)
) )
def delete_expired( def delete_expired(
+1 -1
View File
@@ -289,7 +289,7 @@ class DownloadHistoryOper(DbOper):
self._stage_delete(DownloadHistory, historyid) self._stage_delete(DownloadHistory, historyid)
def stage_delete_history(self, historyid: int) -> None: def stage_delete_history(self, historyid: int) -> None:
"""暂存下载记录删除,不由模型装饰器提交事务。""" """暂存下载记录删除,事务由调用方统一提交"""
self._db.execute( self._db.execute(
sqlalchemy_delete(DownloadHistory).where( sqlalchemy_delete(DownloadHistory).where(
DownloadHistory.id == historyid DownloadHistory.id == historyid
+35 -11
View File
@@ -19,7 +19,11 @@ class PluginDataOper(DbOper):
:param key: 数据key :param key: 数据key
:param value: 数据值 :param value: 数据值
""" """
plugin = PluginData.get_plugin_data_by_key(self._db, plugin_id, key) plugin = self._execute_sync_query(
lambda session: PluginData.get_plugin_data_by_key(
session, plugin_id, key
)
)
if plugin: if plugin:
self._stage_update(plugin, { self._stage_update(plugin, {
"value": value "value": value
@@ -35,8 +39,10 @@ class PluginDataOper(DbOper):
:param key: 数据键 :param key: 数据键
:param value: 数据值 :param value: 数据值
""" """
plugin = await PluginData.async_get_plugin_data_by_key( plugin = await self._execute_async_query(
self._db, plugin_id, key lambda session: PluginData.async_get_plugin_data_by_key(
session, plugin_id, key
)
) )
if plugin: if plugin:
await self._stage_async_update(plugin, {"value": value}) await self._stage_async_update(plugin, {"value": value})
@@ -52,12 +58,18 @@ class PluginDataOper(DbOper):
:param key: 数据key :param key: 数据key
""" """
if key: if key:
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key) data = self._execute_sync_query(
lambda session: PluginData.get_plugin_data_by_key(
session, plugin_id, key
)
)
if not data: if not data:
return None return None
return data.value return data.value
else: else:
return PluginData.get_plugin_data(self._db, plugin_id) return self._execute_sync_query(
lambda session: PluginData.get_plugin_data(session, plugin_id)
)
async def async_get_data(self, plugin_id: str, key: Optional[str] = None) -> Any: async def async_get_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
""" """
@@ -66,13 +78,17 @@ class PluginDataOper(DbOper):
:param key: 数据key :param key: 数据key
""" """
if key: if key:
data = await PluginData.async_get_plugin_data_by_key( data = await self._execute_async_query(
self._db, plugin_id, key lambda session: PluginData.async_get_plugin_data_by_key(
session, plugin_id, key
)
) )
if not data: if not data:
return None return None
return data.value return data.value
return await PluginData.async_get_plugin_data(self._db, plugin_id) return await self._execute_async_query(
lambda session: PluginData.async_get_plugin_data(session, plugin_id)
)
def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any: def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
""" """
@@ -81,7 +97,7 @@ class PluginDataOper(DbOper):
:param key: 数据key :param key: 数据key
""" """
def stage(session: Session) -> None: def stage(session: Session) -> None:
"""兼容删除入口映射到调用方或组合根持有的事务。""" """把删除入口映射到调用方或组合根持有的事务。"""
if key: if key:
PluginData.del_plugin_data_by_key(session, plugin_id, key) PluginData.del_plugin_data_by_key(session, plugin_id, key)
else: else:
@@ -109,11 +125,19 @@ class PluginDataOper(DbOper):
获取插件所有数据 获取插件所有数据
:param plugin_id: 插件id :param plugin_id: 插件id
""" """
return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id) return self._execute_sync_query(
lambda session: PluginData.get_plugin_data_by_plugin_id(
session, plugin_id
)
)
async def async_get_data_all(self, plugin_id: str) -> Any: async def async_get_data_all(self, plugin_id: str) -> Any:
""" """
异步获取插件所有数据。 异步获取插件所有数据。
:param plugin_id: 插件id :param plugin_id: 插件id
""" """
return await PluginData.async_get_plugin_data_by_plugin_id(self._db, plugin_id) return await self._execute_async_query(
lambda session: PluginData.async_get_plugin_data_by_plugin_id(
session, plugin_id
)
)
+50 -45
View File
@@ -75,7 +75,7 @@ class SiteOper(DbOper):
site_id: int, site_id: int,
payload: Mapping[str, Any], payload: Mapping[str, Any],
) -> bool: ) -> bool:
"""暂存站点字段更新,不由模型装饰器提前提交。""" """暂存站点字段更新,事务由调用方统一提交。"""
site = await self.async_get(site_id) site = await self.async_get(site_id)
if not site: if not site:
return False return False
@@ -338,18 +338,22 @@ class SiteOper(DbOper):
async def async_get_icon_by_domain(self, domain: str) -> Optional[SiteIcon]: async def async_get_icon_by_domain(self, domain: str) -> Optional[SiteIcon]:
"""异步按域名获取站点图标。""" """异步按域名获取站点图标。"""
return await SiteIcon.async_get_by_domain(self._db, domain) return await self._execute_async_query(
lambda session: SiteIcon.async_get_by_domain(session, domain)
)
async def async_get_statistic_by_domain( async def async_get_statistic_by_domain(
self, self,
domain: str, domain: str,
) -> Optional[SiteStatistic]: ) -> Optional[SiteStatistic]:
"""异步按域名获取站点统计。""" """异步按域名获取站点统计。"""
return await SiteStatistic.async_get_by_domain(self._db, domain) return await self._execute_async_query(
lambda session: SiteStatistic.async_get_by_domain(session, domain)
)
async def async_list_statistics(self) -> List[SiteStatistic]: async def async_list_statistics(self) -> List[SiteStatistic]:
"""异步获取所有站点统计。""" """异步获取所有站点统计。"""
return await SiteStatistic.async_list(self._db) return await self._execute_async_query(SiteStatistic.async_list)
def get_userdata_by_date(self, date: str) -> List[SiteUserData]: def get_userdata_by_date(self, date: str) -> List[SiteUserData]:
""" """
@@ -371,7 +375,9 @@ class SiteOper(DbOper):
""" """
按域名获取站点图标 按域名获取站点图标
""" """
return SiteIcon.get_by_domain(self._db, domain) return self._execute_sync_query(
lambda session: SiteIcon.get_by_domain(session, domain)
)
def update_icon(self, name: str, domain: str, icon_url: str, icon_base64: str) -> bool: def update_icon(self, name: str, domain: str, icon_url: str, icon_base64: str) -> bool:
""" """
@@ -467,60 +473,59 @@ class SiteOper(DbOper):
""" """
异步站点访问成功 异步站点访问成功
""" """
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") async def write(session: AsyncSession) -> None:
sta = await SiteStatistic.async_get_by_domain(self._db, domain) """在同一异步事务中读取并更新站点成功统计。"""
if sta: lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 使用深复制确保 note 是全新的字典对象 sta = await SiteStatistic.async_get_by_domain(session, domain)
note = dict(sta.note) if sta.note else {} if sta:
avg_seconds = None note = dict(sta.note) if sta.note else {}
avg_seconds = None
if seconds is not None: if seconds is not None:
note[lst_date] = seconds or 1 note[lst_date] = seconds or 1
avg_times = len(note.keys()) avg_times = len(note.keys())
if avg_times > 10: if avg_times > 10:
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10]) note = dict(sorted(
avg_seconds = sum([v for v in note.values()]) // avg_times note.items(), key=lambda item: item[0], reverse=True
)[:10])
await self._stage_async_update(sta, { avg_seconds = sum(note.values()) // avg_times
"success": sta.success + 1, sta.success += 1
"seconds": avg_seconds or sta.seconds, sta.seconds = avg_seconds or sta.seconds
"lst_state": 0, sta.lst_state = 0
"lst_mod_date": lst_date, sta.lst_mod_date = lst_date
"note": note sta.note = note
}) return
else: note = {lst_date: seconds or 1} if seconds is not None else {}
note = {} session.add(SiteStatistic(
if seconds is not None:
note = {
lst_date: seconds or 1
}
await self._stage_async_create(SiteStatistic(
domain=domain, domain=domain,
success=1, success=1,
fail=0, fail=0,
seconds=seconds or 1, seconds=seconds or 1,
lst_state=0, lst_state=0,
lst_mod_date=lst_date, lst_mod_date=lst_date,
note=note note=note,
)) ))
await self._execute_async_write(write)
async def async_fail(self, domain: str): async def async_fail(self, domain: str):
""" """
异步站点访问失败 异步站点访问失败
""" """
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") async def write(session: AsyncSession) -> None:
sta = await SiteStatistic.async_get_by_domain(self._db, domain) """在同一异步事务中读取并更新站点失败统计。"""
if sta: lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
await self._stage_async_update(sta, { sta = await SiteStatistic.async_get_by_domain(session, domain)
"fail": sta.fail + 1, if sta:
"lst_state": 1, sta.fail += 1
"lst_mod_date": lst_date sta.lst_state = 1
}) sta.lst_mod_date = lst_date
else: return
await self._stage_async_create(SiteStatistic( session.add(SiteStatistic(
domain=domain, domain=domain,
success=0, success=0,
fail=1, fail=1,
lst_state=1, lst_state=1,
lst_mod_date=lst_date lst_mod_date=lst_date,
)) ))
await self._execute_async_write(write)
+90 -163
View File
@@ -11,7 +11,7 @@ import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, Tuple, List, Optional from typing import Any, Tuple, List, Optional
from sqlalchemy import delete as sqlalchemy_delete, select from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -100,25 +100,6 @@ class SubscribeOper(DbOper):
订阅管理 订阅管理
""" """
@staticmethod
def _identity_statement(identity: dict, username: Optional[str] = None):
"""构造订阅查重语句,SQL 所有权收口在 Oper。"""
condition = Subscribe._identity_condition( # pylint: disable=protected-access
identity.get("media_source"),
identity.get("media_id"),
identity.get("music_type"),
)
if condition is None or username == "":
return None
statement = select(Subscribe).where(condition)
if username:
statement = statement.where(Subscribe.username == username)
if identity.get("season") is not None:
statement = statement.where(Subscribe.season == identity["season"])
return statement.where(
Subscribe.episode_group == identity.get("episode_group")
)
def _exists(self, identity: dict, username: Optional[str]) -> Optional[Any]: def _exists(self, identity: dict, username: Optional[str]) -> Optional[Any]:
""" """
按身份查重。 按身份查重。
@@ -126,19 +107,19 @@ class SubscribeOper(DbOper):
:param username: 非空时只在该用户的订阅内查 :param username: 非空时只在该用户的订阅内查
:return: 命中的订阅行,未命中为 None :return: 命中的订阅行,未命中为 None
""" """
if isinstance(self._db, Session): if username == "":
statement = self._identity_statement(identity, username) return None
if statement is None:
return None
return self._db.execute(statement).scalars().first()
# 旧 SDK 允许无会话构造 Oper;保留其自动短会话行为,但规范入口不得走这里。
if username: if username:
return Subscribe.exists_by_username( return self._execute_sync_query(
self._db, lambda session: Subscribe.exists_by_username(
username=username, session,
**identity, username=username,
**identity,
)
) )
return Subscribe.exists(self._db, **identity) return self._execute_sync_query(
lambda session: Subscribe.exists(session, **identity)
)
async def _async_exists(self, identity: dict, username: Optional[str]) -> Optional[Any]: async def _async_exists(self, identity: dict, username: Optional[str]) -> Optional[Any]:
""" """
@@ -147,20 +128,18 @@ class SubscribeOper(DbOper):
:param username: 非空时只在该用户的订阅内查 :param username: 非空时只在该用户的订阅内查
:return: 命中的订阅行,未命中为 None :return: 命中的订阅行,未命中为 None
""" """
if isinstance(self._db, AsyncSession): async def query(session: AsyncSession) -> Optional[Subscribe]:
statement = self._identity_statement(identity, username) """在调用方或组合根异步会话中执行订阅查重。"""
if statement is None: if username == "":
return None return None
result = await self._db.execute(statement) if username:
return result.scalars().first() return await Subscribe.async_exists_by_username(
# 同步路径一样只为无会话旧入口保留 Model 的自动短会话兼容。 session,
if username: username=username,
return await Subscribe.async_exists_by_username( **identity,
self._db, )
username=username, return await Subscribe.async_exists(session, **identity)
**identity, return await self._execute_async_query(query)
)
return await Subscribe.async_exists(self._db, **identity)
def stage_add( def stage_add(
self, self,
@@ -297,24 +276,15 @@ class SubscribeOper(DbOper):
""" """
获取订阅 获取订阅
""" """
return self._execute_sync_query( return self._execute_sync_query(lambda session: Subscribe.get(session, sid))
lambda session: session.execute(
select(Subscribe).where(Subscribe.id == sid)
).scalars().first()
)
async def async_get(self, sid: int) -> Optional[Subscribe]: async def async_get(self, sid: int) -> Optional[Subscribe]:
""" """
获取订阅 获取订阅
""" """
if self._db is not None and not isinstance(self._db, (Session, AsyncSession)): return await self._execute_async_query(
# 保留旧测试替身与插件注入对象对 Model ABI 的兼容入口。 lambda session: Subscribe.async_get(session, sid)
return await Subscribe.async_get(self._db, rid=sid) )
async def query(session: AsyncSession) -> Optional[Subscribe]:
"""在调用方异步会话中执行订阅主键查询。"""
result = await session.execute(select(Subscribe).where(Subscribe.id == sid))
return result.scalars().first()
return await self._execute_async_query(query)
async def async_list_by_media_identity( async def async_list_by_media_identity(
self, self,
@@ -323,18 +293,14 @@ class SubscribeOper(DbOper):
music_type: Optional[str] = None, music_type: Optional[str] = None,
) -> List[Subscribe]: ) -> List[Subscribe]:
"""异步按规范媒体身份读取订阅。""" """异步按规范媒体身份读取订阅。"""
async def query(session: AsyncSession) -> List[Subscribe]: return await self._execute_async_query(
"""在调用方异步会话中执行媒体身份列表查询。""" lambda session: Subscribe.async_list_by_media_identity(
condition = Subscribe._identity_condition( # pylint: disable=protected-access session,
media_source, media_id, music_type media_source=media_source,
media_id=media_id,
music_type=music_type,
) )
if condition is None: )
return []
result = await session.execute(select(Subscribe).where(condition))
return list(result.scalars().all())
if isinstance(self._db, AsyncSession):
return await query(self._db)
return await self._execute_async_query(query)
def list_by_media_identity( def list_by_media_identity(
self, self,
@@ -343,15 +309,14 @@ class SubscribeOper(DbOper):
music_type: Optional[str] = None, music_type: Optional[str] = None,
) -> List[Subscribe]: ) -> List[Subscribe]:
"""同步按规范媒体身份读取订阅。""" """同步按规范媒体身份读取订阅。"""
def query(session: Session) -> List[Subscribe]: return self._execute_sync_query(
"""在调用方同步会话中执行媒体身份列表查询。""" lambda session: Subscribe.list_by_media_identity(
condition = Subscribe._identity_condition( # pylint: disable=protected-access session,
media_source, media_id, music_type media_source=media_source,
media_id=media_id,
music_type=music_type,
) )
if condition is None: )
return []
return list(session.execute(select(Subscribe).where(condition)).scalars().all())
return self._execute_sync_query(query)
async def get_candidate( async def get_candidate(
self, self,
@@ -423,18 +388,16 @@ class SubscribeOper(DbOper):
""" """
根据条件查询订阅 根据条件查询订阅
""" """
def query(session: Session) -> Optional[Subscribe]: return self._execute_sync_query(
"""在调用方同步会话中执行类型媒体查询。""" lambda session: Subscribe.get_by(
condition = Subscribe._identity_condition( # pylint: disable=protected-access session,
media_source, media_id, music_type type=type,
media_source=media_source,
media_id=media_id,
season=season,
music_type=music_type,
) )
if condition is None: )
return None
statement = select(Subscribe).where(condition, Subscribe.type == type)
if season is not None:
statement = statement.where(Subscribe.season == season)
return session.execute(statement).scalars().first()
return self._execute_sync_query(query)
async def async_get_by( async def async_get_by(
self, type: str, media_source: MediaSource, media_id: str, self, type: str, media_source: MediaSource, media_id: str,
@@ -444,55 +407,34 @@ class SubscribeOper(DbOper):
""" """
根据条件查询订阅 根据条件查询订阅
""" """
async def query(session: AsyncSession) -> Optional[Subscribe]: return await self._execute_async_query(
"""在调用方异步会话中执行类型媒体查询。""" lambda session: Subscribe.async_get_by(
condition = Subscribe._identity_condition( # pylint: disable=protected-access session,
media_source, media_id, music_type type=type,
media_source=media_source,
media_id=media_id,
season=season,
music_type=music_type,
) )
if condition is None: )
return None
statement = select(Subscribe).where(condition, Subscribe.type == type)
if season is not None:
statement = statement.where(Subscribe.season == season)
result = await session.execute(statement)
return result.scalars().first()
return await self._execute_async_query(query)
def list(self, state: Optional[str] = None) -> List[Subscribe]: def list(self, state: Optional[str] = None) -> List[Subscribe]:
""" """
获取订阅列表 获取订阅列表
""" """
if state:
return self._execute_sync_query(
lambda session: list(session.execute(
select(Subscribe).where(Subscribe.state.in_(state.split(',')))
).scalars().all())
)
return self._execute_sync_query( return self._execute_sync_query(
lambda session: list(session.execute(select(Subscribe)).scalars().all()) lambda session: Subscribe.get_by_state(session, state)
) )
async def async_list(self, state: Optional[str] = None) -> List[Subscribe]: async def async_list(self, state: Optional[str] = None) -> List[Subscribe]:
""" """
异步获取订阅列表 异步获取订阅列表
""" """
if self._db is not None and not isinstance(self._db, (Session, AsyncSession)):
if state:
return await Subscribe.async_get_by_state(self._db, state)
return await Subscribe.async_list(self._db)
if state: if state:
async def query(session: AsyncSession) -> List[Subscribe]: return await self._execute_async_query(
"""在调用方异步会话中执行状态列表查询。""" lambda session: Subscribe.async_get_by_state(session, state)
result = await session.execute( )
select(Subscribe).where(Subscribe.state.in_(state.split(','))) return await self._execute_async_query(Subscribe.async_list)
)
return list(result.scalars().all())
return await self._execute_async_query(query)
async def query_all(session: AsyncSession) -> List[Subscribe]:
"""在调用方异步会话中执行全量订阅查询。"""
result = await session.execute(select(Subscribe))
return list(result.scalars().all())
return await self._execute_async_query(query_all)
async def async_list_by_username( async def async_list_by_username(
self, self,
@@ -501,35 +443,28 @@ class SubscribeOper(DbOper):
mtype: Optional[str] = None, mtype: Optional[str] = None,
) -> List[Subscribe]: ) -> List[Subscribe]:
"""异步按用户获取订阅。""" """异步按用户获取订阅。"""
if self._db is not None and not isinstance(self._db, (Session, AsyncSession)): return await self._execute_async_query(
return await Subscribe.async_list_by_username( lambda session: Subscribe.async_list_by_username(
self._db, username=username, state=state, mtype=mtype session,
username=username,
state=state,
mtype=mtype,
) )
async def query(session: AsyncSession) -> List[Subscribe]: )
"""在调用方异步会话中执行用户筛选查询。"""
statement = select(Subscribe).where(Subscribe.username == username)
if state:
statement = statement.where(Subscribe.state == state)
if mtype:
statement = statement.where(Subscribe.type == mtype)
result = await session.execute(statement)
return list(result.scalars().all())
return await self._execute_async_query(query)
async def async_list_by_title( async def async_list_by_title(
self, self,
title: str, title: str,
season: Optional[int] = None, season: Optional[int] = None,
) -> List[Subscribe]: ) -> List[Subscribe]:
"""异步按标题获取订阅,供旧查询测试和迁移调用兼容""" """在 Oper 会话边界内异步按标题获取订阅。"""
async def query(session: AsyncSession) -> List[Subscribe]: return await self._execute_async_query(
"""在调用方异步会话中执行标题列表查询。""" lambda session: Subscribe.async_list_by_title(
statement = select(Subscribe).where(Subscribe.name == title) session,
if season is not None: title=title,
statement = statement.where(Subscribe.season == season) season=season,
result = await session.execute(statement) )
return list(result.scalars().all()) )
return await self._execute_async_query(query)
def delete(self, sid: int): def delete(self, sid: int):
""" """
@@ -598,30 +533,22 @@ class SubscribeOper(DbOper):
""" """
获取指定用户的订阅 获取指定用户的订阅
""" """
def query(session: Session) -> List[Subscribe]: return self._execute_sync_query(
"""在调用方同步会话中执行用户筛选查询。""" lambda session: Subscribe.list_by_username(
statement = select(Subscribe).where(Subscribe.username == username) session,
if state: username=username,
statement = statement.where(Subscribe.state == state) state=state,
if mtype: mtype=mtype,
statement = statement.where(Subscribe.type == mtype) )
return list(session.execute(statement).scalars().all()) )
return self._execute_sync_query(query)
def list_by_type(self, mtype: str, days: int = 7) -> List[Subscribe]: def list_by_type(self, mtype: str, days: int = 7) -> List[Subscribe]:
""" """
获取指定类型的订阅 获取指定类型的订阅
""" """
def query(session: Session) -> List[Subscribe]: return self._execute_sync_query(
"""在调用方同步会话中执行时间窗订阅查询。""" lambda session: Subscribe.list_by_type(session, mtype, days)
cutoff = time.strftime( )
"%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)),
)
return list(session.execute(select(Subscribe).where(
Subscribe.type == mtype, Subscribe.date >= cutoff
)).scalars().all())
return self._execute_sync_query(query)
def add_history(self, **kwargs): def add_history(self, **kwargs):
""" """
+8 -3
View File
@@ -2,6 +2,8 @@ import copy
import threading import threading
from typing import Any, Optional, Union from typing import Any, Optional, Union
from sqlalchemy.orm import Session
from app.db.base import DbOper from app.db.base import DbOper
from app.db.models.systemconfig import SystemConfig from app.db.models.systemconfig import SystemConfig
from app.schemas.types import SystemConfigKey from app.schemas.types import SystemConfigKey
@@ -20,12 +22,15 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
self._write_lock = threading.RLock() self._write_lock = threading.RLock()
self._loaded = False self._loaded = False
def load_snapshot(self) -> None: def load_snapshot(self, db: Optional[Session] = None) -> None:
"""数据库加载完整配置,并一次性发布新的内存快照。""" """显式会话或 Oper 事务边界加载配置并发布内存快照。"""
with self._write_lock: with self._write_lock:
items = SystemConfig.list(db) if db is not None else self._execute_sync_query(
SystemConfig.list
)
snapshot = { snapshot = {
item.key: copy.deepcopy(item.value) item.key: copy.deepcopy(item.value)
for item in SystemConfig.list(self._db) for item in items
} }
with self._snapshot_lock: with self._snapshot_lock:
self.__SYSTEMCONF = snapshot self.__SYSTEMCONF = snapshot
+1 -1
View File
@@ -269,7 +269,7 @@ class TransferHistoryOper(DbOper):
self._stage_delete(TransferHistory, historyid) self._stage_delete(TransferHistory, historyid)
def stage_delete(self, historyid: int) -> None: def stage_delete(self, historyid: int) -> None:
"""暂存整理记录删除,不由模型装饰器提交事务。""" """暂存整理记录删除,事务由调用方统一提交"""
self._db.execute( self._db.execute(
sqlalchemy_delete(TransferHistory).where( sqlalchemy_delete(TransferHistory).where(
TransferHistory.id == historyid TransferHistory.id == historyid
+10 -6
View File
@@ -27,7 +27,7 @@ class UserOper(DbOper):
""" """
获取用户列表 获取用户列表
""" """
return User.list(self._db) return self._execute_sync_query(User.list)
def add(self, **kwargs): def add(self, **kwargs):
""" """
@@ -40,15 +40,19 @@ class UserOper(DbOper):
""" """
根据用户名获取用户 根据用户名获取用户
""" """
return User.get_by_name(self._db, name) return self._execute_sync_query(
lambda session: User.get_by_name(session, name)
)
def get_by_id(self, user_id: int) -> Optional[User]: def get_by_id(self, user_id: int) -> Optional[User]:
"""按 ID 获取用户。""" """按 ID 获取用户。"""
return User.get_by_id(self._db, user_id) return self._execute_sync_query(
lambda session: User.get_by_id(session, user_id)
)
async def async_list(self) -> List[User]: async def async_list(self) -> List[User]:
"""异步获取用户列表。""" """异步获取用户列表。"""
return await User.async_list(self._db) return await self._execute_async_query(User.async_list)
async def async_create(self, payload: dict) -> Optional[User]: async def async_create(self, payload: dict) -> Optional[User]:
"""异步创建用户。""" """异步创建用户。"""
@@ -126,7 +130,7 @@ class UserOper(DbOper):
""" """
获取用户权限 获取用户权限
""" """
user = User.get_by_name(self._db, name) user = self.get_by_name(name)
if user: if user:
return user.permissions or {} return user.permissions or {}
return {} return {}
@@ -135,7 +139,7 @@ class UserOper(DbOper):
""" """
获取用户个性化设置,返回None表示用户不存在 获取用户个性化设置,返回None表示用户不存在
""" """
user = User.get_by_name(self._db, name) user = self.get_by_name(name)
if user: if user:
return user.settings or {} return user.settings or {}
return None return None
+8 -3
View File
@@ -2,6 +2,8 @@ import copy
import threading import threading
from typing import Any, Union, Dict, Optional from typing import Any, Union, Dict, Optional
from sqlalchemy.orm import Session
from app.db.base import DbOper from app.db.base import DbOper
from app.db.models.userconfig import UserConfig from app.db.models.userconfig import UserConfig
from app.schemas.types import UserConfigKey from app.schemas.types import UserConfigKey
@@ -20,11 +22,14 @@ class UserConfigOper(DbOper, metaclass=Singleton):
self._write_lock = threading.RLock() self._write_lock = threading.RLock()
self._loaded = False self._loaded = False
def load_snapshot(self) -> None: def load_snapshot(self, db: Optional[Session] = None) -> None:
"""数据库加载完整用户配置,并一次性发布新的内存快照。""" """显式会话或 Oper 事务边界加载用户配置并发布内存快照。"""
with self._write_lock: with self._write_lock:
snapshot: dict[str, dict[str, Any]] = {} snapshot: dict[str, dict[str, Any]] = {}
for item in UserConfig.list(self._db): items = UserConfig.list(db) if db is not None else self._execute_sync_query(
UserConfig.list
)
for item in items:
if item.username and item.key: if item.username and item.key:
snapshot.setdefault(item.username, {})[item.key] = copy.deepcopy( snapshot.setdefault(item.username, {})[item.key] = copy.deepcopy(
item.value item.value
+11
View File
@@ -1,6 +1,7 @@
from typing import List, Mapping, Tuple, Optional, Any, Protocol from typing import List, Mapping, Tuple, Optional, Any, Protocol
from sqlalchemy import delete as sqlalchemy_delete from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy.orm import Session
from app.db.base import DbOper from app.db.base import DbOper
from app.db.models.workflow import Workflow from app.db.models.workflow import Workflow
@@ -202,6 +203,8 @@ class WorkflowOper(DbOper):
def stage_start(self, wid: int) -> bool: def stage_start(self, wid: int) -> bool:
"""在调用方持有的会话中暂存运行中状态。""" """在调用方持有的会话中暂存运行中状态。"""
if not isinstance(self._db, Session):
raise RuntimeError("工作流暂存写入需要调用方提供同步 Session")
return Workflow.start(self._db, wid) return Workflow.start(self._db, wid)
def success(self, wid: int, result: Optional[str] = None) -> bool: def success(self, wid: int, result: Optional[str] = None) -> bool:
@@ -214,6 +217,8 @@ class WorkflowOper(DbOper):
def stage_success(self, wid: int, result: Optional[str] = None) -> bool: def stage_success(self, wid: int, result: Optional[str] = None) -> bool:
"""在调用方持有的会话中暂存成功状态。""" """在调用方持有的会话中暂存成功状态。"""
if not isinstance(self._db, Session):
raise RuntimeError("工作流暂存写入需要调用方提供同步 Session")
return Workflow.success(self._db, wid, result) return Workflow.success(self._db, wid, result)
def fail(self, wid: int, result: str) -> bool: def fail(self, wid: int, result: str) -> bool:
@@ -226,6 +231,8 @@ class WorkflowOper(DbOper):
def stage_fail(self, wid: int, result: str) -> bool: def stage_fail(self, wid: int, result: str) -> bool:
"""在调用方持有的会话中暂存失败状态。""" """在调用方持有的会话中暂存失败状态。"""
if not isinstance(self._db, Session):
raise RuntimeError("工作流暂存写入需要调用方提供同步 Session")
return Workflow.fail(self._db, wid, result) return Workflow.fail(self._db, wid, result)
def step( def step(
@@ -255,6 +262,8 @@ class WorkflowOper(DbOper):
execution_state: Optional[dict[str, Any]] = None, execution_state: Optional[dict[str, Any]] = None,
) -> bool: ) -> bool:
"""在调用方持有的会话中暂存动作进度。""" """在调用方持有的会话中暂存动作进度。"""
if not isinstance(self._db, Session):
raise RuntimeError("工作流暂存写入需要调用方提供同步 Session")
return Workflow.update_current_action( return Workflow.update_current_action(
self._db, self._db,
wid, wid,
@@ -277,4 +286,6 @@ class WorkflowOper(DbOper):
reset_count: bool = False, reset_count: bool = False,
) -> bool: ) -> bool:
"""在调用方持有的会话中暂存执行状态重置。""" """在调用方持有的会话中暂存执行状态重置。"""
if not isinstance(self._db, Session):
raise RuntimeError("工作流暂存写入需要调用方提供同步 Session")
return Workflow.reset(self._db, wid, reset_count=reset_count) return Workflow.reset(self._db, wid, reset_count=reset_count)
+6 -6
View File
@@ -1,4 +1,4 @@
"""SQLAlchemy 请求级事务适配器与 Oper 事务执行端口。""" """SQLAlchemy 请求级事务适配器与无会话 Oper 事务执行端口。"""
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Protocol, TypeVar from typing import Protocol, TypeVar
@@ -11,7 +11,7 @@ T = TypeVar("T")
class SyncTransactionRunner(Protocol): class SyncTransactionRunner(Protocol):
"""为无显式 Session 的兼容写入口提供独占同步事务。""" """为无显式 Session 的 Oper 入口提供独占同步事务。"""
def __call__(self, operation: Callable[[Session], T]) -> T: def __call__(self, operation: Callable[[Session], T]) -> T:
"""在一个独占会话中执行并提交操作。""" """在一个独占会话中执行并提交操作。"""
@@ -19,7 +19,7 @@ class SyncTransactionRunner(Protocol):
class AsyncTransactionRunner(Protocol): class AsyncTransactionRunner(Protocol):
"""为无显式 Session 的兼容写入口提供独占异步事务。""" """为无显式 Session 的 Oper 入口提供独占异步事务。"""
def __call__( def __call__(
self, self,
@@ -38,14 +38,14 @@ def configure_transaction_runners(
sync: SyncTransactionRunner, sync: SyncTransactionRunner,
async_: AsyncTransactionRunner, async_: AsyncTransactionRunner,
) -> None: ) -> None:
"""由组合根登记 Oper 兼容入口使用的显式事务执行器。""" """由组合根登记无会话 Oper 入口使用的显式事务执行器。"""
global _sync_transaction_runner, _async_transaction_runner global _sync_transaction_runner, _async_transaction_runner
_sync_transaction_runner = sync _sync_transaction_runner = sync
_async_transaction_runner = async_ _async_transaction_runner = async_
def run_sync_transaction(operation: Callable[[Session], T]) -> T: def run_sync_transaction(operation: Callable[[Session], T]) -> T:
"""委托组合根在独占同步事务中执行兼容写操作。""" """委托组合根在独占同步事务中执行 Oper 操作。"""
if _sync_transaction_runner is None: if _sync_transaction_runner is None:
raise RuntimeError("同步事务执行器尚未配置") raise RuntimeError("同步事务执行器尚未配置")
return _sync_transaction_runner(operation) return _sync_transaction_runner(operation)
@@ -54,7 +54,7 @@ def run_sync_transaction(operation: Callable[[Session], T]) -> T:
async def run_async_transaction( async def run_async_transaction(
operation: Callable[[AsyncSession], Awaitable[T]], operation: Callable[[AsyncSession], Awaitable[T]],
) -> T: ) -> T:
"""委托组合根在独占异步事务中执行兼容写操作。""" """委托组合根在独占异步事务中执行 Oper 操作。"""
if _async_transaction_runner is None: if _async_transaction_runner is None:
raise RuntimeError("异步事务执行器尚未配置") raise RuntimeError("异步事务执行器尚未配置")
return await _async_transaction_runner(operation) return await _async_transaction_runner(operation)
+3 -4
View File
@@ -1,9 +1,8 @@
"""把旧整理历史 Oper 的业务写入方法转交给应用服务。""" """把旧整理历史 Oper 的业务写入方法转交给应用服务。"""
from typing import Optional from typing import Any, Optional
from app.application.history import add_transfer_fail, add_transfer_success from app.application.history import add_transfer_fail, add_transfer_success
from app.db.models.transferhistory import TransferHistory
from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper
from app.domain.context import MediaInfo, MusicInfo from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase from app.domain.meta.metabase import MetaBase
@@ -23,7 +22,7 @@ class TransferHistoryOper(CanonicalTransferHistoryOper):
transferinfo: TransferInfo, transferinfo: TransferInfo,
downloader: Optional[str] = None, downloader: Optional[str] = None,
download_hash: Optional[str] = None, download_hash: Optional[str] = None,
) -> Optional[TransferHistory]: ) -> Optional[Any]:
""" """
按旧签名新增整理成功历史 按旧签名新增整理成功历史
@@ -49,7 +48,7 @@ class TransferHistoryOper(CanonicalTransferHistoryOper):
transferinfo: Optional[TransferInfo] = None, transferinfo: Optional[TransferInfo] = None,
downloader: Optional[str] = None, downloader: Optional[str] = None,
download_hash: Optional[str] = None, download_hash: Optional[str] = None,
) -> Optional[TransferHistory]: ) -> Optional[Any]:
""" """
按旧签名新增整理失败历史 按旧签名新增整理失败历史
+1 -2
View File
@@ -3,7 +3,6 @@
from typing import Any, Optional from typing import Any, Optional
from app.application.subscription.write import add_subscribe, async_add_subscribe from app.application.subscription.write import add_subscribe, async_add_subscribe
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper as CanonicalSubscribeOper from app.db.oper.subscribe import SubscribeOper as CanonicalSubscribeOper
from app.domain.context import MediaInfo, MusicInfo from app.domain.context import MediaInfo, MusicInfo
@@ -72,4 +71,4 @@ class SubscribeOper(CanonicalSubscribeOper):
) )
__all__ = ["Subscribe", "SubscribeOper"] __all__ = ["SubscribeOper"]
-2
View File
@@ -11,12 +11,10 @@ from app.api.deps import (
get_current_user_async, get_current_user_async,
) )
from app.db.oper.user import UserOper from app.db.oper.user import UserOper
from app.db.models.user import User
__all__ = [ __all__ = [
"UserOper", "UserOper",
"User",
"get_current_active_manage_user", "get_current_active_manage_user",
"get_current_active_manage_user_async", "get_current_active_manage_user_async",
"get_current_active_superuser", "get_current_active_superuser",
+4 -2
View File
@@ -184,9 +184,11 @@ def prepare_backend() -> None:
init_db() init_db()
from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.userconfig import UserConfigOper from app.db.oper.userconfig import UserConfigOper
from app.db.session import SessionFactory
SystemConfigOper().load_snapshot() with SessionFactory() as session:
UserConfigOper().load_snapshot() SystemConfigOper().load_snapshot(session)
UserConfigOper().load_snapshot(session)
# 缓存装饰器在测试模块导入时即创建后端,先装配隔离配置对应的适配器。 # 缓存装饰器在测试模块导入时即创建后端,先装配隔离配置对应的适配器。
from app.startup.initializers.cache import configure_cache_dependencies from app.startup.initializers.cache import configure_cache_dependencies
configure_cache_dependencies() configure_cache_dependencies()
+5 -4
View File
@@ -379,10 +379,11 @@ flowchart LR
`db/adapters/subscription.py` 创建独占 Session`startup/composition/subscription.py` 只装配回调, `db/adapters/subscription.py` 创建独占 Session`startup/composition/subscription.py` 只装配回调,
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()` `application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。 只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
`transaction-debt-baseline.json` 当前要求正式只读查询装饰器保持为 0;原有同步/异步写装饰器 `transaction-debt-baseline.json` 要求 Model 上的查询/写装饰器持续保持为 0。Model 与 Base
全部移除,`db_update``async_db_update` 必须持续保持为 0。下载/整理历史的旧插件 Model 不再导入数据库装饰器,所有 `db` 参数都要求显式 Session;这些方法只查询或 stage,不能
与工作流、媒体服务器、站点用户数据、PassKey、SubscribeHistory 旧插件 Model 调用由 `legacy_*` 兼容外壳承接,宿主 Oper 必须显式传递 Session。宿主 Oper 也不得调用 Base 保留的 创建、提交、回滚或关闭事务。无会话入口只存在于 Oper,由 `_execute_*` 经组合根事务执行器
`create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。 承接;内置插件必须调用 Oper,不得直接导入宿主 Model。AST 门禁同时约束装饰器、可选 Session
和插件到 Model 的依赖,保证提交权不会被底层抢走。
- 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application - 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application
Command/Service 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从 Command/Service 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从
`startup/initializers/plugins.py` 注入事务能力,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。 `startup/initializers/plugins.py` 注入事务能力,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。
@@ -377,12 +377,12 @@ app/chain/transfer.py # 保持 TransferChain 兼容门面
- `app/api/endpoints/subscribe.py` 直接持有 Session、模型和 Oper 是治理前证据;当前 endpoint→Session/Model 目标边已清零。 - `app/api/endpoints/subscribe.py` 直接持有 Session、模型和 Oper 是治理前证据;当前 endpoint→Session/Model 目标边已清零。
- Chain、Scheduler、Application 的模型直连属于治理前扫描结果;当前目标 Application/Chain/Runtime→DB 边均为零。 - Chain、Scheduler、Application 的模型直连属于治理前扫描结果;当前目标 Application/Chain/Runtime→DB 边均为零。
- `app/db/models/subscribe.py:121` 起在 ORM 模型上定义查询方法,并通过 `@db_query` 等装饰器执行数据库访问 - ORM Model 仍保留贴近表结构的查询原语,但已全部要求调用方显式传入 Session;Model/Base 的查询、写入和 legacy 事务装饰器均已清零
- `app/db/__init__.py` 的根入口和模型回流曾参与 DB SCC;该自有 SCC 已消除,旧根入口仅作为兼容边界保留。 - `app/db/__init__.py` 的根入口和模型回流曾参与 DB SCC;该自有 SCC 已消除,旧根入口仅作为兼容边界保留。
#### 问题本质 #### 问题本质
前同时存在三种数据访问风格: 治理前同时存在三种数据访问风格:
1. `db/oper` 服务。 1. `db/oper` 服务。
2. ORM 模型类方法。 2. ORM 模型类方法。
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md``docs/rules/` 高于本文 > 规范优先级:`AGENTS.md``docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md``docs/refactor/backend-architecture-governance.md``docs/refactor/backend-module-refactor-compatibility.md` > 相关文档:`docs/architecture-overview.md``docs/refactor/backend-architecture-governance.md``docs/refactor/backend-module-refactor-compatibility.md`
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配Outbox 外围扩展和 Model 查询兼容面仍按风险切片推进。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权。 > 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权。
## 当前复核结论(2026-08-23 ## 当前复核结论(2026-08-23
@@ -15,7 +15,7 @@
### 长期整改阶段 0:治理门禁恢复(2026-08-23 ### 长期整改阶段 0:治理门禁恢复(2026-08-23
- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `805` 个模块、`6525` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。 - 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `805` 个模块、`6500` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
- 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。 - 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。
- 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。 - 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。
- SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。 - SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。
@@ -72,7 +72,7 @@
- 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。 - 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。
- `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。 - `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。
- 依赖图当前为 `805` 个 Python 模块、`6525` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 - 依赖图当前为 `805` 个 Python 模块、`6500` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
- 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。 - 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。
综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。 综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。
@@ -81,7 +81,7 @@
1. **后台任务的统一所有权已覆盖 API 入口,但仍有更深层任务机制待分级。** `app/runtime/tasks.py` 已建立 lifespan 级 TaskRegistry,启动收尾、插件 Release 刷新、Webhook E0 广播、CookieCloud E1 手工调度、消息入口、Seerr 订阅、整理历史 AI 重做、OpenAI/Anthropic 协议流和 WebAgent 断线后执行/快照保存均不再维护端点模块级任务集合或 Starlette 回调,shutdown 会停止接收、取消并有限等待,且生命周期清单明确登记其顺序。主仓 `app/` 已无裸 FastAPI `BackgroundTasks`;当前仍有约 `50` 个更底层 `create_task`/等价任务创建点,与线程池和 APScheduler 并存,后续需逐项确认 owner、取消、等待、重试、幂等和是否 durable,关键业务副作用优先接入已有 Outbox/恢复表。 1. **后台任务的统一所有权已覆盖 API 入口,但仍有更深层任务机制待分级。** `app/runtime/tasks.py` 已建立 lifespan 级 TaskRegistry,启动收尾、插件 Release 刷新、Webhook E0 广播、CookieCloud E1 手工调度、消息入口、Seerr 订阅、整理历史 AI 重做、OpenAI/Anthropic 协议流和 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 是兼容要求,不应删除;宿主高频能力则应逐族补齐可执行的输入校验、结果校验、超时和错误语义。 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 已完成正式装饰器清零。** 写事务装饰器和正式 `db_query/async_db_query` 均为 `0`。站点、消息、用户、订阅、下载/整理历史、工作流、MediaServer、SiteUserData、AgentChat、AgentTaskRun、TransferPending、SystemConfig、PassKey 和 SubscribeHistory 的宿主查询已迁到显式 Session 路径;对应旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。后续重点转为减少 ORM 对象跨层流转,并保持正式装饰器零回退。 3. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。
4. **组合根和全局状态仍形成复杂的隐式运行时图。** Singleton 实例、模块级 provider、`configure_*` 注册函数和兼容 Facade 同时存在;它们解决了旧 ABI 和启动顺序问题,但增加测试污染、重复装配、实例身份和初始化顺序风险。`app/startup/lifecycle/__init__.py` 已有声明式生命周期,`app/startup/initializers/modules.py` 也有分阶段关闭,但尚未做到所有进程级资源都只通过 typed HostRuntime 访问。后续应以“新代码禁止新增 Service Locator/Singleton 依赖、旧入口有命中观测”为 ratchet。 4. **组合根和全局状态仍形成复杂的隐式运行时图。** Singleton 实例、模块级 provider、`configure_*` 注册函数和兼容 Facade 同时存在;它们解决了旧 ABI 和启动顺序问题,但增加测试污染、重复装配、实例身份和初始化顺序风险。`app/startup/lifecycle/__init__.py` 已有声明式生命周期,`app/startup/initializers/modules.py` 也有分阶段关闭,但尚未做到所有进程级资源都只通过 typed HostRuntime 访问。后续应以“新代码禁止新增 Service Locator/Singleton 依赖、旧入口有命中观测”为 ratchet。
### P2:中长期可演进性债务 ### P2:中长期可演进性债务
@@ -108,7 +108,7 @@
1. 未知第三方插件自定义模块方法继续走 `legacy` fallback,不能因宿主契约收口而拒绝加载旧插件。 1. 未知第三方插件自定义模块方法继续走 `legacy` fallback,不能因宿主契约收口而拒绝加载旧插件。
2. `PluginManager``PluginHelper``MoviePilotServerHelper` 等 Facade 继续保留旧公开/私有调用面,并通过 `compat.facade.hit` 统计迁移命中。 2. `PluginManager``PluginHelper``MoviePilotServerHelper` 等 Facade 继续保留旧公开/私有调用面,并通过 `compat.facade.hit` 统计迁移命中。
3. `app/runtime/compat` 的精确旧导入映射、`app.sdk._legacy` 薄门面和插件 V1/V2/V3 三代索引继续存在,直到命中数据和发行策略支持删除。 3. `app/runtime/compat` 的精确旧导入映射、`app.sdk._legacy` 薄门面和插件 V1/V2/V3 三代索引继续存在,直到命中数据和发行策略支持删除。
4. 既有查询 Model 方法保留只读兼容入口;宿主 Oper 必须走显式 Session`legacy_*` 只服务旧插件 ABI,不得成为新 Model 方法的默认模式 4. 插件访问宿主持久化必须经过 Oper 或稳定 SDK;不再保留直接调用宿主 Model 的事务兼容
### 建议的后续治理顺序 ### 建议的后续治理顺序
@@ -184,7 +184,7 @@ MoviePilot V3 当前不是“目录混乱、必须推倒重来”的状态。第
| 专用 EventData model | 53 | Event Contract Registry 已为全部事件登记 typed payload/fallback 原因 | | 专用 EventData model | 53 | Event Contract Registry 已为全部事件登记 typed payload/fallback 原因 |
| 直接读取 `settings` 的文件 | 105 | 仍按模块族迁移,动态协议和安全端口暂保留 | | 直接读取 `settings` 的文件 | 105 | 仍按模块族迁移,动态协议和安全端口暂保留 |
| `SystemConfigOper()` | 1 个 | 仅组合根创建 `SystemConfigService` 时保留 | | `SystemConfigOper()` | 1 个 | 仅组合根创建 `SystemConfigService` 时保留 |
| Model 上的正式 DB 查询装饰器 | 0 | 查询/写装饰器均保持为 0旧插件只读 ABI 由 `legacy_*` 外壳承接 | | Model/Base 上的 DB 装饰器 | 0 | 正式与 legacy 查询/写装饰器全部为 0`db` 参数必须显式传入 |
| 路由端点 | 335 | 11 个已装饰端点超过 80 行,最大 400 行 | | 路由端点 | 335 | 11 个已装饰端点超过 80 行,最大 400 行 |
| Chain 方法超过 150 行 | 18 | 最大 `TransferChain.do_transfer()` 885 行 | | Chain 方法超过 150 行 | 18 | 最大 `TransferChain.do_transfer()` 885 行 |
| Application 方法超过 150 行 | 8 | 最大 296 行 | | Application 方法超过 150 行 | 8 | 最大 296 行 |
@@ -230,8 +230,8 @@ MoviePilot V3 当前不是“目录混乱、必须推倒重来”的状态。第
| 对标来源 | 可复用实践 | MoviePilot 当前差距 | 采用方式 | | 对标来源 | 可复用实践 | MoviePilot 当前差距 | 采用方式 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| [FastAPIBigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/) | Router、依赖和主应用分离;路由按领域聚合 | Router 已分文件,但 `app/api/deps.py` 集中 33 个依赖工厂,部分端点仍编排完整用例 | 保留现有 Router;按垂直切片拆依赖和 presentation mapper,不重做目录树 | | [FastAPIBigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/) | Router、依赖和主应用分离;路由按领域聚合 | Router 已分文件,但 `app/api/deps.py` 集中 33 个依赖工厂,部分端点仍编排完整用例 | 保留现有 Router;按垂直切片拆依赖和 presentation mapper,不重做目录树 |
| [FastAPI 官方 Full Stack Template](https://github.com/fastapi/full-stack-fastapi-template/tree/master/backend/app) | 请求依赖提供 Session,测试和迁移入口明确 | MoviePilot 已有请求 Session 和 UoW但大量 Model 方法仍自行取得 Session/commit | 将 Session 生命周期留在请求/作业边界,Repository 只登记变更 | | [FastAPI 官方 Full Stack Template](https://github.com/fastapi/full-stack-fastapi-template/tree/master/backend/app) | 请求依赖提供 Session,测试和迁移入口明确 | MoviePilot 已有请求 Session 和 UoWModel 隐式事务已清零;仍需继续减少 ORM 对象跨层流转 | 将 Session 生命周期留在请求/作业边界,Repository 只登记变更 |
| [SQLAlchemy Session Basics](https://docs.sqlalchemy.org/en/20/orm/session_basics.html) | Session/事务生命周期应与具体数据操作分离;Session per thread、AsyncSession per task | `@db_update`/`@async_db_update` 隐式创建和提交,跨多个 Repository 的原子性不清晰 | 新写用例强制请求/任务级 UoW;Model 逐步变为映射和约束载体 | | [SQLAlchemy Session Basics](https://docs.sqlalchemy.org/en/20/orm/session_basics.html) | Session/事务生命周期应与具体数据操作分离;Session per thread、AsyncSession per task | Model/Base 已要求显式 Session;无会话 Oper 仍依赖组合根事务执行器 | 新写用例强制请求/任务级 UoW;持续禁止 Model 重新拥有事务 |
| [Starlette Lifespan](https://www.starlette.io/lifespan/) | Lifespan 完成前不接流量;用 typed state 共享进程资源;用 task group 管理异步任务 | 已有声明式生命周期,但仍依赖多个模块全局注册表和裸 `create_task`/线程 | 建立类型化 `HostRuntime/AppState`,旧 provider 继续作兼容门面 | | [Starlette Lifespan](https://www.starlette.io/lifespan/) | Lifespan 完成前不接流量;用 typed state 共享进程资源;用 task group 管理异步任务 | 已有声明式生命周期,但仍依赖多个模块全局注册表和裸 `create_task`/线程 | 建立类型化 `HostRuntime/AppState`,旧 provider 继续作兼容门面 |
| [Uvicorn Deployment](https://www.uvicorn.org/deployment/) 与 [Lifespan](https://www.uvicorn.org/concepts/lifespan/) | reload/workers 使用 import string/factory;每个 worker 独立执行 lifespan | 当前 app 实例与 reload/workers 配置并存,多 worker 会重复控制面 | V3 先明确只支持单 worker;开发 reload 改为 factory/import string;未来再拆 control role | | [Uvicorn Deployment](https://www.uvicorn.org/deployment/) 与 [Lifespan](https://www.uvicorn.org/concepts/lifespan/) | reload/workers 使用 import string/factory;每个 worker 独立执行 lifespan | 当前 app 实例与 reload/workers 配置并存,多 worker 会重复控制面 | V3 先明确只支持单 worker;开发 reload 改为 factory/import string;未来再拆 control role |
| [Home AssistantIntegration Quality Scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/) | 插件/集成按可测试性、错误处理、异步安全、类型和文档分级;豁免必须说明 | Module 能力差异大,只有统一发现和方法名快照,没有每个集成的质量状态 | 为宿主 Module 建立轻量质量清单和逐项 ratchet,不阻塞历史模块运行 | | [Home AssistantIntegration Quality Scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/) | 插件/集成按可测试性、错误处理、异步安全、类型和文档分级;豁免必须说明 | Module 能力差异大,只有统一发现和方法名快照,没有每个集成的质量状态 | 为宿主 Module 建立轻量质量清单和逐项 ratchet,不阻塞历史模块运行 |
@@ -532,22 +532,16 @@ flowchart TB
- `app/application/subscription/write.py` 定义用例 Port`app/db/adapters/subscription.py` 为每次规范新增创建独占同步/异步 Session,`app/startup/composition/subscription.py` 只负责注入; - `app/application/subscription/write.py` 定义用例 Port`app/db/adapters/subscription.py` 为每次规范新增创建独占同步/异步 Session,`app/startup/composition/subscription.py` 只负责注入;
`CreateSubscriptionCommand` / `AsyncCreateSubscriptionCommand` 持有 UoWOper 只执行 `CreateSubscriptionCommand` / `AsyncCreateSubscriptionCommand` 持有 UoWOper 只执行
查重、`add``flush` 查重、`add``flush`
- `SubscribeOper.stage_add()` 的查重 SQL 已收口到 Oper,不再调用 Model 自动会话装饰器; - `SubscribeOper.stage_add()` 的查重 SQL已收口到 Oper;无会话构造 `SubscribeOper()` 时由
无会话构造 `SubscribeOper()` 的旧 SDK 路径保留原自动短会话和返回值,未扩散为规范入口 Oper 的 `_execute_*` 委托组合根事务执行器,Model 不再创建会话
- Chain 把原有“成功消息 → `SubscribeAdded` 事件 → Server 统计”作为显式 post-commit - Chain 把原有“成功消息 → `SubscribeAdded` 事件 → Server 统计”作为显式 post-commit
回调交给 Commandcommit/flush 失败回滚,事件或上报失败只传播原异常,不回滚已提交记录。 回调交给 Commandcommit/flush 失败回滚,事件或上报失败只传播原异常,不回滚已提交记录。
- 同步/异步 `SubscribeChain.add` 方法长度从各 203 行降至 183/186 行;新增 9 个事务边界测试, - 同步/异步 `SubscribeChain.add` 方法长度从各 203 行降至 183/186 行;新增 9 个事务边界测试,
覆盖成功顺序、commit/flush 失败、重复请求、Oper 不提交、事件失败、上报失败与真实落库。 覆盖成功顺序、commit/flush 失败、重复请求、Oper 不提交、事件失败、上报失败与真实落库。
- Model 查询装饰器此前为 123 个:本切片绕开了继承自 `Base.create/async_create` 的自动提交, - Model 查询装饰器曾有 123 个,分切片迁移后已连同 Base 的 12 个 legacy 查询/写装饰器全部删除。
并继续保留既有 Model/旧 SDK 查询兼容;本次 AgentTask 切片将查询装饰器减少到 121 个。 `AgentTask`、PassKey 等 Model 方法保留查询语义,但签名统一要求显式 Session;无 Session 使用方式
2026-08-23 已完成 AgentTask 查询切片:`AgentTaskOper.get/list` 直接在调用方 Session 中执行查询, 只在对应 Oper 上存在,由组合根事务执行器承接。归属过滤、启用状态过滤和稳定排序继续由显式
`AgentTask.get_for_user/list_for_user` 保留原签名和返回语义供旧调用方使用,但不再持有查询装饰器; Session 的 Model 测试与无会话 Oper 测试共同覆盖。
无 Session 的旧 Oper 入口继续由组合根兼容事务执行器承接。随后 PassKey 的宿主同步查询迁移到
`PassKeyOper`,其按用户/凭证的启用状态过滤由显式 Session 测试覆盖;异步 Model 查询保留旧 ABI。
查询装饰器低水位由 123 降至 119,归属过滤、启用状态过滤和创建时间/主键稳定排序由 canonical
Oper 测试覆盖。`PassKey.get_by_user_id/get_by_credential_id`
`AgentTask.get_for_user/list_for_user` 同时保留旧插件省略 Session 的同步调用方式;该路径显式委托
一次性兼容查询会话,不重新增加 Model 查询装饰器,也不影响宿主显式 Session 的事务所有权。
#### ARCH-222:按风险迁移其余写用例 #### ARCH-222:按风险迁移其余写用例
@@ -971,9 +965,9 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
的旧 Oper ABI 委托 Startup 注入的短事务执行器。当前 Model 正式查询装饰器仅剩 30 个(同步 16、异步 14), 的旧 Oper ABI 委托 Startup 注入的短事务执行器。当前 Model 正式查询装饰器仅剩 30 个(同步 16、异步 14),
`db_update``async_db_update` 均为 0Oper 自建 Session/直接提交仍为 0。 `db_update``async_db_update` 均为 0Oper 自建 Session/直接提交仍为 0。
- 数据清理按批次显式提交 UoW,单表失败先回滚会话再继续汇总后续表;不再依赖删除 Model 的隐式提交。 - 数据清理按批次显式提交 UoW,单表失败先回滚会话再继续汇总后续表;不再依赖删除 Model 的隐式提交。
- 收尾批次进一步移除宿主 Oper 对 `Base.create/update/delete/truncate` 八个兼容包装器的调用:显式 - 收尾批次进一步移除宿主 Oper 对 `Base.create/update/delete/truncate` 隐式提交语义的依赖:显式
Session 只 stage,由 Application UoW 提交;无 Session 的 Oper 入口委托 Startup 的短事务执行器。 Session 只 stage,由 Application UoW 提交;无 Session 的 Oper 入口委托 Startup 的短事务执行器。
Base 包装器继续保留给插件/旧模型 ABI,新增 AST 门禁禁止宿主 Oper 回退到隐式提交 Base 方法最终改成纯显式 Session 原语,AST 门禁禁止 Model/Base 再引入装饰器或可选 Session
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。 **禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
@@ -1237,49 +1231,21 @@ Settings 读取作为基础设施边界,架构基线已明确记录该例外
2026-08-23 收口兼容回归:`RuntimeSettingsCompat` 补齐 `update_setting``update_settings` 2026-08-23 收口兼容回归:`RuntimeSettingsCompat` 补齐 `update_setting``update_settings`
`model_dump` 旧 Settings ABI,并由应用组合根注入服务对象,低层 runtime 不再反向导入 `app.application` `model_dump` 旧 Settings ABI,并由应用组合根注入服务对象,低层 runtime 不再反向导入 `app.application`
`SkillHelper` 的技能市场写入继续经过兼容代理,旧插件/测试的模块级替换语义保持。`UserConfigOper` `SkillHelper` 的技能市场写入继续经过兼容代理,旧插件/测试的模块级替换语义保持。`UserConfigOper`
无 Session 查询改为一次性兼容查询会话,显式 Session 仍由调用方持有。配置债务稳定为 8 个文件,Model 无 Session 查询由组合根事务执行器创建一次性会话,显式 Session 仍由调用方持有。配置债务稳定为
查询装饰器在消息、用户和订阅查询切片后曾降至 75 个且写装饰器为 0;四分片全量测试 `5492 passed, 3 skipped`mypy、复杂度、异步阻塞、 8 个文件;Model 查询装饰器在消息、用户和订阅查询切片后曾降至 75 个,随后已全部清零。该阶段
四分片全量测试 `5492 passed, 3 skipped`mypy、复杂度、异步阻塞、
host/plugin 架构基线均通过。 host/plugin 架构基线均通过。
2026-08-23 完成下载/整理历史查询切片:`TransferHistoryOper``DownloadHistoryOper` 的正式入口统一 2026-08-23 分阶段完成下载/整理历史、Workflow、MediaServer、SiteUserData、AgentChat、AgentTaskRun、
通过 `_execute_sync_query` / `_execute_async_query` 复用调用方 Session,正式查询装饰器由 75 降至 TransferPending、SystemConfig、PassKey 与 SubscribeHistory 查询切片:宿主 Oper 统一通过
38 个且写装饰器保持 0。旧插件仍可直接调用 Model 方法;`legacy_db_query` / `legacy_async_db_query` `_execute_sync_query` / `_execute_async_query` 复用调用方 Session,正式 Model 查询装饰器由 75 逐步降至
按签名插入一次性会话,兼容无 Session 的位置参数和关键字参数,同时显式 Session 不创建额外会话 0。各阶段显式 Session 查询、过滤语义、架构基线和全量测试均有回归记录
历史查询、删除工具、类型门禁和插件架构专项共 `101 passed`host/plugin 架构基线通过。
2026-08-23 完成 Workflow 查询切片:`WorkflowOper` 的同步/异步查询入口统一通过 2026-08-23 在正式装饰器清零后继续删除过渡性的 `legacy_db_query``legacy_async_db_query`
`_execute_sync_query` / `_execute_async_query` 复用调用方 Session,正式查询装饰器由 38 降至 `legacy_db_update``legacy_async_db_update`Base 与全部 Model 只接受显式 Session,不再替无会话调用
30 个且写装饰器保持 0。旧插件仍可直接调用 Workflow Model 方法,显式 Session 与无 Session 的 创建或提交事务。原先把 `self._db=None` 直传 Model 的 User、PluginData、Subscribe、Site、配置和下载失败
关键字调用均有回归覆盖;Workflow、架构基线专项共 `76 passed`host/plugin 架构基线通过。 Oper 已迁到 `_execute_*`,插件 SDK 也移除了 User、Subscribe、TransferHistory Model 导出。架构测试新增
三项硬约束:Model/Base 不得导入 DB 装饰器、`db` 参数不得可选、插件 SDK 不得导入 `app.db.models`
2026-08-23 完成 MediaServer 与 SiteUserData 查询切片:`MediaServerOper``SiteOper` 的同步/异步
查询入口统一通过 `_execute_sync_query` / `_execute_async_query` 复用调用方 Session,正式查询装饰器
由 30 降至 18 个(同步 9、异步 9),写装饰器保持 0。旧插件仍可直接调用对应 Model 方法,显式
Session 不创建额外会话,无 Session 的位置参数和关键字参数继续由 `legacy_*` 外壳兼容;专项测试
`158 passed`,四分片全量测试 `5539 passed, 3 skipped`host/plugin 架构基线和 Pylint 均通过。
2026-08-23 完成 AgentChat 与 AgentTaskRun 查询切片:`AgentChatOper``AgentTaskOper` 的查询入口
统一通过 `_execute_sync_query` / `_execute_async_query` 复用调用方 Session,正式查询装饰器由 18
降至 12 个(同步 5、异步 7),写装饰器保持 0。旧插件仍可直接调用对应 Model 方法,显式 Session
不创建额外会话,无 Session 的关键字调用继续由 `legacy_*` 外壳兼容;专项测试 `44 passed`,四分片
全量测试 `5543 passed, 4 skipped`
2026-08-23 完成 TransferPending 与 SystemConfig 查询切片:待整理回放和系统配置的宿主查询统一
复用调用方 Session,正式查询装饰器由 12 降至 9 个(同步 3、异步 6),写装饰器保持 0。旧插件
仍可直接调用对应 Model 方法,显式 Session 不创建额外会话,无 Session 的关键字调用继续由
`legacy_*` 外壳兼容;专项与架构测试 `146 passed`,四分片全量测试 `5547 passed, 3 skipped`
host/plugin 架构基线和 Pylint 均通过。
2026-08-23 完成 PassKey 查询切片:三个异步查询与按 ID 同步查询改由 `legacy_*` 外壳承接,
正式查询装饰器由 9 降至 5 个(同步 2、异步 3),写装饰器保持 0。显式 Session/AsyncSession
不创建额外会话,旧插件无 Session 的位置与关键字调用仍保持兼容;专项与架构测试 `99 passed`
四分片全量测试 `5549 passed, 3 skipped`host/plugin 架构基线和 Pylint 均通过。
2026-08-23 完成 SubscribeHistory 查询切片:同步/异步分页、owner 筛选和存在性查询均由
`legacy_*` 外壳保留旧插件 ABI`SubscribeHistoryOper``SubscribeOper.exist_history` 统一复用
显式 Session/AsyncSession,且按 ID 查询不再调用 Base 查询包装器。正式查询装饰器由 5 降至 0,
同步/异步写装饰器继续保持 0;专项与架构测试 `176 passed`(另有 11 个子测试),四分片全量
测试 `5551 passed, 3 skipped`host/plugin 架构基线和 Pylint 均通过。
#### ARCH-272:异步阻塞检测 #### ARCH-272:异步阻塞检测
@@ -1458,7 +1424,7 @@ rollback:
| 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope | | 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope |
| 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 | | 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 |
| 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 | | 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 |
| Model 事务装饰器 | 正式查询/写装饰器均为 0 | 持续保持为 0;兼容外壳不得被宿主新增调用 | | Model/Base 事务装饰器 | 正式与 legacy 查询/写装饰器均为 0 | 持续保持为 0;`db` 参数保持显式必传 |
| 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW | | 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW |
| 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 | | 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 |
| Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict | | Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict |
+15 -15
View File
@@ -91,21 +91,21 @@ adapters; it does not retain reusable repository implementations.
- `tests/fixtures/architecture/transaction-debt-baseline.json` records formal - `tests/fixtures/architecture/transaction-debt-baseline.json` records formal
decorators in concrete files under `app/db/models/`. Their count is zero and decorators in concrete files under `app/db/models/`. Their count is zero and
must remain zero. Compatibility-only `legacy_*` shells are tracked separately must remain zero. Model/Base code may not import `app.db.decorators`; legacy
and must never be treated as the target design. Model transaction shells have been removed and must not be recreated.
- `legacy_db_query` / `legacy_async_db_query` preserve an existing plugin-facing - Every Model method with a `db` parameter requires an explicit `Session` or
Model method whose no-Session call shape cannot be removed yet. If a Model `AsyncSession`. The parameter may not default to `None`, accept displaced
method has no external ABI obligation, move the query into its Oper and remove business arguments, create a Session, or call `commit()` / `rollback()`.
the Model method instead of adding `legacy_*`. - `Base.create/get/update/delete/list/truncate` and their async forms are plain
- `Base.create/get/update/delete/list/truncate` and their async forms are inherited explicit-session primitives. They only query or stage changes in the caller's
plugin ABI, so `app/db/base.py` deliberately uses legacy query/write wrappers. transaction; they never own transaction lifecycle.
New host code must not call these convenience methods; Oper staging methods and - Host Oper code routes optional-session entry points through
explicit UoW are the canonical path. Removal requires plugin-usage evidence and `_execute_sync_query` / `_execute_async_query` / `_execute_*_write`. Plugins
a separately announced compatibility break, not a mechanical rename. access host persistence through Oper or a curated SDK contract, never by
- Host Oper code must pass an explicit Session through `_execute_sync_query` / importing `app.db.models`.
`_execute_async_query`; new Model methods must not add any legacy decorator. - The public `db_query`, `db_update`, `async_db_query`, and `async_db_update`
- New Model methods must not use `db_query`, `db_update`, `async_db_query`, or exports remain available only for plugin-owned database functions. They are
`async_db_update`, create a Session, or call `commit()` / `rollback()`. forbidden on host Model/Base methods.
- Oper receives a caller-owned Session and may query, add, update, delete, or - Oper receives a caller-owned Session and may query, add, update, delete, or
flush. A composable Oper method must not create its own Session and must not flush. A composable Oper method must not create its own Session and must not
commit or roll back. commit or roll back.
+27 -8
View File
@@ -5,8 +5,11 @@
""" """
import asyncio import asyncio
import sys import sys
from collections.abc import Awaitable, Callable
from typing import TypeVar
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# 必须早于首个牵入 app.runtime.config 的 importapp.db / app.chain.* 都会牵入):引擎本身已惰性, # 必须早于首个牵入 app.runtime.config 的 importapp.db / app.chain.* 都会牵入):引擎本身已惰性,
@@ -21,6 +24,9 @@ prepare_backend()
from app.testing.network_guard import block_real_network # noqa: E402,F401 from app.testing.network_guard import block_real_network # noqa: E402,F401
TResult = TypeVar("TResult")
class _TestDatabaseExecutor: class _TestDatabaseExecutor:
"""让绕过完整 lifespan 的测试仍通过线程执行同步数据库写入。""" """让绕过完整 lifespan 的测试仍通过线程执行同步数据库写入。"""
@@ -89,9 +95,10 @@ def configure_plugin_system_services():
configure_token_runtime_config(lambda: build_token_runtime_config(settings)) configure_token_runtime_config(lambda: build_token_runtime_config(settings))
database_executor = _TestDatabaseExecutor() database_executor = _TestDatabaseExecutor()
system_config = SystemConfigOper() system_config = SystemConfigOper()
system_config.load_snapshot()
user_config = UserConfigOper() user_config = UserConfigOper()
user_config.load_snapshot() with SessionFactory() as session:
system_config.load_snapshot(session)
user_config.load_snapshot(session)
configure_system_config( configure_system_config(
SystemConfigService( SystemConfigService(
repository=system_config, repository=system_config,
@@ -159,14 +166,12 @@ def configure_plugin_system_services():
from app.db.adapters.workflow import TransactionalWorkflowExecutionService from app.db.adapters.workflow import TransactionalWorkflowExecutionService
from app.db.adapters.transaction import TransactionalWriteRunner from app.db.adapters.transaction import TransactionalWriteRunner
def compatibility_sync_session() -> Session: def create_sync_session() -> Session:
"""动态读取可被存量隔离数据库用例替换的 ScopedSession。""" """为无显式会话的 Oper 测试入口创建独占同步 Session。"""
from app.db import decorators return SessionFactory()
return decorators.ScopedSession()
transaction_runner = TransactionalWriteRunner( transaction_runner = TransactionalWriteRunner(
sync_session=compatibility_sync_session, sync_session=create_sync_session,
async_session=async_session_scope, async_session=async_session_scope,
) )
configure_transaction_runners( configure_transaction_runners(
@@ -347,6 +352,20 @@ class DbHarness:
self.session.commit() self.session.commit()
return rows[0] if len(rows) == 1 else list(rows) return rows[0] if len(rows) == 1 else list(rows)
def run_async_session(
self,
operation: Callable[[AsyncSession], Awaitable[TResult]],
) -> TResult:
"""在临时数据库的显式 AsyncSession 中执行被测操作。"""
from app.db.session import async_session_scope
async def execute() -> TResult:
"""打开异步会话并把事务所有权留在测试载具。"""
async with async_session_scope() as session:
return await operation(session)
return asyncio.run(execute())
def cleanup(self) -> None: def cleanup(self) -> None:
"""按水位删除本用例新增的全部行。""" """按水位删除本用例新增的全部行。"""
from sqlalchemy import delete from sqlalchemy import delete
+3 -28
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6525, "edge_count": 6500,
"edge_sha256": "72b9416b5309bb51768b95b3c9da4b167242c3bc3f27d4e561a3f097267ca6a0", "edge_sha256": "f16cc04898ae0fc7602f2d154321035040c6e64155c59b0b0d72a7d707f59ec9",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -3549,7 +3549,6 @@
"app.db.adapters.workflow -> app.db.oper.workflow", "app.db.adapters.workflow -> app.db.oper.workflow",
"app.db.adapters.workflow -> app.db.uow", "app.db.adapters.workflow -> app.db.uow",
"app.db.base -> app.db", "app.db.base -> app.db",
"app.db.base -> app.db.decorators",
"app.db.base -> app.db.uow", "app.db.base -> app.db.uow",
"app.db.base -> app.runtime", "app.db.base -> app.runtime",
"app.db.base -> app.runtime.config", "app.db.base -> app.runtime.config",
@@ -3584,13 +3583,10 @@
"app.db.models._identity -> app.schemas.media", "app.db.models._identity -> app.schemas.media",
"app.db.models.agentchat -> app.db", "app.db.models.agentchat -> app.db",
"app.db.models.agentchat -> app.db.base", "app.db.models.agentchat -> app.db.base",
"app.db.models.agentchat -> app.db.decorators",
"app.db.models.agenttask -> app.db", "app.db.models.agenttask -> app.db",
"app.db.models.agenttask -> app.db.base", "app.db.models.agenttask -> app.db.base",
"app.db.models.agenttask -> app.db.decorators",
"app.db.models.agenttaskrun -> app.db", "app.db.models.agenttaskrun -> app.db",
"app.db.models.agenttaskrun -> app.db.base", "app.db.models.agenttaskrun -> app.db.base",
"app.db.models.agenttaskrun -> app.db.decorators",
"app.db.models.agenttaskrun -> app.db.models", "app.db.models.agenttaskrun -> app.db.models",
"app.db.models.agenttaskrun -> app.db.models.agenttask", "app.db.models.agenttaskrun -> app.db.models.agenttask",
"app.db.models.downloadfailure -> app.db", "app.db.models.downloadfailure -> app.db",
@@ -3599,76 +3595,60 @@
"app.db.models.downloadfailure -> app.db.models._constraints", "app.db.models.downloadfailure -> app.db.models._constraints",
"app.db.models.downloadhistory -> app.db", "app.db.models.downloadhistory -> app.db",
"app.db.models.downloadhistory -> app.db.base", "app.db.models.downloadhistory -> app.db.base",
"app.db.models.downloadhistory -> app.db.decorators",
"app.db.models.downloadhistory -> app.db.models", "app.db.models.downloadhistory -> app.db.models",
"app.db.models.downloadhistory -> app.db.models._constraints", "app.db.models.downloadhistory -> app.db.models._constraints",
"app.db.models.downloadhistory -> app.schemas", "app.db.models.downloadhistory -> app.schemas",
"app.db.models.downloadhistory -> app.schemas.types", "app.db.models.downloadhistory -> app.schemas.types",
"app.db.models.mediaserver -> app.db", "app.db.models.mediaserver -> app.db",
"app.db.models.mediaserver -> app.db.base", "app.db.models.mediaserver -> app.db.base",
"app.db.models.mediaserver -> app.db.decorators",
"app.db.models.mediaserver -> app.db.models", "app.db.models.mediaserver -> app.db.models",
"app.db.models.mediaserver -> app.db.models._constraints", "app.db.models.mediaserver -> app.db.models._constraints",
"app.db.models.mediaserver -> app.schemas", "app.db.models.mediaserver -> app.schemas",
"app.db.models.mediaserver -> app.schemas.types", "app.db.models.mediaserver -> app.schemas.types",
"app.db.models.message -> app.db", "app.db.models.message -> app.db",
"app.db.models.message -> app.db.base", "app.db.models.message -> app.db.base",
"app.db.models.message -> app.db.decorators",
"app.db.models.outbox -> app.db", "app.db.models.outbox -> app.db",
"app.db.models.outbox -> app.db.base", "app.db.models.outbox -> app.db.base",
"app.db.models.passkey -> app.db", "app.db.models.passkey -> app.db",
"app.db.models.passkey -> app.db.base", "app.db.models.passkey -> app.db.base",
"app.db.models.passkey -> app.db.decorators",
"app.db.models.plugindata -> app.db", "app.db.models.plugindata -> app.db",
"app.db.models.plugindata -> app.db.base", "app.db.models.plugindata -> app.db.base",
"app.db.models.plugindata -> app.db.decorators",
"app.db.models.site -> app.db", "app.db.models.site -> app.db",
"app.db.models.site -> app.db.base", "app.db.models.site -> app.db.base",
"app.db.models.site -> app.db.decorators",
"app.db.models.siteicon -> app.db", "app.db.models.siteicon -> app.db",
"app.db.models.siteicon -> app.db.base", "app.db.models.siteicon -> app.db.base",
"app.db.models.siteicon -> app.db.decorators",
"app.db.models.sitestatistic -> app.db", "app.db.models.sitestatistic -> app.db",
"app.db.models.sitestatistic -> app.db.base", "app.db.models.sitestatistic -> app.db.base",
"app.db.models.sitestatistic -> app.db.decorators",
"app.db.models.siteuserdata -> app.db", "app.db.models.siteuserdata -> app.db",
"app.db.models.siteuserdata -> app.db.base", "app.db.models.siteuserdata -> app.db.base",
"app.db.models.siteuserdata -> app.db.decorators",
"app.db.models.subscribe -> app.db", "app.db.models.subscribe -> app.db",
"app.db.models.subscribe -> app.db.base", "app.db.models.subscribe -> app.db.base",
"app.db.models.subscribe -> app.db.decorators",
"app.db.models.subscribe -> app.db.models", "app.db.models.subscribe -> app.db.models",
"app.db.models.subscribe -> app.db.models._constraints", "app.db.models.subscribe -> app.db.models._constraints",
"app.db.models.subscribe -> app.schemas", "app.db.models.subscribe -> app.schemas",
"app.db.models.subscribe -> app.schemas.types", "app.db.models.subscribe -> app.schemas.types",
"app.db.models.subscribehistory -> app.db", "app.db.models.subscribehistory -> app.db",
"app.db.models.subscribehistory -> app.db.base", "app.db.models.subscribehistory -> app.db.base",
"app.db.models.subscribehistory -> app.db.decorators",
"app.db.models.subscribehistory -> app.db.models", "app.db.models.subscribehistory -> app.db.models",
"app.db.models.subscribehistory -> app.db.models._constraints", "app.db.models.subscribehistory -> app.db.models._constraints",
"app.db.models.subscribehistory -> app.schemas", "app.db.models.subscribehistory -> app.schemas",
"app.db.models.subscribehistory -> app.schemas.types", "app.db.models.subscribehistory -> app.schemas.types",
"app.db.models.systemconfig -> app.db", "app.db.models.systemconfig -> app.db",
"app.db.models.systemconfig -> app.db.base", "app.db.models.systemconfig -> app.db.base",
"app.db.models.systemconfig -> app.db.decorators",
"app.db.models.transferhistory -> app.db", "app.db.models.transferhistory -> app.db",
"app.db.models.transferhistory -> app.db.base", "app.db.models.transferhistory -> app.db.base",
"app.db.models.transferhistory -> app.db.decorators",
"app.db.models.transferhistory -> app.db.models", "app.db.models.transferhistory -> app.db.models",
"app.db.models.transferhistory -> app.db.models._constraints", "app.db.models.transferhistory -> app.db.models._constraints",
"app.db.models.transferhistory -> app.schemas", "app.db.models.transferhistory -> app.schemas",
"app.db.models.transferhistory -> app.schemas.types", "app.db.models.transferhistory -> app.schemas.types",
"app.db.models.transferpending -> app.db", "app.db.models.transferpending -> app.db",
"app.db.models.transferpending -> app.db.base", "app.db.models.transferpending -> app.db.base",
"app.db.models.transferpending -> app.db.decorators",
"app.db.models.user -> app.db", "app.db.models.user -> app.db",
"app.db.models.user -> app.db.base", "app.db.models.user -> app.db.base",
"app.db.models.user -> app.db.decorators",
"app.db.models.userconfig -> app.db", "app.db.models.userconfig -> app.db",
"app.db.models.userconfig -> app.db.base", "app.db.models.userconfig -> app.db.base",
"app.db.models.workflow -> app.db", "app.db.models.workflow -> app.db",
"app.db.models.workflow -> app.db.base", "app.db.models.workflow -> app.db.base",
"app.db.models.workflow -> app.db.decorators",
"app.db.oper.agentchat -> app.db", "app.db.oper.agentchat -> app.db",
"app.db.oper.agentchat -> app.db.base", "app.db.oper.agentchat -> app.db.base",
"app.db.oper.agentchat -> app.db.models", "app.db.oper.agentchat -> app.db.models",
@@ -5901,8 +5881,6 @@
"app.sdk._legacy.history -> app.application", "app.sdk._legacy.history -> app.application",
"app.sdk._legacy.history -> app.application.history", "app.sdk._legacy.history -> app.application.history",
"app.sdk._legacy.history -> app.db", "app.sdk._legacy.history -> app.db",
"app.sdk._legacy.history -> app.db.models",
"app.sdk._legacy.history -> app.db.models.transferhistory",
"app.sdk._legacy.history -> app.db.oper", "app.sdk._legacy.history -> app.db.oper",
"app.sdk._legacy.history -> app.db.oper.transferhistory", "app.sdk._legacy.history -> app.db.oper.transferhistory",
"app.sdk._legacy.history -> app.domain", "app.sdk._legacy.history -> app.domain",
@@ -5916,8 +5894,6 @@
"app.sdk._legacy.subscribe -> app.application.subscription", "app.sdk._legacy.subscribe -> app.application.subscription",
"app.sdk._legacy.subscribe -> app.application.subscription.write", "app.sdk._legacy.subscribe -> app.application.subscription.write",
"app.sdk._legacy.subscribe -> app.db", "app.sdk._legacy.subscribe -> app.db",
"app.sdk._legacy.subscribe -> app.db.models",
"app.sdk._legacy.subscribe -> app.db.models.subscribe",
"app.sdk._legacy.subscribe -> app.db.oper", "app.sdk._legacy.subscribe -> app.db.oper",
"app.sdk._legacy.subscribe -> app.db.oper.subscribe", "app.sdk._legacy.subscribe -> app.db.oper.subscribe",
"app.sdk._legacy.subscribe -> app.domain", "app.sdk._legacy.subscribe -> app.domain",
@@ -5927,8 +5903,6 @@
"app.sdk._legacy.user -> app.api", "app.sdk._legacy.user -> app.api",
"app.sdk._legacy.user -> app.api.deps", "app.sdk._legacy.user -> app.api.deps",
"app.sdk._legacy.user -> app.db", "app.sdk._legacy.user -> app.db",
"app.sdk._legacy.user -> app.db.models",
"app.sdk._legacy.user -> app.db.models.user",
"app.sdk._legacy.user -> app.db.oper", "app.sdk._legacy.user -> app.db.oper",
"app.sdk._legacy.user -> app.db.oper.user", "app.sdk._legacy.user -> app.db.oper.user",
"app.sdk.browser -> app.adapters", "app.sdk.browser -> app.adapters",
@@ -6362,6 +6336,7 @@
"app.testing.bootstrap -> app.db.oper", "app.testing.bootstrap -> app.db.oper",
"app.testing.bootstrap -> app.db.oper.systemconfig", "app.testing.bootstrap -> app.db.oper.systemconfig",
"app.testing.bootstrap -> app.db.oper.userconfig", "app.testing.bootstrap -> app.db.oper.userconfig",
"app.testing.bootstrap -> app.db.session",
"app.testing.bootstrap -> app.startup", "app.testing.bootstrap -> app.startup",
"app.testing.bootstrap -> app.startup.initializers", "app.testing.bootstrap -> app.startup.initializers",
"app.testing.bootstrap -> app.startup.initializers.cache", "app.testing.bootstrap -> app.startup.initializers.cache",
+159 -159
View File
@@ -1,41 +1,41 @@
{ {
"schema_version": 2, "schema_version": 2,
"generated_at": "2026-08-23T13:47:08.442528+00:00", "generated_at": "2026-08-23T15:32:22.484415+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O", "platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
"python": "3.14.3", "python": "3.14.3",
"repeat": 3, "repeat": 3,
"targets": { "targets": {
"app.startup.lifecycle": { "app.startup.lifecycle": {
"loaded_app_module_count": 367, "loaded_app_module_count": 366,
"max_ms": 1591.175, "max_ms": 985.623,
"median_ms": 1049.37, "median_ms": 947.803,
"min_ms": 1022.351, "min_ms": 932.937,
"samples_ms": [ "samples_ms": [
1591.175, 947.803,
1049.37, 985.623,
1022.351 932.937
] ]
}, },
"app.factory": { "app.factory": {
"loaded_app_module_count": 379, "loaded_app_module_count": 378,
"max_ms": 1198.166, "max_ms": 957.03,
"median_ms": 1083.805, "median_ms": 950.108,
"min_ms": 1009.309, "min_ms": 947.713,
"samples_ms": [ "samples_ms": [
1009.309, 950.108,
1198.166, 957.03,
1083.805 947.713
] ]
}, },
"app.main": { "app.main": {
"loaded_app_module_count": 381, "loaded_app_module_count": 380,
"max_ms": 1229.477, "max_ms": 1098.929,
"median_ms": 1179.859, "median_ms": 1095.482,
"min_ms": 1129.055, "min_ms": 1076.479,
"samples_ms": [ "samples_ms": [
1129.055, 1076.479,
1179.859, 1095.482,
1229.477 1098.929
] ]
} }
}, },
@@ -47,87 +47,87 @@
{ {
"mode": "normal", "mode": "normal",
"enabled_component_count": 23, "enabled_component_count": 23,
"startup_ms": 0.64, "startup_ms": 0.622,
"full_lifespan_ms": 0.805, "full_lifespan_ms": 0.781,
"stage_ms": { "stage_ms": {
"后台任务登记器": 0.079, "后台任务登记器": 0.076,
"数据库准备": 0.039,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.027,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.023,
"路由": 0.023,
"模块服务": 0.021,
"插件备份恢复": 0.024,
"插件": 0.021,
"定时器": 0.026,
"监控器": 0.022,
"待处理整理回放": 0.025,
"命令服务": 0.025,
"工作流": 0.021,
"插件同步与启动收尾": 0.037
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 23,
"startup_ms": 0.652,
"full_lifespan_ms": 0.808,
"stage_ms": {
"后台任务登记器": 0.079,
"数据库准备": 0.039,
"HTTP 基础能力": 0.033,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.021,
"路由": 0.022,
"模块服务": 0.023,
"插件备份恢复": 0.021,
"插件": 0.022,
"定时器": 0.026,
"监控器": 0.023,
"待处理整理回放": 0.024,
"命令服务": 0.023,
"工作流": 0.025,
"插件同步与启动收尾": 0.035
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 23,
"startup_ms": 0.739,
"full_lifespan_ms": 0.944,
"stage_ms": {
"后台任务登记器": 0.087,
"数据库准备": 0.037, "数据库准备": 0.037,
"HTTP 基础能力": 0.029, "HTTP 基础能力": 0.029,
"领域依赖装配": 0.028, "领域依赖装配": 0.031,
"数据库引擎预热": 0.024, "数据库引擎预热": 0.026,
"数据库连接预算": 0.021, "数据库连接预算": 0.025,
"路由": 0.02, "路由": 0.022,
"模块服务": 0.02, "模块服务": 0.02,
"插件备份恢复": 0.024, "插件备份恢复": 0.023,
"插件": 0.019, "插件": 0.023,
"定时器": 0.024,
"监控器": 0.025,
"待处理整理回放": 0.02,
"命令服务": 0.022,
"工作流": 0.02,
"插件同步与启动收尾": 0.033
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 23,
"startup_ms": 0.629,
"full_lifespan_ms": 0.782,
"stage_ms": {
"后台任务登记器": 0.075,
"数据库准备": 0.039,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.031,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.022,
"路由": 0.023,
"模块服务": 0.021,
"插件备份恢复": 0.023,
"插件": 0.023,
"定时器": 0.025, "定时器": 0.025,
"监控器": 0.054, "监控器": 0.02,
"待处理整理回放": 0.041, "待处理整理回放": 0.024,
"命令服务": 0.034, "命令服务": 0.021,
"工作流": 0.025, "工作流": 0.022,
"插件同步与启动收尾": 0.037 "插件同步与启动收尾": 0.033
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 23,
"startup_ms": 0.631,
"full_lifespan_ms": 0.785,
"stage_ms": {
"后台任务登记器": 0.08,
"数据库准备": 0.04,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.023,
"路由": 0.022,
"模块服务": 0.022,
"插件备份恢复": 0.021,
"插件": 0.025,
"定时器": 0.022,
"监控器": 0.024,
"待处理整理回放": 0.02,
"命令服务": 0.024,
"工作流": 0.02,
"插件同步与启动收尾": 0.034
}, },
"threads_before": 2, "threads_before": 2,
"threads_started": 2, "threads_started": 2,
@@ -138,8 +138,8 @@
"database_connections_started": 0 "database_connections_started": 0
} }
], ],
"median_startup_ms": 0.652, "median_startup_ms": 0.629,
"median_full_lifespan_ms": 0.808, "median_full_lifespan_ms": 0.782,
"enabled_component_count": 23, "enabled_component_count": 23,
"enabled_components": [ "enabled_components": [
"后台任务登记器", "后台任务登记器",
@@ -172,66 +172,66 @@
{ {
"mode": "safe", "mode": "safe",
"enabled_component_count": 11, "enabled_component_count": 11,
"startup_ms": 0.475, "startup_ms": 0.597,
"full_lifespan_ms": 0.619, "full_lifespan_ms": 0.772,
"stage_ms": {
"后台任务登记器": 0.079,
"数据库准备": 0.038,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.023,
"路由": 0.023,
"模块服务": 0.022,
"插件同步与启动收尾": 0.036
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.493,
"full_lifespan_ms": 0.676,
"stage_ms": {
"后台任务登记器": 0.089,
"数据库准备": 0.037,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.021,
"路由": 0.025,
"模块服务": 0.023,
"插件同步与启动收尾": 0.062
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.464,
"full_lifespan_ms": 0.638,
"stage_ms": { "stage_ms": {
"后台任务登记器": 0.075, "后台任务登记器": 0.075,
"数据库准备": 0.036, "数据库准备": 0.044,
"HTTP 基础能力": 0.028, "HTTP 基础能力": 0.033,
"领域依赖装配": 0.028, "领域依赖装配": 0.059,
"数据库引擎预热": 0.023, "数据库引擎预热": 0.061,
"数据库连接预算": 0.022, "数据库连接预算": 0.032,
"路由": 0.027,
"模块服务": 0.046,
"插件同步与启动收尾": 0.044
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.461,
"full_lifespan_ms": 0.642,
"stage_ms": {
"后台任务登记器": 0.076,
"数据库准备": 0.037,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.023,
"路由": 0.024, "路由": 0.024,
"模块服务": 0.021, "模块服务": 0.023,
"插件同步与启动收尾": 0.057 "插件同步与启动收尾": 0.06
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.483,
"full_lifespan_ms": 0.659,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.039,
"HTTP 基础能力": 0.034,
"领域依赖装配": 0.035,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.026,
"路由": 0.025,
"模块服务": 0.024,
"插件同步与启动收尾": 0.059
}, },
"threads_before": 2, "threads_before": 2,
"threads_started": 2, "threads_started": 2,
@@ -242,8 +242,8 @@
"database_connections_started": 0 "database_connections_started": 0
} }
], ],
"median_startup_ms": 0.475, "median_startup_ms": 0.483,
"median_full_lifespan_ms": 0.638, "median_full_lifespan_ms": 0.659,
"enabled_component_count": 11, "enabled_component_count": 11,
"enabled_components": [ "enabled_components": [
"后台任务登记器", "后台任务登记器",
+6 -17
View File
@@ -10,11 +10,11 @@ from sqlalchemy.exc import IntegrityError
from app.agent.orchestrator import AgentManager from app.agent.orchestrator import AgentManager
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
from app.db.engine import get_engine from app.db.engine import get_engine
from app.db import base as db_base
from app.db.oper.agenttask import AgentTaskOper from app.db.oper.agenttask import AgentTaskOper
from app.db.models.agenttask import AgentTask from app.db.models.agenttask import AgentTask
from app.db.models.agenttaskrun import AgentTaskRun from app.db.models.agenttaskrun import AgentTaskRun
from app.db.session import SessionFactory from app.db.session import SessionFactory
from app.db import decorators
Engine = get_engine() Engine = get_engine()
@@ -141,9 +141,11 @@ def test_agenttaskrun_oper_reuses_explicit_query_session(db, monkeypatch):
run = AgentTaskOper().begin_run(task.id) run = AgentTaskOper().begin_run(task.id)
assert run assert run
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
oper = AgentTaskOper(db.session) oper = AgentTaskOper(db.session)
@@ -151,19 +153,6 @@ def test_agenttaskrun_oper_reuses_explicit_query_session(db, monkeypatch):
assert oper.list_runs(task.id) assert oper.list_runs(task.id)
def test_agenttaskrun_model_legacy_query_keeps_keyword_abi(monkeypatch):
"""旧插件以关键字直调 AgentTaskRun 时仍自动补入短会话。"""
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert AgentTaskRun.get_by_run_id(run_id="missing-legacy") is None
assert opened == [True]
def test_begin_run_rolls_back_task_claim_when_run_insert_fails() -> None: def test_begin_run_rolls_back_task_claim_when_run_insert_fails() -> None:
"""运行记录插入失败时,任务的 running 投影必须随事务回滚。""" """运行记录插入失败时,任务的 running 投影必须随事务回滚。"""
first_task = _add_task("run-rollback-first") first_task = _add_task("run-rollback-first")
+59 -29
View File
@@ -432,46 +432,76 @@ def test_database_internals_do_not_import_db_facades():
assert violations == [] assert violations == []
def test_base_crud_is_explicitly_legacy_only(): def test_models_and_base_require_explicit_database_sessions():
"""Base 便利 CRUD 只能保留兼容壳,不得伪装成新的正式事务入口""" """Model/Base 不得装饰事务,且所有 db 参数必须由调用方显式传入"""
path = APP_ROOT / "db" / "base.py" decorator_names = {
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
base_class = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "Base"
)
formal_decorators = {
"db_query", "db_query",
"db_update", "db_update",
"async_db_query", "async_db_query",
"async_db_update", "async_db_update",
"legacy_db_query",
"legacy_db_update",
"legacy_async_db_query",
"legacy_async_db_update",
} }
violations: list[str] = [] violations: list[str] = []
for node in base_class.body: paths = [APP_ROOT / "db" / "base.py"]
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): paths.extend((APP_ROOT / "db" / "models").rglob("*.py"))
continue for path in paths:
decorators = { tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
decorator.id relative = str(path.relative_to(PROJECT_ROOT))
for decorator in node.decorator_list nodes = list(ast.walk(tree))
if isinstance(decorator, ast.Name) for node in nodes:
} if isinstance(node, ast.ImportFrom) and node.module == "app.db.decorators":
if decorators & formal_decorators: violations.append(f"{relative}:{node.lineno}:decorator-import")
violations.append(node.name) if path.name == "base.py":
base_class = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "Base"
)
nodes = list(ast.walk(base_class))
for node in nodes:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for decorator in node.decorator_list:
name = (
decorator.id
if isinstance(decorator, ast.Name)
else decorator.attr
if isinstance(decorator, ast.Attribute)
else None
)
if name in decorator_names:
violations.append(f"{relative}:{node.lineno}:@{name}")
arguments = [*node.args.posonlyargs, *node.args.args]
defaults = [None] * (len(arguments) - len(node.args.defaults)) + list(
node.args.defaults
)
for argument, default in zip(arguments, defaults):
if argument.arg != "db":
continue
annotation = ast.unparse(argument.annotation) if argument.annotation else ""
if default is not None or "None" in annotation:
violations.append(
f"{relative}:{node.lineno}:{node.name}:optional-db"
)
assert violations == [] assert violations == []
def test_models_use_one_legacy_query_compatibility_shell(): def test_plugin_sdk_does_not_import_or_export_host_models():
"""旧 Model 查询统一使用 legacy 装饰器,不得再手写隐式会话 runner""" """插件 SDK 只能暴露 Oper,不得把宿主 ORM Model 作为插件接口"""
retired_names = {"run_legacy_sync_query", "run_legacy_async_query"}
violations: list[str] = [] violations: list[str] = []
for path in (APP_ROOT / "db" / "models").glob("*.py"): for path in (APP_ROOT / "sdk").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
if any( for node in ast.walk(tree):
isinstance(node, ast.Name) and node.id in retired_names if isinstance(node, ast.ImportFrom) and node.module and (
for node in ast.walk(tree) node.module == "app.db.models"
): or node.module.startswith("app.db.models.")
violations.append(str(path.relative_to(PROJECT_ROOT))) ):
violations.append(
f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:{node.module}"
)
assert violations == [] assert violations == []
+31 -8
View File
@@ -11,6 +11,7 @@ import pytest
from app.db.models.systemconfig import SystemConfig from app.db.models.systemconfig import SystemConfig
from app.db.models.userconfig import UserConfig from app.db.models.userconfig import UserConfig
from app.db.uow import run_async_transaction
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -25,10 +26,13 @@ def test_create_persists_and_get_reads_back(db):
""" """
row = SystemConfig(key="base-create", value={"n": 1}) row = SystemConfig(key="base-create", value={"n": 1})
row.create(db.session) row.create(db.session)
db.session.commit()
assert row.id is not None assert row.id is not None
assert SystemConfig.get(db.session, row.id).key == "base-create" assert SystemConfig.get(db.session, row.id).key == "base-create"
assert asyncio.run(SystemConfig.async_get(rid=row.id)).key == "base-create" assert db.run_async_session(
lambda session: SystemConfig.async_get(session, rid=row.id)
).key == "base-create"
def test_get_returns_none_for_missing_id(db): def test_get_returns_none_for_missing_id(db):
@@ -36,7 +40,9 @@ def test_get_returns_none_for_missing_id(db):
主键不存在时返回 None而不是抛异常或返回任意一行 主键不存在时返回 None而不是抛异常或返回任意一行
""" """
assert SystemConfig.get(db.session, -1) is None assert SystemConfig.get(db.session, -1) is None
assert asyncio.run(SystemConfig.async_get(rid=-1)) is None assert db.run_async_session(
lambda session: SystemConfig.async_get(session, rid=-1)
) is None
def test_async_create_flushes_and_assigns_primary_key(db): def test_async_create_flushes_and_assigns_primary_key(db):
@@ -45,7 +51,11 @@ def test_async_create_flushes_and_assigns_primary_key(db):
异步路径的调用方常常紧接着用 id 建立关联拿到 None 会让关联静默丢失 异步路径的调用方常常紧接着用 id 建立关联拿到 None 会让关联静默丢失
""" """
created = asyncio.run(SystemConfig(key="base-async-create", value={"n": 2}).async_create()) created = asyncio.run(run_async_transaction(
lambda session: SystemConfig(
key="base-async-create", value={"n": 2}
).async_create(session)
))
assert created.id is not None assert created.id is not None
assert SystemConfig.get(db.session, created.id).value == {"n": 2} assert SystemConfig.get(db.session, created.id).value == {"n": 2}
@@ -57,11 +67,20 @@ def test_update_writes_payload_fields(db):
""" """
row = SystemConfig(key="base-update", value={"n": 1}) row = SystemConfig(key="base-update", value={"n": 1})
row.create(db.session) row.create(db.session)
db.session.flush()
row.update(db.session, {"value": {"n": 9}}) row.update(db.session, {"value": {"n": 9}})
assert SystemConfig.get(db.session, row.id).value == {"n": 9} assert SystemConfig.get(db.session, row.id).value == {"n": 9}
db.session.commit()
asyncio.run(row.async_update(payload={"value": {"n": 10}})) async def update_in_owned_transaction(session) -> None:
"""在同一异步事务中读取并更新目标行。"""
async_row = await SystemConfig.async_get(session, row.id)
assert async_row is not None
await async_row.async_update(session, payload={"value": {"n": 10}})
asyncio.run(run_async_transaction(update_in_owned_transaction))
db.session.expire_all()
assert SystemConfig.get(db.session, row.id).value == {"n": 10} assert SystemConfig.get(db.session, row.id).value == {"n": 10}
@@ -85,7 +104,9 @@ def test_async_delete_removes_only_the_given_row(db):
dropped = db.add(SystemConfig(key="base-async-del", value={"n": 1})) dropped = db.add(SystemConfig(key="base-async-del", value={"n": 1}))
kept = db.add(SystemConfig(key="base-async-keep", value={"n": 2})) kept = db.add(SystemConfig(key="base-async-keep", value={"n": 2}))
asyncio.run(SystemConfig.async_delete(rid=dropped.id)) asyncio.run(run_async_transaction(
lambda session: SystemConfig.async_delete(session, rid=dropped.id)
))
assert SystemConfig.get(db.session, dropped.id) is None assert SystemConfig.get(db.session, dropped.id) is None
assert SystemConfig.get(db.session, kept.id) is not None assert SystemConfig.get(db.session, kept.id) is not None
@@ -95,7 +116,9 @@ def test_async_delete_tolerates_missing_row(db):
""" """
删除不存在的行不抛异常保持调用方的幂等语义 删除不存在的行不抛异常保持调用方的幂等语义
""" """
asyncio.run(SystemConfig.async_delete(rid=-1)) asyncio.run(run_async_transaction(
lambda session: SystemConfig.async_delete(session, rid=-1)
))
def test_list_returns_every_row_of_that_model_only(db): def test_list_returns_every_row_of_that_model_only(db):
@@ -117,7 +140,7 @@ def test_async_list_matches_sync_list(db):
db.add(UserConfig(username="base-list", key="k", value="v")) db.add(UserConfig(username="base-list", key="k", value="v"))
sync_ids = sorted(item.id for item in UserConfig.list(db.session)) sync_ids = sorted(item.id for item in UserConfig.list(db.session))
async_ids = sorted(item.id for item in asyncio.run(UserConfig.async_list())) async_ids = sorted(item.id for item in db.run_async_session(UserConfig.async_list))
assert sync_ids == async_ids assert sync_ids == async_ids
@@ -132,7 +155,7 @@ def test_truncate_empties_the_table(db):
assert UserConfig.list(db.session) == [] assert UserConfig.list(db.session) == []
db.add(UserConfig(username="base-truncate-async", key="k", value="v")) db.add(UserConfig(username="base-truncate-async", key="k", value="v"))
asyncio.run(UserConfig.async_truncate()) asyncio.run(run_async_transaction(UserConfig.async_truncate))
assert UserConfig.list(db.session) == [] assert UserConfig.list(db.session) == []
+40 -81
View File
@@ -9,14 +9,14 @@ import asyncio
import pytest import pytest
from app.db import decorators from app.db import base as db_base
from app.db.models.passkey import PassKey from app.db.models.passkey import PassKey
from app.db.models.systemconfig import SystemConfig from app.db.models.systemconfig import SystemConfig
from app.db.models.user import User from app.db.models.user import User
from app.db.models.userconfig import UserConfig from app.db.models.userconfig import UserConfig
from app.db.oper.passkey import PassKeyOper from app.db.oper.passkey import PassKeyOper
from app.db.oper.user import UserOper from app.db.oper.user import UserOper
from app.db.session import SessionFactory, async_session_scope from app.db.session import async_session_scope
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -39,7 +39,9 @@ def test_systemconfig_get_by_key_matches_async_twin(db):
found = SystemConfig.get_by_key(db.session, "mp-test-a") found = SystemConfig.get_by_key(db.session, "mp-test-a")
assert found.value == {"n": 1} assert found.value == {"n": 1}
async_found = asyncio.run(SystemConfig.async_get_by_key(key="mp-test-a")) async_found = db.run_async_session(
lambda session: SystemConfig.async_get_by_key(session, "mp-test-a")
)
assert async_found.value == found.value assert async_found.value == found.value
@@ -54,9 +56,11 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
"""SystemConfig 显式同步与异步会话不得触发兼容会话。""" """SystemConfig 显式同步与异步会话不得触发兼容会话。"""
db.add(SystemConfig(key="mp-explicit-config", value=True)) db.add(SystemConfig(key="mp-explicit-config", value=True))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
assert SystemConfig.get_by_key(db.session, "mp-explicit-config") is not None assert SystemConfig.get_by_key(db.session, "mp-explicit-config") is not None
@@ -64,9 +68,11 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
"""验证异步配置查询复用显式 AsyncSession。""" """验证异步配置查询复用显式 AsyncSession。"""
async with async_session_scope() as session: async with async_session_scope() as session:
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"async_session_scope", "run_async_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
) )
assert await SystemConfig.async_get_by_key( assert await SystemConfig.async_get_by_key(
session, session,
@@ -76,20 +82,6 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
asyncio.run(check()) asyncio.run(check())
def test_systemconfig_model_legacy_query_keeps_keyword_abi(db, monkeypatch):
"""旧插件以关键字直调 SystemConfig 时仍自动补入短会话。"""
db.add(SystemConfig(key="mp-legacy-config", value=True))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert SystemConfig.get_by_key(key="mp-legacy-config") is not None
assert opened == [True]
def test_systemconfig_delete_by_key_removes_only_that_key(db): def test_systemconfig_delete_by_key_removes_only_that_key(db):
""" """
按键删除只能删掉那一个键误删会静默丢失其他配置 按键删除只能删掉那一个键误删会静默丢失其他配置
@@ -163,8 +155,12 @@ def test_user_lookup_by_name_and_id_matches_async_twin(db):
by_id = User.get_by_id(db.session, created.id) by_id = User.get_by_id(db.session, created.id)
assert by_name.id == by_id.id == created.id assert by_name.id == by_id.id == created.id
assert asyncio.run(User.async_get_by_name(name="mp-test-user")).id == created.id assert db.run_async_session(
assert asyncio.run(User.async_get_by_id(user_id=created.id)).id == created.id lambda session: User.async_get_by_name(session, "mp-test-user")
).id == created.id
assert db.run_async_session(
lambda session: User.async_get_by_id(session, created.id)
).id == created.id
def test_user_lookup_returns_none_when_absent(db): def test_user_lookup_returns_none_when_absent(db):
@@ -175,14 +171,6 @@ def test_user_lookup_returns_none_when_absent(db):
assert User.get_by_id(db.session, -1) is None assert User.get_by_id(db.session, -1) is None
def test_user_sync_queries_preserve_legacy_no_session_abi(db):
"""旧插件省略 Session 时仍可按用户名和用户 ID 查询。"""
created = db.add(User(name="mp-legacy-query-user", hashed_password="secret"))
assert User.get_by_name("mp-legacy-query-user").id == created.id
assert User.get_by_id(created.id).name == "mp-legacy-query-user"
def test_user_delete_by_name_and_by_id_remove_only_the_target(db): def test_user_delete_by_name_and_by_id_remove_only_the_target(db):
""" """
按名 ID 删除都只能删掉目标用户 按名 ID 删除都只能删掉目标用户
@@ -258,7 +246,9 @@ def test_passkey_listing_excludes_inactive_credentials(db):
listed = PassKey.get_by_user_id(db.session, 9001) listed = PassKey.get_by_user_id(db.session, 9001)
assert {p.credential_id for p in listed} == {"cred-active-1", "cred-active-2"} assert {p.credential_id for p in listed} == {"cred-active-1", "cred-active-2"}
assert {p.credential_id for p in asyncio.run(PassKey.async_get_by_user_id(user_id=9001))} == \ assert {p.credential_id for p in db.run_async_session(
lambda session: PassKey.async_get_by_user_id(session, 9001)
)} == \
{"cred-active-1", "cred-active-2"} {"cred-active-1", "cred-active-2"}
@@ -289,26 +279,20 @@ def test_passkey_lookup_by_credential_id_skips_inactive(db):
assert PassKey.get_by_credential_id(db.session, "cred-live").user_id == 9003 assert PassKey.get_by_credential_id(db.session, "cred-live").user_id == 9003
assert PassKey.get_by_credential_id(db.session, "cred-dead") is None assert PassKey.get_by_credential_id(db.session, "cred-dead") is None
assert asyncio.run(PassKey.async_get_by_credential_id(credential_id="cred-dead")) is None assert db.run_async_session(
lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")
) is None
def test_passkey_model_sync_queries_keep_no_session_plugin_abi(db):
"""旧插件不传 Session 时仍由统一 legacy 装饰器获得短会话查询。"""
db.add(_passkey(9004, "cred-legacy"))
assert [item.credential_id for item in PassKey.get_by_user_id(user_id=9004)] == [
"cred-legacy"
]
assert PassKey.get_by_credential_id("cred-legacy").user_id == 9004
def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch): def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
"""PassKey 其余同步/异步查询必须复用调用方会话。""" """PassKey 其余同步/异步查询必须复用调用方会话。"""
key = db.add(_passkey(9008, "cred-explicit")) key = db.add(_passkey(9008, "cred-explicit"))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
assert PassKey.get_by_id(db.session, key.id).credential_id == "cred-explicit" assert PassKey.get_by_id(db.session, key.id).credential_id == "cred-explicit"
@@ -316,9 +300,11 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
"""验证三个异步查询都复用显式 AsyncSession。""" """验证三个异步查询都复用显式 AsyncSession。"""
async with async_session_scope() as session: async with async_session_scope() as session:
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"async_session_scope", "run_async_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
) )
assert [item.credential_id for item in await PassKey.async_get_by_user_id( assert [item.credential_id for item in await PassKey.async_get_by_user_id(
session, session,
@@ -333,35 +319,6 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
asyncio.run(check()) asyncio.run(check())
def test_passkey_remaining_queries_keep_legacy_keyword_abi(db, monkeypatch):
"""旧插件关键字直调 PassKey 其余查询时仍自动补入短会话。"""
key = db.add(_passkey(9009, "cred-keyword"))
opened_sync = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened_sync.append(True) or SessionFactory()),
)
assert PassKey.get_by_id(passkey_id=key.id) is not None
assert opened_sync == [True]
opened_async = []
original_scope = async_session_scope
def tracked_scope():
"""记录旧异步 ABI 创建的兼容会话作用域。"""
opened_async.append(True)
return original_scope()
monkeypatch.setattr(decorators, "async_session_scope", tracked_scope)
assert asyncio.run(PassKey.async_get_by_user_id(user_id=9009))
assert asyncio.run(PassKey.async_get_by_credential_id(
credential_id="cred-keyword",
)) is not None
assert asyncio.run(PassKey.async_get_by_id(passkey_id=key.id)) is not None
assert opened_async == [True, True, True]
def test_passkey_get_by_id_ignores_active_flag(db): def test_passkey_get_by_id_ignores_active_flag(db):
""" """
按主键取记录是管理用途不应过滤停用状态否则管理端看不到自己刚停用的凭据 按主键取记录是管理用途不应过滤停用状态否则管理端看不到自己刚停用的凭据
@@ -369,7 +326,9 @@ def test_passkey_get_by_id_ignores_active_flag(db):
dead = db.add(_passkey(9004, "cred-admin", is_active=False)) dead = db.add(_passkey(9004, "cred-admin", is_active=False))
assert PassKey.get_by_id(db.session, dead.id).credential_id == "cred-admin" assert PassKey.get_by_id(db.session, dead.id).credential_id == "cred-admin"
assert asyncio.run(PassKey.async_get_by_id(passkey_id=dead.id)).credential_id == "cred-admin" assert db.run_async_session(
lambda session: PassKey.async_get_by_id(session, dead.id)
).credential_id == "cred-admin"
def test_passkey_delete_requires_matching_owner(db): def test_passkey_delete_requires_matching_owner(db):
+16 -6
View File
@@ -108,8 +108,11 @@ def test_list_by_page_is_newest_first_and_paged(db):
assert [h.title for h in page1] == ["p-3", "p-2"] assert [h.title for h in page1] == ["p-3", "p-2"]
assert [h.title for h in DownloadHistory.list_by_page(db.session, page=2, count=2)] == \ assert [h.title for h in DownloadHistory.list_by_page(db.session, page=2, count=2)] == \
["p-1", "p-0"] ["p-1", "p-0"]
assert [h.title for h in asyncio.run( assert [h.title for h in db.run_async_session(
DownloadHistory.async_list_by_page(page=1, count=2))] == ["p-3", "p-2"] lambda session: DownloadHistory.async_list_by_page(
session, page=1, count=2
)
)] == ["p-3", "p-2"]
def test_get_by_path_finds_the_download_directory(db): def test_get_by_path_finds_the_download_directory(db):
@@ -312,10 +315,17 @@ def test_count_and_title_search_match_async_twins(db):
""" """
db.add(_history("Unique Title Here", date="2026-08-13 10:00:00")) db.add(_history("Unique Title Here", date="2026-08-13 10:00:00"))
assert asyncio.run(DownloadHistory.async_count()) >= 1 assert db.run_async_session(DownloadHistory.async_count) >= 1
assert asyncio.run(DownloadHistory.async_count_by_title(title="unique title")) == 1 assert db.run_async_session(
assert [h.title for h in asyncio.run(DownloadHistory.async_list_by_title( lambda session: DownloadHistory.async_count_by_title(
title="UNIQUE TITLE"))] == ["Unique Title Here"] session, title="unique title"
)
) == 1
assert [h.title for h in db.run_async_session(
lambda session: DownloadHistory.async_list_by_title(
session, title="UNIQUE TITLE"
)
)] == ["Unique Title Here"]
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -145,6 +145,7 @@ def test_normalization_also_applies_on_update(db):
row = _write(db, _history(media_source=MediaSource.TMDB, media_id="550")) row = _write(db, _history(media_source=MediaSource.TMDB, media_id="550"))
row.update(db.session, {"media_source": "douban", "media_id": " 1291546 "}) row.update(db.session, {"media_source": "douban", "media_id": " 1291546 "})
db.session.commit()
db.session.expire_all() db.session.expire_all()
updated = TransferHistory.get(db.session, row.id) updated = TransferHistory.get(db.session, row.id)
+28 -27
View File
@@ -9,10 +9,10 @@ import asyncio
import pytest import pytest
from app.db import decorators from app.db import base as db_base
from app.db.models.mediaserver import MediaServerItem from app.db.models.mediaserver import MediaServerItem
from app.db.oper.mediaserver import MediaServerOper from app.db.oper.mediaserver import MediaServerOper
from app.db.session import SessionFactory, async_session_scope from app.db.session import async_session_scope
from app.schemas.types import MediaSource from app.schemas.types import MediaSource
@@ -39,7 +39,9 @@ def test_get_by_itemid_matches_async_twin(db):
db.add(_item("emby", "it-1"), _item("plex", "it-2")) db.add(_item("emby", "it-1"), _item("plex", "it-2"))
assert MediaServerItem.get_by_itemid(db.session, "it-1").server == "emby" assert MediaServerItem.get_by_itemid(db.session, "it-1").server == "emby"
assert asyncio.run(MediaServerItem.async_get_by_itemid(item_id="it-1")).server == "emby" assert db.run_async_session(
lambda session: MediaServerItem.async_get_by_itemid(session, "it-1")
).server == "emby"
assert MediaServerItem.get_by_itemid(db.session, "it-missing") is None assert MediaServerItem.get_by_itemid(db.session, "it-missing") is None
@@ -47,9 +49,11 @@ def test_mediaserver_oper_reuses_explicit_query_sessions(db, monkeypatch):
"""媒体服务器 Oper 绑定调用方会话后不得再创建兼容查询会话。""" """媒体服务器 Oper 绑定调用方会话后不得再创建兼容查询会话。"""
db.add(_item("emby", "explicit-ms", media_id="explicit-1001")) db.add(_item("emby", "explicit-ms", media_id="explicit-1001"))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
assert MediaServerOper(db.session).exists( assert MediaServerOper(db.session).exists(
@@ -62,9 +66,11 @@ def test_mediaserver_oper_reuses_explicit_query_sessions(db, monkeypatch):
"""验证异步存在性查询复用显式 AsyncSession。""" """验证异步存在性查询复用显式 AsyncSession。"""
async with async_session_scope() as session: async with async_session_scope() as session:
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"async_session_scope", "run_async_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
) )
assert await MediaServerOper(session).async_exists( assert await MediaServerOper(session).async_exists(
media_source=MediaSource.TMDB, media_source=MediaSource.TMDB,
@@ -75,20 +81,6 @@ def test_mediaserver_oper_reuses_explicit_query_sessions(db, monkeypatch):
asyncio.run(check()) asyncio.run(check())
def test_mediaserver_model_legacy_query_keeps_keyword_abi(db, monkeypatch):
"""旧插件以关键字直调媒体服务器 Model 时仍自动补入短会话。"""
db.add(_item("emby", "legacy-ms"))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert MediaServerItem.get_by_itemid(item_id="legacy-ms") is not None
assert opened == [True]
def test_get_by_server_itemid_scopes_by_server(db): def test_get_by_server_itemid_scopes_by_server(db):
""" """
条目 ID 只在单个服务器内唯一查找必须同时限定服务器 条目 ID 只在单个服务器内唯一查找必须同时限定服务器
@@ -118,8 +110,14 @@ def test_exist_by_media_identity_requires_source_id_and_type(db):
assert MediaServerItem.exist_by_media_identity( assert MediaServerItem.exist_by_media_identity(
db.session, MediaSource.TMDB, "556", "电影") is None db.session, MediaSource.TMDB, "556", "电影") is None
assert asyncio.run(MediaServerItem.async_exist_by_media_identity( assert db.run_async_session(
media_source=MediaSource.TMDB, media_id="555", mtype="电影")) is not None lambda session: MediaServerItem.async_exist_by_media_identity(
session,
media_source=MediaSource.TMDB,
media_id="555",
mtype="电影",
)
) is not None
@pytest.mark.parametrize("mtype,year,expected", [ @pytest.mark.parametrize("mtype,year,expected", [
@@ -152,8 +150,11 @@ def test_exists_by_title_matches_async_twin(db):
for mtype, year in ((None, None), ("电影", None), (None, "2026"), ("电影", "2026")): for mtype, year in ((None, None), ("电影", None), (None, "2026"), ("电影", "2026")):
sync_found = MediaServerItem.exists_by_title(db.session, "并行标题", mtype, year) sync_found = MediaServerItem.exists_by_title(db.session, "并行标题", mtype, year)
async_found = asyncio.run(MediaServerItem.async_exists_by_title( async_found = db.run_async_session(
title="并行标题", mtype=mtype, year=year)) lambda session: MediaServerItem.async_exists_by_title(
session, title="并行标题", mtype=mtype, year=year
)
)
assert (sync_found is None) == (async_found is None) assert (sync_found is None) == (async_found is None)
+40 -43
View File
@@ -8,14 +8,13 @@ import asyncio
import pytest import pytest
from app.db import decorators from app.db import base as db_base
from app.db.models.agentchat import AgentChat from app.db.models.agentchat import AgentChat
from app.db.models.agenttask import AgentTask from app.db.models.agenttask import AgentTask
from app.db.models.downloadfailure import DownloadFailure from app.db.models.downloadfailure import DownloadFailure
from app.db.models.message import Message from app.db.models.message import Message
from app.db.models.plugindata import PluginData from app.db.models.plugindata import PluginData
from app.db.oper.agenttask import AgentTaskOper from app.db.oper.agenttask import AgentTaskOper
from app.db.session import SessionFactory
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -39,7 +38,9 @@ def test_plugindata_is_scoped_by_plugin_id(db):
rows = PluginData.get_plugin_data(db.session, "PluginA") rows = PluginData.get_plugin_data(db.session, "PluginA")
assert {r.key for r in rows} == {"k1", "k2"} assert {r.key for r in rows} == {"k1", "k2"}
assert {r.key for r in asyncio.run(PluginData.async_get_plugin_data(plugin_id="PluginA"))} \ assert {r.key for r in db.run_async_session(
lambda session: PluginData.async_get_plugin_data(session, "PluginA")
)} \
== {"k1", "k2"} == {"k1", "k2"}
@@ -53,8 +54,11 @@ def test_plugindata_get_by_key_needs_both_plugin_and_key(db):
assert PluginData.get_plugin_data_by_key(db.session, "PluginA", "shared").value == {"v": 1} assert PluginData.get_plugin_data_by_key(db.session, "PluginA", "shared").value == {"v": 1}
assert PluginData.get_plugin_data_by_key(db.session, "PluginB", "shared").value == {"v": 2} assert PluginData.get_plugin_data_by_key(db.session, "PluginB", "shared").value == {"v": 2}
assert PluginData.get_plugin_data_by_key(db.session, "PluginC", "shared") is None assert PluginData.get_plugin_data_by_key(db.session, "PluginC", "shared") is None
assert asyncio.run(PluginData.async_get_plugin_data_by_key( assert db.run_async_session(
plugin_id="PluginA", key="shared")).value == {"v": 1} lambda session: PluginData.async_get_plugin_data_by_key(
session, plugin_id="PluginA", key="shared"
)
).value == {"v": 1}
def test_plugindata_delete_by_key_removes_only_that_entry(db): def test_plugindata_delete_by_key_removes_only_that_entry(db):
@@ -120,7 +124,9 @@ def test_message_list_by_page_matches_async_twin(db):
db.add(_message(f"2026-08-13 11:00:0{index}", f"par-{index}")) db.add(_message(f"2026-08-13 11:00:0{index}", f"par-{index}"))
sync_titles = [m.title for m in Message.list_by_page(db.session, page=1, count=3)] sync_titles = [m.title for m in Message.list_by_page(db.session, page=1, count=3)]
async_titles = [m.title for m in asyncio.run(Message.async_list_by_page(page=1, count=3))] async_titles = [m.title for m in db.run_async_session(
lambda session: Message.async_list_by_page(session, page=1, count=3)
)]
assert sync_titles == async_titles assert sync_titles == async_titles
@@ -181,7 +187,11 @@ def test_message_async_list_sent_excludes_the_clear_boundary(db):
def _titles(**clears) -> set: def _titles(**clears) -> set:
"""取本用例写入的消息标题集合,隔离其他用例可能残留的消息。""" """取本用例写入的消息标题集合,隔离其他用例可能残留的消息。"""
rows = asyncio.run(Message.async_list_sent_by_page(page=1, count=100, **clears)) rows = db.run_async_session(
lambda session: Message.async_list_sent_by_page(
session, page=1, count=100, **clears
)
)
return {m.title for m in rows if m.title.startswith("bd-")} return {m.title for m in rows if m.title.startswith("bd-")}
# 全量清空水位:边界上的两条都属于被清空的那一批 # 全量清空水位:边界上的两条都属于被清空的那一批
@@ -226,7 +236,9 @@ def test_agentchat_get_by_session_takes_the_newest_row(db):
newest = db.add(_chat("s-dup")) newest = db.add(_chat("s-dup"))
assert AgentChat.get_by_session(db.session, "s-dup").id == newest.id assert AgentChat.get_by_session(db.session, "s-dup").id == newest.id
assert asyncio.run(AgentChat.async_get_by_session(session_id="s-dup")).id == newest.id assert db.run_async_session(
lambda session: AgentChat.async_get_by_session(session, "s-dup")
).id == newest.id
def test_agentchat_get_by_session_enforces_user_scope(db): def test_agentchat_get_by_session_enforces_user_scope(db):
@@ -237,16 +249,22 @@ def test_agentchat_get_by_session_enforces_user_scope(db):
assert AgentChat.get_by_session(db.session, "s-owned", user_id="alice") is not None assert AgentChat.get_by_session(db.session, "s-owned", user_id="alice") is not None
assert AgentChat.get_by_session(db.session, "s-owned", user_id="bob") is None assert AgentChat.get_by_session(db.session, "s-owned", user_id="bob") is None
assert asyncio.run(AgentChat.async_get_by_session(session_id="s-owned", user_id="bob")) is None assert db.run_async_session(
lambda session: AgentChat.async_get_by_session(
session, session_id="s-owned", user_id="bob"
)
) is None
def test_agentchat_oper_reuses_explicit_query_sessions(db, monkeypatch): def test_agentchat_oper_reuses_explicit_query_sessions(db, monkeypatch):
"""AgentChatOper 的同步与异步查询必须复用调用方会话。""" """AgentChatOper 的同步与异步查询必须复用调用方会话。"""
db.add(_chat("s-explicit", user_id="explicit")) db.add(_chat("s-explicit", user_id="explicit"))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
from app.db.oper.agentchat import AgentChatOper from app.db.oper.agentchat import AgentChatOper
@@ -259,29 +277,17 @@ def test_agentchat_oper_reuses_explicit_query_sessions(db, monkeypatch):
async with async_session_scope() as session: async with async_session_scope() as session:
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"async_session_scope", "run_async_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
) )
assert await AgentChatOper(session).async_get("s-explicit", "explicit") assert await AgentChatOper(session).async_get("s-explicit", "explicit")
asyncio.run(check()) asyncio.run(check())
def test_agentchat_model_legacy_query_keeps_keyword_abi(db, monkeypatch):
"""旧插件以关键字直调 AgentChat 时仍自动补入短会话。"""
db.add(_chat("s-legacy"))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert AgentChat.get_by_session(session_id="s-legacy") is not None
assert opened == [True]
def test_agentchat_list_by_page_matches_either_user_or_username(db): def test_agentchat_list_by_page_matches_either_user_or_username(db):
""" """
同时给出用户 ID 与用户名时按匹配 同时给出用户 ID 与用户名时按匹配
@@ -324,8 +330,11 @@ def test_agentchat_list_by_page_is_newest_first_and_paged(db):
assert [c.session_id for c in page1] == ["s-p3", "s-p2"] assert [c.session_id for c in page1] == ["s-p3", "s-p2"]
assert [c.session_id for c in page2] == ["s-p1", "s-p0"] assert [c.session_id for c in page2] == ["s-p1", "s-p0"]
assert [c.session_id for c in asyncio.run( assert [c.session_id for c in db.run_async_session(
AgentChat.async_list_by_page(page=1, count=2, user_id="uid-page"))] == ["s-p3", "s-p2"] lambda session: AgentChat.async_list_by_page(
session, page=1, count=2, user_id="uid-page"
)
)] == ["s-p3", "s-p2"]
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -352,18 +361,6 @@ def test_agenttask_get_for_user_enforces_ownership(db):
assert AgentTask.get_for_user(db.session, task_id, user_id="bob") is None assert AgentTask.get_for_user(db.session, task_id, user_id="bob") is None
def test_agenttask_model_queries_keep_no_session_plugin_abi(db):
"""旧插件省略 Session 时仍由统一 legacy 装饰器按原参数查询。"""
task_id = AgentTask.add_task(db.session, **_task("legacy", user_id="legacy-user"))
db.session.commit()
assert AgentTask.get_for_user(
task_id=task_id,
user_id="legacy-user",
).id == task_id
assert [task.id for task in AgentTask.list_for_user(user_id="legacy-user")] == [task_id]
def test_agenttask_oper_reads_with_explicit_session(db, monkeypatch): def test_agenttask_oper_reads_with_explicit_session(db, monkeypatch):
"""AgentTaskOper 的宿主查询使用调用方 Session,不再经过旧事务兼容执行器。""" """AgentTaskOper 的宿主查询使用调用方 Session,不再经过旧事务兼容执行器。"""
task_id = AgentTask.add_task(db.session, **_task("canonical", user_id="alice")) task_id = AgentTask.add_task(db.session, **_task("canonical", user_id="alice"))
+2 -2
View File
@@ -22,8 +22,8 @@ def test_session_factories_are_not_part_of_the_public_contract():
""" """
三个会话工厂不得出现在 ``__all__`` 三个会话工厂不得出现在 ``__all__``
插件访问数据应走 ``DbOper`` 子类或 ``db_query`` / ``async_db_query`` 装饰器 插件访问宿主数据应走 ``DbOper``插件自有表可使用 ``db_query`` / ``async_db_query``
由装饰器收口会话的提交回滚与释放 装饰器由装饰器收口插件自有会话的提交回滚与释放
""" """
leaked = [name for name in INTERNAL_FACTORY_NAMES if name in db_package.__all__] leaked = [name for name in INTERNAL_FACTORY_NAMES if name in db_package.__all__]
assert not leaked, f"会话工厂被重新放进了对外契约:{leaked}" assert not leaked, f"会话工厂被重新放进了对外契约:{leaked}"
+37 -30
View File
@@ -9,13 +9,13 @@ import asyncio
import pytest import pytest
from app.db import decorators from app.db import base as db_base
from app.db.models.site import Site from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData from app.db.models.siteuserdata import SiteUserData
from app.db.oper.site import SiteOper from app.db.oper.site import SiteOper
from app.db.session import SessionFactory, async_session_scope from app.db.session import async_session_scope
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -40,8 +40,12 @@ def test_site_get_by_domain_matches_async_twin(db):
db.add(_site("站点A", "a.test"), _site("站点B", "b.test")) db.add(_site("站点A", "a.test"), _site("站点B", "b.test"))
assert Site.get_by_domain(db.session, "a.test").name == "站点A" assert Site.get_by_domain(db.session, "a.test").name == "站点A"
assert asyncio.run(Site.async_get_by_domain(domain="a.test")).name == "站点A" assert db.run_async_session(
assert asyncio.run(Site.async_get_by_name(name="站点B")).domain == "b.test" lambda session: Site.async_get_by_domain(session, "a.test")
).name == "站点A"
assert db.run_async_session(
lambda session: Site.async_get_by_name(session, "站点B")
).domain == "b.test"
def test_site_get_by_domain_returns_none_when_absent(db): def test_site_get_by_domain_returns_none_when_absent(db):
@@ -61,7 +65,9 @@ def test_site_get_actives_excludes_disabled_sites(db):
_site("停用", "off.test", is_active=False)) _site("停用", "off.test", is_active=False))
assert {s.domain for s in Site.get_actives(db.session)} == {"on1.test", "on2.test"} assert {s.domain for s in Site.get_actives(db.session)} == {"on1.test", "on2.test"}
assert {s.domain for s in asyncio.run(Site.async_get_actives())} == {"on1.test", "on2.test"} assert {s.domain for s in db.run_async_session(Site.async_get_actives)} == {
"on1.test", "on2.test"
}
def test_site_list_order_by_pri_is_ascending(db): def test_site_list_order_by_pri_is_ascending(db):
@@ -73,7 +79,7 @@ def test_site_list_order_by_pri_is_ascending(db):
assert [s.domain for s in Site.list_order_by_pri(db.session)] == \ assert [s.domain for s in Site.list_order_by_pri(db.session)] == \
["p1.test", "p2.test", "p3.test"] ["p1.test", "p2.test", "p3.test"]
assert [s.domain for s in asyncio.run(Site.async_list_order_by_pri())] == \ assert [s.domain for s in db.run_async_session(Site.async_list_order_by_pri)] == \
["p1.test", "p2.test", "p3.test"] ["p1.test", "p2.test", "p3.test"]
@@ -123,7 +129,9 @@ def test_siteicon_get_by_domain_matches_async_twin(db):
SiteIcon(name="站点B", domain="icon-b.test", url="https://icon-b.test/f.ico")) SiteIcon(name="站点B", domain="icon-b.test", url="https://icon-b.test/f.ico"))
assert SiteIcon.get_by_domain(db.session, "icon-a.test").name == "站点A" assert SiteIcon.get_by_domain(db.session, "icon-a.test").name == "站点A"
assert asyncio.run(SiteIcon.async_get_by_domain(domain="icon-a.test")).name == "站点A" assert db.run_async_session(
lambda session: SiteIcon.async_get_by_domain(session, "icon-a.test")
).name == "站点A"
assert SiteIcon.get_by_domain(db.session, "icon-missing.test") is None assert SiteIcon.get_by_domain(db.session, "icon-missing.test") is None
@@ -135,7 +143,9 @@ def test_sitestatistic_get_by_domain_matches_async_twin(db):
SiteStatistic(domain="stat-b.test", success=1, fail=0, seconds=1, lst_state=0)) SiteStatistic(domain="stat-b.test", success=1, fail=0, seconds=1, lst_state=0))
assert SiteStatistic.get_by_domain(db.session, "stat-a.test").success == 3 assert SiteStatistic.get_by_domain(db.session, "stat-a.test").success == 3
assert asyncio.run(SiteStatistic.async_get_by_domain(domain="stat-a.test")).success == 3 assert db.run_async_session(
lambda session: SiteStatistic.async_get_by_domain(session, "stat-a.test")
).success == 3
assert SiteStatistic.get_by_domain(db.session, "stat-missing.test") is None assert SiteStatistic.get_by_domain(db.session, "stat-missing.test") is None
@@ -186,7 +196,11 @@ def test_userdata_get_by_domain_matches_async_twin(db):
for kwargs in ({}, {"workdate": "2026-08-12"}, for kwargs in ({}, {"workdate": "2026-08-12"},
{"workdate": "2026-08-12", "worktime": "20:00:00"}): {"workdate": "2026-08-12", "worktime": "20:00:00"}):
sync_rows = SiteUserData.get_by_domain(db.session, "ud2.test", **kwargs) sync_rows = SiteUserData.get_by_domain(db.session, "ud2.test", **kwargs)
async_rows = asyncio.run(SiteUserData.async_get_by_domain(domain="ud2.test", **kwargs)) async_rows = db.run_async_session(
lambda session: SiteUserData.async_get_by_domain(
session, domain="ud2.test", **kwargs
)
)
assert len(sync_rows) == len(async_rows) assert len(sync_rows) == len(async_rows)
@@ -194,9 +208,11 @@ def test_site_oper_reuses_explicit_userdata_query_sessions(db, monkeypatch):
"""站点用户数据 Oper 必须复用调用方同步与异步会话。""" """站点用户数据 Oper 必须复用调用方同步与异步会话。"""
db.add(_userdata("explicit-site.test", "2026-08-12", "10:00:00")) db.add(_userdata("explicit-site.test", "2026-08-12", "10:00:00"))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
assert SiteOper(db.session).get_userdata_by_domain("explicit-site.test") assert SiteOper(db.session).get_userdata_by_domain("explicit-site.test")
@@ -205,9 +221,11 @@ def test_site_oper_reuses_explicit_userdata_query_sessions(db, monkeypatch):
"""验证异步站点用户数据查询复用显式 AsyncSession。""" """验证异步站点用户数据查询复用显式 AsyncSession。"""
async with async_session_scope() as session: async with async_session_scope() as session:
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"async_session_scope", "run_async_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
) )
assert await SiteOper(session).async_get_userdata_by_domain( assert await SiteOper(session).async_get_userdata_by_domain(
"explicit-site.test" "explicit-site.test"
@@ -216,20 +234,6 @@ def test_site_oper_reuses_explicit_userdata_query_sessions(db, monkeypatch):
asyncio.run(check()) asyncio.run(check())
def test_site_userdata_model_legacy_query_keeps_keyword_abi(db, monkeypatch):
"""旧插件以关键字直调 SiteUserData 时仍自动补入短会话。"""
db.add(_userdata("legacy-site.test", "2026-08-12", "10:00:00"))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert SiteUserData.get_by_domain(domain="legacy-site.test")
assert opened == [True]
def test_userdata_get_by_date_returns_all_domains_of_that_day(db): def test_userdata_get_by_date_returns_all_domains_of_that_day(db):
""" """
按日期查询应跨站点返回当天全部快照 按日期查询应跨站点返回当天全部快照
@@ -283,7 +287,10 @@ def test_userdata_get_latest_matches_async_twin(db):
_userdata("par.test", "2026-08-12", "10:00:00")) _userdata("par.test", "2026-08-12", "10:00:00"))
sync_rows = [(r.domain, r.updated_day) for r in SiteUserData.get_latest(db.session)] sync_rows = [(r.domain, r.updated_day) for r in SiteUserData.get_latest(db.session)]
async_rows = [(r.domain, r.updated_day) for r in asyncio.run(SiteUserData.async_get_latest())] async_rows = [
(r.domain, r.updated_day)
for r in db.run_async_session(SiteUserData.async_get_latest)
]
assert sorted(sync_rows) == sorted(async_rows) assert sorted(sync_rows) == sorted(async_rows)
+43 -57
View File
@@ -10,11 +10,11 @@ import time as _time
import pytest import pytest
from app.db import decorators from app.db import base as db_base
from app.db.models import subscribe as subscribe_module from app.db.models import subscribe as subscribe_module
from app.db.models.subscribe import Subscribe from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory from app.db.models.subscribehistory import SubscribeHistory
from app.db.session import SessionFactory, async_session_scope from app.db.session import async_session_scope
from app.schemas.types import MediaSource, MediaType from app.schemas.types import MediaSource, MediaType
TMDB = str(MediaSource.TMDB) TMDB = str(MediaSource.TMDB)
@@ -67,8 +67,11 @@ def test_exists_matches_async_twin(db):
db.add(_sub("并行", season=1)) db.add(_sub("并行", season=1))
sync_found = Subscribe.exists(db.session, MediaSource.TMDB, "9001", season=1) sync_found = Subscribe.exists(db.session, MediaSource.TMDB, "9001", season=1)
async_found = asyncio.run(Subscribe.async_exists( async_found = db.run_async_session(
media_source=MediaSource.TMDB, media_id="9001", season=1)) lambda session: Subscribe.async_exists(
session, media_source=MediaSource.TMDB, media_id="9001", season=1
)
)
assert sync_found.id == async_found.id assert sync_found.id == async_found.id
@@ -77,9 +80,11 @@ def test_history_queries_reuse_explicit_sessions(db, monkeypatch):
"""订阅历史同步/异步查询必须复用调用方会话。""" """订阅历史同步/异步查询必须复用调用方会话。"""
row = db.add(_history("显式历史", media_id="8501")) row = db.add(_history("显式历史", media_id="8501"))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
assert SubscribeHistory.list_by_type( assert SubscribeHistory.list_by_type(
db.session, MediaType.TV.value, page=1, count=10 db.session, MediaType.TV.value, page=1, count=10
@@ -92,9 +97,11 @@ def test_history_queries_reuse_explicit_sessions(db, monkeypatch):
"""验证异步订阅历史查询复用显式 AsyncSession。""" """验证异步订阅历史查询复用显式 AsyncSession。"""
async with async_session_scope() as session: async with async_session_scope() as session:
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"async_session_scope", "run_async_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
) )
assert await SubscribeHistory.async_list_by_type( assert await SubscribeHistory.async_list_by_type(
session, MediaType.TV.value, page=1, count=10 session, MediaType.TV.value, page=1, count=10
@@ -109,44 +116,6 @@ def test_history_queries_reuse_explicit_sessions(db, monkeypatch):
asyncio.run(check()) asyncio.run(check())
def test_history_queries_keep_legacy_keyword_abi(db, monkeypatch):
"""旧插件关键字直调订阅历史查询时仍自动补入兼容会话。"""
row = db.add(_history("关键字历史", media_id="8601"))
opened_sync = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened_sync.append(True) or SessionFactory()),
)
assert SubscribeHistory.list_by_type(
mtype=MediaType.TV.value, page=1, count=10
)
assert SubscribeHistory.exists(
media_source=MediaSource.TMDB, media_id="8601", season=1
).id == row.id
assert opened_sync == [True, True]
opened_async = []
original_scope = async_session_scope
def tracked_scope():
"""记录旧异步 ABI 创建的兼容会话作用域。"""
opened_async.append(True)
return original_scope()
monkeypatch.setattr(decorators, "async_session_scope", tracked_scope)
assert asyncio.run(SubscribeHistory.async_list_by_type(
mtype=MediaType.TV.value, page=1, count=10
))
assert asyncio.run(SubscribeHistory.async_list_by_type_and_username(
mtype=MediaType.TV.value, username="alice", page=1, count=10
))
assert asyncio.run(SubscribeHistory.async_exists(
media_source=MediaSource.TMDB, media_id="8601", season=1
)) is not None
assert opened_async == [True, True, True]
@pytest.mark.parametrize("media_id", [None, "", " "]) @pytest.mark.parametrize("media_id", [None, "", " "])
def test_exists_rejects_blank_media_id(db, media_id): def test_exists_rejects_blank_media_id(db, media_id):
""" """
@@ -232,7 +201,9 @@ def test_get_by_state_splits_comma_separated_states(db):
assert states == {"N", "R"} assert states == {"N", "R"}
assert len(Subscribe.get_by_state(db.session, "")) >= 3 assert len(Subscribe.get_by_state(db.session, "")) >= 3
assert {s.state for s in asyncio.run(Subscribe.async_get_by_state(state="N,R"))} == {"N", "R"} assert {s.state for s in db.run_async_session(
lambda session: Subscribe.async_get_by_state(session, state="N,R")
)} == {"N", "R"}
def test_get_by_title_optionally_narrows_by_season(db): def test_get_by_title_optionally_narrows_by_season(db):
@@ -281,8 +252,11 @@ def test_list_by_username_matches_async_twin(db):
("N", MediaType.TV.value)): ("N", MediaType.TV.value)):
sync_names = sorted(s.name for s in sync_names = sorted(s.name for s in
Subscribe.list_by_username(db.session, "alice", state, mtype)) Subscribe.list_by_username(db.session, "alice", state, mtype))
async_names = sorted(s.name for s in asyncio.run( async_names = sorted(s.name for s in db.run_async_session(
Subscribe.async_list_by_username(username="alice", state=state, mtype=mtype))) lambda session: Subscribe.async_list_by_username(
session, username="alice", state=state, mtype=mtype
)
))
assert sync_names == async_names assert sync_names == async_names
@@ -319,8 +293,11 @@ def test_list_by_type_includes_the_window_start_boundary(db, frozen_now):
date=one_second_earlier)) date=one_second_earlier))
names = {s.name for s in Subscribe.list_by_type(db.session, MediaType.TV.value, days=7)} names = {s.name for s in Subscribe.list_by_type(db.session, MediaType.TV.value, days=7)}
async_names = {s.name for s in asyncio.run( async_names = {s.name for s in db.run_async_session(
Subscribe.async_list_by_type(mtype=MediaType.TV.value, days=7))} lambda session: Subscribe.async_list_by_type(
session, mtype=MediaType.TV.value, days=7
)
)}
assert "窗口起点上" in names and "窗口起点前一秒" not in names assert "窗口起点上" in names and "窗口起点前一秒" not in names
assert "窗口起点上" in async_names and "窗口起点前一秒" not in async_names assert "窗口起点上" in async_names and "窗口起点前一秒" not in async_names
@@ -363,8 +340,11 @@ def test_history_list_by_type_matches_async_twin(db):
sync_names = [h.name for h in SubscribeHistory.list_by_type( sync_names = [h.name for h in SubscribeHistory.list_by_type(
db.session, MediaType.TV.value, page=1, count=10)] db.session, MediaType.TV.value, page=1, count=10)]
async_names = [h.name for h in asyncio.run(SubscribeHistory.async_list_by_type( async_names = [h.name for h in db.run_async_session(
mtype=MediaType.TV.value, page=1, count=10))] lambda session: SubscribeHistory.async_list_by_type(
session, mtype=MediaType.TV.value, page=1, count=10
)
)]
assert sync_names == async_names assert sync_names == async_names
@@ -392,7 +372,13 @@ def test_history_exists_matches_async_twin(db):
db.add(_history("并行历史", season=1, media_id="8401")) db.add(_history("并行历史", season=1, media_id="8401"))
sync_found = SubscribeHistory.exists(db.session, MediaSource.TMDB, "8401", season=1) sync_found = SubscribeHistory.exists(db.session, MediaSource.TMDB, "8401", season=1)
async_found = asyncio.run(SubscribeHistory.async_exists( async_found = db.run_async_session(
media_source=MediaSource.TMDB, media_id="8401", season=1)) lambda session: SubscribeHistory.async_exists(
session,
media_source=MediaSource.TMDB,
media_id="8401",
season=1,
)
)
assert sync_found.id == async_found.id assert sync_found.id == async_found.id
+19 -6
View File
@@ -401,8 +401,11 @@ def test_list_by_title_matches_async_twin(db):
sync_titles = [h.title for h in TransferHistory.list_by_title( sync_titles = [h.title for h in TransferHistory.list_by_title(
db.session, "ParallelSearch", count=-1)] db.session, "ParallelSearch", count=-1)]
async_titles = [h.title for h in asyncio.run(TransferHistory.async_list_by_title( async_titles = [h.title for h in db.run_async_session(
title="ParallelSearch", count=-1))] lambda session: TransferHistory.async_list_by_title(
session, title="ParallelSearch", count=-1
)
)]
assert sync_titles == async_titles assert sync_titles == async_titles
@@ -415,13 +418,21 @@ def test_count_and_count_by_title_match_async_twins(db):
db.add(_hist("CountMe", status=True, src="/downloads/c1.mkv", dest="/media/c1.mkv"), db.add(_hist("CountMe", status=True, src="/downloads/c1.mkv", dest="/media/c1.mkv"),
_hist("CountMe", status=False, src="/downloads/c2.mkv", dest="/media/c2.mkv")) _hist("CountMe", status=False, src="/downloads/c2.mkv", dest="/media/c2.mkv"))
assert TransferHistory.count(db.session) == asyncio.run(TransferHistory.async_count()) assert TransferHistory.count(db.session) == db.run_async_session(
TransferHistory.async_count
)
assert TransferHistory.count(db.session, status=True) == \ assert TransferHistory.count(db.session, status=True) == \
asyncio.run(TransferHistory.async_count(status=True)) db.run_async_session(
lambda session: TransferHistory.async_count(session, status=True)
)
assert TransferHistory.count_by_title(db.session, "CountMe") == 2 assert TransferHistory.count_by_title(db.session, "CountMe") == 2
assert TransferHistory.count_by_title(db.session, "CountMe", status=False) == 1 assert TransferHistory.count_by_title(db.session, "CountMe", status=False) == 1
assert TransferHistory.count_by_title(db.session, "CountMe") == \ assert TransferHistory.count_by_title(db.session, "CountMe") == \
asyncio.run(TransferHistory.async_count_by_title(title="CountMe")) db.run_async_session(
lambda session: TransferHistory.async_count_by_title(
session, title="CountMe"
)
)
def test_statistic_groups_by_day_within_the_window(db): def test_statistic_groups_by_day_within_the_window(db):
@@ -452,7 +463,9 @@ def test_statistic_includes_the_window_start_boundary(db, frozen_now):
db.add(_hist("窗口起点上", src="/data/bstat.mkv", date=window_start)) db.add(_hist("窗口起点上", src="/data/bstat.mkv", date=window_start))
rows = dict(TransferHistory.statistic(db.session, days=7)) rows = dict(TransferHistory.statistic(db.session, days=7))
async_rows = dict(asyncio.run(TransferHistory.async_statistic(days=7))) async_rows = dict(db.run_async_session(
lambda session: TransferHistory.async_statistic(session, days=7)
))
assert rows.get(boundary_day, 0) == 1 assert rows.get(boundary_day, 0) == 1
assert async_rows.get(boundary_day, 0) == 1 assert async_rows.get(boundary_day, 0) == 1
+6 -23
View File
@@ -7,10 +7,9 @@
""" """
import pytest import pytest
from app.db import decorators from app.db import base as db_base
from app.db.models.transferpending import TransferPending from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper from app.db.oper.transferpending import TransferPendingOper
from app.db.session import SessionFactory
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -160,32 +159,16 @@ def test_oper_reuses_explicit_query_session(db, monkeypatch):
created_at="2026-08-13 10:00:00", created_at="2026-08-13 10:00:00",
)) ))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
assert ("local", "/mnt/explicit.mkv") in TransferPendingOper(db.session).list_all() assert ("local", "/mnt/explicit.mkv") in TransferPendingOper(db.session).list_all()
def test_model_legacy_query_keeps_keyword_abi(db, monkeypatch):
"""旧插件以关键字直调 TransferPending 时仍自动补入短会话。"""
db.add(TransferPending(
storage="local",
src_path="/mnt/legacy.mkv",
created_at="2026-08-13 10:00:00",
))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert TransferPending.list_all(limit=1)
assert opened == [True]
def test_oper_drops_rows_with_missing_fields(db): def test_oper_drops_rows_with_missing_fields(db):
""" """
回放时必须跳过字段残缺的历史遗留行不能把空存储送进整理链 回放时必须跳过字段残缺的历史遗留行不能把空存储送进整理链
+25 -28
View File
@@ -9,10 +9,10 @@ import asyncio
import pytest import pytest
from app.db import decorators from app.db import base as db_base
from app.db.models.workflow import Workflow from app.db.models.workflow import Workflow
from app.db.oper.workflow import WorkflowOper from app.db.oper.workflow import WorkflowOper
from app.db.session import SessionFactory, async_session_scope from app.db.session import async_session_scope
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -52,11 +52,13 @@ def test_list_and_get_by_name_match_async_twins(db):
created = db.add(_flow("wf-name")) created = db.add(_flow("wf-name"))
assert Workflow.get_by_name(db.session, "wf-name").id == created.id assert Workflow.get_by_name(db.session, "wf-name").id == created.id
assert asyncio.run(Workflow.async_get_by_name(name="wf-name")).id == created.id assert db.run_async_session(
lambda session: Workflow.async_get_by_name(session, "wf-name")
).id == created.id
assert Workflow.get_by_name(db.session, "wf-missing") is None assert Workflow.get_by_name(db.session, "wf-missing") is None
sync_ids = sorted(w.id for w in Workflow.list(db.session)) sync_ids = sorted(w.id for w in Workflow.list(db.session))
async_ids = sorted(w.id for w in asyncio.run(Workflow.async_list())) async_ids = sorted(w.id for w in db.run_async_session(Workflow.async_list))
assert sync_ids == async_ids assert sync_ids == async_ids
@@ -64,9 +66,11 @@ def test_workflow_oper_reuses_explicit_query_sessions(db, monkeypatch):
"""WorkflowOper 绑定显式会话后不得再创建兼容查询会话。""" """WorkflowOper 绑定显式会话后不得再创建兼容查询会话。"""
created = db.add(_flow("wf-explicit-session")) created = db.add(_flow("wf-explicit-session"))
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"ScopedSession", "run_sync_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
) )
assert WorkflowOper(db.session).get_by_name(created.name).id == created.id assert WorkflowOper(db.session).get_by_name(created.name).id == created.id
@@ -75,29 +79,17 @@ def test_workflow_oper_reuses_explicit_query_sessions(db, monkeypatch):
"""验证异步 Oper 同样复用调用方会话。""" """验证异步 Oper 同样复用调用方会话。"""
async with async_session_scope() as session: async with async_session_scope() as session:
monkeypatch.setattr( monkeypatch.setattr(
decorators, db_base,
"async_session_scope", "run_async_transaction",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
) )
assert (await WorkflowOper(session).async_get_by_name(created.name)).id == created.id assert (await WorkflowOper(session).async_get_by_name(created.name)).id == created.id
asyncio.run(check()) asyncio.run(check())
def test_workflow_model_legacy_queries_keep_no_session_abi(db, monkeypatch):
"""旧插件直接调用 Workflow Model 时仍应按签名自动补入短会话。"""
created = db.add(_flow("wf-legacy-query"))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert Workflow.get_by_name(name=created.name).id == created.id
assert opened == [True]
def test_enabled_workflows_exclude_paused(db): def test_enabled_workflows_exclude_paused(db):
""" """
启用列表排除暂停状态 启用列表排除暂停状态
@@ -111,8 +103,9 @@ def test_enabled_workflows_exclude_paused(db):
assert {"wf-waiting", "wf-running"} <= names assert {"wf-waiting", "wf-running"} <= names
assert "wf-paused" not in names assert "wf-paused" not in names
assert "wf-paused" not in {w.name for w in assert "wf-paused" not in {
asyncio.run(Workflow.async_get_enabled_workflows())} w.name for w in db.run_async_session(Workflow.async_get_enabled_workflows)
}
def test_timer_triggered_includes_legacy_null_trigger_type(db): def test_timer_triggered_includes_legacy_null_trigger_type(db):
@@ -156,9 +149,13 @@ def test_trigger_lists_match_async_twins(db):
db.add(_flow("wf-t", trigger_type="timer"), _flow("wf-e", trigger_type="event")) db.add(_flow("wf-t", trigger_type="timer"), _flow("wf-e", trigger_type="event"))
assert sorted(w.id for w in Workflow.get_timer_triggered_workflows(db.session)) == \ assert sorted(w.id for w in Workflow.get_timer_triggered_workflows(db.session)) == \
sorted(w.id for w in asyncio.run(Workflow.async_get_timer_triggered_workflows())) sorted(w.id for w in db.run_async_session(
Workflow.async_get_timer_triggered_workflows
))
assert sorted(w.id for w in Workflow.get_event_triggered_workflows(db.session)) == \ assert sorted(w.id for w in Workflow.get_event_triggered_workflows(db.session)) == \
sorted(w.id for w in asyncio.run(Workflow.async_get_event_triggered_workflows())) sorted(w.id for w in db.run_async_session(
Workflow.async_get_event_triggered_workflows
))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+41 -23
View File
@@ -100,12 +100,18 @@ def test_sync_persists_music_without_querying_tv_episodes(database):
) )
chain.episodes = lambda *_args, **_kwargs: pytest.fail("音乐条目不应查询电视剧分集") chain.episodes = lambda *_args, **_kwargs: pytest.fail("音乐条目不应查询电视剧分集")
with patch("app.db.decorators.ScopedSession", database), patch.object( with database() as session:
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, with patch.object(
"get_mediaserver_configs", MEDIA_SERVER_CHAIN_MODULE,
return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])], "MediaServerOper",
): lambda: MediaServerOper(session),
chain.sync() ), patch.object(
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])],
):
chain.sync()
session.commit()
with database() as db: with database() as db:
item = db.query(MediaServerItem).one() item = db.query(MediaServerItem).one()
@@ -187,12 +193,18 @@ def test_sync_updates_rows_and_removes_stale_entries(database):
) )
chain.episodes = lambda *_args, **_kwargs: [] chain.episodes = lambda *_args, **_kwargs: []
with patch("app.db.decorators.ScopedSession", database), patch.object( with database() as session:
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, with patch.object(
"get_mediaserver_configs", MEDIA_SERVER_CHAIN_MODULE,
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])], "MediaServerOper",
): lambda: MediaServerOper(session),
chain.sync() ), patch.object(
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])],
):
chain.sync()
session.commit()
with database() as db: with database() as db:
items = ( items = (
@@ -265,17 +277,23 @@ def test_sync_queries_counts_before_items_and_reports_media_progress(database):
chain.items = items chain.items = items
chain.episodes = lambda *_args, **_kwargs: [] chain.episodes = lambda *_args, **_kwargs: []
with patch("app.db.decorators.ScopedSession", database), patch.object( with database() as session:
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, with patch.object(
"get_mediaserver_configs", MEDIA_SERVER_CHAIN_MODULE,
return_value=[ "MediaServerOper",
SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]), lambda: MediaServerOper(session),
SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]), ), patch.object(
], MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper,
): "get_mediaserver_configs",
chain.sync( return_value=[
progress_callback=lambda **kwargs: progress_snapshots.append(kwargs) SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]),
) SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]),
],
):
chain.sync(
progress_callback=lambda **kwargs: progress_snapshots.append(kwargs)
)
session.commit()
assert events == [ assert events == [
"count:plex-a", "count:plex-a",
+5 -7
View File
@@ -190,16 +190,13 @@ def test_default_sync_writer_persists_once_and_reuses_duplicate(db) -> None:
] ]
def test_stage_add_executes_identity_sql_in_oper(db, monkeypatch) -> None: def test_stage_add_reuses_explicit_session_without_commit(db, monkeypatch) -> None:
"""规范新增路径直接由 Oper 查询,不能退回 Model 自动会话装饰器""" """Oper 将调用方 Session 传给 Model 查询原语,暂存期间不自行提交"""
db.watermark(Subscribe) db.watermark(Subscribe)
commit = Mock(wraps=db.session.commit) commit = Mock(wraps=db.session.commit)
exists = Mock(wraps=Subscribe.exists)
monkeypatch.setattr(db.session, "commit", commit) monkeypatch.setattr(db.session, "commit", commit)
monkeypatch.setattr( monkeypatch.setattr(Subscribe, "exists", exists)
Subscribe,
"exists",
Mock(side_effect=AssertionError("model query must not run")),
)
oper = SubscribeOper(db.session) oper = SubscribeOper(db.session)
identity = { identity = {
"media_source": str(MediaSource.TMDB), "media_source": str(MediaSource.TMDB),
@@ -221,6 +218,7 @@ def test_stage_add_executes_identity_sql_in_oper(db, monkeypatch) -> None:
assert staged.created is True assert staged.created is True
assert staged.subscribe_id > 0 assert staged.subscribe_id > 0
assert exists.call_args.args[0] is db.session
commit.assert_not_called() commit.assert_not_called()
db.session.rollback() db.session.rollback()
+44 -55
View File
@@ -418,11 +418,10 @@ def test_exists_defaults_to_main_season_episode_group():
assert history_model.exists.call_args.kwargs["episode_group"] == "eg-1" assert history_model.exists.call_args.kwargs["episode_group"] == "eg-1"
def test_subscribe_exists_distinguishes_same_season_episode_groups(): def test_subscribe_exists_distinguishes_same_season_episode_groups(db):
"""同一媒体同一季的主季、自定义剧集组应分别命中各自订阅。""" """同一媒体同一季的主季、自定义剧集组应分别命中各自订阅。"""
oper = SubscribeOper() db.watermark(Subscribe)
media_id = str(-(900_000_000 + os.getpid())) media_id = str(-(900_000_000 + os.getpid()))
created_ids = []
rows = [ rows = [
Subscribe(name="主季订阅", type=MediaType.TV.value, state="N", Subscribe(name="主季订阅", type=MediaType.TV.value, state="N",
media_source=MediaSource.TMDB.value, media_id=media_id, media_source=MediaSource.TMDB.value, media_id=media_id,
@@ -431,43 +430,38 @@ def test_subscribe_exists_distinguishes_same_season_episode_groups():
media_source=MediaSource.TMDB.value, media_id=media_id, media_source=MediaSource.TMDB.value, media_id=media_id,
season=1, episode_group="eg-1"), season=1, episode_group="eg-1"),
] ]
try: for row in rows:
for row in rows: row.create(db.session)
row.create(oper._db) db.session.commit()
main_season = Subscribe.exists( main_season = Subscribe.exists(
oper._db, media_source=MediaSource.TMDB, db.session, media_source=MediaSource.TMDB,
media_id=media_id, season=1, episode_group=None, media_id=media_id, season=1, episode_group=None,
) )
created_ids.append(main_season.id) main_name = main_season.name
main_name = main_season.name episode_group = Subscribe.exists(
episode_group = Subscribe.exists( db.session, media_source=MediaSource.TMDB,
oper._db, media_source=MediaSource.TMDB, media_id=media_id, season=1, episode_group="eg-1",
media_id=media_id, season=1, episode_group="eg-1", )
) episode_group_name = episode_group.name
created_ids.append(episode_group.id)
episode_group_name = episode_group.name
assert main_name == "主季订阅" assert main_name == "主季订阅"
assert episode_group_name == "剧集组订阅" assert episode_group_name == "剧集组订阅"
Subscribe.delete(oper._db, rid=created_ids.pop(0)) Subscribe.delete(db.session, rid=main_season.id)
assert Subscribe.exists( db.session.commit()
oper._db, assert Subscribe.exists(
media_source=MediaSource.TMDB, db.session,
media_id=media_id, media_source=MediaSource.TMDB,
season=1, media_id=media_id,
) is None season=1,
finally: ) is None
for subscribe_id in created_ids:
Subscribe.delete(oper._db, rid=subscribe_id)
def test_subscribe_exists_distinguishes_music_entities_with_same_source_id(): def test_subscribe_exists_distinguishes_music_entities_with_same_source_id(db):
"""统一来源 ID 相同时,单曲与专辑仍是两条独立订阅身份。""" """统一来源 ID 相同时,单曲与专辑仍是两条独立订阅身份。"""
oper = SubscribeOper() db.watermark(Subscribe)
media_id = f"music-shared-{os.getpid()}" media_id = f"music-shared-{os.getpid()}"
created_ids = []
rows = [ rows = [
Subscribe( Subscribe(
name="同名单曲", name="同名单曲",
@@ -487,29 +481,24 @@ def test_subscribe_exists_distinguishes_music_entities_with_same_source_id():
total_tracks=10, total_tracks=10,
), ),
] ]
try: for row in rows:
for row in rows: row.create(db.session)
row.create(oper._db) db.session.commit()
recording = Subscribe.exists( recording = Subscribe.exists(
oper._db, db.session,
media_source="musicbrainz", media_source="musicbrainz",
media_id=media_id, media_id=media_id,
music_type="recording", music_type="recording",
) )
created_ids.append(recording.id) album = Subscribe.exists(
album = Subscribe.exists( db.session,
oper._db, media_source="musicbrainz",
media_source="musicbrainz", media_id=media_id,
media_id=media_id, music_type="album",
music_type="album", )
) assert recording.name == "同名单曲"
created_ids.append(album.id) assert album.name == "同名专辑"
assert recording.name == "同名单曲"
assert album.name == "同名专辑"
finally:
for subscribe_id in created_ids:
Subscribe.delete(oper._db, rid=subscribe_id)
def test_subscribe_chain_exists_forwards_episode_group(): def test_subscribe_chain_exists_forwards_episode_group():
+12 -5
View File
@@ -7,6 +7,7 @@ import pytest
from app.db.models.systemconfig import SystemConfig from app.db.models.systemconfig import SystemConfig
from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import SessionFactory
from app.schemas.types import SystemConfigKey from app.schemas.types import SystemConfigKey
from app.foundation.singleton import Singleton from app.foundation.singleton import Singleton
@@ -24,6 +25,12 @@ def _fresh_oper() -> SystemConfigOper:
return oper return oper
def _stored_config(key: str) -> SystemConfig | None:
"""使用显式短会话回读系统配置持久化结果。"""
with SessionFactory() as session:
return SystemConfig.get_by_key(session, key)
def test_constructor_does_not_query_database(monkeypatch): def test_constructor_does_not_query_database(monkeypatch):
"""构造配置对象时不打开数据库会话。""" """构造配置对象时不打开数据库会话。"""
Singleton._instances.pop((SystemConfigOper, (), frozenset()), None) Singleton._instances.pop((SystemConfigOper, (), frozenset()), None)
@@ -167,7 +174,7 @@ def test_set_creates_record_for_falsy_value():
assert oper.set(key, False) is True assert oper.set(key, False) is True
assert oper.get(key) is False assert oper.get(key) is False
assert SystemConfig.get_by_key(oper._db, key) is not None assert _stored_config(key) is not None
def test_set_persists_falsy_value_on_existing_record(): def test_set_persists_falsy_value_on_existing_record():
@@ -178,7 +185,7 @@ def test_set_persists_falsy_value_on_existing_record():
oper.set(key, True) oper.set(key, True)
assert oper.set(key, False) is True assert oper.set(key, False) is True
assert oper.get(key) is False assert oper.get(key) is False
assert SystemConfig.get_by_key(oper._db, key).value is False assert _stored_config(key).value is False
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -200,7 +207,7 @@ async def test_async_set_persists_falsy_value_on_existing_record():
) )
assert await service.async_set(key, False) is True assert await service.async_set(key, False) is True
assert oper.get(key) is False assert oper.get(key) is False
assert SystemConfig.get_by_key(oper._db, key).value is False assert _stored_config(key).value is False
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -221,7 +228,7 @@ async def test_async_set_creates_record_for_falsy_value():
) )
assert await service.async_set(key, 0) is True assert await service.async_set(key, 0) is True
assert oper.get(key) == 0 assert oper.get(key) == 0
assert SystemConfig.get_by_key(oper._db, key).value == 0 assert _stored_config(key).value == 0
def test_delete_removes_record_explicitly(): def test_delete_removes_record_explicitly():
@@ -232,7 +239,7 @@ def test_delete_removes_record_explicitly():
oper.set(key, False) oper.set(key, False)
assert oper.delete(key) is True assert oper.delete(key) is True
assert oper.get(key) is None assert oper.get(key) is None
assert SystemConfig.get_by_key(oper._db, key) is None assert _stored_config(key) is None
def test_mounted_local_disk_delete_empty_dirs_off_is_persisted(): def test_mounted_local_disk_delete_empty_dirs_off_is_persisted():
@@ -0,0 +1,51 @@
"""Transfer/Download History Oper 的显式会话复用验证。"""
import asyncio
from app.db import base as db_base
from app.db.models.transferhistory import TransferHistory
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.models.downloadhistory import DownloadHistory
from app.db.session import 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(
db_base,
"run_sync_transaction",
lambda _operation: (_ 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_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(
db_base,
"run_async_transaction",
lambda _operation: (_ 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())
@@ -1,111 +0,0 @@
"""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]
+3 -3
View File
@@ -1206,7 +1206,7 @@ def test_web_agent_stream_emits_secret_result_only_as_protected_event():
worker = agent_manager._session_workers.pop(session_id, None) worker = agent_manager._session_workers.pop(session_id, None)
if worker: if worker:
worker.cancel() worker.cancel()
AgentChat.delete(rid=existing_chat.id) AgentChatOper().delete_by_id(existing_chat.id)
def test_web_agent_cancel_keeps_existing_display_history(): def test_web_agent_cancel_keeps_existing_display_history():
@@ -1265,7 +1265,7 @@ def test_web_agent_cancel_keeps_existing_display_history():
assert preserved_chat.message_count == 2 assert preserved_chat.message_count == 2
assert preserved_chat.preview == "保留的回答" assert preserved_chat.preview == "保留的回答"
finally: finally:
AgentChat.delete(rid=existing_chat.id) AgentChatOper().delete_by_id(existing_chat.id)
def test_web_agent_stream_rejects_confirmation_without_protected_capability(): def test_web_agent_stream_rejects_confirmation_without_protected_capability():
@@ -1411,7 +1411,7 @@ def test_web_agent_stream_drops_secret_result_after_disconnect():
assert preserved_chat.message_count == 2 assert preserved_chat.message_count == 2
assert preserved_chat.preview == "断线前的回答" assert preserved_chat.preview == "断线前的回答"
finally: finally:
AgentChat.delete(rid=existing_chat.id) AgentChatOper().delete_by_id(existing_chat.id)
def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait(): def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait():