mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor(user): freeze host user query ports
This commit is contained in:
@@ -6,11 +6,11 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.agent.tools.base import MoviePilotTool
|
from app.agent.tools.base import MoviePilotTool
|
||||||
from app.agent.tools.tags import ToolTag
|
from app.agent.tools.tags import ToolTag
|
||||||
from app.chain.subscribe import SubscribeChain
|
|
||||||
from app.application.agentdata import get_agent_user_port
|
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.runtime.log import logger
|
||||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel
|
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel
|
||||||
from app.domain.media import normalize_music_type
|
|
||||||
|
|
||||||
|
|
||||||
class AddSubscribeInput(BaseModel):
|
class AddSubscribeInput(BaseModel):
|
||||||
@@ -154,8 +154,8 @@ class AddSubscribeTool(MoviePilotTool):
|
|||||||
|
|
||||||
mapped_username = await self.run_blocking(
|
mapped_username = await self.run_blocking(
|
||||||
"db",
|
"db",
|
||||||
get_agent_user_port().get_name,
|
get_agent_user_port().find_name_by_bindings,
|
||||||
**{key: self._user_id for key in binding_keys},
|
{key: self._user_id for key in binding_keys},
|
||||||
)
|
)
|
||||||
return mapped_username or resolved_username
|
return mapped_username or resolved_username
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from app.application.security.user import ChainUserRepository
|
||||||
|
|
||||||
AgentDataFactory = Callable[[], Any]
|
AgentDataFactory = Callable[[], Any]
|
||||||
|
|
||||||
@@ -39,12 +41,6 @@ class AgentTaskPort(_PortProxy):
|
|||||||
port_name = "agent_task"
|
port_name = "agent_task"
|
||||||
|
|
||||||
|
|
||||||
class UserPort(_PortProxy):
|
|
||||||
"""用户数据端口代理。"""
|
|
||||||
|
|
||||||
port_name = "user"
|
|
||||||
|
|
||||||
|
|
||||||
class SitePort(_PortProxy):
|
class SitePort(_PortProxy):
|
||||||
"""站点数据端口代理。"""
|
"""站点数据端口代理。"""
|
||||||
|
|
||||||
@@ -129,9 +125,9 @@ def get_agent_task_port() -> Any:
|
|||||||
return get_agent_data_ports().agent_task()
|
return get_agent_data_ports().agent_task()
|
||||||
|
|
||||||
|
|
||||||
def get_agent_user_port() -> Any:
|
def get_agent_user_port() -> ChainUserRepository:
|
||||||
"""创建 Agent 用户数据端口实例。"""
|
"""创建 Agent 用户数据端口实例。"""
|
||||||
return get_agent_data_ports().user()
|
return cast(ChainUserRepository, get_agent_data_ports().user())
|
||||||
|
|
||||||
|
|
||||||
def get_agent_site_port() -> Any:
|
def get_agent_site_port() -> Any:
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from app.application.download.failures import DownloadFailureRepository
|
from app.application.download.failures import DownloadFailureRepository
|
||||||
from app.application.mediaserver import MediaServerRepository
|
from app.application.mediaserver import MediaServerRepository
|
||||||
|
from app.application.security.user import ChainUserRepository
|
||||||
from app.application.transfer.execution import TransferExecutionRepository
|
from app.application.transfer.execution import TransferExecutionRepository
|
||||||
from app.application.transfer.workflow import TransferAdmissionRepository
|
from app.application.transfer.workflow import TransferAdmissionRepository
|
||||||
|
|
||||||
OperFactory = Callable[[], Any]
|
OperFactory = Callable[[], Any]
|
||||||
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
|
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
|
||||||
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
|
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
|
||||||
|
ChainUserRepositoryFactory = Callable[[], ChainUserRepository]
|
||||||
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
|
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
|
||||||
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
|
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ class ChainDataPorts:
|
|||||||
transfer_execution: TransferExecutionRepositoryFactory
|
transfer_execution: TransferExecutionRepositoryFactory
|
||||||
media_server: MediaServerRepositoryFactory
|
media_server: MediaServerRepositoryFactory
|
||||||
download_failure: DownloadFailureRepositoryFactory
|
download_failure: DownloadFailureRepositoryFactory
|
||||||
user: OperFactory
|
user: ChainUserRepositoryFactory
|
||||||
|
|
||||||
|
|
||||||
_ports: Optional[ChainDataPorts] = None
|
_ports: Optional[ChainDataPorts] = None
|
||||||
@@ -50,7 +52,7 @@ def configure_chain_data_ports(
|
|||||||
transfer_execution: TransferExecutionRepositoryFactory,
|
transfer_execution: TransferExecutionRepositoryFactory,
|
||||||
media_server: MediaServerRepositoryFactory,
|
media_server: MediaServerRepositoryFactory,
|
||||||
download_failure: DownloadFailureRepositoryFactory,
|
download_failure: DownloadFailureRepositoryFactory,
|
||||||
user: OperFactory,
|
user: ChainUserRepositoryFactory,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""由启动组合根登记显式命名的 Chain 数据端口实现。"""
|
"""由启动组合根登记显式命名的 Chain 数据端口实现。"""
|
||||||
global _ports
|
global _ports
|
||||||
@@ -114,6 +116,6 @@ def get_chain_download_failure_port() -> DownloadFailureRepository:
|
|||||||
return get_chain_data_ports().download_failure()
|
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()
|
return get_chain_data_ports().user()
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
from datetime import timedelta
|
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 Token as _SchemaToken
|
||||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
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.schemas.types import SystemConfigKey
|
||||||
from app.foundation.singleton import Singleton
|
from app.schemas.user import UserPermissions
|
||||||
|
|
||||||
|
|
||||||
class AuthTicketStore(metaclass=Singleton):
|
class AuthTicketStore(metaclass=Singleton):
|
||||||
@@ -117,12 +120,29 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]:
|
|||||||
class AuthUser(Protocol):
|
class AuthUser(Protocol):
|
||||||
"""认证服务需要的最小用户投影。"""
|
"""认证服务需要的最小用户投影。"""
|
||||||
|
|
||||||
id: int
|
@property
|
||||||
name: str
|
def id(self) -> int:
|
||||||
is_active: bool
|
"""返回用户 ID。"""
|
||||||
is_superuser: bool
|
|
||||||
avatar: Optional[str]
|
@property
|
||||||
permissions: Optional[dict]
|
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):
|
class AuthUserRepository(Protocol):
|
||||||
@@ -206,7 +226,7 @@ class AuthService:
|
|||||||
user_name=user.name,
|
user_name=user.name,
|
||||||
avatar=user.avatar,
|
avatar=user.avatar,
|
||||||
level=level,
|
level=level,
|
||||||
permissions=user.permissions or {},
|
permissions=cast(UserPermissions, dict(user.permissions)),
|
||||||
wizard=show_wizard,
|
wizard=show_wizard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,26 +4,176 @@
|
|||||||
避免 API 层同时承担 HTTP 编排和 ORM 适配职责。
|
避免 API 层同时承担 HTTP 编排和 ORM 适配职责。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
from typing import Any, Protocol
|
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):
|
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 返回用户。"""
|
"""按用户 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:
|
async def async_delete(self, user_id: int) -> None:
|
||||||
@@ -55,23 +205,27 @@ class UserService:
|
|||||||
self._repository = repository
|
self._repository = repository
|
||||||
self._unit_of_work = unit_of_work
|
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()
|
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)
|
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 查询用户。"""
|
"""按用户 ID 查询用户。"""
|
||||||
return await self._repository.async_get_by_id(user_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))
|
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(
|
return await self._write(
|
||||||
lambda: self._repository.async_update(user_id, payload)
|
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)
|
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:
|
try:
|
||||||
result = await operation()
|
result = await operation()
|
||||||
@@ -100,14 +254,14 @@ class UserService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
_configured_user_id_lookup: Callable[[int], Any | None] | None = None
|
_configured_user_id_lookup: Callable[[int], UserSnapshot | None] | None = None
|
||||||
_configured_user_name_lookup: Callable[[str], Any | None] | None = None
|
_configured_user_name_lookup: Callable[[str], UserSnapshot | None] | None = None
|
||||||
_configured_user_channel_lookup: Callable[..., str | None] | None = None
|
_configured_user_channel_lookup: Callable[..., str | None] | None = None
|
||||||
|
|
||||||
|
|
||||||
def configure_user_lookups(
|
def configure_user_lookups(
|
||||||
by_id: Callable[[int], Any | None],
|
by_id: Callable[[int], UserSnapshot | None],
|
||||||
by_name: Callable[[str], Any | None],
|
by_name: Callable[[str], UserSnapshot | None],
|
||||||
by_channel: Callable[..., str | None],
|
by_channel: Callable[..., str | None],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
|
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
|
||||||
@@ -118,14 +272,14 @@ def configure_user_lookups(
|
|||||||
_configured_user_channel_lookup = by_channel
|
_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 用户查询函数。"""
|
"""返回启动阶段登记的按 ID 用户查询函数。"""
|
||||||
if _configured_user_id_lookup is None:
|
if _configured_user_id_lookup is None:
|
||||||
raise RuntimeError("按 ID 的用户查询能力尚未配置")
|
raise RuntimeError("按 ID 的用户查询能力尚未配置")
|
||||||
return _configured_user_id_lookup
|
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:
|
if _configured_user_name_lookup is None:
|
||||||
raise RuntimeError("按用户名的用户查询能力尚未配置")
|
raise RuntimeError("按用户名的用户查询能力尚未配置")
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class NotificationMixin:
|
|||||||
# 仅发送管理员
|
# 仅发送管理员
|
||||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||||
# 读取管理员消息IDS
|
# 读取管理员消息IDS
|
||||||
send_message.targets = useroper.get_settings(
|
send_message.targets = useroper.get_notification_settings(
|
||||||
self.runtime_config.superuser
|
self.runtime_config.superuser
|
||||||
)
|
)
|
||||||
admin_sended = True
|
admin_sended = True
|
||||||
@@ -187,7 +187,7 @@ class NotificationMixin:
|
|||||||
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
|
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
|
||||||
)
|
)
|
||||||
# 读取用户消息IDS
|
# 读取用户消息IDS
|
||||||
send_message.targets = useroper.get_settings(
|
send_message.targets = useroper.get_notification_settings(
|
||||||
send_message.username
|
send_message.username
|
||||||
)
|
)
|
||||||
if send_message.targets is None:
|
if send_message.targets is None:
|
||||||
@@ -198,7 +198,7 @@ class NotificationMixin:
|
|||||||
f"用户 {send_message.username} 不存在,消息将发送给管理员"
|
f"用户 {send_message.username} 不存在,消息将发送给管理员"
|
||||||
)
|
)
|
||||||
# 读取管理员消息IDS
|
# 读取管理员消息IDS
|
||||||
send_message.targets = useroper.get_settings(
|
send_message.targets = useroper.get_notification_settings(
|
||||||
self.runtime_config.superuser
|
self.runtime_config.superuser
|
||||||
)
|
)
|
||||||
admin_sended = True
|
admin_sended = True
|
||||||
@@ -295,7 +295,7 @@ class NotificationMixin:
|
|||||||
# 仅发送管理员
|
# 仅发送管理员
|
||||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||||
# 读取管理员消息IDS
|
# 读取管理员消息IDS
|
||||||
send_message.targets = useroper.get_settings(
|
send_message.targets = await useroper.async_get_notification_settings(
|
||||||
self.runtime_config.superuser
|
self.runtime_config.superuser
|
||||||
)
|
)
|
||||||
admin_sended = True
|
admin_sended = True
|
||||||
@@ -305,7 +305,7 @@ class NotificationMixin:
|
|||||||
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
|
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
|
||||||
)
|
)
|
||||||
# 读取用户消息IDS
|
# 读取用户消息IDS
|
||||||
send_message.targets = useroper.get_settings(
|
send_message.targets = await useroper.async_get_notification_settings(
|
||||||
send_message.username
|
send_message.username
|
||||||
)
|
)
|
||||||
if send_message.targets is None:
|
if send_message.targets is None:
|
||||||
@@ -316,7 +316,7 @@ class NotificationMixin:
|
|||||||
f"用户 {send_message.username} 不存在,消息将发送给管理员"
|
f"用户 {send_message.username} 不存在,消息将发送给管理员"
|
||||||
)
|
)
|
||||||
# 读取管理员消息IDS
|
# 读取管理员消息IDS
|
||||||
send_message.targets = useroper.get_settings(
|
send_message.targets = await useroper.async_get_notification_settings(
|
||||||
self.runtime_config.superuser
|
self.runtime_config.superuser
|
||||||
)
|
)
|
||||||
admin_sended = True
|
admin_sended = True
|
||||||
|
|||||||
@@ -669,8 +669,8 @@ class MediaInteractionChain(ChainBase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
mp_name = (
|
mp_name = (
|
||||||
get_chain_user_port().get_name(
|
get_chain_user_port().find_name_by_bindings(
|
||||||
**{f"{channel.name.lower()}_userid": userid}
|
{f"{channel.name.lower()}_userid": userid}
|
||||||
)
|
)
|
||||||
if channel
|
if channel
|
||||||
else None
|
else None
|
||||||
@@ -982,8 +982,8 @@ class MediaInteractionChain(ChainBase):
|
|||||||
note = None
|
note = None
|
||||||
|
|
||||||
mp_name = (
|
mp_name = (
|
||||||
get_chain_user_port().get_name(
|
get_chain_user_port().find_name_by_bindings(
|
||||||
**{f"{channel.name.lower()}_userid": userid}
|
{f"{channel.name.lower()}_userid": userid}
|
||||||
)
|
)
|
||||||
if channel
|
if channel
|
||||||
else None
|
else None
|
||||||
|
|||||||
+41
-16
@@ -1,17 +1,17 @@
|
|||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
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.chain.data import get_chain_user_port
|
||||||
from app.application.security.otp import OtpUtils
|
from app.application.security.otp import OtpUtils
|
||||||
from app.application.security.token import get_password_hash, verify_password
|
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.chain import ChainBase
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
|
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
|
||||||
from app.schemas.types import ChainEventType
|
from app.schemas.types import ChainEventType
|
||||||
|
|
||||||
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名、密码或验证码错误"
|
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名、密码或验证码错误"
|
||||||
User = Any
|
|
||||||
|
|
||||||
|
|
||||||
MfaMethod = Literal["otp"]
|
MfaMethod = Literal["otp"]
|
||||||
@@ -36,7 +36,7 @@ class UserChain(ChainBase):
|
|||||||
mfa_code: Optional[str] = None,
|
mfa_code: Optional[str] = None,
|
||||||
code: Optional[str] = None,
|
code: Optional[str] = None,
|
||||||
grant_type: Optional[str] = "password"
|
grant_type: Optional[str] = "password"
|
||||||
) -> Tuple[bool, Union[str, User, MfaRequired, None]]:
|
) -> Tuple[bool, Union[str, UserAuthSnapshot, MfaRequired, None]]:
|
||||||
"""
|
"""
|
||||||
认证用户,根据不同的 grant_type 处理不同的认证流程
|
认证用户,根据不同的 grant_type 处理不同的认证流程
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class UserChain(ChainBase):
|
|||||||
if credentials.grant_type == "password":
|
if credentials.grant_type == "password":
|
||||||
# Password 认证
|
# Password 认证
|
||||||
success, user_or_message = self.password_authenticate(credentials=credentials)
|
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)
|
mfa_result = self._verify_mfa(user_or_message, credentials.mfa_code)
|
||||||
if isinstance(mfa_result, MfaRequired):
|
if isinstance(mfa_result, MfaRequired):
|
||||||
@@ -74,7 +74,10 @@ class UserChain(ChainBase):
|
|||||||
if self.runtime_config.auxiliary_auth_enable:
|
if self.runtime_config.auxiliary_auth_enable:
|
||||||
logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...")
|
logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...")
|
||||||
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
|
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 位验证码
|
# 辅助认证成功后再验证 6 位验证码
|
||||||
mfa_result = self._verify_mfa(aux_user_or_message, credentials.mfa_code)
|
mfa_result = self._verify_mfa(aux_user_or_message, credentials.mfa_code)
|
||||||
if isinstance(mfa_result, MfaRequired):
|
if isinstance(mfa_result, MfaRequired):
|
||||||
@@ -102,7 +105,9 @@ class UserChain(ChainBase):
|
|||||||
return False, "不支持的认证类型"
|
return False, "不支持的认证类型"
|
||||||
|
|
||||||
@staticmethod
|
@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":
|
if not credentials or credentials.grant_type != "password":
|
||||||
logger.info("密码认证失败,认证类型不匹配")
|
logger.info("密码认证失败,认证类型不匹配")
|
||||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
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:
|
if not user:
|
||||||
logger.info(f"密码认证失败,用户 {credentials.username} 不存在")
|
logger.info(f"密码认证失败,用户 {credentials.username} 不存在")
|
||||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
@@ -130,7 +138,10 @@ class UserChain(ChainBase):
|
|||||||
|
|
||||||
return True, user
|
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()
|
useroper = get_chain_user_port()
|
||||||
if credentials.username:
|
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:
|
if user and not user.is_active:
|
||||||
logger.info(f"用户 {user.name} 已被禁用,跳过后续身份校验")
|
logger.info(f"用户 {user.name} 已被禁用,跳过后续身份校验")
|
||||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
@@ -166,16 +177,28 @@ class UserChain(ChainBase):
|
|||||||
credentials = result # 使用模块认证返回的认证数据
|
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:
|
if success:
|
||||||
logger.info(f"用户 {credentials.username} 辅助认证通过")
|
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:
|
else:
|
||||||
logger.warning(f"用户 {credentials.username} 辅助认证未通过")
|
logger.warning(f"用户 {credentials.username} 辅助认证未通过")
|
||||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
|
||||||
@staticmethod
|
@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 位验证码。
|
验证密码登录后的 6 位验证码。
|
||||||
|
|
||||||
@@ -213,7 +236,7 @@ class UserChain(ChainBase):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
token, channel, service = credentials.token, credentials.channel, credentials.service
|
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} 认证,必要信息不足")
|
logger.info(f"用户 {username} 未通过 {credentials.grant_type} 认证,必要信息不足")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -232,7 +255,7 @@ class UserChain(ChainBase):
|
|||||||
|
|
||||||
# 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户
|
# 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户
|
||||||
useroper = get_chain_user_port()
|
useroper = get_chain_user_port()
|
||||||
user = useroper.get_by_name(name=username)
|
user = useroper.get_auth_by_name(name=username)
|
||||||
if user:
|
if user:
|
||||||
# 如果用户存在,但是已经被禁用,则直接响应
|
# 如果用户存在,但是已经被禁用,则直接响应
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
@@ -245,8 +268,10 @@ class UserChain(ChainBase):
|
|||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
if credentials.grant_type == "password":
|
if credentials.grant_type == "password":
|
||||||
useroper.add(name=username, is_active=True, is_superuser=False,
|
useroper.create_auxiliary(AuxiliaryUserCreate(
|
||||||
hashed_password=get_password_hash(secrets.token_urlsafe(16)))
|
name=username,
|
||||||
|
hashed_password=get_password_hash(secrets.token_urlsafe(16)),
|
||||||
|
))
|
||||||
logger.info(f"用户 {username} 不存在,已通过 {credentials.grant_type} 认证并已创建普通用户")
|
logger.info(f"用户 {username} 不存在,已通过 {credentials.grant_type} 认证并已创建普通用户")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,25 +23,25 @@ from lark_oapi.api.im.v1 import (
|
|||||||
CreateFileRequestBody,
|
CreateFileRequestBody,
|
||||||
CreateImageRequest,
|
CreateImageRequest,
|
||||||
CreateImageRequestBody,
|
CreateImageRequestBody,
|
||||||
CreateMessageRequest,
|
|
||||||
CreateMessageRequestBody,
|
|
||||||
CreateMessageReactionRequest,
|
CreateMessageReactionRequest,
|
||||||
CreateMessageReactionRequestBody,
|
CreateMessageReactionRequestBody,
|
||||||
|
CreateMessageRequest,
|
||||||
|
CreateMessageRequestBody,
|
||||||
DeleteMessageReactionRequest,
|
DeleteMessageReactionRequest,
|
||||||
|
Emoji,
|
||||||
GetFileRequest,
|
GetFileRequest,
|
||||||
GetImageRequest,
|
GetImageRequest,
|
||||||
GetMessageResourceRequest,
|
GetMessageResourceRequest,
|
||||||
PatchMessageRequest,
|
|
||||||
PatchMessageRequestBody,
|
|
||||||
P2ImChatAccessEventBotP2pChatEnteredV1,
|
P2ImChatAccessEventBotP2pChatEnteredV1,
|
||||||
P2ImMessageMessageReadV1,
|
P2ImMessageMessageReadV1,
|
||||||
P2ImMessageReactionCreatedV1,
|
P2ImMessageReactionCreatedV1,
|
||||||
P2ImMessageReactionDeletedV1,
|
P2ImMessageReactionDeletedV1,
|
||||||
P2ImMessageRecalledV1,
|
P2ImMessageRecalledV1,
|
||||||
P2ImMessageReceiveV1,
|
P2ImMessageReceiveV1,
|
||||||
|
PatchMessageRequest,
|
||||||
|
PatchMessageRequestBody,
|
||||||
ReplyMessageRequest,
|
ReplyMessageRequest,
|
||||||
ReplyMessageRequestBody,
|
ReplyMessageRequestBody,
|
||||||
Emoji,
|
|
||||||
)
|
)
|
||||||
from lark_oapi.core.const import FEISHU_DOMAIN
|
from lark_oapi.core.const import FEISHU_DOMAIN
|
||||||
from lark_oapi.core.enum import LogLevel
|
from lark_oapi.core.enum import LogLevel
|
||||||
@@ -50,18 +50,16 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
|||||||
P2CardActionTriggerResponse,
|
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.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.runtime.thread import ThreadHelper
|
||||||
|
from app.schemas.message import IncomingMessage, Message
|
||||||
|
from app.schemas.types import MessageType, NotificationChannel
|
||||||
|
|
||||||
|
|
||||||
class _ThreadLocalEventLoopProxy:
|
class _ThreadLocalEventLoopProxy:
|
||||||
@@ -115,15 +113,6 @@ lark_ws_client_module.loop = _lark_ws_loop_proxy
|
|||||||
lark_ws_client_module._select = _select_bound_ws_client
|
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:
|
class Feishu:
|
||||||
"""飞书通知客户端,负责长连接收消息与主动发送通知。"""
|
"""飞书通知客户端,负责长连接收消息与主动发送通知。"""
|
||||||
|
|
||||||
@@ -521,7 +510,7 @@ class Feishu:
|
|||||||
binding_ids["feishu_userid"] = user_id
|
binding_ids["feishu_userid"] = user_id
|
||||||
if binding_ids:
|
if binding_ids:
|
||||||
try:
|
try:
|
||||||
mapped_username = UserOper().get_name(**binding_ids)
|
mapped_username = get_configured_user_channel_lookup()(**binding_ids)
|
||||||
if mapped_username:
|
if mapped_username:
|
||||||
return mapped_username
|
return mapped_username
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRep
|
|||||||
from app.db.adapters.transfer.execution import (
|
from app.db.adapters.transfer.execution import (
|
||||||
TransactionalTransferExecutionRepository,
|
TransactionalTransferExecutionRepository,
|
||||||
)
|
)
|
||||||
|
from app.db.adapters.user import (
|
||||||
|
SqlAlchemyUserRepository,
|
||||||
|
TransactionalUserRepository,
|
||||||
|
)
|
||||||
from app.db.adapters.workflow import (
|
from app.db.adapters.workflow import (
|
||||||
TransactionalWorkflowExecutionService,
|
TransactionalWorkflowExecutionService,
|
||||||
TransactionalWorkflowQueryRepository,
|
TransactionalWorkflowQueryRepository,
|
||||||
@@ -140,7 +144,6 @@ from app.db.oper.subscribe import SubscribeOper
|
|||||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||||
from app.db.oper.systemconfig import SystemConfigOper
|
from app.db.oper.systemconfig import SystemConfigOper
|
||||||
from app.db.oper.transferhistory import TransferHistoryOper
|
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.userconfig import UserConfigOper
|
||||||
from app.db.oper.workflow import WorkflowOper
|
from app.db.oper.workflow import WorkflowOper
|
||||||
from app.db.session import (
|
from app.db.session import (
|
||||||
@@ -247,6 +250,14 @@ def _build_runtime_settings_service() -> RuntimeSettingsService:
|
|||||||
return RuntimeSettingsService(legacy_settings)
|
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):
|
async def _async_get_subscribe(subscribe_id: int):
|
||||||
"""通过数据库操作器异步读取订阅,供服务端共享用例使用。"""
|
"""通过数据库操作器异步读取订阅,供服务端共享用例使用。"""
|
||||||
return await SubscribeOper().async_get(subscribe_id)
|
return await SubscribeOper().async_get(subscribe_id)
|
||||||
@@ -795,13 +806,13 @@ async def init_modules() -> HostRuntime:
|
|||||||
"subscribe": SubscribeOper,
|
"subscribe": SubscribeOper,
|
||||||
"subscribe_history": SubscribeHistoryOper,
|
"subscribe_history": SubscribeHistoryOper,
|
||||||
"transfer_history": TransferHistoryOper,
|
"transfer_history": TransferHistoryOper,
|
||||||
"user": UserOper,
|
"user": SqlAlchemyUserRepository,
|
||||||
"workflow": WorkflowOper,
|
"workflow": WorkflowOper,
|
||||||
},
|
},
|
||||||
standalone={
|
standalone={
|
||||||
"passkey": PassKeyOper,
|
"passkey": PassKeyOper,
|
||||||
"system_config": SystemConfigOper,
|
"system_config": SystemConfigOper,
|
||||||
"user": UserOper,
|
"user": _build_transactional_user_repository,
|
||||||
},
|
},
|
||||||
unit_of_work={
|
unit_of_work={
|
||||||
"async": SqlAlchemyAsyncUnitOfWork,
|
"async": SqlAlchemyAsyncUnitOfWork,
|
||||||
@@ -841,8 +852,8 @@ async def init_modules() -> HostRuntime:
|
|||||||
async_transaction=SqlAlchemyAsyncUnitOfWork,
|
async_transaction=SqlAlchemyAsyncUnitOfWork,
|
||||||
),
|
),
|
||||||
authentication=AuthenticationRuntime(
|
authentication=AuthenticationRuntime(
|
||||||
user_repository=UserOper,
|
user_repository=SqlAlchemyUserRepository,
|
||||||
standalone_user=UserOper,
|
standalone_user=_build_transactional_user_repository,
|
||||||
system_config=SystemConfigOper,
|
system_config=SystemConfigOper,
|
||||||
passkey=PassKeyOper,
|
passkey=PassKeyOper,
|
||||||
),
|
),
|
||||||
@@ -897,7 +908,7 @@ async def init_modules() -> HostRuntime:
|
|||||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||||
SessionFactory
|
SessionFactory
|
||||||
),
|
),
|
||||||
user=lambda: UserOper(),
|
user=_build_transactional_user_repository,
|
||||||
)
|
)
|
||||||
configure_outbox_dispatcher(_build_outbox_dispatcher)
|
configure_outbox_dispatcher(_build_outbox_dispatcher)
|
||||||
configure_transfer_retry_config(
|
configure_transfer_retry_config(
|
||||||
@@ -909,13 +920,15 @@ async def init_modules() -> HostRuntime:
|
|||||||
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
|
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
|
||||||
configure_agent_chat_persistence(agent_chat_persistence)
|
configure_agent_chat_persistence(agent_chat_persistence)
|
||||||
configure_user_lookups(
|
configure_user_lookups(
|
||||||
by_id=lambda user_id: UserOper().get_by_id(user_id),
|
by_id=lambda user_id: _build_transactional_user_repository().get_by_id(user_id),
|
||||||
by_name=lambda username: UserOper().get_by_name(username),
|
by_name=lambda username: _build_transactional_user_repository().get_by_name(username),
|
||||||
by_channel=lambda **bindings: UserOper().get_name(**bindings),
|
by_channel=lambda **bindings: (
|
||||||
|
_build_transactional_user_repository().find_name_by_bindings(bindings)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
configure_auth_service(
|
configure_auth_service(
|
||||||
AuthService(
|
AuthService(
|
||||||
users=UserOper(),
|
users=_build_transactional_user_repository(),
|
||||||
config=get_configured_system_config(),
|
config=get_configured_system_config(),
|
||||||
passkeys=PassKeyOper(),
|
passkeys=PassKeyOper(),
|
||||||
)
|
)
|
||||||
@@ -933,7 +946,7 @@ async def init_modules() -> HostRuntime:
|
|||||||
configure_agent_data_ports(
|
configure_agent_data_ports(
|
||||||
agent_chat=lambda: AgentChatOper(),
|
agent_chat=lambda: AgentChatOper(),
|
||||||
agent_task=lambda: AgentTaskOper(),
|
agent_task=lambda: AgentTaskOper(),
|
||||||
user=lambda: UserOper(),
|
user=_build_transactional_user_repository,
|
||||||
site=lambda: TransactionalSiteRepository(
|
site=lambda: TransactionalSiteRepository(
|
||||||
sync_session=SessionFactory,
|
sync_session=SessionFactory,
|
||||||
async_session=async_session_scope,
|
async_session=async_session_scope,
|
||||||
|
|||||||
@@ -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 移植包环 |
|
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
|
||||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||||
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
|||||||
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
|
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
|
||||||
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
||||||
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
||||||
| Ruff 历史诊断 | 872 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
| Ruff 历史诊断 | 869 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||||
| 覆盖率低水位 | Application 78.95%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
| 覆盖率低水位 | Application 79.02%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||||
|
|
||||||
### 3.3 热点文件
|
### 3.3 热点文件
|
||||||
|
|
||||||
|
|||||||
@@ -704,8 +704,8 @@ flowchart LR
|
|||||||
|
|
||||||
| 指标 | 当前值 |
|
| 指标 | 当前值 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| Python 模块 | 852 |
|
| Python 模块 | 853 |
|
||||||
| 内部导入边 | 6,962 |
|
| 内部导入边 | 6,979 |
|
||||||
| 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
|
| 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
|
||||||
| Direct egress | 66(12 条待迁移债务,54 条精确 containment) |
|
| Direct egress | 66(12 条待迁移债务,54 条精确 containment) |
|
||||||
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
|
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
|
|||||||
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
|
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
|
||||||
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
|
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
|
||||||
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
|
| 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 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
||||||
|
|
||||||
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
||||||
|
|||||||
+18
-5
@@ -231,6 +231,10 @@ def configure_plugin_system_services():
|
|||||||
from app.db.adapters.transfer.execution import (
|
from app.db.adapters.transfer.execution import (
|
||||||
TransactionalTransferExecutionRepository,
|
TransactionalTransferExecutionRepository,
|
||||||
)
|
)
|
||||||
|
from app.db.adapters.user import (
|
||||||
|
SqlAlchemyUserRepository,
|
||||||
|
TransactionalUserRepository,
|
||||||
|
)
|
||||||
from app.db.adapters.workflow import (
|
from app.db.adapters.workflow import (
|
||||||
TransactionalWorkflowExecutionService,
|
TransactionalWorkflowExecutionService,
|
||||||
TransactionalWorkflowQueryRepository,
|
TransactionalWorkflowQueryRepository,
|
||||||
@@ -244,7 +248,6 @@ def configure_plugin_system_services():
|
|||||||
from app.db.oper.subscribe import SubscribeOper
|
from app.db.oper.subscribe import SubscribeOper
|
||||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||||
from app.db.oper.transferhistory import TransferHistoryOper
|
from app.db.oper.transferhistory import TransferHistoryOper
|
||||||
from app.db.oper.user import UserOper
|
|
||||||
from app.db.oper.workflow import WorkflowOper
|
from app.db.oper.workflow import WorkflowOper
|
||||||
|
|
||||||
def create_sync_session() -> Session:
|
def create_sync_session() -> Session:
|
||||||
@@ -275,13 +278,16 @@ def configure_plugin_system_services():
|
|||||||
"subscribe": SubscribeOper,
|
"subscribe": SubscribeOper,
|
||||||
"subscribe_history": SubscribeHistoryOper,
|
"subscribe_history": SubscribeHistoryOper,
|
||||||
"transfer_history": TransferHistoryOper,
|
"transfer_history": TransferHistoryOper,
|
||||||
"user": UserOper,
|
"user": SqlAlchemyUserRepository,
|
||||||
"workflow": WorkflowOper,
|
"workflow": WorkflowOper,
|
||||||
},
|
},
|
||||||
standalone={
|
standalone={
|
||||||
"passkey": PassKeyOper,
|
"passkey": PassKeyOper,
|
||||||
"system_config": SystemConfigOper,
|
"system_config": SystemConfigOper,
|
||||||
"user": UserOper,
|
"user": lambda: TransactionalUserRepository(
|
||||||
|
sync_session=SessionFactory,
|
||||||
|
async_session=async_session_scope,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
unit_of_work={
|
unit_of_work={
|
||||||
"async": SqlAlchemyAsyncUnitOfWork,
|
"async": SqlAlchemyAsyncUnitOfWork,
|
||||||
@@ -302,6 +308,13 @@ def configure_plugin_system_services():
|
|||||||
async_session=async_session_scope,
|
async_session=async_session_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def user_repository() -> TransactionalUserRepository:
|
||||||
|
"""按生产组合根方式创建用户短会话仓储。"""
|
||||||
|
return TransactionalUserRepository(
|
||||||
|
sync_session=SessionFactory,
|
||||||
|
async_session=async_session_scope,
|
||||||
|
)
|
||||||
|
|
||||||
configure_chain_data_ports(
|
configure_chain_data_ports(
|
||||||
site=site_repository,
|
site=site_repository,
|
||||||
subscribe=lambda: SubscribeOper(),
|
subscribe=lambda: SubscribeOper(),
|
||||||
@@ -317,7 +330,7 @@ def configure_plugin_system_services():
|
|||||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||||
SessionFactory
|
SessionFactory
|
||||||
),
|
),
|
||||||
user=lambda: UserOper(),
|
user=user_repository,
|
||||||
)
|
)
|
||||||
configure_chain_runtime_context_provider(lambda: ChainRuntimeContext(
|
configure_chain_runtime_context_provider(lambda: ChainRuntimeContext(
|
||||||
module_manager=ModuleManager(),
|
module_manager=ModuleManager(),
|
||||||
@@ -346,7 +359,7 @@ def configure_plugin_system_services():
|
|||||||
configure_agent_data_ports(
|
configure_agent_data_ports(
|
||||||
agent_chat=lambda: AgentChatOper(),
|
agent_chat=lambda: AgentChatOper(),
|
||||||
agent_task=lambda: AgentTaskOper(),
|
agent_task=lambda: AgentTaskOper(),
|
||||||
user=lambda: UserOper(),
|
user=user_repository,
|
||||||
site=site_repository,
|
site=site_repository,
|
||||||
subscribe=lambda: SubscribeOper(),
|
subscribe=lambda: SubscribeOper(),
|
||||||
subscribe_history=lambda: SubscribeHistoryOper(),
|
subscribe_history=lambda: SubscribeHistoryOper(),
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"application": {
|
"application": {
|
||||||
"covered_lines": 10133,
|
"covered_lines": 10195,
|
||||||
"percent": 78.95,
|
"percent": 79.02,
|
||||||
"statements": 12834
|
"statements": 12902
|
||||||
},
|
},
|
||||||
"domain": {
|
"domain": {
|
||||||
"covered_lines": 3392,
|
"covered_lines": 3392,
|
||||||
|
|||||||
+22
-4
@@ -1441,8 +1441,8 @@
|
|||||||
"runtime_only": true
|
"runtime_only": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"edge_count": 6962,
|
"edge_count": 6979,
|
||||||
"edge_sha256": "d78cc1aa6f3837310c7460d2ea17873222b4b50708012ae2f3db249d8197f77e",
|
"edge_sha256": "0fbef3f16d1475a40988a9fedbeb9a9ff67f49d0e3cd8033280b40a399a92d51",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -3957,6 +3957,9 @@
|
|||||||
"app.api.servcookie -> app.runtime.log",
|
"app.api.servcookie -> app.runtime.log",
|
||||||
"app.api.servcookie -> app.schemas",
|
"app.api.servcookie -> app.schemas",
|
||||||
"app.api.servcookie -> app.schemas.servcookie",
|
"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",
|
||||||
"app.application.agenttask -> app.application.database",
|
"app.application.agenttask -> app.application.database",
|
||||||
"app.application.agenttask -> app.runtime",
|
"app.application.agenttask -> app.runtime",
|
||||||
@@ -3987,6 +3990,8 @@
|
|||||||
"app.application.chain.data -> app.application.download",
|
"app.application.chain.data -> app.application.download",
|
||||||
"app.application.chain.data -> app.application.download.failures",
|
"app.application.chain.data -> app.application.download.failures",
|
||||||
"app.application.chain.data -> app.application.mediaserver",
|
"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",
|
||||||
"app.application.chain.data -> app.application.transfer.execution",
|
"app.application.chain.data -> app.application.transfer.execution",
|
||||||
"app.application.chain.data -> app.application.transfer.workflow",
|
"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.configuration",
|
||||||
"app.application.security.auth -> app.application.security",
|
"app.application.security.auth -> app.application.security",
|
||||||
"app.application.security.auth -> app.application.security.token",
|
"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.application.site",
|
||||||
"app.application.security.auth -> app.foundation",
|
"app.application.security.auth -> app.foundation",
|
||||||
"app.application.security.auth -> app.foundation.singleton",
|
"app.application.security.auth -> app.foundation.singleton",
|
||||||
"app.application.security.auth -> app.schemas",
|
"app.application.security.auth -> app.schemas",
|
||||||
"app.application.security.auth -> app.schemas.token",
|
"app.application.security.auth -> app.schemas.token",
|
||||||
"app.application.security.auth -> app.schemas.types",
|
"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",
|
||||||
"app.application.security.cookie -> app.adapters.external",
|
"app.application.security.cookie -> app.adapters.external",
|
||||||
"app.application.security.cookie -> app.adapters.external.ocr",
|
"app.application.security.cookie -> app.adapters.external.ocr",
|
||||||
@@ -5062,6 +5069,7 @@
|
|||||||
"app.chain.user -> app.application.security",
|
"app.chain.user -> app.application.security",
|
||||||
"app.chain.user -> app.application.security.otp",
|
"app.chain.user -> app.application.security.otp",
|
||||||
"app.chain.user -> app.application.security.token",
|
"app.chain.user -> app.application.security.token",
|
||||||
|
"app.chain.user -> app.application.security.user",
|
||||||
"app.chain.user -> app.chain",
|
"app.chain.user -> app.chain",
|
||||||
"app.chain.user -> app.runtime",
|
"app.chain.user -> app.runtime",
|
||||||
"app.chain.user -> app.runtime.log",
|
"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.transferexecutionstep",
|
||||||
"app.db.adapters.transfer.execution -> app.db.oper.transferpending",
|
"app.db.adapters.transfer.execution -> app.db.oper.transferpending",
|
||||||
"app.db.adapters.transfer.execution -> app.db.uow",
|
"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",
|
||||||
"app.db.adapters.workflow -> app.application.workflow",
|
"app.db.adapters.workflow -> app.application.workflow",
|
||||||
"app.db.adapters.workflow -> app.db",
|
"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",
|
||||||
"app.startup.initializers.modules -> app.db.adapters.transfer.admission",
|
"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.transfer.execution",
|
||||||
|
"app.startup.initializers.modules -> app.db.adapters.user",
|
||||||
"app.startup.initializers.modules -> app.db.adapters.workflow",
|
"app.startup.initializers.modules -> app.db.adapters.workflow",
|
||||||
"app.startup.initializers.modules -> app.db.oper",
|
"app.startup.initializers.modules -> app.db.oper",
|
||||||
"app.startup.initializers.modules -> app.db.oper.agentchat",
|
"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.subscribehistory",
|
||||||
"app.startup.initializers.modules -> app.db.oper.systemconfig",
|
"app.startup.initializers.modules -> app.db.oper.systemconfig",
|
||||||
"app.startup.initializers.modules -> app.db.oper.transferhistory",
|
"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.userconfig",
|
||||||
"app.startup.initializers.modules -> app.db.oper.workflow",
|
"app.startup.initializers.modules -> app.db.oper.workflow",
|
||||||
"app.startup.initializers.modules -> app.db.session",
|
"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",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 852,
|
"module_count": 853,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -8813,6 +8830,7 @@
|
|||||||
"app.db.adapters.transfer",
|
"app.db.adapters.transfer",
|
||||||
"app.db.adapters.transfer.admission",
|
"app.db.adapters.transfer.admission",
|
||||||
"app.db.adapters.transfer.execution",
|
"app.db.adapters.transfer.execution",
|
||||||
|
"app.db.adapters.user",
|
||||||
"app.db.adapters.workflow",
|
"app.db.adapters.workflow",
|
||||||
"app.db.base",
|
"app.db.base",
|
||||||
"app.db.decorators",
|
"app.db.decorators",
|
||||||
|
|||||||
+3
-8
@@ -1258,8 +1258,7 @@
|
|||||||
},
|
},
|
||||||
"app/application/security/auth.py": {
|
"app/application/security/auth.py": {
|
||||||
"no-untyped-call": 2,
|
"no-untyped-call": 2,
|
||||||
"no-untyped-def": 1,
|
"no-untyped-def": 1
|
||||||
"type-arg": 1
|
|
||||||
},
|
},
|
||||||
"app/application/security/cookie.py": {
|
"app/application/security/cookie.py": {
|
||||||
"arg-type": 3,
|
"arg-type": 3,
|
||||||
@@ -1582,10 +1581,6 @@
|
|||||||
"union-attr": 31,
|
"union-attr": 31,
|
||||||
"var-annotated": 4
|
"var-annotated": 4
|
||||||
},
|
},
|
||||||
"app/chain/user.py": {
|
|
||||||
"arg-type": 3,
|
|
||||||
"index": 1
|
|
||||||
},
|
|
||||||
"app/chain/workflow.py": {
|
"app/chain/workflow.py": {
|
||||||
"arg-type": 1,
|
"arg-type": 1,
|
||||||
"assignment": 1,
|
"assignment": 1,
|
||||||
@@ -2078,7 +2073,7 @@
|
|||||||
"assignment": 1,
|
"assignment": 1,
|
||||||
"call-overload": 1,
|
"call-overload": 1,
|
||||||
"method-assign": 2,
|
"method-assign": 2,
|
||||||
"no-untyped-def": 4,
|
"no-untyped-def": 3,
|
||||||
"return-value": 1,
|
"return-value": 1,
|
||||||
"type-arg": 25,
|
"type-arg": 25,
|
||||||
"union-attr": 8
|
"union-attr": 8
|
||||||
@@ -3399,7 +3394,7 @@
|
|||||||
"no-untyped-def": 2
|
"no-untyped-def": 2
|
||||||
},
|
},
|
||||||
"app/startup/initializers/modules.py": {
|
"app/startup/initializers/modules.py": {
|
||||||
"arg-type": 17,
|
"arg-type": 16,
|
||||||
"assignment": 1,
|
"assignment": 1,
|
||||||
"attr-defined": 2,
|
"attr-defined": 2,
|
||||||
"misc": 1,
|
"misc": 1,
|
||||||
|
|||||||
@@ -128,9 +128,6 @@
|
|||||||
"app/agent/tools/impl/add_download_tasks.py": {
|
"app/agent/tools/impl/add_download_tasks.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"app/agent/tools/impl/add_subscribe.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"app/agent/tools/impl/ask_user_choice.py": {
|
"app/agent/tools/impl/ask_user_choice.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
@@ -339,9 +336,6 @@
|
|||||||
"app/application/rss.py": {
|
"app/application/rss.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"app/application/security/auth.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"app/application/security/url.py": {
|
"app/application/security/url.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
@@ -615,9 +609,6 @@
|
|||||||
"F401": 1,
|
"F401": 1,
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"app/modules/feishu/feishu.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"app/modules/filemanager/__init__.py": {
|
"app/modules/filemanager/__init__.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
|
|||||||
) as async_add, patch(
|
) as async_add, patch(
|
||||||
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
|
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
|
||||||
return_value=SimpleNamespace(
|
return_value=SimpleNamespace(
|
||||||
get_name=lambda **_kwargs: "moviepilot-user"
|
find_name_by_bindings=lambda _bindings: "moviepilot-user"
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
@@ -59,7 +59,9 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
|
|||||||
new=AsyncMock(return_value=(1, "")),
|
new=AsyncMock(return_value=(1, "")),
|
||||||
) as async_add, patch(
|
) as async_add, patch(
|
||||||
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
|
"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(
|
result = asyncio.run(
|
||||||
tool.run(
|
tool.run(
|
||||||
@@ -85,7 +87,9 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
|
|||||||
new=AsyncMock(return_value=(1, "")),
|
new=AsyncMock(return_value=(1, "")),
|
||||||
) as async_add, patch(
|
) as async_add, patch(
|
||||||
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
|
"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(
|
result = asyncio.run(
|
||||||
tool.run(
|
tool.run(
|
||||||
|
|||||||
@@ -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):
|
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(
|
with patch.object(Feishu, "_build_api_client", return_value=Mock()), patch.object(
|
||||||
Feishu, "_start_ws_client"
|
Feishu, "_start_ws_client"
|
||||||
), patch("app.modules.feishu.feishu.UserOper") as user_oper:
|
), patch(
|
||||||
user_oper.return_value.get_name.return_value = None
|
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
|
||||||
|
return_value=lambda **_bindings: None,
|
||||||
|
):
|
||||||
client = Feishu(
|
client = Feishu(
|
||||||
FEISHU_APP_ID="app-id",
|
FEISHU_APP_ID="app-id",
|
||||||
FEISHU_APP_SECRET="app-secret",
|
FEISHU_APP_SECRET="app-secret",
|
||||||
@@ -426,8 +428,10 @@ def test_feishu_default_open_id_is_admin_without_duplicate_admin_entry():
|
|||||||
"""飞书默认用户 Open ID 无需重复加入管理员名单。"""
|
"""飞书默认用户 Open ID 无需重复加入管理员名单。"""
|
||||||
with patch.object(Feishu, "_build_api_client", return_value=Mock()), patch.object(
|
with patch.object(Feishu, "_build_api_client", return_value=Mock()), patch.object(
|
||||||
Feishu, "_start_ws_client"
|
Feishu, "_start_ws_client"
|
||||||
), patch("app.modules.feishu.feishu.UserOper") as user_oper:
|
), patch(
|
||||||
user_oper.return_value.get_name.return_value = None
|
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
|
||||||
|
return_value=lambda **_bindings: None,
|
||||||
|
):
|
||||||
client = Feishu(
|
client = Feishu(
|
||||||
FEISHU_APP_ID="app-id",
|
FEISHU_APP_ID="app-id",
|
||||||
FEISHU_APP_SECRET="app-secret",
|
FEISHU_APP_SECRET="app-secret",
|
||||||
|
|||||||
@@ -722,6 +722,67 @@ def test_user_and_messaging_chains_use_explicit_data_port_getters():
|
|||||||
assert violations == []
|
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():
|
def test_music_chain_uses_explicit_subscribe_data_port_getter():
|
||||||
"""音乐订阅链不得把 SubscribePortProxy 伪装成 SubscribeOper。"""
|
"""音乐订阅链不得把 SubscribePortProxy 伪装成 SubscribeOper。"""
|
||||||
path = APP_ROOT / "chain" / "_music.py"
|
path = APP_ROOT / "chain" / "_music.py"
|
||||||
|
|||||||
+16
-6
@@ -140,7 +140,10 @@ class TestFeishu(unittest.TestCase):
|
|||||||
def test_parse_message_returns_callback_message(self):
|
def test_parse_message_returns_callback_message(self):
|
||||||
client = self._build_client()
|
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(
|
result = client.parse_message(
|
||||||
{
|
{
|
||||||
"type": "cardAction",
|
"type": "cardAction",
|
||||||
@@ -221,7 +224,10 @@ class TestFeishu(unittest.TestCase):
|
|||||||
client = self._build_client(FEISHU_ADMINS="ou_admin")
|
client = self._build_client(FEISHU_ADMINS="ou_admin")
|
||||||
|
|
||||||
with (
|
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(
|
patch.object(
|
||||||
client, "send_text", return_value={"success": True}
|
client, "send_text", return_value={"success": True}
|
||||||
) as send_text,
|
) as send_text,
|
||||||
@@ -250,10 +256,11 @@ class TestFeishu(unittest.TestCase):
|
|||||||
def test_parse_message_maps_feishu_ids_to_moviepilot_username(self):
|
def test_parse_message_maps_feishu_ids_to_moviepilot_username(self):
|
||||||
client = self._build_client()
|
client = self._build_client()
|
||||||
|
|
||||||
|
get_name = MagicMock(return_value="moviepilot-user")
|
||||||
with patch(
|
with patch(
|
||||||
"app.modules.feishu.feishu.UserOper.get_name",
|
"app.modules.feishu.feishu.get_configured_user_channel_lookup",
|
||||||
return_value="moviepilot-user",
|
return_value=get_name,
|
||||||
) as get_name:
|
):
|
||||||
result = client.parse_message(
|
result = client.parse_message(
|
||||||
{
|
{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
@@ -901,7 +908,10 @@ class TestFeishu(unittest.TestCase):
|
|||||||
def test_parse_message_supports_image_and_file_payloads(self):
|
def test_parse_message_supports_image_and_file_payloads(self):
|
||||||
client = self._build_client()
|
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(
|
image_message = client.parse_message(
|
||||||
{
|
{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user