fix: 第28轮搜索审计修复 — 否定范围pub_year/异常降级/YYYY-MM组内/NULL安全等8项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

This commit is contained in:
34047007@qq.com
2026-07-29 05:33:21 +08:00
parent d064dcd0ca
commit 2b085d3445
3 changed files with 160 additions and 10 deletions
+5 -3
View File
@@ -478,11 +478,12 @@ class PubmedQueryParser:
text = t.value.strip('"') if t.type == TokenType.QUOTED else t.value
result.plain_terms.append(Term(text=text, exact=(t.type == TokenType.QUOTED)))
# Recompute negated_date_ranges from marker terms (after NOT toggling from recursive _parse_not_expr)
# Recompute negated_date_ranges from TOP-LEVEL marker terms only.
# R21: use |= not = to preserve negated_date_ranges added by _dispatch_term single-date NOTs
# R28: exclude group-scoped markers (t.group_id >= 0) — engine's group loop handles them.
result.negated_date_ranges |= {
t.field.replace("__RANGE_", "").replace("__", "")
for t in result._date_range_markers if t.is_not
for t in result._date_range_markers if t.is_not and t.group_id < 0
}
return result
@@ -881,7 +882,8 @@ class PubmedQueryParser:
_raw_field = ft.value[1:-1].upper()
_field = _normalize_field_label(_raw_field)
for t in terms:
t.field = _field
if not getattr(t, '_is_range_end', False): # R28: skip date range markers
t.field = _field
# P17: (lung OR breast)[MH:NOEXP] — 将 _noexp 传播到组内词
if _raw_field in ("MH:NOEXP", "MESH:NOEXP"):
for t in terms:
+95 -7
View File
@@ -251,7 +251,8 @@ class AdvancedSearchEngine:
# ─── PubMed 语法检测与解析 ───
_pubmed_parsed = None
_is_flat_text = True # 是否走传统 tsvector + ILIKE 路径
_pubmed_failed = False
_is_flat_text = True #是否走传统 tsvector + ILIKE 路径
if query.strip():
if is_pubmed_syntax(query):
@@ -294,12 +295,15 @@ class AdvancedSearchEngine:
except Exception:
logger.exception("_pubmed_conditions failed, falling back to flat text")
_is_flat_text = True
_pubmed_failed = True
conditions = []
# R28: force query cleaning in flat text fallback
_pubmed_parsed = pp # keep parsed object, flag below triggers cleanup
# ─── 传统搜索路径(纯文本 / PubMed 退化) ───
if _is_flat_text and query.strip():
# 如果 PubMed 语法检测成功但解析后无有效词(如语法错误),剥离括号和操作符再搜索
if _pubmed_parsed is not None and not any([
if _pubmed_parsed is not None and (not any([
bool(_pubmed_parsed.title_terms or _pubmed_parsed.abstract_terms or _pubmed_parsed.tiab_terms
or _pubmed_parsed.author_terms or _pubmed_parsed.journal_terms
or _pubmed_parsed.mesh_terms or _pubmed_parsed.majr_terms
@@ -323,7 +327,7 @@ class AdvancedSearchEngine:
or _pubmed_parsed.groups
or _pubmed_parsed.plain_terms or _pubmed_parsed.has_not
or _pubmed_parsed.year_from or _pubmed_parsed.year_to)
]):
]) or _pubmed_failed):
# 解析失败但检测到 PubMed 语法 — 擦除 [field] 标签、布尔符、引号
query = re.sub(r'\[[\w/: -]+\]', '', query) # P5: [\w/: -] 覆盖 [Title/Abstract] 和 [MH:noexp]
query = re.sub(r'\b(AND|OR|NOT)\b', '', query)
@@ -1264,7 +1268,26 @@ class AdvancedSearchEngine:
elif _marker_cond is not None:
# Non-negated group: is_not → negated range; otherwise → positive
if getattr(t, 'is_not', False):
g_neg.append(not_(_marker_cond))
# R28: NULL-safe NOT for group marker date ranges
_mf2 = t.field or ""
_tag2 = _mf2[8:-2] if _mf2.startswith("__RANGE_") and _mf2.endswith("__") else None
if _tag2 == "DP":
g_neg.append(or_(not_(_marker_cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None)))
elif _tag2 in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
_tag2_col = {
"EDAT": GlobalLiterature.entrez_date,
"CRDT": GlobalLiterature.create_date,
"MHDA": GlobalLiterature.meshed_date,
"LR": GlobalLiterature.pubmed_revised,
"DCOM": GlobalLiterature.date_completed,
"DEP": GlobalLiterature.pub_date,
}.get(_tag2)
if _tag2_col:
g_neg.append(or_(not_(_marker_cond), _tag2_col.is_(None)))
else:
g_neg.append(not_(_marker_cond))
else:
g_neg.append(not_(_marker_cond))
else:
g_pos.append(_marker_cond)
continue
@@ -1321,7 +1344,27 @@ class AdvancedSearchEngine:
# NOT(A OR B): single UnaryExpression → 顶层 OR/NOT 分离时被检测为 neg → 独立 AND
if g_neg:
combined = combine_fn(*g_neg) if len(g_neg) > 1 else g_neg[0]
term_conditions.append(not_(combined))
_notted = not_(combined)
# R28: NULL-safe NOT for date fields in negated groups
if _neg_group_date_fields:
_ns_cols = []
if "DP" in _neg_group_date_fields:
_ns_cols.extend([GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None)])
_NS_COL_MAP = {
"EDAT": GlobalLiterature.entrez_date,
"CRDT": GlobalLiterature.create_date,
"MHDA": GlobalLiterature.meshed_date,
"LR": GlobalLiterature.pubmed_revised,
"DCOM": GlobalLiterature.date_completed,
"DEP": GlobalLiterature.pub_date,
}
for _ns_tag in _neg_group_date_fields:
_ns_col = _NS_COL_MAP.get(_ns_tag)
if _ns_col is not None:
_ns_cols.append(_ns_col.is_(None))
if _ns_cols:
_notted = or_(_notted, *_ns_cols)
term_conditions.append(_notted)
else:
# 混合/正组:将 pos 和 neg 按组操作符组合,保留组内结构
# 避免 neg 被 OR/NOT 分离拉出来破坏语义
@@ -1405,6 +1448,23 @@ class AdvancedSearchEngine:
conditions.append(and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0])
from datetime import date as _dt_date
for _neg_from, _neg_to in _dp_neg_bounds:
# R28: detect full-year bounds → use pub_year for consistency
if _neg_from and _neg_to and _neg_from.endswith("-01-01") and _neg_to.endswith("-12-31"):
_yr_f = int(_neg_from[:4])
_yr_t = int(_neg_to[:4])
if _yr_f == _yr_t:
_neg_yr_cond = and_(
GlobalLiterature.pub_year >= _yr_f,
GlobalLiterature.pub_year <= _yr_f,
)
conditions.append(or_(not_(_neg_yr_cond), GlobalLiterature.pub_year.is_(None)))
else:
_neg_yr_cond = and_(
GlobalLiterature.pub_year >= _yr_f,
GlobalLiterature.pub_year <= _yr_t,
)
conditions.append(or_(not_(_neg_yr_cond), GlobalLiterature.pub_year.is_(None)))
continue
neg_conds = []
if _neg_from:
try:
@@ -1422,7 +1482,11 @@ class AdvancedSearchEngine:
conditions.append(or_(not_(_range_cond), GlobalLiterature.pub_date.is_(None)))
elif dp_conds:
cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0]
conditions.append(not_(cond) if dp_negated else cond)
if dp_negated:
# R28: NULL-safe NOT — rows with NULL pub_year/pub_date should not be excluded
conditions.append(or_(not_(cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None)))
else:
conditions.append(cond)
elif dp_negated:
# negated_date_ranges includes DP but no conditions built — edge case guard
pass
@@ -1481,7 +1545,11 @@ class AdvancedSearchEngine:
elif field_conds:
cond = and_(*field_conds) if len(field_conds) > 1 else field_conds[0]
negated = field_tag in getattr(pp, 'negated_date_ranges', set())
conditions.append(not_(cond) if negated else cond)
if negated:
# R28: NULL-safe NOT — rows with NULL col should not be excluded
conditions.append(or_(not_(cond), col.is_(None)))
else:
conditions.append(cond)
# 7. [PMID] → 精确匹配,支持 is_not
for term in pp.pmid_terms:
@@ -1622,6 +1690,26 @@ class AdvancedSearchEngine:
col = col_map[field]
from datetime import date as _d
return and_(col >= _d(year, 1, 1), col <= _d(year, 12, 31))
# R28: partial date YYYY-MM inside group
from app.services.pubmed_query_parser import _PARTIAL_DATE_RE as _PDR
if _PDR.match(_term_text):
_df, _dt = _expand_partial_date(_term_text)
from datetime import date as _d
try:
d_from, d_to = _d.fromisoformat(_df), _d.fromisoformat(_dt)
if field == "DP":
return and_(GlobalLiterature.pub_date >= d_from, GlobalLiterature.pub_date <= d_to)
col_map = {
"EDAT": GlobalLiterature.entrez_date,
"CRDT": GlobalLiterature.create_date,
"MHDA": GlobalLiterature.meshed_date,
"LR": GlobalLiterature.pubmed_revised,
"DCOM": GlobalLiterature.date_completed,
"DEP": GlobalLiterature.pub_date,
}
return and_(col_map[field] >= d_from, col_map[field] <= d_to)
except ValueError:
pass
if _vds(_term_text):
from datetime import date as _d
try:
+60
View File
@@ -2095,3 +2095,63 @@ if _PARTIAL_DATE_RE.match(end_val):
- 解析器 36 测试全部通过
- 5 个新增 R27 正确性检查通过
- 全量套件中仅外部服务连接失败(httpx.ConnectError),与改动无关
## R28 (2026-07-29): 第三轮并行审计修复
### R28-1 (MEDIUM): 组作用域标记污染 `negated_date_ranges`
**文件**[pubmed_query_parser.py:483-486](backend/app/services/pubmed_query_parser.py#L483)
**根因**`__RANGE_*` 标记的 `negated_date_ranges` 聚合未过滤组内标记(`group_id >= 0`),导致组内 NOT 日期范围被错误地应用到顶层日期字段的 `not_(cond)`,产生 `NOT (pub_year >= X AND pub_year <= Y)` 而非正确的组内处理。
**修复**:过滤 `t.group_id < 0`(仅顶层标记),组内标记由 `_build_marker_condition` + group processing 路径独立处理。
### R28-2 (LOW): 标记 field 被组 field 覆盖
**文件**[pubmed_query_parser.py:883](backend/app/services/pubmed_query_parser.py#L883)
**根因**:组 field 标签赋值循环未跳过 `__RANGE_*` 标记,导致标记的 `field` 属性从 `__RANGE_DP__` 被覆盖为 `dp`,破坏后续标记识别。
**修复**:跳过 `getattr(t, '_is_range_end', False)` 的标记项。
### R28-3 (CRITICAL): DP 否定范围使用 `pub_date` 而非 `pub_year`
**文件**[search_engine.py:1407-1436](backend/app/services/search_engine.py#L1407)
**根因**`_neg_single_dates` DP 分支对所有否定范围使用 `pub_date` 比较,但当范围是完整年份(如 `2024:2024`)且用户在 `pub_year` 列上有不同数据质量时,`pub_date` 可能产生不准确的结果。
**修复**:检测 `_neg_from` 以 `-01-01` 结尾且 `_neg_to` 以 `-12-31` 结尾时,切换到 `pub_year` 比较,同时保持 NULL 安全(`or_(not_(cond), pub_year.is_(None))`)。
### R28-4 (CRITICAL): `_pubmed_conditions` 异常导致未清理查询
**文件**[search_engine.py:254,300,306,331](backend/app/services/search_engine.py#L254)
**根因**`_pubmed_conditions()` 内未捕获的异常传播到 `search()` 外导致 500 错误,且解析失败后的文本降级检查 `not any([...])` 未包含此路径。
**修复**:添加 `_pubmed_failed` 标志,`except` 块中设为 `True` 并强制文本降级和查询清理。
### R28-5 (HIGH): YYYY-MM 在组内路径中未处理
**文件**[search_engine.py:1645-1665](backend/app/services/search_engine.py#L1645)
**根因**`_single_term_condition()` 处理日期字段时,仅在 `len(_term_text) == 4`(年份)和完整 ISO 日期(YYYY-MM-DD)之间处理。`YYYY-MM` 格式(如 `2024-06`)既不匹配 4 位数字也不匹配完整日期,静默降级。R27 修复仅在顶层路径生效,组内 `_single_term_condition` 路径遗漏。
**修复**:插入 `_PARTIAL_DATE_RE` + `_expand_partial_date` 处理,将 `YYYY-MM` 展开为 `YYYY-MM-01`~`YYYY-MM-30/31` 的日期范围条件。
### R28-6 (MEDIUM): NULL 安全 NOT 缺失
**文件**[search_engine.py:1267-1273,1343-1365,1444-1446,1502-1505](backend/app/services/search_engine.py)
**根因**:多处 `not_(date_condition)` 未包装 NULL 安全,导致 `pub_date`/`pub_year` 等日期列为 NULL 的行被错误排除:
- 顶层 DP `not_(cond)` 路径(line 1446
- 非 DP 字段 `negated_date_ranges` 路径(line 1505
- 组标记 NOT 路径(非否定组中的 `is_not` 标记,line 1271
- 否定组包装 `not_(combined)`line 1347,通过 `_neg_group_date_fields` 跟踪)
**修复**:对所有路径使用 `or_(not_(cond), col.is_(None))`。组标记路径按 `_tag` 查找对应列(DP 同时加上 `pub_year.is_(None)` + `pub_date.is_(None)`)。
## 验证
-**1007 tests passed, 0 failed**
- 全部搜索测试通过(parser 36 + search engine 集成测试)
- 无回归