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 @@
- numeric → gt / lt / eq
- 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}] 在入口自动包成
{kind:'group', op:'AND', children:[{kind:'leaf', comparator:'contains', value:kw}]}
......@@ -30,7 +39,7 @@
注意:
- LIMIT 200:每条规则 SQL 命中上限,避免内存 / UI / 报告撑爆
- 单条规则 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 整列名)
"""
from __future__ import annotations
......@@ -42,7 +51,6 @@ from ..db_adapter import DBConfig, open_db, quote_ident
from ..sql_builder import compile_rule
from ..tab_protocol import (
ColumnSpec,
KpiSpec,
RenderSpec,
SummarySpec,
TableSpec,
......@@ -118,6 +126,9 @@ def run_step_custom_value_check(
}
"""
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_no_valid_leaf = 0
failed = 0
......@@ -126,7 +137,7 @@ def run_step_custom_value_check(
if not custom_rules:
if log:
log("INFO", " · 自定义规则列表为空,跳过", step="custom_value_check")
return _build_result([], 0, 0, 0, 0, 0)
return _build_result([], [], 0, 0, 0, 0, 0)
if log:
log("INFO",
......@@ -134,7 +145,7 @@ def run_step_custom_value_check(
step="custom_value_check")
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)
table_name = rule.get("table_name") or ""
column_name = rule.get("column_name") or ""
......@@ -148,10 +159,11 @@ def run_step_custom_value_check(
display_rule_name = (
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 = {
"rule_index": rule_index,
"rule_name": display_rule_name,
"table_name": table_name,
"table_comment": table_comment,
......@@ -160,10 +172,15 @@ def run_step_custom_value_check(
"data_type": data_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:
matches_out.append({**common, "value": None,
"error": "table_name / column_name 缺失"})
err_entry = {**common, "value": None,
"error": "table_name / column_name 缺失"}
matches_out.append(err_entry)
rule_matches.append(err_entry)
failed += 1
continue
......@@ -179,8 +196,10 @@ def run_step_custom_value_check(
group_node, col_quoted, cfg.db_type,
)
except Exception as e:
matches_out.append({**common, "value": None,
"error": f"条件树编译失败: {type(e).__name__}: {e}"})
err_entry = {**common, "value": None,
"error": f"条件树编译失败: {type(e).__name__}: {e}"}
matches_out.append(err_entry)
rule_matches.append(err_entry)
failed += 1
continue
......@@ -211,8 +230,9 @@ def run_step_custom_value_check(
rows = db.fetchall(sql, tuple(params))
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
matches_out.append({**common, "value": None,
"error": err_msg})
err_entry = {**common, "value": None, "error": err_msg}
matches_out.append(err_entry)
rule_matches.append(err_entry)
failed += 1
if log:
log("WARN",
......@@ -225,9 +245,13 @@ def run_step_custom_value_check(
v = r.get(column_name.lower())
if v is None:
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)
# 把本规则的命中写回(即使空列表也要写,给 tab source 一个稳定引用)
rule_matches_by_idx[f"rule_{rule_index}"] = rule_matches
executed = len(custom_rules) - skipped_empty - skipped_no_valid_leaf - failed
......@@ -240,8 +264,35 @@ def run_step_custom_value_check(
f"命中行 {total_matched_rows}",
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(
matches_out,
rules_meta=rules_meta,
total_rules=len(custom_rules),
executed=executed,
skipped_empty=skipped_empty,
......@@ -254,6 +305,7 @@ def run_step_custom_value_check(
# ── 输出组装 ──────────────────────────────────────────────
def _build_result(
matches: list[dict],
rules_meta: list[dict],
*,
total_rules: int,
executed: int,
......@@ -264,6 +316,21 @@ def _build_result(
) -> dict:
"""组装最终 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 的结构,
让前端 displayedTabs / flatResultRows 拿到空 tab 后渲染「空状态」而不是完全不显示。
"""
......@@ -281,53 +348,72 @@ def _build_result(
"matches": matches,
}
tab = TabProtocol(
key="custom_value_check",
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(
type="info",
template=(
"执行 **{{total}}** 条规则,"
"有效 **{{executed}}** 条,"
"命中 **{{matched}}** 行(每条 SQL 命中行 = 一条 entry,不做 distinct)。"
# 每个规则 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(
key=f"custom_value_check_{idx}",
title=tab_title,
summary=SummarySpec(
type=summary_type,
template=summary_template,
vars=summary_vars,
),
vars={
"total": "this.summary.total_rules",
"executed": "this.summary.executed_rules",
"matched": "this.summary.total_matched_rows",
},
),
tables=(
TableSpec(
title="规则命中明细",
source="this.matches",
columns=(
ColumnSpec(prop="rule_name", label="规则名称", min_width=180,
render=RenderSpec(kind="text")),
ColumnSpec(prop="table_name", label="表名", min_width=160,
render=RenderSpec(kind="text")),
ColumnSpec(prop="column_name", label="字段名", min_width=140,
render=RenderSpec(kind="text")),
ColumnSpec(prop="data_type", label="类型", min_width=100,
align="center",
render=RenderSpec(kind="tag")),
ColumnSpec(prop="value", label="匹配值", min_width=240,
render=RenderSpec(kind="code")),
ColumnSpec(prop="rule_type", label="规则", min_width=120,
align="center",
render=RenderSpec(kind="tag")),
ColumnSpec(prop="error", label="错误", min_width=200,
render=RenderSpec(kind="text")),
tables=(
TableSpec(
title="命中明细",
source=f"this.matches_rule_{idx}",
columns=(
# 不再显示 rule_name(tab 标题已经显示了)
ColumnSpec(prop="table_name", label="表名", min_width=160,
render=RenderSpec(kind="text")),
ColumnSpec(prop="column_name", label="字段名", min_width=140,
render=RenderSpec(kind="text")),
ColumnSpec(prop="data_type", label="类型", min_width=100,
align="center",
render=RenderSpec(kind="tag")),
ColumnSpec(prop="value", label="匹配值", min_width=240,
render=RenderSpec(kind="code")),
ColumnSpec(prop="rule_type", label="规则", min_width=120,
align="center",
render=RenderSpec(kind="tag")),
ColumnSpec(prop="error", label="错误", min_width=200,
render=RenderSpec(kind="text")),
),
),
),
),
)
return inject_protocol(data, step_id="custom_value_check", tabs=[tab])
\ No newline at end of file
)
tabs.append(tab)
return inject_protocol(data, step_id="custom_value_check", tabs=tabs)
\ No newline at end of file
......@@ -1383,16 +1383,29 @@
if (!matched) continue;
matchedStepIds.add(c.step_id);
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({
tabKey: t.tabKey,
stepId: t.stepId,
title: t.title,
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
),
violation_count,
checked_count,
});
}
}
......@@ -1442,18 +1455,30 @@
const fallbackKey = '__unmatched__';
const isExpanded = expandedGroups.value.has(fallbackKey)
|| !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({
key: fallbackKey,
_level: 0,
is_group: true,
title: '其他',
description: 'analysisTree 未匹配',
_checked_count: items.length,
_total_count: items.length,
_checked_count: fallbackItems.length,
_total_count: fallbackItems.length,
_expanded: isExpanded,
});
if (isExpanded) {
for (const it of items) {
for (const it of fallbackItems) {
out.push({
key: it.tabKey,
_level: 1,
......@@ -1598,6 +1623,18 @@
// 点击字段表右侧 + 号:拷贝字段信息 + 字段 data_type(决定可用比较符)
// 已存在的同一字段会被去重(防止用户重复点)
// 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) {
if (!row || !row.table_name || !row.column_name) {
return;
......@@ -1621,8 +1658,9 @@
}
const newRule = {
id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
// 用户输入的展示名(2026-08-12 起新增列):留空时后端回落到 "<table>.<column>"
rule_name: '',
// 默认名「自定义规则N」:跳过当前已用的所有 N,找最小可用编号
// 用户在卡片里改名后,新规则的 N 不冲突
rule_name: nextDefaultRuleName(),
table_name: row.table_name,
table_comment: tableComment,
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