diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md
index 1e63b5972..47b169491 100644
--- a/docs/architecture-overview.md
+++ b/docs/architecture-overview.md
@@ -342,13 +342,18 @@ SQLAlchemy 查询。`models/` 与 `oper/` 按文件一一镜像(站点族聚
```mermaid
flowchart LR
- Caller["Chain / Application / 端点 / Module"]
+ Entry["API / Scheduler / Agent
逻辑操作入口"]
+ Command["Application Command
事务所有者"]
+ UoW["app/db/uow.py
commit / rollback"]
Oper["app/db/oper/*.py
SubscribeOper / TransferHistoryOper ..."]
Models["app/db/models/*.py
SQLAlchemy 模型"]
Engine["app/db/engine.py
同步 + 异步引擎"]
DB[("PostgreSQL / SQLite")]
- Caller --> Oper --> Models --> Engine --> DB
+ Entry --> Command --> Oper --> Models --> Engine --> DB
+ Entry --> UoW --> Engine
+ Command -.提交或回滚.-> UoW
+ Command -.commit 后副作用.-> Effects["Event / Scheduler / Report"]
Models -.before_insert/before_update.-> Norm["_identity.py
media_source/media_id 归一化"]
```
@@ -356,6 +361,9 @@ flowchart LR
归 `app/application/`(见 `application/subscription/write.py`、`application/history.py`)。
订阅新增、查询、变更、删除、身份和搜索契约已经统一收口在 `application/subscription/`,
不再保留主题包之外的第二个写入入口。
+- Oper 只 stage mutation,不创建独立 Session、不提交;Application Command 通过请求或任务
+ 入口注入的 UnitOfWork 统一 `commit/rollback`,事件、刷新和上报只在 commit 成功后执行。
+ `transaction-debt-baseline.json` 将存量 178 个 Model 事务装饰器冻结为只降不增低水位。
- 每次表结构变更必须新增 `database/versions/` 下的 Alembic 迁移。
- 运行期业务配置使用 `SystemConfigKey` 枚举 + `SystemConfigOper`,禁止裸字符串键;
用户级配置使用 `UserConfigOper`。
diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md
index 4b0fb350a..57baa542f 100644
--- a/docs/refactor/backend-architecture-next-stage.md
+++ b/docs/refactor/backend-architecture-next-stage.md
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
-> 实施进度:阶段 0(ARCH-201~203)与阶段 1(ARCH-210~212)已完成,后续任务按 ID 独立提交和回滚
+> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)与 ARCH-220 已完成,后续任务按 ID 独立提交和回滚
## 1. 结论先行
diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md
index 29ab8ee50..2c49cb2f8 100644
--- a/docs/rules/10-data-and-persistent.md
+++ b/docs/rules/10-data-and-persistent.md
@@ -81,6 +81,29 @@ the stub.
Oper classes accept and return persistence values. Turning a `MediaInfo` or
`MetaBase` into a row is business logic and lives in `app/application/`.
+### Transaction ownership ratchet
+
+- `tests/fixtures/architecture/transaction-debt-baseline.json` records the
+ existing Model transaction decorators. The current 178 legacy decorators are
+ migration debt: they may decrease but must never increase or move to a new
+ Model method.
+- New Model methods must not use `db_query`, `db_update`, `async_db_query`, or
+ `async_db_update`, create a Session, or call `commit()` / `rollback()`.
+- Oper receives a caller-owned Session and may query, add, update, delete, or
+ flush. A composable Oper method must not create its own Session and must not
+ commit or roll back.
+- The API, Scheduler, Agent, or another logical operation entry creates the
+ Session and adapts it through `app/db/uow.py`. Application command code owns
+ `commit()` / `rollback()`; events, scheduling refresh, reports, and other
+ external effects run only after a successful commit.
+- A synchronous Session is private to one worker thread. An AsyncSession is
+ private to one asyncio task/operation; neither may be stored in a process
+ singleton or reused by concurrent work.
+
+Run `./.venv/bin/python scripts/architecture/baseline.py --check-host` after
+persistence changes. A deliberate debt reduction may refresh the low-water mark
+with `--write-host`; never refresh it to accept newly introduced debt.
+
**Standard Oper method conventions:**
```python
diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py
index 35acb5746..668a723e8 100644
--- a/scripts/architecture/baseline.py
+++ b/scripts/architecture/baseline.py
@@ -19,6 +19,7 @@ APP_ROOT = PROJECT_ROOT / "app"
BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture"
DEPENDENCY_BASELINE_PATH = BASELINE_ROOT / "dependency-baseline.json"
RUNTIME_BASELINE_PATH = BASELINE_ROOT / "runtime-contract-baseline.json"
+TRANSACTION_BASELINE_PATH = BASELINE_ROOT / "transaction-debt-baseline.json"
PLUGIN_BASELINE_PATH = BASELINE_ROOT / "official-plugin-baseline.json"
PLUGIN_HOOKS = (
"get_actions",
@@ -37,6 +38,25 @@ PLUGIN_HOOKS = (
"init_plugin",
"stop_service",
)
+MODEL_TRANSACTION_DECORATORS = {
+ "async_db_query",
+ "async_db_update",
+ "db_query",
+ "db_update",
+}
+SESSION_FACTORY_NAMES = {
+ "AsyncSession",
+ "AsyncSessionFactory",
+ "ScopedSession",
+ "Session",
+ "SessionFactory",
+ "async_session_scope",
+ "get_async_db",
+ "get_async_session_factory",
+ "get_db",
+ "get_scoped_session",
+ "get_session_factory",
+}
def discover_modules() -> dict[str, Path]:
@@ -250,6 +270,137 @@ def collect_dependency_baseline() -> dict[str, Any]:
}
+def _expression_name(node: ast.AST) -> str:
+ """返回调用或装饰器表达式的点分名称,无法静态解析时返回空串。"""
+ if isinstance(node, ast.Name):
+ return node.id
+ if isinstance(node, ast.Attribute):
+ prefix = _expression_name(node.value)
+ return ".".join(part for part in (prefix, node.attr) if part)
+ if isinstance(node, ast.Call):
+ return _expression_name(node.func)
+ return ""
+
+
+def _iter_owned_functions(tree: ast.Module) -> list[tuple[str, ast.AST]]:
+ """收集模块函数与类方法的稳定限定名,不记录易漂移源码行号。"""
+ methods: list[tuple[str, ast.AST]] = []
+
+ def visit_class(node: ast.ClassDef, parents: tuple[str, ...]) -> None:
+ """递归访问嵌套类,并收集直接定义的方法。"""
+ class_path = (*parents, node.name)
+ for child in node.body:
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ methods.append((".".join((*class_path, child.name)), child))
+ elif isinstance(child, ast.ClassDef):
+ visit_class(child, class_path)
+
+ for statement in tree.body:
+ if isinstance(statement, ast.ClassDef):
+ visit_class(statement, ())
+ elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ methods.append((statement.name, statement))
+ return methods
+
+
+def _collect_method_calls(
+ root: Path,
+ *,
+ operations: set[str],
+) -> list[dict[str, str]]:
+ """按文件、方法和操作收集指定调用,作为只降不增的事务债务清单。"""
+ calls: list[dict[str, str]] = []
+ for path in sorted(root.rglob("*.py")):
+ relative = path.relative_to(PROJECT_ROOT).as_posix()
+ for method, node in _iter_owned_functions(parse_source(path)):
+ for call in ast.walk(node):
+ if not isinstance(call, ast.Call):
+ continue
+ expression = _expression_name(call.func)
+ operation = expression.rsplit(".", 1)[-1]
+ if operation in operations:
+ calls.append(
+ {
+ "file": relative,
+ "method": method,
+ "operation": operation,
+ }
+ )
+ return sorted(
+ calls,
+ key=lambda item: (item["file"], item["method"], item["operation"]),
+ )
+
+
+def collect_transaction_debt_baseline() -> dict[str, Any]:
+ """记录 Model 自动事务和 Oper 会话所有权债务,供 CI 执行单向 ratchet。"""
+ model_root = APP_ROOT / "db" / "models"
+ oper_root = APP_ROOT / "db" / "oper"
+ decorated_methods: list[dict[str, str]] = []
+ for path in sorted(model_root.rglob("*.py")):
+ relative = path.relative_to(PROJECT_ROOT).as_posix()
+ for method, node in _iter_owned_functions(parse_source(path)):
+ for decorator in node.decorator_list:
+ decorator_name = _expression_name(decorator).rsplit(".", 1)[-1]
+ if decorator_name in MODEL_TRANSACTION_DECORATORS:
+ decorated_methods.append(
+ {
+ "decorator": decorator_name,
+ "file": relative,
+ "method": method,
+ }
+ )
+ decorated_methods.sort(
+ key=lambda item: (item["file"], item["method"], item["decorator"])
+ )
+ model_transaction_calls = _collect_method_calls(
+ model_root,
+ operations={"commit", "rollback"},
+ )
+ model_session_factories = _collect_method_calls(
+ model_root,
+ operations=SESSION_FACTORY_NAMES,
+ )
+ oper_transaction_calls = _collect_method_calls(
+ oper_root,
+ operations={"commit", "rollback"},
+ )
+ oper_session_factories = _collect_method_calls(
+ oper_root,
+ operations=SESSION_FACTORY_NAMES,
+ )
+ decorator_counts = {
+ decorator: sum(
+ item["decorator"] == decorator
+ for item in decorated_methods
+ )
+ for decorator in sorted(MODEL_TRANSACTION_DECORATORS)
+ }
+ return {
+ "schema_version": 1,
+ "scope": "app/db/models and app/db/oper transaction ownership debt",
+ "model_decorators": {
+ "count": len(decorated_methods),
+ "by_kind": decorator_counts,
+ "methods": decorated_methods,
+ },
+ "model_transaction_calls": {
+ "count": len(model_transaction_calls),
+ "calls": model_transaction_calls,
+ },
+ "model_session_factories": {
+ "count": len(model_session_factories),
+ "calls": model_session_factories,
+ },
+ "oper_transaction_calls": {
+ "count": len(oper_transaction_calls),
+ "calls": oper_transaction_calls,
+ },
+ "oper_session_factories": {
+ "count": len(oper_session_factories),
+ "calls": oper_session_factories,
+ },
+ }
def _collect_run_module_locations() -> tuple[
dict[str, list[dict[str, Any]]],
list[dict[str, Any]],
@@ -862,6 +1013,45 @@ def semantic_baseline(path: Path, value: dict[str, Any]) -> dict[str, Any]:
return value
+def transaction_ratchet_matches(
+ expected: dict[str, Any],
+ actual: dict[str, Any],
+) -> bool:
+ """事务债务只允许删除既有条目,不允许新增或提高任一分类计数。"""
+ if expected.get("schema_version") != actual.get("schema_version"):
+ return False
+ if expected.get("scope") != actual.get("scope"):
+ return False
+ sections = (
+ ("model_decorators", "methods"),
+ ("model_transaction_calls", "calls"),
+ ("model_session_factories", "calls"),
+ ("oper_transaction_calls", "calls"),
+ ("oper_session_factories", "calls"),
+ )
+ for section, entries_key in sections:
+ expected_section = expected.get(section, {})
+ actual_section = actual.get(section, {})
+ if actual_section.get("count", 0) > expected_section.get("count", 0):
+ return False
+ expected_entries = {
+ json.dumps(item, ensure_ascii=False, sort_keys=True)
+ for item in expected_section.get(entries_key, [])
+ }
+ actual_entries = {
+ json.dumps(item, ensure_ascii=False, sort_keys=True)
+ for item in actual_section.get(entries_key, [])
+ }
+ if not actual_entries.issubset(expected_entries):
+ return False
+ expected_kinds = expected.get("model_decorators", {}).get("by_kind", {})
+ actual_kinds = actual.get("model_decorators", {}).get("by_kind", {})
+ return all(
+ count <= expected_kinds.get(decorator, 0)
+ for decorator, count in actual_kinds.items()
+ )
+
+
def _compare_semantic_values(
expected: Any,
actual: Any,
@@ -919,9 +1109,12 @@ def build_comparison_report(path: Path, actual: dict[str, Any]) -> dict[str, Any
"changed": [],
}
_compare_semantic_values(expected_semantic, actual_semantic, "$", differences)
+ semantic_match = expected_semantic == actual_semantic
+ if path.name == TRANSACTION_BASELINE_PATH.name:
+ semantic_match = transaction_ratchet_matches(expected, actual)
return {
"baseline": str(_display_path(path)),
- "semantic_match": expected_semantic == actual_semantic,
+ "semantic_match": semantic_match,
"expected_provenance": expected.get("provenance"),
"actual_provenance": actual.get("provenance"),
**differences,
@@ -936,6 +1129,21 @@ def check_json(
) -> bool:
"""比较当前扫描结果和已提交基线并输出限定范围的更新提示。"""
expected = json.loads(path.read_text(encoding="utf-8"))
+ if path.name == TRANSACTION_BASELINE_PATH.name:
+ if transaction_ratchet_matches(expected, actual):
+ if expected != actual:
+ print(
+ "事务债务已下降;门禁继续通过,可在本任务提交中显式运行 "
+ "scripts/architecture/baseline.py --write-host 固化新低水位",
+ file=sys.stderr,
+ )
+ return True
+ print(
+ f"事务债务出现新增:{_display_path(path)};"
+ "Model 自动事务、直接 commit/rollback 或 Oper 自建 Session 不得增长",
+ file=sys.stderr,
+ )
+ return False
expected_semantic = semantic_baseline(path, expected)
actual_semantic = semantic_baseline(path, actual)
if expected_semantic == actual_semantic:
@@ -1016,6 +1224,7 @@ def main(argv: Optional[list[str]] = None) -> int:
baselines = [
(DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()),
(RUNTIME_BASELINE_PATH, collect_runtime_baseline()),
+ (TRANSACTION_BASELINE_PATH, collect_transaction_debt_baseline()),
]
write_hint = "--write-host"
else:
diff --git a/tests/fixtures/architecture/transaction-debt-baseline.json b/tests/fixtures/architecture/transaction-debt-baseline.json
new file mode 100644
index 000000000..6bdf7c5ea
--- /dev/null
+++ b/tests/fixtures/architecture/transaction-debt-baseline.json
@@ -0,0 +1,921 @@
+{
+ "model_decorators": {
+ "by_kind": {
+ "async_db_query": 49,
+ "async_db_update": 13,
+ "db_query": 75,
+ "db_update": 41
+ },
+ "count": 178,
+ "methods": [
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/agentchat.py",
+ "method": "AgentChat.async_get_by_session"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/agentchat.py",
+ "method": "AgentChat.async_list_by_page"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/agentchat.py",
+ "method": "AgentChat.get_by_session"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/agentchat.py",
+ "method": "AgentChat.list_by_page"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/agenttask.py",
+ "method": "AgentTask.add_task"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/agenttask.py",
+ "method": "AgentTask.get_for_user"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/agenttask.py",
+ "method": "AgentTask.list_for_user"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/agenttask.py",
+ "method": "AgentTask.update_task"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/agenttaskrun.py",
+ "method": "AgentTaskRun.begin_run"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/agenttaskrun.py",
+ "method": "AgentTaskRun.delete_task_and_runs"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/agenttaskrun.py",
+ "method": "AgentTaskRun.finish_run"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/agenttaskrun.py",
+ "method": "AgentTaskRun.get_by_run_id"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/agenttaskrun.py",
+ "method": "AgentTaskRun.interrupt_task"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/agenttaskrun.py",
+ "method": "AgentTaskRun.list_for_task"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/downloadfailure.py",
+ "method": "DownloadFailure.delete_expired"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadfailure.py",
+ "method": "DownloadFailure.get_active_by_fingerprints"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/downloadfailure.py",
+ "method": "DownloadFailure.record_failure"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadFiles.delete_by_fullpath"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadFiles.delete_orphans"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadFiles.get_by_fullpath"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadFiles.get_by_hash"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadFiles.get_by_savepath"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.async_count"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.async_count_by_title"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.async_list_by_page"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.async_list_by_title"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.delete_before"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.get_by_hash"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.get_by_hashes"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.get_by_media_identity"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.get_by_path"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.get_last_by"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.list_by_date"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.list_by_page"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.list_by_type"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/downloadhistory.py",
+ "method": "DownloadHistory.list_by_user_date"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.async_exist_by_media_identity"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.async_exists_by_title"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.async_get_by_itemid"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.delete_excluded_servers"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.delete_stale"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.empty"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.exist_by_media_identity"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.exists_by_title"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.get_by_itemid"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/mediaserver.py",
+ "method": "MediaServerItem.get_by_server_itemid"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/message.py",
+ "method": "Message.async_list_by_page"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/message.py",
+ "method": "Message.async_list_sent_by_page"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/message.py",
+ "method": "Message.create_and_to_dict"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/message.py",
+ "method": "Message.delete_before"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/message.py",
+ "method": "Message.exists_by_source"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/message.py",
+ "method": "Message.list_by_page"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/passkey.py",
+ "method": "PassKey.async_delete_by_id"
+ },
+ {
+ "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": "async_db_update",
+ "file": "app/db/models/passkey.py",
+ "method": "PassKey.async_update_last_used"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/passkey.py",
+ "method": "PassKey.delete_by_id"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/passkey.py",
+ "method": "PassKey.get_by_credential_id"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/passkey.py",
+ "method": "PassKey.get_by_id"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/passkey.py",
+ "method": "PassKey.get_by_user_id"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/passkey.py",
+ "method": "PassKey.update_last_used"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.async_get_plugin_data"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.async_get_plugin_data_by_key"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.async_get_plugin_data_by_plugin_id"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.del_plugin_data"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.del_plugin_data_by_key"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.get_plugin_data"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.get_plugin_data_by_key"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/plugindata.py",
+ "method": "PluginData.get_plugin_data_by_plugin_id"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.async_get_actives"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.async_get_by_domain"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.async_get_by_name"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.async_list_order_by_pri"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/site.py",
+ "method": "Site.async_reset"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.get_actives"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.get_by_domain"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.get_domains_by_ids"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/site.py",
+ "method": "Site.list_order_by_pri"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/site.py",
+ "method": "Site.reset"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/siteicon.py",
+ "method": "SiteIcon.async_get_by_domain"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/siteicon.py",
+ "method": "SiteIcon.get_by_domain"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/sitestatistic.py",
+ "method": "SiteStatistic.async_get_by_domain"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/sitestatistic.py",
+ "method": "SiteStatistic.get_by_domain"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/sitestatistic.py",
+ "method": "SiteStatistic.reset"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/siteuserdata.py",
+ "method": "SiteUserData.async_get_by_domain"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/siteuserdata.py",
+ "method": "SiteUserData.async_get_latest"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/siteuserdata.py",
+ "method": "SiteUserData.delete_before"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/siteuserdata.py",
+ "method": "SiteUserData.get_by_date"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/siteuserdata.py",
+ "method": "SiteUserData.get_by_domain"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/siteuserdata.py",
+ "method": "SiteUserData.get_latest"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_delete_by_media_identity"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_exists"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_exists_by_username"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_get_by"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_get_by_state"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_get_by_title"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_list_by_media_identity"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_list_by_title"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_list_by_type"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.async_list_by_username"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.delete_by_media_identity"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.exists"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.exists_by_username"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.get_by"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.get_by_state"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.get_by_title"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.list_by_media_identity"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.list_by_type"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribe.py",
+ "method": "Subscribe.list_by_username"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribehistory.py",
+ "method": "SubscribeHistory.async_exists"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribehistory.py",
+ "method": "SubscribeHistory.async_list_by_type"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/subscribehistory.py",
+ "method": "SubscribeHistory.async_list_by_type_and_username"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribehistory.py",
+ "method": "SubscribeHistory.exists"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/subscribehistory.py",
+ "method": "SubscribeHistory.list_by_type"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/systemconfig.py",
+ "method": "SystemConfig.async_get_by_key"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/systemconfig.py",
+ "method": "SystemConfig.delete_by_key"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/systemconfig.py",
+ "method": "SystemConfig.get_by_key"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.async_count"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.async_count_by_title"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.async_list_by_page"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.async_list_by_title"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.async_statistic"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.count"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.count_by_title"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.delete_before"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.get_by_dest"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.get_by_hash"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.get_by_media_identity"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.get_by_src"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.get_success_by_src"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.list_by"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.list_by_date"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.list_by_hash"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.list_by_page"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.list_by_title"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.list_success_by_src"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.list_success_move_by_dest"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.monthly_media_statistics"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.replace_by_src"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.statistic"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/transferhistory.py",
+ "method": "TransferHistory.update_download_hash"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/transferpending.py",
+ "method": "TransferPending.clear"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/transferpending.py",
+ "method": "TransferPending.discard"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/transferpending.py",
+ "method": "TransferPending.list_all"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/transferpending.py",
+ "method": "TransferPending.register"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/user.py",
+ "method": "User.async_delete_by_id"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/user.py",
+ "method": "User.async_delete_by_name"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/user.py",
+ "method": "User.async_get_by_id"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/user.py",
+ "method": "User.async_get_by_name"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/user.py",
+ "method": "User.async_update_otp_by_name"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/user.py",
+ "method": "User.delete_by_id"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/user.py",
+ "method": "User.delete_by_name"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/user.py",
+ "method": "User.get_by_id"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/user.py",
+ "method": "User.get_by_name"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/user.py",
+ "method": "User.update_otp_by_name"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/userconfig.py",
+ "method": "UserConfig.delete_by_key"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/userconfig.py",
+ "method": "UserConfig.get_by_key"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_fail"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_get_by_name"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_get_enabled_workflows"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_get_event_triggered_workflows"
+ },
+ {
+ "decorator": "async_db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_get_timer_triggered_workflows"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_reset"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_start"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_success"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_update_current_action"
+ },
+ {
+ "decorator": "async_db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.async_update_state"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.fail"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.get_by_name"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.get_enabled_workflows"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.get_event_triggered_workflows"
+ },
+ {
+ "decorator": "db_query",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.get_timer_triggered_workflows"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.reset"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.start"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.success"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.update_current_action"
+ },
+ {
+ "decorator": "db_update",
+ "file": "app/db/models/workflow.py",
+ "method": "Workflow.update_state"
+ }
+ ]
+ },
+ "model_session_factories": {
+ "calls": [],
+ "count": 0
+ },
+ "model_transaction_calls": {
+ "calls": [],
+ "count": 0
+ },
+ "oper_session_factories": {
+ "calls": [],
+ "count": 0
+ },
+ "oper_transaction_calls": {
+ "calls": [],
+ "count": 0
+ },
+ "schema_version": 1,
+ "scope": "app/db/models and app/db/oper transaction ownership debt"
+}
diff --git a/tests/test_architecture_baseline_cli.py b/tests/test_architecture_baseline_cli.py
index af50e5b40..2ddd3175b 100644
--- a/tests/test_architecture_baseline_cli.py
+++ b/tests/test_architecture_baseline_cli.py
@@ -45,6 +45,28 @@ def _performance_sample(*, loaded_module_count: int = 10) -> dict:
}
+def _transaction_sample(methods: list[dict[str, str]]) -> dict:
+ """构造最小事务债务 fixture,供单向 ratchet 行为测试。"""
+ return {
+ "schema_version": 1,
+ "scope": "app/db/models and app/db/oper transaction ownership debt",
+ "model_decorators": {
+ "count": len(methods),
+ "by_kind": {
+ "async_db_query": 0,
+ "async_db_update": 0,
+ "db_query": 0,
+ "db_update": len(methods),
+ },
+ "methods": methods,
+ },
+ "model_transaction_calls": {"count": 0, "calls": []},
+ "model_session_factories": {"count": 0, "calls": []},
+ "oper_transaction_calls": {"count": 0, "calls": []},
+ "oper_session_factories": {"count": 0, "calls": []},
+ }
+
+
def test_architecture_legacy_action_requires_scope(capsys):
"""旧操作未明确宿主或插件范围时必须拒绝执行。"""
with pytest.raises(SystemExit) as error:
@@ -209,6 +231,34 @@ def test_plugin_v2_fixture_migrates_before_semantic_comparison(tmp_path: Path):
) == architecture_baseline.semantic_baseline(baseline_path, new_value)
+def test_transaction_ratchet_allows_removal_but_rejects_new_method() -> None:
+ """事务债务低水位允许下降,替换或新增 Model 自动事务仍必须失败。"""
+ first = {
+ "decorator": "db_update",
+ "file": "app/db/models/demo.py",
+ "method": "Demo.save",
+ }
+ second = {
+ "decorator": "db_update",
+ "file": "app/db/models/demo.py",
+ "method": "Demo.delete",
+ }
+ expected = _transaction_sample([first])
+
+ assert architecture_baseline.transaction_ratchet_matches(
+ expected,
+ _transaction_sample([]),
+ )
+ assert not architecture_baseline.transaction_ratchet_matches(
+ expected,
+ _transaction_sample([first, second]),
+ )
+ assert not architecture_baseline.transaction_ratchet_matches(
+ expected,
+ _transaction_sample([second]),
+ )
+
+
def test_architecture_write_host_only_updates_host_files(
tmp_path: Path,
monkeypatch,
@@ -217,6 +267,7 @@ def test_architecture_write_host_only_updates_host_files(
"""宿主写操作不得连带覆盖官方插件 fixture。"""
dependency_path = tmp_path / "dependency.json"
runtime_path = tmp_path / "runtime.json"
+ transaction_path = tmp_path / "transaction.json"
plugin_path = tmp_path / "plugin.json"
monkeypatch.setattr(
architecture_baseline,
@@ -228,6 +279,11 @@ def test_architecture_write_host_only_updates_host_files(
"RUNTIME_BASELINE_PATH",
runtime_path,
)
+ monkeypatch.setattr(
+ architecture_baseline,
+ "TRANSACTION_BASELINE_PATH",
+ transaction_path,
+ )
monkeypatch.setattr(architecture_baseline, "PLUGIN_BASELINE_PATH", plugin_path)
monkeypatch.setattr(
architecture_baseline,
@@ -239,16 +295,25 @@ def test_architecture_write_host_only_updates_host_files(
"collect_runtime_baseline",
lambda: {"scope": "host-runtime"},
)
+ monkeypatch.setattr(
+ architecture_baseline,
+ "collect_transaction_debt_baseline",
+ lambda: {"scope": "host-transaction"},
+ )
assert architecture_baseline.main(["--write-host"]) == 0
assert json.loads(dependency_path.read_text()) == {"scope": "host-dependency"}
assert json.loads(runtime_path.read_text()) == {"scope": "host-runtime"}
+ assert json.loads(transaction_path.read_text()) == {
+ "scope": "host-transaction"
+ }
assert not plugin_path.exists()
output = capsys.readouterr().out
assert "即将写入" in output
assert "dependency.json" in output
assert "runtime.json" in output
+ assert "transaction.json" in output
def test_architecture_write_plugins_only_updates_plugin_file(
@@ -261,6 +326,7 @@ def test_architecture_write_plugins_only_updates_plugin_file(
(plugin_repo / "plugins.v3").mkdir()
dependency_path = tmp_path / "dependency.json"
runtime_path = tmp_path / "runtime.json"
+ transaction_path = tmp_path / "transaction.json"
plugin_path = tmp_path / "plugin.json"
monkeypatch.setattr(
architecture_baseline,
@@ -272,6 +338,11 @@ def test_architecture_write_plugins_only_updates_plugin_file(
"RUNTIME_BASELINE_PATH",
runtime_path,
)
+ monkeypatch.setattr(
+ architecture_baseline,
+ "TRANSACTION_BASELINE_PATH",
+ transaction_path,
+ )
monkeypatch.setattr(architecture_baseline, "PLUGIN_BASELINE_PATH", plugin_path)
assert architecture_baseline.main(
@@ -281,6 +352,7 @@ def test_architecture_write_plugins_only_updates_plugin_file(
assert plugin_path.is_file()
assert not dependency_path.exists()
assert not runtime_path.exists()
+ assert not transaction_path.exists()
def test_architecture_plugin_check_writes_review_report_only_when_requested(
diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py
index d8304bd23..8d21c88c1 100644
--- a/tests/test_architecture_contract_baseline.py
+++ b/tests/test_architecture_contract_baseline.py
@@ -16,6 +16,7 @@ def test_architecture_contract_baselines_match_current_source():
baseline_paths = (
BASELINE_ROOT / "dependency-baseline.json",
BASELINE_ROOT / "runtime-contract-baseline.json",
+ BASELINE_ROOT / "transaction-debt-baseline.json",
)
contents_before = {
path: path.read_bytes()
@@ -56,6 +57,18 @@ def test_official_plugin_baseline_records_external_source():
)
+def test_dependency_baseline_records_nonempty_host_graph() -> None:
+ """宿主依赖 fixture 不得因收集器提前返回而被静默写成空值。"""
+ baseline_path = BASELINE_ROOT / "dependency-baseline.json"
+ baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
+
+ assert baseline["schema_version"] == 1
+ assert baseline["module_count"] == len(baseline["modules"])
+ assert baseline["edge_count"] == len(baseline["edges"])
+ assert baseline["module_count"] > 0
+ assert baseline["edge_count"] > 0
+
+
def test_official_discovery_plugins_explicitly_keep_host_page_envelope():
"""宿主探索页消费的官方插件 API 不得依赖动态路由隐式包装。"""
baseline_path = BASELINE_ROOT / "official-plugin-baseline.json"
@@ -105,6 +118,20 @@ def test_runtime_contract_baseline_excludes_diagnostic_line_numbers():
assert '"line"' not in json.dumps(baseline)
+def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
+ """事务 fixture 必须冻结存量 Model 自动提交,并保持 Oper 自提交为零。"""
+ baseline_path = BASELINE_ROOT / "transaction-debt-baseline.json"
+ baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
+
+ assert baseline["schema_version"] == 1
+ assert baseline["model_decorators"]["count"] == 178
+ assert sum(baseline["model_decorators"]["by_kind"].values()) == 178
+ assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
+ assert baseline["model_session_factories"] == {"count": 0, "calls": []}
+ assert baseline["oper_transaction_calls"] == {"count": 0, "calls": []}
+ assert baseline["oper_session_factories"] == {"count": 0, "calls": []}
+
+
def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources():
"""非功能基线必须同时记录正常/安全模式和隔离资源增量。"""
baseline_path = BASELINE_ROOT / "startup-performance-baseline.json"
diff --git a/tests/test_db_session_lifecycle.py b/tests/test_db_session_lifecycle.py
index d24a00ee5..fabf5dd5c 100644
--- a/tests/test_db_session_lifecycle.py
+++ b/tests/test_db_session_lifecycle.py
@@ -6,9 +6,12 @@
缓存的池化引擎,漏掉任何一类都是连接泄漏。
"""
import asyncio
+import threading
+from concurrent.futures import ThreadPoolExecutor
from unittest.mock import AsyncMock, MagicMock
import pytest
+from sqlalchemy.orm import scoped_session, sessionmaker
from app.runtime.config import global_vars, settings
from app.db import engine as engine_module
@@ -90,6 +93,69 @@ def test_get_async_db_yields_session_from_scope(monkeypatch):
assert used == ["enter", "exit"], "会话作用域未正确进入/退出"
+def test_scoped_sessions_are_not_shared_across_worker_threads(monkeypatch):
+ """同步入口必须为并行工作线程提供不同 Session 实例。"""
+ registry = scoped_session(sessionmaker())
+ barrier = threading.Barrier(2)
+ monkeypatch.setattr(session_module, "_scoped_session", registry)
+
+ def open_in_thread() -> int:
+ """在线程内持有会话直到另一个线程也完成解析。"""
+ session = session_module.ScopedSession()
+ try:
+ barrier.wait(timeout=5)
+ return id(session)
+ finally:
+ registry.remove()
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ session_ids = list(executor.map(lambda _: open_in_thread(), range(2)))
+
+ assert len(set(session_ids)) == 2
+
+
+def test_async_session_scopes_are_not_shared_across_tasks(monkeypatch):
+ """并发异步任务必须各自创建和关闭 AsyncSession 作用域。"""
+ created: list[object] = []
+
+ class FakeAsyncSession:
+ """记录每次作用域构造的独立异步会话替身。"""
+
+ def __init__(self, **_kwargs) -> None:
+ """创建可由异步上下文管理器返回的唯一实例。"""
+ created.append(self)
+
+ async def __aenter__(self):
+ """返回当前会话实例。"""
+ return self
+
+ async def __aexit__(self, *_exc) -> bool:
+ """模拟正常释放且不吞掉异常。"""
+ return False
+
+ monkeypatch.setattr(
+ session_module,
+ "_resolve_async_engine",
+ lambda: (object(), True),
+ )
+ monkeypatch.setattr(session_module, "AsyncSession", FakeAsyncSession)
+
+ async def open_in_task() -> int:
+ """进入一个任务私有的异步会话作用域。"""
+ async with session_module.async_session_scope() as session:
+ await asyncio.sleep(0)
+ return id(session)
+
+ async def run() -> list[int]:
+ """并发执行两个会话作用域。"""
+ return await asyncio.gather(open_in_task(), open_in_task())
+
+ session_ids = asyncio.run(run())
+
+ assert len(created) == 2
+ assert len(set(session_ids)) == 2
+
+
def test_close_database_disposes_pooled_engines(monkeypatch):
"""
close_database 必须释放按事件循环缓存的池化引擎。