mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 11:54:50 +08:00
246 lines
7.9 KiB
Python
246 lines
7.9 KiB
Python
"""
|
|
订阅完成应用命令及其同步事务端口。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Mapping
|
|
from contextlib import AbstractContextManager
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from uuid import uuid4
|
|
|
|
from app.application.classification.reference import EffectiveClassificationSnapshot
|
|
from app.application.outbox import (
|
|
SUBSCRIBE_COMPLETED_TOPIC,
|
|
OutboxDispatchStore,
|
|
OutboxIntent,
|
|
OutboxStager,
|
|
SyncUnitOfWork,
|
|
deliver_outbox_effect,
|
|
)
|
|
from app.application.subscription.contract import (
|
|
SubscriptionHistoryPatch,
|
|
SubscriptionStagingPort,
|
|
)
|
|
from app.runtime.log import logger
|
|
from app.schemas.common import JsonData
|
|
|
|
CompletionEffect = Callable[[], None]
|
|
CompletionReporter = Callable[[Mapping[str, JsonData]], object]
|
|
|
|
|
|
class CompleteSubscriptionCommand:
|
|
"""
|
|
原子完成订阅,并按通知、事件、统计顺序执行提交后副作用。
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: SubscriptionStagingPort,
|
|
unit_of_work: SyncUnitOfWork,
|
|
outbox: Optional[OutboxStager],
|
|
dispatch_store: Optional[OutboxDispatchStore],
|
|
publish: Callable[[dict[str, JsonData]], None],
|
|
) -> None:
|
|
"""
|
|
注入共享同步会话、事件发布端口和可选 durable outbox。
|
|
"""
|
|
self._repository = repository
|
|
self._unit_of_work = unit_of_work
|
|
self._outbox = outbox
|
|
self._dispatch_store = dispatch_store
|
|
self._publish = publish
|
|
|
|
def execute(
|
|
self,
|
|
subscribe_id: int,
|
|
subscribe_info: Mapping[str, JsonData],
|
|
mediainfo: Mapping[str, JsonData],
|
|
notify: CompletionEffect,
|
|
report: CompletionReporter,
|
|
notification: Mapping[str, JsonData] | None = None,
|
|
classification_snapshot: EffectiveClassificationSnapshot | None = None,
|
|
) -> None:
|
|
"""
|
|
在同一事务中写历史、删订阅并暂存完成事件、通知与统计意图。
|
|
"""
|
|
info = dict(subscribe_info)
|
|
if classification_snapshot is not None:
|
|
info.update(
|
|
{
|
|
"media_category_id": classification_snapshot.category_id,
|
|
"media_category": classification_snapshot.path,
|
|
"classification_rule_id": classification_snapshot.rule_id,
|
|
"classification_policy_revision": classification_snapshot.policy_revision,
|
|
"classification_source": classification_snapshot.source,
|
|
}
|
|
)
|
|
occurrence_id = uuid4().hex
|
|
event_key = completion_event_key(
|
|
subscribe_id,
|
|
info,
|
|
occurrence_id=occurrence_id,
|
|
)
|
|
event_payload: dict[str, JsonData] = {
|
|
"subscribe_id": subscribe_id,
|
|
"subscribe_info": info,
|
|
"mediainfo": dict(mediainfo),
|
|
"idempotency_key": event_key,
|
|
}
|
|
report_key = completion_report_key(
|
|
subscribe_id,
|
|
info,
|
|
occurrence_id=occurrence_id,
|
|
)
|
|
notification_key = completion_notification_key(
|
|
subscribe_id,
|
|
info,
|
|
occurrence_id=occurrence_id,
|
|
)
|
|
report_info = _completion_report_payload(info, report_key)
|
|
report_payload: dict[str, JsonData] = {"subscribe_info": report_info}
|
|
try:
|
|
self._repository.stage_history(SubscriptionHistoryPatch.from_subscription(info))
|
|
self._repository.stage_delete_sync(subscribe_id)
|
|
if self._outbox:
|
|
now = datetime.now(timezone.utc)
|
|
self._outbox.stage(
|
|
OutboxIntent(
|
|
event_key=event_key,
|
|
topic=SUBSCRIBE_COMPLETED_TOPIC,
|
|
payload=event_payload,
|
|
),
|
|
now,
|
|
)
|
|
if notification:
|
|
self._outbox.stage(
|
|
OutboxIntent(
|
|
event_key=notification_key,
|
|
topic="subscribe.complete.notification",
|
|
payload={
|
|
"idempotency_key": notification_key,
|
|
"message": dict(notification),
|
|
},
|
|
),
|
|
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
|
|
|
|
if notification:
|
|
if self._dispatch_store:
|
|
deliver_outbox_effect(
|
|
self._dispatch_store,
|
|
notification_key,
|
|
notify,
|
|
)
|
|
else:
|
|
notify()
|
|
else:
|
|
notify()
|
|
if self._dispatch_store:
|
|
deliver_outbox_effect(
|
|
self._dispatch_store,
|
|
event_key,
|
|
lambda: self._publish(event_payload),
|
|
)
|
|
else:
|
|
self._publish(event_payload)
|
|
if self._dispatch_store:
|
|
try:
|
|
report_delivered = deliver_outbox_effect(
|
|
self._dispatch_store,
|
|
report_key,
|
|
lambda: report(report_info),
|
|
)
|
|
except Exception as error:
|
|
logger.warning(f"订阅完成统计上报失败,将由后台重试:{error}")
|
|
else:
|
|
if report_delivered is False:
|
|
logger.warning("订阅完成统计上报未确认,将由后台重试")
|
|
else:
|
|
try:
|
|
report(report_info)
|
|
except Exception as error:
|
|
logger.warning(f"订阅完成统计上报失败:{error}")
|
|
|
|
|
|
def completion_event_key(
|
|
subscribe_id: int,
|
|
subscribe_info: Mapping[str, JsonData],
|
|
*,
|
|
occurrence_id: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
由订阅身份和本次完成事实构造跨重试稳定且不复用的幂等键。
|
|
"""
|
|
resolved_occurrence_id = occurrence_id or uuid4().hex
|
|
return (
|
|
f"subscribe.complete:{subscribe_id}:"
|
|
f"{subscribe_info.get('media_source') or 'unknown'}:"
|
|
f"{subscribe_info.get('media_id') or 'unknown'}:{resolved_occurrence_id}:v1"
|
|
)
|
|
|
|
|
|
def completion_report_key(
|
|
subscribe_id: int,
|
|
subscribe_info: Mapping[str, JsonData],
|
|
*,
|
|
occurrence_id: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
构造可独立重试的订阅完成统计幂等键。
|
|
"""
|
|
event_key = completion_event_key(
|
|
subscribe_id,
|
|
subscribe_info,
|
|
occurrence_id=occurrence_id,
|
|
)
|
|
return f"{event_key}:report"
|
|
|
|
|
|
def completion_notification_key(
|
|
subscribe_id: int,
|
|
subscribe_info: Mapping[str, JsonData],
|
|
*,
|
|
occurrence_id: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
构造订阅完成通知的稳定幂等键,避免恢复时重复生成不同消息。
|
|
"""
|
|
event_key = completion_event_key(
|
|
subscribe_id,
|
|
subscribe_info,
|
|
occurrence_id=occurrence_id,
|
|
)
|
|
return f"{event_key}:notification"
|
|
|
|
|
|
def _completion_report_payload(
|
|
subscribe_info: Mapping[str, JsonData],
|
|
report_key: str,
|
|
) -> dict[str, JsonData]:
|
|
"""
|
|
保留旧统计接口字段,同时为恢复 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]]
|