Commit 33b60dc0 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 规则类型加 'string' + 「跳过空值」开关

字符串规则 (rule_type='string'):
- RULE_TYPES 扩展;rule/code 列与 number/date 共用,存 Python def check(value) -> bool
- ai_rule.py 加 _STRING prompt 模板(字符串语义:len/startswith/re)
- RuleModal tab 加「字符串」+ desc/codePlaceholder/aiBtnLabel 分支 + Python 函数 hint 提示 value 是字符串

跳过空值开关 (skip_null):
- rule 表加 skip_null 列(INTEGER NOT NULL DEFAULT 0)+ 迁移函数
- run_rule() 入口短路 None/空 str/空 bytes(regex 在 _evaluate_row 内联短路)
- RulePayload/RuleOut + _replace/_copy/_row_to_out + _load_task_compiled 全链路透传
- RuleModal 「规则类型」行右侧加 checkbox;测试/保存透传 skip_null
- /api/rule/test 同步支持(用户在 RuleModal 测试空值时也走短路)

UI 修复:
- 「规则类型」行用嵌套 modifier .rule-type-row flex 化,不污染其他 grid cell 行
  (之前全局改 .rule-item-ctrl flex 把 .code-line/.test-line/.regex-line 全压塌)

测试:
- test_rule_types_e2e.py 加 4 个新用例(string 持久化 + dispatch + skip_null=False/True + regex skip_null)
- FAKE_TABLE_ROWS 加 2 行空值('' + None),覆盖 skip_null 场景
- test_rule_runner.py RULE_TYPES 常量断言更新

