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
@@ -11,6 +11,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointGroupDto import (
EvaluationPointGroupBindingCreateDTO,
EvaluationPointGroupBindingUpdateDTO,
EvaluationPointGroupCreateDTO,
EvaluationPointGroupRuleDraftCreateDTO,
EvaluationPointGroupRebindDTO,
EvaluationPointGroupUpdateDTO,
)
@@ -160,6 +161,25 @@ class EvaluationPointGroupController(BaseController):
await self.GroupService.DeleteBinding(BindingId)
return JSONResponse(status_code=200, content={"success": True})
@self.router.get("/{GroupId}/rule-template")
async def GetEvaluationPointGroupRuleTemplate(GroupId: int, payload: dict = Depends(verify_access_token)):
if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看规则模板权限", "data": None})
data = await self.GroupService.GetRuleTemplate(GroupId)
return JSONResponse(status_code=200, content=data.model_dump())
@self.router.post("/{GroupId}/rule-drafts")
async def CreateEvaluationPointGroupRuleDraft(
GroupId: int,
body: EvaluationPointGroupRuleDraftCreateDTO,
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write", "rules:create:write"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有保存规则草稿权限", "data": None})
effective_body = body.model_copy(update={"editor_user_id": body.editor_user_id or int(payload["user_id"])})
data = await self.GroupService.CreateRuleDraft(GroupId, effective_body)
return JSONResponse(status_code=200, content=data.model_dump())
async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool:
for permission_key in permission_keys:
if await self.PermissionService.CheckPermission(user_id, permission_key):
+11 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class EvaluationPointGroupCreateDTO(BaseModel):
@@ -61,3 +61,13 @@ class EvaluationPointGroupBindingUpdateDTO(BaseModel):
priority: int | None = Field(None, description="优先级")
is_active: bool | None = Field(None, description="是否启用")
note: str | None = Field(None, description="备注")
class EvaluationPointGroupRuleDraftCreateDTO(BaseModel):
"""二级分组下新建规则 YAML 草稿请求。"""
model_config = ConfigDict(populate_by_name=True)
yaml_text: str = Field(..., min_length=1, alias="yamlText", description="完整规则 YAML 正文")
change_note: str | None = Field(None, alias="changeNote", description="版本变更说明")
editor_user_id: int | None = Field(None, alias="editorUserId", description="编辑者用户ID")
+45
View File
@@ -1,5 +1,7 @@
from pydantic import BaseModel, Field
from fastapi_modules.fastapi_leaudit.domian.vo.ruleVo import RuleVersionVO
class RuleGroupBindingVO(BaseModel):
"""二级分组下的规则集绑定。"""
@@ -96,4 +98,47 @@ class EvaluationPointGroupBatchDeleteVO(BaseModel):
message: str = Field(..., description="结果消息")
class EvaluationPointGroupRuleTemplateContextVO(BaseModel):
"""二级分组生成规则模板所需上下文。"""
group_id: int = Field(..., description="当前二级分组ID")
group_code: str = Field(..., description="当前二级分组编码")
group_name: str = Field(..., description="当前二级分组名称")
parent_group_id: int = Field(..., description="所属一级分组ID")
parent_group_code: str = Field(..., description="所属一级分组编码")
parent_group_name: str = Field(..., description="所属一级分组名称")
document_type_id: int | None = Field(None, description="关联文档类型ID")
document_type_code: str | None = Field(None, description="关联文档类型编码")
document_type_name: str | None = Field(None, description="关联文档类型名称")
entry_module_id: int | None = Field(None, description="入口模块ID")
entry_module_name: str | None = Field(None, description="入口模块名称")
class EvaluationPointGroupRuleTemplateVO(BaseModel):
"""二级分组规则模板响应。"""
context: EvaluationPointGroupRuleTemplateContextVO = Field(..., description="二级分组上下文")
ruleType: str = Field(..., description="从分组上下文推导出的规则类型编码")
ruleName: str = Field(..., description="规则集名称")
nextVersionNo: str = Field(..., description="建议创建的下一个版本号")
ossPreviewKey: str = Field(..., description="该版本预计写入的 OSS key")
yamlTemplate: str = Field(..., description="可直接编辑的完整 YAML 模板")
existingRuleSetId: int | None = Field(None, description="已存在的规则集ID")
existingBindingId: int | None = Field(None, description="当前分组已存在的绑定ID")
class EvaluationPointGroupRuleDraftVO(BaseModel):
"""二级分组规则草稿创建响应。"""
packId: int = Field(..., description="规则配置 pack ID,当前等于二级分组ID")
groupId: int = Field(..., description="二级分组ID")
ruleName: str = Field(..., description="规则名称")
ruleType: str = Field(..., description="规则类型编码")
ruleSetId: int = Field(..., description="所属规则集ID")
ossKey: str = Field(..., description="草稿写入的 OSS key")
version: RuleVersionVO = Field(..., description="刚创建的规则版本")
binding: RuleGroupBindingVO = Field(..., description="当前二级分组与规则集的绑定信息")
autoBound: bool = Field(..., description="本次是否自动新增了绑定")
EvaluationPointGroupVO.model_rebuild()
@@ -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