From 8b955c04d6191495f63d72bbd521e36a2888b2b5 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 23 Aug 2026 14:37:21 +0800 Subject: [PATCH] refactor: isolate passkey queries --- app/db/models/passkey.py | 22 ++++--- app/db/oper/passkey.py | 14 ++-- docs/architecture-overview.md | 2 +- .../backend-architecture-next-stage.md | 9 ++- docs/rules/10-data-and-persistent.md | 2 +- .../architecture/dependency-baseline.json | 5 +- .../transaction-debt-baseline.json | 26 +------- tests/test_architecture_contract_baseline.py | 4 +- tests/test_db_config_user_queries.py | 66 ++++++++++++++++++- 9 files changed, 100 insertions(+), 50 deletions(-) diff --git a/app/db/models/passkey.py b/app/db/models/passkey.py index 3df67158b..158e9caec 100644 --- a/app/db/models/passkey.py +++ b/app/db/models/passkey.py @@ -5,7 +5,11 @@ from sqlalchemy.orm import Mapped, Session, mapped_column from datetime import datetime from app.db.base import Base, get_id_column -from app.db.decorators import async_db_query, db_query, run_legacy_sync_query +from app.db.decorators import ( + legacy_async_db_query, + legacy_db_query, + run_legacy_sync_query, +) def _get_by_user_id_statement(model: type["PassKey"], user_id: int): @@ -73,9 +77,9 @@ class PassKey(Base): return run_legacy_sync_query(query) @classmethod - @async_db_query + @legacy_async_db_query async def async_get_by_user_id(cls, db: AsyncSession, user_id: int): - """异步获取用户的所有PassKey""" + """异步获取用户的所有 PassKey,并保留旧插件无 Session 调用。""" result = await db.execute( _get_by_user_id_statement(cls, user_id) ) @@ -104,24 +108,24 @@ class PassKey(Base): return run_legacy_sync_query(query) @classmethod - @async_db_query + @legacy_async_db_query async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str): - """异步根据凭证ID获取PassKey""" + """异步根据凭证 ID 获取 PassKey,并保留旧插件无 Session 调用。""" result = await db.execute( _get_by_credential_id_statement(cls, credential_id) ) return result.scalars().first() @classmethod - @db_query + @legacy_db_query def get_by_id(cls, db: Session, passkey_id: int): - """根据ID获取PassKey""" + """根据 ID 获取 PassKey,并保留旧插件无 Session 调用。""" return db.execute(select(cls).where(cls.id == passkey_id)).scalars().first() @classmethod - @async_db_query + @legacy_async_db_query async def async_get_by_id(cls, db: AsyncSession, passkey_id: int): - """异步根据ID获取PassKey""" + """异步根据 ID 获取 PassKey,并保留旧插件无 Session 调用。""" result = await db.execute( select(cls).filter(cls.id == passkey_id) ) diff --git a/app/db/oper/passkey.py b/app/db/oper/passkey.py index 58f469d2d..78a8e8e8a 100644 --- a/app/db/oper/passkey.py +++ b/app/db/oper/passkey.py @@ -2,6 +2,7 @@ from typing import Any, Optional +from sqlalchemy import select from sqlalchemy.orm import Session from app.db.base import DbOper @@ -10,7 +11,6 @@ from app.db.models.passkey import ( _get_by_credential_id_statement, _get_by_user_id_statement, ) -from app.db.uow import run_sync_transaction class PassKeyOper(DbOper): @@ -24,13 +24,13 @@ class PassKeyOper(DbOper): _get_by_user_id_statement(PassKey, user_id) ).scalars().all()) - if isinstance(self._db, Session): - return query(self._db) - return run_sync_transaction(query) + return self._execute_sync_query(query) def list(self) -> list[PassKey]: """读取全部 PassKey,用于判断系统是否已配置通行密钥。""" - return PassKey.list(self._db) + return self._execute_sync_query( + lambda session: list(session.execute(select(PassKey)).scalars().all()) + ) def get_by_credential_id(self, credential_id: str) -> Optional[PassKey]: """按凭证 ID 读取启用的 PassKey。""" @@ -40,9 +40,7 @@ class PassKeyOper(DbOper): _get_by_credential_id_statement(PassKey, credential_id) ).scalars().first() - if isinstance(self._db, Session): - return query(self._db) - return run_sync_transaction(query) + return self._execute_sync_query(query) def create(self, payload: dict[str, Any]) -> PassKey: """创建 PassKey 凭证。""" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 6faae5a0c..dcd693503 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -378,7 +378,7 @@ flowchart LR 成功后执行。订阅新增样板由 `startup/subscription.py` 创建独占 Session, `application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()` 只查重、`add` 和 `flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。 - `transaction-debt-baseline.json` 当前冻结 9 个正式只读查询装饰器;原有同步/异步写装饰器 + `transaction-debt-baseline.json` 当前冻结 5 个正式只读查询装饰器;原有同步/异步写装饰器 已全部移除,`db_update` 与 `async_db_update` 必须持续保持为 0。下载/整理历史的旧插件 Model 与工作流、媒体服务器、站点用户数据旧插件 Model 调用由 `legacy_*` 兼容外壳承接,宿主 Oper 必须显式传递 Session。宿主 Oper 也不得调用 Base 保留的 `create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index b4df44fdf..6c960a825 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -28,7 +28,7 @@ 1. **后台任务的统一所有权已覆盖 API 入口,但仍有更深层任务机制待分级。** `app/runtime/tasks.py` 已建立 lifespan 级 TaskRegistry,启动收尾、插件 Release 刷新、Webhook E0 广播、CookieCloud E1 手工调度、消息入口、Seerr 订阅和 WebAgent 断线后执行/快照保存均不再维护端点模块级任务集合或 Starlette 回调,shutdown 会停止接收、取消并有限等待,且生命周期清单明确登记其顺序。主仓 `app/` 已无裸 FastAPI `BackgroundTasks`;当前仍有约 `50` 个更底层 `create_task`/等价任务创建点,与线程池和 APScheduler 并存,后续需逐项确认 owner、取消、等待、重试、幂等和是否 durable,关键业务副作用优先接入已有 Outbox/恢复表。 2. **动态模块契约仍以 legacy 聚合语义为主。** 当前登记 `212` 个模块方法,其中 `194` 个仍使用 `legacy` aggregation,只有 `14` 个 `first_non_empty`、`4` 个 `ordered_list_merge`。`app/runtime/extensions/module/contracts.py:422-455` 已能登记 family、输入/结果标签和基础签名诊断,但 `193` 个方法没有 required parameters,调度器 `app/runtime/extensions/module/dispatcher.py:109-260` 仍主要依赖运行时反射、返回值形状和短路规则。未知第三方方法保留 legacy fallback 是兼容要求,不应删除;宿主高频能力则应逐族补齐可执行的输入校验、结果校验、超时和错误语义。 -3. **查询侧数据库兼容 ABI 仍未完全收口。** 写事务装饰器已降为 `0`,正式 `db_query/async_db_query` 已降至 `9` 个(`3` 个同步、`6` 个异步)。站点、消息、用户、订阅、下载/整理历史、工作流、MediaServer、SiteUserData、AgentChat、AgentTaskRun、TransferPending 和 SystemConfig 的宿主 Oper 已迁到显式 Session 路径;对应旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。剩余正式装饰器仍会隐式创建会话,查询返回的 ORM 对象也可能跨层流转,后续继续按 PassKey 和 SubscribeHistory 等风险切片迁移。 +3. **查询侧数据库兼容 ABI 仍未完全收口。** 写事务装饰器已降为 `0`,正式 `db_query/async_db_query` 已降至 `5` 个(`2` 个同步、`3` 个异步)。站点、消息、用户、订阅、下载/整理历史、工作流、MediaServer、SiteUserData、AgentChat、AgentTaskRun、TransferPending、SystemConfig 和 PassKey 的宿主查询已迁到显式 Session 路径;对应旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。剩余正式装饰器仍会隐式创建会话,查询返回的 ORM 对象也可能跨层流转,后续继续迁移 SubscribeHistory。 4. **组合根和全局状态仍形成复杂的隐式运行时图。** Singleton 实例、模块级 provider、`configure_*` 注册函数和兼容 Facade 同时存在;它们解决了旧 ABI 和启动顺序问题,但增加测试污染、重复装配、实例身份和初始化顺序风险。`app/startup/lifecycle/__init__.py:161-376` 已有声明式生命周期,`app/startup/modules_initializer.py:505-530` 也有分阶段关闭,但尚未做到所有进程级资源都只通过 typed HostRuntime 访问。后续应以“新代码禁止新增 Service Locator/Singleton 依赖、旧入口有命中观测”为 ratchet。 ### P2:中长期可演进性债务 @@ -1206,6 +1206,11 @@ Session 不创建额外会话,无 Session 的位置参数和关键字参数继 `legacy_*` 外壳兼容;专项与架构测试 `146 passed`,四分片全量测试 `5547 passed, 3 skipped`, host/plugin 架构基线和 Pylint 均通过。 +2026-08-23 完成 PassKey 查询切片:三个异步查询与按 ID 同步查询改由 `legacy_*` 外壳承接, +正式查询装饰器由 9 降至 5 个(同步 2、异步 3),写装饰器保持 0。显式 Session/AsyncSession +不创建额外会话,旧插件无 Session 的位置与关键字调用仍保持兼容;专项与架构测试 `99 passed`, +四分片全量测试 `5549 passed, 3 skipped`,host/plugin 架构基线和 Pylint 均通过。 + #### ARCH-272:异步阻塞检测 **目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。 @@ -1383,7 +1388,7 @@ rollback: | 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope | | 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 | | 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 | -| Model 事务装饰器 | 当前 9 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 | +| Model 事务装饰器 | 当前 5 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 | | 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW | | 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 | | Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict | diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md index 33943f1dd..663c4b997 100644 --- a/docs/rules/10-data-and-persistent.md +++ b/docs/rules/10-data-and-persistent.md @@ -84,7 +84,7 @@ Oper classes accept and return persistence values. Turning a `MediaInfo` or ### Transaction ownership ratchet - `tests/fixtures/architecture/transaction-debt-baseline.json` records the - existing Model transaction decorators. The current 9 decorators are query-only + existing Model transaction decorators. The current 5 decorators are query-only migration debt: they may decrease but must never increase or move to a new Model method. Both `db_update` and `async_db_update` must remain at zero. - `legacy_db_query` / `legacy_async_db_query` are compatibility-only shells for diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 7d5d900ef..31b182b18 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6464, - "edge_sha256": "256ae6f9cd8950b0fe2743e81300551877eb595bda37b8ce114f439b33496af9", + "edge_count": 6463, + "edge_sha256": "086c14fef27199ae6f19fbe5e6a07c348cd1dc8a406ab3129a341647fd90a28f", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -3629,7 +3629,6 @@ "app.db.oper.passkey -> app.db.base", "app.db.oper.passkey -> app.db.models", "app.db.oper.passkey -> app.db.models.passkey", - "app.db.oper.passkey -> app.db.uow", "app.db.oper.plugindata -> app.db", "app.db.oper.plugindata -> app.db.base", "app.db.oper.plugindata -> app.db.models", diff --git a/tests/fixtures/architecture/transaction-debt-baseline.json b/tests/fixtures/architecture/transaction-debt-baseline.json index bcaec4afd..2fcb5a6f9 100644 --- a/tests/fixtures/architecture/transaction-debt-baseline.json +++ b/tests/fixtures/architecture/transaction-debt-baseline.json @@ -1,33 +1,13 @@ { "model_decorators": { "by_kind": { - "async_db_query": 6, + "async_db_query": 3, "async_db_update": 0, - "db_query": 3, + "db_query": 2, "db_update": 0 }, - "count": 9, + "count": 5, "methods": [ - { - "decorator": "async_db_query", - "file": "app/db/models/passkey.py", - "method": "PassKey.async_get_by_credential_id" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/passkey.py", - "method": "PassKey.async_get_by_id" - }, - { - "decorator": "async_db_query", - "file": "app/db/models/passkey.py", - "method": "PassKey.async_get_by_user_id" - }, - { - "decorator": "db_query", - "file": "app/db/models/passkey.py", - "method": "PassKey.get_by_id" - }, { "decorator": "async_db_query", "file": "app/db/models/subscribehistory.py", diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index a40d5b4e5..bb10ee075 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -126,8 +126,8 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None: baseline = json.loads(baseline_path.read_text(encoding="utf-8")) assert baseline["schema_version"] == 1 - assert baseline["model_decorators"]["count"] == 9 - assert sum(baseline["model_decorators"]["by_kind"].values()) == 9 + assert baseline["model_decorators"]["count"] == 5 + assert sum(baseline["model_decorators"]["by_kind"].values()) == 5 assert baseline["model_decorators"]["by_kind"]["db_update"] == 0 assert baseline["model_decorators"]["by_kind"]["async_db_update"] == 0 assert baseline["model_transaction_calls"] == {"count": 0, "calls": []} diff --git a/tests/test_db_config_user_queries.py b/tests/test_db_config_user_queries.py index 064b83f4d..7ef0483e8 100644 --- a/tests/test_db_config_user_queries.py +++ b/tests/test_db_config_user_queries.py @@ -266,12 +266,16 @@ def test_passkey_oper_queries_use_explicit_session(db, monkeypatch): """PassKeyOper 的宿主查询使用调用方 Session,不创建兼容事务。""" db.add(_passkey(9002, "cred-oper"), _passkey(9002, "cred-oper-inactive", is_active=False)) monkeypatch.setattr( - "app.db.oper.passkey.run_sync_transaction", + "app.db.base.run_sync_transaction", lambda _query: pytest.fail("显式 Session 查询不应创建兼容事务"), ) oper = PassKeyOper(db.session) + assert {item.credential_id for item in oper.list()} == { + "cred-oper", + "cred-oper-inactive", + } assert [item.credential_id for item in oper.list_by_user_id(9002)] == ["cred-oper"] assert oper.get_by_credential_id("cred-oper").user_id == 9002 assert oper.get_by_credential_id("cred-oper-inactive") is None @@ -302,6 +306,66 @@ def test_passkey_model_sync_queries_keep_no_session_plugin_abi(db, monkeypatch): assert PassKey.get_by_credential_id("cred-legacy").user_id == 9004 +def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch): + """PassKey 其余同步/异步查询必须复用调用方会话。""" + key = db.add(_passkey(9008, "cred-explicit")) + monkeypatch.setattr( + decorators, + "ScopedSession", + lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")), + ) + assert PassKey.get_by_id(db.session, key.id).credential_id == "cred-explicit" + + async def check() -> None: + """验证三个异步查询都复用显式 AsyncSession。""" + async with async_session_scope() as session: + monkeypatch.setattr( + decorators, + "async_session_scope", + lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")), + ) + assert [item.credential_id for item in await PassKey.async_get_by_user_id( + session, + 9008, + )] == ["cred-explicit"] + assert await PassKey.async_get_by_credential_id( + session, + "cred-explicit", + ) is not None + assert await PassKey.async_get_by_id(session, key.id) is not None + + asyncio.run(check()) + + +def test_passkey_remaining_queries_keep_legacy_keyword_abi(db, monkeypatch): + """旧插件关键字直调 PassKey 其余查询时仍自动补入短会话。""" + key = db.add(_passkey(9009, "cred-keyword")) + opened_sync = [] + monkeypatch.setattr( + decorators, + "ScopedSession", + lambda: (opened_sync.append(True) or SessionFactory()), + ) + assert PassKey.get_by_id(passkey_id=key.id) is not None + assert opened_sync == [True] + + opened_async = [] + original_scope = async_session_scope + + def tracked_scope(): + """记录旧异步 ABI 创建的兼容会话作用域。""" + opened_async.append(True) + return original_scope() + + monkeypatch.setattr(decorators, "async_session_scope", tracked_scope) + assert asyncio.run(PassKey.async_get_by_user_id(user_id=9009)) + assert asyncio.run(PassKey.async_get_by_credential_id( + credential_id="cred-keyword", + )) is not None + assert asyncio.run(PassKey.async_get_by_id(passkey_id=key.id)) is not None + assert opened_async == [True, True, True] + + def test_passkey_get_by_id_ignores_active_flag(db): """ 按主键取记录是管理用途,不应过滤停用状态——否则管理端看不到自己刚停用的凭据。