mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat: make subscribe completion durable
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""订阅完成应用命令及其同步事务端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.application.outbox import OutboxIntent, SyncOutboxTransaction, SyncUnitOfWork
|
||||
|
||||
|
||||
class SubscriptionCompletionRepository(Protocol):
|
||||
"""订阅完成命令需要的最小同步持久化端口。"""
|
||||
|
||||
def add_history(self, **payload: Any) -> None:
|
||||
"""在当前事务中暂存订阅历史。"""
|
||||
...
|
||||
|
||||
def delete(self, subscribe_id: int) -> None:
|
||||
"""在当前事务中暂存订阅删除。"""
|
||||
...
|
||||
|
||||
|
||||
CompletionEffect = Callable[[], None]
|
||||
CompletionReporter = Callable[[Mapping[str, Any]], object]
|
||||
|
||||
|
||||
class CompleteSubscriptionCommand:
|
||||
"""原子完成订阅,并按通知、事件、统计顺序执行提交后副作用。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscriptionCompletionRepository,
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
outbox: SyncOutboxTransaction | None,
|
||||
publish: Callable[[dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""注入共享同步会话、事件发布端口和可选 durable outbox。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._outbox = outbox
|
||||
self._publish = publish
|
||||
|
||||
def execute(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
subscribe_info: Mapping[str, Any],
|
||||
mediainfo: Mapping[str, Any],
|
||||
notify: CompletionEffect,
|
||||
report: CompletionReporter,
|
||||
) -> None:
|
||||
"""在同一事务中写历史、删订阅并暂存完成事件与统计意图。"""
|
||||
info = dict(subscribe_info)
|
||||
event_payload = {
|
||||
"subscribe_id": subscribe_id,
|
||||
"subscribe_info": info,
|
||||
"mediainfo": dict(mediainfo),
|
||||
"idempotency_key": completion_event_key(subscribe_id, info),
|
||||
}
|
||||
event_key = event_payload["idempotency_key"]
|
||||
report_key = completion_report_key(subscribe_id, info)
|
||||
report_payload = {"subscribe_info": _completion_report_payload(info, report_key)}
|
||||
try:
|
||||
self._repository.add_history(**info)
|
||||
self._repository.delete(subscribe_id)
|
||||
if self._outbox:
|
||||
now = datetime.now(timezone.utc)
|
||||
self._outbox.stage(
|
||||
OutboxIntent(
|
||||
event_key=event_key,
|
||||
topic="subscribe.complete",
|
||||
payload=event_payload,
|
||||
),
|
||||
now,
|
||||
)
|
||||
self._outbox.stage(
|
||||
OutboxIntent(
|
||||
event_key=report_key,
|
||||
topic="subscribe.complete.report",
|
||||
payload=report_payload,
|
||||
),
|
||||
now,
|
||||
)
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
notify()
|
||||
self._publish(event_payload)
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(event_key, datetime.now(timezone.utc))
|
||||
if report(report_payload["subscribe_info"]) is False:
|
||||
raise RuntimeError("订阅完成统计上报未确认")
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(report_key, datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def completion_event_key(subscribe_id: int, subscribe_info: Mapping[str, Any]) -> str:
|
||||
"""构造跨重试稳定的订阅完成事件幂等键。"""
|
||||
return (
|
||||
f"subscribe.complete:{subscribe_id}:"
|
||||
f"{subscribe_info.get('media_source') or 'unknown'}:"
|
||||
f"{subscribe_info.get('media_id') or 'unknown'}:v1"
|
||||
)
|
||||
|
||||
|
||||
def completion_report_key(subscribe_id: int, subscribe_info: Mapping[str, Any]) -> str:
|
||||
"""构造可独立重试的订阅完成统计幂等键。"""
|
||||
return f"{completion_event_key(subscribe_id, subscribe_info)}:report"
|
||||
|
||||
|
||||
def _completion_report_payload(
|
||||
subscribe_info: Mapping[str, Any],
|
||||
report_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""保留旧统计接口字段,同时为恢复 handler 固化幂等键。"""
|
||||
return {
|
||||
"media_source": subscribe_info.get("media_source"),
|
||||
"media_id": subscribe_info.get("media_id"),
|
||||
"season": subscribe_info.get("season"),
|
||||
"idempotency_key": report_key,
|
||||
}
|
||||
|
||||
|
||||
CompletionScope = Callable[[], AbstractContextManager[CompleteSubscriptionCommand]]
|
||||
_configured_completion_scope: CompletionScope | None = None
|
||||
|
||||
|
||||
def configure_subscription_completion_scope(provider: CompletionScope) -> None:
|
||||
"""由启动组合根登记订阅完成独占事务作用域。"""
|
||||
global _configured_completion_scope
|
||||
_configured_completion_scope = provider
|
||||
|
||||
|
||||
def get_subscription_completion_scope() -> AbstractContextManager[CompleteSubscriptionCommand]:
|
||||
"""返回一次独占同步订阅完成事务作用域。"""
|
||||
if _configured_completion_scope is None:
|
||||
raise RuntimeError("订阅完成事务作用域尚未配置")
|
||||
return _configured_completion_scope()
|
||||
+28
-32
@@ -46,6 +46,7 @@ from app.application.configuration import (
|
||||
from app.application.messaging.subscribe import SubscribeInteractionHandler
|
||||
from app.application.mediaserver import MediaServerHelper
|
||||
from app.application.subscription.write import add_subscribe, async_add_subscribe
|
||||
from app.application.subscription.complete import get_subscription_completion_scope
|
||||
from app.application.subscription.contract import (
|
||||
build_subscribe_meta as _build_subscribe_meta,
|
||||
subscribe_media_key,
|
||||
@@ -3096,44 +3097,39 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
|
||||
# 完成订阅
|
||||
msgstr = "订阅" if not subscribe.best_version else "洗版"
|
||||
logger.info(f'{mediainfo.title_year} 完成{msgstr}')
|
||||
# 新增订阅历史
|
||||
subscribeoper = SubscribeOper()
|
||||
subscribeoper.add_history(**subscribe.to_dict())
|
||||
# 删除订阅
|
||||
subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
|
||||
# 完成命令在同一 Session/UoW 中写历史、删除订阅并暂存可恢复副作用。
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = self.runtime_config.television_subscribe_url
|
||||
elif mediainfo.type == MediaType.MUSIC:
|
||||
link = self.runtime_config.music_subscribe_url
|
||||
else:
|
||||
link = self.runtime_config.movie_subscribe_url
|
||||
# 完成订阅按规则发送消息
|
||||
self.post_message(
|
||||
_SchemaMessage(
|
||||
mtype=MessageType.Subscribe,
|
||||
ctype=ContentType.SubscribeComplete,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=subscribe.username
|
||||
),
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
msgstr=msgstr,
|
||||
username=subscribe.username
|
||||
)
|
||||
# 发送事件
|
||||
eventmanager.send_event(EventType.SubscribeComplete, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"subscribe_info": subscribe.to_dict(),
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 统计订阅
|
||||
MoviePilotServerHelper.sub_done_async({
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
"season": subscribe.season,
|
||||
})
|
||||
|
||||
def notify() -> None:
|
||||
"""提交成功后发送完成通知,保持历史消息 ABI。"""
|
||||
self.post_message(
|
||||
_SchemaMessage(
|
||||
mtype=MessageType.Subscribe,
|
||||
ctype=ContentType.SubscribeComplete,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=subscribe.username,
|
||||
),
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
msgstr=msgstr,
|
||||
username=subscribe.username,
|
||||
)
|
||||
|
||||
with get_subscription_completion_scope() as command:
|
||||
command.execute(
|
||||
subscribe_id=subscribe.id,
|
||||
subscribe_info=subscribe.to_dict(),
|
||||
mediainfo=mediainfo.to_dict(),
|
||||
notify=notify,
|
||||
report=MoviePilotServerHelper.sub_done_durable,
|
||||
)
|
||||
|
||||
def _interaction_handler(self) -> "SubscribeInteractionHandler":
|
||||
"""构造 /subscribes 交互处理器,业务动作由本链提供。"""
|
||||
|
||||
@@ -249,6 +249,13 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
|
||||
):
|
||||
raise RuntimeError("订阅新增统计上报未确认")
|
||||
|
||||
def dispatch_subscribe_complete_report(message) -> None:
|
||||
"""重放订阅完成统计;未确认时抛错以进入有限重试。"""
|
||||
if not MoviePilotServerHelper.sub_done_durable(
|
||||
message.payload.get("subscribe_info") or {}
|
||||
):
|
||||
raise RuntimeError("订阅完成统计上报未确认")
|
||||
|
||||
session = SessionFactory()
|
||||
return OutboxDispatcher(
|
||||
repository=SqlAlchemyOutboxRepository(session),
|
||||
@@ -267,6 +274,11 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
|
||||
message.payload,
|
||||
),
|
||||
"subscribe.deleted.report": dispatch_subscribe_deleted_report,
|
||||
"subscribe.complete": lambda message: EventManager().send_event(
|
||||
EventType.SubscribeComplete,
|
||||
message.payload,
|
||||
),
|
||||
"subscribe.complete.report": dispatch_subscribe_complete_report,
|
||||
"download.added": lambda message: EventManager().send_event(
|
||||
EventType.DownloadAdded,
|
||||
restore_download_added(message.payload),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""订阅写入事务适配器的启动装配。"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -20,6 +20,10 @@ from app.application.subscription.delete import (
|
||||
DeleteSubscribeCommand,
|
||||
configure_delete_subscribe_scope,
|
||||
)
|
||||
from app.application.subscription.complete import (
|
||||
CompleteSubscriptionCommand,
|
||||
configure_subscription_completion_scope,
|
||||
)
|
||||
from app.application.subscription.mutation import (
|
||||
SubscriptionMutationService,
|
||||
configure_subscription_mutation_scope,
|
||||
@@ -28,6 +32,7 @@ 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.session import SessionFactory
|
||||
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
|
||||
from app.startup.outbox import (
|
||||
SqlAlchemyAsyncOutboxStager,
|
||||
@@ -133,6 +138,26 @@ async def _publish_deleted(payload: dict[str, Any]) -> None:
|
||||
await EventManager().async_send_event(EventType.SubscribeDeleted, payload)
|
||||
|
||||
|
||||
def _publish_completed(payload: dict[str, Any]) -> None:
|
||||
"""发布已提交的订阅完成事件。"""
|
||||
EventManager().send_event(EventType.SubscribeComplete, payload)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def subscription_completion_scope():
|
||||
"""为同步完成链创建独占 Session、UoW 与 durable outbox。"""
|
||||
session = SessionFactory()
|
||||
try:
|
||||
yield CompleteSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
outbox=SqlAlchemyOutboxRepository(session),
|
||||
publish=_publish_completed,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def subscription_mutation_scope():
|
||||
"""为非 HTTP 入口创建独占订阅修改会话、UoW 与 outbox。"""
|
||||
@@ -163,3 +188,4 @@ def configure_transactional_subscription_scopes() -> None:
|
||||
"""登记 Agent 等非 HTTP 入口复用的订阅事务作用域。"""
|
||||
configure_subscription_mutation_scope(subscription_mutation_scope)
|
||||
configure_delete_subscribe_scope(delete_subscribe_scope)
|
||||
configure_subscription_completion_scope(subscription_completion_scope)
|
||||
|
||||
Reference in New Issue
Block a user