mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
264 lines
8.6 KiB
Python
264 lines
8.6 KiB
Python
"""订阅写操作用例及其数据端口。"""
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from contextlib import AbstractAsyncContextManager
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Protocol
|
|
from uuid import uuid4
|
|
|
|
from app.application.outbox import (
|
|
AsyncOutboxTransaction,
|
|
OutboxIntent,
|
|
SUBSCRIBE_MODIFIED_TOPIC,
|
|
)
|
|
from app.schemas.event import SubscribeModifiedEventData
|
|
|
|
|
|
class SubscriptionMutationRepository(Protocol):
|
|
"""订阅写用例需要的异步数据端口。"""
|
|
|
|
async def async_get(self, subscribe_id: int) -> Any | None:
|
|
"""按 ID 获取订阅。"""
|
|
|
|
async def async_update(self, subscribe_id: int, payload: dict[str, Any]) -> Any | None:
|
|
"""更新订阅。"""
|
|
|
|
async def async_stage_update(
|
|
self,
|
|
subscribe_id: int,
|
|
payload: dict[str, Any],
|
|
) -> Any | None:
|
|
"""在调用方事务中暂存更新但不提交。"""
|
|
|
|
def get(self, subscribe_id: int) -> Any | None:
|
|
"""同步按 ID 获取订阅。"""
|
|
|
|
|
|
class SubscriptionHistoryMutationRepository(Protocol):
|
|
"""订阅历史删除用例需要的最小数据端口。"""
|
|
|
|
async def async_get(self, history_id: int) -> Any | None:
|
|
"""按 ID 获取订阅历史。"""
|
|
|
|
async def async_delete(self, history_id: int) -> None:
|
|
"""删除订阅历史。"""
|
|
|
|
|
|
class AsyncUnitOfWork(Protocol):
|
|
"""订阅修改用例使用的异步事务端口。"""
|
|
|
|
async def commit(self) -> None:
|
|
"""提交当前订阅修改事务。"""
|
|
|
|
async def rollback(self) -> None:
|
|
"""回滚当前订阅修改事务。"""
|
|
|
|
|
|
SubscribeModifiedPublisher = Callable[[dict[str, Any]], Awaitable[None]]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubscriptionActor:
|
|
"""订阅写操作的权限主体。"""
|
|
|
|
name: str
|
|
is_superuser: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubscriptionMutation:
|
|
"""一次订阅变更前后的稳定快照。"""
|
|
|
|
old: dict[str, Any]
|
|
new: dict[str, Any]
|
|
event_published: bool = False
|
|
|
|
|
|
class SubscriptionMutationService:
|
|
"""编排订阅访问控制、更新和历史删除。"""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: SubscriptionMutationRepository,
|
|
history_repository: SubscriptionHistoryMutationRepository | None = None,
|
|
unit_of_work: AsyncUnitOfWork | None = None,
|
|
outbox: AsyncOutboxTransaction | None = None,
|
|
publish_modified: SubscribeModifiedPublisher | None = None,
|
|
) -> None:
|
|
"""注入订阅数据、事务与 durable 事件端口。"""
|
|
self._repository = repository
|
|
self._history_repository = history_repository
|
|
self._unit_of_work = unit_of_work
|
|
self._outbox = outbox
|
|
self._publish_modified = publish_modified
|
|
|
|
async def get_accessible(
|
|
self,
|
|
subscribe_id: int,
|
|
actor: SubscriptionActor,
|
|
) -> Any | None:
|
|
"""读取当前主体可访问的订阅。"""
|
|
subscribe = await self._repository.async_get(subscribe_id)
|
|
return subscribe if self.can_access(subscribe, actor) else None
|
|
|
|
def get_accessible_sync(
|
|
self,
|
|
subscribe_id: int,
|
|
actor: SubscriptionActor,
|
|
) -> Any | None:
|
|
"""同步读取当前主体可访问的订阅。"""
|
|
subscribe = self._repository.get(subscribe_id)
|
|
return subscribe if self.can_access(subscribe, actor) else None
|
|
|
|
async def update(
|
|
self,
|
|
subscribe_id: int,
|
|
payload: dict[str, Any],
|
|
actor: SubscriptionActor,
|
|
existing: Any | None = None,
|
|
scene: str = "update",
|
|
) -> SubscriptionMutation | None:
|
|
"""更新订阅,并在同一事务暂存可恢复的 SubscribeModified 事件。"""
|
|
subscribe = existing or await self.get_accessible(subscribe_id, actor)
|
|
if subscribe and not self.can_access(subscribe, actor):
|
|
return None
|
|
if not subscribe:
|
|
return None
|
|
old = subscribe.to_dict()
|
|
if not self._unit_of_work:
|
|
updated = await self._repository.async_update(subscribe_id, payload)
|
|
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
|
|
|
if not self._outbox or not self._publish_modified:
|
|
raise RuntimeError("订阅修改事务缺少 outbox 或事件发布端口")
|
|
try:
|
|
updated = await self._repository.async_stage_update(subscribe_id, payload)
|
|
if not updated:
|
|
return None
|
|
event_payload = SubscribeModifiedEventData(
|
|
subscribe_id=subscribe_id,
|
|
old_subscribe_info=old,
|
|
subscribe_info=updated.to_dict(),
|
|
scene=scene,
|
|
).to_dict()
|
|
event_key = _modified_event_key(subscribe_id, scene)
|
|
event_payload["idempotency_key"] = event_key
|
|
await self._outbox.stage(
|
|
OutboxIntent(
|
|
event_key=event_key,
|
|
topic=SUBSCRIBE_MODIFIED_TOPIC,
|
|
payload=event_payload,
|
|
),
|
|
datetime.now(timezone.utc),
|
|
)
|
|
await self._unit_of_work.commit()
|
|
except Exception:
|
|
await self._unit_of_work.rollback()
|
|
raise
|
|
|
|
await self._publish_modified(event_payload)
|
|
await self._outbox.complete_by_event_key(
|
|
event_key,
|
|
datetime.now(timezone.utc),
|
|
)
|
|
return SubscriptionMutation(
|
|
old=old,
|
|
new=event_payload["subscribe_info"],
|
|
event_published=True,
|
|
)
|
|
|
|
async def update_status(
|
|
self,
|
|
subscribe_id: int,
|
|
state: str,
|
|
actor: SubscriptionActor,
|
|
) -> SubscriptionMutation | None:
|
|
"""更新订阅状态并返回前后快照。"""
|
|
return await self.update(
|
|
subscribe_id,
|
|
{"state": state},
|
|
actor,
|
|
scene="status",
|
|
)
|
|
|
|
async def reset(
|
|
self,
|
|
subscribe_id: int,
|
|
actor: SubscriptionActor,
|
|
) -> SubscriptionMutation | None:
|
|
"""重置订阅进度和手工集数标记。"""
|
|
subscribe = await self.get_accessible(subscribe_id, actor)
|
|
if not subscribe:
|
|
return None
|
|
payload = {
|
|
"note": [],
|
|
"lack_episode": subscribe.total_episode,
|
|
"current_priority": None,
|
|
"current_audio_format": None,
|
|
"current_bitrate": None,
|
|
"current_bit_depth": None,
|
|
"current_sample_rate": None,
|
|
"episode_priority": {},
|
|
"manual_total_episode": 0,
|
|
"state": "R",
|
|
}
|
|
return await self.update(
|
|
subscribe_id,
|
|
payload,
|
|
actor,
|
|
existing=subscribe,
|
|
scene="reset",
|
|
)
|
|
|
|
async def delete_history(
|
|
self,
|
|
history_id: int,
|
|
actor: SubscriptionActor,
|
|
) -> bool:
|
|
"""删除当前主体可访问的订阅历史。"""
|
|
if self._history_repository is None:
|
|
raise RuntimeError("订阅历史数据端口未配置")
|
|
history = await self._history_repository.async_get(history_id)
|
|
if not self.can_access(history, actor):
|
|
return False
|
|
await self._history_repository.async_delete(history_id)
|
|
return True
|
|
|
|
@staticmethod
|
|
def can_access(subscribe: Any, actor: SubscriptionActor) -> bool:
|
|
"""判断主体是否可访问订阅或订阅历史。"""
|
|
if not subscribe:
|
|
return False
|
|
if actor.is_superuser:
|
|
return True
|
|
username = getattr(subscribe, "username", None)
|
|
return bool(username) and username == actor.name
|
|
|
|
|
|
def _modified_event_key(subscribe_id: int, scene: str) -> str:
|
|
"""为一次订阅修改生成重试期间稳定且跨多次相同变更不碰撞的幂等键。"""
|
|
return f"subscribe.modified:{subscribe_id}:{scene}:{uuid4().hex}:v1"
|
|
|
|
|
|
SubscriptionMutationScope = Callable[
|
|
[],
|
|
AbstractAsyncContextManager[SubscriptionMutationService],
|
|
]
|
|
_configured_mutation_scope: SubscriptionMutationScope | None = None
|
|
|
|
|
|
def configure_subscription_mutation_scope(
|
|
provider: SubscriptionMutationScope,
|
|
) -> None:
|
|
"""由启动组合根登记 Agent 等非 HTTP 入口使用的事务作用域。"""
|
|
global _configured_mutation_scope
|
|
_configured_mutation_scope = provider
|
|
|
|
|
|
def get_subscription_mutation_scope() -> AbstractAsyncContextManager[SubscriptionMutationService]:
|
|
"""返回一次独占会话的订阅修改服务作用域。"""
|
|
if _configured_mutation_scope is None:
|
|
raise RuntimeError("订阅修改事务作用域尚未配置")
|
|
return _configured_mutation_scope()
|