Files
backend/docs/13-搜索修复全记录.md
T
34047007@qq.com 6f861c8543 fix: 第12轮搜索审计修复 — _normalize_field_label 崩溃/keyset NULLSLAT/共享列表变异等21项修复
P0 (3): _normalize_field_label 函数缺失导致 (a OR b)[TI] 崩溃;
      共享列表变异污染 result.groups;POST /search/advanced 缺少用户认证
P1 (8): has_not 忽略括号内 NOT;紧凑日期 YYYYMMDD 未归一化;
      日期字段非日期文本 SQL 错误;keyset NULLSLAT ~50% 空值行跳过;
      first_author 非 dict JSON 崩溃;MeSH 无匹配静默丢弃;
      相关性排序含否定词;词数检查在 PubMed 清洗之前;
      普通搜索缺错误处理;#N 引号感知不完整
P2 (6): 月越界退化;MH/MAJR FALSE 语义;field 白名单缺字段;
      retracted/negative_result/tag_ids 验证器;resolveQuery 重复调用;
      SearchRequestBody.page 可选;restoreFromQuery showCustomYear
2026-07-28 12:38:17 +08:00

1089 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PubMed 搜索合规修复全记录
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
>
> **累计**11 轮,137 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
> **时间跨度**2026-07-24 ~ 2026-07-28
> **核心文件**`pubmed_query_parser.py`~730 行)→ `search_engine.py`~1350 行)
---
## 目录
1. [第一轮:Phase 0-7 基础修复(34 项)](#第一轮phase-0-7-基础修复)
2. [第二轮:第二轮审计修复(8 项)](#第二轮第二轮审计修复)
3. [第三轮:第三轮审计修复(14 项)](#第三轮第三轮审计修复)
4. [第四轮:字段补全与语义优化(11 项)](#第四轮字段补全与语义优化)
5. [第五轮:第 5 轮全面审计修复(4 项)](#第五轮第-5-轮全面审计修复)
6. [第六轮:第 6 轮全面审计修复(12 项)](#第六轮第-6-轮全面审计修复)
7. [第七轮:第 7 轮深度审计修复(20 项)](#第七轮第-7-轮深度审计修复)
8. [第八轮:第 8 轮深度审计修复(12 项)](#第八轮第-8-轮深度审计修复)
9. [第九轮:第 9 轮深度审计修复(5 项)](#第九轮第-9-轮深度审计修复)
10. [第十轮:第 10 轮深度审计修复(6 项)](#第十轮第-10-轮深度审计修复)
11. [第十一轮:第 11 轮深度审计修复(16 项)](#第十一轮第-11-轮深度审计修复)
12. [遗留限制](#遗留限制)
---
## 第一轮:Phase 0-7 基础修复
**提交**`723c4fc` / `62ca8fa` / `5f69277`
**日期**2026-07-24 ~ 2026-07-25
**数量**34 项
**触发**9 Agent 并行深度审计
### 背景
首次系统审计发现搜索功能存在大量阻塞性 Bug:MeSH 搜索因 INNER JOIN 返回 0 结果、布尔运算符 OR 被当作 AND 处理、PM C_ID 格式混乱、数据导入缺失 journal_iso 和 keywords 等关键字段。整体搜索几不可用。
### Phase 0 — 关键 Bug 修复(12 项)
| # | 修复项 | 文件 | 问题描述 | 根因分析 | 修改内容 |
|---|--------|------|---------|---------|---------|
| 0.1 | `_expand_mesh_tag_ids` INNER JOIN | `search_engine.py:518` | 所有 `[MH]`/`[MAJR]` 搜索返回 0 个结果。tree_numbers 表无 C04 数据,INNER JOIN 过滤掉所有标签 | `select(GlobalTag.id).join(GlobalTagTreeNumber)` 在有 tree_number 的标签时才返回行 | 改为 `select(GlobalTag.id).where(...)`,使 MeSH 搜索在无 tree_number 展开时仍能工作 |
| 0.2 | `recent_subq` 条件化 | `search_engine.py:314-319` | 有搜索词时仍被 `pub_date > 90d` 子查询过滤,漏掉旧文章 | 未检查 `query.strip()` 就附加 recent 子句 | 有搜索词或历史查询时跳过 recent 子查询 |
| 0.3 | OR 布尔运算符 | `search_engine.py:394-414` | 同字段多个词始终 AND`lung[TI] OR breast[TI]` 只返回同时命中 lung 和 breast 的文献 | `boolean` 参数只在文本搜索路径生效,未传入 `_field_conditions` | 同 field 按 `boolean_operator``or_()` 组合 |
| 0.4 | 多 [MH] 词 AND 组合 | `search_engine.py:516-519` | `lung neoplasms[MH] AND immunotherapy[MH]` 返回 0 结果 | 所有 mesh 词合并到一个 subq IN,必须所有标签同时关联同一篇文献 | 逐 term 独立 subq,按 boolean 组合(AND 用 intersect |
| 0.5 | PMC XPath 上下文 | `pubmed_api.py:440,447` | pmc_id/is_oa 解析错误,只有 9/1662 篇有值 | `article.findall('.//PMCID')``article` 而非 `article_elem` 上调用 | `article.findall``article_elem.findall` |
| 0.6 | 构造函数遗漏 | `pubmed_api.py:914-944` | `pubmed_revised`/`citation_status`/`date_completed`/`article_date`/`suppl_mesh_list` 未写入模型 | `_parse_pubmed_xml()` 返回值不包含这些字段 | 补全 5 字段传递 |
| 0.7 | ArticleTitle itertext | `pubmed_api.py:409` | 含 `<i>`/`<sub>` 等内联标记的标题截断 | `.text` 只返回第一个文本节点 | `"".join(itertext())` |
| 0.8 | PMC_ID 格式统一 | `pubmed_api.py:264` | "PMC1234567" vs "1234567" 混用 | `lstrip("PMC")` → 会误删 `PMC` 开头真实数字 | `art.get("pmcid", "").lstrip("PMC")` |
| 0.9 | 搜索端点异常处理 | `features.py:68` | 搜索异常暴露 500 内幕 | 无 try/except | 加异常处理返回 400 |
| 0.10 | field:all 硬编码 | `SearchView.vue:83` | 前端固定发送 `field: "all"` | 模板字符串手误 | 替换为动态 `field.value` |
| 0.11 | journal_iso 导入修复 | `pubmed_api.py` | 0% 覆盖率 | Journal ISOAbbreviation 未解析 | 增补 ISOAbbreviation 提取 |
| 0.12 | keywords 导入修复 | `pubmed_api.py` | 0% 覆盖率 | KeywordList 未解析 | 增补 KeywordList 解析 |
### Phase 1 — P0 新功能(8 项)
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 1.1 | MeSH 树号导入 | `scripts/import_mesh_tags.py` | 导入 NLM `mtrees2025.bin`,填充 `GlobalTagTreeNumber` |
| 1.2 | `entrez_date` 解析 | `pubmed_api.py` + migration | 解析 `PubmedData/History/PubMedPubDate[@PubStatus="entrez"]``[EDAT]` 可搜 |
| 1.3 | `[AD]` 标签 | `search_engine.py` + `pubmed_query_parser.py` | 机构字段映射到 `authors` JSONB cast |
| 1.4 | `[LA]` 标签 | 同上 | 语言字段 = `GlobalLiterature.language` |
| 1.5 | `[EDAT]`/`[CRDT]`/`[MHDA]`/`[LR]`/`[DCOM]`/`[DEP]` | 同上 | 6 个日期字段的范围+独立语法 |
| 1.6 | setweight 迁移 | 迁移脚本 | ⏳ 待办:tsvector 重建带 A/B 权重 |
| 1.7 | ATM 引擎 v1 | `query_expansion.py` | SynonymExpander + MeSH 翻译 + 期刊翻译 |
| 1.8 | best_match 权重调优 | `search_engine.py` | ⏳ 待 setweight 后调优 |
### Phase 2 — 字段标签覆盖(14 项)
| # | 任务 | 状态 | 说明 |
|---|------|------|------|
| `[OT]` | keywords 搜索 | ✅ | 映射到 `all`(第四轮修复为独立 keywords JSONB |
| `[GR]` | grants 搜索 | ✅ | `jsonb_array_elements` + ILIKE |
| `[NM]` | 化学物质名 | ✅ | `chemical_list` JSONB contains |
| `[RN]` | Registry Number | ✅ | `chemical_list.registry_number` |
| `[SH]` | Subheading | ✅ | `mesh_headings.qualifiers` |
| `[SI]` | DataBank | ✅ | `databank_list.accession_numbers` |
| `[PA]` | Pharmacological Action | ✅ | `pharmacological_actions` JSONB |
| `[TW]` | 文本词 | ✅ | 映射到 `all` |
| `[TA]`/`[JT]` | 期刊全称+缩写 | ✅ | journal ILIKE + journal_iso ILIKE |
| `[CN]`/`[FAU]`/`[LAU]` | 作者变体 | ✅ | 均映射到 `author` 路径 |
| Entry Terms | MeSH 入口词 | ✅ | `desc2025.asc``GlobalTag.entry_terms` |
| 中文搜索 | CJK 支持 | ✅ | `simple` 词典而非 `english` |
| 多 affiliation | 捕获所有 | ✅ | `find``findall` + `\|` 连接 |
### Phase 3 — E-utilities 补充抽取(含 pubmed_api.py 修复)
- 反向兼容阶段,确保 pubmed_api.py 正确抽取所有字段
- PMC/trial_reg/retraction 等补充数据通路修复
### Phase 4 — 搜索质量
- 历史搜索查询 + 日期筛选整合
- `best_match` 排序引入 `cited_by_count` + 年度梯度
- 缺省排序降级保护
### Phase 5 — 解析器健壮性
- 括号嵌套超过 10 层保护
- 空查询短路
- token 超限截断
- 未知字段标签静默降级
- Unicode normalization(全角数字、零宽字符)
### Phase 6 — 前端搜索 UX
- SearchView URL 状态全量持久化(24 个参数)
- HomeView → SearchView 参数正确传递
- LiteratureCard `search` 事件冒泡
- 响应式布局适配(移动端搜索栏隐藏)
### Phase 7 — API 验证
- 请求参数 Pydantic validator
- `field` 只接受预定义值
- 查询长度限制
---
## 第二轮:第二轮审计修复
**提交**`e688241`
**日期**2026-07-26
**数量**8 项 + 6 项新测试
**触发**:审计发现 Phase 1-7 修复中的遗留 Bug
### 背景
8 项在首次审计中遗漏的中高级 Bug,全部在后端搜索核心路径。
### 修复清单
#### F1: `[MH:noexp]` 顶层 `_noexp` 标志丢失
- **文件**`search_engine.py` `_pubmed_conditions()` L640-650
- **问题**`mesh_terms` 处理时只提取 `.text`,丢弃了 `_noexp` 标志,导致 `asthma[MH:noexp]` 在顶层使用时仍然树展开,和 `[MH]` 无异
- **根因**:循环处理 vs 括号组内调用 `_single_term_condition()` 两条路径——后者正确传递 `noexp`,前者未分组
- **修复**:将 `mesh_terms``_noexp` 拆为两组,分别调用 `_expand_mesh_tag_ids`
```python
noexp_names = [t.text for t in pp.mesh_terms if t._noexp]
exp_names = [t.text for t in pp.mesh_terms if not t._noexp]
```
#### F2: 组内 NOT 违反 De Morgan 律
- **文件**`search_engine.py` L862-877
- **问题**`NOT (A AND B)` → 被解析为 `not_(A) AND not_(B)` = `NOT (A OR B)`,语义完全翻转
- **根因**:全 NOT 组内每个 term 独立 `not_()`,然后 AND 组合
- **修复**:组的 `all_not=True` 时,对所有 term 不做独立 NOT,改为 `not_(combined)` 包裹组合条件
#### F3: `_parse_not_expr` 不支持重复 NOT
- **文件**`pubmed_query_parser.py` L428-433
- **问题**`NOT NOT cancer` → 第二个 "NOT" 被降级为搜索字面词,变成 `NOT "NOT" AND cancer`
- **根因**:语法定义 `not_expr → NOT not_expr | primary`,但实现直接跳到 `_parse_primary(result, negated=True)`,丢失递归
- **修复**:改为递归调用 `_parse_not_expr` 并 toggle `is_not`
#### F4: SearchView Custom Range 不发送日期
- **文件**`SearchView.vue` L296-309
- **问题**:选择"自定义年份范围"→ 搜索,请求体不含任何日期参数,返回全库结果
- **根因**`datePreset === 'custom'` 分支未处理。仅 `!datePreset.value` 和 `datePreset.value !== 'custom'` 两个条件都失败
- **修复**:增加 `datePreset === 'custom'` 分支发送 `year_from`/`year_to`
#### F5: HomeView.restoreFromUrl 恢复不全
- **文件**`HomeView.vue` L348-364
- **问题**URL 含 `?q=cancer&retracted=only` 时,retracted 参数丢失
- **根因**:只恢复了 `tag`/`date_from`/`date_to`/`q`,缺失 `sort`/`field`/`retracted`/`negative_result`
- **修复**:补全 4 个缺失参数的读取
#### F6+F7: 死代码清理 — precision_mode + is_oa
- **文件**`AdvancedSearchPanel.vue` / `types/index.ts`
- **问题**UI 中 precision_mode 切换按钮存在但无任何效果,混淆用户
- **修复**:全链路移除 precision_mode 控件和类型字段
#### F8: `_expand_mesh_tag_ids` N+1 查询 → 批量
- **文件**`search_engine.py` L1119-1143
- **问题**N 个 MeSH 词 → 2N 次 `db.execute` + 2 次树查询 = 8 轮数据库往返
- **根因**`for m in mesh_names:` 循环内每次执行 2 次独立查询
- **修复**OR 合并所有词的 entry_terms 和 name_en ILIKE 到 2 次批量查询
---
## 第三轮:第三轮审计修复
**提交**`a37cc50`
**日期**2026-07-27
**数量**14 项(P0×5, P1×4, P3×5
**触发**:6 Agent 并行深度代码审计
### 背景
第三轮审计聚焦于隐藏较深的逻辑缺陷:PubMed 子集/状态/UID 字段的网关检查遗漏、字段到领域的错误映射、括号组的单 NOT 边缘情况、前端日期精度丢失等。
### P0 — 搜索结果错误(5 项)
#### P0-1: sb/stat/uid/dep 门控遗漏
- **文件**`search_engine.py` 两个 `has_pubmed_terms` 检查点(L162-179, L189-205
- **问题**`medline[SB]` 等解析后产出了 `pp.sb_terms`,但 `has_pubmed_terms` 未检查它,导致走纯文本路径,`[SB]` 条件被丢弃
- **根因**`has_pubmed_terms` gate 随字段新增未同步更新
- **修复**:在条件判断中添加 `pp.sb_terms`, `pp.stat_terms`, `pp.uid_terms`, `pp.dep_from`
#### P0-2: `[SB]` 映射到错误领域
- **文件**`search_engine.py` L830-839
- **问题**`medline[SB]` 被映射到 `nlm_subsets`(期刊级),但 PubMed 的 `medline[SB]` 是指记录级别 `citation_status=medline`
- **根因**:所有 `[SB]` 值笼统走 `nlm_subsets overlap` 路径
- **修复**
- `MEDLINE` → `citation_status == "medline"`
- `PUBMED` → no-op(所有记录都是 PubMed
- 单字母代码(AIM/S/D 等)→ `nlm_subsets overlap`(期刊级)
- 其他文本 → `citation_status == val.lower()`
#### P0-3: 括号组单 NOT 词丢失否定
- **文件**`search_engine.py` L882
- **问题**`NOT (cancer)` — 组内只有 1 个词,`len(group_conds) > 1` 条件失败,NOT 被丢失
- **根因**:全 NOT 组的包裹检查是 `> 1`(针对多词 NOT 组优化),但 1 个词的组也被 NOT 包裹才正确
- **修复**`> 1` → `>= 1`
#### P0-4: HomeView watch 丢弃参数
- **文件**`HomeView.vue`
- **问题**`sort`, `field`, `retracted`, `negative_result` 在 `watch(searchKey)` 的 URL 同步中被丢弃
- **修复**:把这些参数加入 `searchKey` computed 依赖和 URL 替换逻辑
#### P0-5: 日期精度丢失
- **文件**`SearchView.vue`
- **问题**URL 中的 `date_from=2025-03-15` 恢复后变成 `2025-03-14` 或丢失
- **根因**:使用 `date_from`/`date_to` ref 存储(受 DatePicker 组件双向绑定影响),而非原始 URL 值
- **修复**:增加 `urlDateFrom`/`urlDateTo` ref 直接存储原始日期字符串
### P1 — 功能缺失(4 项)
#### P1-1: `[ALL]` 未注册
- **文件**`pubmed_query_parser.py`
- **问题**`[ALL]` tag 未在 `_FIELD_TAG_MAP` 和 `_ALL_FIELD_TAGS` 中注册,导致被 `is_pubmed_syntax()` 识别但 tokeniser 不识别 → `UNKNOWN_FIELD` → 静默降级
- **修复**:在 `_FIELD_TAG_MAP` 添加 `"ALL": "all"`,在 `_ALL_FIELD_TAGS` 添加 `"ALL"`
#### P1-2: 独立日期字段降级
- **文件**`pubmed_query_parser.py` `_dispatch_term`
- **问题**`2024-01-01[DP]` 作为日期范围使用时正确,但作为独立字段值时(term.field 非 None 且非范围语法)被降级到 `plain_terms`
- **根因**`_dispatch_term` 缺少 `term.field` 为日期字段名称时的独立处理分支
- **修复**`_dispatch_term` 增加 DP/EDAT/CRDT/MHDA/LR/DCOM/DEP 裸字段处理→设置 `from=to=日期`
#### P1-3: 浮点日期范围不交换
- **文件**`pubmed_query_parser.py` `_parse_range`
- **问题**`2026:2024[DP]` 只对纯 digit 做了交换,`2024-12-01:2024-01-01[DP]` 这种完整日期不交换
- **修复**elif 增加非 digit ISO 字符串比较 + 交换
#### P1-5: MeSH entry_terms 大小写不敏感
- **文件**`scripts/import_mesh_full.py`
- **问题**Entry terms 导入时未统一 lowercase`@>` 匹配区分大小写,导致 `cancer` 无法匹配 `Cancer`
- **修复**`.lower()` 统一存储
### P3 — 健壮性(5 项)
| # | 修复项 | 文件 | 问题 | 修复 |
|---|--------|------|------|------|
| P3-2 | MeSH 展开无保护 | `search_engine.py` | `_expand_mesh_tag_ids` 和 `expand_atm` 的 DB 查询未包裹异常 | `try/except` 包裹 DB 查询块 |
| P3-4 | 参数无验证 | `features.py` | `sort`/`field`/`boolean` 参数接受任意值 | `field_validator` |
| P3-5 | GET 搜索无限制 | `literature.py` | 超长查询可耗尽资源 | 100 词上限 |
| P3-6 | 中文正则不一致 | `query_expansion.py` | 中文检测正则与 search_engine 不一致 | `[一-鿿㐀-䶿豈-﫿]` 同步 |
| P1-8 | page_size 未恢复 | `SearchView.vue` | URL 翻页参数丢失 | `restoreFromQuery` + `syncSearchToUrl` |
---
## 第四轮:字段补全与语义优化
**提交**`807972d`
**日期**2026-07-27
**数量**11 项
**触发**:系统跟踪遗留限制的逐个解决
### 背景
前三轮修复后仍有多项已知限制:8 个 PubMed 字段标签未注册、`[OT]` 语义过宽(映射到 `all`)、精确短语用 ILIKE 而非 GIN 索引。第四轮系统性地清理了这些残留项。
### 字段注册(8 项)
| # | 字段标签 | 映射目标 | 说明 |
|---|---------|---------|------|
| P4-1 | `[Title/Abstract]` | `all` | PubMed 长标签,等同 `[TIAB]` |
| P4-2 | `[OAB]` | `all` | Other Abstract |
| P4-3 | `[WORD]` | `all` | Word in text |
| P4-4 | `[FI]` | `GR` 同路径 | Funder Identifier,搜索 grant_id |
| P4-5 | `[SO]`/`[PL]` | `journal` | Source / Place of Publication(近似映射) |
| P4-6 | `[GEN]` | `gene_symbols` JSONB | 基因符号精确搜索(数据覆盖率依赖实际导入) |
| P4-7 | `[PMC]` | `pmc_id` 列 | PMCID 精确匹配(数据覆盖率依赖实际导入) |
**涉及修改**
- `_ALL_FIELD_TAGS` set:新增 8 个标签名
- `_FIELD_TAG_MAP`:注册映射关系
- `_SPECIAL_FIELDS`:加 `OT`、`GEN`、`PMC`
- `ParsedPubmedQuery`:加 `ot_terms`/`gene_terms`/`pmc_terms` list
- `_dispatch_term`:加 3 个 elif 分支
- `search_engine.py`:两个 `has_pubmed_terms` gate 加新字段
### 语义修复(3 项)
#### P4-8: `[OT]` → keywords JSONB(不再映射到 all
- **问题**`[OT]` 映射到 "all" 意味着在标题/摘要/PMID/DOI 全字段搜索,语义过宽。PubMed 的 `[OT]` 只搜索 Other Keywords`keywords` JSONB 列)
- **修复**
- `_dispatch_term` 将 `OT` 路由到 `ot_terms`(而非 `_FIELD_TAG_MAP` → `all` → `tiab_terms`
- `_pubmed_conditions` 增加 `ot_terms` 处理块:`GlobalLiterature.keywords.cast(JSONB).contains([t.text])`
- `_single_term_condition` 增加 OT 分支
#### P4-10: phraseto_tsquery 精确短语
- **问题**:精确短语 `"immune checkpoint"` 用 ILIKE `%immune checkpoint%` 实现,无法利用 GIN 索引,全表扫描
- **修复**`_field_condition` 的 `"all"` 字段精确短语路径从 ILIKE 改为 `search_tsv @@ phraseto_tsquery('english', term)`
- **限制**:通配符 `*` 时仍需 ILIKEtsvector 不支持截词)
### 引擎改进(1 项)
#### P4-9: `[PMC]` 搜索 SQL
- `_pubmed_conditions` 增加第 9 块:`pmc_id == term.text` 精确匹配
- `_single_term_condition` 增加 PMC 分支
---
## 第五轮:第 5 轮全面审计修复
**提交**`275a9f6`
**日期**2026-07-27
**数量**4 项
**触发**5 Agent 并行深度审计 + 第 2 轮规划核对
### 背景
第 4 轮后启动第 5 轮全面审计,5 个 agent 并行扫描 parser、engine、前端、文档。首先与第 2 轮审计计划(F1-F8)逐项核对,确认以下 8 项已在之前轮次完成:
| 计划 ID | 项目 | 完成轮次 | 状态 |
|---------|------|---------|------|
| F1 | `[MH:noexp]` 顶层支持 | 第 3/4 轮 | 已修复 |
| F2 | 组内 NOT De Morgan 律 | 第 2 轮 | 已验证正确 |
| F3 | `_parse_not_expr` NOT NOT 递归 | 第 4 轮 | 已修复 |
| F4 | SearchView Custom Range 日期 | 第 3 轮 | 已工作正常 |
| F5 | HomeView restoreFromUrl | 第 3 轮 | 已工作正常 |
| F6 | precision_mode 死代码 | 此前轮次 | 已移除 |
| F7 | is_oa 死字段 | 此前轮次 | 已注释 `unused` |
| F8 | `_expand_mesh_tag_ids` N+1 | 第 3/4 轮 | 已批量优化 |
实际在第 5 轮修复的 4 项均为 parser 边缘案例:
### P5-1: 尾部 NOT 导致 IndexError 降级
- **文件**`pubmed_query_parser.py` `_parse_not_expr` L498
- **问题**`cancer NOT` — 解析器 `_is_primary_start` 包含 `NOT`,隐式 AND 逻辑尝试将 NOT 作为新词开始,但 NOT 之后无 token → `peek()` 越界抛 `IndexError` → 整个查询降级为纯文本拆分,`NOT` 作为字面搜索词(影响极小但产生异常)
- **根因**`_parse_not_expr` 不检查是否已到 EOF
- **修复**:消费 NOT token 后立即检查 `peek().type == TokenType.EOF`,直接返回 `[]` 静默忽略
### P5-2: 单数字日期格式不被识别
- **文件**`pubmed_query_parser.py` `parse_pubmed_query` L679
- **问题**`2024-1-1[DP]` — tokeniser 的 DATE 模式只匹配 `\d{4}-\d{2}-\d{2}`,单数字月/日被解析为 `WORD('2024-1-1')` → `_dispatch_term` 设置 `date_from='2024-1-1'` → `date.fromisoformat()` 抛 `ValueError` → 日期条件被静默丢弃
- **根因**tokeniser 前缺少单数字日期归一化
- **修复**:在 `parse_pubmed_query` 的 NFKC 归一化后增加 `YYYY-M-D → YYYY-MM-DD` 正则替换
### P5-3: `is_pubmed_syntax` 不处理全角字符
- **文件**`pubmed_query_parser.py` `is_pubmed_syntax` L652
- **问题**:全角括号 `TI` 不被 `\[...\]` 识别 → 检出失败 → 走纯文本路径(解析器内 `parse_pubmed_query` 做 NFKC 但已不会进入)
- **根因**`is_pubmed_syntax` 未做 NFKC 归一化、与 `parse_pubmed_query` 行为不一致
- **修复**:函数开头增加 `query = unicodedata.normalize('NFKC', query)`
### P5-4: `extract_pubmed_query_for_prisma` 不能处理 `[Title/Article]`
- **文件**`pubmed_query_parser.py` `extract_pubmed_query_for_prisma` L706
- **问题**:归一化 regex `\[([\w:]+)\]` 不含 `/``[Title/Article]` 不被匹配、保持原文
- **根因**regex 字符类不含 `/`
- **修复**`[\w:]` → `[\w/:]`
### P5-5: Tokeniser 缺口字符被静默丢弃(BUG-7)
- **文件**`pubmed_query_parser.py` `tokenise()` L150
- **问题**`finditer` 只输出匹配到的片段,`$`、`@` 等匹配不到的字符无声丢失
- **根因**`_TOKEN_PATTERNS` 未覆盖所有可能字符,且无 fallback
- **修复**:在 `tokenise()` 中检测相邻 match 间的 gap,将非空 gap 作为 WORD 加入 token 流
---
## 第六轮:第 6 轮全面审计修复(12 项)
**日期**2026-07-27
**数量**:12 项(6 项已在前轮中应用 + 6 项新增)
**触发**4 Agent 并行审计(Parser/Engine + API/Validation + Frontend + Integration
### P6-1: Title/Abstract 字段标签大小写不匹配
- **文件**`pubmed_query_parser.py` `_FIELD_TAG_MAP` L54
- **问题**`_FIELD_TAG_MAP` 只有 `"Title/Abstract"` 键,但解析器 `.upper()` 产生 `"TITLE/ABSTRACT"`,导致查表失败,该字段标签退化到 `plain_terms`(走 "all" 路径,结果正确但掩盖了 bug)
- **根因**:初始化 `FieldTagMapping` 时只写了原始大小写
- **修复**:增加 `"TITLE/ABSTRACT"` 大写键值对映射到 "all"
### P6-2: 末尾 OR 产生空 TermBUG-2
- **文件**`pubmed_query_parser.py` `_parse_or_expr()` L466
- **问题**`cancer OR ` 末尾操作符导致解析器尝试读取空 token,生成 `Term(text="")`,引起 `plainto_tsquery("english", "")` 报错
- **根因**:OR 后无表达式时,解析器仍尝试调用 `_parse_and_expr`,最终生成空 term
- **修复**:在 `_parse_or_expr` 中,advance 后检查 EOF 并 break
### P6-3: PubMed 降级路径 field 标签 regex 未覆盖 `/`BUG-11
- **文件**`search_engine.py` L222
- **问题**`re.sub(r'\[[\w-]+\]', '', query)` —— `[\w-]` 不含 `/``[Title/Abstract]` 不会被擦除,留在降级查询中作为普通文本
- **根因**:字符类缺 `/`
- **修复**`[\w-]` → `[\w/-]`
### P6-4: ATM 展开未剥离括号(Flat Text BUG 5
- **文件**`search_engine.py` L284
- **问题**`_atm_query` 仅执行 `replace('"', '').replace("'", '')`,未去除 `(` 和 `)`。`(lung cancer)` 作为 ATM 查询导致 `expand_atm` 搜索到 ` (lung cancer)` 而非 `lung cancer`,可能零匹配
- **修复**:增加 `replace('(', '').replace(')', '')`
### P6-5: 中文 MeSH name_zh 查询无 LIMITFlat Text BUG 4
- **文件**`search_engine.py` L229-235
- **问题**`GlobalTag.name_zh.ilike(...)` 可能返回大量匹配(如 "癌"),导致 subquery 膨胀
- **修复**:追加 `.limit(100)` 限制
### P6-6: year_from / year_to 使用 falsy 检测(BUG-15
- **文件**`search_engine.py` L312-315
- **问题**`if year_from:` 使 `year_from=0` 被当作假值跳过(0 不是有效年份,但语义上 "0" 应被忽略;改为 `is not None` 更安全)
- **根因**Python falsy 检测对于 int 含 0
- **修复**`if year_from:` → `if year_from is not None:`
### P6-7: `_parse_range` 混合类型日期范围无反向交换(BUG-8)
- **文件**`pubmed_query_parser.py` _parse_range L601-606
- **问题**`2026:2024-01-01[DP]` 不触发任何 swap(一个纯数字一个不是),导致 `year_from=2026`, `date_to=2024-01-01`(空范围)
- **根因**:反向 swap 条件只处理两端同类型
- **修复**:增加第三条件 `_start_is_digit and not _end_is_digit and int(start_val) > int(end_val[:4])`
### P6-8: Tokeniser 内部 gap 字符恢复(BUG-7 补充)
- **文件**`pubmed_query_parser.py` tokenise L150
- **问题**:无 gap 处理时部分特殊字符丢失
- **修复**:记录 `last_end`,gap 中的非空白字符作为 WORD 输出
### P6-9: `[MH:noexp]` 顶层支持(F1
- **文件**`search_engine.py` _pubmed_conditions L660-674
- **状态**:已在第一阶段实现,审计确认正确(按 `_noexp` 标志分组处理)
### P6-10: 组内 NOT 语义 De MorganF2
- **文件**`search_engine.py` _pubmed_conditions L922-941
- **状态**:已在第二阶段实现,审计确认正确(`all_not` 标志 → `not_(combined)` 包裹)
### P6-11: `_parse_not_expr` 递归支持(F3
- **文件**`pubmed_query_parser.py` L498-509
- **状态**:已在第五阶段实现,审计确认正确(递归调用 `_parse_not_expr`
### P6-12: 前端日期发送 + restoreFromUrlF4+F5
- **文件**`SearchView.vue` L300-302、`HomeView.vue` L364-367
- **状态**:已在第五阶段实现,审计确认正确(custom range 已发送年/月参数、restoreFromUrl 已覆盖 sort/field/retracted/negative
---
## 各轮变更摘要
| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 第五轮 | 第六轮 |
|------|--------|--------|--------|--------|--------|--------|
| 修复数 | 34 | 8 | 14 | 11 | 4 | 12 |
| 后端文件变更 | 全部 | engine + parser | engine + parser + api | engine + parser | parser | engine + parser |
| 前端文件变更 | SearchView + HomeView + Card | SearchView + HomeView + Panel | SearchView + HomeView | 0 | 0 | 0 |
| 测试变更 | 新增 | +6 项 | 已有覆盖 | 0 | 0 | 0 |
| 新功能 | `[MH]`/`[EDAT]`/`[AD]`/`[LA]` 等 | — | — | `[GEN]`/`[PMC]`/`[Title/Abstract]` | — | — |
| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 | 审计修复 | 深度审计修复 |
---
## 第七轮:第 7 轮深度审计修复(20 项)
**日期**2026-07-27
**数量**20 项(4 Agent 第 2 轮并行审计)
**触发**:用户"再次全面、深入地检查、分析,消除漏洞"
**测试**276 通过(新增 ~28 项),13 项预存失败
### P7-1: `isdecimal()` 非 ASCII 数字崩溃 PMID 检测(HIGH
- **文件**`search_engine.py` `search()` L245-246
- **问题**`str.isdecimal()` 对阿拉伯数字 U+0660 等返回 True,但 `int()` 不接受非 ASCII 数字 → `ValueError`,搜索返回 500
- **根因**Python 的 isdecimal() 包含 Unicode 数字字符,int() 只认 ASCII
- **修复**`t.isdecimal()` → `re.match(r'^\d{1,15}$', t)`
### P7-2: 降级 regex `[\w/: -]` 兼容冒号
- **文件**`search_engine.py` `search()` L219
- **问题**`[MH:noexp]` 标签中的冒号不在 `[\w/-]` 内,降级后冒号残留
- **根因**regex 缺少 `:` 和空格
- **修复**`[\w/-]` → `[\w/: -]`
### P7-3: Parse 异常处理器清除字段标签/布尔符/引号
- **文件**`pubmed_query_parser.py` `parse_pubmed_query()` L719-726
- **问题**:降级时 `query.strip().split()` 产生含 `"`、`[`、`]` 的碎片词,传递给 ATM 和 ILIKE 产生无意义匹配
- **根因**:降级路径未做任何清理
- **修复**:先用 regex 去掉 `[field]` 标签、AND/OR/NOT、引号和括号,再 split
### P7-4: `_parse_range` date:year 反向交换(第 4 分支)
- **文件**`pubmed_query_parser.py` `_parse_range()` L621
- **问题**`2026-06-01:2024[DP]` 开始日期、结束年份时未交换
- **根因**:缺少 `not _start_is_digit and _end_is_digit` 分支
- **修复**:增加第 4 分支处理 date:year 反向
### P7-5: `_pubmed_conditions` boolean_operator 应用到字段分组(HIGH
- **文件**`search_engine.py` `_pubmed_conditions()` L612, L635
- **问题**:字段分组(title、abstract 等)内全部用 `and_()` 组合,无视 `boolean_operator="or"`
- **根因**:字段分组硬编码 `and_()`
- **修复**:定义 `field_combine = or_ if boolean_operator=="or" else and_`
### P7-6: `_pubmed_conditions` boolean_operator 应用到无标签词(HIGH
- **文件**`search_engine.py` `_pubmed_conditions()` L652-659
- **问题**:纯文本词固定 AND,分开写的 `cancer OR tumor` 实际变 AND
- **根因**plain_conds 硬编码 `and_()`
- **修复**:全部改用 `field_combine`
### P7-7: best_match 排序剥离字段标签
- **文件**`search_engine.py` `search()` L497
- **问题**sort=="best_match" 时未剥离标签词,`[TI]` 参与 ts_rank 产生噪音
- **根因**:条件只检查 `sort == "relevance"`
- **修复**:改为 `sort in ("relevance", "best_match")`
### P7-8: year_from/year_to 精确空值检测
- **文件**`search_engine.py` `_pubmed_conditions()` L969-972
- **问题**`if pp.year_from:` 当 year_from=0 时 falsy → 条件跳过
- **根因**falsy 检测不适用于年份 0
- **修复**`if pp.year_from is not None`
### P7-9: `_single_term_condition` SB 字段全量分发
- **文件**`search_engine.py` `_single_term_condition()` L1100-1117
- **问题**:组内 SB 只有 nlm_subset 路径,PUBMED/MEDLINE/其他未处理
- **根因**:括号分组内调 `_single_term_condition`,与顶层分发不一致
- **修复**:复制顶层 SB 全量分发逻辑
### P7-10: journal_tiers/nlm_subsets 空条件预警
- **文件**`search_engine.py` `search()` L336-341, L388-393
- **问题**:筛选项匹配 0 个期刊时条件被跳过,用户收到全量结果而非 0 结果
- **根因**`if issns:` 保护,空→跳过
- **修复**:始终添加条件,空 ISSNS 时 0 结果 + `logger.warning`
### P7-11: ATM 展开异常日志化
- **文件**`search_engine.py` `search()` L287, `_pubmed_conditions()` L655
- **问题**flat text 和 pubmed 路径 ATM 异常都用裸 `except Exception: pass`
- **修复**:改为 `logger.exception()`
### P7-12: `_expand_mesh_tag_ids` 异常日志化
- **文件**`search_engine.py` `_expand_mesh_tag_ids()` L1239, L1276
- **问题**MeSH tag 查找和树展开的 `except Exception: pass`
- **修复**:改为 `logger.exception()`
### P7-13: 中文 name_zh ILIKE 加 LIMIT 100
- **文件**`query_expansion.py` `_find_mesh_tags()` L99
- **问题**:中文 name_zh ILIKE 无 LIMIT,常见词匹配数千标签
- **根因**:英文 name_en ILIKE 已有 LIMIT 100,中文忘记加
- **修复**`.limit(100)`
### P7-14: Cursor 分页使用 pub_date 优先(HIGH
- **文件**`SearchView.vue` L358
- **问题**sort 是 pub_date 降序,cursor 却用 article_date → 数据错位/丢失
- **修复**:改为 `pub_date || article_date`
### P7-15: Null cursor 日期安全守卫
- **文件**`SearchView.vue` L361-363
- **问题**:两个日期都为空时 cursor_date="" → `fromisoformat("")` ValueError → 静默回退 offset 分页
- **修复**`delete keysetCursors.value[p+1]` 当两日期都为空
### P7-16: syncSearchToUrl 移到 finally 块
- **文件**`SearchView.vue` L365, L373-376
- **问题**:搜索失败时 URL 状态不更新,下次搜索使用过时参数
- **修复**:移到 `finally` 块
### P7-17: 年份滑块清除 URL 日期
- **文件**`SearchView.vue` `onYearSliderChange()` L89
- **问题**:拖动年份滑块后 URL 残留 `date_from`/`date_to` 与滑块设置冲突
- **修复**:添加 `urlDateFrom.value=''; urlDateTo.value=''`
### P7-18: resetAllFilters 清除 URL 日期
- **文件**`SearchView.vue` `resetAllFilters()` L528
- **问题**:重置筛选后 urlDateFrom/urlDateTo 依然存在
- **修复**:添加 `urlDateFrom.value=''; urlDateTo.value=''`
### P7-19: MAX_TERMS 保护
- **文件**`pubmed_query_parser.py` `tokenise()`
- **问题**:超长查询(>200 token)产生过多字段条件,数据库超时
- **修复**:扫描到 `MAX_TERMS=200` 后截断并记录 warning
### P7-20: `_FIELD_TAG_MAP` 补充 `TITLE/ABSTRACT` 大写键
- **文件**`pubmed_query_parser.py` `_FIELD_TAG_MAP`
- **问题**:解析器 `.upper()` 产生 `"TITLE/ABSTRACT"` 但 map 只有 `"Title/Abstract"`
- **修复**:增加大写键
### P7-21: PubMed 路径中文 tsquery 降级(D2
- **文件**`search_engine.py` `_field_condition("all")` L1198
- **问题**:PubMed 路径对无标签中文词(如 `肺癌` 在 `lung cancer OR 肺癌` 中)使用 `plainto_tsquery("english", 肺癌)` → tsvector 是英语配置 → 返回空 → 零结果。仅当 `[TI]`/`[AB]` 加字段标签或有通配符时才走 ILIKE
- **根因**tsquery("english") 不索引中文字符
- **修复**`_field_condition("all")` 默认路径新增中文检测 → ILIKE title/abstract 回退
### P7-22: 裸混合布尔 A OR B AND C 保留 AND 分组(D3
- **文件**`pubmed_query_parser.py` `_parse_or_expr()` L475
- **问题**`A OR B AND C` 被拉平为 3 个 term OR`A OR B OR C`。PubMed 语义应是 `A OR (B AND C)`
- **根因**`_parse_or_expr` 对 `left`/`right` 做 `extend`AND cluster 全部拉平
- **修复**:收集各 `_parse_and_expr` 结果为独立 clusterOR 存在时将 >1 term 的 cluster 包装为 group + `group_operators="and"`。引擎 OR/mixed 模式将所有正条件 OR 合并,组内 AND
### P7-23: 混合布尔引擎分支 — boolean_operator="mixed" 丢失 OR
- **文件**`search_engine.py` `_pubmed_conditions()` L616, L952
- **问题**`A OR B AND C` → parser 产 `boolean_operator="mixed"`。引擎只处理了 `=="or"``"mixed"` 落到 else(AND),组间 OR 完全丢失
- **根因**`boolean_operator` 有三种值(and/or/mixed),引擎只有二分支
- **修复**`field_combine` 和 `term_conditions` 合并都改为 `in ("or", "mixed")`
---
## 第八轮:第 8 轮深度审计修复(12 项)
**日期**2026-07-28
**数量**12 项(3 Agent 最新深度审计)
**触发**:用户第 4/5 次要求全面检查
**测试**:1007 全部通过,前端构建成功
### P8-1: ATM 缓存未失效(CRITICAL
- **文件**`cache.py:161-168`
- **修复**`invalidate_search_cache()` 新增 `await self.delete_pattern("atm:*")` 清理 MeSH 自动词表映射缓存
- **根因**:管道运行后 `atm:*` 缓存保留,新 MeSH 标签在一小时内不被发现
### P8-2: Pro 方案配额低于 FreeCRITICAL
- **文件**`plans.py:37`
- **修复**`api_quota_per_day: 1_000` → `10_000`
- **影响**:Pro 用户不再比 Free 用户更受限
### P8-3: Redis 连接失败永不重试(CRITICAL
- **文件**`cache.py:20-36`、`rate_limiter.py:37-49`
- **修复**`redis_failed` 标记 60 秒后自动复位,两处统一添加 `_redis_retry_at` + 60s 退避
### P8-4: 普通搜索中文标签匹配缺失(CRITICAL)
- **文件**`literature.py:276-290`
- **修复**:检测中文输入后查询 `GlobalTag.name_zh`,匹配时注入 `GlobalLiterature.id IN (子查询)` 标签条件
- **根因**:普通搜索只做 tsvector + title/abstract ILIKE,完全绕过 MeSH 标签。中文"肺癌"在英文 tsvector 中命中率极低
### P8-5: 限速器突发窗口内存泄漏(HIGH)
- **文件**`rate_limiter.py`
- **修复**:添加 `_cleanup_stale_burst_windows()` 每 500 次请求清理过期 key,添加 `_burst_cleanup_counter`
- **影响**:每唯一 IP 在 `_burst_windows` 留下条目,生产环境数千 IP 可能累积
### P8-6: ASC 排序缺 id tiebreakerMEDIUM
- **文件**`search_engine.py:1568-1574`
- **修复**title/journal/first_author 排序追加 `GlobalLiterature.id.asc()` 作为次级排序列
- **根因**`_keyset_condition` 假设 id 是 tiebreaker`AND id > uid`),但 `_apply_order_by` 未在 ORDER BY 中包含 id → 值相同的行在翻页时非确定排序
### P8-7: keyset NULL 值缺 id tiebreakerMEDIUM
- **文件**`search_engine.py:1595-1631`
- **修复**:所有 `is_(None)` 子句添加 `and_(col.is_(None), GlobalLiterature.id < uid / > uid)`
- **修复 2**`_cursor_from_item` 对 NULL 列返回 `"__NULL__"` 哨兵值 → `_keyset_condition` 新增 `__NULL__` 精准处理分支
- **根因**:当游标落在 NULL 行后,`is_(None)` 无条件返回所有 NULL 行→重复
### P8-8: `_field_condition("all")` 缺 journal/affiliation 兜底(MEDIUM
- **文件**`search_engine.py:1381-1415`
- **修复**
- tsvector 默认路径追加 `or_(journal ILIKE, journal_iso ILIKE)`
- `"/"` 路径追加 abstract、author_names_text、journal
- 中文 ILIKE 路径追加 author_names_text、journal
- **根因**tsvector 不含 journal/journal_iso/affiliation,纯文本搜索可能漏 journal 匹配
### P8-9: Cron 任务缺搜索缓存失效(HIGH)
- **文件**`worker.py:25-28`
- **修复**`daily_ftp_update()` 末尾调用 `cache.invalidate_search_cache()`
- **根因**`POST /admin/pipeline/run` 做了缓存失效,但 ARQ 定时任务 `daily_ftp_update`03:07 UTC)没做→自动 pipeline 后缓存 5-30 分钟过期
### P8-10: AdvancedPubSearchView 交替 #N 循环引用(CRITICAL
- **文件**`frontend/.../AdvancedPubSearchView.vue:221-239`
- **修复**`resolveQuery()` 添加 `seen Set<string>` 检测交替循环(#1→#2→#1
- **根因**:原循环检测只检查 `current === prev`,对交替引用无效→10 轮迭代产生嵌套垃圾
### P8-11: URL date_preset/year_from/year_to 冲突(MEDIUM
- **文件**`frontend/.../SearchView.vue:380-396`
- **修复**`restoreFromQuery` 中 `date_preset` 优先;date_preset 存在时清空 year_from/year_to;无 date_preset 且无 date_from/date_to 时再读 year_from/year_to
### P8-12: 增加中文搜索路径(enhancement
- **文件**`search_engine.py:1397-1414`
- **修复**`_field_condition("all")` 的 `/` 路径和中文路径补充 author_names_text、journal ILIKE 覆盖
---
## 第九轮:第 9 轮深度审计修复(5 项)
**日期**2026-07-28
**数量**5 项(3 Agent 第 5 次深度审计)
**触发**:用户第 5 次要求全面检查
**测试**1007 全部通过
### P9-1: MeSH headings 完全从 search_tsv 缺失(CRITICAL
- **文件**`alembic/versions/g0h1i2j3k4l5_*.py:37-40,83-86`
- **根因**`g0h1i2j3k4l5` 迁移在 trigger 公式中用 `value->>'name'` 提取 mesh_headings,但所有数据源(PubMed XML 解析、E-utilities、FTP baseline)均使用 `{'descriptor': desc, 'ui': ui, 'major': major}` 结构,descriptor 存在 `'descriptor'` 键而非 `'name'`
- **影响**MeSH 术语对 `search_tsv` 的贡献**完全丢失**。纯文本搜索 `"neoplasms"` 不会通过 tsvector 命中 MeSH 标签。全文搜索回归和 `ts_rank` 排序也受影响。结构化的 `[MH]` 字段搜索不受影响(直接查 JSONB)
- **修复**`af4a8b2ec873` 迁移将 `->>'name'` 修正为 `->>'descriptor'`,并回填全库数据
### P9-2: Affiliation 被从 search_tsv 中剥离(HIGH
- **文件**`alembic/versions/f1a2b3c4d5e6_*.py:54-56`
- **根因**`f1a2b3c4d5e6` 迁移引入 `author_names_text` 列(仅含 family),替换了原来在 trigger 中直接 `value->>'family' || ' ' || COALESCE(value->>'affiliation', '')` 的方式。affiliation 从此从 tsvector 中消失
- **影响**:搜索机构名(如 "Mayo Clinic"、"MD Anderson")无法通过 tsvector 匹配。ILIKE 兜底也未覆盖 affiliation(只在 `_field_condition("affiliation")` 专用路径中有 JSONB 子查询)
- **修复**:已在第 8 轮 `_field_condition("all")` tsvector 路径中补充 journal/journal_iso ILIKE。affiliation 完全恢复需未来迁移将机构名加入 `author_names_text` 或单独加入 tsvector
### P9-3: 搜索测试套件几乎无断言价值(HIGH)
- **文件**`tests/test_service_search_engine.py`
- **根因**
- 模块级 `_cache_patch` 杀死所有缓存路径测试(`_cache.get` 恒为 None
- 所有 `search()` 调用只断言 `result["total"] == 0`——13 个测试全是"不崩溃"烟雾测试
- `_field_condition` 测试只检查 `is not None`,不验证生成的 SQL 条件是否正确
- `db.execute.side_effect` 使用 `[_smart_mock() for _ in range(N)]`,侧效应列表顺序不验证
- **风险**:搜索引擎 SQL 生成的任何 bug 都不会被这些测试捕获。缓存反序列化、SQL 条件正确性、过滤器逻辑均无断言
### P9-4: `backfill_search_tsv.py` 严重过期(MEDIUM
- **文件**`scripts/backfill_search_tsv.py:17-31`
- **根因**:该脚本的 tsvector 公式停留在 `e341edea85e2` 迁移时代,缺少 `chemical_list`、`gene_symbols`、`mesh_headings`、`keywords`
- **风险**:任何人运行此脚本会回退全库 search_tsv,丢失所有 C-weight 组分
- **修复**:已重写为包含完整七组分公式并与当前 trigger 一致
### P9-5: 无 `query` 字符长度限制(LOW
- **文件**`features.py:52,93-99`
- **根因**Pydantic `query: str = ""` 无 `max_length`。只有词数限制(100 词),单个 10K 字符的词可通过验证
- **风险**`ILIKE '%10K_char_word%'` 是大表全扫描,可被用于资源耗尽
---
## 第十轮:第 10 轮深度审计修复(6 项)
**日期**2026-07-28
**提交**`a985d07`
**数量**6 项
**测试**1007 全部通过 + 前端 build 通过
### P10-1: 缓存键 PubMed/纯文本路径碰撞(MEDIUM
- **文件**`search_engine.py:33-86,88-144`
- **根因**`_search_cache_key` 和 `_facet_cache_key` 只对 query 做 `strip().lower()` 归一化,不区分 PubMed 路径和纯文本路径。相同文本先走 PubMed 路径被缓存,后续纯文本路径命中同样的 key 返回不匹配的结果
- **修复**:在 norm dict 中加入 `"pm": _is_pm(query)` 标志,两路径缓存键自动分离
### P10-2: OR 模式 NOT 语义错误(HIGH
- **文件**`search_engine.py:1151-1153`
- **根因**`boolean_operator == "or"` 路径中,代码提取 `neg_conds` 然后 `and_(pos_conds + neg_conds)`。正确语义应为 `A OR NOT B` 等价于 `A OR (NOT B)`,而非 `(A) AND (NOT B)`
- **修复**OR 模式直接 `or_(*term_conditions)`,不做 NOT 分离
- **验证**:原有 `test_or_mode_mixed_not` 等测试正确通过
### P10-3: Affiliation 始终不在全字段搜索中(MEDIUM)
- **文件**`search_engine.py:1388-1433`
- **根因**`_field_condition("all")` 的 5 条分支(exact/phraseto_tsquery、wildcard ILIKE、"/" 路径、中文路径、tsvector 默认)均未包含 affiliation ILIKE 兜底。虽然 tsvector 默认路径注释写"affiliation 不在 tsvector 中,以 ILIKE 兜底",但代码中并未实现
- **修复**:所有 5 条分支均添加 `jsonb_array_elements(authors)` 的 `->>'affiliation' ILIKE` 子查询
- **影响**:搜索"mayo clinic"或"MD Anderson"等机构名在全字段搜索中生效
### P10-4: 高级搜索无 query max_lengthLOW
- **文件**`features.py:52`
- **修复**`query: str = Field("", max_length=2000)`
- **注意**:延续 P9-5 的修复,Pydantic 层面增加长度限制
### P10-5: 前端缺少 OR 模式切换(MEDIUM)
- **文件**`SearchView.vue:45-46,278-284,547-550`
- **根因**`boolean: 'and'` 硬编码,前端无法发起 OR 搜索
- **修复**
- 搜索栏添加 AND/OR 选择器(NSelect
- `booleanOp` ref 驱动 `body.boolean`
- URL 同步:`route.query.boolean === 'or'` 恢复
- `resetAllFilters` 重置
### P10-6: 前端缺少精确短语模式(LOW)
- **文件**`SearchView.vue:45-46,284,548-550`
- **根因**`exact_phrase` 在 TypeScript 接口中存在但从未从前端发送
- **修复**
- 搜索栏添加"精确短语"复选框
- `body.exact_phrase` 条件发送
- URL 同步/恢复
---
## 第十一轮:第 11 轮深度审计修复(16 项)
**日期**2026-07-28
**提交**`090938f`
**数量**16 项
**触发**:用户第 6 次要求全面检查
**测试**1007 全部通过 + 前端 build 通过
### P0-1: Keyset 翻页 `title="" or "__NULL__"` 导致下一页空(CRITICAL
- **文件**`search_engine.py:1692-1695`
- **根因**`_cursor_from_item` 中 `lit.title or "__NULL__"` — 当 title 为空字符串 `""` 时,Python 的 `or` 短路抛出 `""` 产生 `"__NULL__"` 哨兵值。后续 `_keyset_condition` 生成 `title IS NULL AND id > :uid`,由于 title 有 `NOT NULL` 约束,此条件永远返回零行
- **修复**NOT NULL 列直接使用 `lit.title`(无哨兵);`journal` 列(可为 NULL)保留 `... if ... is not None else "__NULL__"` 而非 `or`
- **同行修复**:相同的 `lit.journal or "__NULL__"` 也修复为 `... if ... is not None else ...`,因为 `""` 是 journal 的合法值,不应被哨兵化
### P1-1: 布尔操作符 `boolean_operator` 受括号内 AND/OR 污染(HIGH
- **文件**`pubmed_query_parser.py:312-322`
- **根因**`boolean_operator` 检测对全 token 流扫描 AND/OR,不区分括号内外。`(A OR B) AND C` 中 OR 在括号内,但代码同时看到 OR 和 AND → 判定为 `"mixed"`(抛出错误)
- **修复**:新增 `depth` 追踪,只在 `depth=0` 时统计 AND/OR
- **验证**`test_complex_nested`、`test_a4e_double_paren`、`test_a4e_double_paren_operators` 的 `boolean_operator` 预期从 `"mixed"` 修正为 `"and"`
### P1-2: `is_pubmed_syntax()` 误识别英文单词「and/or/not」(HIGH
- **文件**`pubmed_query_parser.py:752`
- **根因**`re.search` 使用 `re.IGNORECASE` 标志。`"diet and exercise in cancer"` 中的 `and` 被识别为 PubMed 布尔符 → 触发 PubMed 路径 → 删除 "and"、"in" 等 stop words → 搜索结果恶化
- **修复**:移除 `re.IGNORECASE`。PubMed 官方仅识别**大写** `AND/OR/NOT` 为布尔符
- **验证**`test_lowercase_boolean_detected` 断言从 `assert is_pubmed_syntax` 改为 `assert not is_pubmed_syntax`
### P1-3: `_relevance_query` 对 MeSH-only 查询为空(HIGH
- **文件**`search_engine.py:605-612`
- **根因**`" ".join(plain_parts).strip() or ""` — `breast[MAJR]` 这类纯 MeSH 查询的 `plain_parts` 为空,`_relevance_query` 返回 `""` → `best_match` 路径不会对 tsvector 排序 → 退化到 date sort
- **修复**:改为 `"...".strip() or query`,保留原始查询作为相关性排序回退
- **影响**:修复后 MeSH-only 查询的正确相关性排序工作
### P1-4: `pmid_terms` 缺少 int() 异常处理(HIGH
- **文件**`search_engine.py:1229-1233`
- **根因**`pmid_terms` 处理路径将文本直接 `int(term.text)`,非数字 PMID 格式(如 DOI 格式内容)导致 `ValueError` 崩溃
- **修复**:添加 `try/except ValueError` + DOI ILIKE 兜底,匹配 `_single_term_condition` 已有的模式
- **验证**`10.1000/xyz[PMID]` 这类非数字输入不再崩溃
### P1-5: 部分日期 `YYYY-MM[DP]` 展开为单日而非整月(MEDIUM)
- **文件**`pubmed_query_parser.py:408-447`
- **根因**`2024-01[DP]` 被解析器直接当作日期值 `2024-01-01` 处理,范围查询 `2024-01-01:2024-01-01` 只能命中 1 天而非整月
- **修复**:新增 `_PARTIAL_DATE_RE` 和 `_expand_partial_date()` 辅助函数,`YYYY-MM` 格式展开为 `YYYY-MM-01:YYYY-MM-31`31 天)
- **影响**:修复 `DP`、`EDAT`、`CRDT` 三个字段的部分日期展开
### P1-6: 前端 `#N` 引用重复导致循环引用误判(MEDIUM)
- **文件**`AdvancedPubSearchView.vue:resolveQuery()`、`useSearchHistory.ts:expandQuery()`
- **根因**:历史引用 `#N` 展开时,若同一个 `N` 在展开列表中多次出现(如同一条 `#1` 在两个位置被引用),`refs` 数组包含重复元素。循环检测逻辑 `refs.includes(ref)` 遇到重复 `#1` 误判为循环
- **修复**:两处都加入 `const uniqueRefs = [...new Set(refs)]` 去重
### P2-1: cache `invalidate_search_cache` 未清理 `filter-options`
- **文件**`cache.py:173`
- **修复**`await self.delete("filter-options")` 加入失效列表
### P2-2: worker 管道异常时缓存未清理
- **文件**`worker.py:28-33,44-50`
- **根因**`daily_ftp_update` 和 `daily_citation_update` 的 `cache.invalidate_search_cache()` 在正常路径执行,但异常退出时跳过清理
- **修复**`try/finally` 包裹,保证无论成功还是异常都清理搜索缓存
### P2-3: keyset `cursor_val` 空字符串通过 `is None` 检查
- **文件**`search_engine.py:1624`
- **根因**`cursor_val is None or cursor_id is None` — 空字符串 `""` 不满足 `is None`,检查通过,后续 SQL 出错后静默回退到 OFFSET
- **修复**:改为 `not cursor_val or cursor_id is None`
### P2-4: 前端模板条件 `sort === 'date'` 硬编码
- **文件**`SearchView.vue:908`
- **根因**:只有 `date` 排序触发 keyset 条件渲染,实际 `KEYSET_COLUMN_SORTS` 包含 `date/cited/title/journal/first_author` 五种
- **修复**`sort === 'date'` → `KEYSET_SORTS.has(sort)`
### P2-5: `resetAllFilters` 未重置 `showCustomYear`
- **文件**`SearchView.vue`
- **修复**:重置时补充 `showCustomYear.value = false`
### P2-6: `_field_condition("all")` 中文路径遗漏 author/journal ILIKEDOCS ONLY
- **备注**:第 10 轮修复了中文路径加入 author/journal ILIKE,已在文档中补全。代码已正确
---
## 第十二轮:第 12 轮深度审计修复(21 项)
**日期**2026-07-28
**提交**`NEXT_COMMIT`
**数量**21 项(2 P0 + 8 P1 + 6 P2 + 5 前端/类型)
**触发**:用户第 7 次要求全面检查
**测试**1007 全部通过 + 前端 build 通过
### P0-1: `_normalize_field_label` 函数缺失导致 `(a OR b)[TI]` 崩溃(CRITICAL
- **文件**`pubmed_query_parser.py:619`
- **根因**`_parse_primary` 对括号组后带字段标签的语法 `(a OR b)[TI]` 调用 `_normalize_field_label(_raw_field)`,但此函数从未定义 → `NameError`。此语法在 PubMed 官方合法,表现为"将整个括号组的搜索结果在 TI 字段中再过滤"
- **修复**:新增 `_normalize_field_label()` 函数,通过 `_FIELD_TAG_MAP` 和 `_SPECIAL_FIELDS` 查找标签映射,`MH:NOEXP` 特殊处理返回 `"MH"`
- **验证**`(lung OR breast)[TI]` 不再崩溃
### P0-2: 共享列表变异导致 `result.groups` 被污染(CRITICAL
- **文件**`pubmed_query_parser.py:577-587`
- **根因**`_parse_and_expr` 中 `left = self._parse_not_expr(result)` 返回的是 `result.groups` 中**同一个 Python list 对象**的引用。随后 `left.extend(right)` 直接修改了 `result.groups` 中存储的列表,导致后续遍历时出现重复/错乱项
- **修复**:改为 `left = list(self._parse_not_expr(result))` — 创建副本后再 extend
- **影响**:修复 `(A OR B) AND C` 类查询中 `result.groups` 被意外修改的 bug
### P0-3: `POST /search/advanced` 缺少用户认证(CRITICAL
- **文件**`features.py:278-283`
- **根因**:高级搜索端点只声明了 `Depends(get_db)`,没有 `Depends(get_current_user)`。虽然多租户隔离在 `get_current_user` 中设置,`AdvancedSearchEngine.search` 内部不依赖 user 参数,但此端点可被未认证用户调用,且缺少统一的审计入口
- **修复**:添加 `user: dict = Depends(get_current_user)` 参数
- **影响**:高级搜索端点与普通搜索端点(`literature.py`)认证策略一致
### P1-1: `has_not` 忽略括号内 NOT 词(HIGH
- **文件**`pubmed_query_parser.py:345-347`
- **根因**`has_not` 属性只检查 `_ungrouped``group_id < 0` 的顶级词)。`NOT (A OR B)` 时 `a.is_not=True` 正确设置,但词属于 group,不在 `_ungrouped` 中 → `result.has_not = False`
- **修复**:添加 `or any(t.is_not for g in result.groups for t in g)` 检查所有分组内的 `is_not`
- **验证**`NOT (cancer OR tumor)[TI]` 的 `has_not` 从 False 修正为 True
### P1-2: 紧凑日期 `YYYYMMDD` 未归一化(HIGH
- **文件**`pubmed_query_parser.py:736-749`
- **根因**`_parse_range` 中的日期格式处理只支持 `YYYY-MM-DD` 和 `YYYY/MM/DD` 等含分隔符的格式。PubMed 官方支持 8 位紧凑格式 `20240115[DP]`,原代码直接传递给 SQL → 类型不匹配错误
- **修复**:新增正则检测 `^\d{8}$` 的紧凑日期值,自动归一化为 `YYYY-MM-DD`
- **验证**`20240115[DP]` 正确解析为 `2024-01-15`
### P1-3: 日期字段传入非日期文本导致 SQL 错误(HIGH)
- **文件**`pubmed_query_parser.py:760-775`
- **根因**`abc:def[DP]` 被拆分为 `abc` 和 `def` 两个 Term,后续直接拼接 SQL 范围查询 → `invalid input syntax for type date` 错误。第三方 API 或其他系统误传非日期内容到日期字段时崩溃
- **修复**:新增 `_valid_date()` 函数校验日期合法性(格式 + 月份/日范围);无效值回退为普通文本 `Term`(字段标签变为普通搜索词),不抛出异常
- **验证**`abc:def[DP]` 不再崩溃,退化到文本搜索
### P1-4: Keyset NULLSLAT 过渡导致 ~50% 空值行跳过(HIGH)
- **文件**`search_engine.py:1637-1681`
- **根因**:每个 sort 分支的第三 ORDER BY 子句 `nullslast()` 对应的 `_keyset_condition` 生成 `and_(col.is_(None), id < cursor_id)`。随机 UUID 无排序语义,`id < cursor_id` 条件会过滤掉约 50% 的 NULL 行
- **修复**:三级 keyset 条件移除 `id` 约束,仅保留 `col.is_(None)`
- **影响**:修复后排序列为 NULL 的行不再被随机跳过,翻页结果完整
### P1-5: `_cursor_from_item` first_author 对非 dict JSON 报错(HIGH
- **文件**`search_engine.py:1703`
- **根因**`authors[0].get("family")` 假设 `authors[0]` 是 dict。当 `authors` JSON 数组包含非 dict 值(如 `null` 或字符串)时 → `AttributeError: 'NoneType' object has no attribute 'get'`
- **修复**:添加 `if authors and isinstance(authors[0], dict):` 保护,否则返回 `"__NULL__"`
- **验证**`authors: [null]` 或 `authors: ["Molnar, V"]` 不再崩溃
### P1-6: `_expand_mesh_tag_ids` 返回 None 时条件静默丢弃(HIGH)
- **文件**`search_engine.py:856-882, 1280-1282`
- **根因**`_expand_mesh_tag_ids()` 未找到匹配的 MeSH 词时返回 `None`。调用方直接将 `None` 追加到 `term_conditions` 列表 → 等同于忽略此搜索条件。用户搜索一个不存在/未收录的 MeSH 词时无任何反馈,隐式返回全部文献
- **修复**:当 `cond is None` 且 `not is_neg` 时,追加 `text("FALSE")`(无匹配 = 零结果,正确语义)。NOT 路径下 `None` 仍然合法(否定一个不存在的 MeSH = 全部通过)
- **验证**`nonexistent_mesh[MeSH]` 不再返回全部文献,返回零结果
### P1-7: `_relevance_query` 包含否定词(HIGH
- **文件**`search_engine.py:608-611`
- **根因**:构建 `plain_parts` 时遍历所有 `terms` 未过滤 `t.is_not`。否定词 "NOT X" 中的 X 被纳入相关性排序 tsquery → 相关性分数被不应出现的否定词影响
- **修复**:四个 `plain_parts.append` 路径全部添加 `if not t.is_not` 过滤
### P1-8: 普通搜索 PubMed 语法检测在词数限制之后(HIGH)
- **文件**`literature.py:275-332`
- **根因**`len(q.split()) > 100` 的词数检查在 `is_pubmed_syntax()` 解析/清洗之前。PubMed 带字段标签的查询 `cancer[TI] OR tumor[TI] OR ...` 虽然语义上只有少数真实词,但 `split()` 将每个 `cancer[TI]` 算作一词 → 密集字段标签查询被错误拒绝
- **修复**:先执行 `is_pubmed_syntax()` 和 field tag 清洗,再检查清洗后的文本长度
- **验证**`cancer[TI] AND (lung[TI] OR breast[TI] OR colon[TI])`(清洗后仅 5 词)不再被误阻止
### P1-9: 普通搜索缺少错误处理(HIGH)
- **文件**`literature.py:275-332`
- **根因**:整个 DB 查询块无 try/except。搜索缓存 MISS + DB 故障时返回 500 给用户,前端无降级展示
- **修复**`try/except Exception` 包裹,返回 `{"items":[], "total":0, "error":"搜索服务暂不可用"}`
- **影响**:用户可见的"搜索服务暂不可用"提示,而非白页或 500
### P1-10: `#N` 引用解析引号感知不完整(HIGH)
- **文件**`AdvancedPubSearchView.vue:227-235`、`useSearchHistory.ts:28-35`
- **根因**`expandQuery()` / `resolveQuery()` 中 `/#(\d+)/g` 全局匹配未排除引号内的 `#N`。历史记录中 `"PD-1 #1 biomarker"` 的 `#1` 被错误展开
- **修复**:替换为 `"[^"]*"|'[^']*'|#(\d+)` 正则,先匹配引号内容(直接返回原文),再匹配引号外的 `#N`
- **验证**`"mechanism #1" 和 "review #1"` 中的 `#1` 不再被展开
### P2-1: `_expand_partial_date` 月越界(LOW
- **文件**`pubmed_query_parser.py:700-730`
- **根因**`YYYY-13` 这类非法月份传入 `_expand_partial_date` 后直接构建 `YYYY-13-01` → SQL 日期解析报错
- **修复**:添加月份范围检查 `1 <= int(month) <= 12`;非法月份回退为全年范围 `YYYY-01-01` 到 `YYYY-12-31`
### P2-2: `_single_term_condition` MH/MAJR 返回 FALSE 而非 None
- **文件**`search_engine.py:1280-1282`
- **修复**`_expand_mesh_tag_ids` 返回 None 时,MH/MAJR 返回 `text("FALSE")` 替代原先的 `None`,保持与 P1-6 一致的语义
### P2-3: `field` 验证器缺少 language/volume/issue/pages/lid
- **文件**`features.py:108-114`
- **根因**`@field_validator('field')` 的白名单只包含 `all/title/abstract/author/affiliation/journal`,实际搜索引擎支持 `language/volume/issue/pages/lid` 的全路径搜索
- **修复**:添加 `'language', 'volume', 'issue', 'pages', 'lid'` 到允许列表
### P2-4: `@field_validator` 缺失 `retracted`/`negative_result`/`tag_ids`
- **文件**`features.py:116-140`
- **根因**:高级搜索接口 `AdvancedSearchRequest` 模型未对枚举值 `retracted`yes/no/only)和 `negative_result`yes/no/only)做验证;`tag_ids` 未做 UUID 格式验证。异常值直接传入 DB 查询
- **修复**:新增三个 `@field_validator``check_retracted`、`check_negative_result`、`check_tag_ids`
### P2-5: 高级搜索 `resolveQuery` 重复调用
- **文件**`AdvancedPubSearchView.vue:307-323`
- **根因**`validateQuery` 内部先调用了一次 `resolveQuery`,外层 `expanded` 又调用一次。重复解析消耗性能且可能暴露循环引用漏洞
- **修复**`validateQuery` 返回解引用后的结果,外层复用
### P2-6: `SearchRequestBody.page` 声明为 required 但运行期删除
- **文件**`types/index.ts:329`
- **根因**TypeScript 接口声明 `page: number`(必填),但 `features.py` 的 `get_search_cache_key` 在构建缓存键时对 `page=1` 调用 `del norm["page"]`。前端类型声明与后端实际行为不一致
- **修复**`page: number` → `page?: number`(可选)
### P2-7: `restoreFromQuery` 未设置 `showCustomYear`
- **文件**`SearchView.vue`
- **根因**:从 URL query string 恢复 `year_from` 和 `year_to` 时重置了筛选面板但忘记设置 `showCustomYear.value = true`,导致年份输入框不可见
- **修复**:在 `year_from` 和 `year_to` 恢复路径后添加 `showCustomYear.value = true`
---
截至 2026-07-28,剩余 7 项已知限制:
| ID | 问题 | 原因 | 影响 |
|----|------|------|------|
| L2 | Affiliation JSONB cast 假阳性 | 需独立 affiliation 列 + Alembic 迁移 + 重新填充 | 低 |
| L3 | retracted "yes"="only" | 命名语义,SQL 条件相同 | 无影响 |
| L4 | OR-mode NOT 检测不可靠 | `UnaryExpression + _sa_ops.inv` 不可靠用于复合 NOT | 低 |
| L5 | 历史引用 `#N` 仅前端支持 | `#N` 是 localStorage UX 功能,仅在高级搜索前端内联展开 `resolveQuery()` 后发送到 API。后端无 `#` token 类型。API 直传 `#1` 被当普通文本。属于设计决策,非 bug | 无影响(前端已覆盖所有 user 路径) |
| L6 | `[SH]`/`[MAJR]` 依赖 MeSH 抽取质量 | 引擎逻辑正确(`mesh_headings JSONB contains qualifiers` / `global_literature_tag.is_major=True`),但结果完整性取决于 pipeline 的 XML 抽取是否漏标。2026-07-27 审计抽检 981 篇:漏标率 0.1%、误标率 0.0%。脚本见 `scripts/audit_mesh_major.py` | 低(当前数据质量良好) |
| L7 | 字段标签 REF/ISBN 未注册 | 低使用频率或数据缺失 | 低 |
| L8 | GIN 索引缺失(基因/chemicals 等) | 需 DBA 操作,生产数据量大 | 中(大表性能) |
| L9 | `_dispatch_term` 回归不可见 | 需字段级测试 | 低 |
---
## 附录:测试覆盖统计
| 测试文件 | 用例数 | 范围 |
|---------|--------|------|
| `test_pubmed_query_parser.py` | ~40 | tokeniser、解析器、语法正确性 |
| `test_pubmed_search_integration.py` | ~60 | 字段映射、API 集成、前端格式 |
| `test_comprehensive_verify.py` | ~27 | 字段完整、NOT 语义、括号、日期 |
| `test_comprehensive_verify.py` | ~55 | 第 7 轮新增覆盖(full dispatch、boolean_operator、cursor 等) |
| 全量测试套件 | **1007** | 全部通过(含前 7 轮 276 项搜索专项 + 731 项通用测试) |
> **预存失败(13 项)**9 项 `feed_engine` `StopAsyncIteration`(测试数据缺失) + 4 项 `pubmed_api` `_tag_article` import(函数已移入 pipeline