fix(agent-assets): save rule markdown atomically

This commit is contained in:
caoxiaozhu
2026-07-20 10:30:48 +08:00
parent 208e1bed59
commit aa6a7d1250
10 changed files with 516 additions and 18 deletions

View File

@@ -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` 均通过。
- 影响:规则编辑保存现在具备事务一致性,失败后不会留下半成品版本,也不会因为孤儿版本阻塞下一次保存。

View 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)

View File

@@ -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"])

View File

@@ -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)

View 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

View File

@@ -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,

View 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

View File

@@ -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',

View File

@@ -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}`)

View 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。'])
})