feat: add rule draft permission flow

This commit is contained in:
wren
2026-05-06 20:06:41 +08:00
parent 0b76dce2a5
commit f9de903acc
8 changed files with 412 additions and 14 deletions
@@ -6,6 +6,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointGroupDto import (
EvaluationPointGroupBindingCreateDTO,
EvaluationPointGroupBindingUpdateDTO,
EvaluationPointGroupCreateDTO,
EvaluationPointGroupRuleDraftCreateDTO,
EvaluationPointGroupRebindDTO,
EvaluationPointGroupUpdateDTO,
)
@@ -14,6 +15,8 @@ from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointGroupVo import (
EvaluationPointGroupBatchStatusVO,
EvaluationPointGroupDeleteVO,
EvaluationPointGroupListVO,
EvaluationPointGroupRuleDraftVO,
EvaluationPointGroupRuleTemplateVO,
EvaluationPointGroupRebindVO,
EvaluationPointGroupVO,
RuleGroupBindingVO,
@@ -91,3 +94,11 @@ class IEvaluationPointGroupService(ABC):
@abstractmethod
async def DeleteBinding(self, BindingId: int) -> None:
...
@abstractmethod
async def GetRuleTemplate(self, GroupId: int) -> EvaluationPointGroupRuleTemplateVO:
...
@abstractmethod
async def CreateRuleDraft(self, GroupId: int, Body: EvaluationPointGroupRuleDraftCreateDTO) -> EvaluationPointGroupRuleDraftVO:
...
@@ -7,6 +7,7 @@ from typing import Any
from sqlalchemy import bindparam, text
from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
@@ -16,6 +17,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointGroupDto import (
EvaluationPointGroupBindingCreateDTO,
EvaluationPointGroupBindingUpdateDTO,
EvaluationPointGroupCreateDTO,
EvaluationPointGroupRuleDraftCreateDTO,
EvaluationPointGroupRebindDTO,
EvaluationPointGroupUpdateDTO,
)
@@ -24,6 +26,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointGroupVo import (
EvaluationPointGroupBatchStatusVO,
EvaluationPointGroupDeleteVO,
EvaluationPointGroupListVO,
EvaluationPointGroupRuleDraftVO,
EvaluationPointGroupRuleTemplateContextVO,
EvaluationPointGroupRuleTemplateVO,
EvaluationPointGroupRebindVO,
EvaluationPointGroupVO,
RuleGroupBindingVO,
@@ -533,6 +538,73 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
await sync_doc_type_bindings_from_group(session, int(current["group_id"]))
await session.commit()
async def GetRuleTemplate(self, GroupId: int) -> EvaluationPointGroupRuleTemplateVO:
async with GetAsyncSession() as session:
await self._ensure_ready(session)
context = await self._load_rule_context(session, GroupId)
template = self._build_rule_yaml_template(context)
existing_binding_id = await self._get_group_binding_id_for_rule_type(session, GroupId, context["rule_type"])
return EvaluationPointGroupRuleTemplateVO(
context=EvaluationPointGroupRuleTemplateContextVO(
group_id=context["group_id"],
group_code=context["group_code"],
group_name=context["group_name"],
parent_group_id=context["parent_group_id"],
parent_group_code=context["parent_group_code"],
parent_group_name=context["parent_group_name"],
document_type_id=context["document_type_id"],
document_type_code=context["document_type_code"],
document_type_name=context["document_type_name"],
entry_module_id=context["entry_module_id"],
entry_module_name=context["entry_module_name"],
),
ruleType=context["rule_type"],
ruleName=context["rule_name"],
nextVersionNo=context["next_version_no"],
ossPreviewKey=context["oss_preview_key"],
yamlTemplate=template,
existingRuleSetId=context["existing_rule_set_id"],
existingBindingId=existing_binding_id,
)
async def CreateRuleDraft(self, GroupId: int, Body: EvaluationPointGroupRuleDraftCreateDTO) -> EvaluationPointGroupRuleDraftVO:
async with GetAsyncSession() as session:
await self._ensure_ready(session)
context = await self._load_rule_context(session, GroupId)
yaml_text = Body.yaml_text.strip()
if not yaml_text:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "YAML 内容不能为空")
created_version = await self.RuleService.CreateVersion(
RuleType=context["rule_type"],
YamlText=yaml_text,
ChangeNote=Body.change_note.strip() if Body.change_note else None,
EditorUserId=Body.editor_user_id,
)
async with GetAsyncSession() as session:
await self._ensure_ready(session)
rule_set_id = await self._get_rule_set_id_by_type(session, context["rule_type"])
if rule_set_id is None:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "规则版本已创建,但未找到对应规则集")
binding_id, auto_bound = await self._ensure_group_binding_for_rule_set(session, GroupId, rule_set_id)
await sync_doc_type_bindings_from_group(session, GroupId)
await session.commit()
binding_row = await self._get_binding_row(session, binding_id)
binding_vo = await self._build_binding_vo(binding_row)
return EvaluationPointGroupRuleDraftVO(
packId=GroupId,
groupId=GroupId,
ruleName=context["rule_name"],
ruleType=context["rule_type"],
ruleSetId=rule_set_id,
ossKey=OssPathUtils.BuildRuleYamlKey(context["rule_type"], created_version.versionNo),
version=created_version,
binding=binding_vo,
autoBound=auto_bound,
)
async def _ensure_ready(self, session) -> None:
self._rule_set_meta_cache = None
await ensure_rule_group_schema(session)
@@ -550,7 +622,11 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
g.code,
g.description,
g.document_type_id,
dt.code AS document_type_code,
dt.name AS document_type_name,
parent.id AS root_group_id,
parent.name AS root_group_name,
parent.code AS root_group_code,
COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id,
em.name AS entry_module_name,
g.sort_order,
@@ -763,6 +839,186 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
if exists is None:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "规则集不存在")
async def _load_rule_context(self, session, group_id: int) -> dict[str, Any]:
row = await self._get_group_row(session, group_id)
if self._normalize_pid(row["pid"]) == 0:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请先选择二级分组,再创建规则 YAML")
rule_type = self._resolve_rule_type(row)
if not rule_type:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前二级分组缺少可推导的规则类型编码,请先补充分组编码或文档类型编码")
rule_name = str(row.get("document_type_name") or row.get("name") or "").strip()
if not rule_name:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前二级分组缺少规则名称,请先补充分组名称")
next_version_no = await self._get_next_rule_version_no(session, rule_type)
return {
"group_id": int(row["id"]),
"group_code": str(row.get("code") or "").strip(),
"group_name": str(row.get("name") or ""),
"parent_group_id": int(row.get("root_group_id") or 0),
"parent_group_code": str(row.get("root_group_code") or "").strip(),
"parent_group_name": str(row.get("root_group_name") or row.get("entry_module_name") or ""),
"document_type_id": int(row["document_type_id"]) if row.get("document_type_id") is not None else None,
"document_type_code": str(row.get("document_type_code") or "").strip() or None,
"document_type_name": str(row.get("document_type_name") or "").strip() or None,
"entry_module_id": int(row["entry_module_id"]) if row.get("entry_module_id") is not None else None,
"entry_module_name": str(row.get("entry_module_name") or "").strip() or None,
"rule_type": rule_type,
"rule_name": rule_name,
"next_version_no": next_version_no,
"oss_preview_key": OssPathUtils.BuildRuleYamlKey(rule_type, next_version_no),
"existing_rule_set_id": await self._get_rule_set_id_by_type(session, rule_type),
}
def _resolve_rule_type(self, row: Any) -> str:
doc_type_code = str(row.get("document_type_code") or "").strip()
if doc_type_code:
return self._normalize_rule_code(doc_type_code)
child_code = str(row.get("code") or "").strip()
root_code = str(row.get("root_group_code") or "").strip()
if child_code and root_code and child_code != root_code and not child_code.startswith(f"{root_code}."):
return self._normalize_rule_code(f"{root_code}.{child_code}")
if child_code:
return self._normalize_rule_code(child_code)
group_name = str(row.get("name") or "").strip()
if root_code and group_name:
return self._normalize_rule_code(f"{root_code}.{group_name}")
if group_name:
return self._normalize_rule_code(group_name)
return ""
def _normalize_rule_code(self, value: str) -> str:
normalized = ".".join(segment.strip() for segment in value.strip().split(".") if segment.strip())
if not normalized:
return ""
return normalized.replace("/", "_").replace("\\", "_").replace(" ", "_")
async def _get_next_rule_version_no(self, session, rule_type: str) -> str:
row = (
await session.execute(
text(
"""
SELECT COALESCE(MAX(rv.version_seq), 0) AS max_seq
FROM leaudit_rule_versions rv
JOIN leaudit_rule_sets rs ON rs.id = rv.rule_set_id
WHERE rs.rule_type = :rule_type
AND rs.deleted_at IS NULL
"""
),
{"rule_type": rule_type},
)
).mappings().first()
next_seq = int((row or {}).get("max_seq") or 0) + 1
return f"v{next_seq}"
def _build_rule_yaml_template(self, context: dict[str, Any]) -> str:
keywords = [
item
for item in dict.fromkeys(
[
context.get("parent_group_name"),
context.get("group_name"),
context.get("document_type_name"),
context.get("entry_module_name"),
]
)
if item
]
keyword_lines = "\n".join(f" - {item}" for item in keywords) or " - 待补充"
description = (
f"{context['rule_name']} 规则模板。"
f" 入口模块:{context.get('entry_module_name') or '未配置'}"
f" 一级分组:{context.get('parent_group_name') or '未配置'}"
f" 二级分组:{context.get('group_name') or '未配置'}"
)
parent_type = context.get("parent_group_name") or context.get("group_name") or context["rule_type"]
return (
"metadata:\n"
f" type_id: {context['rule_type']}\n"
f" name: {context['rule_name']}\n"
f" version: {context['next_version_no']}\n"
f" last_updated: '{datetime.now().date().isoformat()}'\n"
f" parent: {parent_type}\n"
" classification_keywords:\n"
f"{keyword_lines}\n"
" description: >\n"
f" {description}\n\n"
"sub_documents: []\n"
"rules: []\n"
)
async def _get_group_binding_id_for_rule_type(self, session, group_id: int, rule_type: str) -> int | None:
rule_set_id = await self._get_rule_set_id_by_type(session, rule_type)
if rule_set_id is None:
return None
row = (
await session.execute(
text(
"""
SELECT id
FROM leaudit_rule_group_bindings
WHERE group_id = :group_id
AND rule_set_id = :rule_set_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{"group_id": group_id, "rule_set_id": rule_set_id},
)
).mappings().first()
return int(row["id"]) if row else None
async def _get_rule_set_id_by_type(self, session, rule_type: str) -> int | None:
row = (
await session.execute(
text("SELECT id FROM leaudit_rule_sets WHERE rule_type = :rule_type AND deleted_at IS NULL LIMIT 1"),
{"rule_type": rule_type},
)
).mappings().first()
return int(row["id"]) if row else None
async def _ensure_group_binding_for_rule_set(self, session, group_id: int, rule_set_id: int) -> tuple[int, bool]:
existing = (
await session.execute(
text(
"""
SELECT id
FROM leaudit_rule_group_bindings
WHERE group_id = :group_id
AND rule_set_id = :rule_set_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{"group_id": group_id, "rule_set_id": rule_set_id},
)
).mappings().first()
if existing:
return int(existing["id"]), False
row = (
await session.execute(
text(
"""
INSERT INTO leaudit_rule_group_bindings (
group_id, rule_set_id, priority, is_active, note, created_at, updated_at
) VALUES (
:group_id, :rule_set_id, 100, TRUE, :note, NOW(), NOW()
)
RETURNING id
"""
),
{
"group_id": group_id,
"rule_set_id": rule_set_id,
"note": "由二级分组新建规则 YAML 时自动补绑",
},
)
).mappings().one()
return int(row["id"]), True
def _normalize_create_payload(self, body: EvaluationPointGroupCreateDTO) -> dict[str, Any]:
name = body.name.strip()
code = body.code.strip()
@@ -158,6 +158,14 @@ class RbacAdminServiceImpl(IRbacAdminService):
{"permission_key": "doc_type:create:write", "display_name": "创建文档类型", "module": "doc_type", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/document-types", "route_path": "/document-types"},
{"permission_key": "doc_type:update:write", "display_name": "更新文档类型", "module": "doc_type", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/document-types/{id}", "route_path": "/document-types"},
{"permission_key": "doc_type:delete:delete", "display_name": "删除文档类型", "module": "doc_type", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/document-types/{id}", "route_path": "/document-types"},
{"permission_key": "evaluation_group:list:read", "display_name": "评查点分组列表", "module": "evaluation_group", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"},
{"permission_key": "evaluation_group:create:write", "display_name": "创建评查点分组", "module": "evaluation_group", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"},
{"permission_key": "evaluation_group:update:write", "display_name": "更新评查点分组与绑定", "module": "evaluation_group", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rule-groups"},
{"permission_key": "evaluation_group:batch:write", "display_name": "批量维护评查点分组", "module": "evaluation_group", "resource": "batch", "action": "write", "api_method": "PATCH", "api_path": "/api/v3/evaluation-point-groups/batch/status", "route_path": "/rule-groups"},
{"permission_key": "evaluation_group:delete:delete", "display_name": "删除评查点分组", "module": "evaluation_group", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rule-groups"},
{"permission_key": "rules:list:read", "display_name": "规则配置列表", "module": "rules", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/rule-config-packs", "route_path": "/rules"},
{"permission_key": "rules:content:read", "display_name": "规则 YAML 内容", "module": "rules", "resource": "content", "action": "read", "api_method": "GET", "api_path": "/api/v3/rule-config-packs/{id}", "route_path": "/rules"},
{"permission_key": "rules:create:write", "display_name": "创建规则草稿", "module": "rules", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups/{id}/rule-drafts", "route_path": "/rules"},
{"permission_key": "evaluation_point:list:read", "display_name": "评查点列表", "module": "evaluation_point", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"},
{"permission_key": "evaluation_point:detail:read", "display_name": "评查点详情", "module": "evaluation_point", "resource": "detail", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"},
{"permission_key": "evaluation_point:create:write", "display_name": "创建评查点", "module": "evaluation_point", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"},
@@ -127,6 +127,12 @@ class RuleConfigServiceImpl(IRuleConfigService):
resolved_version_id = current_version_id or fallback_version_id
has_usable_version = bool(rule_set_meta.get("has_usable_version"))
usable_rule_count = int(rule_set_meta.get("usable_rule_count") or 0)
latest_version_id = await self._load_latest_version_id(rule_set_id)
latest_version_seq = await self._load_version_seq_by_id(latest_version_id)
resolved_version_seq = await self._load_version_seq_by_id(resolved_version_id)
if latest_version_id is not None and (resolved_version_id is None or latest_version_seq > resolved_version_seq):
# 规则详情页优先读取最新草稿;上传运行仍以 current/fallback 的可用版本为准。
resolved_version_id = latest_version_id
if resolved_version_id is not None:
yaml_text = await self._load_yaml_text_by_version_id(resolved_version_id)
source_status = "ready" if yaml_text.strip() else "missing"
@@ -214,6 +220,44 @@ class RuleConfigServiceImpl(IRuleConfigService):
except Exception:
return ""
async def _load_latest_version_id(self, rule_set_id: int) -> int | None:
"""在没有可用发布版本时,退回读取最新草稿版本。"""
async with GetAsyncSession() as session:
row = (
await session.execute(
text(
"""
SELECT id
FROM leaudit_rule_versions
WHERE rule_set_id = :rule_set_id
ORDER BY version_seq DESC, id DESC
LIMIT 1
"""
),
{"rule_set_id": rule_set_id},
)
).mappings().first()
return self._to_int(row.get("id")) if row else None
async def _load_version_seq_by_id(self, version_id: int | None) -> int:
if version_id is None:
return -1
async with GetAsyncSession() as session:
row = (
await session.execute(
text(
"""
SELECT version_seq
FROM leaudit_rule_versions
WHERE id = :version_id
LIMIT 1
"""
),
{"version_id": version_id},
)
).mappings().first()
return int(row.get("version_seq") or -1) if row else -1
def _to_int(self, value) -> int | None:
if value is None:
return None