Commit 90e89ed5 authored by Data Governance Dev's avatar Data Governance Dev

feat(backend+web): 自定义规则每条独立 tab + 默认名「自定义规则N」

两个用户反馈:

1) 自定义规则命中明细:原来 N 条规则塞进 1 个 tab,规则一多翻不到。
   改为每条规则单独占 1 个 tab:
     - key = custom_value_check_<idx>(idx 从 1 开始,与前端卡片顺序一致)
     - title = <rule_name>(<table>.<column>)
     - summary alert:成功显示"本规则命中 N 行";失败显示"执行失败:**错误**"(红 alert)
     - table source = this.matches_rule_<idx>(扁平命名)
   失败规则也出 tab(alert 显示错误),用户能直接看到为什么失败。

2) 新建自定义规则时输入框默认「自定义规则N」,N 从已用编号集合里找最小可用,
   用户手动改名后 N 仍递增不会撞名。

数据 / 前端配套:
- 后端 data 顶层加 matches_rule_<idx> / error_rule_<idx> / matched_rule_<idx> 三组扁平字段
- 为什么不走 dict 索引(this.by_rule['rule_X']):前端 evalPathExpr 是简单属性路径
  (this.x.y.z),不识别 [key] 语法,扁平命名最干净
- 前端 flatResultRows 识别 custom_value_check_<idx> 前缀的 tab:命中数从
  matched_rule_<idx> 取,检查数固定 1(每条规则 = 1 个字段)

静态验证(mock 两条规则,1 ok + 1 ORA-00933 失败)已通过:
  tabs: [
    {key:custom_value_check_1, type:info,  vars:{matched:this.matched_rule_1}},
    {key:custom_value_check_2, type:error, vars:{error:this.error_rule_2}}
  ]

