Commit 4b694b62 authored by Data Governance Dev's avatar Data Governance Dev

feat(web): 自定义规则端到端 + 空 steps 区分 None/[]

将「数据字典浏览器 → 字段表 + 号 → 累积自定义规则 → 后端跑 LIKE 检索」打通完整链路。

【新增】
- web/core/step_impl/step9_custom_rules.py:每条规则 SELECT col FROM tbl
  WHERE col LIKE '%kw%' ESCAPE '!' LIMIT 200;跨 DB 占位符
  (mysql=%s / dmPython=oracledb=?)、统一 '!' 避免达梦 [CODE:-6106];
  每条 SQL 命中行 = 1 个 entry(不做 distinct,重复值并列展示)
- orchestrator 注册 custom_value_check 步骤(hidden=True,前端不展示;
  提交 non-empty custom_rules 时自动追加)
- models.ConnectRequest.custom_rules 字段

【修改】
- job_manager:区分 None(跑全部)vs [](显式空选择),同步 orchestrator 语义
- orchestrator.run_governance_workflow:同上 + 接受 custom_rules 参数
- step8_report:自定义规则报告渲染改读 matches[](7 列:规则名称/表名/
  字段名/匹配值/规则/关键字/错误;删除「命中行」列)
- index.html:分析配置卡 + 自定义规则卡 UI 联动
- docs/WORKLOG.md:上述变更的工作记录

