Merge pull request #6485 from InfinityPacer/codex/feat/plugin-data-query-sdk-v3

feat: 提供插件统一订阅与历史查询 SDK
This commit is contained in:
jxxghp
2026-08-28 07:21:44 +08:00
committed by GitHub
21 changed files with 2847 additions and 44 deletions
+347
View File
@@ -0,0 +1,347 @@
"""插件只读数据查询的应用服务与领域端口。"""
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.query import (
DownloadHistoryFilter,
DownloadHistorySnapshot,
QueryPage,
QueryPageRequest,
SubscriptionFilter,
SubscriptionHistoryFilter,
SubscriptionHistorySnapshot,
SubscriptionSnapshot,
TransferHistoryFilter,
TransferHistorySnapshot,
)
RecordT = TypeVar("RecordT", covariant=True)
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[SubscriptionSnapshot]:
"""同步分页查询订阅。"""
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(SubscriptionSnapshot, normalized_page, rows)
async def async_list_subscriptions(
self,
filters: SubscriptionFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[SubscriptionSnapshot]:
"""异步分页查询订阅,业务规则在数据库 worker 中复用同步入口。"""
return await self._async_run(partial(self.list_subscriptions, filters, page))
def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None:
"""同步按 ID 查询订阅。"""
return self._to_item(
SubscriptionSnapshot,
self._subscriptions.get_subscription(subscription_id),
)
async def async_get_subscription(
self,
subscription_id: int,
) -> SubscriptionSnapshot | 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[SubscriptionHistorySnapshot]:
"""同步分页查询订阅完成历史。"""
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(SubscriptionHistorySnapshot, normalized_page, rows)
async def async_list_subscription_history(
self,
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[SubscriptionHistorySnapshot]:
"""异步分页查询订阅完成历史。"""
return await self._async_run(partial(self.list_subscription_history, filters, page))
def get_subscription_history(
self,
history_id: int,
) -> SubscriptionHistorySnapshot | None:
"""同步按 ID 查询订阅完成历史。"""
return self._to_item(
SubscriptionHistorySnapshot,
self._subscriptions.get_subscription_history(history_id),
)
async def async_get_subscription_history(
self,
history_id: int,
) -> SubscriptionHistorySnapshot | 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[DownloadHistorySnapshot]:
"""同步分页查询下载历史。"""
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(DownloadHistorySnapshot, normalized_page, rows)
async def async_list_download_history(
self,
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[DownloadHistorySnapshot]:
"""异步分页查询下载历史。"""
return await self._async_run(partial(self.list_download_history, filters, page))
def get_download_history(self, history_id: int) -> DownloadHistorySnapshot | None:
"""同步按 ID 查询下载历史。"""
return self._to_item(
DownloadHistorySnapshot,
self._histories.get_download_history(history_id),
)
async def async_get_download_history(
self,
history_id: int,
) -> DownloadHistorySnapshot | None:
"""异步按 ID 查询下载历史。"""
return await self._async_run(partial(self.get_download_history, history_id))
def list_transfer_history(
self,
filters: TransferHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[TransferHistorySnapshot]:
"""同步分页查询整理历史。"""
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(TransferHistorySnapshot, normalized_page, rows)
async def async_list_transfer_history(
self,
filters: TransferHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[TransferHistorySnapshot]:
"""异步分页查询整理历史。"""
return await self._async_run(partial(self.list_transfer_history, filters, page))
def get_transfer_history(self, history_id: int) -> TransferHistorySnapshot | None:
"""同步按 ID 查询整理历史。"""
return self._to_item(
TransferHistorySnapshot,
self._histories.get_transfer_history(history_id),
)
async def async_get_transfer_history(
self,
history_id: int,
) -> TransferHistorySnapshot | None:
"""异步按 ID 查询整理历史。"""
return await self._async_run(partial(self.get_transfer_history, history_id))
_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",
]
+140
View File
@@ -0,0 +1,140 @@
"""插件只读数据查询的持久化端口适配器。"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import TypeVar
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.application.query import QueryRows
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.schemas.query import (
DownloadHistoryFilter,
DownloadHistorySnapshot,
QueryPageRequest,
SubscriptionFilter,
SubscriptionHistoryFilter,
SubscriptionHistorySnapshot,
SubscriptionSnapshot,
TransferHistoryFilter,
TransferHistorySnapshot,
)
_SnapshotT = TypeVar("_SnapshotT", bound=BaseModel)
class SqlAlchemyDataQueryAdapter:
"""以短生命周期 Session 和显式 Oper 实现统一查询端口。
Oper 拥有表级筛选、排序和分页语义;适配器只管理一次操作使用的 Session,
并在 Session 关闭前把持久化记录冻结为稳定 Pydantic 快照。
"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""保存由启动组合根提供的同步 Session 工厂。"""
self._session_factory = session_factory
@staticmethod
def _rows(
snapshot_type: type[_SnapshotT],
records: Sequence[object],
total: int,
) -> QueryRows[_SnapshotT]:
"""在会话有效期内投影一页记录,并保护非负总数合同。"""
return QueryRows(
items=[snapshot_type.model_validate(record) for record in records],
total=max(int(total), 0),
)
@staticmethod
def _item(
snapshot_type: type[_SnapshotT],
record: object | None,
) -> _SnapshotT | None:
"""把单条持久化记录投影为稳定快照。"""
return snapshot_type.model_validate(record) if record is not None else None
def list_subscriptions(
self,
*,
filters: SubscriptionFilter,
page: QueryPageRequest,
) -> QueryRows[SubscriptionSnapshot]:
"""通过订阅 Oper 读取并冻结一页当前订阅。"""
with self._session_factory() as session:
records, total = SubscribeOper(session).query(filters, page)
return self._rows(SubscriptionSnapshot, records, total)
def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None:
"""通过订阅 Oper 按 ID 读取并冻结当前订阅。"""
with self._session_factory() as session:
record = SubscribeOper(session).get_by_id(subscription_id)
return self._item(SubscriptionSnapshot, record)
def list_subscription_history(
self,
*,
filters: SubscriptionHistoryFilter,
page: QueryPageRequest,
) -> QueryRows[SubscriptionHistorySnapshot]:
"""通过订阅历史 Oper 读取并冻结一页完成历史。"""
with self._session_factory() as session:
records, total = SubscribeHistoryOper(session).query(filters, page)
return self._rows(SubscriptionHistorySnapshot, records, total)
def get_subscription_history(
self,
history_id: int,
) -> SubscriptionHistorySnapshot | None:
"""通过订阅历史 Oper 按 ID 读取并冻结完成历史。"""
with self._session_factory() as session:
record = SubscribeHistoryOper(session).get_by_id(history_id)
return self._item(SubscriptionHistorySnapshot, record)
def list_download_history(
self,
*,
filters: DownloadHistoryFilter,
page: QueryPageRequest,
) -> QueryRows[DownloadHistorySnapshot]:
"""通过下载历史 Oper 读取并冻结一页下载记录。"""
with self._session_factory() as session:
records, total = DownloadHistoryOper(session).query(filters, page)
return self._rows(DownloadHistorySnapshot, records, total)
def get_download_history(
self,
history_id: int,
) -> DownloadHistorySnapshot | None:
"""通过下载历史 Oper 按 ID 读取并冻结下载记录。"""
with self._session_factory() as session:
record = DownloadHistoryOper(session).get_by_id(history_id)
return self._item(DownloadHistorySnapshot, record)
def list_transfer_history(
self,
*,
filters: TransferHistoryFilter,
page: QueryPageRequest,
) -> QueryRows[TransferHistorySnapshot]:
"""通过整理历史 Oper 读取并冻结一页整理记录。"""
with self._session_factory() as session:
records, total = TransferHistoryOper(session).query(filters, page)
return self._rows(TransferHistorySnapshot, records, total)
def get_transfer_history(
self,
history_id: int,
) -> TransferHistorySnapshot | None:
"""通过整理历史 Oper 按 ID 读取并冻结整理记录。"""
with self._session_factory() as session:
record = TransferHistoryOper(session).get_by_id(history_id)
return self._item(TransferHistorySnapshot, record)
__all__ = ["SqlAlchemyDataQueryAdapter"]
+106 -2
View File
@@ -1,10 +1,25 @@
from typing import Dict, List, Optional, cast
from sqlalchemy import delete as sqlalchemy_delete, update as sqlalchemy_update
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy import func, select
from sqlalchemy import update as sqlalchemy_update
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
from app.db.oper.query import (
descending,
enum_values,
execute_page,
literal_contains,
media_identity_conditions,
music_type_condition,
)
from app.schemas.query import (
DownloadHistoryFilter,
QueryPageRequest,
QuerySortField,
)
from app.schemas.types import MediaSource
@@ -13,6 +28,95 @@ class DownloadHistoryOper(DbOper):
下载历史管理
"""
def get_by_id(self, record_id: int) -> Optional[DownloadHistory]:
"""按稳定记录 ID 读取单条下载历史。"""
return cast(
Optional[DownloadHistory],
self._execute_sync_query(
lambda session: session.execute(
select(DownloadHistory).where(DownloadHistory.id == record_id)
).scalars().first()
),
)
def query(
self,
filters: DownloadHistoryFilter,
page: QueryPageRequest,
) -> tuple[list[DownloadHistory], int]:
"""按稳定筛选和分页合同读取下载历史记录及总数。"""
def execute(session: Session) -> tuple[list[DownloadHistory], int]:
"""在同一会话中构造并执行下载历史 count/page 查询。"""
conditions = media_identity_conditions(DownloadHistory, filters)
ids = enum_values(filters.ids)
media_types = enum_values(filters.media_types)
usernames = enum_values(filters.usernames)
if ids:
conditions.append(DownloadHistory.id.in_(ids))
if media_types:
conditions.append(DownloadHistory.type.in_(media_types))
for column, value in (
(DownloadHistory.title, filters.title),
(DownloadHistory.year, filters.year),
(DownloadHistory.seasons, filters.seasons),
(DownloadHistory.episodes, filters.episodes),
(DownloadHistory.path, filters.path),
(DownloadHistory.download_hash, filters.download_hash),
(DownloadHistory.username, filters.username),
(DownloadHistory.episode_group, filters.episode_group),
):
if value is not None and value != "":
conditions.append(column == value)
if filters.text:
conditions.append(
literal_contains(DownloadHistory.title, filters.text)
| literal_contains(DownloadHistory.path, filters.text)
)
if usernames:
conditions.append(DownloadHistory.username.in_(usernames))
music_condition = music_type_condition(
DownloadHistory.music_type,
filters.music_type,
)
if music_condition is not None:
conditions.append(music_condition)
count_statement = select(func.count(DownloadHistory.id))
page_statement = select(DownloadHistory)
if conditions:
count_statement = count_statement.where(*conditions)
page_statement = page_statement.where(*conditions)
descending_order = descending(page)
if page.sort.field == QuerySortField.ID:
primary = (
DownloadHistory.id.desc()
if descending_order
else DownloadHistory.id.asc()
)
secondary = (
DownloadHistory.date.desc()
if descending_order
else DownloadHistory.date.asc()
)
else:
primary = (
DownloadHistory.date.desc().nullslast()
if descending_order
else DownloadHistory.date.asc().nullsfirst()
)
secondary = (
DownloadHistory.id.desc()
if descending_order
else DownloadHistory.id.asc()
)
page_statement = page_statement.order_by(primary, secondary)
return cast(
tuple[list[DownloadHistory], int],
execute_page(session, count_statement, page_statement, page),
)
return self._execute_sync_query(execute)
def get_by_path(self, path: str) -> Optional[DownloadHistory]:
"""
按路径查询下载记录
+113
View File
@@ -0,0 +1,113 @@
"""只读查询 Oper 共享的持久化原语。
本模块只承载跨表完全相同的查询表示规则和分页执行步骤。每个具体 Oper 仍负责
声明本表模型、筛选字段、SQLAlchemy 条件、count/page 语句以及排序,避免这里退化
成任意表的 Repository。
"""
from collections.abc import Iterable
from enum import Enum
from typing import Any, TypeVar
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.db.base import Base
from app.schemas.query import QueryPageRequest, QuerySortDirection
from app.schemas.types import MEDIA_SOURCE_IDENTIFIER_PATTERN, MUSIC_ENTITY_RECORDING
ModelT = TypeVar("ModelT", bound=Base)
def enum_value(value: Any) -> Any:
"""返回筛选枚举对应的数据库值。"""
return value.value if isinstance(value, Enum) else value
def enum_values(values: Iterable[Any]) -> tuple[Any, ...]:
"""去除空筛选值、归一枚举,并保留调用方顺序。"""
normalized: list[Any] = []
for value in values:
value = enum_value(value)
if value in (None, ""):
continue
normalized.append(value)
return tuple(dict.fromkeys(normalized))
def literal_contains(column: Any, value: str) -> Any:
"""构造不区分大小写且不解释 ``%``、``_`` 通配符的字面包含条件。"""
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return column.ilike(f"%{escaped}%", escape="\\")
def media_identity_conditions(model: Any, query: Any) -> list[Any]:
"""按媒体来源与原生 ID 的成对合同构造条件,并对非法身份 fail-closed。"""
media_source = query.media_source
media_id = query.media_id
if (media_source is None) != (media_id is None):
raise ValueError("media_source 和 media_id 必须同时提供")
if media_source is None:
return []
normalized_id = str(media_id).strip()
if not normalized_id or normalized_id == "0":
raise ValueError("media_id 必须是非零的来源原生 ID")
return [
model.media_source == enum_value(media_source),
model.media_id == normalized_id,
]
def required_media_identity_conditions(model: Any) -> list[Any]:
"""构造只保留可解析来源与非空、非零原生 ID 的条件。"""
return [
model.media_source.is_not(None),
func.trim(model.media_source) != "",
func.lower(func.trim(model.media_source)).regexp_match(
MEDIA_SOURCE_IDENTIFIER_PATTERN
),
model.media_id.is_not(None),
func.trim(model.media_id) != "",
func.trim(model.media_id) != "0",
]
def music_type_condition(column: Any, music_type: str | None) -> Any | None:
"""兼容未标注音乐类型的历史单曲记录。"""
music_type = enum_value(music_type)
if not music_type:
return None
if music_type == MUSIC_ENTITY_RECORDING:
return or_(column == music_type, column.is_(None))
return column == music_type
def execute_page(
session: Session,
count_statement: Any,
page_statement: Any,
page: QueryPageRequest,
) -> tuple[list[ModelT], int]:
"""在同一同步 Session 内先统计再读取一页已构造的查询语句。"""
total = int(session.execute(count_statement).scalar_one() or 0)
records = list(
session.execute(page_statement.offset((page.page - 1) * page.count).limit(page.count)).scalars().all()
)
return records, total
def descending(page: QueryPageRequest) -> bool:
"""返回公开分页排序是否为降序,集中处理枚举表示。"""
return page.sort.direction == QuerySortDirection.DESC
__all__ = [
"descending",
"enum_value",
"enum_values",
"execute_page",
"literal_contains",
"media_identity_conditions",
"music_type_condition",
"required_media_identity_conditions",
]
+81 -2
View File
@@ -9,9 +9,10 @@
"""
import time
from collections.abc import Awaitable, Callable
from typing import Any, Tuple, List, Optional
from typing import Any, List, Optional, Tuple, cast
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -19,6 +20,14 @@ from app.application.subscription.delete import SubscribeDeletionCandidate
from app.db.base import DbOper
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.oper.query import (
descending,
enum_values,
execute_page,
media_identity_conditions,
music_type_condition,
)
from app.schemas.query import QueryPageRequest, QuerySortField, SubscriptionFilter
from app.schemas.types import MediaSource
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
@@ -276,7 +285,77 @@ class SubscribeOper(DbOper):
"""
获取订阅
"""
return self._execute_sync_query(lambda session: Subscribe.get(session, sid))
return self.get_by_id(sid)
def get_by_id(self, record_id: int) -> Optional[Subscribe]:
"""按稳定记录 ID 读取单条订阅。"""
return cast(
Optional[Subscribe],
self._execute_sync_query(
lambda session: session.execute(
select(Subscribe).where(Subscribe.id == record_id)
).scalars().first()
),
)
def query(
self,
filters: SubscriptionFilter,
page: QueryPageRequest,
) -> tuple[list[Subscribe], int]:
"""按稳定筛选和分页合同读取订阅记录及总数。"""
def execute(session: Session) -> tuple[list[Subscribe], int]:
"""在同一会话中构造并执行订阅 count/page 查询。"""
conditions = media_identity_conditions(Subscribe, filters)
ids = enum_values(filters.ids)
names = enum_values(filters.names)
states = enum_values(filters.states)
usernames = enum_values(filters.usernames)
media_types = enum_values(filters.media_types)
if ids:
conditions.append(Subscribe.id.in_(ids))
if names:
conditions.append(Subscribe.name.in_(names))
if states:
conditions.append(Subscribe.state.in_(states))
if usernames:
conditions.append(Subscribe.username.in_(usernames))
if media_types:
conditions.append(Subscribe.type.in_(media_types))
if filters.season is not None:
conditions.append(Subscribe.season == filters.season)
if filters.episode_group is not None:
conditions.append(Subscribe.episode_group == filters.episode_group)
music_condition = music_type_condition(
Subscribe.music_type,
filters.music_type,
)
if music_condition is not None:
conditions.append(music_condition)
count_statement = select(func.count(Subscribe.id))
page_statement = select(Subscribe)
if conditions:
count_statement = count_statement.where(*conditions)
page_statement = page_statement.where(*conditions)
descending_order = descending(page)
if page.sort.field == QuerySortField.ID:
primary = Subscribe.id.desc() if descending_order else Subscribe.id.asc()
secondary = Subscribe.date.desc() if descending_order else Subscribe.date.asc()
else:
primary = (
Subscribe.date.desc().nullslast()
if descending_order
else Subscribe.date.asc().nullsfirst()
)
secondary = Subscribe.id.desc() if descending_order else Subscribe.id.asc()
page_statement = page_statement.order_by(primary, secondary)
return cast(
tuple[list[Subscribe], int],
execute_page(session, count_statement, page_statement, page),
)
return self._execute_sync_query(execute)
async def async_get(self, sid: int) -> Optional[Subscribe]:
"""
+96 -2
View File
@@ -1,10 +1,23 @@
from typing import List, Optional
from typing import List, Optional, cast
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.subscribehistory import SubscribeHistory
from app.db.oper.query import (
descending,
enum_values,
execute_page,
media_identity_conditions,
music_type_condition,
)
from app.schemas.query import (
QueryPageRequest,
QuerySortField,
SubscriptionHistoryFilter,
)
class SubscribeHistoryOper(DbOper):
@@ -12,6 +25,87 @@ class SubscribeHistoryOper(DbOper):
订阅历史管理。
"""
def get_by_id(self, record_id: int) -> Optional[SubscribeHistory]:
"""按稳定记录 ID 读取单条订阅历史。"""
return cast(
Optional[SubscribeHistory],
self._execute_sync_query(
lambda session: session.execute(
select(SubscribeHistory).where(SubscribeHistory.id == record_id)
).scalars().first()
),
)
def query(
self,
filters: SubscriptionHistoryFilter,
page: QueryPageRequest,
) -> tuple[list[SubscribeHistory], int]:
"""按稳定筛选和分页合同读取订阅历史记录及总数。"""
def execute(session: Session) -> tuple[list[SubscribeHistory], int]:
"""在同一会话中构造并执行订阅历史 count/page 查询。"""
conditions = media_identity_conditions(SubscribeHistory, filters)
ids = enum_values(filters.ids)
names = enum_values(filters.names)
usernames = enum_values(filters.usernames)
media_types = enum_values(filters.media_types)
if ids:
conditions.append(SubscribeHistory.id.in_(ids))
if names:
conditions.append(SubscribeHistory.name.in_(names))
if usernames:
conditions.append(SubscribeHistory.username.in_(usernames))
if media_types:
conditions.append(SubscribeHistory.type.in_(media_types))
if filters.season is not None:
conditions.append(SubscribeHistory.season == filters.season)
if filters.episode_group is not None:
conditions.append(
SubscribeHistory.episode_group == filters.episode_group
)
music_condition = music_type_condition(
SubscribeHistory.music_type,
filters.music_type,
)
if music_condition is not None:
conditions.append(music_condition)
count_statement = select(func.count(SubscribeHistory.id))
page_statement = select(SubscribeHistory)
if conditions:
count_statement = count_statement.where(*conditions)
page_statement = page_statement.where(*conditions)
descending_order = descending(page)
if page.sort.field == QuerySortField.ID:
primary = (
SubscribeHistory.id.desc()
if descending_order
else SubscribeHistory.id.asc()
)
secondary = (
SubscribeHistory.date.desc()
if descending_order
else SubscribeHistory.date.asc()
)
else:
primary = (
SubscribeHistory.date.desc().nullslast()
if descending_order
else SubscribeHistory.date.asc().nullsfirst()
)
secondary = (
SubscribeHistory.id.desc()
if descending_order
else SubscribeHistory.id.asc()
)
page_statement = page_statement.order_by(primary, secondary)
return cast(
tuple[list[SubscribeHistory], int],
execute_page(session, count_statement, page_statement, page),
)
return self._execute_sync_query(execute)
async def async_list_by_type(
self,
mtype: str,
+119 -3
View File
@@ -1,12 +1,27 @@
import time
from typing import Any, List, Optional
from typing import Any, List, Optional, cast
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.transferhistory import TransferHistory
from app.db.oper.query import (
descending,
enum_values,
execute_page,
literal_contains,
media_identity_conditions,
music_type_condition,
required_media_identity_conditions,
)
from app.schemas.query import (
QueryPageRequest,
QuerySortField,
TransferHistoryFilter,
)
from app.schemas.types import MediaSource
@@ -20,10 +35,111 @@ class TransferHistoryOper(DbOper):
获取转移历史
:param historyid: 转移历史id
"""
return self._execute_sync_query(
lambda session: TransferHistory.get(session, historyid)
return self.get_by_id(historyid)
def get_by_id(self, record_id: int) -> Optional[TransferHistory]:
"""按稳定记录 ID 读取单条整理历史。"""
return cast(
Optional[TransferHistory],
self._execute_sync_query(
lambda session: session.execute(
select(TransferHistory).where(TransferHistory.id == record_id)
).scalars().first()
),
)
def query(
self,
filters: TransferHistoryFilter,
page: QueryPageRequest,
) -> tuple[list[TransferHistory], int]:
"""按稳定筛选和分页合同读取整理历史记录及总数。"""
def execute(session: Session) -> tuple[list[TransferHistory], int]:
"""在同一会话中构造并执行整理历史 count/page 查询。"""
conditions = media_identity_conditions(TransferHistory, filters)
ids = enum_values(filters.ids)
media_types = enum_values(filters.media_types)
media_sources = enum_values(filters.media_sources)
if ids:
conditions.append(TransferHistory.id.in_(ids))
if media_types:
conditions.append(TransferHistory.type.in_(media_types))
if media_sources:
conditions.append(TransferHistory.media_source.in_(media_sources))
if filters.require_media_identity:
conditions.extend(required_media_identity_conditions(TransferHistory))
if filters.title:
conditions.append(TransferHistory.title == filters.title)
if filters.text:
conditions.append(
literal_contains(TransferHistory.title, filters.text)
| literal_contains(TransferHistory.src, filters.text)
| literal_contains(TransferHistory.dest, filters.text)
)
for column, value in (
(TransferHistory.year, filters.year),
(TransferHistory.seasons, filters.seasons),
(TransferHistory.episodes, filters.episodes),
(TransferHistory.src, filters.src),
(TransferHistory.dest, filters.dest),
(TransferHistory.download_hash, filters.download_hash),
(TransferHistory.episode_group, filters.episode_group),
):
if value is not None and value != "":
conditions.append(column == value)
if filters.status is not None:
if filters.status:
conditions.append(TransferHistory.status.is_(True))
else:
conditions.append(
or_(
TransferHistory.status.is_(False),
TransferHistory.status.is_(None),
)
)
music_condition = music_type_condition(
TransferHistory.music_type,
filters.music_type,
)
if music_condition is not None:
conditions.append(music_condition)
count_statement = select(func.count(TransferHistory.id))
page_statement = select(TransferHistory)
if conditions:
count_statement = count_statement.where(*conditions)
page_statement = page_statement.where(*conditions)
descending_order = descending(page)
if page.sort.field == QuerySortField.ID:
primary = (
TransferHistory.id.desc()
if descending_order
else TransferHistory.id.asc()
)
secondary = (
TransferHistory.date.desc()
if descending_order
else TransferHistory.date.asc()
)
else:
primary = (
TransferHistory.date.desc().nullslast()
if descending_order
else TransferHistory.date.asc().nullsfirst()
)
secondary = (
TransferHistory.id.desc()
if descending_order
else TransferHistory.id.asc()
)
page_statement = page_statement.order_by(primary, secondary)
return cast(
tuple[list[TransferHistory], int],
execute_page(session, count_statement, page_statement, page),
)
return self._execute_sync_query(execute)
async def async_get(self, historyid: int) -> Optional[TransferHistory]:
"""
异步获取转移历史。
+394
View File
@@ -0,0 +1,394 @@
"""插件只读数据查询使用的稳定筛选、分页与数据投影合同。"""
from enum import Enum
from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.schemas.common import JsonData
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource, MediaType
T = TypeVar("T")
DEFAULT_QUERY_PAGE_SIZE = 50
MAX_QUERY_PAGE_SIZE = 200
class _QueryInput(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
"""查询输入共同的严格字段合同。"""
model_config = ConfigDict(extra="forbid")
class QuerySortField(str, Enum):
"""所有公开数据查询共同支持的稳定排序字段。"""
DATE = "date"
ID = "id"
class QuerySortDirection(str, Enum):
"""公开查询的排序方向。"""
ASC = "asc"
DESC = "desc"
class QuerySort(_QueryInput):
"""声明公开查询的稳定排序字段与方向。
日期排序将空日期置于升序开头、降序末尾,并以同方向 ID 打破日期并列;ID
本身唯一,因此按 ID 排序不依赖数据库的隐式行顺序。
"""
field: QuerySortField = QuerySortField.DATE
direction: QuerySortDirection = QuerySortDirection.DESC
class QueryPageRequest(_QueryInput):
"""限制单次读取规模,并通过显式排序为跨页扫描提供稳定顺序。"""
page: int = Field(default=1, ge=1)
count: int = Field(
default=DEFAULT_QUERY_PAGE_SIZE,
ge=1,
le=MAX_QUERY_PAGE_SIZE,
)
sort: QuerySort = Field(default_factory=QuerySort)
class QueryPage(BaseModel, Generic[T]): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
"""公开查询返回的分页 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(_QueryInput):
"""允许省略身份,但显式筛选时要求来源与原生 ID 成对有效。"""
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
@model_validator(mode="after") # type: ignore[misc]
def _validate_media_identity(self) -> "MediaIdentityQuery":
"""规范化 ID,并拒绝显式半对、空白或零值身份。"""
source_provided = "media_source" in self.model_fields_set
id_provided = "media_id" in self.model_fields_set
normalized_id = str(self.media_id).strip() if self.media_id is not None else None
if source_provided != id_provided or bool(self.media_source) != bool(normalized_id):
raise ValueError("media_source 和 media_id 必须同时提供")
if normalized_id == "0":
raise ValueError("media_id 不能为 0")
object.__setattr__(self, "media_id", normalized_id)
return self
class _QuerySnapshot(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
"""只读查询 DTO 的媒体身份与脏数据归一化边界。"""
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@field_validator("media_source", mode="before") # type: ignore[misc]
@classmethod
def _normalize_source(cls, value: Any) -> Optional[MediaSource]:
"""未知旧来源不应使整页查询失败,统一降级为空身份。"""
return normalize_media_source(value)
@model_validator(mode="after") # type: ignore[misc]
def _normalize_identity_pair(self) -> "_QuerySnapshot":
"""输出中的脏半对身份按无身份处理,避免向插件传播不可用主键。"""
normalized_id = str(self.media_id).strip() if self.media_id is not None else ""
if not self.media_source or not normalized_id or normalized_id == "0":
object.__setattr__(self, "media_source", None)
object.__setattr__(self, "media_id", None)
else:
object.__setattr__(self, "media_id", normalized_id)
return self
class SubscriptionSnapshot(_QuerySnapshot):
"""当前订阅的稳定只读快照,不包含 ORM 或写入行为。"""
id: int
name: Optional[str] = None
year: Optional[str] = None
type: Optional[str] = None
keyword: Optional[str] = None
music_type: Optional[str] = None
total_tracks: Optional[int] = None
season: Optional[int] = None
poster: Optional[str] = None
backdrop: Optional[str] = None
vote: Optional[float] = None
description: Optional[str] = None
# 资源选择规则与质量约束均按宿主保存值只读返回。
filter: Optional[str] = None
include: Optional[str] = None
exclude: Optional[str] = None
quality: Optional[str] = None
resolution: Optional[str] = None
effect: Optional[str] = None
audio_quality: Optional[str] = None
audio_format: Optional[str] = None
min_bitrate: Optional[int] = None
min_bit_depth: Optional[int] = None
min_sample_rate: Optional[int] = None
# 订阅进度字段由宿主维护,插件查询不得据此直接写库。
total_episode: Optional[int] = None
start_episode: Optional[int] = None
lack_episode: Optional[int] = None
note: Optional[JsonData] = None
state: Optional[str] = None
last_update: Optional[str] = None
date: Optional[str] = None
username: Optional[str] = None
sites: Optional[list[int]] = None
downloader: Optional[str] = None
best_version: Optional[int] = None
best_version_full: Optional[int] = None
current_priority: Optional[int] = None
current_audio_format: Optional[str] = None
current_bitrate: Optional[int] = None
current_bit_depth: Optional[int] = None
current_sample_rate: Optional[int] = None
episode_priority: Optional[dict[str, int]] = None
save_path: Optional[str] = None
search_imdbid: Optional[int] = None
manual_total_episode: Optional[int] = None
custom_words: Optional[str] = None
media_category: Optional[str] = None
filter_groups: Optional[list[str]] = None
episode_group: Optional[str] = None
class SubscriptionHistorySnapshot(_QuerySnapshot):
"""订阅完成历史的稳定只读快照。"""
id: int
name: Optional[str] = None
year: Optional[str] = None
type: Optional[str] = None
keyword: Optional[str] = None
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
custom_words: Optional[str] = None
media_category: Optional[str] = None
filter_groups: Optional[list[str]] = None
episode_group: Optional[str] = None
class DownloadHistorySnapshot(_QuerySnapshot):
"""下载历史的稳定只读快照。"""
id: int
path: Optional[str] = None
type: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
music_type: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
poster: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
torrent_name: Optional[str] = None
torrent_description: Optional[str] = None
torrent_site: Optional[str] = None
userid: Optional[str] = None
username: Optional[str] = None
channel: Optional[str] = None
date: Optional[str] = None
note: Optional[JsonData] = None
media_category: Optional[str] = None
episode_group: Optional[str] = None
custom_words: Optional[str] = None
class TransferHistorySnapshot(_QuerySnapshot):
"""整理历史的稳定只读快照;内部任务结算标识不会暴露给插件。"""
id: int
src: Optional[str] = None
src_storage: Optional[str] = None
src_fileitem: Optional[JsonData] = None
dest: Optional[str] = None
dest_storage: Optional[str] = None
dest_fileitem: Optional[JsonData] = None
mode: Optional[str] = None
type: Optional[str] = None
category: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
music_type: Optional[str] = None
total_tracks: Optional[int] = None
audio_format: Optional[str] = None
audio_lossless: Optional[bool] = None
bit_depth: Optional[int] = None
sample_rate: Optional[int] = None
bitrate: Optional[int] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
status: bool = False
errmsg: Optional[str] = None
date: Optional[str] = None
files: Optional[JsonData] = None
episode_group: Optional[str] = None
@field_validator("status", mode="before") # type: ignore[misc]
@classmethod
def _normalize_status(cls, value: object) -> bool:
"""旧记录的 NULL 状态按失败处理,避免错误地向插件声明整理成功。"""
return bool(value)
class SubscriptionFilter(MediaIdentityQuery):
"""当前订阅的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配;
``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。
"""
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):
"""订阅完成历史的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配;
``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。
"""
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):
"""下载历史的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串
字段均精确匹配。``text`` 对标题和路径执行转义后的字面包含查询,不把 ``%``
或 ``_`` 解释为通配符。``music_type=recording`` 同时匹配旧 NULL 记录。
"""
ids: tuple[int, ...] = ()
media_types: tuple[MediaType, ...] = ()
title: Optional[str] = None
text: Optional[str] = None
year: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
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):
"""整理历史的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串
字段均精确匹配。``text`` 对标题、源路径和目标路径执行转义后的字面包含查询。
``require_media_identity`` 只保留来源可解析且原生 ID 非空、非零的记录;
``status=False`` 包含旧 NULL 状态,``music_type=recording`` 包含旧 NULL 类型。
"""
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",
"DownloadHistoryFilter",
"DownloadHistorySnapshot",
"MediaIdentityQuery",
"QueryPage",
"QueryPageRequest",
"QuerySort",
"QuerySortDirection",
"QuerySortField",
"SubscriptionFilter",
"SubscriptionHistorySnapshot",
"SubscriptionHistoryFilter",
"SubscriptionSnapshot",
"TransferHistoryFilter",
"TransferHistorySnapshot",
]
+273
View File
@@ -0,0 +1,273 @@
"""插件可使用的订阅与历史只读查询门面。"""
from __future__ import annotations
from typing import Any, Protocol, cast
from app.schemas.query import (
DEFAULT_QUERY_PAGE_SIZE,
MAX_QUERY_PAGE_SIZE,
DownloadHistoryFilter,
DownloadHistorySnapshot,
MediaIdentityQuery,
QueryPage,
QueryPageRequest,
QuerySort,
QuerySortDirection,
QuerySortField,
SubscriptionFilter,
SubscriptionHistoryFilter,
SubscriptionHistorySnapshot,
SubscriptionSnapshot,
TransferHistoryFilter,
TransferHistorySnapshot,
)
class _DataQueryBackend(Protocol):
"""SDK 转发所需的最小类型合同,不向插件公开应用服务实现。"""
def list_subscriptions(
self,
filters: SubscriptionFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[SubscriptionSnapshot]: ...
async def async_list_subscriptions(
self,
filters: SubscriptionFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[SubscriptionSnapshot]: ...
def get_subscription(
self,
subscription_id: int,
) -> SubscriptionSnapshot | None: ...
async def async_get_subscription(
self,
subscription_id: int,
) -> SubscriptionSnapshot | None: ...
def list_subscription_history(
self,
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[SubscriptionHistorySnapshot]: ...
async def async_list_subscription_history(
self,
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[SubscriptionHistorySnapshot]: ...
def get_subscription_history(
self,
history_id: int,
) -> SubscriptionHistorySnapshot | None: ...
async def async_get_subscription_history(
self,
history_id: int,
) -> SubscriptionHistorySnapshot | None: ...
def list_download_history(
self,
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[DownloadHistorySnapshot]: ...
async def async_list_download_history(
self,
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[DownloadHistorySnapshot]: ...
def get_download_history(
self,
history_id: int,
) -> DownloadHistorySnapshot | None: ...
async def async_get_download_history(
self,
history_id: int,
) -> DownloadHistorySnapshot | None: ...
def list_transfer_history(
self,
filters: TransferHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[TransferHistorySnapshot]: ...
async def async_list_transfer_history(
self,
filters: TransferHistoryFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[TransferHistorySnapshot]: ...
def get_transfer_history(
self,
history_id: int,
) -> TransferHistorySnapshot | None: ...
async def async_get_transfer_history(
self,
history_id: int,
) -> TransferHistorySnapshot | None: ...
def _service() -> _DataQueryBackend:
"""获取启动阶段登记的查询服务,避免把应用服务暴露为 SDK 合同。"""
from app.application.query import (
get_configured_data_query_service,
)
return cast(_DataQueryBackend, get_configured_data_query_service())
def list_subscriptions(
filters: SubscriptionFilter | dict[str, Any] | None = None,
page: QueryPageRequest | dict[str, Any] | None = None,
) -> QueryPage[SubscriptionSnapshot]:
"""同步分页读取订阅 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[SubscriptionSnapshot]:
"""异步分页读取订阅 DTO。"""
return await _service().async_list_subscriptions(filters, page)
def get_subscription(subscription_id: int) -> SubscriptionSnapshot | None:
"""同步按 ID 读取订阅 DTO。"""
return _service().get_subscription(subscription_id)
async def async_get_subscription(
subscription_id: int,
) -> SubscriptionSnapshot | 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[SubscriptionHistorySnapshot]:
"""同步分页读取订阅完成历史 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[SubscriptionHistorySnapshot]:
"""异步分页读取订阅完成历史 DTO。"""
return await _service().async_list_subscription_history(filters, page)
def get_subscription_history(history_id: int) -> SubscriptionHistorySnapshot | None:
"""同步按 ID 读取订阅完成历史 DTO。"""
return _service().get_subscription_history(history_id)
async def async_get_subscription_history(
history_id: int,
) -> SubscriptionHistorySnapshot | 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[DownloadHistorySnapshot]:
"""同步分页读取下载历史 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[DownloadHistorySnapshot]:
"""异步分页读取下载历史 DTO。"""
return await _service().async_list_download_history(filters, page)
def get_download_history(history_id: int) -> DownloadHistorySnapshot | None:
"""同步按 ID 读取下载历史 DTO。"""
return _service().get_download_history(history_id)
async def async_get_download_history(
history_id: int,
) -> DownloadHistorySnapshot | 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[TransferHistorySnapshot]:
"""同步分页读取整理历史 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[TransferHistorySnapshot]:
"""异步分页读取整理历史 DTO。"""
return await _service().async_list_transfer_history(filters, page)
def get_transfer_history(history_id: int) -> TransferHistorySnapshot | None:
"""同步按 ID 读取整理历史 DTO。"""
return _service().get_transfer_history(history_id)
async def async_get_transfer_history(
history_id: int,
) -> TransferHistorySnapshot | None:
"""异步按 ID 读取整理历史 DTO。"""
return await _service().async_get_transfer_history(history_id)
__all__ = [
"DEFAULT_QUERY_PAGE_SIZE",
"MAX_QUERY_PAGE_SIZE",
"DownloadHistoryFilter",
"DownloadHistorySnapshot",
"MediaIdentityQuery",
"QueryPage",
"QueryPageRequest",
"QuerySort",
"QuerySortDirection",
"QuerySortField",
"SubscriptionFilter",
"SubscriptionHistoryFilter",
"SubscriptionHistorySnapshot",
"SubscriptionSnapshot",
"TransferHistoryFilter",
"TransferHistorySnapshot",
"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
@@ -88,6 +88,10 @@ from app.application.outbox import (
validate_durable_event_handlers,
)
from app.application.plugin.runtime import configure_plugin_runtime
from app.application.query import (
DataQueryService,
configure_data_query_service,
)
from app.application.security.auth import AuthService, build_superuser_token_payload, configure_auth_service
from app.application.security.passkey import PasskeyService, configure_passkey_service
from app.application.security.url import close_image_proxy_block_log_coalescer
@@ -111,6 +115,7 @@ from app.command import CommandChain
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.adapters.download import TransactionalDownloadFailureRepository
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
from app.db.adapters.site import TransactionalSiteRepository
from app.db.adapters.subscription import TransactionalSubscribeWriter
from app.db.adapters.transaction import TransactionalWriteRunner
@@ -758,6 +763,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,
+3 -3
View File
@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 845 / 6,902 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 850 / 6,944 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,809 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 878 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 78.79%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
| Ruff 历史诊断 | 875 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 78.89%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
+3 -3
View File
@@ -697,15 +697,15 @@ flowchart LR
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
- 延迟导入不被接受为隐藏循环依赖的手段。
### 10.1 2026-08-26 当前收口状态与后续边界
### 10.1 2026-08-27 当前收口状态与后续边界
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 845 |
| 内部导入边 | 6,902 |
| Python 模块 | 850 |
| 内部导入边 | 6,944 |
| 非平凡 SCC | 2`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
| Direct egress | 6612 条待迁移债务,54 条精确 containment |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
+1 -1
View File
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 878 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 875 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 9978,
"percent": 78.79,
"statements": 12664
"covered_lines": 10084,
"percent": 78.89,
"statements": 12782
},
"domain": {
"covered_lines": 3392,
+50 -3
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6902,
"edge_sha256": "e2adb079d1df7415b81cbfa8358536e29e06276c2cb5af3a6f6c634e6f58fb20",
"edge_count": 6944,
"edge_sha256": "9f6be750c55150ef9e061f54ade997c328561b7950f6ec44bce6ada373d01f3e",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -4259,6 +4259,10 @@
"app.application.plugin.transaction -> app.application.database",
"app.application.plugin.transaction -> app.application.plugin",
"app.application.plugin.transaction -> app.application.plugin.identity",
"app.application.query -> app.application",
"app.application.query -> app.application.database",
"app.application.query -> app.schemas",
"app.application.query -> app.schemas.query",
"app.application.recognition -> app.application",
"app.application.recognition -> app.application.configuration",
"app.application.recognition -> app.schemas",
@@ -5159,6 +5163,16 @@
"app.db.adapters.plugininstallation -> app.db.models",
"app.db.adapters.plugininstallation -> app.db.models.pluginidentity",
"app.db.adapters.plugininstallation -> app.db.models.plugininstallation",
"app.db.adapters.query -> app.application",
"app.db.adapters.query -> app.application.query",
"app.db.adapters.query -> app.db",
"app.db.adapters.query -> app.db.oper",
"app.db.adapters.query -> app.db.oper.downloadhistory",
"app.db.adapters.query -> app.db.oper.subscribe",
"app.db.adapters.query -> app.db.oper.subscribehistory",
"app.db.adapters.query -> app.db.oper.transferhistory",
"app.db.adapters.query -> app.schemas",
"app.db.adapters.query -> app.schemas.query",
"app.db.adapters.site -> app.db",
"app.db.adapters.site -> app.db.oper",
"app.db.adapters.site -> app.db.oper.site",
@@ -5341,7 +5355,10 @@
"app.db.oper.downloadhistory -> app.db.base",
"app.db.oper.downloadhistory -> app.db.models",
"app.db.oper.downloadhistory -> app.db.models.downloadhistory",
"app.db.oper.downloadhistory -> app.db.oper",
"app.db.oper.downloadhistory -> app.db.oper.query",
"app.db.oper.downloadhistory -> app.schemas",
"app.db.oper.downloadhistory -> app.schemas.query",
"app.db.oper.downloadhistory -> app.schemas.types",
"app.db.oper.mediaserver -> app.db",
"app.db.oper.mediaserver -> app.db.base",
@@ -5366,6 +5383,11 @@
"app.db.oper.pluginidentity -> app.db.base",
"app.db.oper.pluginidentity -> app.db.models",
"app.db.oper.pluginidentity -> app.db.models.pluginidentity",
"app.db.oper.query -> app.db",
"app.db.oper.query -> app.db.base",
"app.db.oper.query -> app.schemas",
"app.db.oper.query -> app.schemas.query",
"app.db.oper.query -> app.schemas.types",
"app.db.oper.site -> app.db",
"app.db.oper.site -> app.db.base",
"app.db.oper.site -> app.db.models",
@@ -5381,12 +5403,19 @@
"app.db.oper.subscribe -> app.db.models",
"app.db.oper.subscribe -> app.db.models.subscribe",
"app.db.oper.subscribe -> app.db.models.subscribehistory",
"app.db.oper.subscribe -> app.db.oper",
"app.db.oper.subscribe -> app.db.oper.query",
"app.db.oper.subscribe -> app.schemas",
"app.db.oper.subscribe -> app.schemas.query",
"app.db.oper.subscribe -> app.schemas.types",
"app.db.oper.subscribehistory -> app.db",
"app.db.oper.subscribehistory -> app.db.base",
"app.db.oper.subscribehistory -> app.db.models",
"app.db.oper.subscribehistory -> app.db.models.subscribehistory",
"app.db.oper.subscribehistory -> app.db.oper",
"app.db.oper.subscribehistory -> app.db.oper.query",
"app.db.oper.subscribehistory -> app.schemas",
"app.db.oper.subscribehistory -> app.schemas.query",
"app.db.oper.systemconfig -> app.db",
"app.db.oper.systemconfig -> app.db.base",
"app.db.oper.systemconfig -> app.db.models",
@@ -5403,7 +5432,10 @@
"app.db.oper.transferhistory -> app.db.base",
"app.db.oper.transferhistory -> app.db.models",
"app.db.oper.transferhistory -> app.db.models.transferhistory",
"app.db.oper.transferhistory -> app.db.oper",
"app.db.oper.transferhistory -> app.db.oper.query",
"app.db.oper.transferhistory -> app.schemas",
"app.db.oper.transferhistory -> app.schemas.query",
"app.db.oper.transferhistory -> app.schemas.types",
"app.db.oper.transferpending -> app.db",
"app.db.oper.transferpending -> app.db.base",
@@ -7631,6 +7663,10 @@
"app.schemas.openai -> app.schemas.common",
"app.schemas.plugin -> app.schemas",
"app.schemas.plugin -> app.schemas.common",
"app.schemas.query -> app.schemas",
"app.schemas.query -> app.schemas.common",
"app.schemas.query -> app.schemas.media",
"app.schemas.query -> app.schemas.types",
"app.schemas.response -> app.runtime",
"app.schemas.response -> app.runtime.localization",
"app.schemas.search -> app.schemas",
@@ -7755,6 +7791,10 @@
"app.sdk.plugins -> app.runtime.extensions",
"app.sdk.plugins -> app.runtime.extensions.module_manager",
"app.sdk.plugins -> app.runtime.extensions.plugin_manager",
"app.sdk.queries -> app.application",
"app.sdk.queries -> app.application.query",
"app.sdk.queries -> app.schemas",
"app.sdk.queries -> app.schemas.query",
"app.sdk.security -> app.adapters",
"app.sdk.security -> app.adapters.web",
"app.sdk.security -> app.adapters.web.security",
@@ -7948,6 +7988,7 @@
"app.startup.initializers.modules -> app.application.plugin",
"app.startup.initializers.modules -> app.application.plugin.runtime",
"app.startup.initializers.modules -> app.application.plugin.transaction",
"app.startup.initializers.modules -> app.application.query",
"app.startup.initializers.modules -> app.application.security",
"app.startup.initializers.modules -> app.application.security.auth",
"app.startup.initializers.modules -> app.application.security.passkey",
@@ -7982,6 +8023,7 @@
"app.startup.initializers.modules -> app.db.adapters.outbox",
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
"app.startup.initializers.modules -> app.db.adapters.plugininstallation",
"app.startup.initializers.modules -> app.db.adapters.query",
"app.startup.initializers.modules -> app.db.adapters.site",
"app.startup.initializers.modules -> app.db.adapters.subscription",
"app.startup.initializers.modules -> app.db.adapters.transaction",
@@ -8347,7 +8389,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 845,
"module_count": 850,
"modules": [
"app",
"app.adapters",
@@ -8655,6 +8697,7 @@
"app.application.plugin.runtime",
"app.application.plugin.source",
"app.application.plugin.transaction",
"app.application.query",
"app.application.recognition",
"app.application.rss",
"app.application.rules",
@@ -8743,6 +8786,7 @@
"app.db.adapters.outbox",
"app.db.adapters.pluginidentity",
"app.db.adapters.plugininstallation",
"app.db.adapters.query",
"app.db.adapters.site",
"app.db.adapters.subscription",
"app.db.adapters.transaction",
@@ -8795,6 +8839,7 @@
"app.db.oper.passkey",
"app.db.oper.plugindata",
"app.db.oper.pluginidentity",
"app.db.oper.query",
"app.db.oper.site",
"app.db.oper.subscribe",
"app.db.oper.subscribehistory",
@@ -9115,6 +9160,7 @@
"app.schemas.notification",
"app.schemas.openai",
"app.schemas.plugin",
"app.schemas.query",
"app.schemas.response",
"app.schemas.rule",
"app.schemas.search",
@@ -9147,6 +9193,7 @@
"app.sdk.media",
"app.sdk.network",
"app.sdk.plugins",
"app.sdk.queries",
"app.sdk.security",
"app.sdk.services",
"app.sdk.string",
-9
View File
@@ -458,18 +458,12 @@
"app/db/models/workflow.py": {
"I001": 1
},
"app/db/oper/downloadhistory.py": {
"I001": 1
},
"app/db/oper/message.py": {
"I001": 1
},
"app/db/oper/site.py": {
"I001": 1
},
"app/db/oper/subscribe.py": {
"I001": 1
},
"app/db/oper/systemconfig.py": {
"I001": 1
},
@@ -1113,9 +1107,6 @@
"tests/test_agent_lazy_runtime_boundary.py": {
"F401": 1
},
"tests/test_agent_lifecycle.py": {
"I001": 1
},
"tests/test_agent_llm_capability.py": {
"I001": 1
},
@@ -10188,6 +10188,168 @@
"target": "app.runtime.extensions.plugin_manager.PluginManager"
}
],
"app.sdk.queries": [
{
"kind": "import",
"name": "DEFAULT_QUERY_PAGE_SIZE",
"target": "app.schemas.query.DEFAULT_QUERY_PAGE_SIZE"
},
{
"kind": "import",
"name": "DownloadHistoryFilter",
"target": "app.schemas.query.DownloadHistoryFilter"
},
{
"kind": "import",
"name": "DownloadHistorySnapshot",
"target": "app.schemas.query.DownloadHistorySnapshot"
},
{
"kind": "import",
"name": "MAX_QUERY_PAGE_SIZE",
"target": "app.schemas.query.MAX_QUERY_PAGE_SIZE"
},
{
"kind": "import",
"name": "MediaIdentityQuery",
"target": "app.schemas.query.MediaIdentityQuery"
},
{
"kind": "import",
"name": "QueryPage",
"target": "app.schemas.query.QueryPage"
},
{
"kind": "import",
"name": "QueryPageRequest",
"target": "app.schemas.query.QueryPageRequest"
},
{
"kind": "import",
"name": "QuerySort",
"target": "app.schemas.query.QuerySort"
},
{
"kind": "import",
"name": "QuerySortDirection",
"target": "app.schemas.query.QuerySortDirection"
},
{
"kind": "import",
"name": "QuerySortField",
"target": "app.schemas.query.QuerySortField"
},
{
"kind": "import",
"name": "SubscriptionFilter",
"target": "app.schemas.query.SubscriptionFilter"
},
{
"kind": "import",
"name": "SubscriptionHistoryFilter",
"target": "app.schemas.query.SubscriptionHistoryFilter"
},
{
"kind": "import",
"name": "SubscriptionHistorySnapshot",
"target": "app.schemas.query.SubscriptionHistorySnapshot"
},
{
"kind": "import",
"name": "SubscriptionSnapshot",
"target": "app.schemas.query.SubscriptionSnapshot"
},
{
"kind": "import",
"name": "TransferHistoryFilter",
"target": "app.schemas.query.TransferHistoryFilter"
},
{
"kind": "import",
"name": "TransferHistorySnapshot",
"target": "app.schemas.query.TransferHistorySnapshot"
},
{
"kind": "AsyncFunctionDef",
"name": "async_get_download_history",
"target": ""
},
{
"kind": "AsyncFunctionDef",
"name": "async_get_subscription",
"target": ""
},
{
"kind": "AsyncFunctionDef",
"name": "async_get_subscription_history",
"target": ""
},
{
"kind": "AsyncFunctionDef",
"name": "async_get_transfer_history",
"target": ""
},
{
"kind": "AsyncFunctionDef",
"name": "async_list_download_history",
"target": ""
},
{
"kind": "AsyncFunctionDef",
"name": "async_list_subscription_history",
"target": ""
},
{
"kind": "AsyncFunctionDef",
"name": "async_list_subscriptions",
"target": ""
},
{
"kind": "AsyncFunctionDef",
"name": "async_list_transfer_history",
"target": ""
},
{
"kind": "FunctionDef",
"name": "get_download_history",
"target": ""
},
{
"kind": "FunctionDef",
"name": "get_subscription",
"target": ""
},
{
"kind": "FunctionDef",
"name": "get_subscription_history",
"target": ""
},
{
"kind": "FunctionDef",
"name": "get_transfer_history",
"target": ""
},
{
"kind": "FunctionDef",
"name": "list_download_history",
"target": ""
},
{
"kind": "FunctionDef",
"name": "list_subscription_history",
"target": ""
},
{
"kind": "FunctionDef",
"name": "list_subscriptions",
"target": ""
},
{
"kind": "FunctionDef",
"name": "list_transfer_history",
"target": ""
}
],
"app.sdk.security": [
{
"kind": "import",
@@ -6,7 +6,7 @@
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 388,
"loaded_app_module_count": 392,
"max_ms": 1035.341,
"median_ms": 993.245,
"min_ms": 991.998,
@@ -17,7 +17,7 @@
]
},
"app.factory": {
"loaded_app_module_count": 400,
"loaded_app_module_count": 404,
"max_ms": 1006.172,
"median_ms": 1005.88,
"min_ms": 1003.841,
@@ -28,7 +28,7 @@
]
},
"app.main": {
"loaded_app_module_count": 402,
"loaded_app_module_count": 406,
"max_ms": 1085.652,
"median_ms": 1055.994,
"min_ms": 1043.212,
+11 -5
View File
@@ -4,18 +4,20 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import app.agent.orchestrator as agent_module
from app.application.messaging.agent import (
create_web_agent_background_task,
shutdown_web_agent_background_tasks,
)
from app.agent.memory import MemoryManager
from app.agent.orchestrator import (
AGENT_SESSION_QUEUE_MAX_SIZE,
AgentManager,
AgentManagerQueueFullError,
AgentManagerUnavailableError,
)
from app.agent.memory import MemoryManager
from app.agent.tools.base import reopen_blocking_executors
from app.application import query as query_application
from app.application.messaging.agent import (
create_web_agent_background_task,
shutdown_web_agent_background_tasks,
)
from app.sdk import queries as query_sdk
from app.startup.initializers import agent as agent_initializer
from app.startup.initializers import modules as modules_initializer
@@ -196,12 +198,16 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
check_auth = MagicMock()
monkeypatch.setattr(modules_initializer, "start_frontend", start_frontend)
monkeypatch.setattr(modules_initializer, "check_auth", check_auth)
monkeypatch.setattr(query_application, "_configured_data_query_service", None)
try:
runtime = await modules_initializer.init_modules()
assert runtime.workflow.system_config() is (
modules_initializer.get_configured_system_config()
)
query_page = await query_sdk.async_list_subscriptions({"ids": [-1]})
assert query_page.items == []
assert query_page.total == 0
finally:
await modules_initializer.stop_database_worker()
+12 -5
View File
@@ -396,13 +396,20 @@ def test_plugin_settlement_cannot_bypass_task_registry_shutdown_budget(
lifecycle.init_extra.side_effect = settle_plugins
async def run_lifespan() -> None:
"""确认 context 能由 TaskRegistry 的失败结果立即结束"""
async with lifecycle.lifespan(FastAPI()):
"""仅对关闭阶段计时,避免覆盖率启动开销污染停机预算断言"""
lifespan_context = lifecycle.lifespan(FastAPI())
await lifespan_context.__aenter__()
try:
await started.wait()
release.set()
await asyncio.sleep(0)
await asyncio.wait_for(
lifespan_context.__aexit__(None, None, None),
timeout=0.5,
)
finally:
release.set()
await asyncio.sleep(0)
asyncio.run(asyncio.wait_for(run_lifespan(), timeout=0.5))
asyncio.run(run_lifespan())
shutdown.assert_awaited_once_with(timeout_seconds=30.0)
for name, step in shutdown_steps.items():
+917
View File
@@ -0,0 +1,917 @@
"""统一插件只读查询 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 types import SimpleNamespace
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.query import (
DownloadHistoryFilter,
DownloadHistorySnapshot,
QueryPage,
QueryPageRequest,
QuerySort,
QuerySortDirection,
QuerySortField,
SubscriptionFilter,
SubscriptionHistoryFilter,
SubscriptionHistorySnapshot,
SubscriptionSnapshot,
TransferHistoryFilter,
TransferHistorySnapshot,
)
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 query as data_query_module
from app.application.query import DataQueryService
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
from app.db.session import SessionFactory
from app.sdk import queries as sdk
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,
manual_total_episode: int | None = 0,
) -> 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,
manual_total_episode=manual_total_episode,
)
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 | None = 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",
manual_total_episode=1,
)
)
subscribe_history = db.add(_subscribe_history("订阅历史 DTO", media_id="dto-sub-history"))
download_history = db.add(_download_history("下载历史 DTO", media_id="dto-download", path="/dto/download"))
transfer_history = db.add(
_transfer_history(
"整理历史 DTO",
media_id="dto-transfer",
src="/dto/src",
dest="/dto/dest",
)
)
_assert_projected_page(
subscription_page := sdk.list_subscriptions(SubscriptionFilter(media_source=TMDB, media_id="dto-sub")),
SubscriptionSnapshot,
SubscribeModel,
subscribe.id,
)
assert subscription_page.items[0].manual_total_episode == 1
_assert_projected_page(
sdk.list_subscription_history(
SubscriptionHistoryFilter(
media_source=TMDB,
media_id="dto-sub-history",
)
),
SubscriptionHistorySnapshot,
SubscribeHistoryModel,
subscribe_history.id,
)
_assert_projected_page(
sdk.list_download_history(DownloadHistoryFilter(media_source=TMDB, media_id="dto-download")),
DownloadHistorySnapshot,
DownloadHistoryModel,
download_history.id,
)
_assert_projected_page(
sdk.list_transfer_history(TransferHistoryFilter(media_source=TMDB, media_id="dto-transfer")),
TransferHistorySnapshot,
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 Film",
text="match",
year="2026",
seasons="S02",
episodes="E03",
path="/combo/match.mkv",
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 Combo",
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]
def test_structured_text_fields_are_exact_and_text_search_is_explicit(db, query_sdk):
"""结构化字段保持精确匹配,只有 text 承担转义后的模糊搜索。"""
sdk, _executor = query_sdk
db.add(
_download_history(
"Exact Film Extended",
media_id="exact-download",
path="/exact/extended.mkv",
),
_download_history(
"Exact Film",
media_id="exact-download",
path="/exact/base.mkv",
),
_transfer_history(
"Exact Transfer Extended",
media_id="exact-transfer",
src="/exact/extended-src.mkv",
dest="/exact/extended-dest.mkv",
),
_transfer_history(
"Exact Transfer",
media_id="exact-transfer",
src="/exact/base-src.mkv",
dest="/exact/base-dest.mkv",
),
)
exact_downloads = sdk.list_download_history(
DownloadHistoryFilter(
media_source=TMDB,
media_id="exact-download",
title="Exact Film",
)
)
assert [item.path for item in exact_downloads.items] == ["/exact/base.mkv"]
fuzzy_downloads = sdk.list_download_history(
DownloadHistoryFilter(
media_source=TMDB,
media_id="exact-download",
text="extended",
)
)
assert [item.path for item in fuzzy_downloads.items] == ["/exact/extended.mkv"]
exact_transfers = sdk.list_transfer_history(
TransferHistoryFilter(
media_source=TMDB,
media_id="exact-transfer",
title="Exact Transfer",
)
)
assert [item.src for item in exact_transfers.items] == ["/exact/base-src.mkv"]
fuzzy_transfers = sdk.list_transfer_history(
TransferHistoryFilter(
media_source=TMDB,
media_id="exact-transfer",
text="extended-dest",
)
)
assert [item.src for item in fuzzy_transfers.items] == ["/exact/extended-src.mkv"]
def test_snapshots_normalize_legacy_identity_and_transfer_status():
"""旧半对身份和 NULL 整理状态不得使分页投影失败或产生假成功。"""
dirty_transfer = SimpleNamespace(
id=1,
media_source=TMDB,
media_id=" ",
status=None,
)
snapshot = TransferHistorySnapshot.model_validate(dirty_transfer)
assert snapshot.media_source is None
assert snapshot.media_id is None
assert snapshot.status is False
def test_transfer_failure_filter_includes_legacy_null_status(db, query_sdk):
"""失败筛选与快照语义一致,包含状态尚未回填的旧整理记录。"""
sdk, _executor = query_sdk
legacy_row = db.add(
_transfer_history(
"Legacy null status",
media_id="legacy-status",
src="/legacy/status-src.mkv",
dest="/legacy/status-dest.mkv",
status=None,
)
)
legacy_row.status = None
db.session.commit()
page = sdk.list_transfer_history(
TransferHistoryFilter(
media_source=TMDB,
media_id="legacy-status",
status=False,
)
)
assert [item.id for item in page.items] == [legacy_row.id]
assert page.items[0].status is False
def test_transfer_required_identity_excludes_unparseable_legacy_source(db, query_sdk):
"""有效身份筛选不得返回随后会被快照降级为空身份的脏来源。"""
sdk, _executor = query_sdk
valid_row = db.add(
_transfer_history(
"Valid dynamic source",
media_id="valid-dynamic",
src="/valid/source.mkv",
dest="/valid/dest.mkv",
)
)
invalid_row = db.add(
_transfer_history(
"Invalid legacy source",
media_id="invalid-source",
src="/invalid/source.mkv",
dest="/invalid/dest.mkv",
)
)
valid_row.media_source = "plugin-source"
invalid_row.media_source = "invalid!"
db.session.commit()
page = sdk.list_transfer_history(
TransferHistoryFilter(
ids=(valid_row.id, invalid_row.id),
require_media_identity=True,
)
)
assert [item.id for item in page.items] == [valid_row.id]
assert page.items[0].media_source == MediaSource("plugin-source")
def test_query_snapshots_are_owned_by_the_sdk_contract_module():
"""公开查询返回值由独立快照定义,不复用宿主写入或 API 响应模型。"""
snapshots = (
SubscriptionSnapshot,
SubscriptionHistorySnapshot,
DownloadHistorySnapshot,
TransferHistorySnapshot,
)
assert all(snapshot.__module__ == "app.schemas.query" for snapshot in snapshots)
@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_get_projects_records_and_async_uses_executor(db, query_sdk):
"""四类 getter 均返回 DTO,异步入口与同步一致且经过数据库 executor。"""
sdk, executor = query_sdk
subscribe = db.add(_subscribe("Getter subscribe", media_id="getter-subscription"))
subscribe_history = db.add(
_subscribe_history(
"Getter subscription history",
media_id="getter-subscription-history",
)
)
download = db.add(
_download_history(
"Getter download history",
media_id="getter-download",
path="/getter/download",
)
)
transfer = db.add(
_transfer_history(
"Getter transfer history",
media_id="getter-transfer",
src="/getter/src",
dest="/getter/dest",
)
)
caller_thread_id = threading.get_ident()
cases = (
(
sdk.get_subscription,
sdk.async_get_subscription,
subscribe,
SubscriptionSnapshot,
),
(
sdk.get_subscription_history,
sdk.async_get_subscription_history,
subscribe_history,
SubscriptionHistorySnapshot,
),
(
sdk.get_download_history,
sdk.async_get_download_history,
download,
DownloadHistorySnapshot,
),
(
sdk.get_transfer_history,
sdk.async_get_transfer_history,
transfer,
TransferHistorySnapshot,
),
)
for sync_call, async_call, model, snapshot_type in cases:
sync_item = sync_call(model.id)
calls_before = executor.calls
async_item = asyncio.run(async_call(model.id))
assert async_item == sync_item
assert isinstance(sync_item, snapshot_type)
assert not isinstance(sync_item, type(model))
assert sync_item.id == model.id
assert executor.calls == calls_before + 1
assert executor.worker_thread_ids
assert all(thread_id != caller_thread_id for thread_id in executor.worker_thread_ids)
def test_sdk_sync_async_semantics_match_and_async_uses_executor(db, query_sdk):
"""四个查询门面的异步结果与同步一致,并交给 executor 线程。"""
sdk, executor = query_sdk
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"