mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""订阅应用契约与写用例。"""
|
||||
@@ -0,0 +1,67 @@
|
||||
"""订阅编排共享的媒体元数据与身份契约。"""
|
||||
|
||||
from typing import Optional, Protocol, Union
|
||||
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas.media import build_media_key, resolve_media_identity
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||
|
||||
|
||||
class SubscribeSnapshot(Protocol):
|
||||
"""构造订阅媒体契约所需的最小只读字段集合。"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
year: Optional[str]
|
||||
season: Optional[int]
|
||||
media_source: object
|
||||
media_id: object
|
||||
music_type: Optional[str]
|
||||
total_tracks: Optional[int]
|
||||
|
||||
|
||||
def build_subscribe_meta(subscribe: SubscribeSnapshot) -> MetaBase:
|
||||
"""按订阅快照构造主程序链路共用的媒体元数据。"""
|
||||
if subscribe.type == MediaType.MUSIC.value:
|
||||
is_album = getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM
|
||||
return MetaMusic(
|
||||
title=subscribe.name,
|
||||
album=subscribe.name if is_album else None,
|
||||
year=subscribe.year,
|
||||
total_tracks=(
|
||||
getattr(subscribe, "total_tracks", None) if is_album else None
|
||||
),
|
||||
media_source=subscribe.media_source,
|
||||
media_id=(
|
||||
str(subscribe.media_id)
|
||||
if subscribe.media_id is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season
|
||||
meta.type = MediaType(subscribe.type)
|
||||
meta.media_source = subscribe.media_source
|
||||
meta.media_id = subscribe.media_id
|
||||
return meta
|
||||
|
||||
|
||||
def subscribe_media_key(
|
||||
subscribe: SubscribeSnapshot,
|
||||
) -> Union[str, int, None]:
|
||||
"""返回订阅缺失集映射使用的稳定媒体键。"""
|
||||
media_source, media_id = resolve_media_identity(media=subscribe)
|
||||
return build_media_key(media_source, media_id) or media_id
|
||||
|
||||
|
||||
def subscribe_media_keys(subscribe: SubscribeSnapshot) -> list[Union[str, int]]:
|
||||
"""返回缺失集缓存可识别的规范媒体键与旧纯 ID 键。"""
|
||||
media_source, media_id = resolve_media_identity(media=subscribe)
|
||||
candidates = [
|
||||
build_media_key(media_source, media_id),
|
||||
media_id,
|
||||
]
|
||||
return [candidate for candidate in candidates if candidate not in (None, "")]
|
||||
@@ -0,0 +1,117 @@
|
||||
"""订阅删除应用用例及其依赖端口。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Mapping, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscribeDeletionActor:
|
||||
"""执行订阅删除的用户身份。"""
|
||||
|
||||
username: str
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscribeDeletionCandidate:
|
||||
"""删除前读取出的订阅快照,不向应用层暴露 ORM 对象。"""
|
||||
|
||||
subscribe_id: int
|
||||
username: str | None
|
||||
event_payload: Mapping[str, object]
|
||||
|
||||
|
||||
class SubscribeDeletionRepository(Protocol):
|
||||
"""订阅删除用例需要的最小数据访问端口。"""
|
||||
|
||||
async def get_candidate(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> SubscribeDeletionCandidate | None:
|
||||
"""读取订阅及删除事件所需的稳定快照。"""
|
||||
...
|
||||
|
||||
async def stage_delete(self, subscribe_id: int) -> None:
|
||||
"""把已读取的订阅登记为待删除,但不自行提交事务。"""
|
||||
...
|
||||
|
||||
|
||||
class AsyncUnitOfWork(Protocol):
|
||||
"""订阅写用例使用的异步事务端口。"""
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""提交当前事务。"""
|
||||
...
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""回滚当前事务。"""
|
||||
...
|
||||
|
||||
|
||||
SubscribeDeletedPublisher = Callable[
|
||||
[int, Mapping[str, object]],
|
||||
Awaitable[None],
|
||||
]
|
||||
SubscribeDeletedReporter = Callable[[Mapping[str, object]], object]
|
||||
|
||||
|
||||
class DeleteSubscribeCommand:
|
||||
"""按权限删除订阅,并在提交成功后依次发送事件和统计上报。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscribeDeletionRepository,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
report_deleted: SubscribeDeletedReporter,
|
||||
) -> None:
|
||||
"""注入数据访问、事务与提交后副作用端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._publish_deleted = publish_deleted
|
||||
self._report_deleted = report_deleted
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> bool:
|
||||
"""
|
||||
删除当前用户可访问的订阅。
|
||||
|
||||
返回 False 表示订阅不存在或无权访问;该结果由 API 映射为历史兼容的成功响应。
|
||||
提交后的事件与上报保持原有顺序,任一副作用失败都会继续向调用方抛出。
|
||||
"""
|
||||
candidate = await self._repository.get_candidate(subscribe_id)
|
||||
if not self._can_delete(candidate, actor):
|
||||
return False
|
||||
|
||||
await self._repository.stage_delete(subscribe_id)
|
||||
try:
|
||||
await self._unit_of_work.commit()
|
||||
except Exception:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
event_payload = dict(candidate.event_payload)
|
||||
await self._publish_deleted(subscribe_id, event_payload)
|
||||
self._report_deleted(
|
||||
{
|
||||
"media_source": event_payload.get("media_source"),
|
||||
"media_id": event_payload.get("media_id"),
|
||||
"season": event_payload.get("season"),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _can_delete(
|
||||
candidate: SubscribeDeletionCandidate | None,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> bool:
|
||||
"""判断用户是否拥有目标订阅的删除权限。"""
|
||||
if candidate is None:
|
||||
return False
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
@@ -0,0 +1,98 @@
|
||||
"""按媒体身份批量删除订阅的应用用例。"""
|
||||
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.application.subscription.delete import (
|
||||
AsyncUnitOfWork,
|
||||
SubscribeDeletedPublisher,
|
||||
SubscribeDeletionActor,
|
||||
SubscribeDeletionCandidate,
|
||||
)
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class SubscribeIdentityDeletionRepository(Protocol):
|
||||
"""按媒体身份删除订阅所需的数据访问端口。"""
|
||||
|
||||
async def list_candidates_by_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: int | None,
|
||||
music_type: str | None,
|
||||
) -> list[SubscribeDeletionCandidate]:
|
||||
"""读取匹配媒体身份的去重订阅快照。"""
|
||||
...
|
||||
|
||||
async def delete(self, subscribe_id: int) -> None:
|
||||
"""把指定订阅登记为待删除。"""
|
||||
...
|
||||
|
||||
|
||||
SubscribeDeletionEventErrorHandler = Callable[[int, Exception], None]
|
||||
|
||||
|
||||
class DeleteSubscriptionsByIdentityCommand:
|
||||
"""按媒体身份删除当前用户可访问的全部订阅。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscribeIdentityDeletionRepository,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
handle_event_error: SubscribeDeletionEventErrorHandler,
|
||||
) -> None:
|
||||
"""注入数据访问、事务、事件和事件错误处理端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._publish_deleted = publish_deleted
|
||||
self._handle_event_error = handle_event_error
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: int | None,
|
||||
music_type: str | None,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> int:
|
||||
"""删除匹配订阅,并在提交后逐条发送兼容事件。"""
|
||||
candidates = await self._repository.list_candidates_by_identity(
|
||||
media_source,
|
||||
media_id,
|
||||
season,
|
||||
music_type,
|
||||
)
|
||||
deletions = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if self._can_delete(candidate, actor)
|
||||
]
|
||||
for candidate in deletions:
|
||||
await self._repository.stage_delete(candidate.subscribe_id)
|
||||
|
||||
try:
|
||||
await self._unit_of_work.commit()
|
||||
except Exception:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
for candidate in deletions:
|
||||
try:
|
||||
await self._publish_deleted(
|
||||
candidate.subscribe_id,
|
||||
dict(candidate.event_payload),
|
||||
)
|
||||
except Exception as error:
|
||||
self._handle_event_error(candidate.subscribe_id, error)
|
||||
return len(deletions)
|
||||
|
||||
@staticmethod
|
||||
def _can_delete(
|
||||
candidate: SubscribeDeletionCandidate,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> bool:
|
||||
"""判断用户是否拥有候选订阅的删除权限。"""
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
@@ -0,0 +1,76 @@
|
||||
"""订阅存在性、来源定位和类型状态查询应用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
class SubscriptionQueryRepository(Protocol):
|
||||
"""描述订阅查询切片所需的最小仓储能力。"""
|
||||
|
||||
def exists(self, **identity: Any) -> bool:
|
||||
"""按完整订阅身份判断记录是否存在。"""
|
||||
...
|
||||
|
||||
def get_by(self, **identity: Any) -> Optional[Any]:
|
||||
"""按来源关键字中的订阅身份读取单条记录。"""
|
||||
...
|
||||
|
||||
def list(self, state: Optional[str] = None) -> list[Any]:
|
||||
"""按可选状态集合读取订阅记录。"""
|
||||
...
|
||||
|
||||
|
||||
class SubscriptionQueryService:
|
||||
"""封装不修改订阅状态的三个公开查询用例。"""
|
||||
|
||||
_SOURCE_FIELDS = {
|
||||
"type",
|
||||
"season",
|
||||
"media_source",
|
||||
"media_id",
|
||||
"music_type",
|
||||
}
|
||||
|
||||
def __init__(self, repository: SubscriptionQueryRepository) -> None:
|
||||
"""保存订阅查询仓储端口。"""
|
||||
self._repository = repository
|
||||
|
||||
def exists(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
meta: Optional[MetaBase] = None,
|
||||
) -> bool:
|
||||
"""按媒体身份、季、剧集组和音乐实体类型判断订阅是否存在。"""
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
return bool(self._repository.exists(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=getattr(mediainfo, "music_type", None)
|
||||
if mediainfo.type == MediaType.MUSIC else None,
|
||||
season=meta.begin_season if meta else None,
|
||||
episode_group=mediainfo.episode_group,
|
||||
))
|
||||
|
||||
def get_by_source(self, source_keyword: Optional[dict]) -> Optional[Any]:
|
||||
"""从已解析来源关键字筛出稳定身份字段并读取订阅。"""
|
||||
if not source_keyword:
|
||||
return None
|
||||
identity = {
|
||||
key: value
|
||||
for key, value in source_keyword.items()
|
||||
if key in self._SOURCE_FIELDS
|
||||
}
|
||||
return self._repository.get_by(**identity)
|
||||
|
||||
def has_music(self, searchable_states: str) -> bool:
|
||||
"""判断给定可搜索状态内是否至少存在一个音乐订阅。"""
|
||||
return any(
|
||||
subscribe.type == MediaType.MUSIC.value
|
||||
for subscribe in self._repository.list(searchable_states) or []
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""手工订阅搜索应用用例。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.application.subscription.delete import SubscribeDeletionCandidate
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubscribeSearchActor:
|
||||
"""执行手工订阅搜索的用户身份。"""
|
||||
|
||||
username: str
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
class SubscribeSearchRepository(Protocol):
|
||||
"""手工订阅搜索所需的最小读取端口。"""
|
||||
|
||||
async def get_candidate(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> SubscribeDeletionCandidate | None:
|
||||
"""读取单条订阅的归属信息。"""
|
||||
...
|
||||
|
||||
async def list_search_ids(self, username: str, state: str) -> list[int]:
|
||||
"""返回用户当前可搜索状态下的订阅编号。"""
|
||||
...
|
||||
|
||||
|
||||
SubscribeSearchScheduler = Callable[[int | None, str | None], None]
|
||||
|
||||
|
||||
class SearchSubscriptionsCommand:
|
||||
"""按用户权限生成并提交手工订阅搜索任务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscribeSearchRepository,
|
||||
schedule_search: SubscribeSearchScheduler,
|
||||
) -> None:
|
||||
"""注入订阅读取端口和后台任务提交端口。"""
|
||||
self._repository = repository
|
||||
self._schedule_search = schedule_search
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
actor: SubscribeSearchActor,
|
||||
subscribe_id: int | None = None,
|
||||
) -> bool:
|
||||
"""提交单条或当前用户全部可搜索订阅,返回目标是否存在。"""
|
||||
if subscribe_id is not None:
|
||||
candidate = await self._repository.get_candidate(subscribe_id)
|
||||
if not self._can_access(candidate, actor):
|
||||
return False
|
||||
self._schedule_search(subscribe_id, None)
|
||||
return True
|
||||
|
||||
if actor.is_superuser:
|
||||
self._schedule_search(None, "R")
|
||||
return True
|
||||
|
||||
subscribe_ids = await self._repository.list_search_ids(
|
||||
actor.username,
|
||||
"R",
|
||||
)
|
||||
for current_id in subscribe_ids:
|
||||
self._schedule_search(current_id, None)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _can_access(
|
||||
candidate: SubscribeDeletionCandidate | None,
|
||||
actor: SubscribeSearchActor,
|
||||
) -> bool:
|
||||
"""沿用订阅读取接口的超级用户和归属用户权限语义。"""
|
||||
if candidate is None:
|
||||
return False
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
Reference in New Issue
Block a user