Commit 69eea339 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): rule 表加「规则名称」列 → 前端展示「违反哪条规则」

数据明细(不合规行)当前只展示「哪个字段不合规」,没有「哪条规则被违反」。
desc 是给 LLM 看的自然语言(含「必须 18 位」指令式描述),
不适合直接给用户看。加独立字段 name 给前端展示,desc 继续喂 LLM。

【schema 变更】
- rule.name TEXT NOT NULL DEFAULT ''(老规则保持空串,迁移不破坏存量)
- _migrate_rule_name():ALTER TABLE ADD COLUMN name(幂等,123 条老规则验证保留)

【API 校验】
- RulePayload.name: str = PydField(..., min_length=1) —— 必填
- RuleOut.name: str = "" —— 老规则空串兜底

【检测引擎链路】
- CompiledFieldRules.rules 5 元 → 6 元(追加 name)
- _load_task_compiled snapshot / rule_list 都带 name
- _evaluate_row 返回 issues: {field, name, desc}
  → bad_rows 透传到前端「数据明细」备注列

【前端 UI】
- RuleModal:在「规则说明」上方加「规则名称」输入框(红星必填)
  - hasValidRules 加 name.trim() 校验
  - onSave 序列化带 name
  - namePlaceholder 按 rule_type 给不同示例
  - scoped .required 红星样式(不污染全局)
- ResultTable:ruleHint / errTip / 备注列 issues 优先展示 name
- FieldConfigDrawer:ruleHint 同步升级「name(desc)」格式

【兼容策略】
- 老规则 name=空串,UI 三处自动回退 desc 显示
- 编辑老规则保存时强制补 name(避免历史脏数据持续累积)

