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_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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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_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,
|
||||
|
||||
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 = {}) {
|
||||
return apiRequest(`/agent-assets/${assetId}/reviews`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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}。`)
|
||||
|
||||
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