diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index bdc577f..4159a1e 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -894,7 +894,15 @@ class PubmedQueryParser: _parent_gid = len(result.groups) _ungrouped = [t for t in terms if t.group_id < 0] # 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: t.group_id = _parent_gid if _ungrouped: @@ -908,7 +916,17 @@ class PubmedQueryParser: # R21: only write sub_group_refs when creating a parent group, # preventing phantom entries when _ungrouped is empty (all terms already in child groups) 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_negated.append(False) # P16: P19 revert logic handles NOT group tracking # 已分组的 Term(嵌套括号 OR 子组)不再重复加组 diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index a0227c1..7779bc8 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -253,7 +253,7 @@ class AdvancedSearchEngine: _pubmed_parsed = None _pubmed_failed = False _is_flat_text = True #是否走传统 tsvector + ILIKE 路径 - if query.strip(): + if query and query.strip(): if is_pubmed_syntax(query): pp = parse_pubmed_query(query) @@ -1289,7 +1289,18 @@ class AdvancedSearchEngine: _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))) + # 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"): _tag2_col = { "EDAT": GlobalLiterature.entrez_date, @@ -1335,7 +1346,18 @@ class AdvancedSearchEngine: _cmf = t.field or "" _ctag = _cmf[8:-2] if _cmf.startswith("__RANGE_") and _cmf.endswith("__") else None 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"): _ctag_col = { "EDAT": GlobalLiterature.entrez_date, @@ -1386,27 +1408,7 @@ 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] - _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) + term_conditions.append(not_(combined)) else: # 混合/正组:将 pos 和 neg 按组操作符组合,保留组内结构 # 避免 neg 被 OR/NOT 分离拉出来破坏语义 @@ -1525,8 +1527,16 @@ class AdvancedSearchEngine: elif dp_conds: cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0] 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))) + # R30: NULL-safe only for columns actually in the condition + _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: conditions.append(cond) elif dp_negated: diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index d55c7f5..0c59040 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -2,7 +2,7 @@ > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > -> **累计**:24 轮,290+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖 +> **累计**:25 轮,298+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖 > **时间跨度**:2026-07-24 ~ 2026-07-29 > **核心文件**:`pubmed_query_parser.py`(~1100 行)→ `search_engine.py`(~1960 行) @@ -24,7 +24,8 @@ 12. [第十二轮:第 12 轮深度审计修复(21 项)](#第十二轮第-12-轮深度审计修复) 13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复) 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** - 全部搜索测试通过 - 无回归 + +## 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})?)`。 diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index 9751110..1fc567c 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -372,7 +372,7 @@ const { page, total, goToPage } = usePagination({ searchError.value = e?.response?.status === 401 ? '请登录后使用搜索功能' : e?.response?.status === 400 - ? '搜索参数有误,请调整后重试' + ? e?.response?.data?.detail || '搜索参数有误,请调整后重试' : e?.response?.status === 429 ? '请求过于频繁,请稍后重试' : '搜索失败,请检查网络或稍后重试' diff --git a/frontend/src/views/public/AdvancedPubSearchView.vue b/frontend/src/views/public/AdvancedPubSearchView.vue index 17f51de..10deea1 100644 --- a/frontend/src/views/public/AdvancedPubSearchView.vue +++ b/frontend/src/views/public/AdvancedPubSearchView.vue @@ -87,7 +87,7 @@ const translated = computed(() => { } // Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[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) { const key = `dp_range_${m.index}` if (!seen.has(key)) {