refactor(user): freeze host user query ports

This commit is contained in:
jxxghp
2026-08-28 08:44:35 +08:00
parent eb4549b2e6
commit 3f8d5990e7
24 changed files with 810 additions and 157 deletions
+4 -4
View File
@@ -6,11 +6,11 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.subscribe import SubscribeChain
from app.application.agentdata import get_agent_user_port
from app.chain.subscribe import SubscribeChain
from app.domain.media import normalize_music_type
from app.runtime.log import logger
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel
from app.domain.media import normalize_music_type
class AddSubscribeInput(BaseModel):
@@ -154,8 +154,8 @@ class AddSubscribeTool(MoviePilotTool):
mapped_username = await self.run_blocking(
"db",
get_agent_user_port().get_name,
**{key: self._user_id for key in binding_keys},
get_agent_user_port().find_name_by_bindings,
{key: self._user_id for key in binding_keys},
)
return mapped_username or resolved_username
+5 -9
View File
@@ -3,7 +3,9 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from typing import Any, cast
from app.application.security.user import ChainUserRepository
AgentDataFactory = Callable[[], Any]
@@ -39,12 +41,6 @@ class AgentTaskPort(_PortProxy):
port_name = "agent_task"
class UserPort(_PortProxy):
"""用户数据端口代理。"""
port_name = "user"
class SitePort(_PortProxy):
"""站点数据端口代理。"""
@@ -129,9 +125,9 @@ def get_agent_task_port() -> Any:
return get_agent_data_ports().agent_task()
def get_agent_user_port() -> Any:
def get_agent_user_port() -> ChainUserRepository:
"""创建 Agent 用户数据端口实例。"""
return get_agent_data_ports().user()
return cast(ChainUserRepository, get_agent_data_ports().user())
def get_agent_site_port() -> Any:
+5 -3
View File
@@ -12,12 +12,14 @@ from typing import Any, Optional
from app.application.download.failures import DownloadFailureRepository
from app.application.mediaserver import MediaServerRepository
from app.application.security.user import ChainUserRepository
from app.application.transfer.execution import TransferExecutionRepository
from app.application.transfer.workflow import TransferAdmissionRepository
OperFactory = Callable[[], Any]
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
ChainUserRepositoryFactory = Callable[[], ChainUserRepository]
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
@@ -34,7 +36,7 @@ class ChainDataPorts:
transfer_execution: TransferExecutionRepositoryFactory
media_server: MediaServerRepositoryFactory
download_failure: DownloadFailureRepositoryFactory
user: OperFactory
user: ChainUserRepositoryFactory
_ports: Optional[ChainDataPorts] = None
@@ -50,7 +52,7 @@ def configure_chain_data_ports(
transfer_execution: TransferExecutionRepositoryFactory,
media_server: MediaServerRepositoryFactory,
download_failure: DownloadFailureRepositoryFactory,
user: OperFactory,
user: ChainUserRepositoryFactory,
) -> None:
"""由启动组合根登记显式命名的 Chain 数据端口实现。"""
global _ports
@@ -114,6 +116,6 @@ def get_chain_download_failure_port() -> DownloadFailureRepository:
return get_chain_data_ports().download_failure()
def get_chain_user_port() -> Any:
def get_chain_user_port() -> ChainUserRepository:
"""创建用户数据端口实例。"""
return get_chain_data_ports().user()
+32 -12
View File
@@ -1,16 +1,19 @@
import secrets
import threading
import time
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, Optional, Protocol
from typing import Any, Optional, Protocol, cast
from app.application.configuration import get_api_runtime_config_snapshot, get_chain_runtime_config_snapshot
from app.application.security.token import create_access_token
from app.application.security.user import FrozenJson
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.foundation.singleton import Singleton
from app.schemas.token import Token as _SchemaToken
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.application.security.token import create_access_token
from app.application.configuration import get_api_runtime_config_snapshot, get_chain_runtime_config_snapshot
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.schemas.types import SystemConfigKey
from app.foundation.singleton import Singleton
from app.schemas.user import UserPermissions
class AuthTicketStore(metaclass=Singleton):
@@ -117,12 +120,29 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]:
class AuthUser(Protocol):
"""认证服务需要的最小用户投影。"""
id: int
name: str
is_active: bool
is_superuser: bool
avatar: Optional[str]
permissions: Optional[dict]
@property
def id(self) -> int:
"""返回用户 ID。"""
@property
def name(self) -> str:
"""返回用户名。"""
@property
def is_active(self) -> bool:
"""返回账号启用状态。"""
@property
def is_superuser(self) -> bool:
"""返回超级用户状态。"""
@property
def avatar(self) -> Optional[str]:
"""返回用户头像。"""
@property
def permissions(self) -> Mapping[str, FrozenJson]:
"""返回只读权限快照。"""
class AuthUserRepository(Protocol):
@@ -206,7 +226,7 @@ class AuthService:
user_name=user.name,
avatar=user.avatar,
level=level,
permissions=user.permissions or {},
permissions=cast(UserPermissions, dict(user.permissions)),
wizard=show_wizard,
)
+173 -19
View File
@@ -4,26 +4,176 @@
避免 API 层同时承担 HTTP 编排和 ORM 适配职责。
"""
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Protocol, TypeAlias, TypeVar, cast
FrozenJson: TypeAlias = (
str | int | float | bool | None | tuple["FrozenJson", ...] | Mapping[str, "FrozenJson"]
)
T = TypeVar("T")
def _freeze_json(value: Any) -> FrozenJson:
"""递归复制 JSON 值,阻止 ORM JSON 字段在会话外继续被修改。"""
if isinstance(value, Mapping):
return MappingProxyType({str(key): _freeze_json(item) for key, item in value.items()})
if isinstance(value, (list, tuple)):
return tuple(_freeze_json(item) for item in value)
return cast(FrozenJson, value)
def _freeze_mapping(value: Mapping[str, Any] | None) -> Mapping[str, FrozenJson]:
"""把可空 JSON 对象复制为只读映射。"""
frozen = _freeze_json(value or {})
return cast(Mapping[str, FrozenJson], frozen)
@dataclass(frozen=True, slots=True)
class UserSnapshot:
"""脱离数据库会话的只读用户资料与权限快照。"""
id: int
name: str
email: str | None
is_active: bool
is_superuser: bool
avatar: str | None
is_otp: bool
permissions: Mapping[str, FrozenJson]
settings: Mapping[str, FrozenJson]
@classmethod
def build(
cls,
*,
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,
) -> "UserSnapshot":
"""复制持久化字段并构造不可变的公开用户快照。"""
return cls(
id=user_id,
name=name,
email=email,
is_active=bool(is_active),
is_superuser=bool(is_superuser),
avatar=avatar,
is_otp=bool(is_otp),
permissions=_freeze_mapping(permissions),
settings=_freeze_mapping(settings),
)
@dataclass(frozen=True, slots=True)
class UserAuthSnapshot:
"""仅供认证链使用的只读用户凭据快照。"""
user: UserSnapshot
hashed_password: str | None
otp_secret: str | None
@property
def id(self) -> int:
"""返回用户 ID。"""
return self.user.id
@property
def name(self) -> str:
"""返回用户名。"""
return self.user.name
@property
def is_active(self) -> bool:
"""返回账号启用状态。"""
return self.user.is_active
@property
def is_superuser(self) -> bool:
"""返回超级用户状态。"""
return self.user.is_superuser
@property
def avatar(self) -> str | None:
"""返回用户头像。"""
return self.user.avatar
@property
def is_otp(self) -> bool:
"""返回 OTP 启用状态。"""
return self.user.is_otp
@property
def permissions(self) -> Mapping[str, FrozenJson]:
"""返回只读权限快照。"""
return self.user.permissions
@dataclass(frozen=True, slots=True)
class AuxiliaryUserCreate:
"""辅助认证首次落地本地用户所需的最小命令。"""
name: str
hashed_password: str
is_active: bool = True
is_superuser: bool = False
class ChainUserRepository(Protocol):
"""用户 Chain 和 Agent 共享的类型化查询与创建端口。"""
def get_auth_by_name(self, name: str) -> UserAuthSnapshot | None:
"""按用户名读取认证快照。"""
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
"""异步按用户名读取公开用户快照。"""
def create_auxiliary(self, command: AuxiliaryUserCreate) -> UserAuthSnapshot:
"""原子创建辅助认证用户并返回已提交快照。"""
def get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
"""读取通知路由设置;用户不存在时返回空值。"""
async def async_get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
"""异步读取通知路由设置;用户不存在时返回空值。"""
def find_name_by_bindings(self, bindings: Mapping[str, object]) -> str | None:
"""解析唯一启用用户的渠道绑定,歧义时拒绝归属。"""
class UserRepository(Protocol):
"""用户用例所需的最小异步数据端口。"""
async def async_list(self) -> list[Any]:
async def async_list(self) -> list[UserSnapshot]:
"""返回全部用户。"""
async def async_get_by_name(self, name: str) -> Any | None:
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
"""按用户名返回用户。"""
async def async_get_by_id(self, user_id: int) -> Any | None:
async def async_get_by_id(self, user_id: int) -> UserSnapshot | None:
"""按用户 ID 返回用户。"""
async def async_create(self, payload: dict[str, Any]) -> Any | None:
async def async_create(self, payload: dict[str, Any]) -> UserSnapshot | None:
"""创建用户并返回持久化对象。"""
async def async_update(self, user_id: int, payload: dict[str, Any]) -> Any | None:
async def async_update(
self,
user_id: int,
payload: dict[str, Any],
) -> UserSnapshot | None:
"""更新用户并返回原用户对象。"""
async def async_delete(self, user_id: int) -> None:
@@ -55,23 +205,27 @@ class UserService:
self._repository = repository
self._unit_of_work = unit_of_work
async def list(self) -> list[Any]:
async def list(self) -> list[UserSnapshot]:
"""返回用户列表。"""
return await self._repository.async_list()
async def get_by_name(self, name: str) -> Any | None:
async def get_by_name(self, name: str) -> UserSnapshot | None:
"""按用户名查询用户。"""
return await self._repository.async_get_by_name(name)
async def get_by_id(self, user_id: int) -> Any | None:
async def get_by_id(self, user_id: int) -> UserSnapshot | None:
"""按用户 ID 查询用户。"""
return await self._repository.async_get_by_id(user_id)
async def create(self, payload: dict[str, Any]) -> Any | None:
async def create(self, payload: dict[str, Any]) -> UserSnapshot | None:
"""创建用户。"""
return await self._write(lambda: self._repository.async_create(payload))
async def update(self, user_id: int, payload: dict[str, Any]) -> Any | None:
async def update(
self,
user_id: int,
payload: dict[str, Any],
) -> UserSnapshot | None:
"""更新用户。"""
return await self._write(
lambda: self._repository.async_update(user_id, payload)
@@ -87,7 +241,7 @@ class UserService:
lambda: self._repository.async_update_otp_by_name(name, otp, secret)
)
async def _write(self, operation: Callable[[], Awaitable[Any]]) -> Any:
async def _write(self, operation: Callable[[], Awaitable[T]]) -> T:
"""执行用户写入,并在正式请求路径统一提交或回滚。"""
try:
result = await operation()
@@ -100,14 +254,14 @@ class UserService:
raise
_configured_user_id_lookup: Callable[[int], Any | None] | None = None
_configured_user_name_lookup: Callable[[str], Any | None] | None = None
_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
def configure_user_lookups(
by_id: Callable[[int], Any | None],
by_name: Callable[[str], Any | None],
by_id: Callable[[int], UserSnapshot | None],
by_name: Callable[[str], UserSnapshot | None],
by_channel: Callable[..., str | None],
) -> None:
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
@@ -118,14 +272,14 @@ def configure_user_lookups(
_configured_user_channel_lookup = by_channel
def get_configured_user_id_lookup() -> Callable[[int], Any | None]:
def get_configured_user_id_lookup() -> Callable[[int], UserSnapshot | None]:
"""返回启动阶段登记的按 ID 用户查询函数。"""
if _configured_user_id_lookup is None:
raise RuntimeError("按 ID 的用户查询能力尚未配置")
return _configured_user_id_lookup
def get_configured_user_name_lookup() -> Callable[[str], Any | None]:
def get_configured_user_name_lookup() -> Callable[[str], UserSnapshot | None]:
"""返回启动阶段登记的按用户名查询函数。"""
if _configured_user_name_lookup is None:
raise RuntimeError("按用户名的用户查询能力尚未配置")
+6 -6
View File
@@ -177,7 +177,7 @@ class NotificationMixin:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(
send_message.targets = useroper.get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
@@ -187,7 +187,7 @@ class NotificationMixin:
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
)
# 读取用户消息IDS
send_message.targets = useroper.get_settings(
send_message.targets = useroper.get_notification_settings(
send_message.username
)
if send_message.targets is None:
@@ -198,7 +198,7 @@ class NotificationMixin:
f"用户 {send_message.username} 不存在,消息将发送给管理员"
)
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(
send_message.targets = useroper.get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
@@ -295,7 +295,7 @@ class NotificationMixin:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(
send_message.targets = await useroper.async_get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
@@ -305,7 +305,7 @@ class NotificationMixin:
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
)
# 读取用户消息IDS
send_message.targets = useroper.get_settings(
send_message.targets = await useroper.async_get_notification_settings(
send_message.username
)
if send_message.targets is None:
@@ -316,7 +316,7 @@ class NotificationMixin:
f"用户 {send_message.username} 不存在,消息将发送给管理员"
)
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(
send_message.targets = await useroper.async_get_notification_settings(
self.runtime_config.superuser
)
admin_sended = True
+4 -4
View File
@@ -669,8 +669,8 @@ class MediaInteractionChain(ChainBase):
return
mp_name = (
get_chain_user_port().get_name(
**{f"{channel.name.lower()}_userid": userid}
get_chain_user_port().find_name_by_bindings(
{f"{channel.name.lower()}_userid": userid}
)
if channel
else None
@@ -982,8 +982,8 @@ class MediaInteractionChain(ChainBase):
note = None
mp_name = (
get_chain_user_port().get_name(
**{f"{channel.name.lower()}_userid": userid}
get_chain_user_port().find_name_by_bindings(
{f"{channel.name.lower()}_userid": userid}
)
if channel
else None
+41 -16
View File
@@ -1,17 +1,17 @@
import secrets
from dataclasses import dataclass
from typing import Any, Literal, Optional, Tuple, Union
from typing import Literal, Optional, Tuple, Union
from app.application.chain.data import get_chain_user_port
from app.application.security.otp import OtpUtils
from app.application.security.token import get_password_hash, verify_password
from app.application.security.user import AuxiliaryUserCreate, UserAuthSnapshot
from app.chain import ChainBase
from app.runtime.log import logger
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
from app.schemas.types import ChainEventType
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名、密码或验证码错误"
User = Any
MfaMethod = Literal["otp"]
@@ -36,7 +36,7 @@ class UserChain(ChainBase):
mfa_code: Optional[str] = None,
code: Optional[str] = None,
grant_type: Optional[str] = "password"
) -> Tuple[bool, Union[str, User, MfaRequired, None]]:
) -> Tuple[bool, Union[str, UserAuthSnapshot, MfaRequired, None]]:
"""
认证用户,根据不同的 grant_type 处理不同的认证流程
@@ -60,7 +60,7 @@ class UserChain(ChainBase):
if credentials.grant_type == "password":
# Password 认证
success, user_or_message = self.password_authenticate(credentials=credentials)
if success:
if success and isinstance(user_or_message, UserAuthSnapshot):
# 如果用户启用了二次验证,则进一步验证
mfa_result = self._verify_mfa(user_or_message, credentials.mfa_code)
if isinstance(mfa_result, MfaRequired):
@@ -74,7 +74,10 @@ class UserChain(ChainBase):
if self.runtime_config.auxiliary_auth_enable:
logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...")
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
if aux_success:
if aux_success and isinstance(
aux_user_or_message,
UserAuthSnapshot,
):
# 辅助认证成功后再验证 6 位验证码
mfa_result = self._verify_mfa(aux_user_or_message, credentials.mfa_code)
if isinstance(mfa_result, MfaRequired):
@@ -102,7 +105,9 @@ class UserChain(ChainBase):
return False, "不支持的认证类型"
@staticmethod
def password_authenticate(credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
def password_authenticate(
credentials: AuthCredentials,
) -> Tuple[bool, Union[UserAuthSnapshot, str]]:
"""
密码认证
@@ -114,8 +119,11 @@ class UserChain(ChainBase):
if not credentials or credentials.grant_type != "password":
logger.info("密码认证失败,认证类型不匹配")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
if not credentials.username or credentials.password is None:
logger.info("密码认证失败,用户名或密码为空")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
user = get_chain_user_port().get_by_name(name=credentials.username)
user = get_chain_user_port().get_auth_by_name(name=credentials.username)
if not user:
logger.info(f"密码认证失败,用户 {credentials.username} 不存在")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
@@ -130,7 +138,10 @@ class UserChain(ChainBase):
return True, user
def auxiliary_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
def auxiliary_authenticate(
self,
credentials: AuthCredentials,
) -> Tuple[bool, Union[UserAuthSnapshot, str]]:
"""
辅助用户认证
@@ -145,7 +156,7 @@ class UserChain(ChainBase):
# 检查是否因为用户被禁用
useroper = get_chain_user_port()
if credentials.username:
user = useroper.get_by_name(name=credentials.username)
user = useroper.get_auth_by_name(name=credentials.username)
if user and not user.is_active:
logger.info(f"用户 {user.name} 已被禁用,跳过后续身份校验")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
@@ -166,16 +177,28 @@ class UserChain(ChainBase):
credentials = result # 使用模块认证返回的认证数据
# 处理认证成功的逻辑
success = self._process_auth_success(username=credentials.username, credentials=credentials)
resolved_username = credentials.username
if not resolved_username:
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
success = self._process_auth_success(
username=resolved_username,
credentials=credentials,
)
if success:
logger.info(f"用户 {credentials.username} 辅助认证通过")
return True, useroper.get_by_name(credentials.username)
user = useroper.get_auth_by_name(resolved_username)
if user is not None:
return True, user
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
else:
logger.warning(f"用户 {credentials.username} 辅助认证未通过")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
@staticmethod
def _verify_mfa(user: User, mfa_code: Optional[str]) -> Union[bool, MfaRequired]:
def _verify_mfa(
user: UserAuthSnapshot,
mfa_code: Optional[str],
) -> Union[bool, MfaRequired]:
"""
验证密码登录后的 6 位验证码。
@@ -213,7 +236,7 @@ class UserChain(ChainBase):
return False
token, channel, service = credentials.token, credentials.channel, credentials.service
if not all([token, channel, service]):
if not token or not channel or not service:
logger.info(f"用户 {username} 未通过 {credentials.grant_type} 认证,必要信息不足")
return False
@@ -232,7 +255,7 @@ class UserChain(ChainBase):
# 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户
useroper = get_chain_user_port()
user = useroper.get_by_name(name=username)
user = useroper.get_auth_by_name(name=username)
if user:
# 如果用户存在,但是已经被禁用,则直接响应
if not user.is_active:
@@ -245,8 +268,10 @@ class UserChain(ChainBase):
return True
else:
if credentials.grant_type == "password":
useroper.add(name=username, is_active=True, is_superuser=False,
hashed_password=get_password_hash(secrets.token_urlsafe(16)))
useroper.create_auxiliary(AuxiliaryUserCreate(
name=username,
hashed_password=get_password_hash(secrets.token_urlsafe(16)),
))
logger.info(f"用户 {username} 不存在,已通过 {credentials.grant_type} 认证并已创建普通用户")
return True
else:
+193
View File
@@ -0,0 +1,193 @@
"""用户应用端口的 SQLAlchemy 快照与事务适配器。"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from contextlib import AbstractAsyncContextManager
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.application.security.user import (
AuxiliaryUserCreate,
ChainUserRepository,
FrozenJson,
UserAuthSnapshot,
UserRepository,
UserSnapshot,
)
from app.db.models.user import User
from app.db.oper.user import UserOper
from app.db.uow import SqlAlchemyUnitOfWork
def _to_snapshot(model: User) -> UserSnapshot:
"""在 ORM 会话仍有效时复制公开用户字段。"""
return UserSnapshot.build(
user_id=model.id,
name=model.name,
email=model.email,
is_active=model.is_active,
is_superuser=model.is_superuser,
avatar=model.avatar,
is_otp=model.is_otp,
permissions=model.permissions,
settings=model.settings,
)
def _to_auth_snapshot(model: User) -> UserAuthSnapshot:
"""在 ORM 会话仍有效时复制认证凭据和公开资料。"""
return UserAuthSnapshot(
user=_to_snapshot(model),
hashed_password=model.hashed_password,
otp_secret=model.otp_secret,
)
class SqlAlchemyUserRepository(UserRepository):
"""把请求级同步或异步 Session 适配为冻结用户仓储。"""
def __init__(self, session: Session | AsyncSession) -> None:
"""保存请求拥有的 Session;提交与回滚仍由请求 UoW 负责。"""
self._session = session
self._oper = UserOper(db=session)
def get_by_name(self, name: str) -> UserSnapshot | None:
"""在同步请求会话中按用户名读取冻结快照。"""
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:
"""在同步请求会话中按 ID 读取冻结快照。"""
model = self._oper.get_by_id(user_id)
return _to_snapshot(model) if model else None
async def async_list(self) -> list[UserSnapshot]:
"""在异步请求会话中读取全部冻结用户快照。"""
return [_to_snapshot(model) for model in await self._oper.async_list()]
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
"""在异步请求会话中按用户名读取冻结快照。"""
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:
"""在异步请求会话中按 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:
"""在请求事务中暂存用户创建并返回冻结快照。"""
model = await self._oper.async_create(payload)
return _to_snapshot(model) if model else None
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
async def async_delete(self, user_id: int) -> None:
"""在请求事务中暂存用户删除。"""
await self._oper.async_delete(user_id)
async def async_update_otp_by_name(
self,
name: str,
otp: bool,
secret: str,
) -> None:
"""在请求事务中暂存用户 OTP 状态更新。"""
await self._oper.async_update_otp_by_name(name, otp, secret)
class TransactionalUserRepository(ChainUserRepository):
"""为 Chain、Agent 和进程级认证提供短生命周期用户会话。"""
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 get_by_name(self, name: str) -> UserSnapshot | None:
"""按用户名读取公开用户快照。"""
with self._sync_session() as session:
return SqlAlchemyUserRepository(session).get_by_name(name)
def get_by_id(self, user_id: int) -> UserSnapshot | None:
"""按 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:
"""按用户名读取认证凭据快照。"""
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 with self._async_session() as session:
return await SqlAlchemyUserRepository(session).async_get_by_name(name)
def create_auxiliary(self, command: AuxiliaryUserCreate) -> UserAuthSnapshot:
"""在独占事务中创建辅助认证用户,提交失败时完整回滚。"""
with self._sync_session() as session:
session.expire_on_commit = False
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
model = User(
name=command.name,
hashed_password=command.hashed_password,
is_active=command.is_active,
is_superuser=command.is_superuser,
)
session.add(model)
session.flush()
snapshot = _to_auth_snapshot(model)
unit_of_work.commit()
return snapshot
except Exception:
unit_of_work.rollback()
raise
def get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
"""同步读取用户通知设置的只读快照。"""
user = self.get_by_name(name)
return user.settings if user else None
async def async_get_notification_settings(
self,
name: str,
) -> Mapping[str, FrozenJson] | None:
"""异步读取用户通知设置的只读快照。"""
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:
"""仅在全部绑定唯一匹配同一启用用户时返回用户名。"""
if not bindings:
return None
expected = {key: str(value) for key, value in bindings.items()}
with self._sync_session() as session:
matches = {
model.name
for model in UserOper(db=session).list()
if model.is_active
and model.settings
and all(model.settings.get(key) == value for key, value in expected.items())
}
return next(iter(matches)) if len(matches) == 1 else None
+14 -25
View File
@@ -23,25 +23,25 @@ from lark_oapi.api.im.v1 import (
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
CreateMessageReactionRequest,
CreateMessageReactionRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
DeleteMessageReactionRequest,
Emoji,
GetFileRequest,
GetImageRequest,
GetMessageResourceRequest,
PatchMessageRequest,
PatchMessageRequestBody,
P2ImChatAccessEventBotP2pChatEnteredV1,
P2ImMessageMessageReadV1,
P2ImMessageReactionCreatedV1,
P2ImMessageReactionDeletedV1,
P2ImMessageRecalledV1,
P2ImMessageReceiveV1,
PatchMessageRequest,
PatchMessageRequestBody,
ReplyMessageRequest,
ReplyMessageRequestBody,
Emoji,
)
from lark_oapi.core.const import FEISHU_DOMAIN
from lark_oapi.core.enum import LogLevel
@@ -50,18 +50,16 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import (
P2CardActionTriggerResponse,
)
from app.runtime.settings import get_runtime_setting
from app.application.messaging.ingress import submit_message_to_host
from app.domain.context import Context, MediaInfo
from app.application.security.user import get_configured_user_channel_lookup
from app.application.messaging.agent import matches_channel_admin
from app.runtime.log import logger
from app.schemas.message import IncomingMessage
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MessageType
from app.adapters.network.http import RequestUtils
from app.application.messaging.agent import matches_channel_admin
from app.application.messaging.ingress import submit_message_to_host
from app.application.security.user import get_configured_user_channel_lookup
from app.domain.context import Context, MediaInfo
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.runtime.thread import ThreadHelper
from app.schemas.message import IncomingMessage, Message
from app.schemas.types import MessageType, NotificationChannel
class _ThreadLocalEventLoopProxy:
@@ -115,15 +113,6 @@ lark_ws_client_module.loop = _lark_ws_loop_proxy
lark_ws_client_module._select = _select_bound_ws_client
class UserOper:
"""兼容飞书模块存量测试的渠道用户查询门面。"""
@staticmethod
def get_name(**bindings) -> Optional[str]:
"""把渠道标识查询转发到启动组合根登记的用户端口。"""
return get_configured_user_channel_lookup()(**bindings)
class Feishu:
"""飞书通知客户端,负责长连接收消息与主动发送通知。"""
@@ -521,7 +510,7 @@ class Feishu:
binding_ids["feishu_userid"] = user_id
if binding_ids:
try:
mapped_username = UserOper().get_name(**binding_ids)
mapped_username = get_configured_user_channel_lookup()(**binding_ids)
if mapped_username:
return mapped_username
except Exception as err:
+24 -11
View File
@@ -124,6 +124,10 @@ from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRep
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
from app.db.adapters.user import (
SqlAlchemyUserRepository,
TransactionalUserRepository,
)
from app.db.adapters.workflow import (
TransactionalWorkflowExecutionService,
TransactionalWorkflowQueryRepository,
@@ -140,7 +144,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.user import UserOper
from app.db.oper.userconfig import UserConfigOper
from app.db.oper.workflow import WorkflowOper
from app.db.session import (
@@ -247,6 +250,14 @@ def _build_runtime_settings_service() -> RuntimeSettingsService:
return RuntimeSettingsService(legacy_settings)
def _build_transactional_user_repository() -> TransactionalUserRepository:
"""构造供 Chain、Agent 与进程级认证共享的短会话用户仓储。"""
return TransactionalUserRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
async def _async_get_subscribe(subscribe_id: int):
"""通过数据库操作器异步读取订阅,供服务端共享用例使用。"""
return await SubscribeOper().async_get(subscribe_id)
@@ -795,13 +806,13 @@ async def init_modules() -> HostRuntime:
"subscribe": SubscribeOper,
"subscribe_history": SubscribeHistoryOper,
"transfer_history": TransferHistoryOper,
"user": UserOper,
"user": SqlAlchemyUserRepository,
"workflow": WorkflowOper,
},
standalone={
"passkey": PassKeyOper,
"system_config": SystemConfigOper,
"user": UserOper,
"user": _build_transactional_user_repository,
},
unit_of_work={
"async": SqlAlchemyAsyncUnitOfWork,
@@ -841,8 +852,8 @@ async def init_modules() -> HostRuntime:
async_transaction=SqlAlchemyAsyncUnitOfWork,
),
authentication=AuthenticationRuntime(
user_repository=UserOper,
standalone_user=UserOper,
user_repository=SqlAlchemyUserRepository,
standalone_user=_build_transactional_user_repository,
system_config=SystemConfigOper,
passkey=PassKeyOper,
),
@@ -897,7 +908,7 @@ async def init_modules() -> HostRuntime:
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
),
user=lambda: UserOper(),
user=_build_transactional_user_repository,
)
configure_outbox_dispatcher(_build_outbox_dispatcher)
configure_transfer_retry_config(
@@ -909,13 +920,15 @@ async def init_modules() -> HostRuntime:
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
configure_agent_chat_persistence(agent_chat_persistence)
configure_user_lookups(
by_id=lambda user_id: UserOper().get_by_id(user_id),
by_name=lambda username: UserOper().get_by_name(username),
by_channel=lambda **bindings: UserOper().get_name(**bindings),
by_id=lambda user_id: _build_transactional_user_repository().get_by_id(user_id),
by_name=lambda username: _build_transactional_user_repository().get_by_name(username),
by_channel=lambda **bindings: (
_build_transactional_user_repository().find_name_by_bindings(bindings)
),
)
configure_auth_service(
AuthService(
users=UserOper(),
users=_build_transactional_user_repository(),
config=get_configured_system_config(),
passkeys=PassKeyOper(),
)
@@ -933,7 +946,7 @@ async def init_modules() -> HostRuntime:
configure_agent_data_ports(
agent_chat=lambda: AgentChatOper(),
agent_task=lambda: AgentTaskOper(),
user=lambda: UserOper(),
user=_build_transactional_user_repository,
site=lambda: TransactionalSiteRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
+3 -3
View File
@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 852 / 6,962 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 853 / 6,979 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 872 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 78.95%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
| Ruff 历史诊断 | 869 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.02%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
+2 -2
View File
@@ -704,8 +704,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 852 |
| 内部导入边 | 6,962 |
| Python 模块 | 853 |
| 内部导入边 | 6,979 |
| 非平凡 SCC | 2`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
| Direct egress | 6612 条待迁移债务,54 条精确 containment |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
+1 -1
View File
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 872 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 869 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口
+18 -5
View File
@@ -231,6 +231,10 @@ def configure_plugin_system_services():
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
from app.db.adapters.user import (
SqlAlchemyUserRepository,
TransactionalUserRepository,
)
from app.db.adapters.workflow import (
TransactionalWorkflowExecutionService,
TransactionalWorkflowQueryRepository,
@@ -244,7 +248,6 @@ def configure_plugin_system_services():
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.user import UserOper
from app.db.oper.workflow import WorkflowOper
def create_sync_session() -> Session:
@@ -275,13 +278,16 @@ def configure_plugin_system_services():
"subscribe": SubscribeOper,
"subscribe_history": SubscribeHistoryOper,
"transfer_history": TransferHistoryOper,
"user": UserOper,
"user": SqlAlchemyUserRepository,
"workflow": WorkflowOper,
},
standalone={
"passkey": PassKeyOper,
"system_config": SystemConfigOper,
"user": UserOper,
"user": lambda: TransactionalUserRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
),
},
unit_of_work={
"async": SqlAlchemyAsyncUnitOfWork,
@@ -302,6 +308,13 @@ def configure_plugin_system_services():
async_session=async_session_scope,
)
def user_repository() -> TransactionalUserRepository:
"""按生产组合根方式创建用户短会话仓储。"""
return TransactionalUserRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
configure_chain_data_ports(
site=site_repository,
subscribe=lambda: SubscribeOper(),
@@ -317,7 +330,7 @@ def configure_plugin_system_services():
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
),
user=lambda: UserOper(),
user=user_repository,
)
configure_chain_runtime_context_provider(lambda: ChainRuntimeContext(
module_manager=ModuleManager(),
@@ -346,7 +359,7 @@ def configure_plugin_system_services():
configure_agent_data_ports(
agent_chat=lambda: AgentChatOper(),
agent_task=lambda: AgentTaskOper(),
user=lambda: UserOper(),
user=user_repository,
site=site_repository,
subscribe=lambda: SubscribeOper(),
subscribe_history=lambda: SubscribeHistoryOper(),
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10133,
"percent": 78.95,
"statements": 12834
"covered_lines": 10195,
"percent": 79.02,
"statements": 12902
},
"domain": {
"covered_lines": 3392,
+22 -4
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6962,
"edge_sha256": "d78cc1aa6f3837310c7460d2ea17873222b4b50708012ae2f3db249d8197f77e",
"edge_count": 6979,
"edge_sha256": "0fbef3f16d1475a40988a9fedbeb9a9ff67f49d0e3cd8033280b40a399a92d51",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -3957,6 +3957,9 @@
"app.api.servcookie -> app.runtime.log",
"app.api.servcookie -> app.schemas",
"app.api.servcookie -> app.schemas.servcookie",
"app.application.agentdata -> app.application",
"app.application.agentdata -> app.application.security",
"app.application.agentdata -> app.application.security.user",
"app.application.agenttask -> app.application",
"app.application.agenttask -> app.application.database",
"app.application.agenttask -> app.runtime",
@@ -3987,6 +3990,8 @@
"app.application.chain.data -> app.application.download",
"app.application.chain.data -> app.application.download.failures",
"app.application.chain.data -> app.application.mediaserver",
"app.application.chain.data -> app.application.security",
"app.application.chain.data -> app.application.security.user",
"app.application.chain.data -> app.application.transfer",
"app.application.chain.data -> app.application.transfer.execution",
"app.application.chain.data -> app.application.transfer.workflow",
@@ -4300,12 +4305,14 @@
"app.application.security.auth -> app.application.configuration",
"app.application.security.auth -> app.application.security",
"app.application.security.auth -> app.application.security.token",
"app.application.security.auth -> app.application.security.user",
"app.application.security.auth -> app.application.site",
"app.application.security.auth -> app.foundation",
"app.application.security.auth -> app.foundation.singleton",
"app.application.security.auth -> app.schemas",
"app.application.security.auth -> app.schemas.token",
"app.application.security.auth -> app.schemas.types",
"app.application.security.auth -> app.schemas.user",
"app.application.security.cookie -> app.adapters",
"app.application.security.cookie -> app.adapters.external",
"app.application.security.cookie -> app.adapters.external.ocr",
@@ -5062,6 +5069,7 @@
"app.chain.user -> app.application.security",
"app.chain.user -> app.application.security.otp",
"app.chain.user -> app.application.security.token",
"app.chain.user -> app.application.security.user",
"app.chain.user -> app.chain",
"app.chain.user -> app.runtime",
"app.chain.user -> app.runtime.log",
@@ -5226,6 +5234,15 @@
"app.db.adapters.transfer.execution -> app.db.oper.transferexecutionstep",
"app.db.adapters.transfer.execution -> app.db.oper.transferpending",
"app.db.adapters.transfer.execution -> app.db.uow",
"app.db.adapters.user -> app.application",
"app.db.adapters.user -> app.application.security",
"app.db.adapters.user -> app.application.security.user",
"app.db.adapters.user -> app.db",
"app.db.adapters.user -> app.db.models",
"app.db.adapters.user -> app.db.models.user",
"app.db.adapters.user -> app.db.oper",
"app.db.adapters.user -> app.db.oper.user",
"app.db.adapters.user -> app.db.uow",
"app.db.adapters.workflow -> app.application",
"app.db.adapters.workflow -> app.application.workflow",
"app.db.adapters.workflow -> app.db",
@@ -8048,6 +8065,7 @@
"app.startup.initializers.modules -> app.db.adapters.transfer",
"app.startup.initializers.modules -> app.db.adapters.transfer.admission",
"app.startup.initializers.modules -> app.db.adapters.transfer.execution",
"app.startup.initializers.modules -> app.db.adapters.user",
"app.startup.initializers.modules -> app.db.adapters.workflow",
"app.startup.initializers.modules -> app.db.oper",
"app.startup.initializers.modules -> app.db.oper.agentchat",
@@ -8062,7 +8080,6 @@
"app.startup.initializers.modules -> app.db.oper.subscribehistory",
"app.startup.initializers.modules -> app.db.oper.systemconfig",
"app.startup.initializers.modules -> app.db.oper.transferhistory",
"app.startup.initializers.modules -> app.db.oper.user",
"app.startup.initializers.modules -> app.db.oper.userconfig",
"app.startup.initializers.modules -> app.db.oper.workflow",
"app.startup.initializers.modules -> app.db.session",
@@ -8407,7 +8424,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 852,
"module_count": 853,
"modules": [
"app",
"app.adapters",
@@ -8813,6 +8830,7 @@
"app.db.adapters.transfer",
"app.db.adapters.transfer.admission",
"app.db.adapters.transfer.execution",
"app.db.adapters.user",
"app.db.adapters.workflow",
"app.db.base",
"app.db.decorators",
+3 -8
View File
@@ -1258,8 +1258,7 @@
},
"app/application/security/auth.py": {
"no-untyped-call": 2,
"no-untyped-def": 1,
"type-arg": 1
"no-untyped-def": 1
},
"app/application/security/cookie.py": {
"arg-type": 3,
@@ -1582,10 +1581,6 @@
"union-attr": 31,
"var-annotated": 4
},
"app/chain/user.py": {
"arg-type": 3,
"index": 1
},
"app/chain/workflow.py": {
"arg-type": 1,
"assignment": 1,
@@ -2078,7 +2073,7 @@
"assignment": 1,
"call-overload": 1,
"method-assign": 2,
"no-untyped-def": 4,
"no-untyped-def": 3,
"return-value": 1,
"type-arg": 25,
"union-attr": 8
@@ -3399,7 +3394,7 @@
"no-untyped-def": 2
},
"app/startup/initializers/modules.py": {
"arg-type": 17,
"arg-type": 16,
"assignment": 1,
"attr-defined": 2,
"misc": 1,
-9
View File
@@ -128,9 +128,6 @@
"app/agent/tools/impl/add_download_tasks.py": {
"I001": 1
},
"app/agent/tools/impl/add_subscribe.py": {
"I001": 1
},
"app/agent/tools/impl/ask_user_choice.py": {
"I001": 1
},
@@ -339,9 +336,6 @@
"app/application/rss.py": {
"I001": 1
},
"app/application/security/auth.py": {
"I001": 1
},
"app/application/security/url.py": {
"I001": 1
},
@@ -615,9 +609,6 @@
"F401": 1,
"I001": 1
},
"app/modules/feishu/feishu.py": {
"I001": 1
},
"app/modules/filemanager/__init__.py": {
"I001": 1
},
+7 -3
View File
@@ -31,7 +31,7 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
) as async_add, patch(
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
return_value=SimpleNamespace(
get_name=lambda **_kwargs: "moviepilot-user"
find_name_by_bindings=lambda _bindings: "moviepilot-user"
),
):
result = asyncio.run(
@@ -59,7 +59,9 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
new=AsyncMock(return_value=(1, "")),
) as async_add, patch(
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
return_value=SimpleNamespace(get_name=lambda **_kwargs: None),
return_value=SimpleNamespace(
find_name_by_bindings=lambda _bindings: None
),
):
result = asyncio.run(
tool.run(
@@ -85,7 +87,9 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
new=AsyncMock(return_value=(1, "")),
) as async_add, patch(
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
return_value=SimpleNamespace(get_name=lambda **_kwargs: None),
return_value=SimpleNamespace(
find_name_by_bindings=lambda _bindings: None
),
):
result = asyncio.run(
tool.run(
+8 -4
View File
@@ -408,8 +408,10 @@ def test_discord_message_and_callback_use_stable_user_id(payload):
def test_feishu_message_and_card_callback_accept_open_id_or_user_id(payload, admins):
with patch.object(Feishu, "_build_api_client", return_value=Mock()), patch.object(
Feishu, "_start_ws_client"
), patch("app.modules.feishu.feishu.UserOper") as user_oper:
user_oper.return_value.get_name.return_value = None
), patch(
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
return_value=lambda **_bindings: None,
):
client = Feishu(
FEISHU_APP_ID="app-id",
FEISHU_APP_SECRET="app-secret",
@@ -426,8 +428,10 @@ def test_feishu_default_open_id_is_admin_without_duplicate_admin_entry():
"""飞书默认用户 Open ID 无需重复加入管理员名单。"""
with patch.object(Feishu, "_build_api_client", return_value=Mock()), patch.object(
Feishu, "_start_ws_client"
), patch("app.modules.feishu.feishu.UserOper") as user_oper:
user_oper.return_value.get_name.return_value = None
), patch(
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
return_value=lambda **_bindings: None,
):
client = Feishu(
FEISHU_APP_ID="app-id",
FEISHU_APP_SECRET="app-secret",
+61
View File
@@ -722,6 +722,67 @@ def test_user_and_messaging_chains_use_explicit_data_port_getters():
assert violations == []
def test_user_chain_and_agent_ports_are_typed_and_orm_free():
"""用户 Chain、Agent 与宿主模块只能消费 Application 用户端口。"""
chain_data = ast.parse(
(APP_ROOT / "application" / "chain" / "data.py").read_text(
encoding="utf-8-sig"
)
)
agent_data = ast.parse(
(APP_ROOT / "application" / "agentdata.py").read_text(
encoding="utf-8-sig"
)
)
def return_annotation(tree: ast.AST, function_name: str) -> str | None:
"""返回指定函数的源码级返回注解。"""
function = next(
node
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == function_name
)
return ast.unparse(function.returns) if function.returns else None
assert return_annotation(chain_data, "get_chain_user_port") == "ChainUserRepository"
assert return_annotation(agent_data, "get_agent_user_port") == "ChainUserRepository"
assert not any(
isinstance(node, ast.ClassDef) and node.name == "UserPort"
for node in ast.walk(agent_data)
)
production_paths = [
APP_ROOT / "chain" / "user.py",
APP_ROOT / "chain" / "interaction.py",
APP_ROOT / "chain" / "_messaging.py",
APP_ROOT / "agent" / "orchestrator.py",
APP_ROOT / "agent" / "tools" / "impl" / "add_subscribe.py",
APP_ROOT / "modules" / "feishu" / "feishu.py",
]
violations: list[str] = []
for path in production_paths:
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == "UserOper":
violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:class")
if isinstance(node, ast.ImportFrom) and node.module == "app.db.oper.user":
violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:import")
assert violations == []
def test_startup_injects_user_adapter_instead_of_raw_oper():
"""启动组合根不得把无会话 UserOper 注入宿主查询调用面。"""
path = APP_ROOT / "startup" / "initializers" / "modules.py"
source = path.read_text(encoding="utf-8-sig")
assert "TransactionalUserRepository" in source
assert "SqlAlchemyUserRepository" in source
assert "from app.db.oper.user import UserOper" not in source
assert "user=lambda: UserOper()" not in source
def test_music_chain_uses_explicit_subscribe_data_port_getter():
"""音乐订阅链不得把 SubscribePortProxy 伪装成 SubscribeOper。"""
path = APP_ROOT / "chain" / "_music.py"
+16 -6
View File
@@ -140,7 +140,10 @@ class TestFeishu(unittest.TestCase):
def test_parse_message_returns_callback_message(self):
client = self._build_client()
with patch("app.modules.feishu.feishu.UserOper.get_name", return_value=None):
with patch(
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
return_value=lambda **_bindings: None,
):
result = client.parse_message(
{
"type": "cardAction",
@@ -221,7 +224,10 @@ class TestFeishu(unittest.TestCase):
client = self._build_client(FEISHU_ADMINS="ou_admin")
with (
patch("app.modules.feishu.feishu.UserOper.get_name", return_value=None),
patch(
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
return_value=lambda **_bindings: None,
),
patch.object(
client, "send_text", return_value={"success": True}
) as send_text,
@@ -250,10 +256,11 @@ class TestFeishu(unittest.TestCase):
def test_parse_message_maps_feishu_ids_to_moviepilot_username(self):
client = self._build_client()
get_name = MagicMock(return_value="moviepilot-user")
with patch(
"app.modules.feishu.feishu.UserOper.get_name",
return_value="moviepilot-user",
) as get_name:
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
return_value=get_name,
):
result = client.parse_message(
{
"type": "message",
@@ -901,7 +908,10 @@ class TestFeishu(unittest.TestCase):
def test_parse_message_supports_image_and_file_payloads(self):
client = self._build_client()
with patch("app.modules.feishu.feishu.UserOper.get_name", return_value=None):
with patch(
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
return_value=lambda **_bindings: None,
):
image_message = client.parse_message(
{
"type": "message",
+165
View File
@@ -0,0 +1,165 @@
"""用户冻结快照与短事务适配器测试。"""
from contextlib import asynccontextmanager
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.application.security.user import AuxiliaryUserCreate
from app.db.adapters.user import TransactionalUserRepository
from app.db.models.user import User
from app.db.uow import SqlAlchemyUnitOfWork
@pytest.fixture
def user_repository(tmp_path):
"""构造同步和异步共享同一 SQLite 文件的用户仓储。"""
database_path = tmp_path / "users.db"
sync_engine = create_engine(f"sqlite:///{database_path}")
User.__table__.create(sync_engine)
sync_factory = sessionmaker(bind=sync_engine)
@asynccontextmanager
async def async_session():
"""生成一个测试独占的异步会话。"""
async_engine = create_async_engine(f"sqlite+aiosqlite:///{database_path}")
async_factory = async_sessionmaker(bind=async_engine)
try:
async with async_factory() as session:
yield session
finally:
await async_engine.dispose()
repository = TransactionalUserRepository(
sync_session=sync_factory,
async_session=async_session,
)
yield repository, sync_factory
sync_engine.dispose()
def _insert_user(sync_factory, **overrides) -> int:
"""直接写入测试用户并返回主键。"""
values = {
"name": "alice",
"email": "alice@example.com",
"hashed_password": "hash",
"is_active": True,
"is_superuser": True,
"avatar": "avatar",
"is_otp": True,
"otp_secret": "secret",
"permissions": {"features": {"search": True}},
"settings": {"telegram_userid": "42", "targets": ["telegram"]},
}
values.update(overrides)
with sync_factory() as session:
user = User(**values)
session.add(user)
session.commit()
return user.id
@pytest.mark.asyncio
async def test_user_snapshots_are_detached_and_deeply_frozen(user_repository) -> None:
"""会话关闭后公开与认证快照仍可读,嵌套 JSON 不可被调用方修改。"""
repository, sync_factory = user_repository
user_id = _insert_user(sync_factory)
public = repository.get_by_id(user_id)
auth = repository.get_auth_by_name("alice")
async_public = await repository.async_get_by_name("alice")
assert public is not None
assert auth is not None
assert async_public == public
assert auth.user == public
assert auth.hashed_password == "hash"
assert auth.otp_secret == "secret"
assert public.settings["targets"] == ("telegram",)
with pytest.raises(TypeError):
public.settings["telegram_userid"] = "changed" # type: ignore[index]
with pytest.raises(TypeError):
public.permissions["features"]["search"] = False # type: ignore[index]
def test_auxiliary_create_commits_before_return(user_repository) -> None:
"""辅助认证创建成功返回时,新用户必须已对后续独立会话可见。"""
repository, sync_factory = user_repository
created = repository.create_auxiliary(AuxiliaryUserCreate(
name="created",
hashed_password="hash",
))
assert created.name == "created"
with sync_factory() as session:
persisted = session.execute(
select(User).where(User.name == "created")
).scalar_one()
assert persisted.is_active is True
assert persisted.is_superuser is False
def test_auxiliary_create_rolls_back_commit_failure(
user_repository,
monkeypatch,
) -> None:
"""提交异常不得留下仅 flush 成功的辅助认证用户。"""
repository, sync_factory = user_repository
def fail_commit(_unit_of_work) -> None:
"""模拟数据库提交阶段失败。"""
raise RuntimeError("commit failed")
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
repository.create_auxiliary(AuxiliaryUserCreate(
name="rolled-back",
hashed_password="hash",
))
with sync_factory() as session:
assert session.execute(
select(User).where(User.name == "rolled-back")
).scalar_one_or_none() is None
def test_channel_binding_requires_one_active_unambiguous_owner(user_repository) -> None:
"""停用用户与重复渠道绑定都必须拒绝用户归属。"""
repository, sync_factory = user_repository
_insert_user(sync_factory, name="active", settings={"telegram_userid": "42"})
_insert_user(
sync_factory,
name="disabled",
is_active=False,
settings={"telegram_userid": "77"},
)
assert repository.find_name_by_bindings({"telegram_userid": 42}) == "active"
assert repository.find_name_by_bindings({"telegram_userid": 77}) is None
_insert_user(sync_factory, name="conflict", settings={"telegram_userid": "42"})
assert repository.find_name_by_bindings({"telegram_userid": 42}) is None
def test_channel_binding_requires_all_supplied_identifiers(user_repository) -> None:
"""多标识渠道只有全部标识指向同一用户时才允许归属。"""
repository, sync_factory = user_repository
_insert_user(
sync_factory,
name="feishu-user",
settings={"feishu_userid": "u-1", "feishu_openid": "o-1"},
)
assert repository.find_name_by_bindings({
"feishu_userid": "u-1",
"feishu_openid": "o-1",
}) == "feishu-user"
assert repository.find_name_by_bindings({
"feishu_userid": "u-1",
"feishu_openid": "wrong",
}) is None