mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
refactor: close transactional boundary debt batch
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from app.application.history import DownloadHistoryRepository
|
||||
from app.application.security.user import ChainUserRepository
|
||||
|
||||
AgentDataFactory = Callable[[], Any]
|
||||
@@ -150,9 +151,12 @@ def get_agent_transfer_history_port() -> Any:
|
||||
return get_agent_data_ports().transfer_history()
|
||||
|
||||
|
||||
def get_agent_download_history_port() -> Any:
|
||||
"""创建 Agent 下载历史数据端口实例。"""
|
||||
return get_agent_data_ports().download_history()
|
||||
def get_agent_download_history_port() -> DownloadHistoryRepository:
|
||||
"""创建 Agent 类型化下载历史数据端口实例。"""
|
||||
return cast(
|
||||
DownloadHistoryRepository,
|
||||
get_agent_data_ports().download_history(),
|
||||
)
|
||||
|
||||
|
||||
def get_agent_plugin_data_port() -> Any:
|
||||
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.download.failures import DownloadFailureRepository
|
||||
from app.application.history import DownloadHistoryRepository
|
||||
from app.application.mediaserver import MediaServerRepository
|
||||
from app.application.security.user import ChainUserRepository
|
||||
from app.application.transfer.execution import TransferExecutionRepository
|
||||
@@ -18,6 +19,7 @@ from app.application.transfer.workflow import TransferAdmissionRepository
|
||||
|
||||
OperFactory = Callable[[], Any]
|
||||
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
|
||||
DownloadHistoryRepositoryFactory = Callable[[], DownloadHistoryRepository]
|
||||
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
|
||||
ChainUserRepositoryFactory = Callable[[], ChainUserRepository]
|
||||
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
|
||||
@@ -30,7 +32,7 @@ class ChainDataPorts:
|
||||
|
||||
site: OperFactory
|
||||
subscribe: OperFactory
|
||||
download_history: OperFactory
|
||||
download_history: DownloadHistoryRepositoryFactory
|
||||
transfer_history: OperFactory
|
||||
transfer_pending: TransferAdmissionRepositoryFactory
|
||||
transfer_execution: TransferExecutionRepositoryFactory
|
||||
@@ -46,7 +48,7 @@ def configure_chain_data_ports(
|
||||
*,
|
||||
site: OperFactory,
|
||||
subscribe: OperFactory,
|
||||
download_history: OperFactory,
|
||||
download_history: DownloadHistoryRepositoryFactory,
|
||||
transfer_history: OperFactory,
|
||||
transfer_pending: TransferAdmissionRepositoryFactory,
|
||||
transfer_execution: TransferExecutionRepositoryFactory,
|
||||
@@ -86,8 +88,8 @@ def get_chain_subscribe_port() -> Any:
|
||||
return get_chain_data_ports().subscribe()
|
||||
|
||||
|
||||
def get_chain_download_history_port() -> Any:
|
||||
"""创建下载历史数据端口实例。"""
|
||||
def get_chain_download_history_port() -> DownloadHistoryRepository:
|
||||
"""创建类型化的下载历史查询与事务端口实例。"""
|
||||
return get_chain_data_ports().download_history()
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ from pathlib import Path
|
||||
from typing import Any, Protocol, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
|
||||
from app.application.history import (
|
||||
DownloadFileWrite,
|
||||
DownloadHistoryWrite,
|
||||
TransferHistoryRecord,
|
||||
TransferHistoryWriter,
|
||||
)
|
||||
from app.application.transfer.execution import TransferSettlementResult
|
||||
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
@@ -27,8 +32,8 @@ class ChainDurableEventWriter(Protocol):
|
||||
def download_added(
|
||||
self,
|
||||
*,
|
||||
history_payload: dict[str, Any],
|
||||
file_payloads: list[dict[str, Any]],
|
||||
history: DownloadHistoryWrite,
|
||||
files: tuple[DownloadFileWrite, ...],
|
||||
event_payload: dict[str, Any],
|
||||
after_commit: Callable[[], None],
|
||||
publish: Callable[[dict[str, Any]], None],
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from app.schemas.transfer import DownloaderTorrent
|
||||
from app.application.history import DownloadHistorySnapshot
|
||||
from app.schemas.transfer import DownloaderTorrent, DownloadTaskMedia
|
||||
from app.schemas.types import TorrentStatus
|
||||
|
||||
|
||||
@@ -12,7 +13,10 @@ class DownloadTaskService:
|
||||
def __init__(
|
||||
self,
|
||||
list_torrents: Callable[..., List[DownloaderTorrent]],
|
||||
get_history_by_hashes: Callable[[list[str]], dict],
|
||||
get_history_by_hashes: Callable[
|
||||
[list[str]],
|
||||
dict[str, DownloadHistorySnapshot],
|
||||
],
|
||||
start_torrents: Callable[..., bool],
|
||||
stop_torrents: Callable[..., bool],
|
||||
remove_torrents: Callable[..., bool],
|
||||
@@ -36,20 +40,22 @@ class DownloadTaskService:
|
||||
[torrent.hash for torrent in torrents if torrent.hash]
|
||||
)
|
||||
for torrent in torrents:
|
||||
if not torrent.hash:
|
||||
continue
|
||||
history = history_map.get(torrent.hash)
|
||||
if not history:
|
||||
continue
|
||||
torrent.media = {
|
||||
"media_source": history.media_source,
|
||||
"media_id": history.media_id,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.poster,
|
||||
"poster": history.poster,
|
||||
"backdrop": history.image,
|
||||
}
|
||||
torrent.media = DownloadTaskMedia(
|
||||
media_source=history.media_source,
|
||||
media_id=history.media_id,
|
||||
type=history.type,
|
||||
title=history.title,
|
||||
season=history.seasons,
|
||||
episode=history.episodes,
|
||||
image=history.poster,
|
||||
poster=history.poster,
|
||||
backdrop=history.image,
|
||||
)
|
||||
torrent.site_name = history.torrent_site
|
||||
torrent.userid = history.userid
|
||||
torrent.username = history.username
|
||||
|
||||
+250
-15
@@ -1,23 +1,24 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Protocol, Union
|
||||
from typing import Any, Callable, Dict, NoReturn, Optional, Protocol, Union
|
||||
|
||||
from app.application.configuration import TransferRetryConfig, get_transfer_retry_config
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.foundation.text import cut as jieba_cut
|
||||
from app.runtime.cache import TTLCache
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.history import (
|
||||
DownloadHistory as DownloadHistoryView,
|
||||
TransferHistory as TransferHistoryView,
|
||||
DownloadHistory,
|
||||
TransferHistory,
|
||||
TransferHistoryPage,
|
||||
)
|
||||
from app.schemas.workflow import FileItem
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
# 失败重试次数的合法区间。下界为 1:一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)
|
||||
# 不该让文件永久漏整理,所以不允许关闭重试;上界为 10:永远识别不出的文件重试再多
|
||||
@@ -99,6 +100,240 @@ class HistoryMutationResult:
|
||||
message: str = ""
|
||||
|
||||
|
||||
class _FrozenJsonDict(dict[str, JsonData]):
|
||||
"""保留 JSON 字典读取与序列化行为,并拒绝常规原地修改。"""
|
||||
|
||||
def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn:
|
||||
"""拒绝修改已经进入历史快照的嵌套 JSON。"""
|
||||
raise TypeError("下载历史快照 JSON 不可修改")
|
||||
|
||||
__setitem__ = _reject_mutation
|
||||
__delitem__ = _reject_mutation
|
||||
__ior__ = _reject_mutation
|
||||
clear = _reject_mutation
|
||||
pop = _reject_mutation
|
||||
popitem = _reject_mutation
|
||||
setdefault = _reject_mutation
|
||||
update = _reject_mutation
|
||||
|
||||
|
||||
class _FrozenJsonList(list[JsonData]):
|
||||
"""保留 JSON 数组读取与序列化行为,并拒绝常规原地修改。"""
|
||||
|
||||
def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn:
|
||||
"""拒绝修改已经进入历史快照的嵌套 JSON。"""
|
||||
raise TypeError("下载历史快照 JSON 不可修改")
|
||||
|
||||
__setitem__ = _reject_mutation
|
||||
__delitem__ = _reject_mutation
|
||||
__iadd__ = _reject_mutation
|
||||
__imul__ = _reject_mutation
|
||||
append = _reject_mutation
|
||||
clear = _reject_mutation
|
||||
extend = _reject_mutation
|
||||
insert = _reject_mutation
|
||||
pop = _reject_mutation
|
||||
remove = _reject_mutation
|
||||
reverse = _reject_mutation
|
||||
sort = _reject_mutation
|
||||
|
||||
|
||||
def _freeze_json(value: JsonData) -> JsonData:
|
||||
"""递归复制并冻结 JSON 容器,避免快照内部仍暴露可变引用。"""
|
||||
if isinstance(value, dict):
|
||||
return _FrozenJsonDict({key: _freeze_json(item) for key, item in value.items()})
|
||||
if isinstance(value, list):
|
||||
return _FrozenJsonList([_freeze_json(item) for item in value])
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadHistorySnapshot:
|
||||
"""脱离数据库会话后供宿主下载、订阅和整理用例读取的历史快照。"""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
type: str
|
||||
title: str
|
||||
year: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
poster: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
torrent_name: Optional[str] = None
|
||||
torrent_description: Optional[str] = None
|
||||
torrent_site: Optional[str] = None
|
||||
userid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
note: Optional[JsonData] = None
|
||||
media_category: Optional[str] = None
|
||||
episode_group: Optional[str] = None
|
||||
custom_words: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""递归冻结可变 JSON 字段,使 DTO 在所有层级都不可修改。"""
|
||||
object.__setattr__(self, "note", _freeze_json(self.note))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFileSnapshot:
|
||||
"""脱离数据库会话的下载文件关联快照。"""
|
||||
|
||||
id: int
|
||||
downloader: Optional[str]
|
||||
download_hash: Optional[str]
|
||||
fullpath: Optional[str]
|
||||
savepath: Optional[str]
|
||||
filepath: Optional[str]
|
||||
torrentname: Optional[str]
|
||||
state: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadHistoryWrite:
|
||||
"""一次下载成功后写入历史所需的完整稳定数据。"""
|
||||
|
||||
path: str
|
||||
type: str
|
||||
title: str
|
||||
year: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
poster: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
torrent_name: Optional[str] = None
|
||||
torrent_description: Optional[str] = None
|
||||
torrent_site: Optional[str] = None
|
||||
userid: Optional[Union[str, int]] = None
|
||||
username: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
note: Optional[JsonData] = None
|
||||
media_category: Optional[str] = None
|
||||
episode_group: Optional[str] = None
|
||||
custom_words: Optional[str] = None
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""返回可交给持久化适配器的独立字段副本。"""
|
||||
payload = asdict(self)
|
||||
if self.media_source is not None:
|
||||
payload["media_source"] = str(self.media_source)
|
||||
if self.userid is not None:
|
||||
payload["userid"] = str(self.userid)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFileWrite:
|
||||
"""下载任务关联文件的一次稳定写入。"""
|
||||
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
fullpath: Optional[str] = None
|
||||
savepath: Optional[str] = None
|
||||
filepath: Optional[str] = None
|
||||
torrentname: Optional[str] = None
|
||||
state: int = 1
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""返回可交给持久化适配器的独立字段副本。"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class DownloadHistoryQueryPort(Protocol):
|
||||
"""宿主下载、订阅、Agent 和整理用例所需的类型化查询端口。"""
|
||||
|
||||
def get_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载任务 Hash 返回最新历史快照。"""
|
||||
...
|
||||
|
||||
def get_by_hashes(
|
||||
self,
|
||||
download_hashes: list[str],
|
||||
) -> dict[str, DownloadHistorySnapshot]:
|
||||
"""批量返回以下载任务 Hash 为键的最新历史快照。"""
|
||||
...
|
||||
|
||||
def get_by_path(self, path: str) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载保存路径返回历史快照。"""
|
||||
...
|
||||
|
||||
def get_by_media_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""按规范媒体身份返回历史快照。"""
|
||||
...
|
||||
|
||||
def get_file_by_fullpath(
|
||||
self,
|
||||
fullpath: str,
|
||||
) -> Optional[DownloadFileSnapshot]:
|
||||
"""按完整路径返回一条有效下载文件快照。"""
|
||||
...
|
||||
|
||||
def get_files_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
state: Optional[int] = None,
|
||||
) -> list[DownloadFileSnapshot]:
|
||||
"""按下载任务 Hash 返回文件快照。"""
|
||||
...
|
||||
|
||||
def get_files_by_savepath(self, savepath: str) -> list[DownloadFileSnapshot]:
|
||||
"""按保存目录返回下载文件快照。"""
|
||||
...
|
||||
|
||||
async def async_list_by_page(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""异步按下载时间倒序分页返回历史快照。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadHistoryWritePort(Protocol):
|
||||
"""下载历史新增和删除所需的类型化事务端口。"""
|
||||
|
||||
def add(
|
||||
self,
|
||||
history: DownloadHistoryWrite,
|
||||
files: tuple[DownloadFileWrite, ...] = (),
|
||||
) -> int:
|
||||
"""在单一事务中新增历史与关联文件并返回历史 ID。"""
|
||||
...
|
||||
|
||||
async def async_delete(self, history_id: int) -> None:
|
||||
"""在独立异步事务中删除指定历史。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadHistoryRepository(
|
||||
DownloadHistoryQueryPort,
|
||||
DownloadHistoryWritePort,
|
||||
Protocol,
|
||||
):
|
||||
"""组合宿主所需全部下载历史查询和变更能力。"""
|
||||
|
||||
|
||||
class AsyncDownloadHistoryQueryRepository(Protocol):
|
||||
"""下载历史只读用例需要的最小异步持久化端口。"""
|
||||
|
||||
@@ -106,7 +341,7 @@ class AsyncDownloadHistoryQueryRepository(Protocol):
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[Any]:
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""按下载时间倒序分页读取历史记录。"""
|
||||
...
|
||||
|
||||
@@ -228,10 +463,10 @@ class HistoryQueryService:
|
||||
*,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistoryView]:
|
||||
) -> list[DownloadHistory]:
|
||||
"""分页读取下载历史并转换为稳定的接口 DTO。"""
|
||||
records = await self._download_repository.async_list_by_page(page, count)
|
||||
return [DownloadHistoryView.model_validate(record) for record in records]
|
||||
return [DownloadHistory.model_validate(record) for record in records]
|
||||
|
||||
async def list_transfer(
|
||||
self,
|
||||
@@ -276,23 +511,23 @@ class HistoryQueryService:
|
||||
total = await self._transfer_repository.async_count(status=status)
|
||||
|
||||
return TransferHistoryPage(
|
||||
list=[TransferHistoryView.model_validate(record) for record in records],
|
||||
list=[TransferHistory.model_validate(record) for record in records],
|
||||
total=int(total or 0),
|
||||
)
|
||||
|
||||
async def get_transfer(self, history_id: int) -> Optional[TransferHistoryView]:
|
||||
async def get_transfer(self, history_id: int) -> Optional[TransferHistory]:
|
||||
"""读取单条整理历史 DTO,不向调用方泄漏 ORM 实例。"""
|
||||
record = await self._transfer_repository.async_get(history_id)
|
||||
if record is None:
|
||||
return None
|
||||
return TransferHistoryView.model_validate(record)
|
||||
return TransferHistory.model_validate(record)
|
||||
|
||||
async def get_transfers(
|
||||
self,
|
||||
history_ids: list[int],
|
||||
) -> tuple[list[TransferHistoryView], list[int]]:
|
||||
) -> tuple[list[TransferHistory], list[int]]:
|
||||
"""按输入顺序读取多条整理历史,并同时返回缺失 ID。"""
|
||||
records: list[TransferHistoryView] = []
|
||||
records: list[TransferHistory] = []
|
||||
missing_ids: list[int] = []
|
||||
for history_id in history_ids:
|
||||
record = await self.get_transfer(history_id)
|
||||
|
||||
+232
-56
@@ -2,15 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol, TypeVar
|
||||
from typing import Any, Generic, Optional, Protocol, TypeVar, Union
|
||||
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -72,6 +71,10 @@ class ClaimedOutboxMessage:
|
||||
attempt: int
|
||||
|
||||
|
||||
class OutboxLeaseLostError(RuntimeError):
|
||||
"""当前派发 owner 的 attempt 已失效,禁止假报成功或覆盖新 owner。"""
|
||||
|
||||
|
||||
def validate_durable_event_handlers(
|
||||
handlers: Mapping[str, Callable[[ClaimedOutboxMessage], None]],
|
||||
) -> None:
|
||||
@@ -84,40 +87,81 @@ def validate_durable_event_handlers(
|
||||
)
|
||||
|
||||
|
||||
class OutboxRepository(Protocol):
|
||||
"""outbox 写入、claim 和终态更新所需的最小端口。"""
|
||||
class OutboxStager(Protocol):
|
||||
"""只在业务事务中暂存 durable intent 的最小端口。"""
|
||||
|
||||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""在调用方当前事务中暂存意图,不自行提交。"""
|
||||
|
||||
def claim(self, now: datetime, lease_until: datetime) -> ClaimedOutboxMessage | None:
|
||||
|
||||
class OutboxDispatchStore(Protocol):
|
||||
"""使用独立短事务认领和结算 durable intent 的最小端口。"""
|
||||
|
||||
def claim(self, now: datetime, lease_until: datetime) -> Optional[ClaimedOutboxMessage]:
|
||||
"""原子认领一条到期消息。"""
|
||||
|
||||
def complete(self, message_id: int, completed_at: datetime) -> None:
|
||||
"""按消息 ID 标记完成。"""
|
||||
def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领一条到期消息。"""
|
||||
|
||||
def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 标记完成。"""
|
||||
|
||||
def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> None:
|
||||
"""记录有限退避或 dead-letter 终态。"""
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 记录退避或 dead-letter。"""
|
||||
|
||||
class AsyncOutboxTransaction(Protocol):
|
||||
"""异步业务事务暂存并收口 durable intent 的最小端口。"""
|
||||
class AsyncOutboxStager(Protocol):
|
||||
"""只在异步业务事务中暂存 durable intent 的最小端口。"""
|
||||
|
||||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""把 intent 加入调用方当前事务,但不自行提交。"""
|
||||
|
||||
async def complete_by_event_key(
|
||||
class AsyncOutboxDispatchStore(Protocol):
|
||||
"""使用独立异步短事务认领和结算 intent 的最小端口。"""
|
||||
|
||||
async def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领一条到期消息。"""
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> None:
|
||||
"""即时投递成功后按稳定幂等键标记 intent 完成。"""
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 标记完成。"""
|
||||
|
||||
async def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 记录退避或 dead-letter。"""
|
||||
|
||||
|
||||
class SyncUnitOfWork(Protocol):
|
||||
@@ -130,26 +174,106 @@ class SyncUnitOfWork(Protocol):
|
||||
"""回滚业务写入与 outbox intent。"""
|
||||
|
||||
|
||||
class SyncOutboxTransaction(Protocol):
|
||||
"""同步业务事务暂存并收口 durable intent 的最小端口。"""
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostCommitResult(Generic[T]):
|
||||
"""区分已提交业务结果与逐项完成或仍待恢复的后置效果。"""
|
||||
|
||||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""把 intent 加入调用方事务,但不自行提交。"""
|
||||
value: T
|
||||
business_committed: bool
|
||||
completed_effects: tuple[str, ...] = ()
|
||||
pending_effects: tuple[str, ...] = ()
|
||||
|
||||
def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> bool:
|
||||
"""在同步副作用前原子认领 intent,已被其他投递者持有时返回 False。"""
|
||||
|
||||
def complete_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
completed_at: datetime,
|
||||
) -> None:
|
||||
"""即时投递成功后按幂等键标记 intent 完成。"""
|
||||
class PostCommitEffectError(RuntimeError):
|
||||
"""业务已提交但至少一个后置效果失败,并携带可检查的完成结果。"""
|
||||
|
||||
def __init__(self, result: PostCommitResult[Any], errors: tuple[Exception, ...]):
|
||||
"""保存结构化完成状态及逐项原始异常。"""
|
||||
self.result = result
|
||||
self.errors = errors
|
||||
super().__init__(str(errors[0]) if errors else "提交后效果执行失败")
|
||||
|
||||
|
||||
def deliver_outbox_effect(
|
||||
store: OutboxDispatchStore,
|
||||
event_key: str,
|
||||
effect: Callable[[], object],
|
||||
*,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
) -> bool:
|
||||
"""先认领再执行同步效果,并用同一 attempt fencing 结算结果。"""
|
||||
now = (clock or (lambda: datetime.now(timezone.utc)))()
|
||||
claimed = store.claim_by_event_key(
|
||||
event_key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if claimed is None:
|
||||
return False
|
||||
try:
|
||||
confirmed = effect()
|
||||
except Exception as error:
|
||||
store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
if confirmed is False:
|
||||
store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="副作用未确认",
|
||||
dead=False,
|
||||
)
|
||||
return False
|
||||
if not store.complete(claimed.message_id, claimed.attempt, now):
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
return True
|
||||
|
||||
|
||||
async def deliver_async_outbox_effect(
|
||||
store: AsyncOutboxDispatchStore,
|
||||
event_key: str,
|
||||
effect: Callable[[], Awaitable[object]],
|
||||
*,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
) -> bool:
|
||||
"""先认领再执行异步效果,并用同一 attempt fencing 结算结果。"""
|
||||
now = (clock or (lambda: datetime.now(timezone.utc)))()
|
||||
claimed = await store.claim_by_event_key(
|
||||
event_key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if claimed is None:
|
||||
return False
|
||||
try:
|
||||
confirmed = await effect()
|
||||
except Exception as error:
|
||||
await store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
if confirmed is False:
|
||||
await store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="副作用未确认",
|
||||
dead=False,
|
||||
)
|
||||
return False
|
||||
if not await store.complete(claimed.message_id, claimed.attempt, now):
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
return True
|
||||
|
||||
|
||||
class DurableEventCommand:
|
||||
@@ -158,42 +282,85 @@ class DurableEventCommand:
|
||||
def __init__(
|
||||
self,
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
outbox: SyncOutboxTransaction,
|
||||
stager: OutboxStager,
|
||||
store: OutboxDispatchStore,
|
||||
) -> None:
|
||||
"""注入共享同一 Session 的事务与 outbox 端口。"""
|
||||
"""注入业务事务内 stager 与独立短事务 dispatch store。"""
|
||||
self._unit_of_work = unit_of_work
|
||||
self._outbox = outbox
|
||||
self._stager = stager
|
||||
self._store = store
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
intent: OutboxIntent | Callable[[T], OutboxIntent] | None,
|
||||
intent: Optional[Union[OutboxIntent, Callable[[T], OutboxIntent]]],
|
||||
stage_business: Callable[[], T],
|
||||
publish: Callable[[], None] | None,
|
||||
after_commit: Callable[[], None] | None = None,
|
||||
) -> T:
|
||||
"""原子提交业务与可选 intent,再执行可选提交后动作和广播。"""
|
||||
resolved_intent: OutboxIntent | None = None
|
||||
publish: Optional[Callable[[], None]],
|
||||
after_commit: Optional[Callable[[], None]] = None,
|
||||
) -> PostCommitResult[T]:
|
||||
"""原子提交业务与 intent,再逐项记录提交后效果完成语义。"""
|
||||
resolved_intent: Optional[OutboxIntent] = None
|
||||
try:
|
||||
result = stage_business()
|
||||
if intent is not None:
|
||||
resolved_intent = intent(result) if callable(intent) else intent
|
||||
self._outbox.stage(resolved_intent, datetime.now(timezone.utc))
|
||||
self._stager.stage(resolved_intent, datetime.now(timezone.utc))
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
completed: list[str] = []
|
||||
pending: list[str] = []
|
||||
errors: list[Exception] = []
|
||||
if after_commit:
|
||||
after_commit()
|
||||
if publish:
|
||||
publish()
|
||||
try:
|
||||
after_commit()
|
||||
completed.append("after_commit")
|
||||
except Exception as error:
|
||||
pending.append("after_commit")
|
||||
errors.append(error)
|
||||
if resolved_intent is not None:
|
||||
pending.append(resolved_intent.event_key)
|
||||
if resolved_intent is not None and publish is not None:
|
||||
self._outbox.complete_by_event_key(
|
||||
now = datetime.now(timezone.utc)
|
||||
claimed = self._store.claim_by_event_key(
|
||||
resolved_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
return result
|
||||
if claimed is not None:
|
||||
try:
|
||||
publish()
|
||||
settled = self._store.complete(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
if not settled:
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
pending.remove(resolved_intent.event_key)
|
||||
completed.append(resolved_intent.event_key)
|
||||
except OutboxLeaseLostError as error:
|
||||
errors.append(error)
|
||||
except Exception as error:
|
||||
self._store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=datetime.now(timezone.utc),
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
errors.append(error)
|
||||
execution = PostCommitResult(
|
||||
value=result,
|
||||
business_committed=True,
|
||||
completed_effects=tuple(completed),
|
||||
pending_effects=tuple(pending),
|
||||
)
|
||||
if errors:
|
||||
raise PostCommitEffectError(execution, tuple(errors))
|
||||
return execution
|
||||
|
||||
|
||||
class OutboxDispatcher:
|
||||
@@ -201,14 +368,14 @@ class OutboxDispatcher:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: OutboxRepository,
|
||||
repository: OutboxDispatchStore,
|
||||
handlers: dict[str, Callable[[ClaimedOutboxMessage], None]],
|
||||
*,
|
||||
max_attempts: int = 5,
|
||||
lease_seconds: int = OUTBOX_LEASE_SECONDS,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
close: Callable[[], None] | None = None,
|
||||
failure_observer: Callable[[bool], None] | None = None,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
close: Optional[Callable[[], None]] = None,
|
||||
failure_observer: Optional[Callable[[bool], None]] = None,
|
||||
) -> None:
|
||||
"""注入持久端口、topic handler、有界重试策略与失败观测端口。"""
|
||||
self._repository = repository
|
||||
@@ -231,25 +398,34 @@ class OutboxDispatcher:
|
||||
try:
|
||||
handler = self._handlers[message.topic]
|
||||
handler(message)
|
||||
if not self._repository.complete(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
now,
|
||||
):
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
except OutboxLeaseLostError:
|
||||
raise
|
||||
except Exception as error:
|
||||
dead = message.attempt >= self._max_attempts
|
||||
delay = min(3600, 2 ** max(0, message.attempt - 1))
|
||||
self._repository.retry(
|
||||
settled = self._repository.retry(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
next_retry_at=now + timedelta(seconds=delay),
|
||||
last_error=str(error)[:4000],
|
||||
dead=dead,
|
||||
)
|
||||
self._failure_observer(dead)
|
||||
if settled:
|
||||
self._failure_observer(dead)
|
||||
return True
|
||||
self._repository.complete(message.message_id, now)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""释放 dispatcher 工厂创建的短生命周期持久化资源。"""
|
||||
self._close()
|
||||
|
||||
_configured_dispatcher: Callable[[], OutboxDispatcher] | None = None
|
||||
_configured_dispatcher: Optional[Callable[[], OutboxDispatcher]] = None
|
||||
|
||||
|
||||
def configure_outbox_dispatcher(provider: Callable[[], OutboxDispatcher]) -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -43,13 +44,13 @@ class AuthTicketStore(metaclass=Singleton):
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._cleanup(now)
|
||||
self._tickets[ticket] = {
|
||||
"user_id": int(user_id),
|
||||
"provider_id": provider_id,
|
||||
"metadata": metadata or {},
|
||||
"metadata": copy.deepcopy(metadata) if metadata is not None else {},
|
||||
"created_at": now,
|
||||
}
|
||||
self._cleanup(now)
|
||||
return ticket
|
||||
|
||||
def consume(self, ticket: str) -> Optional[dict[str, Any]]:
|
||||
@@ -69,7 +70,7 @@ class AuthTicketStore(metaclass=Singleton):
|
||||
return None
|
||||
if now - float(data.get("created_at") or 0) > self._ttl_seconds:
|
||||
return None
|
||||
return data
|
||||
return copy.deepcopy(data)
|
||||
|
||||
def _cleanup(self, now: Optional[float] = None) -> None:
|
||||
"""
|
||||
@@ -77,7 +78,7 @@ class AuthTicketStore(metaclass=Singleton):
|
||||
|
||||
:param now: 当前时间戳,未传入时自动读取
|
||||
"""
|
||||
current = now or time.time()
|
||||
current = time.time() if now is None else now
|
||||
expired = [
|
||||
key
|
||||
for key, value in self._tickets.items()
|
||||
|
||||
@@ -5,7 +5,6 @@ import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Literal, Optional, Protocol, Tuple
|
||||
from urllib.parse import urlparse
|
||||
@@ -28,9 +27,7 @@ from webauthn.helpers.structs import (
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from app.adapters.cache.redis import RedisHelper
|
||||
from app.application.configuration import get_api_runtime_config_snapshot
|
||||
from app.runtime.cache import TTLCache
|
||||
from app.runtime.log import logger
|
||||
|
||||
PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
|
||||
@@ -46,15 +43,27 @@ class PasskeyChallenge:
|
||||
user_id: Optional[int]
|
||||
|
||||
|
||||
class PasskeyChallengeCache(Protocol):
|
||||
"""PassKey 一次性 challenge 使用的严格原子缓存端口。"""
|
||||
|
||||
def store(self, key: str, value: Any) -> None:
|
||||
"""持久化 challenge,失败时抛出后端异常。"""
|
||||
|
||||
def consume(self, key: str) -> Any:
|
||||
"""原子领取 challenge,不存在时返回 None。"""
|
||||
|
||||
|
||||
class PasskeyChallengeStore:
|
||||
"""使用当前缓存后端签发并原子消费短时 Passkey challenge。"""
|
||||
|
||||
_cache = TTLCache(
|
||||
region="passkey_challenge",
|
||||
maxsize=4096,
|
||||
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
|
||||
)
|
||||
_memory_consume_lock = threading.Lock()
|
||||
_cache: Optional[PasskeyChallengeCache] = None
|
||||
|
||||
@classmethod
|
||||
def _get_cache(cls) -> PasskeyChallengeCache:
|
||||
"""返回已装配缓存,缺失时拒绝签发认证状态。"""
|
||||
if cls._cache is None:
|
||||
raise RuntimeError("PassKey challenge 缓存尚未配置")
|
||||
return cls._cache
|
||||
|
||||
@classmethod
|
||||
def issue(
|
||||
@@ -66,7 +75,7 @@ class PasskeyChallengeStore:
|
||||
) -> str:
|
||||
"""保存 challenge 并返回不携带认证事实的随机事务 token。"""
|
||||
transaction_token = secrets.token_urlsafe(32)
|
||||
cls._cache.set(
|
||||
cls._get_cache().store(
|
||||
transaction_token,
|
||||
PasskeyChallenge(
|
||||
challenge=challenge,
|
||||
@@ -87,17 +96,7 @@ class PasskeyChallengeStore:
|
||||
if not transaction_token:
|
||||
return None
|
||||
|
||||
if cls._cache.is_redis():
|
||||
challenge = RedisHelper().pop(
|
||||
transaction_token,
|
||||
region="passkey_challenge",
|
||||
)
|
||||
else:
|
||||
with cls._memory_consume_lock:
|
||||
try:
|
||||
challenge = cls._cache.pop(transaction_token)
|
||||
except KeyError:
|
||||
challenge = None
|
||||
challenge = cls._get_cache().consume(transaction_token)
|
||||
|
||||
if not isinstance(challenge, PasskeyChallenge):
|
||||
return None
|
||||
@@ -106,6 +105,11 @@ class PasskeyChallengeStore:
|
||||
return challenge
|
||||
|
||||
|
||||
def configure_passkey_challenge_cache(cache: PasskeyChallengeCache) -> None:
|
||||
"""由启动组合根注入 PassKey challenge 的原子缓存。"""
|
||||
PasskeyChallengeStore._cache = cache
|
||||
|
||||
|
||||
class PassKeyRegistrationVerificationError(Exception):
|
||||
"""Passkey 注册响应未通过 WebAuthn 安全校验。"""
|
||||
|
||||
@@ -465,8 +469,13 @@ class PasskeyRepository(Protocol):
|
||||
def create(self, payload: dict[str, Any]) -> Any:
|
||||
"""创建凭证。"""
|
||||
|
||||
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
|
||||
"""更新凭证使用计数。"""
|
||||
def compare_and_update_sign_count(
|
||||
self,
|
||||
passkey_id: int,
|
||||
expected_sign_count: int,
|
||||
sign_count: int,
|
||||
) -> bool:
|
||||
"""仅在签名计数未被并发修改时记录本次认证。"""
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除用户凭证。"""
|
||||
@@ -495,9 +504,18 @@ class PasskeyService:
|
||||
"""创建凭证。"""
|
||||
return self._repository.create(payload)
|
||||
|
||||
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
|
||||
"""更新凭证使用计数。"""
|
||||
return self._repository.update_last_used(passkey, sign_count)
|
||||
def compare_and_update_sign_count(
|
||||
self,
|
||||
passkey_id: int,
|
||||
expected_sign_count: int,
|
||||
sign_count: int,
|
||||
) -> bool:
|
||||
"""以验证时观察到的旧计数提交本次认证。"""
|
||||
return self._repository.compare_and_update_sign_count(
|
||||
passkey_id=passkey_id,
|
||||
expected_sign_count=expected_sign_count,
|
||||
sign_count=sign_count,
|
||||
)
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除用户凭证。"""
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol, TypeAlias, TypeVar, cast
|
||||
from typing import Any, Optional, Protocol, TypeAlias, TypeVar, Union, cast
|
||||
|
||||
FrozenJson: TypeAlias = (
|
||||
str | int | float | bool | None | tuple["FrozenJson", ...] | Mapping[str, "FrozenJson"]
|
||||
)
|
||||
FrozenJson: TypeAlias = Union[
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
bool,
|
||||
None,
|
||||
tuple["FrozenJson", ...],
|
||||
Mapping[str, "FrozenJson"],
|
||||
]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -24,7 +30,9 @@ def _freeze_json(value: Any) -> FrozenJson:
|
||||
return cast(FrozenJson, value)
|
||||
|
||||
|
||||
def _freeze_mapping(value: Mapping[str, Any] | None) -> Mapping[str, FrozenJson]:
|
||||
def _freeze_mapping(
|
||||
value: Optional[Mapping[str, Any]],
|
||||
) -> Mapping[str, FrozenJson]:
|
||||
"""把可空 JSON 对象复制为只读映射。"""
|
||||
frozen = _freeze_json(value or {})
|
||||
return cast(Mapping[str, FrozenJson], frozen)
|
||||
@@ -36,10 +44,10 @@ class UserSnapshot:
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str | None
|
||||
email: Optional[str]
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
avatar: str | None
|
||||
avatar: Optional[str]
|
||||
is_otp: bool
|
||||
permissions: Mapping[str, FrozenJson]
|
||||
settings: Mapping[str, FrozenJson]
|
||||
@@ -50,13 +58,13 @@ class UserSnapshot:
|
||||
*,
|
||||
user_id: int,
|
||||
name: str,
|
||||
email: str | None,
|
||||
is_active: bool | None,
|
||||
is_superuser: bool | None,
|
||||
avatar: str | None,
|
||||
is_otp: bool | None,
|
||||
permissions: Mapping[str, Any] | None,
|
||||
settings: Mapping[str, Any] | None,
|
||||
email: Optional[str],
|
||||
is_active: Optional[bool],
|
||||
is_superuser: Optional[bool],
|
||||
avatar: Optional[str],
|
||||
is_otp: Optional[bool],
|
||||
permissions: Optional[Mapping[str, Any]],
|
||||
settings: Optional[Mapping[str, Any]],
|
||||
) -> "UserSnapshot":
|
||||
"""复制持久化字段并构造不可变的公开用户快照。"""
|
||||
return cls(
|
||||
@@ -77,8 +85,8 @@ class UserAuthSnapshot:
|
||||
"""仅供认证链使用的只读用户凭据快照。"""
|
||||
|
||||
user: UserSnapshot
|
||||
hashed_password: str | None
|
||||
otp_secret: str | None
|
||||
hashed_password: Optional[str]
|
||||
otp_secret: Optional[str]
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
@@ -101,7 +109,7 @@ class UserAuthSnapshot:
|
||||
return self.user.is_superuser
|
||||
|
||||
@property
|
||||
def avatar(self) -> str | None:
|
||||
def avatar(self) -> Optional[str]:
|
||||
"""返回用户头像。"""
|
||||
return self.user.avatar
|
||||
|
||||
@@ -126,13 +134,21 @@ class AuxiliaryUserCreate:
|
||||
is_superuser: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserUpdateResult:
|
||||
"""用户更新事务产出的新快照与原用户名。"""
|
||||
|
||||
user: UserSnapshot
|
||||
previous_name: str
|
||||
|
||||
|
||||
class ChainUserRepository(Protocol):
|
||||
"""用户 Chain 和 Agent 共享的类型化查询与创建端口。"""
|
||||
|
||||
def get_auth_by_name(self, name: str) -> UserAuthSnapshot | None:
|
||||
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
|
||||
"""按用户名读取认证快照。"""
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""异步按用户名读取公开用户快照。"""
|
||||
|
||||
def create_auxiliary(self, command: AuxiliaryUserCreate) -> UserAuthSnapshot:
|
||||
@@ -141,16 +157,19 @@ class ChainUserRepository(Protocol):
|
||||
def get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""读取通知路由设置;用户不存在时返回空值。"""
|
||||
|
||||
async def async_get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""异步读取通知路由设置;用户不存在时返回空值。"""
|
||||
|
||||
def find_name_by_bindings(self, bindings: Mapping[str, object]) -> str | None:
|
||||
def find_name_by_bindings(
|
||||
self,
|
||||
bindings: Mapping[str, object],
|
||||
) -> Optional[str]:
|
||||
"""解析唯一启用用户的渠道绑定,歧义时拒绝归属。"""
|
||||
|
||||
|
||||
@@ -160,24 +179,27 @@ class UserRepository(Protocol):
|
||||
async def async_list(self) -> list[UserSnapshot]:
|
||||
"""返回全部用户。"""
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""按用户名返回用户。"""
|
||||
|
||||
async def async_get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
async def async_get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""按用户 ID 返回用户。"""
|
||||
|
||||
async def async_create(self, payload: dict[str, Any]) -> UserSnapshot | None:
|
||||
async def async_create(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> Optional[UserSnapshot]:
|
||||
"""创建用户并返回持久化对象。"""
|
||||
|
||||
async def async_update(
|
||||
self,
|
||||
user_id: int,
|
||||
payload: dict[str, Any],
|
||||
) -> UserSnapshot | None:
|
||||
"""更新用户并返回原用户对象。"""
|
||||
) -> Optional[UserUpdateResult]:
|
||||
"""更新用户并返回提交后快照发布所需的变更结果。"""
|
||||
|
||||
async def async_delete(self, user_id: int) -> None:
|
||||
"""删除用户。"""
|
||||
async def async_delete(self, user_id: int) -> Optional[str]:
|
||||
"""删除用户并返回被删除用户名。"""
|
||||
|
||||
async def async_update_otp_by_name(self, name: str, otp: bool, secret: str) -> None:
|
||||
"""更新用户 OTP 状态。"""
|
||||
@@ -193,31 +215,51 @@ class AsyncUnitOfWork(Protocol):
|
||||
"""回滚失败的用户写入。"""
|
||||
|
||||
|
||||
class UserConfigurationPublisher(Protocol):
|
||||
"""用户聚合提交后同步进程级配置快照的应用端口。"""
|
||||
|
||||
async def rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""数据库改名提交后迁移对应用户名配置快照。"""
|
||||
|
||||
async def delete(self, username: str) -> None:
|
||||
"""数据库删除提交后移除对应用户名配置快照。"""
|
||||
|
||||
|
||||
class UserNameConflictError(Exception):
|
||||
"""用户名在数据库唯一约束下发生冲突。"""
|
||||
|
||||
|
||||
class LastActiveSuperuserError(Exception):
|
||||
"""用户变更会导致系统不再存在启用的超级管理员。"""
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户管理应用服务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: UserRepository,
|
||||
unit_of_work: AsyncUnitOfWork | None = None,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
configuration: UserConfigurationPublisher,
|
||||
) -> None:
|
||||
"""创建用户服务;旧独立仓储可暂不提供请求级 UoW。"""
|
||||
"""创建用户服务并注入事务边界与提交后配置发布端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._configuration = configuration
|
||||
|
||||
async def list(self) -> list[UserSnapshot]:
|
||||
"""返回用户列表。"""
|
||||
return await self._repository.async_list()
|
||||
|
||||
async def get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""按用户名查询用户。"""
|
||||
return await self._repository.async_get_by_name(name)
|
||||
|
||||
async def get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
async def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""按用户 ID 查询用户。"""
|
||||
return await self._repository.async_get_by_id(user_id)
|
||||
|
||||
async def create(self, payload: dict[str, Any]) -> UserSnapshot | None:
|
||||
async def create(self, payload: dict[str, Any]) -> Optional[UserSnapshot]:
|
||||
"""创建用户。"""
|
||||
return await self._write(lambda: self._repository.async_create(payload))
|
||||
|
||||
@@ -225,44 +267,45 @@ class UserService:
|
||||
self,
|
||||
user_id: int,
|
||||
payload: dict[str, Any],
|
||||
) -> UserSnapshot | None:
|
||||
) -> Optional[UserSnapshot]:
|
||||
"""更新用户。"""
|
||||
return await self._write(
|
||||
lambda: self._repository.async_update(user_id, payload)
|
||||
)
|
||||
result = await self._write(lambda: self._repository.async_update(user_id, payload))
|
||||
if result is None:
|
||||
return None
|
||||
if result.previous_name != result.user.name:
|
||||
await self._configuration.rename(result.previous_name, result.user.name)
|
||||
return result.user
|
||||
|
||||
async def delete(self, user_id: int) -> None:
|
||||
"""删除用户。"""
|
||||
await self._write(lambda: self._repository.async_delete(user_id))
|
||||
username = await self._write(lambda: self._repository.async_delete(user_id))
|
||||
if username is not None:
|
||||
await self._configuration.delete(username)
|
||||
|
||||
async def update_otp(self, name: str, otp: bool, secret: str) -> None:
|
||||
"""更新用户 OTP 状态。"""
|
||||
await self._write(
|
||||
lambda: self._repository.async_update_otp_by_name(name, otp, secret)
|
||||
)
|
||||
await self._write(lambda: self._repository.async_update_otp_by_name(name, otp, secret))
|
||||
|
||||
async def _write(self, operation: Callable[[], Awaitable[T]]) -> T:
|
||||
"""执行用户写入,并在正式请求路径统一提交或回滚。"""
|
||||
try:
|
||||
result = await operation()
|
||||
if self._unit_of_work is not None:
|
||||
await self._unit_of_work.commit()
|
||||
await self._unit_of_work.commit()
|
||||
return result
|
||||
except Exception:
|
||||
if self._unit_of_work is not None:
|
||||
await self._unit_of_work.rollback()
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
_configured_user_id_lookup: Callable[[int], UserSnapshot | None] | None = None
|
||||
_configured_user_name_lookup: Callable[[str], UserSnapshot | None] | None = None
|
||||
_configured_user_channel_lookup: Callable[..., str | None] | None = None
|
||||
_configured_user_id_lookup: Optional[Callable[[int], Optional[UserSnapshot]]] = None
|
||||
_configured_user_name_lookup: Optional[Callable[[str], Optional[UserSnapshot]]] = None
|
||||
_configured_user_channel_lookup: Optional[Callable[..., Optional[str]]] = None
|
||||
|
||||
|
||||
def configure_user_lookups(
|
||||
by_id: Callable[[int], UserSnapshot | None],
|
||||
by_name: Callable[[str], UserSnapshot | None],
|
||||
by_channel: Callable[..., str | None],
|
||||
by_id: Callable[[int], Optional[UserSnapshot]],
|
||||
by_name: Callable[[str], Optional[UserSnapshot]],
|
||||
by_channel: Callable[..., Optional[str]],
|
||||
) -> None:
|
||||
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
|
||||
global _configured_user_id_lookup, _configured_user_name_lookup
|
||||
@@ -272,21 +315,21 @@ def configure_user_lookups(
|
||||
_configured_user_channel_lookup = by_channel
|
||||
|
||||
|
||||
def get_configured_user_id_lookup() -> Callable[[int], UserSnapshot | None]:
|
||||
def get_configured_user_id_lookup() -> Callable[[int], Optional[UserSnapshot]]:
|
||||
"""返回启动阶段登记的按 ID 用户查询函数。"""
|
||||
if _configured_user_id_lookup is None:
|
||||
raise RuntimeError("按 ID 的用户查询能力尚未配置")
|
||||
return _configured_user_id_lookup
|
||||
|
||||
|
||||
def get_configured_user_name_lookup() -> Callable[[str], UserSnapshot | None]:
|
||||
def get_configured_user_name_lookup() -> Callable[[str], Optional[UserSnapshot]]:
|
||||
"""返回启动阶段登记的按用户名查询函数。"""
|
||||
if _configured_user_name_lookup is None:
|
||||
raise RuntimeError("按用户名的用户查询能力尚未配置")
|
||||
return _configured_user_name_lookup
|
||||
|
||||
|
||||
def get_configured_user_channel_lookup() -> Callable[..., str | None]:
|
||||
def get_configured_user_channel_lookup() -> Callable[..., Optional[str]]:
|
||||
"""返回启动阶段登记的渠道身份到用户名查询函数。"""
|
||||
if _configured_user_channel_lookup is None:
|
||||
raise RuntimeError("渠道用户查询能力尚未配置")
|
||||
|
||||
@@ -3,20 +3,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any, Protocol
|
||||
from typing import Optional, Protocol, Union
|
||||
|
||||
from app.application.database import AsyncDatabaseExecutor
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.types import UserConfigKey
|
||||
|
||||
|
||||
class UserConfigurationRepository(Protocol):
|
||||
"""用户配置数据端口。"""
|
||||
|
||||
def get(self, username: str, key: str) -> Any:
|
||||
def get(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
) -> JsonData:
|
||||
"""读取用户配置。"""
|
||||
|
||||
def set(self, username: str, key: str, value: Any) -> Any:
|
||||
def set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""写入用户配置。"""
|
||||
|
||||
def publish_rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""在用户改名提交后迁移进程级配置快照。"""
|
||||
|
||||
def publish_delete(self, username: str) -> None:
|
||||
"""在用户删除提交后移除进程级配置快照。"""
|
||||
|
||||
|
||||
class UserConfigurationService:
|
||||
"""编排用户个性化配置读写。"""
|
||||
@@ -25,30 +42,54 @@ class UserConfigurationService:
|
||||
self,
|
||||
repository: UserConfigurationRepository,
|
||||
*,
|
||||
async_executor: AsyncDatabaseExecutor | None = None,
|
||||
async_executor: Optional[AsyncDatabaseExecutor] = None,
|
||||
) -> None:
|
||||
"""注入用户配置数据端口及可选的异步事务执行能力。"""
|
||||
self._repository = repository
|
||||
self._async_executor = async_executor
|
||||
|
||||
def get(self, username: str, key: str) -> Any:
|
||||
def get(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
) -> JsonData:
|
||||
"""读取用户配置。"""
|
||||
return self._repository.get(username=username, key=key)
|
||||
|
||||
def set(self, username: str, key: str, value: Any) -> Any:
|
||||
def set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""写入用户配置。"""
|
||||
return self._repository.set(username=username, key=key, value=value)
|
||||
self._repository.set(username=username, key=key, value=value)
|
||||
|
||||
async def async_set(self, username: str, key: str, value: Any) -> Any:
|
||||
async def async_set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""异步写入用户配置,并等待数据库提交或回滚完成。"""
|
||||
if self._async_executor is None:
|
||||
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
|
||||
return await self._async_executor.run(
|
||||
partial(self._repository.set, username=username, key=key, value=value)
|
||||
)
|
||||
await self._async_executor.run(partial(self._repository.set, username=username, key=key, value=value))
|
||||
|
||||
async def rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""异步发布已提交的用户名配置迁移。"""
|
||||
if self._async_executor is None:
|
||||
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
|
||||
await self._async_executor.run(partial(self._repository.publish_rename, previous_name, current_name))
|
||||
|
||||
async def delete(self, username: str) -> None:
|
||||
"""异步发布已提交的用户名配置删除。"""
|
||||
if self._async_executor is None:
|
||||
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
|
||||
await self._async_executor.run(partial(self._repository.publish_delete, username))
|
||||
|
||||
|
||||
_configured_user_configuration: UserConfigurationService | None = None
|
||||
_configured_user_configuration: Optional[UserConfigurationService] = None
|
||||
|
||||
|
||||
def configure_user_configuration(service: UserConfigurationService) -> None:
|
||||
|
||||
@@ -4,15 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Protocol
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from app.application.outbox import (
|
||||
OUTBOX_LEASE_SECONDS,
|
||||
SUBSCRIBE_COMPLETED_TOPIC,
|
||||
OutboxDispatchStore,
|
||||
OutboxIntent,
|
||||
SyncOutboxTransaction,
|
||||
OutboxStager,
|
||||
SyncUnitOfWork,
|
||||
deliver_outbox_effect,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
@@ -40,13 +41,15 @@ class CompleteSubscriptionCommand:
|
||||
self,
|
||||
repository: SubscriptionCompletionRepository,
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
outbox: SyncOutboxTransaction | None,
|
||||
outbox: Optional[OutboxStager],
|
||||
dispatch_store: Optional[OutboxDispatchStore],
|
||||
publish: Callable[[dict[str, Any]], 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(
|
||||
@@ -109,40 +112,41 @@ class CompleteSubscriptionCommand:
|
||||
raise
|
||||
|
||||
if notification:
|
||||
if self._claim_sync_delivery(notification_key):
|
||||
if self._dispatch_store:
|
||||
deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
notification_key,
|
||||
notify,
|
||||
)
|
||||
else:
|
||||
notify()
|
||||
self._complete_sync_delivery(notification_key)
|
||||
else:
|
||||
notify()
|
||||
if self._claim_sync_delivery(event_key):
|
||||
if self._dispatch_store:
|
||||
deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
event_key,
|
||||
lambda: self._publish(event_payload),
|
||||
)
|
||||
else:
|
||||
self._publish(event_payload)
|
||||
self._complete_sync_delivery(event_key)
|
||||
if self._claim_sync_delivery(report_key):
|
||||
if self._dispatch_store:
|
||||
try:
|
||||
report_delivered = report(report_payload["subscribe_info"])
|
||||
report_delivered = deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
report_key,
|
||||
lambda: report(report_payload["subscribe_info"]),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅完成统计上报失败,将由后台重试:{error}")
|
||||
else:
|
||||
if report_delivered is False:
|
||||
logger.warning("订阅完成统计上报未确认,将由后台重试")
|
||||
else:
|
||||
self._complete_sync_delivery(report_key)
|
||||
|
||||
def _claim_sync_delivery(self, event_key: str) -> bool:
|
||||
"""在同步副作用前取得 lease,已由恢复投递接管时跳过直投。"""
|
||||
if self._outbox is None:
|
||||
return True
|
||||
now = datetime.now(timezone.utc)
|
||||
return self._outbox.claim_by_event_key(
|
||||
event_key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
|
||||
def _complete_sync_delivery(self, event_key: str) -> None:
|
||||
"""收口当前同步投递持有的 durable intent。"""
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(event_key, datetime.now(timezone.utc))
|
||||
else:
|
||||
try:
|
||||
report(report_payload["subscribe_info"])
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅完成统计上报失败:{error}")
|
||||
|
||||
|
||||
def completion_event_key(subscribe_id: int, subscribe_info: Mapping[str, Any]) -> str:
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""订阅删除应用用例及其依赖端口。"""
|
||||
|
||||
import inspect
|
||||
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import inspect
|
||||
from typing import Any, Awaitable, Callable, Mapping, Protocol, cast
|
||||
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.outbox import (
|
||||
AsyncOutboxTransaction,
|
||||
OutboxIntent,
|
||||
SyncOutboxTransaction,
|
||||
SyncUnitOfWork,
|
||||
SUBSCRIBE_DELETED_TOPIC,
|
||||
AsyncOutboxDispatchStore,
|
||||
AsyncOutboxStager,
|
||||
OutboxDispatchStore,
|
||||
OutboxIntent,
|
||||
OutboxStager,
|
||||
SyncUnitOfWork,
|
||||
deliver_async_outbox_effect,
|
||||
deliver_outbox_effect,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.event import SubscribeDeletedEventData
|
||||
@@ -64,6 +68,7 @@ class SyncSubscribeDeletionRepository(Protocol):
|
||||
"""把已读取的订阅登记为待删除,但不自行提交事务。"""
|
||||
...
|
||||
|
||||
|
||||
class AsyncUnitOfWork(Protocol):
|
||||
"""订阅写用例使用的异步事务端口。"""
|
||||
|
||||
@@ -101,7 +106,8 @@ class DeleteSubscribeCommand:
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
report_deleted: SubscribeDeletedReporter,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
outbox: Optional[AsyncOutboxStager] = None,
|
||||
dispatch_store: Optional[AsyncOutboxDispatchStore] = None,
|
||||
) -> None:
|
||||
"""注入数据访问、事务与提交后副作用端口。"""
|
||||
self._repository = repository
|
||||
@@ -109,6 +115,7 @@ class DeleteSubscribeCommand:
|
||||
self._publish_deleted = publish_deleted
|
||||
self._report_deleted = report_deleted
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -146,28 +153,37 @@ class DeleteSubscribeCommand:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
await self._publish_deleted(effects.event_payload)
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
if self._dispatch_store:
|
||||
await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.event_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
lambda: self._publish_deleted(effects.event_payload),
|
||||
)
|
||||
else:
|
||||
await self._publish_deleted(effects.event_payload)
|
||||
# 上报适配器会自行白名单过滤公开字段;传完整删除前快照可保留音乐实体维度,
|
||||
# 避免 Agent 与 API 入口收敛后丢失 music_type / total_tracks。
|
||||
try:
|
||||
report_result = self._report_deleted(effects.report_payload)
|
||||
if inspect.isawaitable(report_result):
|
||||
report_result = await report_result
|
||||
|
||||
async def report() -> object:
|
||||
"""统一等待同步或异步统计 reporter 的确认结果。"""
|
||||
result = self._report_deleted(effects.report_payload)
|
||||
return await result if inspect.isawaitable(result) else result
|
||||
|
||||
report_result: object
|
||||
if self._dispatch_store:
|
||||
report_result = await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.report_intent.event_key,
|
||||
report,
|
||||
)
|
||||
else:
|
||||
report_result = await report()
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅删除统计上报失败,将由后台重试:{error}")
|
||||
else:
|
||||
if report_result is False:
|
||||
logger.warning("订阅删除统计上报未确认,将由后台重试")
|
||||
elif self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
effects.report_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -180,7 +196,8 @@ class SyncDeleteSubscribeCommand:
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
publish_deleted: SyncSubscribeDeletedPublisher,
|
||||
report_deleted: SyncSubscribeDeletedReporter,
|
||||
outbox: SyncOutboxTransaction | None = None,
|
||||
outbox: Optional[OutboxStager] = None,
|
||||
dispatch_store: Optional[OutboxDispatchStore] = None,
|
||||
) -> None:
|
||||
"""注入同步数据访问、事务与提交后副作用端口。"""
|
||||
self._repository = repository
|
||||
@@ -188,6 +205,7 @@ class SyncDeleteSubscribeCommand:
|
||||
self._publish_deleted = publish_deleted
|
||||
self._report_deleted = report_deleted
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -215,24 +233,29 @@ class SyncDeleteSubscribeCommand:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
self._publish_deleted(effects.event_payload)
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(
|
||||
if self._dispatch_store:
|
||||
deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.event_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
lambda: self._publish_deleted(effects.event_payload),
|
||||
)
|
||||
else:
|
||||
self._publish_deleted(effects.event_payload)
|
||||
try:
|
||||
report_result = self._report_deleted(effects.report_payload)
|
||||
report_result: object
|
||||
if self._dispatch_store:
|
||||
report_result = deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.report_intent.event_key,
|
||||
lambda: self._report_deleted(effects.report_payload),
|
||||
)
|
||||
else:
|
||||
report_result = self._report_deleted(effects.report_payload)
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅删除统计上报失败,将由后台重试:{error}")
|
||||
else:
|
||||
if report_result is False:
|
||||
logger.warning("订阅删除统计上报未确认,将由后台重试")
|
||||
elif self._outbox:
|
||||
self._outbox.complete_by_event_key(
|
||||
effects.report_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -257,6 +280,7 @@ def _build_deletion_effects(
|
||||
event_key = event_payload["idempotency_key"]
|
||||
report_key = f"{event_key}:report"
|
||||
report_payload = dict(subscribe_info)
|
||||
report_payload["idempotency_key"] = report_key
|
||||
return _SubscribeDeletionEffects(
|
||||
event_payload=event_payload,
|
||||
report_payload=report_payload,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""按媒体身份批量删除订阅的应用用例。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Protocol
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from app.application.outbox import (
|
||||
AsyncOutboxTransaction,
|
||||
OutboxIntent,
|
||||
SUBSCRIBE_DELETED_TOPIC,
|
||||
AsyncOutboxDispatchStore,
|
||||
AsyncOutboxStager,
|
||||
OutboxIntent,
|
||||
deliver_async_outbox_effect,
|
||||
)
|
||||
from app.application.subscription.delete import (
|
||||
AsyncUnitOfWork,
|
||||
@@ -48,7 +50,8 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
handle_event_error: SubscribeDeletionEventErrorHandler,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
outbox: Optional[AsyncOutboxStager] = None,
|
||||
dispatch_store: Optional[AsyncOutboxDispatchStore] = None,
|
||||
) -> None:
|
||||
"""注入数据访问、事务、事件和事件错误处理端口。"""
|
||||
self._repository = repository
|
||||
@@ -56,6 +59,7 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
self._publish_deleted = publish_deleted
|
||||
self._handle_event_error = handle_event_error
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -72,11 +76,7 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
season,
|
||||
music_type,
|
||||
)
|
||||
deletions = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if self._can_delete(candidate, actor)
|
||||
]
|
||||
deletions = [candidate 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)
|
||||
@@ -105,12 +105,21 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
|
||||
for candidate, event_payload in events:
|
||||
try:
|
||||
await self._publish_deleted(event_payload)
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
if self._dispatch_store:
|
||||
|
||||
async def publish_event(
|
||||
payload: dict[str, Any] = event_payload,
|
||||
) -> None:
|
||||
"""发布当前删除候选对应的稳定事件快照。"""
|
||||
await self._publish_deleted(payload)
|
||||
|
||||
await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
event_payload["idempotency_key"],
|
||||
datetime.now(timezone.utc),
|
||||
publish_event,
|
||||
)
|
||||
else:
|
||||
await self._publish_deleted(event_payload)
|
||||
except Exception as error:
|
||||
self._handle_event_error(candidate.subscribe_id, error)
|
||||
return len(deletions)
|
||||
|
||||
@@ -4,13 +4,15 @@ 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 typing import Any, Optional, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.outbox import (
|
||||
AsyncOutboxTransaction,
|
||||
OutboxIntent,
|
||||
SUBSCRIBE_MODIFIED_TOPIC,
|
||||
AsyncOutboxDispatchStore,
|
||||
AsyncOutboxStager,
|
||||
OutboxIntent,
|
||||
deliver_async_outbox_effect,
|
||||
)
|
||||
from app.schemas.event import SubscribeModifiedEventData
|
||||
|
||||
@@ -73,6 +75,8 @@ class SubscriptionMutation:
|
||||
old: dict[str, Any]
|
||||
new: dict[str, Any]
|
||||
event_published: bool = False
|
||||
business_committed: bool = False
|
||||
pending_effects: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class SubscriptionMutationService:
|
||||
@@ -83,7 +87,8 @@ class SubscriptionMutationService:
|
||||
repository: SubscriptionMutationRepository,
|
||||
history_repository: SubscriptionHistoryMutationRepository | None = None,
|
||||
unit_of_work: AsyncUnitOfWork | None = None,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
outbox: Optional[AsyncOutboxStager] = None,
|
||||
dispatch_store: Optional[AsyncOutboxDispatchStore] = None,
|
||||
publish_modified: SubscribeModifiedPublisher | None = None,
|
||||
) -> None:
|
||||
"""注入订阅数据、事务与 durable 事件端口。"""
|
||||
@@ -91,6 +96,7 @@ class SubscriptionMutationService:
|
||||
self._history_repository = history_repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
self._publish_modified = publish_modified
|
||||
|
||||
async def get_accessible(
|
||||
@@ -130,8 +136,9 @@ class SubscriptionMutationService:
|
||||
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 或事件发布端口")
|
||||
publish_modified = self._publish_modified
|
||||
if not self._outbox or not self._dispatch_store or not publish_modified:
|
||||
raise RuntimeError("订阅修改事务缺少 outbox stager、store 或事件发布端口")
|
||||
try:
|
||||
updated = await self._repository.async_stage_update(subscribe_id, payload)
|
||||
if not updated:
|
||||
@@ -157,15 +164,21 @@ class SubscriptionMutationService:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
await self._publish_modified(event_payload)
|
||||
await self._outbox.complete_by_event_key(
|
||||
async def publish_event() -> None:
|
||||
"""发布本次事务已持久化的订阅修改事件。"""
|
||||
await publish_modified(event_payload)
|
||||
|
||||
delivered = await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
event_key,
|
||||
datetime.now(timezone.utc),
|
||||
publish_event,
|
||||
)
|
||||
return SubscriptionMutation(
|
||||
old=old,
|
||||
new=event_payload["subscribe_info"],
|
||||
event_published=True,
|
||||
event_published=delivered,
|
||||
business_committed=True,
|
||||
pending_effects=() if delivered else (event_key,),
|
||||
)
|
||||
|
||||
async def update_status(
|
||||
|
||||
@@ -19,7 +19,7 @@ from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Mapping, Optional, Protocol, Tuple
|
||||
|
||||
from app.application.outbox import OutboxIntent, SUBSCRIBE_ADDED_TOPIC
|
||||
from app.application.outbox import SUBSCRIBE_ADDED_TOPIC, OutboxIntent
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||
@@ -237,6 +237,7 @@ def _subscribe_added_intents(
|
||||
event_key = subscription_added_event_key(subscribe_id, payload)
|
||||
event_payload = {
|
||||
"subscribe_id": subscribe_id,
|
||||
"idempotency_key": event_key,
|
||||
"username": username,
|
||||
"mediainfo": dict(payload),
|
||||
}
|
||||
@@ -265,7 +266,15 @@ def _subscribe_added_intents(
|
||||
OutboxIntent(
|
||||
event_key=subscription_added_report_key(subscribe_id, payload),
|
||||
topic="subscribe.added.report",
|
||||
payload={"subscribe_info": dict(payload)},
|
||||
payload={
|
||||
"subscribe_info": {
|
||||
**dict(payload),
|
||||
"idempotency_key": subscription_added_report_key(
|
||||
subscribe_id,
|
||||
payload,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(intents)
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import (
|
||||
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
||||
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.application.history import DownloadHistorySnapshot
|
||||
from app.application.transfer.execution import TransferExecutionCheckpoint
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.media import normalize_music_type
|
||||
@@ -48,7 +49,6 @@ from app.runtime.log import logger
|
||||
from app.schemas.context import MediaInfo as _SchemaMediaInfo
|
||||
from app.schemas.context import MetaInfo as _SchemaMetaInfo
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity
|
||||
from app.schemas.music import MusicInfo as _SchemaMusicInfo
|
||||
from app.schemas.music import MusicMeta as _SchemaMusicMeta
|
||||
@@ -724,7 +724,7 @@ class TransferTask(OptionalMediaIdentityMixin, _ApplicationModel):
|
||||
username: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
download_history: Optional[DownloadHistory] = None
|
||||
download_history: Optional[DownloadHistorySnapshot] = None
|
||||
transfer_batch_id: Optional[str] = None
|
||||
manual: Optional[bool] = False
|
||||
background: Optional[bool] = True
|
||||
|
||||
Reference in New Issue
Block a user