mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: make subscription lifecycle events durable
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
"""订阅删除应用用例及其依赖端口。"""
|
||||
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Mapping, Protocol
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable, Mapping, Protocol, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.outbox import AsyncOutboxTransaction, OutboxIntent
|
||||
from app.schemas.event import SubscribeDeletedEventData
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -48,10 +54,7 @@ class AsyncUnitOfWork(Protocol):
|
||||
...
|
||||
|
||||
|
||||
SubscribeDeletedPublisher = Callable[
|
||||
[int, Mapping[str, object]],
|
||||
Awaitable[None],
|
||||
]
|
||||
SubscribeDeletedPublisher = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
SubscribeDeletedReporter = Callable[[Mapping[str, object]], object]
|
||||
|
||||
|
||||
@@ -64,12 +67,14 @@ class DeleteSubscribeCommand:
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
report_deleted: SubscribeDeletedReporter,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
) -> None:
|
||||
"""注入数据访问、事务与提交后副作用端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._publish_deleted = publish_deleted
|
||||
self._report_deleted = report_deleted
|
||||
self._outbox = outbox
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -85,23 +90,38 @@ class DeleteSubscribeCommand:
|
||||
candidate = await self._repository.get_candidate(subscribe_id)
|
||||
if not self._can_delete(candidate, actor):
|
||||
return False
|
||||
assert candidate is not None
|
||||
|
||||
await self._repository.stage_delete(subscribe_id)
|
||||
event_payload = build_subscribe_deleted_payload(
|
||||
subscribe_id,
|
||||
candidate.event_payload,
|
||||
)
|
||||
event_key = event_payload["idempotency_key"]
|
||||
try:
|
||||
if self._outbox:
|
||||
await self._outbox.stage(
|
||||
OutboxIntent(
|
||||
event_key=event_key,
|
||||
topic="subscribe.deleted",
|
||||
payload=event_payload,
|
||||
),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
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"),
|
||||
}
|
||||
)
|
||||
await self._publish_deleted(event_payload)
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
# 上报适配器会自行白名单过滤公开字段;传完整删除前快照可保留音乐实体维度,
|
||||
# 避免 Agent 与 API 入口收敛后丢失 music_type / total_tracks。
|
||||
self._report_deleted(dict(candidate.event_payload))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@@ -115,3 +135,36 @@ class DeleteSubscribeCommand:
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
|
||||
|
||||
def build_subscribe_deleted_payload(
|
||||
subscribe_id: int,
|
||||
subscribe_info: Mapping[str, object],
|
||||
) -> dict[str, Any]:
|
||||
"""构造兼容旧字段并携带幂等键的订阅删除事件快照。"""
|
||||
event_key = f"subscribe.deleted:{subscribe_id}:{uuid4().hex}:v1"
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
SubscribeDeletedEventData(
|
||||
subscribe_id=subscribe_id,
|
||||
subscribe_info=dict(subscribe_info),
|
||||
idempotency_key=event_key,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
DeleteSubscribeScope = Callable[[], AbstractAsyncContextManager[DeleteSubscribeCommand]]
|
||||
_configured_delete_scope: DeleteSubscribeScope | None = None
|
||||
|
||||
|
||||
def configure_delete_subscribe_scope(provider: DeleteSubscribeScope) -> None:
|
||||
"""由启动组合根登记非 HTTP 入口使用的订阅删除事务作用域。"""
|
||||
global _configured_delete_scope
|
||||
_configured_delete_scope = provider
|
||||
|
||||
|
||||
def get_delete_subscribe_scope() -> AbstractAsyncContextManager[DeleteSubscribeCommand]:
|
||||
"""返回一次独占会话的订阅删除命令作用域。"""
|
||||
if _configured_delete_scope is None:
|
||||
raise RuntimeError("订阅删除事务作用域尚未配置")
|
||||
return _configured_delete_scope()
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""按媒体身份批量删除订阅的应用用例。"""
|
||||
|
||||
from typing import Callable, Protocol
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from app.application.outbox import AsyncOutboxTransaction, OutboxIntent
|
||||
from app.application.subscription.delete import (
|
||||
AsyncUnitOfWork,
|
||||
SubscribeDeletedPublisher,
|
||||
SubscribeDeletionActor,
|
||||
SubscribeDeletionCandidate,
|
||||
build_subscribe_deleted_payload,
|
||||
)
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
@@ -24,8 +27,8 @@ class SubscribeIdentityDeletionRepository(Protocol):
|
||||
"""读取匹配媒体身份的去重订阅快照。"""
|
||||
...
|
||||
|
||||
async def delete(self, subscribe_id: int) -> None:
|
||||
"""把指定订阅登记为待删除。"""
|
||||
async def stage_delete(self, subscribe_id: int) -> None:
|
||||
"""把指定订阅登记为待删除,但不自行提交事务。"""
|
||||
...
|
||||
|
||||
|
||||
@@ -41,12 +44,14 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
handle_event_error: SubscribeDeletionEventErrorHandler,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
) -> None:
|
||||
"""注入数据访问、事务、事件和事件错误处理端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._publish_deleted = publish_deleted
|
||||
self._handle_event_error = handle_event_error
|
||||
self._outbox = outbox
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -68,21 +73,40 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
for candidate in candidates
|
||||
if self._can_delete(candidate, actor)
|
||||
]
|
||||
events: list[tuple[SubscribeDeletionCandidate, dict[str, Any]]] = []
|
||||
for candidate in deletions:
|
||||
await self._repository.stage_delete(candidate.subscribe_id)
|
||||
event_payload = build_subscribe_deleted_payload(
|
||||
candidate.subscribe_id,
|
||||
candidate.event_payload,
|
||||
)
|
||||
events.append((candidate, event_payload))
|
||||
|
||||
try:
|
||||
if self._outbox:
|
||||
now = datetime.now(timezone.utc)
|
||||
for _, event_payload in events:
|
||||
await self._outbox.stage(
|
||||
OutboxIntent(
|
||||
event_key=event_payload["idempotency_key"],
|
||||
topic="subscribe.deleted",
|
||||
payload=event_payload,
|
||||
),
|
||||
now,
|
||||
)
|
||||
await self._unit_of_work.commit()
|
||||
except Exception:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
for candidate in deletions:
|
||||
for candidate, event_payload in events:
|
||||
try:
|
||||
await self._publish_deleted(
|
||||
candidate.subscribe_id,
|
||||
dict(candidate.event_payload),
|
||||
)
|
||||
await self._publish_deleted(event_payload)
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
event_payload["idempotency_key"],
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception as error:
|
||||
self._handle_event_error(candidate.subscribe_id, error)
|
||||
return len(deletions)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""订阅写操作用例及其数据端口。"""
|
||||
|
||||
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
|
||||
from app.schemas.event import SubscribeModifiedEventData
|
||||
|
||||
|
||||
class SubscriptionMutationRepository(Protocol):
|
||||
@@ -13,6 +20,13 @@ class SubscriptionMutationRepository(Protocol):
|
||||
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 获取订阅。"""
|
||||
|
||||
@@ -27,6 +41,19 @@ class SubscriptionHistoryMutationRepository(Protocol):
|
||||
"""删除订阅历史。"""
|
||||
|
||||
|
||||
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:
|
||||
"""订阅写操作的权限主体。"""
|
||||
@@ -41,6 +68,7 @@ class SubscriptionMutation:
|
||||
|
||||
old: dict[str, Any]
|
||||
new: dict[str, Any]
|
||||
event_published: bool = False
|
||||
|
||||
|
||||
class SubscriptionMutationService:
|
||||
@@ -50,10 +78,16 @@ class SubscriptionMutationService:
|
||||
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,
|
||||
@@ -79,16 +113,56 @@ class SubscriptionMutationService:
|
||||
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()
|
||||
updated = await self._repository.async_update(subscribe_id, payload)
|
||||
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
||||
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",
|
||||
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,
|
||||
@@ -97,7 +171,12 @@ class SubscriptionMutationService:
|
||||
actor: SubscriptionActor,
|
||||
) -> SubscriptionMutation | None:
|
||||
"""更新订阅状态并返回前后快照。"""
|
||||
return await self.update(subscribe_id, {"state": state}, actor)
|
||||
return await self.update(
|
||||
subscribe_id,
|
||||
{"state": state},
|
||||
actor,
|
||||
scene="status",
|
||||
)
|
||||
|
||||
async def reset(
|
||||
self,
|
||||
@@ -120,9 +199,13 @@ class SubscriptionMutationService:
|
||||
"manual_total_episode": 0,
|
||||
"state": "R",
|
||||
}
|
||||
old = subscribe.to_dict()
|
||||
updated = await self._repository.async_update(subscribe_id, payload)
|
||||
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
||||
return await self.update(
|
||||
subscribe_id,
|
||||
payload,
|
||||
actor,
|
||||
existing=subscribe,
|
||||
scene="reset",
|
||||
)
|
||||
|
||||
async def delete_history(
|
||||
self,
|
||||
@@ -147,3 +230,30 @@ class SubscriptionMutationService:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user