feat: migrate rule bindings to group-based flow
This commit is contained in:
@@ -2,12 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
from sqlalchemy import text
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ruleConfigVo import RuleConfigPackVO
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ruleConfigVo import (
|
||||
RuleConfigPackListVO,
|
||||
RuleConfigPackRuleSummaryVO,
|
||||
RuleConfigPackVO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.leaudit_bridge.ruleValidator import RuleValidator
|
||||
from fastapi_modules.fastapi_leaudit.services import IOssService
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl
|
||||
@@ -20,41 +30,99 @@ class RuleConfigServiceImpl(IRuleConfigService):
|
||||
def __init__(self, OssService: IOssService | None = None) -> None:
|
||||
self.OssService = OssService or OssServiceImpl()
|
||||
self.RuleService = RuleServiceImpl(self.OssService)
|
||||
self.Validator = RuleValidator()
|
||||
|
||||
async def ListPacks(self) -> list[RuleConfigPackVO]:
|
||||
"""列出规则配置页所需的全部 pack。"""
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
child.id AS group_id,
|
||||
child.name AS subtype,
|
||||
COALESCE(child.document_type_id, root.document_type_id) AS document_type_id,
|
||||
dt.name AS document_type_name,
|
||||
root.id AS root_group_id,
|
||||
COALESCE(root.name, child.name) AS main_type,
|
||||
em.name AS entry_module_name
|
||||
FROM leaudit_evaluation_point_groups child
|
||||
LEFT JOIN leaudit_evaluation_point_groups root
|
||||
ON root.id = child.pid
|
||||
AND root.deleted_at IS NULL
|
||||
LEFT JOIN leaudit_document_types dt
|
||||
ON dt.id = COALESCE(child.document_type_id, root.document_type_id)
|
||||
LEFT JOIN leaudit_entry_modules em
|
||||
ON em.id = COALESCE(child.entry_module_id, root.entry_module_id, dt.entry_module_id)
|
||||
WHERE child.deleted_at IS NULL
|
||||
AND COALESCE(child.pid, 0) <> 0
|
||||
ORDER BY COALESCE(root.sort_order, 0) ASC, root.id ASC, child.sort_order ASC, child.id ASC
|
||||
"""
|
||||
)
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
rows = await self._load_pack_rows()
|
||||
rule_set_map = await self._load_rule_set_meta_map()
|
||||
return [await self._build_pack_vo(row, rule_set_map) for row in rows]
|
||||
|
||||
async def ListPackSummaries(self) -> list[RuleConfigPackListVO]:
|
||||
"""列出规则列表页所需的轻量 pack。"""
|
||||
rows = await self._load_pack_rows()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
group_ids = [int(row["group_id"]) for row in rows]
|
||||
binding_map = await self._load_effective_binding_map(group_ids)
|
||||
rule_set_ids = sorted({int(item["rule_set_id"]) for item in binding_map.values() if item.get("rule_set_id") is not None})
|
||||
rule_set_map = await self._load_rule_set_meta_map_by_ids(rule_set_ids)
|
||||
latest_version_map = await self._load_latest_version_map(rule_set_ids)
|
||||
|
||||
base_items: list[dict[str, Any]] = []
|
||||
resolved_version_ids: set[int] = set()
|
||||
for row in rows:
|
||||
group_id = int(row["group_id"])
|
||||
binding = binding_map.get(group_id)
|
||||
document_type = str(row["document_type_name"] or "").strip()
|
||||
main_type = str(row["main_type"] or "").strip()
|
||||
subtype = str(row["subtype"] or "").strip() or "通用"
|
||||
module_type = str(row["entry_module_name"] or "").strip() or (f"{document_type}评查" if document_type else "规则配置")
|
||||
|
||||
binding_id: int | None = None
|
||||
rule_set_id: int | None = None
|
||||
rule_type: str | None = None
|
||||
rule_name: str | None = None
|
||||
current_version_id: int | None = None
|
||||
fallback_version_id: int | None = None
|
||||
resolved_version_id: int | None = None
|
||||
has_usable_version = False
|
||||
usable_rule_count = 0
|
||||
|
||||
if binding:
|
||||
binding_id = int(binding["id"])
|
||||
rule_set_id = int(binding["rule_set_id"])
|
||||
rule_set_meta = rule_set_map.get(rule_set_id, {})
|
||||
rule_type = str(rule_set_meta.get("rule_type") or "") or None
|
||||
rule_name = str(rule_set_meta.get("rule_name") or "") or None
|
||||
current_version_id = self._to_int(rule_set_meta.get("current_version_id"))
|
||||
fallback_version_id = self._to_int(rule_set_meta.get("fallback_version_id"))
|
||||
has_usable_version = bool(rule_set_meta.get("has_usable_version"))
|
||||
resolved_version_id = current_version_id or fallback_version_id or latest_version_map.get(rule_set_id)
|
||||
if resolved_version_id is not None:
|
||||
resolved_version_ids.add(resolved_version_id)
|
||||
|
||||
base_items.append({
|
||||
"packId": group_id,
|
||||
"groupId": group_id,
|
||||
"rootGroupId": self._to_int(row.get("root_group_id")),
|
||||
"bindingId": binding_id,
|
||||
"ruleSetId": rule_set_id,
|
||||
"ruleType": rule_type,
|
||||
"ruleName": rule_name,
|
||||
"currentVersionId": current_version_id,
|
||||
"fallbackVersionId": fallback_version_id,
|
||||
"resolvedVersionId": resolved_version_id,
|
||||
"hasUsableVersion": has_usable_version,
|
||||
"usableRuleCount": usable_rule_count,
|
||||
"documentTypeId": self._to_int(row.get("document_type_id")),
|
||||
"documentType": document_type,
|
||||
"moduleType": module_type,
|
||||
"mainType": main_type or document_type,
|
||||
"subtype": subtype,
|
||||
})
|
||||
|
||||
version_oss_map = await self._load_version_oss_map(sorted(resolved_version_ids))
|
||||
yaml_summary_map = await self._load_yaml_summaries(version_oss_map)
|
||||
|
||||
packs: list[RuleConfigPackListVO] = []
|
||||
for item in base_items:
|
||||
summary = yaml_summary_map.get(item["resolvedVersionId"]) if item.get("resolvedVersionId") else None
|
||||
rules = summary["rules"] if summary else []
|
||||
source_status = "empty"
|
||||
yaml_name = item.get("ruleName") or ""
|
||||
if item.get("resolvedVersionId") is not None:
|
||||
source_status = "ready" if summary and summary.get("loaded") else "missing"
|
||||
yaml_name = str(summary.get("yaml_name") or yaml_name or "")
|
||||
item["sourceStatus"] = source_status
|
||||
item["yamlName"] = yaml_name
|
||||
item["rules"] = [RuleConfigPackRuleSummaryVO(**rule) for rule in rules]
|
||||
item["usableRuleCount"] = len(rules)
|
||||
packs.append(RuleConfigPackListVO(**item))
|
||||
|
||||
return packs
|
||||
|
||||
async def GetPack(self, PackId: int) -> RuleConfigPackVO:
|
||||
"""获取单个规则配置 pack。"""
|
||||
async with GetAsyncSession() as session:
|
||||
@@ -128,8 +196,6 @@ class RuleConfigServiceImpl(IRuleConfigService):
|
||||
has_usable_version = bool(rule_set_meta.get("has_usable_version"))
|
||||
usable_rule_count = int(rule_set_meta.get("usable_rule_count") or 0)
|
||||
if resolved_version_id is None:
|
||||
# 仅在当前没有生效版本时,才回退到最新版本(通常是草稿)。
|
||||
# 否则规则详情页必须与当前生效版本保持一致,发布/回滚后才能看到真实内容切换。
|
||||
resolved_version_id = await self._load_latest_version_id(rule_set_id)
|
||||
if resolved_version_id is not None:
|
||||
yaml_text = await self._load_yaml_text_by_version_id(resolved_version_id)
|
||||
@@ -157,6 +223,36 @@ class RuleConfigServiceImpl(IRuleConfigService):
|
||||
sourceStatus=source_status,
|
||||
)
|
||||
|
||||
async def _load_pack_rows(self):
|
||||
async with GetAsyncSession() as session:
|
||||
return (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
child.id AS group_id,
|
||||
child.name AS subtype,
|
||||
COALESCE(child.document_type_id, root.document_type_id) AS document_type_id,
|
||||
dt.name AS document_type_name,
|
||||
root.id AS root_group_id,
|
||||
COALESCE(root.name, child.name) AS main_type,
|
||||
em.name AS entry_module_name
|
||||
FROM leaudit_evaluation_point_groups child
|
||||
LEFT JOIN leaudit_evaluation_point_groups root
|
||||
ON root.id = child.pid
|
||||
AND root.deleted_at IS NULL
|
||||
LEFT JOIN leaudit_document_types dt
|
||||
ON dt.id = COALESCE(child.document_type_id, root.document_type_id)
|
||||
LEFT JOIN leaudit_entry_modules em
|
||||
ON em.id = COALESCE(child.entry_module_id, root.entry_module_id, dt.entry_module_id)
|
||||
WHERE child.deleted_at IS NULL
|
||||
AND COALESCE(child.pid, 0) <> 0
|
||||
ORDER BY COALESCE(root.sort_order, 0) ASC, root.id ASC, child.sort_order ASC, child.id ASC
|
||||
"""
|
||||
)
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
async def _load_effective_binding(self, group_id: int):
|
||||
"""读取当前二级分组实际生效的规则集绑定。"""
|
||||
async with GetAsyncSession() as session:
|
||||
@@ -178,8 +274,35 @@ class RuleConfigServiceImpl(IRuleConfigService):
|
||||
).mappings().first()
|
||||
return row
|
||||
|
||||
async def _load_effective_binding_map(self, group_ids: list[int]) -> dict[int, dict[str, Any]]:
|
||||
if not group_ids:
|
||||
return {}
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, group_id, rule_set_id
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
group_id,
|
||||
rule_set_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY group_id ORDER BY priority DESC, id ASC) AS rn
|
||||
FROM leaudit_rule_group_bindings
|
||||
WHERE deleted_at IS NULL
|
||||
AND is_active = TRUE
|
||||
AND group_id = ANY(:group_ids)
|
||||
) t
|
||||
WHERE rn = 1
|
||||
"""
|
||||
),
|
||||
{"group_ids": group_ids},
|
||||
)
|
||||
).mappings().all()
|
||||
return {int(row["group_id"]): dict(row) for row in rows}
|
||||
|
||||
async def _load_rule_set_meta_map(self) -> dict[int, dict[str, object]]:
|
||||
"""批量读取规则集元数据。"""
|
||||
items = await self.RuleService.ListSets()
|
||||
return {
|
||||
item.id: {
|
||||
@@ -193,8 +316,94 @@ class RuleConfigServiceImpl(IRuleConfigService):
|
||||
for item in items
|
||||
}
|
||||
|
||||
async def _load_rule_set_meta_map_by_ids(self, rule_set_ids: list[int]) -> dict[int, dict[str, object]]:
|
||||
if not rule_set_ids:
|
||||
return {}
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
rs.id,
|
||||
rs.rule_type,
|
||||
rs.rule_name,
|
||||
rs.current_version_id,
|
||||
current_rv.id AS usable_current_version_id,
|
||||
fallback_rv.id AS fallback_version_id,
|
||||
CASE
|
||||
WHEN current_rv.id IS NOT NULL OR fallback_rv.id IS NOT NULL THEN TRUE
|
||||
ELSE FALSE
|
||||
END AS has_usable_version
|
||||
FROM leaudit_rule_sets rs
|
||||
LEFT JOIN leaudit_rule_versions current_rv
|
||||
ON current_rv.id = rs.current_version_id
|
||||
AND current_rv.status = 'published'
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT rv.id
|
||||
FROM leaudit_rule_versions rv
|
||||
WHERE rv.rule_set_id = rs.id
|
||||
AND rv.status = 'published'
|
||||
AND (rs.current_version_id IS NULL OR rv.id <> rs.current_version_id)
|
||||
ORDER BY rv.version_seq DESC, rv.id DESC
|
||||
LIMIT 1
|
||||
) fallback_rv ON TRUE
|
||||
WHERE rs.deleted_at IS NULL
|
||||
AND rs.id = ANY(:rule_set_ids)
|
||||
"""
|
||||
),
|
||||
{"rule_set_ids": rule_set_ids},
|
||||
)
|
||||
).mappings().all()
|
||||
return {
|
||||
int(row["id"]): {
|
||||
"rule_type": row["rule_type"],
|
||||
"rule_name": row["rule_name"],
|
||||
"current_version_id": row["current_version_id"],
|
||||
"fallback_version_id": row["fallback_version_id"],
|
||||
"has_usable_version": row["has_usable_version"],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
async def _load_version_oss_map(self, version_ids: list[int]) -> dict[int, str]:
|
||||
if not version_ids:
|
||||
return {}
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, oss_url
|
||||
FROM leaudit_rule_versions
|
||||
WHERE id = ANY(:version_ids)
|
||||
"""
|
||||
),
|
||||
{"version_ids": version_ids},
|
||||
)
|
||||
).mappings().all()
|
||||
return {int(row["id"]): str(row["oss_url"] or "") for row in rows if row.get("oss_url")}
|
||||
|
||||
async def _load_latest_version_map(self, rule_set_ids: list[int]) -> dict[int, int]:
|
||||
if not rule_set_ids:
|
||||
return {}
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT ON (rule_set_id) rule_set_id, id
|
||||
FROM leaudit_rule_versions
|
||||
WHERE rule_set_id = ANY(:rule_set_ids)
|
||||
ORDER BY rule_set_id, version_seq DESC, id DESC
|
||||
"""
|
||||
),
|
||||
{"rule_set_ids": rule_set_ids},
|
||||
)
|
||||
).mappings().all()
|
||||
return {int(row["rule_set_id"]): int(row["id"]) for row in rows}
|
||||
|
||||
async def _load_yaml_text_by_version_id(self, version_id: int) -> str:
|
||||
"""按版本ID读取 YAML 正文。"""
|
||||
async with GetAsyncSession() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
@@ -218,8 +427,131 @@ class RuleConfigServiceImpl(IRuleConfigService):
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
async def _load_yaml_summaries(self, version_oss_map: dict[int, str]) -> dict[int, dict[str, Any]]:
|
||||
def _extract_rule_dependencies(rule: Any) -> list[str]:
|
||||
dependencies: list[str] = []
|
||||
|
||||
for item in list(getattr(rule, "dependencies", []) or []):
|
||||
value = str(item or "").strip()
|
||||
if value:
|
||||
dependencies.append(value)
|
||||
|
||||
for stage in list(getattr(rule, "stages", []) or []):
|
||||
field = str(getattr(stage, "field", "") or "").strip()
|
||||
if field:
|
||||
dependencies.append(field)
|
||||
|
||||
for attr_name in ("seal_id", "signature_id", "element"):
|
||||
attr_value = str(getattr(stage, attr_name, "") or "").strip()
|
||||
if attr_value:
|
||||
dependencies.append(attr_value)
|
||||
|
||||
for item in list(getattr(stage, "fields", []) or []):
|
||||
value = str(item or "").strip()
|
||||
if value:
|
||||
dependencies.append(value)
|
||||
|
||||
prompt = str(getattr(stage, "prompt", "") or "")
|
||||
if prompt:
|
||||
dependencies.extend(
|
||||
match.strip()
|
||||
for match in re.findall(r"\{\{\s*([^}]+?)\s*\}\}", prompt)
|
||||
if match.strip()
|
||||
)
|
||||
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in dependencies:
|
||||
if item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
async def _load_one(version_id: int, oss_url: str):
|
||||
if not oss_url:
|
||||
return version_id, {"loaded": False, "yaml_name": "", "rules": []}
|
||||
try:
|
||||
yaml_text = (await self.OssService.DownloadBytes(oss_url)).decode("utf-8")
|
||||
if not yaml_text.strip():
|
||||
return version_id, {"loaded": False, "yaml_name": "", "rules": []}
|
||||
rules_file = self.Validator.ParseValidated(yaml_text)
|
||||
groups: dict[str, str] = {}
|
||||
try:
|
||||
for group in getattr(rules_file, "rules", []) or []:
|
||||
group_name = getattr(group, "group", "") or ""
|
||||
for rule in getattr(group, "rules", []) or []:
|
||||
groups[getattr(rule, "rule_id", "") or ""] = group_name
|
||||
except Exception:
|
||||
groups = {}
|
||||
summaries = []
|
||||
for rule in getattr(rules_file, "flat_rules", []) or []:
|
||||
stage_items = []
|
||||
check_types = []
|
||||
for idx, stage in enumerate(getattr(rule, "stages", []) or [], start=1):
|
||||
check = str(getattr(stage, "check", "") or getattr(stage, "type", "") or "")
|
||||
check_types.append(check)
|
||||
content = check
|
||||
if check == "ai":
|
||||
content = str(getattr(stage, "prompt", "") or "")
|
||||
elif hasattr(stage, "fields") and getattr(stage, "fields"):
|
||||
content = "、".join(str(item) for item in getattr(stage, "fields")[:3])
|
||||
stage_items.append({"id": str(idx), "check": check, "content": content})
|
||||
summaries.append({
|
||||
"id": getattr(rule, "rule_id", "") or getattr(rule, "name", "") or "-",
|
||||
"ruleId": getattr(rule, "rule_id", "") or "-",
|
||||
"name": getattr(rule, "name", "") or getattr(rule, "rule_id", "") or "未命名规则",
|
||||
"group": groups.get(getattr(rule, "rule_id", "") or "", getattr(rule, "group", "") or "未分组"),
|
||||
"risk": str(getattr(rule, "risk", "medium") or "medium"),
|
||||
"score": str(getattr(rule, "score", "0") or "0"),
|
||||
"type": str(getattr(rule, "type", "deterministic") or "deterministic"),
|
||||
"checkTypes": [item for item in check_types if item],
|
||||
"logic": str(getattr(rule, "logic", "") or ""),
|
||||
"subRules": stage_items,
|
||||
"subRuleIds": list(getattr(rule, "rules", []) or []),
|
||||
"scope": list(getattr(rule, "scope", []) or []),
|
||||
"dependencies": _extract_rule_dependencies(rule),
|
||||
"stageCount": len(stage_items),
|
||||
"appliesIn": list(getattr(rule, "applies_in", []) or []),
|
||||
"prompt": stage_items[0]["content"] if len(stage_items) == 1 and stage_items[0]["check"] == "ai" else "",
|
||||
"description": str(getattr(rule, "desc", "") or ""),
|
||||
})
|
||||
summary_map = {
|
||||
str(item.get("ruleId") or item.get("id") or ""): item
|
||||
for item in summaries
|
||||
if str(item.get("ruleId") or item.get("id") or "")
|
||||
}
|
||||
for item in summaries:
|
||||
if item.get("dependencies"):
|
||||
continue
|
||||
sub_rule_ids = [str(sub_rule_id or "").strip() for sub_rule_id in list(item.get("subRuleIds") or []) if str(sub_rule_id or "").strip()]
|
||||
if not sub_rule_ids:
|
||||
continue
|
||||
merged_dependencies: list[str] = []
|
||||
seen_dependencies: set[str] = set()
|
||||
for sub_rule_id in sub_rule_ids:
|
||||
child_summary = summary_map.get(sub_rule_id)
|
||||
if not child_summary:
|
||||
continue
|
||||
for dependency in list(child_summary.get("dependencies") or []):
|
||||
normalized_dependency = str(dependency or "").strip()
|
||||
if not normalized_dependency or normalized_dependency in seen_dependencies:
|
||||
continue
|
||||
seen_dependencies.add(normalized_dependency)
|
||||
merged_dependencies.append(normalized_dependency)
|
||||
item["dependencies"] = merged_dependencies
|
||||
return version_id, {
|
||||
"loaded": True,
|
||||
"yaml_name": str(getattr(getattr(rules_file, "metadata", None), "name", "") or ""),
|
||||
"rules": summaries,
|
||||
}
|
||||
except Exception:
|
||||
return version_id, {"loaded": False, "yaml_name": "", "rules": []}
|
||||
|
||||
results = await asyncio.gather(*[_load_one(version_id, oss_url) for version_id, oss_url in version_oss_map.items()])
|
||||
return dict(results)
|
||||
|
||||
async def _load_latest_version_id(self, rule_set_id: int) -> int | None:
|
||||
"""在没有可用发布版本时,退回读取最新草稿版本。"""
|
||||
async with GetAsyncSession() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
|
||||
Reference in New Issue
Block a user