mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor: close transactional boundary debt batch
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()
|
||||
|
||||
Reference in New Issue
Block a user