fix: 第17轮搜索审计修复 — 缓存键中毒/_pubmed_conditions异常保护/引号短语exact强制
- P0-1 (HIGH): sub-path A query原地修改后缓存键未更新 → 重新计算 - P0-2 (MEDIUM): _pubmed_conditions无异常保护 → try/except降级flat text - P0-3 (MEDIUM): 引号短语不强制exact_phrase → _phrase_terms_set跟踪
This commit is contained in:
@@ -279,9 +279,15 @@ class AdvancedSearchEngine:
|
|||||||
)
|
)
|
||||||
if has_pubmed_terms:
|
if has_pubmed_terms:
|
||||||
_is_flat_text = False
|
_is_flat_text = False
|
||||||
|
# P17: 异常保护 — _pubmed_conditions 内部异常时降级到 flat text
|
||||||
|
try:
|
||||||
conditions = await AdvancedSearchEngine._pubmed_conditions(
|
conditions = await AdvancedSearchEngine._pubmed_conditions(
|
||||||
db, pp, conditions,
|
db, pp, conditions,
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_pubmed_conditions failed, falling back to flat text")
|
||||||
|
_is_flat_text = True
|
||||||
|
conditions = []
|
||||||
|
|
||||||
# ─── 传统搜索路径(纯文本 / PubMed 退化) ───
|
# ─── 传统搜索路径(纯文本 / PubMed 退化) ───
|
||||||
if _is_flat_text and query.strip():
|
if _is_flat_text and query.strip():
|
||||||
@@ -316,6 +322,38 @@ class AdvancedSearchEngine:
|
|||||||
query = re.sub(r'\b(AND|OR|NOT)\b', '', query)
|
query = re.sub(r'\b(AND|OR|NOT)\b', '', query)
|
||||||
query = query.replace('"', '').replace('(', '').replace(')', '')
|
query = query.replace('"', '').replace('(', '').replace(')', '')
|
||||||
query = ' '.join(query.split())
|
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
|
# 中文搜索:自动匹配 GlobalTag.name_zh → 注入 tag_ids,跳过 ILIKE
|
||||||
import re as _re
|
import re as _re
|
||||||
_CHINESE_RE = _re.compile(r'[一-鿿㐀-䶿豈-]')
|
_CHINESE_RE = _re.compile(r'[一-鿿㐀-䶿豈-]')
|
||||||
@@ -337,6 +375,8 @@ class AdvancedSearchEngine:
|
|||||||
_query_no_quotes = _phrase_pat.sub(' ', query)
|
_query_no_quotes = _phrase_pat.sub(' ', query)
|
||||||
_rest = [t.strip().strip('"').strip("'") for t in _query_no_quotes.split() if t.strip()]
|
_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]
|
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 精确匹配(unique index 5ms 返回)
|
||||||
# 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan
|
# 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan
|
||||||
numeric_terms = [t for t in terms if re.match(r'^\d{1,15}$', t)]
|
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)
|
_cond_before = len(conditions)
|
||||||
if boolean == "and":
|
if boolean == "and":
|
||||||
for term in text_terms:
|
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:
|
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))
|
conditions.append(or_(*or_conds))
|
||||||
|
|
||||||
# 将文本条件与 ATM 条件 OR 组合
|
# 将文本条件与 ATM 条件 OR 组合
|
||||||
|
|||||||
+41
-1
@@ -1210,7 +1210,7 @@
|
|||||||
## 第十六轮:第 16 轮审计修复(4 项修复 + 3 项记录)
|
## 第十六轮:第 16 轮审计修复(4 项修复 + 3 项记录)
|
||||||
|
|
||||||
**日期**:2026-07-29
|
**日期**:2026-07-29
|
||||||
**提交**:`(待推送)`
|
**提交**:`de1f4a4`
|
||||||
**数量**:4 项修复 + 3 项记录
|
**数量**:4 项修复 + 3 项记录
|
||||||
**触发**:用户第 11 次要求全面检查(Round 16,3 并行 agent:Normal 搜索边缘、前端参数、NOT 检测)
|
**触发**:用户第 11 次要求全面检查(Round 16,3 并行 agent:Normal 搜索边缘、前端参数、NOT 检测)
|
||||||
**测试**:1007 全部通过 + 前端 build 通过
|
**测试**: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 项已知限制:
|
截至 2026-07-29,剩余 7 项已知限制:
|
||||||
|
|
||||||
| ID | 问题 | 原因 | 影响 |
|
| ID | 问题 | 原因 | 影响 |
|
||||||
|
|||||||
Reference in New Issue
Block a user