fix: 第24轮搜索审计修复 — 日期intersect/NOT深度/re.ASCII统一等15项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

R24 修复清单:
- H2 (CRITICAL): _dispatch_term 7个日期字段改为intersect模式,防止单日期覆盖已设范围
- H1 (MEDIUM): 混合日期范围交换 >= → >
- BUG-1: keyset游标只使用pub_date,忽略article_date
- BUG-2: 纯*通配符返回text("FALSE")
- BUG-3: page_size上限100
- BUG-4: tag_ids上限200
- BUG-5: _escape_ilike先反转义再转义,避免双重转义
- BUG-6/7/8: _has_any_filter query.strip、数字PMID、_phrase_terms_set NameError
- M1: _parse_not_expr添加深度守卫(MAX_PAREN_DEPTH)
- M3: is_pubmed_syntax添加re.ASCII与_TOKEN_RE一致
- 前端: router.replace → push
- 文档: 追加R24修复记录
This commit is contained in:
34047007@qq.com
2026-07-28 18:42:04 +08:00
parent c1674c602d
commit f8db720419
4 changed files with 353 additions and 55 deletions
+118 -3
View File
@@ -2,9 +2,9 @@
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
>
> **累计**22 轮,248 项修复,80+ 字段标签注册,1007+ 项测试覆盖7 项已知限制
> **累计**23 轮,265+ 项修复,80+ 字段标签注册,1007+ 项测试覆盖
> **时间跨度**2026-07-24 ~ 2026-07-29
> **核心文件**`pubmed_query_parser.py`~850 行)→ `search_engine.py`~1360 行)
> **核心文件**`pubmed_query_parser.py`~1100 行)→ `search_engine.py`~1960 行)
---
@@ -23,7 +23,7 @@
11. [第十一轮:第 11 轮深度审计修复(16 项)](#第十一轮第-11-轮深度审计修复)
12. [第十二轮:第 12 轮深度审计修复(21 项)](#第十二轮第-12-轮深度审计修复)
13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复)
14. [第十四轮:14 轮深度审计修复21 项](#第十四轮第-14-轮深度审计修复)
14. [第十五轮(24 审计修复)](#round-24第-24-次全面审计修复)
15. [遗留限制](#遗留限制)
---
@@ -1731,3 +1731,118 @@
### 测试覆盖
**1007 tests passed**(全量套件,含全部前 22 轮 248 项搜索专项 + 通用测试)
---
## Round 24:第 24 次全面审计修复(2026-07-28
### 审计发现总览
4 路并行审计 agent 覆盖回归检查、搜索引擎代码、解析器/分词器、前端集成。发现 ~20 项问题,其中 HIGH 2 项、MEDIUM 4 项、LOW 10+ 项。
### H2 (CRITICAL): `_dispatch_term` 单日期覆盖已设置的范围值
- **文件**`pubmed_query_parser.py:_dispatch_term`(所有 7 个日期字段分支)
- **根因**`_parse_range` 先设置 `date_from/date_to`,随后 `_parse_atom` 解析 `2024[DP]` 时 `_dispatch_term` 无条件覆盖 `year_from=2024`、`year_to=2024`。当 `2024:2028[DP] AND 2026[DP]` 时,范围被后续单日期覆盖。
- **修复**:所有日期字段的 single-year 和 single-date 分支改为 intersect 模式:
```python
if result.year_from is not None:
result.year_from = max(result.year_from, y)
else:
result.year_from = y
```
同样模式用于 `year_to`min)、`dep_from/edat_from/...`max)、`dep_to/edat_to/...`min)。
- **影响**:多日期条件 AND 组合时保持正确的日期交集而非后写覆盖。
### H1 (MEDIUM): 混合日期范围交换使用 `>=` 而非 `>`
- **文件**`pubmed_query_parser.py:910`
- **根因**`2024-06-15:2024[EDAT]` 混合日期范围交换判断:`if int(start_val[:4]) >= int(end_val)` → 左端年份 >= 右端值时交换。但等值年份不应交换(`2024-06-15:2024` 正确),其他三个交换分支都已用 `>`。
- **修复**`>=` → `>`
### BUG-1 (MEDIUM): Keyset 分页日期游标重复
- **文件**`search_engine.py:_cursor_from_item()`
- **根因**`pub_date=NULL` 时回退 `article_date` 创建游标值,但主查询排序只用 `pub_date`。排序列和游标列不一致 → 结果重复/跳跃。
- **修复**`_cursor_from_item()` 只使用 `lit.pub_date`,忽略 `article_date`。
### BUG-2 (MEDIUM): 单 `*` 通配符匹配全部
- **文件**`search_engine.py:_field_condition()`
- **根因**:纯星号 `*` 通配符进入 wildcard 分支后 `_stem = ""`ILIKE `%%` 匹配所有行。
- **修复**`if not _stem: return text("FALSE")`
### BUG-3 (MEDIUM): `page_size` 无上限
- **文件**`search_engine.py:search()` 入口
- **根因**`page_size` 直接传递给 SQL LIMIT,前端可请求任意大(如 100000)→ OOM 风险。
- **修复**`page_size = min(page_size, 100)`
### BUG-4 (MEDIUM): `tag_ids` 无上限
- **文件**`search_engine.py:search()` 入口
- **根因**`tag_ids` 直接入 IN 子句。`/api/v1/literature/?tag_ids=1,2,...,1000` → 超大 IN 子句,性能差。
- **修复**`tag_ids = list(set(str(t) for t in tag_ids))[:200]`
### BUG-6 (LOW): `_has_any_filter` 中 `query.strip()` 误杀空白查询
- **根因**`_has_any_filter` 内 `query.strip()` 在括号和布尔符被剥离前判断。空格查询 `" "` 被淘汰 → 跳到 `FALSE` 守卫,返回 0 结果。
- **修复**`query.strip()` 改为 `query``" "` 空格在剥离后变 `""` → 正确处理)。
### BUG-7 (LOW): `exact_phrase` + 数字词未含 PMID
- **文件**`search_engine.py` flat text 路径的 numeric branch
- **根因**`exact_phrase=True` 时数字词只做 ILIKE 精确匹配,不检查 PMID 字段。
- **修复**`GlobalLiterature.pmid == int(t)` 同时包含在 `or_()` 中。
### BUG-8 (LOW/MEDIUM): `_phrase_terms_set` NameError 回归
- **文件**`search_engine.py` flat text 路径
- **根因**`_phrase_terms_set` 在 `terms` 列表推导之后定义,但由于 Python 闭包延迟求值,运行时报 `NameError`。
- **修复**:将 `_phrase_terms_set` 移至 `terms` 之前。同时用 `.lower()` 做 case-insensitive dedup。
### Parser M1 (LOW): NOT 递归深度无限制
- **文件**`pubmed_query_parser.py:_parse_not_expr()`
- **根因**:连续 NOT`NOT NOT NOT ... term`)可无限递归。
- **修复**:添加 `_not_depth` 参数,超过 `MAX_PAREN_DEPTH(10)` 时 `raise ParseError`。
### Parser M3 (LOW): `is_pubmed_syntax` 和 `_TOKEN_RE` ASCII 标志不一致
- **文件**`pubmed_query_parser.py:is_pubmed_syntax()`
- **根因**`_TOKEN_RE` 使用 `re.ASCII` 使 `\b` 只识别 ASCII 词边界。`is_pubmed_syntax` 使用 `re.IGNORECASE` 无 ASCII 标志,对非 ASCII 字符行为不同。
- **修复**:两个正则搜索都添加 `re.ASCII`。
### BUG-5 (LOW): `_escape_ilike` 双重转义
- **文件**`search_engine.py:_escape_ilike()`
- **根因**:用户输入 `EGFR\%`(有意搜索百分号)→ `replace('\\', '\\\\')` 后为 `EGFR\\%` → `replace('%', '\\%')` 后为 `EGFR\\\%` → ILIKE 里匹配 `EGFR\%` 而非 `EGFR%`。
- **修复**:先反转义 `\\%` → `%` 和 `\\_` → `_`,再整体转义。
### 前端修复
- **`router.replace` → `router.push`**`syncSearchToUrl` 用 `replace` 导致浏览器后退按钮跳过中间搜索状态。
### 审计结果汇总
| 审计维度 | 结果 |
|---------|------|
| R23 回归 | ✅ 尾随间隙缩进回归已修复 |
| 搜索引擎代码 | ✅ keyset 游标、通配符、上限、escape_ilike 等 8 项修复 |
| 解析器/分词器 | ✅ intersect 日期、NOT 深度、re.ASCII、date swap 等 6 项修复 |
| 前端集成 | ✅ router.replace→push |
### 测试覆盖
**53 parser + search engine tests passed**(非回归验证)。全量测试结果:722 passed,223 failed(全部为外部服务连接失败)+ 62 errors(全部为 `test_teams` 外部服务 + `feed_engine` 测试数据)。
### 剩余已知 LOW 项(未修复)
| # | 描述 | 原因 |
|---|------|------|
| M2 | `_parse_atom` vs `_parse_range` 不同字段归一化路径 | 代码一致性,无实际 bug |
| P1 | 前端 `is_oa` UI 切换控件未实现 | 功能添加,非修复 |
| BUG-9 | `year_from/year_to` 无合法性校验 | 反向范围返回空结果(语义正确),非必须 |
| P2 | OR 模式冗余 `or_()` 嵌套 | 无害,SQL 优化器扁平化 |
| savedPmids | 前端挂载时不从服务器加载 | 前端功能缺失 |
| 429 | 搜索时重复 429 反馈 | 前端 UI 问题 |