mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(sdk): add unified plugin data queries
This commit is contained in:
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -60,6 +60,10 @@ from app.application.configuration import (
|
|||||||
configure_transfer_retry_config,
|
configure_transfer_retry_config,
|
||||||
get_configured_system_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.database import configure_database_governance
|
||||||
from app.application.history import configure_transfer_history_provider
|
from app.application.history import configure_transfer_history_provider
|
||||||
from app.application.image import configure_wallpaper_providers
|
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.application.workflow import WorkflowQueryService, configure_workflow_query
|
||||||
from app.command import CommandChain
|
from app.command import CommandChain
|
||||||
from app.db.adapters.chain import TransactionalChainDurableEventWriter
|
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.download import TransactionalDownloadFailureRepository
|
||||||
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
|
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
|
||||||
from app.db.adapters.site import TransactionalSiteRepository
|
from app.db.adapters.site import TransactionalSiteRepository
|
||||||
@@ -757,6 +762,14 @@ async def init_modules() -> HostRuntime:
|
|||||||
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
||||||
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
||||||
raise
|
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(
|
configure_plugin_persistence(
|
||||||
PluginPersistenceService(
|
PluginPersistenceService(
|
||||||
executor=database_worker,
|
executor=database_worker,
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user