From aa6a7d1250ed3d39573a6b05ad94ae2740f43df5 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 20 Jul 2026 10:30:48 +0800 Subject: [PATCH] fix(agent-assets): save rule markdown atomically --- .../bugs/rule-markdown-runtime-atomic-save.md | 14 ++ .../v1/endpoints/agent_asset_rule_markdown.py | 70 ++++++ server/src/app/api/v1/router.py | 6 +- server/src/app/schemas/agent_asset.py | 1 + .../app/services/agent_asset_rule_markdown.py | 130 +++++++++++ server/src/app/services/agent_assets.py | 2 + ...est_agent_asset_rule_markdown_atomicity.py | 207 ++++++++++++++++++ web/src/services/agentAssets.js | 8 + .../scripts/useAuditRuleVersionActions.js | 23 +- .../audit-rule-markdown-atomic-save.test.mjs | 73 ++++++ 10 files changed, 516 insertions(+), 18 deletions(-) create mode 100644 document/development/2026-07-18/dev-logs/bugs/rule-markdown-runtime-atomic-save.md create mode 100644 server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py create mode 100644 server/src/app/services/agent_asset_rule_markdown.py create mode 100644 server/tests/test_agent_asset_rule_markdown_atomicity.py create mode 100644 web/tests/audit-rule-markdown-atomic-save.test.mjs diff --git a/document/development/2026-07-18/dev-logs/bugs/rule-markdown-runtime-atomic-save.md b/document/development/2026-07-18/dev-logs/bugs/rule-markdown-runtime-atomic-save.md new file mode 100644 index 0000000..adf75a2 --- /dev/null +++ b/document/development/2026-07-18/dev-logs/bugs/rule-markdown-runtime-atomic-save.md @@ -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` 均通过。 + - 影响:规则编辑保存现在具备事务一致性,失败后不会留下半成品版本,也不会因为孤儿版本阻塞下一次保存。 diff --git a/server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py b/server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py new file mode 100644 index 0000000..4f710b0 --- /dev/null +++ b/server/src/app/api/v1/endpoints/agent_asset_rule_markdown.py @@ -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) diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 679984c..5f71dde 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -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_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_feedback import router as agent_feedback_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, ) 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.financial_connectors import router as financial_connectors_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.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_billing_router, tags=["commercial"]) 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_releases_router, tags=["agent-assets"]) router.include_router(agent_feedback_router, tags=["agent-feedback"]) diff --git a/server/src/app/schemas/agent_asset.py b/server/src/app/schemas/agent_asset.py index 8984473..8cc3e5a 100644 --- a/server/src/app/schemas/agent_asset.py +++ b/server/src/app/schemas/agent_asset.py @@ -53,6 +53,7 @@ class AgentAssetVersionCreate(BaseModel): class RuleMarkdownUpdate(BaseModel): version: str = Field(min_length=1, max_length=30) content: str + config_json: dict[str, Any] change_note: str | None = None created_by: str = Field(min_length=1, max_length=100) diff --git a/server/src/app/services/agent_asset_rule_markdown.py b/server/src/app/services/agent_asset_rule_markdown.py new file mode 100644 index 0000000..33ee68d --- /dev/null +++ b/server/src/app/services/agent_asset_rule_markdown.py @@ -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 diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index 83ca7f8..07ad3bd 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -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_testing import AgentAssetRiskRuleTestingMixin 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_spreadsheet import AgentAssetSpreadsheetManager from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHelperMixin @@ -149,6 +150,7 @@ class AgentAssetVersionMixin: class AgentAssetService( AgentAssetSerializationMixin, AgentAssetVersionMixin, + AgentAssetRuleMarkdownMixin, AgentAssetOnlyOfficeMixin, AgentAssetSpreadsheetHelperMixin, AgentAssetRiskRuleLevelMixin, diff --git a/server/tests/test_agent_asset_rule_markdown_atomicity.py b/server/tests/test_agent_asset_rule_markdown_atomicity.py new file mode 100644 index 0000000..f96e60a --- /dev/null +++ b/server/tests/test_agent_asset_rule_markdown_atomicity.py @@ -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 diff --git a/web/src/services/agentAssets.js b/web/src/services/agentAssets.js index c23df4d..820aca5 100644 --- a/web/src/services/agentAssets.js +++ b/web/src/services/agentAssets.js @@ -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 = {}) { return apiRequest(`/agent-assets/${assetId}/reviews`, { method: 'POST', diff --git a/web/src/views/scripts/useAuditRuleVersionActions.js b/web/src/views/scripts/useAuditRuleVersionActions.js index f2bf9b5..bf02738 100644 --- a/web/src/views/scripts/useAuditRuleVersionActions.js +++ b/web/src/views/scripts/useAuditRuleVersionActions.js @@ -1,8 +1,7 @@ import { activateAgentAsset, - createAgentAssetVersion, restoreAgentAssetVersion, - updateAgentAsset + saveAgentAssetRuleMarkdown } from '../../services/agentAssets.js' import { buildRuleConfigPayload, @@ -26,16 +25,6 @@ export function useAuditRuleVersionActions({ resolveActor, toast }) { - async function persistRuleRuntimeConfig(asset, runtimeRule) { - await updateAgentAsset( - asset.id, - { - config_json: buildRuleConfigPayload(asset, runtimeRule) - }, - { actor: resolveActor() } - ) - } - async function saveRuleVersion({ action, changeNote, successLabel }) { if ( !selectedSkill.value || @@ -62,18 +51,18 @@ export function useAuditRuleVersionActions({ actionState.value = action try { - await createAgentAssetVersion( + const actor = resolveActor() + await saveAgentAssetRuleMarkdown( selectedSkill.value.id, { version: nextVersion, content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule), - content_type: 'markdown', + config_json: buildRuleConfigPayload(selectedSkill.value, runtimeRule), change_note: changeNote, - created_by: resolveActor() + created_by: actor }, - { actor: resolveActor() } + { actor } ) - await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule) await refreshCurrentAssets() await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true }) toast(`${successLabel} ${nextVersion}。`) diff --git a/web/tests/audit-rule-markdown-atomic-save.test.mjs b/web/tests/audit-rule-markdown-atomic-save.test.mjs new file mode 100644 index 0000000..f620d98 --- /dev/null +++ b/web/tests/audit-rule-markdown-atomic-save.test.mjs @@ -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。']) +})