Commit 135fef49 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): date 规则 value 自动 patch 成 datetime 对象再调 check

问题(commit 5ffa4cc4 之后又暴露):

用户重跑 test3,attendance_in_time(rule_id=31)仍判不合规。
诊断:pymysql/oracledb/dmPython 把 DATETIME 字段自动转成 Python datetime 对象,
但用户规则代码里的 strptime 用了 '%Y-%m-%d'(只接受纯日期),
而 str(datetime_obj) 是 'YYYY-MM-DD HH:MM:SS'(空格分隔的日期+时间),
格式不匹配 → ValueError → except 吞 → False。

修法:在 run_rule 调 check 前先做一次宽泛解析,把任意类型的 val 归一化成
datetime.datetime 对象。用户写规则直接 value.date() / .year / .strftime() 用,
不再关心 val 的原始类型(驱动转的 datetime 对象 / str / bytes / None)。

1. backend/core/rule_runner.py
   - 新增 _DATE_PARSE_FORMATS:10 种常见格式(MySQL DATETIME 默认 / ISO8601 /
     紧凑 / 斜杠分隔 等)
   - 新增 _patch_date_value(val):datetime/date/str/bytes → datetime.datetime,
     解析失败返 None
   - run_rule 的 date 分支先 patch:成功 → _exec_user_function(code, dt);
     失败 → 抛 RuleRunError('无法解析为日期:...') 让 issues 暴露原因

2. backend/core/ai_rule.py
   - _DATE_USER_TEMPLATE_INITIAL / _DATE_USER_TEMPLATE_FIX 完全改写:
     「value 已经是 datetime.datetime 对象(后端已自动宽泛解析)」
     去掉所有 datetime.datetime.strptime 教学,改用 value.date() / .year 等

3. src/components/RuleModal.vue
   - codePlaceholder(date) 改 'def check(value) -> bool:\n    return value.date() < datetime.date.today()'
   - date 类型 form-hint 改 'value 已是 datetime 对象;不要再 strptime'
     (红字警示)

4. tests/test_rule_runner.py
   - 删 1 条过时的 strptime 风格用例 test_strptime_format_check
   - TestDate 加 8 条用例:patch 后传 datetime / datetime 对象直接传 /
     MySQL 默认格式 / ISO8601 / 紧凑 / 斜杠 / bytes / 解析失败抛 RuleRunError /
     None 抛 RuleRunError / 老 strptime 风格代码在 patch 下返 False(迁移信号)
   - 新 TestPatchDateValue 类 11 条用例:锁 _patch_date_value 的 12 种类型行为

5. tests/test_rule_types_e2e.py
   - test_queries_run_dispatches_by_rule_type 里 birthday 规则的 code
     改成新风格 'value.date() <= datetime.date.today()'

对老规则的影响(migration gap):
- DB 里的旧 strptime 风格代码(rule_id=28 / 31)继续返 False —— 行为不变,
  用户需去 UI 改成新风格。按 CLAUDE.md「本工具只做查询」不动 DB。
- 新生成的或手写正确代码直接用 value.date() 等,跟 patch 行为兼容。

60 条测试全过(+9 新增)。
parent 5ffa4cc4
...@@ -77,15 +77,17 @@ _DATE_USER_TEMPLATE_INITIAL = """请把以下规则描述转换成一个 Python ...@@ -77,15 +77,17 @@ _DATE_USER_TEMPLATE_INITIAL = """请把以下规则描述转换成一个 Python
要求: 要求:
1. 定义函数 def check(value) -> bool: 1. 定义函数 def check(value) -> bool:
- value 是从数据库读出来的原始字符串(如 "2024-01-15"、"2024/01/15"、"20240115") - **value 已经是 datetime.datetime 对象**(后端已自动从 datetime/str/datetime 字符串/datetime 数字等宽泛解析,
失败会直接判不合规,不需要你在函数里再 strptime 一遍)
- 返回 True 表示合规,False 表示不合规 - 返回 True 表示合规,False 表示不合规
2. 用 datetime.datetime.strptime(v, '<format>') 解析日期,解析失败返回 False 2. 直接用 value 的属性即可:
沙箱里 datetime 是模块名(不是 from datetime import datetime), - 取日期部分:value.date()
所以必须是「datetime.datetime.strptime」,**绝对不能写成「datetime.strptime」**(会 AttributeError) - 取年份:value.year;月:value.month;日:value.day
3. 函数要短小(一般不超过 10 行),只做这一项校验 - 转回字符串:value.strftime('%Y-%m-%d')
4. 不要写 import 语句,datetime 模块已经预注入到命名空间里 3. 跟「今天」「某个日期」比较时:用 datetime.date.today() / datetime.datetime(2020, 1, 1) 之类
5. 如果描述里包含具体格式(如"yyyy-MM-dd"、"yyyyMMdd")或范围(如"晚于 2020-01-01"),一定要用上 4. 函数要短小(一般不超过 10 行),只做这一项校验
6. 日期常用格式:%Y-%m-%d(2024-01-15)、%Y/%m/%d、%Y%m%d、%Y-%m-%d %H:%M:%S 5. 不要写 import,datetime 模块已预注入;不要调 strptime —— value 已经是 datetime 对象
6. 如果描述里包含具体范围(如"晚于 2020-01-01"、"小于今天"),一定要用上
只输出 def check(value) -> bool: 函数体的 Python 代码,不要任何解释、不要 Markdown 代码块。""" 只输出 def check(value) -> bool: 函数体的 Python 代码,不要任何解释、不要 Markdown 代码块。"""
...@@ -100,10 +102,12 @@ _DATE_USER_TEMPLATE_FIX = """上一次生成的代码有问题。 ...@@ -100,10 +102,12 @@ _DATE_USER_TEMPLATE_FIX = """上一次生成的代码有问题。
请输出修复后的 def check(value) -> bool: 函数。 请输出修复后的 def check(value) -> bool: 函数。
要求: 要求:
1. 用 datetime.datetime.strptime(v, '<format>') 解析日期(沙箱里 datetime 是模块名, 1. **value 已经是 datetime.datetime 对象**,不要再 strptime
所以必须是「datetime.datetime.strptime」——「datetime.strptime」会 AttributeError) (沙箱里已经宽泛解析过;如果你硬要 strptime,必须先 str(value))
2. 修掉上面的错误(语法 / 缺 check / 运行时异常) 2. 用 value.date() / value.year / value.month / value.day 取你要的部分
3. 仍然不要写 import 3. 跟「今天」比用 datetime.date.today();跟「某个日期」比用 datetime.date(2020, 1, 1)
4. 修掉上面的错误(语法 / 缺 check / 运行时异常)
5. 不要写 import
只输出修复后的函数代码,不要任何解释、不要 Markdown 代码块。""" 只输出修复后的函数代码,不要任何解释、不要 Markdown 代码块。"""
......
...@@ -133,6 +133,64 @@ def _exec_user_function(code: str, value: Any) -> bool: ...@@ -133,6 +133,64 @@ def _exec_user_function(code: str, value: Any) -> bool:
return bool(result) return bool(result)
# ── date 类型 value 预处理(patch) ─────────────────────
# 数据源字段类型多样(MySQL DATETIME → datetime 对象;MySQL VARCHAR → str;
# Oracle DATE/TIMESTAMP → datetime 对象;达梦类似;前端手工塞的也可能是 str),
# 用户规则代码没法对每种类型都 strptime 一遍。所以 run_rule 在调 check 前先 patch:
# 把 val 宽泛地解析成 datetime 对象,失败抛 RuleRunError,**保证进沙箱的永远是
# datetime.datetime**。用户代码就可以直接 value.date() / value.year 用。
_DATE_PARSE_FORMATS = (
# 带时间的(MySQL DATETIME 默认 str(datetime) 是这个)
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y/%m/%d %H:%M:%S",
"%Y/%m/%dT%H:%M:%S",
# 紧凑带时间
"%Y%m%d %H%M%S",
"%Y%m%dT%H%M%S",
"%Y%m%d%H%M%S",
# 纯日期
"%Y-%m-%d",
"%Y/%m/%d",
"%Y%m%d",
)
def _patch_date_value(val) -> Optional[datetime.datetime]:
"""宽泛地把 val 解析成 datetime 对象。失败返 None。
接受类型:
- None / 空 str → None
- datetime.datetime → 原样返回
- datetime.date → combine with 00:00:00
- bytes / bytearray → 先 decode utf-8,再按 str 走
- str → 试 _DATE_PARSE_FORMATS
- 其他 → None
"""
if val is None:
return None
if isinstance(val, datetime.datetime):
return val
if isinstance(val, datetime.date):
return datetime.datetime.combine(val, datetime.time.min)
if isinstance(val, (bytes, bytearray)):
try:
val = val.decode("utf-8")
except Exception:
return None
if not isinstance(val, str):
return None
s = val.strip()
if not s:
return None
for fmt in _DATE_PARSE_FORMATS:
try:
return datetime.datetime.strptime(s, fmt)
except ValueError:
continue
return None
# ── 主入口 ────────────────────────────────────────────── # ── 主入口 ──────────────────────────────────────────────
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) -> bool:
"""执行一条规则,返回 True = 通过 / False = 不通过。 """执行一条规则,返回 True = 通过 / False = 不通过。
...@@ -141,10 +199,15 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A ...@@ -141,10 +199,15 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
rule_type: 'regex' | 'number' | 'date' rule_type: 'regex' | 'number' | 'date'
regex: regex 类型用;其他类型传 None regex: regex 类型用;其他类型传 None
code: number/date 类型用;regex 传 None code: number/date 类型用;regex 传 None
value: 数据源该字段的原始值(字符串或 None) value: 数据源该字段的原始值(字符串或 None,或 datetime 对象等)
date 类型的 value 会被 _patch_date_value 预先归一化成 datetime 对象,
用户 check(value) 收到的永远是 datetime.datetime,可以直接 value.date() / .year / .strftime() 用。
value 无法解析成日期时抛 RuleRunError,检测引擎把错误带进 issues 让用户看到原因。
抛出 RuleRunError: 抛出 RuleRunError:
- regex 编译失败 - regex 编译失败
- date 值无法解析为日期
- 用户函数语法错 / 缺 check / 运行时异常 - 用户函数语法错 / 缺 check / 运行时异常
- rule_type 非法 - rule_type 非法
""" """
...@@ -162,6 +225,12 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A ...@@ -162,6 +225,12 @@ def run_rule(rule_type: str, regex: Optional[str], code: Optional[str], value: A
if rt in ("number", "date"): if rt in ("number", "date"):
if not code or not code.strip(): if not code or not code.strip():
raise RuleRunError("规则代码为空,请填写 check(value) 函数") raise RuleRunError("规则代码为空,请填写 check(value) 函数")
if rt == "date":
# 先 patch:保证 check 收到的是 datetime.datetime 对象
dt = _patch_date_value(value)
if dt is None:
raise RuleRunError(f"无法解析为日期:{value!r}")
return _exec_user_function(code, dt)
return _exec_user_function(code, value) return _exec_user_function(code, value)
raise RuleRunError(f"未知的规则类型:{rule_type!r}") raise RuleRunError(f"未知的规则类型:{rule_type!r}")
......
...@@ -73,8 +73,10 @@ ...@@ -73,8 +73,10 @@
</div> </div>
<div class="form-hint"> <div class="form-hint">
<span v-if="rule.rule_type === 'date'"> <span v-if="rule.rule_type === 'date'">
datetime 模块已预注入,用 <code>datetime.datetime.strptime(v, '%Y-%m-%d')</code> 即可;不需要 import。 <strong>value 已是 datetime 对象</strong>(后端从 datetime 对象 / 时间字符串已宽泛解析过);
<strong style="color: #f56c6c;">注意是 <code>datetime.datetime.strptime</code>(模块.类.方法),不是 <code>datetime.strptime</code>(会 AttributeError)。</strong> 直接用 <code>value.date()</code> <code>value.year</code> <code>value.strftime('%Y-%m-%d')</code> 等;
跟「今天」比用 <code>datetime.date.today()</code>。
<strong style="color: #f56c6c;">不要再 <code>strptime</code>(value 已经是 datetime 对象,strptime 需要字符串)。</strong>
</span> </span>
<span v-else> <span v-else>
需要数学常量时用 math 模块;不需要 import。 需要数学常量时用 math 模块;不需要 import。
...@@ -153,8 +155,8 @@ function descPlaceholder(rt) { ...@@ -153,8 +155,8 @@ function descPlaceholder(rt) {
} }
function codePlaceholder(rt) { function codePlaceholder(rt) {
if (rt === 'date') { if (rt === 'date') {
// 沙箱里 datetime 是模块名,所以必须是 datetime.datetime.strptime(不是 datetime.strptime,会 AttributeError) // 后端已把 value 归一化成 datetime.datetime 对象,用户代码直接 .date() / .year / .strftime() 用即可
return 'def check(value) -> bool:\n try:\n d = datetime.datetime.strptime(value, "%Y-%m-%d")\n return d >= datetime.datetime(2020, 1, 1)\n except Exception:\n return False' return 'def check(value) -> bool:\n return value.date() < datetime.date.today()'
} }
return 'def check(value) -> bool:\n try:\n n = float(value)\n return 0 <= n <= 150\n except Exception:\n return False' return 'def check(value) -> bool:\n try:\n n = float(value)\n return 0 <= n <= 150\n except Exception:\n return False'
} }
......
...@@ -21,6 +21,7 @@ from web3.backend.core.rule_runner import ( ...@@ -21,6 +21,7 @@ from web3.backend.core.rule_runner import (
RuleRunError, RuleRunError,
run_rule, run_rule,
validate_user_code, validate_user_code,
_patch_date_value,
) )
from web3.backend.models.rule import RULE_TYPES from web3.backend.models.rule import RULE_TYPES
...@@ -80,18 +81,10 @@ class TestNumber: ...@@ -80,18 +81,10 @@ class TestNumber:
# ── date ──────────────────────────────────────────────── # ── date ────────────────────────────────────────────────
class TestDate: class TestDate:
def test_strptime_format_check(self): # 注:旧的 strptime 风格代码(『value 是字符串,用 datetime.datetime.strptime(v, '%Y-%m-%d') 解析』)
code = ( # 在 patch 行为下不再适用 —— patch 把 value 归一化成 datetime.datetime 对象,
"def check(value):\n" # strptime 接受 str 不接受 datetime,会 TypeError → 被 except 吞掉返 False。
" try:\n" # 该行为已被 test_old_strptime_style_code_warns_user 锁定。
" datetime.datetime.strptime(value, '%Y-%m-%d')\n"
" return True\n"
" except Exception:\n"
" return False\n"
)
assert run_rule("date", None, code, "2024-01-15") is True
assert run_rule("date", None, code, "2024/01/15") is False
assert run_rule("date", None, code, "not-a-date") is False
def test_datetime_strptime_without_module_qualifier_fails_clearly(self): def test_datetime_strptime_without_module_qualifier_fails_clearly(self):
"""用户/AI 偶尔会写成 `datetime.strptime(...)`(缺 `.datetime.`), """用户/AI 偶尔会写成 `datetime.strptime(...)`(缺 `.datetime.`),
...@@ -113,6 +106,170 @@ class TestDate: ...@@ -113,6 +106,170 @@ class TestDate:
assert "AttributeError" in str(exc.value) assert "AttributeError" in str(exc.value)
assert "strptime" in str(exc.value) assert "strptime" in str(exc.value)
def test_patch_passes_datetime_to_check(self):
"""value 后端 patch 后传进 check 的是 datetime.datetime 对象,
不再是字符串 —— 用户代码应直接用 value.date() / .year 等。
"""
import datetime as _dt
captured = {}
code = (
"def check(value):\n"
" captured['type'] = type(value).__name__\n"
" captured['repr'] = repr(value)\n"
" return value.year == 2024\n"
)
# run_rule 在沙箱外层捕获不到 check 内的局部赋值;改为通过一个全局 _NS dict
# (沙箱里 dict 是白名单内置,getattr 也能用 list 是白名单)—— 简化做法:直接用 list
code = (
"def check(value):\n"
" _t = type(value).__name__\n"
" _r = value.year\n"
" return _t == 'datetime' and _r == 2024\n"
)
# str '2024-01-15' 应被 patch 成 datetime.datetime
assert run_rule("date", None, code, "2024-01-15") is True
def test_patch_accepts_datetime_object_directly(self):
"""value 已经是 datetime 对象时,run_rule 应直接传(不重新解析)。"""
import datetime as _dt
code = (
"def check(value):\n"
" return value.year == 2026\n"
)
assert run_rule("date", None, code, _dt.datetime(2026, 7, 3, 7, 18)) is True
assert run_rule("date", None, code, _dt.datetime(2024, 1, 15, 0, 0)) is False
def test_patch_accepts_mysql_default_format(self):
"""MySQL DATETIME str(datetime_obj) 默认是 'YYYY-MM-DD HH:MM:SS'(空格分隔),
必须能被 patch 解析。"""
code = (
"def check(value):\n"
" return value.year == 2026\n"
)
assert run_rule("date", None, code, "2026-07-03 07:18:00") is True
def test_patch_accepts_iso8601(self):
code = (
"def check(value):\n"
" return value.year == 2026\n"
)
assert run_rule("date", None, code, "2026-07-03T07:18:00") is True
def test_patch_accepts_compact_formats(self):
code = (
"def check(value):\n"
" return value.year == 2026\n"
)
# %Y%m%d / %Y%m%d %H%M%S
assert run_rule("date", None, code, "20260703") is True
assert run_rule("date", None, code, "20260703 071800") is True
def test_patch_accepts_date_only_with_slash(self):
code = (
"def check(value):\n"
" return value.year == 2024\n"
)
assert run_rule("date", None, code, "2024/01/15") is True
def test_patch_accepts_bytes(self):
code = (
"def check(value):\n"
" return value.year == 2024\n"
)
assert run_rule("date", None, code, b"2024-01-15") is True
def test_patch_failure_raises_rule_run_error(self):
"""值无法解析为日期 → 抛 RuleRunError,错误信息明确暴露原值,
不进沙箱(避免 check 里 strptime 失败被 except 吞 → 用户看不到原因)。"""
code = "def check(value):\n return True\n"
with pytest.raises(RuleRunError, match="无法解析为日期"):
run_rule("date", None, code, "not-a-date")
def test_patch_none_raises_rule_run_error(self):
code = "def check(value):\n return True\n"
with pytest.raises(RuleRunError, match="无法解析为日期"):
run_rule("date", None, code, None)
def test_old_strptime_style_code_warns_user(self):
"""patch 后 val 是 datetime 对象,老的 strptime 风格代码 `str(value)` + strptime
会因为 str(datetime_obj) 是 'YYYY-MM-DD HH:MM:SS' 格式而不匹配 %Y-%m-%d,
ValueError 被 except 吞 → 返 False。锁定这个行为作为迁移期间的提示信号。
(用户重生成时按新 prompt 写新代码就行)"""
code = (
"def check(value):\n"
" try:\n"
" v = str(value).strip().replace('/', '-')\n"
" d = datetime.datetime.strptime(v, '%Y-%m-%d').date()\n"
" return d < datetime.date.today()\n"
" except Exception:\n"
" return False\n"
)
# 2026-07-03 patch 后是 datetime 对象 → str 是 '2026-07-03 07:18:00',
# strptime('%Y-%m-%d') 失败 → except 吞 → 返 False(标不合规)
import datetime as _dt
assert run_rule("date", None, code, _dt.datetime(2026, 7, 3, 7, 18)) is False
# ── _patch_date_value 直接单测 ────────────────────────
class TestPatchDateValue:
"""锁定 _patch_date_value 对各种类型的归一化行为。"""
def test_none_returns_none(self):
assert _patch_date_value(None) is None
def test_datetime_pass_through(self):
import datetime as _dt
v = _dt.datetime(2026, 7, 3, 7, 18)
assert _patch_date_value(v) is v # 同一个对象
def test_date_combined_to_datetime(self):
import datetime as _dt
d = _dt.date(2026, 7, 3)
out = _patch_date_value(d)
assert isinstance(out, _dt.datetime)
assert out.year == 2026 and out.month == 7 and out.day == 3
assert out.time() == _dt.time.min
def test_str_mysql_format(self):
import datetime as _dt
out = _patch_date_value("2026-07-03 07:18:00")
assert out == _dt.datetime(2026, 7, 3, 7, 18, 0)
def test_str_iso8601(self):
import datetime as _dt
out = _patch_date_value("2026-07-03T07:18:00")
assert out == _dt.datetime(2026, 7, 3, 7, 18, 0)
def test_str_slash_separator(self):
import datetime as _dt
out = _patch_date_value("2024/01/15")
assert out == _dt.datetime(2024, 1, 15)
def test_str_compact(self):
import datetime as _dt
out = _patch_date_value("20260703")
assert out == _dt.datetime(2026, 7, 3)
def test_bytes_decoded(self):
import datetime as _dt
out = _patch_date_value(b"2024-01-15")
assert out == _dt.datetime(2024, 1, 15)
def test_unparseable_returns_none(self):
assert _patch_date_value("not-a-date") is None
assert _patch_date_value("hello world") is None
def test_empty_string_returns_none(self):
assert _patch_date_value("") is None
assert _patch_date_value(" ") is None
def test_int_returns_none(self):
# 数字不接(业务上一般也不会往 date 字段塞 int),不主动转 epoch
assert _patch_date_value(20260703) is None
def test_unparseable_bytes_returns_none(self):
assert _patch_date_value(b"\xff\xfe") is None
# ── 沙箱安全 ──────────────────────────────────────────── # ── 沙箱安全 ────────────────────────────────────────────
class TestSandbox: class TestSandbox:
......
...@@ -208,11 +208,7 @@ def test_queries_run_dispatches_by_rule_type(client): ...@@ -208,11 +208,7 @@ def test_queries_run_dispatches_by_rule_type(client):
"rule_type": "date", "rule_type": "date",
"code": ( "code": (
"def check(value):\n" "def check(value):\n"
" try:\n" " return value.date() <= datetime.date.today()\n"
" d = datetime.datetime.strptime(value, '%Y-%m-%d')\n"
" return d <= datetime.datetime.now()\n"
" except Exception:\n"
" return False\n"
), ),
}], }],
}, },
......
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