后端 77 passed;vite build clean
parent 29987689
"""AI 生成规则代码(web3 / 2026-08-21)
三种 rule_type 统一入口 `gen_rule()`:
四种 rule_type 统一入口 `gen_rule()`:
- regex : 走老 ai_regex.gen_regex(),返回单行正则字符串
- number: LLM 生成 def check(value) -> bool 的 Python 函数(数值校验)
- date : 同 number(用户代码里自己用 datetime.strptime 解析)
- string: 同 number(字符串校验:长度/前缀/中文/包含等)
强制走 LLM(用户决策:让模型持续见真实描述),拿到代码后用 rule_runner.validate_user_code()
真实校验语法 + 必含 check;编译/校验失败 → 把错误反馈给 LLM 让它修复,最多 5 次。
......@@ -111,6 +112,45 @@ _DATE_USER_TEMPLATE_FIX = """上一次生成的代码有问题。
只输出修复后的函数代码,不要任何解释、不要 Markdown 代码块。"""
# 2026-08-21:字符串校验函数 —— 用户代码里 value 是字符串
# 与 number 区别只是 value 语义(不需要 try/except 数字转换;可以直接 str 操作)
_STRING_USER_TEMPLATE_INITIAL = """请把以下规则描述转换成一个 Python 字符串校验函数。
规则描述:「{desc}」
要求:
1. 定义函数 def check(value) -> bool:
- value 是从数据库读出来的原始字符串(也可能是 None / 非字符串,按需处理)
- 返回 True 表示合规,False 表示不合规
2. 函数要短小(一般不超过 10 行),只做这一项校验
3. 常见字符串校验操作:
- 长度:len(value)
- 前缀/后缀/包含:value.startswith(...) / value.endswith(...) / ... in value
- 大小写:value.upper() / value.lower()
- 正则(如果 regex 不好写):用 re 模块(已预注入,直接 re.search / re.match 即可)
- 中文/字符集判断:遍历字符 ord(c) > 127
4. 如果 value 不是字符串或为空,先 str(value) 转一下;非空才校验
5. 不要 import,需要正则就用 re 模块
只输出 def check(value) -> bool: 函数体的 Python 代码,不要任何解释、不要 Markdown 代码块。"""
_STRING_USER_TEMPLATE_FIX = """上一次生成的代码有问题。
规则描述:「{desc}」
上次生成的代码:
```
{bad_code}
```
运行时错误:`{error_msg}`
请输出修复后的 def check(value) -> bool: 函数。
要求:
1. 函数要短小,只做这一项校验
2. 修掉上面的错误(语法 / 缺 check / 运行时异常)
3. 仍然不要 import;需要正则就用 re 模块
只输出修复后的函数代码,不要任何解释、不要 Markdown 代码块。"""
# ── 从 LLM 返回中提取代码 ─────────────────────────────────
# LLM 偶尔会包一层 ```python ... ```,做容错提取
......@@ -153,13 +193,16 @@ def gen_rule(desc: str, rule_type: str) -> tuple[str, str]:
if not client.available:
return "", "LLM 未配置 API Key,请联系管理员配置或手动填写"
# number / date:选 prompt 模板
# number / date / string:选 prompt 模板
if rule_type == "number":
tpl_init = _NUMBER_USER_TEMPLATE_INITIAL
tpl_fix = _NUMBER_USER_TEMPLATE_FIX
else: # date
elif rule_type == "date":
tpl_init = _DATE_USER_TEMPLATE_INITIAL
tpl_fix = _DATE_USER_TEMPLATE_FIX
else: # string
tpl_init = _STRING_USER_TEMPLATE_INITIAL
tpl_fix = _STRING_USER_TEMPLATE_FIX
bad_code = ""
error_msg = ""
......@@ -196,7 +239,7 @@ def gen_rule(desc: str, rule_type: str) -> tuple[str, str]:
# ── 测试 ─────────────────────────────────────────────
def test_rule(rule_type: str, code_or_regex: str, value: str) -> dict:
def test_rule(rule_type: str, code_or_regex: str, value: str, skip_null: bool = False) -> dict:
"""用 rule_type 对应的引擎校验 value。
返回 dict(与 test_regex 兼容的字段):
......@@ -206,9 +249,13 @@ def test_rule(rule_type: str, code_or_regex: str, value: str) -> dict:
- error: str 错误信息(成功时空串)
"""
try:
passed = run_rule(rule_type, code_or_regex if rule_type == "regex" else None,
code_or_regex if rule_type in ("number", "date") else None,
value)
passed = run_rule(
rule_type,
code_or_regex if rule_type == "regex" else None,
code_or_regex if rule_type in ("number", "date", "string") else None,
value,
skip_null=skip_null,
)
return {"ok": True, "match": passed, "pass_": passed, "error": ""}
except RuleRunError as e:
return {"ok": False, "match": False, "pass_": False, "error": str(e)}
......
"""规则执行引擎(web3)
三种 rule_type 走同一个入口 `run_rule()`,内部按类型 dispatch:
四种 rule_type 走同一个入口 `run_rule()`,内部按类型 dispatch:
- regex : re.compile(regex).search(value) → 命中 = 通过(True)
- number: 用户提供的 Python 函数 def check(value) → 数值校验
- date : 同 number → 日期校验(函数里自己 strptime)
- string: 同 number → 字符串校验(长度/前缀/中文/包含等)
设计要点:
- 数值 / 日期类规则的代码是用 LLM 生成的,**用户/AI 可能写出危险代码**;
- 数值 / 日期 / 字符串类规则的代码是用 LLM 生成的,**用户/AI 可能写出危险代码**;
用受限命名空间 exec:白名单 builtins + 预注入 datetime/re/math/decimal 模块,
禁掉 open/eval/exec/__import__/getattr/globals 等危险入口
- 沙箱不杀进程(不搞 signal.alarm,Windows 不支持),只防「合理范围内的破坏」;
真要做超时限制,得用 multiprocessing / subprocess,下个迭代再说
- 任何错误(语法错 / 缺 check / 运行时异常)都包成 RuleRunError,
检测引擎拿 (passed, error_msg) 决定要不要把这一条规则标红
- skip_null(2026-08-21):值为 None/空 str 时直接 True 跳过;省掉每个 check 函数自己写 if value is None
"""
from __future__ import annotations
......@@ -192,14 +194,22 @@ def _patch_date_value(val) -> Optional[datetime.datetime]:
# ── 主入口 ──────────────────────────────────────────────
def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: Any) -> bool:
def run_rule(
rule_type: str,
regex: Optional[str],
code: Optional[str],
value: Any,
skip_null: bool = False,
) -> bool:
"""执行一条规则,返回 True = 通过 / False = 不通过。
参数:
rule_type: 'regex' | 'number' | 'date'
rule_type: 'regex' | 'number' | 'date' | 'string'
regex: regex 类型用;其他类型传 None
code: number/date 类型用;regex 传 None
code: number/date/string 类型用;regex 传 None
value: 数据源该字段的原始值(字符串或 None,或 datetime 对象等)
skip_null: 为 True 且 value 是 None/空字符串 → 直接 True 跳过,不进 check/正则
(2026-08-21:UI 上「跳过空值」开关,省掉每个 check 函数自己写 if value is None)
date 类型的 value 会被 _patch_date_value 预先归一化成 datetime 对象,
用户 check(value) 收到的永远是 datetime.datetime,可以直接 value.date() / .year / .strftime() 用。
......@@ -213,6 +223,10 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
"""
rt = rule_type or "regex"
# 2026-08-21:跳过空值开关 —— 空值直接 True,不跑规则
if skip_null and _is_null_like(value):
return True
if rt == "regex":
pattern_src = (regex or "").strip() or r"^.+$" # 空 regex = 非空校验
try:
......@@ -222,7 +236,7 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
val_str = "" if value is None else str(value)
return pat.search(val_str) is not None
if rt in ("number", "date"):
if rt in ("number", "date", "string"):
if not code or not code.strip():
raise RuleRunError("规则代码为空,请填写 check(value) 函数")
if rt == "date":
......@@ -231,11 +245,27 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
if dt is None:
raise RuleRunError(f"无法解析为日期:{value!r}")
return _exec_user_function(code, dt)
# number / string:原样传 value 进 check
return _exec_user_function(code, value)
raise RuleRunError(f"未知的规则类型:{rule_type!r}")
def _is_null_like(v: Any) -> bool:
"""判定「空值」:None / 空字符串 / 空 bytes。
数值/日期字段的空值通常就是 None;字符串字段还可能是 ''(DB 里 VARCHAR 默认值)。
bytes 视为空字符串的二进制等价(部分数据源 date 列返回 b'')。
"""
if v is None:
return True
if isinstance(v, str) and v == "":
return True
if isinstance(v, (bytes, bytearray)) and len(v) == 0:
return True
return False
# ── 仅用于 AI 生成阶段 / 测试阶段的「纯语法校验」 ──────────
def validate_user_code(code: str) -> tuple[bool, str]:
"""校验用户代码语法 + 必须含 check(value)。
......
......@@ -43,8 +43,9 @@ class CompiledFieldRules:
字段大小写已归一为小写(与列名对齐)。
"""
field_key: str
# 每条规则:(rule_type, regex_or_None, code_or_None, desc)
rules: list[tuple[str, Optional[str], Optional[str], str]] = field(default_factory=list)
# 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null)
# 2026-08-21:追加 skip_null(值为空时是否跳过)
rules: list[tuple[str, Optional[str], Optional[str], str, bool]] = field(default_factory=list)
@dataclass
......
......@@ -95,6 +95,8 @@ def init_db() -> None:
# 一次性迁移:rule 表加 rule_type / code 列(2026-08-21 扩展规则类型:数值 / 日期)
# 老数据全部保持 rule_type='regex',code 留空 → 跟旧逻辑完全等价
_migrate_rule_type_and_code()
# 一次性迁移:rule 表加 skip_null 列(2026-08-21 「跳过空值」开关,默认 0 = 不跳过)
_migrate_rule_skip_null()
# 一次性迁移:task 表加 checked_at 列(2026-08-21 接 UI「最近检测时间」)
_migrate_task_checked_at()
# 一次性迁移:task 表加 table_comment 列(2026-08-21 接「数据表注释」UI)
......@@ -184,6 +186,24 @@ def _migrate_rule_type_and_code() -> None:
conn.exec_driver_sql("ALTER TABLE rule ADD COLUMN code TEXT")
def _migrate_rule_skip_null() -> None:
"""给 rule 表加 skip_null 列(2026-08-21 「跳过空值」开关)。
默认 0 = 不跳过(老规则行为不变:空值进 check 函数报错,记为不合规)。
ORM 已声明,Base.metadata.create_all 不会给已存在的表加列,所以走 ALTER TABLE ADD COLUMN。
"""
with engine.begin() as conn:
cols = {
row[1]: row
for row in conn.exec_driver_sql("PRAGMA table_info(rule)").fetchall()
}
if "skip_null" not in cols:
logger.info("[migrate] rule 缺 skip_null 列,补建(默认 0,老规则不受影响)")
conn.exec_driver_sql(
"ALTER TABLE rule ADD COLUMN skip_null INTEGER NOT NULL DEFAULT 0"
)
def _migrate_task_checked_at() -> None:
"""给 task 表加 checked_at 列(2026-08-21 接上「最近检测时间」UI)。
......
......@@ -69,12 +69,14 @@ CREATE INDEX idx_field_task ON field(task_id);
-- ─────────────────────────────────────────────────────────────
-- 4) rule · 规则(每条规则挂在一个 field 下)
-- ─────────────────────────────────────────────────────────────
-- rule_type 'regex' / 'number' / 'date' 三类校验规则(2026-08-21 扩展)
-- rule_type 'regex' / 'number' / 'date' / 'string' 四类校验规则(2026-08-21 扩展)
-- regex: 仍是 Python re 模式,存在 regex 列(兼容老数据,默认值)
-- number: Python 函数 def check(value) -> bool,存在 code 列
-- date: 同 number,存在 code 列
-- code number/date 类型的 Python 函数源码(必须含 def check(value): 返回 bool)
-- regex regex 类型专用;number/date 留空
-- string: 同 number,存在 code 列(字符串校验,长度/前缀/中文等)
-- code number/date/string 类型的 Python 函数源码(必须含 def check(value): 返回 bool)
-- regex regex 类型专用;number/date/string 留空
-- skip_null 1 = 值为空(None/空 str)时直接判合规跳过;0 = 空值进 check 函数按不合规处理
CREATE TABLE rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
field_id INTEGER NOT NULL REFERENCES field(id) ON DELETE CASCADE,
......@@ -82,6 +84,7 @@ CREATE TABLE rule (
regex TEXT,
rule_type TEXT NOT NULL DEFAULT 'regex',
code TEXT,
skip_null INTEGER NOT NULL DEFAULT 0,
ord INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_rule_field ON rule(field_id);
......
......@@ -2,18 +2,23 @@
对应 schema.sql 里的:
CREATE TABLE rule (
id, field_id, desc, regex, rule_type, code, ord
id, field_id, desc, regex, rule_type, code, skip_null, ord
)
设计:
- 每条规则属于一个 field(field_id FK + ON DELETE CASCADE)
- desc:自然语言规则说明(如「必须是 18 位中国大陆身份证号」)
- rule_type:'regex' / 'number' / 'date'(2026-08-21 扩展)
- regex:传统正则(向后兼容,老数据全部为 regex)
- number:Python 函数 def check(value) -> bool,存 code 列
- date: 同 number,存 code 列(用户代码里自己用 datetime.strptime 解析)
- rule_type:'regex' / 'number' / 'date' / 'string'(2026-08-21 扩展)
- regex: 传统正则(向后兼容,老数据全部为 regex)
- number: Python 函数 def check(value) -> bool,存 code 列(数值校验)
- date: 同 number(用户代码里自己用 datetime 解析)
- string: 同 number(用户代码里处理字符串,如长度/前缀/中文等)
- regex:仅 rule_type='regex' 时使用(可空——「非空」这种规则 regex 留空 + 检测时由后端补 `^.+$`)
- code: 仅 rule_type in ('number', 'date') 时使用,存 Python 函数源码
- code: 仅 rule_type in ('number','date','string') 时使用,存 Python 函数源码
- skip_null(2026-08-21):值为空(None/空字符串)时是否跳过该规则
- 默认 0(不跳过 —— 空值进 check 函数报错,记为不合规)
- 1 = 跳过(空值直接判合规,不走 check 函数)
- 对 regex 类型同样生效:空值直接 True,不跑正则
- ord:规则在 UI 上的显示顺序(保存时按数组顺序写入)
"""
from __future__ import annotations
......@@ -23,7 +28,7 @@ from sqlalchemy import Column, Integer, Text, String, ForeignKey
from web3.backend.db.database import Base
# rule_type 的合法值集中在一处常量,ORM / 校验 / 提示文案都引用这一份
RULE_TYPES = ("regex", "number", "date")
RULE_TYPES = ("regex", "number", "date", "string")
DEFAULT_RULE_TYPE = "regex"
......@@ -37,7 +42,9 @@ class Rule(Base):
rule_type = Column( # 校验类型(2026-08-21 扩展)
String, nullable=False, default=DEFAULT_RULE_TYPE,
)
code = Column(Text, nullable=True) # Python 函数源码(number/date 用)
code = Column(Text, nullable=True) # Python 函数源码(number/date/string 用)
# 2026-08-21:值为空(None/空 str)时跳过该规则,不走 check 直接判 True
skip_null = Column(Integer, nullable=False, default=0)
ord = Column(Integer, nullable=False, default=0)
def to_dict(self) -> dict:
......@@ -47,6 +54,7 @@ class Rule(Base):
"regex": self.regex,
"rule_type": self.rule_type or DEFAULT_RULE_TYPE,
"code": self.code,
"skip_null": bool(self.skip_null),
"ord": self.ord,
}
......
......@@ -75,8 +75,9 @@ async def ai_gen_rule(req: GenRuleRequest):
# ── /rule/test ───────────────────────────────────────────
class TestRuleRequest(BaseModel):
rule_type: str = Field(DEFAULT_RULE_TYPE, description=f"规则类型:{ ' / '.join(RULE_TYPES) }")
code: str = Field(..., min_length=1, description="regex 时是正则;number/date 时是 Python 函数源码")
code: str = Field(..., min_length=1, description="regex 时是正则;number/date/string 时是 Python 函数源码")
value: str = Field("", description="测试值")
skip_null: bool = Field(False, description="2026-08-21:值为空时是否跳过(前端「跳过空值」开关)")
class TestRuleResponse(BaseModel):
......@@ -93,7 +94,7 @@ async def ai_test_rule(req: TestRuleRequest):
统一走 rule_runner.run_rule(),保证和检测引擎语义一致。
"""
result = test_rule(req.rule_type, req.code, req.value)
result = test_rule(req.rule_type, req.code, req.value, skip_null=req.skip_null)
return TestRuleResponse(
ok=result.get("ok", False),
pass_=result.get("pass_", False),
......
......@@ -150,6 +150,7 @@ def _load_task_compiled(db: Session, task_id: int):
"rule_type": r.rule_type or "regex",
"regex": r.regex,
"code": r.code,
"skip_null": bool(r.skip_null),
}
for r in rules_by_field[f.id]
],
......@@ -157,25 +158,27 @@ def _load_task_compiled(db: Session, task_id: int):
for f in fields
]
# 编译:regex 提前 compile;number/date 不编译(每行 exec)
# 编译:regex 提前 compile;number/date/string 不编译(每行 exec)
# 注意:r.rule_type / r.desc / r.regex / r.code 都是 SQLAlchemy Mapped 字段,
# 运行时是 str,但静态类型是 InstrumentedAttribute —— 用 str() 包一下让 Pylance 也满意
# 2026-08-21:snapshot 追加 skip_null(值为空时是否跳过该规则)
compiled_fields: list[CompiledFieldRules] = []
for f, rules in fields_with_rules:
snapshots: list[tuple[str, Optional[str], Optional[str], str]] = []
snapshots: list[tuple[str, Optional[str], Optional[str], str, bool]] = []
for r in rules:
rt = str(r.rule_type or "regex")
desc = str(r.desc or "")
skip_null = bool(r.skip_null)
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))
snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null))
except re.error as e:
logger.warning(f"[queries] 规则 id={r.id} regex 编译失败:{e}")
snapshots.append((rt, None, None, desc)) # None 标记"坏规则"
snapshots.append((rt, None, None, desc, skip_null)) # None 标记"坏规则"
else:
snapshots.append((rt, None, str(r.code or ""), desc))
snapshots.append((rt, None, str(r.code or ""), desc, skip_null))
compiled_fields.append(CompiledFieldRules(field_key=f.field_key.lower(), rules=snapshots))
# SELECT 列名:按 ord 升序的去重小写列表
......@@ -222,13 +225,19 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
error_cells: list[str] = []
for cf in compiled_fields:
val = raw.get(cf.field_key)
for rule_type, compiled_regex, code, desc in cf.rules:
# 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:
passed: Optional[bool] = None
fail_desc: Optional[str] = None
if rule_type == "regex":
if compiled_regex is None:
passed = False
fail_desc = f"规则正则编译失败:{desc}"
elif skip_null and (val is None or val == ""):
# 2026-08-21:跳过空值开关 —— 空值直接合规,跳过正则
passed = True
else:
pat = re.compile(compiled_regex)
val_str = "" if val is None else str(val)
......@@ -237,7 +246,8 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
fail_desc = desc
else:
try:
passed = run_rule(rule_type, None, code, val)
# 2026-08-21:number/date/string 类型把 skip_null 传给 run_rule,由沙箱入口短路
passed = run_rule(rule_type, None, code, val, skip_null=skip_null)
if not passed:
fail_desc = desc
except RuleRunError as e:
......
......@@ -41,10 +41,12 @@ class RulePayload(BaseModel):
desc: str = PydField(..., min_length=1, description="规则说明(自然语言)")
rule_type: str = PydField(
"regex",
description="规则类型:regex / number / date(2026-08-21 扩展)",
description="规则类型:regex / number / date / string(2026-08-21 扩展)",
)
regex: Optional[str] = PydField(None, description="Python 正则表达式(regex 类型专用,可空)")
code: Optional[str] = PydField(None, description="Python 函数源码(number/date 类型专用)")
code: Optional[str] = PydField(None, description="Python 函数源码(number/date/string 类型专用)")
# 2026-08-21:值为空(None/空 str)时跳过该规则;默认 False(与老行为一致:空值进 check 报错)
skip_null: bool = PydField(False, description="值为空(None/空字符串)时是否跳过该规则")
class FieldPayload(BaseModel):
......@@ -98,6 +100,7 @@ class RuleOut(BaseModel):
regex: Optional[str]
rule_type: str = "regex"
code: Optional[str] = None
skip_null: bool = False # 2026-08-21:跳过空值开关
ord: int
......@@ -174,13 +177,14 @@ def _replace_fields(db: Session, task: Task, fields_payload: list[FieldPayload])
for r_idx, r in enumerate(f.rules):
rt = (r.rule_type or "regex").strip() or "regex"
regex_val = (r.regex or "").strip() or None if rt == "regex" else None
code_val = (r.code or "").strip() or None if rt in ("number", "date") else None
code_val = (r.code or "").strip() or None if rt in ("number", "date", "string") else None
rule = Rule(
field_id=field.id,
desc=r.desc.strip(),
regex=regex_val,
rule_type=rt,
code=code_val,
skip_null=1 if r.skip_null else 0,
ord=r_idx,
)
db.add(rule)
......@@ -207,6 +211,7 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None:
regex=src_r.regex,
rule_type=src_r.rule_type or "regex",
code=src_r.code,
skip_null=src_r.skip_null or 0, # 2026-08-21:复制时也带 skip_null
ord=r_idx,
))
......@@ -236,6 +241,7 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta
id=r.id, desc=r.desc, regex=r.regex,
rule_type=r.rule_type or "regex",
code=r.code,
skip_null=bool(r.skip_null),
ord=r.ord,
)
for r in db.query(Rule).filter(Rule.field_id == f.id).order_by(Rule.ord).all()
......
/**
* AI 相关 API(2026-08-21 扩展规则类型)
*
* 三类规则共用一对接口:
* - genRule(desc, ruleType) → POST /api/ai/rule 后端走 LLM(regex 走老 ai_regex;number/date 走 ai_rule)
* - testRule(ruleType, code, value) → POST /api/rule/test
* 四类规则共用一对接口:
* - genRule(desc, ruleType) → POST /api/ai/rule 后端走 LLM(regex 走老 ai_regex;number/date/string 走 ai_rule)
* - testRule(ruleType, code, value, opts) → POST /api/rule/test
*
* ruleType: 'regex' | 'number' | 'date'
* ruleType: 'regex' | 'number' | 'date' | 'string'
* - regex: code = 单行正则字符串
* - number: code = Python 函数源码(含 def check(value) -> bool)
* - date: code = 同 number
* - string: code = 同 number(字符串校验:长度/前缀/中文/包含等)
*
* 老端点 genRegex / testRegex 保留为薄壳(转发到新接口),兼容 2026-08-20 的 AI 正则流程。
*/
import { http } from './client'
export const RULE_TYPES = ['regex', 'number', 'date']
export const RULE_TYPES = ['regex', 'number', 'date', 'string']
/**
* AI 生成规则代码
* @param {string} desc 规则说明(自然语言)
* @param {string} ruleType 'regex' | 'number' | 'date'
* @param {string} ruleType 'regex' | 'number' | 'date' | 'string'
* @returns { ok: boolean, code: string, note: string }
* - ok=true:code 非空,可直接填到对应输入框
* - ok=false:code 空字符串;note 是失败原因(前端展示)
......@@ -32,14 +33,16 @@ export function genRule(desc, ruleType = 'regex') {
/**
* 用后端引擎校验 value 是否通过
* @param {string} ruleType
* @param {string} code regex → 正则;number/date → Python 函数源码
* @param {string} code regex → 正则;number/date/string → Python 函数源码
* @param {string} value 测试值
* @param {object} [opts]
* @param {boolean} [opts.skipNull] 2026-08-21:值为空(None/空 str)时是否跳过
* @returns { ok: boolean, pass: boolean, error: string }
* - ok false = 代码/正则本身非法;error 是报错信息
* - ok true + pass true/false = 是否通过
*/
export function testRule(ruleType, code, value) {
return http.post('/rule/test', { rule_type: ruleType, code, value })
export function testRule(ruleType, code, value, { skipNull = false } = {}) {
return http.post('/rule/test', { rule_type: ruleType, code, value, skip_null: skipNull })
}
/**
......
......@@ -14,10 +14,10 @@
<button class="btn-text danger" @click="removeRule(idx)">删除</button>
</div>
<!-- 规则类型切换:regex / number / date -->
<!-- 规则类型切换:regex / number / date / string -->
<div class="rule-item-row">
<span class="rule-item-label">规则类型</span>
<div class="rule-item-ctrl">
<div class="rule-item-ctrl rule-type-row">
<div class="rule-type-tabs">
<div
v-for="t in RULE_TYPES"
......@@ -28,6 +28,11 @@
@click="rule.rule_type = t.value"
>{{ t.label }}</div>
</div>
<!-- 2026-08-21:跳过空值开关 —— 右侧独立 checkbox -->
<label class="skip-null-toggle" :title="'字段值为空(NULL / 空字符串)时直接判合规,不再跑本规则'">
<input type="checkbox" v-model="rule.skip_null" />
<span>跳过空值</span>
</label>
</div>
</div>
......@@ -58,7 +63,7 @@
</div>
</div>
<!-- number / date 类型:多行 Python 函数编辑框 -->
<!-- number / date / string 类型:多行 Python 函数编辑框 -->
<div v-else class="rule-item-row">
<span class="rule-item-label">Python 函数</span>
<div class="rule-item-ctrl">
......@@ -78,6 +83,12 @@
跟「今天」比用 <code>datetime.date.today()</code>。
<strong style="color: #f56c6c;">不要再 <code>strptime</code>(value 已经是 datetime 对象,strptime 需要字符串)。</strong>
</span>
<span v-else-if="rule.rule_type === 'string'">
<strong>value 是字符串</strong>(后端从数据库读到的原始字符串)。
常用操作:<code>len(value)</code> / <code>value.startswith(...)</code> /
<code>value.endswith(...)</code> / <code>... in value</code>;
需要正则时直接用 <code>re</code> 模块(已预注入)。
</span>
<span v-else>
需要数学常量时用 math 模块;不需要 import。
</span>
......@@ -124,9 +135,10 @@ const emit = defineEmits(['update:open', 'save'])
// 规则类型定义集中在一处:tab 标签、AI 按钮文案、占位符都引用
const RULE_TYPES = [
{ value: 'regex', label: '正则', hint: '用 Python 正则表达式校验文本(身份证/手机号/邮箱 等)' },
{ value: 'number', label: '数值', hint: '用 Python 函数 def check(value) -> bool 校验数值' },
{ value: 'date', label: '日期', hint: '用 Python 函数 + datetime.strptime 校验日期' },
{ value: 'regex', label: '正则', hint: '用 Python 正则表达式校验文本(身份证/手机号/邮箱 等)' },
{ value: 'number', label: '数值', hint: '用 Python 函数 def check(value) -> bool 校验数值' },
{ value: 'date', label: '日期', hint: '用 Python 函数 + datetime.strptime 校验日期' },
{ value: 'string', label: '字符串', hint: '用 Python 函数 def check(value) -> bool 校验字符串(长度/前缀/中文/包含 等)' },
]
const DEFAULT_RULE_TYPE = 'regex'
......@@ -143,7 +155,7 @@ const hasValidRules = computed(() =>
ruleList.value.length > 0 && ruleList.value.every((r) => {
if (!r.desc.trim()) return false
if ((r.rule_type || 'regex') === 'regex') return r.regex.trim()
return r.code.trim() // number / date
return r.code.trim() // number / date / string
})
)
......@@ -151,6 +163,7 @@ const hasValidRules = computed(() =>
function descPlaceholder(rt) {
if (rt === 'number') return '如:年龄必须在 0 到 150 之间'
if (rt === 'date') return '如:日期必须是 yyyy-MM-dd 格式且在 2020-01-01 之后'
if (rt === 'string') return '如:手机号字段必须是 11 位数字;或:邮箱字段必须包含 @'
return '用自然语言描述规则,如:必须是18位中国大陆身份证号,最后一位可为X'
}
function codePlaceholder(rt) {
......@@ -158,11 +171,15 @@ function codePlaceholder(rt) {
// 后端已把 value 归一化成 datetime.datetime 对象,用户代码直接 .date() / .year / .strftime() 用即可
return 'def check(value) -> bool:\n return value.date() < datetime.date.today()'
}
if (rt === 'string') {
return 'def check(value) -> bool:\n if not isinstance(value, str):\n return False\n return len(value) == 11 and value.isdigit()'
}
return 'def check(value) -> bool:\n try:\n n = float(value)\n return 0 <= n <= 150\n except Exception:\n return False'
}
function aiBtnLabel(rt) {
if (rt === 'number') return '✨ AI 生成数值校验函数'
if (rt === 'date') return '✨ AI 生成日期校验函数'
if (rt === 'string') return '✨ AI 生成字符串校验函数'
return '✨ AI 生成正则'
}
......@@ -175,6 +192,7 @@ watch(() => [props.open, props.field?.key], ([open]) => {
rule_type: r.rule_type || DEFAULT_RULE_TYPE,
code: r.code || '',
regex: r.regex || '',
skip_null: !!r.skip_null, // 2026-08-21:跳过空值开关
testVal: '',
testResult: null,
}))
......@@ -189,6 +207,7 @@ function addRule() {
rule_type: DEFAULT_RULE_TYPE,
regex: '',
code: '',
skip_null: false, // 2026-08-21:跳过空值开关,默认关
testVal: '',
testResult: null,
})
......@@ -243,7 +262,8 @@ function testRule(idx) {
rule.testResult = { text: '请输入测试值', cls: 'fail' }
return
}
apiTestRule(rule.rule_type, code, rule.testVal).then((r) => {
// 2026-08-21:把 skip_null 透传给后端;用户勾选「跳过空值」后,测试时空值也算通过
apiTestRule(rule.rule_type, code, rule.testVal, { skipNull: !!rule.skip_null }).then((r) => {
if (!r.ok) {
rule.testResult = { text: r.error || '语法错误', cls: 'fail' }
} else if (!r.pass) {
......@@ -262,6 +282,7 @@ function onSave() {
const base = {
desc: r.desc.trim(),
rule_type: r.rule_type || DEFAULT_RULE_TYPE,
skip_null: !!r.skip_null, // 2026-08-21:跳过空值开关
}
if (r.rule_type === 'regex') {
base.regex = r.regex.trim()
......@@ -278,6 +299,17 @@ function onSave() {
</script>
<style scoped>
/* 规则类型行 modifier:tabs 在左,「跳过空值」checkbox 推到最右侧。
只对这一行生效 —— 用嵌套 modifier 而不是把 .rule-item-ctrl 全局改成 flex,
否则 .code-line / .test-line / .regex-line 这些内部嵌套 flex 容器会被
flex 父按内容收缩塌陷,textarea 被压成几列宽。
(2026-08-21 踩坑:全局 .rule-item-ctrl flex 化破坏所有其他行的布局) */
.rule-item-ctrl.rule-type-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* 规则类型 tab —— 用 inline-block 撑开,pill 风格;与 .conn-tab 风格统一 */
.rule-type-tabs {
display: inline-flex;
......@@ -306,6 +338,23 @@ function onSave() {
background: #409eff;
color: #fff;
}
/* 2026-08-21:跳过空值 toggle —— checkbox + 文案,最右对齐 */
.skip-null-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #606266;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.skip-null-toggle input[type="checkbox"] {
cursor: pointer;
width: 14px;
height: 14px;
margin: 0;
}
/* Python 函数编辑框:等宽字体 + 稍高 + 灰底 */
.code-line {
......
......@@ -344,7 +344,8 @@ class TestValidateUserCode:
# ── RULE_TYPES 常量 ─────────────────────────────────────
def test_rule_types_constant():
assert RULE_TYPES == ("regex", "number", "date")
# 2026-08-21 扩展:rule_type 加 'string'(字符串 → Python 函数校验)
assert RULE_TYPES == ("regex", "number", "date", "string")
# ── 未知类型 ────────────────────────────────────────────
......
This diff is collapsed.
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