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
+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()