WORKLOG 待用户在 Oracle 端实跑自定义规则验证后追加(不阻塞提交)
parent f4903d81
...@@ -22,6 +22,15 @@ ...@@ -22,6 +22,15 @@
- numeric → gt / lt / eq - numeric → gt / lt / eq
- date → date_before ('<') / date_after ('>') - date → date_before ('<') / date_after ('>')
输出结构:
- 顶层 summary:聚合统计(规则数 / 有效 / 失败 / 命中行)
- 顶层 matches:扁平聚合(兜底用,兜底消费者仍可读)
- by_rule[str, list]:按 rule_index 分组的明细,
key 形如 "rule_1"、"rule_2",给每条规则 tab 提供 source
- _protocol.tabs:
- "自定义规则汇总" 顶层 tab(key=custom_value_check_summary)放 KPI + 整体 summary
- 每条有效 / 失败的规则 1 个 tab,key=custom_value_check_<index>,title="<rule_name>(<table>.<column>)"
兼容: 兼容:
- 旧 payload [{rule_type, user_input}] 在入口自动包成 - 旧 payload [{rule_type, user_input}] 在入口自动包成
{kind:'group', op:'AND', children:[{kind:'leaf', comparator:'contains', value:kw}]} {kind:'group', op:'AND', children:[{kind:'leaf', comparator:'contains', value:kw}]}
...@@ -30,7 +39,7 @@ ...@@ -30,7 +39,7 @@
注意: 注意:
- LIMIT 200:每条规则 SQL 命中上限,避免内存 / UI / 报告撑爆 - LIMIT 200:每条规则 SQL 命中上限,避免内存 / UI / 报告撑爆
- 单条规则 SQL 失败 → 写一行 error 字段继续下一条,不中断整个 step - 单条规则 SQL 失败 → 写一行 error 字段继续下一条,不中断整个 step
- 占位符按 db_type 切换(mysql=%s / dmPython=oracledb=?),由 sql_builder 内部处理 - 占位符按 db_type 切换(mysql=%s / dameng=? / oracle=:N),由 sql_builder 内部处理
- 字段名走 quote_ident 防止注入(call sql_builder 时一次性 quote 整列名) - 字段名走 quote_ident 防止注入(call sql_builder 时一次性 quote 整列名)
""" """
from __future__ import annotations from __future__ import annotations
...@@ -42,7 +51,6 @@ from ..db_adapter import DBConfig, open_db, quote_ident ...@@ -42,7 +51,6 @@ from ..db_adapter import DBConfig, open_db, quote_ident
from ..sql_builder import compile_rule from ..sql_builder import compile_rule
from ..tab_protocol import ( from ..tab_protocol import (
ColumnSpec, ColumnSpec,
KpiSpec,
RenderSpec, RenderSpec,
SummarySpec, SummarySpec,
TableSpec, TableSpec,
...@@ -118,6 +126,9 @@ def run_step_custom_value_check( ...@@ -118,6 +126,9 @@ def run_step_custom_value_check(
} }
""" """
matches_out: list[dict] = [] matches_out: list[dict] = []
# rule_matches_by_idx: 按规则 idx 聚合命中行(rule_<idx> → list[dict]),
# 给每条规则的独立 tab 提供明细;与顶层 matches 扁平聚合并存(兜底消费者用 matches)
rule_matches_by_idx: dict[str, list[dict]] = {}
skipped_empty = 0 skipped_empty = 0
skipped_no_valid_leaf = 0 skipped_no_valid_leaf = 0
failed = 0 failed = 0
...@@ -126,7 +137,7 @@ def run_step_custom_value_check( ...@@ -126,7 +137,7 @@ def run_step_custom_value_check(
if not custom_rules: if not custom_rules:
if log: if log:
log("INFO", " · 自定义规则列表为空,跳过", step="custom_value_check") log("INFO", " · 自定义规则列表为空,跳过", step="custom_value_check")
return _build_result([], 0, 0, 0, 0, 0) return _build_result([], [], 0, 0, 0, 0, 0)
if log: if log:
log("INFO", log("INFO",
...@@ -134,7 +145,7 @@ def run_step_custom_value_check( ...@@ -134,7 +145,7 @@ def run_step_custom_value_check(
step="custom_value_check") step="custom_value_check")
with open_db(cfg) as db: with open_db(cfg) as db:
for raw_rule in custom_rules: for rule_index, raw_rule in enumerate(custom_rules, start=1):
rule = _normalize_rule(raw_rule) rule = _normalize_rule(raw_rule)
table_name = rule.get("table_name") or "" table_name = rule.get("table_name") or ""
column_name = rule.get("column_name") or "" column_name = rule.get("column_name") or ""
...@@ -148,10 +159,11 @@ def run_step_custom_value_check( ...@@ -148,10 +159,11 @@ def run_step_custom_value_check(
display_rule_name = ( display_rule_name = (
user_rule_name user_rule_name
if user_rule_name if user_rule_name
else (f"{table_name}.{column_name}" if table_name and column_name else "(未命名)") else (f"{table_name}.{column_name}" if table_name and column_name else f"自定义规则{rule_index}")
) )
common = { common = {
"rule_index": rule_index,
"rule_name": display_rule_name, "rule_name": display_rule_name,
"table_name": table_name, "table_name": table_name,
"table_comment": table_comment, "table_comment": table_comment,
...@@ -160,10 +172,15 @@ def run_step_custom_value_check( ...@@ -160,10 +172,15 @@ def run_step_custom_value_check(
"data_type": data_type, "data_type": data_type,
"rule_type": rule_type, "rule_type": rule_type,
} }
# 本规则的命中容器(即使没命中也要建,给 tab 一个空数组让前端渲染空表)
rule_matches_by_idx[f"rule_{rule_index}"] = []
rule_matches: list[dict] = []
if not table_name or not column_name: if not table_name or not column_name:
matches_out.append({**common, "value": None, err_entry = {**common, "value": None,
"error": "table_name / column_name 缺失"}) "error": "table_name / column_name 缺失"}
matches_out.append(err_entry)
rule_matches.append(err_entry)
failed += 1 failed += 1
continue continue
...@@ -179,8 +196,10 @@ def run_step_custom_value_check( ...@@ -179,8 +196,10 @@ def run_step_custom_value_check(
group_node, col_quoted, cfg.db_type, group_node, col_quoted, cfg.db_type,
) )
except Exception as e: except Exception as e:
matches_out.append({**common, "value": None, err_entry = {**common, "value": None,
"error": f"条件树编译失败: {type(e).__name__}: {e}"}) "error": f"条件树编译失败: {type(e).__name__}: {e}"}
matches_out.append(err_entry)
rule_matches.append(err_entry)
failed += 1 failed += 1
continue continue
...@@ -211,8 +230,9 @@ def run_step_custom_value_check( ...@@ -211,8 +230,9 @@ def run_step_custom_value_check(
rows = db.fetchall(sql, tuple(params)) rows = db.fetchall(sql, tuple(params))
except Exception as e: except Exception as e:
err_msg = f"{type(e).__name__}: {e}" err_msg = f"{type(e).__name__}: {e}"
matches_out.append({**common, "value": None, err_entry = {**common, "value": None, "error": err_msg}
"error": err_msg}) matches_out.append(err_entry)
rule_matches.append(err_entry)
failed += 1 failed += 1
if log: if log:
log("WARN", log("WARN",
...@@ -225,9 +245,13 @@ def run_step_custom_value_check( ...@@ -225,9 +245,13 @@ def run_step_custom_value_check(
v = r.get(column_name.lower()) v = r.get(column_name.lower())
if v is None: if v is None:
continue continue
matches_out.append({**common, "value": v, "error": None}) entry = {**common, "value": v, "error": None}
matches_out.append(entry)
rule_matches.append(entry)
total_matched_rows += len(rows) total_matched_rows += len(rows)
# 把本规则的命中写回(即使空列表也要写,给 tab source 一个稳定引用)
rule_matches_by_idx[f"rule_{rule_index}"] = rule_matches
executed = len(custom_rules) - skipped_empty - skipped_no_valid_leaf - failed executed = len(custom_rules) - skipped_empty - skipped_no_valid_leaf - failed
...@@ -240,8 +264,35 @@ def run_step_custom_value_check( ...@@ -240,8 +264,35 @@ def run_step_custom_value_check(
f"命中行 {total_matched_rows}", f"命中行 {total_matched_rows}",
step="custom_value_check") step="custom_value_check")
# rules_meta: 给 _build_result 用的「每条规则元信息」,含 idx + 状态
# 顺序按 custom_rules 原顺序(1-based index),保证 tab 顺序与前端卡片顺序一致
rules_meta: list[dict] = []
for rule_index, raw_rule in enumerate(custom_rules, start=1):
rule = _normalize_rule(raw_rule)
user_rule_name = (rule.get("rule_name") or "").strip()
table_name = rule.get("table_name") or ""
column_name = rule.get("column_name") or ""
display_rule_name = (
user_rule_name
if user_rule_name
else (f"{table_name}.{column_name}" if table_name and column_name else f"自定义规则{rule_index}")
)
rule_matches = rule_matches_by_idx.get(f"rule_{rule_index}", [])
# 状态:error in any entry → 失败;否则按 entry 数判断
has_error = any(e.get("error") for e in rule_matches)
status = "failed" if has_error else "ok"
rules_meta.append({
"index": rule_index,
"rule_name": display_rule_name,
"table_name": table_name,
"column_name": column_name,
"matches": rule_matches,
"status": status,
})
return _build_result( return _build_result(
matches_out, matches_out,
rules_meta=rules_meta,
total_rules=len(custom_rules), total_rules=len(custom_rules),
executed=executed, executed=executed,
skipped_empty=skipped_empty, skipped_empty=skipped_empty,
...@@ -254,6 +305,7 @@ def run_step_custom_value_check( ...@@ -254,6 +305,7 @@ def run_step_custom_value_check(
# ── 输出组装 ────────────────────────────────────────────── # ── 输出组装 ──────────────────────────────────────────────
def _build_result( def _build_result(
matches: list[dict], matches: list[dict],
rules_meta: list[dict],
*, *,
total_rules: int, total_rules: int,
executed: int, executed: int,
...@@ -264,6 +316,21 @@ def _build_result( ...@@ -264,6 +316,21 @@ def _build_result(
) -> dict: ) -> dict:
"""组装最终 data dict + 注入 _protocol。 """组装最终 data dict + 注入 _protocol。
每个用户自定义规则单独占一个 tab(key=custom_value_check_<idx>):
- title:<rule_name>(<table>.<column>)
- summary:本规则执行状态 + 命中行数(失败时显示错误)
- table:本规则的命中明细(source=this.matches_rule_<idx>)
数据结构(data 顶层):
- matches_rule_<idx>:本规则的命中明细 list(给 tab.source 用)
- error_rule_<idx>:本规则第一条 error 字符串(失败时 summary 引用)
- matched_rule_<idx>:本规则命中行数 int(summary 引用)
为什么不走 dict 索引(this.by_rule['rule_X']):前端 evalPathExpr 是简单属性路径
(this.x.y.z),不识别 [key]。所以扁平命名(matches_rule_1 / error_rule_1 ...)最干净。
matches 仍保留扁平聚合,给任何兜底消费者用。
单独抽出来:空规则(custom_rules=[])也要返回带 _protocol 的结构, 单独抽出来:空规则(custom_rules=[])也要返回带 _protocol 的结构,
让前端 displayedTabs / flatResultRows 拿到空 tab 后渲染「空状态」而不是完全不显示。 让前端 displayedTabs / flatResultRows 拿到空 tab 后渲染「空状态」而不是完全不显示。
""" """
...@@ -281,37 +348,54 @@ def _build_result( ...@@ -281,37 +348,54 @@ def _build_result(
"matches": matches, "matches": matches,
} }
# 每个规则 1 个 tab
tabs: list[TabProtocol] = []
for m in rules_meta:
idx = m["index"]
rule_name = m["rule_name"]
table_name = m["table_name"]
column_name = m["column_name"]
rule_matches = m.get("matches", [])
status = m.get("status", "ok")
# 把本规则的明细写到 data 顶层(扁平命名,evalPathExpr 直接 this.x 即可)
data[f"matches_rule_{idx}"] = rule_matches
first_error = next((e.get("error") for e in rule_matches if e.get("error")), None)
data[f"error_rule_{idx}"] = first_error or ""
matched_count = len(rule_matches)
data[f"matched_rule_{idx}"] = matched_count
# tab 标题:规则名(表.字段)—— 表.字段 让用户知道这条规则作用在哪个字段
if table_name and column_name:
tab_title = f"{rule_name}({table_name}.{column_name})"
else:
tab_title = rule_name
# summary alert:本规则的执行结果
# 失败 / 命中 0 / 命中 N 三种文案
if status == "failed":
summary_type = "error"
summary_template = "执行失败:**{{error}}**"
summary_vars = {"error": f"this.error_rule_{idx}"}
else:
summary_type = "info" if matched_count > 0 else "warning"
summary_template = "本规则命中 **{{matched}}** 行(每条 SQL 命中行 = 一条 entry,不做 distinct)。"
summary_vars = {"matched": f"this.matched_rule_{idx}"}
tab = TabProtocol( tab = TabProtocol(
key="custom_value_check", key=f"custom_value_check_{idx}",
title="自定义规则命中明细", title=tab_title,
kpis=(
KpiSpec(css_class="kpi-custom", label="规则数",
value="this.summary.total_rules"),
KpiSpec(css_class="kpi-custom", label="有效规则",
value="this.summary.executed_rules"),
KpiSpec(css_class="kpi-custom", label="命中行数",
value="this.summary.total_matched_rows"),
),
summary=SummarySpec( summary=SummarySpec(
type="info", type=summary_type,
template=( template=summary_template,
"执行 **{{total}}** 条规则," vars=summary_vars,
"有效 **{{executed}}** 条,"
"命中 **{{matched}}** 行(每条 SQL 命中行 = 一条 entry,不做 distinct)。"
),
vars={
"total": "this.summary.total_rules",
"executed": "this.summary.executed_rules",
"matched": "this.summary.total_matched_rows",
},
), ),
tables=( tables=(
TableSpec( TableSpec(
title="规则命中明细", title="命中明细",
source="this.matches", source=f"this.matches_rule_{idx}",
columns=( columns=(
ColumnSpec(prop="rule_name", label="规则名称", min_width=180, # 不再显示 rule_name(tab 标题已经显示了)
render=RenderSpec(kind="text")),
ColumnSpec(prop="table_name", label="表名", min_width=160, ColumnSpec(prop="table_name", label="表名", min_width=160,
render=RenderSpec(kind="text")), render=RenderSpec(kind="text")),
ColumnSpec(prop="column_name", label="字段名", min_width=140, ColumnSpec(prop="column_name", label="字段名", min_width=140,
...@@ -330,4 +414,6 @@ def _build_result( ...@@ -330,4 +414,6 @@ def _build_result(
), ),
), ),
) )
return inject_protocol(data, step_id="custom_value_check", tabs=[tab]) tabs.append(tab)
\ No newline at end of file
return inject_protocol(data, step_id="custom_value_check", tabs=tabs)
\ No newline at end of file
...@@ -1383,16 +1383,29 @@ ...@@ -1383,16 +1383,29 @@
if (!matched) continue; if (!matched) continue;
matchedStepIds.add(c.step_id); matchedStepIds.add(c.step_id);
for (const t of matched) { for (const t of matched) {
// 2026-08-13:自定义规则的每个 tab 是 1 条规则
// 「违规」= 本规则命中行数(matched_rule_<idx>);「检查」= 1
// 其它 step 沿用顶层 summary.violations / summary.fields_checked
let violation_count = 0;
let checked_count = 0;
if (t.tabKey && t.tabKey.startsWith('custom_value_check_')) {
const idx = t.tabKey.replace('custom_value_check_', '');
violation_count = (t.data && t.data[`matched_rule_${idx}`]) || 0;
checked_count = 1;
} else {
violation_count = (
(t.data && t.data.summary && t.data.summary.violations) || 0
);
checked_count = (
(t.data && t.data.summary && t.data.summary.fields_checked) || 0
);
}
matchedItems.push({ matchedItems.push({
tabKey: t.tabKey, tabKey: t.tabKey,
stepId: t.stepId, stepId: t.stepId,
title: t.title, title: t.title,
violation_count: ( violation_count,
(t.data && t.data.summary && t.data.summary.violations) || 0 checked_count,
),
checked_count: (
(t.data && t.data.summary && t.data.summary.fields_checked) || 0
),
}); });
} }
} }
...@@ -1442,18 +1455,30 @@ ...@@ -1442,18 +1455,30 @@
const fallbackKey = '__unmatched__'; const fallbackKey = '__unmatched__';
const isExpanded = expandedGroups.value.has(fallbackKey) const isExpanded = expandedGroups.value.has(fallbackKey)
|| !expandedGroups.value.size; // 首次无结果前默认展开兜底组 || !expandedGroups.value.size; // 首次无结果前默认展开兜底组
// 2026-08-13:未匹配分组里也要识别 custom_value_check_<idx> tab
const fallbackItems = items.map(t => {
if (t.tabKey && t.tabKey.startsWith('custom_value_check_')) {
const idx = t.tabKey.replace('custom_value_check_', '');
return {
...t,
violation_count: (t.data && t.data[`matched_rule_${idx}`]) || 0,
checked_count: 1,
};
}
return t;
});
out.push({ out.push({
key: fallbackKey, key: fallbackKey,
_level: 0, _level: 0,
is_group: true, is_group: true,
title: '其他', title: '其他',
description: 'analysisTree 未匹配', description: 'analysisTree 未匹配',
_checked_count: items.length, _checked_count: fallbackItems.length,
_total_count: items.length, _total_count: fallbackItems.length,
_expanded: isExpanded, _expanded: isExpanded,
}); });
if (isExpanded) { if (isExpanded) {
for (const it of items) { for (const it of fallbackItems) {
out.push({ out.push({
key: it.tabKey, key: it.tabKey,
_level: 1, _level: 1,
...@@ -1598,6 +1623,18 @@ ...@@ -1598,6 +1623,18 @@
// 点击字段表右侧 + 号:拷贝字段信息 + 字段 data_type(决定可用比较符) // 点击字段表右侧 + 号:拷贝字段信息 + 字段 data_type(决定可用比较符)
// 已存在的同一字段会被去重(防止用户重复点) // 已存在的同一字段会被去重(防止用户重复点)
// table_comment 兜底:字段行 row 可能不带,从 tableSummary 反查 // table_comment 兜底:字段行 row 可能不带,从 tableSummary 反查
// 默认名「自定义规则N」:扫描已用编号,找最小可用 N(用户手动改名后 N 仍递增)
function nextDefaultRuleName() {
const used = new Set(
(customRules.value || [])
.map(r => (r.rule_name || '').trim())
.filter(Boolean)
);
let n = 1;
while (used.has(`自定义规则${n}`)) n++;
return `自定义规则${n}`;
}
function addCustomRule(row) { function addCustomRule(row) {
if (!row || !row.table_name || !row.column_name) { if (!row || !row.table_name || !row.column_name) {
return; return;
...@@ -1621,8 +1658,9 @@ ...@@ -1621,8 +1658,9 @@
} }
const newRule = { const newRule = {
id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
// 用户输入的展示名(2026-08-12 起新增列):留空时后端回落到 "<table>.<column>" // 默认名「自定义规则N」:跳过当前已用的所有 N,找最小可用编号
rule_name: '', // 用户在卡片里改名后,新规则的 N 不冲突
rule_name: nextDefaultRuleName(),
table_name: row.table_name, table_name: row.table_name,
table_comment: tableComment, table_comment: tableComment,
column_name: row.column_name, column_name: row.column_name,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment