From aa8751f7756c57051af033ab0558adb37f90e0d0 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 28 Aug 2026 10:36:12 +0800 Subject: [PATCH] refactor: close transactional boundary debt batch --- app/adapters/cache/backends.py | 26 +- app/adapters/cache/redis.py | 32 +- .../tools/impl/delete_download_history.py | 2 +- app/agent/tools/impl/query_download_tasks.py | 89 ++-- app/api/context.py | 11 +- app/api/dependencies/auth.py | 2 + app/api/dependencies/subscription.py | 21 +- app/api/endpoints/mfa.py | 17 +- app/api/endpoints/user.py | 36 +- app/application/agentdata.py | 10 +- app/application/chain/data.py | 10 +- app/application/chain/events.py | 11 +- app/application/download/tasks.py | 32 +- app/application/history.py | 265 ++++++++++- app/application/outbox.py | 288 +++++++++--- app/application/security/auth.py | 9 +- app/application/security/passkey.py | 70 +-- app/application/security/user.py | 153 +++--- app/application/security/userconfig.py | 65 ++- app/application/subscription/complete.py | 62 +-- app/application/subscription/delete.py | 84 ++-- app/application/subscription/identity.py | 35 +- app/application/subscription/mutation.py | 33 +- app/application/subscription/write.py | 13 +- app/application/transfer/workflow.py | 4 +- app/chain/_contracts.py | 4 + app/chain/_messaging.py | 60 ++- app/chain/_transfer.py | 54 +-- app/chain/download.py | 69 +-- app/chain/subscribe.py | 16 +- app/chain/transfer.py | 25 +- app/db/adapters/chain.py | 37 +- app/db/adapters/configuration.py | 91 ++++ app/db/adapters/history/__init__.py | 1 + app/db/adapters/history/download.py | 287 ++++++++++++ app/db/adapters/outbox.py | 413 ++++++++++------- app/db/adapters/subscription.py | 216 +++++++-- app/db/adapters/user.py | 122 ++++- app/db/engine.py | 172 ++++--- app/db/models/outbox.py | 2 +- app/db/models/passkey.py | 87 ++-- app/db/models/user.py | 12 +- app/db/models/userconfig.py | 29 +- app/db/oper/passkey.py | 41 +- app/db/oper/userconfig.py | 171 ++++--- app/locales/en-US.json | 1 + app/locales/zh-TW.json | 1 + app/runtime/cache.py | 87 +++- app/runtime/event/dispatch.py | 101 +++- app/runtime/events.py | 30 ++ app/sdk/_legacy/transfer.py | 3 +- app/sdk/cache.py | 3 +- app/startup/composition/context.py | 5 +- app/startup/composition/subscription.py | 16 +- app/startup/initializers/modules.py | 178 ++++--- app/testing/bootstrap.py | 4 +- database/versions/a9d4f2c7e6b1_3_0_18.py | 438 ++++++++++++++++++ docs/architecture-optimization-checklist.md | 79 ++-- docs/architecture-overview.md | 4 +- docs/architecture-refactor-roadmap.md | 10 +- docs/rules/05-architecture.md | 76 ++- docs/rules/10-data-and-persistent.md | 61 ++- scripts/architecture/event_facts.py | 3 +- tests/conftest.py | 108 +++-- .../architecture/coverage-baseline.json | 6 +- .../architecture/dependency-baseline.json | 74 ++- .../architecture/dependency-policy.json | 7 +- .../fixtures/architecture/mypy-baseline.json | 28 +- .../fixtures/architecture/ruff-baseline.json | 70 +-- .../runtime-contract-baseline.json | 175 +++---- .../startup-performance-baseline.json | 250 +++++----- tests/test_agent_channel_admin_identity.py | 4 +- tests/test_architecture_adapter_imports.py | 16 +- tests/test_architecture_dependencies.py | 188 ++++++++ tests/test_architecture_egress.py | 2 +- tests/test_architecture_event_facts.py | 10 +- tests/test_auth_ticket.py | 86 ++++ tests/test_cache_system.py | 111 ++++- tests/test_chain_durable_events.py | 52 ++- tests/test_configuration_initializer.py | 14 +- tests/test_configuration_ports.py | 4 +- tests/test_db_base_crud.py | 34 +- tests/test_db_config_user_queries.py | 234 +++++++--- tests/test_db_oper_layer.py | 155 +++++-- tests/test_download_chain.py | 44 +- tests/test_download_task_service.py | 22 +- tests/test_event_dispatch_snapshot.py | 30 ++ tests/test_feishu.py | 16 +- tests/test_host_runtime_context.py | 19 +- tests/test_mfa_passkey_registration_errors.py | 14 +- tests/test_mfa_passkey_transactions.py | 164 ++++++- tests/test_outbox.py | 284 +++++++++++- tests/test_passkey_challenge.py | 62 ++- tests/test_subscribe_create_command.py | 5 +- tests/test_subscribe_delete_command.py | 83 +++- tests/test_subscription_completion_command.py | 45 +- tests/test_subscription_mutation_outbox.py | 36 +- tests/test_transactional_download_history.py | 196 ++++++++ .../test_transfer_download_history_lookup.py | 16 +- tests/test_transfer_movie_collection.py | 22 +- tests/test_user_endpoint.py | 83 ++++ tests/test_user_name_unique_migration.py | 361 +++++++++++++++ tests/test_user_repository.py | 346 +++++++++++++- tests/test_user_service.py | 108 ++++- tests/test_userconfig_oper.py | 225 ++++++++- 105 files changed, 6507 insertions(+), 1691 deletions(-) create mode 100644 app/db/adapters/configuration.py create mode 100644 app/db/adapters/history/__init__.py create mode 100644 app/db/adapters/history/download.py create mode 100644 database/versions/a9d4f2c7e6b1_3_0_18.py create mode 100644 tests/test_auth_ticket.py create mode 100644 tests/test_transactional_download_history.py create mode 100644 tests/test_user_endpoint.py create mode 100644 tests/test_user_name_unique_migration.py diff --git a/app/adapters/cache/backends.py b/app/adapters/cache/backends.py index 92781b402..fa824c820 100644 --- a/app/adapters/cache/backends.py +++ b/app/adapters/cache/backends.py @@ -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, diff --git a/app/adapters/cache/redis.py b/app/adapters/cache/redis.py index 0ec257ddf..115c7397e 100644 --- a/app/adapters/cache/redis.py +++ b/app/adapters/cache/redis.py @@ -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: """ 删除缓存 diff --git a/app/agent/tools/impl/delete_download_history.py b/app/agent/tools/impl/delete_download_history.py index cf6d1159a..abfdc131c 100644 --- a/app/agent/tools/impl/delete_download_history.py +++ b/app/agent/tools/impl/delete_download_history.py @@ -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) diff --git a/app/agent/tools/impl/query_download_tasks.py b/app/agent/tools/impl/query_download_tasks.py index ae45c251f..44c1ce44d 100644 --- a/app/agent/tools/impl/query_download_tasks.py +++ b/app/agent/tools/impl/query_download_tasks.py @@ -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) diff --git a/app/api/context.py b/app/api/context.py index fbf1d73f4..6e3449716 100644 --- a/app/api/context.py +++ b/app/api/context.py @@ -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 diff --git a/app/api/dependencies/auth.py b/app/api/dependencies/auth.py index d9d5e18d5..547617436 100644 --- a/app/api/dependencies/auth.py +++ b/app/api/dependencies/auth.py @@ -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(), ) diff --git a/app/api/dependencies/subscription.py b/app/api/dependencies/subscription.py index de9abba6a..ba4ed5ce9 100644 --- a/app/api/dependencies/subscription.py +++ b/app/api/dependencies/subscription.py @@ -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, ) diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 5faeedb11..d0b8fc41f 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -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 相关接口 ==================== diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index 39b6b1305..8a0c9083f 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -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) diff --git a/app/application/agentdata.py b/app/application/agentdata.py index 12ba05d07..661cf347a 100644 --- a/app/application/agentdata.py +++ b/app/application/agentdata.py @@ -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: diff --git a/app/application/chain/data.py b/app/application/chain/data.py index f16116d94..a960bf814 100644 --- a/app/application/chain/data.py +++ b/app/application/chain/data.py @@ -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() diff --git a/app/application/chain/events.py b/app/application/chain/events.py index 21300e4f8..30a26c25f 100644 --- a/app/application/chain/events.py +++ b/app/application/chain/events.py @@ -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], diff --git a/app/application/download/tasks.py b/app/application/download/tasks.py index 71d1bf302..1cd267344 100644 --- a/app/application/download/tasks.py +++ b/app/application/download/tasks.py @@ -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 diff --git a/app/application/history.py b/app/application/history.py index ace1f7763..098070ed1 100644 --- a/app/application/history.py +++ b/app/application/history.py @@ -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) diff --git a/app/application/outbox.py b/app/application/outbox.py index 5feaee870..6a20e6679 100644 --- a/app/application/outbox.py +++ b/app/application/outbox.py @@ -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: diff --git a/app/application/security/auth.py b/app/application/security/auth.py index 675f46c03..a9955ce6a 100644 --- a/app/application/security/auth.py +++ b/app/application/security/auth.py @@ -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() diff --git a/app/application/security/passkey.py b/app/application/security/passkey.py index 4ed937736..280d1d3bd 100644 --- a/app/application/security/passkey.py +++ b/app/application/security/passkey.py @@ -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: """删除用户凭证。""" diff --git a/app/application/security/user.py b/app/application/security/user.py index 11f29ef30..3bed95103 100644 --- a/app/application/security/user.py +++ b/app/application/security/user.py @@ -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("渠道用户查询能力尚未配置") diff --git a/app/application/security/userconfig.py b/app/application/security/userconfig.py index 739656363..1d7b64bc5 100644 --- a/app/application/security/userconfig.py +++ b/app/application/security/userconfig.py @@ -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: diff --git a/app/application/subscription/complete.py b/app/application/subscription/complete.py index 563b96457..b3d7d118c 100644 --- a/app/application/subscription/complete.py +++ b/app/application/subscription/complete.py @@ -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: diff --git a/app/application/subscription/delete.py b/app/application/subscription/delete.py index 2bd2fd4e7..8d8ddfdbe 100644 --- a/app/application/subscription/delete.py +++ b/app/application/subscription/delete.py @@ -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, diff --git a/app/application/subscription/identity.py b/app/application/subscription/identity.py index 3a2f49eeb..4c9b54fa9 100644 --- a/app/application/subscription/identity.py +++ b/app/application/subscription/identity.py @@ -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) diff --git a/app/application/subscription/mutation.py b/app/application/subscription/mutation.py index 61afa86ff..5e18f6e81 100644 --- a/app/application/subscription/mutation.py +++ b/app/application/subscription/mutation.py @@ -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( diff --git a/app/application/subscription/write.py b/app/application/subscription/write.py index 17216cadb..b577b86aa 100644 --- a/app/application/subscription/write.py +++ b/app/application/subscription/write.py @@ -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) diff --git a/app/application/transfer/workflow.py b/app/application/transfer/workflow.py index 57eb10a1a..3ff04b582 100644 --- a/app/application/transfer/workflow.py +++ b/app/application/transfer/workflow.py @@ -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 diff --git a/app/chain/_contracts.py b/app/chain/_contracts.py index a1bb29534..e51414a8d 100644 --- a/app/chain/_contracts.py +++ b/app/chain/_contracts.py @@ -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: """调用异步模块能力。""" ... diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py index 6ca8b743c..c170e7d01 100644 --- a/app/chain/_messaging.py +++ b/app/chain/_messaging.py @@ -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, ) diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index 546a54345..2b83059ff 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -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 DTO(TransferTask.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]]: """ 获取整理用自定义识别词:优先使用下载时保存的快照,无快照(历史旧记录)时再按来源实时反查订阅。 diff --git a/app/chain/download.py b/app/chain/download.py index e55822d8c..56e6a99ca 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -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) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 8dd138066..82ca87ac2 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -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) # 下载文件信息 diff --git a/app/chain/transfer.py b/app/chain/transfer.py index fcb2233ca..25a0ba035 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -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 diff --git a/app/db/adapters/chain.py b/app/db/adapters/chain.py index 70f1a7e85..7ea06b1e1 100644 --- a/app/db/adapters/chain.py +++ b/app/db/adapters/chain.py @@ -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() diff --git a/app/db/adapters/configuration.py b/app/db/adapters/configuration.py new file mode 100644 index 000000000..dfa2d28bb --- /dev/null +++ b/app/db/adapters/configuration.py @@ -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() diff --git a/app/db/adapters/history/__init__.py b/app/db/adapters/history/__init__.py new file mode 100644 index 000000000..a78a40c71 --- /dev/null +++ b/app/db/adapters/history/__init__.py @@ -0,0 +1 @@ +"""历史持久化适配器包。""" diff --git a/app/db/adapters/history/download.py b/app/db/adapters/history/download.py new file mode 100644 index 000000000..9dba4977d --- /dev/null +++ b/app/db/adapters/history/download.py @@ -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) diff --git a/app/db/adapters/outbox.py b/app/db/adapters/outbox.py index 740a2005e..609d93cec 100644 --- a/app/db/adapters/outbox.py +++ b/app/db/adapters/outbox.py @@ -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), + ) diff --git a/app/db/adapters/subscription.py b/app/db/adapters/subscription.py index d4a387141..6d6538501 100644 --- a/app/db/adapters/subscription.py +++ b/app/db/adapters/subscription.py @@ -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("订阅新增统计完成凭证已失效") diff --git a/app/db/adapters/user.py b/app/db/adapters/user.py index 8d15813f6..afa68d20d 100644 --- a/app/db/adapters/user.py +++ b/app/db/adapters/user.py @@ -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 diff --git a/app/db/engine.py b/app/db/engine.py index 15ff13779..eeb137ca9 100644 --- a/app/db/engine.py +++ b/app/db/engine.py @@ -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}。" diff --git a/app/db/models/outbox.py b/app/db/models/outbox.py index 98255a016..a8bdbe54f 100644 --- a/app/db/models/outbox.py +++ b/app/db/models/outbox.py @@ -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 diff --git a/app/db/models/passkey.py b/app/db/models/passkey.py index a26b7f0d5..5542533de 100644 --- a/app/db/models/passkey.py +++ b/app/db/models/passkey.py @@ -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 diff --git a/app/db/models/user.py b/app/db/models/user.py index 53a54b603..aab1cee48 100644 --- a/app/db/models/user.py +++ b/app/db/models/user.py @@ -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 diff --git a/app/db/models/userconfig.py b/app/db/models/userconfig.py index 47be8ed46..9ec866373 100644 --- a/app/db/models/userconfig.py +++ b/app/db/models/userconfig.py @@ -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): """在调用方持有的事务中暂存指定用户配置删除。""" diff --git a/app/db/oper/passkey.py b/app/db/oper/passkey.py index 78a8e8e8a..361386101 100644 --- a/app/db/oper/passkey.py +++ b/app/db/oper/passkey.py @@ -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: """删除指定用户的凭证。""" diff --git a/app/db/oper/userconfig.py b/app/db/oper/userconfig.py index d5f22c5e9..a47ad10e1 100644 --- a/app/db/oper/userconfig.py +++ b/app/db/oper/userconfig.py @@ -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) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 360813013..596a36e0b 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -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", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 081da1e6b..1cb00385c 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -223,6 +223,7 @@ "密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位": "密碼需同時包含字母、數字、特殊字元中的至少兩項,且長度大於 6 位", "用户名不能为空": "使用者名稱不能為空", "用户名已被使用": "使用者名稱已被使用", + "必须保留至少一个启用的超级管理员": "必須保留至少一個啟用的超級管理員", "用户不存在": "使用者不存在", "已存在相同名称的工作流": "已存在相同名稱的工作流", "创建工作流成功": "建立工作流成功", diff --git a/app/runtime/cache.py b/app/runtime/cache.py index 17f5f4964..5604972c5 100644 --- a/app/runtime/cache.py +++ b/app/runtime/cache.py @@ -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: """ 根据配置获取缓存后端实例(内存或Redis),maxsize仅在未启用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: """ 删除缓存值 diff --git a/app/runtime/event/dispatch.py b/app/runtime/event/dispatch.py index 4a2750eeb..75fee5551 100644 --- a/app/runtime/event/dispatch.py +++ b/app/runtime/event/dispatch.py @@ -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, diff --git a/app/runtime/events.py b/app/runtime/events.py index 4548c7460..a270d9fb9 100644 --- a/app/runtime/events.py +++ b/app/runtime/events.py @@ -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]: diff --git a/app/sdk/_legacy/transfer.py b/app/sdk/_legacy/transfer.py index 24992e560..64ee79c2a 100644 --- a/app/sdk/_legacy/transfer.py +++ b/app/sdk/_legacy/transfer.py @@ -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) diff --git a/app/sdk/cache.py b/app/sdk/cache.py index 4c086a431..0aac74872 100644 --- a/app/sdk/cache.py +++ b/app/sdk/cache.py @@ -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", diff --git a/app/startup/composition/context.py b/app/startup/composition/context.py index 0ce9e2165..4e074359c 100644 --- a/app/startup/composition/context.py +++ b/app/startup/composition/context.py @@ -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) diff --git a/app/startup/composition/subscription.py b/app/startup/composition/subscription.py index 47cf44db7..e753be12e 100644 --- a/app/startup/composition/subscription.py +++ b/app/startup/composition/subscription.py @@ -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() diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 08485cc4d..cc065e483 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -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( diff --git a/app/testing/bootstrap.py b/app/testing/bootstrap.py index 5cafac9c6..7098b2024 100644 --- a/app/testing/bootstrap.py +++ b/app/testing/bootstrap.py @@ -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() diff --git a/database/versions/a9d4f2c7e6b1_3_0_18.py b/database/versions/a9d4f2c7e6b1_3_0_18.py new file mode 100644 index 000000000..4d3ec93cb --- /dev/null +++ b/database/versions/a9d4f2c7e6b1_3_0_18.py @@ -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, + ) diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index bfccb5daa..24ca34580 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -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 历史诊断 | 869 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | -| 覆盖率低水位 | Application 79.02%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | +| Ruff 历史诊断 | 841 | 低水位门禁通过,但规则集只覆盖 `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`/ORM,Subscription mutation 内部也消费 ORM,因而仍存在 Session 生命周期外 - detached/lazy-load 的潜在风险。公开 Subscription、Site、History QueryService 已经投影 DTO, - 属于完成项,不应重做。 +- Workflow、User 和 DownloadHistory 已迁入冻结 DTO 与 adapter-owned Session 投影; + 剩余 Chain/Agent raw data port 仍可返回 `Any`/ORM,Subscription 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 port,History 整体仍在执行中。 - [ ] `ChainDataPorts`/`AgentDataPorts` 可暂时保留为兼容聚合器,但字段必须显式、可类型检查。 - [ ] 以一个业务纵切面迁移并验证后,再迁移下一组,禁止一次替换所有 Oper。 - [ ] 增加 AST 门禁,禁止向 `ChainDataPorts`、`AgentDataPorts` 和新的 canonical use-case service 注入裸 Oper;SystemConfig 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 adapter;Memory/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 先迁移 Passkey、Backup,再按风险迁移外部调用。 +3. ARCH-108 的 Passkey 缓存边界已验证;下一纵切面是 Backup,之后按风险迁移外部调用。 **退出条件**:冷导入不启动线程;SCC 只剩 TMDB 精确豁免;Application/Chain 到 Adapter 的债务只降不增。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index f6a80f72f..e778e62a2 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -704,8 +704,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 853 | -| 内部导入边 | 6,979 | +| Python 模块 | 856 | +| 内部导入边 | 7,006 | | 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) | | Direct egress | 66(12 条待迁移债务,54 条精确 containment) | | Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) | diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index b3468884c..e657cb750 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -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 command,Session 内完成 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 均先 claim,complete/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 | 当前受控 869 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | +| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 841 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | | S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test | ### S5:Plugin、Agent、Domain、Startup 与最终收口 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index fd8685081..87a947770 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -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* diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md index b804cbd43..e5a76f820 100644 --- a/docs/rules/10-data-and-persistent.md +++ b/docs/rules/10-data-and-persistent.md @@ -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* diff --git a/scripts/architecture/event_facts.py b/scripts/architecture/event_facts.py index 74aacde67..57eee3fba 100644 --- a/scripts/architecture/event_facts.py +++ b/scripts/architecture/event_facts.py @@ -11,7 +11,7 @@ from typing import Any, Literal, TypeAlias _DEFAULT_IDENTITY = "" _DYNAMIC_IDENTITY = "" _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 diff --git a/tests/conftest.py b/tests/conftest.py index 6fe36a4f5..04c178036 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index efbde625c..84ec25b6f 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -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, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 656e567ca..a4952a78d 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 82a27296f..b32f0486b 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -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"}, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 620569d86..9388af840 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -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 }, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 5e6c9b1c0..36c6556d8 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -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 }, @@ -1621,9 +1556,6 @@ "tests/test_string_compat.py": { "I001": 1 }, - "tests/test_subscribe_delete_command.py": { - "I001": 1 - }, "tests/test_subscribe_oper.py": { "F401": 1 }, diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index a22e12b18..56ea9a638 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index bd9c36aff..5d31c512e 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -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": [ "后台任务登记器", diff --git a/tests/test_agent_channel_admin_identity.py b/tests/test_agent_channel_admin_identity.py index 3e71a27f4..43527ba83 100644 --- a/tests/test_agent_channel_admin_identity.py +++ b/tests/test_agent_channel_admin_identity.py @@ -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 diff --git a/tests/test_architecture_adapter_imports.py b/tests/test_architecture_adapter_imports.py index ae92da7e5..440787c54 100644 --- a/tests/test_architecture_adapter_imports.py +++ b/tests/test_architecture_adapter_imports.py @@ -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.*", diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 578a0cdae..3e56fc477 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -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() diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py index 8121a28d3..5b46fe486 100644 --- a/tests/test_architecture_egress.py +++ b/tests/test_architecture_egress.py @@ -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", diff --git a/tests/test_architecture_event_facts.py b/tests/test_architecture_event_facts.py index 09f9d47c1..5ee0cfe6c 100644 --- a/tests/test_architecture_event_facts.py +++ b/tests/test_architecture_event_facts.py @@ -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"] } == { ("", "send_event", "canonical_singleton", ("EventType.Alpha",)), + ( + "", + "send_event_strict", + "canonical_singleton", + ("EventType.Beta",), + ), ("", "send_event", "constructed_manager", ("EventType.Gamma",)), ("", "send_event", "existing_manager", ("ChainEventType.Delta",)), ("publish", "async_send_event", "canonical_singleton", ("EventType.Beta",)), diff --git a/tests/test_auth_ticket.py b/tests/test_auth_ticket.py new file mode 100644 index 000000000..2ad83d187 --- /dev/null +++ b/tests/test_auth_ticket.py @@ -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 diff --git a/tests/test_cache_system.py b/tests/test_cache_system.py index 9fe3ff8f8..5e1ebc6a9 100644 --- a/tests/test_cache_system.py +++ b/tests/test_cache_system.py @@ -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 异步客户端应使用阻塞连接池,避免高并发缓存读取立刻抛出连接耗尽错误。 diff --git a/tests/test_chain_durable_events.py b/tests/test_chain_durable_events.py index 6bb4de442..fdac1cd3e 100644 --- a/tests/test_chain_durable_events.py +++ b/tests/test_chain_durable_events.py @@ -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, diff --git a/tests/test_configuration_initializer.py b/tests/test_configuration_initializer.py index 60b22b21b..880ce3803 100644 --- a/tests/test_configuration_initializer.py +++ b/tests/test_configuration_initializer.py @@ -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", diff --git a/tests/test_configuration_ports.py b/tests/test_configuration_ports.py index 8f399b801..77d3ec58c 100644 --- a/tests/test_configuration_ports.py +++ b/tests/test_configuration_ports.py @@ -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"}), diff --git a/tests/test_db_base_crud.py b/tests/test_db_base_crud.py index 4164e4a47..f64d7ebbb 100644 --- a/tests/test_db_base_crud.py +++ b/tests/test_db_base_crud.py @@ -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) diff --git a/tests/test_db_config_user_queries.py b/tests/test_db_config_user_queries.py index d0acff5a9..cc22e11cd 100644 --- a/tests/test_db_config_user_queries.py +++ b/tests/test_db_config_user_queries.py @@ -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 + ) diff --git a/tests/test_db_oper_layer.py b/tests/test_db_oper_layer.py index 674019740..3b15c6954 100644 --- a/tests/test_db_oper_layer.py +++ b/tests/test_db_oper_layer.py @@ -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() diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 49a1f7499..34c523cd6 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -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" diff --git a/tests/test_download_task_service.py b/tests/test_download_task_service.py index 003b346f6..6e4d713ac 100644 --- a/tests/test_download_task_service.py +++ b/tests/test_download_task_service.py @@ -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 diff --git a/tests/test_event_dispatch_snapshot.py b/tests/test_event_dispatch_snapshot.py index 265fee96e..84a1ee496 100644 --- a/tests/test_event_dispatch_snapshot.py +++ b/tests/test_event_dispatch_snapshot.py @@ -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 = [] diff --git a/tests/test_feishu.py b/tests/test_feishu.py index c1ba408d3..26cc120c7 100644 --- a/tests/test_feishu.py +++ b/tests/test_feishu.py @@ -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): diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index 1706fccba..8457a246c 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -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(), diff --git a/tests/test_mfa_passkey_registration_errors.py b/tests/test_mfa_passkey_registration_errors.py index 04f17ebd0..1b3b7b0c3 100644 --- a/tests/test_mfa_passkey_registration_errors.py +++ b/tests/test_mfa_passkey_registration_errors.py @@ -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: diff --git a/tests/test_mfa_passkey_transactions.py b/tests/test_mfa_passkey_transactions.py index a7f449e89..cdbb359d0 100644 --- a/tests/test_mfa_passkey_transactions.py +++ b/tests/test_mfa_passkey_transactions.py @@ -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() diff --git a/tests/test_outbox.py b/tests/test_outbox.py index 9db9965fa..928f87c28 100644 --- a/tests/test_outbox.py +++ b/tests/test_outbox.py @@ -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:") diff --git a/tests/test_passkey_challenge.py b/tests/test_passkey_challenge.py index 89f795f3d..5072a4f69 100644 --- a/tests/test_passkey_challenge.py +++ b/tests/test_passkey_challenge.py @@ -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", + ) diff --git a/tests/test_subscribe_create_command.py b/tests/test_subscribe_create_command.py index 21575f031..15fd768e0 100644 --- a/tests/test_subscribe_create_command.py +++ b/tests/test_subscribe_create_command.py @@ -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: diff --git a/tests/test_subscribe_delete_command.py b/tests/test_subscribe_delete_command.py index ffc832e2f..17ad1aaa5 100644 --- a/tests/test_subscribe_delete_command.py +++ b/tests/test_subscribe_delete_command.py @@ -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"]) diff --git a/tests/test_subscription_completion_command.py b/tests/test_subscription_completion_command.py index dfc63a447..539f55efd 100644 --- a/tests/test_subscription_completion_command.py +++ b/tests/test_subscription_completion_command.py @@ -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: diff --git a/tests/test_subscription_mutation_outbox.py b/tests/test_subscription_mutation_outbox.py index 80fe21c20..ad602c91c 100644 --- a/tests/test_subscription_mutation_outbox.py +++ b/tests/test_subscription_mutation_outbox.py @@ -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", ] diff --git a/tests/test_transactional_download_history.py b/tests/test_transactional_download_history.py new file mode 100644 index 000000000..adea1148b --- /dev/null +++ b/tests/test_transactional_download_history.py @@ -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 diff --git a/tests/test_transfer_download_history_lookup.py b/tests/test_transfer_download_history_lookup.py index 172475a84..6b4276db6 100644 --- a/tests/test_transfer_download_history_lookup.py +++ b/tests/test_transfer_download_history_lookup.py @@ -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"), ) diff --git a/tests/test_transfer_movie_collection.py b/tests/test_transfer_movie_collection.py index 7c1ed1a3a..c412f27cd 100644 --- a/tests/test_transfer_movie_collection.py +++ b/tests/test_transfer_movie_collection.py @@ -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, ) diff --git a/tests/test_user_endpoint.py b/tests/test_user_endpoint.py new file mode 100644 index 000000000..88428a0ea --- /dev/null +++ b/tests/test_user_endpoint.py @@ -0,0 +1,83 @@ +"""用户端点的稳定业务错误映射测试。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.api.endpoints.user import create_user, delete_user_by_id, update_user +from app.application.security.user import ( + LastActiveSuperuserError, + UserNameConflictError, +) + + +@pytest.mark.asyncio +async def test_create_user_maps_database_name_conflict() -> None: + """创建竞态触发唯一约束时必须返回既有业务响应。""" + service = SimpleNamespace( + create=AsyncMock(side_effect=UserNameConflictError("duplicate")) + ) + user_input = SimpleNamespace(model_dump=lambda: { + "name": "duplicate", + "password": None, + }) + + response = await create_user( + service=service, + user_in=user_input, + current_user=SimpleNamespace(), + ) + + assert response.success is False + assert response.message == "用户已存在" + + +@pytest.mark.asyncio +async def test_update_user_maps_name_and_last_admin_conflicts() -> None: + """改名冲突和最后管理员保护必须保持稳定可读响应。""" + user_input = SimpleNamespace(model_dump=lambda: { + "id": 7, + "name": "renamed", + "password": None, + }) + service = SimpleNamespace( + get_by_id=AsyncMock(return_value=SimpleNamespace(id=7)), + update=AsyncMock(side_effect=UserNameConflictError("renamed")), + ) + + response = await update_user( + service=service, + user_in=user_input, + current_user=SimpleNamespace(), + ) + + assert response.success is False + assert response.message == "用户名已被使用" + + service.update.side_effect = LastActiveSuperuserError("admin") + response = await update_user( + service=service, + user_in=user_input, + current_user=SimpleNamespace(), + ) + assert response.success is False + assert response.message == "必须保留至少一个启用的超级管理员" + + +@pytest.mark.asyncio +async def test_delete_user_maps_last_admin_conflict() -> None: + """删除最后管理员时必须返回业务失败且不伪报成功。""" + service = SimpleNamespace( + get_by_id=AsyncMock(return_value=SimpleNamespace(id=7)), + delete=AsyncMock(side_effect=LastActiveSuperuserError("admin")), + ) + + response = await delete_user_by_id( + service=service, + user_id=7, + current_user=SimpleNamespace(), + ) + + assert response.success is False + assert response.message == "必须保留至少一个启用的超级管理员" diff --git a/tests/test_user_name_unique_migration.py b/tests/test_user_name_unique_migration.py new file mode 100644 index 000000000..442589446 --- /dev/null +++ b/tests/test_user_name_unique_migration.py @@ -0,0 +1,361 @@ +"""用户名唯一性 Alembic 迁移测试。""" + +import importlib + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.dialects import postgresql +from sqlalchemy.exc import IntegrityError +from sqlalchemy.schema import CreateTable + +from app.db.engine import _register_sqlite_foreign_keys +from app.db.models.passkey import PassKey +from app.db.models.user import User +from app.db.models.userconfig import UserConfig + +MIGRATION_MODULE = "database.versions.a9d4f2c7e6b1_3_0_18" + + +def _bind_migration(monkeypatch, connection): + """把用户名迁移绑定到隔离数据库连接。""" + migration = importlib.import_module(MIGRATION_MODULE) + monkeypatch.setattr( + migration, + "op", + Operations(MigrationContext.configure(connection)), + ) + return migration + + +def _legacy_metadata() -> tuple[sa.MetaData, sa.Table, sa.Table]: + """构造允许重名且带用户外键的旧版最小表结构。""" + metadata = sa.MetaData() + users = sa.Table( + "user", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Index("ix_user_name", "name"), + ) + passkeys = sa.Table( + "passkey", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("user.id"), + nullable=False, + ), + ) + return metadata, users, passkeys + + +def _user_rows(connection, users: sa.Table) -> list[dict]: + """按稳定身份返回用户快照。""" + return list(connection.execute(sa.select(users).order_by(users.c.id)).mappings()) + + +def _indexes(connection) -> dict[str, dict]: + """返回用户表的命名索引。""" + return {index["name"]: index for index in sa.inspect(connection).get_indexes("user")} + + +def test_user_model_declares_exact_unique_name_index() -> None: + """当前用户聚合模型必须声明唯一身份和精确级联约束。""" + indexes = {index.name: index for index in User.__table__.indexes} + + assert "ix_user_name" not in indexes + assert indexes["ux_user_name"].unique is True + assert tuple(column.name for column in indexes["ux_user_name"].columns) == ("name",) + user_config_fk = next(iter(UserConfig.__table__.foreign_keys)) + assert user_config_fk.target_fullname == "user.name" + assert user_config_fk.onupdate == "CASCADE" + assert user_config_fk.ondelete == "CASCADE" + assert UserConfig.__table__.c.username.nullable is False + assert UserConfig.__table__.c.key.nullable is False + constraints = {constraint.name: constraint for constraint in UserConfig.__table__.constraints} + assert {column.name for column in constraints["uq_userconfig_username_key"].columns} == {"username", "key"} + passkey_fk = next(iter(PassKey.__table__.foreign_keys)) + assert passkey_fk.target_fullname == "user.id" + assert passkey_fk.ondelete == "CASCADE" + + +def test_sqlite_connection_registration_enables_foreign_keys() -> None: + """每条宿主 SQLite 连接都必须实际启用外键检查。""" + engine = sa.create_engine("sqlite://") + _register_sqlite_foreign_keys(engine) + with engine.connect() as connection: + assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar_one() == 1 + engine.dispose() + + +def test_user_child_constraints_compile_for_postgresql() -> None: + """用户从属约束必须生成 PostgreSQL 可执行的级联 DDL。""" + user_config_ddl = str( + CreateTable(UserConfig.__table__).compile( + dialect=postgresql.dialect(), + ) + ) + passkey_ddl = str( + CreateTable(PassKey.__table__).compile( + dialect=postgresql.dialect(), + ) + ) + + assert ('FOREIGN KEY(username) REFERENCES "user" (name) ON DELETE CASCADE ON UPDATE CASCADE') in user_config_ddl + assert ('FOREIGN KEY(user_id) REFERENCES "user" (id) ON DELETE CASCADE') in passkey_ddl + + +def test_user_child_migration_repairs_data_and_cascades(monkeypatch) -> None: + """迁移清债后配置和 PassKey 不得脱离用户主体,并保持可逆。""" + metadata = sa.MetaData() + users = sa.Table( + "user", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("is_active", sa.Boolean()), + sa.Index("ix_user_name", "name"), + ) + configs = sa.Table( + "userconfig", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("username", sa.String(), nullable=True), + sa.Column("key", sa.String(), nullable=True), + sa.Column("value", sa.JSON()), + ) + passkeys = sa.Table( + "passkey", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("user.id"), + nullable=False, + ), + ) + engine = sa.create_engine("sqlite://") + with engine.connect() as connection: + metadata.create_all(connection) + connection.execute( + users.insert().values( + id=1, + name="old", + is_active=True, + ) + ) + connection.execute( + configs.insert(), + [ + {"id": 1, "username": "old", "key": "theme", "value": "first"}, + {"id": 2, "username": "old", "key": "theme", "value": "duplicate"}, + {"id": 3, "username": "ghost", "key": "theme", "value": "orphan"}, + {"id": 4, "username": None, "key": "theme", "value": "null-user"}, + {"id": 5, "username": "old", "key": None, "value": "null-key"}, + ], + ) + connection.execute( + passkeys.insert(), + [ + {"id": 11, "user_id": 1}, + {"id": 12, "user_id": 999}, + ], + ) + connection.commit() + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + connection.commit() + migration.upgrade() + connection.commit() + + assert connection.execute(sa.select(configs.c.id, configs.c.username, configs.c.key)).all() == [ + (1, "old", "theme") + ] + assert connection.execute(sa.select(passkeys.c.id)).scalars().all() == [11] + inspector = sa.inspect(connection) + config_columns = {column["name"]: column for column in inspector.get_columns("userconfig")} + assert config_columns["username"]["nullable"] is False + assert config_columns["key"]["nullable"] is False + config_fk = inspector.get_foreign_keys("userconfig")[0] + assert config_fk["options"] == { + "ondelete": "CASCADE", + "onupdate": "CASCADE", + } + passkey_fk = inspector.get_foreign_keys("passkey")[0] + assert passkey_fk["options"] == {"ondelete": "CASCADE"} + unique = {item["name"]: item for item in inspector.get_unique_constraints("userconfig")} + assert unique["uq_userconfig_username_key"]["column_names"] == [ + "username", + "key", + ] + + connection.exec_driver_sql("PRAGMA foreign_keys=ON") + connection.execute(users.update().values(name="new")) + assert connection.execute(sa.select(configs.c.username)).scalar_one() == "new" + with pytest.raises(IntegrityError): + with connection.begin_nested(): + connection.execute( + configs.insert().values( + username="new", + key="theme", + value="duplicate", + ) + ) + with pytest.raises(IntegrityError): + with connection.begin_nested(): + connection.execute( + configs.insert().values( + username="new", + key=None, + value="empty-key", + ) + ) + connection.execute(users.delete()) + assert connection.execute(sa.select(configs.c.id)).all() == [] + assert connection.execute(sa.select(passkeys.c.id)).all() == [] + connection.commit() + + migration.downgrade() + connection.commit() + downgraded = sa.inspect(connection) + columns = {column["name"]: column for column in downgraded.get_columns("userconfig")} + assert columns["username"]["nullable"] is True + assert columns["key"]["nullable"] is True + assert downgraded.get_foreign_keys("userconfig") == [] + assert downgraded.get_foreign_keys("passkey")[0]["options"] == {} + engine.dispose() + + +def test_migration_preserves_rows_and_foreign_keys_across_replay( + monkeypatch, +) -> None: + """升级应确定性修复重名并在降级、再升级时保留用户身份。""" + engine = sa.create_engine("sqlite://") + metadata, users, passkeys = _legacy_metadata() + with engine.begin() as connection: + connection.exec_driver_sql("PRAGMA foreign_keys = ON") + metadata.create_all(connection) + connection.execute( + users.insert(), + [ + {"id": 1, "name": "alice", "is_active": True}, + {"id": 2, "name": "alice", "is_active": True}, + {"id": 3, "name": "alice", "is_active": None}, + { + "id": 4, + "name": "alice__duplicate_2", + "is_active": True, + }, + {"id": 5, "name": "bob", "is_active": True}, + ], + ) + connection.execute( + passkeys.insert(), + [ + {"id": 11, "user_id": 2}, + {"id": 12, "user_id": 3}, + ], + ) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + upgraded_rows = _user_rows(connection, users) + migration.upgrade() + + assert _user_rows(connection, users) == upgraded_rows + assert upgraded_rows == [ + {"id": 1, "name": "alice", "is_active": True}, + { + "id": 2, + "name": "alice__duplicate_2_1", + "is_active": False, + }, + { + "id": 3, + "name": "alice__duplicate_3", + "is_active": False, + }, + { + "id": 4, + "name": "alice__duplicate_2", + "is_active": True, + }, + {"id": 5, "name": "bob", "is_active": True}, + ] + assert connection.execute(sa.select(passkeys.c.id, passkeys.c.user_id).order_by(passkeys.c.id)).all() == [ + (11, 2), + (12, 3), + ] + index = _indexes(connection)["ux_user_name"] + assert tuple(index["column_names"]) == ("name",) + assert index["unique"] == 1 + assert "ix_user_name" not in _indexes(connection) + + with pytest.raises(IntegrityError): + with connection.begin_nested(): + connection.execute( + users.insert().values( + id=6, + name="alice", + is_active=True, + ) + ) + + migration.downgrade() + downgraded_rows = _user_rows(connection, users) + assert downgraded_rows == upgraded_rows + assert "ux_user_name" not in _indexes(connection) + assert tuple(_indexes(connection)["ix_user_name"]["column_names"]) == ("name",) + connection.execute(users.insert().values(id=6, name="alice", is_active=True)) + + migration.upgrade() + + assert _user_rows(connection, users)[-1] == { + "id": 6, + "name": "alice__duplicate_6", + "is_active": False, + } + assert connection.execute(sa.select(passkeys.c.user_id).order_by(passkeys.c.id)).scalars().all() == [2, 3] + assert _indexes(connection)["ux_user_name"]["unique"] == 1 + + +def test_migration_repairs_malformed_canonical_index(monkeypatch) -> None: + """升级必须替换同名但列或唯一语义错误的部分迁移索引。""" + engine = sa.create_engine("sqlite://") + metadata, users, _passkeys = _legacy_metadata() + with engine.begin() as connection: + metadata.create_all(connection) + connection.execute( + users.insert(), + [{"id": 1, "name": "alice", "is_active": True}], + ) + connection.exec_driver_sql('CREATE INDEX ux_user_name ON "user" (is_active)') + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + + index = _indexes(connection)["ux_user_name"] + assert tuple(index["column_names"]) == ("name",) + assert index["unique"] == 1 + + +def test_migration_accepts_fresh_current_schema(monkeypatch) -> None: + """当前模型已建表时,重复升级不得创建冲突索引。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + User.__table__.create(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + index = _indexes(connection)["ux_user_name"] + assert tuple(index["column_names"]) == ("name",) + assert index["unique"] == 1 diff --git a/tests/test_user_repository.py b/tests/test_user_repository.py index 3d1991d7a..8479995a0 100644 --- a/tests/test_user_repository.py +++ b/tests/test_user_repository.py @@ -1,16 +1,51 @@ """用户冻结快照与短事务适配器测试。""" +import asyncio +import threading from contextlib import asynccontextmanager import pytest from sqlalchemy import create_engine, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import sessionmaker -from app.application.security.user import AuxiliaryUserCreate -from app.db.adapters.user import TransactionalUserRepository +from app.application.security.user import ( + AuxiliaryUserCreate, + LastActiveSuperuserError, + UserNameConflictError, + UserService, +) +from app.application.security.userconfig import UserConfigurationService +from app.db.adapters.configuration import TransactionalUserConfigurationRepository +from app.db.adapters.user import SqlAlchemyUserRepository, TransactionalUserRepository +from app.db.engine import _register_sqlite_foreign_keys +from app.db.models.passkey import PassKey from app.db.models.user import User -from app.db.uow import SqlAlchemyUnitOfWork +from app.db.models.userconfig import UserConfig +from app.db.oper.userconfig import UserConfigOper +from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork +from app.foundation.singleton import Singleton + + +class _InlineDatabaseExecutor: + """在测试协程内执行短同步配置发布。""" + + async def run(self, operation): + """执行并返回同步操作结果。""" + return operation() + + +def _configuration_service(sync_factory) -> UserConfigurationService: + """从当前测试数据库加载并返回用户配置发布服务。""" + Singleton._instances.pop((UserConfigOper, (), frozenset()), None) + snapshot = UserConfigOper() + repository = TransactionalUserConfigurationRepository(sync_factory, snapshot) + repository.load_snapshot() + return UserConfigurationService( + repository, + async_executor=_InlineDatabaseExecutor(), + ) @pytest.fixture @@ -62,6 +97,27 @@ def _insert_user(sync_factory, **overrides) -> int: return user.id +@asynccontextmanager +async def _user_write_context(database_path): + """创建包含用户聚合表的异步请求会话。""" + engine = create_async_engine(f"sqlite+aiosqlite:///{database_path}") + sync_engine = create_engine(f"sqlite:///{database_path}") + _register_sqlite_foreign_keys(engine.sync_engine) + _register_sqlite_foreign_keys(sync_engine) + sync_factory = sessionmaker(bind=sync_engine, expire_on_commit=False) + async with engine.begin() as connection: + await connection.run_sync(User.__table__.create) + await connection.run_sync(UserConfig.__table__.create) + await connection.run_sync(PassKey.__table__.create) + factory = async_sessionmaker(bind=engine, expire_on_commit=False) + try: + async with factory() as session: + yield session, sync_factory + finally: + await engine.dispose() + sync_engine.dispose() + + @pytest.mark.asyncio async def test_user_snapshots_are_detached_and_deeply_frozen(user_repository) -> None: """会话关闭后公开与认证快照仍可读,嵌套 JSON 不可被调用方修改。""" @@ -89,16 +145,16 @@ def test_auxiliary_create_commits_before_return(user_repository) -> None: """辅助认证创建成功返回时,新用户必须已对后续独立会话可见。""" repository, sync_factory = user_repository - created = repository.create_auxiliary(AuxiliaryUserCreate( - name="created", - hashed_password="hash", - )) + created = repository.create_auxiliary( + AuxiliaryUserCreate( + name="created", + hashed_password="hash", + ) + ) assert created.name == "created" with sync_factory() as session: - persisted = session.execute( - select(User).where(User.name == "created") - ).scalar_one() + persisted = session.execute(select(User).where(User.name == "created")).scalar_one() assert persisted.is_active is True assert persisted.is_superuser is False @@ -117,15 +173,15 @@ def test_auxiliary_create_rolls_back_commit_failure( monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit) with pytest.raises(RuntimeError, match="commit failed"): - repository.create_auxiliary(AuxiliaryUserCreate( - name="rolled-back", - hashed_password="hash", - )) + repository.create_auxiliary( + AuxiliaryUserCreate( + name="rolled-back", + hashed_password="hash", + ) + ) with sync_factory() as session: - assert session.execute( - select(User).where(User.name == "rolled-back") - ).scalar_one_or_none() is None + assert session.execute(select(User).where(User.name == "rolled-back")).scalar_one_or_none() is None def test_channel_binding_requires_one_active_unambiguous_owner(user_repository) -> None: @@ -155,11 +211,251 @@ def test_channel_binding_requires_all_supplied_identifiers(user_repository) -> N settings={"feishu_userid": "u-1", "feishu_openid": "o-1"}, ) - assert repository.find_name_by_bindings({ - "feishu_userid": "u-1", - "feishu_openid": "o-1", - }) == "feishu-user" - assert repository.find_name_by_bindings({ - "feishu_userid": "u-1", - "feishu_openid": "wrong", - }) is None + assert ( + repository.find_name_by_bindings( + { + "feishu_userid": "u-1", + "feishu_openid": "o-1", + } + ) + == "feishu-user" + ) + assert ( + repository.find_name_by_bindings( + { + "feishu_userid": "u-1", + "feishu_openid": "wrong", + } + ) + is None + ) + + +@pytest.mark.asyncio +async def test_user_rename_migrates_configuration_atomically(tmp_path) -> None: + """改名必须通过数据库级联迁移旧偏好。""" + async with _user_write_context(tmp_path / "rename.db") as (session, sync_factory): + user = User(name="old", is_active=True, is_superuser=False) + session.add_all( + [ + user, + UserConfig(username="old", key="theme", value="dark"), + ] + ) + await session.commit() + configuration = _configuration_service(sync_factory) + service = UserService( + SqlAlchemyUserRepository(session), + SqlAlchemyAsyncUnitOfWork(session), + configuration, + ) + + renamed = await service.update(user.id, {"name": "new"}) + + assert renamed is not None + assert renamed.name == "new" + configs = (await session.execute(select(UserConfig))).scalars().all() + assert [(item.username, item.key, item.value) for item in configs] == [("new", "theme", "dark")] + assert configuration.get("old", "theme") is None + assert configuration.get("new", "theme") == "dark" + + +@pytest.mark.asyncio +async def test_user_delete_removes_configuration_and_passkeys(tmp_path) -> None: + """删除用户必须在同一事务中清理字符串偏好和外键凭据。""" + async with _user_write_context(tmp_path / "delete.db") as (session, sync_factory): + user = User(name="member", is_active=True, is_superuser=False) + session.add(user) + await session.flush() + session.add_all( + [ + UserConfig(username="member", key="theme", value="dark"), + PassKey( + user_id=user.id, + credential_id="credential", + public_key="public-key", + ), + ] + ) + await session.commit() + configuration = _configuration_service(sync_factory) + service = UserService( + SqlAlchemyUserRepository(session), + SqlAlchemyAsyncUnitOfWork(session), + configuration, + ) + + await service.delete(user.id) + + assert (await session.execute(select(User))).scalar_one_or_none() is None + assert (await session.execute(select(UserConfig))).scalar_one_or_none() is None + assert (await session.execute(select(PassKey))).scalar_one_or_none() is None + assert configuration.get("member", "theme") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [{"is_active": False}, {"is_superuser": False}]) +async def test_last_active_superuser_cannot_be_disabled_or_demoted( + tmp_path, + payload, +) -> None: + """更新最后一个启用管理员时必须拒绝停用和降权。""" + async with _user_write_context(tmp_path / "last-admin-update.db") as (session, sync_factory): + admin = User(name="admin", is_active=True, is_superuser=True) + session.add(admin) + await session.commit() + service = UserService( + SqlAlchemyUserRepository(session), + SqlAlchemyAsyncUnitOfWork(session), + _configuration_service(sync_factory), + ) + + with pytest.raises(LastActiveSuperuserError): + await service.update(admin.id, payload) + + persisted = (await session.execute(select(User))).scalar_one() + assert persisted.is_active is True + assert persisted.is_superuser is True + + +@pytest.mark.asyncio +async def test_last_active_superuser_cannot_be_deleted(tmp_path) -> None: + """删除最后一个启用管理员必须完整回滚。""" + async with _user_write_context(tmp_path / "last-admin-delete.db") as (session, sync_factory): + admin = User(name="admin", is_active=True, is_superuser=True) + session.add(admin) + await session.commit() + service = UserService( + SqlAlchemyUserRepository(session), + SqlAlchemyAsyncUnitOfWork(session), + _configuration_service(sync_factory), + ) + + with pytest.raises(LastActiveSuperuserError): + await service.delete(admin.id) + + assert (await session.execute(select(User))).scalar_one().name == "admin" + + +@pytest.mark.asyncio +async def test_superuser_can_be_deleted_when_another_active_admin_remains(tmp_path) -> None: + """存在另一个启用管理员时允许删除目标管理员。""" + async with _user_write_context(tmp_path / "multiple-admins.db") as (session, sync_factory): + first = User(name="first", is_active=True, is_superuser=True) + second = User(name="second", is_active=True, is_superuser=True) + session.add_all([first, second]) + await session.commit() + service = UserService( + SqlAlchemyUserRepository(session), + SqlAlchemyAsyncUnitOfWork(session), + _configuration_service(sync_factory), + ) + + await service.delete(first.id) + + names = (await session.execute(select(User.name))).scalars().all() + assert names == ["second"] + + +@pytest.mark.asyncio +async def test_database_unique_constraint_is_mapped_to_application_error(tmp_path) -> None: + """并发前置检查失效后,数据库唯一约束仍返回稳定应用错误。""" + async with _user_write_context(tmp_path / "duplicate.db") as (session, sync_factory): + session.add(User(name="duplicate", is_active=True, is_superuser=False)) + await session.commit() + service = UserService( + SqlAlchemyUserRepository(session), + SqlAlchemyAsyncUnitOfWork(session), + _configuration_service(sync_factory), + ) + + with pytest.raises(UserNameConflictError): + await service.create({"name": "duplicate"}) + + users = (await session.execute(select(User))).scalars().all() + assert [item.name for item in users] == ["duplicate"] + + +async def _race_user_mutation_and_config_set( + *, + database_path, + mutation: str, + set_username: str, +) -> tuple[list[UserConfig], object]: + """并发执行用户身份变更和配置写入并返回最终数据库、快照。""" + async with _user_write_context(database_path) as (session, sync_factory): + user = User(name="old", is_active=True, is_superuser=False) + session.add_all( + [ + user, + UserConfig(username="old", key="theme", value="initial"), + ] + ) + await session.commit() + configuration = _configuration_service(sync_factory) + service = UserService( + SqlAlchemyUserRepository(session), + SqlAlchemyAsyncUnitOfWork(session), + configuration, + ) + barrier = threading.Barrier(2) + + def set_config() -> None: + """与用户事务同时尝试提交配置。""" + barrier.wait() + configuration.set(set_username, "theme", "concurrent") + + async def mutate_user() -> None: + """与配置事务同时提交改名或删除。""" + await asyncio.to_thread(barrier.wait) + if mutation == "rename": + await service.update(user.id, {"name": "new"}) + else: + await service.delete(user.id) + + results = await asyncio.gather( + asyncio.to_thread(set_config), + mutate_user(), + return_exceptions=True, + ) + for result in results: + if isinstance(result, BaseException): + assert isinstance(result, IntegrityError) + session.expire_all() + rows = list((await session.execute(select(UserConfig))).scalars().all()) + snapshot = configuration.get( + "new" if mutation == "rename" else "old", + "theme", + ) + return rows, snapshot + + +@pytest.mark.asyncio +@pytest.mark.parametrize("set_username", ["old", "new"]) +async def test_user_rename_and_config_set_are_consistent( + tmp_path, + set_username, +) -> None: + """改名与旧名/新名配置真并发后不得产生孤儿或覆盖已提交值。""" + rows, snapshot = await _race_user_mutation_and_config_set( + database_path=tmp_path / f"rename-set-{set_username}.db", + mutation="rename", + set_username=set_username, + ) + + assert {row.username for row in rows} == {"new"} + assert len(rows) == 1 + assert snapshot == rows[0].value + + +@pytest.mark.asyncio +async def test_user_delete_and_config_set_cannot_recreate_orphan(tmp_path) -> None: + """删除与配置真并发后数据库和快照都不得重建无主体配置。""" + rows, snapshot = await _race_user_mutation_and_config_set( + database_path=tmp_path / "delete-set.db", + mutation="delete", + set_username="old", + ) + + assert rows == [] + assert snapshot is None diff --git a/tests/test_user_service.py b/tests/test_user_service.py index d18108642..45e10e83d 100644 --- a/tests/test_user_service.py +++ b/tests/test_user_service.py @@ -4,7 +4,15 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from app.application.security.user import UserService +from app.application.security.user import UserService, UserSnapshot, UserUpdateResult + + +def _configuration_publisher() -> MagicMock: + """构造可检查的提交后用户配置发布端口。""" + publisher = MagicMock() + publisher.rename = AsyncMock() + publisher.delete = AsyncMock() + return publisher @pytest.mark.asyncio @@ -15,11 +23,14 @@ async def test_user_service_commits_staged_mutation() -> None: unit_of_work = MagicMock() unit_of_work.commit = AsyncMock() unit_of_work.rollback = AsyncMock() - service = UserService(repository, unit_of_work) + configuration = _configuration_publisher() + service = UserService(repository, unit_of_work, configuration) assert await service.create({"name": "demo"}) == {"id": 7} unit_of_work.commit.assert_awaited_once_with() unit_of_work.rollback.assert_not_awaited() + configuration.rename.assert_not_awaited() + configuration.delete.assert_not_awaited() @pytest.mark.asyncio @@ -30,10 +41,101 @@ async def test_user_service_rolls_back_failed_mutation() -> None: unit_of_work = MagicMock() unit_of_work.commit = AsyncMock() unit_of_work.rollback = AsyncMock() - service = UserService(repository, unit_of_work) + configuration = _configuration_publisher() + service = UserService(repository, unit_of_work, configuration) with pytest.raises(RuntimeError, match="write failed"): await service.delete(7) unit_of_work.rollback.assert_awaited_once_with() unit_of_work.commit.assert_not_awaited() + configuration.delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_user_service_rolls_back_failed_commit() -> None: + """提交阶段失败时也必须显式回滚当前用户聚合事务。""" + repository = MagicMock() + repository.async_update = AsyncMock( + return_value=UserUpdateResult( + user=UserSnapshot.build( + user_id=7, + name="demo", + email=None, + is_active=True, + is_superuser=False, + avatar=None, + is_otp=False, + permissions=None, + settings=None, + ), + previous_name="old", + ) + ) + unit_of_work = MagicMock() + unit_of_work.commit = AsyncMock(side_effect=RuntimeError("commit failed")) + unit_of_work.rollback = AsyncMock() + configuration = _configuration_publisher() + service = UserService(repository, unit_of_work, configuration) + + with pytest.raises(RuntimeError, match="commit failed"): + await service.update(7, {"name": "demo"}) + + unit_of_work.commit.assert_awaited_once_with() + unit_of_work.rollback.assert_awaited_once_with() + configuration.rename.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_user_service_publishes_rename_only_after_commit() -> None: + """用户改名必须先提交聚合事务,再同步用户名配置快照。""" + calls: list[str] = [] + repository = MagicMock() + repository.async_update = AsyncMock( + return_value=UserUpdateResult( + user=UserSnapshot.build( + user_id=7, + name="new", + email=None, + is_active=True, + is_superuser=False, + avatar=None, + is_otp=False, + permissions=None, + settings=None, + ), + previous_name="old", + ) + ) + unit_of_work = MagicMock() + unit_of_work.commit = AsyncMock(side_effect=lambda: calls.append("commit")) + unit_of_work.rollback = AsyncMock() + configuration = _configuration_publisher() + configuration.rename = AsyncMock(side_effect=lambda *_args: calls.append("publish")) + service = UserService(repository, unit_of_work, configuration) + + result = await service.update(7, {"name": "new"}) + + assert result is not None + assert result.name == "new" + assert calls == ["commit", "publish"] + configuration.rename.assert_awaited_once_with("old", "new") + + +@pytest.mark.asyncio +async def test_user_service_does_not_rollback_committed_publish_failure() -> None: + """提交后快照发布失败不得伪装成可回滚的用户事务。""" + repository = MagicMock() + repository.async_delete = AsyncMock(return_value="member") + unit_of_work = MagicMock() + unit_of_work.commit = AsyncMock() + unit_of_work.rollback = AsyncMock() + configuration = _configuration_publisher() + configuration.delete = AsyncMock(side_effect=RuntimeError("publish failed")) + service = UserService(repository, unit_of_work, configuration) + + with pytest.raises(RuntimeError, match="publish failed"): + await service.delete(7) + + unit_of_work.commit.assert_awaited_once_with() + unit_of_work.rollback.assert_not_awaited() diff --git a/tests/test_userconfig_oper.py b/tests/test_userconfig_oper.py index bef97b1d8..971b8926d 100644 --- a/tests/test_userconfig_oper.py +++ b/tests/test_userconfig_oper.py @@ -2,13 +2,18 @@ import asyncio import threading +from concurrent.futures import ThreadPoolExecutor from types import SimpleNamespace import pytest from app.application.security.userconfig import UserConfigurationService +from app.db.adapters.configuration import TransactionalUserConfigurationRepository +from app.db.models.user import User from app.db.models.userconfig import UserConfig from app.db.oper.userconfig import UserConfigOper +from app.db.session import SessionFactory +from app.db.uow import SqlAlchemyUnitOfWork from app.foundation.singleton import Singleton @@ -24,10 +29,26 @@ def _fresh_oper() -> UserConfigOper: """重置单例并显式加载用户配置快照。""" Singleton._instances.pop((UserConfigOper, (), frozenset()), None) oper = UserConfigOper() - oper.load_snapshot() + with SessionFactory() as session: + oper.load_snapshot(session) return oper +def _fresh_repository() -> tuple[ + TransactionalUserConfigurationRepository, + UserConfigOper, +]: + """构造使用新快照单例的用户配置短事务仓储。""" + oper = _fresh_oper() + return TransactionalUserConfigurationRepository(SessionFactory, oper), oper + + +def _add_users(db, *usernames: str) -> None: + """为配置用例创建受外键保护的真实用户主体。""" + db.watermark(User) + db.add(*[User(name=username, is_active=True, is_superuser=False) for username in usernames]) + + def test_constructor_does_not_query_database(monkeypatch): """构造用户配置对象时不打开数据库会话。""" Singleton._instances.pop((UserConfigOper, (), frozenset()), None) @@ -77,44 +98,50 @@ def test_load_snapshot_publishes_complete_dictionary(monkeypatch): @pytest.mark.asyncio async def test_async_write_uses_same_repository_rule(db) -> None: """异步入口提交后同步读取立即看到相同结果。""" - oper = _fresh_oper() + _add_users(db, "async-user") + repository, _oper = _fresh_repository() service = UserConfigurationService( - oper, + repository, async_executor=_ThreadDatabaseExecutor(), ) await service.async_set("async-user", "theme", "dark") assert service.get("async-user", "theme") == "dark" - assert UserConfig.get_by_key( - db.session, - username="async-user", - key="theme", - ).value == "dark" + assert ( + UserConfig.get_by_key( + db.session, + username="async-user", + key="theme", + ).value + == "dark" + ) -def test_existing_falsey_value_is_removed_from_db_but_kept_until_reload(db) -> None: - """已有用户配置写入假值时删除记录,当前快照仍保留该假值直到重载。""" +def test_existing_falsey_value_removes_database_and_snapshot_entry(db) -> None: + """已有用户配置写入假值时,数据库记录与快照同步移除。""" db.watermark(UserConfig) + _add_users(db, "falsey-user") oper = _fresh_oper() oper.set("falsey-user", "enabled", True) oper.set("falsey-user", "enabled", False) - assert UserConfig.get_by_key( - db.session, - username="falsey-user", - key="enabled", - ) is None - assert oper.get("falsey-user", "enabled") is False - - oper.load_snapshot() + assert ( + UserConfig.get_by_key( + db.session, + username="falsey-user", + key="enabled", + ) + is None + ) assert oper.get("falsey-user", "enabled") is None def test_falsey_value_without_existing_row_is_persisted(db) -> None: """不存在的用户配置写入假值时保留记录,兼容历史写入规则。""" db.watermark(UserConfig) + _add_users(db, "new-falsey-user") oper = _fresh_oper() oper.set("new-falsey-user", "enabled", False) @@ -127,3 +154,165 @@ def test_falsey_value_without_existing_row_is_persisted(db) -> None: assert persisted is not None assert persisted.value is False assert oper.get("new-falsey-user", "enabled") is False + + +def test_repository_deeply_isolates_inputs_and_outputs(db) -> None: + """嵌套可变配置在写入和读取两端都不得泄漏快照内部引用。""" + db.watermark(UserConfig) + _add_users(db, "isolated-user") + repository, _oper = _fresh_repository() + payload = {"nested": {"items": ["original"]}} + + repository.set("isolated-user", "layout", payload) + payload["nested"]["items"].append("input-mutated") + first = repository.get("isolated-user", "layout") + assert first == {"nested": {"items": ["original"]}} + + first["nested"]["items"].append("output-mutated") + assert repository.get("isolated-user", "layout") == {"nested": {"items": ["original"]}} + db.session.expire_all() + assert UserConfig.get_by_key( + db.session, + username="isolated-user", + key="layout", + ).value == {"nested": {"items": ["original"]}} + + +def test_commit_failure_never_publishes_snapshot(db, monkeypatch) -> None: + """数据库提交失败必须回滚暂存值,并保持既有快照不变。""" + db.watermark(UserConfig) + _add_users(db, "commit-user") + repository, oper = _fresh_repository() + repository.set("commit-user", "theme", "old") + published = threading.Event() + original_publish = oper.publish + + def track_publish(*args, **kwargs) -> None: + """记录任何不应发生的提交后发布。""" + published.set() + original_publish(*args, **kwargs) + + def fail_commit(_unit_of_work) -> None: + """模拟数据库在提交边界失败。""" + raise RuntimeError("commit failed") + + monkeypatch.setattr(oper, "publish", track_publish) + monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit) + + with pytest.raises(RuntimeError, match="commit failed"): + repository.set("commit-user", "theme", "new") + + assert published.is_set() is False + assert repository.get("commit-user", "theme") == "old" + db.session.expire_all() + assert ( + UserConfig.get_by_key( + db.session, + username="commit-user", + key="theme", + ).value + == "old" + ) + + +def test_publish_failure_reloads_committed_database_value(db, monkeypatch) -> None: + """提交成功但增量发布失败时,从数据库重载快照后再传播异常。""" + db.watermark(UserConfig) + _add_users(db, "reload-user") + repository, oper = _fresh_repository() + repository.set("reload-user", "theme", "old") + + def fail_publish(*_args, **_kwargs) -> None: + """模拟提交后的内存快照发布失败。""" + raise RuntimeError("publish failed") + + monkeypatch.setattr(oper, "publish", fail_publish) + + with pytest.raises(RuntimeError, match="publish failed"): + repository.set("reload-user", "theme", "committed") + + assert repository.get("reload-user", "theme") == "committed" + db.session.expire_all() + assert ( + UserConfig.get_by_key( + db.session, + username="reload-user", + key="theme", + ).value + == "committed" + ) + + +def test_rename_publish_failure_reloads_committed_database_state( + db, + monkeypatch, +) -> None: + """改名已提交但快照迁移失败时,重载后只能看到新用户名配置。""" + db.watermark(UserConfig) + _add_users(db, "old-name") + repository, oper = _fresh_repository() + repository.set("old-name", "theme", "dark") + user = User.get_by_name(db.session, "old-name") + user.name = "new-name" + db.session.commit() + + def fail_publish(*_args, **_kwargs) -> None: + """模拟改名提交后的增量快照发布失败。""" + raise RuntimeError("rename publish failed") + + monkeypatch.setattr(oper, "publish_rename", fail_publish) + + with pytest.raises(RuntimeError, match="rename publish failed"): + repository.publish_rename("old-name", "new-name") + + assert repository.get("old-name", "theme") is None + assert repository.get("new-name", "theme") == "dark" + + +def test_delete_publish_failure_reloads_committed_database_state( + db, + monkeypatch, +) -> None: + """用户删除已提交但快照删除失败时,重载后不得残留旧用户名配置。""" + db.watermark(UserConfig) + _add_users(db, "deleted-user") + repository, oper = _fresh_repository() + repository.set("deleted-user", "theme", "dark") + db.session.delete(User.get_by_name(db.session, "deleted-user")) + db.session.commit() + + def fail_publish(*_args, **_kwargs) -> None: + """模拟删除提交后的增量快照发布失败。""" + raise RuntimeError("delete publish failed") + + monkeypatch.setattr(oper, "publish_delete", fail_publish) + + with pytest.raises(RuntimeError, match="delete publish failed"): + repository.publish_delete("deleted-user") + + assert repository.get("deleted-user", "theme") is None + + +def test_concurrent_writes_keep_database_and_snapshot_consistent(db) -> None: + """同一配置的并发短事务按提交顺序发布,最终快照与数据库一致。""" + db.watermark(UserConfig) + _add_users(db, "concurrent-user") + repository, _oper = _fresh_repository() + values = [{"revision": revision} for revision in range(12)] + + with ThreadPoolExecutor(max_workers=6) as executor: + list( + executor.map( + lambda value: repository.set("concurrent-user", "layout", value), + values, + ) + ) + + db.session.expire_all() + persisted = UserConfig.get_by_key( + db.session, + username="concurrent-user", + key="layout", + ) + assert persisted is not None + assert repository.get("concurrent-user", "layout") == persisted.value