mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor: migrate high-risk write transactions
This commit is contained in:
+4
-1
@@ -307,7 +307,10 @@ def get_agent_chat_service(
|
|||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
) -> AgentChatService:
|
) -> AgentChatService:
|
||||||
"""组装 Agent 会话历史查询和删除服务。"""
|
"""组装 Agent 会话历史查询和删除服务。"""
|
||||||
return AgentChatService(repository=_repository("agent_chat", db))
|
return AgentChatService(
|
||||||
|
repository=_repository("agent_chat", db),
|
||||||
|
unit_of_work=_transaction("async", db),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_mediaserver_query_service(
|
def get_mediaserver_query_service(
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ class AsyncAgentChatRepository(Protocol):
|
|||||||
"""删除指定服务端会话。"""
|
"""删除指定服务端会话。"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
async def async_stage_delete(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""暂存删除指定服务端会话,不提交调用方事务。"""
|
||||||
|
...
|
||||||
|
|
||||||
def get(self, session_id: str, user_id: Optional[str] = None) -> Optional[Any]:
|
def get(self, session_id: str, user_id: Optional[str] = None) -> Optional[Any]:
|
||||||
"""同步读取服务端会话。"""
|
"""同步读取服务端会话。"""
|
||||||
...
|
...
|
||||||
@@ -83,12 +91,29 @@ class AgentChatRecord:
|
|||||||
messages: list[dict]
|
messages: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncUnitOfWork(Protocol):
|
||||||
|
"""Agent 会话异步写用例所需的最小事务端口。"""
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
"""提交当前请求事务。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
"""回滚当前请求事务。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class AgentChatService:
|
class AgentChatService:
|
||||||
"""统一执行 Agent 会话查询、访问控制和删除。"""
|
"""统一执行 Agent 会话查询、访问控制和删除。"""
|
||||||
|
|
||||||
def __init__(self, repository: AsyncAgentChatRepository) -> None:
|
def __init__(
|
||||||
"""保存异步会话持久化端口。"""
|
self,
|
||||||
|
repository: AsyncAgentChatRepository,
|
||||||
|
unit_of_work: Optional[AsyncUnitOfWork] = None,
|
||||||
|
) -> None:
|
||||||
|
"""保存会话持久化端口和可选请求级事务。"""
|
||||||
self._repository = repository
|
self._repository = repository
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
|
||||||
async def list(
|
async def list(
|
||||||
self,
|
self,
|
||||||
@@ -137,7 +162,18 @@ class AgentChatService:
|
|||||||
record = await self.get_accessible(session_id, principal)
|
record = await self.get_accessible(session_id, principal)
|
||||||
if record is None:
|
if record is None:
|
||||||
return False
|
return False
|
||||||
return await self._repository.async_delete(session_id=session_id)
|
if self._unit_of_work is None:
|
||||||
|
return await self._repository.async_delete(session_id=session_id)
|
||||||
|
try:
|
||||||
|
deleted = await self._repository.async_stage_delete(
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
if deleted:
|
||||||
|
await self._unit_of_work.commit()
|
||||||
|
return deleted
|
||||||
|
except Exception:
|
||||||
|
await self._unit_of_work.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
def get_sync(self, session_id: str) -> Optional[AgentChatRecord]:
|
def get_sync(self, session_id: str) -> Optional[AgentChatRecord]:
|
||||||
"""同步读取会话投影,供同步 Agent 编排路径使用。"""
|
"""同步读取会话投影,供同步 Agent 编排路径使用。"""
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""插件持久化数据写用例。"""
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class PluginDataMutationRepository(Protocol):
|
||||||
|
"""插件数据删除命令所需的无提交仓储端口。"""
|
||||||
|
|
||||||
|
def stage_delete(self, plugin_id: str) -> None:
|
||||||
|
"""暂存目标插件的全部持久化数据删除。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class UnitOfWork(Protocol):
|
||||||
|
"""插件数据同步写用例所需的事务端口。"""
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
"""提交当前逻辑操作。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
"""回滚当前逻辑操作。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class DeletePluginDataCommand:
|
||||||
|
"""在一个显式事务中删除目标插件的全部持久化数据。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: PluginDataMutationRepository,
|
||||||
|
unit_of_work: UnitOfWork,
|
||||||
|
) -> None:
|
||||||
|
"""保存无提交仓储和事务所有者。"""
|
||||||
|
self._repository = repository
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
|
||||||
|
def execute(self, plugin_id: str) -> None:
|
||||||
|
"""暂存并提交删除;任一步失败时回滚并传播原异常。"""
|
||||||
|
try:
|
||||||
|
self._repository.stage_delete(plugin_id)
|
||||||
|
self._unit_of_work.commit()
|
||||||
|
except Exception:
|
||||||
|
self._unit_of_work.rollback()
|
||||||
|
raise
|
||||||
@@ -314,6 +314,21 @@ class AgentChatOper(DbOper):
|
|||||||
await AgentChat.async_delete(self._db, chat.id)
|
await AgentChat.async_delete(self._db, chat.id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def async_stage_delete(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""暂存 Agent 会话删除并 flush,不提交请求级事务。"""
|
||||||
|
if not isinstance(self._db, AsyncSession):
|
||||||
|
raise RuntimeError("Agent 会话暂存删除需要调用方提供 AsyncSession")
|
||||||
|
chat = await self.async_get(session_id=session_id, user_id=user_id)
|
||||||
|
if not chat:
|
||||||
|
return False
|
||||||
|
await self._db.delete(chat)
|
||||||
|
await self._db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def to_summary(chat: AgentChat) -> dict[str, Any]:
|
def to_summary(chat: AgentChat) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.base import DbOper
|
from app.db.base import DbOper
|
||||||
from app.db.models.plugindata import PluginData
|
from app.db.models.plugindata import PluginData
|
||||||
|
|
||||||
@@ -82,6 +85,15 @@ class PluginDataOper(DbOper):
|
|||||||
else:
|
else:
|
||||||
PluginData.del_plugin_data(self._db, plugin_id)
|
PluginData.del_plugin_data(self._db, plugin_id)
|
||||||
|
|
||||||
|
def stage_delete(self, plugin_id: str) -> None:
|
||||||
|
"""暂存目标插件全部数据删除并 flush,不提交调用方事务。"""
|
||||||
|
if not isinstance(self._db, Session):
|
||||||
|
raise RuntimeError("插件数据暂存删除需要调用方提供 Session")
|
||||||
|
self._db.execute(
|
||||||
|
delete(PluginData).where(PluginData.plugin_id == plugin_id)
|
||||||
|
)
|
||||||
|
self._db.flush()
|
||||||
|
|
||||||
def truncate(self):
|
def truncate(self):
|
||||||
"""
|
"""
|
||||||
清空插件数据
|
清空插件数据
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.runtime.extensions.plugin_manager import (
|
|||||||
)
|
)
|
||||||
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
|
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
|
||||||
from app.application.plugin.catalog import PluginCatalogService
|
from app.application.plugin.catalog import PluginCatalogService
|
||||||
|
from app.application.plugin.data import DeletePluginDataCommand
|
||||||
from app.adapters.external.plugin.client import PluginMarketClient
|
from app.adapters.external.plugin.client import PluginMarketClient
|
||||||
from app.runtime.extensions.plugin.storage import (
|
from app.runtime.extensions.plugin.storage import (
|
||||||
PluginStorage,
|
PluginStorage,
|
||||||
@@ -41,6 +42,8 @@ from app.adapters.system.plugin.package import PluginPackageManager
|
|||||||
from app.adapters.system.host import SystemUtils
|
from app.adapters.system.host import SystemUtils
|
||||||
from app.db.oper.plugindata import PluginDataOper
|
from app.db.oper.plugindata import PluginDataOper
|
||||||
from app.db.oper.systemconfig import SystemConfigOper
|
from app.db.oper.systemconfig import SystemConfigOper
|
||||||
|
from app.db.session import SessionFactory
|
||||||
|
from app.db.uow import SqlAlchemyUnitOfWork
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.foundation.version import compare_version
|
from app.foundation.version import compare_version
|
||||||
from app.schemas.plugin import PluginRuntimeStatus
|
from app.schemas.plugin import PluginRuntimeStatus
|
||||||
@@ -52,6 +55,18 @@ async def _async_write_plugin_config(key, value):
|
|||||||
return await SystemConfigOper().async_set(key, value)
|
return await SystemConfigOper().async_set(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_plugin_data(plugin_id: str) -> None:
|
||||||
|
"""用独占同步会话执行插件重置的数据删除事务。"""
|
||||||
|
session = SessionFactory()
|
||||||
|
try:
|
||||||
|
DeletePluginDataCommand(
|
||||||
|
repository=PluginDataOper(session),
|
||||||
|
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||||
|
).execute(plugin_id)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None:
|
def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None:
|
||||||
"""在执行旧插件顶层代码前准备其静态导入所需的宿主资源。"""
|
"""在执行旧插件顶层代码前准备其静态导入所需的宿主资源。"""
|
||||||
for capability_id in scan_plugin_resource_imports(plugin_id, plugin_dir):
|
for capability_id in scan_plugin_resource_imports(plugin_id, plugin_dir):
|
||||||
@@ -98,7 +113,7 @@ def configure_plugin_services() -> None:
|
|||||||
write=lambda key, value: SystemConfigOper().set(key, value),
|
write=lambda key, value: SystemConfigOper().set(key, value),
|
||||||
async_write=_async_write_plugin_config,
|
async_write=_async_write_plugin_config,
|
||||||
delete=lambda key: SystemConfigOper().delete(key),
|
delete=lambda key: SystemConfigOper().delete(key),
|
||||||
delete_data=lambda plugin_id: PluginDataOper().del_data(plugin_id),
|
delete_data=_delete_plugin_data,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,9 @@ flowchart LR
|
|||||||
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
|
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
|
||||||
只查重、`add` 和 `flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
|
只查重、`add` 和 `flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
|
||||||
`transaction-debt-baseline.json` 将存量 178 个 Model 事务装饰器冻结为只降不增低水位。
|
`transaction-debt-baseline.json` 将存量 178 个 Model 事务装饰器冻结为只降不增低水位。
|
||||||
|
- 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application
|
||||||
|
Command/Service 持有 UoW,Oper 的 `stage_*` 方法只修改当前会话。插件数据重置从
|
||||||
|
`startup/plugins_initializer.py` 创建独占会话,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。
|
||||||
- 每次表结构变更必须新增 `database/versions/` 下的 Alembic 迁移。
|
- 每次表结构变更必须新增 `database/versions/` 下的 Alembic 迁移。
|
||||||
- 运行期业务配置使用 `SystemConfigKey` 枚举 + `SystemConfigOper`,禁止裸字符串键;
|
- 运行期业务配置使用 `SystemConfigKey` 枚举 + `SystemConfigOper`,禁止裸字符串键;
|
||||||
用户级配置使用 `UserConfigOper`。
|
用户级配置使用 `UserConfigOper`。
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
||||||
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
||||||
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
|
> 相关文档:`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)与 ARCH-220~221 已完成,后续任务按 ID 独立提交和回滚
|
> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)与阶段 2(ARCH-220~222)已完成,后续任务按 ID 独立提交和回滚
|
||||||
|
|
||||||
## 1. 结论先行
|
## 1. 结论先行
|
||||||
|
|
||||||
@@ -399,6 +399,20 @@ flowchart TB
|
|||||||
|
|
||||||
每个切片沿用 ARCH-221,不允许批量移动全部 Model 方法。查询方法可在写边界稳定后再迁移。
|
每个切片沿用 ARCH-221,不允许批量移动全部 Model 方法。查询方法可在写边界稳定后再迁移。
|
||||||
|
|
||||||
|
**实施记录(2026-08-21)**:
|
||||||
|
|
||||||
|
| 风险域 | 规范事务入口 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 站点配置 | `SiteMutationCommand` + Async UoW | create/update/priorities/delete/reset 均先 stage 再 commit |
|
||||||
|
| 下载/整理历史 | `DownloadHistoryMutationCommand`、`TransferHistoryMutationCommand` + Sync UoW | 多表删除与文件副作用顺序已有聚焦回归 |
|
||||||
|
| 工作流 | `WorkflowMutationCommand`、`WorkflowDefinitionCommand` + Sync/Async UoW | 定义写入提交后才刷新 timer/event |
|
||||||
|
| Agent chat | `AgentChatService` + 请求级 Async UoW | API 会话删除改为 `async_stage_delete()`;失败回滚、缺失不提交 |
|
||||||
|
| 插件数据重置 | `DeletePluginDataCommand` + 独占 Sync Session/UoW | `PluginDataOper.stage_delete()` 只 DELETE/flush;重置链由 startup 装配 |
|
||||||
|
|
||||||
|
旧插件与宿主存量代码直接构造 `PluginDataOper`、`AgentChatOper` 的行为继续保留;新 API 和插件
|
||||||
|
重置链不得回退到这些自动提交兼容方法。五类矩阵聚焦测试共 57 项通过,事务 ratchet 仍为
|
||||||
|
178 且没有新增或搬移 Model 装饰器。
|
||||||
|
|
||||||
### 阶段 3:类型化运行时装配,减少全局服务定位
|
### 阶段 3:类型化运行时装配,减少全局服务定位
|
||||||
|
|
||||||
#### ARCH-230:建立类型化 HostRuntime / AppState
|
#### ARCH-230:建立类型化 HostRuntime / AppState
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ Oper classes accept and return persistence values. Turning a `MediaInfo` or
|
|||||||
UoW and post-commit callback, and `SubscribeOper.stage_add()` only queries,
|
UoW and post-commit callback, and `SubscribeOper.stage_add()` only queries,
|
||||||
adds, and flushes. Preserve `SubscribeOper.add()` only for legacy SDK callers;
|
adds, and flushes. Preserve `SubscribeOper.add()` only for legacy SDK callers;
|
||||||
new host code must not use that auto-commit compatibility path.
|
new host code must not use that auto-commit compatibility path.
|
||||||
|
- The same rule applies to `SiteMutationCommand`, history/workflow commands,
|
||||||
|
`AgentChatService.delete()`, and `DeletePluginDataCommand`: bind the repository
|
||||||
|
and UoW to one request/operation Session. Legacy plugin-facing Oper methods may
|
||||||
|
remain temporarily, but a new endpoint or startup workflow must call `stage_*`.
|
||||||
|
|
||||||
Run `./.venv/bin/python scripts/architecture/baseline.py --check-host` after
|
Run `./.venv/bin/python scripts/architecture/baseline.py --check-host` after
|
||||||
persistence changes. A deliberate debt reduction may refresh the low-water mark
|
persistence changes. A deliberate debt reduction may refresh the low-water mark
|
||||||
|
|||||||
+7
-3
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6092,
|
"edge_count": 6095,
|
||||||
"edge_sha256": "1935d43d8d3c0c3e3687109f0b81a4e56cbc4bc2ed4763a706e2b0d7c119f378",
|
"edge_sha256": "06c09c175ac007c7ef891e2a25f5036c8bc3993a817837c03824bb591168ea73",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -5890,12 +5890,15 @@
|
|||||||
"app.startup.plugins_initializer -> app.application",
|
"app.startup.plugins_initializer -> app.application",
|
||||||
"app.startup.plugins_initializer -> app.application.plugin",
|
"app.startup.plugins_initializer -> app.application.plugin",
|
||||||
"app.startup.plugins_initializer -> app.application.plugin.catalog",
|
"app.startup.plugins_initializer -> app.application.plugin.catalog",
|
||||||
|
"app.startup.plugins_initializer -> app.application.plugin.data",
|
||||||
"app.startup.plugins_initializer -> app.application.plugin.routes",
|
"app.startup.plugins_initializer -> app.application.plugin.routes",
|
||||||
"app.startup.plugins_initializer -> app.application.site",
|
"app.startup.plugins_initializer -> app.application.site",
|
||||||
"app.startup.plugins_initializer -> app.db",
|
"app.startup.plugins_initializer -> app.db",
|
||||||
"app.startup.plugins_initializer -> app.db.oper",
|
"app.startup.plugins_initializer -> app.db.oper",
|
||||||
"app.startup.plugins_initializer -> app.db.oper.plugindata",
|
"app.startup.plugins_initializer -> app.db.oper.plugindata",
|
||||||
"app.startup.plugins_initializer -> app.db.oper.systemconfig",
|
"app.startup.plugins_initializer -> app.db.oper.systemconfig",
|
||||||
|
"app.startup.plugins_initializer -> app.db.session",
|
||||||
|
"app.startup.plugins_initializer -> app.db.uow",
|
||||||
"app.startup.plugins_initializer -> app.foundation",
|
"app.startup.plugins_initializer -> app.foundation",
|
||||||
"app.startup.plugins_initializer -> app.foundation.version",
|
"app.startup.plugins_initializer -> app.foundation.version",
|
||||||
"app.startup.plugins_initializer -> app.runtime",
|
"app.startup.plugins_initializer -> app.runtime",
|
||||||
@@ -6109,7 +6112,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": 757,
|
"module_count": 758,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -6379,6 +6382,7 @@
|
|||||||
"app.application.plugin",
|
"app.application.plugin",
|
||||||
"app.application.plugin.catalog",
|
"app.application.plugin.catalog",
|
||||||
"app.application.plugin.config",
|
"app.application.plugin.config",
|
||||||
|
"app.application.plugin.data",
|
||||||
"app.application.plugin.folders",
|
"app.application.plugin.folders",
|
||||||
"app.application.plugin.install",
|
"app.application.plugin.install",
|
||||||
"app.application.plugin.routes",
|
"app.application.plugin.routes",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Agent 会话删除的请求级事务边界测试。"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.application.messaging.chat import AgentChatService
|
||||||
|
|
||||||
|
|
||||||
|
def _principal() -> SimpleNamespace:
|
||||||
|
"""构造拥有目标会话的普通用户。"""
|
||||||
|
return SimpleNamespace(id=1, name="alice", is_superuser=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _chat() -> SimpleNamespace:
|
||||||
|
"""构造应用服务投影所需的最小会话记录。"""
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=9,
|
||||||
|
session_id="session-9",
|
||||||
|
client_session_id=None,
|
||||||
|
title="事务会话",
|
||||||
|
channel=None,
|
||||||
|
source=None,
|
||||||
|
user_id="1",
|
||||||
|
username="alice",
|
||||||
|
original_chat_id=None,
|
||||||
|
message_count=0,
|
||||||
|
created_at=None,
|
||||||
|
updated_at=None,
|
||||||
|
display_messages=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_stages_then_commits_once() -> None:
|
||||||
|
"""有权限的会话删除只能由 Service 在暂存成功后提交一次。"""
|
||||||
|
calls: list[str] = []
|
||||||
|
repository = Mock()
|
||||||
|
repository.async_get = AsyncMock(return_value=_chat())
|
||||||
|
repository.async_stage_delete = AsyncMock(
|
||||||
|
side_effect=lambda **_kwargs: calls.append("stage") or True
|
||||||
|
)
|
||||||
|
unit_of_work = Mock()
|
||||||
|
unit_of_work.commit = AsyncMock(
|
||||||
|
side_effect=lambda: calls.append("commit")
|
||||||
|
)
|
||||||
|
unit_of_work.rollback = AsyncMock()
|
||||||
|
service = AgentChatService(repository, unit_of_work)
|
||||||
|
|
||||||
|
assert await service.delete("session-9", _principal()) is True
|
||||||
|
|
||||||
|
assert calls == ["stage", "commit"]
|
||||||
|
unit_of_work.rollback.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_rolls_back_flush_failure() -> None:
|
||||||
|
"""暂存删除失败必须回滚并传播原异常。"""
|
||||||
|
error = RuntimeError("flush failed")
|
||||||
|
repository = Mock()
|
||||||
|
repository.async_get = AsyncMock(return_value=_chat())
|
||||||
|
repository.async_stage_delete = AsyncMock(side_effect=error)
|
||||||
|
unit_of_work = Mock()
|
||||||
|
unit_of_work.commit = AsyncMock()
|
||||||
|
unit_of_work.rollback = AsyncMock()
|
||||||
|
service = AgentChatService(repository, unit_of_work)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as raised:
|
||||||
|
await service.delete("session-9", _principal())
|
||||||
|
|
||||||
|
assert raised.value is error
|
||||||
|
unit_of_work.commit.assert_not_awaited()
|
||||||
|
unit_of_work.rollback.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_missing_chat_does_not_open_write_transaction() -> None:
|
||||||
|
"""会话不存在时保持旧 False 返回,且不执行 stage 或 commit。"""
|
||||||
|
repository = Mock()
|
||||||
|
repository.async_get = AsyncMock(return_value=None)
|
||||||
|
repository.async_stage_delete = AsyncMock()
|
||||||
|
unit_of_work = Mock()
|
||||||
|
unit_of_work.commit = AsyncMock()
|
||||||
|
unit_of_work.rollback = AsyncMock()
|
||||||
|
service = AgentChatService(repository, unit_of_work)
|
||||||
|
|
||||||
|
assert await service.delete("missing", _principal()) is False
|
||||||
|
|
||||||
|
repository.async_stage_delete.assert_not_awaited()
|
||||||
|
unit_of_work.commit.assert_not_awaited()
|
||||||
|
unit_of_work.rollback.assert_not_awaited()
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""插件持久化数据删除的事务所有权测试。"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.application.plugin.data import DeletePluginDataCommand
|
||||||
|
from app.db.models.plugindata import PluginData
|
||||||
|
from app.db.oper.plugindata import PluginDataOper
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_plugin_data_stages_then_commits() -> None:
|
||||||
|
"""插件重置必须在仓储暂存完成后由 Application Command 提交。"""
|
||||||
|
calls: list[str] = []
|
||||||
|
repository = Mock()
|
||||||
|
repository.stage_delete.side_effect = lambda _plugin_id: calls.append("stage")
|
||||||
|
unit_of_work = Mock()
|
||||||
|
unit_of_work.commit.side_effect = lambda: calls.append("commit")
|
||||||
|
command = DeletePluginDataCommand(repository, unit_of_work)
|
||||||
|
|
||||||
|
command.execute("Demo")
|
||||||
|
|
||||||
|
assert calls == ["stage", "commit"]
|
||||||
|
repository.stage_delete.assert_called_once_with("Demo")
|
||||||
|
unit_of_work.rollback.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_plugin_data_rolls_back_commit_failure() -> None:
|
||||||
|
"""插件数据删除提交失败必须回滚并保留原异常。"""
|
||||||
|
error = RuntimeError("commit failed")
|
||||||
|
repository = Mock()
|
||||||
|
unit_of_work = Mock()
|
||||||
|
unit_of_work.commit.side_effect = error
|
||||||
|
command = DeletePluginDataCommand(repository, unit_of_work)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as raised:
|
||||||
|
command.execute("Demo")
|
||||||
|
|
||||||
|
assert raised.value is error
|
||||||
|
unit_of_work.rollback.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_data_oper_stage_delete_does_not_commit(db, monkeypatch) -> None:
|
||||||
|
"""Oper 只暂存目标插件删除,其他插件数据与提交权均不受影响。"""
|
||||||
|
db.add(
|
||||||
|
PluginData(plugin_id="Target", key="one", value=1),
|
||||||
|
PluginData(plugin_id="Other", key="two", value=2),
|
||||||
|
)
|
||||||
|
commit = Mock(wraps=db.session.commit)
|
||||||
|
monkeypatch.setattr(db.session, "commit", commit)
|
||||||
|
oper = PluginDataOper(db.session)
|
||||||
|
|
||||||
|
oper.stage_delete("Target")
|
||||||
|
|
||||||
|
remaining = PluginData.get_plugin_data(db.session, "Other")
|
||||||
|
deleted = PluginData.get_plugin_data(db.session, "Target")
|
||||||
|
assert [item.key for item in remaining] == ["two"]
|
||||||
|
assert deleted == []
|
||||||
|
commit.assert_not_called()
|
||||||
|
db.session.rollback()
|
||||||
Reference in New Issue
Block a user