From 7fa03650bd39a66e6faeb98464ec8c7f9a6db34d Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 27 Aug 2026 22:57:14 +0800 Subject: [PATCH 1/8] feat(sdk): add unified plugin data queries --- app/application/data_query.py | 350 ++++++++++++++ app/db/adapters/data_query.py | 375 +++++++++++++++ app/schemas/query.py | 215 +++++++++ app/sdk/queries.py | 173 +++++++ app/startup/initializers/modules.py | 13 + tests/test_sdk_queries.py | 705 ++++++++++++++++++++++++++++ 6 files changed, 1831 insertions(+) create mode 100644 app/application/data_query.py create mode 100644 app/db/adapters/data_query.py create mode 100644 app/schemas/query.py create mode 100644 app/sdk/queries.py create mode 100644 tests/test_sdk_queries.py diff --git a/app/application/data_query.py b/app/application/data_query.py new file mode 100644 index 000000000..6b83a9b2f --- /dev/null +++ b/app/application/data_query.py @@ -0,0 +1,350 @@ +"""插件只读数据查询的应用服务与领域端口。""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from functools import partial +from typing import Any, Callable, Generic, Protocol, TypeVar, cast + +from pydantic import BaseModel + +from app.application.database import AsyncDatabaseExecutor +from app.schemas.history import DownloadHistory, TransferHistory +from app.schemas.query import ( + DownloadHistoryFilter, + QueryPage, + QueryPageRequest, + SubscribeHistory, + SubscriptionFilter, + SubscriptionHistoryFilter, + TransferHistoryFilter, +) +from app.schemas.subscribe import Subscribe + +RecordT = TypeVar("RecordT") +DtoT = TypeVar("DtoT", bound=BaseModel) +ResultT = TypeVar("ResultT") + + +@dataclass(frozen=True, slots=True) +class QueryRows(Generic[RecordT]): + """查询端口返回的短生命周期结果,不跨越应用服务边界。""" + + items: Sequence[RecordT] + total: int + + +class SubscriptionQueryPort(Protocol): + """订阅领域查询端口,包含订阅和订阅完成历史两个只读切片。""" + + def list_subscriptions( + self, + *, + filters: SubscriptionFilter, + page: QueryPageRequest, + ) -> QueryRows[object]: + """按组合筛选和稳定分页读取订阅记录。""" + ... + + def get_subscription(self, subscription_id: int) -> object | None: + """按 ID 读取一条订阅记录。""" + ... + + def list_subscription_history( + self, + *, + filters: SubscriptionHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[object]: + """按组合筛选和稳定分页读取订阅完成历史。""" + ... + + def get_subscription_history(self, history_id: int) -> object | None: + """按 ID 读取一条订阅完成历史。""" + ... + + +class HistoryQueryPort(Protocol): + """下载与整理历史查询端口。""" + + def list_download_history( + self, + *, + filters: DownloadHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[object]: + """按组合筛选和稳定分页读取下载历史。""" + ... + + def get_download_history(self, history_id: int) -> object | None: + """按 ID 读取一条下载历史。""" + ... + + def list_transfer_history( + self, + *, + filters: TransferHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[object]: + """按组合筛选和稳定分页读取整理历史。""" + ... + + def get_transfer_history(self, history_id: int) -> object | None: + """按 ID 读取一条整理历史。""" + ... + + +class DataQueryService: + """把订阅和历史查询统一投影为不含持久化实现的 DTO。""" + + def __init__( + self, + *, + subscriptions: SubscriptionQueryPort, + histories: HistoryQueryPort, + async_executor: AsyncDatabaseExecutor, + ) -> None: + """保存两个领域查询端口和异步数据库执行边界。""" + self._subscriptions = subscriptions + self._histories = histories + self._async_executor = async_executor + + @staticmethod + def _page_request(page: QueryPageRequest | dict[str, Any] | None) -> QueryPageRequest: + """把调用方输入规范化为有界分页合同。""" + if page is None: + return QueryPageRequest() + if isinstance(page, QueryPageRequest): + return page + return cast(QueryPageRequest, QueryPageRequest.model_validate(page)) + + @staticmethod + def _filter( + value: Any, + filter_type: type[SubscriptionFilter] + | type[SubscriptionHistoryFilter] + | type[DownloadHistoryFilter] + | type[TransferHistoryFilter], + ) -> Any: + """把字典筛选条件转换为带媒体身份校验的模型。""" + if value is None: + return filter_type() + if isinstance(value, filter_type): + return value + return filter_type.model_validate(value) + + @staticmethod + def _rows(value: QueryRows[object]) -> QueryRows[object]: + """校验端口结果的总数,避免负数污染分页合同。""" + if not isinstance(value, QueryRows): + raise TypeError("查询端口必须返回 QueryRows") + return QueryRows( + items=value.items, + total=max(int(value.total), 0), + ) + + @classmethod + def _to_page( + cls, + dto_type: type[DtoT], + page: QueryPageRequest, + rows: QueryRows[object], + ) -> QueryPage[DtoT]: + """在查询端口结果离开应用层前完成 DTO 投影。""" + normalized_rows = cls._rows(rows) + return cast( + QueryPage[DtoT], + QueryPage( + items=[dto_type.model_validate(item) for item in normalized_rows.items], + total=normalized_rows.total, + page=page.page, + count=page.count, + ), + ) + + @staticmethod + def _to_item(dto_type: type[DtoT], item: object | None) -> DtoT | None: + """把单条端口记录投影为稳定 DTO。""" + return dto_type.model_validate(item) if item is not None else None + + async def _async_run(self, operation: Callable[[], ResultT]) -> ResultT: + """通过统一数据库执行器运行一个同步查询用例。""" + return cast(ResultT, await self._async_executor.run(operation)) + + def list_subscriptions( + self, + filters: SubscriptionFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[Subscribe]: + """同步分页查询订阅。""" + normalized_page = self._page_request(page) + normalized_filters = self._filter(filters, SubscriptionFilter) + rows = self._subscriptions.list_subscriptions( + filters=normalized_filters, + page=normalized_page, + ) + return self._to_page(Subscribe, normalized_page, rows) + + async def async_list_subscriptions( + self, + filters: SubscriptionFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[Subscribe]: + """异步分页查询订阅,业务规则在数据库 worker 中复用同步入口。""" + return await self._async_run( + partial(self.list_subscriptions, filters, page) + ) + + def get_subscription(self, subscription_id: int) -> Subscribe | None: + """同步按 ID 查询订阅。""" + return self._to_item( + Subscribe, + self._subscriptions.get_subscription(subscription_id), + ) + + async def async_get_subscription(self, subscription_id: int) -> Subscribe | None: + """异步按 ID 查询订阅。""" + return await self._async_run( + partial(self.get_subscription, subscription_id) + ) + + def list_subscription_history( + self, + filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[SubscribeHistory]: + """同步分页查询订阅完成历史。""" + normalized_page = self._page_request(page) + normalized_filters = self._filter(filters, SubscriptionHistoryFilter) + rows = self._subscriptions.list_subscription_history( + filters=normalized_filters, + page=normalized_page, + ) + return self._to_page(SubscribeHistory, normalized_page, rows) + + async def async_list_subscription_history( + self, + filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[SubscribeHistory]: + """异步分页查询订阅完成历史。""" + return await self._async_run( + partial(self.list_subscription_history, filters, page) + ) + + def get_subscription_history(self, history_id: int) -> SubscribeHistory | None: + """同步按 ID 查询订阅完成历史。""" + return self._to_item( + SubscribeHistory, + self._subscriptions.get_subscription_history(history_id), + ) + + async def async_get_subscription_history( + self, + history_id: int, + ) -> SubscribeHistory | None: + """异步按 ID 查询订阅完成历史。""" + return await self._async_run( + partial(self.get_subscription_history, history_id) + ) + + def list_download_history( + self, + filters: DownloadHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[DownloadHistory]: + """同步分页查询下载历史。""" + normalized_page = self._page_request(page) + normalized_filters = self._filter(filters, DownloadHistoryFilter) + rows = self._histories.list_download_history( + filters=normalized_filters, + page=normalized_page, + ) + return self._to_page(DownloadHistory, normalized_page, rows) + + async def async_list_download_history( + self, + filters: DownloadHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[DownloadHistory]: + """异步分页查询下载历史。""" + return await self._async_run( + partial(self.list_download_history, filters, page) + ) + + def get_download_history(self, history_id: int) -> DownloadHistory | None: + """同步按 ID 查询下载历史。""" + return self._to_item( + DownloadHistory, + self._histories.get_download_history(history_id), + ) + + async def async_get_download_history(self, history_id: int) -> DownloadHistory | None: + """异步按 ID 查询下载历史。""" + return await self._async_run( + partial(self.get_download_history, history_id) + ) + + def list_transfer_history( + self, + filters: TransferHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[TransferHistory]: + """同步分页查询整理历史。""" + normalized_page = self._page_request(page) + normalized_filters = self._filter(filters, TransferHistoryFilter) + rows = self._histories.list_transfer_history( + filters=normalized_filters, + page=normalized_page, + ) + return self._to_page(TransferHistory, normalized_page, rows) + + async def async_list_transfer_history( + self, + filters: TransferHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[TransferHistory]: + """异步分页查询整理历史。""" + return await self._async_run( + partial(self.list_transfer_history, filters, page) + ) + + def get_transfer_history(self, history_id: int) -> TransferHistory | None: + """同步按 ID 查询整理历史。""" + return self._to_item( + TransferHistory, + self._histories.get_transfer_history(history_id), + ) + + async def async_get_transfer_history(self, history_id: int) -> TransferHistory | None: + """异步按 ID 查询整理历史。""" + return await self._async_run( + partial(self.get_transfer_history, history_id) + ) + + +_configured_data_query_service: DataQueryService | None = None + + +def configure_data_query_service(service: DataQueryService) -> None: + """由启动组合根登记插件数据查询服务。""" + global _configured_data_query_service + _configured_data_query_service = service + + +def get_configured_data_query_service() -> DataQueryService: + """返回启动阶段登记的插件数据查询服务。""" + if _configured_data_query_service is None: + raise RuntimeError("插件数据查询服务尚未配置") + return _configured_data_query_service + + +__all__ = [ + "DataQueryService", + "HistoryQueryPort", + "QueryRows", + "SubscriptionQueryPort", + "configure_data_query_service", + "get_configured_data_query_service", +] diff --git a/app/db/adapters/data_query.py b/app/db/adapters/data_query.py new file mode 100644 index 000000000..41d3769bd --- /dev/null +++ b/app/db/adapters/data_query.py @@ -0,0 +1,375 @@ +"""插件只读数据查询的 SQLAlchemy 持久化适配器。""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from enum import Enum +from typing import Any, TypeVar + +from pydantic import BaseModel +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.application.data_query import QueryRows +from app.db.base import Base +from app.db.models.downloadhistory import DownloadHistory +from app.db.models.subscribe import Subscribe +from app.db.models.subscribehistory import SubscribeHistory as SubscribeHistoryModel +from app.db.models.transferhistory import TransferHistory +from app.schemas.history import ( + DownloadHistory as DownloadHistoryView, +) +from app.schemas.history import ( + TransferHistory as TransferHistoryView, +) +from app.schemas.query import ( + DownloadHistoryFilter, + QueryPageRequest, + QuerySortDirection, + QuerySortField, + SubscriptionFilter, + SubscriptionHistoryFilter, + TransferHistoryFilter, +) +from app.schemas.query import SubscribeHistory as SubscribeHistoryView +from app.schemas.subscribe import Subscribe as SubscribeView +from app.schemas.types import MUSIC_ENTITY_RECORDING + +_ModelT = TypeVar("_ModelT", bound=Base) +_ViewT = TypeVar("_ViewT", bound=BaseModel) + + +def _enum_value(value: Any) -> Any: + """返回枚举筛选值的稳定数据库表示。""" + return value.value if isinstance(value, Enum) else value + + +def _values(values: Iterable[Any]) -> tuple[Any, ...]: + """去除空筛选值并保留调用方声明的顺序。""" + normalized: list[Any] = [] + for value in values: + value = _enum_value(value) + if value in (None, ""): + continue + normalized.append(value) + return tuple(dict.fromkeys(normalized)) + + +def _contains(column: Any, value: str) -> Any: + """构造不区分大小写且不解释通配符的字面包含筛选。""" + escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return column.ilike(f"%{escaped}%", escape="\\") + + +def _music_type_condition(column: Any, music_type: str | None) -> Any | None: + """兼容未标注音乐类型的历史单曲记录。""" + if not music_type: + return None + if music_type == MUSIC_ENTITY_RECORDING: + return or_(column == music_type, column.is_(None)) + return column == music_type + + +class SqlAlchemyDataQueryAdapter: + """以短生命周期同步 Session 执行统一只读分页查询。 + + 这个适配器是查询层唯一接触 SQLAlchemy Model 的边界。每个公开方法在同一 + Session 中先统计再读取当前页,并在 Session 仍有效时转换成 Pydantic DTO, + 因而调用方不会持有 ORM 实例或延迟加载状态。 + """ + + def __init__(self, session_factory: Callable[[], Session]) -> None: + """保存由启动组合根提供的同步 Session 工厂。""" + self._session_factory = session_factory + + @staticmethod + def _identity_conditions(model: Any, query: Any) -> list[Any]: + """按媒体来源与原生 ID 的成对合同构造筛选条件。""" + media_source = query.media_source + media_id = query.media_id + if (media_source is None) != (media_id is None): + # Pydantic 合同会先拒绝这种输入;这里仍保留拒绝,避免未校验对象 + # 在持久化边界退化成只按 NULL 查询而扩大结果集。 + raise ValueError("media_source 和 media_id 必须同时提供") + if media_source is None: + return [] + normalized_id = str(media_id).strip() + if not normalized_id or normalized_id == "0": + raise ValueError("media_id 必须是非零的来源原生 ID") + return [ + model.media_source == _enum_value(media_source), + model.media_id == normalized_id, + ] + + @staticmethod + def _require_media_identity(model: Any) -> list[Any]: + """只保留来源和原生 ID 均存在且非空的记录。""" + return [ + model.media_source.is_not(None), + func.trim(model.media_source) != "", + model.media_id.is_not(None), + func.trim(model.media_id) != "", + func.trim(model.media_id) != "0", + ] + + @staticmethod + def _order_by(model: Any, request: QueryPageRequest) -> tuple[Any, ...]: + """构造可跨页复现的排序,日期相同时始终以主键打破平局。""" + descending = request.sort.direction == QuerySortDirection.DESC + if request.sort.field == QuerySortField.ID: + primary = model.id.desc() if descending else model.id.asc() + secondary = model.date.desc() if descending else model.date.asc() + return primary, secondary + primary = model.date.desc().nullslast() if descending else model.date.asc().nullsfirst() + secondary = model.id.desc() if descending else model.id.asc() + return primary, secondary + + def _page( + self, + *, + model: type[_ModelT], + view_model: type[_ViewT], + conditions: Iterable[Any], + page: QueryPageRequest, + ) -> QueryRows[_ViewT]: + """在单个 Session 内完成 count、分页读取和 DTO 投影。""" + conditions = tuple(conditions) + count_statement = select(func.count(model.id)) + page_statement = select(model) + if conditions: + count_statement = count_statement.where(*conditions) + page_statement = page_statement.where(*conditions) + page_statement = ( + page_statement.order_by(*self._order_by(model, page)).offset((page.page - 1) * page.count).limit(page.count) + ) + + with self._session_factory() as session: + total = int(session.execute(count_statement).scalar_one() or 0) + records = session.execute(page_statement).scalars().all() + # model_validate 必须在会话内完成;返回值只包含 Pydantic 数据。 + items = [view_model.model_validate(record) for record in records] + return QueryRows(items=items, total=total) + + def _get( + self, + *, + model: type[_ModelT], + view_model: type[_ViewT], + record_id: int, + ) -> _ViewT | None: + """在短 Session 内按主键读取并冻结单条 Pydantic 投影。""" + statement = select(model).where(model.id == record_id) + with self._session_factory() as session: + record = session.execute(statement).scalars().first() + return view_model.model_validate(record) if record is not None else None + + def list_subscriptions( + self, + *, + filters: SubscriptionFilter, + page: QueryPageRequest, + ) -> QueryRows[SubscribeView]: + """按受控组合条件分页查询当前订阅。""" + query = filters + conditions = self._identity_conditions(Subscribe, query) + ids = _values(query.ids) + names = _values(query.names) + states = _values(query.states) + usernames = _values(query.usernames) + media_types = _values(query.media_types) + if ids: + conditions.append(Subscribe.id.in_(ids)) + if names: + conditions.append(Subscribe.name.in_(names)) + if states: + conditions.append(Subscribe.state.in_(states)) + if usernames: + conditions.append(Subscribe.username.in_(usernames)) + if media_types: + conditions.append(Subscribe.type.in_(media_types)) + if query.season is not None: + conditions.append(Subscribe.season == query.season) + if query.episode_group is not None: + conditions.append(Subscribe.episode_group == query.episode_group) + music_condition = _music_type_condition(Subscribe.music_type, query.music_type) + if music_condition is not None: + conditions.append(music_condition) + return self._page( + model=Subscribe, + view_model=SubscribeView, + conditions=conditions, + page=page, + ) + + def get_subscription(self, subscription_id: int) -> SubscribeView | None: + """按主键查询订阅并返回脱离 Session 的 DTO。""" + return self._get( + model=Subscribe, + view_model=SubscribeView, + record_id=subscription_id, + ) + + def list_subscription_history( + self, + *, + filters: SubscriptionHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[SubscribeHistoryView]: + """按受控组合条件分页查询订阅完成历史。""" + query = filters + conditions = self._identity_conditions(SubscribeHistoryModel, query) + ids = _values(query.ids) + names = _values(query.names) + usernames = _values(query.usernames) + media_types = _values(query.media_types) + if ids: + conditions.append(SubscribeHistoryModel.id.in_(ids)) + if names: + conditions.append(SubscribeHistoryModel.name.in_(names)) + if usernames: + conditions.append(SubscribeHistoryModel.username.in_(usernames)) + if media_types: + conditions.append(SubscribeHistoryModel.type.in_(media_types)) + if query.season is not None: + conditions.append(SubscribeHistoryModel.season == query.season) + if query.episode_group is not None: + conditions.append(SubscribeHistoryModel.episode_group == query.episode_group) + music_condition = _music_type_condition( + SubscribeHistoryModel.music_type, + query.music_type, + ) + if music_condition is not None: + conditions.append(music_condition) + return self._page( + model=SubscribeHistoryModel, + view_model=SubscribeHistoryView, + conditions=conditions, + page=page, + ) + + def get_subscription_history( + self, + history_id: int, + ) -> SubscribeHistoryView | None: + """按主键查询订阅完成历史并返回稳定 DTO。""" + return self._get( + model=SubscribeHistoryModel, + view_model=SubscribeHistoryView, + record_id=history_id, + ) + + def list_download_history( + self, + *, + filters: DownloadHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[DownloadHistoryView]: + """按受控组合条件分页查询下载历史。""" + query = filters + conditions = self._identity_conditions(DownloadHistory, query) + ids = _values(query.ids) + media_types = _values(query.media_types) + usernames = _values(query.usernames) + if ids: + conditions.append(DownloadHistory.id.in_(ids)) + if media_types: + conditions.append(DownloadHistory.type.in_(media_types)) + for column, value in ( + (DownloadHistory.title, query.title), + (DownloadHistory.path, query.path), + ): + if value: + conditions.append(_contains(column, value)) + for column, value in ( + (DownloadHistory.year, query.year), + (DownloadHistory.seasons, query.seasons), + (DownloadHistory.episodes, query.episodes), + (DownloadHistory.download_hash, query.download_hash), + (DownloadHistory.username, query.username), + (DownloadHistory.episode_group, query.episode_group), + ): + if value is not None and value != "": + conditions.append(column == value) + if usernames: + conditions.append(DownloadHistory.username.in_(usernames)) + music_condition = _music_type_condition(DownloadHistory.music_type, query.music_type) + if music_condition is not None: + conditions.append(music_condition) + return self._page( + model=DownloadHistory, + view_model=DownloadHistoryView, + conditions=conditions, + page=page, + ) + + def get_download_history(self, history_id: int) -> DownloadHistoryView | None: + """按主键查询下载历史并返回稳定 DTO。""" + return self._get( + model=DownloadHistory, + view_model=DownloadHistoryView, + record_id=history_id, + ) + + def list_transfer_history( + self, + *, + filters: TransferHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[TransferHistoryView]: + """按受控组合条件分页查询整理历史。""" + query = filters + conditions = self._identity_conditions(TransferHistory, query) + ids = _values(query.ids) + media_types = _values(query.media_types) + media_sources = _values(query.media_sources) + if ids: + conditions.append(TransferHistory.id.in_(ids)) + if media_types: + conditions.append(TransferHistory.type.in_(media_types)) + if media_sources: + conditions.append(TransferHistory.media_source.in_(media_sources)) + if query.require_media_identity: + conditions.extend(self._require_media_identity(TransferHistory)) + if query.title: + conditions.append(_contains(TransferHistory.title, query.title)) + if query.text: + conditions.append( + or_( + _contains(TransferHistory.title, query.text), + _contains(TransferHistory.src, query.text), + _contains(TransferHistory.dest, query.text), + ) + ) + for column, value in ( + (TransferHistory.year, query.year), + (TransferHistory.seasons, query.seasons), + (TransferHistory.episodes, query.episodes), + (TransferHistory.src, query.src), + (TransferHistory.dest, query.dest), + (TransferHistory.download_hash, query.download_hash), + (TransferHistory.episode_group, query.episode_group), + ): + if value is not None and value != "": + conditions.append(column == value) + if query.status is not None: + conditions.append(TransferHistory.status == query.status) + music_condition = _music_type_condition(TransferHistory.music_type, query.music_type) + if music_condition is not None: + conditions.append(music_condition) + return self._page( + model=TransferHistory, + view_model=TransferHistoryView, + conditions=conditions, + page=page, + ) + + def get_transfer_history(self, history_id: int) -> TransferHistoryView | None: + """按主键查询整理历史并返回稳定 DTO。""" + return self._get( + model=TransferHistory, + view_model=TransferHistoryView, + record_id=history_id, + ) + + +__all__ = ["SqlAlchemyDataQueryAdapter"] diff --git a/app/schemas/query.py b/app/schemas/query.py new file mode 100644 index 000000000..684e366cf --- /dev/null +++ b/app/schemas/query.py @@ -0,0 +1,215 @@ +"""插件只读数据查询使用的稳定筛选、分页与数据投影合同。""" + +from enum import Enum +from typing import Generic, Optional, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.common import JsonData +from app.schemas.history import DownloadHistory, TransferHistory +from app.schemas.media import OptionalMediaIdentityMixin +from app.schemas.subscribe import Subscribe +from app.schemas.types import MediaSource, MediaType + +T = TypeVar("T") + +DEFAULT_QUERY_PAGE_SIZE = 50 +MAX_QUERY_PAGE_SIZE = 200 + + +class _QueryInput(BaseModel): + """查询输入共同的严格字段合同。""" + + model_config = ConfigDict(extra="forbid") + + +class QuerySortField(str, Enum): + """所有公开数据查询共同支持的稳定排序字段。""" + + DATE = "date" + ID = "id" + + +class QuerySortDirection(str, Enum): + """公开查询的排序方向。""" + + ASC = "asc" + DESC = "desc" + + +class QuerySort(_QueryInput): + """声明公开查询的稳定排序字段与方向。""" + + field: QuerySortField = QuerySortField.DATE + direction: QuerySortDirection = QuerySortDirection.DESC + + +class QueryPageRequest(_QueryInput): + """限制插件单次读取规模,并为跨页扫描提供稳定顺序。""" + + page: int = Field(default=1, ge=1) + count: int = Field( + default=DEFAULT_QUERY_PAGE_SIZE, + ge=1, + le=MAX_QUERY_PAGE_SIZE, + ) + sort: QuerySort = Field(default_factory=QuerySort) + + +class QueryPage(BaseModel, Generic[T]): + """公开查询返回的分页 DTO。""" + + items: list[T] = Field(default_factory=list) + total: int = Field(default=0, ge=0) + page: int = Field(default=1, ge=1) + count: int = Field( + default=DEFAULT_QUERY_PAGE_SIZE, + ge=1, + le=MAX_QUERY_PAGE_SIZE, + ) + + @property + def has_next(self) -> bool: + """返回当前分页后是否仍有记录。""" + return self.page * self.count < self.total + + +class MediaIdentityQuery(OptionalMediaIdentityMixin, _QueryInput): + """允许省略身份,但显式筛选时要求来源与原生 ID 成对有效。""" + + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + + +class SubscribeHistory(OptionalMediaIdentityMixin, BaseModel): + """订阅完成历史的稳定只读投影。""" + + id: int + name: Optional[str] = None + year: Optional[str] = None + type: Optional[str] = None + keyword: Optional[str] = None + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + music_type: Optional[str] = None + total_tracks: Optional[int] = None + season: Optional[int] = None + poster: Optional[str] = None + backdrop: Optional[str] = None + vote: Optional[float] = None + description: Optional[str] = None + filter: Optional[str] = None + include: Optional[str] = None + exclude: Optional[str] = None + quality: Optional[str] = None + resolution: Optional[str] = None + effect: Optional[str] = None + audio_quality: Optional[str] = None + audio_format: Optional[str] = None + min_bitrate: Optional[int] = None + min_bit_depth: Optional[int] = None + min_sample_rate: Optional[int] = None + total_episode: Optional[int] = None + start_episode: Optional[int] = None + date: Optional[str] = None + username: Optional[str] = None + sites: Optional[list[int]] = None + best_version: Optional[int] = None + best_version_full: Optional[int] = None + current_priority: Optional[int] = None + current_audio_format: Optional[str] = None + current_bitrate: Optional[int] = None + current_bit_depth: Optional[int] = None + current_sample_rate: Optional[int] = None + episode_priority: Optional[dict[str, int]] = None + save_path: Optional[str] = None + search_imdbid: Optional[int] = None + note: Optional[JsonData] = None + custom_words: Optional[str] = None + media_category: Optional[str] = None + filter_groups: Optional[list[str]] = None + episode_group: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class SubscriptionFilter(MediaIdentityQuery): + """订阅查询允许组合的稳定业务字段。""" + + ids: tuple[int, ...] = () + names: tuple[str, ...] = () + states: tuple[str, ...] = () + usernames: tuple[str, ...] = () + media_types: tuple[MediaType, ...] = () + season: Optional[int] = None + episode_group: Optional[str] = None + music_type: Optional[str] = None + + +class SubscriptionHistoryFilter(MediaIdentityQuery): + """订阅完成历史查询允许组合的稳定业务字段。""" + + ids: tuple[int, ...] = () + names: tuple[str, ...] = () + usernames: tuple[str, ...] = () + media_types: tuple[MediaType, ...] = () + season: Optional[int] = None + episode_group: Optional[str] = None + music_type: Optional[str] = None + + +class DownloadHistoryFilter(MediaIdentityQuery): + """下载历史查询允许组合的稳定业务字段。""" + + ids: tuple[int, ...] = () + media_types: tuple[MediaType, ...] = () + title: Optional[str] = None + year: Optional[str] = None + seasons: Optional[str] = None + episodes: Optional[str] = None + path: Optional[str] = None + download_hash: Optional[str] = None + username: Optional[str] = None + usernames: tuple[str, ...] = () + music_type: Optional[str] = None + episode_group: Optional[str] = None + + +class TransferHistoryFilter(MediaIdentityQuery): + """整理历史查询允许组合的稳定业务字段。""" + + ids: tuple[int, ...] = () + media_types: tuple[MediaType, ...] = () + media_sources: tuple[MediaSource, ...] = () + require_media_identity: bool = False + title: Optional[str] = None + text: Optional[str] = None + year: Optional[str] = None + seasons: Optional[str] = None + episodes: Optional[str] = None + src: Optional[str] = None + dest: Optional[str] = None + status: Optional[bool] = None + download_hash: Optional[str] = None + music_type: Optional[str] = None + episode_group: Optional[str] = None + + +__all__ = [ + "DEFAULT_QUERY_PAGE_SIZE", + "MAX_QUERY_PAGE_SIZE", + "DownloadHistory", + "DownloadHistoryFilter", + "MediaIdentityQuery", + "QueryPage", + "QueryPageRequest", + "QuerySort", + "QuerySortDirection", + "QuerySortField", + "Subscribe", + "SubscribeHistory", + "SubscriptionFilter", + "SubscriptionHistoryFilter", + "TransferHistory", + "TransferHistoryFilter", +] diff --git a/app/sdk/queries.py b/app/sdk/queries.py new file mode 100644 index 000000000..145a633f0 --- /dev/null +++ b/app/sdk/queries.py @@ -0,0 +1,173 @@ +"""插件可使用的订阅与历史只读查询门面。""" + +from __future__ import annotations + +from typing import Any + +from app.schemas.query import ( + DEFAULT_QUERY_PAGE_SIZE, + MAX_QUERY_PAGE_SIZE, + DownloadHistory, + DownloadHistoryFilter, + MediaIdentityQuery, + QueryPage, + QueryPageRequest, + QuerySort, + QuerySortDirection, + QuerySortField, + Subscribe, + SubscribeHistory, + SubscriptionFilter, + SubscriptionHistoryFilter, + TransferHistory, + TransferHistoryFilter, +) + + +def _service() -> Any: + """获取启动阶段登记的查询服务,避免把应用服务暴露为 SDK 合同。""" + from app.application.data_query import ( + get_configured_data_query_service, + ) + + return get_configured_data_query_service() + + +def list_subscriptions( + filters: SubscriptionFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[Subscribe]: + """同步分页读取订阅 DTO。""" + return _service().list_subscriptions(filters, page) + + +async def async_list_subscriptions( + filters: SubscriptionFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[Subscribe]: + """异步分页读取订阅 DTO。""" + return await _service().async_list_subscriptions(filters, page) + + +def get_subscription(subscription_id: int) -> Subscribe | None: + """同步按 ID 读取订阅 DTO。""" + return _service().get_subscription(subscription_id) + + +async def async_get_subscription(subscription_id: int) -> Subscribe | None: + """异步按 ID 读取订阅 DTO。""" + return await _service().async_get_subscription(subscription_id) + + +def list_subscription_history( + filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[SubscribeHistory]: + """同步分页读取订阅完成历史 DTO。""" + return _service().list_subscription_history(filters, page) + + +async def async_list_subscription_history( + filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[SubscribeHistory]: + """异步分页读取订阅完成历史 DTO。""" + return await _service().async_list_subscription_history(filters, page) + + +def get_subscription_history(history_id: int) -> SubscribeHistory | None: + """同步按 ID 读取订阅完成历史 DTO。""" + return _service().get_subscription_history(history_id) + + +async def async_get_subscription_history(history_id: int) -> SubscribeHistory | None: + """异步按 ID 读取订阅完成历史 DTO。""" + return await _service().async_get_subscription_history(history_id) + + +def list_download_history( + filters: DownloadHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[DownloadHistory]: + """同步分页读取下载历史 DTO。""" + return _service().list_download_history(filters, page) + + +async def async_list_download_history( + filters: DownloadHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[DownloadHistory]: + """异步分页读取下载历史 DTO。""" + return await _service().async_list_download_history(filters, page) + + +def get_download_history(history_id: int) -> DownloadHistory | None: + """同步按 ID 读取下载历史 DTO。""" + return _service().get_download_history(history_id) + + +async def async_get_download_history(history_id: int) -> DownloadHistory | None: + """异步按 ID 读取下载历史 DTO。""" + return await _service().async_get_download_history(history_id) + + +def list_transfer_history( + filters: TransferHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[TransferHistory]: + """同步分页读取整理历史 DTO。""" + return _service().list_transfer_history(filters, page) + + +async def async_list_transfer_history( + filters: TransferHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, +) -> QueryPage[TransferHistory]: + """异步分页读取整理历史 DTO。""" + return await _service().async_list_transfer_history(filters, page) + + +def get_transfer_history(history_id: int) -> TransferHistory | None: + """同步按 ID 读取整理历史 DTO。""" + return _service().get_transfer_history(history_id) + + +async def async_get_transfer_history(history_id: int) -> TransferHistory | None: + """异步按 ID 读取整理历史 DTO。""" + return await _service().async_get_transfer_history(history_id) + + +__all__ = [ + "DEFAULT_QUERY_PAGE_SIZE", + "MAX_QUERY_PAGE_SIZE", + "DownloadHistory", + "DownloadHistoryFilter", + "MediaIdentityQuery", + "QueryPage", + "QueryPageRequest", + "QuerySort", + "QuerySortDirection", + "QuerySortField", + "Subscribe", + "SubscribeHistory", + "SubscriptionFilter", + "SubscriptionHistoryFilter", + "TransferHistory", + "TransferHistoryFilter", + "async_get_download_history", + "async_get_subscription", + "async_get_subscription_history", + "async_get_transfer_history", + "async_list_download_history", + "async_list_subscription_history", + "async_list_subscriptions", + "async_list_transfer_history", + "get_download_history", + "get_subscription", + "get_subscription_history", + "get_transfer_history", + "list_download_history", + "list_subscription_history", + "list_subscriptions", + "list_transfer_history", +] diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 9ebe1256b..65405992c 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -60,6 +60,10 @@ from app.application.configuration import ( configure_transfer_retry_config, get_configured_system_config, ) +from app.application.data_query import ( + DataQueryService, + configure_data_query_service, +) from app.application.database import configure_database_governance from app.application.history import configure_transfer_history_provider from app.application.image import configure_wallpaper_providers @@ -105,6 +109,7 @@ from app.application.subscription.write import configure_subscribe_writer from app.application.workflow import WorkflowQueryService, configure_workflow_query from app.command import CommandChain from app.db.adapters.chain import TransactionalChainDurableEventWriter +from app.db.adapters.data_query import SqlAlchemyDataQueryAdapter from app.db.adapters.download import TransactionalDownloadFailureRepository from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository from app.db.adapters.site import TransactionalSiteRepository @@ -757,6 +762,14 @@ async def init_modules() -> HostRuntime: except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常 logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}") raise + data_query_adapter = SqlAlchemyDataQueryAdapter(SessionFactory) + configure_data_query_service( + DataQueryService( + subscriptions=data_query_adapter, + histories=data_query_adapter, + async_executor=database_worker, + ) + ) configure_plugin_persistence( PluginPersistenceService( executor=database_worker, diff --git a/tests/test_sdk_queries.py b/tests/test_sdk_queries.py new file mode 100644 index 000000000..dd51f6757 --- /dev/null +++ b/tests/test_sdk_queries.py @@ -0,0 +1,705 @@ +"""统一插件只读查询 SDK 的真实 SQLite 合同测试。 + +这些用例从真实 SQLAlchemy 表读取,再经查询服务和 ``app.sdk.queries`` 返回, +因此同时约束筛选、分页、DTO 投影以及异步数据库执行边界。所有数据都由共享的 +隔离 SQLite harness 写入;测试不调用外部服务。 +""" + +from __future__ import annotations + +import asyncio +import inspect +import threading +from collections.abc import Callable +from typing import Any + +import pytest + +from app.db.models.downloadhistory import DownloadHistory as DownloadHistoryModel +from app.db.models.subscribe import Subscribe as SubscribeModel +from app.db.models.subscribehistory import SubscribeHistory as SubscribeHistoryModel +from app.db.models.transferhistory import TransferHistory as TransferHistoryModel +from app.schemas.history import ( + DownloadHistory as DownloadHistoryDTO, + TransferHistory as TransferHistoryDTO, +) +from app.schemas.query import ( + DownloadHistoryFilter, + QueryPage, + QueryPageRequest, + QuerySort, + QuerySortDirection, + QuerySortField, + SubscribeHistory as SubscribeHistoryDTO, + SubscriptionFilter, + SubscriptionHistoryFilter, + TransferHistoryFilter, +) +from app.schemas.subscribe import Subscribe as SubscribeDTO +from app.schemas.types import MediaSource, MediaType + + +TMDB = MediaSource.TMDB.value + + +class _RecordingExecutor: + """记录服务是否把异步查询提交到独立执行线程。""" + + def __init__(self) -> None: + self.calls = 0 + self.worker_thread_ids: list[int] = [] + + async def run(self, operation: Callable[[], Any]) -> Any: + """在线程中执行同步查询,模拟宿主数据库 worker 的调用合同。""" + self.calls += 1 + + def invoke() -> Any: + self.worker_thread_ids.append(threading.get_ident()) + return operation() + + return await asyncio.to_thread(invoke) + + +@pytest.fixture +def query_sdk(db, monkeypatch): + """装配真实数据查询适配器,并把 SDK 绑定到本用例的 SQLite 服务。""" + from app.application import data_query as data_query_module + from app.application.data_query import DataQueryService + from app.db.adapters.data_query import SqlAlchemyDataQueryAdapter + from app.db.session import SessionFactory + from app.sdk import queries as sdk + + adapter = SqlAlchemyDataQueryAdapter(SessionFactory) + executor = _RecordingExecutor() + service = DataQueryService( + subscriptions=adapter, + histories=adapter, + async_executor=executor, + ) + monkeypatch.setattr( + data_query_module, + "_configured_data_query_service", + service, + ) + return sdk, executor + + +def _subscribe( + name: str, + *, + media_id: str, + state: str = "N", + username: str = "alice", + mtype: str = MediaType.TV.value, + season: int | None = 1, + episode_group: str | None = None, + date: str = "2026-08-27 10:00:00", + music_type: str | None = None, +) -> SubscribeModel: + """构造隔离用例使用的订阅行。""" + return SubscribeModel( + name=name, + type=mtype, + state=state, + media_source=TMDB, + media_id=media_id, + season=season, + episode_group=episode_group, + username=username, + date=date, + music_type=music_type, + ) + + +def _subscribe_history( + name: str, + *, + media_id: str, + username: str = "alice", + mtype: str = MediaType.TV.value, + season: int | None = 1, + episode_group: str | None = None, + date: str = "2026-08-27 10:00:00", + music_type: str | None = None, +) -> SubscribeHistoryModel: + """构造隔离用例使用的订阅完成历史行。""" + return SubscribeHistoryModel( + name=name, + type=mtype, + media_source=TMDB, + media_id=media_id, + season=season, + episode_group=episode_group, + username=username, + date=date, + music_type=music_type, + ) + + +def _download_history( + title: str, + *, + media_id: str, + path: str, + year: str = "2026", + seasons: str | None = "S01", + episodes: str | None = "E01", + username: str = "alice", + download_hash: str | None = "hash-1", + episode_group: str | None = None, + date: str = "2026-08-27 10:00:00", + mtype: str = MediaType.TV.value, + music_type: str | None = None, +) -> DownloadHistoryModel: + """构造隔离用例使用的下载历史行。""" + return DownloadHistoryModel( + path=path, + type=mtype, + title=title, + year=year, + media_source=TMDB, + media_id=media_id, + music_type=music_type, + seasons=seasons, + episodes=episodes, + username=username, + download_hash=download_hash, + episode_group=episode_group, + date=date, + ) + + +def _transfer_history( + title: str, + *, + media_id: str | None, + src: str, + dest: str, + year: str = "2026", + seasons: str | None = "S01", + episodes: str | None = "E01", + download_hash: str | None = "hash-1", + episode_group: str | None = None, + status: bool = True, + date: str = "2026-08-27 10:00:00", + mtype: str = MediaType.TV.value, +) -> TransferHistoryModel: + """构造隔离用例使用的整理历史行。""" + return TransferHistoryModel( + src=src, + src_storage="local", + dest=dest, + type=mtype, + title=title, + year=year, + media_source=TMDB if media_id is not None else None, + media_id=media_id, + seasons=seasons, + episodes=episodes, + download_hash=download_hash, + episode_group=episode_group, + status=status, + date=date, + ) + + +def _assert_projected_page(page: QueryPage, dto_type: type[Any], model_type: type[Any], row_id: int) -> None: + """断言分页结果是稳定 DTO,而不是 ORM 行或延迟加载对象。""" + assert isinstance(page, QueryPage) + assert page.total == 1 + assert len(page.items) == 1 + item = page.items[0] + assert isinstance(item, dto_type) + assert not isinstance(item, model_type) + assert item.id == row_id + assert item.media_source == MediaSource.TMDB + + +def test_sdk_projects_subscription_and_three_history_domains_to_dtos(db, query_sdk): + """订阅、订阅完成历史、下载历史、整理历史均只返回 Pydantic 投影。""" + sdk, _executor = query_sdk + subscribe = db.add(_subscribe("订阅 DTO", media_id="dto-sub")) + subscribe_history = db.add( + _subscribe_history("订阅历史 DTO", media_id="dto-sub-history") + ) + download_history = db.add( + _download_history("下载历史 DTO", media_id="dto-download", path="/dto/download") + ) + transfer_history = db.add( + _transfer_history( + "整理历史 DTO", + media_id="dto-transfer", + src="/dto/src", + dest="/dto/dest", + ) + ) + + _assert_projected_page( + sdk.list_subscriptions( + SubscriptionFilter(media_source=TMDB, media_id="dto-sub") + ), + SubscribeDTO, + SubscribeModel, + subscribe.id, + ) + _assert_projected_page( + sdk.list_subscription_history( + SubscriptionHistoryFilter( + media_source=TMDB, + media_id="dto-sub-history", + ) + ), + SubscribeHistoryDTO, + SubscribeHistoryModel, + subscribe_history.id, + ) + _assert_projected_page( + sdk.list_download_history( + DownloadHistoryFilter(media_source=TMDB, media_id="dto-download") + ), + DownloadHistoryDTO, + DownloadHistoryModel, + download_history.id, + ) + _assert_projected_page( + sdk.list_transfer_history( + TransferHistoryFilter(media_source=TMDB, media_id="dto-transfer") + ), + TransferHistoryDTO, + TransferHistoryModel, + transfer_history.id, + ) + + +def test_sdk_applies_combined_filters_in_each_query_domain(db, query_sdk): + """多个筛选条件必须按 AND 组合,不能只应用媒体身份或单个主条件。""" + sdk, _executor = query_sdk + + db.add( + _subscribe( + "订阅命中", + media_id="combo-sub", + state="N", + username="alice", + season=2, + episode_group="eg-a", + ), + _subscribe( + "状态不符", + media_id="combo-sub", + state="R", + username="alice", + season=2, + episode_group="eg-a", + ), + _subscribe( + "用户不符", + media_id="combo-sub", + state="N", + username="bob", + season=2, + episode_group="eg-a", + ), + _subscribe( + "季不符", + media_id="combo-sub", + state="N", + username="alice", + season=1, + episode_group="eg-a", + ), + _subscribe( + "剧集组不符", + media_id="combo-sub", + state="N", + username="alice", + season=2, + episode_group="eg-b", + ), + _subscribe( + "类型不符", + media_id="combo-sub", + state="N", + username="alice", + mtype=MediaType.MOVIE.value, + season=2, + episode_group="eg-a", + ), + ) + subscribe_page = sdk.list_subscriptions( + SubscriptionFilter( + media_source=TMDB, + media_id="combo-sub", + states=("N",), + usernames=("alice",), + media_types=(MediaType.TV,), + season=2, + episode_group="eg-a", + ) + ) + assert [item.name for item in subscribe_page.items] == ["订阅命中"] + + db.add( + _subscribe_history( + "历史命中", + media_id="combo-sub-history", + username="alice", + season=2, + episode_group="eg-a", + ), + _subscribe_history( + "历史用户不符", + media_id="combo-sub-history", + username="bob", + season=2, + episode_group="eg-a", + ), + _subscribe_history( + "历史季不符", + media_id="combo-sub-history", + username="alice", + season=1, + episode_group="eg-a", + ), + _subscribe_history( + "历史类型不符", + media_id="combo-sub-history", + username="alice", + mtype=MediaType.MOVIE.value, + season=2, + episode_group="eg-a", + ), + ) + history_page = sdk.list_subscription_history( + SubscriptionHistoryFilter( + media_source=TMDB, + media_id="combo-sub-history", + usernames=("alice",), + media_types=(MediaType.TV,), + season=2, + episode_group="eg-a", + ) + ) + assert [item.name for item in history_page.items] == ["历史命中"] + + db.add( + _download_history( + "Combo Film", + media_id="combo-download", + path="/combo/match.mkv", + year="2026", + seasons="S02", + episodes="E03", + username="alice", + download_hash="combo-hash", + episode_group="eg-a", + ), + _download_history( + "Combo Film", + media_id="combo-download", + path="/combo/wrong-user.mkv", + year="2026", + seasons="S02", + episodes="E03", + username="bob", + download_hash="combo-hash", + episode_group="eg-a", + ), + _download_history( + "Combo Film", + media_id="combo-download", + path="/combo/wrong-episode.mkv", + year="2026", + seasons="S02", + episodes="E04", + username="alice", + download_hash="combo-hash", + episode_group="eg-a", + ), + ) + download_page = sdk.list_download_history( + DownloadHistoryFilter( + media_source=TMDB, + media_id="combo-download", + media_types=(MediaType.TV,), + title="Combo", + year="2026", + seasons="S02", + episodes="E03", + path="match", + download_hash="combo-hash", + username="alice", + episode_group="eg-a", + ) + ) + assert [item.path for item in download_page.items] == ["/combo/match.mkv"] + + db.add( + _transfer_history( + "Transfer Combo", + media_id="combo-transfer", + src="/combo/src-match.mkv", + dest="/combo/dest-match.mkv", + year="2026", + seasons="S02", + episodes="E03", + download_hash="transfer-hash", + episode_group="eg-a", + ), + _transfer_history( + "Transfer Combo", + media_id="combo-transfer", + src="/combo/src-failed.mkv", + dest="/combo/dest-failed.mkv", + year="2026", + seasons="S02", + episodes="E03", + download_hash="transfer-hash", + episode_group="eg-a", + status=False, + ), + _transfer_history( + "Transfer No Identity", + media_id=None, + src="/combo/src-no-id.mkv", + dest="/combo/dest-no-id.mkv", + year="2026", + seasons="S02", + episodes="E03", + download_hash="transfer-hash", + episode_group="eg-a", + ), + ) + transfer_page = sdk.list_transfer_history( + TransferHistoryFilter( + media_types=(MediaType.TV,), + media_sources=(MediaSource.TMDB,), + require_media_identity=True, + title="Transfer", + text="dest-match", + year="2026", + seasons="S02", + episodes="E03", + src="/combo/src-match.mkv", + dest="/combo/dest-match.mkv", + status=True, + download_hash="transfer-hash", + episode_group="eg-a", + ) + ) + assert [item.src for item in transfer_page.items] == ["/combo/src-match.mkv"] + + +def test_sdk_pagination_reports_total_and_stable_date_id_order(db, query_sdk): + """同日期记录按 ID 打破平局,跨页总数和结果顺序保持稳定。""" + sdk, _executor = query_sdk + rows = db.add( + _download_history( + "排序一", + media_id="sort-download", + path="/sort/one", + date="2026-08-27 10:00:00", + download_hash="sort-1", + ), + _download_history( + "排序二", + media_id="sort-download", + path="/sort/two", + date="2026-08-27 10:00:00", + download_hash="sort-2", + ), + _download_history( + "排序三", + media_id="sort-download", + path="/sort/three", + date="2026-08-26 10:00:00", + download_hash="sort-3", + ), + ) + rows = list(rows) + expected_desc = sorted(rows, key=lambda row: (row.date, row.id), reverse=True) + filters = DownloadHistoryFilter(media_source=TMDB, media_id="sort-download") + + page_one = sdk.list_download_history( + filters, + QueryPageRequest(page=1, count=2), + ) + page_two = sdk.list_download_history( + filters, + QueryPageRequest(page=2, count=2), + ) + + assert page_one.total == 3 + assert page_one.page == 1 + assert page_one.count == 2 + assert page_one.has_next is True + assert [item.id for item in page_one.items] == [ + row.id for row in expected_desc[:2] + ] + assert page_two.total == 3 + assert page_two.has_next is False + assert [item.id for item in page_two.items] == [expected_desc[2].id] + + asc_page = sdk.list_download_history( + filters, + QueryPageRequest( + page=1, + count=3, + sort=QuerySort( + field=QuerySortField.DATE, + direction=QuerySortDirection.ASC, + ), + ), + ) + expected_asc = sorted(rows, key=lambda row: (row.date, row.id)) + assert [item.id for item in asc_page.items] == [ + row.id for row in expected_asc + ] + + +@pytest.mark.parametrize( + "payload", + [ + {"media_source": TMDB}, + {"media_id": "half-id"}, + {"media_source": TMDB, "media_id": ""}, + {"media_source": TMDB, "media_id": " "}, + {"media_source": TMDB, "media_id": "0"}, + {"media_source": "not valid!", "media_id": "id"}, + ], +) +def test_sdk_rejects_invalid_or_half_media_identity_fail_closed(query_sdk, payload): + """媒体身份不完整、空白、零值或未知来源不得退化成全表查询。""" + sdk, _executor = query_sdk + + with pytest.raises(ValueError): + sdk.list_download_history(payload) + + +def test_sdk_get_returns_none_for_missing_records(query_sdk): + """四个查询领域的按 ID 读取在未命中时统一返回 None。""" + sdk, _executor = query_sdk + missing_id = 2_147_483_647 + + assert sdk.get_subscription(missing_id) is None + assert sdk.get_subscription_history(missing_id) is None + assert sdk.get_download_history(missing_id) is None + assert sdk.get_transfer_history(missing_id) is None + + +def test_sdk_sync_async_semantics_match_and_async_uses_executor(db, query_sdk): + """四个查询门面的异步结果与同步一致,并交给 executor 线程。""" + sdk, executor = query_sdk + subscribe = db.add( + _subscribe("订阅同步异步", media_id="async-subscription") + ) + subscribe_history = db.add( + _subscribe_history( + "订阅历史同步异步", + media_id="async-subscription-history", + ) + ) + download = db.add( + _download_history( + "同步异步一致", + media_id="async-download", + path="/async/download", + ) + ) + transfer = db.add( + _transfer_history( + "整理历史同步异步", + media_id="async-transfer", + src="/async/src", + dest="/async/dest", + ) + ) + request = QueryPageRequest(page=1, count=10) + caller_thread_id = threading.get_ident() + + cases = ( + ( + sdk.list_subscriptions, + sdk.async_list_subscriptions, + SubscriptionFilter( + media_source=TMDB, + media_id="async-subscription", + ), + subscribe.id, + ), + ( + sdk.list_subscription_history, + sdk.async_list_subscription_history, + SubscriptionHistoryFilter( + media_source=TMDB, + media_id="async-subscription-history", + ), + subscribe_history.id, + ), + ( + sdk.list_download_history, + sdk.async_list_download_history, + DownloadHistoryFilter( + media_source=TMDB, + media_id="async-download", + ), + download.id, + ), + ( + sdk.list_transfer_history, + sdk.async_list_transfer_history, + TransferHistoryFilter( + media_source=TMDB, + media_id="async-transfer", + ), + transfer.id, + ), + ) + for sync_call, async_call, filters, expected_id in cases: + sync_page = sync_call(filters, request) + calls_before = executor.calls + async_page = asyncio.run(async_call(filters, request)) + + assert async_page == sync_page + assert [item.id for item in async_page.items] == [expected_id] + assert executor.calls == calls_before + 1 + + assert executor.worker_thread_ids + assert all(thread_id != caller_thread_id for thread_id in executor.worker_thread_ids) + + +def test_sdk_public_exports_do_not_leak_persistence_implementation(query_sdk): + """SDK 的机器可读公开合同只允许 DTO、筛选模型和查询函数。""" + sdk, _executor = query_sdk + forbidden_tokens = ("session", "oper", "provider", "configure") + forbidden_modules = ( + "app.db.models", + "app.db.oper", + "app.db.session", + "app.application", + ) + + assert sdk.__all__ + for name in sdk.__all__: + lowered = name.casefold() + assert not any(token in lowered for token in forbidden_tokens), name + exported = getattr(sdk, name) + module_name = getattr(exported, "__module__", "") + assert not module_name.startswith(forbidden_modules), ( + name, + module_name, + ) + if inspect.isfunction(exported): + assert module_name == "app.sdk.queries" + + assert not hasattr(sdk, "TransactionalDataQueryRepository") + assert not hasattr(sdk, "DataQueryService") + assert not hasattr(sdk, "get_configured_data_query_service") + + +def test_query_tests_use_local_sqlite_backend(query_sdk): + """查询 focused tests 固定在隔离 SQLite 上,不依赖外部数据库或网络。""" + from app.db.engine import get_engine + + engine = get_engine() + assert engine.url.get_backend_name() == "sqlite" From 6bae137d258d8ff9669a28bc7815d8af3eb73de4 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 27 Aug 2026 23:20:17 +0800 Subject: [PATCH 2/8] refactor(sdk): isolate plugin query snapshots --- app/application/data_query.py | 101 +++++----- app/db/adapters/data_query.py | 59 +++--- app/schemas/query.py | 182 ++++++++++++++++-- app/sdk/queries.py | 154 ++++++++++++--- .../architecture/dependency-baseline.json | 36 +++- .../runtime-contract-baseline.json | 162 ++++++++++++++++ tests/test_sdk_queries.py | 159 +++++++++++---- 7 files changed, 685 insertions(+), 168 deletions(-) diff --git a/app/application/data_query.py b/app/application/data_query.py index 6b83a9b2f..0d4a08bae 100644 --- a/app/application/data_query.py +++ b/app/application/data_query.py @@ -10,19 +10,20 @@ from typing import Any, Callable, Generic, Protocol, TypeVar, cast from pydantic import BaseModel from app.application.database import AsyncDatabaseExecutor -from app.schemas.history import DownloadHistory, TransferHistory from app.schemas.query import ( DownloadHistoryFilter, + DownloadHistorySnapshot, QueryPage, QueryPageRequest, - SubscribeHistory, SubscriptionFilter, SubscriptionHistoryFilter, + SubscriptionHistorySnapshot, + SubscriptionSnapshot, TransferHistoryFilter, + TransferHistorySnapshot, ) -from app.schemas.subscribe import Subscribe -RecordT = TypeVar("RecordT") +RecordT = TypeVar("RecordT", covariant=True) DtoT = TypeVar("DtoT", bound=BaseModel) ResultT = TypeVar("ResultT") @@ -176,7 +177,7 @@ class DataQueryService: self, filters: SubscriptionFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[Subscribe]: + ) -> QueryPage[SubscriptionSnapshot]: """同步分页查询订阅。""" normalized_page = self._page_request(page) normalized_filters = self._filter(filters, SubscriptionFilter) @@ -184,36 +185,35 @@ class DataQueryService: filters=normalized_filters, page=normalized_page, ) - return self._to_page(Subscribe, normalized_page, rows) + return self._to_page(SubscriptionSnapshot, normalized_page, rows) async def async_list_subscriptions( self, filters: SubscriptionFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[Subscribe]: + ) -> QueryPage[SubscriptionSnapshot]: """异步分页查询订阅,业务规则在数据库 worker 中复用同步入口。""" - return await self._async_run( - partial(self.list_subscriptions, filters, page) - ) + return await self._async_run(partial(self.list_subscriptions, filters, page)) - def get_subscription(self, subscription_id: int) -> Subscribe | None: + def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None: """同步按 ID 查询订阅。""" return self._to_item( - Subscribe, + SubscriptionSnapshot, self._subscriptions.get_subscription(subscription_id), ) - async def async_get_subscription(self, subscription_id: int) -> Subscribe | None: + async def async_get_subscription( + self, + subscription_id: int, + ) -> SubscriptionSnapshot | None: """异步按 ID 查询订阅。""" - return await self._async_run( - partial(self.get_subscription, subscription_id) - ) + return await self._async_run(partial(self.get_subscription, subscription_id)) def list_subscription_history( self, filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[SubscribeHistory]: + ) -> QueryPage[SubscriptionHistorySnapshot]: """同步分页查询订阅完成历史。""" normalized_page = self._page_request(page) normalized_filters = self._filter(filters, SubscriptionHistoryFilter) @@ -221,39 +221,38 @@ class DataQueryService: filters=normalized_filters, page=normalized_page, ) - return self._to_page(SubscribeHistory, normalized_page, rows) + return self._to_page(SubscriptionHistorySnapshot, normalized_page, rows) async def async_list_subscription_history( self, filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[SubscribeHistory]: + ) -> QueryPage[SubscriptionHistorySnapshot]: """异步分页查询订阅完成历史。""" - return await self._async_run( - partial(self.list_subscription_history, filters, page) - ) + return await self._async_run(partial(self.list_subscription_history, filters, page)) - def get_subscription_history(self, history_id: int) -> SubscribeHistory | None: + def get_subscription_history( + self, + history_id: int, + ) -> SubscriptionHistorySnapshot | None: """同步按 ID 查询订阅完成历史。""" return self._to_item( - SubscribeHistory, + SubscriptionHistorySnapshot, self._subscriptions.get_subscription_history(history_id), ) async def async_get_subscription_history( self, history_id: int, - ) -> SubscribeHistory | None: + ) -> SubscriptionHistorySnapshot | None: """异步按 ID 查询订阅完成历史。""" - return await self._async_run( - partial(self.get_subscription_history, history_id) - ) + return await self._async_run(partial(self.get_subscription_history, history_id)) def list_download_history( self, filters: DownloadHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[DownloadHistory]: + ) -> QueryPage[DownloadHistorySnapshot]: """同步分页查询下载历史。""" normalized_page = self._page_request(page) normalized_filters = self._filter(filters, DownloadHistoryFilter) @@ -261,36 +260,35 @@ class DataQueryService: filters=normalized_filters, page=normalized_page, ) - return self._to_page(DownloadHistory, normalized_page, rows) + return self._to_page(DownloadHistorySnapshot, normalized_page, rows) async def async_list_download_history( self, filters: DownloadHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[DownloadHistory]: + ) -> QueryPage[DownloadHistorySnapshot]: """异步分页查询下载历史。""" - return await self._async_run( - partial(self.list_download_history, filters, page) - ) + return await self._async_run(partial(self.list_download_history, filters, page)) - def get_download_history(self, history_id: int) -> DownloadHistory | None: + def get_download_history(self, history_id: int) -> DownloadHistorySnapshot | None: """同步按 ID 查询下载历史。""" return self._to_item( - DownloadHistory, + DownloadHistorySnapshot, self._histories.get_download_history(history_id), ) - async def async_get_download_history(self, history_id: int) -> DownloadHistory | None: + async def async_get_download_history( + self, + history_id: int, + ) -> DownloadHistorySnapshot | None: """异步按 ID 查询下载历史。""" - return await self._async_run( - partial(self.get_download_history, history_id) - ) + return await self._async_run(partial(self.get_download_history, history_id)) def list_transfer_history( self, filters: TransferHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[TransferHistory]: + ) -> QueryPage[TransferHistorySnapshot]: """同步分页查询整理历史。""" normalized_page = self._page_request(page) normalized_filters = self._filter(filters, TransferHistoryFilter) @@ -298,30 +296,29 @@ class DataQueryService: filters=normalized_filters, page=normalized_page, ) - return self._to_page(TransferHistory, normalized_page, rows) + return self._to_page(TransferHistorySnapshot, normalized_page, rows) async def async_list_transfer_history( self, filters: TransferHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, - ) -> QueryPage[TransferHistory]: + ) -> QueryPage[TransferHistorySnapshot]: """异步分页查询整理历史。""" - return await self._async_run( - partial(self.list_transfer_history, filters, page) - ) + return await self._async_run(partial(self.list_transfer_history, filters, page)) - def get_transfer_history(self, history_id: int) -> TransferHistory | None: + def get_transfer_history(self, history_id: int) -> TransferHistorySnapshot | None: """同步按 ID 查询整理历史。""" return self._to_item( - TransferHistory, + TransferHistorySnapshot, self._histories.get_transfer_history(history_id), ) - async def async_get_transfer_history(self, history_id: int) -> TransferHistory | None: + async def async_get_transfer_history( + self, + history_id: int, + ) -> TransferHistorySnapshot | None: """异步按 ID 查询整理历史。""" - return await self._async_run( - partial(self.get_transfer_history, history_id) - ) + return await self._async_run(partial(self.get_transfer_history, history_id)) _configured_data_query_service: DataQueryService | None = None diff --git a/app/db/adapters/data_query.py b/app/db/adapters/data_query.py index 41d3769bd..7d2d43cc3 100644 --- a/app/db/adapters/data_query.py +++ b/app/db/adapters/data_query.py @@ -16,23 +16,19 @@ from app.db.models.downloadhistory import DownloadHistory from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory as SubscribeHistoryModel from app.db.models.transferhistory import TransferHistory -from app.schemas.history import ( - DownloadHistory as DownloadHistoryView, -) -from app.schemas.history import ( - TransferHistory as TransferHistoryView, -) from app.schemas.query import ( DownloadHistoryFilter, + DownloadHistorySnapshot, QueryPageRequest, QuerySortDirection, QuerySortField, SubscriptionFilter, SubscriptionHistoryFilter, + SubscriptionHistorySnapshot, + SubscriptionSnapshot, TransferHistoryFilter, + TransferHistorySnapshot, ) -from app.schemas.query import SubscribeHistory as SubscribeHistoryView -from app.schemas.subscribe import Subscribe as SubscribeView from app.schemas.types import MUSIC_ENTITY_RECORDING _ModelT = TypeVar("_ModelT", bound=Base) @@ -168,7 +164,7 @@ class SqlAlchemyDataQueryAdapter: *, filters: SubscriptionFilter, page: QueryPageRequest, - ) -> QueryRows[SubscribeView]: + ) -> QueryRows[SubscriptionSnapshot]: """按受控组合条件分页查询当前订阅。""" query = filters conditions = self._identity_conditions(Subscribe, query) @@ -196,16 +192,16 @@ class SqlAlchemyDataQueryAdapter: conditions.append(music_condition) return self._page( model=Subscribe, - view_model=SubscribeView, + view_model=SubscriptionSnapshot, conditions=conditions, page=page, ) - def get_subscription(self, subscription_id: int) -> SubscribeView | None: + def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None: """按主键查询订阅并返回脱离 Session 的 DTO。""" return self._get( model=Subscribe, - view_model=SubscribeView, + view_model=SubscriptionSnapshot, record_id=subscription_id, ) @@ -214,7 +210,7 @@ class SqlAlchemyDataQueryAdapter: *, filters: SubscriptionHistoryFilter, page: QueryPageRequest, - ) -> QueryRows[SubscribeHistoryView]: + ) -> QueryRows[SubscriptionHistorySnapshot]: """按受控组合条件分页查询订阅完成历史。""" query = filters conditions = self._identity_conditions(SubscribeHistoryModel, query) @@ -242,7 +238,7 @@ class SqlAlchemyDataQueryAdapter: conditions.append(music_condition) return self._page( model=SubscribeHistoryModel, - view_model=SubscribeHistoryView, + view_model=SubscriptionHistorySnapshot, conditions=conditions, page=page, ) @@ -250,11 +246,11 @@ class SqlAlchemyDataQueryAdapter: def get_subscription_history( self, history_id: int, - ) -> SubscribeHistoryView | None: + ) -> SubscriptionHistorySnapshot | None: """按主键查询订阅完成历史并返回稳定 DTO。""" return self._get( model=SubscribeHistoryModel, - view_model=SubscribeHistoryView, + view_model=SubscriptionHistorySnapshot, record_id=history_id, ) @@ -263,7 +259,7 @@ class SqlAlchemyDataQueryAdapter: *, filters: DownloadHistoryFilter, page: QueryPageRequest, - ) -> QueryRows[DownloadHistoryView]: + ) -> QueryRows[DownloadHistorySnapshot]: """按受控组合条件分页查询下载历史。""" query = filters conditions = self._identity_conditions(DownloadHistory, query) @@ -276,20 +272,23 @@ class SqlAlchemyDataQueryAdapter: conditions.append(DownloadHistory.type.in_(media_types)) for column, value in ( (DownloadHistory.title, query.title), - (DownloadHistory.path, query.path), - ): - if value: - conditions.append(_contains(column, value)) - for column, value in ( (DownloadHistory.year, query.year), (DownloadHistory.seasons, query.seasons), (DownloadHistory.episodes, query.episodes), + (DownloadHistory.path, query.path), (DownloadHistory.download_hash, query.download_hash), (DownloadHistory.username, query.username), (DownloadHistory.episode_group, query.episode_group), ): if value is not None and value != "": conditions.append(column == value) + if query.text: + conditions.append( + or_( + _contains(DownloadHistory.title, query.text), + _contains(DownloadHistory.path, query.text), + ) + ) if usernames: conditions.append(DownloadHistory.username.in_(usernames)) music_condition = _music_type_condition(DownloadHistory.music_type, query.music_type) @@ -297,16 +296,16 @@ class SqlAlchemyDataQueryAdapter: conditions.append(music_condition) return self._page( model=DownloadHistory, - view_model=DownloadHistoryView, + view_model=DownloadHistorySnapshot, conditions=conditions, page=page, ) - def get_download_history(self, history_id: int) -> DownloadHistoryView | None: + def get_download_history(self, history_id: int) -> DownloadHistorySnapshot | None: """按主键查询下载历史并返回稳定 DTO。""" return self._get( model=DownloadHistory, - view_model=DownloadHistoryView, + view_model=DownloadHistorySnapshot, record_id=history_id, ) @@ -315,7 +314,7 @@ class SqlAlchemyDataQueryAdapter: *, filters: TransferHistoryFilter, page: QueryPageRequest, - ) -> QueryRows[TransferHistoryView]: + ) -> QueryRows[TransferHistorySnapshot]: """按受控组合条件分页查询整理历史。""" query = filters conditions = self._identity_conditions(TransferHistory, query) @@ -331,7 +330,7 @@ class SqlAlchemyDataQueryAdapter: if query.require_media_identity: conditions.extend(self._require_media_identity(TransferHistory)) if query.title: - conditions.append(_contains(TransferHistory.title, query.title)) + conditions.append(TransferHistory.title == query.title) if query.text: conditions.append( or_( @@ -358,16 +357,16 @@ class SqlAlchemyDataQueryAdapter: conditions.append(music_condition) return self._page( model=TransferHistory, - view_model=TransferHistoryView, + view_model=TransferHistorySnapshot, conditions=conditions, page=page, ) - def get_transfer_history(self, history_id: int) -> TransferHistoryView | None: + def get_transfer_history(self, history_id: int) -> TransferHistorySnapshot | None: """按主键查询整理历史并返回稳定 DTO。""" return self._get( model=TransferHistory, - view_model=TransferHistoryView, + view_model=TransferHistorySnapshot, record_id=history_id, ) diff --git a/app/schemas/query.py b/app/schemas/query.py index 684e366cf..8cc31c0c7 100644 --- a/app/schemas/query.py +++ b/app/schemas/query.py @@ -1,14 +1,12 @@ """插件只读数据查询使用的稳定筛选、分页与数据投影合同。""" from enum import Enum -from typing import Generic, Optional, TypeVar +from typing import Any, Generic, Optional, TypeVar -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.schemas.common import JsonData -from app.schemas.history import DownloadHistory, TransferHistory -from app.schemas.media import OptionalMediaIdentityMixin -from app.schemas.subscribe import Subscribe +from app.schemas.media import OptionalMediaIdentityMixin, normalize_media_source from app.schemas.types import MediaSource, MediaType T = TypeVar("T") @@ -17,7 +15,7 @@ DEFAULT_QUERY_PAGE_SIZE = 50 MAX_QUERY_PAGE_SIZE = 200 -class _QueryInput(BaseModel): +class _QueryInput(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy """查询输入共同的严格字段合同。""" model_config = ConfigDict(extra="forbid") @@ -56,7 +54,7 @@ class QueryPageRequest(_QueryInput): sort: QuerySort = Field(default_factory=QuerySort) -class QueryPage(BaseModel, Generic[T]): +class QueryPage(BaseModel, Generic[T]): # type: ignore[misc] # Pydantic imports are skipped by strict mypy """公开查询返回的分页 DTO。""" items: list[T] = Field(default_factory=list) @@ -74,23 +72,105 @@ class QueryPage(BaseModel, Generic[T]): return self.page * self.count < self.total -class MediaIdentityQuery(OptionalMediaIdentityMixin, _QueryInput): +class MediaIdentityQuery( + OptionalMediaIdentityMixin, + _QueryInput, +): """允许省略身份,但显式筛选时要求来源与原生 ID 成对有效。""" media_source: Optional[MediaSource] = None media_id: Optional[str] = None -class SubscribeHistory(OptionalMediaIdentityMixin, BaseModel): - """订阅完成历史的稳定只读投影。""" +class _QuerySnapshot(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy + """只读查询 DTO 的媒体身份与脏数据归一化边界。""" + + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + @field_validator("media_source", mode="before") # type: ignore[misc] + @classmethod + def _normalize_source(cls, value: Any) -> Optional[MediaSource]: + """未知旧来源不应使整页查询失败,统一降级为空身份。""" + return normalize_media_source(value) + + @model_validator(mode="after") # type: ignore[misc] + def _normalize_identity_pair(self) -> "_QuerySnapshot": + """输出中的脏半对身份按无身份处理,避免向插件传播不可用主键。""" + normalized_id = str(self.media_id).strip() if self.media_id is not None else "" + if not self.media_source or not normalized_id or normalized_id == "0": + object.__setattr__(self, "media_source", None) + object.__setattr__(self, "media_id", None) + else: + object.__setattr__(self, "media_id", normalized_id) + return self + + +class SubscriptionSnapshot(_QuerySnapshot): + """当前订阅的稳定只读快照,不包含 ORM 或写入行为。""" + + id: int + name: Optional[str] = None + year: Optional[str] = None + type: Optional[str] = None + keyword: Optional[str] = None + music_type: Optional[str] = None + total_tracks: Optional[int] = None + season: Optional[int] = None + poster: Optional[str] = None + backdrop: Optional[str] = None + vote: Optional[float] = None + description: Optional[str] = None + # 资源选择规则与质量约束均按宿主保存值只读返回。 + filter: Optional[str] = None + include: Optional[str] = None + exclude: Optional[str] = None + quality: Optional[str] = None + resolution: Optional[str] = None + effect: Optional[str] = None + audio_quality: Optional[str] = None + audio_format: Optional[str] = None + min_bitrate: Optional[int] = None + min_bit_depth: Optional[int] = None + min_sample_rate: Optional[int] = None + # 订阅进度字段由宿主维护,插件查询不得据此直接写库。 + total_episode: Optional[int] = None + start_episode: Optional[int] = None + lack_episode: Optional[int] = None + note: Optional[JsonData] = None + state: Optional[str] = None + last_update: Optional[str] = None + date: Optional[str] = None + username: Optional[str] = None + sites: Optional[list[int]] = None + downloader: Optional[str] = None + best_version: Optional[int] = None + best_version_full: Optional[int] = None + current_priority: Optional[int] = None + current_audio_format: Optional[str] = None + current_bitrate: Optional[int] = None + current_bit_depth: Optional[int] = None + current_sample_rate: Optional[int] = None + episode_priority: Optional[dict[str, int]] = None + save_path: Optional[str] = None + search_imdbid: Optional[int] = None + manual_total_episode: Optional[int] = None + custom_words: Optional[str] = None + media_category: Optional[str] = None + filter_groups: Optional[list[str]] = None + episode_group: Optional[str] = None + + +class SubscriptionHistorySnapshot(_QuerySnapshot): + """订阅完成历史的稳定只读快照。""" id: int name: Optional[str] = None year: Optional[str] = None type: Optional[str] = None keyword: Optional[str] = None - media_source: Optional[MediaSource] = None - media_id: Optional[str] = None music_type: Optional[str] = None total_tracks: Optional[int] = None season: Optional[int] = None @@ -124,13 +204,78 @@ class SubscribeHistory(OptionalMediaIdentityMixin, BaseModel): episode_priority: Optional[dict[str, int]] = None save_path: Optional[str] = None search_imdbid: Optional[int] = None - note: Optional[JsonData] = None custom_words: Optional[str] = None media_category: Optional[str] = None filter_groups: Optional[list[str]] = None episode_group: Optional[str] = None - model_config = ConfigDict(from_attributes=True) + +class DownloadHistorySnapshot(_QuerySnapshot): + """下载历史的稳定只读快照。""" + + id: int + path: Optional[str] = None + type: Optional[str] = None + title: Optional[str] = None + year: Optional[str] = None + music_type: Optional[str] = None + seasons: Optional[str] = None + episodes: Optional[str] = None + image: Optional[str] = None + poster: Optional[str] = None + downloader: Optional[str] = None + download_hash: Optional[str] = None + torrent_name: Optional[str] = None + torrent_description: Optional[str] = None + torrent_site: Optional[str] = None + userid: Optional[str] = None + username: Optional[str] = None + channel: Optional[str] = None + date: Optional[str] = None + note: Optional[JsonData] = None + media_category: Optional[str] = None + episode_group: Optional[str] = None + custom_words: Optional[str] = None + + +class TransferHistorySnapshot(_QuerySnapshot): + """整理历史的稳定只读快照;内部任务结算标识不会暴露给插件。""" + + id: int + src: Optional[str] = None + src_storage: Optional[str] = None + src_fileitem: Optional[JsonData] = None + dest: Optional[str] = None + dest_storage: Optional[str] = None + dest_fileitem: Optional[JsonData] = None + mode: Optional[str] = None + type: Optional[str] = None + category: Optional[str] = None + title: Optional[str] = None + year: Optional[str] = None + music_type: Optional[str] = None + total_tracks: Optional[int] = None + audio_format: Optional[str] = None + audio_lossless: Optional[bool] = None + bit_depth: Optional[int] = None + sample_rate: Optional[int] = None + bitrate: Optional[int] = None + seasons: Optional[str] = None + episodes: Optional[str] = None + image: Optional[str] = None + downloader: Optional[str] = None + download_hash: Optional[str] = None + status: bool = False + errmsg: Optional[str] = None + date: Optional[str] = None + files: Optional[JsonData] = None + episode_group: Optional[str] = None + + @field_validator("status", mode="before") # type: ignore[misc] + @classmethod + def _normalize_status(cls, value: object) -> bool: + """旧记录的 NULL 状态按失败处理,避免错误地向插件声明整理成功。""" + return bool(value) class SubscriptionFilter(MediaIdentityQuery): @@ -164,6 +309,7 @@ class DownloadHistoryFilter(MediaIdentityQuery): ids: tuple[int, ...] = () media_types: tuple[MediaType, ...] = () title: Optional[str] = None + text: Optional[str] = None year: Optional[str] = None seasons: Optional[str] = None episodes: Optional[str] = None @@ -198,18 +344,18 @@ class TransferHistoryFilter(MediaIdentityQuery): __all__ = [ "DEFAULT_QUERY_PAGE_SIZE", "MAX_QUERY_PAGE_SIZE", - "DownloadHistory", "DownloadHistoryFilter", + "DownloadHistorySnapshot", "MediaIdentityQuery", "QueryPage", "QueryPageRequest", "QuerySort", "QuerySortDirection", "QuerySortField", - "Subscribe", - "SubscribeHistory", "SubscriptionFilter", + "SubscriptionHistorySnapshot", "SubscriptionHistoryFilter", - "TransferHistory", + "SubscriptionSnapshot", "TransferHistoryFilter", + "TransferHistorySnapshot", ] diff --git a/app/sdk/queries.py b/app/sdk/queries.py index 145a633f0..d9150dae5 100644 --- a/app/sdk/queries.py +++ b/app/sdk/queries.py @@ -2,41 +2,133 @@ from __future__ import annotations -from typing import Any +from typing import Any, Protocol, cast from app.schemas.query import ( DEFAULT_QUERY_PAGE_SIZE, MAX_QUERY_PAGE_SIZE, - DownloadHistory, DownloadHistoryFilter, + DownloadHistorySnapshot, MediaIdentityQuery, QueryPage, QueryPageRequest, QuerySort, QuerySortDirection, QuerySortField, - Subscribe, - SubscribeHistory, SubscriptionFilter, SubscriptionHistoryFilter, - TransferHistory, + SubscriptionHistorySnapshot, + SubscriptionSnapshot, TransferHistoryFilter, + TransferHistorySnapshot, ) -def _service() -> Any: +class _DataQueryBackend(Protocol): + """SDK 转发所需的最小类型合同,不向插件公开应用服务实现。""" + + def list_subscriptions( + self, + filters: SubscriptionFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[SubscriptionSnapshot]: ... + + async def async_list_subscriptions( + self, + filters: SubscriptionFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[SubscriptionSnapshot]: ... + + def get_subscription( + self, + subscription_id: int, + ) -> SubscriptionSnapshot | None: ... + + async def async_get_subscription( + self, + subscription_id: int, + ) -> SubscriptionSnapshot | None: ... + + def list_subscription_history( + self, + filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[SubscriptionHistorySnapshot]: ... + + async def async_list_subscription_history( + self, + filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[SubscriptionHistorySnapshot]: ... + + def get_subscription_history( + self, + history_id: int, + ) -> SubscriptionHistorySnapshot | None: ... + + async def async_get_subscription_history( + self, + history_id: int, + ) -> SubscriptionHistorySnapshot | None: ... + + def list_download_history( + self, + filters: DownloadHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[DownloadHistorySnapshot]: ... + + async def async_list_download_history( + self, + filters: DownloadHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[DownloadHistorySnapshot]: ... + + def get_download_history( + self, + history_id: int, + ) -> DownloadHistorySnapshot | None: ... + + async def async_get_download_history( + self, + history_id: int, + ) -> DownloadHistorySnapshot | None: ... + + def list_transfer_history( + self, + filters: TransferHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[TransferHistorySnapshot]: ... + + async def async_list_transfer_history( + self, + filters: TransferHistoryFilter | dict[str, Any] | None = None, + page: QueryPageRequest | dict[str, Any] | None = None, + ) -> QueryPage[TransferHistorySnapshot]: ... + + def get_transfer_history( + self, + history_id: int, + ) -> TransferHistorySnapshot | None: ... + + async def async_get_transfer_history( + self, + history_id: int, + ) -> TransferHistorySnapshot | None: ... + + +def _service() -> _DataQueryBackend: """获取启动阶段登记的查询服务,避免把应用服务暴露为 SDK 合同。""" from app.application.data_query import ( get_configured_data_query_service, ) - return get_configured_data_query_service() + return cast(_DataQueryBackend, get_configured_data_query_service()) def list_subscriptions( filters: SubscriptionFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[Subscribe]: +) -> QueryPage[SubscriptionSnapshot]: """同步分页读取订阅 DTO。""" return _service().list_subscriptions(filters, page) @@ -44,17 +136,19 @@ def list_subscriptions( async def async_list_subscriptions( filters: SubscriptionFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[Subscribe]: +) -> QueryPage[SubscriptionSnapshot]: """异步分页读取订阅 DTO。""" return await _service().async_list_subscriptions(filters, page) -def get_subscription(subscription_id: int) -> Subscribe | None: +def get_subscription(subscription_id: int) -> SubscriptionSnapshot | None: """同步按 ID 读取订阅 DTO。""" return _service().get_subscription(subscription_id) -async def async_get_subscription(subscription_id: int) -> Subscribe | None: +async def async_get_subscription( + subscription_id: int, +) -> SubscriptionSnapshot | None: """异步按 ID 读取订阅 DTO。""" return await _service().async_get_subscription(subscription_id) @@ -62,7 +156,7 @@ async def async_get_subscription(subscription_id: int) -> Subscribe | None: def list_subscription_history( filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[SubscribeHistory]: +) -> QueryPage[SubscriptionHistorySnapshot]: """同步分页读取订阅完成历史 DTO。""" return _service().list_subscription_history(filters, page) @@ -70,17 +164,19 @@ def list_subscription_history( async def async_list_subscription_history( filters: SubscriptionHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[SubscribeHistory]: +) -> QueryPage[SubscriptionHistorySnapshot]: """异步分页读取订阅完成历史 DTO。""" return await _service().async_list_subscription_history(filters, page) -def get_subscription_history(history_id: int) -> SubscribeHistory | None: +def get_subscription_history(history_id: int) -> SubscriptionHistorySnapshot | None: """同步按 ID 读取订阅完成历史 DTO。""" return _service().get_subscription_history(history_id) -async def async_get_subscription_history(history_id: int) -> SubscribeHistory | None: +async def async_get_subscription_history( + history_id: int, +) -> SubscriptionHistorySnapshot | None: """异步按 ID 读取订阅完成历史 DTO。""" return await _service().async_get_subscription_history(history_id) @@ -88,7 +184,7 @@ async def async_get_subscription_history(history_id: int) -> SubscribeHistory | def list_download_history( filters: DownloadHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[DownloadHistory]: +) -> QueryPage[DownloadHistorySnapshot]: """同步分页读取下载历史 DTO。""" return _service().list_download_history(filters, page) @@ -96,17 +192,19 @@ def list_download_history( async def async_list_download_history( filters: DownloadHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[DownloadHistory]: +) -> QueryPage[DownloadHistorySnapshot]: """异步分页读取下载历史 DTO。""" return await _service().async_list_download_history(filters, page) -def get_download_history(history_id: int) -> DownloadHistory | None: +def get_download_history(history_id: int) -> DownloadHistorySnapshot | None: """同步按 ID 读取下载历史 DTO。""" return _service().get_download_history(history_id) -async def async_get_download_history(history_id: int) -> DownloadHistory | None: +async def async_get_download_history( + history_id: int, +) -> DownloadHistorySnapshot | None: """异步按 ID 读取下载历史 DTO。""" return await _service().async_get_download_history(history_id) @@ -114,7 +212,7 @@ async def async_get_download_history(history_id: int) -> DownloadHistory | None: def list_transfer_history( filters: TransferHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[TransferHistory]: +) -> QueryPage[TransferHistorySnapshot]: """同步分页读取整理历史 DTO。""" return _service().list_transfer_history(filters, page) @@ -122,17 +220,19 @@ def list_transfer_history( async def async_list_transfer_history( filters: TransferHistoryFilter | dict[str, Any] | None = None, page: QueryPageRequest | dict[str, Any] | None = None, -) -> QueryPage[TransferHistory]: +) -> QueryPage[TransferHistorySnapshot]: """异步分页读取整理历史 DTO。""" return await _service().async_list_transfer_history(filters, page) -def get_transfer_history(history_id: int) -> TransferHistory | None: +def get_transfer_history(history_id: int) -> TransferHistorySnapshot | None: """同步按 ID 读取整理历史 DTO。""" return _service().get_transfer_history(history_id) -async def async_get_transfer_history(history_id: int) -> TransferHistory | None: +async def async_get_transfer_history( + history_id: int, +) -> TransferHistorySnapshot | None: """异步按 ID 读取整理历史 DTO。""" return await _service().async_get_transfer_history(history_id) @@ -140,20 +240,20 @@ async def async_get_transfer_history(history_id: int) -> TransferHistory | None: __all__ = [ "DEFAULT_QUERY_PAGE_SIZE", "MAX_QUERY_PAGE_SIZE", - "DownloadHistory", "DownloadHistoryFilter", + "DownloadHistorySnapshot", "MediaIdentityQuery", "QueryPage", "QueryPageRequest", "QuerySort", "QuerySortDirection", "QuerySortField", - "Subscribe", - "SubscribeHistory", "SubscriptionFilter", "SubscriptionHistoryFilter", - "TransferHistory", + "SubscriptionHistorySnapshot", + "SubscriptionSnapshot", "TransferHistoryFilter", + "TransferHistorySnapshot", "async_get_download_history", "async_get_subscription", "async_get_subscription_history", diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 15337674d..4a822d27b 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1441,8 +1441,8 @@ "runtime_only": true } }, - "edge_count": 6898, - "edge_sha256": "c73faf7e29ee12a862cf2332f1803fd4eb8a3f4d2994ed81075ff198e896f8df", + "edge_count": 6924, + "edge_sha256": "d5471439086bcfdd8060b77670af6f821a34e3a864ba7e2cb7230c599a1cb3bb", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -4008,6 +4008,10 @@ "app.application.configuration -> app.schemas.types", "app.application.dashboard -> app.schemas", "app.application.dashboard -> app.schemas.dashboard", + "app.application.data_query -> app.application", + "app.application.data_query -> app.application.database", + "app.application.data_query -> app.schemas", + "app.application.data_query -> app.schemas.query", "app.application.directory -> app.adapters", "app.application.directory -> app.adapters.system", "app.application.directory -> app.adapters.system.host", @@ -5131,6 +5135,18 @@ "app.db.adapters.chain -> app.db.oper.transferpending", "app.db.adapters.chain -> app.db.oper.transfersettlementreceipt", "app.db.adapters.chain -> app.db.uow", + "app.db.adapters.data_query -> app.application", + "app.db.adapters.data_query -> app.application.data_query", + "app.db.adapters.data_query -> app.db", + "app.db.adapters.data_query -> app.db.base", + "app.db.adapters.data_query -> app.db.models", + "app.db.adapters.data_query -> app.db.models.downloadhistory", + "app.db.adapters.data_query -> app.db.models.subscribe", + "app.db.adapters.data_query -> app.db.models.subscribehistory", + "app.db.adapters.data_query -> app.db.models.transferhistory", + "app.db.adapters.data_query -> app.schemas", + "app.db.adapters.data_query -> app.schemas.query", + "app.db.adapters.data_query -> app.schemas.types", "app.db.adapters.download -> app.db", "app.db.adapters.download -> app.db.oper", "app.db.adapters.download -> app.db.oper.downloadfailure", @@ -7629,6 +7645,10 @@ "app.schemas.openai -> app.schemas.common", "app.schemas.plugin -> app.schemas", "app.schemas.plugin -> app.schemas.common", + "app.schemas.query -> app.schemas", + "app.schemas.query -> app.schemas.common", + "app.schemas.query -> app.schemas.media", + "app.schemas.query -> app.schemas.types", "app.schemas.response -> app.runtime", "app.schemas.response -> app.runtime.localization", "app.schemas.search -> app.schemas", @@ -7748,6 +7768,10 @@ "app.sdk.plugins -> app.runtime.extensions", "app.sdk.plugins -> app.runtime.extensions.module_manager", "app.sdk.plugins -> app.runtime.extensions.plugin_manager", + "app.sdk.queries -> app.application", + "app.sdk.queries -> app.application.data_query", + "app.sdk.queries -> app.schemas", + "app.sdk.queries -> app.schemas.query", "app.sdk.security -> app.adapters", "app.sdk.security -> app.adapters.web", "app.sdk.security -> app.adapters.web.security", @@ -7929,6 +7953,7 @@ "app.startup.initializers.modules -> app.application.chain.data", "app.startup.initializers.modules -> app.application.chain.events", "app.startup.initializers.modules -> app.application.configuration", + "app.startup.initializers.modules -> app.application.data_query", "app.startup.initializers.modules -> app.application.database", "app.startup.initializers.modules -> app.application.history", "app.startup.initializers.modules -> app.application.image", @@ -7971,6 +7996,7 @@ "app.startup.initializers.modules -> app.db", "app.startup.initializers.modules -> app.db.adapters", "app.startup.initializers.modules -> app.db.adapters.chain", + "app.startup.initializers.modules -> app.db.adapters.data_query", "app.startup.initializers.modules -> app.db.adapters.download", "app.startup.initializers.modules -> app.db.adapters.outbox", "app.startup.initializers.modules -> app.db.adapters.pluginidentity", @@ -8343,7 +8369,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 844, + "module_count": 848, "modules": [ "app", "app.adapters", @@ -8603,6 +8629,7 @@ "app.application.commands", "app.application.configuration", "app.application.dashboard", + "app.application.data_query", "app.application.database", "app.application.directory", "app.application.download", @@ -8735,6 +8762,7 @@ "app.db", "app.db.adapters", "app.db.adapters.chain", + "app.db.adapters.data_query", "app.db.adapters.download", "app.db.adapters.outbox", "app.db.adapters.pluginidentity", @@ -9111,6 +9139,7 @@ "app.schemas.notification", "app.schemas.openai", "app.schemas.plugin", + "app.schemas.query", "app.schemas.response", "app.schemas.rule", "app.schemas.search", @@ -9142,6 +9171,7 @@ "app.sdk.media", "app.sdk.network", "app.sdk.plugins", + "app.sdk.queries", "app.sdk.security", "app.sdk.services", "app.sdk.string", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 14942cc72..fd884c692 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -10174,6 +10174,168 @@ "target": "app.runtime.extensions.plugin_manager.PluginManager" } ], + "app.sdk.queries": [ + { + "kind": "import", + "name": "DEFAULT_QUERY_PAGE_SIZE", + "target": "app.schemas.query.DEFAULT_QUERY_PAGE_SIZE" + }, + { + "kind": "import", + "name": "DownloadHistoryFilter", + "target": "app.schemas.query.DownloadHistoryFilter" + }, + { + "kind": "import", + "name": "DownloadHistorySnapshot", + "target": "app.schemas.query.DownloadHistorySnapshot" + }, + { + "kind": "import", + "name": "MAX_QUERY_PAGE_SIZE", + "target": "app.schemas.query.MAX_QUERY_PAGE_SIZE" + }, + { + "kind": "import", + "name": "MediaIdentityQuery", + "target": "app.schemas.query.MediaIdentityQuery" + }, + { + "kind": "import", + "name": "QueryPage", + "target": "app.schemas.query.QueryPage" + }, + { + "kind": "import", + "name": "QueryPageRequest", + "target": "app.schemas.query.QueryPageRequest" + }, + { + "kind": "import", + "name": "QuerySort", + "target": "app.schemas.query.QuerySort" + }, + { + "kind": "import", + "name": "QuerySortDirection", + "target": "app.schemas.query.QuerySortDirection" + }, + { + "kind": "import", + "name": "QuerySortField", + "target": "app.schemas.query.QuerySortField" + }, + { + "kind": "import", + "name": "SubscriptionFilter", + "target": "app.schemas.query.SubscriptionFilter" + }, + { + "kind": "import", + "name": "SubscriptionHistoryFilter", + "target": "app.schemas.query.SubscriptionHistoryFilter" + }, + { + "kind": "import", + "name": "SubscriptionHistorySnapshot", + "target": "app.schemas.query.SubscriptionHistorySnapshot" + }, + { + "kind": "import", + "name": "SubscriptionSnapshot", + "target": "app.schemas.query.SubscriptionSnapshot" + }, + { + "kind": "import", + "name": "TransferHistoryFilter", + "target": "app.schemas.query.TransferHistoryFilter" + }, + { + "kind": "import", + "name": "TransferHistorySnapshot", + "target": "app.schemas.query.TransferHistorySnapshot" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_get_download_history", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_get_subscription", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_get_subscription_history", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_get_transfer_history", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_list_download_history", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_list_subscription_history", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_list_subscriptions", + "target": "" + }, + { + "kind": "AsyncFunctionDef", + "name": "async_list_transfer_history", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "get_download_history", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "get_subscription", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "get_subscription_history", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "get_transfer_history", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "list_download_history", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "list_subscription_history", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "list_subscriptions", + "target": "" + }, + { + "kind": "FunctionDef", + "name": "list_transfer_history", + "target": "" + } + ], "app.sdk.security": [ { "kind": "import", diff --git a/tests/test_sdk_queries.py b/tests/test_sdk_queries.py index dd51f6757..191e494f8 100644 --- a/tests/test_sdk_queries.py +++ b/tests/test_sdk_queries.py @@ -11,6 +11,7 @@ import asyncio import inspect import threading from collections.abc import Callable +from types import SimpleNamespace from typing import Any import pytest @@ -19,26 +20,23 @@ from app.db.models.downloadhistory import DownloadHistory as DownloadHistoryMode from app.db.models.subscribe import Subscribe as SubscribeModel from app.db.models.subscribehistory import SubscribeHistory as SubscribeHistoryModel from app.db.models.transferhistory import TransferHistory as TransferHistoryModel -from app.schemas.history import ( - DownloadHistory as DownloadHistoryDTO, - TransferHistory as TransferHistoryDTO, -) from app.schemas.query import ( DownloadHistoryFilter, + DownloadHistorySnapshot, QueryPage, QueryPageRequest, QuerySort, QuerySortDirection, QuerySortField, - SubscribeHistory as SubscribeHistoryDTO, SubscriptionFilter, SubscriptionHistoryFilter, + SubscriptionHistorySnapshot, + SubscriptionSnapshot, TransferHistoryFilter, + TransferHistorySnapshot, ) -from app.schemas.subscribe import Subscribe as SubscribeDTO from app.schemas.types import MediaSource, MediaType - TMDB = MediaSource.TMDB.value @@ -95,6 +93,7 @@ def _subscribe( episode_group: str | None = None, date: str = "2026-08-27 10:00:00", music_type: str | None = None, + manual_total_episode: int | None = 0, ) -> SubscribeModel: """构造隔离用例使用的订阅行。""" return SubscribeModel( @@ -108,6 +107,7 @@ def _subscribe( username=username, date=date, music_type=music_type, + manual_total_episode=manual_total_episode, ) @@ -218,13 +218,15 @@ def _assert_projected_page(page: QueryPage, dto_type: type[Any], model_type: typ def test_sdk_projects_subscription_and_three_history_domains_to_dtos(db, query_sdk): """订阅、订阅完成历史、下载历史、整理历史均只返回 Pydantic 投影。""" sdk, _executor = query_sdk - subscribe = db.add(_subscribe("订阅 DTO", media_id="dto-sub")) - subscribe_history = db.add( - _subscribe_history("订阅历史 DTO", media_id="dto-sub-history") - ) - download_history = db.add( - _download_history("下载历史 DTO", media_id="dto-download", path="/dto/download") + subscribe = db.add( + _subscribe( + "订阅 DTO", + media_id="dto-sub", + manual_total_episode=1, + ) ) + subscribe_history = db.add(_subscribe_history("订阅历史 DTO", media_id="dto-sub-history")) + download_history = db.add(_download_history("下载历史 DTO", media_id="dto-download", path="/dto/download")) transfer_history = db.add( _transfer_history( "整理历史 DTO", @@ -235,13 +237,12 @@ def test_sdk_projects_subscription_and_three_history_domains_to_dtos(db, query_s ) _assert_projected_page( - sdk.list_subscriptions( - SubscriptionFilter(media_source=TMDB, media_id="dto-sub") - ), - SubscribeDTO, + subscription_page := sdk.list_subscriptions(SubscriptionFilter(media_source=TMDB, media_id="dto-sub")), + SubscriptionSnapshot, SubscribeModel, subscribe.id, ) + assert subscription_page.items[0].manual_total_episode == 1 _assert_projected_page( sdk.list_subscription_history( SubscriptionHistoryFilter( @@ -249,23 +250,19 @@ def test_sdk_projects_subscription_and_three_history_domains_to_dtos(db, query_s media_id="dto-sub-history", ) ), - SubscribeHistoryDTO, + SubscriptionHistorySnapshot, SubscribeHistoryModel, subscribe_history.id, ) _assert_projected_page( - sdk.list_download_history( - DownloadHistoryFilter(media_source=TMDB, media_id="dto-download") - ), - DownloadHistoryDTO, + sdk.list_download_history(DownloadHistoryFilter(media_source=TMDB, media_id="dto-download")), + DownloadHistorySnapshot, DownloadHistoryModel, download_history.id, ) _assert_projected_page( - sdk.list_transfer_history( - TransferHistoryFilter(media_source=TMDB, media_id="dto-transfer") - ), - TransferHistoryDTO, + sdk.list_transfer_history(TransferHistoryFilter(media_source=TMDB, media_id="dto-transfer")), + TransferHistorySnapshot, TransferHistoryModel, transfer_history.id, ) @@ -422,11 +419,12 @@ def test_sdk_applies_combined_filters_in_each_query_domain(db, query_sdk): media_source=TMDB, media_id="combo-download", media_types=(MediaType.TV,), - title="Combo", + title="Combo Film", + text="match", year="2026", seasons="S02", episodes="E03", - path="match", + path="/combo/match.mkv", download_hash="combo-hash", username="alice", episode_group="eg-a", @@ -475,7 +473,7 @@ def test_sdk_applies_combined_filters_in_each_query_domain(db, query_sdk): media_types=(MediaType.TV,), media_sources=(MediaSource.TMDB,), require_media_identity=True, - title="Transfer", + title="Transfer Combo", text="dest-match", year="2026", seasons="S02", @@ -533,9 +531,7 @@ def test_sdk_pagination_reports_total_and_stable_date_id_order(db, query_sdk): assert page_one.page == 1 assert page_one.count == 2 assert page_one.has_next is True - assert [item.id for item in page_one.items] == [ - row.id for row in expected_desc[:2] - ] + assert [item.id for item in page_one.items] == [row.id for row in expected_desc[:2]] assert page_two.total == 3 assert page_two.has_next is False assert [item.id for item in page_two.items] == [expected_desc[2].id] @@ -552,9 +548,98 @@ def test_sdk_pagination_reports_total_and_stable_date_id_order(db, query_sdk): ), ) expected_asc = sorted(rows, key=lambda row: (row.date, row.id)) - assert [item.id for item in asc_page.items] == [ - row.id for row in expected_asc - ] + assert [item.id for item in asc_page.items] == [row.id for row in expected_asc] + + +def test_structured_text_fields_are_exact_and_text_search_is_explicit(db, query_sdk): + """结构化字段保持精确匹配,只有 text 承担转义后的模糊搜索。""" + sdk, _executor = query_sdk + db.add( + _download_history( + "Exact Film Extended", + media_id="exact-download", + path="/exact/extended.mkv", + ), + _download_history( + "Exact Film", + media_id="exact-download", + path="/exact/base.mkv", + ), + _transfer_history( + "Exact Transfer Extended", + media_id="exact-transfer", + src="/exact/extended-src.mkv", + dest="/exact/extended-dest.mkv", + ), + _transfer_history( + "Exact Transfer", + media_id="exact-transfer", + src="/exact/base-src.mkv", + dest="/exact/base-dest.mkv", + ), + ) + + exact_downloads = sdk.list_download_history( + DownloadHistoryFilter( + media_source=TMDB, + media_id="exact-download", + title="Exact Film", + ) + ) + assert [item.path for item in exact_downloads.items] == ["/exact/base.mkv"] + fuzzy_downloads = sdk.list_download_history( + DownloadHistoryFilter( + media_source=TMDB, + media_id="exact-download", + text="extended", + ) + ) + assert [item.path for item in fuzzy_downloads.items] == ["/exact/extended.mkv"] + + exact_transfers = sdk.list_transfer_history( + TransferHistoryFilter( + media_source=TMDB, + media_id="exact-transfer", + title="Exact Transfer", + ) + ) + assert [item.src for item in exact_transfers.items] == ["/exact/base-src.mkv"] + fuzzy_transfers = sdk.list_transfer_history( + TransferHistoryFilter( + media_source=TMDB, + media_id="exact-transfer", + text="extended-dest", + ) + ) + assert [item.src for item in fuzzy_transfers.items] == ["/exact/extended-src.mkv"] + + +def test_snapshots_normalize_legacy_identity_and_transfer_status(): + """旧半对身份和 NULL 整理状态不得使分页投影失败或产生假成功。""" + dirty_transfer = SimpleNamespace( + id=1, + media_source=TMDB, + media_id=" ", + status=None, + ) + + snapshot = TransferHistorySnapshot.model_validate(dirty_transfer) + + assert snapshot.media_source is None + assert snapshot.media_id is None + assert snapshot.status is False + + +def test_query_snapshots_are_owned_by_the_sdk_contract_module(): + """公开查询返回值由独立快照定义,不复用宿主写入或 API 响应模型。""" + snapshots = ( + SubscriptionSnapshot, + SubscriptionHistorySnapshot, + DownloadHistorySnapshot, + TransferHistorySnapshot, + ) + + assert all(snapshot.__module__ == "app.schemas.query" for snapshot in snapshots) @pytest.mark.parametrize( @@ -590,9 +675,7 @@ def test_sdk_get_returns_none_for_missing_records(query_sdk): def test_sdk_sync_async_semantics_match_and_async_uses_executor(db, query_sdk): """四个查询门面的异步结果与同步一致,并交给 executor 线程。""" sdk, executor = query_sdk - subscribe = db.add( - _subscribe("订阅同步异步", media_id="async-subscription") - ) + subscribe = db.add(_subscribe("订阅同步异步", media_id="async-subscription")) subscribe_history = db.add( _subscribe_history( "订阅历史同步异步", From 4476cb6c7934f845ee21757da9de8460c954e5f5 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 27 Aug 2026 23:23:39 +0800 Subject: [PATCH 3/8] chore(architecture): sync query contract baselines --- app/schemas/exports.py | 1 + docs/architecture-optimization-checklist.md | 2 +- docs/architecture-overview.md | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 90d80157d..b9bc7901b 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -301,6 +301,7 @@ SCHEMA_EXPORTS = { 'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'), 'PluginUpdateCandidate': ('app.schemas.plugin', 'PluginUpdateCandidate'), 'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'), + 'PrivateAttr': ('app.schemas.plugin', 'PrivateAttr'), 'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'), 'ProgressKeyData': ('app.schemas.common', 'ProgressKeyData'), 'RadarrMovie': ('app.schemas.servarr', 'RadarrMovie'), diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index edce89cbe..1f43ae4bf 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 844 / 6,898 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 848 / 6,924 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 3a9265edd..11408e4d2 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -697,15 +697,15 @@ flowchart LR SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。 - 延迟导入不被接受为隐藏循环依赖的手段。 -### 10.1 2026-08-26 当前收口状态与后续边界 +### 10.1 2026-08-27 当前收口状态与后续边界 当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自 `tests/fixtures/architecture/`,更新基线前必须先审查语义变化: | 指标 | 当前值 | |---|---:| -| Python 模块 | 844 | -| 内部导入边 | 6,898 | +| Python 模块 | 848 | +| 内部导入边 | 6,924 | | 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) | | Direct egress | 66(12 条待迁移债务,54 条精确 containment) | | Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) | From d1d95dded7038586703428527d340d51e5412eab Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 27 Aug 2026 23:41:38 +0800 Subject: [PATCH 4/8] chore(architecture): sync ruff baseline metrics --- docs/architecture-optimization-checklist.md | 2 +- docs/architecture-refactor-roadmap.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 1f43ae4bf..dc87fd4e0 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 | | 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 | | 全量 mypy 历史债务 | 11,827 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 | -| Ruff 历史诊断 | 889 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| Ruff 历史诊断 | 888 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 78.63%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index dbef163da..db7e58947 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -144,7 +144,7 @@ G-ARCH 只有在以下条件全部满足后才可完成: | S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 | | S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 | | S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 | -| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 889 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | +| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 888 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | | S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test | ### S5:Plugin、Agent、Domain、Startup 与最终收口 From 9575a523e8150a4019510864d897516131c7ea82 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Fri, 28 Aug 2026 00:11:59 +0800 Subject: [PATCH 5/8] refactor(sdk): isolate plugin query persistence --- app/application/{data_query.py => query.py} | 0 app/db/adapters/data_query.py | 374 ------------------ app/db/adapters/query.py | 140 +++++++ app/db/oper/downloadhistory.py | 108 ++++- app/db/oper/query.py | 113 ++++++ app/db/oper/subscribe.py | 83 +++- app/db/oper/subscribehistory.py | 98 ++++- app/db/oper/transferhistory.py | 122 +++++- app/schemas/query.py | 55 ++- app/sdk/queries.py | 2 +- app/startup/initializers/modules.py | 10 +- docs/architecture-optimization-checklist.md | 4 +- docs/architecture-overview.md | 4 +- docs/architecture-refactor-roadmap.md | 2 +- .../architecture/dependency-baseline.json | 65 +-- .../fixtures/architecture/ruff-baseline.json | 9 - tests/test_agent_lifecycle.py | 14 +- tests/test_sdk_queries.py | 69 +++- 18 files changed, 825 insertions(+), 447 deletions(-) rename app/application/{data_query.py => query.py} (100%) delete mode 100644 app/db/adapters/data_query.py create mode 100644 app/db/adapters/query.py create mode 100644 app/db/oper/query.py diff --git a/app/application/data_query.py b/app/application/query.py similarity index 100% rename from app/application/data_query.py rename to app/application/query.py diff --git a/app/db/adapters/data_query.py b/app/db/adapters/data_query.py deleted file mode 100644 index 7d2d43cc3..000000000 --- a/app/db/adapters/data_query.py +++ /dev/null @@ -1,374 +0,0 @@ -"""插件只读数据查询的 SQLAlchemy 持久化适配器。""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable -from enum import Enum -from typing import Any, TypeVar - -from pydantic import BaseModel -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.application.data_query import QueryRows -from app.db.base import Base -from app.db.models.downloadhistory import DownloadHistory -from app.db.models.subscribe import Subscribe -from app.db.models.subscribehistory import SubscribeHistory as SubscribeHistoryModel -from app.db.models.transferhistory import TransferHistory -from app.schemas.query import ( - DownloadHistoryFilter, - DownloadHistorySnapshot, - QueryPageRequest, - QuerySortDirection, - QuerySortField, - SubscriptionFilter, - SubscriptionHistoryFilter, - SubscriptionHistorySnapshot, - SubscriptionSnapshot, - TransferHistoryFilter, - TransferHistorySnapshot, -) -from app.schemas.types import MUSIC_ENTITY_RECORDING - -_ModelT = TypeVar("_ModelT", bound=Base) -_ViewT = TypeVar("_ViewT", bound=BaseModel) - - -def _enum_value(value: Any) -> Any: - """返回枚举筛选值的稳定数据库表示。""" - return value.value if isinstance(value, Enum) else value - - -def _values(values: Iterable[Any]) -> tuple[Any, ...]: - """去除空筛选值并保留调用方声明的顺序。""" - normalized: list[Any] = [] - for value in values: - value = _enum_value(value) - if value in (None, ""): - continue - normalized.append(value) - return tuple(dict.fromkeys(normalized)) - - -def _contains(column: Any, value: str) -> Any: - """构造不区分大小写且不解释通配符的字面包含筛选。""" - escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - return column.ilike(f"%{escaped}%", escape="\\") - - -def _music_type_condition(column: Any, music_type: str | None) -> Any | None: - """兼容未标注音乐类型的历史单曲记录。""" - if not music_type: - return None - if music_type == MUSIC_ENTITY_RECORDING: - return or_(column == music_type, column.is_(None)) - return column == music_type - - -class SqlAlchemyDataQueryAdapter: - """以短生命周期同步 Session 执行统一只读分页查询。 - - 这个适配器是查询层唯一接触 SQLAlchemy Model 的边界。每个公开方法在同一 - Session 中先统计再读取当前页,并在 Session 仍有效时转换成 Pydantic DTO, - 因而调用方不会持有 ORM 实例或延迟加载状态。 - """ - - def __init__(self, session_factory: Callable[[], Session]) -> None: - """保存由启动组合根提供的同步 Session 工厂。""" - self._session_factory = session_factory - - @staticmethod - def _identity_conditions(model: Any, query: Any) -> list[Any]: - """按媒体来源与原生 ID 的成对合同构造筛选条件。""" - media_source = query.media_source - media_id = query.media_id - if (media_source is None) != (media_id is None): - # Pydantic 合同会先拒绝这种输入;这里仍保留拒绝,避免未校验对象 - # 在持久化边界退化成只按 NULL 查询而扩大结果集。 - raise ValueError("media_source 和 media_id 必须同时提供") - if media_source is None: - return [] - normalized_id = str(media_id).strip() - if not normalized_id or normalized_id == "0": - raise ValueError("media_id 必须是非零的来源原生 ID") - return [ - model.media_source == _enum_value(media_source), - model.media_id == normalized_id, - ] - - @staticmethod - def _require_media_identity(model: Any) -> list[Any]: - """只保留来源和原生 ID 均存在且非空的记录。""" - return [ - model.media_source.is_not(None), - func.trim(model.media_source) != "", - model.media_id.is_not(None), - func.trim(model.media_id) != "", - func.trim(model.media_id) != "0", - ] - - @staticmethod - def _order_by(model: Any, request: QueryPageRequest) -> tuple[Any, ...]: - """构造可跨页复现的排序,日期相同时始终以主键打破平局。""" - descending = request.sort.direction == QuerySortDirection.DESC - if request.sort.field == QuerySortField.ID: - primary = model.id.desc() if descending else model.id.asc() - secondary = model.date.desc() if descending else model.date.asc() - return primary, secondary - primary = model.date.desc().nullslast() if descending else model.date.asc().nullsfirst() - secondary = model.id.desc() if descending else model.id.asc() - return primary, secondary - - def _page( - self, - *, - model: type[_ModelT], - view_model: type[_ViewT], - conditions: Iterable[Any], - page: QueryPageRequest, - ) -> QueryRows[_ViewT]: - """在单个 Session 内完成 count、分页读取和 DTO 投影。""" - conditions = tuple(conditions) - count_statement = select(func.count(model.id)) - page_statement = select(model) - if conditions: - count_statement = count_statement.where(*conditions) - page_statement = page_statement.where(*conditions) - page_statement = ( - page_statement.order_by(*self._order_by(model, page)).offset((page.page - 1) * page.count).limit(page.count) - ) - - with self._session_factory() as session: - total = int(session.execute(count_statement).scalar_one() or 0) - records = session.execute(page_statement).scalars().all() - # model_validate 必须在会话内完成;返回值只包含 Pydantic 数据。 - items = [view_model.model_validate(record) for record in records] - return QueryRows(items=items, total=total) - - def _get( - self, - *, - model: type[_ModelT], - view_model: type[_ViewT], - record_id: int, - ) -> _ViewT | None: - """在短 Session 内按主键读取并冻结单条 Pydantic 投影。""" - statement = select(model).where(model.id == record_id) - with self._session_factory() as session: - record = session.execute(statement).scalars().first() - return view_model.model_validate(record) if record is not None else None - - def list_subscriptions( - self, - *, - filters: SubscriptionFilter, - page: QueryPageRequest, - ) -> QueryRows[SubscriptionSnapshot]: - """按受控组合条件分页查询当前订阅。""" - query = filters - conditions = self._identity_conditions(Subscribe, query) - ids = _values(query.ids) - names = _values(query.names) - states = _values(query.states) - usernames = _values(query.usernames) - media_types = _values(query.media_types) - if ids: - conditions.append(Subscribe.id.in_(ids)) - if names: - conditions.append(Subscribe.name.in_(names)) - if states: - conditions.append(Subscribe.state.in_(states)) - if usernames: - conditions.append(Subscribe.username.in_(usernames)) - if media_types: - conditions.append(Subscribe.type.in_(media_types)) - if query.season is not None: - conditions.append(Subscribe.season == query.season) - if query.episode_group is not None: - conditions.append(Subscribe.episode_group == query.episode_group) - music_condition = _music_type_condition(Subscribe.music_type, query.music_type) - if music_condition is not None: - conditions.append(music_condition) - return self._page( - model=Subscribe, - view_model=SubscriptionSnapshot, - conditions=conditions, - page=page, - ) - - def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None: - """按主键查询订阅并返回脱离 Session 的 DTO。""" - return self._get( - model=Subscribe, - view_model=SubscriptionSnapshot, - record_id=subscription_id, - ) - - def list_subscription_history( - self, - *, - filters: SubscriptionHistoryFilter, - page: QueryPageRequest, - ) -> QueryRows[SubscriptionHistorySnapshot]: - """按受控组合条件分页查询订阅完成历史。""" - query = filters - conditions = self._identity_conditions(SubscribeHistoryModel, query) - ids = _values(query.ids) - names = _values(query.names) - usernames = _values(query.usernames) - media_types = _values(query.media_types) - if ids: - conditions.append(SubscribeHistoryModel.id.in_(ids)) - if names: - conditions.append(SubscribeHistoryModel.name.in_(names)) - if usernames: - conditions.append(SubscribeHistoryModel.username.in_(usernames)) - if media_types: - conditions.append(SubscribeHistoryModel.type.in_(media_types)) - if query.season is not None: - conditions.append(SubscribeHistoryModel.season == query.season) - if query.episode_group is not None: - conditions.append(SubscribeHistoryModel.episode_group == query.episode_group) - music_condition = _music_type_condition( - SubscribeHistoryModel.music_type, - query.music_type, - ) - if music_condition is not None: - conditions.append(music_condition) - return self._page( - model=SubscribeHistoryModel, - view_model=SubscriptionHistorySnapshot, - conditions=conditions, - page=page, - ) - - def get_subscription_history( - self, - history_id: int, - ) -> SubscriptionHistorySnapshot | None: - """按主键查询订阅完成历史并返回稳定 DTO。""" - return self._get( - model=SubscribeHistoryModel, - view_model=SubscriptionHistorySnapshot, - record_id=history_id, - ) - - def list_download_history( - self, - *, - filters: DownloadHistoryFilter, - page: QueryPageRequest, - ) -> QueryRows[DownloadHistorySnapshot]: - """按受控组合条件分页查询下载历史。""" - query = filters - conditions = self._identity_conditions(DownloadHistory, query) - ids = _values(query.ids) - media_types = _values(query.media_types) - usernames = _values(query.usernames) - if ids: - conditions.append(DownloadHistory.id.in_(ids)) - if media_types: - conditions.append(DownloadHistory.type.in_(media_types)) - for column, value in ( - (DownloadHistory.title, query.title), - (DownloadHistory.year, query.year), - (DownloadHistory.seasons, query.seasons), - (DownloadHistory.episodes, query.episodes), - (DownloadHistory.path, query.path), - (DownloadHistory.download_hash, query.download_hash), - (DownloadHistory.username, query.username), - (DownloadHistory.episode_group, query.episode_group), - ): - if value is not None and value != "": - conditions.append(column == value) - if query.text: - conditions.append( - or_( - _contains(DownloadHistory.title, query.text), - _contains(DownloadHistory.path, query.text), - ) - ) - if usernames: - conditions.append(DownloadHistory.username.in_(usernames)) - music_condition = _music_type_condition(DownloadHistory.music_type, query.music_type) - if music_condition is not None: - conditions.append(music_condition) - return self._page( - model=DownloadHistory, - view_model=DownloadHistorySnapshot, - conditions=conditions, - page=page, - ) - - def get_download_history(self, history_id: int) -> DownloadHistorySnapshot | None: - """按主键查询下载历史并返回稳定 DTO。""" - return self._get( - model=DownloadHistory, - view_model=DownloadHistorySnapshot, - record_id=history_id, - ) - - def list_transfer_history( - self, - *, - filters: TransferHistoryFilter, - page: QueryPageRequest, - ) -> QueryRows[TransferHistorySnapshot]: - """按受控组合条件分页查询整理历史。""" - query = filters - conditions = self._identity_conditions(TransferHistory, query) - ids = _values(query.ids) - media_types = _values(query.media_types) - media_sources = _values(query.media_sources) - if ids: - conditions.append(TransferHistory.id.in_(ids)) - if media_types: - conditions.append(TransferHistory.type.in_(media_types)) - if media_sources: - conditions.append(TransferHistory.media_source.in_(media_sources)) - if query.require_media_identity: - conditions.extend(self._require_media_identity(TransferHistory)) - if query.title: - conditions.append(TransferHistory.title == query.title) - if query.text: - conditions.append( - or_( - _contains(TransferHistory.title, query.text), - _contains(TransferHistory.src, query.text), - _contains(TransferHistory.dest, query.text), - ) - ) - for column, value in ( - (TransferHistory.year, query.year), - (TransferHistory.seasons, query.seasons), - (TransferHistory.episodes, query.episodes), - (TransferHistory.src, query.src), - (TransferHistory.dest, query.dest), - (TransferHistory.download_hash, query.download_hash), - (TransferHistory.episode_group, query.episode_group), - ): - if value is not None and value != "": - conditions.append(column == value) - if query.status is not None: - conditions.append(TransferHistory.status == query.status) - music_condition = _music_type_condition(TransferHistory.music_type, query.music_type) - if music_condition is not None: - conditions.append(music_condition) - return self._page( - model=TransferHistory, - view_model=TransferHistorySnapshot, - conditions=conditions, - page=page, - ) - - def get_transfer_history(self, history_id: int) -> TransferHistorySnapshot | None: - """按主键查询整理历史并返回稳定 DTO。""" - return self._get( - model=TransferHistory, - view_model=TransferHistorySnapshot, - record_id=history_id, - ) - - -__all__ = ["SqlAlchemyDataQueryAdapter"] diff --git a/app/db/adapters/query.py b/app/db/adapters/query.py new file mode 100644 index 000000000..e999e4cd0 --- /dev/null +++ b/app/db/adapters/query.py @@ -0,0 +1,140 @@ +"""插件只读数据查询的持久化端口适配器。""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import TypeVar + +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.application.query import QueryRows +from app.db.oper.downloadhistory import DownloadHistoryOper +from app.db.oper.subscribe import SubscribeOper +from app.db.oper.subscribehistory import SubscribeHistoryOper +from app.db.oper.transferhistory import TransferHistoryOper +from app.schemas.query import ( + DownloadHistoryFilter, + DownloadHistorySnapshot, + QueryPageRequest, + SubscriptionFilter, + SubscriptionHistoryFilter, + SubscriptionHistorySnapshot, + SubscriptionSnapshot, + TransferHistoryFilter, + TransferHistorySnapshot, +) + +_SnapshotT = TypeVar("_SnapshotT", bound=BaseModel) + + +class SqlAlchemyDataQueryAdapter: + """以短生命周期 Session 和显式 Oper 实现统一查询端口。 + + Oper 拥有表级筛选、排序和分页语义;适配器只管理一次操作使用的 Session, + 并在 Session 关闭前把持久化记录冻结为稳定 Pydantic 快照。 + """ + + def __init__(self, session_factory: Callable[[], Session]) -> None: + """保存由启动组合根提供的同步 Session 工厂。""" + self._session_factory = session_factory + + @staticmethod + def _rows( + snapshot_type: type[_SnapshotT], + records: Sequence[object], + total: int, + ) -> QueryRows[_SnapshotT]: + """在会话有效期内投影一页记录,并保护非负总数合同。""" + return QueryRows( + items=[snapshot_type.model_validate(record) for record in records], + total=max(int(total), 0), + ) + + @staticmethod + def _item( + snapshot_type: type[_SnapshotT], + record: object | None, + ) -> _SnapshotT | None: + """把单条持久化记录投影为稳定快照。""" + return snapshot_type.model_validate(record) if record is not None else None + + def list_subscriptions( + self, + *, + filters: SubscriptionFilter, + page: QueryPageRequest, + ) -> QueryRows[SubscriptionSnapshot]: + """通过订阅 Oper 读取并冻结一页当前订阅。""" + with self._session_factory() as session: + records, total = SubscribeOper(session).query(filters, page) + return self._rows(SubscriptionSnapshot, records, total) + + def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None: + """通过订阅 Oper 按 ID 读取并冻结当前订阅。""" + with self._session_factory() as session: + record = SubscribeOper(session).get_by_id(subscription_id) + return self._item(SubscriptionSnapshot, record) + + def list_subscription_history( + self, + *, + filters: SubscriptionHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[SubscriptionHistorySnapshot]: + """通过订阅历史 Oper 读取并冻结一页完成历史。""" + with self._session_factory() as session: + records, total = SubscribeHistoryOper(session).query(filters, page) + return self._rows(SubscriptionHistorySnapshot, records, total) + + def get_subscription_history( + self, + history_id: int, + ) -> SubscriptionHistorySnapshot | None: + """通过订阅历史 Oper 按 ID 读取并冻结完成历史。""" + with self._session_factory() as session: + record = SubscribeHistoryOper(session).get_by_id(history_id) + return self._item(SubscriptionHistorySnapshot, record) + + def list_download_history( + self, + *, + filters: DownloadHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[DownloadHistorySnapshot]: + """通过下载历史 Oper 读取并冻结一页下载记录。""" + with self._session_factory() as session: + records, total = DownloadHistoryOper(session).query(filters, page) + return self._rows(DownloadHistorySnapshot, records, total) + + def get_download_history( + self, + history_id: int, + ) -> DownloadHistorySnapshot | None: + """通过下载历史 Oper 按 ID 读取并冻结下载记录。""" + with self._session_factory() as session: + record = DownloadHistoryOper(session).get_by_id(history_id) + return self._item(DownloadHistorySnapshot, record) + + def list_transfer_history( + self, + *, + filters: TransferHistoryFilter, + page: QueryPageRequest, + ) -> QueryRows[TransferHistorySnapshot]: + """通过整理历史 Oper 读取并冻结一页整理记录。""" + with self._session_factory() as session: + records, total = TransferHistoryOper(session).query(filters, page) + return self._rows(TransferHistorySnapshot, records, total) + + def get_transfer_history( + self, + history_id: int, + ) -> TransferHistorySnapshot | None: + """通过整理历史 Oper 按 ID 读取并冻结整理记录。""" + with self._session_factory() as session: + record = TransferHistoryOper(session).get_by_id(history_id) + return self._item(TransferHistorySnapshot, record) + + +__all__ = ["SqlAlchemyDataQueryAdapter"] diff --git a/app/db/oper/downloadhistory.py b/app/db/oper/downloadhistory.py index 9c173fa6d..5edaba329 100644 --- a/app/db/oper/downloadhistory.py +++ b/app/db/oper/downloadhistory.py @@ -1,10 +1,25 @@ from typing import Dict, List, Optional, cast -from sqlalchemy import delete as sqlalchemy_delete, update as sqlalchemy_update +from sqlalchemy import delete as sqlalchemy_delete +from sqlalchemy import func, select +from sqlalchemy import update as sqlalchemy_update from sqlalchemy.orm import Session from app.db.base import DbOper -from app.db.models.downloadhistory import DownloadHistory, DownloadFiles +from app.db.models.downloadhistory import DownloadFiles, DownloadHistory +from app.db.oper.query import ( + descending, + enum_values, + execute_page, + literal_contains, + media_identity_conditions, + music_type_condition, +) +from app.schemas.query import ( + DownloadHistoryFilter, + QueryPageRequest, + QuerySortField, +) from app.schemas.types import MediaSource @@ -13,6 +28,95 @@ class DownloadHistoryOper(DbOper): 下载历史管理 """ + def get_by_id(self, record_id: int) -> Optional[DownloadHistory]: + """按稳定记录 ID 读取单条下载历史。""" + return cast( + Optional[DownloadHistory], + self._execute_sync_query( + lambda session: session.execute( + select(DownloadHistory).where(DownloadHistory.id == record_id) + ).scalars().first() + ), + ) + + def query( + self, + filters: DownloadHistoryFilter, + page: QueryPageRequest, + ) -> tuple[list[DownloadHistory], int]: + """按稳定筛选和分页合同读取下载历史记录及总数。""" + def execute(session: Session) -> tuple[list[DownloadHistory], int]: + """在同一会话中构造并执行下载历史 count/page 查询。""" + conditions = media_identity_conditions(DownloadHistory, filters) + ids = enum_values(filters.ids) + media_types = enum_values(filters.media_types) + usernames = enum_values(filters.usernames) + if ids: + conditions.append(DownloadHistory.id.in_(ids)) + if media_types: + conditions.append(DownloadHistory.type.in_(media_types)) + for column, value in ( + (DownloadHistory.title, filters.title), + (DownloadHistory.year, filters.year), + (DownloadHistory.seasons, filters.seasons), + (DownloadHistory.episodes, filters.episodes), + (DownloadHistory.path, filters.path), + (DownloadHistory.download_hash, filters.download_hash), + (DownloadHistory.username, filters.username), + (DownloadHistory.episode_group, filters.episode_group), + ): + if value is not None and value != "": + conditions.append(column == value) + if filters.text: + conditions.append( + literal_contains(DownloadHistory.title, filters.text) + | literal_contains(DownloadHistory.path, filters.text) + ) + if usernames: + conditions.append(DownloadHistory.username.in_(usernames)) + music_condition = music_type_condition( + DownloadHistory.music_type, + filters.music_type, + ) + if music_condition is not None: + conditions.append(music_condition) + + count_statement = select(func.count(DownloadHistory.id)) + page_statement = select(DownloadHistory) + if conditions: + count_statement = count_statement.where(*conditions) + page_statement = page_statement.where(*conditions) + descending_order = descending(page) + if page.sort.field == QuerySortField.ID: + primary = ( + DownloadHistory.id.desc() + if descending_order + else DownloadHistory.id.asc() + ) + secondary = ( + DownloadHistory.date.desc() + if descending_order + else DownloadHistory.date.asc() + ) + else: + primary = ( + DownloadHistory.date.desc().nullslast() + if descending_order + else DownloadHistory.date.asc().nullsfirst() + ) + secondary = ( + DownloadHistory.id.desc() + if descending_order + else DownloadHistory.id.asc() + ) + page_statement = page_statement.order_by(primary, secondary) + return cast( + tuple[list[DownloadHistory], int], + execute_page(session, count_statement, page_statement, page), + ) + + return self._execute_sync_query(execute) + def get_by_path(self, path: str) -> Optional[DownloadHistory]: """ 按路径查询下载记录 diff --git a/app/db/oper/query.py b/app/db/oper/query.py new file mode 100644 index 000000000..49f4fc112 --- /dev/null +++ b/app/db/oper/query.py @@ -0,0 +1,113 @@ +"""只读查询 Oper 共享的持久化原语。 + +本模块只承载跨表完全相同的查询表示规则和分页执行步骤。每个具体 Oper 仍负责 +声明本表模型、筛选字段、SQLAlchemy 条件、count/page 语句以及排序,避免这里退化 +成任意表的 Repository。 +""" + +from collections.abc import Iterable +from enum import Enum +from typing import Any, TypeVar + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.schemas.query import QueryPageRequest, QuerySortDirection +from app.schemas.types import MEDIA_SOURCE_IDENTIFIER_PATTERN, MUSIC_ENTITY_RECORDING + +ModelT = TypeVar("ModelT", bound=Base) + + +def enum_value(value: Any) -> Any: + """返回筛选枚举对应的数据库值。""" + return value.value if isinstance(value, Enum) else value + + +def enum_values(values: Iterable[Any]) -> tuple[Any, ...]: + """去除空筛选值、归一枚举,并保留调用方顺序。""" + normalized: list[Any] = [] + for value in values: + value = enum_value(value) + if value in (None, ""): + continue + normalized.append(value) + return tuple(dict.fromkeys(normalized)) + + +def literal_contains(column: Any, value: str) -> Any: + """构造不区分大小写且不解释 ``%``、``_`` 通配符的字面包含条件。""" + escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return column.ilike(f"%{escaped}%", escape="\\") + + +def media_identity_conditions(model: Any, query: Any) -> list[Any]: + """按媒体来源与原生 ID 的成对合同构造条件,并对非法身份 fail-closed。""" + media_source = query.media_source + media_id = query.media_id + if (media_source is None) != (media_id is None): + raise ValueError("media_source 和 media_id 必须同时提供") + if media_source is None: + return [] + normalized_id = str(media_id).strip() + if not normalized_id or normalized_id == "0": + raise ValueError("media_id 必须是非零的来源原生 ID") + return [ + model.media_source == enum_value(media_source), + model.media_id == normalized_id, + ] + + +def required_media_identity_conditions(model: Any) -> list[Any]: + """构造只保留可解析来源与非空、非零原生 ID 的条件。""" + return [ + model.media_source.is_not(None), + func.trim(model.media_source) != "", + func.lower(func.trim(model.media_source)).regexp_match( + MEDIA_SOURCE_IDENTIFIER_PATTERN + ), + model.media_id.is_not(None), + func.trim(model.media_id) != "", + func.trim(model.media_id) != "0", + ] + + +def music_type_condition(column: Any, music_type: str | None) -> Any | None: + """兼容未标注音乐类型的历史单曲记录。""" + music_type = enum_value(music_type) + if not music_type: + return None + if music_type == MUSIC_ENTITY_RECORDING: + return or_(column == music_type, column.is_(None)) + return column == music_type + + +def execute_page( + session: Session, + count_statement: Any, + page_statement: Any, + page: QueryPageRequest, +) -> tuple[list[ModelT], int]: + """在同一同步 Session 内先统计再读取一页已构造的查询语句。""" + total = int(session.execute(count_statement).scalar_one() or 0) + records = list( + session.execute(page_statement.offset((page.page - 1) * page.count).limit(page.count)).scalars().all() + ) + return records, total + + +def descending(page: QueryPageRequest) -> bool: + """返回公开分页排序是否为降序,集中处理枚举表示。""" + return page.sort.direction == QuerySortDirection.DESC + + +__all__ = [ + "descending", + "enum_value", + "enum_values", + "execute_page", + "literal_contains", + "media_identity_conditions", + "music_type_condition", + "required_media_identity_conditions", +] diff --git a/app/db/oper/subscribe.py b/app/db/oper/subscribe.py index b1e18717a..8d5468723 100644 --- a/app/db/oper/subscribe.py +++ b/app/db/oper/subscribe.py @@ -9,9 +9,10 @@ """ import time from collections.abc import Awaitable, Callable -from typing import Any, Tuple, List, Optional +from typing import Any, List, Optional, Tuple, cast from sqlalchemy import delete as sqlalchemy_delete +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -19,6 +20,14 @@ from app.application.subscription.delete import SubscribeDeletionCandidate from app.db.base import DbOper from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory +from app.db.oper.query import ( + descending, + enum_values, + execute_page, + media_identity_conditions, + music_type_condition, +) +from app.schemas.query import QueryPageRequest, QuerySortField, SubscriptionFilter from app.schemas.types import MediaSource INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode") @@ -276,7 +285,77 @@ class SubscribeOper(DbOper): """ 获取订阅 """ - return self._execute_sync_query(lambda session: Subscribe.get(session, sid)) + return self.get_by_id(sid) + + def get_by_id(self, record_id: int) -> Optional[Subscribe]: + """按稳定记录 ID 读取单条订阅。""" + return cast( + Optional[Subscribe], + self._execute_sync_query( + lambda session: session.execute( + select(Subscribe).where(Subscribe.id == record_id) + ).scalars().first() + ), + ) + + def query( + self, + filters: SubscriptionFilter, + page: QueryPageRequest, + ) -> tuple[list[Subscribe], int]: + """按稳定筛选和分页合同读取订阅记录及总数。""" + def execute(session: Session) -> tuple[list[Subscribe], int]: + """在同一会话中构造并执行订阅 count/page 查询。""" + conditions = media_identity_conditions(Subscribe, filters) + ids = enum_values(filters.ids) + names = enum_values(filters.names) + states = enum_values(filters.states) + usernames = enum_values(filters.usernames) + media_types = enum_values(filters.media_types) + if ids: + conditions.append(Subscribe.id.in_(ids)) + if names: + conditions.append(Subscribe.name.in_(names)) + if states: + conditions.append(Subscribe.state.in_(states)) + if usernames: + conditions.append(Subscribe.username.in_(usernames)) + if media_types: + conditions.append(Subscribe.type.in_(media_types)) + if filters.season is not None: + conditions.append(Subscribe.season == filters.season) + if filters.episode_group is not None: + conditions.append(Subscribe.episode_group == filters.episode_group) + music_condition = music_type_condition( + Subscribe.music_type, + filters.music_type, + ) + if music_condition is not None: + conditions.append(music_condition) + + count_statement = select(func.count(Subscribe.id)) + page_statement = select(Subscribe) + if conditions: + count_statement = count_statement.where(*conditions) + page_statement = page_statement.where(*conditions) + descending_order = descending(page) + if page.sort.field == QuerySortField.ID: + primary = Subscribe.id.desc() if descending_order else Subscribe.id.asc() + secondary = Subscribe.date.desc() if descending_order else Subscribe.date.asc() + else: + primary = ( + Subscribe.date.desc().nullslast() + if descending_order + else Subscribe.date.asc().nullsfirst() + ) + secondary = Subscribe.id.desc() if descending_order else Subscribe.id.asc() + page_statement = page_statement.order_by(primary, secondary) + return cast( + tuple[list[Subscribe], int], + execute_page(session, count_statement, page_statement, page), + ) + + return self._execute_sync_query(execute) async def async_get(self, sid: int) -> Optional[Subscribe]: """ diff --git a/app/db/oper/subscribehistory.py b/app/db/oper/subscribehistory.py index 35d8d0421..869af34f9 100644 --- a/app/db/oper/subscribehistory.py +++ b/app/db/oper/subscribehistory.py @@ -1,10 +1,23 @@ -from typing import List, Optional +from typing import List, Optional, cast -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session from app.db.base import DbOper from app.db.models.subscribehistory import SubscribeHistory +from app.db.oper.query import ( + descending, + enum_values, + execute_page, + media_identity_conditions, + music_type_condition, +) +from app.schemas.query import ( + QueryPageRequest, + QuerySortField, + SubscriptionHistoryFilter, +) class SubscribeHistoryOper(DbOper): @@ -12,6 +25,87 @@ class SubscribeHistoryOper(DbOper): 订阅历史管理。 """ + def get_by_id(self, record_id: int) -> Optional[SubscribeHistory]: + """按稳定记录 ID 读取单条订阅历史。""" + return cast( + Optional[SubscribeHistory], + self._execute_sync_query( + lambda session: session.execute( + select(SubscribeHistory).where(SubscribeHistory.id == record_id) + ).scalars().first() + ), + ) + + def query( + self, + filters: SubscriptionHistoryFilter, + page: QueryPageRequest, + ) -> tuple[list[SubscribeHistory], int]: + """按稳定筛选和分页合同读取订阅历史记录及总数。""" + def execute(session: Session) -> tuple[list[SubscribeHistory], int]: + """在同一会话中构造并执行订阅历史 count/page 查询。""" + conditions = media_identity_conditions(SubscribeHistory, filters) + ids = enum_values(filters.ids) + names = enum_values(filters.names) + usernames = enum_values(filters.usernames) + media_types = enum_values(filters.media_types) + if ids: + conditions.append(SubscribeHistory.id.in_(ids)) + if names: + conditions.append(SubscribeHistory.name.in_(names)) + if usernames: + conditions.append(SubscribeHistory.username.in_(usernames)) + if media_types: + conditions.append(SubscribeHistory.type.in_(media_types)) + if filters.season is not None: + conditions.append(SubscribeHistory.season == filters.season) + if filters.episode_group is not None: + conditions.append( + SubscribeHistory.episode_group == filters.episode_group + ) + music_condition = music_type_condition( + SubscribeHistory.music_type, + filters.music_type, + ) + if music_condition is not None: + conditions.append(music_condition) + + count_statement = select(func.count(SubscribeHistory.id)) + page_statement = select(SubscribeHistory) + if conditions: + count_statement = count_statement.where(*conditions) + page_statement = page_statement.where(*conditions) + descending_order = descending(page) + if page.sort.field == QuerySortField.ID: + primary = ( + SubscribeHistory.id.desc() + if descending_order + else SubscribeHistory.id.asc() + ) + secondary = ( + SubscribeHistory.date.desc() + if descending_order + else SubscribeHistory.date.asc() + ) + else: + primary = ( + SubscribeHistory.date.desc().nullslast() + if descending_order + else SubscribeHistory.date.asc().nullsfirst() + ) + secondary = ( + SubscribeHistory.id.desc() + if descending_order + else SubscribeHistory.id.asc() + ) + page_statement = page_statement.order_by(primary, secondary) + return cast( + tuple[list[SubscribeHistory], int], + execute_page(session, count_statement, page_statement, page), + ) + + return self._execute_sync_query(execute) + async def async_list_by_type( self, mtype: str, diff --git a/app/db/oper/transferhistory.py b/app/db/oper/transferhistory.py index 8b3b44fc2..4c1049c2d 100644 --- a/app/db/oper/transferhistory.py +++ b/app/db/oper/transferhistory.py @@ -1,12 +1,27 @@ import time -from typing import Any, List, Optional +from typing import Any, List, Optional, cast from sqlalchemy import delete as sqlalchemy_delete +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db.base import DbOper from app.db.models.transferhistory import TransferHistory +from app.db.oper.query import ( + descending, + enum_values, + execute_page, + literal_contains, + media_identity_conditions, + music_type_condition, + required_media_identity_conditions, +) +from app.schemas.query import ( + QueryPageRequest, + QuerySortField, + TransferHistoryFilter, +) from app.schemas.types import MediaSource @@ -20,10 +35,111 @@ class TransferHistoryOper(DbOper): 获取转移历史 :param historyid: 转移历史id """ - return self._execute_sync_query( - lambda session: TransferHistory.get(session, historyid) + return self.get_by_id(historyid) + + def get_by_id(self, record_id: int) -> Optional[TransferHistory]: + """按稳定记录 ID 读取单条整理历史。""" + return cast( + Optional[TransferHistory], + self._execute_sync_query( + lambda session: session.execute( + select(TransferHistory).where(TransferHistory.id == record_id) + ).scalars().first() + ), ) + def query( + self, + filters: TransferHistoryFilter, + page: QueryPageRequest, + ) -> tuple[list[TransferHistory], int]: + """按稳定筛选和分页合同读取整理历史记录及总数。""" + def execute(session: Session) -> tuple[list[TransferHistory], int]: + """在同一会话中构造并执行整理历史 count/page 查询。""" + conditions = media_identity_conditions(TransferHistory, filters) + ids = enum_values(filters.ids) + media_types = enum_values(filters.media_types) + media_sources = enum_values(filters.media_sources) + if ids: + conditions.append(TransferHistory.id.in_(ids)) + if media_types: + conditions.append(TransferHistory.type.in_(media_types)) + if media_sources: + conditions.append(TransferHistory.media_source.in_(media_sources)) + if filters.require_media_identity: + conditions.extend(required_media_identity_conditions(TransferHistory)) + if filters.title: + conditions.append(TransferHistory.title == filters.title) + if filters.text: + conditions.append( + literal_contains(TransferHistory.title, filters.text) + | literal_contains(TransferHistory.src, filters.text) + | literal_contains(TransferHistory.dest, filters.text) + ) + for column, value in ( + (TransferHistory.year, filters.year), + (TransferHistory.seasons, filters.seasons), + (TransferHistory.episodes, filters.episodes), + (TransferHistory.src, filters.src), + (TransferHistory.dest, filters.dest), + (TransferHistory.download_hash, filters.download_hash), + (TransferHistory.episode_group, filters.episode_group), + ): + if value is not None and value != "": + conditions.append(column == value) + if filters.status is not None: + if filters.status: + conditions.append(TransferHistory.status.is_(True)) + else: + conditions.append( + or_( + TransferHistory.status.is_(False), + TransferHistory.status.is_(None), + ) + ) + music_condition = music_type_condition( + TransferHistory.music_type, + filters.music_type, + ) + if music_condition is not None: + conditions.append(music_condition) + + count_statement = select(func.count(TransferHistory.id)) + page_statement = select(TransferHistory) + if conditions: + count_statement = count_statement.where(*conditions) + page_statement = page_statement.where(*conditions) + descending_order = descending(page) + if page.sort.field == QuerySortField.ID: + primary = ( + TransferHistory.id.desc() + if descending_order + else TransferHistory.id.asc() + ) + secondary = ( + TransferHistory.date.desc() + if descending_order + else TransferHistory.date.asc() + ) + else: + primary = ( + TransferHistory.date.desc().nullslast() + if descending_order + else TransferHistory.date.asc().nullsfirst() + ) + secondary = ( + TransferHistory.id.desc() + if descending_order + else TransferHistory.id.asc() + ) + page_statement = page_statement.order_by(primary, secondary) + return cast( + tuple[list[TransferHistory], int], + execute_page(session, count_statement, page_statement, page), + ) + + return self._execute_sync_query(execute) + async def async_get(self, historyid: int) -> Optional[TransferHistory]: """ 异步获取转移历史。 diff --git a/app/schemas/query.py b/app/schemas/query.py index 8cc31c0c7..1fc3f42e6 100644 --- a/app/schemas/query.py +++ b/app/schemas/query.py @@ -6,7 +6,7 @@ from typing import Any, Generic, Optional, TypeVar from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.schemas.common import JsonData -from app.schemas.media import OptionalMediaIdentityMixin, normalize_media_source +from app.schemas.media import normalize_media_source from app.schemas.types import MediaSource, MediaType T = TypeVar("T") @@ -36,14 +36,18 @@ class QuerySortDirection(str, Enum): class QuerySort(_QueryInput): - """声明公开查询的稳定排序字段与方向。""" + """声明公开查询的稳定排序字段与方向。 + + 日期排序将空日期置于升序开头、降序末尾,并以同方向 ID 打破日期并列;ID + 本身唯一,因此按 ID 排序不依赖数据库的隐式行顺序。 + """ field: QuerySortField = QuerySortField.DATE direction: QuerySortDirection = QuerySortDirection.DESC class QueryPageRequest(_QueryInput): - """限制插件单次读取规模,并为跨页扫描提供稳定顺序。""" + """限制单次读取规模,并通过显式排序为跨页扫描提供稳定顺序。""" page: int = Field(default=1, ge=1) count: int = Field( @@ -72,15 +76,25 @@ class QueryPage(BaseModel, Generic[T]): # type: ignore[misc] # Pydantic import return self.page * self.count < self.total -class MediaIdentityQuery( - OptionalMediaIdentityMixin, - _QueryInput, -): +class MediaIdentityQuery(_QueryInput): """允许省略身份,但显式筛选时要求来源与原生 ID 成对有效。""" media_source: Optional[MediaSource] = None media_id: Optional[str] = None + @model_validator(mode="after") # type: ignore[misc] + def _validate_media_identity(self) -> "MediaIdentityQuery": + """规范化 ID,并拒绝显式半对、空白或零值身份。""" + source_provided = "media_source" in self.model_fields_set + id_provided = "media_id" in self.model_fields_set + normalized_id = str(self.media_id).strip() if self.media_id is not None else None + if source_provided != id_provided or bool(self.media_source) != bool(normalized_id): + raise ValueError("media_source 和 media_id 必须同时提供") + if normalized_id == "0": + raise ValueError("media_id 不能为 0") + object.__setattr__(self, "media_id", normalized_id) + return self + class _QuerySnapshot(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy """只读查询 DTO 的媒体身份与脏数据归一化边界。""" @@ -279,7 +293,11 @@ class TransferHistorySnapshot(_QuerySnapshot): class SubscriptionFilter(MediaIdentityQuery): - """订阅查询允许组合的稳定业务字段。""" + """当前订阅的组合筛选合同。 + + 所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配; + ``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。 + """ ids: tuple[int, ...] = () names: tuple[str, ...] = () @@ -292,7 +310,11 @@ class SubscriptionFilter(MediaIdentityQuery): class SubscriptionHistoryFilter(MediaIdentityQuery): - """订阅完成历史查询允许组合的稳定业务字段。""" + """订阅完成历史的组合筛选合同。 + + 所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配; + ``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。 + """ ids: tuple[int, ...] = () names: tuple[str, ...] = () @@ -304,7 +326,12 @@ class SubscriptionHistoryFilter(MediaIdentityQuery): class DownloadHistoryFilter(MediaIdentityQuery): - """下载历史查询允许组合的稳定业务字段。""" + """下载历史的组合筛选合同。 + + 所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串 + 字段均精确匹配。``text`` 对标题和路径执行转义后的字面包含查询,不把 ``%`` + 或 ``_`` 解释为通配符。``music_type=recording`` 同时匹配旧 NULL 记录。 + """ ids: tuple[int, ...] = () media_types: tuple[MediaType, ...] = () @@ -322,7 +349,13 @@ class DownloadHistoryFilter(MediaIdentityQuery): class TransferHistoryFilter(MediaIdentityQuery): - """整理历史查询允许组合的稳定业务字段。""" + """整理历史的组合筛选合同。 + + 所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串 + 字段均精确匹配。``text`` 对标题、源路径和目标路径执行转义后的字面包含查询。 + ``require_media_identity`` 只保留来源可解析且原生 ID 非空、非零的记录; + ``status=False`` 包含旧 NULL 状态,``music_type=recording`` 包含旧 NULL 类型。 + """ ids: tuple[int, ...] = () media_types: tuple[MediaType, ...] = () diff --git a/app/sdk/queries.py b/app/sdk/queries.py index d9150dae5..8346397fa 100644 --- a/app/sdk/queries.py +++ b/app/sdk/queries.py @@ -118,7 +118,7 @@ class _DataQueryBackend(Protocol): def _service() -> _DataQueryBackend: """获取启动阶段登记的查询服务,避免把应用服务暴露为 SDK 合同。""" - from app.application.data_query import ( + from app.application.query import ( get_configured_data_query_service, ) diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 65405992c..3b35a7071 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -60,10 +60,6 @@ from app.application.configuration import ( configure_transfer_retry_config, get_configured_system_config, ) -from app.application.data_query import ( - DataQueryService, - configure_data_query_service, -) from app.application.database import configure_database_governance from app.application.history import configure_transfer_history_provider from app.application.image import configure_wallpaper_providers @@ -92,6 +88,10 @@ from app.application.outbox import ( validate_durable_event_handlers, ) from app.application.plugin.runtime import configure_plugin_runtime +from app.application.query import ( + DataQueryService, + configure_data_query_service, +) from app.application.security.auth import AuthService, build_superuser_token_payload, configure_auth_service from app.application.security.passkey import PasskeyService, configure_passkey_service from app.application.security.url import close_image_proxy_block_log_coalescer @@ -109,9 +109,9 @@ from app.application.subscription.write import configure_subscribe_writer from app.application.workflow import WorkflowQueryService, configure_workflow_query from app.command import CommandChain from app.db.adapters.chain import TransactionalChainDurableEventWriter -from app.db.adapters.data_query import SqlAlchemyDataQueryAdapter from app.db.adapters.download import TransactionalDownloadFailureRepository from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository +from app.db.adapters.query import SqlAlchemyDataQueryAdapter from app.db.adapters.site import TransactionalSiteRepository from app.db.adapters.subscription import TransactionalSubscribeWriter from app.db.adapters.transaction import TransactionalWriteRunner diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index dc87fd4e0..ad3756f5f 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 848 / 6,924 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 849 / 6,940 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 | | 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 | | 全量 mypy 历史债务 | 11,827 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 | -| Ruff 历史诊断 | 888 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| Ruff 历史诊断 | 885 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 78.63%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 11408e4d2..1cbd4efc5 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -704,8 +704,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 848 | -| 内部导入边 | 6,924 | +| Python 模块 | 849 | +| 内部导入边 | 6,940 | | 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) | | Direct egress | 66(12 条待迁移债务,54 条精确 containment) | | Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) | diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index db7e58947..eaa9254b3 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -144,7 +144,7 @@ G-ARCH 只有在以下条件全部满足后才可完成: | S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 | | S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 | | S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 | -| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 888 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | +| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 885 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | | S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test | ### S5:Plugin、Agent、Domain、Startup 与最终收口 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 4a822d27b..ddbe5094b 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1441,8 +1441,8 @@ "runtime_only": true } }, - "edge_count": 6924, - "edge_sha256": "d5471439086bcfdd8060b77670af6f821a34e3a864ba7e2cb7230c599a1cb3bb", + "edge_count": 6940, + "edge_sha256": "8ff91be099f1230655ceeb006bd1bf604063054ddb0721b3e887c956ea1251fb", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -4008,10 +4008,6 @@ "app.application.configuration -> app.schemas.types", "app.application.dashboard -> app.schemas", "app.application.dashboard -> app.schemas.dashboard", - "app.application.data_query -> app.application", - "app.application.data_query -> app.application.database", - "app.application.data_query -> app.schemas", - "app.application.data_query -> app.schemas.query", "app.application.directory -> app.adapters", "app.application.directory -> app.adapters.system", "app.application.directory -> app.adapters.system.host", @@ -4264,6 +4260,10 @@ "app.application.plugin.transaction -> app.application.database", "app.application.plugin.transaction -> app.application.plugin", "app.application.plugin.transaction -> app.application.plugin.identity", + "app.application.query -> app.application", + "app.application.query -> app.application.database", + "app.application.query -> app.schemas", + "app.application.query -> app.schemas.query", "app.application.recognition -> app.application", "app.application.recognition -> app.application.configuration", "app.application.recognition -> app.schemas", @@ -5135,18 +5135,6 @@ "app.db.adapters.chain -> app.db.oper.transferpending", "app.db.adapters.chain -> app.db.oper.transfersettlementreceipt", "app.db.adapters.chain -> app.db.uow", - "app.db.adapters.data_query -> app.application", - "app.db.adapters.data_query -> app.application.data_query", - "app.db.adapters.data_query -> app.db", - "app.db.adapters.data_query -> app.db.base", - "app.db.adapters.data_query -> app.db.models", - "app.db.adapters.data_query -> app.db.models.downloadhistory", - "app.db.adapters.data_query -> app.db.models.subscribe", - "app.db.adapters.data_query -> app.db.models.subscribehistory", - "app.db.adapters.data_query -> app.db.models.transferhistory", - "app.db.adapters.data_query -> app.schemas", - "app.db.adapters.data_query -> app.schemas.query", - "app.db.adapters.data_query -> app.schemas.types", "app.db.adapters.download -> app.db", "app.db.adapters.download -> app.db.oper", "app.db.adapters.download -> app.db.oper.downloadfailure", @@ -5176,6 +5164,16 @@ "app.db.adapters.plugininstallation -> app.db.models", "app.db.adapters.plugininstallation -> app.db.models.pluginidentity", "app.db.adapters.plugininstallation -> app.db.models.plugininstallation", + "app.db.adapters.query -> app.application", + "app.db.adapters.query -> app.application.query", + "app.db.adapters.query -> app.db", + "app.db.adapters.query -> app.db.oper", + "app.db.adapters.query -> app.db.oper.downloadhistory", + "app.db.adapters.query -> app.db.oper.subscribe", + "app.db.adapters.query -> app.db.oper.subscribehistory", + "app.db.adapters.query -> app.db.oper.transferhistory", + "app.db.adapters.query -> app.schemas", + "app.db.adapters.query -> app.schemas.query", "app.db.adapters.site -> app.db", "app.db.adapters.site -> app.db.oper", "app.db.adapters.site -> app.db.oper.site", @@ -5355,7 +5353,10 @@ "app.db.oper.downloadhistory -> app.db.base", "app.db.oper.downloadhistory -> app.db.models", "app.db.oper.downloadhistory -> app.db.models.downloadhistory", + "app.db.oper.downloadhistory -> app.db.oper", + "app.db.oper.downloadhistory -> app.db.oper.query", "app.db.oper.downloadhistory -> app.schemas", + "app.db.oper.downloadhistory -> app.schemas.query", "app.db.oper.downloadhistory -> app.schemas.types", "app.db.oper.mediaserver -> app.db", "app.db.oper.mediaserver -> app.db.base", @@ -5380,6 +5381,11 @@ "app.db.oper.pluginidentity -> app.db.base", "app.db.oper.pluginidentity -> app.db.models", "app.db.oper.pluginidentity -> app.db.models.pluginidentity", + "app.db.oper.query -> app.db", + "app.db.oper.query -> app.db.base", + "app.db.oper.query -> app.schemas", + "app.db.oper.query -> app.schemas.query", + "app.db.oper.query -> app.schemas.types", "app.db.oper.site -> app.db", "app.db.oper.site -> app.db.base", "app.db.oper.site -> app.db.models", @@ -5395,12 +5401,19 @@ "app.db.oper.subscribe -> app.db.models", "app.db.oper.subscribe -> app.db.models.subscribe", "app.db.oper.subscribe -> app.db.models.subscribehistory", + "app.db.oper.subscribe -> app.db.oper", + "app.db.oper.subscribe -> app.db.oper.query", "app.db.oper.subscribe -> app.schemas", + "app.db.oper.subscribe -> app.schemas.query", "app.db.oper.subscribe -> app.schemas.types", "app.db.oper.subscribehistory -> app.db", "app.db.oper.subscribehistory -> app.db.base", "app.db.oper.subscribehistory -> app.db.models", "app.db.oper.subscribehistory -> app.db.models.subscribehistory", + "app.db.oper.subscribehistory -> app.db.oper", + "app.db.oper.subscribehistory -> app.db.oper.query", + "app.db.oper.subscribehistory -> app.schemas", + "app.db.oper.subscribehistory -> app.schemas.query", "app.db.oper.systemconfig -> app.db", "app.db.oper.systemconfig -> app.db.base", "app.db.oper.systemconfig -> app.db.models", @@ -5417,7 +5430,10 @@ "app.db.oper.transferhistory -> app.db.base", "app.db.oper.transferhistory -> app.db.models", "app.db.oper.transferhistory -> app.db.models.transferhistory", + "app.db.oper.transferhistory -> app.db.oper", + "app.db.oper.transferhistory -> app.db.oper.query", "app.db.oper.transferhistory -> app.schemas", + "app.db.oper.transferhistory -> app.schemas.query", "app.db.oper.transferhistory -> app.schemas.types", "app.db.oper.transferpending -> app.db", "app.db.oper.transferpending -> app.db.base", @@ -7769,7 +7785,7 @@ "app.sdk.plugins -> app.runtime.extensions.module_manager", "app.sdk.plugins -> app.runtime.extensions.plugin_manager", "app.sdk.queries -> app.application", - "app.sdk.queries -> app.application.data_query", + "app.sdk.queries -> app.application.query", "app.sdk.queries -> app.schemas", "app.sdk.queries -> app.schemas.query", "app.sdk.security -> app.adapters", @@ -7953,7 +7969,6 @@ "app.startup.initializers.modules -> app.application.chain.data", "app.startup.initializers.modules -> app.application.chain.events", "app.startup.initializers.modules -> app.application.configuration", - "app.startup.initializers.modules -> app.application.data_query", "app.startup.initializers.modules -> app.application.database", "app.startup.initializers.modules -> app.application.history", "app.startup.initializers.modules -> app.application.image", @@ -7966,6 +7981,7 @@ "app.startup.initializers.modules -> app.application.plugin", "app.startup.initializers.modules -> app.application.plugin.runtime", "app.startup.initializers.modules -> app.application.plugin.transaction", + "app.startup.initializers.modules -> app.application.query", "app.startup.initializers.modules -> app.application.security", "app.startup.initializers.modules -> app.application.security.auth", "app.startup.initializers.modules -> app.application.security.passkey", @@ -7996,11 +8012,11 @@ "app.startup.initializers.modules -> app.db", "app.startup.initializers.modules -> app.db.adapters", "app.startup.initializers.modules -> app.db.adapters.chain", - "app.startup.initializers.modules -> app.db.adapters.data_query", "app.startup.initializers.modules -> app.db.adapters.download", "app.startup.initializers.modules -> app.db.adapters.outbox", "app.startup.initializers.modules -> app.db.adapters.pluginidentity", "app.startup.initializers.modules -> app.db.adapters.plugininstallation", + "app.startup.initializers.modules -> app.db.adapters.query", "app.startup.initializers.modules -> app.db.adapters.site", "app.startup.initializers.modules -> app.db.adapters.subscription", "app.startup.initializers.modules -> app.db.adapters.transaction", @@ -8369,7 +8385,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 848, + "module_count": 849, "modules": [ "app", "app.adapters", @@ -8629,7 +8645,6 @@ "app.application.commands", "app.application.configuration", "app.application.dashboard", - "app.application.data_query", "app.application.database", "app.application.directory", "app.application.download", @@ -8678,6 +8693,7 @@ "app.application.plugin.runtime", "app.application.plugin.source", "app.application.plugin.transaction", + "app.application.query", "app.application.recognition", "app.application.rss", "app.application.rules", @@ -8762,11 +8778,11 @@ "app.db", "app.db.adapters", "app.db.adapters.chain", - "app.db.adapters.data_query", "app.db.adapters.download", "app.db.adapters.outbox", "app.db.adapters.pluginidentity", "app.db.adapters.plugininstallation", + "app.db.adapters.query", "app.db.adapters.site", "app.db.adapters.subscription", "app.db.adapters.transaction", @@ -8819,6 +8835,7 @@ "app.db.oper.passkey", "app.db.oper.plugindata", "app.db.oper.pluginidentity", + "app.db.oper.query", "app.db.oper.site", "app.db.oper.subscribe", "app.db.oper.subscribehistory", diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 6758b3e1d..438fbe92b 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -467,18 +467,12 @@ "app/db/models/workflow.py": { "I001": 1 }, - "app/db/oper/downloadhistory.py": { - "I001": 1 - }, "app/db/oper/message.py": { "I001": 1 }, "app/db/oper/site.py": { "I001": 1 }, - "app/db/oper/subscribe.py": { - "I001": 1 - }, "app/db/oper/systemconfig.py": { "I001": 1 }, @@ -1128,9 +1122,6 @@ "tests/test_agent_lazy_runtime_boundary.py": { "F401": 1 }, - "tests/test_agent_lifecycle.py": { - "I001": 1 - }, "tests/test_agent_llm_capability.py": { "I001": 1 }, diff --git a/tests/test_agent_lifecycle.py b/tests/test_agent_lifecycle.py index 16b7eb94a..14a3c8167 100644 --- a/tests/test_agent_lifecycle.py +++ b/tests/test_agent_lifecycle.py @@ -4,18 +4,19 @@ from unittest.mock import AsyncMock, MagicMock import pytest import app.agent.orchestrator as agent_module -from app.application.messaging.agent import ( - create_web_agent_background_task, - shutdown_web_agent_background_tasks, -) +from app.agent.memory import MemoryManager from app.agent.orchestrator import ( AGENT_SESSION_QUEUE_MAX_SIZE, AgentManager, AgentManagerQueueFullError, AgentManagerUnavailableError, ) -from app.agent.memory import MemoryManager from app.agent.tools.base import reopen_blocking_executors +from app.application.messaging.agent import ( + create_web_agent_background_task, + shutdown_web_agent_background_tasks, +) +from app.application.query import get_configured_data_query_service from app.startup.initializers import agent as agent_initializer from app.startup.initializers import modules as modules_initializer @@ -202,6 +203,9 @@ async def test_agent_initialization_failure_does_not_stop_module_startup( assert runtime.workflow.system_config() is ( modules_initializer.get_configured_system_config() ) + query_page = get_configured_data_query_service().list_subscriptions({"ids": [-1]}) + assert query_page.items == [] + assert query_page.total == 0 finally: await modules_initializer.stop_database_worker() diff --git a/tests/test_sdk_queries.py b/tests/test_sdk_queries.py index 191e494f8..33be4bff3 100644 --- a/tests/test_sdk_queries.py +++ b/tests/test_sdk_queries.py @@ -61,9 +61,9 @@ class _RecordingExecutor: @pytest.fixture def query_sdk(db, monkeypatch): """装配真实数据查询适配器,并把 SDK 绑定到本用例的 SQLite 服务。""" - from app.application import data_query as data_query_module - from app.application.data_query import DataQueryService - from app.db.adapters.data_query import SqlAlchemyDataQueryAdapter + from app.application import query as data_query_module + from app.application.query import DataQueryService + from app.db.adapters.query import SqlAlchemyDataQueryAdapter from app.db.session import SessionFactory from app.sdk import queries as sdk @@ -180,7 +180,7 @@ def _transfer_history( episodes: str | None = "E01", download_hash: str | None = "hash-1", episode_group: str | None = None, - status: bool = True, + status: bool | None = True, date: str = "2026-08-27 10:00:00", mtype: str = MediaType.TV.value, ) -> TransferHistoryModel: @@ -630,6 +630,67 @@ def test_snapshots_normalize_legacy_identity_and_transfer_status(): assert snapshot.status is False +def test_transfer_failure_filter_includes_legacy_null_status(db, query_sdk): + """失败筛选与快照语义一致,包含状态尚未回填的旧整理记录。""" + sdk, _executor = query_sdk + legacy_row = db.add( + _transfer_history( + "Legacy null status", + media_id="legacy-status", + src="/legacy/status-src.mkv", + dest="/legacy/status-dest.mkv", + status=None, + ) + ) + legacy_row.status = None + db.session.commit() + + page = sdk.list_transfer_history( + TransferHistoryFilter( + media_source=TMDB, + media_id="legacy-status", + status=False, + ) + ) + + assert [item.id for item in page.items] == [legacy_row.id] + assert page.items[0].status is False + + +def test_transfer_required_identity_excludes_unparseable_legacy_source(db, query_sdk): + """有效身份筛选不得返回随后会被快照降级为空身份的脏来源。""" + sdk, _executor = query_sdk + valid_row = db.add( + _transfer_history( + "Valid dynamic source", + media_id="valid-dynamic", + src="/valid/source.mkv", + dest="/valid/dest.mkv", + ) + ) + invalid_row = db.add( + _transfer_history( + "Invalid legacy source", + media_id="invalid-source", + src="/invalid/source.mkv", + dest="/invalid/dest.mkv", + ) + ) + valid_row.media_source = "plugin-source" + invalid_row.media_source = "invalid!" + db.session.commit() + + page = sdk.list_transfer_history( + TransferHistoryFilter( + ids=(valid_row.id, invalid_row.id), + require_media_identity=True, + ) + ) + + assert [item.id for item in page.items] == [valid_row.id] + assert page.items[0].media_source == MediaSource("plugin-source") + + def test_query_snapshots_are_owned_by_the_sdk_contract_module(): """公开查询返回值由独立快照定义,不复用宿主写入或 API 响应模型。""" snapshots = ( From 7959f27cd6b9269f7c861ac3cf080e8578c32028 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Fri, 28 Aug 2026 00:14:53 +0800 Subject: [PATCH 6/8] test(sdk): cover query getters and startup injection --- tests/test_agent_lifecycle.py | 6 ++-- tests/test_sdk_queries.py | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/tests/test_agent_lifecycle.py b/tests/test_agent_lifecycle.py index 14a3c8167..14ef84749 100644 --- a/tests/test_agent_lifecycle.py +++ b/tests/test_agent_lifecycle.py @@ -12,11 +12,12 @@ from app.agent.orchestrator import ( AgentManagerUnavailableError, ) from app.agent.tools.base import reopen_blocking_executors +from app.application import query as query_application from app.application.messaging.agent import ( create_web_agent_background_task, shutdown_web_agent_background_tasks, ) -from app.application.query import get_configured_data_query_service +from app.sdk import queries as query_sdk from app.startup.initializers import agent as agent_initializer from app.startup.initializers import modules as modules_initializer @@ -197,13 +198,14 @@ async def test_agent_initialization_failure_does_not_stop_module_startup( check_auth = MagicMock() monkeypatch.setattr(modules_initializer, "start_frontend", start_frontend) monkeypatch.setattr(modules_initializer, "check_auth", check_auth) + monkeypatch.setattr(query_application, "_configured_data_query_service", None) try: runtime = await modules_initializer.init_modules() assert runtime.workflow.system_config() is ( modules_initializer.get_configured_system_config() ) - query_page = get_configured_data_query_service().list_subscriptions({"ids": [-1]}) + query_page = await query_sdk.async_list_subscriptions({"ids": [-1]}) assert query_page.items == [] assert query_page.total == 0 finally: diff --git a/tests/test_sdk_queries.py b/tests/test_sdk_queries.py index 33be4bff3..0c08e7fcb 100644 --- a/tests/test_sdk_queries.py +++ b/tests/test_sdk_queries.py @@ -733,6 +733,74 @@ def test_sdk_get_returns_none_for_missing_records(query_sdk): assert sdk.get_transfer_history(missing_id) is None +def test_sdk_get_projects_records_and_async_uses_executor(db, query_sdk): + """四类 getter 均返回 DTO,异步入口与同步一致且经过数据库 executor。""" + sdk, executor = query_sdk + subscribe = db.add(_subscribe("Getter subscribe", media_id="getter-subscription")) + subscribe_history = db.add( + _subscribe_history( + "Getter subscription history", + media_id="getter-subscription-history", + ) + ) + download = db.add( + _download_history( + "Getter download history", + media_id="getter-download", + path="/getter/download", + ) + ) + transfer = db.add( + _transfer_history( + "Getter transfer history", + media_id="getter-transfer", + src="/getter/src", + dest="/getter/dest", + ) + ) + caller_thread_id = threading.get_ident() + cases = ( + ( + sdk.get_subscription, + sdk.async_get_subscription, + subscribe, + SubscriptionSnapshot, + ), + ( + sdk.get_subscription_history, + sdk.async_get_subscription_history, + subscribe_history, + SubscriptionHistorySnapshot, + ), + ( + sdk.get_download_history, + sdk.async_get_download_history, + download, + DownloadHistorySnapshot, + ), + ( + sdk.get_transfer_history, + sdk.async_get_transfer_history, + transfer, + TransferHistorySnapshot, + ), + ) + + for sync_call, async_call, model, snapshot_type in cases: + sync_item = sync_call(model.id) + calls_before = executor.calls + async_item = asyncio.run(async_call(model.id)) + + assert async_item == sync_item + assert isinstance(sync_item, snapshot_type) + assert not isinstance(sync_item, type(model)) + assert sync_item.id == model.id + assert executor.calls == calls_before + 1 + + assert executor.worker_thread_ids + assert all(thread_id != caller_thread_id for thread_id in executor.worker_thread_ids) + + def test_sdk_sync_async_semantics_match_and_async_uses_executor(db, query_sdk): """四个查询门面的异步结果与同步一致,并交给 executor 线程。""" sdk, executor = query_sdk From e31b3f8de7cb27abcf2c72342e970b6734f44a0b Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Fri, 28 Aug 2026 00:26:59 +0800 Subject: [PATCH 7/8] chore(architecture): sync query quality baselines --- docs/architecture-optimization-checklist.md | 2 +- tests/fixtures/architecture/coverage-baseline.json | 6 +++--- .../fixtures/architecture/startup-performance-baseline.json | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index ad3756f5f..bf345af49 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -79,7 +79,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 | | 全量 mypy 历史债务 | 11,827 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 | | Ruff 历史诊断 | 885 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | -| 覆盖率低水位 | Application 78.63%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | +| 覆盖率低水位 | Application 78.74%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index 43e601d8f..167918165 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,8 +1,8 @@ { "application": { - "covered_lines": 9992, - "percent": 78.63, - "statements": 12707 + "covered_lines": 10098, + "percent": 78.74, + "statements": 12825 }, "domain": { "covered_lines": 3392, diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 7d8e7c78b..bd807e6c0 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 388, + "loaded_app_module_count": 392, "max_ms": 1035.341, "median_ms": 993.245, "min_ms": 991.998, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 400, + "loaded_app_module_count": 404, "max_ms": 1006.172, "median_ms": 1005.88, "min_ms": 1003.841, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 402, + "loaded_app_module_count": 406, "max_ms": 1085.652, "median_ms": 1055.994, "min_ms": 1043.212, From 1937ebeb5a9d8b74c966f1e69f71488974d099ef Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 28 Aug 2026 07:11:01 +0800 Subject: [PATCH 8/8] test(lifecycle): scope shutdown budget assertion --- tests/test_lifecycle_shutdown.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 133c5c4fc..47e9d00e0 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -396,13 +396,20 @@ def test_plugin_settlement_cannot_bypass_task_registry_shutdown_budget( lifecycle.init_extra.side_effect = settle_plugins async def run_lifespan() -> None: - """确认 context 能由 TaskRegistry 的失败结果立即结束。""" - async with lifecycle.lifespan(FastAPI()): + """仅对关闭阶段计时,避免覆盖率启动开销污染停机预算断言。""" + lifespan_context = lifecycle.lifespan(FastAPI()) + await lifespan_context.__aenter__() + try: await started.wait() - release.set() - await asyncio.sleep(0) + await asyncio.wait_for( + lifespan_context.__aexit__(None, None, None), + timeout=0.5, + ) + finally: + release.set() + await asyncio.sleep(0) - asyncio.run(asyncio.wait_for(run_lifespan(), timeout=0.5)) + asyncio.run(run_lifespan()) shutdown.assert_awaited_once_with(timeout_seconds=30.0) for name, step in shutdown_steps.items():