mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: make subscription lifecycle events durable
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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="订阅不存在")
|
||||
|
||||
|
||||
@@ -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 幂等。"""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,由启动组合层注入壁纸来源。
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user