fix: 第30轮搜索审计修复 — 否定组NULL安全/嵌套括号_has_or/传递子组/DP标记列精度等8项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

This commit is contained in:
34047007@qq.com
2026-07-29 07:30:36 +08:00
parent 475d6bb80c
commit fc53a5b255
5 changed files with 129 additions and 32 deletions
+20 -2
View File
@@ -894,7 +894,15 @@ class PubmedQueryParser:
_parent_gid = len(result.groups) _parent_gid = len(result.groups)
_ungrouped = [t for t in terms if t.group_id < 0] _ungrouped = [t for t in terms if t.group_id < 0]
# P20: track sub-groups created by _parse_or_expr inside this paren # P20: track sub-groups created by _parse_or_expr inside this paren
_child_gids = sorted(set(t.group_id for t in terms if t.group_id >= 0)) # R30: exclude gids already in existing sub_group_refs (transitive children)
# and gids that are themselves parent groups (have sub_group_refs)
_existing_children = set(g for refs in result.sub_group_refs for g in refs)
_child_candidates = sorted(set(t.group_id for t in terms if t.group_id >= 0))
_child_gids = [
gid for gid in _child_candidates
if gid not in _existing_children
and (gid >= len(result.sub_group_refs) or not result.sub_group_refs[gid])
]
for t in _ungrouped: for t in _ungrouped:
t.group_id = _parent_gid t.group_id = _parent_gid
if _ungrouped: if _ungrouped:
@@ -908,7 +916,17 @@ class PubmedQueryParser:
# R21: only write sub_group_refs when creating a parent group, # R21: only write sub_group_refs when creating a parent group,
# preventing phantom entries when _ungrouped is empty (all terms already in child groups) # preventing phantom entries when _ungrouped is empty (all terms already in child groups)
result.groups.append(_ungrouped) result.groups.append(_ungrouped)
_has_or = any(t.type == TokenType.OR for t in self.tokens[start_pos:end_pos]) # R30: track paren depth to avoid counting OR inside nested parens
_or_depth = 0
_has_or = False
for _t in self.tokens[start_pos:end_pos]:
if _t.type == TokenType.LPAREN:
_or_depth += 1
elif _t.type == TokenType.RPAREN:
_or_depth -= 1
elif _t.type == TokenType.OR and _or_depth == 0:
_has_or = True
break
result.group_operators.append("or" if _has_or else "and") result.group_operators.append("or" if _has_or else "and")
result.group_negated.append(False) # P16: P19 revert logic handles NOT group tracking result.group_negated.append(False) # P16: P19 revert logic handles NOT group tracking
# 已分组的 Term(嵌套括号 OR 子组)不再重复加组 # 已分组的 Term(嵌套括号 OR 子组)不再重复加组
+36 -26
View File
@@ -253,7 +253,7 @@ class AdvancedSearchEngine:
_pubmed_parsed = None _pubmed_parsed = None
_pubmed_failed = False _pubmed_failed = False
_is_flat_text = True #是否走传统 tsvector + ILIKE 路径 _is_flat_text = True #是否走传统 tsvector + ILIKE 路径
if query.strip(): if query and query.strip():
if is_pubmed_syntax(query): if is_pubmed_syntax(query):
pp = parse_pubmed_query(query) pp = parse_pubmed_query(query)
@@ -1289,7 +1289,18 @@ class AdvancedSearchEngine:
_mf2 = t.field or "" _mf2 = t.field or ""
_tag2 = _mf2[8:-2] if _mf2.startswith("__RANGE_") and _mf2.endswith("__") else None _tag2 = _mf2[8:-2] if _mf2.startswith("__RANGE_") and _mf2.endswith("__") else None
if _tag2 == "DP": if _tag2 == "DP":
g_neg.append(or_(not_(_marker_cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None))) # R30: column-precise NULL safety based on marker range values
_dp_parts = (t.text or "").split(":", 1)
_dp_ns = set()
for _dp_p in _dp_parts:
if _dp_p and _dp_p.isdigit() and len(_dp_p) == 4:
_dp_ns.add(GlobalLiterature.pub_year)
elif _dp_p:
_dp_ns.add(GlobalLiterature.pub_date)
if _dp_ns:
g_neg.append(or_(not_(_marker_cond), *(_c.is_(None) for _c in _dp_ns)))
else:
g_neg.append(not_(_marker_cond))
elif _tag2 in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"): elif _tag2 in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
_tag2_col = { _tag2_col = {
"EDAT": GlobalLiterature.entrez_date, "EDAT": GlobalLiterature.entrez_date,
@@ -1335,7 +1346,18 @@ class AdvancedSearchEngine:
_cmf = t.field or "" _cmf = t.field or ""
_ctag = _cmf[8:-2] if _cmf.startswith("__RANGE_") and _cmf.endswith("__") else None _ctag = _cmf[8:-2] if _cmf.startswith("__RANGE_") and _cmf.endswith("__") else None
if _ctag == "DP": if _ctag == "DP":
child_neg.append(or_(not_(_c_marker_cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None))) # R30: column-precise NULL safety for child sub-group DP markers
_cdp_parts = (t.text or "").split(":", 1)
_cdp_ns = set()
for _cdp_p in _cdp_parts:
if _cdp_p and _cdp_p.isdigit() and len(_cdp_p) == 4:
_cdp_ns.add(GlobalLiterature.pub_year)
elif _cdp_p:
_cdp_ns.add(GlobalLiterature.pub_date)
if _cdp_ns:
child_neg.append(or_(not_(_c_marker_cond), *(_c.is_(None) for _c in _cdp_ns)))
else:
child_neg.append(not_(_c_marker_cond))
elif _ctag in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"): elif _ctag in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
_ctag_col = { _ctag_col = {
"EDAT": GlobalLiterature.entrez_date, "EDAT": GlobalLiterature.entrez_date,
@@ -1386,27 +1408,7 @@ class AdvancedSearchEngine:
# NOT(A OR B): single UnaryExpression → 顶层 OR/NOT 分离时被检测为 neg → 独立 AND # NOT(A OR B): single UnaryExpression → 顶层 OR/NOT 分离时被检测为 neg → 独立 AND
if g_neg: if g_neg:
combined = combine_fn(*g_neg) if len(g_neg) > 1 else g_neg[0] combined = combine_fn(*g_neg) if len(g_neg) > 1 else g_neg[0]
_notted = not_(combined) term_conditions.append(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: else:
# 混合/正组:将 pos 和 neg 按组操作符组合,保留组内结构 # 混合/正组:将 pos 和 neg 按组操作符组合,保留组内结构
# 避免 neg 被 OR/NOT 分离拉出来破坏语义 # 避免 neg 被 OR/NOT 分离拉出来破坏语义
@@ -1525,8 +1527,16 @@ class AdvancedSearchEngine:
elif dp_conds: elif dp_conds:
cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0] cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0]
if dp_negated: if dp_negated:
# R28: NULL-safe NOT — rows with NULL pub_year/pub_date should not be excluded # R30: NULL-safe only for columns actually in the condition
conditions.append(or_(not_(cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None))) _dp_ns = []
if pp.year_from is not None or pp.year_to is not None:
_dp_ns.append(GlobalLiterature.pub_year.is_(None))
if pp.date_from is not None or pp.date_to is not None:
_dp_ns.append(GlobalLiterature.pub_date.is_(None))
if _dp_ns:
conditions.append(or_(not_(cond), *_dp_ns))
else:
conditions.append(not_(cond))
else: else:
conditions.append(cond) conditions.append(cond)
elif dp_negated: elif dp_negated:
+71 -2
View File
@@ -2,7 +2,7 @@
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
> >
> **累计**24 轮,290+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖 > **累计**25 轮,298+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖
> **时间跨度**2026-07-24 ~ 2026-07-29 > **时间跨度**2026-07-24 ~ 2026-07-29
> **核心文件**`pubmed_query_parser.py`~1100 行)→ `search_engine.py`~1960 行) > **核心文件**`pubmed_query_parser.py`~1100 行)→ `search_engine.py`~1960 行)
@@ -24,7 +24,8 @@
12. [第十二轮:第 12 轮深度审计修复(21 项)](#第十二轮第-12-轮深度审计修复) 12. [第十二轮:第 12 轮深度审计修复(21 项)](#第十二轮第-12-轮深度审计修复)
13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复) 13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复)
14. [第十五轮(第 24 次审计修复)](#round-24第-24-次全面审计修复) 14. [第十五轮(第 24 次审计修复)](#round-24第-24-次全面审计修复)
15. [遗留限制](#遗留限制) 15. [R30(第 5 轮并行审计修复)](#r30-2026-07-29-第五轮并行审计修复)
16. [遗留限制](#遗留限制)
--- ---
@@ -2237,3 +2238,71 @@ if _PARTIAL_DATE_RE.match(end_val):
- ✅ **1007 tests passed, 0 failed** - ✅ **1007 tests passed, 0 failed**
- 全部搜索测试通过 - 全部搜索测试通过
- 无回归 - 无回归
## R30 (2026-07-29): 第五轮并行审计修复
> **变更类型**:6 项修复(1 HIGH 已在前序会话修复 + 5 项新增)
### R30-1 (HIGH): 否定组 NULL 安全包裹整个组合条件
**文件**[search_engine.py:1385-1409](backend/app/services/search_engine.py#L1385)
**根因**:R28 为否定组添加的 NULL 安全 NOT 包裹过于宽泛:`or_(not_(date_cond + keyword_combined), col1.is_(None), col2.is_(None))`。当日期列为 NULL 时,关键词匹配也通过 NULL 安全逃逸,导致否定组中的关键词条件失效。
**修复**:完全移除此 NULL 安全包裹层。否定组现在使用纯 `not_(combined)`,不附加列 NULL 检查。日期列的 NULL 安全由列精确的 `dp_negated` 路径单独处理。
### R30-2 (MEDIUM): `dp_negated` 双列 NULL 安全不精确
**文件**[search_engine.py:1525-1531](backend/app/services/search_engine.py#L1525)
**根因**:顶层 `dp_negated` 路径始终同时添加 `pub_year.is_(None)` 和 `pub_date.is_(None)`,即使条件仅使用其中一列(如纯年范围 `2024:2025[DP]` 只使用 `pub_year`)。
**修复**:检查条件使用了哪些列(`year_from/to` → `pub_year``date_from/to` → `pub_date`),仅对实际使用的列添加 NULL 安全。
### R30-3 (MEDIUM): 嵌套括号 `_has_or` 跨越作用域边界
**文件**[pubmed_query_parser.py:911](backend/app/services/pubmed_query_parser.py#L911)
**根因**`_has_or` 通过扫描 `start_pos:end_pos` 范围内所有 token 检测 OR 运算符。嵌套括号如 `((A OR B AND C) AND D)` 中,外层括号的 `_has_or` 扫描到内层 OR,导致外层分组操作符被设为 "or" 而非 "and"。
**修复**:在扫描过程中跟踪括号深度,仅计数当前深度(`_or_depth == 0`)的 OR token。
### R30-4 (MEDIUM): `_child_gids` 包含传递性子组
**文件**[pubmed_query_parser.py:897](backend/app/services/pubmed_query_parser.py#L897)
**根因**`_child_gids` 从所有返回的 Term 中收集 group_id,包括嵌套括号创建的传递性父组。这些传递性组不应作为当前组的子组引用。
**修复**:过滤掉 (a) 已在现有 `sub_group_refs` 中的 gid(传递性子组)和 (b) 自身就是父组的 gid(`sub_group_refs` 非空)。
### R30-5 (MEDIUM): 非否定组 DP 标记 NULL 安全列不精确
**文件**[search_engine.py:1289-1292](backend/app/services/search_engine.py#L1289), [search_engine.py:1337-1338](backend/app/services/search_engine.py#L1337)
**根因**:非否定组中的 DP 标记 `is_not=True`(如 `NOT 2024:2025[DP]` 在非否定组内)和子组路径中的 DP 标记始终同时添加 `pub_year.is_(None)` 和 `pub_date.is_(None)`。
**修复**:根据标记文本中的范围值类型(4 位年份 → `pub_year`,日期格式 → `pub_date`)选择性地添加 NULL 安全列。
### R30-6 (LOW): `query=None` 崩溃
**文件**[search_engine.py:256](backend/app/services/search_engine.py#L256)
**根因**`query.strip()` 在 `query` 为 `None` 时抛出 `AttributeError`。
**修复**:改为 `if query and query.strip():`。
### R30-7 (LOW): 搜索错误详情丢失
**文件**[SearchView.vue](frontend/src/views/app/SearchView.vue)
**根因**400 错误的 catch 块使用硬编码的 '搜索参数有误' 提示,未展示服务器的错误详情。
**修复**:对 400 错误优先展示 `e?.response?.data?.detail`。
### R30-8 (LOW): 日期范围正则遗漏 YYYY-MM-DD
**文件**[AdvancedPubSearchView.vue](frontend/src/views/public/AdvancedPubSearchView.vue)
**根因**:日期范围正则只匹配 `YYYY:YYYY` 和 `YYYY/MM/DD:YYYY/MM/DD`,遗漏 `YYYY-MM-DD:YYYY-MM-DD`ISO 格式)。
**修复**:添加 `-` 作为可选日期分隔符。`(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)`。
+1 -1
View File
@@ -372,7 +372,7 @@ const { page, total, goToPage } = usePagination({
searchError.value = e?.response?.status === 401 searchError.value = e?.response?.status === 401
? '请登录后使用搜索功能' ? '请登录后使用搜索功能'
: e?.response?.status === 400 : e?.response?.status === 400
? '搜索参数有误,请调整后重试' ? e?.response?.data?.detail || '搜索参数有误,请调整后重试'
: e?.response?.status === 429 : e?.response?.status === 429
? '请求过于频繁,请稍后重试' ? '请求过于频繁,请稍后重试'
: '搜索失败,请检查网络或稍后重试' : '搜索失败,请检查网络或稍后重试'
@@ -87,7 +87,7 @@ const translated = computed(() => {
} }
// Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[DP] // Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[DP]
// displayText #N DP // displayText #N DP
const yrRe = /(\d{4}(?:\/\d{2}\/\d{2})?)\s*:\s*(\d{4}(?:\/\d{2}\/\d{2})?)\s*\[DP\]/g const yrRe = /(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)\s*:\s*(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)\s*\[DP\]/g
while ((m = yrRe.exec(displayText)) !== null) { while ((m = yrRe.exec(displayText)) !== null) {
const key = `dp_range_${m.index}` const key = `dp_range_${m.index}`
if (!seen.has(key)) { if (!seen.has(key)) {