mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
refactor: unify subscription deletion transactions
This commit is contained in:
@@ -23,10 +23,18 @@ class SubscribeInteractionActions(Protocol):
|
||||
声明订阅交互需要调用的业务动作。
|
||||
"""
|
||||
|
||||
def refresh(self):
|
||||
def refresh(self) -> Any:
|
||||
"""执行订阅刷新。"""
|
||||
...
|
||||
|
||||
def check(self) -> Any:
|
||||
"""执行订阅元数据检查。"""
|
||||
...
|
||||
|
||||
def search(self, **kwargs: Any) -> Any:
|
||||
"""按消息入口参数执行订阅搜索。"""
|
||||
...
|
||||
|
||||
|
||||
class SubscribeInteractionRepository(Protocol):
|
||||
"""订阅消息交互所需的同步数据端口。"""
|
||||
@@ -37,14 +45,11 @@ class SubscribeInteractionRepository(Protocol):
|
||||
def get(self, subscribe_id: int) -> Optional[Any]:
|
||||
"""按 ID 返回订阅。"""
|
||||
|
||||
def delete(self, subscribe_id: int) -> Any:
|
||||
"""删除订阅。"""
|
||||
|
||||
def check(self):
|
||||
def check(self) -> Any:
|
||||
"""执行订阅元数据检查。"""
|
||||
...
|
||||
|
||||
def search(self, **kwargs):
|
||||
def search(self, **kwargs: Any) -> Any:
|
||||
"""执行订阅搜索。"""
|
||||
...
|
||||
|
||||
@@ -62,15 +67,15 @@ class SubscribeInteractionHandler:
|
||||
messenger: MessageGateway,
|
||||
actions: SubscribeInteractionActions,
|
||||
repository: SubscribeInteractionRepository,
|
||||
report_deleted: Callable[[dict], Any],
|
||||
):
|
||||
delete_subscription: Callable[[int], bool],
|
||||
) -> None:
|
||||
"""
|
||||
注入消息投递接口和订阅业务动作。
|
||||
"""
|
||||
self._messenger = messenger
|
||||
self._actions = actions
|
||||
self._repository = repository
|
||||
self._report_deleted = report_deleted
|
||||
self._delete_subscription = delete_subscription
|
||||
|
||||
def remote_list(
|
||||
self,
|
||||
@@ -727,15 +732,10 @@ class SubscribeInteractionHandler:
|
||||
if not subscribe:
|
||||
missing.append(str(subscribe_id))
|
||||
continue
|
||||
if not self._delete_subscription(subscribe_id):
|
||||
missing.append(str(subscribe_id))
|
||||
continue
|
||||
deleted.append(subscribe.name)
|
||||
self._repository.delete(subscribe_id)
|
||||
self._report_deleted(
|
||||
{
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
"season": subscribe.season,
|
||||
}
|
||||
)
|
||||
|
||||
if not deleted and missing:
|
||||
return False, f"未找到订阅:{', '.join(missing)}"
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""订阅删除应用用例及其依赖端口。"""
|
||||
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import inspect
|
||||
from typing import Any, Awaitable, Callable, Mapping, Protocol, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.outbox import AsyncOutboxTransaction, OutboxIntent
|
||||
from app.application.outbox import (
|
||||
AsyncOutboxTransaction,
|
||||
OutboxIntent,
|
||||
SyncOutboxTransaction,
|
||||
SyncUnitOfWork,
|
||||
)
|
||||
from app.schemas.event import SubscribeDeletedEventData
|
||||
|
||||
|
||||
@@ -43,6 +48,20 @@ class SubscribeDeletionRepository(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class SyncSubscribeDeletionRepository(Protocol):
|
||||
"""同步消息入口执行订阅删除所需的最小数据访问端口。"""
|
||||
|
||||
def get_candidate_sync(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> SubscribeDeletionCandidate | None:
|
||||
"""读取订阅及删除事件所需的稳定快照。"""
|
||||
...
|
||||
|
||||
def stage_delete_sync(self, subscribe_id: int) -> None:
|
||||
"""把已读取的订阅登记为待删除,但不自行提交事务。"""
|
||||
...
|
||||
|
||||
class AsyncUnitOfWork(Protocol):
|
||||
"""订阅写用例使用的异步事务端口。"""
|
||||
|
||||
@@ -57,6 +76,18 @@ class AsyncUnitOfWork(Protocol):
|
||||
|
||||
SubscribeDeletedPublisher = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
SubscribeDeletedReporter = Callable[[Mapping[str, object]], object | Awaitable[object]]
|
||||
SyncSubscribeDeletedPublisher = Callable[[dict[str, Any]], None]
|
||||
SyncSubscribeDeletedReporter = Callable[[Mapping[str, object]], object]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SubscribeDeletionEffects:
|
||||
"""同步和异步入口共用的删除事件、统计与 outbox 意图。"""
|
||||
|
||||
event_payload: dict[str, Any]
|
||||
report_payload: dict[str, object]
|
||||
event_intent: OutboxIntent
|
||||
report_intent: OutboxIntent
|
||||
|
||||
|
||||
class DeleteSubscribeCommand:
|
||||
@@ -89,36 +120,23 @@ class DeleteSubscribeCommand:
|
||||
提交后的事件与上报保持原有顺序,任一副作用失败都会继续向调用方抛出。
|
||||
"""
|
||||
candidate = await self._repository.get_candidate(subscribe_id)
|
||||
if not self._can_delete(candidate, actor):
|
||||
if not can_delete_subscribe(candidate, actor):
|
||||
return False
|
||||
assert candidate is not None
|
||||
|
||||
await self._repository.stage_delete(subscribe_id)
|
||||
event_payload = build_subscribe_deleted_payload(
|
||||
effects = _build_deletion_effects(
|
||||
subscribe_id,
|
||||
candidate.event_payload,
|
||||
)
|
||||
event_key = event_payload["idempotency_key"]
|
||||
report_key = f"{event_key}:report"
|
||||
try:
|
||||
await self._repository.stage_delete(subscribe_id)
|
||||
if self._outbox:
|
||||
await self._outbox.stage(
|
||||
OutboxIntent(
|
||||
event_key=event_key,
|
||||
topic="subscribe.deleted",
|
||||
payload=event_payload,
|
||||
),
|
||||
effects.event_intent,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
await self._outbox.stage(
|
||||
OutboxIntent(
|
||||
event_key=report_key,
|
||||
topic="subscribe.deleted.report",
|
||||
payload={
|
||||
"idempotency_key": report_key,
|
||||
"subscribe_info": dict(candidate.event_payload),
|
||||
},
|
||||
),
|
||||
effects.report_intent,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
await self._unit_of_work.commit()
|
||||
@@ -126,37 +144,125 @@ class DeleteSubscribeCommand:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
await self._publish_deleted(event_payload)
|
||||
await self._publish_deleted(effects.event_payload)
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
event_key,
|
||||
effects.event_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
# 上报适配器会自行白名单过滤公开字段;传完整删除前快照可保留音乐实体维度,
|
||||
# 避免 Agent 与 API 入口收敛后丢失 music_type / total_tracks。
|
||||
report_result = self._report_deleted(dict(candidate.event_payload))
|
||||
report_result = self._report_deleted(effects.report_payload)
|
||||
if inspect.isawaitable(report_result):
|
||||
report_result = await report_result
|
||||
if report_result is False:
|
||||
raise RuntimeError("订阅删除统计上报未确认")
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
report_key,
|
||||
effects.report_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _can_delete(
|
||||
candidate: SubscribeDeletionCandidate | None,
|
||||
|
||||
class SyncDeleteSubscribeCommand:
|
||||
"""为同步消息入口执行同一订阅删除事务与 durable 副作用协议。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SyncSubscribeDeletionRepository,
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
publish_deleted: SyncSubscribeDeletedPublisher,
|
||||
report_deleted: SyncSubscribeDeletedReporter,
|
||||
outbox: SyncOutboxTransaction | 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
|
||||
|
||||
def execute(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> bool:
|
||||
"""判断用户是否拥有目标订阅的删除权限。"""
|
||||
if candidate is None:
|
||||
"""同步删除当前用户可访问的订阅,并保持事件和统计的可靠投递顺序。"""
|
||||
candidate = self._repository.get_candidate_sync(subscribe_id)
|
||||
if not can_delete_subscribe(candidate, actor):
|
||||
return False
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
assert candidate is not None
|
||||
|
||||
effects = _build_deletion_effects(
|
||||
subscribe_id,
|
||||
candidate.event_payload,
|
||||
)
|
||||
try:
|
||||
self._repository.stage_delete_sync(subscribe_id)
|
||||
if self._outbox:
|
||||
now = datetime.now(timezone.utc)
|
||||
self._outbox.stage(effects.event_intent, now)
|
||||
self._outbox.stage(effects.report_intent, now)
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
self._publish_deleted(effects.event_payload)
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(
|
||||
effects.event_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
if self._report_deleted(effects.report_payload) is False:
|
||||
raise RuntimeError("订阅删除统计上报未确认")
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(
|
||||
effects.report_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def can_delete_subscribe(
|
||||
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
|
||||
|
||||
|
||||
def _build_deletion_effects(
|
||||
subscribe_id: int,
|
||||
subscribe_info: Mapping[str, object],
|
||||
) -> _SubscribeDeletionEffects:
|
||||
"""一次性构造两种执行风格共用的事件、上报和 durable intent。"""
|
||||
event_payload = build_subscribe_deleted_payload(subscribe_id, subscribe_info)
|
||||
event_key = event_payload["idempotency_key"]
|
||||
report_key = f"{event_key}:report"
|
||||
report_payload = dict(subscribe_info)
|
||||
return _SubscribeDeletionEffects(
|
||||
event_payload=event_payload,
|
||||
report_payload=report_payload,
|
||||
event_intent=OutboxIntent(
|
||||
event_key=event_key,
|
||||
topic="subscribe.deleted",
|
||||
payload=event_payload,
|
||||
),
|
||||
report_intent=OutboxIntent(
|
||||
event_key=report_key,
|
||||
topic="subscribe.deleted.report",
|
||||
payload={
|
||||
"idempotency_key": report_key,
|
||||
"subscribe_info": report_payload,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_subscribe_deleted_payload(
|
||||
@@ -176,7 +282,9 @@ def build_subscribe_deleted_payload(
|
||||
|
||||
|
||||
DeleteSubscribeScope = Callable[[], AbstractAsyncContextManager[DeleteSubscribeCommand]]
|
||||
SyncDeleteSubscribeScope = Callable[[], AbstractContextManager[SyncDeleteSubscribeCommand]]
|
||||
_configured_delete_scope: DeleteSubscribeScope | None = None
|
||||
_configured_sync_delete_scope: SyncDeleteSubscribeScope | None = None
|
||||
|
||||
|
||||
def configure_delete_subscribe_scope(provider: DeleteSubscribeScope) -> None:
|
||||
@@ -190,3 +298,16 @@ def get_delete_subscribe_scope() -> AbstractAsyncContextManager[DeleteSubscribeC
|
||||
if _configured_delete_scope is None:
|
||||
raise RuntimeError("订阅删除事务作用域尚未配置")
|
||||
return _configured_delete_scope()
|
||||
|
||||
|
||||
def configure_sync_delete_subscribe_scope(provider: SyncDeleteSubscribeScope) -> None:
|
||||
"""由启动组合根登记同步消息入口使用的订阅删除事务作用域。"""
|
||||
global _configured_sync_delete_scope
|
||||
_configured_sync_delete_scope = provider
|
||||
|
||||
|
||||
def get_sync_delete_subscribe_scope() -> AbstractContextManager[SyncDeleteSubscribeCommand]:
|
||||
"""返回一次独占同步会话的订阅删除命令作用域。"""
|
||||
if _configured_sync_delete_scope is None:
|
||||
raise RuntimeError("同步订阅删除事务作用域尚未配置")
|
||||
return _configured_sync_delete_scope()
|
||||
|
||||
+15
-12
@@ -48,6 +48,10 @@ from app.application.messaging.message import MessageTemplateHelper
|
||||
from app.application.mediaserver import MediaServerHelper
|
||||
from app.application.subscription.write import add_subscribe, async_add_subscribe
|
||||
from app.application.subscription.complete import get_subscription_completion_scope
|
||||
from app.application.subscription.delete import (
|
||||
SubscribeDeletionActor,
|
||||
get_sync_delete_subscribe_scope,
|
||||
)
|
||||
from app.application.subscription.contract import (
|
||||
build_subscribe_meta as _build_subscribe_meta,
|
||||
subscribe_media_key,
|
||||
@@ -3171,9 +3175,18 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
|
||||
messenger=self,
|
||||
actions=self,
|
||||
repository=SubscribeOper(),
|
||||
report_deleted=MoviePilotServerHelper.sub_done_async,
|
||||
delete_subscription=self._delete_subscription,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_subscription(subscribe_id: int) -> bool:
|
||||
"""通过统一同步命令删除订阅,保留消息入口原有的全局管理权限。"""
|
||||
with get_sync_delete_subscribe_scope() as command:
|
||||
return command.execute(
|
||||
subscribe_id,
|
||||
SubscribeDeletionActor(username="", is_superuser=True),
|
||||
)
|
||||
|
||||
def remote_delete(self, arg_str: str, channel: NotificationChannel,
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
@@ -3189,28 +3202,18 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
|
||||
save_history=False))
|
||||
return
|
||||
arg_strs = str(arg_str).split()
|
||||
subscribeoper = SubscribeOper()
|
||||
for arg_str in arg_strs:
|
||||
arg_str = arg_str.strip()
|
||||
if not arg_str.isdigit():
|
||||
continue
|
||||
subscribe_id = int(arg_str)
|
||||
subscribe = subscribeoper.get(subscribe_id)
|
||||
if not subscribe:
|
||||
if not self._delete_subscription(subscribe_id):
|
||||
self.post_message(_SchemaMessage(
|
||||
channel=channel, source=source,
|
||||
title=f"订阅编号 {subscribe_id} 不存在!",
|
||||
userid=userid,
|
||||
save_history=False))
|
||||
return
|
||||
# 删除订阅
|
||||
subscribeoper.delete(subscribe_id)
|
||||
# 统计订阅
|
||||
MoviePilotServerHelper.sub_done_async({
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
"season": subscribe.season,
|
||||
})
|
||||
# 重新发送消息
|
||||
self.remote_list(channel=channel, userid=userid, source=source)
|
||||
|
||||
|
||||
@@ -319,22 +319,36 @@ class SubscribeOper(DbOper):
|
||||
)
|
||||
|
||||
async def get_candidate(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> Optional[SubscribeDeletionCandidate]:
|
||||
"""读取订阅删除用例需要的权限字段与完整事件快照。"""
|
||||
subscribe = await self.async_get(subscribe_id)
|
||||
return self._deletion_candidate(subscribe_id, subscribe)
|
||||
|
||||
def get_candidate_sync(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> Optional[SubscribeDeletionCandidate]:
|
||||
"""同步读取订阅删除用例需要的权限字段与完整事件快照。"""
|
||||
return self._deletion_candidate(subscribe_id, self.get(subscribe_id))
|
||||
|
||||
@staticmethod
|
||||
def _deletion_candidate(
|
||||
subscribe_id: int,
|
||||
subscribe: Optional[Subscribe],
|
||||
) -> Optional[SubscribeDeletionCandidate]:
|
||||
"""把 ORM 行投影为同步和异步删除命令共用的稳定快照。"""
|
||||
if not subscribe:
|
||||
return None
|
||||
values = subscribe.__dict__
|
||||
event_payload = {
|
||||
column.name: values.get(column.name)
|
||||
for column in subscribe.__table__.columns
|
||||
}
|
||||
return SubscribeDeletionCandidate(
|
||||
subscribe_id=subscribe_id,
|
||||
username=subscribe.username,
|
||||
event_payload=event_payload,
|
||||
event_payload={
|
||||
column.name: values.get(column.name)
|
||||
for column in subscribe.__table__.columns
|
||||
},
|
||||
)
|
||||
|
||||
async def list_candidates_by_identity(
|
||||
@@ -484,6 +498,12 @@ class SubscribeOper(DbOper):
|
||||
sqlalchemy_delete(Subscribe).where(Subscribe.id == sid)
|
||||
)
|
||||
|
||||
def stage_delete_sync(self, sid: int) -> None:
|
||||
"""同步登记订阅删除但不提交,由 Application UnitOfWork 控制事务边界。"""
|
||||
if not isinstance(self._db, Session):
|
||||
raise RuntimeError("同步订阅删除需要调用方提供 Session")
|
||||
self._db.execute(sqlalchemy_delete(Subscribe).where(Subscribe.id == sid))
|
||||
|
||||
async def async_update(self, sid: int, payload: dict) -> Optional[Subscribe]:
|
||||
"""
|
||||
异步更新订阅。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""订阅事务作用域及提交后回调的组合装配。"""
|
||||
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -10,7 +11,9 @@ from app.application.subscription.complete import (
|
||||
)
|
||||
from app.application.subscription.delete import (
|
||||
DeleteSubscribeCommand,
|
||||
SyncDeleteSubscribeCommand,
|
||||
configure_delete_subscribe_scope,
|
||||
configure_sync_delete_subscribe_scope,
|
||||
)
|
||||
from app.application.subscription.mutation import (
|
||||
SubscriptionMutationService,
|
||||
@@ -38,13 +41,18 @@ async def _publish_deleted(payload: dict[str, Any]) -> None:
|
||||
await EventManager().async_send_event(EventType.SubscribeDeleted, payload)
|
||||
|
||||
|
||||
def _publish_deleted_sync(payload: dict[str, Any]) -> None:
|
||||
"""为同步消息入口发布事务已提交的订阅删除事件。"""
|
||||
EventManager().send_event(EventType.SubscribeDeleted, payload)
|
||||
|
||||
|
||||
def _publish_completed(payload: dict[str, Any]) -> None:
|
||||
"""发布已提交的订阅完成事件。"""
|
||||
EventManager().send_event(EventType.SubscribeComplete, payload)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def subscription_completion_scope():
|
||||
def subscription_completion_scope() -> Iterator[CompleteSubscriptionCommand]:
|
||||
"""为同步完成链创建独占 Session、UoW 与 durable outbox。"""
|
||||
session = SessionFactory()
|
||||
try:
|
||||
@@ -59,7 +67,7 @@ def subscription_completion_scope():
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def subscription_mutation_scope():
|
||||
async def subscription_mutation_scope() -> AsyncIterator[SubscriptionMutationService]:
|
||||
"""为非 HTTP 入口创建独占订阅修改会话、UoW 与 outbox。"""
|
||||
async with async_session_scope() as session:
|
||||
yield SubscriptionMutationService(
|
||||
@@ -72,7 +80,7 @@ async def subscription_mutation_scope():
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def delete_subscribe_scope():
|
||||
async def delete_subscribe_scope() -> AsyncIterator[DeleteSubscribeCommand]:
|
||||
"""为非 HTTP 入口创建独占订阅删除会话、UoW 与 outbox。"""
|
||||
async with async_session_scope() as session:
|
||||
yield DeleteSubscribeCommand(
|
||||
@@ -84,8 +92,25 @@ async def delete_subscribe_scope():
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sync_delete_subscribe_scope() -> Iterator[SyncDeleteSubscribeCommand]:
|
||||
"""为同步消息入口创建独占 Session、UoW 与 durable outbox。"""
|
||||
session = SessionFactory()
|
||||
try:
|
||||
yield SyncDeleteSubscribeCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
publish_deleted=_publish_deleted_sync,
|
||||
report_deleted=MoviePilotServerHelper.sub_done_durable,
|
||||
outbox=SqlAlchemyOutboxRepository(session),
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def configure_transactional_subscription_scopes() -> None:
|
||||
"""登记 Agent 等非 HTTP 入口复用的订阅事务作用域。"""
|
||||
configure_subscription_mutation_scope(subscription_mutation_scope)
|
||||
configure_delete_subscribe_scope(delete_subscribe_scope)
|
||||
configure_sync_delete_subscribe_scope(sync_delete_subscribe_scope)
|
||||
configure_subscription_completion_scope(subscription_completion_scope)
|
||||
|
||||
Reference in New Issue
Block a user