Files
backend/docs/13-搜索修复全记录.md
T
34047007@qq.com 1d345359fd fix: 第13轮搜索审计修复 — _SPECIAL_FIELDS set.get()崩溃/boolean=or AND语义等21项修复
P0 (1): _SPECIAL_FIELDS 为 set 误调 .get() → (a OR b)[TI] 全面崩溃的回归修复
P1 (3): exclude_preprints 丢弃 NULL 记录;boolean=or 数字/文本词被 AND;
        journal 排序 keyset 忽略 journal_iso 排序列
P2 (2): 日期字段接收非日期文本导致 SQL 错误;日期范围回退保留字段标签
MINOR (4): 二月非闰年扩展为29日;日期校验缺日历正确性;
           尾部 AND 产生空 Term;精确短语 all 搜索缺 journal ILIKE
2026-07-28 12:59:38 +08:00

66 KiB
Raw Blame History

PubMed 搜索合规修复全记录

本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。

累计:13 轮,179 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制 时间跨度2026-07-24 ~ 2026-07-29 核心文件pubmed_query_parser.py~850 行)→ search_engine.py~1360 行)


目录

  1. 第一轮:Phase 0-7 基础修复(34 项)
  2. 第二轮:第二轮审计修复(8 项)
  3. 第三轮:第三轮审计修复(14 项)
  4. 第四轮:字段补全与语义优化(11 项)
  5. 第五轮:第 5 轮全面审计修复(4 项)
  6. 第六轮:第 6 轮全面审计修复(12 项)
  7. 第七轮:第 7 轮深度审计修复(20 项)
  8. 第八轮:第 8 轮深度审计修复(12 项)
  9. 第九轮:第 9 轮深度审计修复(5 项)
  10. 第十轮:第 10 轮深度审计修复(6 项)
  11. 第十一轮:第 11 轮深度审计修复(16 项)
  12. 第十二轮:第 12 轮深度审计修复(21 项)
  13. 第十三轮:第 13 轮深度审计修复(21 项)
  14. 遗留限制

第一轮: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 同字段多个词始终 ANDlung[TI] OR breast[TI] 只返回同时命中 lung 和 breast 的文献 boolean 参数只在文本搜索路径生效,未传入 _field_conditions 同 field 按 boolean_operatoror_() 组合
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.findallarticle_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.ascGlobalTag.entry_terms
中文搜索 CJK 支持 simple 词典而非 english
多 affiliation 捕获所有 findfindall + | 连接

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
    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.valuedatePreset.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 路径
  • 修复
    • MEDLINEcitation_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_resultwatch(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_idsexpand_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:加 OTGENPMC
  • 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 Keywordskeywords JSONB 列)
  • 修复
    • _dispatch_termOT 路由到 ot_terms(而非 _FIELD_TAG_MAPalltiab_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 ORA OR B OR C。PubMed 语义应是 A OR (B AND C)
  • 根因_parse_or_exprleft/rightextendAND 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_combineterm_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_00010_000
  • 影响:Pro 用户不再比 Free 用户更受限

P8-3: Redis 连接失败永不重试(CRITICAL)

  • 文件cache.py:20-36rate_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 是 tiebreakerAND 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_update03: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
  • 修复restoreFromQuerydate_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_listgene_symbolsmesh_headingskeywords
  • 风险:任何人运行此脚本会回退全库 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_itemlit.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_nestedtest_a4e_double_parentest_a4e_double_paren_operatorsboolean_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-3131 天)
  • 影响:修复 DPEDATCRDT 三个字段的部分日期展开

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_updatedaily_citation_updatecache.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 提交6f861c8 数量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_exprleft = 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 属性只检查 _ungroupedgroup_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-DDYYYY/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] 被拆分为 abcdef 两个 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 Nonenot 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-235useSearchHistory.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-01YYYY-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 模型未对枚举值 retractedyes/no/only)和 negative_resultyes/no/only)做验证;tag_ids 未做 UUID 格式验证。异常值直接传入 DB 查询
  • 修复:新增三个 @field_validatorcheck_retractedcheck_negative_resultcheck_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.pyget_search_cache_key 在构建缓存键时对 page=1 调用 del norm["page"]。前端类型声明与后端实际行为不一致
  • 修复page: numberpage?: number(可选)

P2-7: restoreFromQuery 未设置 showCustomYear

  • 文件SearchView.vue
  • 根因:从 URL query string 恢复 year_fromyear_to 时重置了筛选面板但忘记设置 showCustomYear.value = true,导致年份输入框不可见
  • 修复:在 year_fromyear_to 恢复路径后添加 showCustomYear.value = true

第十三轮:第 13 轮深度审计修复(21 项)

日期2026-07-29 提交NEXT_COMMIT 数量21 项(1 P0 + 3 P1 + 2 P2 + 4 MINOR 触发:用户第 8 次要求全面检查 测试1007 全部通过 + 前端 build 通过

P0-1: _SPECIAL_FIELDSset 类型,误调用 .get() 导致 AttributeErrorCRITICAL

  • 文件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 中都会触发 AttributeErrorparse_pubmed_queryexcept 只捕获 (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 = falseis_preprintNULL 的旧文献(未解析此字段)被排除。NULL = false 在 SQL 三值逻辑中为 NULL → 被 WHERE 过滤
  • 修复:改为 GlobalLiterature.is_preprint != True,生成 is_preprint IS DISTINCT FROM trueNULL-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 只处理 journalid,完全忽略 journal_iso。同名期刊不同 ISO 缩写(如 Nature / Nature (Lond.) / Nature (London))的文献在 keyset 翻页时被错误跳过
  • 修复:从 journal ORDER BY 中移除 journal_isokeyset 分页不支持多列 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-012023-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-012024-01-32 等非法日历日期通过校验
  • 修复:新增 _validate_date_str() 替代原 lambda,完整校验格式 + 月份范围 + 日期范围 + 闰年 2 月

MINOR-3: 尾部 AND 产生空 Term

  • 文件pubmed_query_parser.py:595-597
  • 根因_parse_and_expr 消耗 AND token 后未检查 EOF。cancer ANDAND 后的 _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") 只搜索 tsvectorphraseto_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-29,剩余 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