From 6bae137d258d8ff9669a28bc7815d8af3eb73de4 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 27 Aug 2026 23:20:17 +0800 Subject: [PATCH] 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( "订阅历史同步异步",