Merge remote-tracking branch 'upstream/v3' into codex/chore/floating-build-toolchain

# Conflicts:
#	docs/architecture-optimization-checklist.md
#	docs/architecture-refactor-roadmap.md
This commit is contained in:
InfinityPacer
2026-08-28 10:51:01 +08:00
105 changed files with 6507 additions and 1691 deletions
+25 -1
View File
@@ -11,13 +11,14 @@ from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
from app.runtime.cache import (
DEFAULT_CACHE_REGION,
AsyncCacheBackend,
AtomicCacheBackend,
CacheBackend,
configure_cache_factories,
)
from app.runtime.settings import get_runtime_setting
class RedisBackend(CacheBackend):
class RedisBackend(AtomicCacheBackend):
"""通过同步 Redis 客户端实现缓存后端。"""
def __init__(self, ttl: Optional[int] = None) -> None:
@@ -40,6 +41,29 @@ class RedisBackend(CacheBackend):
return
self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)
def store(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
**kwargs: Any,
) -> None:
"""严格写入 Redis,供安全敏感的一次性状态使用。"""
ttl = self.ttl if ttl is None else ttl
if ttl is not None and ttl <= 0:
self.redis_helper.consume(key, region=region)
return
self.redis_helper.store(key, value, ttl=ttl, region=region, **kwargs)
def consume(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Optional[Any]:
"""通过 Redis 原子命令严格领取一个缓存值。"""
return self.redis_helper.consume(key, region=region)
def exists(
self,
key: str,
+21 -11
View File
@@ -204,15 +204,21 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
:param kwargs: 其他参数
"""
try:
self._connect()
redis_key = self.__make_redis_key(region, key)
# 对值进行序列化
serialized_value = serialize(value)
kwargs.pop("maxsize", None)
self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)
self.store(key=key, value=value, ttl=ttl, region=region, **kwargs)
except Exception as e:
logger.error(f"Failed to set key: {key} in region: {region}, error: {e}")
def store(self, key: str, value: Any, ttl: Optional[int] = None,
region: Optional[str] = "DEFAULT", **kwargs: Any) -> None:
"""严格写入缓存,连接或序列化故障向调用方传播。"""
self._connect()
redis_key = self.__make_redis_key(region, key)
serialized_value = serialize(value)
kwargs.pop("maxsize", None)
stored = self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)
if stored is not True:
raise RuntimeError("Redis cache write was not acknowledged")
def exists(self, key: str, region: Optional[str] = "DEFAULT") -> bool:
"""
判断缓存键是否存在
@@ -249,18 +255,22 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
return None
def pop(self, key: str, region: Optional[str] = "DEFAULT") -> Optional[Any]:
"""原子读取并删除缓存值。"""
"""兼容旧调用;后端故障记录日志并返回空值。"""
try:
self._connect()
redis_key = self.__make_redis_key(region, key)
value = self.client.getdel(redis_key)
return deserialize(value) if value is not None else None
return self.consume(key=key, region=region)
except Exception as e:
logger.error(
f"Failed to pop key: {key} in region: {region}, error: {e}"
)
return None
def consume(self, key: str, region: Optional[str] = "DEFAULT") -> Optional[Any]:
"""通过单条 GETDEL 严格领取缓存值。"""
self._connect()
redis_key = self.__make_redis_key(region, key)
value = self.client.getdel(redis_key)
return deserialize(value) if value is not None else None
def delete(self, key: str, region: Optional[str] = "DEFAULT") -> None:
"""
删除缓存
@@ -37,7 +37,7 @@ class DeleteDownloadHistoryTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: history_id={history_id}")
try:
await get_agent_download_history_port().async_delete_history(history_id)
await get_agent_download_history_port().async_delete(history_id)
return f"下载历史记录 ID: {history_id} 已成功删除"
except Exception as e:
logger.error(f"删除下载历史记录失败: {e}", exc_info=True)
+59 -30
View File
@@ -7,10 +7,12 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.application.agentdata import get_agent_download_history_port
from app.application.history import DownloadHistorySnapshot
from app.chain.download import DownloadChain
from app.runtime.log import logger
from app.schemas.transfer import DownloaderTorrent
from app.schemas.common import JsonData
from app.schemas.transfer import DownloaderTorrent, DownloadTaskMedia
from app.schemas.types import MUSIC_ENTITY_RECORDING, TorrentQueryStatus, media_type_to_agent
@@ -99,48 +101,75 @@ class QueryDownloadTasksTool(MoviePilotTool):
@staticmethod
def _apply_download_history(
torrent: DownloaderTorrent, history: Any
torrent: DownloaderTorrent, history: Optional[DownloadHistorySnapshot]
) -> None:
"""将下载历史中的补充信息回填到下载任务结果中。"""
if not history:
return
if hasattr(torrent, "media"):
media_payload = {
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.image,
"poster": history.poster,
"media_source": history.media_source,
"media_id": history.media_id,
}
music_note = (
(history.note or {}).get("music")
if isinstance(history.note, dict)
else None
) or {}
music_media = music_note.get("media") or {}
media = DownloadTaskMedia(
type=history.type,
title=history.title,
season=history.seasons,
episode=history.episodes,
image=history.image,
poster=history.poster,
media_source=history.media_source,
media_id=history.media_id,
)
music_media = QueryDownloadTasksTool._history_music_media(history)
if media_type_to_agent(history.type) == "music":
media_payload.update({
"music_type": music_media.get("music_type") or MUSIC_ENTITY_RECORDING,
"artists": music_media.get("artists") or [],
"album": music_media.get("album"),
"album_id": music_media.get("album_id"),
"total_tracks": music_media.get("total_tracks"),
"track_number": music_media.get("track_number"),
})
torrent.media = media_payload
music_type = music_media.get("music_type")
artists = music_media.get("artists")
album = music_media.get("album")
album_id = music_media.get("album_id")
total_tracks = music_media.get("total_tracks")
track_number = music_media.get("track_number")
media.music_type = (
music_type if isinstance(music_type, str) else MUSIC_ENTITY_RECORDING
)
media.artists = (
[artist for artist in artists if isinstance(artist, str)]
if isinstance(artists, list)
else []
)
media.album = album if isinstance(album, str) else None
media.album_id = album_id if isinstance(album_id, str) else None
media.total_tracks = (
total_tracks
if isinstance(total_tracks, int) and not isinstance(total_tracks, bool)
else None
)
media.track_number = (
track_number
if isinstance(track_number, int) and not isinstance(track_number, bool)
else None
)
torrent.media = media
if hasattr(torrent, "username"):
torrent.username = history.username
torrent.userid = history.userid
@staticmethod
def _history_music_media(
history: DownloadHistorySnapshot,
) -> dict[str, JsonData]:
"""从冻结历史备注中读取经过结构校验的音乐媒体字段。"""
note = history.note
if not isinstance(note, dict):
return {}
music = note.get("music")
if not isinstance(music, dict):
return {}
media = music.get("media")
return media if isinstance(media, dict) else {}
@classmethod
def _load_history_map(
cls, torrents: List[DownloaderTorrent]
) -> Dict[str, Any]:
) -> Dict[str, DownloadHistorySnapshot]:
"""批量加载下载历史,避免逐条查询形成 N+1。"""
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
hashes = [torrent.hash for torrent in torrents if torrent.hash]
if not hashes:
return {}
return get_agent_download_history_port().get_by_hashes(hashes)
+9 -2
View File
@@ -10,7 +10,7 @@ from app.application.configuration import (
get_api_runtime_config_snapshot,
)
from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork
from app.application.outbox import AsyncOutboxTransaction
from app.application.outbox import AsyncOutboxDispatchStore, AsyncOutboxStager
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
@@ -161,6 +161,13 @@ def get_subscription_transaction(
def get_subscription_outbox(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncOutboxTransaction:
) -> AsyncOutboxStager:
"""构造与订阅写入共享请求会话的 outbox 端口。"""
return runtime.outbox(session)
def get_subscription_outbox_store(
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncOutboxDispatchStore:
"""返回使用独立短事务的订阅 outbox 派发存储。"""
return runtime.dispatch_store
+2
View File
@@ -20,6 +20,7 @@ from app.application.security.user import (
UserRepository,
UserService,
)
from app.application.security.userconfig import get_configured_user_configuration
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.startup.composition.context import HostRuntime
@@ -36,6 +37,7 @@ def get_user_service(
unit_of_work=cast(
AsyncUnitOfWork, runtime.persistence.async_transaction(db)
),
configuration=get_configured_user_configuration(),
)
+17 -4
View File
@@ -13,12 +13,13 @@ from app.api.context import (
get_host_runtime,
get_subscription_history_repository,
get_subscription_outbox,
get_subscription_outbox_store,
get_subscription_repository,
get_subscription_transaction,
get_sync_session,
resolve_background_task_registry,
)
from app.application.outbox import AsyncOutboxTransaction
from app.application.outbox import AsyncOutboxDispatchStore, AsyncOutboxStager
from app.application.scheduling import start_scheduler_job
from app.application.servarr import ServarrSubscriptionService
from app.application.subscription.delete import (
@@ -64,7 +65,10 @@ async def _publish_subscribe_modified(payload: dict[str, Any]) -> None:
def get_delete_subscribe_command(
repository_port: object = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(
get_subscription_outbox_store
),
) -> DeleteSubscribeCommand:
"""组装请求级订阅删除用例及其具体适配器。"""
return DeleteSubscribeCommand(
@@ -73,6 +77,7 @@ def get_delete_subscribe_command(
publish_deleted=_publish_subscribe_deleted,
report_deleted=MoviePilotServerHelper.async_sub_done_durable,
outbox=outbox,
dispatch_store=dispatch_store,
)
@@ -90,7 +95,10 @@ def _log_subscribe_deleted_event_error(
def get_delete_subscriptions_by_identity_command(
repository_port: object = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(
get_subscription_outbox_store
),
) -> DeleteSubscriptionsByIdentityCommand:
"""组装请求级按媒体身份删除订阅用例。"""
return DeleteSubscriptionsByIdentityCommand(
@@ -99,6 +107,7 @@ def get_delete_subscriptions_by_identity_command(
publish_deleted=_publish_subscribe_deleted,
handle_event_error=_log_subscribe_deleted_event_error,
outbox=outbox,
dispatch_store=dispatch_store,
)
@@ -165,7 +174,10 @@ def get_subscription_mutation_service(
get_subscription_history_repository
),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(
get_subscription_outbox_store
),
) -> SubscriptionMutationService:
"""组装异步订阅写服务。"""
return SubscriptionMutationService(
@@ -173,6 +185,7 @@ def get_subscription_mutation_service(
history_repository=history_repository,
unit_of_work=cast(MutationUnitOfWork, unit_of_work),
outbox=outbox,
dispatch_store=dispatch_store,
publish_modified=_publish_subscribe_modified,
)
+8 -9
View File
@@ -101,7 +101,11 @@ def _verify_passkey_and_update(
)
if success:
service.update_last_used(passkey, new_sign_count)
success = service.compare_and_update_sign_count(
passkey_id=passkey.id,
expected_sign_count=int(passkey.sign_count or 0),
sign_count=new_sign_count,
)
return success, new_sign_count
@@ -142,16 +146,11 @@ async def mfa_status(
service: UserService = Depends(get_user_service),
) -> Any:
"""
检查指定用户是否启用了二次验证
检查指定启用用户是否开启 OTP,并隐藏账号不存在或禁用状态。
"""
user = await service.get_by_name(username)
if not user:
return _SchemaResponse(success=False, message="用户不存在")
# 检查是否启用了OTP
has_otp = user.is_otp
return _SchemaResponse(success=True, data={"enabled": bool(has_otp)})
has_otp = bool(user and user.is_active and user.is_otp)
return _SchemaResponse(success=True, data={"enabled": has_otp})
# ==================== OTP 相关接口 ====================
+23 -13
View File
@@ -11,7 +11,11 @@ from app.api.dependencies.auth import (
)
from app.api.response import ResponseAPIRouter
from app.application.security.token import PasswordTooLongError, get_password_hash
from app.application.security.user import UserService
from app.application.security.user import (
LastActiveSuperuserError,
UserNameConflictError,
UserService,
)
from app.application.security.userconfig import get_configured_user_configuration
from app.schemas.common import FileNameData as _SchemaFileNameData
from app.schemas.common import ValueData as _SchemaValueData
@@ -44,9 +48,6 @@ async def create_user(
"""
新增用户
"""
user = await service.get_by_name(user_in.name)
if user:
return _SchemaResponse(success=False, message="用户已存在")
user_info = user_in.model_dump()
if user_info.get("password"):
try:
@@ -54,7 +55,10 @@ async def create_user(
except PasswordTooLongError as error:
return _SchemaResponse(success=False, message=str(error))
user_info.pop("password")
user = await service.create(user_info)
try:
user = await service.create(user_info)
except UserNameConflictError:
return _SchemaResponse(success=False, message="用户已存在")
return _SchemaResponse(success=True if user else False)
@@ -86,14 +90,14 @@ async def update_user(
user_name = user_info.get("name")
if not user_name:
return _SchemaResponse(success=False, message="用户名不能为空")
# 新用户名去重
users = await service.list()
for u in users:
if u.name == user_name and u.id != user_info["id"]:
return _SchemaResponse(success=False, message="用户名已被使用")
if not user:
return _SchemaResponse(success=False, message="用户不存在")
await service.update(user_info["id"], user_info)
try:
await service.update(user_info["id"], user_info)
except UserNameConflictError:
return _SchemaResponse(success=False, message="用户名已被使用")
except LastActiveSuperuserError:
return _SchemaResponse(success=False, message="必须保留至少一个启用的超级管理员")
return _SchemaResponse(success=True)
@@ -180,7 +184,10 @@ async def delete_user_by_id(
user = await service.get_by_id(user_id)
if not user:
return _SchemaResponse(success=False, message="用户不存在")
await service.delete(user_id)
try:
await service.delete(user_id)
except LastActiveSuperuserError:
return _SchemaResponse(success=False, message="必须保留至少一个启用的超级管理员")
return _SchemaResponse(success=True)
@@ -197,7 +204,10 @@ async def delete_user_by_name(
user = await service.get_by_name(user_name)
if not user:
return _SchemaResponse(success=False, message="用户不存在")
await service.delete(user.id)
try:
await service.delete(user.id)
except LastActiveSuperuserError:
return _SchemaResponse(success=False, message="必须保留至少一个启用的超级管理员")
return _SchemaResponse(success=True)
+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
+4
View File
@@ -17,6 +17,10 @@ class ChainRuntimeMixinHost(Protocol):
"""调用同步模块能力。"""
...
def run_module_strict(self, method: str, **kwargs: Any) -> Any:
"""同步调用模块能力,并向调用方传播 provider 失败。"""
...
async def async_run_module(self, method: str, **kwargs: Any) -> Any:
"""调用异步模块能力。"""
...
+54 -6
View File
@@ -6,7 +6,7 @@ eventmanager、messageoper、messagequeue 等协作对象。
"""
import copy
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union, cast
from app.application.chain.data import get_chain_user_port
from app.application.messaging.message import MessageTemplateHelper
@@ -15,6 +15,7 @@ from app.chain._contracts import ChainRuntimeMixinHost
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.foundation.identity import normalize_internal_user_id
from app.runtime.correlation import correlation_scope
from app.runtime.log import logger
from app.schemas.message import Message, MessageResponse
from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
@@ -140,6 +141,8 @@ class NotificationMixin:
:param kwargs: 其他参数(覆盖业务对象属性值)
:return: 成功或失败
"""
strict_delivery = bool(kwargs.pop("_strict_delivery", False))
strict_source = kwargs.pop("_strict_source", None)
# 添加格式化的时间参数
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 渲染消息
@@ -156,8 +159,13 @@ class NotificationMixin:
logger.warning("消息为空,跳过发送")
return
if message.save_history:
self.messageoper.add(**message.model_dump())
if not strict_source or not self.messageoper.exists_by_source(
strict_source
):
self.messageoper.add(**message.model_dump())
dispatch_message = self._normalize_notification_for_dispatch(message)
if strict_source and dispatch_message.source == strict_source:
dispatch_message.source = None
# 发送消息按设置隔离
if not dispatch_message.userid and dispatch_message.mtype:
# 消息隔离设置
@@ -221,8 +229,11 @@ class NotificationMixin:
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(send_message),
)
self.messagequeue.send_message(
"post_message", message=send_message, **kwargs
self._deliver_notification(
send_message,
strict_delivery=strict_delivery,
immediately=False,
kwargs=kwargs,
)
if not send_orignal:
return
@@ -232,10 +243,47 @@ class NotificationMixin:
data=self._build_notice_message_data(dispatch_message),
)
# 按原消息发送
self._deliver_notification(
dispatch_message,
strict_delivery=strict_delivery,
immediately=bool(dispatch_message.userid),
kwargs=kwargs,
)
def post_message_strict(self, message: Message, *, event_key: str) -> None:
"""同步执行真实通知 provider,并通过调用上下文携带稳定事件键。"""
durable_message = message.model_copy(deep=True)
strict_source = None
if not durable_message.source:
strict_source = f"outbox:{event_key}"
durable_message.source = strict_source
with correlation_scope(event_key):
self.post_message(
durable_message,
_strict_delivery=True,
_strict_source=strict_source,
)
def _deliver_notification(
self,
message: Message,
*,
strict_delivery: bool,
immediately: bool,
kwargs: dict[str, object],
) -> None:
"""普通通知使用调度队列;durable 恢复同步执行并传播 provider 错误。"""
if strict_delivery:
host = cast(ChainRuntimeMixinHost, self)
host.run_module_strict(
"post_message",
message=message,
)
return
self.messagequeue.send_message(
"post_message",
message=dispatch_message,
immediately=True if dispatch_message.userid else False,
message=message,
immediately=immediately,
**kwargs,
)
+27 -27
View File
@@ -22,7 +22,13 @@ from app.application.configuration import (
get_configured_system_config,
)
from app.application.formatting import EpisodeFormatRuleHelper
from app.application.history import clear_transfer_failures, resolve_history
from app.application.history import (
DownloadFileSnapshot,
DownloadHistoryQueryPort,
DownloadHistorySnapshot,
clear_transfer_failures,
resolve_history,
)
from app.application.transfer.execution import TransferExecutionCommand
from app.application.transfer.workflow import TransferTask, job_lock
from app.chain._contracts import TransferMixinHost
@@ -37,7 +43,6 @@ from app.foundation import text as text_tools
from app.runtime.config import global_vars
from app.runtime.log import logger
from app.runtime.tasks import get_task_registry
from app.schemas.history import DownloadHistory as _SchemaDownloadHistory
from app.schemas.message import Message
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import EpisodeFormatRule as _SchemaEpisodeFormatRule
@@ -53,8 +58,6 @@ from app.schemas.types import (
)
from app.schemas.workflow import FileItem
DownloadFiles = Any
DownloadHistory = Any
TransferHistory = Any
@@ -266,7 +269,7 @@ class FileFilterMixin:
@staticmethod
def _download_history_music_type(
download_history: Optional[DownloadHistory],
download_history: Optional[DownloadHistorySnapshot],
) -> Optional[str]:
"""从下载历史字段或旧版音乐备注中恢复音乐实体类型。"""
music_type = normalize_music_type(
@@ -288,7 +291,7 @@ class FileFilterMixin:
@classmethod
def _restore_music_download_context(
cls,
download_history: Optional[DownloadHistory],
download_history: Optional[DownloadHistorySnapshot],
file_path: Path,
) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。"""
@@ -878,7 +881,7 @@ class HistoryMatchMixin:
__mixin_host_protocol__ = TransferMixinHost
@staticmethod
def _match_download_file(
download_file: DownloadFiles,
download_file: DownloadFileSnapshot,
file_path: Path,
save_path: Path,
) -> bool:
@@ -899,11 +902,11 @@ class HistoryMatchMixin:
def _resolve_history_from_download_files(
self,
downloadhis: Any,
download_files: List[DownloadFiles],
repository: DownloadHistoryQueryPort,
download_files: List[DownloadFileSnapshot],
file_path: Optional[Path] = None,
save_path: Optional[Path] = None,
) -> Optional[DownloadHistory]:
) -> Optional[DownloadHistorySnapshot]:
"""
从下载文件记录中解析唯一的下载历史
"""
@@ -924,28 +927,28 @@ class HistoryMatchMixin:
if download_file.download_hash
}
if len(download_hashes) == 1:
return downloadhis.get_by_hash(next(iter(download_hashes)))
return repository.get_by_hash(next(iter(download_hashes)))
return None
def _resolve_download_history(
self,
downloadhis: Any,
repository: DownloadHistoryQueryPort,
file_path: Path,
bluray_dir: bool = False,
download_hash: Optional[str] = None,
) -> Optional[DownloadHistory]:
) -> Optional[DownloadHistorySnapshot]:
"""
根据显式 hash文件路径或种子根目录回查下载历史
"""
if download_hash:
return downloadhis.get_by_hash(download_hash)
return repository.get_by_hash(download_hash)
if bluray_dir:
return downloadhis.get_by_path(file_path.as_posix())
return repository.get_by_path(file_path.as_posix())
download_file = downloadhis.get_file_by_fullpath(file_path.as_posix())
if download_file:
return downloadhis.get_by_hash(download_file.download_hash)
download_file = repository.get_file_by_fullpath(file_path.as_posix())
if download_file and download_file.download_hash:
return repository.get_by_hash(download_file.download_hash)
# 多文件种子里的字幕/附加文件可能没有稳定的 fullpath 记录,
# 退回到父目录和 savepath 继续查找,尽量补齐同一种子的关联信息。
@@ -953,13 +956,13 @@ class HistoryMatchMixin:
for parent_path in file_path.parents:
parent_posix = parent_path.as_posix()
download_files = downloadhis.get_files_by_savepath(parent_posix) or []
download_files = repository.get_files_by_savepath(parent_posix) or []
if parent_posix in shared_download_roots:
# 共享下载根目录只能接受有明确文件记录的匹配,
# 避免单文件/磁力任务把整个根目录污染成同一媒体。
history = self._resolve_history_from_download_files(
downloadhis=downloadhis,
repository=repository,
download_files=download_files,
file_path=file_path,
save_path=parent_path,
@@ -968,12 +971,12 @@ class HistoryMatchMixin:
return history
break
download_history = downloadhis.get_by_path(parent_posix)
download_history = repository.get_by_path(parent_posix)
if download_history:
return download_history
history = self._resolve_history_from_download_files(
downloadhis=downloadhis,
repository=repository,
download_files=download_files,
)
if history:
@@ -984,10 +987,7 @@ class HistoryMatchMixin:
@staticmethod
def _is_movie_year_conflict(
file_meta: MetaBase,
# 两种 DownloadHistory 都会进来:库模型(本文件按 ORM 行查历史)与
# schemas DTOTransferTask.download_history)。本函数只按 getattr 取
# year 与 type,对两者一视同仁
media: Union[DownloadHistory, _SchemaDownloadHistory, MediaInfo, MusicInfo]
media: Union[DownloadHistorySnapshot, MediaInfo, MusicInfo]
) -> bool:
"""
判断文件名年份是否与已识别电影年份冲突
@@ -1159,7 +1159,7 @@ class ManualHistoryMixin:
__mixin_host_protocol__ = TransferMixinHost
@staticmethod
def _get_subscribe_custom_words(
history_record: Optional[DownloadHistory],
history_record: Optional[DownloadHistorySnapshot],
) -> Optional[List[str]]:
"""
获取整理用自定义识别词优先使用下载时保存的快照无快照历史旧记录时再按来源实时反查订阅
+42 -27
View File
@@ -25,6 +25,7 @@ from app.application.download.failures import (
DownloadFailureWrite,
)
from app.application.download.tasks import DownloadTaskService
from app.application.history import DownloadFileWrite, DownloadHistoryWrite
from app.application.torrent import TorrentHelper
from app.chain import ChainBase
from app.chain.media import MediaChain
@@ -1267,23 +1268,33 @@ class DownloadChain(ChainBase):
download_path = download_dir / Path(file_list[0]).stem if file_list else download_dir
save_path = download_dir if layout == "NoSubfolder" or not folder_name else download_path
media_source, media_id = resolve_media_identity(media=media)
history_payload = {
"path": download_path.as_posix(), "type": media.type.value,
"title": media.title, "year": media.year,
"media_source": media_source, "media_id": media_id,
"music_type": getattr(media, "music_type", None), "seasons": meta.season,
"episodes": download_episodes or meta.episode,
"image": media.get_backdrop_image(), "poster": media.get_poster_image(),
"downloader": downloader, "download_hash": download_hash,
"torrent_name": torrent.title, "torrent_description": torrent.description,
"torrent_site": torrent.site_name, "userid": userid, "username": username,
"channel": channel.value if channel else None,
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"media_category": media.category, "episode_group": media.episode_group,
"note": self._build_download_note(source, media, meta),
"custom_words": custom_words,
}
files_to_add = []
history = DownloadHistoryWrite(
path=download_path.as_posix(),
type=media.type.value,
title=media.title,
year=media.year,
media_source=media_source,
media_id=media_id,
music_type=getattr(media, "music_type", None),
seasons=meta.season,
episodes=download_episodes or meta.episode,
image=media.get_backdrop_image(),
poster=media.get_poster_image(),
downloader=downloader,
download_hash=download_hash,
torrent_name=torrent.title,
torrent_description=torrent.description,
torrent_site=torrent.site_name,
userid=userid,
username=username,
channel=channel.value if channel else None,
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
media_category=media.category,
episode_group=media.episode_group,
note=self._build_download_note(source, media, meta),
custom_words=custom_words,
)
files_to_add: list[DownloadFileWrite] = []
for file in file_list:
if episodes:
file_meta = MetaInfo(Path(file).stem)
@@ -1291,11 +1302,17 @@ class DownloadChain(ChainBase):
continue
if not Path(file).suffix or Path(file).suffix.lower() not in self.runtime_config.media_extensions:
continue
files_to_add.append({
"download_hash": download_hash, "downloader": downloader,
"fullpath": (save_path / file).as_posix(), "savepath": save_path.as_posix(),
"filepath": file, "torrentname": meta.org_string,
})
files_to_add.append(
DownloadFileWrite(
download_hash=download_hash,
downloader=downloader,
fullpath=(save_path / file).as_posix(),
savepath=save_path.as_posix(),
filepath=file,
torrentname=meta.org_string,
)
)
frozen_files = tuple(files_to_add)
event_payload = {
"hash": download_hash, "context": context, "username": username,
"downloader": downloader, "episodes": episodes or meta.episode_list, "source": source,
@@ -1313,15 +1330,13 @@ class DownloadChain(ChainBase):
durable_event_writer = getattr(self, "durable_event_writer", None)
if durable_event_writer:
durable_event_writer.download_added(
history_payload=history_payload, file_payloads=files_to_add,
history=history,
files=frozen_files,
event_payload=event_payload, after_commit=after_commit,
publish=lambda payload: self.eventmanager.send_event(EventType.DownloadAdded, payload),
)
return
downloadhis = get_chain_download_history_port()
downloadhis.add(**history_payload)
if files_to_add:
downloadhis.add_files(files_to_add)
get_chain_download_history_port().add(history, frozen_files)
after_commit()
self.eventmanager.send_event(EventType.DownloadAdded, event_payload)
+11 -5
View File
@@ -3202,17 +3202,23 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
# 所有下载记录
downloadhis = get_chain_download_history_port()
download_his = downloadhis.get_by_media_identity(
media_source=subscribe.media_source,
media_id=subscribe.media_id,
music_type=getattr(subscribe, "music_type", None),
)
download_his = []
if subscribe.media_source and subscribe.media_id:
download_his = downloadhis.get_by_media_identity(
media_source=subscribe.media_source,
media_id=subscribe.media_id,
music_type=getattr(subscribe, "music_type", None),
)
if download_his:
for his in download_his:
if not his.download_hash:
continue
# 查询下载文件
files = downloadhis.get_files_by_hash(his.download_hash, state=1)
if files:
for file in files:
if not file.filepath:
continue
# 识别文件名
file_meta = MetaInfo(file.filepath)
# 下载文件信息
+14 -11
View File
@@ -25,6 +25,8 @@ from app.application.configuration import get_configured_system_config
from app.application.directory import DirectoryHelper
from app.application.formatting import FormatParser
from app.application.history import (
DownloadHistoryQueryPort,
DownloadHistorySnapshot,
add_transfer_fail,
add_transfer_success,
clear_transfer_failures,
@@ -121,8 +123,6 @@ from app.schemas.types import (
)
from app.schemas.workflow import FileItem
DownloadHistory = Any
# 下载器锁
downloader_lock = threading.Lock()
# 任务锁
@@ -3678,8 +3678,10 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
continue
# 查询下载记录识别情况
downloadhis: DownloadHistory = get_chain_download_history_port().get_by_hash(
torrent.hash
downloadhis: Optional[DownloadHistorySnapshot] = (
get_chain_download_history_port().get_by_hash(torrent.hash)
if torrent.hash
else None
)
# 下载记录中的媒体类型作为整理类型来源,无下载记录时留空由文件后缀兜底
mtype: Optional[MediaType] = None
@@ -4289,14 +4291,14 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
def _build_main_meta(
main_fileitem: FileItem,
main_bluray_dir: bool,
download_history_oper: Any,
download_history_repository: DownloadHistoryQueryPort,
) -> Optional[MetaBase]:
"""
构建主视频元数据
"""
main_path = Path(main_fileitem.path)
main_download_history = self._resolve_download_history(
downloadhis=download_history_oper,
repository=download_history_repository,
file_path=main_path,
bluray_dir=main_bluray_dir,
download_hash=download_hash,
@@ -4389,7 +4391,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
if not items:
return [], {}
download_history_oper = get_chain_download_history_port()
download_history_repository = get_chain_download_history_port()
inherited_map: Dict[Tuple[str, str], MetaBase] = {}
main_items_by_dir, extra_items_by_dir = _build_directory_index(items)
main_items = [
@@ -4440,7 +4442,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
main_meta = _build_main_meta(
related_main_fileitem,
False,
download_history_oper,
download_history_repository,
)
if main_meta:
inherited_map[self._get_file_key(current_item)] = deepcopy(main_meta)
@@ -4489,7 +4491,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
main_path = Path(main_item.path)
main_download_history = self._resolve_download_history(
downloadhis=download_history_oper,
repository=download_history_repository,
file_path=main_path,
bluray_dir=main_bluray_dir,
download_hash=download_hash,
@@ -4688,9 +4690,9 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
continue
# 提前获取下载历史,以便获取自定义识别词
downloadhis = get_chain_download_history_port()
download_history_repository = get_chain_download_history_port()
download_history = self._resolve_download_history(
downloadhis=downloadhis,
repository=download_history_repository,
file_path=file_path,
bluray_dir=bluray_dir,
download_hash=download_hash,
@@ -4741,6 +4743,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
)
if (
not manual
and task_mediainfo
and self._is_movie_year_conflict(file_meta, task_mediainfo)
):
task_mediainfo = None
+25 -12
View File
@@ -19,7 +19,12 @@ from app.application.chain.events import (
snapshot_transfer_result,
transfer_result_event_key,
)
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
from app.application.history import (
DownloadFileWrite,
DownloadHistoryWrite,
TransferHistoryRecord,
TransferHistoryWriter,
)
from app.application.outbox import (
DOWNLOAD_ADDED_TOPIC,
DurableEventCommand,
@@ -32,7 +37,10 @@ from app.application.transfer.execution import (
TransferExecutionState,
TransferSettlementResult,
)
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
from app.db.adapters.outbox import (
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
)
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.transferexecutionstep import TransferExecutionStepOper
@@ -114,8 +122,8 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
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],
@@ -124,17 +132,20 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
session = self._session_factory()
try:
repository = DownloadHistoryOper(session)
outbox = SqlAlchemyOutboxRepository(session)
outbox = SqlAlchemyOutboxStager(session)
command = DurableEventCommand(
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=outbox,
stager=outbox,
store=SqlAlchemyOutboxDispatchStore(self._session_factory),
)
def stage_business() -> int:
"""在同一事务暂存下载历史和可选文件清单。"""
history = repository.stage_add(history_payload)
if file_payloads:
repository.stage_add_files(file_payloads)
return int(history.id)
record = repository.stage_add(history.to_payload())
if files:
repository.stage_add_files([
file_item.to_payload() for file_item in files
])
return int(record.id)
def build_intent(history_id: int) -> OutboxIntent:
"""历史 ID 确定后构造本次下载事实的稳定事件键。"""
@@ -182,7 +193,8 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
command = DurableEventCommand(
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxRepository(session),
stager=SqlAlchemyOutboxStager(session),
store=SqlAlchemyOutboxDispatchStore(self._session_factory),
)
def stage_business() -> _StagedTransferResult:
@@ -271,7 +283,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
)
try:
result = command.execute(
execution = command.execute(
intent=build_intent if topic is not None else None,
stage_business=stage_business,
publish=(
@@ -301,6 +313,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
if replay is None:
raise
return replay
result = execution.value
return result.settlement or result.history
finally:
session.close()
+91
View File
@@ -0,0 +1,91 @@
"""用户配置快照的显式短会话与事务适配器。"""
from __future__ import annotations
from collections.abc import Callable
from typing import Optional, Union
from sqlalchemy.orm import Session
from app.db.oper.userconfig import UserConfigOper
from app.db.uow import SqlAlchemyUnitOfWork
from app.schemas.common import JsonData
from app.schemas.types import UserConfigKey
class TransactionalUserConfigurationRepository:
"""在短事务提交后发布用户配置缓存,并在发布失败时重载事实源。"""
def __init__(
self,
session_factory: Callable[[], Session],
snapshot: Optional[UserConfigOper] = None,
) -> None:
"""保存会话工厂及进程级用户配置快照。"""
self._session_factory = session_factory
self._snapshot = snapshot or UserConfigOper()
def load_snapshot(self) -> None:
"""使用独立只读会话从数据库发布完整配置快照。"""
with self._session_factory() as session:
self._snapshot.load_snapshot(session)
def get(
self,
username: str,
key: Union[str, UserConfigKey],
) -> JsonData:
"""从进程级快照读取一项深拷贝配置。"""
return self._snapshot.get(username=username, key=key)
def set(
self,
username: str,
key: Union[str, UserConfigKey],
value: JsonData,
) -> None:
"""提交一项配置后发布快照,发布异常时从数据库恢复快照。"""
with self._snapshot.write_scope():
with self._session_factory() as session:
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
deleted = self._snapshot.stage_set(
session,
username,
key,
value,
)
unit_of_work.commit()
except Exception:
unit_of_work.rollback()
raise
try:
self._snapshot.publish(
username,
key,
value,
deleted=deleted,
)
except Exception:
self.load_snapshot()
raise
def publish_rename(self, previous_name: str, current_name: str) -> None:
"""发布已提交用户改名,并在同一写锁内以数据库事实源收口。"""
with self._snapshot.write_scope():
try:
self._snapshot.publish_rename(previous_name, current_name)
except Exception:
self.load_snapshot()
raise
self.load_snapshot()
def publish_delete(self, username: str) -> None:
"""发布已提交用户删除,并在同一写锁内以数据库事实源收口。"""
with self._snapshot.write_scope():
try:
self._snapshot.publish_delete(username)
except Exception:
self.load_snapshot()
raise
self.load_snapshot()
+1
View File
@@ -0,0 +1 @@
"""历史持久化适配器包。"""
+287
View File
@@ -0,0 +1,287 @@
"""下载历史的类型化查询、写入与事务适配器。"""
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from copy import deepcopy
from typing import Optional, TypeVar, Union
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.application.history import (
DownloadFileSnapshot,
DownloadFileWrite,
DownloadHistorySnapshot,
DownloadHistoryWrite,
)
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource
ResultT = TypeVar("ResultT")
def _project_history(record: object) -> DownloadHistorySnapshot:
"""在 Session 内把下载历史 ORM 记录投影为不可变快照。"""
history_id = getattr(record, "id", None)
path = getattr(record, "path", None)
media_type = getattr(record, "type", None)
title = getattr(record, "title", None)
if (
not isinstance(history_id, int)
or not isinstance(path, str)
or not isinstance(media_type, str)
or not isinstance(title, str)
):
raise ValueError("下载历史记录缺少稳定身份、路径、类型或标题")
media_source = normalize_media_source(getattr(record, "media_source", None))
media_id_value = getattr(record, "media_id", None)
media_id = str(media_id_value).strip() if media_id_value is not None else None
if not media_source or not media_id or media_id == "0":
media_source = None
media_id = None
return DownloadHistorySnapshot(
id=history_id,
path=path,
type=media_type,
title=title,
year=getattr(record, "year", None),
media_source=media_source,
media_id=media_id,
music_type=getattr(record, "music_type", None),
seasons=getattr(record, "seasons", None),
episodes=getattr(record, "episodes", None),
image=getattr(record, "image", None),
poster=getattr(record, "poster", None),
downloader=getattr(record, "downloader", None),
download_hash=getattr(record, "download_hash", None),
torrent_name=getattr(record, "torrent_name", None),
torrent_description=getattr(record, "torrent_description", None),
torrent_site=getattr(record, "torrent_site", None),
userid=(
str(userid_value)
if (userid_value := getattr(record, "userid", None)) is not None
else None
),
username=getattr(record, "username", None),
channel=getattr(record, "channel", None),
date=getattr(record, "date", None),
note=deepcopy(getattr(record, "note", None)),
media_category=getattr(record, "media_category", None),
episode_group=getattr(record, "episode_group", None),
custom_words=getattr(record, "custom_words", None),
)
def _project_file(record: object) -> DownloadFileSnapshot:
"""在 Session 内把下载文件 ORM 记录投影为不可变快照。"""
file_id = getattr(record, "id", None)
state = getattr(record, "state", None)
if not isinstance(file_id, int) or not isinstance(state, int):
raise ValueError("下载文件记录缺少稳定身份或状态")
return DownloadFileSnapshot(
id=file_id,
downloader=getattr(record, "downloader", None),
download_hash=getattr(record, "download_hash", None),
fullpath=getattr(record, "fullpath", None),
savepath=getattr(record, "savepath", None),
filepath=getattr(record, "filepath", None),
torrentname=getattr(record, "torrentname", None),
state=state,
)
class TransactionalDownloadHistoryRepository:
"""为 Chain 和 Agent 下载历史读写创建短生命周期 Session。"""
def __init__(
self,
*,
sync_session: Callable[[], Session],
async_session: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
"""保存由启动组合根提供的同步与异步 Session 工厂。"""
self._sync_session = sync_session
self._async_session = async_session
def _read(self, operation: Callable[[DownloadHistoryOper], ResultT]) -> ResultT:
"""在独立同步 Session 中执行一次只读操作。"""
session = self._sync_session()
try:
return operation(DownloadHistoryOper(session))
finally:
session.close()
def get_by_hash(
self,
download_hash: str,
) -> Optional[DownloadHistorySnapshot]:
"""按下载任务 Hash 返回最新历史快照。"""
return self._read(
lambda repository: (
_project_history(record)
if (record := repository.get_by_hash(download_hash)) is not None
else None
)
)
def get_by_hashes(
self,
download_hashes: list[str],
) -> dict[str, DownloadHistorySnapshot]:
"""批量返回以下载任务 Hash 为键的最新历史快照。"""
return self._read(
lambda repository: {
download_hash: _project_history(record)
for download_hash, record in repository.get_by_hashes(download_hashes).items()
}
)
def get_by_path(self, path: str) -> Optional[DownloadHistorySnapshot]:
"""按下载保存路径返回历史快照。"""
return self._read(
lambda repository: (
_project_history(record)
if (record := repository.get_by_path(path)) is not None
else None
)
)
def get_by_media_identity(
self,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> list[DownloadHistorySnapshot]:
"""按规范媒体身份返回历史快照。"""
return self._read(
lambda repository: [
_project_history(record)
for record in repository.get_by_media_identity(
media_source=media_source,
media_id=media_id,
music_type=music_type,
)
]
)
def get_file_by_fullpath(
self,
fullpath: str,
) -> Optional[DownloadFileSnapshot]:
"""按完整路径返回一条有效下载文件快照。"""
return self._read(
lambda repository: (
_project_file(record)
if (record := repository.get_file_by_fullpath(fullpath)) is not None
else None
)
)
def get_files_by_hash(
self,
download_hash: str,
state: Optional[int] = None,
) -> list[DownloadFileSnapshot]:
"""按下载任务 Hash 返回文件快照。"""
return self._read(
lambda repository: [
_project_file(record)
for record in repository.get_files_by_hash(
download_hash,
state=state,
)
]
)
def get_files_by_savepath(self, savepath: str) -> list[DownloadFileSnapshot]:
"""按保存目录返回下载文件快照。"""
return self._read(
lambda repository: [
_project_file(record)
for record in repository.get_files_by_savepath(savepath)
]
)
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
) -> list[DownloadHistorySnapshot]:
"""在独立异步 Session 内分页读取并投影历史。"""
async with self._async_session() as session:
records = await DownloadHistoryOper(session).async_list_by_page(
page,
count,
)
return [_project_history(record) for record in records]
def add(
self,
history: DownloadHistoryWrite,
files: tuple[DownloadFileWrite, ...] = (),
) -> int:
"""在一个同步事务中新增历史与关联文件。"""
session = self._sync_session()
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
repository = DownloadHistoryOper(session)
record = repository.stage_add(history.to_payload())
if files:
repository.stage_add_files([file_item.to_payload() for file_item in files])
history_id = int(record.id)
unit_of_work.commit()
return history_id
except Exception:
unit_of_work.rollback()
raise
finally:
session.close()
async def async_delete(self, history_id: int) -> None:
"""在一个异步事务中删除指定下载历史。"""
async with self._async_session() as session:
unit_of_work = SqlAlchemyAsyncUnitOfWork(session)
try:
await DownloadHistoryOper(session).async_delete_history(history_id)
await unit_of_work.commit()
except Exception:
await unit_of_work.rollback()
raise
class SessionDownloadHistoryRepository:
"""把 API 请求持有的 Session 适配为下载历史查询和暂存端口。"""
def __init__(self, session: Union[Session, AsyncSession]) -> None:
"""保存由请求依赖独占的数据库 Session。"""
self._session = session
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
) -> list[DownloadHistorySnapshot]:
"""在请求异步 Session 内分页读取并投影历史。"""
if not isinstance(self._session, AsyncSession):
raise RuntimeError("下载历史异步查询需要 AsyncSession")
records = await DownloadHistoryOper(self._session).async_list_by_page(
page,
count,
)
return [_project_history(record) for record in records]
def stage_delete_history(self, history_id: int) -> None:
"""在请求同步 Session 内暂存下载历史删除。"""
if not isinstance(self._session, Session):
raise RuntimeError("下载历史同步删除需要 Session")
DownloadHistoryOper(self._session).stage_delete_history(history_id)
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
"""在请求同步 Session 内暂存下载文件失效状态。"""
if not isinstance(self._session, Session):
raise RuntimeError("下载文件同步变更需要 Session")
DownloadHistoryOper(self._session).stage_delete_file_by_fullpath(fullpath)
+249 -164
View File
@@ -1,10 +1,15 @@
"""Application outbox 端口的 SQLAlchemy 持久化适配器。"""
"""Application outbox 暂存与派发端口的 SQLAlchemy 适配器。"""
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from datetime import datetime
from typing import Optional
from sqlalchemy import or_, select, update
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from sqlalchemy.sql import Select
from sqlalchemy.sql.dml import Update
from app.application.outbox import ClaimedOutboxMessage, OutboxIntent
from app.db.base import execute_dml
@@ -16,198 +21,278 @@ def _iso(value: datetime) -> str:
return value.isoformat()
class SqlAlchemyOutboxRepository:
"""使用调用方 Session 原子暂存并条件认领 outbox"""
def _message(model: OutboxMessage, attempt: int) -> ClaimedOutboxMessage:
"""把已认领 ORM 行复制为脱离会话的稳定消息"""
return ClaimedOutboxMessage(
message_id=model.id,
event_key=model.event_key,
topic=model.topic,
payload=dict(model.payload),
payload_version=model.payload_version,
attempt=attempt,
)
def _claim_query(
now_text: str,
event_key: Optional[str] = None,
) -> Select[tuple[OutboxMessage]]:
"""构造到期且 lease 可取得的候选查询。"""
statement = select(OutboxMessage).where(
OutboxMessage.status.in_(("pending", "processing")),
OutboxMessage.next_retry_at <= now_text,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
),
)
if event_key is not None:
statement = statement.where(OutboxMessage.event_key == event_key)
return statement.order_by(OutboxMessage.id).limit(1)
def _claim_update(
candidate: OutboxMessage,
now_text: str,
lease_until: datetime,
) -> Update:
"""构造带旧 attempt fencing 的条件认领更新。"""
return (
update(OutboxMessage)
.where(
OutboxMessage.id == candidate.id,
OutboxMessage.attempt == candidate.attempt,
OutboxMessage.status.in_(("pending", "processing")),
OutboxMessage.next_retry_at <= now_text,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
),
)
.values(
status="processing",
attempt=candidate.attempt + 1,
lease_until=_iso(lease_until),
)
)
class SqlAlchemyOutboxStager:
"""只在调用方同步业务事务中暂存 durable intent。"""
def __init__(self, session: Session) -> None:
"""保存由调用方拥有的 SQLAlchemy Session。"""
"""保存业务事务拥有的同步 Session。"""
self._session = session
def stage(self, intent: OutboxIntent, now: datetime) -> None:
"""加入当前事务并 flush,使唯一键冲突在业务 commit 前暴露。"""
self._session.add(
OutboxMessage(
event_key=intent.event_key,
topic=intent.topic,
payload_version=intent.payload_version,
payload=intent.payload,
status="pending",
attempt=0,
next_retry_at=_iso(now),
created_at=_iso(now),
)
)
"""加入当前事务并 flush,使唯一键冲突在 commit 前暴露。"""
self._session.add(_outbox_model(intent, now))
self._session.flush()
class SqlAlchemyAsyncOutboxStager:
"""只在调用方异步业务事务中暂存 durable intent。"""
def __init__(self, session: AsyncSession) -> None:
"""保存业务事务拥有的异步 Session。"""
self._session = session
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
"""暂存并 flush,业务行与 intent 由同一次 commit 决定。"""
self._session.add(_outbox_model(intent, now))
await self._session.flush()
class SqlAlchemyOutboxDispatchStore:
"""用独立同步短事务认领并结算 outbox 消息。"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""保存每次操作创建独立 Session 的工厂。"""
self._session_factory = session_factory
def claim(
self,
now: datetime,
lease_until: datetime,
) -> ClaimedOutboxMessage | None:
"""条件更新候选行;并发丢失竞争时返回 None"""
now_text = _iso(now)
candidate = self._session.execute(
select(OutboxMessage)
.where(
OutboxMessage.status.in_(("pending", "processing")),
OutboxMessage.next_retry_at <= now_text,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
),
)
.order_by(OutboxMessage.id)
.limit(1)
).scalars().first()
if candidate is None:
return None
next_attempt = candidate.attempt + 1
claimed = execute_dml(
self._session,
update(OutboxMessage)
.where(
OutboxMessage.id == candidate.id,
OutboxMessage.attempt == candidate.attempt,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
),
)
.values(
status="processing",
attempt=next_attempt,
lease_until=_iso(lease_until),
),
)
self._session.commit()
if not claimed:
return None
return ClaimedOutboxMessage(
message_id=candidate.id,
event_key=candidate.event_key,
topic=candidate.topic,
payload=dict(candidate.payload),
payload_version=candidate.payload_version,
attempt=next_attempt,
)
) -> Optional[ClaimedOutboxMessage]:
"""原子认领最早一条到期消息"""
return self._claim(now, lease_until)
def claim_by_event_key(
self,
event_key: str,
now: datetime,
lease_until: datetime,
) -> bool:
"""按事件键原子认领同步投递,避免与 dispatcher 并发重复发送"""
) -> Optional[ClaimedOutboxMessage]:
"""稳定事件键原子认领到期消息"""
return self._claim(now, lease_until, event_key)
def _claim(
self,
now: datetime,
lease_until: datetime,
event_key: Optional[str] = None,
) -> Optional[ClaimedOutboxMessage]:
"""在独立事务中以 compare-and-swap 取得 lease。"""
now_text = _iso(now)
candidate = self._session.execute(
select(OutboxMessage)
.where(
OutboxMessage.event_key == event_key,
OutboxMessage.status.in_(("pending", "processing")),
OutboxMessage.next_retry_at <= now_text,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
with self._session_factory() as session:
candidate = session.execute(_claim_query(now_text, event_key)).scalars().first()
if candidate is None:
return None
next_attempt = candidate.attempt + 1
claimed = execute_dml(
session,
_claim_update(candidate, now_text, lease_until),
)
session.commit()
return _message(candidate, next_attempt) if claimed else None
def complete(
self,
message_id: int,
attempt: int,
completed_at: datetime,
) -> bool:
"""仅允许当前 attempt 的 processing owner 标记完成。"""
with self._session_factory() as session:
changed = execute_dml(
session,
update(OutboxMessage)
.where(
OutboxMessage.id == message_id,
OutboxMessage.status == "processing",
OutboxMessage.attempt == attempt,
)
.values(
status="completed",
completed_at=_iso(completed_at),
lease_until=None,
),
)
.limit(1)
).scalars().first()
if candidate is None:
return False
claimed = execute_dml(
self._session,
update(OutboxMessage)
.where(
OutboxMessage.id == candidate.id,
OutboxMessage.attempt == candidate.attempt,
OutboxMessage.event_key == event_key,
OutboxMessage.status.in_(("pending", "processing")),
OutboxMessage.next_retry_at <= now_text,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
),
)
.values(
status="processing",
attempt=OutboxMessage.attempt + 1,
lease_until=_iso(lease_until),
),
)
self._session.commit()
return bool(claimed)
def complete(self, message_id: int, completed_at: datetime) -> None:
"""持久化完成终态并释放 lease。"""
self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.id == message_id)
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
)
self._session.commit()
def complete_by_event_key(self, event_key: str, completed_at: datetime) -> None:
"""即时 post-commit 全部成功时按幂等键收口对应 intent。"""
self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.event_key == event_key)
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
)
self._session.commit()
session.commit()
return bool(changed)
def retry(
self,
message_id: int,
attempt: int,
*,
next_retry_at: datetime,
last_error: str,
dead: bool,
) -> bool:
"""仅允许当前 attempt 的 owner 释放 lease 或写入 dead 终态。"""
with self._session_factory() as session:
changed = execute_dml(
session,
update(OutboxMessage)
.where(
OutboxMessage.id == message_id,
OutboxMessage.status == "processing",
OutboxMessage.attempt == attempt,
)
.values(
status="dead" if dead else "pending",
next_retry_at=_iso(next_retry_at),
lease_until=None,
last_error=last_error,
),
)
session.commit()
return bool(changed)
class SqlAlchemyAsyncOutboxDispatchStore:
"""用独立异步短事务认领并结算 outbox 消息。"""
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
"""持久化下一次退避或不可自动重试的 dead 终态"""
self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.id == message_id)
.values(
status="dead" if dead else "pending",
next_retry_at=_iso(next_retry_at),
lease_until=None,
last_error=last_error,
)
)
self._session.commit()
"""保存每次操作创建独立异步 Session 的工厂"""
self._session_factory = session_factory
class SqlAlchemyAsyncOutboxStager:
"""只负责把 outbox 意图加入调用方异步事务。"""
def __init__(self, session: AsyncSession) -> None:
"""保存由异步订阅命令拥有的 Session。"""
self._session = session
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
"""暂存并 flush,确保业务行与意图由同一次 commit 决定。"""
self._session.add(
OutboxMessage(
event_key=intent.event_key,
topic=intent.topic,
payload_version=intent.payload_version,
payload=intent.payload,
status="pending",
attempt=0,
next_retry_at=_iso(now),
created_at=_iso(now),
)
)
await self._session.flush()
async def complete_by_event_key(
async def claim_by_event_key(
self,
event_key: str,
now: datetime,
lease_until: datetime,
) -> Optional[ClaimedOutboxMessage]:
"""按稳定事件键原子认领到期消息。"""
now_text = _iso(now)
async with self._session_factory() as session:
candidate = (await session.execute(_claim_query(now_text, event_key))).scalars().first()
if candidate is None:
return None
next_attempt = candidate.attempt + 1
result = await session.execute(_claim_update(candidate, now_text, lease_until))
await session.commit()
return _message(candidate, next_attempt) if result.rowcount else None
async def complete(
self,
message_id: int,
attempt: int,
completed_at: datetime,
) -> None:
"""异步 post-commit 全部成功时按幂等键收口 intent"""
await self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.event_key == event_key)
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
)
await self._session.commit()
) -> bool:
"""仅允许当前 attempt 的 processing owner 标记完成"""
async with self._session_factory() as session:
result = await session.execute(
update(OutboxMessage)
.where(
OutboxMessage.id == message_id,
OutboxMessage.status == "processing",
OutboxMessage.attempt == attempt,
)
.values(
status="completed",
completed_at=_iso(completed_at),
lease_until=None,
)
)
await session.commit()
return bool(result.rowcount)
async def retry(
self,
message_id: int,
attempt: int,
*,
next_retry_at: datetime,
last_error: str,
dead: bool,
) -> bool:
"""仅允许当前 attempt 的 owner 释放 lease 或写入 dead 终态。"""
async with self._session_factory() as session:
result = await session.execute(
update(OutboxMessage)
.where(
OutboxMessage.id == message_id,
OutboxMessage.status == "processing",
OutboxMessage.attempt == attempt,
)
.values(
status="dead" if dead else "pending",
next_retry_at=_iso(next_retry_at),
lease_until=None,
last_error=last_error,
)
)
await session.commit()
return bool(result.rowcount)
def _outbox_model(intent: OutboxIntent, now: datetime) -> OutboxMessage:
"""构造由业务事务持有的新 outbox ORM 行。"""
payload = dict(intent.payload)
payload["idempotency_key"] = intent.event_key
return OutboxMessage(
event_key=intent.event_key,
topic=intent.topic,
payload_version=intent.payload_version,
payload=payload,
status="pending",
attempt=0,
next_retry_at=_iso(now),
created_at=_iso(now),
)
+184 -32
View File
@@ -2,12 +2,19 @@
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timezone
from typing import Any
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.application.outbox import (
OUTBOX_LEASE_SECONDS,
AsyncOutboxDispatchStore,
ClaimedOutboxMessage,
OutboxDispatchStore,
OutboxLeaseLostError,
)
from app.application.subscription.write import (
AfterCommitEffect,
AsyncAfterCommitEffect,
@@ -18,8 +25,10 @@ from app.application.subscription.write import (
subscription_added_report_key,
)
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxRepository,
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
)
from app.db.oper.subscribe import SubscribeOper
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
@@ -51,7 +60,8 @@ class TransactionalSubscribeWriter:
"""在独占同步会话内执行一次完整订阅新增事务。"""
session = self._sync_session()
try:
outbox = SqlAlchemyOutboxRepository(session)
outbox = SqlAlchemyOutboxStager(session)
dispatch_store = SqlAlchemyOutboxDispatchStore(self._sync_session)
command = CreateSubscriptionCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
@@ -61,21 +71,13 @@ class TransactionalSubscribeWriter:
def delivered(subscribe_id: int) -> None:
"""执行提交后编排,分别收口已确认的 durable intent。"""
if after_commit:
report_delivered = after_commit(subscribe_id)
outbox.complete_by_event_key(
subscription_added_event_key(subscribe_id, payload),
datetime.now(timezone.utc),
_deliver_added_effects(
dispatch_store,
subscribe_id,
payload,
notification,
lambda: after_commit(subscribe_id),
)
if notification:
outbox.complete_by_event_key(
subscription_added_notification_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
if report_delivered is not False:
outbox.complete_by_event_key(
subscription_added_report_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
return command.execute(
identity,
@@ -98,6 +100,7 @@ class TransactionalSubscribeWriter:
"""在独占异步会话作用域内执行一次完整订阅新增事务。"""
async with self._async_session() as session:
outbox = SqlAlchemyAsyncOutboxStager(session)
dispatch_store = SqlAlchemyAsyncOutboxDispatchStore(self._async_session)
command = AsyncCreateSubscriptionCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
@@ -107,21 +110,13 @@ class TransactionalSubscribeWriter:
async def delivered(subscribe_id: int) -> None:
"""异步执行提交后编排,分别收口已确认的 durable intent。"""
if after_commit:
report_delivered = await after_commit(subscribe_id)
await outbox.complete_by_event_key(
subscription_added_event_key(subscribe_id, payload),
datetime.now(timezone.utc),
await _deliver_added_effects_async(
dispatch_store,
subscribe_id,
payload,
notification,
lambda: after_commit(subscribe_id),
)
if notification:
await outbox.complete_by_event_key(
subscription_added_notification_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
if report_delivered is not False:
await outbox.complete_by_event_key(
subscription_added_report_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
return await command.execute(
identity,
@@ -130,3 +125,160 @@ class TransactionalSubscribeWriter:
delivered,
notification,
)
def _added_effect_keys(
subscribe_id: int,
payload: dict[str, Any],
notification: Optional[dict[str, object]],
) -> tuple[str, ...]:
"""返回组合回调实际包含的独立 durable effect 键。"""
keys = [subscription_added_event_key(subscribe_id, payload)]
if notification:
keys.append(subscription_added_notification_key(subscribe_id, payload))
keys.append(subscription_added_report_key(subscribe_id, payload))
return tuple(keys)
def _claim_added_effects(
store: OutboxDispatchStore,
keys: tuple[str, ...],
now: datetime,
) -> Optional[tuple[ClaimedOutboxMessage, ...]]:
"""全量认领组合回调;竞争丢失时释放本次已取得的 lease。"""
claimed: list[ClaimedOutboxMessage] = []
for key in keys:
message = store.claim_by_event_key(
key,
now,
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
)
if message is None:
for owned in claimed:
store.retry(
owned.message_id,
owned.attempt,
next_retry_at=now,
last_error="组合副作用由其他 owner 接管",
dead=False,
)
return None
claimed.append(message)
return tuple(claimed)
def _deliver_added_effects(
store: OutboxDispatchStore,
subscribe_id: int,
payload: dict[str, Any],
notification: Optional[dict[str, object]],
effect: Callable[[], Optional[bool]],
) -> None:
"""认领组合回调并按事件、通知、统计的确认结果分别结算。"""
now = datetime.now(timezone.utc)
claimed = _claim_added_effects(
store,
_added_effect_keys(subscribe_id, payload, notification),
now,
)
if claimed is None:
return
try:
report_delivered = effect()
except Exception as error:
for message in claimed:
store.retry(
message.message_id,
message.attempt,
next_retry_at=now,
last_error=str(error)[:4000],
dead=False,
)
raise
for message in claimed[:-1]:
if not store.complete(message.message_id, message.attempt, now):
raise OutboxLeaseLostError("订阅新增完成凭证已失效")
report = claimed[-1]
if report_delivered is False:
store.retry(
report.message_id,
report.attempt,
next_retry_at=now,
last_error="订阅新增统计未确认",
dead=False,
)
else:
if not store.complete(report.message_id, report.attempt, now):
raise OutboxLeaseLostError("订阅新增统计完成凭证已失效")
async def _claim_added_effects_async(
store: AsyncOutboxDispatchStore,
keys: tuple[str, ...],
now: datetime,
) -> Optional[tuple[ClaimedOutboxMessage, ...]]:
"""异步全量认领组合回调,竞争丢失时释放已取得 lease。"""
claimed: list[ClaimedOutboxMessage] = []
for key in keys:
message = await store.claim_by_event_key(
key,
now,
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
)
if message is None:
for owned in claimed:
await store.retry(
owned.message_id,
owned.attempt,
next_retry_at=now,
last_error="组合副作用由其他 owner 接管",
dead=False,
)
return None
claimed.append(message)
return tuple(claimed)
async def _deliver_added_effects_async(
store: AsyncOutboxDispatchStore,
subscribe_id: int,
payload: dict[str, Any],
notification: Optional[dict[str, object]],
effect: Callable[[], Any],
) -> None:
"""异步认领组合回调并按各 intent 的确认结果分别结算。"""
now = datetime.now(timezone.utc)
claimed = await _claim_added_effects_async(
store,
_added_effect_keys(subscribe_id, payload, notification),
now,
)
if claimed is None:
return
try:
report_delivered = await effect()
except Exception as error:
for message in claimed:
await store.retry(
message.message_id,
message.attempt,
next_retry_at=now,
last_error=str(error)[:4000],
dead=False,
)
raise
for message in claimed[:-1]:
if not await store.complete(message.message_id, message.attempt, now):
raise OutboxLeaseLostError("订阅新增完成凭证已失效")
report = claimed[-1]
if report_delivered is False:
await store.retry(
report.message_id,
report.attempt,
next_retry_at=now,
last_error="订阅新增统计未确认",
dead=False,
)
else:
if not await store.complete(report.message_id, report.attempt, now):
raise OutboxLeaseLostError("订阅新增统计完成凭证已失效")
+100 -22
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from contextlib import AbstractAsyncContextManager
from typing import Any
from typing import Any, Optional, cast
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -13,9 +15,12 @@ from app.application.security.user import (
AuxiliaryUserCreate,
ChainUserRepository,
FrozenJson,
LastActiveSuperuserError,
UserAuthSnapshot,
UserNameConflictError,
UserRepository,
UserSnapshot,
UserUpdateResult,
)
from app.db.models.user import User
from app.db.oper.user import UserOper
@@ -54,12 +59,12 @@ class SqlAlchemyUserRepository(UserRepository):
self._session = session
self._oper = UserOper(db=session)
def get_by_name(self, name: str) -> UserSnapshot | None:
def get_by_name(self, name: str) -> Optional[UserSnapshot]:
"""在同步请求会话中按用户名读取冻结快照。"""
model = self._oper.get_by_name(name)
return _to_snapshot(model) if model else None
def get_by_id(self, user_id: int) -> UserSnapshot | None:
def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
"""在同步请求会话中按 ID 读取冻结快照。"""
model = self._oper.get_by_id(user_id)
return _to_snapshot(model) if model else None
@@ -68,33 +73,64 @@ class SqlAlchemyUserRepository(UserRepository):
"""在异步请求会话中读取全部冻结用户快照。"""
return [_to_snapshot(model) for model in await self._oper.async_list()]
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
"""在异步请求会话中按用户名读取冻结快照。"""
model = await self._oper.async_get_by_name(name)
return _to_snapshot(model) if model else None
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 读取冻结快照。"""
model = await self._oper.async_get_by_id(user_id)
return _to_snapshot(model) if model else None
async def async_create(self, payload: dict[str, Any]) -> UserSnapshot | None:
async def async_create(
self,
payload: dict[str, Any],
) -> Optional[UserSnapshot]:
"""在请求事务中暂存用户创建并返回冻结快照。"""
model = await self._oper.async_create(payload)
return _to_snapshot(model) if model else None
session = self._require_async_session()
model = User(**payload)
session.add(model)
try:
await session.flush()
except IntegrityError as error:
raise UserNameConflictError(payload.get("name")) from error
return _to_snapshot(model)
async def async_update(
self,
user_id: int,
payload: dict[str, Any],
) -> UserSnapshot | None:
"""在请求事务中暂存用户更新并返回更新后的冻结快照"""
model = await self._oper.async_update(user_id, payload)
return _to_snapshot(model) if model else None
) -> Optional[UserUpdateResult]:
"""原子更新用户;数据库外键负责按用户名级联偏好"""
session = self._require_async_session()
model = await self._locked_user(session, user_id, payload)
if model is None:
return None
old_name = model.name
new_name = str(payload.get("name", old_name))
values = {key: value for key, value in payload.items() if key != "id"}
for key, value in values.items():
setattr(model, key, value)
try:
await session.flush()
except IntegrityError as error:
raise UserNameConflictError(new_name) from error
return UserUpdateResult(
user=_to_snapshot(model),
previous_name=old_name,
)
async def async_delete(self, user_id: int) -> None:
"""在请求事务中暂存用户删除"""
await self._oper.async_delete(user_id)
async def async_delete(self, user_id: int) -> Optional[str]:
"""原子删除用户;数据库外键负责级联偏好和 PassKey"""
session = self._require_async_session()
model = await self._locked_user(session, user_id, None)
if model is None:
return None
username: str = model.name
await session.delete(model)
await session.flush()
return username
async def async_update_otp_by_name(
self,
@@ -105,6 +141,45 @@ class SqlAlchemyUserRepository(UserRepository):
"""在请求事务中暂存用户 OTP 状态更新。"""
await self._oper.async_update_otp_by_name(name, otp, secret)
def _require_async_session(self) -> AsyncSession:
"""返回写用例要求的异步 Session,拒绝错误组合。"""
if not isinstance(self._session, AsyncSession):
raise RuntimeError("用户异步写入必须绑定 AsyncSession")
return self._session
@staticmethod
async def _locked_user(
session: AsyncSession,
user_id: int,
payload: Optional[dict[str, Any]],
) -> Optional[User]:
"""先锁管理员集合再锁目标用户,保护并发下最后一个启用管理员。"""
result = await session.execute(
select(User)
.where(User.is_active.is_(True), User.is_superuser.is_(True))
.order_by(User.id)
.with_for_update()
)
administrators = list(result.scalars().all())
model = next(
(administrator for administrator in administrators if administrator.id == user_id),
None,
)
if model is None:
locked = await session.execute(select(User).where(User.id == user_id).with_for_update())
model = cast(Optional[User], locked.scalars().first())
if model is None:
return None
remains_active = (
payload is not None
and bool(payload.get("is_active", model.is_active))
and bool(payload.get("is_superuser", model.is_superuser))
)
removes_active_superuser = bool(model.is_active and model.is_superuser and not remains_active)
if removes_active_superuser and len(administrators) <= 1:
raise LastActiveSuperuserError(model.name)
return model
class TransactionalUserRepository(ChainUserRepository):
"""为 Chain、Agent 和进程级认证提供短生命周期用户会话。"""
@@ -119,23 +194,23 @@ class TransactionalUserRepository(ChainUserRepository):
self._sync_session = sync_session
self._async_session = async_session
def get_by_name(self, name: str) -> UserSnapshot | None:
def get_by_name(self, name: str) -> Optional[UserSnapshot]:
"""按用户名读取公开用户快照。"""
with self._sync_session() as session:
return SqlAlchemyUserRepository(session).get_by_name(name)
def get_by_id(self, user_id: int) -> UserSnapshot | None:
def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
"""按 ID 读取公开用户快照。"""
with self._sync_session() as session:
return SqlAlchemyUserRepository(session).get_by_id(user_id)
def get_auth_by_name(self, name: str) -> UserAuthSnapshot | None:
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
"""按用户名读取认证凭据快照。"""
with self._sync_session() as session:
model = UserOper(db=session).get_by_name(name)
return _to_auth_snapshot(model) if model else None
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
"""异步按用户名读取公开用户快照。"""
async with self._async_session() as session:
return await SqlAlchemyUserRepository(session).async_get_by_name(name)
@@ -164,7 +239,7 @@ class TransactionalUserRepository(ChainUserRepository):
def get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
) -> Optional[Mapping[str, FrozenJson]]:
"""同步读取用户通知设置的只读快照。"""
user = self.get_by_name(name)
return user.settings if user else None
@@ -172,12 +247,15 @@ class TransactionalUserRepository(ChainUserRepository):
async def async_get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
) -> Optional[Mapping[str, FrozenJson]]:
"""异步读取用户通知设置的只读快照。"""
user = await self.async_get_by_name(name)
return user.settings if user else None
def find_name_by_bindings(self, bindings: Mapping[str, object]) -> str | None:
def find_name_by_bindings(
self,
bindings: Mapping[str, object],
) -> Optional[str]:
"""仅在全部绑定唯一匹配同一启用用户时返回用户名。"""
if not bindings:
return None
+105 -67
View File
@@ -4,25 +4,27 @@
同步引擎与未池化的全局异步引擎都在此按需创建首次访问时不在 import
按事件循环池化的异步引擎由 session 模块创建三者的构建参数在这里收口
"""
import threading
from typing import Dict, Optional, cast
from typing import Any, Dict, Optional, cast
from sqlalchemy import NullPool, QueuePool, create_engine, event, text
from sqlalchemy.engine import Engine as SyncEngine
from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine, create_async_engine
from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import Pool
from app.foundation.environment import is_free_threaded_runtime
from app.runtime.settings import get_runtime_setting
from app.db.diagnostics import _register_database_error_logging
from app.db.worker import DATABASE_WORKER_MAX_WORKERS
from app.foundation.environment import is_free_threaded_runtime
from app.runtime.log import logger
from app.runtime.observability import record_metric
from app.runtime.settings import get_runtime_setting
def _database_backend_label() -> str:
"""把数据库类型收敛为有限的观测标签。"""
return "postgresql" if get_runtime_setting('DB_TYPE').lower() == "postgresql" else "sqlite"
return "postgresql" if get_runtime_setting("DB_TYPE").lower() == "postgresql" else "sqlite"
def _sync_postgresql_driver() -> Optional[str]:
@@ -49,6 +51,23 @@ def _register_database_pool_metrics(engine: SyncEngine) -> None:
event.listen(engine.pool, "checkin", record_checkin)
def _register_sqlite_foreign_keys(engine: SyncEngine) -> None:
"""为每条 SQLite 连接启用模型声明的级联和引用完整性约束。"""
def enable_foreign_keys(
dbapi_connection: Any,
_connection_record: Any,
) -> None:
"""在连接进入池前启用 SQLite 外键检查。"""
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA foreign_keys=ON")
finally:
cursor.close()
event.listen(engine, "connect", enable_foreign_keys)
def _async_pool_kwargs(pooled: bool) -> dict:
"""
异步引擎的连接池参数
@@ -61,9 +80,9 @@ def _async_pool_kwargs(pooled: bool) -> dict:
if not pooled:
return {"poolclass": NullPool}
return {
"pool_size": get_runtime_setting('DB_ASYNC_POOL_SIZE'),
"max_overflow": get_runtime_setting('DB_ASYNC_MAX_OVERFLOW'),
"pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'),
"pool_size": get_runtime_setting("DB_ASYNC_POOL_SIZE"),
"max_overflow": get_runtime_setting("DB_ASYNC_MAX_OVERFLOW"),
"pool_timeout": get_runtime_setting("DB_POOL_TIMEOUT"),
}
@@ -75,7 +94,7 @@ def _get_database_engine(is_async: bool = False, pooled: bool = False):
:return: 返回对应的数据库引擎
"""
# 根据数据库类型选择连接方式
if get_runtime_setting('DB_TYPE').lower() == "postgresql":
if get_runtime_setting("DB_TYPE").lower() == "postgresql":
return _get_postgresql_engine(is_async, pooled=pooled)
else:
return _get_sqlite_engine(is_async, pooled=pooled)
@@ -87,39 +106,42 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False):
"""
# 连接参数
_connect_args = {
"timeout": get_runtime_setting('DB_TIMEOUT'),
"timeout": get_runtime_setting("DB_TIMEOUT"),
}
# 允许部署侧注入驱动级参数(如 PgBouncer 事务模式下的 statement_cache_size
_connect_args.update(get_runtime_setting('DB_CONNECT_ARGS') or {})
_connect_args.update(get_runtime_setting("DB_CONNECT_ARGS") or {})
# 启用 WAL 模式时的额外配置
if get_runtime_setting('DB_WAL_ENABLE'):
if get_runtime_setting("DB_WAL_ENABLE"):
_connect_args["check_same_thread"] = False
# 创建同步引擎
if not is_async:
# 根据池类型设置 poolclass 和相关参数
_pool_class = NullPool if get_runtime_setting('DB_POOL_TYPE') == "NullPool" else QueuePool
_pool_class = NullPool if get_runtime_setting("DB_POOL_TYPE") == "NullPool" else QueuePool
# 数据库参数
_db_kwargs = {
"url": get_runtime_setting('DB_SQLITE_URL')(),
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
"echo": get_runtime_setting('DB_ECHO'),
"url": get_runtime_setting("DB_SQLITE_URL")(),
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
"echo": get_runtime_setting("DB_ECHO"),
"poolclass": _pool_class,
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
"connect_args": _connect_args
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
"connect_args": _connect_args,
}
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
if _pool_class == QueuePool:
_db_kwargs.update({
"pool_size": get_runtime_setting('DB_SQLITE_POOL_SIZE'),
"pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'),
"max_overflow": get_runtime_setting('DB_SQLITE_MAX_OVERFLOW')
})
_db_kwargs.update(
{
"pool_size": get_runtime_setting("DB_SQLITE_POOL_SIZE"),
"pool_timeout": get_runtime_setting("DB_POOL_TIMEOUT"),
"max_overflow": get_runtime_setting("DB_SQLITE_MAX_OVERFLOW"),
}
)
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_sqlite_foreign_keys(engine)
_register_database_error_logging(engine)
_register_database_pool_metrics(engine)
@@ -129,7 +151,7 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False):
# 设置一次,而同步引擎的首次创建由 lifespan 数据库准备组件中的 init_db() 完成,
# 不存在一群线程
# 等在锁上的场面;即便退化到运行期首次访问,阻塞的也只是本地 SQLite 的一次 PRAGMA。
_journal_mode = "WAL" if get_runtime_setting('DB_WAL_ENABLE') else "DELETE"
_journal_mode = "WAL" if get_runtime_setting("DB_WAL_ENABLE") else "DELETE"
with engine.connect() as connection:
current_mode = connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")).scalar()
print(f"SQLite database journal mode set to: {current_mode}")
@@ -138,15 +160,16 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False):
else:
# 数据库参数,只能使用 NullPool
_db_kwargs = {
"url": get_runtime_setting('DB_SQLITE_URL')("aiosqlite"),
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
"echo": get_runtime_setting('DB_ECHO'),
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
"url": get_runtime_setting("DB_SQLITE_URL")("aiosqlite"),
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
"echo": get_runtime_setting("DB_ECHO"),
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
"connect_args": _connect_args,
**_async_pool_kwargs(pooled),
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
_register_sqlite_foreign_keys(async_engine.sync_engine)
_register_database_error_logging(async_engine.sync_engine)
_register_database_pool_metrics(async_engine.sync_engine)
@@ -162,51 +185,55 @@ def _get_postgresql_engine(is_async: bool = False, pooled: bool = False):
"""
获取PostgreSQL数据库引擎
"""
db_url = get_runtime_setting('DB_POSTGRESQL_URL')(_sync_postgresql_driver())
db_url = get_runtime_setting("DB_POSTGRESQL_URL")(_sync_postgresql_driver())
# PostgreSQL连接参数。允许部署侧注入驱动级参数,
# 例如经 PgBouncer 事务模式接入时 asyncpg 需要 statement_cache_size=0
_connect_args = dict(get_runtime_setting('DB_CONNECT_ARGS') or {})
_connect_args = dict(get_runtime_setting("DB_CONNECT_ARGS") or {})
# 创建同步引擎
if not is_async:
# 根据池类型设置 poolclass 和相关参数
_pool_class = NullPool if get_runtime_setting('DB_POOL_TYPE') == "NullPool" else QueuePool
_pool_class = NullPool if get_runtime_setting("DB_POOL_TYPE") == "NullPool" else QueuePool
# 数据库参数
_db_kwargs = {
"url": db_url,
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
"echo": get_runtime_setting('DB_ECHO'),
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
"echo": get_runtime_setting("DB_ECHO"),
"poolclass": _pool_class,
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
"connect_args": _connect_args
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
"connect_args": _connect_args,
}
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
if _pool_class == QueuePool:
_db_kwargs.update({
"pool_size": get_runtime_setting('DB_POSTGRESQL_POOL_SIZE'),
"pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'),
"max_overflow": get_runtime_setting('DB_POSTGRESQL_MAX_OVERFLOW')
})
_db_kwargs.update(
{
"pool_size": get_runtime_setting("DB_POSTGRESQL_POOL_SIZE"),
"pool_timeout": get_runtime_setting("DB_POOL_TIMEOUT"),
"max_overflow": get_runtime_setting("DB_POSTGRESQL_MAX_OVERFLOW"),
}
)
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
_register_database_error_logging(engine)
_register_database_pool_metrics(engine)
print(f"PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}")
print(
f"PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}"
)
return engine
else:
async_db_url = get_runtime_setting('DB_POSTGRESQL_URL')("asyncpg")
async_db_url = get_runtime_setting("DB_POSTGRESQL_URL")("asyncpg")
# 数据库参数,只能使用 NullPool
_db_kwargs = {
"url": async_db_url,
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
"echo": get_runtime_setting('DB_ECHO'),
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
"echo": get_runtime_setting("DB_ECHO"),
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
"connect_args": _connect_args,
**_async_pool_kwargs(pooled),
}
@@ -214,7 +241,9 @@ def _get_postgresql_engine(is_async: bool = False, pooled: bool = False):
async_engine = create_async_engine(**_db_kwargs)
_register_database_error_logging(async_engine.sync_engine)
_register_database_pool_metrics(async_engine.sync_engine)
print(f"Async PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}")
print(
f"Async PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}"
)
return async_engine
@@ -291,7 +320,7 @@ def _async_pool_enabled() -> bool:
"""
是否启用异步连接池设为 NullPool 可回退到池化前的行为
"""
return str(get_runtime_setting('DB_ASYNC_POOL_TYPE') or "").strip().lower() != "nullpool"
return str(get_runtime_setting("DB_ASYNC_POOL_TYPE") or "").strip().lower() != "nullpool"
def connection_budget() -> Dict[str, int]:
@@ -306,16 +335,23 @@ def connection_budget() -> Dict[str, int]:
就顶穿了 max_connections
:return: 单进程各项上限worker 数与合计
"""
if get_runtime_setting('DB_TYPE').lower() == "postgresql":
sync_max = get_runtime_setting('DB_POSTGRESQL_POOL_SIZE') + get_runtime_setting('DB_POSTGRESQL_MAX_OVERFLOW')
if get_runtime_setting("DB_TYPE").lower() == "postgresql":
sync_max = get_runtime_setting("DB_POSTGRESQL_POOL_SIZE") + get_runtime_setting("DB_POSTGRESQL_MAX_OVERFLOW")
else:
sync_max = get_runtime_setting('DB_SQLITE_POOL_SIZE') + get_runtime_setting('DB_SQLITE_MAX_OVERFLOW')
if get_runtime_setting('DB_POOL_TYPE') == "NullPool":
sync_max = get_runtime_setting("DB_SQLITE_POOL_SIZE") + get_runtime_setting("DB_SQLITE_MAX_OVERFLOW")
if get_runtime_setting("DB_POOL_TYPE") == "NullPool":
# 未池化连接由通用线程池和专属数据库 worker 共同创建,二者都要计入上限估计。
sync_max = get_runtime_setting('CONF').threadpool + DATABASE_WORKER_MAX_WORKERS
async_max = (get_runtime_setting('DB_ASYNC_POOL_SIZE') + get_runtime_setting('DB_ASYNC_MAX_OVERFLOW')
if _async_pool_enabled() else 0)
fallback = get_runtime_setting('DB_ASYNC_FALLBACK_LIMIT') if _async_pool_enabled() else get_runtime_setting('CONF').scheduler
sync_max = get_runtime_setting("CONF").threadpool + DATABASE_WORKER_MAX_WORKERS
async_max = (
get_runtime_setting("DB_ASYNC_POOL_SIZE") + get_runtime_setting("DB_ASYNC_MAX_OVERFLOW")
if _async_pool_enabled()
else 0
)
fallback = (
get_runtime_setting("DB_ASYNC_FALLBACK_LIMIT")
if _async_pool_enabled()
else get_runtime_setting("CONF").scheduler
)
per_worker = sync_max + async_max + fallback
# worker 数非法时按 1 计:退化成 0 会让合计归零、反而误判「额度充足」
workers = get_runtime_setting("API_WORKERS", 1) or 1
@@ -339,27 +375,29 @@ def check_connection_budget() -> bool:
:return: 是否在额度之内
"""
budget = connection_budget()
if get_runtime_setting('DB_TYPE').lower() != "postgresql":
logger.info(f"数据库连接理论峰值: {budget['total']} "
f"(单进程 {budget['per_worker']} = 同步 {budget['sync']} + 异步池 "
f"{budget['async_pooled']} + 回退 {budget['async_fallback']}"
f"worker {budget['workers']})")
if get_runtime_setting("DB_TYPE").lower() != "postgresql":
logger.info(
f"数据库连接理论峰值: {budget['total']} "
f"(单进程 {budget['per_worker']} = 同步 {budget['sync']} + 异步池 "
f"{budget['async_pooled']} + 回退 {budget['async_fallback']}"
f"worker {budget['workers']})"
)
return True
try:
with get_engine().connect() as conn:
max_conn = int(conn.execute(text("SHOW max_connections")).scalar() or 0)
reserved = int(
conn.execute(text("SHOW superuser_reserved_connections")).scalar() or 0
)
reserved = int(conn.execute(text("SHOW superuser_reserved_connections")).scalar() or 0)
except Exception as err:
logger.warn(f"无法读取 PostgreSQL 连接上限,跳过额度校验: {err}")
return True
available = max_conn - reserved
total = budget["total"]
detail = (f"理论峰值 {total} = 单进程 {budget['per_worker']} (同步 {budget['sync']} "
f"+ 异步池 {budget['async_pooled']} + 回退 {budget['async_fallback']}) "
f"x worker {budget['workers']},数据库可用 {available} "
f"(max_connections {max_conn} - 保留 {reserved})")
detail = (
f"理论峰值 {total} = 单进程 {budget['per_worker']} (同步 {budget['sync']} "
f"+ 异步池 {budget['async_pooled']} + 回退 {budget['async_fallback']}) "
f"x worker {budget['workers']},数据库可用 {available} "
f"(max_connections {max_conn} - 保留 {reserved})"
)
if total > available:
logger.error(
f"数据库连接额度不足:{detail}"
+1 -1
View File
@@ -2,7 +2,7 @@
from typing import Any, Optional
from sqlalchemy import Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy import JSON, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, get_id_column
+45 -42
View File
@@ -1,8 +1,9 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Integer, String, Boolean, DateTime, Text, select, ForeignKey, update
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from datetime import datetime
from app.db.base import Base, get_id_column
@@ -13,7 +14,8 @@ def _get_by_user_id_statement(model: type["PassKey"], user_id: int):
def _get_by_credential_id_statement(
model: type["PassKey"], credential_id: str,
model: type["PassKey"],
credential_id: str,
):
"""构造按凭证 ID 筛选启用 PassKey 的查询语句。"""
return select(model).where(
@@ -26,10 +28,20 @@ class PassKey(Base):
"""
用户PassKey凭证表
"""
# ID
id = get_id_column()
# 用户ID
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey(
"user.id",
name="fk_passkey_user_id_user",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
# 凭证ID (credential_id)
credential_id: Mapped[str] = mapped_column(String, nullable=False, unique=True, index=True)
# 凭证公钥
@@ -51,40 +63,32 @@ class PassKey(Base):
@classmethod
def get_by_user_id(
cls,
db: Session,
user_id: int,
cls,
db: Session,
user_id: int,
):
"""在调用方 Session 中获取用户的所有启用 PassKey。"""
return list(db.execute(
_get_by_user_id_statement(cls, user_id)
).scalars().all())
return list(db.execute(_get_by_user_id_statement(cls, user_id)).scalars().all())
@classmethod
async def async_get_by_user_id(cls, db: AsyncSession, user_id: int):
"""在调用方 AsyncSession 中获取用户的所有启用 PassKey。"""
result = await db.execute(
_get_by_user_id_statement(cls, user_id)
)
result = await db.execute(_get_by_user_id_statement(cls, user_id))
return list(result.scalars().all())
@classmethod
def get_by_credential_id(
cls,
db: Session,
credential_id: str,
cls,
db: Session,
credential_id: str,
):
"""在调用方 Session 中按凭证 ID 获取启用 PassKey。"""
return db.execute(
_get_by_credential_id_statement(cls, credential_id)
).scalars().first()
return db.execute(_get_by_credential_id_statement(cls, credential_id)).scalars().first()
@classmethod
async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str):
"""在调用方 AsyncSession 中根据凭证 ID 获取启用 PassKey。"""
result = await db.execute(
_get_by_credential_id_statement(cls, credential_id)
)
result = await db.execute(_get_by_credential_id_statement(cls, credential_id))
return result.scalars().first()
@classmethod
@@ -95,17 +99,13 @@ class PassKey(Base):
@classmethod
async def async_get_by_id(cls, db: AsyncSession, passkey_id: int):
"""在调用方 AsyncSession 中根据 ID 获取 PassKey。"""
result = await db.execute(
select(cls).filter(cls.id == passkey_id)
)
result = await db.execute(select(cls).filter(cls.id == passkey_id))
return result.scalars().first()
@classmethod
def delete_by_id(cls, db: Session, passkey_id: int, user_id: int):
"""删除指定用户的PassKey"""
passkey = db.execute(
select(cls).where(cls.id == passkey_id, cls.user_id == user_id)
).scalars().first()
passkey = db.execute(select(cls).where(cls.id == passkey_id, cls.user_id == user_id)).scalars().first()
if passkey:
db.delete(passkey)
return True
@@ -114,12 +114,7 @@ class PassKey(Base):
@classmethod
async def async_delete_by_id(cls, db: AsyncSession, passkey_id: int, user_id: int):
"""异步删除指定用户的PassKey"""
result = await db.execute(
select(cls).filter(
cls.id == passkey_id,
cls.user_id == user_id
)
)
result = await db.execute(select(cls).filter(cls.id == passkey_id, cls.user_id == user_id))
passkey = result.scalars().first()
if passkey:
await db.delete(passkey)
@@ -128,16 +123,24 @@ class PassKey(Base):
def update_last_used(self, db: Session, sign_count: int):
"""更新最后使用时间和签名计数"""
db.execute(update(type(self)).where(type(self).id == self.id).values(
last_used_at=datetime.now(),
sign_count=sign_count,
))
db.execute(
update(type(self))
.where(type(self).id == self.id)
.values(
last_used_at=datetime.now(),
sign_count=sign_count,
)
)
return True
async def async_update_last_used(self, db: AsyncSession, sign_count: int):
"""异步更新最后使用时间和签名计数"""
await db.execute(update(type(self)).where(type(self).id == self.id).values(
last_used_at=datetime.now(),
sign_count=sign_count,
))
await db.execute(
update(type(self))
.where(type(self).id == self.id)
.values(
last_used_at=datetime.now(),
sign_count=sign_count,
)
)
return True
+10 -2
View File
@@ -1,5 +1,6 @@
from typing import Any, Optional
from sqlalchemy import Boolean, JSON, String, select
from sqlalchemy import JSON, Boolean, Index, String, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
@@ -10,10 +11,11 @@ class User(Base):
"""
用户表
"""
# ID
id = get_id_column()
# 用户名,唯一值
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
# 邮箱
email: Mapped[Optional[str]] = mapped_column(String)
# 加密后密码
@@ -33,6 +35,8 @@ class User(Base):
# 用户个性化设置 json
settings: Mapped[Optional[Any]] = mapped_column(JSON, default=dict)
__table_args__ = (Index("ux_user_name", "name", unique=True),)
@classmethod
def get_by_name(
cls,
@@ -68,18 +72,21 @@ class User(Base):
return result.scalars().first()
def delete_by_name(self, db: Session, name: str):
"""在调用方同步会话中按用户名暂存删除。"""
user = self.get_by_name(db, name)
if user:
db.delete(user)
return True
async def async_delete_by_name(self, db: AsyncSession, name: str):
"""在调用方异步会话中按用户名暂存删除。"""
user = await self.async_get_by_name(db, name)
if user:
await db.delete(user)
return True
def delete_by_id(self, db: Session, user_id: int):
"""在调用方同步会话中按用户 ID 暂存删除。"""
user = self.get_by_id(db, user_id)
if user:
db.delete(user)
@@ -94,6 +101,7 @@ class User(Base):
return True
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
"""在调用方同步会话中更新指定用户的 OTP 状态。"""
user = self.get_by_name(db, name)
if user:
user.is_otp = otp
+21 -8
View File
@@ -1,33 +1,46 @@
from typing import Any, Optional
from sqlalchemy import String, UniqueConstraint, JSON, select
from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base
from app.db.base import Base, get_id_column
class UserConfig(Base):
"""
用户配置表
"""
id = get_id_column()
# 用户名
username: Mapped[Optional[str]] = mapped_column(String)
username: Mapped[str] = mapped_column(
String,
ForeignKey(
"user.name",
name="fk_userconfig_username_user",
onupdate="CASCADE",
ondelete="CASCADE",
),
nullable=False,
)
# 配置键
key: Mapped[Optional[str]] = mapped_column(String)
key: Mapped[str] = mapped_column(String, nullable=False)
# 值
value: Mapped[Optional[Any]] = mapped_column(JSON)
__table_args__ = (
# 用户名和配置键联合唯一
UniqueConstraint('username', 'key'),
UniqueConstraint(
"username",
"key",
name="uq_userconfig_username_key",
),
)
@classmethod
def get_by_key(cls, db: Session, username: str, key: str):
"""在调用方 Session 中查询用户配置。"""
return db.execute(
select(cls).where(cls.username == username, cls.key == key)
).scalars().first()
return db.execute(select(cls).where(cls.username == username, cls.key == key)).scalars().first()
def delete_by_key(self, db: Session, username: str, key: str):
"""在调用方持有的事务中暂存指定用户配置删除。"""
+34 -7
View File
@@ -1,11 +1,12 @@
"""PassKey 数据访问适配器。"""
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy import or_, select, update
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.base import DbOper, execute_dml
from app.db.models.passkey import (
PassKey,
_get_by_credential_id_statement,
@@ -54,11 +55,37 @@ class PassKeyOper(DbOper):
session.add(passkey)
session.flush()
def update_last_used(self, passkey: PassKey, sign_count: int) -> bool:
"""更新凭证最后使用时间和签名计数。"""
return bool(self._execute_sync_write(
lambda session: passkey.update_last_used(session, sign_count)
))
def compare_and_update_sign_count(
self,
passkey_id: int,
expected_sign_count: int,
sign_count: int,
) -> bool:
"""仅在凭证仍启用且签名计数未变化时记录本次认证。"""
if sign_count < expected_sign_count or (
expected_sign_count > 0 and sign_count == expected_sign_count
):
return False
count_matches = PassKey.sign_count == expected_sign_count
if expected_sign_count == 0:
count_matches = or_(PassKey.sign_count == 0, PassKey.sign_count.is_(None))
statement = (
update(PassKey)
.where(
PassKey.id == passkey_id,
PassKey.is_active.is_(True),
count_matches,
)
.values(
last_used_at=datetime.now(),
sign_count=sign_count,
)
)
return self._execute_sync_write(
lambda session: execute_dml(session, statement)
) == 1
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""删除指定用户的凭证。"""
+109 -62
View File
@@ -1,23 +1,26 @@
import copy
import threading
from typing import Any, Union, Dict, Optional
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Optional, Union
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.userconfig import UserConfig
from app.schemas.types import UserConfigKey
from app.foundation.singleton import Singleton
from app.schemas.common import JsonData
from app.schemas.types import UserConfigKey
class UserConfigOper(DbOper, metaclass=Singleton):
"""
用户配置管理
"""
def __init__(self):
def __init__(self) -> None:
"""初始化空快照,数据库加载由启动组合根显式执行。"""
super().__init__()
self.__USERCONF = {}
self.__USERCONF: dict[str, dict[str, JsonData]] = {}
self._snapshot_lock = threading.RLock()
self._write_lock = threading.RLock()
self._loaded = False
@@ -25,7 +28,7 @@ class UserConfigOper(DbOper, metaclass=Singleton):
def load_snapshot(self, db: Optional[Session] = None) -> None:
"""从显式会话或 Oper 事务边界加载用户配置并发布内存快照。"""
with self._write_lock:
snapshot: dict[str, dict[str, Any]] = {}
snapshot: dict[str, dict[str, JsonData]] = {}
items = UserConfig.list(db) if db is not None else self._execute_sync_query(
UserConfig.list
)
@@ -43,37 +46,109 @@ class UserConfigOper(DbOper, metaclass=Singleton):
if not self._loaded:
raise RuntimeError("用户配置快照尚未加载")
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
"""
设置用户配置
"""
if isinstance(key, UserConfigKey):
key = key.value
@contextmanager
def write_scope(self) -> Iterator[None]:
"""串行化数据库提交与对应快照发布,避免并发写入乱序。"""
self._require_loaded()
with self._write_lock:
yield
def write(db):
"""在当前事务中按用户配置的假值规则写入记录。"""
conf = UserConfig.get_by_key(db=db, username=username, key=key)
if conf:
if value:
conf.value = copy.deepcopy(value)
else:
db.delete(conf)
else:
db.add(
UserConfig(
username=username,
key=key,
value=copy.deepcopy(value),
)
)
def stage_set(
self,
db: Session,
username: str,
key: Union[str, UserConfigKey],
value: JsonData,
) -> bool:
"""在调用方 Session 中暂存写入,返回提交后是否应移除缓存项。"""
if isinstance(key, UserConfigKey):
key = key.value
conf = UserConfig.get_by_key(db=db, username=username, key=key)
if conf:
if value:
conf.value = copy.deepcopy(value)
return False
db.delete(conf)
return True
db.add(
UserConfig(
username=username,
key=key,
value=copy.deepcopy(value),
)
)
return False
self._execute_sync_write(write)
# 既有运行时语义会保留刚写入的假值,即使其数据库记录被删除。
self.__set_config_cache(username=username, key=key, value=value)
def publish(
self,
username: str,
key: Union[str, UserConfigKey],
value: JsonData,
*,
deleted: bool,
) -> None:
"""仅在数据库提交成功后原子发布对应配置快照。"""
if isinstance(key, UserConfigKey):
key = key.value
if not username or not key:
return
with self._snapshot_lock:
if deleted:
user_cache = self.__USERCONF.get(username)
if user_cache is None:
return
user_cache.pop(key, None)
if not user_cache:
self.__USERCONF.pop(username, None)
return
self.__USERCONF.setdefault(username, {})[key] = copy.deepcopy(value)
def get(self, username: str, key: Optional[Union[str, UserConfigKey]] = None) -> Any:
def publish_rename(self, previous_name: str, current_name: str) -> None:
"""原子迁移已提交改名对应的配置快照,并清理目标孤儿配置。"""
if not previous_name or not current_name or previous_name == current_name:
return
with self._snapshot_lock:
self._require_loaded()
values = self.__USERCONF.pop(previous_name, None)
self.__USERCONF.pop(current_name, None)
if values:
self.__USERCONF[current_name] = copy.deepcopy(values)
def publish_delete(self, username: str) -> None:
"""原子移除已提交用户删除对应的配置快照。"""
if not username:
return
with self._snapshot_lock:
self._require_loaded()
self.__USERCONF.pop(username, None)
def set(
self,
username: str,
key: Union[str, UserConfigKey],
value: JsonData,
) -> None:
"""
通过兼容事务入口设置用户配置
新宿主调用应使用 ``TransactionalUserConfigurationRepository``此方法保留给
旧插件 ABI并与规范适配器共享同一暂存提交后发布及失败恢复语义
"""
with self.write_scope():
deleted = self._execute_sync_write(
lambda db: self.stage_set(db, username, key, value)
)
try:
self.publish(username, key, value, deleted=deleted)
except Exception:
self.load_snapshot()
raise
def get(
self,
username: Optional[str],
key: Optional[Union[str, UserConfigKey]] = None,
) -> JsonData:
"""
获取用户配置
"""
@@ -84,34 +159,6 @@ class UserConfigOper(DbOper, metaclass=Singleton):
if isinstance(key, UserConfigKey):
key = key.value
if not key:
return copy.deepcopy(self.__get_config_caches(username=username))
return copy.deepcopy(self.__get_config_cache(username=username, key=key))
def __set_config_cache(self, username: str, key: str, value: Any):
"""
设置配置缓存
"""
if not username or not key:
return
with self._snapshot_lock:
user_cache = self.__USERCONF.setdefault(username, {})
user_cache[key] = copy.deepcopy(value)
def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]:
"""
获取配置缓存
"""
if not username or not self.__USERCONF:
return None
return self.__USERCONF.get(username)
def __get_config_cache(self, username: str, key: str) -> Any:
"""
获取配置缓存
"""
if not username or not key or not self.__USERCONF:
return None
user_cache = self.__get_config_caches(username)
if not user_cache:
return None
return user_cache.get(key)
return copy.deepcopy(self.__USERCONF.get(username))
user_cache = self.__USERCONF.get(username)
return copy.deepcopy(user_cache.get(key) if user_cache else None)
+1
View File
@@ -227,6 +227,7 @@
"密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位": "Password must contain at least two of letters, numbers, and special characters, and be longer than 6 characters",
"用户名不能为空": "Username cannot be empty",
"用户名已被使用": "Username is already in use",
"必须保留至少一个启用的超级管理员": "At least one active superuser must remain",
"用户不存在": "User does not exist",
"已存在相同名称的工作流": "A workflow with the same name already exists",
"创建工作流成功": "Workflow created successfully",
+1
View File
@@ -223,6 +223,7 @@
"密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位": "密碼需同時包含字母、數字、特殊字元中的至少兩項,且長度大於 6 位",
"用户名不能为空": "使用者名稱不能為空",
"用户名已被使用": "使用者名稱已被使用",
"必须保留至少一个启用的超级管理员": "必須保留至少一個啟用的超級管理員",
"用户不存在": "使用者不存在",
"已存在相同名称的工作流": "已存在相同名稱的工作流",
"创建工作流成功": "建立工作流成功",
+80 -7
View File
@@ -3,10 +3,10 @@ import inspect
import logging
import threading
from abc import ABC, abstractmethod
from contextlib import contextmanager, asynccontextmanager
from contextlib import asynccontextmanager, contextmanager
from functools import wraps
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Generator, AsyncGenerator, Tuple, Literal, Union
from typing import Any, AsyncGenerator, Callable, Dict, Generator, Literal, Optional, Tuple, Union
from cachetools import LRUCache as MemoryLRUCache
from cachetools import TLRUCache as MemoryTLRUCache
@@ -21,7 +21,7 @@ DEFAULT_CACHE_TTL = 365 * 24 * 60 * 60
logger = logging.getLogger(__name__)
_backend_type_provider: Callable[[], str] = lambda: "memory"
_redis_factory: Optional[Callable[[Optional[int]], "CacheBackend"]] = None
_redis_factory: Optional[Callable[[Optional[int]], "AtomicCacheBackend"]] = None
_async_redis_factory: Optional[
Callable[[Optional[int]], "AsyncCacheBackend"]
] = None
@@ -35,7 +35,7 @@ _file_ttl_provider: Callable[[], int] = lambda: DEFAULT_CACHE_TTL
def configure_cache_factories(
*,
backend_type_provider: Callable[[], str],
redis_factory: Callable[[Optional[int]], "CacheBackend"],
redis_factory: Callable[[Optional[int]], "AtomicCacheBackend"],
async_redis_factory: Callable[[Optional[int]], "AsyncCacheBackend"],
file_factory: Callable[[Optional[Path]], "CacheBackend"],
async_file_factory: Callable[[Optional[Path]], "AsyncCacheBackend"],
@@ -245,6 +245,43 @@ class CacheBackend(ABC):
return False
class AtomicCacheBackend(CacheBackend):
"""支持严格写入和原子领取的一次性缓存后端契约。"""
@abstractmethod
def store(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
**kwargs: Any,
) -> None:
"""写入缓存;后端故障必须向调用方传播。"""
@abstractmethod
def consume(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Any:
"""原子读取并删除缓存值,不存在时返回 None。"""
def pop(
self,
key: str,
default: Any = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Any:
"""以原子领取实现兼容的字典 pop 语义。"""
value = self.consume(key=key, region=region)
if value is not None:
return value
if default is not None:
return default
raise KeyError(key)
class AsyncCacheBackend(CacheBackend):
"""
缓存后端基类定义通用的缓存接口异步
@@ -420,7 +457,7 @@ class _MemoryTLRUCache(MemoryTLRUCache):
self.__setting_ttls.pop(key, None)
class MemoryBackend(CacheBackend):
class MemoryBackend(AtomicCacheBackend):
"""
基于 `cachetools.TLRUCache` 实现的缓存后端
"""
@@ -481,6 +518,32 @@ class MemoryBackend(CacheBackend):
else:
region_cache[key] = value
def store(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
**kwargs: Any,
) -> None:
"""严格写入内存缓存。"""
self.set(key=key, value=value, ttl=ttl, region=region, **kwargs)
def consume(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Any:
"""在区域缓存锁内原子领取一个值。"""
with self._lock:
region_cache = self.__get_region_cache(region or DEFAULT_CACHE_REGION)
if region_cache is None:
return None
try:
return region_cache.pop(key)
except KeyError:
return None
def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:
"""
判断缓存键是否存在
@@ -721,7 +784,7 @@ def AsyncFileCache(
def Cache(cache_type: Literal['ttl', 'lru'] = 'ttl',
maxsize: Optional[int] = None,
ttl: Optional[int] = None) -> CacheBackend:
ttl: Optional[int] = None) -> AtomicCacheBackend:
"""
根据配置获取缓存后端实例内存或Redismaxsize仅在未启用Redis时生效
@@ -1023,7 +1086,7 @@ class CacheProxy:
缓存代理类将缓存后端的方法直接代理到实例上
"""
def __init__(self, cache_backend: CacheBackend, region: str):
def __init__(self, cache_backend: AtomicCacheBackend, region: str):
"""
初始化缓存代理
@@ -1096,6 +1159,16 @@ class CacheProxy:
kwargs.setdefault('region', self._region)
self._cache_backend.set(key, value, **kwargs)
def store(self, key: str, value: Any, **kwargs: Any) -> None:
"""严格写入缓存,后端故障向调用方传播。"""
kwargs.setdefault('region', self._region)
self._cache_backend.store(key, value, **kwargs)
def consume(self, key: str, **kwargs: Any) -> Any:
"""原子领取并删除缓存值。"""
kwargs.setdefault('region', self._region)
return self._cache_backend.consume(key, **kwargs)
def delete(self, key: str, **kwargs) -> None:
"""
删除缓存值
+100 -1
View File
@@ -7,11 +7,11 @@ import time
from collections.abc import Callable
from typing import Any
from app.runtime.correlation import correlation_scope
from app.runtime.event.binding import EventBindingResolver
from app.runtime.event.registry import EventRegistry
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.runtime.correlation import correlation_scope
from app.runtime.observability import observe_duration
from app.schemas.types import EventType
@@ -129,6 +129,44 @@ class EventDispatcher:
(handler, isolated),
)
def dispatch_broadcast_strict(
self,
event: Any,
async_runner: Callable[[Any], Any],
) -> None:
"""串行执行广播处理器并等待完成,任一处理失败时向调用方抛出。"""
handlers = self._registry.broadcast_snapshot(event.event_type)
target_plugin_id = None
if event.event_type == EventType.MessageAction and isinstance(
event.event_data,
dict,
):
target_plugin_id = event.event_data.get("__mp_target_plugin_id")
for handler_id, handler in handlers:
if not self._registry.is_handler_enabled(handler):
continue
if target_plugin_id and not self.should_dispatch_to_target_plugin(
handler,
handler_id,
str(target_plugin_id),
):
continue
if isinstance(event.event_data, dict):
event_data = event.event_data.copy()
event_data.pop("__mp_target_plugin_id", None)
else:
event_data = event.event_data
isolated = self._event_factory(
event_type=event.event_type,
event_data=event_data,
priority=event.priority,
correlation_id=event.correlation_id,
)
if inspect.iscoroutinefunction(handler):
async_runner(self.invoke_async_strict(handler, isolated))
else:
self.invoke_sync_strict(handler, isolated)
def safe_invoke_sync(self, handler: Callable, event: Any) -> None:
"""仅在处理器启用时执行同步调用。"""
if self._registry.is_handler_enabled(handler):
@@ -162,6 +200,34 @@ class EventDispatcher:
e=err,
)
def invoke_sync_strict(
self,
handler: Callable[..., object],
event: Any,
) -> None:
"""解析并执行同步处理器,记录错误后向 durable 调用方传播。"""
resolved = self._binding_resolver.resolve(handler)
if not resolved:
raise RuntimeError("事件处理器实例不可用")
method, binding, class_name, method_name = resolved
with correlation_scope(event.correlation_id):
try:
with observe_duration(
"event.handler.duration",
event_type=event.event_type.value,
handler_type="bound" if class_name else "function",
):
method(event)
except Exception as err:
self._error_handler(
event=event,
module_name=binding.owner_name,
class_name=class_name,
method_name=method_name,
e=err,
)
raise
async def invoke_async(self, handler: Callable, event: Any) -> None:
"""解析实例绑定,并按处理器类型选择协程、线程池或同步调用。"""
resolved = self._binding_resolver.resolve(handler)
@@ -190,6 +256,39 @@ class EventDispatcher:
e=err,
)
async def invoke_async_strict(
self,
handler: Callable[..., object],
event: Any,
) -> None:
"""解析并等待处理器完成,记录错误后向 durable 调用方传播。"""
resolved = self._binding_resolver.resolve(handler)
if not resolved:
raise RuntimeError("事件处理器实例不可用")
method, binding, class_name, method_name = resolved
with correlation_scope(event.correlation_id):
try:
with observe_duration(
"event.handler.duration",
event_type=event.event_type.value,
handler_type="bound" if class_name else "function",
):
if inspect.iscoroutinefunction(method):
await method(event)
elif binding.run_sync_in_threadpool or not class_name:
await run_in_threadpool(method, event)
else:
method(event)
except Exception as err:
self._error_handler(
event=event,
module_name=binding.owner_name,
class_name=class_name,
method_name=method_name,
e=err,
)
raise
@staticmethod
def should_dispatch_to_target_plugin(
handler: Callable,
+30
View File
@@ -526,6 +526,36 @@ class EventManager(metaclass=Singleton):
logger.error(f"Unknown event type: {etype}")
return None
def send_event_strict(
self,
etype: EventType,
data: Optional[Union[dict[str, object], ChainEventData]] = None,
priority: Optional[int] = DEFAULT_EVENT_PRIORITY,
) -> Event:
"""同步等待全部广播处理器完成,任一失败时阻止 durable 消息结算。"""
event = Event(etype, data, priority)
with self.__lifecycle_lock:
if self.__lifecycle_state != "running":
raise RuntimeError(f"事件处理处于 {self.__lifecycle_state} 状态")
self.__dispatcher.dispatch_broadcast_strict(
event,
self.__wait_strict_async_handler,
)
return event
@staticmethod
def __wait_strict_async_handler(coroutine: Any) -> Any:
"""在主事件循环等待异步处理器,禁止循环线程同步等待自身。"""
loop = global_vars.loop
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
if running_loop is loop:
coroutine.close()
raise RuntimeError("主事件循环线程不能同步等待 durable 事件处理器")
return asyncio.run_coroutine_threadsafe(coroutine, loop).result()
async def async_send_event(self, etype: Union[EventType, ChainEventType],
data: Optional[Union[Dict, ChainEventData]] = None,
priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
+2 -1
View File
@@ -26,8 +26,9 @@ class TransferTask(CanonicalTransferTask):
meta: Optional[Any] = None
mediainfo: Optional[Any] = None
download_history: Optional[Any] = None
def to_dict(self):
def to_dict(self) -> dict[str, Any]:
"""返回兼容领域对象和旧 Pydantic 对象的任务字典。"""
values = vars(self).copy()
values["fileitem"] = _serialize_legacy_value(self.fileitem)
+2 -1
View File
@@ -11,6 +11,7 @@ from app.runtime.cache import (
AsyncCacheBackend,
AsyncFileCache,
AsyncMemoryBackend,
AtomicCacheBackend,
Cache,
CacheBackend,
FileCache,
@@ -23,8 +24,8 @@ from app.runtime.cache import (
is_fresh,
)
__all__ = [
"AtomicCacheBackend",
"AsyncCache",
"AsyncCacheBackend",
"AsyncFileBackend",
+3 -2
View File
@@ -10,7 +10,7 @@ from app.application.messaging.chat import (
AsyncAgentChatRepository,
AsyncUnitOfWork,
)
from app.application.outbox import AsyncOutboxTransaction
from app.application.outbox import AsyncOutboxDispatchStore, AsyncOutboxStager
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
@@ -40,7 +40,7 @@ class AsyncUnitOfWorkFactory(Protocol):
class AsyncOutboxFactory(Protocol):
"""由请求会话构造异步 outbox 事务端口的工厂。"""
def __call__(self, session: object) -> AsyncOutboxTransaction:
def __call__(self, session: object) -> AsyncOutboxStager:
"""绑定请求会话并返回 outbox 暂存与收口端口。"""
...
@@ -179,6 +179,7 @@ class SubscriptionRuntime:
history_repository: SubscriptionHistoryRepositoryFactory
transaction: AsyncUnitOfWorkFactory
outbox: AsyncOutboxFactory
dispatch_store: AsyncOutboxDispatchStore
@dataclass(frozen=True, slots=True)
+13 -3
View File
@@ -20,8 +20,10 @@ from app.application.subscription.mutation import (
configure_subscription_mutation_scope,
)
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxRepository,
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
)
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
@@ -59,7 +61,8 @@ def subscription_completion_scope() -> Iterator[CompleteSubscriptionCommand]:
yield CompleteSubscriptionCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxRepository(session),
outbox=SqlAlchemyOutboxStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
publish=_publish_completed,
)
finally:
@@ -75,6 +78,9 @@ async def subscription_mutation_scope() -> AsyncIterator[SubscriptionMutationSer
history_repository=SubscribeHistoryOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(
async_session_scope
),
publish_modified=_publish_modified,
)
@@ -89,6 +95,9 @@ async def delete_subscribe_scope() -> AsyncIterator[DeleteSubscribeCommand]:
publish_deleted=_publish_deleted,
report_deleted=MoviePilotServerHelper.async_sub_done_durable,
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(
async_session_scope
),
)
@@ -102,7 +111,8 @@ def sync_delete_subscribe_scope() -> Iterator[SyncDeleteSubscribeCommand]:
unit_of_work=SqlAlchemyUnitOfWork(session),
publish_deleted=_publish_deleted_sync,
report_deleted=MoviePilotServerHelper.sub_done_durable,
outbox=SqlAlchemyOutboxRepository(session),
outbox=SqlAlchemyOutboxStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
)
finally:
session.close()
+121 -57
View File
@@ -82,6 +82,7 @@ from app.application.messaging.message import (
)
from app.application.module import configure_module_runtime
from app.application.outbox import (
ClaimedOutboxMessage,
OutboxDispatcher,
configure_outbox_dispatcher,
durable_event_topic,
@@ -93,7 +94,12 @@ from app.application.query import (
configure_data_query_service,
)
from app.application.security.auth import AuthService, build_superuser_token_payload, configure_auth_service
from app.application.security.passkey import PasskeyService, configure_passkey_service
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyService,
configure_passkey_challenge_cache,
configure_passkey_service,
)
from app.application.security.url import close_image_proxy_block_log_coalescer
from app.application.security.user import configure_user_lookups
from app.application.security.userconfig import (
@@ -113,9 +119,18 @@ from app.application.workflow import (
)
from app.command import CommandChain
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
from app.db.adapters.download import TransactionalDownloadFailureRepository
from app.db.adapters.history.download import (
SessionDownloadHistoryRepository,
TransactionalDownloadHistoryRepository,
)
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxDispatchStore,
)
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
from app.db.adapters.site import TransactionalSiteRepository
from app.db.adapters.subscription import TransactionalSubscribeWriter
@@ -134,7 +149,6 @@ from app.db.adapters.workflow import (
)
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
from app.db.oper.message import MessageOper
from app.db.oper.passkey import PassKeyOper
@@ -144,7 +158,6 @@ from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.userconfig import UserConfigOper
from app.db.oper.workflow import WorkflowOper
from app.db.session import (
SessionFactory,
@@ -159,7 +172,7 @@ from app.db.uow import (
configure_transaction_runners,
)
from app.db.worker import DatabaseWorker
from app.runtime.cache import AsyncFileCache, FileCache
from app.runtime.cache import AsyncFileCache, FileCache, TTLCache
from app.runtime.config import settings as legacy_settings
from app.runtime.events import EventHandlerBinding, EventManager
from app.runtime.execution import run_in_threadpool_to_completion
@@ -227,7 +240,7 @@ async def _initialize_configuration_services(
) -> SystemConfigOper:
"""加载完整配置快照后发布系统与用户配置服务。"""
system_config = SystemConfigOper()
user_config = UserConfigOper()
user_config = TransactionalUserConfigurationRepository(SessionFactory)
await database_worker.run(system_config.load_snapshot)
await database_worker.run(user_config.load_snapshot)
configure_system_config(
@@ -345,122 +358,159 @@ def configure_runtime_data_providers(workflow_query: WorkflowQueryService) -> No
)
def _build_outbox_dispatcher() -> OutboxDispatcher:
"""创建一次恢复批次独占的 Session、Repository 和事件 handler。"""
def dispatch_subscribe_deleted_report(message) -> None:
def _build_outbox_handlers() -> dict[
str,
Callable[[ClaimedOutboxMessage], None],
]:
"""构造等待真实执行边界的 at-least-once 通知、事件和统计 handler。"""
def discard_event_receipt(_event: object) -> None:
"""丢弃普通事件 API 的回执,使 outbox handler 仅表达结算成功。"""
def dispatch_subscribe_deleted_report(message: ClaimedOutboxMessage) -> None:
"""重放订阅删除统计;未确认时抛错以进入有限重试。"""
if not MoviePilotServerHelper.sub_done_durable(
message.payload.get("subscribe_info") or {}
):
raise RuntimeError("订阅删除统计上报未确认")
def dispatch_subscribe_added_report(message) -> None:
def dispatch_subscribe_added_report(message: ClaimedOutboxMessage) -> None:
"""重放订阅新增统计;未确认时抛错以进入有限重试。"""
if not MoviePilotServerHelper.sub_reg_durable(
message.payload.get("subscribe_info") or {}
):
raise RuntimeError("订阅新增统计上报未确认")
def dispatch_subscribe_complete_report(message) -> None:
def dispatch_subscribe_complete_report(message: ClaimedOutboxMessage) -> None:
"""重放订阅完成统计;未确认时抛错以进入有限重试。"""
if not MoviePilotServerHelper.sub_done_durable(
message.payload.get("subscribe_info") or {}
):
raise RuntimeError("订阅完成统计上报未确认")
def dispatch_subscribe_notification(message) -> None:
def dispatch_subscribe_notification(message: ClaimedOutboxMessage) -> None:
"""恢复订阅完成通知;消息快照无需重建领域对象。"""
snapshot = message.payload.get("message") or {}
if not isinstance(snapshot, dict):
raise RuntimeError("订阅完成通知快照格式无效")
CommandChain().post_message(Message.model_validate(snapshot))
CommandChain().post_message_strict(
Message.model_validate(snapshot),
event_key=message.event_key,
)
def dispatch_subscribe_added_notification(message) -> None:
def dispatch_subscribe_added_notification(message: ClaimedOutboxMessage) -> None:
"""恢复订阅新增通知;恢复使用提交前冻结的渲染消息快照。"""
snapshot = message.payload.get("message") or {}
if not isinstance(snapshot, dict):
raise RuntimeError("订阅新增通知快照格式无效")
CommandChain().post_message(Message.model_validate(snapshot))
CommandChain().post_message_strict(
Message.model_validate(snapshot),
event_key=message.event_key,
)
handlers = {
handlers: dict[str, Callable[[ClaimedOutboxMessage], None]] = {
durable_event_topic(
EventType.SubscribeAdded
): lambda message: EventManager().send_event(
EventType.SubscribeAdded,
message.payload,
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.SubscribeAdded,
message.payload,
)
),
"subscribe.added.report": dispatch_subscribe_added_report,
"subscribe.added.notification": dispatch_subscribe_added_notification,
durable_event_topic(
EventType.SubscribeModified
): lambda message: EventManager().send_event(
EventType.SubscribeModified,
message.payload,
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.SubscribeModified,
message.payload,
)
),
durable_event_topic(
EventType.SubscribeDeleted
): lambda message: EventManager().send_event(
EventType.SubscribeDeleted,
message.payload,
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.SubscribeDeleted,
message.payload,
)
),
"subscribe.deleted.report": dispatch_subscribe_deleted_report,
durable_event_topic(
EventType.SubscribeComplete
): lambda message: EventManager().send_event(
EventType.SubscribeComplete,
message.payload,
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.SubscribeComplete,
message.payload,
)
),
"subscribe.complete.report": dispatch_subscribe_complete_report,
"subscribe.complete.notification": dispatch_subscribe_notification,
durable_event_topic(
EventType.DownloadAdded
): lambda message: EventManager().send_event(
EventType.DownloadAdded,
restore_download_added(message.payload),
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.DownloadAdded,
restore_download_added(message.payload),
)
),
durable_event_topic(
EventType.TransferComplete
): lambda message: EventManager().send_event(
EventType.TransferComplete,
restore_transfer_result(message.payload),
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.TransferComplete,
restore_transfer_result(message.payload),
)
),
durable_event_topic(
EventType.TransferFailed
): lambda message: EventManager().send_event(
EventType.TransferFailed,
restore_transfer_result(message.payload),
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.TransferFailed,
restore_transfer_result(message.payload),
)
),
durable_event_topic(
EventType.SubtitleTransferComplete
): lambda message: EventManager().send_event(
EventType.SubtitleTransferComplete,
restore_transfer_result(message.payload),
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.SubtitleTransferComplete,
restore_transfer_result(message.payload),
)
),
durable_event_topic(
EventType.SubtitleTransferFailed
): lambda message: EventManager().send_event(
EventType.SubtitleTransferFailed,
restore_transfer_result(message.payload),
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.SubtitleTransferFailed,
restore_transfer_result(message.payload),
)
),
durable_event_topic(
EventType.AudioTransferComplete
): lambda message: EventManager().send_event(
EventType.AudioTransferComplete,
restore_transfer_result(message.payload),
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.AudioTransferComplete,
restore_transfer_result(message.payload),
)
),
durable_event_topic(
EventType.AudioTransferFailed
): lambda message: EventManager().send_event(
EventType.AudioTransferFailed,
restore_transfer_result(message.payload),
): lambda message: discard_event_receipt(
EventManager().send_event_strict(
EventType.AudioTransferFailed,
restore_transfer_result(message.payload),
)
),
}
validate_durable_event_handlers(handlers)
session = SessionFactory()
return handlers
def _build_outbox_dispatcher() -> OutboxDispatcher:
"""创建使用独立短事务和 attempt fencing 的恢复 dispatcher。"""
return OutboxDispatcher(
repository=SqlAlchemyOutboxRepository(session),
handlers=handlers,
close=session.close,
repository=SqlAlchemyOutboxDispatchStore(SessionFactory),
handlers=_build_outbox_handlers(),
failure_observer=lambda dead: record_metric(
"scheduler.job.dead_letter" if dead else "scheduler.job.retry",
owner="outbox",
@@ -798,7 +848,7 @@ async def init_modules() -> HostRuntime:
sync_session=get_db,
async_session=get_async_db,
repositories={
"download_history": DownloadHistoryOper,
"download_history": SessionDownloadHistoryRepository,
"media_server": MediaServerOper,
"message": MessageOper,
"passkey": PassKeyOper,
@@ -832,6 +882,10 @@ async def init_modules() -> HostRuntime:
)
)
configure_workflow_query(workflow_query)
download_history_repository = TransactionalDownloadHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
agent_chat_persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=database_worker,
@@ -859,7 +913,7 @@ async def init_modules() -> HostRuntime:
),
messaging=MessagingRuntime(repository=MessageOper),
history=HistoryRuntime(
download_repository=DownloadHistoryOper,
download_repository=SessionDownloadHistoryRepository,
transfer_repository=TransferHistoryOper,
media_server_repository=MediaServerOper,
),
@@ -870,6 +924,9 @@ async def init_modules() -> HostRuntime:
history_repository=SubscribeHistoryOper,
transaction=SqlAlchemyAsyncUnitOfWork,
outbox=SqlAlchemyAsyncOutboxStager,
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(
async_session_scope
),
),
workflow=WorkflowRuntime(
query=workflow_query,
@@ -896,7 +953,7 @@ async def init_modules() -> HostRuntime:
async_session=async_session_scope,
),
subscribe=lambda: SubscribeOper(),
download_history=lambda: DownloadHistoryOper(),
download_history=lambda: download_history_repository,
transfer_history=lambda: TransferHistoryOper(),
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
SessionFactory
@@ -933,6 +990,13 @@ async def init_modules() -> HostRuntime:
passkeys=PassKeyOper(),
)
)
configure_passkey_challenge_cache(
TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
)
)
configure_passkey_service(PasskeyService(repository=PassKeyOper()))
configure_transfer_history_provider(lambda: TransferHistoryOper())
configure_site_query_service(SiteQueryService(repository=TransactionalSiteRepository(
@@ -954,7 +1018,7 @@ async def init_modules() -> HostRuntime:
subscribe=lambda: SubscribeOper(),
subscribe_history=lambda: SubscribeHistoryOper(),
transfer_history=lambda: TransferHistoryOper(),
download_history=lambda: DownloadHistoryOper(),
download_history=lambda: download_history_repository,
plugin_data=lambda: PluginDataOper(),
)
configure_agent_task_execution(AgentTaskExecutionService(
+2 -2
View File
@@ -194,12 +194,12 @@ def prepare_backend() -> None:
configs=lambda _config_key, _conf_type: [],
modules=lambda _module_type: [],
)
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.userconfig import UserConfigOper
with SessionFactory() as session:
SystemConfigOper().load_snapshot(session)
UserConfigOper().load_snapshot(session)
TransactionalUserConfigurationRepository(SessionFactory).load_snapshot()
# 缓存装饰器在测试模块导入时即创建后端,先装配隔离配置对应的适配器。
from app.startup.initializers.cache import configure_cache_dependencies
configure_cache_dependencies()
+438
View File
@@ -0,0 +1,438 @@
"""3.0.18 强制用户身份唯一并建立用户从属数据级联约束。
Revision ID: a9d4f2c7e6b1
Revises: f6d8b0c2e4a7
Create Date: 2026-08-28
"""
from collections import defaultdict
from typing import Optional
import sqlalchemy as sa
from alembic import op
revision = "a9d4f2c7e6b1"
down_revision = "f6d8b0c2e4a7"
branch_labels = None
depends_on = None
_TABLE_NAME = "user"
_UNIQUE_INDEX = "ux_user_name"
_LEGACY_INDEX = "ix_user_name"
_REQUIRED_COLUMNS = {"id", "name", "is_active"}
_USER_CONFIG_TABLE = "userconfig"
_USER_CONFIG_FOREIGN_KEY = "fk_userconfig_username_user"
_USER_CONFIG_UNIQUE = "uq_userconfig_username_key"
_PASSKEY_TABLE = "passkey"
_PASSKEY_FOREIGN_KEY = "fk_passkey_user_id_user"
_FOREIGN_KEY_NAMING_CONVENTION = {
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"uq": "uq_%(table_name)s_%(column_0_name)s_%(column_1_name)s",
}
def _column_names() -> set[str]:
"""返回用户表字段;表不存在时允许迁移空数据库。"""
inspector = sa.inspect(op.get_bind())
if _TABLE_NAME not in inspector.get_table_names():
return set()
return {column["name"] for column in inspector.get_columns(_TABLE_NAME)}
def _index_definitions() -> dict[str, dict]:
"""返回用户表按名称索引的当前定义。"""
return {index["name"]: index for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME) if index.get("name")}
def _constraint_definitions() -> dict[str, dict]:
"""返回用户表按名称索引的唯一约束定义。"""
return {
constraint["name"]: constraint
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(_TABLE_NAME)
if constraint.get("name")
}
def _table_columns(table_name: str) -> dict[str, dict]:
"""返回指定表的字段定义;表不存在时返回空映射。"""
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return {}
return {column["name"]: column for column in inspector.get_columns(table_name)}
def _foreign_keys(table_name: str) -> list[dict]:
"""返回指定表的外键定义;表不存在时返回空列表。"""
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return []
return list(inspector.get_foreign_keys(table_name))
def _unique_constraints(table_name: str) -> list[dict]:
"""返回指定表的唯一约束;表不存在时返回空列表。"""
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return []
return list(inspector.get_unique_constraints(table_name))
def _foreign_key_matches(
foreign_key: dict,
*,
column: str,
referred_column: str,
ondelete: Optional[str],
onupdate: Optional[str],
) -> bool:
"""判断外键列、目标列与级联动作是否完全符合规范。"""
options = foreign_key.get("options") or {}
actual_ondelete = str(options.get("ondelete") or "").upper() or None
actual_onupdate = str(options.get("onupdate") or "").upper() or None
return (
tuple(foreign_key.get("constrained_columns") or ()) == (column,)
and foreign_key.get("referred_table") == _TABLE_NAME
and tuple(foreign_key.get("referred_columns") or ()) == (referred_column,)
and actual_ondelete == ondelete
and actual_onupdate == onupdate
)
def _delete_orphans(
*,
table_name: str,
column: str,
referred_column: str,
delete_nulls: bool,
) -> None:
"""建立约束前删除无法归属到现有用户的历史从属行。"""
columns = _table_columns(table_name)
if column not in columns:
return
child = sa.table(table_name, sa.column(column))
parent = sa.table(_TABLE_NAME, sa.column(referred_column))
child_column = child.c[column]
orphaned = ~sa.exists(sa.select(1).select_from(parent).where(parent.c[referred_column] == child_column))
if delete_nulls:
orphaned = sa.or_(child_column.is_(None), orphaned)
op.get_bind().execute(child.delete().where(orphaned))
def _normalize_user_configs() -> None:
"""删除空键和重复配置,仅保留每个用户键最早的历史行。"""
columns = _table_columns(_USER_CONFIG_TABLE)
if not {"id", "username", "key"}.issubset(columns):
return
configs = sa.table(
_USER_CONFIG_TABLE,
sa.column("id", sa.Integer()),
sa.column("username", sa.String()),
sa.column("key", sa.String()),
)
connection = op.get_bind()
connection.execute(configs.delete().where(configs.c.key.is_(None)))
rows = connection.execute(sa.select(configs.c.id, configs.c.username, configs.c.key).order_by(configs.c.id)).all()
seen: set[tuple[str, str]] = set()
duplicate_ids: list[int] = []
for row_id, username, key in rows:
identity = (username, key)
if identity in seen:
duplicate_ids.append(row_id)
else:
seen.add(identity)
if duplicate_ids:
connection.execute(configs.delete().where(configs.c.id.in_(duplicate_ids)))
def _repair_user_config_unique() -> None:
"""把用户配置键约束修复为命名的非空双列唯一约束。"""
columns = _table_columns(_USER_CONFIG_TABLE)
if "key" not in columns:
return
constraints = _unique_constraints(_USER_CONFIG_TABLE)
relevant = [
constraint
for constraint in constraints
if tuple(constraint.get("column_names") or ()) == ("username", "key")
or constraint.get("name") == _USER_CONFIG_UNIQUE
]
matches = (
len(relevant) == 1
and relevant[0].get("name") == _USER_CONFIG_UNIQUE
and tuple(relevant[0].get("column_names") or ()) == ("username", "key")
)
if matches and not bool(columns["key"].get("nullable")):
return
with op.batch_alter_table(
_USER_CONFIG_TABLE,
naming_convention=_FOREIGN_KEY_NAMING_CONVENTION,
) as batch_op:
for constraint in relevant:
batch_op.drop_constraint(
constraint.get("name") or _USER_CONFIG_UNIQUE,
type_="unique",
)
if bool(columns["key"].get("nullable")):
batch_op.alter_column(
"key",
existing_type=columns["key"]["type"],
existing_nullable=True,
nullable=False,
)
batch_op.create_unique_constraint(
_USER_CONFIG_UNIQUE,
["username", "key"],
)
def _repair_foreign_key(
*,
table_name: str,
column: str,
referred_column: str,
constraint_name: str,
nullable: bool,
ondelete: Optional[str],
onupdate: Optional[str] = None,
) -> None:
"""跨 SQLite/PostgreSQL 精确修复一条用户从属外键。"""
columns = _table_columns(table_name)
if column not in columns:
return
relevant = [
foreign_key
for foreign_key in _foreign_keys(table_name)
if tuple(foreign_key.get("constrained_columns") or ()) == (column,)
]
matches = len(relevant) == 1 and _foreign_key_matches(
relevant[0],
column=column,
referred_column=referred_column,
ondelete=ondelete,
onupdate=onupdate,
)
if matches and bool(columns[column].get("nullable")) == nullable:
return
with op.batch_alter_table(
table_name,
naming_convention=_FOREIGN_KEY_NAMING_CONVENTION,
) as batch_op:
for foreign_key in relevant:
batch_op.drop_constraint(
foreign_key.get("name") or constraint_name,
type_="foreignkey",
)
if bool(columns[column].get("nullable")) != nullable:
batch_op.alter_column(
column,
existing_type=columns[column]["type"],
existing_nullable=bool(columns[column].get("nullable")),
nullable=nullable,
)
batch_op.create_foreign_key(
constraint_name,
_TABLE_NAME,
[column],
[referred_column],
ondelete=ondelete,
onupdate=onupdate,
)
def _drop_user_config_foreign_key() -> None:
"""降级时移除用户名外键并恢复历史可空字段。"""
columns = _table_columns(_USER_CONFIG_TABLE)
if "username" not in columns:
return
relevant = [
foreign_key
for foreign_key in _foreign_keys(_USER_CONFIG_TABLE)
if tuple(foreign_key.get("constrained_columns") or ()) == ("username",)
]
key_is_nullable = bool(columns.get("key", {}).get("nullable", True))
if not relevant and bool(columns["username"].get("nullable")) and key_is_nullable:
return
with op.batch_alter_table(
_USER_CONFIG_TABLE,
naming_convention=_FOREIGN_KEY_NAMING_CONVENTION,
) as batch_op:
for foreign_key in relevant:
batch_op.drop_constraint(
foreign_key.get("name") or _USER_CONFIG_FOREIGN_KEY,
type_="foreignkey",
)
if not bool(columns["username"].get("nullable")):
batch_op.alter_column(
"username",
existing_type=columns["username"]["type"],
existing_nullable=False,
nullable=True,
)
if "key" in columns and not key_is_nullable:
batch_op.alter_column(
"key",
existing_type=columns["key"]["type"],
existing_nullable=False,
nullable=True,
)
def _replacement_name(
*,
original_name: str,
user_id: int,
used_names: set[str],
) -> str:
"""生成可重放且不覆盖任何现有用户的重复用户名。"""
base_name = f"{original_name}__duplicate_{user_id}"
candidate = base_name
collision = 1
while candidate in used_names:
candidate = f"{base_name}_{collision}"
collision += 1
return candidate
def _normalize_duplicate_names() -> None:
"""保留同名最早用户,并就地停用、重命名其余用户。
迁移不删除或合并用户行因此按 ``user.id`` 建立的 PassKey 等外键
仍指向原记录没有外键语义的历史用户名快照也不做猜测性改写
"""
users = sa.table(
_TABLE_NAME,
sa.column("id", sa.Integer()),
sa.column("name", sa.String()),
sa.column("is_active", sa.Boolean()),
)
connection = op.get_bind()
rows = connection.execute(sa.select(users.c.id, users.c.name).order_by(users.c.id)).mappings().all()
duplicate_groups: dict[str, list[int]] = defaultdict(list)
used_names = set()
for row in rows:
name = row["name"]
if name is None:
raise RuntimeError("用户表存在空用户名,无法建立用户名唯一约束")
duplicate_groups[name].append(row["id"])
used_names.add(name)
for original_name in sorted(duplicate_groups):
duplicate_ids = duplicate_groups[original_name][1:]
for user_id in duplicate_ids:
replacement = _replacement_name(
original_name=original_name,
user_id=user_id,
used_names=used_names,
)
connection.execute(users.update().where(users.c.id == user_id).values(name=replacement, is_active=False))
used_names.add(replacement)
def _repair_unique_index() -> None:
"""把用户名约束修复为精确的单列唯一索引。"""
constraints = _constraint_definitions()
if _UNIQUE_INDEX in constraints:
with op.batch_alter_table(_TABLE_NAME) as batch_op:
batch_op.drop_constraint(_UNIQUE_INDEX, type_="unique")
indexes = _index_definitions()
current = indexes.get(_UNIQUE_INDEX)
if current is not None and (
tuple(current.get("column_names") or ()) != ("name",) or not bool(current.get("unique"))
):
op.drop_index(_UNIQUE_INDEX, table_name=_TABLE_NAME)
current = None
if current is None:
op.create_index(
_UNIQUE_INDEX,
_TABLE_NAME,
["name"],
unique=True,
)
legacy = _index_definitions().get(_LEGACY_INDEX)
if legacy is not None:
op.drop_index(_LEGACY_INDEX, table_name=_TABLE_NAME)
def upgrade() -> None:
"""归一用户身份并约束 UserConfig、PassKey 必须归属现有用户。"""
columns = _column_names()
if not columns:
return
missing_columns = _REQUIRED_COLUMNS - columns
if missing_columns:
names = ", ".join(sorted(missing_columns))
raise RuntimeError(f"用户表缺少迁移必需字段: {names}")
_normalize_duplicate_names()
_repair_unique_index()
_delete_orphans(
table_name=_USER_CONFIG_TABLE,
column="username",
referred_column="name",
delete_nulls=True,
)
_normalize_user_configs()
_repair_user_config_unique()
_repair_foreign_key(
table_name=_USER_CONFIG_TABLE,
column="username",
referred_column="name",
constraint_name=_USER_CONFIG_FOREIGN_KEY,
nullable=False,
ondelete="CASCADE",
onupdate="CASCADE",
)
_delete_orphans(
table_name=_PASSKEY_TABLE,
column="user_id",
referred_column="id",
delete_nulls=True,
)
_repair_foreign_key(
table_name=_PASSKEY_TABLE,
column="user_id",
referred_column="id",
constraint_name=_PASSKEY_FOREIGN_KEY,
nullable=False,
ondelete="CASCADE",
)
def downgrade() -> None:
"""撤销用户名唯一索引,但保留已停用和改名的历史用户。
自动恢复原重名会覆盖升级后的合法改名也无法判定共享用户名快照的
归属因此降级只恢复旧版非唯一查询索引
"""
if not _column_names():
return
_drop_user_config_foreign_key()
_repair_foreign_key(
table_name=_PASSKEY_TABLE,
column="user_id",
referred_column="id",
constraint_name=_PASSKEY_FOREIGN_KEY,
nullable=False,
ondelete=None,
)
indexes = _index_definitions()
if _UNIQUE_INDEX in indexes:
op.drop_index(_UNIQUE_INDEX, table_name=_TABLE_NAME)
constraints = _constraint_definitions()
if _UNIQUE_INDEX in constraints:
with op.batch_alter_table(_TABLE_NAME) as batch_op:
batch_op.drop_constraint(_UNIQUE_INDEX, type_="unique")
indexes = _index_definitions()
legacy = indexes.get(_LEGACY_INDEX)
if legacy is not None and (tuple(legacy.get("column_names") or ()) != ("name",) or bool(legacy.get("unique"))):
op.drop_index(_LEGACY_INDEX, table_name=_TABLE_NAME)
legacy = None
if legacy is None:
op.create_index(
_LEGACY_INDEX,
_TABLE_NAME,
["name"],
unique=False,
)
+43 -36
View File
@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 853 / 6,979 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 856 / 7,006 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 868 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.02%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
| Ruff 历史诊断 | 840 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.39%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
@@ -101,7 +101,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
## 4. 优化清单总表
状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`渐进` 表示应按触碰路径逐步收敛。
状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`执行中` 表示只完成部分纵切面;`已验证` 表示实现和本地合同已落地但未在本文中声明未知的提交或 CI;`渐进` 表示应按触碰路径逐步收敛。
| ID | 优先级 | 状态 | 事项 | 目标结果 |
|---|---|---|---|---|
@@ -110,10 +110,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| ARCH-102 | P1 | 已交付 | 将 Transfer pending 升级为真实 E3 状态机 | `e9de149db``a2e249f20` 已推送;Unit Tests `33092427327`、Pylint `33092427348` 全绿,崩溃结果未知时进入人工确认 |
| ARCH-103 | P1 | 执行中 | 类型化 Chain/Agent 数据 Port 与 DTO | 宿主主路径不再注入无 Session Oper,不向入口泄漏 ORM |
| ARCH-104 | P1 | 待执行 | 收口跨多次写入的业务事务 | 站点/规则引用清理可整体回滚或幂等恢复 |
| ARCH-105 | P1 | 待执行 | 明确 post-commit 与 Outbox 完成语义 | 业务提交、后置效果 pending”可被调用方正确识别 |
| ARCH-105 | P1 | 已验证 | 明确 post-commit 与 Outbox 完成语义 | 业务提交、effect 完成/pending 可区分;stager/store 分离且 claim/settlement 受 fencing,外部 sink 仍承担 at-least-once 幂等边界 |
| ARCH-106 | P1 | 待执行 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 导入或普通 Chain 构造不再启动进程资源 |
| ARCH-107 | P1 | 待执行 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 |
| ARCH-108 | P1 | 执行 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | 依赖倒置有明确例外、低水位迁移顺序 |
| ARCH-108 | P1 | 执行 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Passkey 缓存纵切面已验证,其余 Adapter/HTTP/DNS 债务继续按低水位迁移 |
| ARCH-109 | P1 | 待执行 | 按用例拆分超大 Chain、Scheduler 和厚 API | 稳定 Facade 保留,决策/I/O/状态/生命周期各有 owner |
| ARCH-110 | P1 | 待执行 | Module/Event Contract 分可信级执行 | 宿主 provider 严格,第三方插件仍兼容诊断 |
| ARCH-111 | P1 | 待执行 | 升级复杂度、类型、覆盖率和并发原语门禁 | 高风险私有路径也进入只降不增的治理面 |
@@ -268,14 +268,13 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
**问题与证据**
- `app/application/chain/data.py:14-29,134-176` 的 Oper factory 和 getter 基本都`Any`
`app/application/agentdata.py:91-120` 通过 `__dict__.update()` 动态组装端口。
- `app/startup/initializers/modules.py:848-863,896-910` 仍向生产 Chain/Agent 注入多个无 Session Oper。
- `app/application/chain/data.py` 的 Site/Subscribe/TransferHistory factory 仍`Any`
`app/application/agentdata.py` 通过 `__dict__.update()` 动态组装未迁移端口。
- `app/startup/initializers/modules.py` 仍向生产 Chain/Agent 注入部分无 Session Oper。
- 无 Session Oper 会为单次调用独立创建事务;一个业务操作的“查询后更新”可能被拆成多个事务。
- Workflow query 已在 S1-L2 迁入冻结 DTO adapter-owned Session 投影;其余 Chain/Agent raw data port
返回 `Any`/ORMSubscription mutation 内部也消费 ORM因而仍存在 Session 生命周期外
detached/lazy-load 的潜在风险。公开 Subscription、Site、History QueryService 已经投影 DTO
属于完成项,不应重做。
- Workflow、User 和 DownloadHistory 已迁入冻结 DTO adapter-owned Session 投影;
剩余 Chain/Agent raw data port 仍可返回 `Any`/ORMSubscription mutation 内部也消费 ORM
因而尚有 Session 生命周期外 detached/lazy-load 风险。
- S1-L2 由 `b4f873654``a01a35bcb` 交付;精确 head SHA 的 Unit Tests `33098869736`
Pylint `33098869837` 全绿,Application 覆盖率低水位提升并固化至 `78.78%`。该证据只完成
Workflow query 纵切面,不能替代 S1-L3 对其余 Chain/Agent raw data port 的清零。
@@ -293,15 +292,20 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
伪注入;Workflow 执行服务只在 Application owner 配置一次,不再重复注册到 `ChainDataPorts`
- [x] DownloadFailure/MediaServer 两个 registry 字段改用冻结 DTO 与 typed Repository factory
ORM 不越过短 Session,媒体库远端枚举期间不持有事务,旧 Oper/Compat 与公开 Chain ABI 保持不变。
- [x] User Chain/Agent/认证查询改用冻结 `UserSnapshot`/`UserAuthSnapshot`;创建、更名和
删除由请求级 UoW 原子提交,用户名唯一约束、最后一个启用超级管理员保护与
UserConfig/PassKey 级联约束共同守住身份聚合。
- [x] DownloadHistory 查询和写入改用冻结 DTO、typed Port 与短 Session adapter,删除在单一
UoW 中处理;TransferHistory 仍是 raw portHistory 整体仍在执行中。
- [ ] `ChainDataPorts`/`AgentDataPorts` 可暂时保留为兼容聚合器,但字段必须显式、可类型检查。
- [ ] 以一个业务纵切面迁移并验证后,再迁移下一组,禁止一次替换所有 Oper。
- [ ] 增加 AST 门禁,禁止向 `ChainDataPorts``AgentDataPorts` 和新的 canonical use-case service
注入裸 OperSystemConfig singleton、legacy transaction runner 等兼容边界使用精确 allowlist。
**首批建议**
**后续顺序**
1. Workflow query DTO
2. Chain/Agent 的 Subscribe/History/User/Site raw port。
1. 完成 TransferHistory,收口 History 剩余半边
2. Chain/Agent 的 Subscribe/Site raw port。
3. Subscription mutation 与站点、规则组引用更新。
4. Agent 数据能力。
@@ -350,28 +354,29 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
### ARCH-105 明确 post-commit 与 Outbox 完成语义
**问题与证据**
**已实现事实与语义边界**
- `app/application/outbox.py:167-192` 在业务提交后直接执行 `after_commit()`、即时 publish 和完成标记;
某一步抛错时,调用方可能收到失败,但业务行和 intent 已经提交
- 通用 `DurableEventCommand` commit 后没有先调用已经定义的 `claim_by_event_key()`dispatcher 可在
commit 与请求线程即时 publish 之间先 claim 并投递,随后请求线程再次 publish,形成双发窗口
`app/application/subscription/complete.py:111-145` 已提供先 claim 的正确参考。
- 下载历史及事件 intent 已原子提交;通知在 commit 后同步执行,模块后处理和字幕再投进线程池
进程在提交与这些动作完成之间退出时,未持久化的动作不会自动恢复。
- `SqlAlchemyOutboxRepository` 同时提供不提交的 `stage()` 和内部自提交的 dispatcher 方法,
事务所有权没有由类型清楚表达。
- `OutboxStager` 在业务 Session 中 stage/flush`OutboxDispatchStore` 的 claim、complete 和 retry
每次使用独立短事务,业务与 dispatcher 的事务所有权已由类型分开
- 请求线程即时投递与 dispatcher 均先按稳定 event key 原子 claim;同一 lease 期间只有
一个 owner,过期 owner 不能用旧 attempt 覆盖新 owner 的 complete/retry
- `PostCommitResult`/`PostCommitEffectError` 保留“业务已提交”事实,并逐项列出已完成和
pending effect,后置效果失败不伪装成业务回滚
- lease/attempt fencing 只保护宿主的认领与结算。外部调用成功但 complete 落库前崩溃时,
intent 仍会重放;因此交付承诺是 at-least-once。事件载荷和宿主 correlation context
携带稳定 event key,支持幂等的消费者应使用它;旧通知插件保持原方法签名,不能宣称外部
provider 已获得 exactly-once 或统一幂等能力。
**目标与步骤**
- [ ] Command 返回结构化结果:业务是否提交、哪些后置效果完成、哪些处于 pending。
- [ ] commit 后先取得 delivery lease;未取得时跳过请求线程直投,确保与 dispatcher 排他。
- [ ] 每个 post-commit effect 使用独立 intent 隔离和结算,避免效果 A 失败导致效果 B 被一起重放。
- [x] Command 返回结构化结果:业务是否提交、哪些后置效果完成、哪些处于 pending。
- [x] commit 后先取得 delivery lease;未取得时跳过请求线程直投,确保与 dispatcher 排他。
- [x] 每个 post-commit effect 使用独立 intent 隔离和结算,避免效果 A 失败导致效果 B 被一起重放。
单个外部效果仍是 at-least-once,必须有稳定幂等键和幂等 handler/消费者。
- [ ] 按完成承诺、可重建性、外部不可逆性和业务重要性划分 E0-E3;用户可见性只是因素之一。
- [ ] 拆分 `OutboxStager``OutboxDispatchStore`,避免业务 Session 调用自提交方法。
- [ ] 在 commit、claim、publish、complete、通知和任务提交各断点注入异常/崩溃,验证声明等级与实际恢复一致。
- [ ] 增加请求线程即时投递与 dispatcher 并发竞争测试,以及“外部调用成功、complete 前崩溃”的重放测试。
- [x] 拆分 `OutboxStager``OutboxDispatchStore`,避免业务 Session 调用自提交方法。
- [x] 在 commit、claim、publish、complete、通知和任务提交各断点注入异常/崩溃,验证声明等级与实际恢复一致。
- [x] 增加请求线程即时投递与 dispatcher 并发竞争测试,以及“外部调用成功、complete 前崩溃”的重放测试。
```bash
.venv/bin/python -m pytest \
@@ -469,7 +474,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
`security/passkey.py``backup.py``image.py``rss.py``security/cookie.py`
- `app/chain` 有 8 个文件、13 条直接 Adapter 导入,使用 `RequestUtils`、Browser、Cloudflare、
CookieCloud、ServerHelper 等具体能力。
- Passkey Application 服务直接判断 Redis 后端并调用 `RedisHelper.pop()`,安全策略识别了具体实现。
- Passkey Application 已改为消费启动注入的 `PasskeyChallengeCache`,不再判断 Redis 或导入
具体 cache adapterMemory/Redis 均实现严格 `AtomicCacheBackend.store/consume`
- 审计时 LLM streaming、第三方 SDK、移植库和本地控制面没有精确例外表;S0-L2.4b 已建立
66 条完整 egress identity 与 zero-growth policy,其中 11 条普通 HTTP/Session bridge 和 1 条
Application DNS I/O 是清零债务;每条初始边另有独立指纹上界,不能靠同时刷新 baseline/policy
@@ -479,7 +485,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] 建立 Application/Chain 原始 Adapter 直连事实与精确临时 policy,冻结新增、替换和陈旧条目。
- [x] 建立全宿主 direct egress 事实;SDK/stream/vendor/local-control 例外精确到 bindings/uses 指纹。
- [ ] 将 Passkey 原子领取提升为 runtime cache contract,由 Memory/Redis backend 分别实现。
- [x] 将 Passkey 原子领取提升为 runtime cache contract,由 Memory/Redis backend 分别实现。
- [ ] 为 Backup 定义 Application-owned artifact store Port,由 startup 注入文件系统实现。
- [ ] 将 policy 中 11 条普通 HTTP/Session bridge 债务迁移到统一网络能力并把目标收缩为空。
- [ ] 为 Application SSRF 校验注入 DNS 解析 Port,清除 `socket.getaddrinfo` 直接 I/O。
@@ -611,7 +617,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
1. ARCH-102 设计并迁移 Transfer E3 状态机。
2. 以站点/规则引用清理为 ARCH-103/104 的第一个 typed Port + UoW 纵切面。
3. 完成 ARCH-105 post-commit 结构化结果Outbox 角色拆分
3. ARCH-105 已完成 post-commit 结构化结果Outbox 角色拆分和 claim fencing
后续只继续清理独立 intent 与更完整的崩溃矩阵。
**退出条件**:故障注入覆盖清单列出的崩溃窗口;schema 有 migration;主路径不再 fail-open;调用方能区分
业务提交与后置效果 pending。
@@ -620,7 +627,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
1. ARCH-106 将日志、消息队列和主循环 gateway 纳入 lifecycle。
2. ARCH-107 拆出 `chain/base.py` 并消除新增 SCC。
3. ARCH-108 先迁移 PasskeyBackup按风险迁移外部调用。
3. ARCH-108 Passkey 缓存边界已验证;下一纵切面是 Backup之后按风险迁移外部调用。
**退出条件**:冷导入不启动线程;SCC 只剩 TMDB 精确豁免;Application/Chain 到 Adapter 的债务只降不增。
+2 -2
View File
@@ -704,8 +704,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 853 |
| 内部导入边 | 6,979 |
| Python 模块 | 856 |
| 内部导入边 | 7,006 |
| 非平凡 SCC | 2`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
| Direct egress | 6612 条待迁移债务,54 条精确 containment |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
+5 -5
View File
@@ -103,14 +103,14 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S1-L3.2 Chain registry/DI | `ACTIVE` | S1-L3.1 | 显式类型化 factory,删除 PortProxy 与失效的双重注入,构造器注入真实控制调用 |
| S1-L3.2.1 Registry hygiene | `DELIVERED` | S1-L3.1 | `ac7a20132`:删除零消费者 PortProxy/动态转发和 `ChainRuntimeContext.data_ports` 伪注入;Workflow 退出 Chain registry,只保留 Application owner 单一配置入口;Unit Tests `33120205586`、Pylint `33120205581` 全绿 |
| S1-L3.3 DownloadFailure/MediaServer | `DELIVERED` | S1-L3.2 | `5fb62108a`:两组 raw factory 已替换为冻结 DTO/typed Port;失败冷却在 Session 内投影,媒体库查询只返回标量且每个 upsert/cleanup 独立短事务,远端枚举不持有 Session;旧 Oper 与插件可见 Chain ABI 保持不变;Unit Tests `33127544925`、Pylint `33127544927` 全绿,Application 覆盖率低水位提升至 `78.95%` |
| S1-L3.4 User | `PLANNED` | S1-L3.3 | 认证、偏好与渠道绑定投影冻结快照,User Chain/Agent 不接收 ORM |
| S1-L3.5 History | `PLANNED` | S1-L3.4 | Download/Transfer history 统一 typed query/mutation,删除下载历史双事务 fail-open |
| S1-L3.4 User | `VERIFIED` | S1-L3.3 | User Chain/Agent/认证改用冻结 typed snapshot;创建、更名、删除与最后一个启用超级管理员保护归并到单 UoW;用户名唯一索引及 UserConfig/PassKey 级联迁移落地,UserConfig 在 commit 后持写锁重载数据库事实源并发布内存快照 |
| S1-L3.5 History | `IN_PROGRESS` | S1-L3.4 | DownloadHistory 已迁入冻结 DTO、typed query/write Port 与短 Session adapter,下载历史删除不再拆成 fail-open 双事务;TransferHistory 半边尚未完成,本项不宣称整体交付 |
| S1-L3.6 Site | `PLANNED` | S1-L3.5 | 复用 Site query/health,补齐同步 typed commandSession 内完成 DTO 投影 |
| S1-L3.7 Subscription | `PLANNED` | S1-L3.6 | Chain/Workflow/interaction 全部消费 typed query/command;完成后进入 S1-L4 原子事务收口 |
| S1-L3.8 Agent/Transfer locator gate | `PLANNED` | S1-L3.7 | 删除 AgentDataPorts 与 Chain locator 跨层泄漏,AST 门禁确认 canonical 无 raw getter/Oper/Any |
| S1-L4 Subscription mutation UoW | `PLANNED` | S1-L3 | Subscription mutation 不跨 Session 传 ORM,正式写路径一个 UoW,旧自动事务入口退出 canonical 路径 |
| S1-L5 站点/规则引用原子清理 | `PLANNED` | S1-L4 | SystemConfig+Subscribe 同事务更新,commit 后快照原子发布,并发/故障注入无部分状态 |
| S1-L6 Outbox 完成语义 | `PLANNED` | S0 | claim 竞争双发清零;业务提交与 effect pending 可区分;stager/store 分离;handler 幂等与崩溃测试完整 |
| S1-L6 Outbox 完成语义 | `VERIFIED` | S0 | 事务内 `OutboxStager` 与独立短事务 `OutboxDispatchStore` 已分离;即时投递与 dispatcher 均先 claimcomplete/retry 受 attempt fencing`PostCommitResult` 区分已提交业务、已完成与 pending effect。事件载荷和宿主 correlation context 携带稳定 event key;旧通知插件保持原签名并承认 at-least-once 重复边界 |
### S2:进程生命周期、循环与 Adapter 边界
@@ -122,7 +122,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S2-L1 日志/消息资源显式生命周期 | `PLANNED` | S0 | import 和非消息 Chain 构造零新增线程;bootstrap 显式创建,失败和正常关闭均收口 |
| S2-L2 ChainBase 与 SCC 清零 | `PLANNED` | S0-L2.2 | canonical `app.chain.base` 落地,包根无 eager/重复导出,宿主包根导入清零,Chain SCC 消失 |
| S2-L3 GlobalVar/provider 注册收口 | `PLANNED` | S2-L1 | `global_vars` canonical 消费清零,provider 注册进入显式装配阶段并可 reset;Legacy 入口精确保留 |
| S2-L4 Passkey 缓存边界 | `PLANNED` | S0-L2.4 | Application 不识别 Redis;原子 consume 由 runtime cache contract + backend 实现 |
| S2-L4 Passkey 缓存边界 | `VERIFIED` | S0-L2.4 | `PasskeyChallengeCache` 由 startup 注入,Application 不识别 Redis;严格 `AtomicCacheBackend.store/consume` 由 Memory/Redis backend 分别实现,challenge 仅能被原子领取一次 |
| S2-L5 Backup artifact Port | `PLANNED` | S0-L2.4 | Application 不构造 `BackupFiles`,文件 I/O 由注入 Adapter 拥有 |
| S2-L6 Application Adapter/DNS 债务清零 | `PLANNED` | S2-L4,S2-L5 | Application 到具体 Adapter 的未批准边归零,SSRF DNS I/O 进入注入 Port,批准通用机制有精确规则和门禁 |
| S2-L7 Chain Adapter/宿主 HTTP 债务清零 | `PLANNED` | S2-L6 | Chain 具体 Adapter 与 11 条普通 direct HTTP/Session bridge 归零;SDK/stream/vendor 例外保持精确 containment |
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 868 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 840 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口
+59 -17
View File
@@ -64,16 +64,17 @@ to make the directory tree look symmetrical.
| `app/application/subscription/` | Subscription use cases: `write.py` owns media-to-row translation and the write port; `contract.py` owns shared metadata/media-key projection; query, mutation, deletion, identity and search stay in their single-word modules |
| `app/application/search/` | Search state and later search-plan use cases |
| `app/application/download/` | Download task querying/control and selection use cases; `failures.py` owns the frozen failure-cooldown write/query DTOs and persistence Port |
| `app/application/history.py` | History use cases and persistence contracts; DownloadHistory owns frozen query/write DTOs and typed ports, while TransferHistory remains a separate unfinished migration surface |
| `app/application/music/` | Multi-source music catalog orchestration |
| `app/application/chain/` | Injectable Chain runtime capabilities: `context.py` owns the runtime dependency aggregate, `data.py` owns named persistence ports, and `events.py` owns durable event write contracts plus replayable payload conversion |
| `app/application/agentdata.py` | Named Agent data ports; canonical Agent consumers use `get_agent_*_port()` and do not alias legacy proxies to Oper classes |
| `app/application/outbox.py` | Durable intent and Outbox repository/dispatcher contracts for post-commit side effects |
| `app/application/outbox.py` | Durable intent, transaction-only stager, short-transaction dispatch store, claim fencing and structured post-commit result contracts |
| `app/application/transfer/` | Durable transfer use cases: `workflow.py` owns admission/planning/queue behavior; `execution.py` owns stable operation identity, step/checkpoint state, retry/manual-review commands and terminal-settlement DTOs |
| `app/application/plugin/` | Plugin market catalog, installation command, installed-plugin identity contract and startup migration, runtime port, folder operations and dynamic-route use cases; filenames remain single words (`catalog.py`, `identity.py`, `migration.py`, `install.py`, `runtime.py`, `folders.py`, `routes.py`) |
| `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup |
| `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here |
| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `ingress.py` owns the single channel-to-host loopback boundary; `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use |
| `app/application/security/` | Authentication, authorization, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy |
| `app/application/security/` | Authentication, authorization, frozen user/auth projections, atomic user aggregate commands, per-user configuration publication, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy |
Application services may use domain rules and runtime contracts. They own the
persistence Protocol needed by a use case, but must not import `app.db`,
@@ -457,10 +458,14 @@ SQLAlchemy models stay under `app/db/models/`; the data access classes live in
carries the role. Two verified aggregation exceptions exist: the site family
(`Passkey`, `SiteIcon`, `SiteStatistic`, `SiteUserData`) is consolidated in
`oper/site.py`, and `AgentTaskRun` lives in `oper/agenttask.py`. DB adapters use
Oper classes instead of issuing SQLAlchemy queries directly. Application and
Chain code reaches persistence through named Ports/Protocols; concrete DB adapters
are the layer that adapts those Ports to Oper classes. Every schema change
requires an Alembic migration under `database/versions/`.
Oper classes for ordinary entity access. Adapter-owned cross-row locks and
compare-and-set transitions may issue focused SQLAlchemy statements when the
atomic persistence invariant cannot be expressed by an entity Oper; those
statements stay private to the adapter and require concurrency tests.
Application and Chain code reaches persistence through named Ports/Protocols;
concrete DB adapters are the only layer that adapts those Ports to Oper/Session
mechanics. Every schema change requires an Alembic migration under
`database/versions/`.
Oper classes take and return persistence values, not domain objects. Translating
`MediaInfo` / `MetaBase` into a row is business logic and belongs in
@@ -477,17 +482,40 @@ path cannot forget them. Identity representation rules themselves
alongside the two identity mixins; `app/domain/media.py` keeps only source
policy. `app/db` therefore has no dependency on `app/domain`.
User identity is a single aggregate boundary. `app/application/security/user.py`
owns frozen user/auth snapshots and the atomic create/update/delete command;
`app/db/adapters/user.py` binds each mutation to one request UoW and locks the
active-superuser set before a destructive change. The database enforces unique
user names and cascades rename/delete to `UserConfig` and delete to `PassKey`.
The configured user-configuration repository publishes its in-memory snapshot
only after the user transaction commits, and reloads from the database if
publication fails.
Download history is the verified half of the History migration:
`app/application/history.py` owns frozen `DownloadHistorySnapshot` /
`DownloadFileSnapshot` values and typed query/write ports;
`app/db/adapters/history/download.py` performs projection and mutations in short
Session scopes. TransferHistory still uses its existing ports, so this must not
be described as completion of the entire History boundary.
Durable post-commit side effects have a separate boundary:
- `app/application/outbox.py` owns the Outbox intent, repository and dispatcher
contracts. An Application command stages the business mutation and its durable
intent in the same transaction.
- `app/db/adapters/outbox.py` implements the persistence port with SQLAlchemy;
- `app/application/outbox.py` separates `OutboxStager`, which only stages in the
caller's business transaction, from `OutboxDispatchStore`, whose claim,
complete and retry operations own independent short transactions.
- `app/db/adapters/outbox.py` implements both roles with SQLAlchemy;
`app/startup/composition/subscription.py` and the other composition modules
provide the concrete repository, UoW and handlers.
- The dispatcher claims an intent with a lease, executes the topic handler, and
records retry/dead-letter state. Handlers must be idempotent and must not rely
on a live request object.
inject the stager, dispatch-store factory, UoW and handlers.
- Immediate request-thread delivery and the dispatcher both claim before
calling a handler. Lease acquisition is atomic, while complete/retry is fenced
by the claimed attempt so an expired owner cannot settle a newer claim.
- `PostCommitResult` distinguishes the committed business value from completed
and pending effects. This does not provide exactly-once delivery: a process may
stop after an external sink succeeds but before complete is persisted. Outbox
delivery is therefore at-least-once. Event payloads and the host correlation
context carry the stable event key, and consumers that support deduplication
should use it. Legacy notification plugins retain their existing method
signature, so the host must not claim provider-level exactly-once delivery.
- Terminal history is part of the shared data-maintenance policy and is cleaned
in bounded daily batches only when that policy is enabled. Completed intents
default to 30-day retention and dead letters to 90 days; both values are
@@ -546,6 +574,10 @@ expired claimed task remains exclusively owned by fenced recovery APIs.
- Startup registers concrete cache factories before decorated business modules
are imported. Cache contracts remain in `app/runtime/cache.py`; Redis/file
implementations remain in `app/adapters/cache/backends.py`.
- Security-sensitive one-shot state uses the strict `AtomicCacheBackend`
`store/consume` contract. Memory and Redis implement atomic consume; startup
injects that capability through `PasskeyChallengeCache`, so Passkey
Application code never identifies or imports the Redis implementation.
- `app/runtime/log.py` is a dependency leaf with no `app.*` imports. Foundation
emits no runtime logs; upper-layer owners decide whether failures are
operationally relevant.
@@ -573,6 +605,9 @@ expired claimed task remains exclusively owned by fenced recovery APIs.
may use `app.core`, `app.helper`, `app.utils` or `app.log`.
- New plugins use `app.sdk`. In DEBUG mode, a legacy plugin import remains
functional and emits one actionable warning per plugin and legacy module.
- Plugin compatibility changes belong only in curated SDK/Legacy exports or the
exact Compat manifest. `app/plugins/**` contains runtime plugin copies and is
excluded from host refactors, dependency baselines and ownership migrations.
- Delayed imports are not accepted as a way to hide dependency cycles.
### Dependency facts and semantic policy
@@ -677,8 +712,13 @@ driven workflow registration.
| `app/application/download/failures.py` | Frozen download-failure cooldown write/query DTOs and Chain persistence Port |
| `app/db/adapters/download.py` | Short-session download-failure snapshot and mutation adapter |
| `app/db/adapters/mediaserver.py` | Per-operation media-server cache query/upsert/cleanup transaction adapter |
| `app/application/outbox.py` | Durable intent, topic handler and Outbox repository contracts |
| `app/db/adapters/outbox.py` | SQLAlchemy Outbox persistence, claim/lease and retry state adapter |
| `app/application/history.py` | History use cases; frozen DownloadHistory DTOs and typed query/write ports, with TransferHistory migration still pending |
| `app/db/adapters/history/download.py` | DownloadHistory short-session snapshot, query and mutation adapter |
| `app/application/security/user.py` | Frozen user/auth projections and atomic user aggregate service contracts |
| `app/db/adapters/user.py` | User projection plus request-UoW mutation adapter |
| `app/db/adapters/configuration.py` | Commit-after UserConfig snapshot publication and fact-source reload adapter |
| `app/application/outbox.py` | Durable intent, stager/store, claim fencing, topic handler and structured post-commit contracts |
| `app/db/adapters/outbox.py` | SQLAlchemy Outbox transaction-only stagers and short-transaction claim/settlement stores |
| `app/application/chain/events.py` | Chain durable-event write port, settlement projection and replayable payload conversion |
| `app/application/transfer/workflow.py` | Transfer task, durable admission, versioned planning input/checkpoint contracts and queue use case |
| `app/db/adapters/transfer/admission.py` | SQLAlchemy admission/checkpoint persistence, CAS state transition and detached snapshot adapter |
@@ -691,6 +731,8 @@ driven workflow registration.
| `app/startup/initializers/` | Domain-scoped initialization and shutdown hooks |
| `app/chain/agent.py` | `AgentChain(ChainBase)`: the chain-layer entry for Agent sessions; Agent runtime stays in `app/agent/` |
| `app/runtime/config.py` | `ConfigModel`, `Settings` and deployment configuration |
| `app/runtime/cache.py` | Cache contracts and memory policy, including strict atomic store/consume for one-shot security state |
| `app/application/security/passkey.py` | Injected Passkey challenge cache port and one-shot challenge issue/consume policy |
| `app/runtime/tasks.py` | TaskRegistry owner, cancellation and bounded shutdown waiting |
| `app/runtime/execution.py` | Shared execution/thread-boundary helpers and context propagation |
| `app/runtime/correlation.py` | Correlation ID context and propagation boundary |
@@ -751,4 +793,4 @@ imports, entrypoint (`api`/`agent`/`monitor`/`workflow`/`doctor`) imports of
modules only through `run_module` dispatch), and downloader SDK
(`qbittorrentapi`, `transmission_rpc`) imports inside `app/chain`.
*Last Updated: 2026-08-27*
*Last Updated: 2026-08-28*
+52 -9
View File
@@ -130,18 +130,45 @@ adapters; it does not retain reusable repository implementations.
`AgentChatService.delete()`, and `DeletePluginDataCommand`: bind the repository
and UoW to one request/operation Session. Legacy plugin-facing Oper methods may
remain temporarily, but a new endpoint or startup workflow must call `stage_*`.
- User create/update/delete is an aggregate command owned by
`app/application/security/user.py`. It uses one request AsyncSession/UoW, locks
active superusers before destructive changes, and rejects removal of the last
enabled superuser. Query ports return frozen user/auth snapshots rather than
ORM rows.
- The database is the final user-identity guard: `user.name` is unique;
`UserConfig.username` cascades on user rename/delete; `PassKey.user_id`
cascades on user delete; `(UserConfig.username, UserConfig.key)` is unique and
non-null. A schema change to any of these constraints requires a replay-safe
migration that repairs legacy duplicates/orphans before creating constraints.
- DownloadHistory is projected into frozen DTOs within the adapter Session.
Its typed query/write port and delete mutation use short Session/UoW scopes.
TransferHistory has not completed the same migration and must be tracked
separately rather than treating the whole History area as typed.
### Durable post-commit side effects
Business mutations that must survive process interruption stage their durable
intent through `app/application/outbox.py` in the same Session/UoW as the
business row. `app/db/adapters/outbox.py` is the SQLAlchemy implementation;
startup composition supplies the repository, transaction scope and topic
handlers.
intent through `OutboxStager` in the same Session/UoW as the business row.
`OutboxDispatchStore` owns separate short transactions for claim, complete and
retry; a business Session must never call those self-committing operations.
`app/db/adapters/outbox.py` implements both roles, and startup composition
supplies the stager, store factory, transaction scope and topic handlers.
The dispatcher claims an intent with a lease, executes an idempotent handler,
and records bounded retries or dead-letter state. The shared data-maintenance
policy controls bounded terminal-history cleanup, with user-configurable 30-day
Immediate delivery and the dispatcher both claim before execution. Claim is
atomic and complete/retry is fenced by the claimed attempt, so an expired owner
cannot settle a newer lease. `PostCommitResult` separately reports the committed
business value plus completed and pending effects; a post-commit failure cannot
be represented as a rollback of already committed business data.
This boundary is at-least-once, not exactly-once. If an external sink succeeds
and the process stops before complete is persisted, the intent can be replayed.
Event payloads and the host correlation context therefore carry the stable
event key, and consumers that support deduplication should use it. Legacy
notification plugins retain their existing method signature and remain an
at-least-once boundary where duplicate provider delivery is possible. The
dispatcher records bounded retries or dead-letter state.
The shared data-maintenance policy controls bounded terminal-history cleanup,
with user-configurable 30-day
completed and 90-day dead-letter defaults; `0` disables either cleanup. It must
not delete pending or leased processing rows. The `app/runtime/tasks.py`
TaskRegistry is only the owner for in-process work and bounded shutdown waiting;
@@ -237,6 +264,15 @@ configuration.set(username="alice", key="notification_enabled", value=True)
The no-Session `UserConfigOper()` form is legacy plugin ABI only and must not be
copied into host code.
`TransactionalUserConfigurationRepository` stages a set in a short transaction
and publishes the process snapshot only after commit. User rename/delete is
first completed by the user aggregate transaction through database cascades;
post-commit publication then acquires the write lock and reloads the database
fact source so concurrent set/rename/delete operations converge on committed
state. If publication fails, the repository reloads that fact source instead of
rolling back or hiding the already committed user mutation. Reads and published
JSON values are copied so callers cannot mutate shared cache state.
---
## Settings / Environment Configuration
@@ -280,12 +316,19 @@ def get_movie_detail(tmdb_id: int) -> dict:
When `REDIS_HOST` is configured, `app/modules/redis/` provides a distributed cache backend. Prefer `FileCache` for single-node deployments.
Security-sensitive one-shot state uses `AtomicCacheBackend`, not a concrete
Redis helper. Its strict `store()` surfaces backend write failures and
`consume()` atomically returns-and-removes a value. Both Memory and Redis
backends implement this contract; Passkey receives the capability from startup
through `PasskeyChallengeCache`, so an authentication or registration challenge
can be accepted only once without Application knowing the configured backend.
---
## Data Lifecycle Rules
- **TransferHistory:** Records are inserted after every successful file transfer. Do not delete records without user confirmation.
- **DownloadHistory:** Records are inserted when a download task is added. Linked `DownloadFiles` records track individual files within a torrent.
- **DownloadHistory:** Records are inserted when a download task is added. Linked `DownloadFiles` records track individual files within a torrent. Host query/write callers use frozen DTOs and the typed DownloadHistory port; ORM rows remain inside the adapter Session.
- **SystemConfig:** Values may be read and written freely at runtime. Changes to watched config keys trigger `on_config_changed()` on registered classes via `ConfigReloadMixin`.
- **MediaServerItem:** This is a cache of the remote media server library. It is refreshed on media server sync events and can be safely cleared and rebuilt.
@@ -297,4 +340,4 @@ When `REDIS_HOST` is configured, `app/modules/redis/` provides a distributed cac
- `settings.API_TOKEN` and other secret fields must not be included in log output or API responses.
- The `config list --show-secrets` flag exists specifically to gate secret visibility in the CLI.
*Last Updated: 2026-08-27*
*Last Updated: 2026-08-28*
+2 -1
View File
@@ -11,7 +11,7 @@ from typing import Any, Literal, TypeAlias
_DEFAULT_IDENTITY = "<default>"
_DYNAMIC_IDENTITY = "<dynamic>"
_EVENT_MANAGER_METHODS = {"add_event_listener", "register"}
_PRODUCER_METHODS = {"async_send_event", "send_event"}
_PRODUCER_METHODS = {"async_send_event", "send_event", "send_event_strict"}
_COMPREHENSION_SCOPES = (
ast.ListComp,
ast.SetComp,
@@ -61,6 +61,7 @@ class _BoundEventMethod:
"add_event_listener",
"register",
"send_event",
"send_event_strict",
"async_send_event",
]
receiver_kind: str
+59 -49
View File
@@ -3,6 +3,7 @@
引导与网络守卫均复用 ``app/testing`` 的共享 harness与插件仓 conftest 同源
引导逻辑只在 ``app/testing`` 维护一处
"""
import asyncio
import sys
from collections.abc import Awaitable, Callable
@@ -124,8 +125,8 @@ def configure_plugin_system_services():
configure_user_configuration,
)
from app.application.service import configure_service_directory
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.userconfig import UserConfigOper
from app.db.session import (
SessionFactory,
async_session_scope,
@@ -159,10 +160,10 @@ def configure_plugin_system_services():
configure_token_runtime_config(lambda: build_token_runtime_config(settings))
database_executor = _TestDatabaseExecutor()
system_config = SystemConfigOper()
user_config = UserConfigOper()
user_config = TransactionalUserConfigurationRepository(SessionFactory)
with SessionFactory() as session:
system_config.load_snapshot(session)
user_config.load_snapshot(session)
user_config.load_snapshot()
configure_system_config(
SystemConfigService(
repository=system_config,
@@ -201,6 +202,7 @@ def configure_plugin_system_services():
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.extensions.service_config import ServiceConfigHelper
configure_service_directory(
configs=ServiceConfigHelper.get_configs,
modules=lambda module_type: ModuleManager().get_running_type_modules(module_type),
@@ -216,6 +218,7 @@ def configure_plugin_system_services():
configure_workflow_runtime,
)
from app.workflow import WorkflowManager
configure_workflow_runtime(lambda: WorkflowManager())
from app.application.agentdata import configure_agent_data_ports
from app.application.agenttask import (
@@ -320,42 +323,39 @@ def configure_plugin_system_services():
subscribe=lambda: SubscribeOper(),
download_history=lambda: DownloadHistoryOper(),
transfer_history=lambda: TransferHistoryOper(),
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
SessionFactory
),
transfer_execution=lambda: TransactionalTransferExecutionRepository(
SessionFactory
),
transfer_pending=lambda: TransactionalTransferAdmissionRepository(SessionFactory),
transfer_execution=lambda: TransactionalTransferExecutionRepository(SessionFactory),
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
),
download_failure=lambda: TransactionalDownloadFailureRepository(SessionFactory),
user=user_repository,
)
configure_chain_runtime_context_provider(lambda: ChainRuntimeContext(
module_manager=ModuleManager(),
plugin_manager=PluginManager(),
event_manager=EventManager(),
message_oper=MessageOper(),
message_helper=MessageHelper(),
file_cache=FileCache(),
async_file_cache=AsyncFileCache(),
message_queue_factory=lambda callback: MessageQueueManager(
send_callback=callback
),
module_dispatcher_factory=ModuleInvocationDispatcher,
configuration=build_chain_runtime_config(settings),
))
configure_chain_runtime_context_provider(
lambda: ChainRuntimeContext(
module_manager=ModuleManager(),
plugin_manager=PluginManager(),
event_manager=EventManager(),
message_oper=MessageOper(),
message_helper=MessageHelper(),
file_cache=FileCache(),
async_file_cache=AsyncFileCache(),
message_queue_factory=lambda callback: MessageQueueManager(send_callback=callback),
module_dispatcher_factory=ModuleInvocationDispatcher,
configuration=build_chain_runtime_config(settings),
)
)
configure_site_query_service(SiteQueryService(repository=site_repository()))
configure_site_health_service(SiteHealthService(repository=site_repository()))
configure_workflow_query(WorkflowQueryService(
repository=TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
configure_workflow_query(
WorkflowQueryService(
repository=TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
)
))
)
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.plugindata import PluginDataOper
configure_agent_data_ports(
agent_chat=lambda: AgentChatOper(),
agent_task=lambda: AgentTaskOper(),
@@ -367,11 +367,13 @@ def configure_plugin_system_services():
download_history=lambda: DownloadHistoryOper(),
plugin_data=lambda: PluginDataOper(),
)
configure_agent_task_execution(AgentTaskExecutionService(
repository=lambda session: AgentTaskOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
))
configure_agent_task_execution(
AgentTaskExecutionService(
repository=lambda session: AgentTaskOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
)
)
configure_agent_chat_persistence(
AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
@@ -395,18 +397,17 @@ def configure_plugin_system_services():
)
helper = PluginHelper()
configure_plugin_system(PluginSystemServices(
market=PluginMarketClient(helper),
package=PluginPackageManager(helper),
dependency=PluginDependencyInstaller(helper),
dependency_manifest_status=dependency_manifest_status,
compatible_flags=lambda flag: (
[flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, [])
if flag else []
),
frozen=lambda: False,
install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"),
))
configure_plugin_system(
PluginSystemServices(
market=PluginMarketClient(helper),
package=PluginPackageManager(helper),
dependency=PluginDependencyInstaller(helper),
dependency_manifest_status=dependency_manifest_status,
compatible_flags=lambda flag: [flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) if flag else [],
frozen=lambda: False,
install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"),
)
)
from app.agent.llm.gateway import register_llm_provider_runtime
from app.agent.llm.provider import LLMProviderManager
from app.agent.skills.registry import SkillHelper
@@ -484,7 +485,16 @@ class DbHarness:
except Exception: # noqa: BLE001 会话已不可用时也要继续尝试清理
pass
for model, mark in self._watermarks.items():
from app.db.base import Base
table_order = {table: index for index, table in enumerate(Base.metadata.sorted_tables)}
models = sorted(
self._watermarks,
key=lambda model: table_order.get(model.__table__, -1),
reverse=True,
)
for model in models:
mark = self._watermarks[model]
try:
self.session.execute(delete(model).where(model.id > mark))
self.session.commit()
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10195,
"percent": 79.02,
"statements": 12902
"covered_lines": 10432,
"percent": 79.39,
"statements": 13141
},
"domain": {
"covered_lines": 3392,
+49 -25
View File
@@ -14,9 +14,9 @@
"workflow_to_db": []
},
"direct_adapter_imports": {
"count": 28,
"count": 27,
"counts_by_source_root": {
"app.application": 15,
"app.application": 14,
"app.chain": 13
},
"edges": [
@@ -68,10 +68,6 @@
"source": "app.application.security.cookie",
"target": "app.adapters.network.http"
},
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis"
},
{
"source": "app.application.torrent",
"target": "app.adapters.network.http"
@@ -143,7 +139,7 @@
],
"target_root": "app.adapters"
},
"source_count": 18,
"source_count": 17,
"sources": [
"app.application.backup",
"app.application.directory",
@@ -152,7 +148,6 @@
"app.application.rss",
"app.application.rules",
"app.application.security.cookie",
"app.application.security.passkey",
"app.application.torrent",
"app.application.transfer.workflow",
"app.chain._recognition",
@@ -164,9 +159,8 @@
"app.chain.subscribe",
"app.chain.system"
],
"target_count": 11,
"target_count": 10,
"targets": [
"app.adapters.cache.redis",
"app.adapters.external.cookiecloud",
"app.adapters.external.ocr",
"app.adapters.external.server",
@@ -197,7 +191,7 @@
"from:redis.asyncio.Redis as Redis",
"import:redis as redis"
],
"fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7",
"fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81",
"kind": "network_sdk",
"source": "app.adapters.cache.redis",
"target": "redis",
@@ -226,6 +220,7 @@
"RedisHelper.clear|call:pipeline",
"RedisHelper.clear|call:scan_iter",
"RedisHelper.close|call:close",
"RedisHelper.consume|call:getdel",
"RedisHelper.delete|call:delete",
"RedisHelper.exists|call:exists",
"RedisHelper.get|call:get",
@@ -233,10 +228,9 @@
"RedisHelper.items|call:get",
"RedisHelper.items|call:scan_iter",
"RedisHelper.items|call:scan_iter",
"RedisHelper.pop|call:getdel",
"RedisHelper.set_memory_limit|call:config_set",
"RedisHelper.set_memory_limit|call:config_set",
"RedisHelper.set|call:set"
"RedisHelper.store|call:set"
]
},
{
@@ -1441,8 +1435,8 @@
"runtime_only": true
}
},
"edge_count": 6979,
"edge_sha256": "0fbef3f16d1475a40988a9fedbeb9a9ff67f49d0e3cd8033280b40a399a92d51",
"edge_count": 7006,
"edge_sha256": "74169f74212541ac2c1f990578f2e1d67f084944c08f1894c5fc1bff1d9016bd",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2360,11 +2354,13 @@
"app.agent.tools.impl.query_download_tasks -> app.agent.tools.tags",
"app.agent.tools.impl.query_download_tasks -> app.application",
"app.agent.tools.impl.query_download_tasks -> app.application.agentdata",
"app.agent.tools.impl.query_download_tasks -> app.application.history",
"app.agent.tools.impl.query_download_tasks -> app.chain",
"app.agent.tools.impl.query_download_tasks -> app.chain.download",
"app.agent.tools.impl.query_download_tasks -> app.runtime",
"app.agent.tools.impl.query_download_tasks -> app.runtime.log",
"app.agent.tools.impl.query_download_tasks -> app.schemas",
"app.agent.tools.impl.query_download_tasks -> app.schemas.common",
"app.agent.tools.impl.query_download_tasks -> app.schemas.transfer",
"app.agent.tools.impl.query_download_tasks -> app.schemas.types",
"app.agent.tools.impl.query_downloaders -> app.agent",
@@ -3006,6 +3002,7 @@
"app.api.dependencies.auth -> app.application.security.auth",
"app.api.dependencies.auth -> app.application.security.passkey",
"app.api.dependencies.auth -> app.application.security.user",
"app.api.dependencies.auth -> app.application.security.userconfig",
"app.api.dependencies.auth -> app.schemas",
"app.api.dependencies.auth -> app.schemas.token",
"app.api.dependencies.auth -> app.startup",
@@ -3958,6 +3955,7 @@
"app.api.servcookie -> app.schemas",
"app.api.servcookie -> app.schemas.servcookie",
"app.application.agentdata -> app.application",
"app.application.agentdata -> app.application.history",
"app.application.agentdata -> app.application.security",
"app.application.agentdata -> app.application.security.user",
"app.application.agenttask -> app.application",
@@ -3989,6 +3987,7 @@
"app.application.chain.data -> app.application",
"app.application.chain.data -> app.application.download",
"app.application.chain.data -> app.application.download.failures",
"app.application.chain.data -> app.application.history",
"app.application.chain.data -> app.application.mediaserver",
"app.application.chain.data -> app.application.security",
"app.application.chain.data -> app.application.security.user",
@@ -4035,6 +4034,8 @@
"app.application.download.selection -> app.schemas",
"app.application.download.selection -> app.schemas.media",
"app.application.download.selection -> app.schemas.mediaserver",
"app.application.download.tasks -> app.application",
"app.application.download.tasks -> app.application.history",
"app.application.download.tasks -> app.schemas",
"app.application.download.tasks -> app.schemas.transfer",
"app.application.download.tasks -> app.schemas.types",
@@ -4067,6 +4068,7 @@
"app.application.history -> app.runtime.cache",
"app.application.history -> app.runtime.log",
"app.application.history -> app.schemas",
"app.application.history -> app.schemas.common",
"app.application.history -> app.schemas.history",
"app.application.history -> app.schemas.media",
"app.application.history -> app.schemas.transfer",
@@ -4328,13 +4330,9 @@
"app.application.security.cookie -> app.foundation.url",
"app.application.security.cookie -> app.runtime",
"app.application.security.cookie -> app.runtime.log",
"app.application.security.passkey -> app.adapters",
"app.application.security.passkey -> app.adapters.cache",
"app.application.security.passkey -> app.adapters.cache.redis",
"app.application.security.passkey -> app.application",
"app.application.security.passkey -> app.application.configuration",
"app.application.security.passkey -> app.runtime",
"app.application.security.passkey -> app.runtime.cache",
"app.application.security.passkey -> app.runtime.log",
"app.application.security.token -> app.application",
"app.application.security.token -> app.application.configuration",
@@ -4351,6 +4349,9 @@
"app.application.security.url -> app.runtime.log",
"app.application.security.userconfig -> app.application",
"app.application.security.userconfig -> app.application.database",
"app.application.security.userconfig -> app.schemas",
"app.application.security.userconfig -> app.schemas.common",
"app.application.security.userconfig -> app.schemas.types",
"app.application.servarr -> app.schemas",
"app.application.servarr -> app.schemas.types",
"app.application.server.report -> app.schemas",
@@ -4467,6 +4468,7 @@
"app.application.transfer.workflow -> app.adapters.system",
"app.application.transfer.workflow -> app.adapters.system.host",
"app.application.transfer.workflow -> app.application",
"app.application.transfer.workflow -> app.application.history",
"app.application.transfer.workflow -> app.application.transfer",
"app.application.transfer.workflow -> app.application.transfer.execution",
"app.application.transfer.workflow -> app.domain",
@@ -4482,7 +4484,6 @@
"app.application.transfer.workflow -> app.schemas",
"app.application.transfer.workflow -> app.schemas.context",
"app.application.transfer.workflow -> app.schemas.file",
"app.application.transfer.workflow -> app.schemas.history",
"app.application.transfer.workflow -> app.schemas.media",
"app.application.transfer.workflow -> app.schemas.music",
"app.application.transfer.workflow -> app.schemas.system",
@@ -4534,6 +4535,7 @@
"app.chain._messaging -> app.foundation",
"app.chain._messaging -> app.foundation.identity",
"app.chain._messaging -> app.runtime",
"app.chain._messaging -> app.runtime.correlation",
"app.chain._messaging -> app.runtime.log",
"app.chain._messaging -> app.schemas",
"app.chain._messaging -> app.schemas.message",
@@ -4612,7 +4614,6 @@
"app.chain._transfer -> app.runtime.log",
"app.chain._transfer -> app.runtime.tasks",
"app.chain._transfer -> app.schemas",
"app.chain._transfer -> app.schemas.history",
"app.chain._transfer -> app.schemas.message",
"app.chain._transfer -> app.schemas.tmdb",
"app.chain._transfer -> app.schemas.transfer",
@@ -4655,6 +4656,7 @@
"app.chain.download -> app.application.download.failures",
"app.chain.download -> app.application.download.selection",
"app.chain.download -> app.application.download.tasks",
"app.chain.download -> app.application.history",
"app.chain.download -> app.application.torrent",
"app.chain.download -> app.chain",
"app.chain.download -> app.chain.media",
@@ -5148,6 +5150,13 @@
"app.db.adapters.chain -> app.db.oper.transferpending",
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.uow",
"app.db.adapters.configuration -> app.db",
"app.db.adapters.configuration -> app.db.oper",
"app.db.adapters.configuration -> app.db.oper.userconfig",
"app.db.adapters.configuration -> app.db.uow",
"app.db.adapters.configuration -> app.schemas",
"app.db.adapters.configuration -> app.schemas.common",
"app.db.adapters.configuration -> app.schemas.types",
"app.db.adapters.download -> app.application",
"app.db.adapters.download -> app.application.download",
"app.db.adapters.download -> app.application.download.failures",
@@ -5155,6 +5164,15 @@
"app.db.adapters.download -> app.db.oper",
"app.db.adapters.download -> app.db.oper.downloadfailure",
"app.db.adapters.download -> app.db.uow",
"app.db.adapters.history.download -> app.application",
"app.db.adapters.history.download -> app.application.history",
"app.db.adapters.history.download -> app.db",
"app.db.adapters.history.download -> app.db.oper",
"app.db.adapters.history.download -> app.db.oper.downloadhistory",
"app.db.adapters.history.download -> app.db.uow",
"app.db.adapters.history.download -> app.schemas",
"app.db.adapters.history.download -> app.schemas.media",
"app.db.adapters.history.download -> app.schemas.types",
"app.db.adapters.mediaserver -> app.application",
"app.db.adapters.mediaserver -> app.application.mediaserver",
"app.db.adapters.mediaserver -> app.db",
@@ -5203,6 +5221,7 @@
"app.db.adapters.site -> app.db.oper.site",
"app.db.adapters.site -> app.db.uow",
"app.db.adapters.subscription -> app.application",
"app.db.adapters.subscription -> app.application.outbox",
"app.db.adapters.subscription -> app.application.subscription",
"app.db.adapters.subscription -> app.application.subscription.write",
"app.db.adapters.subscription -> app.db",
@@ -5490,6 +5509,7 @@
"app.db.oper.userconfig -> app.foundation",
"app.db.oper.userconfig -> app.foundation.singleton",
"app.db.oper.userconfig -> app.schemas",
"app.db.oper.userconfig -> app.schemas.common",
"app.db.oper.userconfig -> app.schemas.types",
"app.db.oper.workflow -> app.db",
"app.db.oper.workflow -> app.db.base",
@@ -8053,7 +8073,10 @@
"app.startup.initializers.modules -> app.db",
"app.startup.initializers.modules -> app.db.adapters",
"app.startup.initializers.modules -> app.db.adapters.chain",
"app.startup.initializers.modules -> app.db.adapters.configuration",
"app.startup.initializers.modules -> app.db.adapters.download",
"app.startup.initializers.modules -> app.db.adapters.history",
"app.startup.initializers.modules -> app.db.adapters.history.download",
"app.startup.initializers.modules -> app.db.adapters.mediaserver",
"app.startup.initializers.modules -> app.db.adapters.outbox",
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
@@ -8070,7 +8093,6 @@
"app.startup.initializers.modules -> app.db.oper",
"app.startup.initializers.modules -> app.db.oper.agentchat",
"app.startup.initializers.modules -> app.db.oper.agenttask",
"app.startup.initializers.modules -> app.db.oper.downloadhistory",
"app.startup.initializers.modules -> app.db.oper.mediaserver",
"app.startup.initializers.modules -> app.db.oper.message",
"app.startup.initializers.modules -> app.db.oper.passkey",
@@ -8080,7 +8102,6 @@
"app.startup.initializers.modules -> app.db.oper.subscribehistory",
"app.startup.initializers.modules -> app.db.oper.systemconfig",
"app.startup.initializers.modules -> app.db.oper.transferhistory",
"app.startup.initializers.modules -> app.db.oper.userconfig",
"app.startup.initializers.modules -> app.db.oper.workflow",
"app.startup.initializers.modules -> app.db.session",
"app.startup.initializers.modules -> app.db.uow",
@@ -8239,10 +8260,10 @@
"app.testing.bootstrap -> app.application.service",
"app.testing.bootstrap -> app.db",
"app.testing.bootstrap -> app.db.adapters",
"app.testing.bootstrap -> app.db.adapters.configuration",
"app.testing.bootstrap -> app.db.adapters.transaction",
"app.testing.bootstrap -> app.db.oper",
"app.testing.bootstrap -> app.db.oper.systemconfig",
"app.testing.bootstrap -> app.db.oper.userconfig",
"app.testing.bootstrap -> app.db.session",
"app.testing.bootstrap -> app.db.uow",
"app.testing.bootstrap -> app.startup",
@@ -8424,7 +8445,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 853,
"module_count": 856,
"modules": [
"app",
"app.adapters",
@@ -8818,7 +8839,10 @@
"app.db",
"app.db.adapters",
"app.db.adapters.chain",
"app.db.adapters.configuration",
"app.db.adapters.download",
"app.db.adapters.history",
"app.db.adapters.history.download",
"app.db.adapters.mediaserver",
"app.db.adapters.outbox",
"app.db.adapters.pluginidentity",
+1 -6
View File
@@ -131,11 +131,6 @@
"target": "app.adapters.network.http",
"tracking": "S2-L6"
},
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
"tracking": "S2-L4"
},
{
"source": "app.application.torrent",
"target": "app.adapters.network.http",
@@ -291,7 +286,7 @@
"owner": "$source",
"reason": "精确 Adapter source 持有其 Redis、浏览器、HTTP 或 DNS transport;上层只能消费 Port 或统一 Facade。",
"facts": [
{"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7"},
{"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81"},
{"source": "app.adapters.network.browser", "target": "cloakbrowser", "kind": "network_sdk", "fingerprint": "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173"},
{"source": "app.adapters.network.doh", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71"},
{"source": "app.adapters.network.doh", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b"},
+7 -21
View File
@@ -495,9 +495,8 @@
},
"app/agent/tools/impl/query_download_tasks.py": {
"arg-type": 6,
"assignment": 1,
"misc": 1,
"no-any-return": 2,
"no-any-return": 1,
"no-untyped-def": 2
},
"app/agent/tools/impl/query_downloaders.py": {
@@ -1128,10 +1127,6 @@
"type-arg": 1,
"union-attr": 2
},
"app/application/download/tasks.py": {
"assignment": 1,
"type-arg": 1
},
"app/application/downloader.py": {
"arg-type": 1,
"no-untyped-def": 1
@@ -1271,9 +1266,6 @@
"app/application/security/otp.py": {
"no-any-return": 3
},
"app/application/security/passkey.py": {
"no-untyped-call": 1
},
"app/application/security/token.py": {
"no-any-return": 3,
"operator": 1
@@ -1319,7 +1311,7 @@
"type-arg": 3
},
"app/application/subscription/complete.py": {
"arg-type": 3
"arg-type": 2
},
"app/application/subscription/contract.py": {
"arg-type": 2,
@@ -1391,7 +1383,7 @@
"arg-type": 9,
"assignment": 4,
"attr-defined": 55,
"no-any-return": 4,
"no-any-return": 3,
"no-untyped-call": 1,
"no-untyped-def": 8,
"return-value": 2,
@@ -1748,9 +1740,6 @@
"no-untyped-def": 2,
"type-arg": 4
},
"app/db/oper/userconfig.py": {
"no-untyped-def": 4
},
"app/db/oper/workflow.py": {
"arg-type": 1,
"no-any-return": 6,
@@ -3364,9 +3353,6 @@
"app/schemas/workflow.py": {
"misc": 19
},
"app/sdk/_legacy/transfer.py": {
"no-untyped-def": 1
},
"app/sdk/string.py": {
"attr-defined": 1,
"no-untyped-def": 2,
@@ -3394,13 +3380,13 @@
"no-untyped-def": 2
},
"app/startup/initializers/modules.py": {
"arg-type": 16,
"arg-type": 13,
"assignment": 1,
"attr-defined": 2,
"misc": 1,
"no-any-return": 2,
"no-untyped-call": 33,
"no-untyped-def": 12,
"no-untyped-call": 32,
"no-untyped-def": 7,
"return-value": 5
},
"app/startup/initializers/plugins.py": {
@@ -3431,7 +3417,7 @@
"type-arg": 1
},
"app/testing/bootstrap.py": {
"no-untyped-call": 3,
"no-untyped-call": 2,
"no-untyped-def": 3,
"type-arg": 6
},
+1 -69
View File
@@ -164,9 +164,6 @@
"app/agent/tools/impl/query_custom_filter_rules.py": {
"I001": 1
},
"app/agent/tools/impl/query_download_tasks.py": {
"I001": 1
},
"app/agent/tools/impl/query_installed_plugins.py": {
"I001": 1
},
@@ -300,9 +297,6 @@
"app/application/formatting.py": {
"I001": 1
},
"app/application/history.py": {
"I001": 1
},
"app/application/image.py": {
"I001": 1
},
@@ -327,9 +321,6 @@
"app/application/notification.py": {
"I001": 1
},
"app/application/outbox.py": {
"I001": 1
},
"app/application/plugin/runtime.py": {
"I001": 1
},
@@ -345,22 +336,10 @@
"app/application/storage.py": {
"I001": 1
},
"app/application/subscription/delete.py": {
"I001": 1
},
"app/application/subscription/identity.py": {
"I001": 1
},
"app/application/subscription/mutation.py": {
"I001": 1
},
"app/application/subscription/priority.py": {
"F401": 1,
"I001": 1
},
"app/application/subscription/write.py": {
"I001": 1
},
"app/application/torrent.py": {
"F541": 3,
"I001": 1
@@ -380,9 +359,6 @@
"app/db/__init__.py": {
"I001": 1
},
"app/db/adapters/outbox.py": {
"I001": 1
},
"app/db/adapters/transaction.py": {
"I001": 1
},
@@ -392,9 +368,6 @@
"app/db/diagnostics.py": {
"I001": 1
},
"app/db/engine.py": {
"I001": 1
},
"app/db/models/__init__.py": {
"I001": 1
},
@@ -410,12 +383,6 @@
"app/db/models/message.py": {
"I001": 1
},
"app/db/models/outbox.py": {
"I001": 1
},
"app/db/models/passkey.py": {
"I001": 1
},
"app/db/models/plugindata.py": {
"I001": 1
},
@@ -440,12 +407,6 @@
"app/db/models/systemconfig.py": {
"I001": 1
},
"app/db/models/user.py": {
"I001": 1
},
"app/db/models/userconfig.py": {
"I001": 1
},
"app/db/models/workflow.py": {
"I001": 1
},
@@ -461,9 +422,6 @@
"app/db/oper/user.py": {
"I001": 1
},
"app/db/oper/userconfig.py": {
"I001": 1
},
"app/db/session.py": {
"I001": 1
},
@@ -889,8 +847,7 @@
"I001": 1
},
"app/runtime/cache.py": {
"E731": 2,
"I001": 1
"E731": 2
},
"app/runtime/capabilities/__init__.py": {
"I001": 1
@@ -910,9 +867,6 @@
"app/runtime/dependencies.py": {
"I001": 1
},
"app/runtime/event/dispatch.py": {
"I001": 1
},
"app/runtime/event/errors.py": {
"I001": 1
},
@@ -998,9 +952,6 @@
"app/sdk/_legacy/user.py": {
"I001": 1
},
"app/sdk/cache.py": {
"I001": 1
},
"app/sdk/config.py": {
"I001": 1
},
@@ -1176,9 +1127,6 @@
"tests/test_builtin_skill_boundaries.py": {
"I001": 1
},
"tests/test_cache_system.py": {
"I001": 1
},
"tests/test_capability_registry.py": {
"I001": 1
},
@@ -1198,9 +1146,6 @@
"tests/test_coalesce.py": {
"I001": 1
},
"tests/test_configuration_initializer.py": {
"I001": 1
},
"tests/test_dashboard_system_info.py": {
"I001": 1
},
@@ -1295,10 +1240,6 @@
"tests/test_feedback_issue_scripts.py": {
"I001": 1
},
"tests/test_feishu.py": {
"E402": 5,
"I001": 1
},
"tests/test_feishu_media_message.py": {
"E402": 3
},
@@ -1419,9 +1360,6 @@
"tests/test_metamusic.py": {
"I001": 1
},
"tests/test_mfa_passkey_registration_errors.py": {
"I001": 1
},
"tests/test_module_manager_capability_adapter.py": {
"I001": 1
},
@@ -1495,9 +1433,6 @@
"tests/test_observability.py": {
"I001": 1
},
"tests/test_passkey_challenge.py": {
"I001": 1
},
"tests/test_password_hashing.py": {
"I001": 1
},
@@ -1618,9 +1553,6 @@
"tests/test_string_compat.py": {
"I001": 1
},
"tests/test_subscribe_delete_command.py": {
"I001": 1
},
"tests/test_subscribe_oper.py": {
"F401": 1
},
+90 -85
View File
@@ -1621,14 +1621,14 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"29f02d6f5d4a82f6e2d304b899ee9d652dc5d3a1f375e45da2a7c7ba72fad828",
"fb5095b18017555a9da2ee5f5442814a0c783e61d99cec42c7f464c5a25a0968"
"e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b"
]
},
"EventType.AudioTransferFailed": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"9e26ea89da42c77926126f7440c0365e24a63d4eb10280b270d29bf767bd47cf",
"c32e40027e195e6b664a7d5d287554804c65d04187dfbf5d922a7d832efd9ef0"
"c32e40027e195e6b664a7d5d287554804c65d04187dfbf5d922a7d832efd9ef0",
"e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b"
]
},
"EventType.CommandExcute": {
@@ -1659,7 +1659,7 @@
"EventType.DownloadAdded": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"090d218ce64d85213d4d0c51eac059a36365f0edf6976acb9d4588e921d63587",
"912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d",
"f2e4ec70164d8ccb46a0f032c57c6c0452bee9ee55a2aa69e4f770e7d2c855b3",
"f2e4ec70164d8ccb46a0f032c57c6c0452bee9ee55a2aa69e4f770e7d2c855b3"
]
@@ -1764,16 +1764,16 @@
"EventType.SubscribeAdded": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c",
"6dd6b59761660ab65659ea4e14c06108aa850c8d62fabf50391632ba2e74d8fe",
"79efb9919712de5265f3e9f8aafbdf53eede9a42c9c5d8bedb402ee6df9fab68",
"cdc72f0211e4bd640c76419d7e0266b95a542f84eeeabc45654e1a66c6718688"
]
},
"EventType.SubscribeComplete": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"8d8703c6323a227c4dfe4234e7d3056abb815e284464122faeccca87b050f3aa",
"a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c"
"a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c",
"ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b"
]
},
"EventType.SubscribeDeleted": {
@@ -1781,7 +1781,7 @@
"producer_fingerprints": [
"49cb2961dd38ea10d2a53c7208fb3ca66ecda4c7c2f8cb2c29ea3ac62629a512",
"7ee0ba0bfdba520a389d45c33d361120a0d710c3ea37b1e03532dd7364a52d47",
"b8610de798a3412aec42fde23dae3662f29464f3e1bea538f3e18156fcd65841",
"a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b",
"cbff1d6d00bcade626974178f292471c2bb7643ceb9360a0ad9fa23b87c3597d"
]
},
@@ -1789,17 +1789,17 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"330678cc36e39079b052e5a6f1493cd38cf40565ddf8b231dc20c94207cb1ba9",
"3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b",
"4204fef37b0e1b87ff665209b1085359b778096827bed514cf61c6488ede3ee3",
"4fc5c37849498747d5d61944b47381612c88d9b6320fda9ab48e3c1fa2444232",
"89165b03827d9801a260dd54dd03e77f903795b8872bd043a79e8283181c786f",
"8d7d49151bd35a4eb2fdc8d82f514484d7f04146718129d3f429ab847d38f6d8",
"ca81edd9f904843fdaaec15c0b774861c055742cdb0a27215ad43b0acfeaa73b"
]
},
"EventType.SubtitleTransferComplete": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"10a4bd31fc73023e9abf0fc63c64bb43a1dfec208c546b603f687462a2cda27a",
"814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976",
"9839276934bc6e557b3ec07a8a3f1d4b372f4dc792eb6198650e940140231083"
]
},
@@ -1807,7 +1807,7 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"1c2a6b622bf96b2c46e0831c124e24f2810bbbd8b8f0ffd259a7e6912ec207b6",
"70effe3d0dbcb65ef333864d678cd7cc01f89849cdb2cb3e358acbc751548451"
"f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d"
]
},
"EventType.SystemError": {
@@ -1823,13 +1823,13 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"063a823d8634ee118279717c8a9ed35eaae48a0533f05d472180572ce4f50096",
"e00dae2c489550d72bfd2962b0ab5bdfa3992cddf012fd6827bab9a77fa17f22"
"f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307"
]
},
"EventType.TransferFailed": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"67272ed4bd81ea85c2e276e04b413f6d33665d260e4ff64aa900beb7fba8e071",
"be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c",
"ec3818c033105e65936b400e72d6832cef6d6a4bec84286ed9cab6596b09ac70"
]
},
@@ -2856,64 +2856,16 @@
"qualname": "_publish_modified",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.DownloadAdded"
],
"fingerprint": "090d218ce64d85213d4d0c51eac059a36365f0edf6976acb9d4588e921d63587",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubtitleTransferComplete"
],
"fingerprint": "10a4bd31fc73023e9abf0fc63c64bb43a1dfec208c546b603f687462a2cda27a",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.TransferFailed"
],
"fingerprint": "67272ed4bd81ea85c2e276e04b413f6d33665d260e4ff64aa900beb7fba8e071",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubtitleTransferFailed"
],
"fingerprint": "70effe3d0dbcb65ef333864d678cd7cc01f89849cdb2cb3e358acbc751548451",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubscribeAdded"
],
"fingerprint": "79efb9919712de5265f3e9f8aafbdf53eede9a42c9c5d8bedb402ee6df9fab68",
"fingerprint": "1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -2922,34 +2874,34 @@
"events": [
"EventType.SubscribeModified"
],
"fingerprint": "8d7d49151bd35a4eb2fdc8d82f514484d7f04146718129d3f429ab847d38f6d8",
"fingerprint": "3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubscribeComplete"
"EventType.SubtitleTransferComplete"
],
"fingerprint": "8d8703c6323a227c4dfe4234e7d3056abb815e284464122faeccca87b050f3aa",
"fingerprint": "814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.AudioTransferFailed"
"EventType.DownloadAdded"
],
"fingerprint": "9e26ea89da42c77926126f7440c0365e24a63d4eb10280b270d29bf767bd47cf",
"fingerprint": "912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -2958,22 +2910,34 @@
"events": [
"EventType.SubscribeDeleted"
],
"fingerprint": "b8610de798a3412aec42fde23dae3662f29464f3e1bea538f3e18156fcd65841",
"fingerprint": "a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.TransferComplete"
"EventType.TransferFailed"
],
"fingerprint": "e00dae2c489550d72bfd2962b0ab5bdfa3992cddf012fd6827bab9a77fa17f22",
"fingerprint": "be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.AudioTransferFailed"
],
"fingerprint": "e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -2982,10 +2946,46 @@
"events": [
"EventType.AudioTransferComplete"
],
"fingerprint": "fb5095b18017555a9da2ee5f5442814a0c783e61d99cec42c7f464c5a25a0968",
"fingerprint": "e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubscribeComplete"
],
"fingerprint": "ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubtitleTransferFailed"
],
"fingerprint": "f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.TransferComplete"
],
"fingerprint": "f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -9748,6 +9748,11 @@
"name": "AsyncRedisBackend",
"target": "app.adapters.cache.backends.AsyncRedisBackend"
},
{
"kind": "import",
"name": "AtomicCacheBackend",
"target": "app.runtime.cache.AtomicCacheBackend"
},
{
"kind": "import",
"name": "Cache",
+125 -125
View File
@@ -1,41 +1,41 @@
{
"schema_version": 2,
"generated_at": "2026-08-27T23:28:48.301843+00:00",
"generated_at": "2026-08-28T02:17:28.865452+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
"python": "3.14.3",
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 394,
"max_ms": 1298.973,
"median_ms": 999.845,
"min_ms": 994.938,
"loaded_app_module_count": 398,
"max_ms": 1026.146,
"median_ms": 992.33,
"min_ms": 991.09,
"samples_ms": [
1298.973,
999.845,
994.938
1026.146,
992.33,
991.09
]
},
"app.factory": {
"loaded_app_module_count": 406,
"max_ms": 1079.626,
"median_ms": 1019.979,
"min_ms": 1014.804,
"loaded_app_module_count": 410,
"max_ms": 1065.169,
"median_ms": 1013.553,
"min_ms": 1013.18,
"samples_ms": [
1019.979,
1014.804,
1079.626
1065.169,
1013.553,
1013.18
]
},
"app.main": {
"loaded_app_module_count": 408,
"max_ms": 1075.788,
"median_ms": 1060.558,
"min_ms": 1057.105,
"loaded_app_module_count": 412,
"max_ms": 1069.112,
"median_ms": 1062.81,
"min_ms": 1062.635,
"samples_ms": [
1075.788,
1060.558,
1057.105
1069.112,
1062.81,
1062.635
]
}
},
@@ -48,85 +48,85 @@
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.674,
"full_lifespan_ms": 1.563,
"full_lifespan_ms": 1.508,
"stage_ms": {
"后台任务登记器": 0.095,
"数据库准备": 0.036,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.026,
"路由": 0.023,
"模块服务": 0.026,
"插件备份恢复": 0.022,
"插件": 0.021,
"定时器": 0.028,
"监控器": 0.02,
"待处理整理回放": 0.023,
"命令服务": 0.022,
"工作流": 0.025,
"插件同步与启动收尾": 0.02
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.654,
"full_lifespan_ms": 1.521,
"stage_ms": {
"后台任务登记器": 0.081,
"数据库准备": 0.039,
"HTTP 基础能力": 0.031,
"数据库准备": 0.037,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.024,
"路由": 0.026,
"模块服务": 0.026,
"插件备份恢复": 0.024,
"插件": 0.025,
"定时器": 0.026,
"监控器": 0.023,
"待处理整理回放": 0.021,
"命令服务": 0.026,
"工作流": 0.023,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.662,
"full_lifespan_ms": 1.526,
"stage_ms": {
"后台任务登记器": 0.073,
"数据库准备": 0.036,
"HTTP 基础能力": 0.034,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.025,
"路由": 0.025,
"模块服务": 0.024,
"插件备份恢复": 0.027,
"插件": 0.024,
"定时器": 0.025,
"监控器": 0.024,
"待处理整理回放": 0.023,
"命令服务": 0.023,
"工作流": 0.023,
"插件同步与启动收尾": 0.023
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.651,
"full_lifespan_ms": 1.495,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.038,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.024,
"路由": 0.026,
"模块服务": 0.025,
"插件备份恢复": 0.023,
"插件": 0.024,
"定时器": 0.022,
"监控器": 0.023,
"待处理整理回放": 0.021,
"命令服务": 0.024,
"工作流": 0.021,
"插件同步与启动收尾": 0.024
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.696,
"full_lifespan_ms": 1.687,
"stage_ms": {
"后台任务登记器": 0.086,
"数据库准备": 0.039,
"HTTP 基础能力": 0.032,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.026,
"路由": 0.026,
"模块服务": 0.026,
"插件备份恢复": 0.022,
"插件": 0.025,
"定时器": 0.026,
"监控器": 0.024,
"定时器": 0.023,
"监控器": 0.025,
"待处理整理回放": 0.024,
"命令服务": 0.021,
"工作流": 0.022,
"命令服务": 0.024,
"工作流": 0.024,
"插件同步与启动收尾": 0.024
},
"threads_before": 2,
@@ -138,8 +138,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.662,
"median_full_lifespan_ms": 1.526,
"median_startup_ms": 0.674,
"median_full_lifespan_ms": 1.521,
"enabled_component_count": 25,
"enabled_components": [
"后台任务登记器",
@@ -174,18 +174,18 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.485,
"full_lifespan_ms": 0.915,
"startup_ms": 0.502,
"full_lifespan_ms": 0.905,
"stage_ms": {
"后台任务登记器": 0.08,
"数据库准备": 0.038,
"后台任务登记器": 0.081,
"数据库准备": 0.044,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.031,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.023,
"路由": 0.023,
"领域依赖装配": 0.03,
"数据库引擎预热": 0.027,
"数据库连接预算": 0.027,
"路由": 0.026,
"模块服务": 0.025,
"插件同步与启动收尾": 0.022
"插件同步与启动收尾": 0.023
},
"threads_before": 2,
"threads_started": 2,
@@ -198,18 +198,18 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.548,
"full_lifespan_ms": 0.942,
"startup_ms": 0.499,
"full_lifespan_ms": 0.921,
"stage_ms": {
"后台任务登记器": 0.091,
"数据库准备": 0.04,
"HTTP 基础能力": 0.042,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.023,
"数据库连接预算": 0.029,
"路由": 0.024,
"模块服务": 0.022,
"插件同步与启动收尾": 0.021
"后台任务登记器": 0.081,
"数据库准备": 0.039,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.026,
"路由": 0.026,
"模块服务": 0.025,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
@@ -222,18 +222,18 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.496,
"full_lifespan_ms": 0.923,
"startup_ms": 0.507,
"full_lifespan_ms": 0.924,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.035,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.026,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.024,
"数据库准备": 0.037,
"HTTP 基础能力": 0.032,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.025,
"路由": 0.024,
"模块服务": 0.024,
"插件同步与启动收尾": 0.026
"模块服务": 0.025,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
@@ -244,8 +244,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.496,
"median_full_lifespan_ms": 0.923,
"median_startup_ms": 0.502,
"median_full_lifespan_ms": 0.921,
"enabled_component_count": 13,
"enabled_components": [
"后台任务登记器",
+2 -2
View File
@@ -7,10 +7,10 @@ import pytest
from app.application.messaging.agent import matches_channel_admin, resolve_config_principal_ids
from app.modules.discord import DiscordModule
from app.modules.feishu.feishu import Feishu
from app.modules.qqbot import QQBotModule
from app.modules.qqbot.module import QQBotModule
from app.modules.slack import SlackModule
from app.modules.synologychat import SynologyChatModule
from app.modules.telegram import TelegramModule
from app.modules.telegram.module import TelegramModule
from app.modules.vocechat import VoceChatModule
from app.modules.wechat import WechatModule
from app.modules.wechat.wechatbot import WeChatBot
+7 -9
View File
@@ -35,7 +35,6 @@ FROZEN_DIRECT_ADAPTER_IMPORTS = {
("app.application.security.cookie", "app.adapters.external.ocr"): "S2-L6",
("app.application.security.cookie", "app.adapters.network.browser"): "S2-L6",
("app.application.security.cookie", "app.adapters.network.http"): "S2-L6",
("app.application.security.passkey", "app.adapters.cache.redis"): "S2-L4",
("app.application.torrent", "app.adapters.network.http"): "S2-L6",
("app.application.transfer.workflow", "app.adapters.system.host"): "S2-L6",
("app.chain._recognition", "app.adapters.external.server"): "S2-L7",
@@ -261,7 +260,6 @@ def test_current_direct_adapter_imports_match_temporary_debt_policy() -> None:
assert _adapter_policy_scope_errors(adapter_policy["scope"], contract["scope"]) == []
assert entries == sorted(entries, key=lambda item: (item["source"], item["target"]))
assert Counter(FROZEN_DIRECT_ADAPTER_IMPORTS.values()) == {
"S2-L4": 1,
"S2-L5": 1,
"S2-L6": 13,
"S2-L7": 13,
@@ -323,9 +321,9 @@ def test_adapter_policy_rejects_add_remove_and_replacement() -> None:
def test_adapter_policy_rejects_manual_policy_bypasses() -> None:
"""手工 policy 也不能接纳新边、错 owner、重复项或越界范围。"""
valid = {
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
"tracking": "S2-L4",
"source": "app.application.backup",
"target": "app.adapters.system.backup.files",
"tracking": "S2-L5",
}
invalid_entries = [
{
@@ -336,18 +334,18 @@ def test_adapter_policy_rejects_manual_policy_bypasses() -> None:
{
"source": valid["source"],
"target": "app.adapters.network.browser",
"tracking": "S2-L4",
"tracking": "S2-L5",
},
{**valid, "tracking": "S2-L5"},
{**valid, "tracking": "S2-L6"},
{
"source": "app.api.sample",
"target": valid["target"],
"tracking": "S2-L4",
"tracking": "S2-L5",
},
{
"source": valid["source"],
"target": "app.db.adapters.subscription",
"tracking": "S2-L4",
"tracking": "S2-L5",
},
{
"source": "app.application.*",
+188
View File
@@ -491,6 +491,160 @@ def test_download_failure_and_mediaserver_ports_are_typed_and_detached():
assert "TransactionalMediaServerRepository(SessionFactory)" in startup_source
def test_download_history_ports_are_typed_detached_and_canonically_injected():
"""下载历史宿主调用面只能消费冻结快照和显式事务 adapter。"""
history_path = APP_ROOT / "application" / "history.py"
history_tree = ast.parse(
history_path.read_text(encoding="utf-8"),
filename=str(history_path),
)
classes = {
node.name: node
for node in history_tree.body
if isinstance(node, ast.ClassDef)
}
for class_name in (
"DownloadHistorySnapshot",
"DownloadFileSnapshot",
"DownloadHistoryWrite",
"DownloadFileWrite",
):
decorator = next(
item
for item in classes[class_name].decorator_list
if isinstance(item, ast.Call)
and isinstance(item.func, ast.Name)
and item.func.id == "dataclass"
)
keywords = {
item.arg: ast.literal_eval(item.value)
for item in decorator.keywords
}
assert keywords == {"frozen": True, "slots": True}
for class_name in ("DownloadHistoryQueryPort", "DownloadHistoryWritePort"):
annotations = [
ast.unparse(node.returns)
for node in classes[class_name].body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.returns is not None
]
assert annotations
assert all("Any" not in annotation for annotation in annotations)
data_path = APP_ROOT / "application" / "chain" / "data.py"
data_tree = ast.parse(data_path.read_text(encoding="utf-8"), filename=str(data_path))
data_class = next(
node
for node in data_tree.body
if isinstance(node, ast.ClassDef) and node.name == "ChainDataPorts"
)
annotations = {
node.target.id: ast.unparse(node.annotation)
for node in data_class.body
if isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
}
returns = {
node.name: ast.unparse(node.returns)
for node in ast.walk(data_tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.returns is not None
}
agent_tree = ast.parse(
(APP_ROOT / "application" / "agentdata.py").read_text(encoding="utf-8")
)
agent_return = next(
ast.unparse(node.returns)
for node in ast.walk(agent_tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "get_agent_download_history_port"
and node.returns is not None
)
assert annotations["download_history"] == "DownloadHistoryRepositoryFactory"
assert returns["get_chain_download_history_port"] == "DownloadHistoryRepository"
assert agent_return == "DownloadHistoryRepository"
consumer_paths = (
APP_ROOT / "chain" / "_transfer.py",
APP_ROOT / "chain" / "download.py",
APP_ROOT / "chain" / "transfer.py",
APP_ROOT / "agent" / "tools" / "impl" / "query_download_tasks.py",
APP_ROOT / "agent" / "tools" / "impl" / "delete_download_history.py",
APP_ROOT / "application" / "transfer" / "workflow.py",
)
for path in consumer_paths:
source = path.read_text(encoding="utf-8")
assert "app.db.oper.downloadhistory" not in source
assert "DownloadHistory = Any" not in source
assert "DownloadFiles = Any" not in source
startup_source = (
APP_ROOT / "startup" / "initializers" / "modules.py"
).read_text(encoding="utf-8")
assert "TransactionalDownloadHistoryRepository(" in startup_source
assert "SessionDownloadHistoryRepository" in startup_source
assert "DownloadHistoryOper" not in startup_source
adapter_source = (
APP_ROOT / "db" / "adapters" / "history" / "download.py"
).read_text(encoding="utf-8")
assert "class TransactionalDownloadHistoryRepository" in adapter_source
assert "class SessionDownloadHistoryRepository" in adapter_source
assert "_project_history" in adapter_source
assert "SqlAlchemyUnitOfWork" in adapter_source
legacy_source = (
APP_ROOT / "sdk" / "_legacy" / "transfer.py"
).read_text(encoding="utf-8")
assert "download_history: Optional[Any]" in legacy_source
def test_user_configuration_uses_typed_transactional_adapter():
"""用户配置宿主入口只消费类型化端口,旧 Oper 写入口仅承担兼容 ABI。"""
application_path = APP_ROOT / "application" / "security" / "userconfig.py"
application_source = application_path.read_text(encoding="utf-8")
application_tree = ast.parse(application_source, filename=str(application_path))
repository = next(
node
for node in application_tree.body
if isinstance(node, ast.ClassDef)
and node.name == "UserConfigurationRepository"
)
returns = {
node.name: ast.unparse(node.returns)
for node in repository.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.returns is not None
}
assert returns == {
"get": "JsonData",
"set": "None",
"publish_rename": "None",
"publish_delete": "None",
}
assert "Any" not in application_source
startup_source = (
APP_ROOT / "startup" / "initializers" / "modules.py"
).read_text(encoding="utf-8")
assert "TransactionalUserConfigurationRepository(SessionFactory)" in startup_source
assert "UserConfigOper" not in startup_source
adapter_path = APP_ROOT / "db" / "adapters" / "configuration.py"
adapter_source = adapter_path.read_text(encoding="utf-8")
assert "class TransactionalUserConfigurationRepository" in adapter_source
assert "SqlAlchemyUnitOfWork" in adapter_source
assert "Any" not in adapter_source
oper_source = (
APP_ROOT / "db" / "oper" / "userconfig.py"
).read_text(encoding="utf-8")
assert "def stage_set(" in oper_source
assert "def set(" in oper_source
assert "Any" not in oper_source
def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports():
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
@@ -1467,6 +1621,40 @@ def test_cache_contract_does_not_import_concrete_adapters():
} == set()
def test_passkey_application_does_not_select_cache_backend():
"""PassKey 用例只消费原子缓存端口,不得识别 Redis 或后端类型。"""
modules = _discover_modules()
path = modules["app.application.security.passkey"]
dependencies = _resolve_imports(
"app.application.security.passkey",
path,
set(modules),
)
source = path.read_text(encoding="utf-8-sig")
assert {
dependency
for dependency in dependencies
if dependency.startswith("app.adapters.cache")
} == set()
assert "RedisHelper" not in source
assert ".is_redis(" not in source
def test_startup_explicitly_configures_passkey_challenge_cache():
"""PassKey challenge 缓存必须由启动组合根显式装配。"""
path = APP_ROOT / "startup" / "initializers" / "modules.py"
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
configured = any(
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "configure_passkey_challenge_cache"
for node in ast.walk(tree)
)
assert configured is True
def test_resource_adapter_does_not_restart_process():
"""资源下载安装适配器不得反向调用进程重启能力。"""
modules = _discover_modules()
+1 -1
View File
@@ -110,7 +110,7 @@ FROZEN_EGRESS_REASON_BY_EDGE = {
for edge in edges
}
FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
("app.adapters.cache.redis", "redis"): "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7",
("app.adapters.cache.redis", "redis"): "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81",
("app.adapters.external.market", "httpx2"): "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f",
("app.adapters.external.market", "requests"): "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3",
("app.adapters.network.browser", "cloakbrowser"): "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173",
+9 -1
View File
@@ -699,6 +699,8 @@ import app.schemas.types as schema_types
receiver = eventmanager
emit = receiver.send_event
emit(EventType.Alpha)
strict_emit = receiver.send_event_strict
strict_emit(EventType.Beta)
EventManager().send_event(EventType.Gamma)
EventManager.get_existing_instance().send_event(ChainEventType.Delta)
@@ -710,7 +712,7 @@ async def publish():
)
assert facts["consumers"] == []
assert len(facts["producers"]) == 4
assert len(facts["producers"]) == 5
assert {
(
fact["qualname"],
@@ -721,6 +723,12 @@ async def publish():
for fact in facts["producers"]
} == {
("<module>", "send_event", "canonical_singleton", ("EventType.Alpha",)),
(
"<module>",
"send_event_strict",
"canonical_singleton",
("EventType.Beta",),
),
("<module>", "send_event", "constructed_manager", ("EventType.Gamma",)),
("<module>", "send_event", "existing_manager", ("ChainEventType.Delta",)),
("publish", "async_send_event", "canonical_singleton", ("EventType.Beta",)),
+86
View File
@@ -0,0 +1,86 @@
"""插件认证一次性票据的生命周期测试。"""
from concurrent.futures import ThreadPoolExecutor
import pytest
from app.application.security import auth
from app.application.security.auth import AuthTicketStore
@pytest.fixture(autouse=True)
def clear_auth_tickets():
"""隔离单例票据缓存,避免测试间共享认证事实。"""
store = AuthTicketStore()
with store._lock:
store._tickets.clear()
yield
with store._lock:
store._tickets.clear()
def test_auth_ticket_can_only_be_consumed_once():
"""成功领取后立即删除票据,后续兑换不得重复获得认证事实。"""
store = AuthTicketStore()
ticket = store.create(user_id=1, provider_id="plugin:test")
assert store.consume(ticket)["user_id"] == 1
assert store.consume(ticket) is None
def test_auth_ticket_concurrent_consumers_have_single_winner():
"""多个并发兑换请求只能有一个成功领取同一票据。"""
store = AuthTicketStore()
ticket = store.create(user_id=1, provider_id="plugin:test")
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(lambda _: store.consume(ticket), range(8)))
assert sum(result is not None for result in results) == 1
def test_auth_ticket_ttl_boundary_and_expiration(monkeypatch):
"""票据在 TTL 边界内有效,超过边界后即使首次领取也必须失败。"""
now = [1_000.0]
monkeypatch.setattr(auth.time, "time", lambda: now[0])
store = AuthTicketStore()
boundary_ticket = store.create(user_id=1, provider_id="plugin:test")
now[0] += store._ttl_seconds
assert store.consume(boundary_ticket) is not None
expired_ticket = store.create(user_id=1, provider_id="plugin:test")
now[0] += store._ttl_seconds + 0.001
assert store.consume(expired_ticket) is None
assert store.consume(expired_ticket) is None
def test_auth_ticket_metadata_is_detached_from_callers():
"""签发和领取两侧都不能通过可变对象改写缓存中的认证元数据。"""
store = AuthTicketStore()
metadata = {"groups": ["users"]}
ticket = store.create(
user_id=1,
provider_id="plugin:test",
metadata=metadata,
)
metadata["groups"].append("admins")
consumed = store.consume(ticket)
assert consumed["metadata"] == {"groups": ["users"]}
def test_auth_ticket_capacity_is_a_hard_limit(monkeypatch):
"""连续签发也不得让票据表永久超过容量上限。"""
monkeypatch.setattr(AuthTicketStore, "_max_items", 4)
store = AuthTicketStore()
tickets = [
store.create(user_id=index, provider_id="plugin:test")
for index in range(6)
]
assert len(store._tickets) == 4
assert store.consume(tickets[0]) is None
assert store.consume(tickets[-1]) is not None
+110 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -13,15 +14,17 @@ from app.adapters.cache.backends import (
FileBackend,
RedisBackend,
)
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper, serialize
from app.runtime.cache import (
AsyncFileCache,
AsyncMemoryBackend,
FileCache,
MemoryBackend,
TTLCache,
cached,
)
from app.runtime.config import settings
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper, serialize
def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
"""
@@ -720,6 +723,112 @@ def test_redis_helper_pop_uses_atomic_getdel():
assert calls == ["region:passkey_challenge:key:token"]
def test_redis_helper_strict_consume_propagates_backend_failure():
"""严格领取必须区分 Redis 故障与键不存在,旧 pop 仍保持兼容返回值。"""
class FailingClient:
"""模拟 GETDEL 连接故障。"""
def getdel(self, _key):
"""报告 Redis 命令失败。"""
raise ConnectionError("redis unavailable")
helper = RedisHelper()
helper.client = FailingClient()
try:
with pytest.raises(ConnectionError, match="redis unavailable"):
helper.consume("token", region="passkey_challenge")
assert helper.pop("token", region="passkey_challenge") is None
finally:
helper.client = None
def test_redis_helper_strict_store_requires_backend_acknowledgement():
"""严格写入不得把 Redis 未确认写入当成成功签发。"""
class RejectingClient:
"""模拟 Redis 拒绝确认 SET。"""
def set(self, *_args, **_kwargs):
"""返回未写入状态。"""
return False
helper = RedisHelper()
helper.client = RejectingClient()
try:
with pytest.raises(RuntimeError, match="not acknowledged"):
helper.store("token", "challenge", region="passkey_challenge")
helper.set("token", "challenge", region="passkey_challenge")
finally:
helper.client = None
def test_memory_atomic_cache_consume_has_single_winner_and_honors_ttl():
"""内存原子缓存与 Redis 一样只允许一次领取并服从 TTL。"""
cache = MemoryBackend(ttl=60)
region = "atomic_memory_contract"
cache.clear(region=region)
cache.store("token", "challenge", region=region)
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(
lambda _: cache.consume("token", region=region),
range(8),
))
assert results.count("challenge") == 1
assert results.count(None) == 7
cache.store("expired", "challenge", ttl=0, region=region)
assert cache.consume("expired", region=region) is None
def test_ttl_cache_legacy_pop_uses_atomic_consume_contract():
"""插件既有 TTLCache.pop 入口保持可用并获得原子领取语义。"""
cache = TTLCache(region="legacy_atomic_pop", maxsize=8, ttl=60)
cache.clear()
cache.set("token", "challenge")
def pop_token(_index):
"""兼容入口不存在时返回空值,便于汇总并发结果。"""
try:
return cache.pop("token")
except KeyError:
return None
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(pop_token, range(8)))
assert results.count("challenge") == 1
assert results.count(None) == 7
def test_redis_backend_strict_store_and_consume_use_atomic_helper():
"""Redis 适配器将严格缓存契约完整委托给底层原子实现。"""
calls = []
class FakeHelper:
"""记录严格 Redis 缓存调用。"""
def store(self, key, value, ttl=None, region=None, **kwargs):
"""记录严格写入。"""
calls.append(("store", key, value, ttl, region, kwargs))
def consume(self, key, region=None):
"""记录原子领取。"""
calls.append(("consume", key, region))
return "challenge"
backend = RedisBackend(ttl=60)
backend.redis_helper = FakeHelper()
backend.store("token", "challenge", region="passkey_challenge")
assert backend.consume("token", region="passkey_challenge") == "challenge"
assert calls == [
("store", "token", "challenge", 60, "passkey_challenge", {}),
("consume", "token", "passkey_challenge"),
]
def test_async_redis_helper_uses_blocking_pool_settings(monkeypatch):
"""
Redis 异步客户端应使用阻塞连接池避免高并发缓存读取立刻抛出连接耗尽错误
+28 -24
View File
@@ -20,7 +20,11 @@ from app.application.chain.events import (
snapshot_transfer_result,
transfer_result_event_key,
)
from app.application.history import TransferHistoryMutationCommand
from app.application.history import (
DownloadFileWrite,
DownloadHistoryWrite,
TransferHistoryMutationCommand,
)
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionConflictError,
@@ -250,22 +254,22 @@ def test_download_history_and_event_intent_share_one_transaction():
calls = []
writer.download_added(
history_payload={
"path": "/downloads/Demo.mkv",
"type": MediaType.MOVIE.value,
"title": "Demo",
"download_hash": "hash-2",
},
file_payloads=[
{
"download_hash": "hash-2",
"downloader": "qb",
"fullpath": "/downloads/Demo.mkv",
"savepath": "/downloads",
"filepath": "Demo.mkv",
"torrentname": "Demo torrent",
}
],
history=DownloadHistoryWrite(
path="/downloads/Demo.mkv",
type=MediaType.MOVIE.value,
title="Demo",
download_hash="hash-2",
),
files=(
DownloadFileWrite(
download_hash="hash-2",
downloader="qb",
fullpath="/downloads/Demo.mkv",
savepath="/downloads",
filepath="Demo.mkv",
torrentname="Demo torrent",
),
),
event_payload={
"hash": "hash-2",
"context": context,
@@ -292,13 +296,13 @@ def test_download_history_and_event_intent_share_one_transaction():
]
writer.download_added(
history_payload={
"path": "/downloads/duplicate.mkv",
"type": MediaType.MOVIE.value,
"title": "Duplicate",
"download_hash": "hash-2",
},
file_payloads=[],
history=DownloadHistoryWrite(
path="/downloads/duplicate.mkv",
type=MediaType.MOVIE.value,
title="Duplicate",
download_hash="hash-2",
),
files=(),
event_payload={
"hash": "hash-2",
"context": context,
+11 -3
View File
@@ -4,9 +4,9 @@ from unittest.mock import AsyncMock
import pytest
from app.application.configuration import configure_runtime_settings
from app.startup.initializers import modules as modules_initializer
from app.startup.lifecycle import initialize_modules_component
from app.application.configuration import configure_runtime_settings
class _InlineWorker:
@@ -74,7 +74,11 @@ async def test_configuration_services_publish_after_both_snapshots_load(
events.append("load-user")
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
monkeypatch.setattr(
modules_initializer,
"TransactionalUserConfigurationRepository",
lambda _session_factory: _UserConfig(),
)
monkeypatch.setattr(
modules_initializer,
"configure_system_config",
@@ -112,7 +116,11 @@ async def test_configuration_load_failure_does_not_publish_partial_service(
raise RuntimeError("load failed")
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
monkeypatch.setattr(
modules_initializer,
"TransactionalUserConfigurationRepository",
lambda _session_factory: _UserConfig(),
)
monkeypatch.setattr(
modules_initializer,
"configure_system_config",
+2 -2
View File
@@ -117,8 +117,8 @@ def test_user_configuration_service_supports_sync_and_async_writes() -> None:
async_executor=_InlineDatabaseExecutor(),
)
assert service.set("alice", "theme", "dark") is True
assert asyncio.run(service.async_set("alice", "theme", "light")) is True
assert service.set("alice", "theme", "dark") is None
assert asyncio.run(service.async_set("alice", "theme", "light")) is None
assert repository.set.call_args_list == [
((), {"username": "alice", "key": "theme", "value": "dark"}),
((), {"username": "alice", "key": "theme", "value": "light"}),
+17 -17
View File
@@ -5,11 +5,13 @@ ORM 基类通用增删改查的行为。
任何一个出偏差都会同时影响所有表同步方法与其异步孪生方法必须给出相同结果
否则同一张表经 API异步与经调度任务同步会看到不同的数据
"""
import asyncio
import pytest
from app.db.models.systemconfig import SystemConfig
from app.db.models.user import User
from app.db.models.userconfig import UserConfig
from app.db.uow import run_async_transaction
@@ -30,9 +32,7 @@ def test_create_persists_and_get_reads_back(db):
assert row.id is not None
assert SystemConfig.get(db.session, row.id).key == "base-create"
assert db.run_async_session(
lambda session: SystemConfig.async_get(session, rid=row.id)
).key == "base-create"
assert db.run_async_session(lambda session: SystemConfig.async_get(session, rid=row.id)).key == "base-create"
def test_get_returns_none_for_missing_id(db):
@@ -40,9 +40,7 @@ def test_get_returns_none_for_missing_id(db):
主键不存在时返回 None而不是抛异常或返回任意一行
"""
assert SystemConfig.get(db.session, -1) is None
assert db.run_async_session(
lambda session: SystemConfig.async_get(session, rid=-1)
) is None
assert db.run_async_session(lambda session: SystemConfig.async_get(session, rid=-1)) is None
def test_async_create_flushes_and_assigns_primary_key(db):
@@ -51,11 +49,11 @@ def test_async_create_flushes_and_assigns_primary_key(db):
异步路径的调用方常常紧接着用 id 建立关联拿到 None 会让关联静默丢失
"""
created = asyncio.run(run_async_transaction(
lambda session: SystemConfig(
key="base-async-create", value={"n": 2}
).async_create(session)
))
created = asyncio.run(
run_async_transaction(
lambda session: SystemConfig(key="base-async-create", value={"n": 2}).async_create(session)
)
)
assert created.id is not None
assert SystemConfig.get(db.session, created.id).value == {"n": 2}
@@ -104,9 +102,7 @@ def test_async_delete_removes_only_the_given_row(db):
dropped = db.add(SystemConfig(key="base-async-del", value={"n": 1}))
kept = db.add(SystemConfig(key="base-async-keep", value={"n": 2}))
asyncio.run(run_async_transaction(
lambda session: SystemConfig.async_delete(session, rid=dropped.id)
))
asyncio.run(run_async_transaction(lambda session: SystemConfig.async_delete(session, rid=dropped.id)))
assert SystemConfig.get(db.session, dropped.id) is None
assert SystemConfig.get(db.session, kept.id) is not None
@@ -116,15 +112,14 @@ def test_async_delete_tolerates_missing_row(db):
"""
删除不存在的行不抛异常保持调用方的幂等语义
"""
asyncio.run(run_async_transaction(
lambda session: SystemConfig.async_delete(session, rid=-1)
))
asyncio.run(run_async_transaction(lambda session: SystemConfig.async_delete(session, rid=-1)))
def test_list_returns_every_row_of_that_model_only(db):
"""
列举必须限定在本模型对应的表不能跨表
"""
db.add(User(name="base-user"))
db.add(UserConfig(username="base-user", key="k", value="v"))
listed = UserConfig.list(db.session)
@@ -137,6 +132,7 @@ def test_async_list_matches_sync_list(db):
"""
同步与异步列举必须返回同一批主键
"""
db.add(User(name="base-list"))
db.add(UserConfig(username="base-list", key="k", value="v"))
sync_ids = sorted(item.id for item in UserConfig.list(db.session))
@@ -149,6 +145,10 @@ def test_truncate_empties_the_table(db):
"""
清表后该模型不再有任何行同步与异步实现须一致
"""
db.add(
User(name="base-truncate"),
User(name="base-truncate-async"),
)
db.add(UserConfig(username="base-truncate", key="k", value="v"))
UserConfig.truncate(db.session)
+171 -63
View File
@@ -5,6 +5,7 @@
一个能被日志发现的异常同步方法都有一个已是 2.0 写法的异步孪生方法这里对同一
批数据同时跑两条路径并要求结果一致同步侧改写后若有偏差这个断言会直接暴露
"""
import asyncio
import pytest
@@ -29,19 +30,17 @@ def _track(db):
# SystemConfig
# --------------------------------------------------------------------------- #
def test_systemconfig_get_by_key_matches_async_twin(db):
"""
按键取配置的同步与异步结果必须一致且只命中同名键
"""
db.add(SystemConfig(key="mp-test-a", value={"n": 1}),
SystemConfig(key="mp-test-b", value={"n": 2}))
db.add(SystemConfig(key="mp-test-a", value={"n": 1}), SystemConfig(key="mp-test-b", value={"n": 2}))
found = SystemConfig.get_by_key(db.session, "mp-test-a")
assert found.value == {"n": 1}
async_found = db.run_async_session(
lambda session: SystemConfig.async_get_by_key(session, "mp-test-a")
)
async_found = db.run_async_session(lambda session: SystemConfig.async_get_by_key(session, "mp-test-a"))
assert async_found.value == found.value
@@ -58,9 +57,7 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外同步事务")),
)
assert SystemConfig.get_by_key(db.session, "mp-explicit-config") is not None
@@ -70,14 +67,15 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_async_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外异步事务")),
)
assert (
await SystemConfig.async_get_by_key(
session,
"mp-explicit-config",
)
is not None
)
assert await SystemConfig.async_get_by_key(
session,
"mp-explicit-config",
) is not None
asyncio.run(check())
@@ -86,8 +84,7 @@ def test_systemconfig_delete_by_key_removes_only_that_key(db):
"""
按键删除只能删掉那一个键误删会静默丢失其他配置
"""
db.add(SystemConfig(key="mp-test-del", value={"n": 1}),
SystemConfig(key="mp-test-keep", value={"n": 2}))
db.add(SystemConfig(key="mp-test-del", value={"n": 1}), SystemConfig(key="mp-test-keep", value={"n": 2}))
assert SystemConfig().delete_by_key(db.session, "mp-test-del") is True
@@ -106,12 +103,15 @@ def test_systemconfig_delete_by_key_tolerates_missing_key(db):
# UserConfig
# --------------------------------------------------------------------------- #
def test_userconfig_get_by_key_scopes_by_username(db):
"""
用户配置必须同时按用户名和键命中只按键会把别人的配置读给当前用户
"""
db.add(UserConfig(username="alice", key="theme", value="dark"),
UserConfig(username="bob", key="theme", value="light"))
db.add(User(name="alice"), User(name="bob"))
db.add(
UserConfig(username="alice", key="theme", value="dark"), UserConfig(username="bob", key="theme", value="light")
)
assert UserConfig.get_by_key(db.session, username="alice", key="theme").value == "dark"
assert UserConfig.get_by_key(db.session, username="bob", key="theme").value == "light"
@@ -122,8 +122,10 @@ def test_userconfig_delete_by_key_removes_only_that_user(db):
"""
删除某用户的配置不能波及同名键的其他用户
"""
db.add(UserConfig(username="alice", key="theme", value="dark"),
UserConfig(username="bob", key="theme", value="light"))
db.add(User(name="alice"), User(name="bob"))
db.add(
UserConfig(username="alice", key="theme", value="dark"), UserConfig(username="bob", key="theme", value="light")
)
assert UserConfig().delete_by_key(db.session, username="alice", key="theme") is True
@@ -142,25 +144,21 @@ def test_userconfig_delete_by_key_tolerates_missing_row(db):
# User
# --------------------------------------------------------------------------- #
def test_user_lookup_by_name_and_id_matches_async_twin(db):
"""
按名与按 ID 取用户的同步异步结果必须指向同一行
登录链路走同步API 依赖注入走异步两者不一致会表现为能登录但查不到自己
"""
created = db.add(User(name="mp-test-user", email="u@example.com",
hashed_password="x", is_active=True))
created = db.add(User(name="mp-test-user", email="u@example.com", hashed_password="x", is_active=True))
by_name = User.get_by_name(db.session, "mp-test-user")
by_id = User.get_by_id(db.session, created.id)
assert by_name.id == by_id.id == created.id
assert db.run_async_session(
lambda session: User.async_get_by_name(session, "mp-test-user")
).id == created.id
assert db.run_async_session(
lambda session: User.async_get_by_id(session, created.id)
).id == created.id
assert db.run_async_session(lambda session: User.async_get_by_name(session, "mp-test-user")).id == created.id
assert db.run_async_session(lambda session: User.async_get_by_id(session, created.id)).id == created.id
def test_user_lookup_returns_none_when_absent(db):
@@ -210,10 +208,8 @@ def test_user_async_mutations_match_sync_behaviour(db):
db.add(User(name="mp-test-async-otp", hashed_password="x", is_otp=False))
oper = UserOper()
assert asyncio.run(oper.async_update_otp_by_name(
name="mp-test-async-otp", otp=True, secret="S2")) is True
assert asyncio.run(oper.async_update_otp_by_name(
name="mp-test-nobody", otp=True, secret="S2")) is False
assert asyncio.run(oper.async_update_otp_by_name(name="mp-test-async-otp", otp=True, secret="S2")) is True
assert asyncio.run(oper.async_update_otp_by_name(name="mp-test-nobody", otp=True, secret="S2")) is False
assert asyncio.run(oper.async_delete_by_name(name="mp-test-async-otp")) is True
assert User.get_by_name(db.session, "mp-test-async-otp") is None
@@ -222,14 +218,50 @@ def test_user_async_mutations_match_sync_behaviour(db):
assert User.get_by_id(db.session, async_id_user.id) is None
def test_legacy_user_oper_delete_cascades_user_children(db):
"""旧 UserOper 删除入口保持可用,并由数据库清除配置和 PassKey。"""
user = db.add(User(name="legacy-delete", is_active=True))
user_id = user.id
username = user.name
db.add(
UserConfig(username=username, key="theme", value="dark"),
_passkey(user_id, "legacy-delete-credential"),
)
assert asyncio.run(UserOper().async_delete_by_name(username)) is True
db.session.expire_all()
assert User.get_by_id(db.session, user_id) is None
assert UserConfig.get_by_key(db.session, username, "theme") is None
assert PassKey.get_by_credential_id(db.session, "legacy-delete-credential") is None
# --------------------------------------------------------------------------- #
# PassKey
# --------------------------------------------------------------------------- #
def _passkey(user_id: int, credential_id: str, is_active: bool = True) -> PassKey:
"""构造一条 PassKey 记录。"""
return PassKey(user_id=user_id, credential_id=credential_id,
public_key="pk", sign_count=0, is_active=is_active)
return PassKey(user_id=user_id, credential_id=credential_id, public_key="pk", sign_count=0, is_active=is_active)
@pytest.fixture(autouse=True)
def _create_passkey_owners(request, db) -> None:
"""PassKey 查询用例必须使用受外键保护的真实用户主体。"""
if not request.node.name.startswith("test_passkey_"):
return
db.add(
*[
User(
id=user_id,
name=f"passkey-owner-{user_id}",
is_active=True,
is_superuser=False,
)
for user_id in range(9001, 9012)
]
)
def test_passkey_listing_excludes_inactive_credentials(db):
@@ -238,18 +270,19 @@ def test_passkey_listing_excludes_inactive_credentials(db):
停用的凭据仍能被列出意味着它还会出现在登录选项里等于停用没生效
"""
db.add(_passkey(9001, "cred-active-1"),
_passkey(9001, "cred-active-2"),
_passkey(9001, "cred-inactive", is_active=False),
_passkey(9002, "cred-other"))
db.add(
_passkey(9001, "cred-active-1"),
_passkey(9001, "cred-active-2"),
_passkey(9001, "cred-inactive", is_active=False),
_passkey(9002, "cred-other"),
)
listed = PassKey.get_by_user_id(db.session, 9001)
assert {p.credential_id for p in listed} == {"cred-active-1", "cred-active-2"}
assert {p.credential_id for p in db.run_async_session(
lambda session: PassKey.async_get_by_user_id(session, 9001)
)} == \
{"cred-active-1", "cred-active-2"}
assert {
p.credential_id for p in db.run_async_session(lambda session: PassKey.async_get_by_user_id(session, 9001))
} == {"cred-active-1", "cred-active-2"}
def test_passkey_oper_queries_use_explicit_session(db, monkeypatch):
@@ -279,9 +312,7 @@ def test_passkey_lookup_by_credential_id_skips_inactive(db):
assert PassKey.get_by_credential_id(db.session, "cred-live").user_id == 9003
assert PassKey.get_by_credential_id(db.session, "cred-dead") is None
assert db.run_async_session(
lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")
) is None
assert db.run_async_session(lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")) is None
def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
@@ -290,9 +321,7 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外同步事务")),
)
assert PassKey.get_by_id(db.session, key.id).credential_id == "cred-explicit"
@@ -302,18 +331,22 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_async_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外异步事务")),
)
assert [
item.credential_id
for item in await PassKey.async_get_by_user_id(
session,
9008,
)
] == ["cred-explicit"]
assert (
await PassKey.async_get_by_credential_id(
session,
"cred-explicit",
)
is not None
)
assert [item.credential_id for item in await PassKey.async_get_by_user_id(
session,
9008,
)] == ["cred-explicit"]
assert await PassKey.async_get_by_credential_id(
session,
"cred-explicit",
) is not None
assert await PassKey.async_get_by_id(session, key.id) is not None
asyncio.run(check())
@@ -326,9 +359,7 @@ def test_passkey_get_by_id_ignores_active_flag(db):
dead = db.add(_passkey(9004, "cred-admin", is_active=False))
assert PassKey.get_by_id(db.session, dead.id).credential_id == "cred-admin"
assert db.run_async_session(
lambda session: PassKey.async_get_by_id(session, dead.id)
).credential_id == "cred-admin"
assert db.run_async_session(lambda session: PassKey.async_get_by_id(session, dead.id)).credential_id == "cred-admin"
def test_passkey_delete_requires_matching_owner(db):
@@ -367,3 +398,80 @@ def test_passkey_update_last_used_persists_sign_count(db):
assert key.update_last_used(db.session, sign_count=42) is True
assert PassKey.get_by_id(db.session, key.id).sign_count == 42
def test_passkey_oper_sign_count_compare_and_swap_has_single_winner(db):
"""两个基于同一旧计数的认证提交只能有一个更新成功。"""
key = db.add(_passkey(9008, "cred-cas"))
key.sign_count = 41
db.session.flush()
oper = PassKeyOper(db.session)
assert (
oper.compare_and_update_sign_count(
passkey_id=key.id,
expected_sign_count=41,
sign_count=42,
)
is True
)
assert (
oper.compare_and_update_sign_count(
passkey_id=key.id,
expected_sign_count=41,
sign_count=43,
)
is False
)
db.session.expire_all()
assert PassKey.get_by_id(db.session, key.id).sign_count == 42
def test_passkey_oper_sign_count_cas_rejects_inactive_or_regressed_key(db):
"""停用凭证及未递增的非零计数都不得被认证提交覆盖。"""
inactive = db.add(_passkey(9009, "cred-cas-inactive", is_active=False))
active = db.add(_passkey(9010, "cred-cas-regressed"))
active.sign_count = 5
db.session.flush()
oper = PassKeyOper(db.session)
assert (
oper.compare_and_update_sign_count(
passkey_id=inactive.id,
expected_sign_count=0,
sign_count=1,
)
is False
)
assert (
oper.compare_and_update_sign_count(
passkey_id=active.id,
expected_sign_count=5,
sign_count=4,
)
is False
)
assert (
oper.compare_and_update_sign_count(
passkey_id=active.id,
expected_sign_count=5,
sign_count=5,
)
is False
)
def test_passkey_oper_sign_count_cas_allows_counterless_authenticator(db):
"""不支持签名计数器的认证器允许按 WebAuthn 约定保持零计数。"""
key = db.add(_passkey(9011, "cred-cas-counterless"))
oper = PassKeyOper(db.session)
assert (
oper.compare_and_update_sign_count(
passkey_id=key.id,
expected_sign_count=0,
sign_count=0,
)
is True
)
+109 -46
View File
@@ -5,6 +5,7 @@ Oper 层大多是模型方法的薄封装,但薄封装恰恰是最容易出错
默认值漏传聚合逻辑写在这一层这些都绕过了模型侧的测试这里对着真实数据库
验证 Oper 的对外契约而不是验证它调了哪个模型方法
"""
import asyncio
import importlib
from unittest.mock import Mock
@@ -48,14 +49,26 @@ def test_oper_with_explicit_session_does_not_commit_caller_transaction(db, monke
@pytest.fixture(autouse=True)
def _track(db):
"""把本文件涉及的表纳入用例级回收。"""
db.watermark(Site, SiteIcon, SiteStatistic, SiteUserData, PluginData, Workflow,
User, UserConfig, MediaServerItem, DownloadHistory, DownloadFiles)
db.watermark(
Site,
SiteIcon,
SiteStatistic,
SiteUserData,
PluginData,
Workflow,
User,
UserConfig,
MediaServerItem,
DownloadHistory,
DownloadFiles,
)
# --------------------------------------------------------------------------- #
# SiteOper
# --------------------------------------------------------------------------- #
def _site_kwargs(name: str, domain: str, **extra) -> dict:
"""构造新增站点的参数。"""
return dict(name=name, domain=domain, url=f"https://{domain}/", **extra)
@@ -184,8 +197,7 @@ def test_site_oper_userdata_readers(db):
assert any(r.domain == "op-read.test" for r in oper.get_userdata())
assert any(r.domain == "op-read.test" for r in oper.get_userdata_by_date(today))
assert any(r.domain == "op-read.test" for r in oper.get_userdata_latest())
assert [r.domain for r in asyncio.run(
oper.async_get_userdata_by_domain("op-read.test"))] == ["op-read.test"]
assert [r.domain for r in asyncio.run(oper.async_get_userdata_by_domain("op-read.test"))] == ["op-read.test"]
def test_site_oper_update_icon_creates_then_only_overwrites_with_content(db):
@@ -256,8 +268,7 @@ def test_site_oper_success_caps_the_timing_note_at_ten_entries(db):
只靠循环调用无法触及上限分支
"""
old_note = {f"2026-08-13 10:00:{index:02d}": index + 1 for index in range(10)}
db.add(SiteStatistic(domain="op-cap.test", success=10, fail=0, seconds=5,
lst_state=0, note=old_note))
db.add(SiteStatistic(domain="op-cap.test", success=10, fail=0, seconds=5, lst_state=0, note=old_note))
SiteOper(db=db.session).success("op-cap.test", seconds=99)
@@ -298,6 +309,7 @@ def test_site_oper_async_success_and_fail_match_sync(db):
# PluginDataOper
# --------------------------------------------------------------------------- #
def test_plugindata_oper_save_is_upsert(db):
"""
同一键重复保存走更新而不是新增
@@ -359,10 +371,20 @@ def test_plugindata_oper_async_accessors_match_sync(db):
# WorkflowOper
# --------------------------------------------------------------------------- #
def _workflow_kwargs(name: str, **extra) -> dict:
"""构造新增工作流的参数。"""
return dict(name=name, description=name, timer="0 * * * *", state="W",
actions=[], flows=[], context={}, execution_state={}, **extra)
return dict(
name=name,
description=name,
timer="0 * * * *",
state="W",
actions=[],
flows=[],
context={},
execution_state={},
**extra,
)
def test_workflow_oper_add_rejects_duplicate_name(db):
@@ -430,6 +452,7 @@ def test_workflow_oper_event_list_and_async_accessors(db):
# UserOper / UserConfigOper
# --------------------------------------------------------------------------- #
def test_user_oper_reads_permissions_and_settings(db):
"""
权限与个性化设置的读取在用户不存在时各有约定的空值
@@ -437,8 +460,7 @@ def test_user_oper_reads_permissions_and_settings(db):
权限返回 {} 而设置返回 None上层据此区分没有权限没有这个用户
"""
oper = UserOper(db=db.session)
oper.add(name="op-user", hashed_password="x",
permissions={"discovery": True}, settings={"theme": "dark"})
oper.add(name="op-user", hashed_password="x", permissions={"discovery": True}, settings={"theme": "dark"})
assert oper.get_by_name("op-user").name == "op-user"
assert oper.get_permissions("op-user") == {"discovery": True}
@@ -471,6 +493,7 @@ def test_userconfig_oper_set_get_and_delete_on_empty_value(db):
空值删除是恢复默认的实现方式退化成写入空串会让默认值再也拿不回来
"""
db.add(User(name="op-cfg-user", is_active=True))
oper = UserConfigOper()
oper.set("op-cfg-user", "theme", "dark")
@@ -486,6 +509,10 @@ def test_userconfig_oper_scopes_cache_by_username(db):
"""
内存缓存必须按用户名隔离且用户名为空时返回全量缓存
"""
db.add(
User(name="op-cfg-a", is_active=True),
User(name="op-cfg-b", is_active=True),
)
oper = UserConfigOper()
oper.set("op-cfg-a", "theme", "dark")
oper.set("op-cfg-b", "theme", "light")
@@ -501,10 +528,19 @@ def test_userconfig_oper_scopes_cache_by_username(db):
# MediaServerOper
# --------------------------------------------------------------------------- #
def _server_item(item_id: str, **extra) -> dict:
"""构造媒体服务器条目的写入参数。"""
payload = dict(server="emby", library="lib", item_id=item_id, item_type="电影",
title="片名", year="2026", media_source=TMDB, media_id="5001")
payload = dict(
server="emby",
library="lib",
item_id=item_id,
item_type="电影",
title="片名",
year="2026",
media_source=TMDB,
media_id="5001",
)
payload.update(extra)
return payload
@@ -554,17 +590,13 @@ def test_mediaserver_oper_exists_checks_season_presence(db):
季信息缺失却判为已入库会让整季订阅被跳过
"""
oper = MediaServerOper(db=db.session)
oper.add(**_server_item("ms-season", media_id="5200", item_type="电视剧",
seasoninfo={"1": [1, 2]}))
oper.add(**_server_item("ms-season", media_id="5200", item_type="电视剧", seasoninfo={"1": [1, 2]}))
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧",
season="1") is not None
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧",
season="2") is None
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧", season="1") is not None
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧", season="2") is None
oper.add(**_server_item("ms-noseason", media_id="5300", item_type="电视剧"))
assert oper.exists(media_source=TMDB, media_id="5300", mtype="电视剧",
season="1") is None
assert oper.exists(media_source=TMDB, media_id="5300", mtype="电视剧", season="1") is None
def test_mediaserver_oper_get_item_id_and_async_twins(db):
@@ -577,8 +609,7 @@ def test_mediaserver_oper_get_item_id_and_async_twins(db):
assert oper.get_item_id(media_source=TMDB, media_id="5400", mtype="电影") == "ms-id"
assert oper.get_item_id(media_source=TMDB, media_id="5999", mtype="电影") is None
assert asyncio.run(oper.async_get_item_id(
media_source=TMDB, media_id="5400", mtype="电影")) == "ms-id"
assert asyncio.run(oper.async_get_item_id(media_source=TMDB, media_id="5400", mtype="电影")) == "ms-id"
assert asyncio.run(oper.async_exists(title="片名", mtype="电影", year="2026")) is not None
@@ -602,6 +633,7 @@ def test_mediaserver_oper_cleanup_entry_points(db):
# DownloadHistoryOper
# --------------------------------------------------------------------------- #
def test_downloadhistory_oper_get_by_hashes_returns_a_mapping(db):
"""
批量查询返回hash -> 历史映射供上层直接按 hash 取用
@@ -609,10 +641,8 @@ def test_downloadhistory_oper_get_by_hashes_returns_a_mapping(db):
上层拿到列表还要自己配对正是 N+1 的温床这里的契约是映射
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/a", type=MediaType.TV.value, title="A",
download_hash="oh-a", date="2026-08-13 10:00:00")
oper.add(path="/downloads/b", type=MediaType.TV.value, title="B",
download_hash="oh-b", date="2026-08-13 10:00:00")
oper.add(path="/downloads/a", type=MediaType.TV.value, title="A", download_hash="oh-a", date="2026-08-13 10:00:00")
oper.add(path="/downloads/b", type=MediaType.TV.value, title="B", download_hash="oh-b", date="2026-08-13 10:00:00")
mapping = oper.get_by_hashes(["oh-a", "oh-b", "oh-missing"])
@@ -626,12 +656,28 @@ def test_downloadhistory_oper_file_entry_points(db):
文件记录的写入与四个读取入口构成完整闭环删除只置状态
"""
oper = DownloadHistoryOper(db=db.session)
oper.add_files([
dict(downloader="qb", download_hash="oh-f", fullpath="/downloads/f/a.mkv",
savepath="/downloads/f", filepath="a.mkv", torrentname="种子", state=1),
dict(downloader="qb", download_hash="oh-f", fullpath="/downloads/f/b.mkv",
savepath="/downloads/f", filepath="b.mkv", torrentname="种子", state=1),
])
oper.add_files(
[
dict(
downloader="qb",
download_hash="oh-f",
fullpath="/downloads/f/a.mkv",
savepath="/downloads/f",
filepath="a.mkv",
torrentname="种子",
state=1,
),
dict(
downloader="qb",
download_hash="oh-f",
fullpath="/downloads/f/b.mkv",
savepath="/downloads/f",
filepath="b.mkv",
torrentname="种子",
state=1,
),
]
)
assert len(oper.get_files_by_hash("oh-f")) == 2
assert len(oper.get_files_by_hash("oh-f", state=1)) == 2
@@ -651,20 +697,27 @@ def test_downloadhistory_oper_query_entry_points(db):
路径hash媒体身份分页与时间窗口五个查询入口都应透传生效
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/q", type=MediaType.TV.value, title="Q", year="2026",
media_source=TMDB, media_id="4001", seasons="S01",
download_hash="oh-q", username="alice", date="2026-08-13 10:00:00")
oper.add(
path="/downloads/q",
type=MediaType.TV.value,
title="Q",
year="2026",
media_source=TMDB,
media_id="4001",
seasons="S01",
download_hash="oh-q",
username="alice",
date="2026-08-13 10:00:00",
)
assert oper.get_by_path("/downloads/q").title == "Q"
assert oper.get_by_hash("oh-q").title == "Q"
assert len(oper.get_by_media_identity(media_source=TMDB, media_id="4001")) == 1
assert oper.list_by_page(page=1, count=1)[0].title == "Q"
assert [h.title for h in oper.list_by_user_date("2026-08-20", username="alice")] == ["Q"]
assert [h.title for h in oper.list_by_date("2026-08-01", MediaType.TV.value,
TMDB, "4001", "S01")] == ["Q"]
assert [h.title for h in oper.list_by_date("2026-08-01", MediaType.TV.value, TMDB, "4001", "S01")] == ["Q"]
assert [h.title for h in oper.list_by_type(MediaType.TV.value, days=36500)] == ["Q"]
assert [h.title for h in oper.get_last_by(mtype=MediaType.TV.value,
media_source=TMDB, media_id="4001")] == ["Q"]
assert [h.title for h in oper.get_last_by(mtype=MediaType.TV.value, media_source=TMDB, media_id="4001")] == ["Q"]
def test_downloadhistory_oper_delete_entry_points(db):
@@ -672,12 +725,21 @@ def test_downloadhistory_oper_delete_entry_points(db):
历史与文件记录的删除入口都应真正落库
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/d", type=MediaType.TV.value, title="D",
download_hash="oh-d", date="2026-08-13 10:00:00")
oper.add(path="/downloads/d", type=MediaType.TV.value, title="D", download_hash="oh-d", date="2026-08-13 10:00:00")
history = oper.get_by_hash("oh-d")
oper.add_files([dict(downloader="qb", download_hash="oh-d",
fullpath="/downloads/d/a.mkv", savepath="/downloads/d",
filepath="a.mkv", torrentname="种子", state=1)])
oper.add_files(
[
dict(
downloader="qb",
download_hash="oh-d",
fullpath="/downloads/d/a.mkv",
savepath="/downloads/d",
filepath="a.mkv",
torrentname="种子",
state=1,
)
]
)
file_row = oper.get_file_by_fullpath("/downloads/d/a.mkv")
oper.delete_downloadfile(file_row.id)
@@ -692,8 +754,9 @@ def test_downloadhistory_oper_async_delete(db):
异步删除历史与同步等效
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/ad", type=MediaType.TV.value, title="AD",
download_hash="oh-ad", date="2026-08-13 10:00:00")
oper.add(
path="/downloads/ad", type=MediaType.TV.value, title="AD", download_hash="oh-ad", date="2026-08-13 10:00:00"
)
history = oper.get_by_hash("oh-ad")
db.session.commit()
+28 -16
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterator
from dataclasses import asdict
from pathlib import Path
from types import SimpleNamespace
@@ -10,6 +11,7 @@ from app.application.download.failures import (
DownloadFailureSnapshot,
DownloadFailureWrite,
)
from app.application.history import DownloadHistorySnapshot
from app.chain.download import DownloadChain
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
@@ -21,6 +23,19 @@ from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MediaSource, MediaType
@pytest.fixture(autouse=True)
def _restore_eventmanager_instance_override() -> Iterator[None]:
"""每个用例后恢复事件单例实例属性,避免遮蔽后续类级 monkeypatch。"""
instance = download_module.eventmanager
marker = object()
original = vars(instance).get("send_event", marker)
yield
if original is marker:
vars(instance).pop("send_event", None)
else:
vars(instance)["send_event"] = original
@pytest.fixture(autouse=True)
def _mock_tmdb_supplement(monkeypatch):
"""隔离下载用例中的 TMDB 辅助识别外部边界。"""
@@ -41,10 +56,8 @@ class _FakeDownloadHistoryOper:
避免单元测试写入真实下载历史只验证下载链路的控制流
"""
def add(self, **_kwargs):
pass
def add_files(self, _files):
def add(self, _history, _files=()):
"""忽略当前用例无需验证的类型化历史写入。"""
pass
@@ -266,13 +279,9 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
class _CapturingDownloadHistoryOper:
"""捕获写入下载历史的字段,验证识别词快照确实落库。"""
def add(self, **kwargs):
def add(self, history, _files=()):
"""捕获下载历史字段。"""
captured.update(kwargs)
def add_files(self, _files):
"""忽略与当前断言无关的下载文件记录。"""
pass
captured.update(history.to_payload())
_FakeThreadHelper.submitted = []
monkeypatch.setattr(
@@ -1283,13 +1292,15 @@ def test_downloading_includes_media_type_and_source_site(monkeypatch):
正在下载任务应从下载历史回填媒体类型和来源站点
"""
torrent = DownloaderTorrent(hash="download-hash", title="Demo.Release")
history = SimpleNamespace(
history = DownloadHistorySnapshot(
id=1,
path="/downloads/Demo.Release.mkv",
episodes="E02",
image="https://images.example.com/backdrop.jpg",
poster="https://images.example.com/poster.jpg",
seasons="S01",
title="示例剧集",
media_source=MediaSource.TMDB.value,
media_source=MediaSource.TMDB,
media_id="1001",
torrent_site="示例站点",
type="电视剧",
@@ -1307,10 +1318,11 @@ def test_downloading_includes_media_type_and_source_site(monkeypatch):
result = chain.downloading(name="qb-main")
assert result == [torrent]
assert torrent.media["type"] == "电视剧"
assert torrent.media["image"] == "https://images.example.com/poster.jpg"
assert torrent.media["poster"] == "https://images.example.com/poster.jpg"
assert torrent.media["backdrop"] == "https://images.example.com/backdrop.jpg"
assert torrent.media is not None
assert torrent.media.type == "电视剧"
assert torrent.media.image == "https://images.example.com/poster.jpg"
assert torrent.media.poster == "https://images.example.com/poster.jpg"
assert torrent.media.backdrop == "https://images.example.com/backdrop.jpg"
assert torrent.site_name == "示例站点"
assert torrent.userid == "user-1"
assert torrent.username == "tester"
+13 -9
View File
@@ -1,24 +1,27 @@
"""下载任务应用服务测试。"""
from types import SimpleNamespace
from app.application.download.tasks import DownloadTaskService
from app.application.history import DownloadHistorySnapshot
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MediaSource
def test_download_task_service_enriches_history_and_controls_task():
"""下载任务查询应附加历史媒体信息,控制方法只转发规范参数。"""
torrent = SimpleNamespace(hash="hash", media=None)
history = SimpleNamespace(
media_source="tmdb",
torrent = DownloaderTorrent(hash="hash")
history = DownloadHistorySnapshot(
id=1,
path="/downloads/test",
media_source=MediaSource.TMDB,
media_id="123",
type="电影",
title="测试电影",
seasons=[1],
episodes=[2],
seasons="1",
episodes="2",
poster="poster",
image="backdrop",
torrent_site="站点",
userid=1,
userid="1",
username="alice",
)
calls = []
@@ -31,7 +34,8 @@ def test_download_task_service_enriches_history_and_controls_task():
)
assert service.downloading("qb") == [torrent]
assert torrent.media["media_id"] == "123"
assert torrent.media is not None
assert torrent.media.media_id == "123"
assert torrent.username == "alice"
assert service.set_downloading("hash", "start", "qb") is True
assert service.set_downloading("hash", "stop", "qb") is True
+30
View File
@@ -164,6 +164,36 @@ def test_broadcast_dispatch_uses_subscription_snapshot(isolated_eventmanager):
assert calls == ["mutating", "late"]
def test_strict_broadcast_waits_and_propagates_handler_failure(
isolated_eventmanager,
monkeypatch,
):
"""durable 广播不入队,并在真实 handler 失败时阻止调用方结算。"""
isolated_eventmanager._EventManager__lifecycle_state = "running"
calls = []
def handler(event):
"""记录稳定键后模拟真实 handler 失败。"""
calls.append(event.event_data["idempotency_key"])
raise RuntimeError("delivery failed")
monkeypatch.setattr(
isolated_eventmanager,
"_EventManager__handle_event_error",
lambda *_args, **_kwargs: None,
)
isolated_eventmanager.add_event_listener(EventType.ConfigChanged, handler)
with pytest.raises(RuntimeError, match="delivery failed"):
isolated_eventmanager.send_event_strict(
EventType.ConfigChanged,
{"idempotency_key": "config.changed:v1"},
)
assert calls == ["config.changed:v1"]
assert isolated_eventmanager._EventManager__event_queue.empty()
def test_sync_chain_dispatch_uses_subscription_snapshot(isolated_eventmanager):
"""同步链式事件中的订阅变更不影响当前处理器序列。"""
calls = []
+9 -7
View File
@@ -12,15 +12,17 @@ ensure_optional_stub("psutil")
ensure_optional_stub("dateparser")
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
from app.modules.feishu import FeishuModule
from app.modules.feishu.feishu import Feishu
from app.schemas import Message
from app.schemas.message import (
ChannelCapability,
ChannelCapabilityManager,
from app.modules.feishu import FeishuModule # noqa: E402
from app.modules.feishu.feishu import Feishu # noqa: E402
from app.schemas.message import ( # noqa: E402
Message,
MessageResponse,
)
from app.schemas.types import NotificationChannel, MessageType
from app.schemas.notification import ( # noqa: E402
ChannelCapability,
ChannelCapabilityManager,
)
from app.schemas.types import MessageType, NotificationChannel # noqa: E402
class TestFeishu(unittest.TestCase):
+17 -2
View File
@@ -87,8 +87,22 @@ class _Outbox:
async def stage(self, intent, now) -> None:
"""模拟暂存 durable intent。"""
async def complete_by_event_key(self, event_key, completed_at) -> None:
"""模拟收口 durable intent。"""
class _DispatchStore:
"""提供订阅即时副作用所需的独立派发存储替身。"""
async def claim_by_event_key(self, event_key, now, lease_until):
"""模拟未取得指定消息 lease。"""
return None
async def complete(self, message_id, attempt, completed_at) -> bool:
"""模拟按 attempt 完成消息。"""
return True
async def retry(self, message_id, attempt, **kwargs) -> bool:
"""模拟按 attempt 释放消息。"""
return True
class _RuntimeSettings:
@@ -150,6 +164,7 @@ def _runtime() -> HostRuntime:
history_repository=_Repository,
transaction=_UnitOfWork,
outbox=_Outbox,
dispatch_store=_DispatchStore(),
),
workflow=WorkflowRuntime(
query=SimpleNamespace(),
+13 -1
View File
@@ -7,11 +7,23 @@ from webauthn.helpers.exceptions import InvalidRegistrationResponse
from app.api.endpoints import mfa as mfa_endpoint
from app.application.security import passkey as passkey_helper
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyChallengeStore,
PassKeyHelper,
PassKeyRegistrationOriginMismatchError,
PassKeyRegistrationVerificationError,
PasskeyChallengeStore,
configure_passkey_challenge_cache,
)
from app.runtime.cache import TTLCache
def setup_function():
"""为注册错误路径显式装配隔离的 challenge 缓存。"""
configure_passkey_challenge_cache(TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
))
def _registration_request(user_id: int = 1) -> mfa_endpoint.PassKeyRegistrationFinish:
+161 -3
View File
@@ -1,5 +1,5 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastapi import HTTPException
@@ -7,7 +7,12 @@ from starlette.requests import Request
from starlette.responses import Response
from app.api.endpoints import mfa as mfa_endpoint
from app.application.security.passkey import PasskeyChallengeStore
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyChallengeStore,
configure_passkey_challenge_cache,
)
from app.runtime.cache import TTLCache
def _request() -> Request:
@@ -25,7 +30,46 @@ def _request() -> Request:
def setup_function():
PasskeyChallengeStore._cache.clear()
configure_passkey_challenge_cache(TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
))
@pytest.mark.asyncio
async def test_mfa_status_hides_missing_disabled_and_unconfigured_accounts():
"""匿名状态查询不得用状态码、消息或数据区分非 OTP 账号。"""
users = [
None,
SimpleNamespace(is_active=False, is_otp=True),
SimpleNamespace(is_active=True, is_otp=False),
]
responses = []
for user in users:
service = SimpleNamespace(get_by_name=AsyncMock(return_value=user))
response = await mfa_endpoint.mfa_status("candidate", service=service)
responses.append(response.model_dump())
assert responses == [
{"success": True, "message": "", "data": {"enabled": False}},
] * len(users)
@pytest.mark.asyncio
async def test_mfa_status_reports_otp_only_for_active_account():
"""启用账号已配置 OTP 时仍应允许登录流程进入二次验证。"""
service = SimpleNamespace(
get_by_name=AsyncMock(
return_value=SimpleNamespace(is_active=True, is_otp=True)
)
)
response = await mfa_endpoint.mfa_status("candidate", service=service)
assert response.success is True
assert response.data == {"enabled": True}
def test_registration_transaction_is_bound_to_current_user():
@@ -186,3 +230,117 @@ def test_authentication_finish_token_cannot_be_replayed():
assert result.access_token == "access-token"
assert replay_error.value.status_code == 401
assert replay_error.value.detail == "认证请求已失效"
def test_authentication_finish_fails_closed_when_challenge_backend_fails(
monkeypatch,
):
"""challenge 后端故障不得继续查询凭证或签发 Token。"""
class FailingCache:
"""模拟 challenge 原子领取期间缓存不可用。"""
def consume(self, _key):
"""报告后端不可用。"""
raise RuntimeError("cache unavailable")
monkeypatch.setattr(PasskeyChallengeStore, "_cache", FailingCache())
service = SimpleNamespace(get_by_credential_id=Mock())
auth_service = SimpleNamespace(build_token_response=Mock())
with patch.object(
mfa_endpoint,
"get_configured_auth_service",
return_value=auth_service,
), patch.object(
mfa_endpoint,
"set_or_refresh_resource_token_cookie",
) as set_cookie, pytest.raises(HTTPException) as exc_info:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token="transaction-token",
),
service=service,
)
assert exc_info.value.status_code == 401
service.get_by_credential_id.assert_not_called()
auth_service.build_token_response.assert_not_called()
set_cookie.assert_not_called()
@pytest.mark.parametrize("cas_failure", [False, RuntimeError("write failed")])
def test_authentication_finish_does_not_issue_token_when_sign_count_write_fails(
cas_failure,
):
"""签名计数 CAS 冲突或事务失败时,认证不得越过持久化门禁。"""
token = PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=1,
)
passkey_req = mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token=token,
)
passkey = SimpleNamespace(
id=10,
user_id=1,
public_key="public-key",
sign_count=5,
)
user = SimpleNamespace(
id=1,
name="user",
is_active=True,
is_superuser=False,
)
compare_and_update = Mock()
if isinstance(cas_failure, Exception):
compare_and_update.side_effect = cas_failure
else:
compare_and_update.return_value = cas_failure
service = SimpleNamespace(
get_by_credential_id=Mock(return_value=passkey),
compare_and_update_sign_count=compare_and_update,
)
auth_service = SimpleNamespace(build_token_response=Mock())
with patch.object(
mfa_endpoint,
"_extract_and_standardize_credential_id",
return_value="credential-id",
), patch.object(
mfa_endpoint,
"get_configured_user_id_lookup",
return_value=Mock(return_value=user),
), patch.object(
mfa_endpoint.PassKeyHelper,
"verify_authentication_response",
return_value=(True, 6),
), patch.object(
mfa_endpoint,
"get_configured_auth_service",
return_value=auth_service,
), patch.object(
mfa_endpoint,
"set_or_refresh_resource_token_cookie",
) as set_cookie:
with pytest.raises(HTTPException) as exc_info:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=passkey_req,
service=service,
)
assert exc_info.value.status_code == 401
compare_and_update.assert_called_once_with(
passkey_id=10,
expected_sign_count=5,
sign_count=6,
)
auth_service.build_token_response.assert_not_called()
set_cookie.assert_not_called()
+272 -12
View File
@@ -1,6 +1,8 @@
"""durable side-effect outbox 原子性、认领、重试与幂等测试。"""
"""durable side-effect outbox 原子性、认领、重试与稳定重放测试。"""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from threading import Barrier
from unittest.mock import MagicMock
import pytest
@@ -8,9 +10,17 @@ from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.maintenance import CleanupPolicy, DataCleanupService
from app.application.outbox import ClaimedOutboxMessage, OutboxDispatcher, OutboxIntent
from app.application.outbox import (
ClaimedOutboxMessage,
OutboxDispatcher,
OutboxIntent,
OutboxLeaseLostError,
)
from app.application.subscription.write import CreateSubscriptionCommand
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
from app.db.adapters.outbox import (
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
)
from app.db.base import Base
from app.db.maintenance import DatabaseCleanupRepository
from app.db.models.outbox import OutboxMessage
@@ -138,11 +148,32 @@ def test_dispatcher_marks_success_and_closes_owned_resource() -> None:
)
assert dispatcher.dispatch_one() is True
repository.complete.assert_called_once_with(7, now)
repository.complete.assert_called_once_with(7, 1, now)
dispatcher.close()
close.assert_called_once_with()
def test_dispatcher_raises_when_complete_loses_lease() -> None:
"""handler 成功但 complete fencing 失败时必须明确报告 lease 丢失。"""
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
repository = MagicMock()
message = ClaimedOutboxMessage(7, "key", "test", {}, 1, 1)
repository.claim.return_value = message
repository.complete.return_value = False
handler = MagicMock()
dispatcher = OutboxDispatcher(
repository,
{"test": handler},
clock=lambda: now,
)
with pytest.raises(OutboxLeaseLostError, match="完成凭证"):
dispatcher.dispatch_one()
handler.assert_called_once_with(message)
repository.retry.assert_not_called()
def test_sync_outbox_claim_is_exclusive_for_event_key() -> None:
"""同步投递与恢复投递竞争同一 intent 时只允许一个取得 lease。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
@@ -153,20 +184,19 @@ def test_sync_outbox_claim_is_exclusive_for_event_key() -> None:
event_key = "subscribe.complete:7:tmdb:123:v1"
with factory() as session:
repository = SqlAlchemyOutboxRepository(session)
repository = SqlAlchemyOutboxStager(session)
repository.stage(
OutboxIntent(event_key=event_key, topic="subscribe.complete", payload={}),
now,
)
session.commit()
with factory() as owner, factory() as competitor:
assert SqlAlchemyOutboxRepository(owner).claim_by_event_key(
event_key, now, lease_until
) is True
assert SqlAlchemyOutboxRepository(competitor).claim_by_event_key(
event_key, now, lease_until
) is False
store = SqlAlchemyOutboxDispatchStore(factory)
owner = store.claim_by_event_key(event_key, now, lease_until)
competitor = store.claim_by_event_key(event_key, now, lease_until)
assert owner is not None
assert owner.attempt == 1
assert competitor is None
with factory() as session:
message = session.execute(select(OutboxMessage)).scalar_one()
@@ -175,6 +205,236 @@ def test_sync_outbox_claim_is_exclusive_for_event_key() -> None:
assert message.lease_until == lease_until.isoformat()
def test_concurrent_claim_allows_exactly_one_owner(tmp_path) -> None:
"""两个独立 dispatcher 并发竞争同一消息时只允许一个取得 lease。"""
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'outbox.db'}")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key="race:v1", topic="test", payload={}),
now,
)
session.commit()
barrier = Barrier(2)
def claim():
"""同时开始一次独立短事务认领。"""
barrier.wait()
return SqlAlchemyOutboxDispatchStore(factory).claim_by_event_key(
"race:v1",
now,
now + timedelta(seconds=60),
)
with ThreadPoolExecutor(max_workers=2) as executor:
claimed = list(executor.map(lambda _index: claim(), range(2)))
owners = [message for message in claimed if message is not None]
assert len(owners) == 1
assert owners[0].attempt == 1
engine.dispose()
def test_expired_owner_cannot_settle_new_attempt() -> None:
"""lease 过期后的旧 owner 不得覆盖新 attempt 的完成或重试状态。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key="fenced:v1", topic="test", payload={}),
now,
)
session.commit()
store = SqlAlchemyOutboxDispatchStore(factory)
first = store.claim_by_event_key(
"fenced:v1",
now,
now + timedelta(seconds=1),
)
second_now = now + timedelta(seconds=2)
second = store.claim_by_event_key(
"fenced:v1",
second_now,
second_now + timedelta(seconds=60),
)
assert first is not None
assert second is not None
assert second.attempt == first.attempt + 1
assert store.complete(first.message_id, first.attempt, second_now) is False
assert store.retry(
first.message_id,
first.attempt,
next_retry_at=second_now,
last_error="stale owner",
dead=False,
) is False
assert store.complete(second.message_id, second.attempt, second_now) is True
def test_handler_replays_with_stable_key_after_success_before_complete_crash() -> None:
"""外部成功后 complete 前崩溃会按稳定键至少再次投递一次。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
event_key = "external-effect:v1"
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key=event_key, topic="external", payload={}),
now,
)
session.commit()
store = SqlAlchemyOutboxDispatchStore(factory)
first = store.claim(now, now + timedelta(seconds=1))
assert first is not None
external_results: list[str] = []
def handler(message: ClaimedOutboxMessage) -> None:
"""记录 at-least-once 外部效果及其稳定幂等键。"""
assert message.payload["idempotency_key"] == message.event_key
external_results.append(message.event_key)
handler(first)
dispatcher = OutboxDispatcher(
store,
{"external": handler},
clock=lambda: now + timedelta(seconds=2),
)
assert dispatcher.dispatch_one() is True
assert external_results == [event_key, event_key]
with factory() as session:
persisted = session.execute(select(OutboxMessage)).scalar_one()
assert persisted.status == "completed"
assert persisted.attempt == 2
@pytest.mark.parametrize("handler_kind", ["event", "notification"])
def test_startup_handler_replays_strict_boundary_with_stable_key(
handler_kind,
monkeypatch,
) -> None:
"""真实 startup handler 等待执行边界,并以同一键诚实重放。"""
from app.command import CommandChain
from app.runtime.events import EventManager
from app.startup.initializers.modules import _build_outbox_handlers
calls = []
if handler_kind == "event":
topic = "subscribe.added"
payload = {"subscribe_id": 7}
monkeypatch.setattr(
EventManager,
"send_event_strict",
lambda _self, _etype, data: calls.append(data["idempotency_key"]),
)
else:
topic = "subscribe.complete.notification"
payload = {"message": {"title": "完成", "text": "Test"}}
monkeypatch.setattr(
CommandChain,
"post_message_strict",
lambda _self, _message, *, event_key: calls.append(event_key),
)
handlers = _build_outbox_handlers()
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
event_key = f"startup:{handler_kind}:v1"
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key=event_key, topic=topic, payload=payload),
now,
)
session.commit()
store = SqlAlchemyOutboxDispatchStore(factory)
first = store.claim(now, now + timedelta(seconds=1))
assert first is not None
handlers[topic](first)
dispatcher = OutboxDispatcher(
store,
handlers,
clock=lambda: now + timedelta(seconds=2),
)
assert dispatcher.dispatch_one() is True
assert calls == [event_key, event_key]
def test_strict_notification_preserves_legacy_provider_signature(monkeypatch) -> None:
"""durable 通知只传既有 message 参数,并在调用上下文携带稳定键。"""
from app.command import CommandChain
from app.runtime.correlation import get_correlation_id
from app.schemas.message import Message
chain = CommandChain()
received = []
def legacy_provider(message) -> None:
"""模拟只接受旧式单参数签名的第三方通知 provider。"""
received.append((message, get_correlation_id()))
monkeypatch.setattr(chain.eventmanager, "send_event", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
chain,
"run_module_strict",
lambda method, **kwargs: legacy_provider(**kwargs),
)
chain.post_message_strict(
Message(title="完成", text="Test", save_history=False),
event_key="subscribe.complete:7:notification",
)
assert len(received) == 1
assert received[0][0].source is None
assert received[0][1] == "subscribe.complete:7:notification"
def test_strict_notification_retry_writes_history_once(monkeypatch) -> None:
"""provider 失败后按稳定键重试,历史只写一次而渠道继续 at-least-once。"""
from app.command import CommandChain
from app.schemas.message import Message
chain = CommandChain()
history_sources = set()
provider_sources = []
monkeypatch.setattr(chain.eventmanager, "send_event", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
chain.messageoper,
"exists_by_source",
lambda source: source in history_sources,
)
monkeypatch.setattr(
chain.messageoper,
"add",
lambda **payload: history_sources.add(payload["source"]),
)
def deliver(_method, *, message) -> None:
"""第一次模拟外部失败,第二次成功,并记录 provider 实际路由 source。"""
provider_sources.append(message.source)
if len(provider_sources) == 1:
raise RuntimeError("temporary")
monkeypatch.setattr(chain, "run_module_strict", deliver)
message = Message(title="完成", text="Test")
with pytest.raises(RuntimeError, match="temporary"):
chain.post_message_strict(message, event_key="subscribe.complete:7:notification")
chain.post_message_strict(message, event_key="subscribe.complete:7:notification")
assert history_sources == {"outbox:subscribe.complete:7:notification"}
assert provider_sources == [None, None]
def test_outbox_cleanup_removes_only_expired_terminal_history_in_batches() -> None:
"""清理只删除超过各自保留期的终态记录,并按批次持续收口。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
+60 -2
View File
@@ -1,11 +1,21 @@
from concurrent.futures import ThreadPoolExecutor
import pytest
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyChallengeStore,
configure_passkey_challenge_cache,
)
from app.runtime.cache import TTLCache
from app.application.security.passkey import PasskeyChallengeStore
def setup_function():
PasskeyChallengeStore._cache.clear()
configure_passkey_challenge_cache(TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
))
def test_challenge_can_only_be_consumed_once():
@@ -90,3 +100,51 @@ def test_concurrent_consumers_have_single_winner():
results = list(executor.map(lambda _: consume(), range(8)))
assert sum(result is not None for result in results) == 1
def test_challenge_store_requires_explicit_cache(monkeypatch):
"""未完成启动装配时不得签发一个实际未保存的事务 token。"""
monkeypatch.setattr(PasskeyChallengeStore, "_cache", None)
with pytest.raises(RuntimeError, match="缓存尚未配置"):
PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=None,
)
@pytest.mark.parametrize("operation", ["store", "consume"])
def test_challenge_cache_failure_is_not_treated_as_a_cache_miss(
monkeypatch,
operation,
):
"""安全缓存故障必须向认证入口传播,不能伪装成正常 miss。"""
class FailingCache:
"""模拟严格缓存写入或领取故障。"""
def store(self, _key, _value):
"""按用例模拟写入结果。"""
if operation == "store":
raise RuntimeError("cache unavailable")
def consume(self, _key):
"""按用例模拟领取结果。"""
if operation == "consume":
raise RuntimeError("cache unavailable")
return None
monkeypatch.setattr(PasskeyChallengeStore, "_cache", FailingCache())
with pytest.raises(RuntimeError, match="cache unavailable"):
if operation == "store":
PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=None,
)
else:
PasskeyChallengeStore.consume(
transaction_token="transaction-token",
purpose="authentication",
)
+1 -4
View File
@@ -186,10 +186,7 @@ def test_default_sync_writer_persists_once_and_reuses_duplicate(db) -> None:
media_id="arch-221-sync",
)
assert [row.id for row in rows] == [first[0]]
assert after_commit.call_args_list == [
((first[0],), {}),
((first[0],), {}),
]
assert after_commit.call_args_list == [((first[0],), {})]
def test_default_sync_writer_keeps_failed_report_pending_without_raising(db) -> None:
+67 -16
View File
@@ -5,11 +5,12 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.orm import Session
from app.application.outbox import ClaimedOutboxMessage
from app.application.subscription.delete import (
DeleteSubscribeCommand,
SyncDeleteSubscribeCommand,
SubscribeDeletionActor,
SubscribeDeletionCandidate,
SyncDeleteSubscribeCommand,
)
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
@@ -69,9 +70,27 @@ class _Outbox:
if self.stage_error:
raise self.stage_error
async def complete_by_event_key(self, event_key, _completed_at):
"""记录即时事件成功后的 intent 收口"""
self.calls.append(("outbox_complete", event_key))
async def claim_by_event_key(self, event_key, _now, _lease_until):
"""记录并返回当前测试拥有的异步 lease"""
self.calls.append(("outbox_claim", event_key))
return ClaimedOutboxMessage(
message_id=len(self.calls),
event_key=event_key,
topic="test",
payload={},
payload_version=1,
attempt=1,
)
async def complete(self, message_id, attempt, _completed_at):
"""记录带 attempt fencing 的异步完成。"""
self.calls.append(("outbox_complete", message_id, attempt))
return True
async def retry(self, message_id, attempt, **_kwargs):
"""记录带 attempt fencing 的异步重试。"""
self.calls.append(("outbox_retry", message_id, attempt))
return True
class _SyncRepository:
@@ -125,9 +144,27 @@ class _SyncOutbox:
"""记录同步暂存的 intent。"""
self.calls.append(("outbox_stage", intent))
def complete_by_event_key(self, event_key, _completed_at):
"""记录同步完成的 intent"""
self.calls.append(("outbox_complete", event_key))
def claim_by_event_key(self, event_key, _now, _lease_until):
"""记录并返回当前测试拥有的同步 lease"""
self.calls.append(("outbox_claim", event_key))
return ClaimedOutboxMessage(
message_id=len(self.calls),
event_key=event_key,
topic="test",
payload={},
payload_version=1,
attempt=1,
)
def complete(self, message_id, attempt, _completed_at):
"""记录带 attempt fencing 的同步完成。"""
self.calls.append(("outbox_complete", message_id, attempt))
return True
def retry(self, message_id, attempt, **_kwargs):
"""记录带 attempt fencing 的同步重试。"""
self.calls.append(("outbox_retry", message_id, attempt))
return True
def _candidate(username="alice"):
@@ -175,6 +212,7 @@ def _command(
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
dispatch_store=outbox,
)
@@ -197,6 +235,7 @@ def _async_report_command(candidate, calls, result=True, error=None, outbox=None
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
dispatch_store=outbox,
)
@@ -224,6 +263,7 @@ def _sync_command(
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
dispatch_store=outbox,
)
@@ -242,7 +282,9 @@ async def test_owner_delete_commits_before_event_and_report():
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
assert calls[3][2]["subscribe_info"] == _candidate().event_payload
assert calls[3][2]["idempotency_key"].startswith("subscribe.deleted:7:")
assert calls[4][1] == _candidate().event_payload
report_payload = dict(calls[4][1])
assert report_payload.pop("idempotency_key").endswith(":report")
assert report_payload == _candidate().event_payload
@pytest.mark.asyncio
@@ -357,8 +399,10 @@ async def test_delete_stages_outbox_before_commit_and_completes_after_event():
"outbox_stage",
"outbox_stage",
"commit",
"outbox_claim",
"event",
"outbox_complete",
"outbox_claim",
"report",
"outbox_complete",
]
@@ -366,8 +410,8 @@ async def test_delete_stages_outbox_before_commit_and_completes_after_event():
assert intent.topic == "subscribe.deleted"
report_intent = calls[3][1]
assert report_intent.topic == "subscribe.deleted.report"
assert intent.event_key == calls[5][2]["idempotency_key"]
assert calls[6][1] == intent.event_key
assert intent.event_key == calls[6][2]["idempotency_key"]
assert calls[5][1] == intent.event_key
assert calls[8][1] == report_intent.event_key
@@ -408,7 +452,8 @@ async def test_async_reporter_completes_report_intent_only_after_confirmation():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report", "outbox_complete",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_complete",
]
@@ -425,7 +470,8 @@ async def test_async_reporter_false_keeps_report_intent_pending():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_retry",
]
@@ -447,7 +493,8 @@ async def test_async_reporter_error_keeps_report_intent_pending():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_retry",
]
@@ -505,7 +552,8 @@ def test_sync_delete_uses_same_durable_effect_order():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report", "outbox_complete",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_complete",
]
assert calls[2][1].topic == "subscribe.deleted"
assert calls[3][1].topic == "subscribe.deleted.report"
@@ -527,9 +575,12 @@ def test_sync_delete_report_failure_returns_success_and_keeps_intent_pending():
) is True
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_retry",
]
assert calls[7][1] == _candidate().event_payload
report_payload = dict(calls[9][1])
assert report_payload.pop("idempotency_key").endswith(":report")
assert report_payload == _candidate().event_payload
@pytest.mark.parametrize("failure", ["delete", "commit"])
+35 -10
View File
@@ -4,6 +4,7 @@ from datetime import datetime
import pytest
from app.application.outbox import ClaimedOutboxMessage
from app.application.subscription.complete import CompleteSubscriptionCommand
@@ -54,14 +55,29 @@ class _Outbox:
"""记录 durable intent。"""
self.calls.append(("stage", intent))
def claim_by_event_key(self, event_key: str, _now: datetime, _lease_until: datetime) -> bool:
def claim_by_event_key(self, event_key: str, _now: datetime, _lease_until: datetime):
"""记录同步投递认领结果。"""
self.calls.append(("claim", event_key))
return self.claim_result
if not self.claim_result:
return None
return ClaimedOutboxMessage(
message_id=len(self.calls),
event_key=event_key,
topic="test",
payload={},
payload_version=1,
attempt=1,
)
def complete_by_event_key(self, event_key: str, _now: datetime) -> None:
def complete(self, message_id: int, attempt: int, _now: datetime) -> bool:
"""记录成功副作用对应的 intent 收口。"""
self.calls.append(("complete", event_key))
self.calls.append(("complete", message_id, attempt))
return True
def retry(self, message_id: int, attempt: int, **_kwargs) -> bool:
"""记录失败副作用对应的 intent 释放。"""
self.calls.append(("retry", message_id, attempt))
return True
def _command(
@@ -93,10 +109,12 @@ def _command(
raise report_error
return report_result
outbox = _Outbox(calls, claim_result)
return CompleteSubscriptionCommand(
repository=_Repository(calls),
unit_of_work=_UnitOfWork(calls),
outbox=_Outbox(calls, claim_result),
outbox=outbox,
dispatch_store=outbox,
publish=publish,
), notify, report
@@ -128,7 +146,9 @@ def test_completion_stages_business_and_independent_intents_before_commit(failur
if failure == "notify":
assert [call[0] for call in calls[5:]] == ["notify"]
elif failure == "event":
assert [call[0] for call in calls[5:]] == ["notify", "claim", "event"]
assert [call[0] for call in calls[5:]] == [
"notify", "claim", "event", "retry",
]
def test_completion_report_failure_returns_success_and_keeps_intent_pending():
@@ -146,7 +166,7 @@ def test_completion_report_failure_returns_success_and_keeps_intent_pending():
assert [call[0] for call in calls] == [
"history", "delete", "stage", "stage", "commit",
"notify", "claim", "event", "complete", "claim", "report",
"notify", "claim", "event", "complete", "claim", "report", "retry",
]
@@ -168,7 +188,7 @@ def test_completion_report_error_returns_success_and_keeps_intent_pending():
assert [call[0] for call in calls] == [
"history", "delete", "stage", "stage", "commit",
"notify", "claim", "event", "complete", "claim", "report",
"notify", "claim", "event", "complete", "claim", "report", "retry",
]
@@ -215,8 +235,13 @@ def test_completion_stages_and_closes_notification_snapshot() -> None:
"subscribe.complete.report",
]
assert staged[1].payload["message"]["title"] == "完成"
completed = [call[1] for call in calls if call[0] == "complete"]
assert completed[0].endswith(":notification")
completed = [call for call in calls if call[0] == "complete"]
notification_claim = next(
call for call in calls
if call[0] == "claim" and call[1].endswith(":notification")
)
assert completed[0][1] > 0
assert notification_claim[1].endswith(":notification")
def test_completion_skips_sync_delivery_owned_by_outbox_dispatcher() -> None:
+30 -6
View File
@@ -2,6 +2,7 @@
import pytest
from app.application.outbox import ClaimedOutboxMessage
from app.application.subscription.mutation import (
SubscriptionActor,
SubscriptionMutationService,
@@ -84,9 +85,27 @@ class _Outbox:
if self.stage_error:
raise self.stage_error
async def complete_by_event_key(self, event_key: str, _completed_at) -> None:
"""记录即时事件成功后的完成键"""
self.calls.append(("outbox_complete", event_key))
async def claim_by_event_key(self, event_key, _now, _lease_until):
"""记录并返回当前测试拥有的派发 lease"""
self.calls.append(("outbox_claim", event_key))
return ClaimedOutboxMessage(
message_id=7,
event_key=event_key,
topic="subscribe.modified",
payload={},
payload_version=1,
attempt=1,
)
async def complete(self, message_id, attempt, _completed_at):
"""记录带 attempt fencing 的完成结算。"""
self.calls.append(("outbox_complete", message_id, attempt))
return True
async def retry(self, message_id, attempt, **_kwargs):
"""记录带 attempt fencing 的失败释放。"""
self.calls.append(("outbox_retry", message_id, attempt))
return True
def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
@@ -99,10 +118,12 @@ def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
if event_error:
raise event_error
outbox = outbox or _Outbox(calls)
return SubscriptionMutationService(
repository=_Repository(subscribe, calls),
unit_of_work=_UnitOfWork(calls),
outbox=outbox or _Outbox(calls),
outbox=outbox,
dispatch_store=outbox,
publish_modified=publish,
)
@@ -129,14 +150,15 @@ async def test_modified_event_is_staged_with_update_and_completed_after_publish(
"stage_update",
"outbox_stage",
"commit",
"outbox_claim",
"event",
"outbox_complete",
]
intent = calls[2][1]
assert intent.topic == "subscribe.modified"
assert intent.event_key.startswith("subscribe.modified:7:update:")
assert calls[4][1]["idempotency_key"] == intent.event_key
assert calls[5][1] == intent.event_key
assert calls[5][1]["idempotency_key"] == intent.event_key
assert calls[6][1:] == (7, 1)
@pytest.mark.asyncio
@@ -181,5 +203,7 @@ async def test_modified_event_failure_keeps_committed_intent_pending():
"stage_update",
"outbox_stage",
"commit",
"outbox_claim",
"event",
"outbox_retry",
]
@@ -0,0 +1,196 @@
"""下载历史类型化适配器的投影与事务测试。"""
import asyncio
import pytest
from app.application.history import (
DownloadFileSnapshot,
DownloadFileWrite,
DownloadHistorySnapshot,
DownloadHistoryWrite,
)
from app.db.adapters.history.download import (
SessionDownloadHistoryRepository,
TransactionalDownloadHistoryRepository,
)
from app.db.models.downloadhistory import DownloadHistory
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import SqlAlchemyUnitOfWork
from app.schemas.types import MediaSource, MediaType
def _repository() -> TransactionalDownloadHistoryRepository:
"""构造绑定测试数据库短 Session 的下载历史仓储。"""
return TransactionalDownloadHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def _history_write(
*,
download_hash: str = "typed-history-hash",
) -> DownloadHistoryWrite:
"""构造覆盖 Chain 消费字段的类型化下载历史写入。"""
return DownloadHistoryWrite(
path="/downloads/Typed.Show.S01",
type=MediaType.TV.value,
title="Typed Show",
year="2026",
media_source=MediaSource.TMDB,
media_id="7001",
music_type=None,
seasons="S01",
episodes="E01-E02",
image="https://example.test/backdrop.jpg",
poster="https://example.test/poster.jpg",
downloader="qb",
download_hash=download_hash,
torrent_name="Typed Show torrent",
torrent_description="description",
torrent_site="Example",
userid=7,
username="alice",
channel="telegram",
date="2026-08-28 10:00:00",
note={
"source": "subscribe",
"nested": {"season": 1, "episodes": [1, 2]},
},
media_category="剧集",
episode_group="group-1",
custom_words="S02 => S01",
)
def _file_write(
*,
download_hash: str = "typed-history-hash",
) -> DownloadFileWrite:
"""构造与类型化历史关联的下载文件写入。"""
return DownloadFileWrite(
downloader="qb",
download_hash=download_hash,
fullpath="/downloads/Typed.Show.S01/Episode01.mkv",
savepath="/downloads/Typed.Show.S01",
filepath="Episode01.mkv",
torrentname="Typed Show torrent",
)
def test_transactional_repository_projects_detached_snapshots(db) -> None:
"""所有同步查询都应在 Session 内投影,且 JSON 不与后续查询共享。"""
repository = _repository()
history_id = repository.add(_history_write(), (_file_write(),))
by_hash = repository.get_by_hash("typed-history-hash")
by_path = repository.get_by_path("/downloads/Typed.Show.S01")
by_hashes = repository.get_by_hashes(["typed-history-hash"])
by_identity = repository.get_by_media_identity(
MediaSource.TMDB,
"7001",
)
by_fullpath = repository.get_file_by_fullpath("/downloads/Typed.Show.S01/Episode01.mkv")
by_file_hash = repository.get_files_by_hash(
"typed-history-hash",
state=1,
)
by_savepath = repository.get_files_by_savepath("/downloads/Typed.Show.S01")
assert isinstance(by_hash, DownloadHistorySnapshot)
assert by_hash.id == history_id
assert by_hash.userid == "7"
assert by_hash.media_source == MediaSource.TMDB
assert by_path == by_hash
assert by_hashes == {"typed-history-hash": by_hash}
assert by_identity == [by_hash]
assert isinstance(by_fullpath, DownloadFileSnapshot)
assert by_file_hash == [by_fullpath]
assert by_savepath == [by_fullpath]
assert not hasattr(by_hash, "_sa_instance_state")
assert not hasattr(by_fullpath, "_sa_instance_state")
assert isinstance(by_hash.note, dict)
with pytest.raises(TypeError, match="不可修改"):
by_hash.note["source"] = "mutated"
nested = by_hash.note["nested"]
assert isinstance(nested, dict)
with pytest.raises(TypeError, match="不可修改"):
nested["season"] = 2
episodes = nested["episodes"]
assert isinstance(episodes, list)
with pytest.raises(TypeError, match="不可修改"):
episodes.append(3)
refreshed = repository.get_by_hash("typed-history-hash")
assert refreshed is not None
assert refreshed.note == {
"source": "subscribe",
"nested": {"season": 1, "episodes": [1, 2]},
}
def test_transactional_repository_rolls_back_history_and_files_on_commit_failure(
db,
monkeypatch,
) -> None:
"""历史与文件提交失败时必须整体回滚,不得留下半写入记录。"""
repository = _repository()
download_hash = "typed-rollback-hash"
def fail_commit(_unit_of_work) -> None:
"""模拟数据库提交失败。"""
raise RuntimeError("commit failed")
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
repository.add(
_history_write(download_hash=download_hash),
(_file_write(download_hash=download_hash),),
)
assert repository.get_by_hash(download_hash) is None
assert repository.get_files_by_hash(download_hash) == []
def test_transactional_repository_async_query_and_delete(db) -> None:
"""异步分页返回脱离 Session 的快照,删除由独立事务提交。"""
repository = _repository()
history_id = repository.add(_history_write(download_hash="typed-async-hash"))
async def exercise() -> list[DownloadHistorySnapshot]:
"""在同一事件循环中执行异步分页和删除。"""
records = await repository.async_list_by_page(count=10)
await repository.async_delete(history_id)
return records
records = asyncio.run(exercise())
assert any(record.id == history_id for record in records)
assert all(not hasattr(record, "_sa_instance_state") for record in records)
assert repository.get_by_hash("typed-async-hash") is None
def test_session_repository_obeys_caller_transaction(db) -> None:
"""请求级 adapter 只暂存变更,提交与回滚由调用方 UoW 决定。"""
history = db.add(
DownloadHistory(
path="/downloads/request-history",
type=MediaType.MOVIE.value,
title="Request History",
download_hash="request-history-hash",
)
)
with SessionFactory() as session:
repository = SessionDownloadHistoryRepository(session)
repository.stage_delete_history(history.id)
session.rollback()
assert _repository().get_by_hash("request-history-hash") is not None
with SessionFactory() as session:
repository = SessionDownloadHistoryRepository(session)
repository.stage_delete_history(history.id)
session.commit()
assert _repository().get_by_hash("request-history-hash") is None
@@ -64,7 +64,7 @@ def test_resolve_download_history_falls_back_to_parent_download_path():
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/season-pack/Test.Show.S01E01.mkv"),
)
@@ -85,7 +85,7 @@ def test_resolve_download_history_falls_back_to_unique_savepath_hash():
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/season-pack/subs/Test.Show.S01E01.zh.ass"),
)
@@ -108,7 +108,7 @@ def test_resolve_download_history_skips_ambiguous_savepath_hashes():
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"),
)
@@ -128,7 +128,7 @@ def test_resolve_download_history_stops_at_shared_download_root_path(monkeypatch
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
@@ -156,7 +156,7 @@ def test_resolve_download_history_stops_at_shared_download_root_savepath(monkeyp
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
@@ -184,7 +184,7 @@ def test_resolve_download_history_accepts_shared_root_savepath_for_exact_file(mo
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
@@ -215,7 +215,7 @@ def test_resolve_download_history_stops_at_type_category_download_root(monkeypat
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/电视剧/动漫/Ghost.Concert.mkv"),
)
@@ -301,7 +301,7 @@ def test_resolve_download_history_stops_at_nested_category_root(monkeypatch):
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/动漫/日本番剧/Ghost.Concert.mkv"),
)
+12 -10
View File
@@ -1,15 +1,16 @@
from dataclasses import replace
from types import SimpleNamespace
import pytest
from app.application.history import DownloadHistorySnapshot
from app.application.transfer.workflow import TransferTask
from app.chain.transfer import TransferChain
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.runtime.config import settings
from app.schemas.file import FileItem
from app.schemas.history import DownloadHistory
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
def _make_chain() -> TransferChain:
@@ -59,17 +60,18 @@ def _make_file_meta(year: str = "2013") -> _FileMeta:
return _FileMeta(year=year)
def _make_history() -> SimpleNamespace:
def _make_history() -> DownloadHistorySnapshot:
"""构造被合集首部电影占用的下载历史。"""
return SimpleNamespace(
return DownloadHistorySnapshot(
id=1,
path="/downloads/The.Hunger.Games.Complete.4-Film.Collection",
download_hash="collection-hash",
downloader="qbittorrent",
type=MediaType.MOVIE.value,
title="饥饿游戏",
year="2012",
tmdbid=70160,
doubanid=None,
media_source=MediaSource.TMDB,
media_id="70160",
episode_group=None,
media_category=None,
username=None,
@@ -82,12 +84,12 @@ def test_movie_year_conflict_only_applies_to_movies():
"""仅电影年份冲突应触发逐文件识别,电视剧季包仍复用下载历史。"""
file_meta = _make_file_meta()
movie_history = _make_history()
tv_history = SimpleNamespace(type=MediaType.TV, year="2012")
tv_history = replace(movie_history, type=MediaType.TV.value)
assert TransferChain._is_movie_year_conflict(file_meta, movie_history)
assert not TransferChain._is_movie_year_conflict(file_meta, tv_history)
movie_history.year = "2013"
assert not TransferChain._is_movie_year_conflict(file_meta, movie_history)
same_year_history = replace(movie_history, year="2013")
assert not TransferChain._is_movie_year_conflict(file_meta, same_year_history)
def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch):
@@ -136,7 +138,7 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch)
size=1024,
),
meta=_make_file_meta(),
download_history=DownloadHistory(**vars(_make_history())),
download_history=_make_history(),
preview=True,
)

Some files were not shown because too many files have changed in this diff Show More