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
+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