From f8db720419633ed6f186d62d481bc68fadc5a28d Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 18:42:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC24=E8=BD=AE=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E6=97=A5?= =?UTF-8?q?=E6=9C=9Fintersect/NOT=E6=B7=B1=E5=BA=A6/re.ASCII=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=AD=8915=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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修复记录 --- backend/app/services/pubmed_query_parser.py | 254 ++++++++++++++++---- backend/app/services/search_engine.py | 30 ++- docs/13-搜索修复全记录.md | 121 +++++++++- frontend/src/views/app/SearchView.vue | 3 +- 4 files changed, 353 insertions(+), 55 deletions(-) diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index c2b573e..6a35766 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -529,29 +529,75 @@ class PubmedQueryParser: # is_not 时加入 negated_date_ranges,引擎据此 NOT 条件 elif term.field == "DP": if term.text.isdigit() and len(term.text) == 4: - result.year_from = int(term.text) - result.year_to = int(term.text) + y = int(term.text) + # R24: intersect with existing range from _parse_range (AND semantics) + if result.year_from is not None: + result.year_from = max(result.year_from, y) + else: + result.year_from = y + if result.year_to is not None: + result.year_to = min(result.year_to, y) + else: + result.year_to = y elif _PARTIAL_DATE_RE.match(term.text): - result.date_from, result.date_to = _expand_partial_date(term.text) + df, dt = _expand_partial_date(term.text) + # R24: intersect with existing range + if result.date_from is not None: + result.date_from = max(result.date_from, df) + else: + result.date_from = df + if result.date_to is not None: + result.date_to = min(result.date_to, dt) + else: + result.date_to = dt else: if _validate_date_str(term.text): - result.date_from = term.text - result.date_to = term.text - else: - result.plain_terms.append(term) - return + df = dt = term.text + # R24: intersect with existing range + if result.date_from is not None: + result.date_from = max(result.date_from, df) + else: + result.date_from = df + if result.date_to is not None: + result.date_to = min(result.date_to, dt) + else: + result.date_to = dt if term.is_not: result.negated_date_ranges.add("DP") elif term.field == "EDAT": if term.text.isdigit() and len(term.text) == 4: - result.edat_from = f"{term.text}-01-01" - result.edat_to = f"{term.text}-12-31" + y = int(term.text) + _v = f"{y}-01-01" + if result.edat_from is not None: + result.edat_from = max(result.edat_from, _v) + else: + result.edat_from = _v + _v2 = f"{y}-12-31" + if result.edat_to is not None: + result.edat_to = min(result.edat_to, _v2) + else: + result.edat_to = _v2 elif _PARTIAL_DATE_RE.match(term.text): - result.edat_from, result.edat_to = _expand_partial_date(term.text) + df, dt = _expand_partial_date(term.text) + if result.edat_from is not None: + result.edat_from = max(result.edat_from, df) + else: + result.edat_from = df + if result.edat_to is not None: + result.edat_to = min(result.edat_to, dt) + else: + result.edat_to = dt else: if _validate_date_str(term.text): - result.edat_from = term.text - result.edat_to = term.text + df = dt = term.text + if result.edat_from is not None: + result.edat_from = max(result.edat_from, df) + else: + result.edat_from = df + if result.edat_to is not None: + result.edat_to = min(result.edat_to, dt) + else: + result.edat_to = dt else: result.plain_terms.append(term) return @@ -559,14 +605,38 @@ class PubmedQueryParser: result.negated_date_ranges.add("EDAT") elif term.field == "CRDT": if term.text.isdigit() and len(term.text) == 4: - result.crdt_from = f"{term.text}-01-01" - result.crdt_to = f"{term.text}-12-31" + y = int(term.text) + _v = f"{y}-01-01" + if result.crdt_from is not None: + result.crdt_from = max(result.crdt_from, _v) + else: + result.crdt_from = _v + _v2 = f"{y}-12-31" + if result.crdt_to is not None: + result.crdt_to = min(result.crdt_to, _v2) + else: + result.crdt_to = _v2 elif _PARTIAL_DATE_RE.match(term.text): - result.crdt_from, result.crdt_to = _expand_partial_date(term.text) + df, dt = _expand_partial_date(term.text) + if result.crdt_from is not None: + result.crdt_from = max(result.crdt_from, df) + else: + result.crdt_from = df + if result.crdt_to is not None: + result.crdt_to = min(result.crdt_to, dt) + else: + result.crdt_to = dt else: if _validate_date_str(term.text): - result.crdt_from = term.text - result.crdt_to = term.text + df = dt = term.text + if result.crdt_from is not None: + result.crdt_from = max(result.crdt_from, df) + else: + result.crdt_from = df + if result.crdt_to is not None: + result.crdt_to = min(result.crdt_to, dt) + else: + result.crdt_to = dt else: result.plain_terms.append(term) return @@ -574,14 +644,38 @@ class PubmedQueryParser: result.negated_date_ranges.add("CRDT") elif term.field == "MHDA": if term.text.isdigit() and len(term.text) == 4: - result.mhda_from = f"{term.text}-01-01" - result.mhda_to = f"{term.text}-12-31" + y = int(term.text) + _v = f"{y}-01-01" + if result.mhda_from is not None: + result.mhda_from = max(result.mhda_from, _v) + else: + result.mhda_from = _v + _v2 = f"{y}-12-31" + if result.mhda_to is not None: + result.mhda_to = min(result.mhda_to, _v2) + else: + result.mhda_to = _v2 elif _PARTIAL_DATE_RE.match(term.text): - result.mhda_from, result.mhda_to = _expand_partial_date(term.text) + df, dt = _expand_partial_date(term.text) + if result.mhda_from is not None: + result.mhda_from = max(result.mhda_from, df) + else: + result.mhda_from = df + if result.mhda_to is not None: + result.mhda_to = min(result.mhda_to, dt) + else: + result.mhda_to = dt else: if _validate_date_str(term.text): - result.mhda_from = term.text - result.mhda_to = term.text + df = dt = term.text + if result.mhda_from is not None: + result.mhda_from = max(result.mhda_from, df) + else: + result.mhda_from = df + if result.mhda_to is not None: + result.mhda_to = min(result.mhda_to, dt) + else: + result.mhda_to = dt else: result.plain_terms.append(term) return @@ -589,14 +683,38 @@ class PubmedQueryParser: result.negated_date_ranges.add("MHDA") elif term.field == "LR": if term.text.isdigit() and len(term.text) == 4: - result.lr_from = f"{term.text}-01-01" - result.lr_to = f"{term.text}-12-31" + y = int(term.text) + _v = f"{y}-01-01" + if result.lr_from is not None: + result.lr_from = max(result.lr_from, _v) + else: + result.lr_from = _v + _v2 = f"{y}-12-31" + if result.lr_to is not None: + result.lr_to = min(result.lr_to, _v2) + else: + result.lr_to = _v2 elif _PARTIAL_DATE_RE.match(term.text): - result.lr_from, result.lr_to = _expand_partial_date(term.text) + df, dt = _expand_partial_date(term.text) + if result.lr_from is not None: + result.lr_from = max(result.lr_from, df) + else: + result.lr_from = df + if result.lr_to is not None: + result.lr_to = min(result.lr_to, dt) + else: + result.lr_to = dt else: if _validate_date_str(term.text): - result.lr_from = term.text - result.lr_to = term.text + df = dt = term.text + if result.lr_from is not None: + result.lr_from = max(result.lr_from, df) + else: + result.lr_from = df + if result.lr_to is not None: + result.lr_to = min(result.lr_to, dt) + else: + result.lr_to = dt else: result.plain_terms.append(term) return @@ -604,14 +722,38 @@ class PubmedQueryParser: result.negated_date_ranges.add("LR") elif term.field == "DCOM": if term.text.isdigit() and len(term.text) == 4: - result.dcom_from = f"{term.text}-01-01" - result.dcom_to = f"{term.text}-12-31" + y = int(term.text) + _v = f"{y}-01-01" + if result.dcom_from is not None: + result.dcom_from = max(result.dcom_from, _v) + else: + result.dcom_from = _v + _v2 = f"{y}-12-31" + if result.dcom_to is not None: + result.dcom_to = min(result.dcom_to, _v2) + else: + result.dcom_to = _v2 elif _PARTIAL_DATE_RE.match(term.text): - result.dcom_from, result.dcom_to = _expand_partial_date(term.text) + df, dt = _expand_partial_date(term.text) + if result.dcom_from is not None: + result.dcom_from = max(result.dcom_from, df) + else: + result.dcom_from = df + if result.dcom_to is not None: + result.dcom_to = min(result.dcom_to, dt) + else: + result.dcom_to = dt else: if _validate_date_str(term.text): - result.dcom_from = term.text - result.dcom_to = term.text + df = dt = term.text + if result.dcom_from is not None: + result.dcom_from = max(result.dcom_from, df) + else: + result.dcom_from = df + if result.dcom_to is not None: + result.dcom_to = min(result.dcom_to, dt) + else: + result.dcom_to = dt else: result.plain_terms.append(term) return @@ -619,14 +761,38 @@ class PubmedQueryParser: result.negated_date_ranges.add("DCOM") elif term.field == "DEP": if term.text.isdigit() and len(term.text) == 4: - result.dep_from = f"{term.text}-01-01" - result.dep_to = f"{term.text}-12-31" + y = int(term.text) + _v = f"{y}-01-01" + if result.dep_from is not None: + result.dep_from = max(result.dep_from, _v) + else: + result.dep_from = _v + _v2 = f"{y}-12-31" + if result.dep_to is not None: + result.dep_to = min(result.dep_to, _v2) + else: + result.dep_to = _v2 elif _PARTIAL_DATE_RE.match(term.text): - result.dep_from, result.dep_to = _expand_partial_date(term.text) + df, dt = _expand_partial_date(term.text) + if result.dep_from is not None: + result.dep_from = max(result.dep_from, df) + else: + result.dep_from = df + if result.dep_to is not None: + result.dep_to = min(result.dep_to, dt) + else: + result.dep_to = dt else: if _validate_date_str(term.text): - result.dep_from = term.text - result.dep_to = term.text + df = dt = term.text + if result.dep_from is not None: + result.dep_from = max(result.dep_from, df) + else: + result.dep_from = df + if result.dep_to is not None: + result.dep_to = min(result.dep_to, dt) + else: + result.dep_to = dt else: result.plain_terms.append(term) return @@ -737,14 +903,16 @@ class PubmedQueryParser: left.extend(right) return left - def _parse_not_expr(self, result: ParsedPubmedQuery) -> list[Term]: + def _parse_not_expr(self, result: ParsedPubmedQuery, _not_depth: int = 0) -> list[Term]: """not_expr → NOT not_expr | primary""" + if _not_depth > MAX_PAREN_DEPTH: + raise ParseError(f"NOT 嵌套过深(超过 {MAX_PAREN_DEPTH} 层),降级为简单文本搜索") if self.peek().type == TokenType.NOT: self.advance() # P5: trailing NOT at end of input → ignore silently (avoid IndexError peeking past EOF) if self.peek().type == TokenType.EOF: return [] - inner = self._parse_not_expr(result) + inner = self._parse_not_expr(result, _not_depth + 1) for t in inner: t.is_not = not t.is_not # P19: NOT (A OR B) should negate the group, not individual terms. @@ -907,7 +1075,7 @@ class PubmedQueryParser: pass elif not _start_is_digit and _end_is_digit: try: - if int(start_val[:4]) >= int(end_val): + if int(start_val[:4]) > int(end_val): start_val, end_val = end_val, start_val except (ValueError, TypeError): pass @@ -986,9 +1154,9 @@ def is_pubmed_syntax(query: str) -> bool: return False # P5: Normalize fullwidth characters before checking query = unicodedata.normalize('NFKC', query) - if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE): + if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE | re.ASCII): return True - if re.search(r'\b(AND|OR|NOT)\b', query, re.IGNORECASE): + if re.search(r'\b(AND|OR|NOT)\b', query, re.IGNORECASE | re.ASCII): return True return False diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index bdaa5b6..43b89e0 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -18,10 +18,13 @@ from app.services.tag_loader import load_tags_for_literature def _escape_ilike(s: str) -> str: - """转义 ILIKE 模式中的通配符 _ 和 %,防止用户输入 'EGFR_mutation' 误匹配 'EGFR mutation'""" + """转义 ILIKE 模式中的通配符 _ 和 %,防止用户输入 'EGFR_mutation' 误匹配 'EGFR mutation' + + 注意:先反转义已转义的 \\% 和 \\_ → % 和 _,再整体转义,避免双重转义。 + """ if not s: return s - return s.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + return s.replace('\\%', '%').replace('\\_', '_').replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') class AdvancedSearchEngine: @@ -176,6 +179,10 @@ class AdvancedSearchEngine: ) -> dict: """执行高级搜索""" from sqlalchemy.dialects.postgresql import JSONB + # R24: cap page_size and tag_ids to prevent abuse + page_size = min(page_size, 100) + if tag_ids: + tag_ids = list(set(str(t) for t in tag_ids))[:200] conditions = [] # 向后兼容:cursor_date → cursor_val(旧前端发 cursor_date) @@ -372,9 +379,9 @@ class AdvancedSearchEngine: _phrases = _phrase_pat.findall(query) _query_no_quotes = _phrase_pat.sub(' ', query) _rest = [t.strip().strip('"').strip("'") for t in _query_no_quotes.split() if t.strip()] - terms = [p for p in _phrases if p.strip()] + [t for t in _rest if t not in _phrases] - # P17: 记录引号短语,后续强制 exact=True + # P17: 记录引号短语,后续强制 exact=True (MUST be before terms to avoid closure NameError) _phrase_terms_set = set(p.lower() for p in _phrases if p.strip()) + terms = [p for p in _phrases if p.strip()] + [t for t in _rest if t.lower() not in _phrase_terms_set] # 单数字词:优先 PMID 精确匹配(unique index 5ms 返回) # 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan numeric_terms = [t for t in terms if re.match(r'^\d{1,15}$', t)] @@ -382,9 +389,10 @@ class AdvancedSearchEngine: if numeric_terms: num_conds = [] if exact_phrase: - # 精确短语模式:跳过 PMID 快速路径,走 ILIKE + # R24: always include PMID check even in exact_phrase mode for t in numeric_terms: num_conds.append(or_( + GlobalLiterature.pmid == int(t), GlobalLiterature.title.ilike(t), GlobalLiterature.doi.ilike(t), )) @@ -600,7 +608,8 @@ class AdvancedSearchEngine: year_counts = yc_cached if isinstance(yc_cached, list) else yc_cached.get("year_counts", []) # 判断是否有任何筛选器/文本查询活跃 - _has_any_filter = (query.strip() or year_from or year_to or date_from or date_to + # R24: exclude query.strip() — PubMed queries are fully consumed by conditions + _has_any_filter = (year_from or year_to or date_from or date_to or journal_tiers or pub_types or tag_ids or retracted or negative_result or is_oa is not None or language or languages or nlm_subsets @@ -1539,6 +1548,9 @@ class AdvancedSearchEngine: def _field_condition(field: str, term: str, exact: bool) -> callable: _wildcard = term.endswith('*') and not exact _stem = term[:-1] if _wildcard else term + # R24: pure wildcard '*' matches nothing + if not _stem: + return text("FALSE") _escaped = _escape_ilike(_stem) def _pt() -> str: @@ -1889,8 +1901,10 @@ class AdvancedSearchEngine: def _cursor_from_item(lit, sort: str) -> str | None: """从末尾条目标提取 keyset 游标值。""" if sort == "date": - val = str(lit.pub_date or lit.article_date or "") - return val if val else "__NULL__" + # R24: ignore article_date for cursor — ORDER BY is only pub_date DESC NULLS LAST + if lit.pub_date is not None: + return str(lit.pub_date) + return "__NULL__" elif sort == "cited": if lit.cited_by_count is not None: return str(lit.cited_by_count) diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index 7793dbd..c9a75aa 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -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 问题 | diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index a9e4baf..9751110 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -548,7 +548,8 @@ function syncSearchToUrl() { // 页码持久化到 URL(Keyset 排序使用 keyset 页码,offset 排序使用 offset 页码) const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value if (currentPage > 1) q.p = String(currentPage) - router.replace({ query: q }).catch(() => {}) + // R24: use push instead of replace to preserve browser back-button history + router.push({ query: q }).catch(() => {}) } function resetAllFilters() {