refactor: make subscription lifecycle events durable

This commit is contained in:
jxxghp
2026-08-22 07:43:41 +08:00
parent 8f94fd620d
commit c5de1c7b1b
27 changed files with 1106 additions and 230 deletions
+67 -14
View File
@@ -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()
+32 -8
View File
@@ -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)
+118 -8
View File
@@ -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()