From 5fa2fbec7b5f455d2e6a5910b6987f7a12f92604 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 15:12:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC17=E8=BD=AE=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E9=94=AE=E4=B8=AD=E6=AF=92/=5Fpubmed=5Fconditions?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E4=BF=9D=E6=8A=A4/=E5=BC=95=E5=8F=B7?= =?UTF-8?q?=E7=9F=AD=E8=AF=ADexact=E5=BC=BA=E5=88=B6=20-=20P0-1=20(HIGH):?= =?UTF-8?q?=20sub-path=20A=20query=E5=8E=9F=E5=9C=B0=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=90=8E=E7=BC=93=E5=AD=98=E9=94=AE=E6=9C=AA=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20=E2=86=92=20=E9=87=8D=E6=96=B0=E8=AE=A1=E7=AE=97=20-=20P0-2?= =?UTF-8?q?=20(MEDIUM):=20=5Fpubmed=5Fconditions=E6=97=A0=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E4=BF=9D=E6=8A=A4=20=E2=86=92=20try/except=E9=99=8D?= =?UTF-8?q?=E7=BA=A7flat=20text=20-=20P0-3=20(MEDIUM):=20=E5=BC=95?= =?UTF-8?q?=E5=8F=B7=E7=9F=AD=E8=AF=AD=E4=B8=8D=E5=BC=BA=E5=88=B6exact=5Fp?= =?UTF-8?q?hrase=20=E2=86=92=20=5Fphrase=5Fterms=5Fset=E8=B7=9F=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/search_engine.py | 51 ++++++++++++++++++++++++--- docs/13-搜索修复全记录.md | 42 +++++++++++++++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 35cba2b..4afae2d 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -279,9 +279,15 @@ class AdvancedSearchEngine: ) if has_pubmed_terms: _is_flat_text = False - conditions = await AdvancedSearchEngine._pubmed_conditions( - db, pp, conditions, - ) + # P17: 异常保护 — _pubmed_conditions 内部异常时降级到 flat text + try: + conditions = await AdvancedSearchEngine._pubmed_conditions( + db, pp, conditions, + ) + except Exception: + logger.exception("_pubmed_conditions failed, falling back to flat text") + _is_flat_text = True + conditions = [] # ─── 传统搜索路径(纯文本 / PubMed 退化) ─── if _is_flat_text and query.strip(): @@ -316,6 +322,38 @@ class AdvancedSearchEngine: query = re.sub(r'\b(AND|OR|NOT)\b', '', query) query = query.replace('"', '').replace('(', '').replace(')', '') query = ' '.join(query.split()) + # P17: query 被原地修改后,缓存键指向脏数据 → 重新计算 + _search_cache_key = AdvancedSearchEngine._search_cache_key( + query, field, boolean, exact_phrase, + year_from, year_to, date_from, date_to, + journal_tiers, pub_types, tag_ids, + retracted, negative_result, + is_oa, language, languages, nlm_subsets, + page_size, sort, page, + has_abstract=has_abstract, + is_free_full_text=is_free_full_text, + has_full_text=has_full_text, + has_associated_data=has_associated_data, + species=species, sex=sex, age=age, + medline_only=medline_only, + exclude_preprints=exclude_preprints, + cursor_val=cursor_val, + cursor_id=cursor_id, + ) + _facet_cache_key = AdvancedSearchEngine._facet_cache_key( + query, field, boolean, exact_phrase, + year_from, year_to, date_from, date_to, + journal_tiers, pub_types, tag_ids, + retracted, negative_result, + is_oa, language, languages, nlm_subsets, + has_abstract=has_abstract, + is_free_full_text=is_free_full_text, + has_full_text=has_full_text, + has_associated_data=has_associated_data, + species=species, sex=sex, age=age, + medline_only=medline_only, + exclude_preprints=exclude_preprints, + ) # 中文搜索:自动匹配 GlobalTag.name_zh → 注入 tag_ids,跳过 ILIKE import re as _re _CHINESE_RE = _re.compile(r'[一-鿿㐀-䶿豈-﫿]') @@ -337,6 +375,8 @@ class AdvancedSearchEngine: _query_no_quotes = _phrase_pat.sub(' ', query) _rest = [t.strip().strip('"').strip("'") for t in _query_no_quotes.split() if t.strip()] terms = [p for p in _phrases if p.strip()] + [t for t in _rest if t not in _phrases] + # P17: 记录引号短语,后续强制 exact=True + _phrase_terms_set = set(p.lower() for p in _phrases if p.strip()) # 单数字词:优先 PMID 精确匹配(unique index 5ms 返回) # 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan numeric_terms = [t for t in terms if re.match(r'^\d{1,15}$', t)] @@ -387,9 +427,10 @@ class AdvancedSearchEngine: _cond_before = len(conditions) if boolean == "and": for term in text_terms: - conditions.append(AdvancedSearchEngine._field_condition(field, term, exact_phrase)) + _exact = exact_phrase or term in _phrase_terms_set # P17: 引号短语强制 exact + conditions.append(AdvancedSearchEngine._field_condition(field, term, _exact)) else: - or_conds = [AdvancedSearchEngine._field_condition(field, t, exact_phrase) for t in text_terms] + or_conds = [AdvancedSearchEngine._field_condition(field, t, exact_phrase or t in _phrase_terms_set) for t in text_terms] conditions.append(or_(*or_conds)) # 将文本条件与 ATM 条件 OR 组合 diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index a6cb87a..6c4cc85 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -1210,7 +1210,7 @@ ## 第十六轮:第 16 轮审计修复(4 项修复 + 3 项记录) **日期**:2026-07-29 -**提交**:`(待推送)` +**提交**:`de1f4a4` **数量**:4 项修复 + 3 项记录 **触发**:用户第 11 次要求全面检查(Round 16,3 并行 agent:Normal 搜索边缘、前端参数、NOT 检测) **测试**:1007 全部通过 + 前端 build 通过 @@ -1253,6 +1253,46 @@ --- +## 第十七轮:第 17 轮审计修复(3 项) + +**日期**:2026-07-29 +**提交**:`(待推送)` +**数量**:3 项(1 HIGH + 2 MEDIUM) +**触发**:用户第 12 次要求全面检查(Round 17,3 并行 agent:`_is_flat_text` 降级路径、facet 一致性、前端参数映射) +**审计**:facet 一致性和缓存键验证通过,未发现问题 +**测试**:1007 全部通过 + 前端 build 通过 + +### P0-1 (HIGH): `_search_cache_key` 在 sub-path A 中被污染 + +- **文件**:`search_engine.py:314-318` +- **根因**:`_search_cache_key` 在第 186 行用原始 `query` 预计算。第 314-318 行在 sub-path A 中对 `query` 原地修改(剥离 field tags/布尔符/引号/括号),但缓存键未更新。后续请求命中此缓存时,返回的是剥离后的空查询结果。 +- **触发条件**:仅当 `is_pubmed_syntax(query)=True` 但 `parse_pubmed_query(query)` 返回空有效字段时(如 `"NOT"`、`"AND OR"` 等无意义查询)。实际影响极小,但属于正确性 bug。 +- **修复**:在 `query` 原地修改后重新调用 `_search_cache_key()` 和 `_facet_cache_key()`,确保缓存键反映剥离后的查询内容。 + +### P0-2 (MEDIUM): `_pubmed_conditions` 无异常保护 + +- **文件**:`search_engine.py:282-284` +- **根因**:`_pubmed_conditions()` 调用无 try/except。内部虽有零散异常处理,但 `AttributeError`/`TypeError` 等会传播到 `search()` 外 → 500 错误(`literature.py:330` 已有全局保护,但高级搜索引擎没有)。 +- **修复**:包裹 try/except Exception,异常时 `logger.exception()` 并降级到 flat text 路径:`_is_flat_text = True; conditions = []` + +### P0-3 (MEDIUM): 引号短语不强制 exact_phrase + +- **文件**:`search_engine.py:334-393` +- **根因**:flat text 路径从查询中提取引号短语 `"lung cancer"` 作为独立词,但传入 `_field_condition(term, exact_phrase)` 时使用全局 `exact_phrase` 参数。`exact_phrase=False` 时,引号短语被拆散为 `lung AND cancer` 而非保持 `<->` 短语搜索。 +- **修复**:记录引号短语集 `_phrase_terms_set`,传入 `_field_condition` 时:引号短语始终 `exact=True`,其他词使用全局 `exact_phrase`。 + +### 审计结果汇总 + +| 审计维度 | 结果 | +|---------|------| +| `_is_flat_text` 降级路径 | ✅ 缓存键中毒(P0-1)已修复、异常保护(P0-2)已添加、引号短语(P0-3)已修复 | +| Fragment/缓存完整性 | ✅ `parse_pubmed_query` 异常降级 clean、回退路径比普通搜索更全面 | +| `year_counts` facet 一致性 | ✅ 筛选条件与主查询完全一致、缓存键差异仅限于分页参数(设计意图)、无问题 | +| Facet 缓存键完备性 | ✅ `_facet_cache_key` 包含全部 26 个筛选参数,无缺失 | +| 前端参数映射 | ✅ SearchView.vue 发送全部 28 个参数、HomeView 参数子集缩小属设计意图、`is_oa` 后端功能被 `is_free_full_text` 覆盖、无实际 gap | + +--- + 截至 2026-07-29,剩余 7 项已知限制: | ID | 问题 | 原因 | 影响 |