mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-01 21:47:50 +08:00
Merge remote-tracking branch 'upstream/v3' into codex/chore/floating-build-toolchain
# Conflicts: # docs/architecture-optimization-checklist.md # docs/architecture-refactor-roadmap.md
This commit is contained in:
Vendored
+25
-1
@@ -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,
|
||||
|
||||
Vendored
+21
-11
@@ -204,15 +204,21 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||
:param kwargs: 其他参数
|
||||
"""
|
||||
try:
|
||||
self._connect()
|
||||
redis_key = self.__make_redis_key(region, key)
|
||||
# 对值进行序列化
|
||||
serialized_value = serialize(value)
|
||||
kwargs.pop("maxsize", None)
|
||||
self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)
|
||||
self.store(key=key, value=value, ttl=ttl, region=region, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set key: {key} in region: {region}, error: {e}")
|
||||
|
||||
def store(self, key: str, value: Any, ttl: Optional[int] = None,
|
||||
region: Optional[str] = "DEFAULT", **kwargs: Any) -> None:
|
||||
"""严格写入缓存,连接或序列化故障向调用方传播。"""
|
||||
self._connect()
|
||||
redis_key = self.__make_redis_key(region, key)
|
||||
serialized_value = serialize(value)
|
||||
kwargs.pop("maxsize", None)
|
||||
stored = self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)
|
||||
if stored is not True:
|
||||
raise RuntimeError("Redis cache write was not acknowledged")
|
||||
|
||||
def exists(self, key: str, region: Optional[str] = "DEFAULT") -> bool:
|
||||
"""
|
||||
判断缓存键是否存在
|
||||
@@ -249,18 +255,22 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||
return None
|
||||
|
||||
def pop(self, key: str, region: Optional[str] = "DEFAULT") -> Optional[Any]:
|
||||
"""原子读取并删除缓存值。"""
|
||||
"""兼容旧调用;后端故障记录日志并返回空值。"""
|
||||
try:
|
||||
self._connect()
|
||||
redis_key = self.__make_redis_key(region, key)
|
||||
value = self.client.getdel(redis_key)
|
||||
return deserialize(value) if value is not None else None
|
||||
return self.consume(key=key, region=region)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to pop key: {key} in region: {region}, error: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def consume(self, key: str, region: Optional[str] = "DEFAULT") -> Optional[Any]:
|
||||
"""通过单条 GETDEL 严格领取缓存值。"""
|
||||
self._connect()
|
||||
redis_key = self.__make_redis_key(region, key)
|
||||
value = self.client.getdel(redis_key)
|
||||
return deserialize(value) if value is not None else None
|
||||
|
||||
def delete(self, key: str, region: Optional[str] = "DEFAULT") -> None:
|
||||
"""
|
||||
删除缓存
|
||||
|
||||
@@ -37,7 +37,7 @@ class DeleteDownloadHistoryTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: history_id={history_id}")
|
||||
|
||||
try:
|
||||
await get_agent_download_history_port().async_delete_history(history_id)
|
||||
await get_agent_download_history_port().async_delete(history_id)
|
||||
return f"下载历史记录 ID: {history_id} 已成功删除"
|
||||
except Exception as e:
|
||||
logger.error(f"删除下载历史记录失败: {e}", exc_info=True)
|
||||
|
||||
@@ -7,10 +7,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.application.agentdata import get_agent_download_history_port
|
||||
from app.application.history import DownloadHistorySnapshot
|
||||
from app.chain.download import DownloadChain
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.transfer import DownloaderTorrent
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.transfer import DownloaderTorrent, DownloadTaskMedia
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, TorrentQueryStatus, media_type_to_agent
|
||||
|
||||
|
||||
@@ -99,48 +101,75 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _apply_download_history(
|
||||
torrent: DownloaderTorrent, history: Any
|
||||
torrent: DownloaderTorrent, history: Optional[DownloadHistorySnapshot]
|
||||
) -> None:
|
||||
"""将下载历史中的补充信息回填到下载任务结果中。"""
|
||||
if not history:
|
||||
return
|
||||
if hasattr(torrent, "media"):
|
||||
media_payload = {
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
"poster": history.poster,
|
||||
"media_source": history.media_source,
|
||||
"media_id": history.media_id,
|
||||
}
|
||||
music_note = (
|
||||
(history.note or {}).get("music")
|
||||
if isinstance(history.note, dict)
|
||||
else None
|
||||
) or {}
|
||||
music_media = music_note.get("media") or {}
|
||||
media = DownloadTaskMedia(
|
||||
type=history.type,
|
||||
title=history.title,
|
||||
season=history.seasons,
|
||||
episode=history.episodes,
|
||||
image=history.image,
|
||||
poster=history.poster,
|
||||
media_source=history.media_source,
|
||||
media_id=history.media_id,
|
||||
)
|
||||
music_media = QueryDownloadTasksTool._history_music_media(history)
|
||||
if media_type_to_agent(history.type) == "music":
|
||||
media_payload.update({
|
||||
"music_type": music_media.get("music_type") or MUSIC_ENTITY_RECORDING,
|
||||
"artists": music_media.get("artists") or [],
|
||||
"album": music_media.get("album"),
|
||||
"album_id": music_media.get("album_id"),
|
||||
"total_tracks": music_media.get("total_tracks"),
|
||||
"track_number": music_media.get("track_number"),
|
||||
})
|
||||
torrent.media = media_payload
|
||||
music_type = music_media.get("music_type")
|
||||
artists = music_media.get("artists")
|
||||
album = music_media.get("album")
|
||||
album_id = music_media.get("album_id")
|
||||
total_tracks = music_media.get("total_tracks")
|
||||
track_number = music_media.get("track_number")
|
||||
media.music_type = (
|
||||
music_type if isinstance(music_type, str) else MUSIC_ENTITY_RECORDING
|
||||
)
|
||||
media.artists = (
|
||||
[artist for artist in artists if isinstance(artist, str)]
|
||||
if isinstance(artists, list)
|
||||
else []
|
||||
)
|
||||
media.album = album if isinstance(album, str) else None
|
||||
media.album_id = album_id if isinstance(album_id, str) else None
|
||||
media.total_tracks = (
|
||||
total_tracks
|
||||
if isinstance(total_tracks, int) and not isinstance(total_tracks, bool)
|
||||
else None
|
||||
)
|
||||
media.track_number = (
|
||||
track_number
|
||||
if isinstance(track_number, int) and not isinstance(track_number, bool)
|
||||
else None
|
||||
)
|
||||
torrent.media = media
|
||||
if hasattr(torrent, "username"):
|
||||
torrent.username = history.username
|
||||
torrent.userid = history.userid
|
||||
|
||||
@staticmethod
|
||||
def _history_music_media(
|
||||
history: DownloadHistorySnapshot,
|
||||
) -> dict[str, JsonData]:
|
||||
"""从冻结历史备注中读取经过结构校验的音乐媒体字段。"""
|
||||
note = history.note
|
||||
if not isinstance(note, dict):
|
||||
return {}
|
||||
music = note.get("music")
|
||||
if not isinstance(music, dict):
|
||||
return {}
|
||||
media = music.get("media")
|
||||
return media if isinstance(media, dict) else {}
|
||||
|
||||
@classmethod
|
||||
def _load_history_map(
|
||||
cls, torrents: List[DownloaderTorrent]
|
||||
) -> Dict[str, Any]:
|
||||
) -> Dict[str, DownloadHistorySnapshot]:
|
||||
"""批量加载下载历史,避免逐条查询形成 N+1。"""
|
||||
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
|
||||
hashes = [torrent.hash for torrent in torrents if torrent.hash]
|
||||
if not hashes:
|
||||
return {}
|
||||
return get_agent_download_history_port().get_by_hashes(hashes)
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -101,7 +101,11 @@ def _verify_passkey_and_update(
|
||||
)
|
||||
|
||||
if success:
|
||||
service.update_last_used(passkey, new_sign_count)
|
||||
success = service.compare_and_update_sign_count(
|
||||
passkey_id=passkey.id,
|
||||
expected_sign_count=int(passkey.sign_count or 0),
|
||||
sign_count=new_sign_count,
|
||||
)
|
||||
|
||||
return success, new_sign_count
|
||||
|
||||
@@ -142,16 +146,11 @@ async def mfa_status(
|
||||
service: UserService = Depends(get_user_service),
|
||||
) -> Any:
|
||||
"""
|
||||
检查指定用户是否启用了二次验证
|
||||
检查指定启用用户是否开启 OTP,并隐藏账号不存在或禁用状态。
|
||||
"""
|
||||
user = await service.get_by_name(username)
|
||||
if not user:
|
||||
return _SchemaResponse(success=False, message="用户不存在")
|
||||
|
||||
# 检查是否启用了OTP
|
||||
has_otp = user.is_otp
|
||||
|
||||
return _SchemaResponse(success=True, data={"enabled": bool(has_otp)})
|
||||
has_otp = bool(user and user.is_active and user.is_otp)
|
||||
return _SchemaResponse(success=True, data={"enabled": has_otp})
|
||||
|
||||
|
||||
# ==================== OTP 相关接口 ====================
|
||||
|
||||
+23
-13
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from app.application.history import DownloadHistoryRepository
|
||||
from app.application.security.user import ChainUserRepository
|
||||
|
||||
AgentDataFactory = Callable[[], Any]
|
||||
@@ -150,9 +151,12 @@ def get_agent_transfer_history_port() -> Any:
|
||||
return get_agent_data_ports().transfer_history()
|
||||
|
||||
|
||||
def get_agent_download_history_port() -> Any:
|
||||
"""创建 Agent 下载历史数据端口实例。"""
|
||||
return get_agent_data_ports().download_history()
|
||||
def get_agent_download_history_port() -> DownloadHistoryRepository:
|
||||
"""创建 Agent 类型化下载历史数据端口实例。"""
|
||||
return cast(
|
||||
DownloadHistoryRepository,
|
||||
get_agent_data_ports().download_history(),
|
||||
)
|
||||
|
||||
|
||||
def get_agent_plugin_data_port() -> Any:
|
||||
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.download.failures import DownloadFailureRepository
|
||||
from app.application.history import DownloadHistoryRepository
|
||||
from app.application.mediaserver import MediaServerRepository
|
||||
from app.application.security.user import ChainUserRepository
|
||||
from app.application.transfer.execution import TransferExecutionRepository
|
||||
@@ -18,6 +19,7 @@ from app.application.transfer.workflow import TransferAdmissionRepository
|
||||
|
||||
OperFactory = Callable[[], Any]
|
||||
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
|
||||
DownloadHistoryRepositoryFactory = Callable[[], DownloadHistoryRepository]
|
||||
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
|
||||
ChainUserRepositoryFactory = Callable[[], ChainUserRepository]
|
||||
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
|
||||
@@ -30,7 +32,7 @@ class ChainDataPorts:
|
||||
|
||||
site: OperFactory
|
||||
subscribe: OperFactory
|
||||
download_history: OperFactory
|
||||
download_history: DownloadHistoryRepositoryFactory
|
||||
transfer_history: OperFactory
|
||||
transfer_pending: TransferAdmissionRepositoryFactory
|
||||
transfer_execution: TransferExecutionRepositoryFactory
|
||||
@@ -46,7 +48,7 @@ def configure_chain_data_ports(
|
||||
*,
|
||||
site: OperFactory,
|
||||
subscribe: OperFactory,
|
||||
download_history: OperFactory,
|
||||
download_history: DownloadHistoryRepositoryFactory,
|
||||
transfer_history: OperFactory,
|
||||
transfer_pending: TransferAdmissionRepositoryFactory,
|
||||
transfer_execution: TransferExecutionRepositoryFactory,
|
||||
@@ -86,8 +88,8 @@ def get_chain_subscribe_port() -> Any:
|
||||
return get_chain_data_ports().subscribe()
|
||||
|
||||
|
||||
def get_chain_download_history_port() -> Any:
|
||||
"""创建下载历史数据端口实例。"""
|
||||
def get_chain_download_history_port() -> DownloadHistoryRepository:
|
||||
"""创建类型化的下载历史查询与事务端口实例。"""
|
||||
return get_chain_data_ports().download_history()
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ from pathlib import Path
|
||||
from typing import Any, Protocol, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
|
||||
from app.application.history import (
|
||||
DownloadFileWrite,
|
||||
DownloadHistoryWrite,
|
||||
TransferHistoryRecord,
|
||||
TransferHistoryWriter,
|
||||
)
|
||||
from app.application.transfer.execution import TransferSettlementResult
|
||||
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
@@ -27,8 +32,8 @@ class ChainDurableEventWriter(Protocol):
|
||||
def download_added(
|
||||
self,
|
||||
*,
|
||||
history_payload: dict[str, Any],
|
||||
file_payloads: list[dict[str, Any]],
|
||||
history: DownloadHistoryWrite,
|
||||
files: tuple[DownloadFileWrite, ...],
|
||||
event_payload: dict[str, Any],
|
||||
after_commit: Callable[[], None],
|
||||
publish: Callable[[dict[str, Any]], None],
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from app.schemas.transfer import DownloaderTorrent
|
||||
from app.application.history import DownloadHistorySnapshot
|
||||
from app.schemas.transfer import DownloaderTorrent, DownloadTaskMedia
|
||||
from app.schemas.types import TorrentStatus
|
||||
|
||||
|
||||
@@ -12,7 +13,10 @@ class DownloadTaskService:
|
||||
def __init__(
|
||||
self,
|
||||
list_torrents: Callable[..., List[DownloaderTorrent]],
|
||||
get_history_by_hashes: Callable[[list[str]], dict],
|
||||
get_history_by_hashes: Callable[
|
||||
[list[str]],
|
||||
dict[str, DownloadHistorySnapshot],
|
||||
],
|
||||
start_torrents: Callable[..., bool],
|
||||
stop_torrents: Callable[..., bool],
|
||||
remove_torrents: Callable[..., bool],
|
||||
@@ -36,20 +40,22 @@ class DownloadTaskService:
|
||||
[torrent.hash for torrent in torrents if torrent.hash]
|
||||
)
|
||||
for torrent in torrents:
|
||||
if not torrent.hash:
|
||||
continue
|
||||
history = history_map.get(torrent.hash)
|
||||
if not history:
|
||||
continue
|
||||
torrent.media = {
|
||||
"media_source": history.media_source,
|
||||
"media_id": history.media_id,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.poster,
|
||||
"poster": history.poster,
|
||||
"backdrop": history.image,
|
||||
}
|
||||
torrent.media = DownloadTaskMedia(
|
||||
media_source=history.media_source,
|
||||
media_id=history.media_id,
|
||||
type=history.type,
|
||||
title=history.title,
|
||||
season=history.seasons,
|
||||
episode=history.episodes,
|
||||
image=history.poster,
|
||||
poster=history.poster,
|
||||
backdrop=history.image,
|
||||
)
|
||||
torrent.site_name = history.torrent_site
|
||||
torrent.userid = history.userid
|
||||
torrent.username = history.username
|
||||
|
||||
+250
-15
@@ -1,23 +1,24 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Protocol, Union
|
||||
from typing import Any, Callable, Dict, NoReturn, Optional, Protocol, Union
|
||||
|
||||
from app.application.configuration import TransferRetryConfig, get_transfer_retry_config
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.foundation.text import cut as jieba_cut
|
||||
from app.runtime.cache import TTLCache
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.history import (
|
||||
DownloadHistory as DownloadHistoryView,
|
||||
TransferHistory as TransferHistoryView,
|
||||
DownloadHistory,
|
||||
TransferHistory,
|
||||
TransferHistoryPage,
|
||||
)
|
||||
from app.schemas.workflow import FileItem
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
# 失败重试次数的合法区间。下界为 1:一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)
|
||||
# 不该让文件永久漏整理,所以不允许关闭重试;上界为 10:永远识别不出的文件重试再多
|
||||
@@ -99,6 +100,240 @@ class HistoryMutationResult:
|
||||
message: str = ""
|
||||
|
||||
|
||||
class _FrozenJsonDict(dict[str, JsonData]):
|
||||
"""保留 JSON 字典读取与序列化行为,并拒绝常规原地修改。"""
|
||||
|
||||
def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn:
|
||||
"""拒绝修改已经进入历史快照的嵌套 JSON。"""
|
||||
raise TypeError("下载历史快照 JSON 不可修改")
|
||||
|
||||
__setitem__ = _reject_mutation
|
||||
__delitem__ = _reject_mutation
|
||||
__ior__ = _reject_mutation
|
||||
clear = _reject_mutation
|
||||
pop = _reject_mutation
|
||||
popitem = _reject_mutation
|
||||
setdefault = _reject_mutation
|
||||
update = _reject_mutation
|
||||
|
||||
|
||||
class _FrozenJsonList(list[JsonData]):
|
||||
"""保留 JSON 数组读取与序列化行为,并拒绝常规原地修改。"""
|
||||
|
||||
def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn:
|
||||
"""拒绝修改已经进入历史快照的嵌套 JSON。"""
|
||||
raise TypeError("下载历史快照 JSON 不可修改")
|
||||
|
||||
__setitem__ = _reject_mutation
|
||||
__delitem__ = _reject_mutation
|
||||
__iadd__ = _reject_mutation
|
||||
__imul__ = _reject_mutation
|
||||
append = _reject_mutation
|
||||
clear = _reject_mutation
|
||||
extend = _reject_mutation
|
||||
insert = _reject_mutation
|
||||
pop = _reject_mutation
|
||||
remove = _reject_mutation
|
||||
reverse = _reject_mutation
|
||||
sort = _reject_mutation
|
||||
|
||||
|
||||
def _freeze_json(value: JsonData) -> JsonData:
|
||||
"""递归复制并冻结 JSON 容器,避免快照内部仍暴露可变引用。"""
|
||||
if isinstance(value, dict):
|
||||
return _FrozenJsonDict({key: _freeze_json(item) for key, item in value.items()})
|
||||
if isinstance(value, list):
|
||||
return _FrozenJsonList([_freeze_json(item) for item in value])
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadHistorySnapshot:
|
||||
"""脱离数据库会话后供宿主下载、订阅和整理用例读取的历史快照。"""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
type: str
|
||||
title: str
|
||||
year: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
poster: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
torrent_name: Optional[str] = None
|
||||
torrent_description: Optional[str] = None
|
||||
torrent_site: Optional[str] = None
|
||||
userid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
note: Optional[JsonData] = None
|
||||
media_category: Optional[str] = None
|
||||
episode_group: Optional[str] = None
|
||||
custom_words: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""递归冻结可变 JSON 字段,使 DTO 在所有层级都不可修改。"""
|
||||
object.__setattr__(self, "note", _freeze_json(self.note))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFileSnapshot:
|
||||
"""脱离数据库会话的下载文件关联快照。"""
|
||||
|
||||
id: int
|
||||
downloader: Optional[str]
|
||||
download_hash: Optional[str]
|
||||
fullpath: Optional[str]
|
||||
savepath: Optional[str]
|
||||
filepath: Optional[str]
|
||||
torrentname: Optional[str]
|
||||
state: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadHistoryWrite:
|
||||
"""一次下载成功后写入历史所需的完整稳定数据。"""
|
||||
|
||||
path: str
|
||||
type: str
|
||||
title: str
|
||||
year: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
poster: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
torrent_name: Optional[str] = None
|
||||
torrent_description: Optional[str] = None
|
||||
torrent_site: Optional[str] = None
|
||||
userid: Optional[Union[str, int]] = None
|
||||
username: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
note: Optional[JsonData] = None
|
||||
media_category: Optional[str] = None
|
||||
episode_group: Optional[str] = None
|
||||
custom_words: Optional[str] = None
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""返回可交给持久化适配器的独立字段副本。"""
|
||||
payload = asdict(self)
|
||||
if self.media_source is not None:
|
||||
payload["media_source"] = str(self.media_source)
|
||||
if self.userid is not None:
|
||||
payload["userid"] = str(self.userid)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFileWrite:
|
||||
"""下载任务关联文件的一次稳定写入。"""
|
||||
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
fullpath: Optional[str] = None
|
||||
savepath: Optional[str] = None
|
||||
filepath: Optional[str] = None
|
||||
torrentname: Optional[str] = None
|
||||
state: int = 1
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""返回可交给持久化适配器的独立字段副本。"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class DownloadHistoryQueryPort(Protocol):
|
||||
"""宿主下载、订阅、Agent 和整理用例所需的类型化查询端口。"""
|
||||
|
||||
def get_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载任务 Hash 返回最新历史快照。"""
|
||||
...
|
||||
|
||||
def get_by_hashes(
|
||||
self,
|
||||
download_hashes: list[str],
|
||||
) -> dict[str, DownloadHistorySnapshot]:
|
||||
"""批量返回以下载任务 Hash 为键的最新历史快照。"""
|
||||
...
|
||||
|
||||
def get_by_path(self, path: str) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载保存路径返回历史快照。"""
|
||||
...
|
||||
|
||||
def get_by_media_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""按规范媒体身份返回历史快照。"""
|
||||
...
|
||||
|
||||
def get_file_by_fullpath(
|
||||
self,
|
||||
fullpath: str,
|
||||
) -> Optional[DownloadFileSnapshot]:
|
||||
"""按完整路径返回一条有效下载文件快照。"""
|
||||
...
|
||||
|
||||
def get_files_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
state: Optional[int] = None,
|
||||
) -> list[DownloadFileSnapshot]:
|
||||
"""按下载任务 Hash 返回文件快照。"""
|
||||
...
|
||||
|
||||
def get_files_by_savepath(self, savepath: str) -> list[DownloadFileSnapshot]:
|
||||
"""按保存目录返回下载文件快照。"""
|
||||
...
|
||||
|
||||
async def async_list_by_page(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""异步按下载时间倒序分页返回历史快照。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadHistoryWritePort(Protocol):
|
||||
"""下载历史新增和删除所需的类型化事务端口。"""
|
||||
|
||||
def add(
|
||||
self,
|
||||
history: DownloadHistoryWrite,
|
||||
files: tuple[DownloadFileWrite, ...] = (),
|
||||
) -> int:
|
||||
"""在单一事务中新增历史与关联文件并返回历史 ID。"""
|
||||
...
|
||||
|
||||
async def async_delete(self, history_id: int) -> None:
|
||||
"""在独立异步事务中删除指定历史。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadHistoryRepository(
|
||||
DownloadHistoryQueryPort,
|
||||
DownloadHistoryWritePort,
|
||||
Protocol,
|
||||
):
|
||||
"""组合宿主所需全部下载历史查询和变更能力。"""
|
||||
|
||||
|
||||
class AsyncDownloadHistoryQueryRepository(Protocol):
|
||||
"""下载历史只读用例需要的最小异步持久化端口。"""
|
||||
|
||||
@@ -106,7 +341,7 @@ class AsyncDownloadHistoryQueryRepository(Protocol):
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[Any]:
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""按下载时间倒序分页读取历史记录。"""
|
||||
...
|
||||
|
||||
@@ -228,10 +463,10 @@ class HistoryQueryService:
|
||||
*,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistoryView]:
|
||||
) -> list[DownloadHistory]:
|
||||
"""分页读取下载历史并转换为稳定的接口 DTO。"""
|
||||
records = await self._download_repository.async_list_by_page(page, count)
|
||||
return [DownloadHistoryView.model_validate(record) for record in records]
|
||||
return [DownloadHistory.model_validate(record) for record in records]
|
||||
|
||||
async def list_transfer(
|
||||
self,
|
||||
@@ -276,23 +511,23 @@ class HistoryQueryService:
|
||||
total = await self._transfer_repository.async_count(status=status)
|
||||
|
||||
return TransferHistoryPage(
|
||||
list=[TransferHistoryView.model_validate(record) for record in records],
|
||||
list=[TransferHistory.model_validate(record) for record in records],
|
||||
total=int(total or 0),
|
||||
)
|
||||
|
||||
async def get_transfer(self, history_id: int) -> Optional[TransferHistoryView]:
|
||||
async def get_transfer(self, history_id: int) -> Optional[TransferHistory]:
|
||||
"""读取单条整理历史 DTO,不向调用方泄漏 ORM 实例。"""
|
||||
record = await self._transfer_repository.async_get(history_id)
|
||||
if record is None:
|
||||
return None
|
||||
return TransferHistoryView.model_validate(record)
|
||||
return TransferHistory.model_validate(record)
|
||||
|
||||
async def get_transfers(
|
||||
self,
|
||||
history_ids: list[int],
|
||||
) -> tuple[list[TransferHistoryView], list[int]]:
|
||||
) -> tuple[list[TransferHistory], list[int]]:
|
||||
"""按输入顺序读取多条整理历史,并同时返回缺失 ID。"""
|
||||
records: list[TransferHistoryView] = []
|
||||
records: list[TransferHistory] = []
|
||||
missing_ids: list[int] = []
|
||||
for history_id in history_ids:
|
||||
record = await self.get_transfer(history_id)
|
||||
|
||||
+232
-56
@@ -2,15 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol, TypeVar
|
||||
from typing import Any, Generic, Optional, Protocol, TypeVar, Union
|
||||
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -72,6 +71,10 @@ class ClaimedOutboxMessage:
|
||||
attempt: int
|
||||
|
||||
|
||||
class OutboxLeaseLostError(RuntimeError):
|
||||
"""当前派发 owner 的 attempt 已失效,禁止假报成功或覆盖新 owner。"""
|
||||
|
||||
|
||||
def validate_durable_event_handlers(
|
||||
handlers: Mapping[str, Callable[[ClaimedOutboxMessage], None]],
|
||||
) -> None:
|
||||
@@ -84,40 +87,81 @@ def validate_durable_event_handlers(
|
||||
)
|
||||
|
||||
|
||||
class OutboxRepository(Protocol):
|
||||
"""outbox 写入、claim 和终态更新所需的最小端口。"""
|
||||
class OutboxStager(Protocol):
|
||||
"""只在业务事务中暂存 durable intent 的最小端口。"""
|
||||
|
||||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""在调用方当前事务中暂存意图,不自行提交。"""
|
||||
|
||||
def claim(self, now: datetime, lease_until: datetime) -> ClaimedOutboxMessage | None:
|
||||
|
||||
class OutboxDispatchStore(Protocol):
|
||||
"""使用独立短事务认领和结算 durable intent 的最小端口。"""
|
||||
|
||||
def claim(self, now: datetime, lease_until: datetime) -> Optional[ClaimedOutboxMessage]:
|
||||
"""原子认领一条到期消息。"""
|
||||
|
||||
def complete(self, message_id: int, completed_at: datetime) -> None:
|
||||
"""按消息 ID 标记完成。"""
|
||||
def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领一条到期消息。"""
|
||||
|
||||
def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 标记完成。"""
|
||||
|
||||
def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> None:
|
||||
"""记录有限退避或 dead-letter 终态。"""
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 记录退避或 dead-letter。"""
|
||||
|
||||
class AsyncOutboxTransaction(Protocol):
|
||||
"""异步业务事务暂存并收口 durable intent 的最小端口。"""
|
||||
class AsyncOutboxStager(Protocol):
|
||||
"""只在异步业务事务中暂存 durable intent 的最小端口。"""
|
||||
|
||||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""把 intent 加入调用方当前事务,但不自行提交。"""
|
||||
|
||||
async def complete_by_event_key(
|
||||
class AsyncOutboxDispatchStore(Protocol):
|
||||
"""使用独立异步短事务认领和结算 intent 的最小端口。"""
|
||||
|
||||
async def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领一条到期消息。"""
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> None:
|
||||
"""即时投递成功后按稳定幂等键标记 intent 完成。"""
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 标记完成。"""
|
||||
|
||||
async def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> bool:
|
||||
"""仅由当前 attempt 的 owner 记录退避或 dead-letter。"""
|
||||
|
||||
|
||||
class SyncUnitOfWork(Protocol):
|
||||
@@ -130,26 +174,106 @@ class SyncUnitOfWork(Protocol):
|
||||
"""回滚业务写入与 outbox intent。"""
|
||||
|
||||
|
||||
class SyncOutboxTransaction(Protocol):
|
||||
"""同步业务事务暂存并收口 durable intent 的最小端口。"""
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostCommitResult(Generic[T]):
|
||||
"""区分已提交业务结果与逐项完成或仍待恢复的后置效果。"""
|
||||
|
||||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""把 intent 加入调用方事务,但不自行提交。"""
|
||||
value: T
|
||||
business_committed: bool
|
||||
completed_effects: tuple[str, ...] = ()
|
||||
pending_effects: tuple[str, ...] = ()
|
||||
|
||||
def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> bool:
|
||||
"""在同步副作用前原子认领 intent,已被其他投递者持有时返回 False。"""
|
||||
|
||||
def complete_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
completed_at: datetime,
|
||||
) -> None:
|
||||
"""即时投递成功后按幂等键标记 intent 完成。"""
|
||||
class PostCommitEffectError(RuntimeError):
|
||||
"""业务已提交但至少一个后置效果失败,并携带可检查的完成结果。"""
|
||||
|
||||
def __init__(self, result: PostCommitResult[Any], errors: tuple[Exception, ...]):
|
||||
"""保存结构化完成状态及逐项原始异常。"""
|
||||
self.result = result
|
||||
self.errors = errors
|
||||
super().__init__(str(errors[0]) if errors else "提交后效果执行失败")
|
||||
|
||||
|
||||
def deliver_outbox_effect(
|
||||
store: OutboxDispatchStore,
|
||||
event_key: str,
|
||||
effect: Callable[[], object],
|
||||
*,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
) -> bool:
|
||||
"""先认领再执行同步效果,并用同一 attempt fencing 结算结果。"""
|
||||
now = (clock or (lambda: datetime.now(timezone.utc)))()
|
||||
claimed = store.claim_by_event_key(
|
||||
event_key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if claimed is None:
|
||||
return False
|
||||
try:
|
||||
confirmed = effect()
|
||||
except Exception as error:
|
||||
store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
if confirmed is False:
|
||||
store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="副作用未确认",
|
||||
dead=False,
|
||||
)
|
||||
return False
|
||||
if not store.complete(claimed.message_id, claimed.attempt, now):
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
return True
|
||||
|
||||
|
||||
async def deliver_async_outbox_effect(
|
||||
store: AsyncOutboxDispatchStore,
|
||||
event_key: str,
|
||||
effect: Callable[[], Awaitable[object]],
|
||||
*,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
) -> bool:
|
||||
"""先认领再执行异步效果,并用同一 attempt fencing 结算结果。"""
|
||||
now = (clock or (lambda: datetime.now(timezone.utc)))()
|
||||
claimed = await store.claim_by_event_key(
|
||||
event_key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if claimed is None:
|
||||
return False
|
||||
try:
|
||||
confirmed = await effect()
|
||||
except Exception as error:
|
||||
await store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
if confirmed is False:
|
||||
await store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="副作用未确认",
|
||||
dead=False,
|
||||
)
|
||||
return False
|
||||
if not await store.complete(claimed.message_id, claimed.attempt, now):
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
return True
|
||||
|
||||
|
||||
class DurableEventCommand:
|
||||
@@ -158,42 +282,85 @@ class DurableEventCommand:
|
||||
def __init__(
|
||||
self,
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
outbox: SyncOutboxTransaction,
|
||||
stager: OutboxStager,
|
||||
store: OutboxDispatchStore,
|
||||
) -> None:
|
||||
"""注入共享同一 Session 的事务与 outbox 端口。"""
|
||||
"""注入业务事务内 stager 与独立短事务 dispatch store。"""
|
||||
self._unit_of_work = unit_of_work
|
||||
self._outbox = outbox
|
||||
self._stager = stager
|
||||
self._store = store
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
intent: OutboxIntent | Callable[[T], OutboxIntent] | None,
|
||||
intent: Optional[Union[OutboxIntent, Callable[[T], OutboxIntent]]],
|
||||
stage_business: Callable[[], T],
|
||||
publish: Callable[[], None] | None,
|
||||
after_commit: Callable[[], None] | None = None,
|
||||
) -> T:
|
||||
"""原子提交业务与可选 intent,再执行可选提交后动作和广播。"""
|
||||
resolved_intent: OutboxIntent | None = None
|
||||
publish: Optional[Callable[[], None]],
|
||||
after_commit: Optional[Callable[[], None]] = None,
|
||||
) -> PostCommitResult[T]:
|
||||
"""原子提交业务与 intent,再逐项记录提交后效果完成语义。"""
|
||||
resolved_intent: Optional[OutboxIntent] = None
|
||||
try:
|
||||
result = stage_business()
|
||||
if intent is not None:
|
||||
resolved_intent = intent(result) if callable(intent) else intent
|
||||
self._outbox.stage(resolved_intent, datetime.now(timezone.utc))
|
||||
self._stager.stage(resolved_intent, datetime.now(timezone.utc))
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
completed: list[str] = []
|
||||
pending: list[str] = []
|
||||
errors: list[Exception] = []
|
||||
if after_commit:
|
||||
after_commit()
|
||||
if publish:
|
||||
publish()
|
||||
try:
|
||||
after_commit()
|
||||
completed.append("after_commit")
|
||||
except Exception as error:
|
||||
pending.append("after_commit")
|
||||
errors.append(error)
|
||||
if resolved_intent is not None:
|
||||
pending.append(resolved_intent.event_key)
|
||||
if resolved_intent is not None and publish is not None:
|
||||
self._outbox.complete_by_event_key(
|
||||
now = datetime.now(timezone.utc)
|
||||
claimed = self._store.claim_by_event_key(
|
||||
resolved_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
return result
|
||||
if claimed is not None:
|
||||
try:
|
||||
publish()
|
||||
settled = self._store.complete(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
if not settled:
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
pending.remove(resolved_intent.event_key)
|
||||
completed.append(resolved_intent.event_key)
|
||||
except OutboxLeaseLostError as error:
|
||||
errors.append(error)
|
||||
except Exception as error:
|
||||
self._store.retry(
|
||||
claimed.message_id,
|
||||
claimed.attempt,
|
||||
next_retry_at=datetime.now(timezone.utc),
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
errors.append(error)
|
||||
execution = PostCommitResult(
|
||||
value=result,
|
||||
business_committed=True,
|
||||
completed_effects=tuple(completed),
|
||||
pending_effects=tuple(pending),
|
||||
)
|
||||
if errors:
|
||||
raise PostCommitEffectError(execution, tuple(errors))
|
||||
return execution
|
||||
|
||||
|
||||
class OutboxDispatcher:
|
||||
@@ -201,14 +368,14 @@ class OutboxDispatcher:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: OutboxRepository,
|
||||
repository: OutboxDispatchStore,
|
||||
handlers: dict[str, Callable[[ClaimedOutboxMessage], None]],
|
||||
*,
|
||||
max_attempts: int = 5,
|
||||
lease_seconds: int = OUTBOX_LEASE_SECONDS,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
close: Callable[[], None] | None = None,
|
||||
failure_observer: Callable[[bool], None] | None = None,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
close: Optional[Callable[[], None]] = None,
|
||||
failure_observer: Optional[Callable[[bool], None]] = None,
|
||||
) -> None:
|
||||
"""注入持久端口、topic handler、有界重试策略与失败观测端口。"""
|
||||
self._repository = repository
|
||||
@@ -231,25 +398,34 @@ class OutboxDispatcher:
|
||||
try:
|
||||
handler = self._handlers[message.topic]
|
||||
handler(message)
|
||||
if not self._repository.complete(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
now,
|
||||
):
|
||||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||||
except OutboxLeaseLostError:
|
||||
raise
|
||||
except Exception as error:
|
||||
dead = message.attempt >= self._max_attempts
|
||||
delay = min(3600, 2 ** max(0, message.attempt - 1))
|
||||
self._repository.retry(
|
||||
settled = self._repository.retry(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
next_retry_at=now + timedelta(seconds=delay),
|
||||
last_error=str(error)[:4000],
|
||||
dead=dead,
|
||||
)
|
||||
self._failure_observer(dead)
|
||||
if settled:
|
||||
self._failure_observer(dead)
|
||||
return True
|
||||
self._repository.complete(message.message_id, now)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""释放 dispatcher 工厂创建的短生命周期持久化资源。"""
|
||||
self._close()
|
||||
|
||||
_configured_dispatcher: Callable[[], OutboxDispatcher] | None = None
|
||||
_configured_dispatcher: Optional[Callable[[], OutboxDispatcher]] = None
|
||||
|
||||
|
||||
def configure_outbox_dispatcher(provider: Callable[[], OutboxDispatcher]) -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -43,13 +44,13 @@ class AuthTicketStore(metaclass=Singleton):
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._cleanup(now)
|
||||
self._tickets[ticket] = {
|
||||
"user_id": int(user_id),
|
||||
"provider_id": provider_id,
|
||||
"metadata": metadata or {},
|
||||
"metadata": copy.deepcopy(metadata) if metadata is not None else {},
|
||||
"created_at": now,
|
||||
}
|
||||
self._cleanup(now)
|
||||
return ticket
|
||||
|
||||
def consume(self, ticket: str) -> Optional[dict[str, Any]]:
|
||||
@@ -69,7 +70,7 @@ class AuthTicketStore(metaclass=Singleton):
|
||||
return None
|
||||
if now - float(data.get("created_at") or 0) > self._ttl_seconds:
|
||||
return None
|
||||
return data
|
||||
return copy.deepcopy(data)
|
||||
|
||||
def _cleanup(self, now: Optional[float] = None) -> None:
|
||||
"""
|
||||
@@ -77,7 +78,7 @@ class AuthTicketStore(metaclass=Singleton):
|
||||
|
||||
:param now: 当前时间戳,未传入时自动读取
|
||||
"""
|
||||
current = now or time.time()
|
||||
current = time.time() if now is None else now
|
||||
expired = [
|
||||
key
|
||||
for key, value in self._tickets.items()
|
||||
|
||||
@@ -5,7 +5,6 @@ import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Literal, Optional, Protocol, Tuple
|
||||
from urllib.parse import urlparse
|
||||
@@ -28,9 +27,7 @@ from webauthn.helpers.structs import (
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from app.adapters.cache.redis import RedisHelper
|
||||
from app.application.configuration import get_api_runtime_config_snapshot
|
||||
from app.runtime.cache import TTLCache
|
||||
from app.runtime.log import logger
|
||||
|
||||
PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
|
||||
@@ -46,15 +43,27 @@ class PasskeyChallenge:
|
||||
user_id: Optional[int]
|
||||
|
||||
|
||||
class PasskeyChallengeCache(Protocol):
|
||||
"""PassKey 一次性 challenge 使用的严格原子缓存端口。"""
|
||||
|
||||
def store(self, key: str, value: Any) -> None:
|
||||
"""持久化 challenge,失败时抛出后端异常。"""
|
||||
|
||||
def consume(self, key: str) -> Any:
|
||||
"""原子领取 challenge,不存在时返回 None。"""
|
||||
|
||||
|
||||
class PasskeyChallengeStore:
|
||||
"""使用当前缓存后端签发并原子消费短时 Passkey challenge。"""
|
||||
|
||||
_cache = TTLCache(
|
||||
region="passkey_challenge",
|
||||
maxsize=4096,
|
||||
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
|
||||
)
|
||||
_memory_consume_lock = threading.Lock()
|
||||
_cache: Optional[PasskeyChallengeCache] = None
|
||||
|
||||
@classmethod
|
||||
def _get_cache(cls) -> PasskeyChallengeCache:
|
||||
"""返回已装配缓存,缺失时拒绝签发认证状态。"""
|
||||
if cls._cache is None:
|
||||
raise RuntimeError("PassKey challenge 缓存尚未配置")
|
||||
return cls._cache
|
||||
|
||||
@classmethod
|
||||
def issue(
|
||||
@@ -66,7 +75,7 @@ class PasskeyChallengeStore:
|
||||
) -> str:
|
||||
"""保存 challenge 并返回不携带认证事实的随机事务 token。"""
|
||||
transaction_token = secrets.token_urlsafe(32)
|
||||
cls._cache.set(
|
||||
cls._get_cache().store(
|
||||
transaction_token,
|
||||
PasskeyChallenge(
|
||||
challenge=challenge,
|
||||
@@ -87,17 +96,7 @@ class PasskeyChallengeStore:
|
||||
if not transaction_token:
|
||||
return None
|
||||
|
||||
if cls._cache.is_redis():
|
||||
challenge = RedisHelper().pop(
|
||||
transaction_token,
|
||||
region="passkey_challenge",
|
||||
)
|
||||
else:
|
||||
with cls._memory_consume_lock:
|
||||
try:
|
||||
challenge = cls._cache.pop(transaction_token)
|
||||
except KeyError:
|
||||
challenge = None
|
||||
challenge = cls._get_cache().consume(transaction_token)
|
||||
|
||||
if not isinstance(challenge, PasskeyChallenge):
|
||||
return None
|
||||
@@ -106,6 +105,11 @@ class PasskeyChallengeStore:
|
||||
return challenge
|
||||
|
||||
|
||||
def configure_passkey_challenge_cache(cache: PasskeyChallengeCache) -> None:
|
||||
"""由启动组合根注入 PassKey challenge 的原子缓存。"""
|
||||
PasskeyChallengeStore._cache = cache
|
||||
|
||||
|
||||
class PassKeyRegistrationVerificationError(Exception):
|
||||
"""Passkey 注册响应未通过 WebAuthn 安全校验。"""
|
||||
|
||||
@@ -465,8 +469,13 @@ class PasskeyRepository(Protocol):
|
||||
def create(self, payload: dict[str, Any]) -> Any:
|
||||
"""创建凭证。"""
|
||||
|
||||
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
|
||||
"""更新凭证使用计数。"""
|
||||
def compare_and_update_sign_count(
|
||||
self,
|
||||
passkey_id: int,
|
||||
expected_sign_count: int,
|
||||
sign_count: int,
|
||||
) -> bool:
|
||||
"""仅在签名计数未被并发修改时记录本次认证。"""
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除用户凭证。"""
|
||||
@@ -495,9 +504,18 @@ class PasskeyService:
|
||||
"""创建凭证。"""
|
||||
return self._repository.create(payload)
|
||||
|
||||
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
|
||||
"""更新凭证使用计数。"""
|
||||
return self._repository.update_last_used(passkey, sign_count)
|
||||
def compare_and_update_sign_count(
|
||||
self,
|
||||
passkey_id: int,
|
||||
expected_sign_count: int,
|
||||
sign_count: int,
|
||||
) -> bool:
|
||||
"""以验证时观察到的旧计数提交本次认证。"""
|
||||
return self._repository.compare_and_update_sign_count(
|
||||
passkey_id=passkey_id,
|
||||
expected_sign_count=expected_sign_count,
|
||||
sign_count=sign_count,
|
||||
)
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除用户凭证。"""
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol, TypeAlias, TypeVar, cast
|
||||
from typing import Any, Optional, Protocol, TypeAlias, TypeVar, Union, cast
|
||||
|
||||
FrozenJson: TypeAlias = (
|
||||
str | int | float | bool | None | tuple["FrozenJson", ...] | Mapping[str, "FrozenJson"]
|
||||
)
|
||||
FrozenJson: TypeAlias = Union[
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
bool,
|
||||
None,
|
||||
tuple["FrozenJson", ...],
|
||||
Mapping[str, "FrozenJson"],
|
||||
]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -24,7 +30,9 @@ def _freeze_json(value: Any) -> FrozenJson:
|
||||
return cast(FrozenJson, value)
|
||||
|
||||
|
||||
def _freeze_mapping(value: Mapping[str, Any] | None) -> Mapping[str, FrozenJson]:
|
||||
def _freeze_mapping(
|
||||
value: Optional[Mapping[str, Any]],
|
||||
) -> Mapping[str, FrozenJson]:
|
||||
"""把可空 JSON 对象复制为只读映射。"""
|
||||
frozen = _freeze_json(value or {})
|
||||
return cast(Mapping[str, FrozenJson], frozen)
|
||||
@@ -36,10 +44,10 @@ class UserSnapshot:
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str | None
|
||||
email: Optional[str]
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
avatar: str | None
|
||||
avatar: Optional[str]
|
||||
is_otp: bool
|
||||
permissions: Mapping[str, FrozenJson]
|
||||
settings: Mapping[str, FrozenJson]
|
||||
@@ -50,13 +58,13 @@ class UserSnapshot:
|
||||
*,
|
||||
user_id: int,
|
||||
name: str,
|
||||
email: str | None,
|
||||
is_active: bool | None,
|
||||
is_superuser: bool | None,
|
||||
avatar: str | None,
|
||||
is_otp: bool | None,
|
||||
permissions: Mapping[str, Any] | None,
|
||||
settings: Mapping[str, Any] | None,
|
||||
email: Optional[str],
|
||||
is_active: Optional[bool],
|
||||
is_superuser: Optional[bool],
|
||||
avatar: Optional[str],
|
||||
is_otp: Optional[bool],
|
||||
permissions: Optional[Mapping[str, Any]],
|
||||
settings: Optional[Mapping[str, Any]],
|
||||
) -> "UserSnapshot":
|
||||
"""复制持久化字段并构造不可变的公开用户快照。"""
|
||||
return cls(
|
||||
@@ -77,8 +85,8 @@ class UserAuthSnapshot:
|
||||
"""仅供认证链使用的只读用户凭据快照。"""
|
||||
|
||||
user: UserSnapshot
|
||||
hashed_password: str | None
|
||||
otp_secret: str | None
|
||||
hashed_password: Optional[str]
|
||||
otp_secret: Optional[str]
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
@@ -101,7 +109,7 @@ class UserAuthSnapshot:
|
||||
return self.user.is_superuser
|
||||
|
||||
@property
|
||||
def avatar(self) -> str | None:
|
||||
def avatar(self) -> Optional[str]:
|
||||
"""返回用户头像。"""
|
||||
return self.user.avatar
|
||||
|
||||
@@ -126,13 +134,21 @@ class AuxiliaryUserCreate:
|
||||
is_superuser: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserUpdateResult:
|
||||
"""用户更新事务产出的新快照与原用户名。"""
|
||||
|
||||
user: UserSnapshot
|
||||
previous_name: str
|
||||
|
||||
|
||||
class ChainUserRepository(Protocol):
|
||||
"""用户 Chain 和 Agent 共享的类型化查询与创建端口。"""
|
||||
|
||||
def get_auth_by_name(self, name: str) -> UserAuthSnapshot | None:
|
||||
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
|
||||
"""按用户名读取认证快照。"""
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""异步按用户名读取公开用户快照。"""
|
||||
|
||||
def create_auxiliary(self, command: AuxiliaryUserCreate) -> UserAuthSnapshot:
|
||||
@@ -141,16 +157,19 @@ class ChainUserRepository(Protocol):
|
||||
def get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""读取通知路由设置;用户不存在时返回空值。"""
|
||||
|
||||
async def async_get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""异步读取通知路由设置;用户不存在时返回空值。"""
|
||||
|
||||
def find_name_by_bindings(self, bindings: Mapping[str, object]) -> str | None:
|
||||
def find_name_by_bindings(
|
||||
self,
|
||||
bindings: Mapping[str, object],
|
||||
) -> Optional[str]:
|
||||
"""解析唯一启用用户的渠道绑定,歧义时拒绝归属。"""
|
||||
|
||||
|
||||
@@ -160,24 +179,27 @@ class UserRepository(Protocol):
|
||||
async def async_list(self) -> list[UserSnapshot]:
|
||||
"""返回全部用户。"""
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""按用户名返回用户。"""
|
||||
|
||||
async def async_get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
async def async_get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""按用户 ID 返回用户。"""
|
||||
|
||||
async def async_create(self, payload: dict[str, Any]) -> UserSnapshot | None:
|
||||
async def async_create(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> Optional[UserSnapshot]:
|
||||
"""创建用户并返回持久化对象。"""
|
||||
|
||||
async def async_update(
|
||||
self,
|
||||
user_id: int,
|
||||
payload: dict[str, Any],
|
||||
) -> UserSnapshot | None:
|
||||
"""更新用户并返回原用户对象。"""
|
||||
) -> Optional[UserUpdateResult]:
|
||||
"""更新用户并返回提交后快照发布所需的变更结果。"""
|
||||
|
||||
async def async_delete(self, user_id: int) -> None:
|
||||
"""删除用户。"""
|
||||
async def async_delete(self, user_id: int) -> Optional[str]:
|
||||
"""删除用户并返回被删除用户名。"""
|
||||
|
||||
async def async_update_otp_by_name(self, name: str, otp: bool, secret: str) -> None:
|
||||
"""更新用户 OTP 状态。"""
|
||||
@@ -193,31 +215,51 @@ class AsyncUnitOfWork(Protocol):
|
||||
"""回滚失败的用户写入。"""
|
||||
|
||||
|
||||
class UserConfigurationPublisher(Protocol):
|
||||
"""用户聚合提交后同步进程级配置快照的应用端口。"""
|
||||
|
||||
async def rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""数据库改名提交后迁移对应用户名配置快照。"""
|
||||
|
||||
async def delete(self, username: str) -> None:
|
||||
"""数据库删除提交后移除对应用户名配置快照。"""
|
||||
|
||||
|
||||
class UserNameConflictError(Exception):
|
||||
"""用户名在数据库唯一约束下发生冲突。"""
|
||||
|
||||
|
||||
class LastActiveSuperuserError(Exception):
|
||||
"""用户变更会导致系统不再存在启用的超级管理员。"""
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户管理应用服务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: UserRepository,
|
||||
unit_of_work: AsyncUnitOfWork | None = None,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
configuration: UserConfigurationPublisher,
|
||||
) -> None:
|
||||
"""创建用户服务;旧独立仓储可暂不提供请求级 UoW。"""
|
||||
"""创建用户服务并注入事务边界与提交后配置发布端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._configuration = configuration
|
||||
|
||||
async def list(self) -> list[UserSnapshot]:
|
||||
"""返回用户列表。"""
|
||||
return await self._repository.async_list()
|
||||
|
||||
async def get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""按用户名查询用户。"""
|
||||
return await self._repository.async_get_by_name(name)
|
||||
|
||||
async def get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
async def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""按用户 ID 查询用户。"""
|
||||
return await self._repository.async_get_by_id(user_id)
|
||||
|
||||
async def create(self, payload: dict[str, Any]) -> UserSnapshot | None:
|
||||
async def create(self, payload: dict[str, Any]) -> Optional[UserSnapshot]:
|
||||
"""创建用户。"""
|
||||
return await self._write(lambda: self._repository.async_create(payload))
|
||||
|
||||
@@ -225,44 +267,45 @@ class UserService:
|
||||
self,
|
||||
user_id: int,
|
||||
payload: dict[str, Any],
|
||||
) -> UserSnapshot | None:
|
||||
) -> Optional[UserSnapshot]:
|
||||
"""更新用户。"""
|
||||
return await self._write(
|
||||
lambda: self._repository.async_update(user_id, payload)
|
||||
)
|
||||
result = await self._write(lambda: self._repository.async_update(user_id, payload))
|
||||
if result is None:
|
||||
return None
|
||||
if result.previous_name != result.user.name:
|
||||
await self._configuration.rename(result.previous_name, result.user.name)
|
||||
return result.user
|
||||
|
||||
async def delete(self, user_id: int) -> None:
|
||||
"""删除用户。"""
|
||||
await self._write(lambda: self._repository.async_delete(user_id))
|
||||
username = await self._write(lambda: self._repository.async_delete(user_id))
|
||||
if username is not None:
|
||||
await self._configuration.delete(username)
|
||||
|
||||
async def update_otp(self, name: str, otp: bool, secret: str) -> None:
|
||||
"""更新用户 OTP 状态。"""
|
||||
await self._write(
|
||||
lambda: self._repository.async_update_otp_by_name(name, otp, secret)
|
||||
)
|
||||
await self._write(lambda: self._repository.async_update_otp_by_name(name, otp, secret))
|
||||
|
||||
async def _write(self, operation: Callable[[], Awaitable[T]]) -> T:
|
||||
"""执行用户写入,并在正式请求路径统一提交或回滚。"""
|
||||
try:
|
||||
result = await operation()
|
||||
if self._unit_of_work is not None:
|
||||
await self._unit_of_work.commit()
|
||||
await self._unit_of_work.commit()
|
||||
return result
|
||||
except Exception:
|
||||
if self._unit_of_work is not None:
|
||||
await self._unit_of_work.rollback()
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
_configured_user_id_lookup: Callable[[int], UserSnapshot | None] | None = None
|
||||
_configured_user_name_lookup: Callable[[str], UserSnapshot | None] | None = None
|
||||
_configured_user_channel_lookup: Callable[..., str | None] | None = None
|
||||
_configured_user_id_lookup: Optional[Callable[[int], Optional[UserSnapshot]]] = None
|
||||
_configured_user_name_lookup: Optional[Callable[[str], Optional[UserSnapshot]]] = None
|
||||
_configured_user_channel_lookup: Optional[Callable[..., Optional[str]]] = None
|
||||
|
||||
|
||||
def configure_user_lookups(
|
||||
by_id: Callable[[int], UserSnapshot | None],
|
||||
by_name: Callable[[str], UserSnapshot | None],
|
||||
by_channel: Callable[..., str | None],
|
||||
by_id: Callable[[int], Optional[UserSnapshot]],
|
||||
by_name: Callable[[str], Optional[UserSnapshot]],
|
||||
by_channel: Callable[..., Optional[str]],
|
||||
) -> None:
|
||||
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
|
||||
global _configured_user_id_lookup, _configured_user_name_lookup
|
||||
@@ -272,21 +315,21 @@ def configure_user_lookups(
|
||||
_configured_user_channel_lookup = by_channel
|
||||
|
||||
|
||||
def get_configured_user_id_lookup() -> Callable[[int], UserSnapshot | None]:
|
||||
def get_configured_user_id_lookup() -> Callable[[int], Optional[UserSnapshot]]:
|
||||
"""返回启动阶段登记的按 ID 用户查询函数。"""
|
||||
if _configured_user_id_lookup is None:
|
||||
raise RuntimeError("按 ID 的用户查询能力尚未配置")
|
||||
return _configured_user_id_lookup
|
||||
|
||||
|
||||
def get_configured_user_name_lookup() -> Callable[[str], UserSnapshot | None]:
|
||||
def get_configured_user_name_lookup() -> Callable[[str], Optional[UserSnapshot]]:
|
||||
"""返回启动阶段登记的按用户名查询函数。"""
|
||||
if _configured_user_name_lookup is None:
|
||||
raise RuntimeError("按用户名的用户查询能力尚未配置")
|
||||
return _configured_user_name_lookup
|
||||
|
||||
|
||||
def get_configured_user_channel_lookup() -> Callable[..., str | None]:
|
||||
def get_configured_user_channel_lookup() -> Callable[..., Optional[str]]:
|
||||
"""返回启动阶段登记的渠道身份到用户名查询函数。"""
|
||||
if _configured_user_channel_lookup is None:
|
||||
raise RuntimeError("渠道用户查询能力尚未配置")
|
||||
|
||||
@@ -3,20 +3,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any, Protocol
|
||||
from typing import Optional, Protocol, Union
|
||||
|
||||
from app.application.database import AsyncDatabaseExecutor
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.types import UserConfigKey
|
||||
|
||||
|
||||
class UserConfigurationRepository(Protocol):
|
||||
"""用户配置数据端口。"""
|
||||
|
||||
def get(self, username: str, key: str) -> Any:
|
||||
def get(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
) -> JsonData:
|
||||
"""读取用户配置。"""
|
||||
|
||||
def set(self, username: str, key: str, value: Any) -> Any:
|
||||
def set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""写入用户配置。"""
|
||||
|
||||
def publish_rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""在用户改名提交后迁移进程级配置快照。"""
|
||||
|
||||
def publish_delete(self, username: str) -> None:
|
||||
"""在用户删除提交后移除进程级配置快照。"""
|
||||
|
||||
|
||||
class UserConfigurationService:
|
||||
"""编排用户个性化配置读写。"""
|
||||
@@ -25,30 +42,54 @@ class UserConfigurationService:
|
||||
self,
|
||||
repository: UserConfigurationRepository,
|
||||
*,
|
||||
async_executor: AsyncDatabaseExecutor | None = None,
|
||||
async_executor: Optional[AsyncDatabaseExecutor] = None,
|
||||
) -> None:
|
||||
"""注入用户配置数据端口及可选的异步事务执行能力。"""
|
||||
self._repository = repository
|
||||
self._async_executor = async_executor
|
||||
|
||||
def get(self, username: str, key: str) -> Any:
|
||||
def get(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
) -> JsonData:
|
||||
"""读取用户配置。"""
|
||||
return self._repository.get(username=username, key=key)
|
||||
|
||||
def set(self, username: str, key: str, value: Any) -> Any:
|
||||
def set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""写入用户配置。"""
|
||||
return self._repository.set(username=username, key=key, value=value)
|
||||
self._repository.set(username=username, key=key, value=value)
|
||||
|
||||
async def async_set(self, username: str, key: str, value: Any) -> Any:
|
||||
async def async_set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""异步写入用户配置,并等待数据库提交或回滚完成。"""
|
||||
if self._async_executor is None:
|
||||
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
|
||||
return await self._async_executor.run(
|
||||
partial(self._repository.set, username=username, key=key, value=value)
|
||||
)
|
||||
await self._async_executor.run(partial(self._repository.set, username=username, key=key, value=value))
|
||||
|
||||
async def rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""异步发布已提交的用户名配置迁移。"""
|
||||
if self._async_executor is None:
|
||||
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
|
||||
await self._async_executor.run(partial(self._repository.publish_rename, previous_name, current_name))
|
||||
|
||||
async def delete(self, username: str) -> None:
|
||||
"""异步发布已提交的用户名配置删除。"""
|
||||
if self._async_executor is None:
|
||||
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
|
||||
await self._async_executor.run(partial(self._repository.publish_delete, username))
|
||||
|
||||
|
||||
_configured_user_configuration: UserConfigurationService | None = None
|
||||
_configured_user_configuration: Optional[UserConfigurationService] = None
|
||||
|
||||
|
||||
def configure_user_configuration(service: UserConfigurationService) -> None:
|
||||
|
||||
@@ -4,15 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Protocol
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from app.application.outbox import (
|
||||
OUTBOX_LEASE_SECONDS,
|
||||
SUBSCRIBE_COMPLETED_TOPIC,
|
||||
OutboxDispatchStore,
|
||||
OutboxIntent,
|
||||
SyncOutboxTransaction,
|
||||
OutboxStager,
|
||||
SyncUnitOfWork,
|
||||
deliver_outbox_effect,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
@@ -40,13 +41,15 @@ class CompleteSubscriptionCommand:
|
||||
self,
|
||||
repository: SubscriptionCompletionRepository,
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
outbox: SyncOutboxTransaction | None,
|
||||
outbox: Optional[OutboxStager],
|
||||
dispatch_store: Optional[OutboxDispatchStore],
|
||||
publish: Callable[[dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""注入共享同步会话、事件发布端口和可选 durable outbox。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
self._publish = publish
|
||||
|
||||
def execute(
|
||||
@@ -109,40 +112,41 @@ class CompleteSubscriptionCommand:
|
||||
raise
|
||||
|
||||
if notification:
|
||||
if self._claim_sync_delivery(notification_key):
|
||||
if self._dispatch_store:
|
||||
deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
notification_key,
|
||||
notify,
|
||||
)
|
||||
else:
|
||||
notify()
|
||||
self._complete_sync_delivery(notification_key)
|
||||
else:
|
||||
notify()
|
||||
if self._claim_sync_delivery(event_key):
|
||||
if self._dispatch_store:
|
||||
deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
event_key,
|
||||
lambda: self._publish(event_payload),
|
||||
)
|
||||
else:
|
||||
self._publish(event_payload)
|
||||
self._complete_sync_delivery(event_key)
|
||||
if self._claim_sync_delivery(report_key):
|
||||
if self._dispatch_store:
|
||||
try:
|
||||
report_delivered = report(report_payload["subscribe_info"])
|
||||
report_delivered = deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
report_key,
|
||||
lambda: report(report_payload["subscribe_info"]),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅完成统计上报失败,将由后台重试:{error}")
|
||||
else:
|
||||
if report_delivered is False:
|
||||
logger.warning("订阅完成统计上报未确认,将由后台重试")
|
||||
else:
|
||||
self._complete_sync_delivery(report_key)
|
||||
|
||||
def _claim_sync_delivery(self, event_key: str) -> bool:
|
||||
"""在同步副作用前取得 lease,已由恢复投递接管时跳过直投。"""
|
||||
if self._outbox is None:
|
||||
return True
|
||||
now = datetime.now(timezone.utc)
|
||||
return self._outbox.claim_by_event_key(
|
||||
event_key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
|
||||
def _complete_sync_delivery(self, event_key: str) -> None:
|
||||
"""收口当前同步投递持有的 durable intent。"""
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(event_key, datetime.now(timezone.utc))
|
||||
else:
|
||||
try:
|
||||
report(report_payload["subscribe_info"])
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅完成统计上报失败:{error}")
|
||||
|
||||
|
||||
def completion_event_key(subscribe_id: int, subscribe_info: Mapping[str, Any]) -> str:
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""订阅删除应用用例及其依赖端口。"""
|
||||
|
||||
import inspect
|
||||
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import inspect
|
||||
from typing import Any, Awaitable, Callable, Mapping, Protocol, cast
|
||||
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.outbox import (
|
||||
AsyncOutboxTransaction,
|
||||
OutboxIntent,
|
||||
SyncOutboxTransaction,
|
||||
SyncUnitOfWork,
|
||||
SUBSCRIBE_DELETED_TOPIC,
|
||||
AsyncOutboxDispatchStore,
|
||||
AsyncOutboxStager,
|
||||
OutboxDispatchStore,
|
||||
OutboxIntent,
|
||||
OutboxStager,
|
||||
SyncUnitOfWork,
|
||||
deliver_async_outbox_effect,
|
||||
deliver_outbox_effect,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.event import SubscribeDeletedEventData
|
||||
@@ -64,6 +68,7 @@ class SyncSubscribeDeletionRepository(Protocol):
|
||||
"""把已读取的订阅登记为待删除,但不自行提交事务。"""
|
||||
...
|
||||
|
||||
|
||||
class AsyncUnitOfWork(Protocol):
|
||||
"""订阅写用例使用的异步事务端口。"""
|
||||
|
||||
@@ -101,7 +106,8 @@ class DeleteSubscribeCommand:
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
report_deleted: SubscribeDeletedReporter,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
outbox: Optional[AsyncOutboxStager] = None,
|
||||
dispatch_store: Optional[AsyncOutboxDispatchStore] = None,
|
||||
) -> None:
|
||||
"""注入数据访问、事务与提交后副作用端口。"""
|
||||
self._repository = repository
|
||||
@@ -109,6 +115,7 @@ class DeleteSubscribeCommand:
|
||||
self._publish_deleted = publish_deleted
|
||||
self._report_deleted = report_deleted
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -146,28 +153,37 @@ class DeleteSubscribeCommand:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
await self._publish_deleted(effects.event_payload)
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
if self._dispatch_store:
|
||||
await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.event_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
lambda: self._publish_deleted(effects.event_payload),
|
||||
)
|
||||
else:
|
||||
await self._publish_deleted(effects.event_payload)
|
||||
# 上报适配器会自行白名单过滤公开字段;传完整删除前快照可保留音乐实体维度,
|
||||
# 避免 Agent 与 API 入口收敛后丢失 music_type / total_tracks。
|
||||
try:
|
||||
report_result = self._report_deleted(effects.report_payload)
|
||||
if inspect.isawaitable(report_result):
|
||||
report_result = await report_result
|
||||
|
||||
async def report() -> object:
|
||||
"""统一等待同步或异步统计 reporter 的确认结果。"""
|
||||
result = self._report_deleted(effects.report_payload)
|
||||
return await result if inspect.isawaitable(result) else result
|
||||
|
||||
report_result: object
|
||||
if self._dispatch_store:
|
||||
report_result = await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.report_intent.event_key,
|
||||
report,
|
||||
)
|
||||
else:
|
||||
report_result = await report()
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅删除统计上报失败,将由后台重试:{error}")
|
||||
else:
|
||||
if report_result is False:
|
||||
logger.warning("订阅删除统计上报未确认,将由后台重试")
|
||||
elif self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
effects.report_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -180,7 +196,8 @@ class SyncDeleteSubscribeCommand:
|
||||
unit_of_work: SyncUnitOfWork,
|
||||
publish_deleted: SyncSubscribeDeletedPublisher,
|
||||
report_deleted: SyncSubscribeDeletedReporter,
|
||||
outbox: SyncOutboxTransaction | None = None,
|
||||
outbox: Optional[OutboxStager] = None,
|
||||
dispatch_store: Optional[OutboxDispatchStore] = None,
|
||||
) -> None:
|
||||
"""注入同步数据访问、事务与提交后副作用端口。"""
|
||||
self._repository = repository
|
||||
@@ -188,6 +205,7 @@ class SyncDeleteSubscribeCommand:
|
||||
self._publish_deleted = publish_deleted
|
||||
self._report_deleted = report_deleted
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -215,24 +233,29 @@ class SyncDeleteSubscribeCommand:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
self._publish_deleted(effects.event_payload)
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(
|
||||
if self._dispatch_store:
|
||||
deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.event_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
lambda: self._publish_deleted(effects.event_payload),
|
||||
)
|
||||
else:
|
||||
self._publish_deleted(effects.event_payload)
|
||||
try:
|
||||
report_result = self._report_deleted(effects.report_payload)
|
||||
report_result: object
|
||||
if self._dispatch_store:
|
||||
report_result = deliver_outbox_effect(
|
||||
self._dispatch_store,
|
||||
effects.report_intent.event_key,
|
||||
lambda: self._report_deleted(effects.report_payload),
|
||||
)
|
||||
else:
|
||||
report_result = self._report_deleted(effects.report_payload)
|
||||
except Exception as error:
|
||||
logger.warning(f"订阅删除统计上报失败,将由后台重试:{error}")
|
||||
else:
|
||||
if report_result is False:
|
||||
logger.warning("订阅删除统计上报未确认,将由后台重试")
|
||||
elif self._outbox:
|
||||
self._outbox.complete_by_event_key(
|
||||
effects.report_intent.event_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -257,6 +280,7 @@ def _build_deletion_effects(
|
||||
event_key = event_payload["idempotency_key"]
|
||||
report_key = f"{event_key}:report"
|
||||
report_payload = dict(subscribe_info)
|
||||
report_payload["idempotency_key"] = report_key
|
||||
return _SubscribeDeletionEffects(
|
||||
event_payload=event_payload,
|
||||
report_payload=report_payload,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""按媒体身份批量删除订阅的应用用例。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Protocol
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from app.application.outbox import (
|
||||
AsyncOutboxTransaction,
|
||||
OutboxIntent,
|
||||
SUBSCRIBE_DELETED_TOPIC,
|
||||
AsyncOutboxDispatchStore,
|
||||
AsyncOutboxStager,
|
||||
OutboxIntent,
|
||||
deliver_async_outbox_effect,
|
||||
)
|
||||
from app.application.subscription.delete import (
|
||||
AsyncUnitOfWork,
|
||||
@@ -48,7 +50,8 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
handle_event_error: SubscribeDeletionEventErrorHandler,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
outbox: Optional[AsyncOutboxStager] = None,
|
||||
dispatch_store: Optional[AsyncOutboxDispatchStore] = None,
|
||||
) -> None:
|
||||
"""注入数据访问、事务、事件和事件错误处理端口。"""
|
||||
self._repository = repository
|
||||
@@ -56,6 +59,7 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
self._publish_deleted = publish_deleted
|
||||
self._handle_event_error = handle_event_error
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -72,11 +76,7 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
season,
|
||||
music_type,
|
||||
)
|
||||
deletions = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if self._can_delete(candidate, actor)
|
||||
]
|
||||
deletions = [candidate for candidate in candidates if self._can_delete(candidate, actor)]
|
||||
events: list[tuple[SubscribeDeletionCandidate, dict[str, Any]]] = []
|
||||
for candidate in deletions:
|
||||
await self._repository.stage_delete(candidate.subscribe_id)
|
||||
@@ -105,12 +105,21 @@ class DeleteSubscriptionsByIdentityCommand:
|
||||
|
||||
for candidate, event_payload in events:
|
||||
try:
|
||||
await self._publish_deleted(event_payload)
|
||||
if self._outbox:
|
||||
await self._outbox.complete_by_event_key(
|
||||
if self._dispatch_store:
|
||||
|
||||
async def publish_event(
|
||||
payload: dict[str, Any] = event_payload,
|
||||
) -> None:
|
||||
"""发布当前删除候选对应的稳定事件快照。"""
|
||||
await self._publish_deleted(payload)
|
||||
|
||||
await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
event_payload["idempotency_key"],
|
||||
datetime.now(timezone.utc),
|
||||
publish_event,
|
||||
)
|
||||
else:
|
||||
await self._publish_deleted(event_payload)
|
||||
except Exception as error:
|
||||
self._handle_event_error(candidate.subscribe_id, error)
|
||||
return len(deletions)
|
||||
|
||||
@@ -4,13 +4,15 @@ from collections.abc import Awaitable, Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from typing import Any, Optional, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.outbox import (
|
||||
AsyncOutboxTransaction,
|
||||
OutboxIntent,
|
||||
SUBSCRIBE_MODIFIED_TOPIC,
|
||||
AsyncOutboxDispatchStore,
|
||||
AsyncOutboxStager,
|
||||
OutboxIntent,
|
||||
deliver_async_outbox_effect,
|
||||
)
|
||||
from app.schemas.event import SubscribeModifiedEventData
|
||||
|
||||
@@ -73,6 +75,8 @@ class SubscriptionMutation:
|
||||
old: dict[str, Any]
|
||||
new: dict[str, Any]
|
||||
event_published: bool = False
|
||||
business_committed: bool = False
|
||||
pending_effects: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class SubscriptionMutationService:
|
||||
@@ -83,7 +87,8 @@ class SubscriptionMutationService:
|
||||
repository: SubscriptionMutationRepository,
|
||||
history_repository: SubscriptionHistoryMutationRepository | None = None,
|
||||
unit_of_work: AsyncUnitOfWork | None = None,
|
||||
outbox: AsyncOutboxTransaction | None = None,
|
||||
outbox: Optional[AsyncOutboxStager] = None,
|
||||
dispatch_store: Optional[AsyncOutboxDispatchStore] = None,
|
||||
publish_modified: SubscribeModifiedPublisher | None = None,
|
||||
) -> None:
|
||||
"""注入订阅数据、事务与 durable 事件端口。"""
|
||||
@@ -91,6 +96,7 @@ class SubscriptionMutationService:
|
||||
self._history_repository = history_repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._outbox = outbox
|
||||
self._dispatch_store = dispatch_store
|
||||
self._publish_modified = publish_modified
|
||||
|
||||
async def get_accessible(
|
||||
@@ -130,8 +136,9 @@ class SubscriptionMutationService:
|
||||
updated = await self._repository.async_update(subscribe_id, payload)
|
||||
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
||||
|
||||
if not self._outbox or not self._publish_modified:
|
||||
raise RuntimeError("订阅修改事务缺少 outbox 或事件发布端口")
|
||||
publish_modified = self._publish_modified
|
||||
if not self._outbox or not self._dispatch_store or not publish_modified:
|
||||
raise RuntimeError("订阅修改事务缺少 outbox stager、store 或事件发布端口")
|
||||
try:
|
||||
updated = await self._repository.async_stage_update(subscribe_id, payload)
|
||||
if not updated:
|
||||
@@ -157,15 +164,21 @@ class SubscriptionMutationService:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
await self._publish_modified(event_payload)
|
||||
await self._outbox.complete_by_event_key(
|
||||
async def publish_event() -> None:
|
||||
"""发布本次事务已持久化的订阅修改事件。"""
|
||||
await publish_modified(event_payload)
|
||||
|
||||
delivered = await deliver_async_outbox_effect(
|
||||
self._dispatch_store,
|
||||
event_key,
|
||||
datetime.now(timezone.utc),
|
||||
publish_event,
|
||||
)
|
||||
return SubscriptionMutation(
|
||||
old=old,
|
||||
new=event_payload["subscribe_info"],
|
||||
event_published=True,
|
||||
event_published=delivered,
|
||||
business_committed=True,
|
||||
pending_effects=() if delivered else (event_key,),
|
||||
)
|
||||
|
||||
async def update_status(
|
||||
|
||||
@@ -19,7 +19,7 @@ from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Mapping, Optional, Protocol, Tuple
|
||||
|
||||
from app.application.outbox import OutboxIntent, SUBSCRIBE_ADDED_TOPIC
|
||||
from app.application.outbox import SUBSCRIBE_ADDED_TOPIC, OutboxIntent
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||
@@ -237,6 +237,7 @@ def _subscribe_added_intents(
|
||||
event_key = subscription_added_event_key(subscribe_id, payload)
|
||||
event_payload = {
|
||||
"subscribe_id": subscribe_id,
|
||||
"idempotency_key": event_key,
|
||||
"username": username,
|
||||
"mediainfo": dict(payload),
|
||||
}
|
||||
@@ -265,7 +266,15 @@ def _subscribe_added_intents(
|
||||
OutboxIntent(
|
||||
event_key=subscription_added_report_key(subscribe_id, payload),
|
||||
topic="subscribe.added.report",
|
||||
payload={"subscribe_info": dict(payload)},
|
||||
payload={
|
||||
"subscribe_info": {
|
||||
**dict(payload),
|
||||
"idempotency_key": subscription_added_report_key(
|
||||
subscribe_id,
|
||||
payload,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(intents)
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import (
|
||||
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
||||
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.application.history import DownloadHistorySnapshot
|
||||
from app.application.transfer.execution import TransferExecutionCheckpoint
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.media import normalize_music_type
|
||||
@@ -48,7 +49,6 @@ from app.runtime.log import logger
|
||||
from app.schemas.context import MediaInfo as _SchemaMediaInfo
|
||||
from app.schemas.context import MetaInfo as _SchemaMetaInfo
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity
|
||||
from app.schemas.music import MusicInfo as _SchemaMusicInfo
|
||||
from app.schemas.music import MusicMeta as _SchemaMusicMeta
|
||||
@@ -724,7 +724,7 @@ class TransferTask(OptionalMediaIdentityMixin, _ApplicationModel):
|
||||
username: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
download_history: Optional[DownloadHistory] = None
|
||||
download_history: Optional[DownloadHistorySnapshot] = None
|
||||
transfer_batch_id: Optional[str] = None
|
||||
manual: Optional[bool] = False
|
||||
background: Optional[bool] = True
|
||||
|
||||
@@ -17,6 +17,10 @@ class ChainRuntimeMixinHost(Protocol):
|
||||
"""调用同步模块能力。"""
|
||||
...
|
||||
|
||||
def run_module_strict(self, method: str, **kwargs: Any) -> Any:
|
||||
"""同步调用模块能力,并向调用方传播 provider 失败。"""
|
||||
...
|
||||
|
||||
async def async_run_module(self, method: str, **kwargs: Any) -> Any:
|
||||
"""调用异步模块能力。"""
|
||||
...
|
||||
|
||||
+54
-6
@@ -6,7 +6,7 @@ eventmanager、messageoper、messagequeue 等协作对象。
|
||||
"""
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from app.application.chain.data import get_chain_user_port
|
||||
from app.application.messaging.message import MessageTemplateHelper
|
||||
@@ -15,6 +15,7 @@ from app.chain._contracts import ChainRuntimeMixinHost
|
||||
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.foundation.identity import normalize_internal_user_id
|
||||
from app.runtime.correlation import correlation_scope
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.message import Message, MessageResponse
|
||||
from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
|
||||
@@ -140,6 +141,8 @@ class NotificationMixin:
|
||||
:param kwargs: 其他参数(覆盖业务对象属性值)
|
||||
:return: 成功或失败
|
||||
"""
|
||||
strict_delivery = bool(kwargs.pop("_strict_delivery", False))
|
||||
strict_source = kwargs.pop("_strict_source", None)
|
||||
# 添加格式化的时间参数
|
||||
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
# 渲染消息
|
||||
@@ -156,8 +159,13 @@ class NotificationMixin:
|
||||
logger.warning("消息为空,跳过发送")
|
||||
return
|
||||
if message.save_history:
|
||||
self.messageoper.add(**message.model_dump())
|
||||
if not strict_source or not self.messageoper.exists_by_source(
|
||||
strict_source
|
||||
):
|
||||
self.messageoper.add(**message.model_dump())
|
||||
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||
if strict_source and dispatch_message.source == strict_source:
|
||||
dispatch_message.source = None
|
||||
# 发送消息按设置隔离
|
||||
if not dispatch_message.userid and dispatch_message.mtype:
|
||||
# 消息隔离设置
|
||||
@@ -221,8 +229,11 @@ class NotificationMixin:
|
||||
etype=EventType.NoticeMessage,
|
||||
data=self._build_notice_message_data(send_message),
|
||||
)
|
||||
self.messagequeue.send_message(
|
||||
"post_message", message=send_message, **kwargs
|
||||
self._deliver_notification(
|
||||
send_message,
|
||||
strict_delivery=strict_delivery,
|
||||
immediately=False,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
if not send_orignal:
|
||||
return
|
||||
@@ -232,10 +243,47 @@ class NotificationMixin:
|
||||
data=self._build_notice_message_data(dispatch_message),
|
||||
)
|
||||
# 按原消息发送
|
||||
self._deliver_notification(
|
||||
dispatch_message,
|
||||
strict_delivery=strict_delivery,
|
||||
immediately=bool(dispatch_message.userid),
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
def post_message_strict(self, message: Message, *, event_key: str) -> None:
|
||||
"""同步执行真实通知 provider,并通过调用上下文携带稳定事件键。"""
|
||||
durable_message = message.model_copy(deep=True)
|
||||
strict_source = None
|
||||
if not durable_message.source:
|
||||
strict_source = f"outbox:{event_key}"
|
||||
durable_message.source = strict_source
|
||||
with correlation_scope(event_key):
|
||||
self.post_message(
|
||||
durable_message,
|
||||
_strict_delivery=True,
|
||||
_strict_source=strict_source,
|
||||
)
|
||||
|
||||
def _deliver_notification(
|
||||
self,
|
||||
message: Message,
|
||||
*,
|
||||
strict_delivery: bool,
|
||||
immediately: bool,
|
||||
kwargs: dict[str, object],
|
||||
) -> None:
|
||||
"""普通通知使用调度队列;durable 恢复同步执行并传播 provider 错误。"""
|
||||
if strict_delivery:
|
||||
host = cast(ChainRuntimeMixinHost, self)
|
||||
host.run_module_strict(
|
||||
"post_message",
|
||||
message=message,
|
||||
)
|
||||
return
|
||||
self.messagequeue.send_message(
|
||||
"post_message",
|
||||
message=dispatch_message,
|
||||
immediately=True if dispatch_message.userid else False,
|
||||
message=message,
|
||||
immediately=immediately,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
+27
-27
@@ -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]]:
|
||||
"""
|
||||
获取整理用自定义识别词:优先使用下载时保存的快照,无快照(历史旧记录)时再按来源实时反查订阅。
|
||||
|
||||
+42
-27
@@ -25,6 +25,7 @@ from app.application.download.failures import (
|
||||
DownloadFailureWrite,
|
||||
)
|
||||
from app.application.download.tasks import DownloadTaskService
|
||||
from app.application.history import DownloadFileWrite, DownloadHistoryWrite
|
||||
from app.application.torrent import TorrentHelper
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
@@ -1267,23 +1268,33 @@ class DownloadChain(ChainBase):
|
||||
download_path = download_dir / Path(file_list[0]).stem if file_list else download_dir
|
||||
save_path = download_dir if layout == "NoSubfolder" or not folder_name else download_path
|
||||
media_source, media_id = resolve_media_identity(media=media)
|
||||
history_payload = {
|
||||
"path": download_path.as_posix(), "type": media.type.value,
|
||||
"title": media.title, "year": media.year,
|
||||
"media_source": media_source, "media_id": media_id,
|
||||
"music_type": getattr(media, "music_type", None), "seasons": meta.season,
|
||||
"episodes": download_episodes or meta.episode,
|
||||
"image": media.get_backdrop_image(), "poster": media.get_poster_image(),
|
||||
"downloader": downloader, "download_hash": download_hash,
|
||||
"torrent_name": torrent.title, "torrent_description": torrent.description,
|
||||
"torrent_site": torrent.site_name, "userid": userid, "username": username,
|
||||
"channel": channel.value if channel else None,
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"media_category": media.category, "episode_group": media.episode_group,
|
||||
"note": self._build_download_note(source, media, meta),
|
||||
"custom_words": custom_words,
|
||||
}
|
||||
files_to_add = []
|
||||
history = DownloadHistoryWrite(
|
||||
path=download_path.as_posix(),
|
||||
type=media.type.value,
|
||||
title=media.title,
|
||||
year=media.year,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=getattr(media, "music_type", None),
|
||||
seasons=meta.season,
|
||||
episodes=download_episodes or meta.episode,
|
||||
image=media.get_backdrop_image(),
|
||||
poster=media.get_poster_image(),
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
torrent_name=torrent.title,
|
||||
torrent_description=torrent.description,
|
||||
torrent_site=torrent.site_name,
|
||||
userid=userid,
|
||||
username=username,
|
||||
channel=channel.value if channel else None,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
media_category=media.category,
|
||||
episode_group=media.episode_group,
|
||||
note=self._build_download_note(source, media, meta),
|
||||
custom_words=custom_words,
|
||||
)
|
||||
files_to_add: list[DownloadFileWrite] = []
|
||||
for file in file_list:
|
||||
if episodes:
|
||||
file_meta = MetaInfo(Path(file).stem)
|
||||
@@ -1291,11 +1302,17 @@ class DownloadChain(ChainBase):
|
||||
continue
|
||||
if not Path(file).suffix or Path(file).suffix.lower() not in self.runtime_config.media_extensions:
|
||||
continue
|
||||
files_to_add.append({
|
||||
"download_hash": download_hash, "downloader": downloader,
|
||||
"fullpath": (save_path / file).as_posix(), "savepath": save_path.as_posix(),
|
||||
"filepath": file, "torrentname": meta.org_string,
|
||||
})
|
||||
files_to_add.append(
|
||||
DownloadFileWrite(
|
||||
download_hash=download_hash,
|
||||
downloader=downloader,
|
||||
fullpath=(save_path / file).as_posix(),
|
||||
savepath=save_path.as_posix(),
|
||||
filepath=file,
|
||||
torrentname=meta.org_string,
|
||||
)
|
||||
)
|
||||
frozen_files = tuple(files_to_add)
|
||||
event_payload = {
|
||||
"hash": download_hash, "context": context, "username": username,
|
||||
"downloader": downloader, "episodes": episodes or meta.episode_list, "source": source,
|
||||
@@ -1313,15 +1330,13 @@ class DownloadChain(ChainBase):
|
||||
durable_event_writer = getattr(self, "durable_event_writer", None)
|
||||
if durable_event_writer:
|
||||
durable_event_writer.download_added(
|
||||
history_payload=history_payload, file_payloads=files_to_add,
|
||||
history=history,
|
||||
files=frozen_files,
|
||||
event_payload=event_payload, after_commit=after_commit,
|
||||
publish=lambda payload: self.eventmanager.send_event(EventType.DownloadAdded, payload),
|
||||
)
|
||||
return
|
||||
downloadhis = get_chain_download_history_port()
|
||||
downloadhis.add(**history_payload)
|
||||
if files_to_add:
|
||||
downloadhis.add_files(files_to_add)
|
||||
get_chain_download_history_port().add(history, frozen_files)
|
||||
after_commit()
|
||||
self.eventmanager.send_event(EventType.DownloadAdded, event_payload)
|
||||
|
||||
|
||||
+11
-5
@@ -3202,17 +3202,23 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
|
||||
|
||||
# 所有下载记录
|
||||
downloadhis = get_chain_download_history_port()
|
||||
download_his = downloadhis.get_by_media_identity(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=subscribe.media_id,
|
||||
music_type=getattr(subscribe, "music_type", None),
|
||||
)
|
||||
download_his = []
|
||||
if subscribe.media_source and subscribe.media_id:
|
||||
download_his = downloadhis.get_by_media_identity(
|
||||
media_source=subscribe.media_source,
|
||||
media_id=subscribe.media_id,
|
||||
music_type=getattr(subscribe, "music_type", None),
|
||||
)
|
||||
if download_his:
|
||||
for his in download_his:
|
||||
if not his.download_hash:
|
||||
continue
|
||||
# 查询下载文件
|
||||
files = downloadhis.get_files_by_hash(his.download_hash, state=1)
|
||||
if files:
|
||||
for file in files:
|
||||
if not file.filepath:
|
||||
continue
|
||||
# 识别文件名
|
||||
file_meta = MetaInfo(file.filepath)
|
||||
# 下载文件信息
|
||||
|
||||
+14
-11
@@ -25,6 +25,8 @@ from app.application.configuration import get_configured_system_config
|
||||
from app.application.directory import DirectoryHelper
|
||||
from app.application.formatting import FormatParser
|
||||
from app.application.history import (
|
||||
DownloadHistoryQueryPort,
|
||||
DownloadHistorySnapshot,
|
||||
add_transfer_fail,
|
||||
add_transfer_success,
|
||||
clear_transfer_failures,
|
||||
@@ -121,8 +123,6 @@ from app.schemas.types import (
|
||||
)
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
DownloadHistory = Any
|
||||
|
||||
# 下载器锁
|
||||
downloader_lock = threading.Lock()
|
||||
# 任务锁
|
||||
@@ -3678,8 +3678,10 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
continue
|
||||
|
||||
# 查询下载记录识别情况
|
||||
downloadhis: DownloadHistory = get_chain_download_history_port().get_by_hash(
|
||||
torrent.hash
|
||||
downloadhis: Optional[DownloadHistorySnapshot] = (
|
||||
get_chain_download_history_port().get_by_hash(torrent.hash)
|
||||
if torrent.hash
|
||||
else None
|
||||
)
|
||||
# 下载记录中的媒体类型作为整理类型来源,无下载记录时留空由文件后缀兜底
|
||||
mtype: Optional[MediaType] = None
|
||||
@@ -4289,14 +4291,14 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
def _build_main_meta(
|
||||
main_fileitem: FileItem,
|
||||
main_bluray_dir: bool,
|
||||
download_history_oper: Any,
|
||||
download_history_repository: DownloadHistoryQueryPort,
|
||||
) -> Optional[MetaBase]:
|
||||
"""
|
||||
构建主视频元数据。
|
||||
"""
|
||||
main_path = Path(main_fileitem.path)
|
||||
main_download_history = self._resolve_download_history(
|
||||
downloadhis=download_history_oper,
|
||||
repository=download_history_repository,
|
||||
file_path=main_path,
|
||||
bluray_dir=main_bluray_dir,
|
||||
download_hash=download_hash,
|
||||
@@ -4389,7 +4391,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
if not items:
|
||||
return [], {}
|
||||
|
||||
download_history_oper = get_chain_download_history_port()
|
||||
download_history_repository = get_chain_download_history_port()
|
||||
inherited_map: Dict[Tuple[str, str], MetaBase] = {}
|
||||
main_items_by_dir, extra_items_by_dir = _build_directory_index(items)
|
||||
main_items = [
|
||||
@@ -4440,7 +4442,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
main_meta = _build_main_meta(
|
||||
related_main_fileitem,
|
||||
False,
|
||||
download_history_oper,
|
||||
download_history_repository,
|
||||
)
|
||||
if main_meta:
|
||||
inherited_map[self._get_file_key(current_item)] = deepcopy(main_meta)
|
||||
@@ -4489,7 +4491,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
|
||||
main_path = Path(main_item.path)
|
||||
main_download_history = self._resolve_download_history(
|
||||
downloadhis=download_history_oper,
|
||||
repository=download_history_repository,
|
||||
file_path=main_path,
|
||||
bluray_dir=main_bluray_dir,
|
||||
download_hash=download_hash,
|
||||
@@ -4688,9 +4690,9 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
continue
|
||||
|
||||
# 提前获取下载历史,以便获取自定义识别词
|
||||
downloadhis = get_chain_download_history_port()
|
||||
download_history_repository = get_chain_download_history_port()
|
||||
download_history = self._resolve_download_history(
|
||||
downloadhis=downloadhis,
|
||||
repository=download_history_repository,
|
||||
file_path=file_path,
|
||||
bluray_dir=bluray_dir,
|
||||
download_hash=download_hash,
|
||||
@@ -4741,6 +4743,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
)
|
||||
if (
|
||||
not manual
|
||||
and task_mediainfo
|
||||
and self._is_movie_year_conflict(file_meta, task_mediainfo)
|
||||
):
|
||||
task_mediainfo = None
|
||||
|
||||
+25
-12
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""历史持久化适配器包。"""
|
||||
@@ -0,0 +1,287 @@
|
||||
"""下载历史的类型化查询、写入与事务适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from copy import deepcopy
|
||||
from typing import Optional, TypeVar, Union
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.history import (
|
||||
DownloadFileSnapshot,
|
||||
DownloadFileWrite,
|
||||
DownloadHistorySnapshot,
|
||||
DownloadHistoryWrite,
|
||||
)
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
|
||||
def _project_history(record: object) -> DownloadHistorySnapshot:
|
||||
"""在 Session 内把下载历史 ORM 记录投影为不可变快照。"""
|
||||
history_id = getattr(record, "id", None)
|
||||
path = getattr(record, "path", None)
|
||||
media_type = getattr(record, "type", None)
|
||||
title = getattr(record, "title", None)
|
||||
if (
|
||||
not isinstance(history_id, int)
|
||||
or not isinstance(path, str)
|
||||
or not isinstance(media_type, str)
|
||||
or not isinstance(title, str)
|
||||
):
|
||||
raise ValueError("下载历史记录缺少稳定身份、路径、类型或标题")
|
||||
media_source = normalize_media_source(getattr(record, "media_source", None))
|
||||
media_id_value = getattr(record, "media_id", None)
|
||||
media_id = str(media_id_value).strip() if media_id_value is not None else None
|
||||
if not media_source or not media_id or media_id == "0":
|
||||
media_source = None
|
||||
media_id = None
|
||||
return DownloadHistorySnapshot(
|
||||
id=history_id,
|
||||
path=path,
|
||||
type=media_type,
|
||||
title=title,
|
||||
year=getattr(record, "year", None),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=getattr(record, "music_type", None),
|
||||
seasons=getattr(record, "seasons", None),
|
||||
episodes=getattr(record, "episodes", None),
|
||||
image=getattr(record, "image", None),
|
||||
poster=getattr(record, "poster", None),
|
||||
downloader=getattr(record, "downloader", None),
|
||||
download_hash=getattr(record, "download_hash", None),
|
||||
torrent_name=getattr(record, "torrent_name", None),
|
||||
torrent_description=getattr(record, "torrent_description", None),
|
||||
torrent_site=getattr(record, "torrent_site", None),
|
||||
userid=(
|
||||
str(userid_value)
|
||||
if (userid_value := getattr(record, "userid", None)) is not None
|
||||
else None
|
||||
),
|
||||
username=getattr(record, "username", None),
|
||||
channel=getattr(record, "channel", None),
|
||||
date=getattr(record, "date", None),
|
||||
note=deepcopy(getattr(record, "note", None)),
|
||||
media_category=getattr(record, "media_category", None),
|
||||
episode_group=getattr(record, "episode_group", None),
|
||||
custom_words=getattr(record, "custom_words", None),
|
||||
)
|
||||
|
||||
|
||||
def _project_file(record: object) -> DownloadFileSnapshot:
|
||||
"""在 Session 内把下载文件 ORM 记录投影为不可变快照。"""
|
||||
file_id = getattr(record, "id", None)
|
||||
state = getattr(record, "state", None)
|
||||
if not isinstance(file_id, int) or not isinstance(state, int):
|
||||
raise ValueError("下载文件记录缺少稳定身份或状态")
|
||||
return DownloadFileSnapshot(
|
||||
id=file_id,
|
||||
downloader=getattr(record, "downloader", None),
|
||||
download_hash=getattr(record, "download_hash", None),
|
||||
fullpath=getattr(record, "fullpath", None),
|
||||
savepath=getattr(record, "savepath", None),
|
||||
filepath=getattr(record, "filepath", None),
|
||||
torrentname=getattr(record, "torrentname", None),
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
class TransactionalDownloadHistoryRepository:
|
||||
"""为 Chain 和 Agent 下载历史读写创建短生命周期 Session。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sync_session: Callable[[], Session],
|
||||
async_session: Callable[[], AbstractAsyncContextManager[AsyncSession]],
|
||||
) -> None:
|
||||
"""保存由启动组合根提供的同步与异步 Session 工厂。"""
|
||||
self._sync_session = sync_session
|
||||
self._async_session = async_session
|
||||
|
||||
def _read(self, operation: Callable[[DownloadHistoryOper], ResultT]) -> ResultT:
|
||||
"""在独立同步 Session 中执行一次只读操作。"""
|
||||
session = self._sync_session()
|
||||
try:
|
||||
return operation(DownloadHistoryOper(session))
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载任务 Hash 返回最新历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: (
|
||||
_project_history(record)
|
||||
if (record := repository.get_by_hash(download_hash)) is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
def get_by_hashes(
|
||||
self,
|
||||
download_hashes: list[str],
|
||||
) -> dict[str, DownloadHistorySnapshot]:
|
||||
"""批量返回以下载任务 Hash 为键的最新历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: {
|
||||
download_hash: _project_history(record)
|
||||
for download_hash, record in repository.get_by_hashes(download_hashes).items()
|
||||
}
|
||||
)
|
||||
|
||||
def get_by_path(self, path: str) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载保存路径返回历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: (
|
||||
_project_history(record)
|
||||
if (record := repository.get_by_path(path)) is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
def get_by_media_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""按规范媒体身份返回历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: [
|
||||
_project_history(record)
|
||||
for record in repository.get_by_media_identity(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=music_type,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def get_file_by_fullpath(
|
||||
self,
|
||||
fullpath: str,
|
||||
) -> Optional[DownloadFileSnapshot]:
|
||||
"""按完整路径返回一条有效下载文件快照。"""
|
||||
return self._read(
|
||||
lambda repository: (
|
||||
_project_file(record)
|
||||
if (record := repository.get_file_by_fullpath(fullpath)) is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
def get_files_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
state: Optional[int] = None,
|
||||
) -> list[DownloadFileSnapshot]:
|
||||
"""按下载任务 Hash 返回文件快照。"""
|
||||
return self._read(
|
||||
lambda repository: [
|
||||
_project_file(record)
|
||||
for record in repository.get_files_by_hash(
|
||||
download_hash,
|
||||
state=state,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def get_files_by_savepath(self, savepath: str) -> list[DownloadFileSnapshot]:
|
||||
"""按保存目录返回下载文件快照。"""
|
||||
return self._read(
|
||||
lambda repository: [
|
||||
_project_file(record)
|
||||
for record in repository.get_files_by_savepath(savepath)
|
||||
]
|
||||
)
|
||||
|
||||
async def async_list_by_page(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""在独立异步 Session 内分页读取并投影历史。"""
|
||||
async with self._async_session() as session:
|
||||
records = await DownloadHistoryOper(session).async_list_by_page(
|
||||
page,
|
||||
count,
|
||||
)
|
||||
return [_project_history(record) for record in records]
|
||||
|
||||
def add(
|
||||
self,
|
||||
history: DownloadHistoryWrite,
|
||||
files: tuple[DownloadFileWrite, ...] = (),
|
||||
) -> int:
|
||||
"""在一个同步事务中新增历史与关联文件。"""
|
||||
session = self._sync_session()
|
||||
unit_of_work = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
repository = DownloadHistoryOper(session)
|
||||
record = repository.stage_add(history.to_payload())
|
||||
if files:
|
||||
repository.stage_add_files([file_item.to_payload() for file_item in files])
|
||||
history_id = int(record.id)
|
||||
unit_of_work.commit()
|
||||
return history_id
|
||||
except Exception:
|
||||
unit_of_work.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
async def async_delete(self, history_id: int) -> None:
|
||||
"""在一个异步事务中删除指定下载历史。"""
|
||||
async with self._async_session() as session:
|
||||
unit_of_work = SqlAlchemyAsyncUnitOfWork(session)
|
||||
try:
|
||||
await DownloadHistoryOper(session).async_delete_history(history_id)
|
||||
await unit_of_work.commit()
|
||||
except Exception:
|
||||
await unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class SessionDownloadHistoryRepository:
|
||||
"""把 API 请求持有的 Session 适配为下载历史查询和暂存端口。"""
|
||||
|
||||
def __init__(self, session: Union[Session, AsyncSession]) -> None:
|
||||
"""保存由请求依赖独占的数据库 Session。"""
|
||||
self._session = session
|
||||
|
||||
async def async_list_by_page(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""在请求异步 Session 内分页读取并投影历史。"""
|
||||
if not isinstance(self._session, AsyncSession):
|
||||
raise RuntimeError("下载历史异步查询需要 AsyncSession")
|
||||
records = await DownloadHistoryOper(self._session).async_list_by_page(
|
||||
page,
|
||||
count,
|
||||
)
|
||||
return [_project_history(record) for record in records]
|
||||
|
||||
def stage_delete_history(self, history_id: int) -> None:
|
||||
"""在请求同步 Session 内暂存下载历史删除。"""
|
||||
if not isinstance(self._session, Session):
|
||||
raise RuntimeError("下载历史同步删除需要 Session")
|
||||
DownloadHistoryOper(self._session).stage_delete_history(history_id)
|
||||
|
||||
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
|
||||
"""在请求同步 Session 内暂存下载文件失效状态。"""
|
||||
if not isinstance(self._session, Session):
|
||||
raise RuntimeError("下载文件同步变更需要 Session")
|
||||
DownloadHistoryOper(self._session).stage_delete_file_by_fullpath(fullpath)
|
||||
+249
-164
@@ -1,10 +1,15 @@
|
||||
"""Application outbox 端口的 SQLAlchemy 持久化适配器。"""
|
||||
"""Application outbox 暂存与派发端口的 SQLAlchemy 适配器。"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import Select
|
||||
from sqlalchemy.sql.dml import Update
|
||||
|
||||
from app.application.outbox import ClaimedOutboxMessage, OutboxIntent
|
||||
from app.db.base import execute_dml
|
||||
@@ -16,198 +21,278 @@ def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
class SqlAlchemyOutboxRepository:
|
||||
"""使用调用方 Session 原子暂存并条件认领 outbox。"""
|
||||
def _message(model: OutboxMessage, attempt: int) -> ClaimedOutboxMessage:
|
||||
"""把已认领 ORM 行复制为脱离会话的稳定消息。"""
|
||||
return ClaimedOutboxMessage(
|
||||
message_id=model.id,
|
||||
event_key=model.event_key,
|
||||
topic=model.topic,
|
||||
payload=dict(model.payload),
|
||||
payload_version=model.payload_version,
|
||||
attempt=attempt,
|
||||
)
|
||||
|
||||
|
||||
def _claim_query(
|
||||
now_text: str,
|
||||
event_key: Optional[str] = None,
|
||||
) -> Select[tuple[OutboxMessage]]:
|
||||
"""构造到期且 lease 可取得的候选查询。"""
|
||||
statement = select(OutboxMessage).where(
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
if event_key is not None:
|
||||
statement = statement.where(OutboxMessage.event_key == event_key)
|
||||
return statement.order_by(OutboxMessage.id).limit(1)
|
||||
|
||||
|
||||
def _claim_update(
|
||||
candidate: OutboxMessage,
|
||||
now_text: str,
|
||||
lease_until: datetime,
|
||||
) -> Update:
|
||||
"""构造带旧 attempt fencing 的条件认领更新。"""
|
||||
return (
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=candidate.attempt + 1,
|
||||
lease_until=_iso(lease_until),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyOutboxStager:
|
||||
"""只在调用方同步业务事务中暂存 durable intent。"""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""保存由调用方拥有的 SQLAlchemy Session。"""
|
||||
"""保存业务事务拥有的同步 Session。"""
|
||||
self._session = session
|
||||
|
||||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""加入当前事务并 flush,使唯一键冲突在业务 commit 前暴露。"""
|
||||
self._session.add(
|
||||
OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=intent.payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
)
|
||||
"""加入当前事务并 flush,使唯一键冲突在 commit 前暴露。"""
|
||||
self._session.add(_outbox_model(intent, now))
|
||||
self._session.flush()
|
||||
|
||||
|
||||
class SqlAlchemyAsyncOutboxStager:
|
||||
"""只在调用方异步业务事务中暂存 durable intent。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""保存业务事务拥有的异步 Session。"""
|
||||
self._session = session
|
||||
|
||||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""暂存并 flush,业务行与 intent 由同一次 commit 决定。"""
|
||||
self._session.add(_outbox_model(intent, now))
|
||||
await self._session.flush()
|
||||
|
||||
|
||||
class SqlAlchemyOutboxDispatchStore:
|
||||
"""用独立同步短事务认领并结算 outbox 消息。"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存每次操作创建独立 Session 的工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def claim(
|
||||
self,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> ClaimedOutboxMessage | None:
|
||||
"""条件更新候选行;并发丢失竞争时返回 None。"""
|
||||
now_text = _iso(now)
|
||||
candidate = self._session.execute(
|
||||
select(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.order_by(OutboxMessage.id)
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if candidate is None:
|
||||
return None
|
||||
next_attempt = candidate.attempt + 1
|
||||
claimed = execute_dml(
|
||||
self._session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=next_attempt,
|
||||
lease_until=_iso(lease_until),
|
||||
),
|
||||
)
|
||||
self._session.commit()
|
||||
if not claimed:
|
||||
return None
|
||||
return ClaimedOutboxMessage(
|
||||
message_id=candidate.id,
|
||||
event_key=candidate.event_key,
|
||||
topic=candidate.topic,
|
||||
payload=dict(candidate.payload),
|
||||
payload_version=candidate.payload_version,
|
||||
attempt=next_attempt,
|
||||
)
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""原子认领最早一条到期消息。"""
|
||||
return self._claim(now, lease_until)
|
||||
|
||||
def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> bool:
|
||||
"""按事件键原子认领同步投递,避免与 dispatcher 并发重复发送。"""
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领到期消息。"""
|
||||
return self._claim(now, lease_until, event_key)
|
||||
|
||||
def _claim(
|
||||
self,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
event_key: Optional[str] = None,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""在独立事务中以 compare-and-swap 取得 lease。"""
|
||||
now_text = _iso(now)
|
||||
candidate = self._session.execute(
|
||||
select(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.event_key == event_key,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
with self._session_factory() as session:
|
||||
candidate = session.execute(_claim_query(now_text, event_key)).scalars().first()
|
||||
if candidate is None:
|
||||
return None
|
||||
next_attempt = candidate.attempt + 1
|
||||
claimed = execute_dml(
|
||||
session,
|
||||
_claim_update(candidate, now_text, lease_until),
|
||||
)
|
||||
session.commit()
|
||||
return _message(candidate, next_attempt) if claimed else None
|
||||
|
||||
def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 processing owner 标记完成。"""
|
||||
with self._session_factory() as session:
|
||||
changed = execute_dml(
|
||||
session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="completed",
|
||||
completed_at=_iso(completed_at),
|
||||
lease_until=None,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if candidate is None:
|
||||
return False
|
||||
claimed = execute_dml(
|
||||
self._session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
OutboxMessage.event_key == event_key,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=OutboxMessage.attempt + 1,
|
||||
lease_until=_iso(lease_until),
|
||||
),
|
||||
)
|
||||
self._session.commit()
|
||||
return bool(claimed)
|
||||
|
||||
def complete(self, message_id: int, completed_at: datetime) -> None:
|
||||
"""持久化完成终态并释放 lease。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.id == message_id)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
self._session.commit()
|
||||
|
||||
def complete_by_event_key(self, event_key: str, completed_at: datetime) -> None:
|
||||
"""即时 post-commit 全部成功时按幂等键收口对应 intent。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.event_key == event_key)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
self._session.commit()
|
||||
session.commit()
|
||||
return bool(changed)
|
||||
|
||||
def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 owner 释放 lease 或写入 dead 终态。"""
|
||||
with self._session_factory() as session:
|
||||
changed = execute_dml(
|
||||
session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="dead" if dead else "pending",
|
||||
next_retry_at=_iso(next_retry_at),
|
||||
lease_until=None,
|
||||
last_error=last_error,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return bool(changed)
|
||||
|
||||
|
||||
class SqlAlchemyAsyncOutboxDispatchStore:
|
||||
"""用独立异步短事务认领并结算 outbox 消息。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
|
||||
) -> None:
|
||||
"""持久化下一次退避或不可自动重试的 dead 终态。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.id == message_id)
|
||||
.values(
|
||||
status="dead" if dead else "pending",
|
||||
next_retry_at=_iso(next_retry_at),
|
||||
lease_until=None,
|
||||
last_error=last_error,
|
||||
)
|
||||
)
|
||||
self._session.commit()
|
||||
"""保存每次操作创建独立异步 Session 的工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
class SqlAlchemyAsyncOutboxStager:
|
||||
"""只负责把 outbox 意图加入调用方异步事务。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""保存由异步订阅命令拥有的 Session。"""
|
||||
self._session = session
|
||||
|
||||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""暂存并 flush,确保业务行与意图由同一次 commit 决定。"""
|
||||
self._session.add(
|
||||
OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=intent.payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def complete_by_event_key(
|
||||
async def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领到期消息。"""
|
||||
now_text = _iso(now)
|
||||
async with self._session_factory() as session:
|
||||
candidate = (await session.execute(_claim_query(now_text, event_key))).scalars().first()
|
||||
if candidate is None:
|
||||
return None
|
||||
next_attempt = candidate.attempt + 1
|
||||
result = await session.execute(_claim_update(candidate, now_text, lease_until))
|
||||
await session.commit()
|
||||
return _message(candidate, next_attempt) if result.rowcount else None
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> None:
|
||||
"""异步 post-commit 全部成功时按幂等键收口 intent。"""
|
||||
await self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.event_key == event_key)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
await self._session.commit()
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 processing owner 标记完成。"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="completed",
|
||||
completed_at=_iso(completed_at),
|
||||
lease_until=None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 owner 释放 lease 或写入 dead 终态。"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="dead" if dead else "pending",
|
||||
next_retry_at=_iso(next_retry_at),
|
||||
lease_until=None,
|
||||
last_error=last_error,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
|
||||
def _outbox_model(intent: OutboxIntent, now: datetime) -> OutboxMessage:
|
||||
"""构造由业务事务持有的新 outbox ORM 行。"""
|
||||
payload = dict(intent.payload)
|
||||
payload["idempotency_key"] = intent.event_key
|
||||
return OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
|
||||
+184
-32
@@ -2,12 +2,19 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.outbox import (
|
||||
OUTBOX_LEASE_SECONDS,
|
||||
AsyncOutboxDispatchStore,
|
||||
ClaimedOutboxMessage,
|
||||
OutboxDispatchStore,
|
||||
OutboxLeaseLostError,
|
||||
)
|
||||
from app.application.subscription.write import (
|
||||
AfterCommitEffect,
|
||||
AsyncAfterCommitEffect,
|
||||
@@ -18,8 +25,10 @@ from app.application.subscription.write import (
|
||||
subscription_added_report_key,
|
||||
)
|
||||
from app.db.adapters.outbox import (
|
||||
SqlAlchemyAsyncOutboxDispatchStore,
|
||||
SqlAlchemyAsyncOutboxStager,
|
||||
SqlAlchemyOutboxRepository,
|
||||
SqlAlchemyOutboxDispatchStore,
|
||||
SqlAlchemyOutboxStager,
|
||||
)
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
|
||||
@@ -51,7 +60,8 @@ class TransactionalSubscribeWriter:
|
||||
"""在独占同步会话内执行一次完整订阅新增事务。"""
|
||||
session = self._sync_session()
|
||||
try:
|
||||
outbox = SqlAlchemyOutboxRepository(session)
|
||||
outbox = SqlAlchemyOutboxStager(session)
|
||||
dispatch_store = SqlAlchemyOutboxDispatchStore(self._sync_session)
|
||||
command = CreateSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
@@ -61,21 +71,13 @@ class TransactionalSubscribeWriter:
|
||||
def delivered(subscribe_id: int) -> None:
|
||||
"""执行提交后编排,分别收口已确认的 durable intent。"""
|
||||
if after_commit:
|
||||
report_delivered = after_commit(subscribe_id)
|
||||
outbox.complete_by_event_key(
|
||||
subscription_added_event_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
_deliver_added_effects(
|
||||
dispatch_store,
|
||||
subscribe_id,
|
||||
payload,
|
||||
notification,
|
||||
lambda: after_commit(subscribe_id),
|
||||
)
|
||||
if notification:
|
||||
outbox.complete_by_event_key(
|
||||
subscription_added_notification_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
if report_delivered is not False:
|
||||
outbox.complete_by_event_key(
|
||||
subscription_added_report_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return command.execute(
|
||||
identity,
|
||||
@@ -98,6 +100,7 @@ class TransactionalSubscribeWriter:
|
||||
"""在独占异步会话作用域内执行一次完整订阅新增事务。"""
|
||||
async with self._async_session() as session:
|
||||
outbox = SqlAlchemyAsyncOutboxStager(session)
|
||||
dispatch_store = SqlAlchemyAsyncOutboxDispatchStore(self._async_session)
|
||||
command = AsyncCreateSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
|
||||
@@ -107,21 +110,13 @@ class TransactionalSubscribeWriter:
|
||||
async def delivered(subscribe_id: int) -> None:
|
||||
"""异步执行提交后编排,分别收口已确认的 durable intent。"""
|
||||
if after_commit:
|
||||
report_delivered = await after_commit(subscribe_id)
|
||||
await outbox.complete_by_event_key(
|
||||
subscription_added_event_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
await _deliver_added_effects_async(
|
||||
dispatch_store,
|
||||
subscribe_id,
|
||||
payload,
|
||||
notification,
|
||||
lambda: after_commit(subscribe_id),
|
||||
)
|
||||
if notification:
|
||||
await outbox.complete_by_event_key(
|
||||
subscription_added_notification_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
if report_delivered is not False:
|
||||
await outbox.complete_by_event_key(
|
||||
subscription_added_report_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return await command.execute(
|
||||
identity,
|
||||
@@ -130,3 +125,160 @@ class TransactionalSubscribeWriter:
|
||||
delivered,
|
||||
notification,
|
||||
)
|
||||
|
||||
|
||||
def _added_effect_keys(
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
notification: Optional[dict[str, object]],
|
||||
) -> tuple[str, ...]:
|
||||
"""返回组合回调实际包含的独立 durable effect 键。"""
|
||||
keys = [subscription_added_event_key(subscribe_id, payload)]
|
||||
if notification:
|
||||
keys.append(subscription_added_notification_key(subscribe_id, payload))
|
||||
keys.append(subscription_added_report_key(subscribe_id, payload))
|
||||
return tuple(keys)
|
||||
|
||||
|
||||
def _claim_added_effects(
|
||||
store: OutboxDispatchStore,
|
||||
keys: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> Optional[tuple[ClaimedOutboxMessage, ...]]:
|
||||
"""全量认领组合回调;竞争丢失时释放本次已取得的 lease。"""
|
||||
claimed: list[ClaimedOutboxMessage] = []
|
||||
for key in keys:
|
||||
message = store.claim_by_event_key(
|
||||
key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if message is None:
|
||||
for owned in claimed:
|
||||
store.retry(
|
||||
owned.message_id,
|
||||
owned.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="组合副作用由其他 owner 接管",
|
||||
dead=False,
|
||||
)
|
||||
return None
|
||||
claimed.append(message)
|
||||
return tuple(claimed)
|
||||
|
||||
|
||||
def _deliver_added_effects(
|
||||
store: OutboxDispatchStore,
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
notification: Optional[dict[str, object]],
|
||||
effect: Callable[[], Optional[bool]],
|
||||
) -> None:
|
||||
"""认领组合回调并按事件、通知、统计的确认结果分别结算。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
claimed = _claim_added_effects(
|
||||
store,
|
||||
_added_effect_keys(subscribe_id, payload, notification),
|
||||
now,
|
||||
)
|
||||
if claimed is None:
|
||||
return
|
||||
try:
|
||||
report_delivered = effect()
|
||||
except Exception as error:
|
||||
for message in claimed:
|
||||
store.retry(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
for message in claimed[:-1]:
|
||||
if not store.complete(message.message_id, message.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增完成凭证已失效")
|
||||
report = claimed[-1]
|
||||
if report_delivered is False:
|
||||
store.retry(
|
||||
report.message_id,
|
||||
report.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="订阅新增统计未确认",
|
||||
dead=False,
|
||||
)
|
||||
else:
|
||||
if not store.complete(report.message_id, report.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增统计完成凭证已失效")
|
||||
|
||||
|
||||
async def _claim_added_effects_async(
|
||||
store: AsyncOutboxDispatchStore,
|
||||
keys: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> Optional[tuple[ClaimedOutboxMessage, ...]]:
|
||||
"""异步全量认领组合回调,竞争丢失时释放已取得 lease。"""
|
||||
claimed: list[ClaimedOutboxMessage] = []
|
||||
for key in keys:
|
||||
message = await store.claim_by_event_key(
|
||||
key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if message is None:
|
||||
for owned in claimed:
|
||||
await store.retry(
|
||||
owned.message_id,
|
||||
owned.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="组合副作用由其他 owner 接管",
|
||||
dead=False,
|
||||
)
|
||||
return None
|
||||
claimed.append(message)
|
||||
return tuple(claimed)
|
||||
|
||||
|
||||
async def _deliver_added_effects_async(
|
||||
store: AsyncOutboxDispatchStore,
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
notification: Optional[dict[str, object]],
|
||||
effect: Callable[[], Any],
|
||||
) -> None:
|
||||
"""异步认领组合回调并按各 intent 的确认结果分别结算。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
claimed = await _claim_added_effects_async(
|
||||
store,
|
||||
_added_effect_keys(subscribe_id, payload, notification),
|
||||
now,
|
||||
)
|
||||
if claimed is None:
|
||||
return
|
||||
try:
|
||||
report_delivered = await effect()
|
||||
except Exception as error:
|
||||
for message in claimed:
|
||||
await store.retry(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
for message in claimed[:-1]:
|
||||
if not await store.complete(message.message_id, message.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增完成凭证已失效")
|
||||
report = claimed[-1]
|
||||
if report_delivered is False:
|
||||
await store.retry(
|
||||
report.message_id,
|
||||
report.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="订阅新增统计未确认",
|
||||
dead=False,
|
||||
)
|
||||
else:
|
||||
if not await store.complete(report.message_id, report.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增统计完成凭证已失效")
|
||||
|
||||
+100
-22
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Any
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -13,9 +15,12 @@ from app.application.security.user import (
|
||||
AuxiliaryUserCreate,
|
||||
ChainUserRepository,
|
||||
FrozenJson,
|
||||
LastActiveSuperuserError,
|
||||
UserAuthSnapshot,
|
||||
UserNameConflictError,
|
||||
UserRepository,
|
||||
UserSnapshot,
|
||||
UserUpdateResult,
|
||||
)
|
||||
from app.db.models.user import User
|
||||
from app.db.oper.user import UserOper
|
||||
@@ -54,12 +59,12 @@ class SqlAlchemyUserRepository(UserRepository):
|
||||
self._session = session
|
||||
self._oper = UserOper(db=session)
|
||||
|
||||
def get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
def get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""在同步请求会话中按用户名读取冻结快照。"""
|
||||
model = self._oper.get_by_name(name)
|
||||
return _to_snapshot(model) if model else None
|
||||
|
||||
def get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""在同步请求会话中按 ID 读取冻结快照。"""
|
||||
model = self._oper.get_by_id(user_id)
|
||||
return _to_snapshot(model) if model else None
|
||||
@@ -68,33 +73,64 @@ class SqlAlchemyUserRepository(UserRepository):
|
||||
"""在异步请求会话中读取全部冻结用户快照。"""
|
||||
return [_to_snapshot(model) for model in await self._oper.async_list()]
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""在异步请求会话中按用户名读取冻结快照。"""
|
||||
model = await self._oper.async_get_by_name(name)
|
||||
return _to_snapshot(model) if model else None
|
||||
|
||||
async def async_get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
async def async_get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""在异步请求会话中按 ID 读取冻结快照。"""
|
||||
model = await self._oper.async_get_by_id(user_id)
|
||||
return _to_snapshot(model) if model else None
|
||||
|
||||
async def async_create(self, payload: dict[str, Any]) -> UserSnapshot | None:
|
||||
async def async_create(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> Optional[UserSnapshot]:
|
||||
"""在请求事务中暂存用户创建并返回冻结快照。"""
|
||||
model = await self._oper.async_create(payload)
|
||||
return _to_snapshot(model) if model else None
|
||||
session = self._require_async_session()
|
||||
model = User(**payload)
|
||||
session.add(model)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError as error:
|
||||
raise UserNameConflictError(payload.get("name")) from error
|
||||
return _to_snapshot(model)
|
||||
|
||||
async def async_update(
|
||||
self,
|
||||
user_id: int,
|
||||
payload: dict[str, Any],
|
||||
) -> UserSnapshot | None:
|
||||
"""在请求事务中暂存用户更新并返回更新后的冻结快照。"""
|
||||
model = await self._oper.async_update(user_id, payload)
|
||||
return _to_snapshot(model) if model else None
|
||||
) -> Optional[UserUpdateResult]:
|
||||
"""原子更新用户;数据库外键负责按用户名级联偏好。"""
|
||||
session = self._require_async_session()
|
||||
model = await self._locked_user(session, user_id, payload)
|
||||
if model is None:
|
||||
return None
|
||||
old_name = model.name
|
||||
new_name = str(payload.get("name", old_name))
|
||||
values = {key: value for key, value in payload.items() if key != "id"}
|
||||
for key, value in values.items():
|
||||
setattr(model, key, value)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError as error:
|
||||
raise UserNameConflictError(new_name) from error
|
||||
return UserUpdateResult(
|
||||
user=_to_snapshot(model),
|
||||
previous_name=old_name,
|
||||
)
|
||||
|
||||
async def async_delete(self, user_id: int) -> None:
|
||||
"""在请求事务中暂存用户删除。"""
|
||||
await self._oper.async_delete(user_id)
|
||||
async def async_delete(self, user_id: int) -> Optional[str]:
|
||||
"""原子删除用户;数据库外键负责级联偏好和 PassKey。"""
|
||||
session = self._require_async_session()
|
||||
model = await self._locked_user(session, user_id, None)
|
||||
if model is None:
|
||||
return None
|
||||
username: str = model.name
|
||||
await session.delete(model)
|
||||
await session.flush()
|
||||
return username
|
||||
|
||||
async def async_update_otp_by_name(
|
||||
self,
|
||||
@@ -105,6 +141,45 @@ class SqlAlchemyUserRepository(UserRepository):
|
||||
"""在请求事务中暂存用户 OTP 状态更新。"""
|
||||
await self._oper.async_update_otp_by_name(name, otp, secret)
|
||||
|
||||
def _require_async_session(self) -> AsyncSession:
|
||||
"""返回写用例要求的异步 Session,拒绝错误组合。"""
|
||||
if not isinstance(self._session, AsyncSession):
|
||||
raise RuntimeError("用户异步写入必须绑定 AsyncSession")
|
||||
return self._session
|
||||
|
||||
@staticmethod
|
||||
async def _locked_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
payload: Optional[dict[str, Any]],
|
||||
) -> Optional[User]:
|
||||
"""先锁管理员集合再锁目标用户,保护并发下最后一个启用管理员。"""
|
||||
result = await session.execute(
|
||||
select(User)
|
||||
.where(User.is_active.is_(True), User.is_superuser.is_(True))
|
||||
.order_by(User.id)
|
||||
.with_for_update()
|
||||
)
|
||||
administrators = list(result.scalars().all())
|
||||
model = next(
|
||||
(administrator for administrator in administrators if administrator.id == user_id),
|
||||
None,
|
||||
)
|
||||
if model is None:
|
||||
locked = await session.execute(select(User).where(User.id == user_id).with_for_update())
|
||||
model = cast(Optional[User], locked.scalars().first())
|
||||
if model is None:
|
||||
return None
|
||||
remains_active = (
|
||||
payload is not None
|
||||
and bool(payload.get("is_active", model.is_active))
|
||||
and bool(payload.get("is_superuser", model.is_superuser))
|
||||
)
|
||||
removes_active_superuser = bool(model.is_active and model.is_superuser and not remains_active)
|
||||
if removes_active_superuser and len(administrators) <= 1:
|
||||
raise LastActiveSuperuserError(model.name)
|
||||
return model
|
||||
|
||||
|
||||
class TransactionalUserRepository(ChainUserRepository):
|
||||
"""为 Chain、Agent 和进程级认证提供短生命周期用户会话。"""
|
||||
@@ -119,23 +194,23 @@ class TransactionalUserRepository(ChainUserRepository):
|
||||
self._sync_session = sync_session
|
||||
self._async_session = async_session
|
||||
|
||||
def get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
def get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""按用户名读取公开用户快照。"""
|
||||
with self._sync_session() as session:
|
||||
return SqlAlchemyUserRepository(session).get_by_name(name)
|
||||
|
||||
def get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""按 ID 读取公开用户快照。"""
|
||||
with self._sync_session() as session:
|
||||
return SqlAlchemyUserRepository(session).get_by_id(user_id)
|
||||
|
||||
def get_auth_by_name(self, name: str) -> UserAuthSnapshot | None:
|
||||
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
|
||||
"""按用户名读取认证凭据快照。"""
|
||||
with self._sync_session() as session:
|
||||
model = UserOper(db=session).get_by_name(name)
|
||||
return _to_auth_snapshot(model) if model else None
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""异步按用户名读取公开用户快照。"""
|
||||
async with self._async_session() as session:
|
||||
return await SqlAlchemyUserRepository(session).async_get_by_name(name)
|
||||
@@ -164,7 +239,7 @@ class TransactionalUserRepository(ChainUserRepository):
|
||||
def get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""同步读取用户通知设置的只读快照。"""
|
||||
user = self.get_by_name(name)
|
||||
return user.settings if user else None
|
||||
@@ -172,12 +247,15 @@ class TransactionalUserRepository(ChainUserRepository):
|
||||
async def async_get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""异步读取用户通知设置的只读快照。"""
|
||||
user = await self.async_get_by_name(name)
|
||||
return user.settings if user else None
|
||||
|
||||
def find_name_by_bindings(self, bindings: Mapping[str, object]) -> str | None:
|
||||
def find_name_by_bindings(
|
||||
self,
|
||||
bindings: Mapping[str, object],
|
||||
) -> Optional[str]:
|
||||
"""仅在全部绑定唯一匹配同一启用用户时返回用户名。"""
|
||||
if not bindings:
|
||||
return None
|
||||
|
||||
+105
-67
@@ -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}。"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy import JSON, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
+45
-42
@@ -1,8 +1,9 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text, select, ForeignKey, update
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
@@ -13,7 +14,8 @@ def _get_by_user_id_statement(model: type["PassKey"], user_id: int):
|
||||
|
||||
|
||||
def _get_by_credential_id_statement(
|
||||
model: type["PassKey"], credential_id: str,
|
||||
model: type["PassKey"],
|
||||
credential_id: str,
|
||||
):
|
||||
"""构造按凭证 ID 筛选启用 PassKey 的查询语句。"""
|
||||
return select(model).where(
|
||||
@@ -26,10 +28,20 @@ class PassKey(Base):
|
||||
"""
|
||||
用户PassKey凭证表
|
||||
"""
|
||||
|
||||
# ID
|
||||
id = get_id_column()
|
||||
# 用户ID
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey(
|
||||
"user.id",
|
||||
name="fk_passkey_user_id_user",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# 凭证ID (credential_id)
|
||||
credential_id: Mapped[str] = mapped_column(String, nullable=False, unique=True, index=True)
|
||||
# 凭证公钥
|
||||
@@ -51,40 +63,32 @@ class PassKey(Base):
|
||||
|
||||
@classmethod
|
||||
def get_by_user_id(
|
||||
cls,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
cls,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
):
|
||||
"""在调用方 Session 中获取用户的所有启用 PassKey。"""
|
||||
return list(db.execute(
|
||||
_get_by_user_id_statement(cls, user_id)
|
||||
).scalars().all())
|
||||
return list(db.execute(_get_by_user_id_statement(cls, user_id)).scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def async_get_by_user_id(cls, db: AsyncSession, user_id: int):
|
||||
"""在调用方 AsyncSession 中获取用户的所有启用 PassKey。"""
|
||||
result = await db.execute(
|
||||
_get_by_user_id_statement(cls, user_id)
|
||||
)
|
||||
result = await db.execute(_get_by_user_id_statement(cls, user_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
def get_by_credential_id(
|
||||
cls,
|
||||
db: Session,
|
||||
credential_id: str,
|
||||
cls,
|
||||
db: Session,
|
||||
credential_id: str,
|
||||
):
|
||||
"""在调用方 Session 中按凭证 ID 获取启用 PassKey。"""
|
||||
return db.execute(
|
||||
_get_by_credential_id_statement(cls, credential_id)
|
||||
).scalars().first()
|
||||
return db.execute(_get_by_credential_id_statement(cls, credential_id)).scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str):
|
||||
"""在调用方 AsyncSession 中根据凭证 ID 获取启用 PassKey。"""
|
||||
result = await db.execute(
|
||||
_get_by_credential_id_statement(cls, credential_id)
|
||||
)
|
||||
result = await db.execute(_get_by_credential_id_statement(cls, credential_id))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@@ -95,17 +99,13 @@ class PassKey(Base):
|
||||
@classmethod
|
||||
async def async_get_by_id(cls, db: AsyncSession, passkey_id: int):
|
||||
"""在调用方 AsyncSession 中根据 ID 获取 PassKey。"""
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.id == passkey_id)
|
||||
)
|
||||
result = await db.execute(select(cls).filter(cls.id == passkey_id))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
def delete_by_id(cls, db: Session, passkey_id: int, user_id: int):
|
||||
"""删除指定用户的PassKey"""
|
||||
passkey = db.execute(
|
||||
select(cls).where(cls.id == passkey_id, cls.user_id == user_id)
|
||||
).scalars().first()
|
||||
passkey = db.execute(select(cls).where(cls.id == passkey_id, cls.user_id == user_id)).scalars().first()
|
||||
if passkey:
|
||||
db.delete(passkey)
|
||||
return True
|
||||
@@ -114,12 +114,7 @@ class PassKey(Base):
|
||||
@classmethod
|
||||
async def async_delete_by_id(cls, db: AsyncSession, passkey_id: int, user_id: int):
|
||||
"""异步删除指定用户的PassKey"""
|
||||
result = await db.execute(
|
||||
select(cls).filter(
|
||||
cls.id == passkey_id,
|
||||
cls.user_id == user_id
|
||||
)
|
||||
)
|
||||
result = await db.execute(select(cls).filter(cls.id == passkey_id, cls.user_id == user_id))
|
||||
passkey = result.scalars().first()
|
||||
if passkey:
|
||||
await db.delete(passkey)
|
||||
@@ -128,16 +123,24 @@ class PassKey(Base):
|
||||
|
||||
def update_last_used(self, db: Session, sign_count: int):
|
||||
"""更新最后使用时间和签名计数"""
|
||||
db.execute(update(type(self)).where(type(self).id == self.id).values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
))
|
||||
db.execute(
|
||||
update(type(self))
|
||||
.where(type(self).id == self.id)
|
||||
.values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def async_update_last_used(self, db: AsyncSession, sign_count: int):
|
||||
"""异步更新最后使用时间和签名计数"""
|
||||
await db.execute(update(type(self)).where(type(self).id == self.id).values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
))
|
||||
await db.execute(
|
||||
update(type(self))
|
||||
.where(type(self).id == self.id)
|
||||
.values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
from typing import Any, Optional
|
||||
from sqlalchemy import String, UniqueConstraint, JSON, select
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db.base import get_id_column, Base
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
|
||||
class UserConfig(Base):
|
||||
"""
|
||||
用户配置表
|
||||
"""
|
||||
|
||||
id = get_id_column()
|
||||
# 用户名
|
||||
username: Mapped[Optional[str]] = mapped_column(String)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String,
|
||||
ForeignKey(
|
||||
"user.name",
|
||||
name="fk_userconfig_username_user",
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
# 配置键
|
||||
key: Mapped[Optional[str]] = mapped_column(String)
|
||||
key: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 值
|
||||
value: Mapped[Optional[Any]] = mapped_column(JSON)
|
||||
|
||||
__table_args__ = (
|
||||
# 用户名和配置键联合唯一
|
||||
UniqueConstraint('username', 'key'),
|
||||
UniqueConstraint(
|
||||
"username",
|
||||
"key",
|
||||
name="uq_userconfig_username_key",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_key(cls, db: Session, username: str, key: str):
|
||||
"""在调用方 Session 中查询用户配置。"""
|
||||
return db.execute(
|
||||
select(cls).where(cls.username == username, cls.key == key)
|
||||
).scalars().first()
|
||||
return db.execute(select(cls).where(cls.username == username, cls.key == key)).scalars().first()
|
||||
|
||||
def delete_by_key(self, db: Session, username: str, key: str):
|
||||
"""在调用方持有的事务中暂存指定用户配置删除。"""
|
||||
|
||||
+34
-7
@@ -1,11 +1,12 @@
|
||||
"""PassKey 数据访问适配器。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.base import DbOper, execute_dml
|
||||
from app.db.models.passkey import (
|
||||
PassKey,
|
||||
_get_by_credential_id_statement,
|
||||
@@ -54,11 +55,37 @@ class PassKeyOper(DbOper):
|
||||
session.add(passkey)
|
||||
session.flush()
|
||||
|
||||
def update_last_used(self, passkey: PassKey, sign_count: int) -> bool:
|
||||
"""更新凭证最后使用时间和签名计数。"""
|
||||
return bool(self._execute_sync_write(
|
||||
lambda session: passkey.update_last_used(session, sign_count)
|
||||
))
|
||||
def compare_and_update_sign_count(
|
||||
self,
|
||||
passkey_id: int,
|
||||
expected_sign_count: int,
|
||||
sign_count: int,
|
||||
) -> bool:
|
||||
"""仅在凭证仍启用且签名计数未变化时记录本次认证。"""
|
||||
if sign_count < expected_sign_count or (
|
||||
expected_sign_count > 0 and sign_count == expected_sign_count
|
||||
):
|
||||
return False
|
||||
|
||||
count_matches = PassKey.sign_count == expected_sign_count
|
||||
if expected_sign_count == 0:
|
||||
count_matches = or_(PassKey.sign_count == 0, PassKey.sign_count.is_(None))
|
||||
|
||||
statement = (
|
||||
update(PassKey)
|
||||
.where(
|
||||
PassKey.id == passkey_id,
|
||||
PassKey.is_active.is_(True),
|
||||
count_matches,
|
||||
)
|
||||
.values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
)
|
||||
)
|
||||
return self._execute_sync_write(
|
||||
lambda session: execute_dml(session, statement)
|
||||
) == 1
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除指定用户的凭证。"""
|
||||
|
||||
+109
-62
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位": "密碼需同時包含字母、數字、特殊字元中的至少兩項,且長度大於 6 位",
|
||||
"用户名不能为空": "使用者名稱不能為空",
|
||||
"用户名已被使用": "使用者名稱已被使用",
|
||||
"必须保留至少一个启用的超级管理员": "必須保留至少一個啟用的超級管理員",
|
||||
"用户不存在": "使用者不存在",
|
||||
"已存在相同名称的工作流": "已存在相同名稱的工作流",
|
||||
"创建工作流成功": "建立工作流成功",
|
||||
|
||||
+80
-7
@@ -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:
|
||||
"""
|
||||
删除缓存值
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -26,8 +26,9 @@ class TransferTask(CanonicalTransferTask):
|
||||
|
||||
meta: Optional[Any] = None
|
||||
mediainfo: Optional[Any] = None
|
||||
download_history: Optional[Any] = None
|
||||
|
||||
def to_dict(self):
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""返回兼容领域对象和旧 Pydantic 对象的任务字典。"""
|
||||
values = vars(self).copy()
|
||||
values["fileitem"] = _serialize_legacy_value(self.fileitem)
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 853 / 6,979 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 856 / 7,006 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
|
||||
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
||||
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
||||
| Ruff 历史诊断 | 868 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 79.02%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
| Ruff 历史诊断 | 840 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 79.39%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
@@ -101,7 +101,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
## 4. 优化清单总表
|
||||
|
||||
状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`渐进` 表示应按触碰路径逐步收敛。
|
||||
状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`执行中` 表示只完成部分纵切面;`已验证` 表示实现和本地合同已落地但未在本文中声明未知的提交或 CI;`渐进` 表示应按触碰路径逐步收敛。
|
||||
|
||||
| ID | 优先级 | 状态 | 事项 | 目标结果 |
|
||||
|---|---|---|---|---|
|
||||
@@ -110,10 +110,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| ARCH-102 | P1 | 已交付 | 将 Transfer pending 升级为真实 E3 状态机 | `e9de149db`、`a2e249f20` 已推送;Unit Tests `33092427327`、Pylint `33092427348` 全绿,崩溃结果未知时进入人工确认 |
|
||||
| ARCH-103 | P1 | 执行中 | 类型化 Chain/Agent 数据 Port 与 DTO | 宿主主路径不再注入无 Session Oper,不向入口泄漏 ORM |
|
||||
| ARCH-104 | P1 | 待执行 | 收口跨多次写入的业务事务 | 站点/规则引用清理可整体回滚或幂等恢复 |
|
||||
| ARCH-105 | P1 | 待执行 | 明确 post-commit 与 Outbox 完成语义 | “业务已提交、后置效果 pending”可被调用方正确识别 |
|
||||
| ARCH-105 | P1 | 已验证 | 明确 post-commit 与 Outbox 完成语义 | 业务提交、effect 完成/pending 可区分;stager/store 分离且 claim/settlement 受 fencing,外部 sink 仍承担 at-least-once 幂等边界 |
|
||||
| ARCH-106 | P1 | 待执行 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 导入或普通 Chain 构造不再启动进程资源 |
|
||||
| ARCH-107 | P1 | 待执行 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 |
|
||||
| ARCH-108 | P1 | 待执行 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | 依赖倒置有明确例外、低水位和迁移顺序 |
|
||||
| ARCH-108 | P1 | 执行中 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Passkey 缓存纵切面已验证,其余 Adapter/HTTP/DNS 债务继续按低水位迁移 |
|
||||
| ARCH-109 | P1 | 待执行 | 按用例拆分超大 Chain、Scheduler 和厚 API | 稳定 Facade 保留,决策/I/O/状态/生命周期各有 owner |
|
||||
| ARCH-110 | P1 | 待执行 | Module/Event Contract 分可信级执行 | 宿主 provider 严格,第三方插件仍兼容诊断 |
|
||||
| ARCH-111 | P1 | 待执行 | 升级复杂度、类型、覆盖率和并发原语门禁 | 高风险私有路径也进入只降不增的治理面 |
|
||||
@@ -268,14 +268,13 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
**问题与证据**
|
||||
|
||||
- `app/application/chain/data.py:14-29,134-176` 的 Oper factory 和 getter 基本都是 `Any`;
|
||||
`app/application/agentdata.py:91-120` 还通过 `__dict__.update()` 动态组装端口。
|
||||
- `app/startup/initializers/modules.py:848-863,896-910` 仍向生产 Chain/Agent 注入多个无 Session Oper。
|
||||
- `app/application/chain/data.py` 的 Site/Subscribe/TransferHistory factory 仍是 `Any`;
|
||||
`app/application/agentdata.py` 仍通过 `__dict__.update()` 动态组装未迁移端口。
|
||||
- `app/startup/initializers/modules.py` 仍向生产 Chain/Agent 注入部分无 Session Oper。
|
||||
- 无 Session Oper 会为单次调用独立创建事务;一个业务操作的“查询后更新”可能被拆成多个事务。
|
||||
- Workflow query 已在 S1-L2 迁入冻结 DTO 和 adapter-owned Session 投影;其余 Chain/Agent raw data port
|
||||
仍返回 `Any`/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 的债务只降不增。
|
||||
|
||||
|
||||
@@ -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` 观察面) |
|
||||
|
||||
@@ -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 | 当前受控 868 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
|
||||
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 840 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
|
||||
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
||||
|
||||
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any, Literal, TypeAlias
|
||||
_DEFAULT_IDENTITY = "<default>"
|
||||
_DYNAMIC_IDENTITY = "<dynamic>"
|
||||
_EVENT_MANAGER_METHODS = {"add_event_listener", "register"}
|
||||
_PRODUCER_METHODS = {"async_send_event", "send_event"}
|
||||
_PRODUCER_METHODS = {"async_send_event", "send_event", "send_event_strict"}
|
||||
_COMPREHENSION_SCOPES = (
|
||||
ast.ListComp,
|
||||
ast.SetComp,
|
||||
@@ -61,6 +61,7 @@ class _BoundEventMethod:
|
||||
"add_event_listener",
|
||||
"register",
|
||||
"send_event",
|
||||
"send_event_strict",
|
||||
"async_send_event",
|
||||
]
|
||||
receiver_kind: str
|
||||
|
||||
+59
-49
@@ -3,6 +3,7 @@
|
||||
引导与网络守卫均复用 ``app/testing`` 的共享 harness(与插件仓 conftest 同源),
|
||||
引导逻辑只在 ``app/testing`` 维护一处。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -124,8 +125,8 @@ def configure_plugin_system_services():
|
||||
configure_user_configuration,
|
||||
)
|
||||
from app.application.service import configure_service_directory
|
||||
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.oper.userconfig import UserConfigOper
|
||||
from app.db.session import (
|
||||
SessionFactory,
|
||||
async_session_scope,
|
||||
@@ -159,10 +160,10 @@ def configure_plugin_system_services():
|
||||
configure_token_runtime_config(lambda: build_token_runtime_config(settings))
|
||||
database_executor = _TestDatabaseExecutor()
|
||||
system_config = SystemConfigOper()
|
||||
user_config = UserConfigOper()
|
||||
user_config = TransactionalUserConfigurationRepository(SessionFactory)
|
||||
with SessionFactory() as session:
|
||||
system_config.load_snapshot(session)
|
||||
user_config.load_snapshot(session)
|
||||
user_config.load_snapshot()
|
||||
configure_system_config(
|
||||
SystemConfigService(
|
||||
repository=system_config,
|
||||
@@ -201,6 +202,7 @@ def configure_plugin_system_services():
|
||||
from app.runtime.extensions.module_manager import ModuleManager
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.runtime.extensions.service_config import ServiceConfigHelper
|
||||
|
||||
configure_service_directory(
|
||||
configs=ServiceConfigHelper.get_configs,
|
||||
modules=lambda module_type: ModuleManager().get_running_type_modules(module_type),
|
||||
@@ -216,6 +218,7 @@ def configure_plugin_system_services():
|
||||
configure_workflow_runtime,
|
||||
)
|
||||
from app.workflow import WorkflowManager
|
||||
|
||||
configure_workflow_runtime(lambda: WorkflowManager())
|
||||
from app.application.agentdata import configure_agent_data_ports
|
||||
from app.application.agenttask import (
|
||||
@@ -320,42 +323,39 @@ def configure_plugin_system_services():
|
||||
subscribe=lambda: SubscribeOper(),
|
||||
download_history=lambda: DownloadHistoryOper(),
|
||||
transfer_history=lambda: TransferHistoryOper(),
|
||||
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
|
||||
SessionFactory
|
||||
),
|
||||
transfer_execution=lambda: TransactionalTransferExecutionRepository(
|
||||
SessionFactory
|
||||
),
|
||||
transfer_pending=lambda: TransactionalTransferAdmissionRepository(SessionFactory),
|
||||
transfer_execution=lambda: TransactionalTransferExecutionRepository(SessionFactory),
|
||||
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
|
||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||
SessionFactory
|
||||
),
|
||||
download_failure=lambda: TransactionalDownloadFailureRepository(SessionFactory),
|
||||
user=user_repository,
|
||||
)
|
||||
configure_chain_runtime_context_provider(lambda: ChainRuntimeContext(
|
||||
module_manager=ModuleManager(),
|
||||
plugin_manager=PluginManager(),
|
||||
event_manager=EventManager(),
|
||||
message_oper=MessageOper(),
|
||||
message_helper=MessageHelper(),
|
||||
file_cache=FileCache(),
|
||||
async_file_cache=AsyncFileCache(),
|
||||
message_queue_factory=lambda callback: MessageQueueManager(
|
||||
send_callback=callback
|
||||
),
|
||||
module_dispatcher_factory=ModuleInvocationDispatcher,
|
||||
configuration=build_chain_runtime_config(settings),
|
||||
))
|
||||
configure_chain_runtime_context_provider(
|
||||
lambda: ChainRuntimeContext(
|
||||
module_manager=ModuleManager(),
|
||||
plugin_manager=PluginManager(),
|
||||
event_manager=EventManager(),
|
||||
message_oper=MessageOper(),
|
||||
message_helper=MessageHelper(),
|
||||
file_cache=FileCache(),
|
||||
async_file_cache=AsyncFileCache(),
|
||||
message_queue_factory=lambda callback: MessageQueueManager(send_callback=callback),
|
||||
module_dispatcher_factory=ModuleInvocationDispatcher,
|
||||
configuration=build_chain_runtime_config(settings),
|
||||
)
|
||||
)
|
||||
configure_site_query_service(SiteQueryService(repository=site_repository()))
|
||||
configure_site_health_service(SiteHealthService(repository=site_repository()))
|
||||
configure_workflow_query(WorkflowQueryService(
|
||||
repository=TransactionalWorkflowQueryRepository(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
configure_workflow_query(
|
||||
WorkflowQueryService(
|
||||
repository=TransactionalWorkflowQueryRepository(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
from app.db.oper.agenttask import AgentTaskOper
|
||||
from app.db.oper.plugindata import PluginDataOper
|
||||
|
||||
configure_agent_data_ports(
|
||||
agent_chat=lambda: AgentChatOper(),
|
||||
agent_task=lambda: AgentTaskOper(),
|
||||
@@ -367,11 +367,13 @@ def configure_plugin_system_services():
|
||||
download_history=lambda: DownloadHistoryOper(),
|
||||
plugin_data=lambda: PluginDataOper(),
|
||||
)
|
||||
configure_agent_task_execution(AgentTaskExecutionService(
|
||||
repository=lambda session: AgentTaskOper(session),
|
||||
async_executor=database_executor,
|
||||
sync_transaction=transaction_runner.sync,
|
||||
))
|
||||
configure_agent_task_execution(
|
||||
AgentTaskExecutionService(
|
||||
repository=lambda session: AgentTaskOper(session),
|
||||
async_executor=database_executor,
|
||||
sync_transaction=transaction_runner.sync,
|
||||
)
|
||||
)
|
||||
configure_agent_chat_persistence(
|
||||
AgentChatPersistenceService(
|
||||
repository=lambda session: AgentChatOper(session),
|
||||
@@ -395,18 +397,17 @@ def configure_plugin_system_services():
|
||||
)
|
||||
|
||||
helper = PluginHelper()
|
||||
configure_plugin_system(PluginSystemServices(
|
||||
market=PluginMarketClient(helper),
|
||||
package=PluginPackageManager(helper),
|
||||
dependency=PluginDependencyInstaller(helper),
|
||||
dependency_manifest_status=dependency_manifest_status,
|
||||
compatible_flags=lambda flag: (
|
||||
[flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, [])
|
||||
if flag else []
|
||||
),
|
||||
frozen=lambda: False,
|
||||
install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"),
|
||||
))
|
||||
configure_plugin_system(
|
||||
PluginSystemServices(
|
||||
market=PluginMarketClient(helper),
|
||||
package=PluginPackageManager(helper),
|
||||
dependency=PluginDependencyInstaller(helper),
|
||||
dependency_manifest_status=dependency_manifest_status,
|
||||
compatible_flags=lambda flag: [flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) if flag else [],
|
||||
frozen=lambda: False,
|
||||
install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"),
|
||||
)
|
||||
)
|
||||
from app.agent.llm.gateway import register_llm_provider_runtime
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
from app.agent.skills.registry import SkillHelper
|
||||
@@ -484,7 +485,16 @@ class DbHarness:
|
||||
except Exception: # noqa: BLE001 会话已不可用时也要继续尝试清理
|
||||
pass
|
||||
|
||||
for model, mark in self._watermarks.items():
|
||||
from app.db.base import Base
|
||||
|
||||
table_order = {table: index for index, table in enumerate(Base.metadata.sorted_tables)}
|
||||
models = sorted(
|
||||
self._watermarks,
|
||||
key=lambda model: table_order.get(model.__table__, -1),
|
||||
reverse=True,
|
||||
)
|
||||
for model in models:
|
||||
mark = self._watermarks[model]
|
||||
try:
|
||||
self.session.execute(delete(model).where(model.id > mark))
|
||||
self.session.commit()
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"application": {
|
||||
"covered_lines": 10195,
|
||||
"percent": 79.02,
|
||||
"statements": 12902
|
||||
"covered_lines": 10432,
|
||||
"percent": 79.39,
|
||||
"statements": 13141
|
||||
},
|
||||
"domain": {
|
||||
"covered_lines": 3392,
|
||||
|
||||
+49
-25
@@ -14,9 +14,9 @@
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"direct_adapter_imports": {
|
||||
"count": 28,
|
||||
"count": 27,
|
||||
"counts_by_source_root": {
|
||||
"app.application": 15,
|
||||
"app.application": 14,
|
||||
"app.chain": 13
|
||||
},
|
||||
"edges": [
|
||||
@@ -68,10 +68,6 @@
|
||||
"source": "app.application.security.cookie",
|
||||
"target": "app.adapters.network.http"
|
||||
},
|
||||
{
|
||||
"source": "app.application.security.passkey",
|
||||
"target": "app.adapters.cache.redis"
|
||||
},
|
||||
{
|
||||
"source": "app.application.torrent",
|
||||
"target": "app.adapters.network.http"
|
||||
@@ -143,7 +139,7 @@
|
||||
],
|
||||
"target_root": "app.adapters"
|
||||
},
|
||||
"source_count": 18,
|
||||
"source_count": 17,
|
||||
"sources": [
|
||||
"app.application.backup",
|
||||
"app.application.directory",
|
||||
@@ -152,7 +148,6 @@
|
||||
"app.application.rss",
|
||||
"app.application.rules",
|
||||
"app.application.security.cookie",
|
||||
"app.application.security.passkey",
|
||||
"app.application.torrent",
|
||||
"app.application.transfer.workflow",
|
||||
"app.chain._recognition",
|
||||
@@ -164,9 +159,8 @@
|
||||
"app.chain.subscribe",
|
||||
"app.chain.system"
|
||||
],
|
||||
"target_count": 11,
|
||||
"target_count": 10,
|
||||
"targets": [
|
||||
"app.adapters.cache.redis",
|
||||
"app.adapters.external.cookiecloud",
|
||||
"app.adapters.external.ocr",
|
||||
"app.adapters.external.server",
|
||||
@@ -197,7 +191,7 @@
|
||||
"from:redis.asyncio.Redis as Redis",
|
||||
"import:redis as redis"
|
||||
],
|
||||
"fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7",
|
||||
"fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81",
|
||||
"kind": "network_sdk",
|
||||
"source": "app.adapters.cache.redis",
|
||||
"target": "redis",
|
||||
@@ -226,6 +220,7 @@
|
||||
"RedisHelper.clear|call:pipeline",
|
||||
"RedisHelper.clear|call:scan_iter",
|
||||
"RedisHelper.close|call:close",
|
||||
"RedisHelper.consume|call:getdel",
|
||||
"RedisHelper.delete|call:delete",
|
||||
"RedisHelper.exists|call:exists",
|
||||
"RedisHelper.get|call:get",
|
||||
@@ -233,10 +228,9 @@
|
||||
"RedisHelper.items|call:get",
|
||||
"RedisHelper.items|call:scan_iter",
|
||||
"RedisHelper.items|call:scan_iter",
|
||||
"RedisHelper.pop|call:getdel",
|
||||
"RedisHelper.set_memory_limit|call:config_set",
|
||||
"RedisHelper.set_memory_limit|call:config_set",
|
||||
"RedisHelper.set|call:set"
|
||||
"RedisHelper.store|call:set"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1441,8 +1435,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 6979,
|
||||
"edge_sha256": "0fbef3f16d1475a40988a9fedbeb9a9ff67f49d0e3cd8033280b40a399a92d51",
|
||||
"edge_count": 7006,
|
||||
"edge_sha256": "74169f74212541ac2c1f990578f2e1d67f084944c08f1894c5fc1bff1d9016bd",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2360,11 +2354,13 @@
|
||||
"app.agent.tools.impl.query_download_tasks -> app.agent.tools.tags",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.application",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.application.agentdata",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.application.history",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.chain",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.chain.download",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.runtime",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.runtime.log",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.schemas",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.schemas.common",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.schemas.transfer",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.schemas.types",
|
||||
"app.agent.tools.impl.query_downloaders -> app.agent",
|
||||
@@ -3006,6 +3002,7 @@
|
||||
"app.api.dependencies.auth -> app.application.security.auth",
|
||||
"app.api.dependencies.auth -> app.application.security.passkey",
|
||||
"app.api.dependencies.auth -> app.application.security.user",
|
||||
"app.api.dependencies.auth -> app.application.security.userconfig",
|
||||
"app.api.dependencies.auth -> app.schemas",
|
||||
"app.api.dependencies.auth -> app.schemas.token",
|
||||
"app.api.dependencies.auth -> app.startup",
|
||||
@@ -3958,6 +3955,7 @@
|
||||
"app.api.servcookie -> app.schemas",
|
||||
"app.api.servcookie -> app.schemas.servcookie",
|
||||
"app.application.agentdata -> app.application",
|
||||
"app.application.agentdata -> app.application.history",
|
||||
"app.application.agentdata -> app.application.security",
|
||||
"app.application.agentdata -> app.application.security.user",
|
||||
"app.application.agenttask -> app.application",
|
||||
@@ -3989,6 +3987,7 @@
|
||||
"app.application.chain.data -> app.application",
|
||||
"app.application.chain.data -> app.application.download",
|
||||
"app.application.chain.data -> app.application.download.failures",
|
||||
"app.application.chain.data -> app.application.history",
|
||||
"app.application.chain.data -> app.application.mediaserver",
|
||||
"app.application.chain.data -> app.application.security",
|
||||
"app.application.chain.data -> app.application.security.user",
|
||||
@@ -4035,6 +4034,8 @@
|
||||
"app.application.download.selection -> app.schemas",
|
||||
"app.application.download.selection -> app.schemas.media",
|
||||
"app.application.download.selection -> app.schemas.mediaserver",
|
||||
"app.application.download.tasks -> app.application",
|
||||
"app.application.download.tasks -> app.application.history",
|
||||
"app.application.download.tasks -> app.schemas",
|
||||
"app.application.download.tasks -> app.schemas.transfer",
|
||||
"app.application.download.tasks -> app.schemas.types",
|
||||
@@ -4067,6 +4068,7 @@
|
||||
"app.application.history -> app.runtime.cache",
|
||||
"app.application.history -> app.runtime.log",
|
||||
"app.application.history -> app.schemas",
|
||||
"app.application.history -> app.schemas.common",
|
||||
"app.application.history -> app.schemas.history",
|
||||
"app.application.history -> app.schemas.media",
|
||||
"app.application.history -> app.schemas.transfer",
|
||||
@@ -4328,13 +4330,9 @@
|
||||
"app.application.security.cookie -> app.foundation.url",
|
||||
"app.application.security.cookie -> app.runtime",
|
||||
"app.application.security.cookie -> app.runtime.log",
|
||||
"app.application.security.passkey -> app.adapters",
|
||||
"app.application.security.passkey -> app.adapters.cache",
|
||||
"app.application.security.passkey -> app.adapters.cache.redis",
|
||||
"app.application.security.passkey -> app.application",
|
||||
"app.application.security.passkey -> app.application.configuration",
|
||||
"app.application.security.passkey -> app.runtime",
|
||||
"app.application.security.passkey -> app.runtime.cache",
|
||||
"app.application.security.passkey -> app.runtime.log",
|
||||
"app.application.security.token -> app.application",
|
||||
"app.application.security.token -> app.application.configuration",
|
||||
@@ -4351,6 +4349,9 @@
|
||||
"app.application.security.url -> app.runtime.log",
|
||||
"app.application.security.userconfig -> app.application",
|
||||
"app.application.security.userconfig -> app.application.database",
|
||||
"app.application.security.userconfig -> app.schemas",
|
||||
"app.application.security.userconfig -> app.schemas.common",
|
||||
"app.application.security.userconfig -> app.schemas.types",
|
||||
"app.application.servarr -> app.schemas",
|
||||
"app.application.servarr -> app.schemas.types",
|
||||
"app.application.server.report -> app.schemas",
|
||||
@@ -4467,6 +4468,7 @@
|
||||
"app.application.transfer.workflow -> app.adapters.system",
|
||||
"app.application.transfer.workflow -> app.adapters.system.host",
|
||||
"app.application.transfer.workflow -> app.application",
|
||||
"app.application.transfer.workflow -> app.application.history",
|
||||
"app.application.transfer.workflow -> app.application.transfer",
|
||||
"app.application.transfer.workflow -> app.application.transfer.execution",
|
||||
"app.application.transfer.workflow -> app.domain",
|
||||
@@ -4482,7 +4484,6 @@
|
||||
"app.application.transfer.workflow -> app.schemas",
|
||||
"app.application.transfer.workflow -> app.schemas.context",
|
||||
"app.application.transfer.workflow -> app.schemas.file",
|
||||
"app.application.transfer.workflow -> app.schemas.history",
|
||||
"app.application.transfer.workflow -> app.schemas.media",
|
||||
"app.application.transfer.workflow -> app.schemas.music",
|
||||
"app.application.transfer.workflow -> app.schemas.system",
|
||||
@@ -4534,6 +4535,7 @@
|
||||
"app.chain._messaging -> app.foundation",
|
||||
"app.chain._messaging -> app.foundation.identity",
|
||||
"app.chain._messaging -> app.runtime",
|
||||
"app.chain._messaging -> app.runtime.correlation",
|
||||
"app.chain._messaging -> app.runtime.log",
|
||||
"app.chain._messaging -> app.schemas",
|
||||
"app.chain._messaging -> app.schemas.message",
|
||||
@@ -4612,7 +4614,6 @@
|
||||
"app.chain._transfer -> app.runtime.log",
|
||||
"app.chain._transfer -> app.runtime.tasks",
|
||||
"app.chain._transfer -> app.schemas",
|
||||
"app.chain._transfer -> app.schemas.history",
|
||||
"app.chain._transfer -> app.schemas.message",
|
||||
"app.chain._transfer -> app.schemas.tmdb",
|
||||
"app.chain._transfer -> app.schemas.transfer",
|
||||
@@ -4655,6 +4656,7 @@
|
||||
"app.chain.download -> app.application.download.failures",
|
||||
"app.chain.download -> app.application.download.selection",
|
||||
"app.chain.download -> app.application.download.tasks",
|
||||
"app.chain.download -> app.application.history",
|
||||
"app.chain.download -> app.application.torrent",
|
||||
"app.chain.download -> app.chain",
|
||||
"app.chain.download -> app.chain.media",
|
||||
@@ -5148,6 +5150,13 @@
|
||||
"app.db.adapters.chain -> app.db.oper.transferpending",
|
||||
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
|
||||
"app.db.adapters.chain -> app.db.uow",
|
||||
"app.db.adapters.configuration -> app.db",
|
||||
"app.db.adapters.configuration -> app.db.oper",
|
||||
"app.db.adapters.configuration -> app.db.oper.userconfig",
|
||||
"app.db.adapters.configuration -> app.db.uow",
|
||||
"app.db.adapters.configuration -> app.schemas",
|
||||
"app.db.adapters.configuration -> app.schemas.common",
|
||||
"app.db.adapters.configuration -> app.schemas.types",
|
||||
"app.db.adapters.download -> app.application",
|
||||
"app.db.adapters.download -> app.application.download",
|
||||
"app.db.adapters.download -> app.application.download.failures",
|
||||
@@ -5155,6 +5164,15 @@
|
||||
"app.db.adapters.download -> app.db.oper",
|
||||
"app.db.adapters.download -> app.db.oper.downloadfailure",
|
||||
"app.db.adapters.download -> app.db.uow",
|
||||
"app.db.adapters.history.download -> app.application",
|
||||
"app.db.adapters.history.download -> app.application.history",
|
||||
"app.db.adapters.history.download -> app.db",
|
||||
"app.db.adapters.history.download -> app.db.oper",
|
||||
"app.db.adapters.history.download -> app.db.oper.downloadhistory",
|
||||
"app.db.adapters.history.download -> app.db.uow",
|
||||
"app.db.adapters.history.download -> app.schemas",
|
||||
"app.db.adapters.history.download -> app.schemas.media",
|
||||
"app.db.adapters.history.download -> app.schemas.types",
|
||||
"app.db.adapters.mediaserver -> app.application",
|
||||
"app.db.adapters.mediaserver -> app.application.mediaserver",
|
||||
"app.db.adapters.mediaserver -> app.db",
|
||||
@@ -5203,6 +5221,7 @@
|
||||
"app.db.adapters.site -> app.db.oper.site",
|
||||
"app.db.adapters.site -> app.db.uow",
|
||||
"app.db.adapters.subscription -> app.application",
|
||||
"app.db.adapters.subscription -> app.application.outbox",
|
||||
"app.db.adapters.subscription -> app.application.subscription",
|
||||
"app.db.adapters.subscription -> app.application.subscription.write",
|
||||
"app.db.adapters.subscription -> app.db",
|
||||
@@ -5490,6 +5509,7 @@
|
||||
"app.db.oper.userconfig -> app.foundation",
|
||||
"app.db.oper.userconfig -> app.foundation.singleton",
|
||||
"app.db.oper.userconfig -> app.schemas",
|
||||
"app.db.oper.userconfig -> app.schemas.common",
|
||||
"app.db.oper.userconfig -> app.schemas.types",
|
||||
"app.db.oper.workflow -> app.db",
|
||||
"app.db.oper.workflow -> app.db.base",
|
||||
@@ -8053,7 +8073,10 @@
|
||||
"app.startup.initializers.modules -> app.db",
|
||||
"app.startup.initializers.modules -> app.db.adapters",
|
||||
"app.startup.initializers.modules -> app.db.adapters.chain",
|
||||
"app.startup.initializers.modules -> app.db.adapters.configuration",
|
||||
"app.startup.initializers.modules -> app.db.adapters.download",
|
||||
"app.startup.initializers.modules -> app.db.adapters.history",
|
||||
"app.startup.initializers.modules -> app.db.adapters.history.download",
|
||||
"app.startup.initializers.modules -> app.db.adapters.mediaserver",
|
||||
"app.startup.initializers.modules -> app.db.adapters.outbox",
|
||||
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
|
||||
@@ -8070,7 +8093,6 @@
|
||||
"app.startup.initializers.modules -> app.db.oper",
|
||||
"app.startup.initializers.modules -> app.db.oper.agentchat",
|
||||
"app.startup.initializers.modules -> app.db.oper.agenttask",
|
||||
"app.startup.initializers.modules -> app.db.oper.downloadhistory",
|
||||
"app.startup.initializers.modules -> app.db.oper.mediaserver",
|
||||
"app.startup.initializers.modules -> app.db.oper.message",
|
||||
"app.startup.initializers.modules -> app.db.oper.passkey",
|
||||
@@ -8080,7 +8102,6 @@
|
||||
"app.startup.initializers.modules -> app.db.oper.subscribehistory",
|
||||
"app.startup.initializers.modules -> app.db.oper.systemconfig",
|
||||
"app.startup.initializers.modules -> app.db.oper.transferhistory",
|
||||
"app.startup.initializers.modules -> app.db.oper.userconfig",
|
||||
"app.startup.initializers.modules -> app.db.oper.workflow",
|
||||
"app.startup.initializers.modules -> app.db.session",
|
||||
"app.startup.initializers.modules -> app.db.uow",
|
||||
@@ -8239,10 +8260,10 @@
|
||||
"app.testing.bootstrap -> app.application.service",
|
||||
"app.testing.bootstrap -> app.db",
|
||||
"app.testing.bootstrap -> app.db.adapters",
|
||||
"app.testing.bootstrap -> app.db.adapters.configuration",
|
||||
"app.testing.bootstrap -> app.db.adapters.transaction",
|
||||
"app.testing.bootstrap -> app.db.oper",
|
||||
"app.testing.bootstrap -> app.db.oper.systemconfig",
|
||||
"app.testing.bootstrap -> app.db.oper.userconfig",
|
||||
"app.testing.bootstrap -> app.db.session",
|
||||
"app.testing.bootstrap -> app.db.uow",
|
||||
"app.testing.bootstrap -> app.startup",
|
||||
@@ -8424,7 +8445,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 853,
|
||||
"module_count": 856,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -8818,7 +8839,10 @@
|
||||
"app.db",
|
||||
"app.db.adapters",
|
||||
"app.db.adapters.chain",
|
||||
"app.db.adapters.configuration",
|
||||
"app.db.adapters.download",
|
||||
"app.db.adapters.history",
|
||||
"app.db.adapters.history.download",
|
||||
"app.db.adapters.mediaserver",
|
||||
"app.db.adapters.outbox",
|
||||
"app.db.adapters.pluginidentity",
|
||||
|
||||
+1
-6
@@ -131,11 +131,6 @@
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.security.passkey",
|
||||
"target": "app.adapters.cache.redis",
|
||||
"tracking": "S2-L4"
|
||||
},
|
||||
{
|
||||
"source": "app.application.torrent",
|
||||
"target": "app.adapters.network.http",
|
||||
@@ -291,7 +286,7 @@
|
||||
"owner": "$source",
|
||||
"reason": "精确 Adapter source 持有其 Redis、浏览器、HTTP 或 DNS transport;上层只能消费 Port 或统一 Facade。",
|
||||
"facts": [
|
||||
{"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7"},
|
||||
{"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81"},
|
||||
{"source": "app.adapters.network.browser", "target": "cloakbrowser", "kind": "network_sdk", "fingerprint": "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173"},
|
||||
{"source": "app.adapters.network.doh", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71"},
|
||||
{"source": "app.adapters.network.doh", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b"},
|
||||
|
||||
+7
-21
@@ -495,9 +495,8 @@
|
||||
},
|
||||
"app/agent/tools/impl/query_download_tasks.py": {
|
||||
"arg-type": 6,
|
||||
"assignment": 1,
|
||||
"misc": 1,
|
||||
"no-any-return": 2,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-def": 2
|
||||
},
|
||||
"app/agent/tools/impl/query_downloaders.py": {
|
||||
@@ -1128,10 +1127,6 @@
|
||||
"type-arg": 1,
|
||||
"union-attr": 2
|
||||
},
|
||||
"app/application/download/tasks.py": {
|
||||
"assignment": 1,
|
||||
"type-arg": 1
|
||||
},
|
||||
"app/application/downloader.py": {
|
||||
"arg-type": 1,
|
||||
"no-untyped-def": 1
|
||||
@@ -1271,9 +1266,6 @@
|
||||
"app/application/security/otp.py": {
|
||||
"no-any-return": 3
|
||||
},
|
||||
"app/application/security/passkey.py": {
|
||||
"no-untyped-call": 1
|
||||
},
|
||||
"app/application/security/token.py": {
|
||||
"no-any-return": 3,
|
||||
"operator": 1
|
||||
@@ -1319,7 +1311,7 @@
|
||||
"type-arg": 3
|
||||
},
|
||||
"app/application/subscription/complete.py": {
|
||||
"arg-type": 3
|
||||
"arg-type": 2
|
||||
},
|
||||
"app/application/subscription/contract.py": {
|
||||
"arg-type": 2,
|
||||
@@ -1391,7 +1383,7 @@
|
||||
"arg-type": 9,
|
||||
"assignment": 4,
|
||||
"attr-defined": 55,
|
||||
"no-any-return": 4,
|
||||
"no-any-return": 3,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 8,
|
||||
"return-value": 2,
|
||||
@@ -1748,9 +1740,6 @@
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 4
|
||||
},
|
||||
"app/db/oper/userconfig.py": {
|
||||
"no-untyped-def": 4
|
||||
},
|
||||
"app/db/oper/workflow.py": {
|
||||
"arg-type": 1,
|
||||
"no-any-return": 6,
|
||||
@@ -3364,9 +3353,6 @@
|
||||
"app/schemas/workflow.py": {
|
||||
"misc": 19
|
||||
},
|
||||
"app/sdk/_legacy/transfer.py": {
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/sdk/string.py": {
|
||||
"attr-defined": 1,
|
||||
"no-untyped-def": 2,
|
||||
@@ -3394,13 +3380,13 @@
|
||||
"no-untyped-def": 2
|
||||
},
|
||||
"app/startup/initializers/modules.py": {
|
||||
"arg-type": 16,
|
||||
"arg-type": 13,
|
||||
"assignment": 1,
|
||||
"attr-defined": 2,
|
||||
"misc": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 33,
|
||||
"no-untyped-def": 12,
|
||||
"no-untyped-call": 32,
|
||||
"no-untyped-def": 7,
|
||||
"return-value": 5
|
||||
},
|
||||
"app/startup/initializers/plugins.py": {
|
||||
@@ -3431,7 +3417,7 @@
|
||||
"type-arg": 1
|
||||
},
|
||||
"app/testing/bootstrap.py": {
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-def": 3,
|
||||
"type-arg": 6
|
||||
},
|
||||
|
||||
+1
-69
@@ -164,9 +164,6 @@
|
||||
"app/agent/tools/impl/query_custom_filter_rules.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/agent/tools/impl/query_download_tasks.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/agent/tools/impl/query_installed_plugins.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -300,9 +297,6 @@
|
||||
"app/application/formatting.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/history.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/image.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -327,9 +321,6 @@
|
||||
"app/application/notification.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/outbox.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/plugin/runtime.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -345,22 +336,10 @@
|
||||
"app/application/storage.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/subscription/delete.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/subscription/identity.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/subscription/mutation.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/subscription/priority.py": {
|
||||
"F401": 1,
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/subscription/write.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/application/torrent.py": {
|
||||
"F541": 3,
|
||||
"I001": 1
|
||||
@@ -380,9 +359,6 @@
|
||||
"app/db/__init__.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/adapters/outbox.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/adapters/transaction.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -392,9 +368,6 @@
|
||||
"app/db/diagnostics.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/engine.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/models/__init__.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -410,12 +383,6 @@
|
||||
"app/db/models/message.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/models/outbox.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/models/passkey.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/models/plugindata.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -440,12 +407,6 @@
|
||||
"app/db/models/systemconfig.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/models/user.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/models/userconfig.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/models/workflow.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -461,9 +422,6 @@
|
||||
"app/db/oper/user.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/userconfig.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/session.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -889,8 +847,7 @@
|
||||
"I001": 1
|
||||
},
|
||||
"app/runtime/cache.py": {
|
||||
"E731": 2,
|
||||
"I001": 1
|
||||
"E731": 2
|
||||
},
|
||||
"app/runtime/capabilities/__init__.py": {
|
||||
"I001": 1
|
||||
@@ -910,9 +867,6 @@
|
||||
"app/runtime/dependencies.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/runtime/event/dispatch.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/runtime/event/errors.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -998,9 +952,6 @@
|
||||
"app/sdk/_legacy/user.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/sdk/cache.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/sdk/config.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1176,9 +1127,6 @@
|
||||
"tests/test_builtin_skill_boundaries.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_cache_system.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_capability_registry.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1198,9 +1146,6 @@
|
||||
"tests/test_coalesce.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_configuration_initializer.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_dashboard_system_info.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1295,10 +1240,6 @@
|
||||
"tests/test_feedback_issue_scripts.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_feishu.py": {
|
||||
"E402": 5,
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_feishu_media_message.py": {
|
||||
"E402": 3
|
||||
},
|
||||
@@ -1419,9 +1360,6 @@
|
||||
"tests/test_metamusic.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_mfa_passkey_registration_errors.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_module_manager_capability_adapter.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1495,9 +1433,6 @@
|
||||
"tests/test_observability.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_passkey_challenge.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_password_hashing.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1618,9 +1553,6 @@
|
||||
"tests/test_string_compat.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_subscribe_delete_command.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_subscribe_oper.py": {
|
||||
"F401": 1
|
||||
},
|
||||
|
||||
+90
-85
@@ -1621,14 +1621,14 @@
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"29f02d6f5d4a82f6e2d304b899ee9d652dc5d3a1f375e45da2a7c7ba72fad828",
|
||||
"fb5095b18017555a9da2ee5f5442814a0c783e61d99cec42c7f464c5a25a0968"
|
||||
"e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b"
|
||||
]
|
||||
},
|
||||
"EventType.AudioTransferFailed": {
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"9e26ea89da42c77926126f7440c0365e24a63d4eb10280b270d29bf767bd47cf",
|
||||
"c32e40027e195e6b664a7d5d287554804c65d04187dfbf5d922a7d832efd9ef0"
|
||||
"c32e40027e195e6b664a7d5d287554804c65d04187dfbf5d922a7d832efd9ef0",
|
||||
"e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b"
|
||||
]
|
||||
},
|
||||
"EventType.CommandExcute": {
|
||||
@@ -1659,7 +1659,7 @@
|
||||
"EventType.DownloadAdded": {
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"090d218ce64d85213d4d0c51eac059a36365f0edf6976acb9d4588e921d63587",
|
||||
"912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d",
|
||||
"f2e4ec70164d8ccb46a0f032c57c6c0452bee9ee55a2aa69e4f770e7d2c855b3",
|
||||
"f2e4ec70164d8ccb46a0f032c57c6c0452bee9ee55a2aa69e4f770e7d2c855b3"
|
||||
]
|
||||
@@ -1764,16 +1764,16 @@
|
||||
"EventType.SubscribeAdded": {
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c",
|
||||
"6dd6b59761660ab65659ea4e14c06108aa850c8d62fabf50391632ba2e74d8fe",
|
||||
"79efb9919712de5265f3e9f8aafbdf53eede9a42c9c5d8bedb402ee6df9fab68",
|
||||
"cdc72f0211e4bd640c76419d7e0266b95a542f84eeeabc45654e1a66c6718688"
|
||||
]
|
||||
},
|
||||
"EventType.SubscribeComplete": {
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"8d8703c6323a227c4dfe4234e7d3056abb815e284464122faeccca87b050f3aa",
|
||||
"a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c"
|
||||
"a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c",
|
||||
"ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b"
|
||||
]
|
||||
},
|
||||
"EventType.SubscribeDeleted": {
|
||||
@@ -1781,7 +1781,7 @@
|
||||
"producer_fingerprints": [
|
||||
"49cb2961dd38ea10d2a53c7208fb3ca66ecda4c7c2f8cb2c29ea3ac62629a512",
|
||||
"7ee0ba0bfdba520a389d45c33d361120a0d710c3ea37b1e03532dd7364a52d47",
|
||||
"b8610de798a3412aec42fde23dae3662f29464f3e1bea538f3e18156fcd65841",
|
||||
"a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b",
|
||||
"cbff1d6d00bcade626974178f292471c2bb7643ceb9360a0ad9fa23b87c3597d"
|
||||
]
|
||||
},
|
||||
@@ -1789,17 +1789,17 @@
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"330678cc36e39079b052e5a6f1493cd38cf40565ddf8b231dc20c94207cb1ba9",
|
||||
"3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b",
|
||||
"4204fef37b0e1b87ff665209b1085359b778096827bed514cf61c6488ede3ee3",
|
||||
"4fc5c37849498747d5d61944b47381612c88d9b6320fda9ab48e3c1fa2444232",
|
||||
"89165b03827d9801a260dd54dd03e77f903795b8872bd043a79e8283181c786f",
|
||||
"8d7d49151bd35a4eb2fdc8d82f514484d7f04146718129d3f429ab847d38f6d8",
|
||||
"ca81edd9f904843fdaaec15c0b774861c055742cdb0a27215ad43b0acfeaa73b"
|
||||
]
|
||||
},
|
||||
"EventType.SubtitleTransferComplete": {
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"10a4bd31fc73023e9abf0fc63c64bb43a1dfec208c546b603f687462a2cda27a",
|
||||
"814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976",
|
||||
"9839276934bc6e557b3ec07a8a3f1d4b372f4dc792eb6198650e940140231083"
|
||||
]
|
||||
},
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"1c2a6b622bf96b2c46e0831c124e24f2810bbbd8b8f0ffd259a7e6912ec207b6",
|
||||
"70effe3d0dbcb65ef333864d678cd7cc01f89849cdb2cb3e358acbc751548451"
|
||||
"f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d"
|
||||
]
|
||||
},
|
||||
"EventType.SystemError": {
|
||||
@@ -1823,13 +1823,13 @@
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"063a823d8634ee118279717c8a9ed35eaae48a0533f05d472180572ce4f50096",
|
||||
"e00dae2c489550d72bfd2962b0ab5bdfa3992cddf012fd6827bab9a77fa17f22"
|
||||
"f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307"
|
||||
]
|
||||
},
|
||||
"EventType.TransferFailed": {
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"67272ed4bd81ea85c2e276e04b413f6d33665d260e4ff64aa900beb7fba8e071",
|
||||
"be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c",
|
||||
"ec3818c033105e65936b400e72d6832cef6d6a4bec84286ed9cab6596b09ac70"
|
||||
]
|
||||
},
|
||||
@@ -2856,64 +2856,16 @@
|
||||
"qualname": "_publish_modified",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.DownloadAdded"
|
||||
],
|
||||
"fingerprint": "090d218ce64d85213d4d0c51eac059a36365f0edf6976acb9d4588e921d63587",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.SubtitleTransferComplete"
|
||||
],
|
||||
"fingerprint": "10a4bd31fc73023e9abf0fc63c64bb43a1dfec208c546b603f687462a2cda27a",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.TransferFailed"
|
||||
],
|
||||
"fingerprint": "67272ed4bd81ea85c2e276e04b413f6d33665d260e4ff64aa900beb7fba8e071",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.SubtitleTransferFailed"
|
||||
],
|
||||
"fingerprint": "70effe3d0dbcb65ef333864d678cd7cc01f89849cdb2cb3e358acbc751548451",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.SubscribeAdded"
|
||||
],
|
||||
"fingerprint": "79efb9919712de5265f3e9f8aafbdf53eede9a42c9c5d8bedb402ee6df9fab68",
|
||||
"fingerprint": "1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
@@ -2922,34 +2874,34 @@
|
||||
"events": [
|
||||
"EventType.SubscribeModified"
|
||||
],
|
||||
"fingerprint": "8d7d49151bd35a4eb2fdc8d82f514484d7f04146718129d3f429ab847d38f6d8",
|
||||
"fingerprint": "3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.SubscribeComplete"
|
||||
"EventType.SubtitleTransferComplete"
|
||||
],
|
||||
"fingerprint": "8d8703c6323a227c4dfe4234e7d3056abb815e284464122faeccca87b050f3aa",
|
||||
"fingerprint": "814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.AudioTransferFailed"
|
||||
"EventType.DownloadAdded"
|
||||
],
|
||||
"fingerprint": "9e26ea89da42c77926126f7440c0365e24a63d4eb10280b270d29bf767bd47cf",
|
||||
"fingerprint": "912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
@@ -2958,22 +2910,34 @@
|
||||
"events": [
|
||||
"EventType.SubscribeDeleted"
|
||||
],
|
||||
"fingerprint": "b8610de798a3412aec42fde23dae3662f29464f3e1bea538f3e18156fcd65841",
|
||||
"fingerprint": "a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.TransferComplete"
|
||||
"EventType.TransferFailed"
|
||||
],
|
||||
"fingerprint": "e00dae2c489550d72bfd2962b0ab5bdfa3992cddf012fd6827bab9a77fa17f22",
|
||||
"fingerprint": "be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.AudioTransferFailed"
|
||||
],
|
||||
"fingerprint": "e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b",
|
||||
"invalid": false,
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
@@ -2982,10 +2946,46 @@
|
||||
"events": [
|
||||
"EventType.AudioTransferComplete"
|
||||
],
|
||||
"fingerprint": "fb5095b18017555a9da2ee5f5442814a0c783e61d99cec42c7f464c5a25a0968",
|
||||
"fingerprint": "e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "_build_outbox_dispatcher",
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.SubscribeComplete"
|
||||
],
|
||||
"fingerprint": "ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b",
|
||||
"invalid": false,
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.SubtitleTransferFailed"
|
||||
],
|
||||
"fingerprint": "f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d",
|
||||
"invalid": false,
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.TransferComplete"
|
||||
],
|
||||
"fingerprint": "f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307",
|
||||
"invalid": false,
|
||||
"method": "send_event_strict",
|
||||
"qualname": "_build_outbox_handlers",
|
||||
"receiver_kind": "constructed_manager"
|
||||
},
|
||||
{
|
||||
@@ -9748,6 +9748,11 @@
|
||||
"name": "AsyncRedisBackend",
|
||||
"target": "app.adapters.cache.backends.AsyncRedisBackend"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "AtomicCacheBackend",
|
||||
"target": "app.runtime.cache.AtomicCacheBackend"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "Cache",
|
||||
|
||||
+125
-125
@@ -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": [
|
||||
"后台任务登记器",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -699,6 +699,8 @@ import app.schemas.types as schema_types
|
||||
receiver = eventmanager
|
||||
emit = receiver.send_event
|
||||
emit(EventType.Alpha)
|
||||
strict_emit = receiver.send_event_strict
|
||||
strict_emit(EventType.Beta)
|
||||
EventManager().send_event(EventType.Gamma)
|
||||
EventManager.get_existing_instance().send_event(ChainEventType.Delta)
|
||||
|
||||
@@ -710,7 +712,7 @@ async def publish():
|
||||
)
|
||||
|
||||
assert facts["consumers"] == []
|
||||
assert len(facts["producers"]) == 4
|
||||
assert len(facts["producers"]) == 5
|
||||
assert {
|
||||
(
|
||||
fact["qualname"],
|
||||
@@ -721,6 +723,12 @@ async def publish():
|
||||
for fact in facts["producers"]
|
||||
} == {
|
||||
("<module>", "send_event", "canonical_singleton", ("EventType.Alpha",)),
|
||||
(
|
||||
"<module>",
|
||||
"send_event_strict",
|
||||
"canonical_singleton",
|
||||
("EventType.Beta",),
|
||||
),
|
||||
("<module>", "send_event", "constructed_manager", ("EventType.Gamma",)),
|
||||
("<module>", "send_event", "existing_manager", ("ChainEventType.Delta",)),
|
||||
("publish", "async_send_event", "canonical_singleton", ("EventType.Beta",)),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""插件认证一次性票据的生命周期测试。"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.security import auth
|
||||
from app.application.security.auth import AuthTicketStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_auth_tickets():
|
||||
"""隔离单例票据缓存,避免测试间共享认证事实。"""
|
||||
store = AuthTicketStore()
|
||||
with store._lock:
|
||||
store._tickets.clear()
|
||||
yield
|
||||
with store._lock:
|
||||
store._tickets.clear()
|
||||
|
||||
|
||||
def test_auth_ticket_can_only_be_consumed_once():
|
||||
"""成功领取后立即删除票据,后续兑换不得重复获得认证事实。"""
|
||||
store = AuthTicketStore()
|
||||
ticket = store.create(user_id=1, provider_id="plugin:test")
|
||||
|
||||
assert store.consume(ticket)["user_id"] == 1
|
||||
assert store.consume(ticket) is None
|
||||
|
||||
|
||||
def test_auth_ticket_concurrent_consumers_have_single_winner():
|
||||
"""多个并发兑换请求只能有一个成功领取同一票据。"""
|
||||
store = AuthTicketStore()
|
||||
ticket = store.create(user_id=1, provider_id="plugin:test")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
results = list(executor.map(lambda _: store.consume(ticket), range(8)))
|
||||
|
||||
assert sum(result is not None for result in results) == 1
|
||||
|
||||
|
||||
def test_auth_ticket_ttl_boundary_and_expiration(monkeypatch):
|
||||
"""票据在 TTL 边界内有效,超过边界后即使首次领取也必须失败。"""
|
||||
now = [1_000.0]
|
||||
monkeypatch.setattr(auth.time, "time", lambda: now[0])
|
||||
store = AuthTicketStore()
|
||||
|
||||
boundary_ticket = store.create(user_id=1, provider_id="plugin:test")
|
||||
now[0] += store._ttl_seconds
|
||||
assert store.consume(boundary_ticket) is not None
|
||||
|
||||
expired_ticket = store.create(user_id=1, provider_id="plugin:test")
|
||||
now[0] += store._ttl_seconds + 0.001
|
||||
assert store.consume(expired_ticket) is None
|
||||
assert store.consume(expired_ticket) is None
|
||||
|
||||
|
||||
def test_auth_ticket_metadata_is_detached_from_callers():
|
||||
"""签发和领取两侧都不能通过可变对象改写缓存中的认证元数据。"""
|
||||
store = AuthTicketStore()
|
||||
metadata = {"groups": ["users"]}
|
||||
ticket = store.create(
|
||||
user_id=1,
|
||||
provider_id="plugin:test",
|
||||
metadata=metadata,
|
||||
)
|
||||
metadata["groups"].append("admins")
|
||||
|
||||
consumed = store.consume(ticket)
|
||||
|
||||
assert consumed["metadata"] == {"groups": ["users"]}
|
||||
|
||||
|
||||
def test_auth_ticket_capacity_is_a_hard_limit(monkeypatch):
|
||||
"""连续签发也不得让票据表永久超过容量上限。"""
|
||||
monkeypatch.setattr(AuthTicketStore, "_max_items", 4)
|
||||
store = AuthTicketStore()
|
||||
|
||||
tickets = [
|
||||
store.create(user_id=index, provider_id="plugin:test")
|
||||
for index in range(6)
|
||||
]
|
||||
|
||||
assert len(store._tickets) == 4
|
||||
assert store.consume(tickets[0]) is None
|
||||
assert store.consume(tickets[-1]) is not None
|
||||
+110
-1
@@ -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 异步客户端应使用阻塞连接池,避免高并发缓存读取立刻抛出连接耗尽错误。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -117,8 +117,8 @@ def test_user_configuration_service_supports_sync_and_async_writes() -> None:
|
||||
async_executor=_InlineDatabaseExecutor(),
|
||||
)
|
||||
|
||||
assert service.set("alice", "theme", "dark") is True
|
||||
assert asyncio.run(service.async_set("alice", "theme", "light")) is True
|
||||
assert service.set("alice", "theme", "dark") is None
|
||||
assert asyncio.run(service.async_set("alice", "theme", "light")) is None
|
||||
assert repository.set.call_args_list == [
|
||||
((), {"username": "alice", "key": "theme", "value": "dark"}),
|
||||
((), {"username": "alice", "key": "theme", "value": "light"}),
|
||||
|
||||
+17
-17
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
一个能被日志发现的异常。同步方法都有一个已是 2.0 写法的异步孪生方法,这里对同一
|
||||
批数据同时跑两条路径并要求结果一致——同步侧改写后若有偏差,这个断言会直接暴露。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
@@ -29,19 +30,17 @@ def _track(db):
|
||||
# SystemConfig
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_systemconfig_get_by_key_matches_async_twin(db):
|
||||
"""
|
||||
按键取配置的同步与异步结果必须一致,且只命中同名键。
|
||||
"""
|
||||
db.add(SystemConfig(key="mp-test-a", value={"n": 1}),
|
||||
SystemConfig(key="mp-test-b", value={"n": 2}))
|
||||
db.add(SystemConfig(key="mp-test-a", value={"n": 1}), SystemConfig(key="mp-test-b", value={"n": 2}))
|
||||
|
||||
found = SystemConfig.get_by_key(db.session, "mp-test-a")
|
||||
assert found.value == {"n": 1}
|
||||
|
||||
async_found = db.run_async_session(
|
||||
lambda session: SystemConfig.async_get_by_key(session, "mp-test-a")
|
||||
)
|
||||
async_found = db.run_async_session(lambda session: SystemConfig.async_get_by_key(session, "mp-test-a"))
|
||||
assert async_found.value == found.value
|
||||
|
||||
|
||||
@@ -58,9 +57,7 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
db_base,
|
||||
"run_sync_transaction",
|
||||
lambda _operation: (_ for _ in ()).throw(
|
||||
AssertionError("不应创建额外同步事务")
|
||||
),
|
||||
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外同步事务")),
|
||||
)
|
||||
assert SystemConfig.get_by_key(db.session, "mp-explicit-config") is not None
|
||||
|
||||
@@ -70,14 +67,15 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
db_base,
|
||||
"run_async_transaction",
|
||||
lambda _operation: (_ for _ in ()).throw(
|
||||
AssertionError("不应创建额外异步事务")
|
||||
),
|
||||
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外异步事务")),
|
||||
)
|
||||
assert (
|
||||
await SystemConfig.async_get_by_key(
|
||||
session,
|
||||
"mp-explicit-config",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert await SystemConfig.async_get_by_key(
|
||||
session,
|
||||
"mp-explicit-config",
|
||||
) is not None
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
@@ -86,8 +84,7 @@ def test_systemconfig_delete_by_key_removes_only_that_key(db):
|
||||
"""
|
||||
按键删除只能删掉那一个键,误删会静默丢失其他配置。
|
||||
"""
|
||||
db.add(SystemConfig(key="mp-test-del", value={"n": 1}),
|
||||
SystemConfig(key="mp-test-keep", value={"n": 2}))
|
||||
db.add(SystemConfig(key="mp-test-del", value={"n": 1}), SystemConfig(key="mp-test-keep", value={"n": 2}))
|
||||
|
||||
assert SystemConfig().delete_by_key(db.session, "mp-test-del") is True
|
||||
|
||||
@@ -106,12 +103,15 @@ def test_systemconfig_delete_by_key_tolerates_missing_key(db):
|
||||
# UserConfig
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_userconfig_get_by_key_scopes_by_username(db):
|
||||
"""
|
||||
用户配置必须同时按用户名和键命中——只按键会把别人的配置读给当前用户。
|
||||
"""
|
||||
db.add(UserConfig(username="alice", key="theme", value="dark"),
|
||||
UserConfig(username="bob", key="theme", value="light"))
|
||||
db.add(User(name="alice"), User(name="bob"))
|
||||
db.add(
|
||||
UserConfig(username="alice", key="theme", value="dark"), UserConfig(username="bob", key="theme", value="light")
|
||||
)
|
||||
|
||||
assert UserConfig.get_by_key(db.session, username="alice", key="theme").value == "dark"
|
||||
assert UserConfig.get_by_key(db.session, username="bob", key="theme").value == "light"
|
||||
@@ -122,8 +122,10 @@ def test_userconfig_delete_by_key_removes_only_that_user(db):
|
||||
"""
|
||||
删除某用户的配置不能波及同名键的其他用户。
|
||||
"""
|
||||
db.add(UserConfig(username="alice", key="theme", value="dark"),
|
||||
UserConfig(username="bob", key="theme", value="light"))
|
||||
db.add(User(name="alice"), User(name="bob"))
|
||||
db.add(
|
||||
UserConfig(username="alice", key="theme", value="dark"), UserConfig(username="bob", key="theme", value="light")
|
||||
)
|
||||
|
||||
assert UserConfig().delete_by_key(db.session, username="alice", key="theme") is True
|
||||
|
||||
@@ -142,25 +144,21 @@ def test_userconfig_delete_by_key_tolerates_missing_row(db):
|
||||
# User
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_user_lookup_by_name_and_id_matches_async_twin(db):
|
||||
"""
|
||||
按名与按 ID 取用户的同步、异步结果必须指向同一行。
|
||||
|
||||
登录链路走同步、API 依赖注入走异步,两者不一致会表现为「能登录但查不到自己」。
|
||||
"""
|
||||
created = db.add(User(name="mp-test-user", email="u@example.com",
|
||||
hashed_password="x", is_active=True))
|
||||
created = db.add(User(name="mp-test-user", email="u@example.com", hashed_password="x", is_active=True))
|
||||
|
||||
by_name = User.get_by_name(db.session, "mp-test-user")
|
||||
by_id = User.get_by_id(db.session, created.id)
|
||||
assert by_name.id == by_id.id == created.id
|
||||
|
||||
assert db.run_async_session(
|
||||
lambda session: User.async_get_by_name(session, "mp-test-user")
|
||||
).id == created.id
|
||||
assert db.run_async_session(
|
||||
lambda session: User.async_get_by_id(session, created.id)
|
||||
).id == created.id
|
||||
assert db.run_async_session(lambda session: User.async_get_by_name(session, "mp-test-user")).id == created.id
|
||||
assert db.run_async_session(lambda session: User.async_get_by_id(session, created.id)).id == created.id
|
||||
|
||||
|
||||
def test_user_lookup_returns_none_when_absent(db):
|
||||
@@ -210,10 +208,8 @@ def test_user_async_mutations_match_sync_behaviour(db):
|
||||
db.add(User(name="mp-test-async-otp", hashed_password="x", is_otp=False))
|
||||
oper = UserOper()
|
||||
|
||||
assert asyncio.run(oper.async_update_otp_by_name(
|
||||
name="mp-test-async-otp", otp=True, secret="S2")) is True
|
||||
assert asyncio.run(oper.async_update_otp_by_name(
|
||||
name="mp-test-nobody", otp=True, secret="S2")) is False
|
||||
assert asyncio.run(oper.async_update_otp_by_name(name="mp-test-async-otp", otp=True, secret="S2")) is True
|
||||
assert asyncio.run(oper.async_update_otp_by_name(name="mp-test-nobody", otp=True, secret="S2")) is False
|
||||
|
||||
assert asyncio.run(oper.async_delete_by_name(name="mp-test-async-otp")) is True
|
||||
assert User.get_by_name(db.session, "mp-test-async-otp") is None
|
||||
@@ -222,14 +218,50 @@ def test_user_async_mutations_match_sync_behaviour(db):
|
||||
assert User.get_by_id(db.session, async_id_user.id) is None
|
||||
|
||||
|
||||
def test_legacy_user_oper_delete_cascades_user_children(db):
|
||||
"""旧 UserOper 删除入口保持可用,并由数据库清除配置和 PassKey。"""
|
||||
user = db.add(User(name="legacy-delete", is_active=True))
|
||||
user_id = user.id
|
||||
username = user.name
|
||||
db.add(
|
||||
UserConfig(username=username, key="theme", value="dark"),
|
||||
_passkey(user_id, "legacy-delete-credential"),
|
||||
)
|
||||
|
||||
assert asyncio.run(UserOper().async_delete_by_name(username)) is True
|
||||
|
||||
db.session.expire_all()
|
||||
assert User.get_by_id(db.session, user_id) is None
|
||||
assert UserConfig.get_by_key(db.session, username, "theme") is None
|
||||
assert PassKey.get_by_credential_id(db.session, "legacy-delete-credential") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PassKey
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _passkey(user_id: int, credential_id: str, is_active: bool = True) -> PassKey:
|
||||
"""构造一条 PassKey 记录。"""
|
||||
return PassKey(user_id=user_id, credential_id=credential_id,
|
||||
public_key="pk", sign_count=0, is_active=is_active)
|
||||
return PassKey(user_id=user_id, credential_id=credential_id, public_key="pk", sign_count=0, is_active=is_active)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _create_passkey_owners(request, db) -> None:
|
||||
"""PassKey 查询用例必须使用受外键保护的真实用户主体。"""
|
||||
if not request.node.name.startswith("test_passkey_"):
|
||||
return
|
||||
db.add(
|
||||
*[
|
||||
User(
|
||||
id=user_id,
|
||||
name=f"passkey-owner-{user_id}",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
for user_id in range(9001, 9012)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_passkey_listing_excludes_inactive_credentials(db):
|
||||
@@ -238,18 +270,19 @@ def test_passkey_listing_excludes_inactive_credentials(db):
|
||||
|
||||
停用的凭据仍能被列出意味着它还会出现在登录选项里,等于停用没生效。
|
||||
"""
|
||||
db.add(_passkey(9001, "cred-active-1"),
|
||||
_passkey(9001, "cred-active-2"),
|
||||
_passkey(9001, "cred-inactive", is_active=False),
|
||||
_passkey(9002, "cred-other"))
|
||||
db.add(
|
||||
_passkey(9001, "cred-active-1"),
|
||||
_passkey(9001, "cred-active-2"),
|
||||
_passkey(9001, "cred-inactive", is_active=False),
|
||||
_passkey(9002, "cred-other"),
|
||||
)
|
||||
|
||||
listed = PassKey.get_by_user_id(db.session, 9001)
|
||||
|
||||
assert {p.credential_id for p in listed} == {"cred-active-1", "cred-active-2"}
|
||||
assert {p.credential_id for p in db.run_async_session(
|
||||
lambda session: PassKey.async_get_by_user_id(session, 9001)
|
||||
)} == \
|
||||
{"cred-active-1", "cred-active-2"}
|
||||
assert {
|
||||
p.credential_id for p in db.run_async_session(lambda session: PassKey.async_get_by_user_id(session, 9001))
|
||||
} == {"cred-active-1", "cred-active-2"}
|
||||
|
||||
|
||||
def test_passkey_oper_queries_use_explicit_session(db, monkeypatch):
|
||||
@@ -279,9 +312,7 @@ def test_passkey_lookup_by_credential_id_skips_inactive(db):
|
||||
|
||||
assert PassKey.get_by_credential_id(db.session, "cred-live").user_id == 9003
|
||||
assert PassKey.get_by_credential_id(db.session, "cred-dead") is None
|
||||
assert db.run_async_session(
|
||||
lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")
|
||||
) is None
|
||||
assert db.run_async_session(lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")) is None
|
||||
|
||||
|
||||
def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
|
||||
@@ -290,9 +321,7 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
db_base,
|
||||
"run_sync_transaction",
|
||||
lambda _operation: (_ for _ in ()).throw(
|
||||
AssertionError("不应创建额外同步事务")
|
||||
),
|
||||
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外同步事务")),
|
||||
)
|
||||
assert PassKey.get_by_id(db.session, key.id).credential_id == "cred-explicit"
|
||||
|
||||
@@ -302,18 +331,22 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
db_base,
|
||||
"run_async_transaction",
|
||||
lambda _operation: (_ for _ in ()).throw(
|
||||
AssertionError("不应创建额外异步事务")
|
||||
),
|
||||
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外异步事务")),
|
||||
)
|
||||
assert [
|
||||
item.credential_id
|
||||
for item in await PassKey.async_get_by_user_id(
|
||||
session,
|
||||
9008,
|
||||
)
|
||||
] == ["cred-explicit"]
|
||||
assert (
|
||||
await PassKey.async_get_by_credential_id(
|
||||
session,
|
||||
"cred-explicit",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert [item.credential_id for item in await PassKey.async_get_by_user_id(
|
||||
session,
|
||||
9008,
|
||||
)] == ["cred-explicit"]
|
||||
assert await PassKey.async_get_by_credential_id(
|
||||
session,
|
||||
"cred-explicit",
|
||||
) is not None
|
||||
assert await PassKey.async_get_by_id(session, key.id) is not None
|
||||
|
||||
asyncio.run(check())
|
||||
@@ -326,9 +359,7 @@ def test_passkey_get_by_id_ignores_active_flag(db):
|
||||
dead = db.add(_passkey(9004, "cred-admin", is_active=False))
|
||||
|
||||
assert PassKey.get_by_id(db.session, dead.id).credential_id == "cred-admin"
|
||||
assert db.run_async_session(
|
||||
lambda session: PassKey.async_get_by_id(session, dead.id)
|
||||
).credential_id == "cred-admin"
|
||||
assert db.run_async_session(lambda session: PassKey.async_get_by_id(session, dead.id)).credential_id == "cred-admin"
|
||||
|
||||
|
||||
def test_passkey_delete_requires_matching_owner(db):
|
||||
@@ -367,3 +398,80 @@ def test_passkey_update_last_used_persists_sign_count(db):
|
||||
assert key.update_last_used(db.session, sign_count=42) is True
|
||||
|
||||
assert PassKey.get_by_id(db.session, key.id).sign_count == 42
|
||||
|
||||
|
||||
def test_passkey_oper_sign_count_compare_and_swap_has_single_winner(db):
|
||||
"""两个基于同一旧计数的认证提交只能有一个更新成功。"""
|
||||
key = db.add(_passkey(9008, "cred-cas"))
|
||||
key.sign_count = 41
|
||||
db.session.flush()
|
||||
oper = PassKeyOper(db.session)
|
||||
|
||||
assert (
|
||||
oper.compare_and_update_sign_count(
|
||||
passkey_id=key.id,
|
||||
expected_sign_count=41,
|
||||
sign_count=42,
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
oper.compare_and_update_sign_count(
|
||||
passkey_id=key.id,
|
||||
expected_sign_count=41,
|
||||
sign_count=43,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
db.session.expire_all()
|
||||
assert PassKey.get_by_id(db.session, key.id).sign_count == 42
|
||||
|
||||
|
||||
def test_passkey_oper_sign_count_cas_rejects_inactive_or_regressed_key(db):
|
||||
"""停用凭证及未递增的非零计数都不得被认证提交覆盖。"""
|
||||
inactive = db.add(_passkey(9009, "cred-cas-inactive", is_active=False))
|
||||
active = db.add(_passkey(9010, "cred-cas-regressed"))
|
||||
active.sign_count = 5
|
||||
db.session.flush()
|
||||
oper = PassKeyOper(db.session)
|
||||
|
||||
assert (
|
||||
oper.compare_and_update_sign_count(
|
||||
passkey_id=inactive.id,
|
||||
expected_sign_count=0,
|
||||
sign_count=1,
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
oper.compare_and_update_sign_count(
|
||||
passkey_id=active.id,
|
||||
expected_sign_count=5,
|
||||
sign_count=4,
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
oper.compare_and_update_sign_count(
|
||||
passkey_id=active.id,
|
||||
expected_sign_count=5,
|
||||
sign_count=5,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_passkey_oper_sign_count_cas_allows_counterless_authenticator(db):
|
||||
"""不支持签名计数器的认证器允许按 WebAuthn 约定保持零计数。"""
|
||||
key = db.add(_passkey(9011, "cred-cas-counterless"))
|
||||
oper = PassKeyOper(db.session)
|
||||
|
||||
assert (
|
||||
oper.compare_and_update_sign_count(
|
||||
passkey_id=key.id,
|
||||
expected_sign_count=0,
|
||||
sign_count=0,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
+109
-46
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -7,7 +7,12 @@ from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.endpoints import mfa as mfa_endpoint
|
||||
from app.application.security.passkey import PasskeyChallengeStore
|
||||
from app.application.security.passkey import (
|
||||
PASSKEY_CHALLENGE_TTL_SECONDS,
|
||||
PasskeyChallengeStore,
|
||||
configure_passkey_challenge_cache,
|
||||
)
|
||||
from app.runtime.cache import TTLCache
|
||||
|
||||
|
||||
def _request() -> Request:
|
||||
@@ -25,7 +30,46 @@ def _request() -> Request:
|
||||
|
||||
|
||||
def setup_function():
|
||||
PasskeyChallengeStore._cache.clear()
|
||||
configure_passkey_challenge_cache(TTLCache(
|
||||
region="passkey_challenge",
|
||||
maxsize=4096,
|
||||
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
|
||||
))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mfa_status_hides_missing_disabled_and_unconfigured_accounts():
|
||||
"""匿名状态查询不得用状态码、消息或数据区分非 OTP 账号。"""
|
||||
users = [
|
||||
None,
|
||||
SimpleNamespace(is_active=False, is_otp=True),
|
||||
SimpleNamespace(is_active=True, is_otp=False),
|
||||
]
|
||||
responses = []
|
||||
|
||||
for user in users:
|
||||
service = SimpleNamespace(get_by_name=AsyncMock(return_value=user))
|
||||
response = await mfa_endpoint.mfa_status("candidate", service=service)
|
||||
responses.append(response.model_dump())
|
||||
|
||||
assert responses == [
|
||||
{"success": True, "message": "", "data": {"enabled": False}},
|
||||
] * len(users)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mfa_status_reports_otp_only_for_active_account():
|
||||
"""启用账号已配置 OTP 时仍应允许登录流程进入二次验证。"""
|
||||
service = SimpleNamespace(
|
||||
get_by_name=AsyncMock(
|
||||
return_value=SimpleNamespace(is_active=True, is_otp=True)
|
||||
)
|
||||
)
|
||||
|
||||
response = await mfa_endpoint.mfa_status("candidate", service=service)
|
||||
|
||||
assert response.success is True
|
||||
assert response.data == {"enabled": True}
|
||||
|
||||
|
||||
def test_registration_transaction_is_bound_to_current_user():
|
||||
@@ -186,3 +230,117 @@ def test_authentication_finish_token_cannot_be_replayed():
|
||||
assert result.access_token == "access-token"
|
||||
assert replay_error.value.status_code == 401
|
||||
assert replay_error.value.detail == "认证请求已失效"
|
||||
|
||||
|
||||
def test_authentication_finish_fails_closed_when_challenge_backend_fails(
|
||||
monkeypatch,
|
||||
):
|
||||
"""challenge 后端故障不得继续查询凭证或签发 Token。"""
|
||||
class FailingCache:
|
||||
"""模拟 challenge 原子领取期间缓存不可用。"""
|
||||
|
||||
def consume(self, _key):
|
||||
"""报告后端不可用。"""
|
||||
raise RuntimeError("cache unavailable")
|
||||
|
||||
monkeypatch.setattr(PasskeyChallengeStore, "_cache", FailingCache())
|
||||
service = SimpleNamespace(get_by_credential_id=Mock())
|
||||
auth_service = SimpleNamespace(build_token_response=Mock())
|
||||
|
||||
with patch.object(
|
||||
mfa_endpoint,
|
||||
"get_configured_auth_service",
|
||||
return_value=auth_service,
|
||||
), patch.object(
|
||||
mfa_endpoint,
|
||||
"set_or_refresh_resource_token_cookie",
|
||||
) as set_cookie, pytest.raises(HTTPException) as exc_info:
|
||||
mfa_endpoint.passkey_authenticate_finish(
|
||||
request=_request(),
|
||||
response=Response(),
|
||||
passkey_req=mfa_endpoint.PassKeyAuthenticationFinish(
|
||||
credential={"id": "credential-id"},
|
||||
transaction_token="transaction-token",
|
||||
),
|
||||
service=service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
service.get_by_credential_id.assert_not_called()
|
||||
auth_service.build_token_response.assert_not_called()
|
||||
set_cookie.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cas_failure", [False, RuntimeError("write failed")])
|
||||
def test_authentication_finish_does_not_issue_token_when_sign_count_write_fails(
|
||||
cas_failure,
|
||||
):
|
||||
"""签名计数 CAS 冲突或事务失败时,认证不得越过持久化门禁。"""
|
||||
token = PasskeyChallengeStore.issue(
|
||||
challenge="server-challenge",
|
||||
purpose="authentication",
|
||||
user_id=1,
|
||||
)
|
||||
passkey_req = mfa_endpoint.PassKeyAuthenticationFinish(
|
||||
credential={"id": "credential-id"},
|
||||
transaction_token=token,
|
||||
)
|
||||
passkey = SimpleNamespace(
|
||||
id=10,
|
||||
user_id=1,
|
||||
public_key="public-key",
|
||||
sign_count=5,
|
||||
)
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
name="user",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
compare_and_update = Mock()
|
||||
if isinstance(cas_failure, Exception):
|
||||
compare_and_update.side_effect = cas_failure
|
||||
else:
|
||||
compare_and_update.return_value = cas_failure
|
||||
service = SimpleNamespace(
|
||||
get_by_credential_id=Mock(return_value=passkey),
|
||||
compare_and_update_sign_count=compare_and_update,
|
||||
)
|
||||
auth_service = SimpleNamespace(build_token_response=Mock())
|
||||
|
||||
with patch.object(
|
||||
mfa_endpoint,
|
||||
"_extract_and_standardize_credential_id",
|
||||
return_value="credential-id",
|
||||
), patch.object(
|
||||
mfa_endpoint,
|
||||
"get_configured_user_id_lookup",
|
||||
return_value=Mock(return_value=user),
|
||||
), patch.object(
|
||||
mfa_endpoint.PassKeyHelper,
|
||||
"verify_authentication_response",
|
||||
return_value=(True, 6),
|
||||
), patch.object(
|
||||
mfa_endpoint,
|
||||
"get_configured_auth_service",
|
||||
return_value=auth_service,
|
||||
), patch.object(
|
||||
mfa_endpoint,
|
||||
"set_or_refresh_resource_token_cookie",
|
||||
) as set_cookie:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
mfa_endpoint.passkey_authenticate_finish(
|
||||
request=_request(),
|
||||
response=Response(),
|
||||
passkey_req=passkey_req,
|
||||
service=service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
compare_and_update.assert_called_once_with(
|
||||
passkey_id=10,
|
||||
expected_sign_count=5,
|
||||
sign_count=6,
|
||||
)
|
||||
auth_service.build_token_response.assert_not_called()
|
||||
set_cookie.assert_not_called()
|
||||
|
||||
+272
-12
@@ -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:")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.outbox import ClaimedOutboxMessage
|
||||
from app.application.subscription.mutation import (
|
||||
SubscriptionActor,
|
||||
SubscriptionMutationService,
|
||||
@@ -84,9 +85,27 @@ class _Outbox:
|
||||
if self.stage_error:
|
||||
raise self.stage_error
|
||||
|
||||
async def complete_by_event_key(self, event_key: str, _completed_at) -> None:
|
||||
"""记录即时事件成功后的完成键。"""
|
||||
self.calls.append(("outbox_complete", event_key))
|
||||
async def claim_by_event_key(self, event_key, _now, _lease_until):
|
||||
"""记录并返回当前测试拥有的派发 lease。"""
|
||||
self.calls.append(("outbox_claim", event_key))
|
||||
return ClaimedOutboxMessage(
|
||||
message_id=7,
|
||||
event_key=event_key,
|
||||
topic="subscribe.modified",
|
||||
payload={},
|
||||
payload_version=1,
|
||||
attempt=1,
|
||||
)
|
||||
|
||||
async def complete(self, message_id, attempt, _completed_at):
|
||||
"""记录带 attempt fencing 的完成结算。"""
|
||||
self.calls.append(("outbox_complete", message_id, attempt))
|
||||
return True
|
||||
|
||||
async def retry(self, message_id, attempt, **_kwargs):
|
||||
"""记录带 attempt fencing 的失败释放。"""
|
||||
self.calls.append(("outbox_retry", message_id, attempt))
|
||||
return True
|
||||
|
||||
|
||||
def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
|
||||
@@ -99,10 +118,12 @@ def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
|
||||
if event_error:
|
||||
raise event_error
|
||||
|
||||
outbox = outbox or _Outbox(calls)
|
||||
return SubscriptionMutationService(
|
||||
repository=_Repository(subscribe, calls),
|
||||
unit_of_work=_UnitOfWork(calls),
|
||||
outbox=outbox or _Outbox(calls),
|
||||
outbox=outbox,
|
||||
dispatch_store=outbox,
|
||||
publish_modified=publish,
|
||||
)
|
||||
|
||||
@@ -129,14 +150,15 @@ async def test_modified_event_is_staged_with_update_and_completed_after_publish(
|
||||
"stage_update",
|
||||
"outbox_stage",
|
||||
"commit",
|
||||
"outbox_claim",
|
||||
"event",
|
||||
"outbox_complete",
|
||||
]
|
||||
intent = calls[2][1]
|
||||
assert intent.topic == "subscribe.modified"
|
||||
assert intent.event_key.startswith("subscribe.modified:7:update:")
|
||||
assert calls[4][1]["idempotency_key"] == intent.event_key
|
||||
assert calls[5][1] == intent.event_key
|
||||
assert calls[5][1]["idempotency_key"] == intent.event_key
|
||||
assert calls[6][1:] == (7, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -181,5 +203,7 @@ async def test_modified_event_failure_keeps_committed_intent_pending():
|
||||
"stage_update",
|
||||
"outbox_stage",
|
||||
"commit",
|
||||
"outbox_claim",
|
||||
"event",
|
||||
"outbox_retry",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""下载历史类型化适配器的投影与事务测试。"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.history import (
|
||||
DownloadFileSnapshot,
|
||||
DownloadFileWrite,
|
||||
DownloadHistorySnapshot,
|
||||
DownloadHistoryWrite,
|
||||
)
|
||||
from app.db.adapters.history.download import (
|
||||
SessionDownloadHistoryRepository,
|
||||
TransactionalDownloadHistoryRepository,
|
||||
)
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.session import SessionFactory, async_session_scope
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def _repository() -> TransactionalDownloadHistoryRepository:
|
||||
"""构造绑定测试数据库短 Session 的下载历史仓储。"""
|
||||
return TransactionalDownloadHistoryRepository(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
)
|
||||
|
||||
|
||||
def _history_write(
|
||||
*,
|
||||
download_hash: str = "typed-history-hash",
|
||||
) -> DownloadHistoryWrite:
|
||||
"""构造覆盖 Chain 消费字段的类型化下载历史写入。"""
|
||||
return DownloadHistoryWrite(
|
||||
path="/downloads/Typed.Show.S01",
|
||||
type=MediaType.TV.value,
|
||||
title="Typed Show",
|
||||
year="2026",
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="7001",
|
||||
music_type=None,
|
||||
seasons="S01",
|
||||
episodes="E01-E02",
|
||||
image="https://example.test/backdrop.jpg",
|
||||
poster="https://example.test/poster.jpg",
|
||||
downloader="qb",
|
||||
download_hash=download_hash,
|
||||
torrent_name="Typed Show torrent",
|
||||
torrent_description="description",
|
||||
torrent_site="Example",
|
||||
userid=7,
|
||||
username="alice",
|
||||
channel="telegram",
|
||||
date="2026-08-28 10:00:00",
|
||||
note={
|
||||
"source": "subscribe",
|
||||
"nested": {"season": 1, "episodes": [1, 2]},
|
||||
},
|
||||
media_category="剧集",
|
||||
episode_group="group-1",
|
||||
custom_words="S02 => S01",
|
||||
)
|
||||
|
||||
|
||||
def _file_write(
|
||||
*,
|
||||
download_hash: str = "typed-history-hash",
|
||||
) -> DownloadFileWrite:
|
||||
"""构造与类型化历史关联的下载文件写入。"""
|
||||
return DownloadFileWrite(
|
||||
downloader="qb",
|
||||
download_hash=download_hash,
|
||||
fullpath="/downloads/Typed.Show.S01/Episode01.mkv",
|
||||
savepath="/downloads/Typed.Show.S01",
|
||||
filepath="Episode01.mkv",
|
||||
torrentname="Typed Show torrent",
|
||||
)
|
||||
|
||||
|
||||
def test_transactional_repository_projects_detached_snapshots(db) -> None:
|
||||
"""所有同步查询都应在 Session 内投影,且 JSON 不与后续查询共享。"""
|
||||
repository = _repository()
|
||||
history_id = repository.add(_history_write(), (_file_write(),))
|
||||
|
||||
by_hash = repository.get_by_hash("typed-history-hash")
|
||||
by_path = repository.get_by_path("/downloads/Typed.Show.S01")
|
||||
by_hashes = repository.get_by_hashes(["typed-history-hash"])
|
||||
by_identity = repository.get_by_media_identity(
|
||||
MediaSource.TMDB,
|
||||
"7001",
|
||||
)
|
||||
by_fullpath = repository.get_file_by_fullpath("/downloads/Typed.Show.S01/Episode01.mkv")
|
||||
by_file_hash = repository.get_files_by_hash(
|
||||
"typed-history-hash",
|
||||
state=1,
|
||||
)
|
||||
by_savepath = repository.get_files_by_savepath("/downloads/Typed.Show.S01")
|
||||
|
||||
assert isinstance(by_hash, DownloadHistorySnapshot)
|
||||
assert by_hash.id == history_id
|
||||
assert by_hash.userid == "7"
|
||||
assert by_hash.media_source == MediaSource.TMDB
|
||||
assert by_path == by_hash
|
||||
assert by_hashes == {"typed-history-hash": by_hash}
|
||||
assert by_identity == [by_hash]
|
||||
assert isinstance(by_fullpath, DownloadFileSnapshot)
|
||||
assert by_file_hash == [by_fullpath]
|
||||
assert by_savepath == [by_fullpath]
|
||||
assert not hasattr(by_hash, "_sa_instance_state")
|
||||
assert not hasattr(by_fullpath, "_sa_instance_state")
|
||||
|
||||
assert isinstance(by_hash.note, dict)
|
||||
with pytest.raises(TypeError, match="不可修改"):
|
||||
by_hash.note["source"] = "mutated"
|
||||
nested = by_hash.note["nested"]
|
||||
assert isinstance(nested, dict)
|
||||
with pytest.raises(TypeError, match="不可修改"):
|
||||
nested["season"] = 2
|
||||
episodes = nested["episodes"]
|
||||
assert isinstance(episodes, list)
|
||||
with pytest.raises(TypeError, match="不可修改"):
|
||||
episodes.append(3)
|
||||
refreshed = repository.get_by_hash("typed-history-hash")
|
||||
assert refreshed is not None
|
||||
assert refreshed.note == {
|
||||
"source": "subscribe",
|
||||
"nested": {"season": 1, "episodes": [1, 2]},
|
||||
}
|
||||
|
||||
|
||||
def test_transactional_repository_rolls_back_history_and_files_on_commit_failure(
|
||||
db,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""历史与文件提交失败时必须整体回滚,不得留下半写入记录。"""
|
||||
repository = _repository()
|
||||
download_hash = "typed-rollback-hash"
|
||||
|
||||
def fail_commit(_unit_of_work) -> None:
|
||||
"""模拟数据库提交失败。"""
|
||||
raise RuntimeError("commit failed")
|
||||
|
||||
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
repository.add(
|
||||
_history_write(download_hash=download_hash),
|
||||
(_file_write(download_hash=download_hash),),
|
||||
)
|
||||
|
||||
assert repository.get_by_hash(download_hash) is None
|
||||
assert repository.get_files_by_hash(download_hash) == []
|
||||
|
||||
|
||||
def test_transactional_repository_async_query_and_delete(db) -> None:
|
||||
"""异步分页返回脱离 Session 的快照,删除由独立事务提交。"""
|
||||
repository = _repository()
|
||||
history_id = repository.add(_history_write(download_hash="typed-async-hash"))
|
||||
|
||||
async def exercise() -> list[DownloadHistorySnapshot]:
|
||||
"""在同一事件循环中执行异步分页和删除。"""
|
||||
records = await repository.async_list_by_page(count=10)
|
||||
await repository.async_delete(history_id)
|
||||
return records
|
||||
|
||||
records = asyncio.run(exercise())
|
||||
|
||||
assert any(record.id == history_id for record in records)
|
||||
assert all(not hasattr(record, "_sa_instance_state") for record in records)
|
||||
assert repository.get_by_hash("typed-async-hash") is None
|
||||
|
||||
|
||||
def test_session_repository_obeys_caller_transaction(db) -> None:
|
||||
"""请求级 adapter 只暂存变更,提交与回滚由调用方 UoW 决定。"""
|
||||
history = db.add(
|
||||
DownloadHistory(
|
||||
path="/downloads/request-history",
|
||||
type=MediaType.MOVIE.value,
|
||||
title="Request History",
|
||||
download_hash="request-history-hash",
|
||||
)
|
||||
)
|
||||
|
||||
with SessionFactory() as session:
|
||||
repository = SessionDownloadHistoryRepository(session)
|
||||
repository.stage_delete_history(history.id)
|
||||
session.rollback()
|
||||
assert _repository().get_by_hash("request-history-hash") is not None
|
||||
|
||||
with SessionFactory() as session:
|
||||
repository = SessionDownloadHistoryRepository(session)
|
||||
repository.stage_delete_history(history.id)
|
||||
session.commit()
|
||||
assert _repository().get_by_hash("request-history-hash") is None
|
||||
@@ -64,7 +64,7 @@ def test_resolve_download_history_falls_back_to_parent_download_path():
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/season-pack/Test.Show.S01E01.mkv"),
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_resolve_download_history_falls_back_to_unique_savepath_hash():
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/season-pack/subs/Test.Show.S01E01.zh.ass"),
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ def test_resolve_download_history_skips_ambiguous_savepath_hashes():
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"),
|
||||
)
|
||||
|
||||
@@ -128,7 +128,7 @@ def test_resolve_download_history_stops_at_shared_download_root_path(monkeypatch
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/Ghost.Concert.mkv"),
|
||||
)
|
||||
|
||||
@@ -156,7 +156,7 @@ def test_resolve_download_history_stops_at_shared_download_root_savepath(monkeyp
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/Ghost.Concert.mkv"),
|
||||
)
|
||||
|
||||
@@ -184,7 +184,7 @@ def test_resolve_download_history_accepts_shared_root_savepath_for_exact_file(mo
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/Ghost.Concert.mkv"),
|
||||
)
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_resolve_download_history_stops_at_type_category_download_root(monkeypat
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/电视剧/动漫/Ghost.Concert.mkv"),
|
||||
)
|
||||
|
||||
@@ -301,7 +301,7 @@ def test_resolve_download_history_stops_at_nested_category_root(monkeypatch):
|
||||
)
|
||||
|
||||
history = _make_chain()._resolve_download_history(
|
||||
downloadhis=oper,
|
||||
repository=oper,
|
||||
file_path=Path("/downloads/动漫/日本番剧/Ghost.Concert.mkv"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.history import DownloadHistorySnapshot
|
||||
from app.application.transfer.workflow import TransferTask
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.runtime.config import settings
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def _make_chain() -> TransferChain:
|
||||
@@ -59,17 +60,18 @@ def _make_file_meta(year: str = "2013") -> _FileMeta:
|
||||
return _FileMeta(year=year)
|
||||
|
||||
|
||||
def _make_history() -> SimpleNamespace:
|
||||
def _make_history() -> DownloadHistorySnapshot:
|
||||
"""构造被合集首部电影占用的下载历史。"""
|
||||
return SimpleNamespace(
|
||||
return DownloadHistorySnapshot(
|
||||
id=1,
|
||||
path="/downloads/The.Hunger.Games.Complete.4-Film.Collection",
|
||||
download_hash="collection-hash",
|
||||
downloader="qbittorrent",
|
||||
type=MediaType.MOVIE.value,
|
||||
title="饥饿游戏",
|
||||
year="2012",
|
||||
tmdbid=70160,
|
||||
doubanid=None,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="70160",
|
||||
episode_group=None,
|
||||
media_category=None,
|
||||
username=None,
|
||||
@@ -82,12 +84,12 @@ def test_movie_year_conflict_only_applies_to_movies():
|
||||
"""仅电影年份冲突应触发逐文件识别,电视剧季包仍复用下载历史。"""
|
||||
file_meta = _make_file_meta()
|
||||
movie_history = _make_history()
|
||||
tv_history = SimpleNamespace(type=MediaType.TV, year="2012")
|
||||
tv_history = replace(movie_history, type=MediaType.TV.value)
|
||||
|
||||
assert TransferChain._is_movie_year_conflict(file_meta, movie_history)
|
||||
assert not TransferChain._is_movie_year_conflict(file_meta, tv_history)
|
||||
movie_history.year = "2013"
|
||||
assert not TransferChain._is_movie_year_conflict(file_meta, movie_history)
|
||||
same_year_history = replace(movie_history, year="2013")
|
||||
assert not TransferChain._is_movie_year_conflict(file_meta, same_year_history)
|
||||
|
||||
|
||||
def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch):
|
||||
@@ -136,7 +138,7 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch)
|
||||
size=1024,
|
||||
),
|
||||
meta=_make_file_meta(),
|
||||
download_history=DownloadHistory(**vars(_make_history())),
|
||||
download_history=_make_history(),
|
||||
preview=True,
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user