2026-07-27 11:22:22 +08:00
# PubMed 搜索合规修复全记录
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
>
2026-07-28 13:21:24 +08:00
> **累计**: 14 轮,200 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
2026-07-28 12:59:38 +08:00
> **时间跨度**: 2026-07-24 ~ 2026-07-29
> **核心文件**: `pubmed_query_parser.py`( ~850 行)→ `search_engine.py`( ~1360 行)
2026-07-27 11:22:22 +08:00
---
## 目录
1. [第一轮:Phase 0-7 基础修复(34 项) ](#第一轮phase-0-7-基础修复 )
2. [第二轮:第二轮审计修复(8 项) ](#第二轮第二轮审计修复 )
3. [第三轮:第三轮审计修复(14 项) ](#第三轮第三轮审计修复 )
4. [第四轮:字段补全与语义优化(11 项) ](#第四轮字段补全与语义优化 )
2026-07-27 11:55:03 +08:00
5. [第五轮:第 5 轮全面审计修复(4 项) ](#第五轮第-5-轮全面审计修复 )
2026-07-27 12:47:09 +08:00
6. [第六轮:第 6 轮全面审计修复(12 项) ](#第六轮第-6-轮全面审计修复 )
2026-07-28 11:02:29 +08:00
7. [第七轮:第 7 轮深度审计修复(20 项) ](#第七轮第-7-轮深度审计修复 )
8. [第八轮:第 8 轮深度审计修复(12 项) ](#第八轮第-8-轮深度审计修复 )
2026-07-28 11:26:35 +08:00
9. [第九轮:第 9 轮深度审计修复(5 项) ](#第九轮第-9-轮深度审计修复 )
2026-07-28 11:41:38 +08:00
10. [第十轮:第 10 轮深度审计修复(6 项) ](#第十轮第-10-轮深度审计修复 )
2026-07-28 12:09:55 +08:00
11. [第十一轮:第 11 轮深度审计修复(16 项) ](#第十一轮第-11-轮深度审计修复 )
2026-07-28 12:59:38 +08:00
12. [第十二轮:第 12 轮深度审计修复(21 项) ](#第十二轮第-12-轮深度审计修复 )
13. [第十三轮:第 13 轮深度审计修复(21 项) ](#第十三轮第-13-轮深度审计修复 )
2026-07-28 13:21:24 +08:00
14. [第十四轮:第 14 轮深度审计修复(21 项) ](#第十四轮第-14-轮深度审计修复 )
15. [遗留限制 ](#遗留限制 )
2026-07-27 11:22:22 +08:00
---
## 第一轮: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)`
- **限制**:通配符 ` *` 时仍需 ILIKE( tsvector 不支持截词)
### 引擎改进(1 项)
#### P4-9: ` [PMC]` 搜索 SQL
- ` _pubmed_conditions` 增加第 9 块:` pmc_id == term.text` 精确匹配
- ` _single_term_condition` 增加 PMC 分支
---
2026-07-27 11:55:03 +08:00
## 第五轮:第 5 轮全面审计修复
2026-07-27 11:55:21 +08:00
**提交**: ` 275a9f6`
2026-07-27 11:55:03 +08:00
**日期**: 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/:]`
2026-07-27 12:47:09 +08:00
### 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 产生空 Term( BUG-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 查询无 LIMIT( Flat 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 Morgan( F2)
- **文件**: ` 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: 前端日期发送 + restoreFromUrl( F4+F5)
- **文件**: ` SearchView.vue` L300-302、` HomeView.vue` L364-367
- **状态**:已在第五阶段实现,审计确认正确(custom range 已发送年/月参数、restoreFromUrl 已覆盖 sort/field/retracted/negative)
2026-07-27 11:55:03 +08:00
---
2026-07-27 11:22:22 +08:00
## 各轮变更摘要
2026-07-27 12:47:09 +08:00
| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 第五轮 | 第六轮 |
|------|--------|--------|--------|--------|--------|--------|
| 修复数 | 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]` | — | — |
| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 | 审计修复 | 深度审计修复 |
2026-07-27 11:22:22 +08:00
---
2026-07-27 13:01:52 +08:00
## 第七轮:第 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"`
- **修复**:增加大写键
2026-07-27 14:23:34 +08:00
### 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 全部拉平
2026-07-27 15:15:36 +08:00
- **修复**:收集各 ` _parse_and_expr` 结果为独立 cluster, OR 存在时将 >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")`
2026-07-27 14:23:34 +08:00
2026-07-27 13:01:52 +08:00
---
2026-07-28 11:02:29 +08:00
## 第八轮:第 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 方案配额低于 Free( CRITICAL)
- **文件**: ` 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 tiebreaker( MEDIUM)
- **文件**: ` 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 tiebreaker( MEDIUM)
- **文件**: ` 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 覆盖
---
2026-07-28 11:26:35 +08:00
## 第九轮:第 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%'` 是大表全扫描,可被用于资源耗尽
---
2026-07-28 11:41:38 +08:00
## 第十轮:第 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_length( LOW)
- **文件**: ` 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 同步/恢复
---
2026-07-28 12:09:55 +08:00
## 第十一轮:第 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 ILIKE( DOCS ONLY)
- **备注**:第 10 轮修复了中文路径加入 author/journal ILIKE,已在文档中补全。代码已正确
---
2026-07-27 11:22:22 +08:00
2026-07-28 12:38:17 +08:00
## 第十二轮:第 12 轮深度审计修复(21 项)
**日期**: 2026-07-28
2026-07-28 12:38:23 +08:00
**提交**: ` 6f861c8`
2026-07-28 12:38:17 +08:00
**数量**: 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 12:59:38 +08:00
## 第十三轮:第 13 轮深度审计修复(21 项)
**日期**: 2026-07-29
2026-07-28 12:59:44 +08:00
**提交**: ` 1d34535`
2026-07-28 12:59:38 +08:00
**数量**: 21 项(1 P0 + 3 P1 + 2 P2 + 4 MINOR)
**触发**:用户第 8 次要求全面检查
**测试**: 1007 全部通过 + 前端 build 通过
### P0-1: ` _SPECIAL_FIELDS` 为 ` set` 类型,误调用 ` .get()` 导致 ` AttributeError`( CRITICAL)
- **文件**: ` pubmed_query_parser.py:44-49`
- **根因**: Round 12 新增的 ` _normalize_field_label()` 函数在第 48 行调用 ` _SPECIAL_FIELDS.get(raw)`。但 ` _SPECIAL_FIELDS` 是 Python ` set` 字面量(` {...}`),没有 ` .get()` 方法。Python 在求值 ` _FIELD_TAG_MAP.get(raw, _SPECIAL_FIELDS.get(raw))` 时会先计算第二个参数,无论 ` raw` 是否在 ` _FIELD_TAG_MAP` 中都会触发 ` AttributeError`。` parse_pubmed_query` 的 ` except` 只捕获 ` (ParseError, IndexError, ValueError)`, ` AttributeError` 传播到调用方 → 500 错误
- **修复**:拆分为三行:` if raw in _FIELD_TAG_MAP: return _FIELD_TAG_MAP[raw]` + ` if raw in _SPECIAL_FIELDS: return raw` + ` return None`
- **影响**: Round 12 引入的回归。` (cancer)[TI]`、` (a OR b)[DP]` 等所有带字段标签的括号组全面崩溃。本轮修复后恢复正常
- **验证**: ` (lung cancer OR breast cancer)[TI]` 不再崩溃
### P1-1: ` exclude_preprints` 丢弃 ` is_preprint=NULL` 记录(HIGH)
- **文件**: ` search_engine.py:541-542`
- **根因**: ` GlobalLiterature.is_preprint == False` 生成 ` WHERE is_preprint = false`。` is_preprint` 为 ` NULL` 的旧文献(未解析此字段)被排除。` NULL = false` 在 SQL 三值逻辑中为 ` NULL` → 被 WHERE 过滤
- **修复**:改为 ` GlobalLiterature.is_preprint != True`,生成 ` is_preprint IS DISTINCT FROM true`( NULL-safe,保留 false 和 NULL 行)
- **验证**:开启 ` exclude_preprints` 筛选后,` is_preprint=NULL` 的记录不再被静默丢弃
### P1-2: 普通搜索 ` boolean="or"` 模式下数字词和文本词被 AND 连接(HIGH)
- **文件**: ` search_engine.py:341-405`
- **根因**: ` boolean="or"` 时数字词条件(如 PMID 匹配)和文本词条件各自 OR 化后作为独立的元素加入 ` conditions` 列表。最终 ` and_(*conditions)` 将两者 AND 连接。例如 ` "12345 cancer"` 且 ` boolean="or"`:用户期望 ` PMID=12345 OR 包含cancer`,实际执行 ` PMID=12345 AND 包含cancer`
- **修复**:在数字词和文本词处理完成后,如果 ` boolean == "or"`,将 ` _term_start` 之后的所有词条件合并为一个 ` or_(*_term_conds)`
- **验证**: ` "30221571 pembrolizumab"` 且 ` boolean="or"`,结果应为 PMID 30221571 或包含 pembrolizumab 的文章(OR),而非同时满足
### P1-3: Journal 排序 keyset 忽略 ` journal_iso` 排序列(HIGH)
- **文件**: ` search_engine.py:1618-1621`
- **根因**: ` _apply_order_by("journal")` 返回 ` [journal ASC, journal_iso ASC, id ASC]`。但 ` _keyset_condition` 只处理 ` journal` 和 ` id`,完全忽略 ` journal_iso`。同名期刊不同 ISO 缩写(如 ` Nature` / ` Nature (Lond.)` / ` Nature (London)`)的文献在 keyset 翻页时被错误跳过
- **修复**:从 journal ORDER BY 中移除 ` journal_iso`( keyset 分页不支持多列 tiebreaker,其他所有排序模式均使用单列 + id)
- **影响**: journal + journal_iso 组合排序在非 keyset 路径(总数据量少时用 OFFSET)也不影响结果正确性,仅影响同行期刊的展示顺序
### P2-1: 日期字段 ` _dispatch_term` 未验证非日期文本(MEDIUM)
- **文件**: ` pubmed_query_parser.py:436-533`
- **根因**: ` cancer[DP]`、` foo[EDAT]` 等非日期文字传入日期字段时,` _dispatch_term` 的 else 分支直接赋值 ` result.date_from = term.text`。` "cancer"` 作为非法日期值传入 PostgreSQL 查询 → ` invalid input syntax for type date`
- **修复**: 7 个日期字段(DP/EDAT/CRDT/MHDA/LR/DCOM/DEP)的 else 分支加入 ` _validate_date_str()` 检查,非法文本路由到 ` plain_terms`
- **验证**: ` cancer[DP]` 不再导致 SQL 错误,退化到文本搜索
### P2-2: 日期范围回退保留日期字段标签(MEDIUM)
- **文件**: ` pubmed_query_parser.py:752-754`
- **根因**: ` lung:cancer[DP]` 范围中 ` cancer` 不是合法日期,` _parse_range` 返回 ` Term(txt, field="DP", ...)`。` _dispatch_term` 的 DP 分支将 ` "lung:cancer[DP]"` 作为非法日期值处理
- **修复**:回退 Term 的 ` field` 设为 ` None`(而非保留 ` field`),使其路由到 ` plain_terms`
- **影响**:非法日期范围内容降级到纯文本搜索
### MINOR-1: ` 二月` 始终被扩展为 29 天,非闰年产生非法日期
- **文件**: ` pubmed_query_parser.py:31-41`
- **根因**: ` _LAST_DAY[2] = 29` 对所有年份生效。` 2023-02[DP]` 被展开为 ` 2023-02-01` 到 ` 2023-02-29`,其中 ` 2023-02-29` 是非法日期
- **修复**:新增 ` _is_leap_year()` 函数;` _expand_partial_date()` 中对 ` month == 2 and last_day == 29` 且非闰年时置 ` last_day = 28`
### MINOR-2: ` _validate_date_str` 缺失日历正确性校验
- **文件**: ` pubmed_query_parser.py:749-751`
- **根因**: Round 12 的 ` _valid_date` lambda 只校验格式(是否为 YYYY 或 YYYY-MM-DD),不校验月份范围(1-12)和日期范围(1-月末)。` 2024-13-01`、` 2024-01-32` 等非法日历日期通过校验
- **修复**:新增 ` _validate_date_str()` 替代原 lambda,完整校验格式 + 月份范围 + 日期范围 + 闰年 2 月
### MINOR-3: 尾部 AND 产生空 Term
- **文件**: ` pubmed_query_parser.py:595-597`
- **根因**: ` _parse_and_expr` 消耗 AND token 后未检查 EOF。` cancer AND` 中 ` AND` 后的 ` _parse_not_expr` 推进到 EOF 后返回空 Term
- **修复**: ` self.advance()` 后检查 ` self.peek().type == TokenType.EOF → break`
### MINOR-4: 精确短语 "all" 搜索缺少 journal ILIKE 回退
- **文件**: ` search_engine.py:1415-1424`
- **根因**: ` exact=True` 时 ` _field_condition("all")` 只搜索 tsvector( phraseto_tsquery)和 affiliation ILIKE。journal/journal_iso 不在 tsvector 中(注释 line 1454 确认),"Nature" 作为精确短语搜索时无法匹配期刊名。非精确路径(line 1456-1462)正确包含 journal ILIKE
- **修复**:在精确短语路径中加入 ` GlobalLiterature.journal.ilike(pat)` 和 ` GlobalLiterature.journal_iso.ilike(pat)`
---
2026-07-28 13:21:24 +08:00
## 第十四轮:第 14 轮深度审计修复(21 项)
**日期**: 2026-07-29
2026-07-28 13:21:38 +08:00
**提交**: ` daf169d`
2026-07-28 13:21:24 +08:00
**数量**: 21 项(2 HIGH + 2 MINOR)
**触发**:用户第 9 次要求全面检查
**测试**: 1007 全部通过 + 前端 build 通过
### BUG-1 (HIGH): 非 DP 日期字段单年值未设置专用 ` *_from`/` *_to`
- **文件**: ` pubmed_query_parser.py:480-561`
- **根因**: ` 2024[EDAT]`、` 2024[CRDT]` 等非 DP 日期字段的单年值只设置了共享的 ` year_from`/` year_to`,未设置专用的 ` edat_from`/` edat_to`。` _pubmed_conditions`( search_engine.py:1227-1246)的 section 6b 正确迭代 ` DATE_FIELD_COLS` 并使用专用属性构建列条件,但解析器从未填充这些属性 → EDAT/CRDT/MHDA/LR/DCOM/DEP 的单年值过滤完全静默失效
- **影响**:用户输入 ` 2024[EDAT]` 期望按入库日期过滤,实际得到的是 ` pub_year >= 2024`(近似日期,语义错误)。EDAT 列过滤完全 skipped
- **修复**:6 个非 DP 日期字段的单年值分支改为设置专用的 ` *_from`=` YYYY-01-01` 和 ` *_to`=` YYYY-12-31`,不再设置 ` year_from`/` year_to`
### BUG-2 (HIGH): 跨日期字段 ` year_from`/` year_to` 互相覆盖
- **文件**: ` pubmed_query_parser.py:480-561`
- **根因**: ` year_from`/` year_to` 是 ` ParsedPubmedQuery` 的共享属性。` 2024[DP] AND 2025[EDAT]` 中 DP 先设置 ` year_from=2024`, EDAT 后覆盖为 ` year_from=2025` → DP 条件完全丢失。最终引擎只看到 ` pub_year >= 2025`
- **影响**:用户查询 ` 2024[DP] AND 2025[EDAT]` 期望「2024年出版 AND 2025年入库」,实际得到「2025年出版」(DP 条件丢失)
- **修复**:非 DP 日期字段不再设置 ` year_from`/` year_to`(仅设置专用字段)。DP 字段保持设置 ` year_from`/` year_to`。引擎 section 6b 已通过专用字段正确生成 SQL 条件
### BUG-3 (MINOR): MHDA/LR/DCOM/DEP 缺少 ` _PARTIAL_DATE_RE` 分支
- **文件**: ` pubmed_query_parser.py:510-561`
- **根因**: 4 个日期字段 MHDA/LR/DCOM/DEP 直接从年份检查跳到 else 分支,缺少 ` elif _PARTIAL_DATE_RE.match(term.text)` 的展开步骤。` 2024-02[MHDA]` 被降级为纯文本搜索而非展开为整月范围。DP/EDAT/CRDT 已有此分支
- **修复**:为 4 个字段各添加 ` _PARTIAL_DATE_RE` 展开分支,使用各自的专用属性(` mhda_from/mhda_to` 等)
### BUG-4 (MINOR): ` _single_term_condition` 未处理括号组内日期字段
- **文件**: ` search_engine.py:1358-1362`
- **根因**: ` _single_term_condition` 处理了所有 30+ 特殊字段(MH/PT/GR/SH/RN 等),但完全遗漏了日期字段(DP/EDAT/CRDT/MHDA/LR/DCOM/DEP)。括号组 ` (2024[DP] OR 2025[DP])` 中的日期词回退到全文本 ILIKE ` %2024%`
- **修复**:在回退前添加日期字段处理:4 位数年份展开为全年范围列条件,完整日期使用列等值条件。` DP → pub_year/pub_date`, ` EDAT → entrez_date`, ` CRDT → create_date`, ` MHDA → meshed_date`, ` LR → pubmed_revised`, ` DCOM → date_completed`, ` DEP → pub_date`
- **验证**: ` (2024[EDAT] OR 2025[EDAT])` 正确生成 ` entrez_date 年内范围 OR` 条件
---
2026-07-28 13:50:37 +08:00
## 第十五轮:第 15 轮审计(1 项修复)
**日期**: 2026-07-28
**提交**: ` 5698d94`
**数量**: 1 项(MEDIUM)
**触发**:用户第 10 次要求全面检查
**测试**: 1006 全部通过 + 前端 build 通过
**审计范围**:端到端字段分发审计、缓存/Facet 一致性审计、前端参数发送审计(3 并行 agent)
### ` __RANGE_*` 标记在括号组内被错误过滤
- **文件**: ` search_engine.py:1138`
- **根因**: ` _parse_range()` 在解析 ` 2024:2025[EDAT]` 等日期范围时,为表明此语法已在顶层 ` _parse_range` 中直接设置 ` result.edat_from/edat_to`,生成了一个副作用的 ` __RANGE_EDAT__` 标记 Term( ` _is_range_end=True`)。该标记在顶层被正确过滤(不进入 ` _dispatch_term`),但出现在括号组内 ` (2024:2025[EDAT] AND cancer)` 时,` _parse_primary` 将其与组内其他词一起放入 ` result.groups`。` _pubmed_conditions` 遍历组内词调用 ` _single_term_condition()` 时,` __RANGE_EDAT__` 无对应 handler → 回退到 ` _field_condition("all", "2024:2025")`,将范围值当作纯文本搜索 → SQL 中多出一条无意义条件 ` search_tsv @@ plainto_tsquery('2024:2025')`,返回零结果(静默数据丢失)
- **影响**:任何包含 ` (start:end[date-field] ...)` 括号组的 PubMed 查询,日期范围条件按顶层 AND 正确应用,但括号组内多出一条多余的假条件,导致符合条件的文献被错误排除。单条 ` 2024:2025[EDAT]`(无括号组)不受影响
- **修复**:在 ` _pubmed_conditions` 的组遍历循环中,在调用 ` _single_term_condition` 前检查 ` getattr(t, '_is_range_end', False)`,是则跳过
- **验证**: ` (2024:2025[EDAT] AND cancer)` 不再因为多余的 ` __RANGE_EDAT__` 过滤而返回零结果。完全等效于 ` 2024:2025[EDAT] AND cancer`
### 审计结果汇总
| 审计维度 | 结果 |
|---------|------|
| 端到端字段分发(parser→engine) | ✅ 所有 30+ 字段标签正确分发。` __RANGE_*` 在组内回退已修复 |
| 缓存键参数完备性 | ✅ ` _search_cache_key` + ` _facet_cache_key` 包含全部参数 |
| Facet 查询与主查询一致性 | ✅ 条件完全一致 |
| ` DATE_FIELD_COLS` 列名映射 | ✅ 6 列全部正确 |
| 前端参数发送 | ✅ SearchView.vue 完整发送全部 28 个参数 |
| Plan P1-4/P1-7/P1-8/F-1 | ✅ 前期轮次已全部实现 |
---
2026-07-28 14:08:46 +08:00
## 第十六轮:第 16 轮审计修复(4 项修复 + 3 项记录)
**日期**: 2026-07-29
**提交**: ` (待推送)`
**数量**:4 项修复 + 3 项记录
**触发**:用户第 11 次要求全面检查(Round 16, 3 并行 agent: Normal 搜索边缘、前端参数、NOT 检测)
**测试**: 1007 全部通过 + 前端 build 通过
### Bug-1 (MEDIUM): ` _parse_primary` 括号组内重复 group 赋值
- **文件**: ` pubmed_query_parser.py:706-709`
- **根因**: ` _parse_or_expr` 在 ` A OR B AND C` 时为 ` [B, C]` 创建 sub-group。随后 ` _parse_primary` 将所有 terms(含已 sub-group 的)再统一加到 parent group。sub-group 内的 term 同时出现在两个 group → ` _pubmed_conditions` 遍历 group 列表时为其生成两套条件 → SQL 中产生重复/多余的过滤条件,静默排除合法结果
- **影响**: ` NOT (A OR B AND C)` 类带 sub-group 的括号组查询可能返回零结果
- **修复**: ` _parse_primary` 只从 ` t.group_id < 0`(未分配)的 term 创建 parent group。sub-group 已分配的不再加入。同时增加 depth 守卫:` _parse_or_expr` 在 ` self._depth > 0`(括号内)时直接 flat 返回,不创建 sub-group
### Bug-2 (MEDIUM): ` all_not` 混淆外部 NOT 与内部 NOT
- **文件**: ` search_engine.py:1134-1172`
- **根因**: ` all_not = all(t.is_not for t in group)` 无法区分 ` NOT (A OR B)`(外部 NOT:应生成 ` not_(or_(A, B))`) 和 ` (NOT A OR NOT B)`(内部 NOT:应生成 ` or_(not_(A), not_(B))`)。两者都 ` all_not=True`,但语义完全不同
- **修复**:
- 解析器端:新增 ` ParsedPubmedQuery.group_negated: list[bool]` 字段,` _parse_primary` 在创建 parent group 时记录是否为外部 NOT wrapper
- 引擎端:用 ` group_negated[idx]` 替代 ` all_not`,外部 NOT 走 ` not_(combine_fn(g_neg))`,内部 NOT 走 ` combine_fn(g_pos + g_neg_with_not_)`
- **验证**: ` NOT (A OR B)` 与 ` (NOT A OR NOT B)` 生成不同的 SQL 条件组合
### Bug-3 (MEDIUM): 搜索错误显示为"no results"
- **文件**: ` SearchView.vue:365-367`
- **根因**: catch 块只调用 ` toast.apiError()`(瞬态通知提示),但 ` results = []` 导致 ` <NEmpty>` 显示"未找到匹配文献",用户以为搜索有结果只是条件过严,实际是后端错误
- **修复**:新增 ` searchError` ref,catch 时设置明确错误信息,模板条件渲染 ` <NResult>` 错误面板替代 ` NEmpty`。成功搜索时清除 ` searchError`
### Bug-4 (LOW): UUID 类型转换在中文标签子查询中
- **文件**: ` literature.py:293-294`
- **根因**: ` [str(t) for t in _tag_matches]` 将 UUID 转字符串后传给 ` in_(...)`,某些驱动下可能导致类型不匹配
- **修复**:改为 ` list(_tag_matches)` 传递原生 UUID 对象
### 审计结果汇总
| 审计维度 | 结果 |
|---------|------|
| Normal 搜索边缘情况 | ✅ ` _parse_primary` 重复 group 已修复。PubMed 降级路径 field tag 清洗已正确。ATM 展开括号剥离已正确 |
| 前端参数发送 | ✅ SearchView.vue 完整发送全部 28 个参数,` SearchRequestBody` 类型正确 |
| NOT 检测 | ✅ ` group_negated` 新增 track, ` all_not` 已替换。NOT-wrapped parens 与 sub-group 交互部分缓解(depth guard)。剩余 De Morgan 双重否定场景(LOW,理论正确性,实际罕见) |
---
2026-07-28 12:59:38 +08:00
截至 2026-07-29,剩余 7 项已知限制:
2026-07-27 11:22:22 +08:00
| ID | 问题 | 原因 | 影响 |
|----|------|------|------|
| L2 | Affiliation JSONB cast 假阳性 | 需独立 affiliation 列 + Alembic 迁移 + 重新填充 | 低 |
| L3 | retracted "yes"="only" | 命名语义,SQL 条件相同 | 无影响 |
| L4 | OR-mode NOT 检测不可靠 | ` UnaryExpression + _sa_ops.inv` 不可靠用于复合 NOT | 低 |
2026-07-27 15:18:36 +08:00
| L5 | 历史引用 ` #N ` 仅前端支持 | ` #N ` 是 localStorage UX 功能,仅在高级搜索前端内联展开 ` resolveQuery()` 后发送到 API。后端无 ` #` token 类型。API 直传 ` #1 ` 被当普通文本。属于设计决策,非 bug | 无影响(前端已覆盖所有 user 路径) |
2026-07-27 15:19:39 +08:00
| 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 操作,生产数据量大 | 中(大表性能) |
2026-07-28 13:50:37 +08:00
| L9 | ` _dispatch_term` 回归不可见 | 需字段级测试。第 15 轮修复了 ` __RANGE_*` 组内回退 | 低 |
2026-07-27 11:22:22 +08:00
---
## 附录:测试覆盖统计
| 测试文件 | 用例数 | 范围 |
|---------|--------|------|
| ` test_pubmed_query_parser.py` | ~40 | tokeniser、解析器、语法正确性 |
| ` test_pubmed_search_integration.py` | ~60 | 字段映射、API 集成、前端格式 |
| ` test_comprehensive_verify.py` | ~27 | 字段完整、NOT 语义、括号、日期 |
2026-07-27 13:01:52 +08:00
| ` test_comprehensive_verify.py` | ~55 | 第 7 轮新增覆盖(full dispatch、boolean_operator、cursor 等) |
2026-07-28 13:50:37 +08:00
| 全量测试套件 | **1006** | 全部通过(含前 15 轮 287 项搜索专项 + 719 项通用测试) |
2026-07-27 13:01:52 +08:00
> **预存失败(13 项)**: 9 项 ` feed_engine` ` StopAsyncIteration`(测试数据缺失) + 4 项 ` pubmed_api` ` _tag_article` import(函数已移入 pipeline)