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
+20 -27
View File
@@ -6,11 +6,15 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.events import eventmanager
from app.application.agentdata import SubscribePort as SubscribeOper
from app.adapters.external.server import MoviePilotServerHelper
from app.application.subscription.delete import (
SubscribeDeletionActor,
get_delete_subscribe_scope,
)
from app.application.subscription.mutation import (
SubscriptionActor,
get_subscription_mutation_scope,
)
from app.runtime.log import logger
from app.schemas.types import EventType
class DeleteSubscribeInput(BaseModel):
@@ -45,32 +49,21 @@ class DeleteSubscribeTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
try:
subscribe_oper = SubscribeOper()
# 获取订阅信息
subscribe = await subscribe_oper.async_get(subscribe_id)
async with get_subscription_mutation_scope() as mutation:
subscribe = await mutation.get_accessible(
subscribe_id,
SubscriptionActor(name="agent", is_superuser=True),
)
if not subscribe:
return f"订阅 ID {subscribe_id} 不存在"
# 在删除之前获取订阅信息(用于事件)
subscribe_info = subscribe.to_dict()
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
MoviePilotServerHelper.sub_done_async(
{
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
"music_type": subscribe.music_type,
"total_tracks": subscribe.total_tracks,
"season": subscribe.season,
}
)
# 发送事件
await eventmanager.async_send_event(
EventType.SubscribeDeleted,
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
async with get_delete_subscribe_scope() as command:
deleted = await command.execute(
subscribe_id,
SubscribeDeletionActor(username="agent", is_superuser=True),
)
if not deleted:
return f"订阅 ID {subscribe_id} 不存在"
return f"成功删除订阅:{subscribe.name} ({subscribe.year})"
except Exception as e:
+39 -43
View File
@@ -7,11 +7,12 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.events import eventmanager
from app.application.agentdata import SubscribePort as SubscribeOper
from app.application.subscription.mutation import (
SubscriptionActor,
get_subscription_mutation_scope,
)
from app.runtime.log import logger
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import EventType, media_type_to_agent
from app.schemas.types import media_type_to_agent
class UpdateSubscribeInput(BaseModel):
@@ -172,8 +173,9 @@ class UpdateSubscribeTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
try:
subscribe_oper = SubscribeOper()
subscribe = await subscribe_oper.async_get(subscribe_id)
actor = SubscriptionActor(name="agent", is_superuser=True)
async with get_subscription_mutation_scope() as mutation:
subscribe = await mutation.get_accessible(subscribe_id, actor)
if not subscribe:
return json.dumps(
{"success": False, "message": f"订阅不存在: {subscribe_id}"},
@@ -206,9 +208,6 @@ class UpdateSubscribeTool(MoviePilotTool):
ensure_ascii=False,
)
# 保存旧数据用于事件
old_subscribe_dict = subscribe.to_dict()
# 构建更新字典
subscribe_dict = {}
@@ -306,24 +305,21 @@ class UpdateSubscribeTool(MoviePilotTool):
ensure_ascii=False,
)
# 更新订阅
await subscribe_oper.async_update(subscribe_id, subscribe_dict)
# 重新获取更新后的订阅数据
updated_subscribe = await subscribe_oper.async_get(subscribe_id)
# 发送订阅调整事件
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subscribe_id,
old_subscribe_info=old_subscribe_dict,
subscribe_info=updated_subscribe.to_dict()
if updated_subscribe
else {},
# Agent 工具没有 FastAPI 请求会话,由组合根提供一次独占事务作用域;
# 更新和 durable intent 必须共享同一 AsyncSession。
async with get_subscription_mutation_scope() as mutation:
change = await mutation.update(
subscribe_id,
subscribe_dict,
actor,
scene="agent_update",
).to_dict(),
)
)
if not change:
return json.dumps(
{"success": False, "message": f"订阅不存在: {subscribe_id}"},
ensure_ascii=False,
)
updated_subscribe = change.new
# 构建返回结果
result = {
@@ -335,23 +331,23 @@ class UpdateSubscribeTool(MoviePilotTool):
if updated_subscribe:
result["subscribe"] = {
"id": updated_subscribe.id,
"name": updated_subscribe.name,
"year": updated_subscribe.year,
"type": media_type_to_agent(updated_subscribe.type),
"music_type": updated_subscribe.music_type,
"total_tracks": updated_subscribe.total_tracks,
"media_source": updated_subscribe.media_source,
"media_id": updated_subscribe.media_id,
"season": updated_subscribe.season,
"state": updated_subscribe.state,
"total_episode": updated_subscribe.total_episode,
"manual_total_episode": updated_subscribe.manual_total_episode,
"lack_episode": updated_subscribe.lack_episode,
"start_episode": updated_subscribe.start_episode,
"quality": updated_subscribe.quality,
"resolution": updated_subscribe.resolution,
"effect": updated_subscribe.effect,
"id": updated_subscribe.get("id"),
"name": updated_subscribe.get("name"),
"year": updated_subscribe.get("year"),
"type": media_type_to_agent(updated_subscribe.get("type")),
"music_type": updated_subscribe.get("music_type"),
"total_tracks": updated_subscribe.get("total_tracks"),
"media_source": updated_subscribe.get("media_source"),
"media_id": updated_subscribe.get("media_id"),
"season": updated_subscribe.get("season"),
"state": updated_subscribe.get("state"),
"total_episode": updated_subscribe.get("total_episode"),
"manual_total_episode": updated_subscribe.get("manual_total_episode"),
"lack_episode": updated_subscribe.get("lack_episode"),
"start_episode": updated_subscribe.get("start_episode"),
"quality": updated_subscribe.get("quality"),
"resolution": updated_subscribe.get("resolution"),
"effect": updated_subscribe.get("effect"),
}
return json.dumps(result, ensure_ascii=False, indent=2)
+63 -1
View File
@@ -6,7 +6,18 @@ from typing import cast
from fastapi import Depends, Request
from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork
from app.startup.context import AgentChatRuntime, HostRuntime
from app.application.outbox import AsyncOutboxTransaction
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
SubscriptionHistoryMutationRepository,
SubscriptionMutationRepository,
)
from app.startup.context import (
AgentChatRuntime,
HostRuntime,
SubscriptionRuntime,
)
def get_host_runtime(request: Request) -> HostRuntime:
@@ -46,3 +57,54 @@ def get_agent_chat_transaction(
) -> AsyncUnitOfWork:
"""构造绑定当前请求会话的 Agent 会话事务端口。"""
return cast(AsyncUnitOfWork, runtime.transaction(session))
def get_subscription_runtime(
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionRuntime:
"""从完整宿主运行时收窄到订阅写事务能力。"""
return runtime.subscription
async def get_subscription_session(
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncGenerator[object, None]:
"""从订阅运行时生成请求独占的异步会话。"""
async for session in runtime.async_session():
yield session
def get_subscription_repository(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> (
SubscriptionMutationRepository
| SubscribeDeletionRepository
| SubscribeIdentityDeletionRepository
):
"""构造绑定当前请求会话的订阅仓储。"""
return runtime.repository(session)
def get_subscription_history_repository(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> SubscriptionHistoryMutationRepository:
"""构造绑定当前请求会话的订阅历史仓储。"""
return runtime.history_repository(session)
def get_subscription_transaction(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncUnitOfWork:
"""构造绑定当前订阅请求会话的异步事务端口。"""
return cast(AsyncUnitOfWork, runtime.transaction(session))
def get_subscription_outbox(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncOutboxTransaction:
"""构造与订阅写入共享请求会话的 outbox 端口。"""
return runtime.outbox(session)
+52 -18
View File
@@ -1,17 +1,36 @@
"""订阅领域的请求级 command/query 依赖。"""
from typing import Any, cast
from fastapi import BackgroundTasks, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.adapters.external.server import MoviePilotServerHelper
from app.api.context import (
get_subscription_history_repository,
get_subscription_outbox,
get_subscription_repository,
get_subscription_transaction,
)
from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository, transaction
from app.api.dependencies.data import repository
from app.application.outbox import AsyncOutboxTransaction
from app.application.scheduling import Scheduler
from app.application.servarr import ServarrSubscriptionService
from app.application.subscription.delete import DeleteSubscribeCommand
from app.application.subscription.delete import (
AsyncUnitOfWork as DeleteUnitOfWork,
DeleteSubscribeCommand,
SubscribeDeletionRepository,
)
from app.application.subscription.identity import DeleteSubscriptionsByIdentityCommand
from app.application.subscription.mutation import SubscriptionMutationService
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
AsyncUnitOfWork as MutationUnitOfWork,
SubscriptionHistoryMutationRepository,
SubscriptionMutationRepository,
SubscriptionMutationService,
)
from app.application.subscription.query import SubscriptionQueryService
from app.application.subscription.search import SearchSubscriptionsCommand
from app.runtime.events import eventmanager
@@ -20,25 +39,29 @@ from app.schemas.types import EventType
async def _publish_subscribe_deleted(
subscribe_id: int,
subscribe_info: dict,
payload: dict[str, Any],
) -> None:
"""通过宿主事件总线发布已提交的订阅删除事件。"""
await eventmanager.async_send_event(
EventType.SubscribeDeleted,
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
await eventmanager.async_send_event(EventType.SubscribeDeleted, payload)
async def _publish_subscribe_modified(payload: dict[str, Any]) -> None:
"""通过宿主事件总线发布已提交的订阅修改事件。"""
await eventmanager.async_send_event(EventType.SubscribeModified, payload)
def get_delete_subscribe_command(
db: AsyncSession = Depends(get_async_db),
repository_port: object = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
) -> DeleteSubscribeCommand:
"""组装请求级订阅删除用例及其具体适配器。"""
return DeleteSubscribeCommand(
repository=repository("subscribe", db),
unit_of_work=transaction("async", db),
repository=cast(SubscribeDeletionRepository, repository_port),
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
publish_deleted=_publish_subscribe_deleted,
report_deleted=MoviePilotServerHelper.sub_done_async,
outbox=outbox,
)
@@ -54,14 +77,17 @@ def _log_subscribe_deleted_event_error(
def get_delete_subscriptions_by_identity_command(
db: AsyncSession = Depends(get_async_db),
repository_port: object = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
) -> DeleteSubscriptionsByIdentityCommand:
"""组装请求级按媒体身份删除订阅用例。"""
return DeleteSubscriptionsByIdentityCommand(
repository=repository("subscribe", db),
unit_of_work=transaction("async", db),
repository=cast(SubscribeIdentityDeletionRepository, repository_port),
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
publish_deleted=_publish_subscribe_deleted,
handle_event_error=_log_subscribe_deleted_event_error,
outbox=outbox,
)
@@ -98,12 +124,20 @@ def get_subscription_query_service(
def get_subscription_mutation_service(
db: AsyncSession = Depends(get_async_db),
repository_port: object = Depends(get_subscription_repository),
history_repository: SubscriptionHistoryMutationRepository = Depends(
get_subscription_history_repository
),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
) -> SubscriptionMutationService:
"""组装异步订阅写服务。"""
return SubscriptionMutationService(
repository=repository("subscribe", db),
history_repository=repository("subscribe_history", db),
repository=cast(SubscriptionMutationRepository, repository_port),
history_repository=history_repository,
unit_of_work=cast(MutationUnitOfWork, unit_of_work),
outbox=outbox,
publish_modified=_publish_subscribe_modified,
)
+31 -30
View File
@@ -14,8 +14,8 @@ from app.schemas.workflow import Subscribe as _SchemaSubscribe
from app.api.response import ResponseAPIRouter
from app.chain.subscribe import SubscribeChain
from app.runtime.config import settings
from app.domain.context import MediaInfo
from app.runtime.events import eventmanager
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo
from app.adapters.web.security.access import verify_token, verify_apitoken
from app.application.subscription.delete import (
@@ -294,16 +294,16 @@ async def update_subscribe(
)
if not change:
return _SchemaResponse(success=False, message="订阅不存在")
# 发送订阅调整事件
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subscribe_in.id,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="update",
).to_dict(),
)
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subscribe_in.id,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="update",
).to_dict(),
)
return _SchemaResponse(success=True)
@@ -327,16 +327,16 @@ async def update_subscribe_status(
change = await mutation.update_status(subid, state, actor)
if not change:
return _SchemaResponse(success=False, message="订阅不存在")
# 发送订阅调整事件
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="status",
).to_dict(),
)
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="status",
).to_dict(),
)
return _SchemaResponse(success=True)
@@ -388,15 +388,16 @@ async def reset_subscribes(
)
change = await mutation.reset(subid, actor)
if change:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="reset",
).to_dict(),
)
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="reset",
).to_dict(),
)
return _SchemaResponse(success=True)
return _SchemaResponse(success=False, message="订阅不存在")
+14
View File
@@ -53,6 +53,20 @@ class OutboxRepository(Protocol):
"""记录有限退避或 dead-letter 终态。"""
class AsyncOutboxTransaction(Protocol):
"""异步业务事务暂存并收口 durable intent 的最小端口。"""
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
"""把 intent 加入调用方当前事务,但不自行提交。"""
async def complete_by_event_key(
self,
event_key: str,
completed_at: datetime,
) -> None:
"""即时投递成功后按稳定幂等键标记 intent 完成。"""
class OutboxDispatcher:
"""认领并派发 outbox,按 event key 依赖 handler 幂等。"""
+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()
+16
View File
@@ -496,6 +496,22 @@ class SubscribeOper(DbOper):
await subscribe.async_update(self._db, payload)
return subscribe
async def async_stage_update(
self,
sid: int,
payload: dict,
) -> Optional[Subscribe]:
"""在调用方 AsyncSession 中暂存订阅更新并 flush,不提交事务。"""
if not isinstance(self._db, AsyncSession):
raise RuntimeError("异步订阅修改需要调用方提供 AsyncSession")
subscribe = await self.async_get(sid)
if not subscribe:
return None
for key, value in _normalize_integer_flags(payload).items():
setattr(subscribe, key, value)
await self._db.flush()
return subscribe
async def async_update_filter_groups(
self, sid: int, filter_groups: List[str]
) -> Optional[Subscribe]:
+2
View File
@@ -54,6 +54,8 @@ class EventContract:
_PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
EventType.ConfigChanged: event_schemas.ConfigChangeEventData,
EventType.AgentTokensUsage: event_schemas.AgentTokensUsageEventData,
EventType.SubscribeAdded: event_schemas.SubscribeAddedEventData,
EventType.SubscribeDeleted: event_schemas.SubscribeDeletedEventData,
EventType.SubscribeModified: event_schemas.SubscribeModifiedEventData,
ChainEventType.PluginDataReset: event_schemas.PluginDataResetEventData,
ChainEventType.AuthVerification: event_schemas.AuthCredentials,
+22 -1
View File
@@ -680,6 +680,7 @@ class SubscribeModifiedEventData(BaseEventData):
subscribe_info: Dict[str, Any] = Field(default_factory=dict, description="更新后订阅快照")
scene: str = Field(default="update", description="触发场景:update/status/reset/agent_update")
fields: List[str] = Field(default_factory=list, description="真实变更字段")
idempotency_key: Optional[str] = Field(default=None, description="宿主生成的幂等键")
@model_validator(mode="after")
def compute_fields(self):
@@ -700,13 +701,33 @@ class SubscribeModifiedEventData(BaseEventData):
"""
输出公开事件 payload,避免内部属性被未来扩展意外暴露。
"""
return {
payload = {
"subscribe_id": self.subscribe_id,
"old_subscribe_info": self.old_subscribe_info,
"subscribe_info": self.subscribe_info,
"scene": self.scene,
"fields": list(self.fields),
}
if self.idempotency_key:
payload["idempotency_key"] = self.idempotency_key
return payload
class SubscribeAddedEventData(BaseEventData):
"""SubscribeAdded 广播事件的可恢复公开 payload。"""
subscribe_id: int = Field(description="订阅 ID")
username: Optional[str] = Field(default=None, description="发起订阅的用户")
mediainfo: Dict[str, Any] = Field(default_factory=dict, description="媒体信息快照")
idempotency_key: Optional[str] = Field(default=None, description="宿主生成的幂等键")
class SubscribeDeletedEventData(BaseEventData):
"""SubscribeDeleted 广播事件的可恢复公开 payload。"""
subscribe_id: int = Field(description="订阅 ID")
subscribe_info: Dict[str, Any] = Field(default_factory=dict, description="删除前订阅快照")
idempotency_key: Optional[str] = Field(default=None, description="宿主生成的幂等键")
class SubscribeCompletionCheckEventData(ChainEventData):
+2
View File
@@ -323,7 +323,9 @@ SCHEMA_EXPORTS = {
'StorageUsage': ('app.schemas.file', 'StorageUsage'),
'SubscrbieInfo': ('app.schemas.subscribe', 'SubscrbieInfo'),
'Subscribe': ('app.schemas.workflow', 'Subscribe'),
'SubscribeAddedEventData': ('app.schemas.event', 'SubscribeAddedEventData'),
'SubscribeCompletionCheckEventData': ('app.schemas.event', 'SubscribeCompletionCheckEventData'),
'SubscribeDeletedEventData': ('app.schemas.event', 'SubscribeDeletedEventData'),
'SubscribeDownloadFileInfo': ('app.schemas.subscribe', 'SubscribeDownloadFileInfo'),
'SubscribeEpisodeInfo': ('app.schemas.subscribe', 'SubscribeEpisodeInfo'),
'SubscribeEpisodesRefreshEventData': ('app.schemas.event', 'SubscribeEpisodesRefreshEventData'),
+50
View File
@@ -8,6 +8,13 @@ from app.application.messaging.chat import (
AsyncAgentChatRepository,
AsyncUnitOfWork,
)
from app.application.outbox import AsyncOutboxTransaction
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
SubscriptionHistoryMutationRepository,
SubscriptionMutationRepository,
)
class AgentChatRepositoryFactory(Protocol):
@@ -26,6 +33,37 @@ class AsyncUnitOfWorkFactory(Protocol):
...
class AsyncOutboxFactory(Protocol):
"""由请求会话构造异步 outbox 事务端口的工厂。"""
def __call__(self, session: object) -> AsyncOutboxTransaction:
"""绑定请求会话并返回 outbox 暂存与收口端口。"""
...
class SubscriptionRepositoryFactory(Protocol):
"""由请求会话构造订阅写仓储的工厂。"""
def __call__(
self,
session: object,
) -> (
SubscriptionMutationRepository
| SubscribeDeletionRepository
| SubscribeIdentityDeletionRepository
):
"""绑定请求会话并返回订阅领域仓储。"""
...
class SubscriptionHistoryRepositoryFactory(Protocol):
"""由请求会话构造订阅历史写仓储的工厂。"""
def __call__(self, session: object) -> SubscriptionHistoryMutationRepository:
"""绑定请求会话并返回订阅历史仓储。"""
...
class AsyncSessionProvider(Protocol):
"""FastAPI 请求级异步会话提供器。"""
@@ -70,9 +108,21 @@ class AgentChatRuntime:
transaction: AsyncUnitOfWorkFactory
@dataclass(frozen=True, slots=True)
class SubscriptionRuntime:
"""订阅 API 可见的请求级写事务运行时。"""
async_session: AsyncSessionProvider
repository: SubscriptionRepositoryFactory
history_repository: SubscriptionHistoryRepositoryFactory
transaction: AsyncUnitOfWorkFactory
outbox: AsyncOutboxFactory
@dataclass(frozen=True, slots=True)
class HostRuntime:
"""宿主组合根构建且在一个 FastAPI lifespan 内共享的运行时对象。"""
agent_chat: AgentChatRuntime
subscription: SubscriptionRuntime
compatibility_api_data: CompatibilityApiData
+23 -4
View File
@@ -55,7 +55,7 @@ from app.application.security.userconfig import (
)
from app.application.history import configure_transfer_history_provider
from app.application.outbox import OutboxDispatcher, configure_outbox_dispatcher
from app.startup.outbox import SqlAlchemyOutboxRepository
from app.startup.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
from app.application.site.query import SiteQueryService, configure_site_query_service
from app.application.site.health import SiteHealthService, configure_site_health_service
from app.application.workflow import WorkflowQueryService, configure_workflow_query
@@ -103,8 +103,11 @@ from app.startup.managed_resources_initializer import (
init_managed_resources,
stop_managed_resources,
)
from app.startup.subscription import TransactionalSubscribeWriter
from app.startup.context import AgentChatRuntime, HostRuntime
from app.startup.subscription import (
TransactionalSubscribeWriter,
configure_transactional_subscription_scopes,
)
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
from app.adapters.web.security.access import set_superuser_token_payload_provider
from app.application.security.auth import build_superuser_token_payload
from app.application.image import configure_wallpaper_providers
@@ -205,7 +208,15 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
"subscribe.added": lambda message: EventManager().send_event(
EventType.SubscribeAdded,
message.payload,
)
),
"subscribe.modified": lambda message: EventManager().send_event(
EventType.SubscribeModified,
message.payload,
),
"subscribe.deleted": lambda message: EventManager().send_event(
EventType.SubscribeDeleted,
message.payload,
),
},
close=session.close,
)
@@ -455,6 +466,13 @@ async def init_modules() -> HostRuntime:
repository=AgentChatOper,
transaction=SqlAlchemyAsyncUnitOfWork,
),
subscription=SubscriptionRuntime(
async_session=get_async_db,
repository=SubscribeOper,
history_repository=SubscribeHistoryOper,
transaction=SqlAlchemyAsyncUnitOfWork,
outbox=SqlAlchemyAsyncOutboxStager,
),
compatibility_api_data=api_data,
)
configure_api_data_runtime(host_runtime.compatibility_api_data)
@@ -515,6 +533,7 @@ async def init_modules() -> HostRuntime:
async_session=async_session_scope,
)
)
configure_transactional_subscription_scopes()
# 托管资源只在这里装配声明与 adapter,具体资源仍由首个消费者显式激活。
init_managed_resources()
# 应用服务不反向依赖 Chain,由启动组合层注入壁纸来源。
+57 -1
View File
@@ -1,8 +1,9 @@
"""订阅写入事务适配器的启动装配。"""
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -14,12 +15,25 @@ from app.application.subscription.write import (
CreateSubscriptionCommand,
subscription_added_event_key,
)
from app.application.subscription.delete import (
DeleteSubscribeCommand,
configure_delete_subscribe_scope,
)
from app.application.subscription.mutation import (
SubscriptionMutationService,
configure_subscription_mutation_scope,
)
from app.adapters.external.server import MoviePilotServerHelper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.session import async_session_scope
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.startup.outbox import (
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxRepository,
)
from app.runtime.events import EventManager
from app.schemas.types import EventType
class TransactionalSubscribeWriter:
@@ -98,3 +112,45 @@ class TransactionalSubscribeWriter:
username,
delivered,
)
async def _publish_modified(payload: dict[str, Any]) -> None:
"""发布事务已提交的订阅修改事件。"""
await EventManager().async_send_event(EventType.SubscribeModified, payload)
async def _publish_deleted(payload: dict[str, Any]) -> None:
"""发布事务已提交的订阅删除事件。"""
await EventManager().async_send_event(EventType.SubscribeDeleted, payload)
@asynccontextmanager
async def subscription_mutation_scope():
"""为非 HTTP 入口创建独占订阅修改会话、UoW 与 outbox。"""
async with async_session_scope() as session:
yield SubscriptionMutationService(
repository=SubscribeOper(session),
history_repository=SubscribeHistoryOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
publish_modified=_publish_modified,
)
@asynccontextmanager
async def delete_subscribe_scope():
"""为非 HTTP 入口创建独占订阅删除会话、UoW 与 outbox。"""
async with async_session_scope() as session:
yield DeleteSubscribeCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
publish_deleted=_publish_deleted,
report_deleted=MoviePilotServerHelper.sub_done_async,
outbox=SqlAlchemyAsyncOutboxStager(session),
)
def configure_transactional_subscription_scopes() -> None:
"""登记 Agent 等非 HTTP 入口复用的订阅事务作用域。"""
configure_subscription_mutation_scope(subscription_mutation_scope)
configure_delete_subscribe_scope(delete_subscribe_scope)