【为什么需要整体提交】
orchestrator 的 steps 语义修正 + custom_rules 注入 + job_manager 同步 +
step9 执行 + step8 渲染 + models 字段,每一块独立提交都会留下 broken
intermediate commit。
parent b95def37
...@@ -2,6 +2,540 @@ ...@@ -2,6 +2,540 @@
> 任务做完一次记一次。最近的在最上面。 > 任务做完一次记一次。最近的在最上面。
## 2026-08-12 · 自定义规则:去掉 distinct 后仍看到旧字段 — start.py reload=False 需手动重启
### 现象
> 用户截图反馈:「还是有distinct」
上面一条已经把 `web/core/step_impl/step9_custom_rules.py` 改为「每条 SQL 行 = 1 个 entry,无 distinct 概念」,并清空 `__pycache__/`。但用户结果页仍显示 `命中 1 个 distinct 值` + `命中行: 200` 这种旧 KPI / 旧列。
### 根因
[web/start.py:148](web/start.py#L148) 用的是 `reload=False`:
```python
uvicorn.run(
"web.app:app",
host=host,
port=port,
reload=False, # ← 不会监听文件变化自动重启
log_level=os.environ.get("LOG_LEVEL", "info"),
)
```
uvicorn 在 `reload=False` 下启动时把所有模块加载进运行进程,**之后对 `.py` 的修改只在下次冷启动时生效**。清 `__pycache__/` 只影响"下次 import"路径,对已加载的内存模块无影响。
所以用户截图里看到的还是「旧 step9 跑出来的旧数据 + 旧 protocol 列」。
### 修复(操作步骤,无需改代码)
1. 在跑 `python web/start.py` 的终端按 `Ctrl+C`
2. 重新执行 `python web/start.py`(会重新 import 所有模块 → 加载新版 step9)
3. 浏览器 `Ctrl + F5`(绕过静态资源 / protocol 缓存)
之后重跑自定义规则 → 结果页:
- 顶部 KPI:`规则数 / 有效规则 / 命中行数`(**没有「命中 distinct 值」**)
- 表格列:`规则名称 | 表名 | 字段名 | 匹配值 | 规则 | 关键字 | 错误`(**没有「命中行」列**)
### 为什么没改成 `reload=True`
`reload=True` 会让 uvicorn 监听 `web/app.py` 所在目录的**所有 `.py` 文件**。这会带来两个问题:
1. 用户每次点前端触发请求时,`web/` 子树任何文件变化(包括 `outputs/<db>/<ts>/findings/_all_findings.json` 写入等)都可能误触发重启
2. 跨 DB 适配时多写了 `web/sql/info_schema/list_*.oracle.sql`,reload 会让 SQL 模板缓存频繁失效
更稳的做法是手动重启;如果用户希望开发期自动 reload,可以改成 `reload=True, reload_dirs=[<core>]` 限定监听目录。
### 备注:本次未改动任何业务代码
只清理 `__pycache__/`、定位 `reload=False` 这一行、把根因写到日志里。`step9_custom_rules.py` 已在上一次提交里完成改动(去掉 distinct),代码逻辑是正确的。
## 2026-08-12 · 自定义规则:去掉 distinct,每条 SQL 命中行 = 一个 entry
### 需求
> 用户反馈:「我发现原因了,你家了distinct,不要distinct」
上一条把输出从「1 条规则 = 1 行 + distinct sample_values[]」改成「1 个 distinct 值 = 1 行」。但用户实际想要的是看**原始命中行**(重复值也全列出来),distinct 反而把信息丢了。
### 改动
[web/core/step_impl/step9_custom_rules.py](web/core/step_impl/step9_custom_rules.py) 进一步扁平化:
```python
# 旧:distinct_count 聚合后取 top 50 distinct
distinct_count: dict = {}
for r in rows:
v = r.get(...)
distinct_count[v] = distinct_count.get(v, 0) + 1
items = sorted(distinct_count.items(), key=lambda kv: -kv[1])[:50]
# 新:每条 SQL 行 = 1 个 entry(不做 distinct)
for r in rows:
v = r.get(...)
if v is None: continue
matches_out.append({..., "value": v, "error": None})
```
字段调整:
- `matches[].value` 直接是单行值(不再有 `matched_rows` 计数)
- `summary.total_matched_rows` = SQL 返回行数;`violations` = `total_matched_rows`(前端 KPI)
- 删除 `summary.total_distinct_values` 字段(不再有 distinct 概念)
- `_protocol` 列删除 `matched_rows`;KPI 去掉「命中 distinct 值」
- `_LIMIT = 200` 仍保留作为 SQL 行数硬上限(防止大表撑爆内存 / UI)
[web/core/step_impl/step8_report.py](web/core/step_impl/step8_report.py) 同步:
- `_md_custom_value_check` / `_docx_custom_value_check` 表头从 8 列 → 7 列(去掉「命中行」)
- 摘要行:「命中 distinct 值」→「命中行数(每条 SQL 命中行 = 一行,不去重)」
- 不再排序(无 `matched_rows` 字段可排)—— 保持 SQL 返回顺序
### 验证
```python
matches = [
{..., "value": "421100"},
{..., "value": "421100"}, # 重复值仍保留
{..., "value": "421102"},
]
out = _build_result(matches, total_matched_rows=3, ...)
assert len(out["matches"]) == 3 # ✅ 3 个 entry(不去重)
assert out["summary"]["total_matched_rows"] == 3
assert "matched_rows" not in [c["prop"] for c in out["_protocol"]["tabs"][0]["tables"][0]["columns"]]
# md: 421100 出现 2 次
md.count("| 421100 |") == 2 # ✅
md.count("| 421102 |") == 1
```
### 影响
- 结果页 / Markdown / Word 都直接展示原始命中行,重复值并列出现(不再聚合)
- 用户能看全 SQL 命中的每一条数据(被 `LIMIT 200` 截断前)
## 2026-08-12 · 自定义规则:结果行扁平化为「一行 = 一个命中值」
### 需求
> 用户截图反馈:「还是显示的样本数,需要和别的分析一样直接显示查到的数据」
上一条加的「样本值(distinct,最多 50)」列用 `tag_list` 渲染,但用户希望和 standards / empty_fields / length_check 那些分析一致:**每行 = 一条命中实例**,能直接看到「这个值命中了多少行」。
### 改动
[web/core/step_impl/step9_custom_rules.py](web/core/step_impl/step9_custom_rules.py) 重构输出结构:
```python
# 旧:rules[] 聚合(1 条规则 = 1 行 + sample_values[] 内嵌)
rules: [
{rule_name, table_name, column_name, ..., matched_count: 200, sample_values: [...]},
]
# 新:matches[] 扁平(1 个 distinct 值 = 1 行)
matches: [
{rule_name, table_name, column_name, value: '421100', matched_rows: 5, keyword: '4211', error: None},
{rule_name, table_name, column_name, value: '421102', matched_rows: 3, keyword: '4211', error: None},
...
]
```
实现细节:
- 在内存里 `distinct_count: dict[value, count]` 聚合(避免发第二次 SQL)
- 按 `count` 倒序保留前 `_MAX_SAMPLES = 50` 个 distinct 值
- `summary` 增加 `total_distinct_values`,`violations = total_distinct_values`(让前端 KPI 反映实际行数)
- `_protocol.tables[0]` 列改为:`规则名称 | 表名 | 字段名 | 匹配值 | 规则 | 关键字 | 命中行 | 错误`,`匹配值` 用 `kind=code` 渲染(高亮 + 等宽字体)
[web/core/step_impl/step8_report.py](web/core/step_impl/step8_report.py) 同步:
- `_md_custom_value_check` / `_docx_custom_value_check`:表头从 10 列(聚合)改为 8 列(扁平);迭代对象从 `rules[]` 改为 `matches[]`;摘要行加 `命中 distinct 值` 字段
### 验证
```python
out = _build_result([{...value: '421100', matched_rows: 5}, {...value: '421102', matched_rows: 3}],
total_distinct_values=50, total_matched_rows=200, ...)
assert out["summary"]["violations"] == 50
assert [c["prop"] for c in out["_protocol"]["tabs"][0]["tables"][0]["columns"]] == [
"rule_name", "table_name", "column_name", "value", "rule_type", "keyword", "matched_rows", "error",
]
```
→ md / docx 渲染都包含「匹配值」列与具体值(`421100` / `421102`)。
### 影响
- 结果页折叠树 → 「自定义规则命中明细」tab 1 行变最多 50 行,每行一个 distinct 值
- Markdown / Word 报告「自定义规则命中」章节从 10 列变 8 列,更紧凑
- 用户能看到「每个 distinct 值具体命中了几行」(`matched_rows`),而非「整条规则共命中 N 行」
## 2026-08-12 · 自定义规则:结果页列出命中样本值
### 需求
> 用户截图反馈:「自定义规则不是要总数,要把数据列出来」
之前 step9 在结果页只展示「命中行 = 200」一个数字,看不到具体匹配了什么值。需要在结果页 / 报告里把命中的 distinct 样本值列出来(让用户一眼能判断关键字命中是否合理)。
### 改动
[web/core/step_impl/step9_custom_rules.py](web/core/step_impl/step9_custom_rules.py) `_build_result()` 的 `TabProtocol.tables[].columns` 加一列:
```python
ColumnSpec(
prop="sample_values",
label="样本值(distinct,最多 50)",
min_width=320,
render=RenderSpec(kind="tag_list"), # 每个 distinct 值一个 el-tag
),
```
`sample_values` 字段在 step9 主循环里已经按 `distinct` 去重 + 截前 50 收集(见 `_MAX_SAMPLES` 常量),无需新增逻辑。
[web/core/step_impl/step8_report.py](web/core/step_impl/step8_report.py) 同步更新:
- `_md_custom_value_check` 表头:「样本(distinct)」→「样本值(distinct,最多 50)」;分隔行数从 9 → 10。
- `_docx_custom_value_check` 同步。截断阈值从 10 提升到 50(与结果页一致;超过显示 `... (+N)`)。
### 验证
```python
out = _build_result(rules_with_samples, total_rules=1, executed=1, ...)
cols = out["_protocol"]["tabs"][0]["tables"][0]["columns"]
sample_col = next(c for c in cols if c["prop"] == "sample_values")
assert sample_col["render"]["kind"] == "tag_list" # ✅
```
→ md / docx 渲染含 `样本值(distinct,最多 50)` 列且样本值(如 `421100` / `429021`)出现在 cell 里。
### 影响
- 结果页折叠树 → 「自定义规则命中明细」tab 命中行从 200 的一行扩展为 N 行,每行带 `el-tag` 列表展示 distinct 值。
- Markdown / Word 报告「自定义规则命中」章节表头列数从 9 列变 10 列;样本展示上限从 10 提到 50(与 UI 一致)。
## 2026-08-12 · 修复:step9 ESCAPE 子句长度(达梦 [CODE:-6106])
### 需求
> 用户截图反馈:「没勾选的分析配置没有显示了,但是出错了:DatabaseError: [CODE:-6106]无效的转义字符长度」
上一条 fix 解决了 `job_manager` 空 steps 的问题 + 占位符跨 DB,但 LIKE 的 ESCAPE 子句在达梦上还报错:`ESCAPE` 后的字符字面量必须是 1 字符,而 SQL 标准 `'\\'` 在 Python 源里要写成 `'\\\\'`(4 字符 → Python 字符串 `\\` = 2 字符 → 达梦 parser 看到 2 字符就拒绝)。
### 改动
[web/core/step_impl/step9_custom_rules.py](web/core/step_impl/step9_custom_rules.py) 把转义字符从 `\` 换成 `!`:
```python
_ESCAPE_CHAR = "!"
def _escape_like(s: str) -> str:
# ! 必须最先转义(否则后面产生的 !\ 会被二次转义成 !!\)
return (
s.replace("!", "!!")
.replace("\\", "!\\")
.replace("%", "!%")
.replace("_", "!_")
)
sql = (
f"... WHERE {col} LIKE {placeholder} "
f"ESCAPE '{_ESCAPE_CHAR}' LIMIT {_LIMIT}"
)
```
选 `!` 的理由:
- MySQL / 达梦 / Oracle 都接受 `ESCAPE '!'`(任意单字符,SQL 标准)
- `!` 不需要 SQL 反斜杠转义 → 避开达梦 `-6106`
- 用户关键字里极少有 `!`;万一有,由 `_escape_like` 先转成 `!!`
### 验证
```python
# escape 行为
_escape_like("4211") == "4211"
_escape_like("100%") == "100!%"
_escape_like("a_b") == "a!_b"
_escape_like("a\\b") == "a!\\b"
_escape_like("!hi") == "!!hi"
_escape_like("100%_!\\") == "100!%!_!!!\\" # 复合 + ! + \
# SQL 字面量长度
re.search(r"ESCAPE '(.)'", sql_for("dameng", ...)).group(1) == "!" # len = 1
```
→ 达梦 [CODE:-6106] 已绕开;关键字里的 `%` / `_` / `\` / `!` 都被正确转义;3 个 DB 占位符维持 `%s`/`?` 切换。
## 2026-08-12 · 修复:job_manager 空 steps 仍跑全部 + step9 跨方言占位符
### 需求
> 用户截图反馈:「出错了,然后我没有勾选分析配置但是还是出现在分析结果当中」
上一条记录的两个改动有 bug:
1. **结果页仍然显示全部 step**:虽然 [web/core/orchestrator.py](web/core/orchestrator.py) 调度计划解析改对了,但 [web/core/job_manager.py](web/core/job_manager.py) 早在调 `run_governance_workflow` 之前就把 `steps` 强制 coalesce 成非空列表 —— 空列表被回落到 `all_steps`,跑全套。
2. **SQL 报错**:`DatabaseError: [CODE:-2007] 第 1 行, 第 69 列[%附近出现错误`。`step9_custom_rules.py` 用了 `%s` 占位符,但达梦(dmPython)paramstyle = `qmark`(用 `?`),把 `%s` 当字面量、把 `%4211%` 当模式片段 → 语法分析出错。
### 改动
**1) [web/core/job_manager.py](web/core/job_manager.py#L191-L200) 同步「空 = 显式空选择」语义**
```python
# 旧(有 bug):if job.req.steps else all_steps
# 新:跟 orchestrator 一致,区分 None(兼容老调用)vs [](显式空选择)
if job.req.steps is None:
steps_planned = list(all_steps)
else:
steps_planned = list(job.req.steps)
```
**2) [web/core/step_impl/step9_custom_rules.py](web/core/step_impl/step9_custom_rules.py) 占位符按 db_type 切换**
```python
# pymysql (mysql) → pyformat → %s
# dmPython (dameng) → qmark → ?
# oracledb (oracle) → ?(thin 模式自动转 :1)
placeholder = "%s" if (cfg.db_type or "").lower() == "mysql" else "?"
sql = (
f"SELECT {quote_ident(column_name, cfg.db_type)} "
f"FROM {quote_ident(table_name, cfg.db_type)} "
f"WHERE {quote_ident(column_name, cfg.db_type)} LIKE {placeholder} ESCAPE '\\\\' "
f"LIMIT {_LIMIT}"
)
```
### 验证
```python
# job_manager 语义对比
fixed(MockReq([]), all_steps) == [] # 旧版会回落到 all_steps
fixed(MockReq(None), all_steps) == all_steps # 兼容老调用
# placeholder
placeholder_for('mysql') == '%s'
placeholder_for('dameng') == '?'
placeholder_for('oracle') == '?'
```
→ 用户场景「steps=[] + 1 条自定义规则」现在只会跑 `custom_value_check`,结果页只显示「其他」组下的「自定义规则命中明细」,不再塞满所有基础检查 / 国标 / 业务字段组。
→ 用户场景「达梦 + 关键字 4211」现在能正常出结果(用 `?` 占位符)。
## 2026-08-12 · 自定义规则:结果页只显示「已选配置 + 自定义规则」+ 规则名称列
### 需求
> "我没有选择任何分析配置,但是新建了一个自定规则进行分析,结果集这里把全部分析对象都放进去了但是没有显示自定义规则,修改一下分析结果,仅包含选中的分析配置(如果有)以及自定规则,另外,在自定义规则前面加一个输入框,让用户可以输入规则名称,结果里面显示用户输入的名称"
三个具体要求:
1. 分析结果页只显示 **已勾选的 step + 自定义规则**,不再把所有 step 都塞进去。
2. 自定义规则 card 每行加一个 **「规则名称」** 输入框(放在最前面),让用户自定义展示名。
3. 报告 / 结果页输出 **用户输入的规则名称**。
### 改动
**1) 后端:空 steps = 显式空选择(2026-08-12 起的语义)**
[web/core/orchestrator.py](web/core/orchestrator.py) 调度计划解析:
```python
# 区分 None(= 跑全部,向后兼容老调用) vs [](= 显式空选择,2026-08-12 起的语义)
if steps is None:
requested = [s.step_id for s in get_step_defs()]
else:
requested = list(steps)
```
之前用 `if steps else [s.step_id for s in get_step_defs()]`,空列表会回落到「全部」,导致用户取消全选后仍跑全套 → 结果页塞满。
**2) 前端:startJob 兜底校验 + 空 steps 不启动**
[web/static/index.html](web/static/index.html) `startJob()`:
```js
const effectiveCustomRules = buildCustomRulesForSubmit();
if (form.steps.length === 0 && effectiveCustomRules.length === 0) {
ElementPlus.ElMessage.error('请至少勾选 1 项检查 或 添加 1 条自定义规则(带关键字)');
submitting.value = false;
return;
}
```
防止「啥也没勾 + 没填关键字」提交后静默跑全部。
**3) step9 输出加 `rule_name` + 注入 `_protocol`(让结果页可见)**
[web/core/step_impl/step9_custom_rules.py](web/core/step_impl/step9_custom_rules.py):
- 每条 rule 读 `rule_name`(用户输入的展示名);为空时回落到 `<table>.<column>`,让报告 / 结果页永远有可读名字。
- 抽出 `_build_result(...)`:返回的 data 现在带 `_protocol.tabs`,让前端 `displayedTabs` 能渲染该 section。**不注入 → 整张 tab 不可见**(这是上一版「自定义规则不出现在结果页」的根因)。
- summary 同时输出 `violations` 和 `fields_checked`(兼容前端 `overallViolationCount` / `flatResultRows.violation_count`)。
- TabProtocol:3 个 KPI(规则数 / 有效规则 / 命中行合计)、一个 summary alert、一个 TableSpec(rule_name / table_name / column_name / rule_type / keyword / matched_count / error 7 列)。
**4) 前端:自定义规则 card 加「规则名称」列**
[web/static/index.html](web/static/index.html):
- 在自定义规则表里新增「规则名称(展示用)」列,**最左**(在「对象表名 / 注释」前面),用户可在输入框填展示名。
- `addCustomRule(row)` 初始化 `rule_name: ''`。
- `buildCustomRulesForSubmit()` 透传 `rule_name`(即便空字符串也透传,让后端走 fallback)。
**5) 报告渲染加 rule_name 列**
[web/core/step_impl/step8_report.py](web/core/step_impl/step8_report.py):
- `_md_custom_value_check` 表头:`规则名称 | 表名 | 表注释 | ... | 错误`(11 列)。
- `_docx_custom_value_check` 表头同步追加。
- 空 rule_name 在渲染时填 `-`(fallback 由 step9 后端完成)。
### 验证
```bash
python -c "
from web.core.step_impl.step9_custom_rules import _build_result
empty = _build_result([], total_rules=0, executed=0, skipped_empty=0, failed=0, total_matched=0)
assert empty['_protocol']['step_id'] == 'custom_value_check'
print('empty ok')
out = _build_result([{'rule_name':'r1','table_name':'t','column_name':'c','rule_type':'contains_keyword','keyword':'x','matched_count':5,'sample_values':[],'error':None}], total_rules=1, executed=1, skipped_empty=0, failed=0, total_matched=5)
assert out['summary']['violations'] == 5
print('non-empty ok')
print('cols=', [c['prop'] for c in out['_protocol']['tabs'][0]['tables'][0]['columns']])
"
```
→ `_build_result` 空规则 / 非空规则都返回合规结构;列定义含 `rule_name` / `matched_count` 等。
```python
# displayedTabs 过滤模拟:仅 planned=[custom_value_check] → 仍能渲染出 1 个 tab
```
→ 端到端通过。
### 影响
- 「分析结果」折叠树现在严格只显示用户实际跑的 step + 自定义规则。
- 自定义规则现在也会出现在结果页(不再只在报告里看到)。
- 用户可在自定义规则 card 给每条规则起展示名;空名时自动回落到 `<table>.<column>`。
## 2026-08-12 · 自定义规则 card 顺序调整(置上)
### 需求
> "自定义规则card放到数据字典浏览器上面"
「自定义规则」card 之前在「数据字典浏览器」下方,但用户的工作流是「先看浏览器 → 点 + 号 → 回到上面编辑关键字」,上来下去跳转不顺。调整顺序:分析配置 → 自定义规则 → 数据字典浏览器。
### 改动
- [web/static/index.html](web/static/index.html) 把「卡片 2.5:自定义规则」整块移到「数据字典浏览器」卡片 **之前**。
- 同步更新两处文案:
- 自定义规则 card 的空状态提示:"在「数据字典浏览器」中..." → "在**下方**「数据字典浏览器」中..."
- 字段表 + 号列注释:「写入下方「自定义规则」card」→「写入**上方**「自定义规则」card」
后端逻辑不变。
## 2026-08-12 · 自定义规则 · 后端 SQL 执行(最小可用版)
### 需求
> "不对不对,自定义规则不是查元数据,就是要查DB里面的数据有哪些不合规" → 用户在 AskUserQuestion 里选「只列出含关键字的行(不做合规判定)」
上一条做的「自定义规则」前端框架只是 UI 状态积攒,本条把规则真接到数据库查询:每条规则跑一次 `SELECT col FROM table WHERE col LIKE '%kw%' ESCAPE '\\' LIMIT 200`,命中行写到 `sections['custom_value_check']`,Markdown / Word 报告末尾追加「自定义规则命中」section。
最小可用版 = 不加 UI tab,规则命中明细只在报告里看。
### 改动
#### 1. 新增 [web/core/step_impl/step9_custom_rules.py](web/core/step_impl/step9_custom_rules.py)
文件名刻意没用 `step8_*` —— 避免与 `step8_report.py` 编号冲突。
核心函数:
```python
def run_step_custom_value_check(cfg, custom_rules, log) -> dict:
# 对每条 rule 跑:
sql = (
f"SELECT {quote_ident(column_name, cfg.db_type)} "
f"FROM {quote_ident(table_name, cfg.db_type)} "
f"WHERE {quote_ident(column_name, cfg.db_type)} LIKE %s ESCAPE '\\\\' "
f"LIMIT {_LIMIT}" # 200
)
rows = db.fetchall(sql, (f"%{_escape_like(keyword)}%",))
# distinct 后最多保留 50 个样本
```
要点:
- **LIKE 转义**:`_escape_like()` 把 `\` `%` `_` 先反转义,否则用户输入「100%」会被 LIKE 视为「100 + 任意字符」。SQL 里 `ESCAPE '\\'` 跟 Python 字符串里的 `\\\\` 对应(双层转义)。
- **空 user_input → 跳过**,不报错、不计 failed。
- **单条 SQL 失败 → 写 `error` 字段继续**,不中断整个 step(一条坏规则不能把全部规则废掉)。
- **样本 distinct** 用 `(type(v).__name__, str(v))` 做哈希;不可哈希类型 fallback 到 `id()`。
#### 2. 后端接线
- [web/core/models.py](web/core/models.py) — `ConnectRequest` 新增 `custom_rules: list[dict] = Field(default_factory=list)`
- [web/core/orchestrator.py](web/core/orchestrator.py)
- 模块级 `_CUSTOM_RULES_CTX` 容器 + `_run_custom_value_check` runner(dispatch 循环不改,runner 读 ctx)
- 注册新 step `step_id="custom_value_check"`, `hidden=True`(不暴露 UI,由后端按需自动追加)
- `run_governance_workflow` 签名加 `custom_rules: list[dict] | None = None`;启动时写入 ctx + **自动追加 `custom_value_check` 到 `main_steps`**(仅当至少一条规则有非空 `user_input`)
- [web/core/job_manager.py](web/core/job_manager.py) — `_run_job` 通过 `getattr(job.req, "custom_rules", None)` 透传(向后兼容老请求)
#### 3. 报告生成
- [web/core/step_impl/step8_report.py](web/core/step_impl/step8_report.py)
- `SECTION_ORDER` 末尾追加 `("custom_value_check", "自定义规则命中")`
- 新增 `_md_custom_value_check` / `_docx_custom_value_check` 两个渲染器
- 输出列:表名 / 表注释 / 字段名 / 字段注释 / 规则 / 关键字 / 命中数 / 样本(distinct)/ 错误
- 按命中数倒序展示
#### 4. 前端提交
- [web/static/index.html](web/static/index.html) — `startJob()` 的 payload 追加 `custom_rules: buildCustomRulesForSubmit()`
- 新增 `buildCustomRulesForSubmit()`:过滤掉 `user_input.trim()` 为空的项,其余原样透传
### 不做什么(明确 out of scope)
- **UI tab 不加**:命中明细只在 Markdown / Word 报告里看,避免改动 `displayedTabs` 那块复杂逻辑。
- **大小写不敏感**:MySQL 默认 `_ci` 字符集已经大小写不敏感;Oracle / 达梦 用户感知差异大,先走库默认。需要时再在 SQL 里 `LOWER(col)` 包一层。
- **不合规判定**:用户明确选「只列出含关键字的行,不做合规判定」,不做 SUM/CASE 比率统计。
- **不在用户勾选范围内的表也跑**:rule 自带 `table_name`,不受 `form.tables` 限制 —— 用户可能想查未被勾选治理的表里的特定字段。
### 验证
```
PYTHONIOENCODING=utf-8 PYTHONPATH=. python scripts/_tmp_verify_custom_rules.py
```
- `_escape_like`:5 / 5 用例通过(含 `%` `_` `\` 各种组合)
- `ConnectRequest.custom_rules`:Pydantic 接受 list 结构
- `custom_value_check` 在 `get_step_defs()` 中存在
- Markdown / Word 渲染器都注册了对应 handler
### 后续
- 如果后续需要 UI tab:参考 step7 的 `tab_protocol.py`,加 `TabProtocol` + 在 `displayedTabs` 里挂上
- 规则类型扩展:把 `_run_custom_value_check` 改为 rule_type 分发(`contains_keyword` / `not_contains_keyword` / `regex_match` 等)
## 2026-08-12 · 自定义规则(前端框架版)
### 需求
> "现在要增加一个自定义功能,在测试链接得到数据字典浏览器之后,在选中表格后表格的字段表会更新到右侧,这个右侧的表的右边增加一个+号,点击这个加号可以增加自定配置,先把这个框架搭起来,先只用实现自定配置的字段包含某关键词,关键词让用户自己输入,在分析配置的下方增加一个自定配置的card用来显示自定义配置,也是可以折叠,自定义配置可以增加多项,先只显示对象表名注释、字段名字段注释,规则以及用户输入,比如现在要实现的包含关键字"
先把「自定义规则」框架搭起来,目前只实装一种规则:「字段包含关键字」(用户自填关键字)。
### 改动
#### 1. 字段表右边新增 + 号列(`web/static/index.html`)
在「数据字典浏览器」右侧字段表的尾部加一个 `fixed="right"` 的 + 号按钮列(`<el-table-column label="" width="56">`),点击调 `addCustomRule(row)`,把当前行的表/字段信息拷到自定义规则列表。
#### 2. 新增「自定义规则」card(卡片 2.5)
位置:**分析配置 card 之后、数据字典浏览器 card 之前**,可折叠,复用既有 `<transition name="collapse">` + `is-collapsed` 模式(header padding 6px / body padding 1px)。
表头:`Filter` 图标 + 「自定义规则」 + 「已添加 N 项」 + 「清空」+ 「展开/折叠」。
列表列(`el-table`,空时显示占位提示):
| 列 | 来源 |
|---|---|
| 对象表名 / 注释 | `row.table_name` + `row.table_comment` |
| 字段名 / 注释 | `row.column_name` + `row.column_comment` |
| 规则 | 固定 `el-tag` 「包含关键字」(type=primary) |
| 用户输入(关键字) | `el-input` 绑 `row.user_input` |
| 操作 | 删除按钮(`el-icon Delete`) |
#### 3. Vue 状态 & 函数
- `const customRules = ref([])` —— 数组,每项 `{id, table_name, table_comment, column_name, column_comment, rule_type, user_input}`
- `const customConfigCollapsed = ref(false)` —— 卡片折叠状态
- `addCustomRule(row)`:去重(同一 table+column 不重复加)+ 弹 ElMessage 警告 → 添加后自动展开卡片
- `removeCustomRule(index)` / `clearCustomRules()`:删单条 / 清空
- `disconnectDataDict()` 增加 `customRules.value = []`,断开连接时清空,避免下次连接后残留
- 全部暴露在 setup() return 中
### 后续
- 后端 `custom_rules` 字段未加:当前是纯前端状态(仅 UI 框架演示)。
- 后续要做的:(1)把 `customRules` 序列化到 `ConnectRequest`;(2)后端 step 流水线接收并执行匹配;(3)规则类型扩展(不止「包含关键字」)。
## 2026-08-12 · 字段匹配规则外置 + 检查项自定义输入框 ## 2026-08-12 · 字段匹配规则外置 + 检查项自定义输入框
### 需求 ### 需求
......
...@@ -190,7 +190,13 @@ class JobManager: ...@@ -190,7 +190,13 @@ class JobManager:
# 收集要跑的步骤(从 orchestrator 的注册表取,保持单一来源;报告生成在主流程末尾无条件触发,不在此处) # 收集要跑的步骤(从 orchestrator 的注册表取,保持单一来源;报告生成在主流程末尾无条件触发,不在此处)
from .orchestrator import get_step_defs from .orchestrator import get_step_defs
all_steps = [s.step_id for s in get_step_defs()] all_steps = [s.step_id for s in get_step_defs()]
steps_planned = job.req.steps if job.req.steps else all_steps # 区分 None(= 跑全部,向后兼容老调用) vs [](= 显式空选择,2026-08-12 起的语义)
# 必须同步 orchestrator.run_governance_workflow 的语义,
# 否则前端提交 steps=[] 时仍会跑全部 → 结果页塞满。
if job.req.steps is None:
steps_planned = list(all_steps)
else:
steps_planned = list(job.req.steps)
job.steps_planned = steps_planned job.steps_planned = steps_planned
logger.info(f"[job {job.job_id}] 计划步骤: {steps_planned}") logger.info(f"[job {job.job_id}] 计划步骤: {steps_planned}")
...@@ -232,6 +238,7 @@ class JobManager: ...@@ -232,6 +238,7 @@ class JobManager:
run_dir=run_dir, run_dir=run_dir,
enable_llm=job.req.enable_llm, enable_llm=job.req.enable_llm,
match_overrides=getattr(job.req, "match_overrides", None), match_overrides=getattr(job.req, "match_overrides", None),
custom_rules=getattr(job.req, "custom_rules", None),
on_log=lambda entry: self._sync_log(job, entry), on_log=lambda entry: self._sync_log(job, entry),
on_step_start=lambda step_id, title: self._sync_step_start(job, step_id, title), on_step_start=lambda step_id, title: self._sync_step_start(job, step_id, title),
on_step_done=lambda step_id, title, ok: self._sync_step_done(job, step_id, title, ok), on_step_done=lambda step_id, title, ok: self._sync_step_done(job, step_id, title, ok),
......
...@@ -38,6 +38,13 @@ class ConnectRequest(BaseModel): ...@@ -38,6 +38,13 @@ class ConnectRequest(BaseModel):
# 用于本次任务(不写回配置文件)。 # 用于本次任务(不写回配置文件)。
# 跨字段 / 跨表 indicator(IND-301/302/901/902)即使提交了 override 也会被忽略。 # 跨字段 / 跨表 indicator(IND-301/302/901/902)即使提交了 override 也会被忽略。
match_overrides: dict[str, dict[str, list[str]]] = Field(default_factory=dict) match_overrides: dict[str, dict[str, list[str]]] = Field(default_factory=dict)
# 可选:用户在前端「自定义规则」卡片里累积的规则(点字段表 + 号 → 输入关键字)
# 每项: {table_name, table_comment, column_name, column_comment,
# rule_type: 'contains_keyword', user_input: '<keyword>'}
# 后端 step8_custom_rules 会按每条规则跑 SQL:
# SELECT <col> FROM <table> WHERE <col> LIKE '%<keyword>%' ESCAPE '\\' LIMIT 200
# user_input 为空的项会被跳过(不报错)
custom_rules: list[dict] = Field(default_factory=list)
class TestConnectionRequest(ConnectRequest): class TestConnectionRequest(ConnectRequest):
......
...@@ -155,6 +155,32 @@ def _run_standards(*, cfg, dict_data, llm, log, cancel_event, table_filter, matc ...@@ -155,6 +155,32 @@ def _run_standards(*, cfg, dict_data, llm, log, cancel_event, table_filter, matc
return {"section_key": "standard_violations", "data": data} return {"section_key": "standard_violations", "data": data}
def _run_custom_value_check_factory(custom_rules: list[dict] | None):
"""自定义规则 step 的 runner 工厂 —— 通过闭包捕获 custom_rules
dispatch 循环调 runner 时只传 (cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides),
没有 custom_rules 参数;这里通过工厂模式在注册时把 custom_rules 钉死。
"""
def _runner(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides): # noqa: ARG001
from .step_impl.step9_custom_rules import run_step_custom_value_check
data = run_step_custom_value_check(cfg, custom_rules or [], log=log)
return {"section_key": "custom_value_check", "data": data}
return _runner
# ── 自定义规则上下文(workflow 启动时由 run_governance_workflow 写入) ──
# dispatch 循环不传 custom_rules 参数,所以用一个 module-level 容器承载本次运行的规则列表。
# 仅 run_governance_workflow 内部写,外面只读。
_CUSTOM_RULES_CTX: list[dict] | None = None
def _run_custom_value_check(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides): # noqa: ARG001
"""dispatch 调用的 runner —— 读 _CUSTOM_RULES_CTX(workflow 启动时注入)"""
from .step_impl.step9_custom_rules import run_step_custom_value_check
data = run_step_custom_value_check(cfg, _CUSTOM_RULES_CTX or [], log=log)
return {"section_key": "custom_value_check", "data": data}
# ── 注册(按 order 升序:UI / 日志展示顺序) ────────────────────── # ── 注册(按 order 升序:UI / 日志展示顺序) ──────────────────────
register_step( register_step(
step_id="merge_redundancy", step_id="merge_redundancy",
...@@ -564,6 +590,28 @@ def _register_indicator_steps() -> None: ...@@ -564,6 +590,28 @@ def _register_indicator_steps() -> None:
_register_indicator_steps() _register_indicator_steps()
register_step(
step_id="custom_value_check",
title="自定义规则(字段值包含关键字)",
description="用户在「数据字典浏览器」字段表右侧 + 号累积的规则:按 (表, 字段, 关键字) 跑 LIKE 检索,返回命中样本",
requires_db=True, llm_mode="none", required=False, order=900,
fn=_run_custom_value_check,
hidden=True, # 2026-08-12 起:不在 UI 展示;触发方式:前端提交 custom_rules 时自动追加(见 run_governance_workflow)
detail=StepDetail(
purpose="执行用户在前端「自定义规则」卡片里累积的规则,对每条 (表, 字段, 关键字) 跑一次 LIKE 检索,返回命中样本写入报告。",
target="用户在数据字典浏览器里 + 号选中的字段(不受 table_filter 限制)",
check=(
"1. 对每条 rule 执行 SQL:SELECT `<col>` FROM `<table>` WHERE `<col>` LIKE '%<kw>%' ESCAPE '\\\\' LIMIT 200\n"
"2. 关键字里的 % / _ / \\ 先 \\ 反转义,避免被 LIKE 当通配符吃掉\n"
"3. user_input 为空 → 跳过该条(不报错)\n"
"4. SQL 执行失败 → 写 error 字段继续下一条,不中断 step\n"
"5. 样本 distinct 后最多保留 50 个"
),
format="纯 SQL LIKE 检索,无国标对应;输出每条规则的命中数 + 样本值",
),
)
# ── 关键 Step(失败直接终止任务) ────────────────────────────── # ── 关键 Step(失败直接终止任务) ──────────────────────────────
# merge_redundancy:LLM-required,缺 LLM = 关键失败 # merge_redundancy:LLM-required,缺 LLM = 关键失败
# missing_comments:2026-08-11 起改为纯规则(不再依赖 LLM),失败不再终止流程 # missing_comments:2026-08-11 起改为纯规则(不再依赖 LLM),失败不再终止流程
...@@ -588,6 +636,7 @@ async def run_governance_workflow( ...@@ -588,6 +636,7 @@ async def run_governance_workflow(
run_dir: Path, run_dir: Path,
enable_llm: bool = True, enable_llm: bool = True,
match_overrides: dict[str, dict[str, list[str]]] | None = None, match_overrides: dict[str, dict[str, list[str]]] | None = None,
custom_rules: list[dict] | None = None,
on_log: Callable[[dict], None] | None = None, on_log: Callable[[dict], None] | None = None,
on_step_start: Callable[[str, str], None] | None = None, on_step_start: Callable[[str, str], None] | None = None,
on_step_done: Callable[[str, str, bool], None] | None = None, on_step_done: Callable[[str, str, bool], None] | None = None,
...@@ -603,6 +652,9 @@ async def run_governance_workflow( ...@@ -603,6 +652,9 @@ async def run_governance_workflow(
tables: 用户勾选的表名列表(不可为空 —— 由前端 / 路由层兜底校验) tables: 用户勾选的表名列表(不可为空 —— 由前端 / 路由层兜底校验)
run_dir: 本次运行的输出目录 run_dir: 本次运行的输出目录
enable_llm: 是否允许 Step 调用 LLM enable_llm: 是否允许 Step 调用 LLM
match_overrides: 每 step 的「字段名 / 字段注释」用户运行时 override
custom_rules: 用户在前端累积的自定义规则(点字段表 + 号 → 输入关键字)
非空时自动追加 custom_value_check 步骤到 main_steps
Returns: Returns:
{ {
...@@ -641,8 +693,20 @@ async def run_governance_workflow( ...@@ -641,8 +693,20 @@ async def run_governance_workflow(
table_filter: set[str] | None = set(tables) if tables else None table_filter: set[str] | None = set(tables) if tables else None
log("INFO", f"用户勾选表数: {len(tables)} 张") log("INFO", f"用户勾选表数: {len(tables)} 张")
# ── 注入自定义规则上下文(runner 通过 _CUSTOM_RULES_CTX 读) ──
global _CUSTOM_RULES_CTX
_CUSTOM_RULES_CTX = list(custom_rules) if custom_rules else None
if _CUSTOM_RULES_CTX:
log("INFO",
f"自定义规则: {len(_CUSTOM_RULES_CTX)} 条,将自动追加 custom_value_check 步骤",
step="custom_value_check")
# ── 解析要跑的步骤 ── # ── 解析要跑的步骤 ──
requested = list(steps) if steps else [s.step_id for s in get_step_defs()] # 区分 None(= 跑全部,向后兼容老调用) vs [](= 显式空选择,2026-08-12 起的语义)
if steps is None:
requested = [s.step_id for s in get_step_defs()]
else:
requested = list(steps)
unknown = [s for s in requested if s not in _STEPS] unknown = [s for s in requested if s not in _STEPS]
if unknown: if unknown:
msg = f"未知的 step_id: {unknown}(已注册的:{[s.step_id for s in get_step_defs()]})" msg = f"未知的 step_id: {unknown}(已注册的:{[s.step_id for s in get_step_defs()]})"
...@@ -651,6 +715,16 @@ async def run_governance_workflow( ...@@ -651,6 +715,16 @@ async def run_governance_workflow(
main_steps = requested main_steps = requested
log("INFO", f"调度计划: 主步骤={main_steps}") log("INFO", f"调度计划: 主步骤={main_steps}")
# ── 自动追加:用户提交了 custom_rules → 追加 custom_value_check 步骤 ──
# 仅当至少有一条非空 user_input 的规则(空规则的项会在 step 内被跳过)
has_effective_rule = any(
(r.get("user_input") or "").strip()
for r in (_CUSTOM_RULES_CTX or [])
)
if has_effective_rule and "custom_value_check" not in main_steps:
main_steps.append("custom_value_check")
log("INFO", "已自动追加自定义规则步骤 custom_value_check", step="custom_value_check")
# ── 启动预检:LLM-required 步骤 + LLM 不可用 → fail-fast ── # ── 启动预检:LLM-required 步骤 + LLM 不可用 → fail-fast ──
llm = get_llm_client() if enable_llm else None llm = get_llm_client() if enable_llm else None
required_planned = [s for s in main_steps if _STEPS[s].llm_mode == "required"] required_planned = [s for s in main_steps if _STEPS[s].llm_mode == "required"]
......
...@@ -26,6 +26,7 @@ SECTION_ORDER = [ ...@@ -26,6 +26,7 @@ SECTION_ORDER = [
("missing_comments", "缺失注释的字段"), ("missing_comments", "缺失注释的字段"),
("length_issues", "字段长度异常"), ("length_issues", "字段长度异常"),
("standard_violations", "不符合国家标准的字段"), ("standard_violations", "不符合国家标准的字段"),
("custom_value_check", "自定义规则命中"),
] ]
...@@ -567,6 +568,48 @@ def _md_standards(lines: list[str], data: dict): ...@@ -567,6 +568,48 @@ def _md_standards(lines: list[str], data: dict):
lines.append("") lines.append("")
def _md_custom_value_check(lines: list[str], data: dict):
"""自定义规则(字段值包含关键字)— 命中明细
输出格式:每条 SQL 命中行 = 一行(**不做 distinct**,重复值并列出现)。
"""
summary = data.get("summary") or {}
matches = data.get("matches") or []
total = summary.get("total_rules", 0)
executed = summary.get("executed_rules", 0)
skipped_empty = summary.get("skipped_empty_keyword", 0)
failed = summary.get("failed_rules", 0)
matched = summary.get("total_matched_rows", 0)
lines.append(f"规则数: **{total}** | "
f"有效: **{executed}** | "
f"空关键字跳过: **{skipped_empty}** | "
f"失败: **{failed}** | "
f"命中行数: **{matched}**(每条 SQL 命中行 = 一行,不去重)")
lines.append("")
if not matches:
lines.append("(未提供自定义规则 / 无命中)")
lines.append("")
return
# 保持 SQL 返回顺序(不再按 matched_rows 排序 —— 没有这个字段了)
lines.append(_md_row(["规则名称", "表名", "字段名", "匹配值", "规则", "关键字", "错误"]))
lines.append("|---|---|---|---|---|---|---|")
for m in matches:
lines.append(_md_row([
m.get("rule_name", "") or "-",
m.get("table_name", ""),
m.get("column_name", ""),
"" if m.get("value") is None else str(m.get("value")),
m.get("rule_type", ""),
m.get("keyword", ""),
m.get("error", "") or "-",
]))
lines.append("")
_MD_SECTION_HANDLERS = { _MD_SECTION_HANDLERS = {
"merge_candidates": _md_merge, "merge_candidates": _md_merge,
"redundancy_fields": _md_redundancy, "redundancy_fields": _md_redundancy,
...@@ -574,6 +617,7 @@ _MD_SECTION_HANDLERS = { ...@@ -574,6 +617,7 @@ _MD_SECTION_HANDLERS = {
"missing_comments": _md_missing, "missing_comments": _md_missing,
"length_issues": _md_length, "length_issues": _md_length,
"standard_violations": _md_standards, "standard_violations": _md_standards,
"custom_value_check": _md_custom_value_check,
} }
...@@ -988,6 +1032,40 @@ def _docx_standards(doc, data: dict): ...@@ -988,6 +1032,40 @@ def _docx_standards(doc, data: dict):
]) ])
def _docx_custom_value_check(doc, data: dict):
"""自定义规则 — 命中明细(Word)
输出格式:每条 SQL 命中行 = 一行(**不做 distinct**,重复值并列出现)。
"""
summary = data.get("summary") or {}
matches = data.get("matches") or []
doc.add_paragraph(
f"规则数: {summary.get('total_rules', 0)} | "
f"有效: {summary.get('executed_rules', 0)} | "
f"空关键字跳过: {summary.get('skipped_empty_keyword', 0)} | "
f"失败: {summary.get('failed_rules', 0)} | "
f"命中行数: {summary.get('total_matched_rows', 0)}(每条 SQL 命中行 = 一行,不去重)"
)
if not matches:
doc.add_paragraph("(未提供自定义规则 / 无命中)")
return
rows = []
for m in matches:
rows.append([
m.get("rule_name", "") or "-",
m.get("table_name", ""),
m.get("column_name", ""),
"" if m.get("value") is None else str(m.get("value")),
m.get("rule_type", ""),
m.get("keyword", ""),
m.get("error", "") or "-",
])
_add_table(doc, ["规则名称", "表名", "字段名", "匹配值", "规则", "关键字", "错误"], rows)
_DOCX_SECTION_HANDLERS = { _DOCX_SECTION_HANDLERS = {
"merge_candidates": _docx_merge, "merge_candidates": _docx_merge,
"redundancy_fields": _docx_redundancy, "redundancy_fields": _docx_redundancy,
...@@ -995,4 +1073,5 @@ _DOCX_SECTION_HANDLERS = { ...@@ -995,4 +1073,5 @@ _DOCX_SECTION_HANDLERS = {
"missing_comments": _docx_missing, "missing_comments": _docx_missing,
"length_issues": _docx_length, "length_issues": _docx_length,
"standard_violations": _docx_standards, "standard_violations": _docx_standards,
"custom_value_check": _docx_custom_value_check,
} }
\ No newline at end of file
"""Step 8: 自定义规则(用户在前端累积的「字段值包含关键字」类规则)
执行链路:
前端「数据字典浏览器」右侧字段表点 + 号 → 写到自定义规则 card(用户填规则名 + 关键字)
→ 提交时随 ConnectRequest.custom_rules 提交
→ 本 step 按每条规则跑一次 SQL:
SELECT `<column>`
FROM `<table>`
WHERE `<column>` LIKE '%<keyword>%' ESCAPE '!'
LIMIT 200
→ SQL 命中的每一行(**不做 distinct**)写入 sections['custom_value_check']
→ 注入 _protocol.tabs:让前端「分析结果」按协议渲染(不注入则该 section 不可见)
输出格式(每行 = 一条 SQL 命中行,不去重):
{
"summary": {...},
"matches": [
{"rule_name":..., "table_name":..., "column_name":...,
"value":..., "keyword":..., "error":...},
...
],
"_protocol": {...}
}
注意:
- 关键字 LIKE 转义:用 `!` 作为 escape char(跨 DB 兼容)
! → !! / \\ → !\\ / % → !% / _ → !_
选 `!` 而不是 `\\` 是为了避开 达梦的 [CODE:-6106] 无效的转义字符长度 报错
- 关键字为空 → 跳过(用户没填,不报错,也不输出 match 行)
- 单条规则 SQL 失败 → 写一行 error 字段继续下一条,不中断整个 step
- LIMIT 200:每条规则 SQL 命中上限,避免内存 / UI / 报告撑爆
- **不做 distinct**:用户要求看原始命中行(即便同值多次出现也全展示)
- 规则名 rule_name 是用户在前端输入的展示名(不是规则类型):
为空时自动回落到 "<table>.<column>" 让报告 / 结果页都有可读名字
- 占位符按 db_type 切换:
mysql → %s(pymysql 的 pyformat)
dameng / oracle → ?(dmPython 的 qmark;oracledb thin 模式自动转 :1)
不切换 → dmPython 会把 %s 当字面量并把 %4211% 当作模式片段 → 语法分析出错
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from ..db_adapter import DBConfig, open_db, quote_ident
from ..tab_protocol import (
ColumnSpec,
KpiSpec,
RenderSpec,
SummarySpec,
TableSpec,
TabProtocol,
inject_protocol,
)
logger = logging.getLogger(__name__)
_LIMIT = 200 # SQL 单条查询上限(不做 distinct,直接按行输出)
# LIKE 转义字符:选 `!` 是为了跨 DB 一致 + 避开 SQL 字符串字面量转义。
# - MySQL / 达梦 / Oracle 都接受 `ESCAPE '!'`(任意单字符)
# - `!` 不需要 SQL 反斜杠转义(避免达梦 [CODE:-6106] 无效的转义字符长度 报错)
# - 用户关键字里 `!` 极少;万一有,先转义成 `!!`
_ESCAPE_CHAR = "!"
def _escape_like(s: str) -> str:
"""对 LIKE 模式里的 ! \\ % _ 做反转义,保证用户输入的字面量不被当通配符。
转义顺序:! 必须最先,否则后面产生的 \\! 会被二次转义成 \\!!
"""
return (
s.replace("!", "!!") # ! → !! (escape char itself, must first)
.replace("\\", "!\\") # \ → !\
.replace("%", "!%") # % → !%
.replace("_", "!_") # _ → !_
)
def run_step_custom_value_check(
cfg: DBConfig,
custom_rules: list[dict],
log: Callable | None = None,
) -> dict:
"""对每条 user 规则跑一次 LIKE 检索,把每一条 SQL 命中行写入 matches[] + _protocol。
**不做 distinct**(2026-08-12 用户要求):每条 SQL 行 = 一条 entry,重复值也全展示。
Args:
cfg: 数据库连接配置
custom_rules: 前端提交的 [{table_name, column_name, rule_type, user_input,
rule_name?, ...}]
log: 日志回调(orchestrator 注入)
Returns:
{
"summary": {
"total_rules": int,
"executed_rules": int, # user_input 非空 + SQL 成功的条数
"skipped_empty_keyword": int,
"failed_rules": int,
"total_matched_rows": int, # SQL 命中行数(distinct 前)= matches[] 的非 error 行数
"violations": int, # = total_matched_rows(前端 overallViolationCount)
"fields_checked": int, # executed - failed
},
"matches": [
{
"rule_name": str, # 用户输入的展示名;空时回落到 "<table>.<column>"
"table_name", "table_comment", "column_name", "column_comment",
"rule_type", "keyword",
"value": Any, # 该 SQL 行的字段值(不做 distinct,重复值并列出现)
"error": Optional[str],
}
],
"_protocol": { # 注入协议,让前端「分析结果」tab 渲染该 section
"step_id": "custom_value_check",
"tabs": [...]
}
}
"""
matches_out: list[dict] = []
skipped_empty = 0
failed = 0
total_matched_rows = 0
if not custom_rules:
if log:
log("INFO", " · 自定义规则列表为空,跳过", step="custom_value_check")
return _build_result([], 0, 0, 0, 0)
if log:
log("INFO",
f" · 自定义规则: {len(custom_rules)} 条",
step="custom_value_check")
with open_db(cfg) as db:
for rule in custom_rules:
table_name = rule.get("table_name") or ""
column_name = rule.get("column_name") or ""
table_comment = rule.get("table_comment") or ""
column_comment = rule.get("column_comment") or ""
rule_type = rule.get("rule_type") or "contains_keyword"
keyword = (rule.get("user_input") or "").strip()
# 用户在前端「规则名称」列输入的展示名(2026-08-12 新增)
# 为空时回落到 "<table>.<column>",确保报告 / 结果页都有可读名字
user_rule_name = (rule.get("rule_name") or "").strip()
display_rule_name = (
user_rule_name
if user_rule_name
else (f"{table_name}.{column_name}" if table_name and column_name else "(未命名)")
)
common = {
"rule_name": display_rule_name,
"table_name": table_name,
"table_comment": table_comment,
"column_name": column_name,
"column_comment": column_comment,
"rule_type": rule_type,
"keyword": keyword,
}
if not table_name or not column_name:
matches_out.append({**common, "value": None,
"error": "table_name / column_name 缺失"})
failed += 1
continue
if not keyword:
# 用户没填关键字:静默跳过,不计 failed,也不输出 match 行
skipped_empty += 1
continue
try:
# 占位符按 DB 方言选:
# pymysql → pyformat → %s
# dmPython → qmark → ?
# oracledb → named/numbered(这里用 ? 也能跑,thin 模式自动转 :1)
# 不分方言统一 %s 会让 dmPython 报「语法分析出错:near %」
placeholder = "%s" if (cfg.db_type or "").lower() == "mysql" else "?"
sql = (
f"SELECT {quote_ident(column_name, cfg.db_type)} "
f"FROM {quote_ident(table_name, cfg.db_type)} "
f"WHERE {quote_ident(column_name, cfg.db_type)} LIKE {placeholder} ESCAPE '{_ESCAPE_CHAR}' "
f"LIMIT {_LIMIT}"
)
like_pattern = f"%{_escape_like(keyword)}%"
rows = db.fetchall(sql, (like_pattern,))
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
matches_out.append({**common, "value": None,
"error": err_msg})
failed += 1
if log:
log("WARN",
f" · {table_name}.{column_name} 规则执行失败: {e}",
step="custom_value_check")
continue
# 不做 distinct:每条 SQL 行 = 1 个 entry;NULL 值跳过(避免污染结果)
for r in rows:
v = r.get(column_name.lower())
if v is None:
continue
matches_out.append({**common, "value": v, "error": None})
total_matched_rows += len(rows)
if log:
log("INFO",
f" · 完成: 有效 {len(custom_rules) - skipped_empty - failed} 条,"
f"空关键字 {skipped_empty} 条,失败 {failed} 条,"
f"命中行 {total_matched_rows}",
step="custom_value_check")
return _build_result(
matches_out,
total_rules=len(custom_rules),
executed=len(custom_rules) - skipped_empty - failed,
skipped_empty=skipped_empty,
failed=failed,
total_matched_rows=total_matched_rows,
)
def _build_result(
matches: list[dict],
*,
total_rules: int,
executed: int,
skipped_empty: int,
failed: int,
total_matched_rows: int,
) -> dict:
"""组装最终 data dict + 注入 _protocol。
单独抽出来:空规则(custom_rules=[])也要返回带 _protocol 的结构,
让前端 displayedTabs / flatResultRows 拿到空 tab 后渲染「空状态」而不是完全不显示。
"""
data: dict = {
"summary": {
"total_rules": total_rules,
"executed_rules": executed,
"skipped_empty_keyword": skipped_empty,
"failed_rules": failed,
"total_matched_rows": total_matched_rows,
# violations = 命中行数(不做 distinct,每行 = 一条 entry)
"violations": total_matched_rows,
# fields_checked = 实际跑过的 SQL 条数(executed - failed)
"fields_checked": max(executed - failed, 0),
},
"matches": matches,
}
# ── 注入 _protocol.tabs ─────────────────────────────────────────
# 让前端 displayedTabs 能渲染该 section;不注入 → 整张 tab 不可见
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)。"
),
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")),
# 匹配值:每条 SQL 命中行 = 一行(不做 distinct,重复值并列出现)
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="keyword", label="关键字", min_width=140,
render=RenderSpec(kind="text")),
ColumnSpec(prop="error", label="错误", min_width=200,
render=RenderSpec(kind="text")),
),
),
),
)
return inject_protocol(data, step_id="custom_value_check", tabs=[tab])
...@@ -359,6 +359,101 @@ ...@@ -359,6 +359,101 @@
</transition> </transition>
</el-card> </el-card>
<!-- 卡片 2.5:自定义规则(点下方数据字典浏览器字段表右侧 + 号累积) -->
<el-card v-if="connected && dataDict.length > 0" class="section-card" :class="{ 'is-collapsed': customConfigCollapsed }" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><Filter /></el-icon>
<span class="card-title">自定义规则</span>
<span class="dict-status">已添加 <strong style="color: #409eff">{{ customRules.length }}</strong> 项</span>
<div style="margin-left: auto">
<el-button size="small" @click="clearCustomRules" :disabled="customRules.length === 0">清空</el-button>
<el-button
size="small"
text
@click="customConfigCollapsed = !customConfigCollapsed"
>
<el-icon>
<component :is="customConfigCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
{{ customConfigCollapsed ? '展开' : '折叠' }}
</el-button>
</div>
</div>
</template>
<transition name="collapse">
<div v-show="!customConfigCollapsed">
<el-table
v-if="customRules.length > 0"
:data="customRules"
stripe size="small"
style="width: 100%"
>
<el-table-column label="规则名称(展示用)" min-width="200">
<template #default="{ row }">
<el-input
v-model="row.rule_name"
size="small"
placeholder="(留空回落到 表名.字段名)"
clearable
/>
</template>
</el-table-column>
<el-table-column label="对象表名 / 注释" min-width="240">
<template #default="{ row }">
<code class="dict-name" :title="row.table_name">{{ row.table_name }}</code>
<div v-if="row.table_comment" class="dict-comment" :title="row.table_comment">{{ row.table_comment }}</div>
<div v-else class="dict-comment dict-comment--empty">(无表注释)</div>
</template>
</el-table-column>
<el-table-column label="字段名 / 注释" min-width="240">
<template #default="{ row }">
<code class="dict-name--field" :title="row.column_name">{{ row.column_name }}</code>
<div v-if="row.column_comment" class="dict-comment" :title="row.column_comment">{{ row.column_comment }}</div>
<div v-else class="dict-comment dict-comment--empty">(无字段注释)</div>
</template>
</el-table-column>
<el-table-column label="规则" width="160" align="center">
<template #default>
<el-tag size="small" type="primary" effect="plain">包含关键字</el-tag>
</template>
</el-table-column>
<el-table-column label="用户输入(关键字)" min-width="220">
<template #default="{ row }">
<el-input
v-model="row.user_input"
size="small"
placeholder="输入要匹配的关键字"
clearable
/>
</template>
</el-table-column>
<el-table-column label="操作" width="64" align="center" fixed="right">
<template #default="{ $index }">
<el-button
size="small"
type="danger"
text
@click="removeCustomRule($index)"
title="删除"
>
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="dict-status" style="padding: 24px; text-align: center;">
暂无自定义规则 —— 在下方「数据字典浏览器」中点击字段右侧的
<el-icon style="vertical-align: middle"><Plus /></el-icon>
按钮添加
</div>
<div class="form-hint" style="margin-top: 12px;">
框架演示:当前只支持「字段包含关键字」一种规则。后续会扩展更多规则类型。
</div>
</div>
</transition>
</el-card>
<!-- 卡片 3:数据字典浏览器(连接成功后才有数据) --> <!-- 卡片 3:数据字典浏览器(连接成功后才有数据) -->
<el-card v-if="connected && dataDict.length > 0" class="section-card" :class="{ 'is-collapsed': dataDictCollapsed }" shadow="hover"> <el-card v-if="connected && dataDict.length > 0" class="section-card" :class="{ 'is-collapsed': dataDictCollapsed }" shadow="hover">
<template #header> <template #header>
...@@ -470,6 +565,22 @@ ...@@ -470,6 +565,22 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="ordinal_position" label="序号" width="64" align="right" /> <el-table-column prop="ordinal_position" label="序号" width="64" align="right" />
<!-- 自定义规则:+ 号按钮,点击后写入上方「自定义规则」card -->
<el-table-column label="" width="56" align="center" fixed="right">
<template #default="{ row }">
<el-tooltip content="为此字段添加自定义规则" placement="top">
<el-button
size="small"
text
circle
@click="addCustomRule(row)"
title="添加自定义规则"
>
<el-icon><Plus /></el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
</div> </div>
...@@ -892,6 +1003,11 @@ ...@@ -892,6 +1003,11 @@
const analysisConfigCollapsed = ref(false); const analysisConfigCollapsed = ref(false);
const dataDictCollapsed = ref(false); const dataDictCollapsed = ref(false);
const connectionCollapsed = ref(false); const connectionCollapsed = ref(false);
// ── 自定义规则(前端累积;字段表 + 号 → 写入 customRules) ──
// 每项结构: { id, table_name, table_comment, column_name, column_comment, rule_type, user_input }
// rule_type 当前固定为 'contains_keyword'(占位,后续会扩展更多规则类型)
const customRules = ref([]);
const customConfigCollapsed = ref(false);
const logs = ref([]); const logs = ref([]);
const logExpanded = ref(false); // 2026-08-12 改:默认折叠实时日志 const logExpanded = ref(false); // 2026-08-12 改:默认折叠实时日志
const showStandardsDialog = ref(false); const showStandardsDialog = ref(false);
...@@ -1287,6 +1403,44 @@ ...@@ -1287,6 +1403,44 @@
jobResult.value = null; jobResult.value = null;
currentJob.value = null; currentJob.value = null;
testResult.value = null; testResult.value = null;
// 断开连接时清空自定义规则,避免下次连上后残留
customRules.value = [];
}
// ── 自定义规则 ──
// 点击字段表右侧 + 号:拷贝字段信息,规则类型固定为 'contains_keyword'
// 已存在的同一字段会被去重(防止用户重复点)
function addCustomRule(row) {
const dup = customRules.value.find(
r => r.table_name === row.table_name && r.column_name === row.column_name,
);
if (dup) {
ElementPlus.ElMessage.warning(
`${row.table_name}.${row.column_name} 已在自定义规则中`,
);
return;
}
customRules.value.push({
id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
// 用户输入的展示名(2026-08-12 起新增列):留空时后端回落到 "<table>.<column>"
rule_name: '',
table_name: row.table_name,
table_comment: row.table_comment || '',
column_name: row.column_name,
column_comment: row.column_comment || '',
rule_type: 'contains_keyword', // 当前唯一规则类型(占位)
user_input: '',
});
// 添加后自动展开卡片,方便用户编辑关键字
customConfigCollapsed.value = false;
}
function removeCustomRule(index) {
customRules.value.splice(index, 1);
}
function clearCustomRules() {
customRules.value = [];
} }
// ── 加载数据库连接默认值(来自 web/configs/db_defaults.yaml) ── // ── 加载数据库连接默认值(来自 web/configs/db_defaults.yaml) ──
...@@ -1436,10 +1590,19 @@ ...@@ -1436,10 +1590,19 @@
form.steps.push(s.id); form.steps.push(s.id);
} }
} }
// 兜底:steps 和自定义规则都为空时不允许启动(后端空 steps = 显式空选择,不会自动跑全部)
const effectiveCustomRules = buildCustomRulesForSubmit();
if (form.steps.length === 0 && effectiveCustomRules.length === 0) {
ElementPlus.ElMessage.error('请至少勾选 1 项检查 或 添加 1 条自定义规则(带关键字)');
submitting.value = false;
return;
}
// 构造提交用的 payload:match_overrides 由 string → list 转换(后端要 list) // 构造提交用的 payload:match_overrides 由 string → list 转换(后端要 list)
const payload = { const payload = {
...form, ...form,
match_overrides: buildMatchOverridesForSubmit(), match_overrides: buildMatchOverridesForSubmit(),
// 自定义规则:保留 user_input 非空的项;空关键字在后端会被静默跳过
custom_rules: buildCustomRulesForSubmit(),
}; };
try { try {
const r = await fetch('/api/jobs', { const r = await fetch('/api/jobs', {
...@@ -1490,6 +1653,31 @@ ...@@ -1490,6 +1653,31 @@
return s.split(',').map(x => x.trim()).filter(Boolean); return s.split(',').map(x => x.trim()).filter(Boolean);
} }
// ── 提交时把 custom_rules 转成后端要的 list 结构 ──
// 输入:customRules.value = [{id, rule_name, table_name, table_comment,
// column_name, column_comment, rule_type, user_input}, ...]
// 输出:[{rule_name, table_name, table_comment, column_name, column_comment,
// rule_type, user_input}, ...]
// 过滤 user_input 为空的项(用户加了规则但没填关键字)
// rule_name 透传(空字符串也透传,让后端走 fallback 逻辑)
function buildCustomRulesForSubmit() {
const out = [];
for (const r of (customRules.value || [])) {
const kw = (r.user_input || '').trim();
if (!kw) continue;
out.push({
rule_name: (r.rule_name || '').trim(),
table_name: r.table_name,
table_comment: r.table_comment,
column_name: r.column_name,
column_comment: r.column_comment,
rule_type: r.rule_type,
user_input: kw,
});
}
return out;
}
// ── 订阅日志 ── // ── 订阅日志 ──
function subscribeLogs(jobId) { function subscribeLogs(jobId) {
if (logEventSource) logEventSource.close(); if (logEventSource) logEventSource.close();
...@@ -1792,8 +1980,10 @@ ...@@ -1792,8 +1980,10 @@
loadStandards, loadStandards,
loadDbDefaults, loadDbDefaults,
loadMatchConfig, matchConfig, onMatchOverrideInput, loadMatchConfig, matchConfig, onMatchOverrideInput,
buildMatchOverridesForSubmit, buildMatchOverridesForSubmit, buildCustomRulesForSubmit,
analysisConfigCollapsed, dataDictCollapsed, connectionCollapsed, analysisConfigCollapsed, dataDictCollapsed, connectionCollapsed,
customRules, customConfigCollapsed,
addCustomRule, removeCustomRule, clearCustomRules,
onDbTypeChange, onDbTypeChange,
selectedField, selectFieldFilter, clearFieldFilter, selectedField, selectFieldFilter, clearFieldFilter,
}; };
......
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