mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
feat: make subscribe reports durable
This commit is contained in:
Vendored
+30
@@ -10,6 +10,7 @@ from app.runtime.config import settings
|
|||||||
from app.domain.context import MediaInfo, MusicInfo
|
from app.domain.context import MediaInfo, MusicInfo
|
||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
|
from app.runtime.observability import observe_compat_facade
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
MUSIC_ENTITY_RECORDING,
|
MUSIC_ENTITY_RECORDING,
|
||||||
MediaType,
|
MediaType,
|
||||||
@@ -38,6 +39,7 @@ def configure_server_application_services(
|
|||||||
_server_sharing_service = sharing_service
|
_server_sharing_service = sharing_service
|
||||||
|
|
||||||
|
|
||||||
|
@observe_compat_facade("MoviePilotServerHelper")
|
||||||
class MoviePilotServerHelper:
|
class MoviePilotServerHelper:
|
||||||
"""
|
"""
|
||||||
MoviePilot 服务端请求辅助工具。
|
MoviePilot 服务端请求辅助工具。
|
||||||
@@ -870,6 +872,20 @@ class MoviePilotServerHelper:
|
|||||||
res = await cls.async_subscribe_add(payload)
|
res = await cls.async_subscribe_add(payload)
|
||||||
return bool(res is not None and res.status_code == 200)
|
return bool(res is not None and res.status_code == 200)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sub_reg_durable(cls, sub: dict) -> bool:
|
||||||
|
"""同步上报新增统计;明确禁用时视为无需投递。"""
|
||||||
|
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
|
return True
|
||||||
|
return cls.sub_reg(sub)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def async_sub_reg_durable(cls, sub: dict) -> bool:
|
||||||
|
"""异步上报新增统计;明确禁用时视为无需投递。"""
|
||||||
|
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
|
return True
|
||||||
|
return await cls.async_sub_reg(sub)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def sub_done(cls, sub: dict) -> bool:
|
def sub_done(cls, sub: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -894,6 +910,20 @@ class MoviePilotServerHelper:
|
|||||||
res = await cls.async_subscribe_done(payload)
|
res = await cls.async_subscribe_done(payload)
|
||||||
return bool(res is not None and res.status_code == 200)
|
return bool(res is not None and res.status_code == 200)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sub_done_durable(cls, sub: dict) -> bool:
|
||||||
|
"""同步上报完成统计;明确禁用时视为无需投递。"""
|
||||||
|
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
|
return True
|
||||||
|
return cls.sub_done(sub)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def async_sub_done_durable(cls, sub: dict) -> bool:
|
||||||
|
"""异步上报完成统计;明确禁用时视为无需投递。"""
|
||||||
|
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
|
return True
|
||||||
|
return await cls.async_sub_done(sub)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def sub_reg_async(cls, sub: dict) -> bool:
|
def sub_reg_async(cls, sub: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def get_delete_subscribe_command(
|
|||||||
repository=cast(SubscribeDeletionRepository, repository_port),
|
repository=cast(SubscribeDeletionRepository, repository_port),
|
||||||
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
|
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
|
||||||
publish_deleted=_publish_subscribe_deleted,
|
publish_deleted=_publish_subscribe_deleted,
|
||||||
report_deleted=MoviePilotServerHelper.async_sub_done,
|
report_deleted=MoviePilotServerHelper.async_sub_done_durable,
|
||||||
outbox=outbox,
|
outbox=outbox,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,13 @@ class CreateSubscriptionCommand:
|
|||||||
staged = self._repository.stage_add(identity, payload, username)
|
staged = self._repository.stage_add(identity, payload, username)
|
||||||
if staged.created:
|
if staged.created:
|
||||||
if self._outbox:
|
if self._outbox:
|
||||||
self._outbox.stage(
|
now = datetime.now(timezone.utc)
|
||||||
_subscribe_added_intent(staged.subscribe_id, payload, username),
|
for intent in _subscribe_added_intents(
|
||||||
datetime.now(timezone.utc),
|
staged.subscribe_id,
|
||||||
)
|
payload,
|
||||||
|
username,
|
||||||
|
):
|
||||||
|
self._outbox.stage(intent, now)
|
||||||
self._unit_of_work.commit()
|
self._unit_of_work.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
self._unit_of_work.rollback()
|
self._unit_of_work.rollback()
|
||||||
@@ -202,10 +205,13 @@ class AsyncCreateSubscriptionCommand:
|
|||||||
)
|
)
|
||||||
if staged.created:
|
if staged.created:
|
||||||
if self._outbox:
|
if self._outbox:
|
||||||
await self._outbox.stage(
|
now = datetime.now(timezone.utc)
|
||||||
_subscribe_added_intent(staged.subscribe_id, payload, username),
|
for intent in _subscribe_added_intents(
|
||||||
datetime.now(timezone.utc),
|
staged.subscribe_id,
|
||||||
)
|
payload,
|
||||||
|
username,
|
||||||
|
):
|
||||||
|
await self._outbox.stage(intent, now)
|
||||||
await self._unit_of_work.commit()
|
await self._unit_of_work.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await self._unit_of_work.rollback()
|
await self._unit_of_work.rollback()
|
||||||
@@ -215,20 +221,29 @@ class AsyncCreateSubscriptionCommand:
|
|||||||
return staged.subscribe_id, staged.message
|
return staged.subscribe_id, staged.message
|
||||||
|
|
||||||
|
|
||||||
def _subscribe_added_intent(
|
def _subscribe_added_intents(
|
||||||
subscribe_id: int,
|
subscribe_id: int,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
username: str | None,
|
username: str | None,
|
||||||
) -> OutboxIntent:
|
) -> tuple[OutboxIntent, OutboxIntent]:
|
||||||
"""构造版本化订阅新增事件,event key 同时作为 handler 幂等键。"""
|
"""构造订阅新增事件与外部统计的同事务 durable intents。"""
|
||||||
return OutboxIntent(
|
event_key = subscription_added_event_key(subscribe_id, payload)
|
||||||
event_key=subscription_added_event_key(subscribe_id, payload),
|
event_payload = {
|
||||||
topic="subscribe.added",
|
|
||||||
payload={
|
|
||||||
"subscribe_id": subscribe_id,
|
"subscribe_id": subscribe_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
"mediainfo": dict(payload),
|
"mediainfo": dict(payload),
|
||||||
},
|
}
|
||||||
|
return (
|
||||||
|
OutboxIntent(
|
||||||
|
event_key=event_key,
|
||||||
|
topic="subscribe.added",
|
||||||
|
payload=event_payload,
|
||||||
|
),
|
||||||
|
OutboxIntent(
|
||||||
|
event_key=subscription_added_report_key(subscribe_id, payload),
|
||||||
|
topic="subscribe.added.report",
|
||||||
|
payload={"subscribe_info": dict(payload)},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -241,6 +256,11 @@ def subscription_added_event_key(subscribe_id: int, payload: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def subscription_added_report_key(subscribe_id: int, payload: dict) -> str:
|
||||||
|
"""返回与新增事件身份一致但可独立重试的统计幂等键。"""
|
||||||
|
return f"{subscription_added_event_key(subscribe_id, payload)}:report"
|
||||||
|
|
||||||
|
|
||||||
_configured_subscribe_writer: Callable[[], SubscribeWriter] | None = None
|
_configured_subscribe_writer: Callable[[], SubscribeWriter] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -958,9 +958,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
|
|||||||
"username": context.username,
|
"username": context.username,
|
||||||
"mediainfo": context.mediainfo.to_dict(),
|
"mediainfo": context.mediainfo.to_dict(),
|
||||||
})
|
})
|
||||||
MoviePilotServerHelper.sub_reg_async(
|
if not MoviePilotServerHelper.sub_reg_durable(
|
||||||
self.__subscribe_report_payload(context)
|
self.__subscribe_report_payload(context)
|
||||||
)
|
):
|
||||||
|
raise RuntimeError("订阅新增统计上报未确认")
|
||||||
|
|
||||||
async def __async_post_subscribe_added(
|
async def __async_post_subscribe_added(
|
||||||
self,
|
self,
|
||||||
@@ -993,9 +994,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
|
|||||||
"username": context.username,
|
"username": context.username,
|
||||||
"mediainfo": context.mediainfo.to_dict(),
|
"mediainfo": context.mediainfo.to_dict(),
|
||||||
})
|
})
|
||||||
await MoviePilotServerHelper.async_sub_reg(
|
if not await MoviePilotServerHelper.async_sub_reg_durable(
|
||||||
self.__subscribe_report_payload(context)
|
self.__subscribe_report_payload(context)
|
||||||
)
|
):
|
||||||
|
raise RuntimeError("订阅新增统计上报未确认")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __build_subscribe_create_context(
|
def __build_subscribe_create_context(
|
||||||
|
|||||||
@@ -237,11 +237,18 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
|
|||||||
"""创建一次恢复批次独占的 Session、Repository 和事件 handler。"""
|
"""创建一次恢复批次独占的 Session、Repository 和事件 handler。"""
|
||||||
def dispatch_subscribe_deleted_report(message) -> None:
|
def dispatch_subscribe_deleted_report(message) -> None:
|
||||||
"""重放订阅删除统计;未确认时抛错以进入有限重试。"""
|
"""重放订阅删除统计;未确认时抛错以进入有限重试。"""
|
||||||
if not MoviePilotServerHelper.sub_done(
|
if not MoviePilotServerHelper.sub_done_durable(
|
||||||
message.payload.get("subscribe_info") or {}
|
message.payload.get("subscribe_info") or {}
|
||||||
):
|
):
|
||||||
raise RuntimeError("订阅删除统计上报未确认")
|
raise RuntimeError("订阅删除统计上报未确认")
|
||||||
|
|
||||||
|
def dispatch_subscribe_added_report(message) -> None:
|
||||||
|
"""重放订阅新增统计;未确认时抛错以进入有限重试。"""
|
||||||
|
if not MoviePilotServerHelper.sub_reg_durable(
|
||||||
|
message.payload.get("subscribe_info") or {}
|
||||||
|
):
|
||||||
|
raise RuntimeError("订阅新增统计上报未确认")
|
||||||
|
|
||||||
session = SessionFactory()
|
session = SessionFactory()
|
||||||
return OutboxDispatcher(
|
return OutboxDispatcher(
|
||||||
repository=SqlAlchemyOutboxRepository(session),
|
repository=SqlAlchemyOutboxRepository(session),
|
||||||
@@ -250,6 +257,7 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
|
|||||||
EventType.SubscribeAdded,
|
EventType.SubscribeAdded,
|
||||||
message.payload,
|
message.payload,
|
||||||
),
|
),
|
||||||
|
"subscribe.added.report": dispatch_subscribe_added_report,
|
||||||
"subscribe.modified": lambda message: EventManager().send_event(
|
"subscribe.modified": lambda message: EventManager().send_event(
|
||||||
EventType.SubscribeModified,
|
EventType.SubscribeModified,
|
||||||
message.payload,
|
message.payload,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.application.subscription.write import (
|
|||||||
AsyncCreateSubscriptionCommand,
|
AsyncCreateSubscriptionCommand,
|
||||||
CreateSubscriptionCommand,
|
CreateSubscriptionCommand,
|
||||||
subscription_added_event_key,
|
subscription_added_event_key,
|
||||||
|
subscription_added_report_key,
|
||||||
)
|
)
|
||||||
from app.application.subscription.delete import (
|
from app.application.subscription.delete import (
|
||||||
DeleteSubscribeCommand,
|
DeleteSubscribeCommand,
|
||||||
@@ -76,6 +77,10 @@ class TransactionalSubscribeWriter:
|
|||||||
subscription_added_event_key(subscribe_id, payload),
|
subscription_added_event_key(subscribe_id, payload),
|
||||||
datetime.now(timezone.utc),
|
datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
outbox.complete_by_event_key(
|
||||||
|
subscription_added_report_key(subscribe_id, payload),
|
||||||
|
datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
return command.execute(identity, payload, username, delivered)
|
return command.execute(identity, payload, username, delivered)
|
||||||
finally:
|
finally:
|
||||||
@@ -105,6 +110,10 @@ class TransactionalSubscribeWriter:
|
|||||||
subscription_added_event_key(subscribe_id, payload),
|
subscription_added_event_key(subscribe_id, payload),
|
||||||
datetime.now(timezone.utc),
|
datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
await outbox.complete_by_event_key(
|
||||||
|
subscription_added_report_key(subscribe_id, payload),
|
||||||
|
datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
return await command.execute(
|
return await command.execute(
|
||||||
identity,
|
identity,
|
||||||
@@ -145,7 +154,7 @@ async def delete_subscribe_scope():
|
|||||||
repository=SubscribeOper(session),
|
repository=SubscribeOper(session),
|
||||||
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
|
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
|
||||||
publish_deleted=_publish_deleted,
|
publish_deleted=_publish_deleted,
|
||||||
report_deleted=MoviePilotServerHelper.async_sub_done,
|
report_deleted=MoviePilotServerHelper.async_sub_done_durable,
|
||||||
outbox=SqlAlchemyAsyncOutboxStager(session),
|
outbox=SqlAlchemyAsyncOutboxStager(session),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ def test_subscription_and_outbox_intent_commit_together() -> None:
|
|||||||
result = command.execute({}, {"name": "demo"}, "user")
|
result = command.execute({}, {"name": "demo"}, "user")
|
||||||
|
|
||||||
assert result == (42, "ok")
|
assert result == (42, "ok")
|
||||||
assert calls == ["subscription", "outbox", "commit"]
|
assert calls == ["subscription", "outbox", "outbox", "commit"]
|
||||||
intent = outbox.stage.call_args.args[0]
|
intent = outbox.stage.call_args_list[0].args[0]
|
||||||
assert intent.event_key == "subscribe.added:42:unknown:unknown:v1"
|
assert intent.event_key == "subscribe.added:42:unknown:unknown:v1"
|
||||||
assert intent.payload["subscribe_id"] == 42
|
assert intent.payload["subscribe_id"] == 42
|
||||||
|
report_intent = outbox.stage.call_args_list[1].args[0]
|
||||||
|
assert report_intent.topic == "subscribe.added.report"
|
||||||
|
assert report_intent.event_key.endswith(":report")
|
||||||
|
|
||||||
|
|
||||||
def test_outbox_stage_failure_rolls_back_business_transaction() -> None:
|
def test_outbox_stage_failure_rolls_back_business_transaction() -> None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.adapters.external.server import (
|
|||||||
)
|
)
|
||||||
from app.application.server.report import ServerReportService
|
from app.application.server.report import ServerReportService
|
||||||
from app.application.server.share import ServerSharingService
|
from app.application.server.share import ServerSharingService
|
||||||
|
from app.runtime.config import settings
|
||||||
from app.schemas.types import MediaSource
|
from app.schemas.types import MediaSource
|
||||||
|
|
||||||
|
|
||||||
@@ -237,3 +238,19 @@ class MoviePilotServerHelperTests(unittest.TestCase):
|
|||||||
"tmdbid": 99,
|
"tmdbid": 99,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_durable_subscribe_report_treats_disabled_sharing_as_success(self):
|
||||||
|
"""用户关闭统计分享时,durable intent 应视为无需远端投递。"""
|
||||||
|
with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object(
|
||||||
|
MoviePilotServerHelper,
|
||||||
|
"sub_reg",
|
||||||
|
) as reporter:
|
||||||
|
self.assertTrue(MoviePilotServerHelper.sub_reg_durable({"media_id": "1"}))
|
||||||
|
reporter.assert_not_called()
|
||||||
|
|
||||||
|
with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object(
|
||||||
|
MoviePilotServerHelper,
|
||||||
|
"sub_done",
|
||||||
|
) as reporter:
|
||||||
|
self.assertTrue(MoviePilotServerHelper.sub_done_durable({"media_id": "1"}))
|
||||||
|
reporter.assert_not_called()
|
||||||
|
|||||||
Reference in New Issue
Block a user