【测试】
- test_rule_types_e2e 6 处 rule payload 补 name(Pydantic 必填后端会拒 422)
- pytest tests/ → 123 passed
- vite build → ✓ built in 9.64s
- 冒烟:init_db() 对线上 DB 跑迁移,老规则全保留默认 ''
parent cc4144fd
......@@ -43,9 +43,11 @@ class CompiledFieldRules:
字段大小写已归一为小写(与列名对齐)。
"""
field_key: str
# 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null)
# 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null, name)
# 2026-08-21:追加 skip_null(值为空时是否跳过)
rules: list[tuple[str, Optional[str], Optional[str], str, bool]] = field(default_factory=list)
# 2026-08-24:追加 name(规则名称)→ 给 _evaluate_row 写到 issues 里,
# 前端「数据明细」按字段+规则名展示「违反哪条规则」
rules: list[tuple[str, Optional[str], Optional[str], str, bool, str]] = field(default_factory=list)
@dataclass
......
......@@ -97,6 +97,8 @@ def init_db() -> None:
_migrate_rule_type_and_code()
# 一次性迁移:rule 表加 skip_null 列(2026-08-21 「跳过空值」开关,默认 0 = 不跳过)
_migrate_rule_skip_null()
# 一次性迁移:rule 表加 name 列(2026-08-24 「规则名称」用于前端展示「违反哪条规则」,默认 '' 老规则 desc 兜底)
_migrate_rule_name()
# 一次性迁移:task 表加 checked_at 列(2026-08-21 接 UI「最近检测时间」)
_migrate_task_checked_at()
# 一次性迁移:task 表加 table_comment 列(2026-08-21 接「数据表注释」UI)
......@@ -204,6 +206,25 @@ def _migrate_rule_skip_null() -> None:
)
def _migrate_rule_name() -> None:
"""给 rule 表加 name 列(2026-08-24 「规则名称」展示「违反哪条规则」用)。
NOT NULL DEFAULT '':老规则保持空串,迁移不破坏存量数据;
新建/编辑规则时由前端 RuleModal 强制非空 + API Pydantic 校验双保险。
UI 在 name 为空时用 desc 兜底显示。
"""
with engine.begin() as conn:
cols = {
row[1]: row
for row in conn.exec_driver_sql("PRAGMA table_info(rule)").fetchall()
}
if "name" not in cols:
logger.info("[migrate] rule 缺 name 列,补建(默认 '',老规则显示 desc 兜底)")
conn.exec_driver_sql(
"ALTER TABLE rule ADD COLUMN name TEXT NOT NULL DEFAULT ''"
)
def _migrate_task_checked_at() -> None:
"""给 task 表加 checked_at 列(2026-08-21 接上「最近检测时间」UI)。
......
......@@ -77,10 +77,14 @@ CREATE INDEX idx_field_task ON field(task_id);
-- code number/date/string 类型的 Python 函数源码(必须含 def check(value): 返回 bool)
-- regex regex 类型专用;number/date/string 留空
-- skip_null 1 = 值为空(None/空 str)时直接判合规跳过;0 = 空值进 check 函数按不合规处理
-- name 规则名称(2026-08-24):用于前端显示「违反哪条规则」
-- NOT NULL DEFAULT '':老规则(2026-08-24 之前)保持空串;
-- UI 在 name 为空时用 desc 兜底显示;新建规则必须非空(API 校验 + RuleModal 强制)
CREATE TABLE rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
field_id INTEGER NOT NULL REFERENCES field(id) ON DELETE CASCADE,
desc TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
regex TEXT,
rule_type TEXT NOT NULL DEFAULT 'regex',
code TEXT,
......
......@@ -19,6 +19,10 @@
- 默认 0(不跳过 —— 空值进 check 函数报错,记为不合规)
- 1 = 跳过(空值直接判合规,不走 check 函数)
- 对 regex 类型同样生效:空值直接 True,不跑正则
- name(2026-08-24):规则名称,给前端展示「违反哪条规则」用
- NOT NULL DEFAULT '':老规则保持空串(迁移不破坏存量数据)
- 新建/编辑时由 RuleModal 强制非空(前端)+ RulePayload 校验(API)
- 为空时 UI 用 desc 兜底显示
- ord:规则在 UI 上的显示顺序(保存时按数组顺序写入)
"""
from __future__ import annotations
......@@ -38,6 +42,8 @@ class Rule(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
field_id = Column(Integer, ForeignKey("field.id", ondelete="CASCADE"), nullable=False)
desc = Column(Text, nullable=False) # 规则说明(自然语言)
# 2026-08-24:规则名称,给前端展示「违反哪条规则」用;老规则默认空串(迁移兼容)
name = Column(Text, nullable=False, default="", server_default="")
regex = Column(Text, nullable=True) # Python 正则(regex 类型用)
rule_type = Column( # 校验类型(2026-08-21 扩展)
String, nullable=False, default=DEFAULT_RULE_TYPE,
......@@ -51,6 +57,8 @@ class Rule(Base):
return {
"id": self.id,
"desc": self.desc,
# 2026-08-24:规则名称;空串表示「老规则未填」,UI 用 desc 兜底
"name": self.name or "",
"regex": self.regex,
"rule_type": self.rule_type or DEFAULT_RULE_TYPE,
"code": self.code,
......
......@@ -147,6 +147,8 @@ def _load_task_compiled(db: Session, task_id: int):
{
"id": r.id,
"desc": r.desc,
# 2026-08-24:规则名称,老规则空串;前端用 desc 兜底显示
"name": r.name or "",
"rule_type": r.rule_type or "regex",
"regex": r.regex,
"code": r.code,
......@@ -162,23 +164,25 @@ def _load_task_compiled(db: Session, task_id: int):
# 注意:r.rule_type / r.desc / r.regex / r.code 都是 SQLAlchemy Mapped 字段,
# 运行时是 str,但静态类型是 InstrumentedAttribute —— 用 str() 包一下让 Pylance 也满意
# 2026-08-21:snapshot 追加 skip_null(值为空时是否跳过该规则)
# 2026-08-24:snapshot 再追加 name(规则名称)→ _evaluate_row 写到 issues → 数据明细展示
compiled_fields: list[CompiledFieldRules] = []
for f, rules in fields_with_rules:
snapshots: list[tuple[str, Optional[str], Optional[str], str, bool]] = []
snapshots: list[tuple[str, Optional[str], Optional[str], str, bool, str]] = []
for r in rules:
rt = str(r.rule_type or "regex")
desc = str(r.desc or "")
skip_null = bool(r.skip_null)
name = str(r.name or "")
if rt == "regex":
regex_src = str(r.regex or "").strip() or r"^.+$"
try:
compiled_pat = re.compile(regex_src)
snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null))
snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null, name))
except re.error as e:
logger.warning(f"[queries] 规则 id={r.id} regex 编译失败:{e}")
snapshots.append((rt, None, None, desc, skip_null)) # None 标记"坏规则"
snapshots.append((rt, None, None, desc, skip_null, name)) # None 标记"坏规则"
else:
snapshots.append((rt, None, str(r.code or ""), desc, skip_null))
snapshots.append((rt, None, str(r.code or ""), desc, skip_null, name))
compiled_fields.append(CompiledFieldRules(field_key=f.field_key.lower(), rules=snapshots))
# SELECT 列名:按 ord 升序的去重小写列表
......@@ -219,7 +223,10 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
"""对一行业务数据跑所有规则,返 (error_cells, issues)。
error_cells: 不合规字段 key 列表(去重保序)
issues: 每条不通过的规则的 {field, desc}
issues: 每条不通过的规则的 {field, name, desc}
- name: 2026-08-24 新增,规则名称(前端展示「违反哪条规则」用);
老规则空串时由 UI 用 desc 兜底
- desc: 规则说明(自然语言;执行失败时是「规则执行失败:xxx」)
"""
issues: list[dict] = []
error_cells: list[str] = []
......@@ -228,7 +235,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
# 2026-08-21:字段级「跳过空值」开关(per-field,非 per-rule)
# —— 检测引擎目前是 per-rule;如果未来要 per-field,把 skip_null 上提到 Field 模型
# 现在是 per-rule:跑每条规则时单独判断
for rule_type, compiled_regex, code, desc, skip_null in cf.rules:
for rule_type, compiled_regex, code, desc, skip_null, name in cf.rules:
passed: Optional[bool] = None
fail_desc: Optional[str] = None
if rule_type == "regex":
......@@ -254,7 +261,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
passed = False
fail_desc = f"规则执行失败:{e}"
if passed is False:
issues.append({"field": cf.field_key, "desc": fail_desc})
issues.append({"field": cf.field_key, "name": name, "desc": fail_desc})
if cf.field_key not in error_cells:
error_cells.append(cf.field_key)
return error_cells, issues
......
......@@ -39,6 +39,9 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
class RulePayload(BaseModel):
desc: str = PydField(..., min_length=1, description="规则说明(自然语言)")
# 2026-08-24:规则名称,前端展示「违反哪条规则」用;必填(min_length=1)
# 老规则(2026-08-24 之前)空名由迁移兜底(DB 默认 '' + UI desc 兜底显示)
name: str = PydField(..., min_length=1, description="规则名称(前端展示「违反哪条规则」用,必填)")
rule_type: str = PydField(
"regex",
description="规则类型:regex / number / date / string(2026-08-21 扩展)",
......@@ -97,6 +100,8 @@ class FieldOut(BaseModel):
class RuleOut(BaseModel):
id: int
desc: str
# 2026-08-24:规则名称;老规则可能是空串(迁移兼容),UI 用 desc 兜底
name: str = ""
regex: Optional[str]
rule_type: str = "regex"
code: Optional[str] = None
......@@ -181,6 +186,7 @@ def _replace_fields(db: Session, task: Task, fields_payload: list[FieldPayload])
rule = Rule(
field_id=field.id,
desc=r.desc.strip(),
name=r.name.strip(), # 2026-08-24:规则名称(必填,Pydantic 已保证非空)
regex=regex_val,
rule_type=rt,
code=code_val,
......@@ -208,6 +214,7 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None:
db.add(Rule(
field_id=new_f.id,
desc=src_r.desc,
name=src_r.name or "", # 2026-08-24:复制时也带 name(老规则可能为空串)
regex=src_r.regex,
rule_type=src_r.rule_type or "regex",
code=src_r.code,
......@@ -238,7 +245,9 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta
for f in db.query(Field).filter(Field.task_id == t.id).order_by(Field.ord).all():
rules = [
RuleOut(
id=r.id, desc=r.desc, regex=r.regex,
id=r.id, desc=r.desc,
name=r.name or "", # 2026-08-24:规则名称(老规则空串,UI desc 兜底)
regex=r.regex,
rule_type=r.rule_type or "regex",
code=r.code,
skip_null=bool(r.skip_null),
......
......@@ -72,10 +72,13 @@ function onSave() {
emit('save', list)
}
// 字段规则数 hover 提示文案 —— 跟 ResultTable.ruleHint 同款:有 ruleList 就列每条 desc,否则给通用文案
// 字段规则数 hover 提示文案 —— 跟 ResultTable.ruleHint 同款:
// 2026-08-24 起优先展示「规则名(desc)」,老规则空名时回退到 desc
function ruleHint(f) {
if (Array.isArray(f.ruleList) && f.ruleList.length > 0) {
return f.ruleList.map((r) => r.desc).join(';')
return f.ruleList
.map((r) => (r.name && r.name.trim() ? `${r.name}(${r.desc})` : r.desc))
.join(';')
}
return `该字段配置了 ${f.rules || 0} 条校验规则`
}
......
......@@ -72,13 +72,15 @@
:style="tdStyle(col)"
>
<!-- 备注列:所有 issues 列表 -->
<!-- 2026-08-24:优先展示规则名(name),老规则空名时回退到 desc -->
<template v-if="col.key === '__reason'">
<template v-if="Array.isArray(row.issues) && row.issues.length > 0">
<span
v-for="(it, idx) in row.issues"
:key="idx"
class="issue-line"
>• {{ it.field }}:{{ it.desc }}</span>
:title="it.desc || ''"
>• {{ it.field }}:{{ it.name ? it.name : it.desc }}</span>
</template>
<span v-else class="null-val">—</span>
</template>
......@@ -266,10 +268,13 @@ function isErrorCell(row, key) {
// 错误描述:按 col.key(小写)在 row.issues 里找匹配,找不到给通用文案
// 注:后端 issues[].field 是小写 cf.field_key(db_adapter 归一过),
// 不用 col.cn(保留原大小写作为表头显示)
// 2026-08-24:tooltip 优先展示「规则名(desc)」,老规则空名时只展示 desc
function errTip(row, key) {
if (!isErrorCell(row, key)) return ''
const issue = (row.issues || []).find((it) => it.field === key)
return issue ? issue.desc : '该字段不合法'
if (!issue) return '该字段不合法'
const label = issue.name && issue.name.trim() ? issue.name : issue.desc
return issue.name && issue.name.trim() ? `${label}:${issue.desc}` : label
}
function cellClass(row, key) {
return {
......@@ -277,10 +282,13 @@ function cellClass(row, key) {
'cell-reason': key === '__reason',
}
}
// 表头规则 tooltip:优先用 ruleList([{desc}]),否则给通用文案
// 表头规则 tooltip:2026-08-24 起优先展示「规则名」;name 为空时(老规则)回退到 desc
// 格式:`规则名(desc)` 或仅 desc —— 让用户一眼看到「这条规则叫什么」
function ruleHint(col) {
if (Array.isArray(col.ruleList) && col.ruleList.length > 0) {
return col.ruleList.map((r) => r.desc).join(';')
return col.ruleList
.map((r) => (r.name && r.name.trim() ? `${r.name}(${r.desc})` : r.desc))
.join(';')
}
return `该字段配置了 ${col.rules || 0} 条校验规则`
}
......
......@@ -36,6 +36,20 @@
</div>
</div>
<!-- 2026-08-24:规则名称 → 给前端数据明细展示「违反哪条规则」用,必填 -->
<div class="rule-item-row">
<span class="rule-item-label"><span class="required">*</span>规则名称</span>
<div class="rule-item-ctrl">
<input
type="text"
class="text-input"
v-model="rule.name"
:placeholder="namePlaceholder(rule.rule_type)"
maxlength="64"
/>
</div>
</div>
<div class="rule-item-row">
<span class="rule-item-label">规则说明</span>
<div class="rule-item-ctrl">
......@@ -158,15 +172,25 @@ const title = computed(() => {
const ruleList = ref([])
const aiLoading = ref({})
// 「至少有一条规则且 desc + code/regex 都填了」才能保存(避免保存空规则)
// 「至少有一条规则且 name + desc + code/regex 都填了」才能保存(避免保存空规则)
// 2026-08-24:name 也是必填(前端展示「违反哪条规则」用)
const hasValidRules = computed(() =>
ruleList.value.length > 0 && ruleList.value.every((r) => {
if (!r.name.trim()) return false
if (!r.desc.trim()) return false
if ((r.rule_type || 'regex') === 'regex') return r.regex.trim()
return r.code.trim() // number / date / string
})
)
// 规则名称占位符:给个简短示例,按 rule_type 给不同建议
function namePlaceholder(rt) {
if (rt === 'number') return '如:年龄范围 0-150'
if (rt === 'date') return '如:日期格式合法'
if (rt === 'string') return '如:手机号格式合法'
return '如:身份证号 18 位'
}
// 规则说明 / 函数 / AI 按钮文案,按 rule_type 切换
function descPlaceholder(rt) {
if (rt === 'number') return '如:年龄必须在 0 到 150 之间'
......@@ -197,6 +221,9 @@ watch(() => [props.open, props.field?.key], ([open]) => {
// 后端 Pydantic 序列化用 snake_case(rule_type),前端也用同名
ruleList.value = (props.field.rules || []).map((r) => ({
...r,
// 2026-08-24:老规则(2026-08-24 之前)可能没有 name 字段(API 默认 ''),UI 上输入框空着;
// 保存时若仍为空会被 hasValidRules 拦截,强制用户补一个名称
name: r.name || '',
rule_type: r.rule_type || DEFAULT_RULE_TYPE,
code: r.code || '',
regex: r.regex || '',
......@@ -211,6 +238,7 @@ watch(() => [props.open, props.field?.key], ([open]) => {
function addRule() {
ruleList.value.push({
id: Date.now(),
name: '', // 2026-08-24:规则名称(必填)
desc: '',
rule_type: DEFAULT_RULE_TYPE,
regex: '',
......@@ -288,6 +316,8 @@ function testRule(idx) {
function onSave() {
const rules = ruleList.value.map((r) => {
const base = {
// 2026-08-24:name 必填,hasValidRules 已保证 trim() 后非空
name: r.name.trim(),
desc: r.desc.trim(),
rule_type: r.rule_type || DEFAULT_RULE_TYPE,
skip_null: !!r.skip_null, // 2026-08-21:跳过空值开关
......@@ -307,6 +337,19 @@ function onSave() {
</script>
<style scoped>
/* 2026-08-24:必填红星 —— .form-label .required 是全局样式,这里 .rule-item-label 是 scoped,
沿用全局的视觉规范(#f56c6c)但不污染外面 */
.rule-item-label .required {
color: #f56c6c;
margin-right: 2px;
}
.rule-item-label {
width: 90px;
flex-shrink: 0;
text-align: right;
font-size: 13px;
color: #606266;
}
/* 规则类型行 modifier:tabs 在左,「跳过空值」checkbox 推到最右侧。
只对这一行生效 —— 用嵌套 modifier 而不是把 .rule-item-ctrl 全局改成 flex,
否则 .code-line / .test-line / .regex-line 这些内部嵌套 flex 容器会被
......
......@@ -119,6 +119,7 @@ def test_create_task_with_number_and_date_rules(client):
"key": "age",
"show_default": True,
"rules": [{
"name": "年龄范围",
"desc": "年龄 0-150",
"rule_type": "number",
"code": (
......@@ -135,6 +136,7 @@ def test_create_task_with_number_and_date_rules(client):
"key": "birthday",
"show_default": True,
"rules": [{
"name": "生日不晚于今天",
"desc": "生日早于今天",
"rule_type": "date",
"code": (
......@@ -179,7 +181,7 @@ def test_default_rule_type_is_regex(client):
"fields": [{
"key": "id_card",
"show_default": True,
"rules": [{"desc": "必须 18 位", "regex": r"^.{18}$"}], # 不传 rule_type
"rules": [{"name": "18位校验", "desc": "必须 18 位", "regex": r"^.{18}$"}], # 不传 rule_type
}],
})
assert r.status_code == 200, r.text
......@@ -205,6 +207,7 @@ def test_queries_run_dispatches_by_rule_type(client):
"key": "age",
"show_default": True,
"rules": [{
"name": "年龄范围",
"desc": "年龄 0-150",
"rule_type": "number",
"code": (
......@@ -220,6 +223,7 @@ def test_queries_run_dispatches_by_rule_type(client):
"key": "birthday",
"show_default": True,
"rules": [{
"name": "生日不晚于今天",
"desc": "不能晚于今天",
"rule_type": "date",
"code": (
......@@ -284,6 +288,7 @@ def test_queries_run_rule_failure_marks_row_bad_with_reason(client):
"key": "age",
"show_default": True,
"rules": [{
"name": "好规则的名称",
"desc": "好规则的 desc",
"rule_type": "number",
"code": "def broken_syntax(:\n pass", # 故意坏
......@@ -377,6 +382,7 @@ def test_create_task_with_string_rule(client):
"key": "name",
"show_default": True,
"rules": [{
"name": "姓名长度",
"desc": "姓名长度 2-20",
"rule_type": "string",
"code": (
......@@ -414,6 +420,7 @@ def test_string_rule_dispatch_in_queries(client):
"key": "name",
"show_default": True,
"rules": [{
"name": "姓名长度下限",
"desc": "姓名长度 >= 2",
"rule_type": "string",
"code": (
......@@ -456,6 +463,7 @@ def test_string_rule_without_skip_null_catches_nulls(client):
"key": "name",
"show_default": True,
"rules": [{
"name": "姓名长度下限",
"desc": "姓名长度 >= 2",
"rule_type": "string",
"code": "def check(value):\n return isinstance(value, str) and len(value) >= 2\n",
......@@ -496,6 +504,7 @@ def test_skip_null_applies_to_regex_too(client):
"key": "name",
"show_default": True,
"rules": [{
"name": "姓名必须是中文",
"desc": "姓名必须是中文(≥1 个汉字)",
"rule_type": "regex",
"regex": r"^[一-龥]+$",
......
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