refactor: close transactional boundary debt batch

This commit is contained in:
jxxghp
2026-08-28 10:36:12 +08:00
parent 3f8d5990e7
commit aa8751f775
105 changed files with 6507 additions and 1691 deletions
+7 -3
View File
@@ -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:
+6 -4
View File
@@ -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()
+8 -3
View File
@@ -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],
+19 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+5 -4
View File
@@ -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()
+44 -26
View File
@@ -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:
"""删除用户凭证。"""
+98 -55
View File
@@ -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("渠道用户查询能力尚未配置")
+53 -12
View File
@@ -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:
+33 -29
View File
@@ -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:
+54 -30
View File
@@ -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,
+22 -13
View File
@@ -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)
+23 -10
View File
@@ -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(
+11 -2
View File
@@ -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)
+2 -2
View File
@@ -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