refactor: close transactional boundary debt batch

This commit is contained in:
jxxghp
2026-08-28 10:36:12 +08:00
parent 3f8d5990e7
commit aa8751f775
105 changed files with 6507 additions and 1691 deletions
+5 -4
View File
@@ -1,3 +1,4 @@
import copy
import secrets
import threading
import time
@@ -43,13 +44,13 @@ class AuthTicketStore(metaclass=Singleton):
ticket = secrets.token_urlsafe(32)
now = time.time()
with self._lock:
self._cleanup(now)
self._tickets[ticket] = {
"user_id": int(user_id),
"provider_id": provider_id,
"metadata": metadata or {},
"metadata": copy.deepcopy(metadata) if metadata is not None else {},
"created_at": now,
}
self._cleanup(now)
return ticket
def consume(self, ticket: str) -> Optional[dict[str, Any]]:
@@ -69,7 +70,7 @@ class AuthTicketStore(metaclass=Singleton):
return None
if now - float(data.get("created_at") or 0) > self._ttl_seconds:
return None
return data
return copy.deepcopy(data)
def _cleanup(self, now: Optional[float] = None) -> None:
"""
@@ -77,7 +78,7 @@ class AuthTicketStore(metaclass=Singleton):
:param now: 当前时间戳,未传入时自动读取
"""
current = now or time.time()
current = time.time() if now is None else now
expired = [
key
for key, value in self._tickets.items()
+44 -26
View File
@@ -5,7 +5,6 @@ import base64
import binascii
import json
import secrets
import threading
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Protocol, Tuple
from urllib.parse import urlparse
@@ -28,9 +27,7 @@ from webauthn.helpers.structs import (
UserVerificationRequirement,
)
from app.adapters.cache.redis import RedisHelper
from app.application.configuration import get_api_runtime_config_snapshot
from app.runtime.cache import TTLCache
from app.runtime.log import logger
PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
@@ -46,15 +43,27 @@ class PasskeyChallenge:
user_id: Optional[int]
class PasskeyChallengeCache(Protocol):
"""PassKey 一次性 challenge 使用的严格原子缓存端口。"""
def store(self, key: str, value: Any) -> None:
"""持久化 challenge,失败时抛出后端异常。"""
def consume(self, key: str) -> Any:
"""原子领取 challenge,不存在时返回 None。"""
class PasskeyChallengeStore:
"""使用当前缓存后端签发并原子消费短时 Passkey challenge。"""
_cache = TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
)
_memory_consume_lock = threading.Lock()
_cache: Optional[PasskeyChallengeCache] = None
@classmethod
def _get_cache(cls) -> PasskeyChallengeCache:
"""返回已装配缓存,缺失时拒绝签发认证状态。"""
if cls._cache is None:
raise RuntimeError("PassKey challenge 缓存尚未配置")
return cls._cache
@classmethod
def issue(
@@ -66,7 +75,7 @@ class PasskeyChallengeStore:
) -> str:
"""保存 challenge 并返回不携带认证事实的随机事务 token。"""
transaction_token = secrets.token_urlsafe(32)
cls._cache.set(
cls._get_cache().store(
transaction_token,
PasskeyChallenge(
challenge=challenge,
@@ -87,17 +96,7 @@ class PasskeyChallengeStore:
if not transaction_token:
return None
if cls._cache.is_redis():
challenge = RedisHelper().pop(
transaction_token,
region="passkey_challenge",
)
else:
with cls._memory_consume_lock:
try:
challenge = cls._cache.pop(transaction_token)
except KeyError:
challenge = None
challenge = cls._get_cache().consume(transaction_token)
if not isinstance(challenge, PasskeyChallenge):
return None
@@ -106,6 +105,11 @@ class PasskeyChallengeStore:
return challenge
def configure_passkey_challenge_cache(cache: PasskeyChallengeCache) -> None:
"""由启动组合根注入 PassKey challenge 的原子缓存。"""
PasskeyChallengeStore._cache = cache
class PassKeyRegistrationVerificationError(Exception):
"""Passkey 注册响应未通过 WebAuthn 安全校验。"""
@@ -465,8 +469,13 @@ class PasskeyRepository(Protocol):
def create(self, payload: dict[str, Any]) -> Any:
"""创建凭证。"""
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
"""更新凭证使用计数。"""
def compare_and_update_sign_count(
self,
passkey_id: int,
expected_sign_count: int,
sign_count: int,
) -> bool:
"""仅在签名计数未被并发修改时记录本次认证。"""
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""删除用户凭证。"""
@@ -495,9 +504,18 @@ class PasskeyService:
"""创建凭证。"""
return self._repository.create(payload)
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
"""更新凭证使用计数。"""
return self._repository.update_last_used(passkey, sign_count)
def compare_and_update_sign_count(
self,
passkey_id: int,
expected_sign_count: int,
sign_count: int,
) -> bool:
"""以验证时观察到的旧计数提交本次认证。"""
return self._repository.compare_and_update_sign_count(
passkey_id=passkey_id,
expected_sign_count=expected_sign_count,
sign_count=sign_count,
)
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""删除用户凭证。"""
+98 -55
View File
@@ -7,11 +7,17 @@
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Protocol, TypeAlias, TypeVar, cast
from typing import Any, Optional, Protocol, TypeAlias, TypeVar, Union, cast
FrozenJson: TypeAlias = (
str | int | float | bool | None | tuple["FrozenJson", ...] | Mapping[str, "FrozenJson"]
)
FrozenJson: TypeAlias = Union[
str,
int,
float,
bool,
None,
tuple["FrozenJson", ...],
Mapping[str, "FrozenJson"],
]
T = TypeVar("T")
@@ -24,7 +30,9 @@ def _freeze_json(value: Any) -> FrozenJson:
return cast(FrozenJson, value)
def _freeze_mapping(value: Mapping[str, Any] | None) -> Mapping[str, FrozenJson]:
def _freeze_mapping(
value: Optional[Mapping[str, Any]],
) -> Mapping[str, FrozenJson]:
"""把可空 JSON 对象复制为只读映射。"""
frozen = _freeze_json(value or {})
return cast(Mapping[str, FrozenJson], frozen)
@@ -36,10 +44,10 @@ class UserSnapshot:
id: int
name: str
email: str | None
email: Optional[str]
is_active: bool
is_superuser: bool
avatar: str | None
avatar: Optional[str]
is_otp: bool
permissions: Mapping[str, FrozenJson]
settings: Mapping[str, FrozenJson]
@@ -50,13 +58,13 @@ class UserSnapshot:
*,
user_id: int,
name: str,
email: str | None,
is_active: bool | None,
is_superuser: bool | None,
avatar: str | None,
is_otp: bool | None,
permissions: Mapping[str, Any] | None,
settings: Mapping[str, Any] | None,
email: Optional[str],
is_active: Optional[bool],
is_superuser: Optional[bool],
avatar: Optional[str],
is_otp: Optional[bool],
permissions: Optional[Mapping[str, Any]],
settings: Optional[Mapping[str, Any]],
) -> "UserSnapshot":
"""复制持久化字段并构造不可变的公开用户快照。"""
return cls(
@@ -77,8 +85,8 @@ class UserAuthSnapshot:
"""仅供认证链使用的只读用户凭据快照。"""
user: UserSnapshot
hashed_password: str | None
otp_secret: str | None
hashed_password: Optional[str]
otp_secret: Optional[str]
@property
def id(self) -> int:
@@ -101,7 +109,7 @@ class UserAuthSnapshot:
return self.user.is_superuser
@property
def avatar(self) -> str | None:
def avatar(self) -> Optional[str]:
"""返回用户头像。"""
return self.user.avatar
@@ -126,13 +134,21 @@ class AuxiliaryUserCreate:
is_superuser: bool = False
@dataclass(frozen=True, slots=True)
class UserUpdateResult:
"""用户更新事务产出的新快照与原用户名。"""
user: UserSnapshot
previous_name: str
class ChainUserRepository(Protocol):
"""用户 Chain 和 Agent 共享的类型化查询与创建端口。"""
def get_auth_by_name(self, name: str) -> UserAuthSnapshot | None:
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
"""按用户名读取认证快照。"""
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
"""异步按用户名读取公开用户快照。"""
def create_auxiliary(self, command: AuxiliaryUserCreate) -> UserAuthSnapshot:
@@ -141,16 +157,19 @@ class ChainUserRepository(Protocol):
def get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
) -> Optional[Mapping[str, FrozenJson]]:
"""读取通知路由设置;用户不存在时返回空值。"""
async def async_get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
) -> Optional[Mapping[str, FrozenJson]]:
"""异步读取通知路由设置;用户不存在时返回空值。"""
def find_name_by_bindings(self, bindings: Mapping[str, object]) -> str | None:
def find_name_by_bindings(
self,
bindings: Mapping[str, object],
) -> Optional[str]:
"""解析唯一启用用户的渠道绑定,歧义时拒绝归属。"""
@@ -160,24 +179,27 @@ class UserRepository(Protocol):
async def async_list(self) -> list[UserSnapshot]:
"""返回全部用户。"""
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
"""按用户名返回用户。"""
async def async_get_by_id(self, user_id: int) -> UserSnapshot | None:
async def async_get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
"""按用户 ID 返回用户。"""
async def async_create(self, payload: dict[str, Any]) -> UserSnapshot | None:
async def async_create(
self,
payload: dict[str, Any],
) -> Optional[UserSnapshot]:
"""创建用户并返回持久化对象。"""
async def async_update(
self,
user_id: int,
payload: dict[str, Any],
) -> UserSnapshot | None:
"""更新用户并返回原用户对象"""
) -> Optional[UserUpdateResult]:
"""更新用户并返回提交后快照发布所需的变更结果"""
async def async_delete(self, user_id: int) -> None:
"""删除用户。"""
async def async_delete(self, user_id: int) -> Optional[str]:
"""删除用户并返回被删除用户名"""
async def async_update_otp_by_name(self, name: str, otp: bool, secret: str) -> None:
"""更新用户 OTP 状态。"""
@@ -193,31 +215,51 @@ class AsyncUnitOfWork(Protocol):
"""回滚失败的用户写入。"""
class UserConfigurationPublisher(Protocol):
"""用户聚合提交后同步进程级配置快照的应用端口。"""
async def rename(self, previous_name: str, current_name: str) -> None:
"""数据库改名提交后迁移对应用户名配置快照。"""
async def delete(self, username: str) -> None:
"""数据库删除提交后移除对应用户名配置快照。"""
class UserNameConflictError(Exception):
"""用户名在数据库唯一约束下发生冲突。"""
class LastActiveSuperuserError(Exception):
"""用户变更会导致系统不再存在启用的超级管理员。"""
class UserService:
"""用户管理应用服务。"""
def __init__(
self,
repository: UserRepository,
unit_of_work: AsyncUnitOfWork | None = None,
unit_of_work: AsyncUnitOfWork,
configuration: UserConfigurationPublisher,
) -> None:
"""创建用户服务;旧独立仓储可暂不提供请求级 UoW"""
"""创建用户服务并注入事务边界与提交后配置发布端口"""
self._repository = repository
self._unit_of_work = unit_of_work
self._configuration = configuration
async def list(self) -> list[UserSnapshot]:
"""返回用户列表。"""
return await self._repository.async_list()
async def get_by_name(self, name: str) -> UserSnapshot | None:
async def get_by_name(self, name: str) -> Optional[UserSnapshot]:
"""按用户名查询用户。"""
return await self._repository.async_get_by_name(name)
async def get_by_id(self, user_id: int) -> UserSnapshot | None:
async def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
"""按用户 ID 查询用户。"""
return await self._repository.async_get_by_id(user_id)
async def create(self, payload: dict[str, Any]) -> UserSnapshot | None:
async def create(self, payload: dict[str, Any]) -> Optional[UserSnapshot]:
"""创建用户。"""
return await self._write(lambda: self._repository.async_create(payload))
@@ -225,44 +267,45 @@ class UserService:
self,
user_id: int,
payload: dict[str, Any],
) -> UserSnapshot | None:
) -> Optional[UserSnapshot]:
"""更新用户。"""
return await self._write(
lambda: self._repository.async_update(user_id, payload)
)
result = await self._write(lambda: self._repository.async_update(user_id, payload))
if result is None:
return None
if result.previous_name != result.user.name:
await self._configuration.rename(result.previous_name, result.user.name)
return result.user
async def delete(self, user_id: int) -> None:
"""删除用户。"""
await self._write(lambda: self._repository.async_delete(user_id))
username = await self._write(lambda: self._repository.async_delete(user_id))
if username is not None:
await self._configuration.delete(username)
async def update_otp(self, name: str, otp: bool, secret: str) -> None:
"""更新用户 OTP 状态。"""
await self._write(
lambda: self._repository.async_update_otp_by_name(name, otp, secret)
)
await self._write(lambda: self._repository.async_update_otp_by_name(name, otp, secret))
async def _write(self, operation: Callable[[], Awaitable[T]]) -> T:
"""执行用户写入,并在正式请求路径统一提交或回滚。"""
try:
result = await operation()
if self._unit_of_work is not None:
await self._unit_of_work.commit()
await self._unit_of_work.commit()
return result
except Exception:
if self._unit_of_work is not None:
await self._unit_of_work.rollback()
await self._unit_of_work.rollback()
raise
_configured_user_id_lookup: Callable[[int], UserSnapshot | None] | None = None
_configured_user_name_lookup: Callable[[str], UserSnapshot | None] | None = None
_configured_user_channel_lookup: Callable[..., str | None] | None = None
_configured_user_id_lookup: Optional[Callable[[int], Optional[UserSnapshot]]] = None
_configured_user_name_lookup: Optional[Callable[[str], Optional[UserSnapshot]]] = None
_configured_user_channel_lookup: Optional[Callable[..., Optional[str]]] = None
def configure_user_lookups(
by_id: Callable[[int], UserSnapshot | None],
by_name: Callable[[str], UserSnapshot | None],
by_channel: Callable[..., str | None],
by_id: Callable[[int], Optional[UserSnapshot]],
by_name: Callable[[str], Optional[UserSnapshot]],
by_channel: Callable[..., Optional[str]],
) -> None:
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
global _configured_user_id_lookup, _configured_user_name_lookup
@@ -272,21 +315,21 @@ def configure_user_lookups(
_configured_user_channel_lookup = by_channel
def get_configured_user_id_lookup() -> Callable[[int], UserSnapshot | None]:
def get_configured_user_id_lookup() -> Callable[[int], Optional[UserSnapshot]]:
"""返回启动阶段登记的按 ID 用户查询函数。"""
if _configured_user_id_lookup is None:
raise RuntimeError("按 ID 的用户查询能力尚未配置")
return _configured_user_id_lookup
def get_configured_user_name_lookup() -> Callable[[str], UserSnapshot | None]:
def get_configured_user_name_lookup() -> Callable[[str], Optional[UserSnapshot]]:
"""返回启动阶段登记的按用户名查询函数。"""
if _configured_user_name_lookup is None:
raise RuntimeError("按用户名的用户查询能力尚未配置")
return _configured_user_name_lookup
def get_configured_user_channel_lookup() -> Callable[..., str | None]:
def get_configured_user_channel_lookup() -> Callable[..., Optional[str]]:
"""返回启动阶段登记的渠道身份到用户名查询函数。"""
if _configured_user_channel_lookup is None:
raise RuntimeError("渠道用户查询能力尚未配置")
+53 -12
View File
@@ -3,20 +3,37 @@
from __future__ import annotations
from functools import partial
from typing import Any, Protocol
from typing import Optional, Protocol, Union
from app.application.database import AsyncDatabaseExecutor
from app.schemas.common import JsonData
from app.schemas.types import UserConfigKey
class UserConfigurationRepository(Protocol):
"""用户配置数据端口。"""
def get(self, username: str, key: str) -> Any:
def get(
self,
username: str,
key: Union[str, UserConfigKey],
) -> JsonData:
"""读取用户配置。"""
def set(self, username: str, key: str, value: Any) -> Any:
def set(
self,
username: str,
key: Union[str, UserConfigKey],
value: JsonData,
) -> None:
"""写入用户配置。"""
def publish_rename(self, previous_name: str, current_name: str) -> None:
"""在用户改名提交后迁移进程级配置快照。"""
def publish_delete(self, username: str) -> None:
"""在用户删除提交后移除进程级配置快照。"""
class UserConfigurationService:
"""编排用户个性化配置读写。"""
@@ -25,30 +42,54 @@ class UserConfigurationService:
self,
repository: UserConfigurationRepository,
*,
async_executor: AsyncDatabaseExecutor | None = None,
async_executor: Optional[AsyncDatabaseExecutor] = None,
) -> None:
"""注入用户配置数据端口及可选的异步事务执行能力。"""
self._repository = repository
self._async_executor = async_executor
def get(self, username: str, key: str) -> Any:
def get(
self,
username: str,
key: Union[str, UserConfigKey],
) -> JsonData:
"""读取用户配置。"""
return self._repository.get(username=username, key=key)
def set(self, username: str, key: str, value: Any) -> Any:
def set(
self,
username: str,
key: Union[str, UserConfigKey],
value: JsonData,
) -> None:
"""写入用户配置。"""
return self._repository.set(username=username, key=key, value=value)
self._repository.set(username=username, key=key, value=value)
async def async_set(self, username: str, key: str, value: Any) -> Any:
async def async_set(
self,
username: str,
key: Union[str, UserConfigKey],
value: JsonData,
) -> None:
"""异步写入用户配置,并等待数据库提交或回滚完成。"""
if self._async_executor is None:
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
return await self._async_executor.run(
partial(self._repository.set, username=username, key=key, value=value)
)
await self._async_executor.run(partial(self._repository.set, username=username, key=key, value=value))
async def rename(self, previous_name: str, current_name: str) -> None:
"""异步发布已提交的用户名配置迁移。"""
if self._async_executor is None:
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
await self._async_executor.run(partial(self._repository.publish_rename, previous_name, current_name))
async def delete(self, username: str) -> None:
"""异步发布已提交的用户名配置删除。"""
if self._async_executor is None:
raise RuntimeError("用户配置异步数据库执行端口尚未配置")
await self._async_executor.run(partial(self._repository.publish_delete, username))
_configured_user_configuration: UserConfigurationService | None = None
_configured_user_configuration: Optional[UserConfigurationService] = None
def configure_user_configuration(service: UserConfigurationService) -> None: