feat(sdk): add unified plugin data queries

This commit is contained in:
InfinityPacer
2026-08-27 23:41:54 +08:00
parent 513d867dd6
commit 7fa03650bd
6 changed files with 1831 additions and 0 deletions
+350
View File
@@ -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",
]
+375
View File
@@ -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"]
+215
View File
@@ -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",
]
+173
View File
@@ -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",
]
+13
View File
@@ -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,
+705
View File
@@ -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"