refactor: unify subscription deletion transactions

This commit is contained in:
jxxghp
2026-08-24 02:38:18 +08:00
parent 563e727187
commit e274672a6a
10 changed files with 482 additions and 82 deletions
+17 -17
View File
@@ -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)}"
+153 -32
View File
@@ -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
View File
@@ -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)
+27 -7
View File
@@ -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]:
"""
异步更新订阅。
+28 -3
View File
@@ -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)