fix(agent-assets): save rule markdown atomically
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# 规则正文与运行时配置分步保存产生孤儿版本
|
||||||
|
|
||||||
|
日期:2026-07-18
|
||||||
|
文档路径:document/development/2026-07-18/dev-logs/bugs/rule-markdown-runtime-atomic-save.md
|
||||||
|
|
||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 16:20:记录 bug 修复:普通 Markdown 规则先创建版本、再更新运行时 JSON,第二个请求失败时会留下不可重试的孤儿版本和版本号冲突。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。
|
||||||
|
- 原因:前端将一个业务动作拆成创建 `AgentAssetVersion` 和 PATCH 资产配置两个独立事务,任一网络、审计或持久化异常都可能只完成前半段。
|
||||||
|
- 修改:新增 `POST /agent-assets/{id}/rule-markdown` 单一接口和专用规则保存 mixin;在一个 SQLAlchemy 事务中创建版本、更新 current/working 指针、白名单合并运行时配置并写审计;Markdown 内嵌 `expense-rule` JSON 必须与运行配置一致;表格规则和 JSON 风险规则继续走各自专用接口。
|
||||||
|
- 修改:前端保存 Markdown 和运行时 JSON 均改为一次原子请求;新增服务和路由按职责拆分,主服务文件 703 行、主路由文件 773 行,均低于项目硬上限。
|
||||||
|
- 验证:成功落库、审计失败、响应构建失败、提交失败与审计记录回滚测试通过;管理模块后端联合回归 `176 passed`,前端全量回归 `824/824`,迁移回归 `66 passed, 1 skipped`,Vite 生产构建、Ruff、Compose 配置解析及 `git diff --check` 均通过。
|
||||||
|
- 影响:规则编辑保存现在具备事务一致性,失败后不会留下半成品版本,也不会因为孤儿版本阻塞下一次保存。
|
||||||
70
server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py
Normal file
70
server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext, get_db, require_rule_editor_user
|
||||||
|
from app.schemas.agent_asset import AgentAssetVersionRead, RuleMarkdownUpdate
|
||||||
|
from app.schemas.common import ErrorResponse
|
||||||
|
from app.services.agent_asset_access import stable_user_principal
|
||||||
|
from app.services.agent_assets import AgentAssetService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/agent-assets")
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||||
|
RequestIdHeader = Annotated[
|
||||||
|
str | None,
|
||||||
|
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(current_user: CurrentUserContext) -> str:
|
||||||
|
return stable_user_principal(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_asset_error(exc: Exception) -> None:
|
||||||
|
if isinstance(exc, (LookupError, FileNotFoundError)):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
if isinstance(exc, (PermissionError, ValueError)):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/rule-markdown",
|
||||||
|
response_model=AgentAssetVersionRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="原子保存规则 Markdown 与运行时配置",
|
||||||
|
description=(
|
||||||
|
"在同一数据库事务内创建规则版本、更新工作版本指针,并同步运行时 JSON。"
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "版本重复、规则类型不支持或正文与运行时配置不一致。",
|
||||||
|
},
|
||||||
|
status.HTTP_404_NOT_FOUND: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "资产不存在。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def save_agent_asset_rule_markdown(
|
||||||
|
asset_id: str,
|
||||||
|
payload: RuleMarkdownUpdate,
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetVersionRead:
|
||||||
|
try:
|
||||||
|
payload = payload.model_copy(update={"created_by": _actor(current_user)})
|
||||||
|
return AgentAssetService(db, current_user=current_user).save_rule_markdown(
|
||||||
|
asset_id,
|
||||||
|
payload,
|
||||||
|
actor=_actor(current_user),
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
@@ -2,6 +2,9 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api.v1.endpoints.agent_asset_releases import router as agent_asset_releases_router
|
from app.api.v1.endpoints.agent_asset_releases import router as agent_asset_releases_router
|
||||||
from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_risk_rules_router
|
from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_risk_rules_router
|
||||||
|
from app.api.v1.endpoints.agent_asset_rule_markdown import (
|
||||||
|
router as agent_asset_rule_markdown_router,
|
||||||
|
)
|
||||||
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
|
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
|
||||||
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
|
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
|
||||||
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
||||||
@@ -28,8 +31,8 @@ from app.api.v1.endpoints.expense_application_previews import (
|
|||||||
router as expense_application_previews_router,
|
router as expense_application_previews_router,
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
|
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
|
||||||
from app.api.v1.endpoints.financial_connectors import router as financial_connectors_router
|
|
||||||
from app.api.v1.endpoints.finance_report_configs import router as finance_report_configs_router
|
from app.api.v1.endpoints.finance_report_configs import router as finance_report_configs_router
|
||||||
|
from app.api.v1.endpoints.financial_connectors import router as financial_connectors_router
|
||||||
from app.api.v1.endpoints.health import router as health_router
|
from app.api.v1.endpoints.health import router as health_router
|
||||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||||
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import (
|
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import (
|
||||||
@@ -56,6 +59,7 @@ router.include_router(cfo_value_router, tags=["analytics"])
|
|||||||
router.include_router(commercial_router, tags=["commercial"])
|
router.include_router(commercial_router, tags=["commercial"])
|
||||||
router.include_router(commercial_billing_router, tags=["commercial"])
|
router.include_router(commercial_billing_router, tags=["commercial"])
|
||||||
router.include_router(agent_assets_router, tags=["agent-assets"])
|
router.include_router(agent_assets_router, tags=["agent-assets"])
|
||||||
|
router.include_router(agent_asset_rule_markdown_router, tags=["agent-assets"])
|
||||||
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
|
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
|
||||||
router.include_router(agent_asset_releases_router, tags=["agent-assets"])
|
router.include_router(agent_asset_releases_router, tags=["agent-assets"])
|
||||||
router.include_router(agent_feedback_router, tags=["agent-feedback"])
|
router.include_router(agent_feedback_router, tags=["agent-feedback"])
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class AgentAssetVersionCreate(BaseModel):
|
|||||||
class RuleMarkdownUpdate(BaseModel):
|
class RuleMarkdownUpdate(BaseModel):
|
||||||
version: str = Field(min_length=1, max_length=30)
|
version: str = Field(min_length=1, max_length=30)
|
||||||
content: str
|
content: str
|
||||||
|
config_json: dict[str, Any]
|
||||||
change_note: str | None = None
|
change_note: str | None = None
|
||||||
created_by: str = Field(min_length=1, max_length=100)
|
created_by: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|||||||
130
server/src/app/services/agent_asset_rule_markdown.py
Normal file
130
server/src/app/services/agent_asset_rule_markdown.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""规则 Markdown 与运行时配置的原子保存职责。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.core.agent_enums import AgentAssetContentType, AgentAssetType
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.agent_asset import AgentAssetVersion
|
||||||
|
from app.schemas.agent_asset import AgentAssetVersionRead, RuleMarkdownUpdate
|
||||||
|
from app.services.expense_rule_runtime_defaults import EXPENSE_RULE_CODE_BLOCK_PATTERN
|
||||||
|
|
||||||
|
logger = get_logger("app.services.agent_asset_rule_markdown")
|
||||||
|
|
||||||
|
RULE_RUNTIME_CONFIG_FIELDS = (
|
||||||
|
"runtime_kind",
|
||||||
|
"runtime_rule",
|
||||||
|
"rule_template_key",
|
||||||
|
"rule_template_label",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRuleMarkdownMixin:
|
||||||
|
"""只处理普通 Markdown 规则的版本与运行配置同步。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_rule_markdown_runtime(payload: RuleMarkdownUpdate) -> None:
|
||||||
|
runtime_rule = payload.config_json.get("runtime_rule")
|
||||||
|
if not isinstance(runtime_rule, dict):
|
||||||
|
raise ValueError("运行时配置必须包含 runtime_rule 对象。")
|
||||||
|
|
||||||
|
match = EXPENSE_RULE_CODE_BLOCK_PATTERN.search(payload.content)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError("规则 Markdown 必须包含 expense-rule 运行时配置块。")
|
||||||
|
try:
|
||||||
|
embedded_runtime_rule = json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("规则 Markdown 中的运行时 JSON 不合法。") from exc
|
||||||
|
if embedded_runtime_rule != runtime_rule:
|
||||||
|
raise ValueError("规则 Markdown 与运行时配置不一致。")
|
||||||
|
|
||||||
|
runtime_kind = str(runtime_rule.get("kind") or "").strip()
|
||||||
|
configured_kind = str(payload.config_json.get("runtime_kind") or "").strip()
|
||||||
|
if not runtime_kind or configured_kind != runtime_kind:
|
||||||
|
raise ValueError("runtime_kind 必须与 runtime_rule.kind 保持一致。")
|
||||||
|
|
||||||
|
def save_rule_markdown(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
payload: RuleMarkdownUpdate,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> AgentAssetVersionRead:
|
||||||
|
"""在单个事务内同时保存规则正文、版本指针与运行时配置。"""
|
||||||
|
|
||||||
|
self._ensure_ready()
|
||||||
|
asset = self.repository.get(asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise LookupError("Asset not found")
|
||||||
|
if self.access_scope is not None:
|
||||||
|
self.access_scope.require_write(asset)
|
||||||
|
if asset.asset_type != AgentAssetType.RULE.value:
|
||||||
|
raise ValueError("只有规则资产可以保存规则 Markdown。")
|
||||||
|
|
||||||
|
detail_mode = str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||||
|
if detail_mode in {"spreadsheet", "json_risk"}:
|
||||||
|
raise ValueError("当前规则类型必须使用专用编辑接口。")
|
||||||
|
if self.repository.get_version(asset_id, payload.version):
|
||||||
|
raise ValueError(f"版本号 {payload.version} 已存在")
|
||||||
|
self._validate_rule_markdown_runtime(payload)
|
||||||
|
|
||||||
|
previous_config = asset.config_json or {}
|
||||||
|
before = {
|
||||||
|
**self._asset_snapshot(asset),
|
||||||
|
"runtime_kind": str(previous_config.get("runtime_kind") or ""),
|
||||||
|
"rule_template_key": str(previous_config.get("rule_template_key") or ""),
|
||||||
|
}
|
||||||
|
next_config = dict(previous_config)
|
||||||
|
for field_name in RULE_RUNTIME_CONFIG_FIELDS:
|
||||||
|
if field_name in payload.config_json:
|
||||||
|
next_config[field_name] = payload.config_json[field_name]
|
||||||
|
|
||||||
|
version = AgentAssetVersion(
|
||||||
|
tenant_id=asset.tenant_id,
|
||||||
|
scope=asset.scope,
|
||||||
|
asset_id=asset.id,
|
||||||
|
version=payload.version,
|
||||||
|
content=self._serialize_content(
|
||||||
|
payload.content,
|
||||||
|
AgentAssetContentType.MARKDOWN.value,
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note=payload.change_note,
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.db.add(version)
|
||||||
|
asset.current_version = payload.version
|
||||||
|
asset.working_version = payload.version
|
||||||
|
asset.config_json = next_config
|
||||||
|
self.db.add(asset)
|
||||||
|
self.audit_service.log_action(
|
||||||
|
actor=actor,
|
||||||
|
action="save_rule_markdown",
|
||||||
|
resource_type=asset.asset_type,
|
||||||
|
resource_id=asset.id,
|
||||||
|
before_json=before,
|
||||||
|
after_json={
|
||||||
|
**self._asset_snapshot(asset),
|
||||||
|
"runtime_kind": str(next_config.get("runtime_kind") or ""),
|
||||||
|
"rule_template_key": str(next_config.get("rule_template_key") or ""),
|
||||||
|
},
|
||||||
|
request_id=request_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
self.db.refresh(version)
|
||||||
|
self.db.refresh(asset)
|
||||||
|
saved_version = self._serialize_version(version, asset)
|
||||||
|
self.db.commit()
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Saved rule markdown atomically asset_id=%s version=%s",
|
||||||
|
asset_id,
|
||||||
|
payload.version,
|
||||||
|
)
|
||||||
|
return saved_version
|
||||||
@@ -36,6 +36,7 @@ from app.services.agent_asset_risk_rule_publish import AgentAssetRiskRulePublish
|
|||||||
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
|
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
|
||||||
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
|
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
|
||||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
|
from app.services.agent_asset_rule_markdown import AgentAssetRuleMarkdownMixin
|
||||||
from app.services.agent_asset_serialization import AgentAssetSerializationMixin
|
from app.services.agent_asset_serialization import AgentAssetSerializationMixin
|
||||||
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
|
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
|
||||||
from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHelperMixin
|
from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHelperMixin
|
||||||
@@ -149,6 +150,7 @@ class AgentAssetVersionMixin:
|
|||||||
class AgentAssetService(
|
class AgentAssetService(
|
||||||
AgentAssetSerializationMixin,
|
AgentAssetSerializationMixin,
|
||||||
AgentAssetVersionMixin,
|
AgentAssetVersionMixin,
|
||||||
|
AgentAssetRuleMarkdownMixin,
|
||||||
AgentAssetOnlyOfficeMixin,
|
AgentAssetOnlyOfficeMixin,
|
||||||
AgentAssetSpreadsheetHelperMixin,
|
AgentAssetSpreadsheetHelperMixin,
|
||||||
AgentAssetRiskRuleLevelMixin,
|
AgentAssetRiskRuleLevelMixin,
|
||||||
|
|||||||
207
server/tests/test_agent_asset_rule_markdown_atomicity.py
Normal file
207
server/tests/test_agent_asset_rule_markdown_atomicity.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.deps import get_db
|
||||||
|
from app.core.agent_enums import AgentAssetType
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.schemas.agent_asset import RuleMarkdownUpdate
|
||||||
|
from app.services.agent_assets import AgentAssetService
|
||||||
|
|
||||||
|
|
||||||
|
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
|
def override_db() -> Generator[Session, None, None]:
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_db
|
||||||
|
return TestClient(app), session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def _finance_headers() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"x-auth-username": "finance",
|
||||||
|
"x-auth-name": "Finance",
|
||||||
|
"x-auth-role-codes": "finance",
|
||||||
|
"x-auth-is-admin": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown_rule(db: Session) -> AgentAsset:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
return db.scalar(
|
||||||
|
select(AgentAsset)
|
||||||
|
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
|
||||||
|
.where(AgentAsset.config_json["detail_mode"].as_string().is_(None))
|
||||||
|
.order_by(AgentAsset.code)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(version: str) -> RuleMarkdownUpdate:
|
||||||
|
runtime_rule = {
|
||||||
|
"kind": "policy_rule_draft",
|
||||||
|
"version": 2,
|
||||||
|
"template_key": "general_policy_v1",
|
||||||
|
"rule_name": "原子保存验证规则",
|
||||||
|
"scenario": "expense",
|
||||||
|
"review_required": True,
|
||||||
|
}
|
||||||
|
return RuleMarkdownUpdate(
|
||||||
|
version=version,
|
||||||
|
content=(
|
||||||
|
"# 原子保存验证规则\n\n"
|
||||||
|
"```expense-rule\n"
|
||||||
|
'{"kind":"policy_rule_draft","version":2,'
|
||||||
|
'"template_key":"general_policy_v1",'
|
||||||
|
'"rule_name":"原子保存验证规则",'
|
||||||
|
'"scenario":"expense","review_required":true}\n'
|
||||||
|
"```"
|
||||||
|
),
|
||||||
|
config_json={
|
||||||
|
"runtime_kind": "policy_rule_draft",
|
||||||
|
"runtime_rule": runtime_rule,
|
||||||
|
"rule_template_key": "general_policy_v1",
|
||||||
|
"rule_template_label": "通用制度模板",
|
||||||
|
"unrelated_client_field": "must-not-overwrite-server-config",
|
||||||
|
},
|
||||||
|
change_note="验证 Markdown 与运行配置原子保存。",
|
||||||
|
created_by="untrusted-client-actor",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_rule_markdown_endpoint_commits_version_and_runtime_config_together() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
rule = _markdown_rule(db)
|
||||||
|
assert rule is not None
|
||||||
|
rule_id = rule.id
|
||||||
|
published_version = rule.published_version
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/agent-assets/{rule_id}/rule-markdown",
|
||||||
|
headers=_finance_headers(),
|
||||||
|
json=_payload("v9.0.1").model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
assert response.json()["version"] == "v9.0.1"
|
||||||
|
with session_factory() as db:
|
||||||
|
stored = db.get(AgentAsset, rule_id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.current_version == "v9.0.1"
|
||||||
|
assert stored.working_version == "v9.0.1"
|
||||||
|
assert stored.published_version == published_version
|
||||||
|
assert stored.config_json["runtime_rule"]["version"] == 2
|
||||||
|
assert "unrelated_client_field" not in stored.config_json
|
||||||
|
version = db.scalar(
|
||||||
|
select(AgentAssetVersion).where(
|
||||||
|
AgentAssetVersion.asset_id == rule_id,
|
||||||
|
AgentAssetVersion.version == "v9.0.1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert version is not None
|
||||||
|
assert version.created_by == "username:finance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_rule_markdown_rolls_back_version_and_config_when_audit_fails(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
_, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
rule = _markdown_rule(db)
|
||||||
|
assert rule is not None
|
||||||
|
original_current_version = rule.current_version
|
||||||
|
original_working_version = rule.working_version
|
||||||
|
original_config = deepcopy(rule.config_json)
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
|
||||||
|
def fail_audit(**_kwargs) -> None:
|
||||||
|
raise RuntimeError("injected audit failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service.audit_service, "log_action", fail_audit)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="injected audit failure"):
|
||||||
|
service.save_rule_markdown(
|
||||||
|
rule.id,
|
||||||
|
_payload("v9.0.2"),
|
||||||
|
actor="username:finance",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
stored = db.get(AgentAsset, rule.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.current_version == original_current_version
|
||||||
|
assert stored.working_version == original_working_version
|
||||||
|
assert stored.config_json == original_config
|
||||||
|
assert db.scalar(
|
||||||
|
select(AgentAssetVersion).where(
|
||||||
|
AgentAssetVersion.asset_id == rule.id,
|
||||||
|
AgentAssetVersion.version == "v9.0.2",
|
||||||
|
)
|
||||||
|
) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_rule_markdown_rolls_back_when_response_build_fails(monkeypatch) -> None:
|
||||||
|
_, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
rule = _markdown_rule(db)
|
||||||
|
assert rule is not None
|
||||||
|
original_current_version = rule.current_version
|
||||||
|
original_config = deepcopy(rule.config_json)
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
|
||||||
|
def fail_response(*_args) -> None:
|
||||||
|
raise RuntimeError("injected response failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_serialize_version", fail_response)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="injected response failure"):
|
||||||
|
service.save_rule_markdown(
|
||||||
|
rule.id,
|
||||||
|
_payload("v9.0.3"),
|
||||||
|
actor="username:finance",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
stored = db.get(AgentAsset, rule.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.current_version == original_current_version
|
||||||
|
assert stored.config_json == original_config
|
||||||
|
assert db.scalar(
|
||||||
|
select(AgentAssetVersion).where(
|
||||||
|
AgentAssetVersion.asset_id == rule.id,
|
||||||
|
AgentAssetVersion.version == "v9.0.3",
|
||||||
|
)
|
||||||
|
) is None
|
||||||
|
assert db.scalar(
|
||||||
|
select(AuditLog).where(
|
||||||
|
AuditLog.resource_id == rule.id,
|
||||||
|
AuditLog.action == "save_rule_markdown",
|
||||||
|
)
|
||||||
|
) is None
|
||||||
@@ -405,6 +405,14 @@ export function createAgentAssetVersion(assetId, payload, options = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveAgentAssetRuleMarkdown(assetId, payload, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/rule-markdown`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function createAgentAssetReview(assetId, payload, options = {}) {
|
export function createAgentAssetReview(assetId, payload, options = {}) {
|
||||||
return apiRequest(`/agent-assets/${assetId}/reviews`, {
|
return apiRequest(`/agent-assets/${assetId}/reviews`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
activateAgentAsset,
|
activateAgentAsset,
|
||||||
createAgentAssetVersion,
|
|
||||||
restoreAgentAssetVersion,
|
restoreAgentAssetVersion,
|
||||||
updateAgentAsset
|
saveAgentAssetRuleMarkdown
|
||||||
} from '../../services/agentAssets.js'
|
} from '../../services/agentAssets.js'
|
||||||
import {
|
import {
|
||||||
buildRuleConfigPayload,
|
buildRuleConfigPayload,
|
||||||
@@ -26,16 +25,6 @@ export function useAuditRuleVersionActions({
|
|||||||
resolveActor,
|
resolveActor,
|
||||||
toast
|
toast
|
||||||
}) {
|
}) {
|
||||||
async function persistRuleRuntimeConfig(asset, runtimeRule) {
|
|
||||||
await updateAgentAsset(
|
|
||||||
asset.id,
|
|
||||||
{
|
|
||||||
config_json: buildRuleConfigPayload(asset, runtimeRule)
|
|
||||||
},
|
|
||||||
{ actor: resolveActor() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRuleVersion({ action, changeNote, successLabel }) {
|
async function saveRuleVersion({ action, changeNote, successLabel }) {
|
||||||
if (
|
if (
|
||||||
!selectedSkill.value ||
|
!selectedSkill.value ||
|
||||||
@@ -62,18 +51,18 @@ export function useAuditRuleVersionActions({
|
|||||||
actionState.value = action
|
actionState.value = action
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createAgentAssetVersion(
|
const actor = resolveActor()
|
||||||
|
await saveAgentAssetRuleMarkdown(
|
||||||
selectedSkill.value.id,
|
selectedSkill.value.id,
|
||||||
{
|
{
|
||||||
version: nextVersion,
|
version: nextVersion,
|
||||||
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
|
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
|
||||||
content_type: 'markdown',
|
config_json: buildRuleConfigPayload(selectedSkill.value, runtimeRule),
|
||||||
change_note: changeNote,
|
change_note: changeNote,
|
||||||
created_by: resolveActor()
|
created_by: actor
|
||||||
},
|
},
|
||||||
{ actor: resolveActor() }
|
{ actor }
|
||||||
)
|
)
|
||||||
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
|
|
||||||
await refreshCurrentAssets()
|
await refreshCurrentAssets()
|
||||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||||
toast(`${successLabel} ${nextVersion}。`)
|
toast(`${successLabel} ${nextVersion}。`)
|
||||||
|
|||||||
73
web/tests/audit-rule-markdown-atomic-save.test.mjs
Normal file
73
web/tests/audit-rule-markdown-atomic-save.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useAuditRuleVersionActions } from '../src/views/scripts/useAuditRuleVersionActions.js'
|
||||||
|
|
||||||
|
test('规则 Markdown 与运行时 JSON 通过单一原子接口保存', async (t) => {
|
||||||
|
const requests = []
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
globalThis.fetch = async (url, options) => {
|
||||||
|
requests.push({ url, options })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => ({ version: 'v1.0.1' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshCount = 0
|
||||||
|
let detailCount = 0
|
||||||
|
const notices = []
|
||||||
|
const actionState = ref('')
|
||||||
|
const actions = useAuditRuleVersionActions({
|
||||||
|
selectedSkill: ref({
|
||||||
|
id: 'rule-id',
|
||||||
|
currentVersion: 'v1.0.0',
|
||||||
|
markdownContent: '# 差旅规则',
|
||||||
|
runtimeRuleText: JSON.stringify({
|
||||||
|
kind: 'policy_rule_draft',
|
||||||
|
version: 2,
|
||||||
|
template_key: 'general_policy_v1',
|
||||||
|
rule_name: '差旅规则',
|
||||||
|
scenario: 'travel',
|
||||||
|
review_required: true
|
||||||
|
}),
|
||||||
|
config_json: { preserved: 'server-field' },
|
||||||
|
usesSpreadsheetRule: false
|
||||||
|
}),
|
||||||
|
selectedSkillIsRule: ref(true),
|
||||||
|
canEditMarkdown: ref(true),
|
||||||
|
canManageSelected: ref(true),
|
||||||
|
actionState,
|
||||||
|
detailBusy: ref(false),
|
||||||
|
refreshCurrentAssets: async () => {
|
||||||
|
refreshCount += 1
|
||||||
|
},
|
||||||
|
loadSelectedAssetDetail: async () => {
|
||||||
|
detailCount += 1
|
||||||
|
},
|
||||||
|
resolveActor: () => 'username:finance',
|
||||||
|
toast: (message) => notices.push(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
await actions.saveRuleMarkdown()
|
||||||
|
|
||||||
|
assert.equal(requests.length, 1)
|
||||||
|
assert.equal(requests[0].url, '/api/v1/agent-assets/rule-id/rule-markdown')
|
||||||
|
assert.equal(requests[0].options.method, 'POST')
|
||||||
|
const body = JSON.parse(requests[0].options.body)
|
||||||
|
assert.equal(body.version, 'v1.0.1')
|
||||||
|
assert.equal(body.created_by, 'username:finance')
|
||||||
|
assert.equal(body.config_json.runtime_rule.version, 2)
|
||||||
|
assert.equal(body.config_json.preserved, 'server-field')
|
||||||
|
assert.match(body.content, /```expense-rule/)
|
||||||
|
assert.equal('content_type' in body, false)
|
||||||
|
assert.equal(refreshCount, 1)
|
||||||
|
assert.equal(detailCount, 1)
|
||||||
|
assert.equal(actionState.value, '')
|
||||||
|
assert.deepEqual(notices, ['规则 Markdown 已保存为 v1.0.1。'])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user