From 6f861c8543dbad6455da3db6dd3a2abd7f6a819b Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 12:38:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC12=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=5Fnormalize?= =?UTF-8?q?=5Ffield=5Flabel=20=E5=B4=A9=E6=BA=83/keyset=20NULLSLAT/?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=88=97=E8=A1=A8=E5=8F=98=E5=BC=82=E7=AD=89?= =?UTF-8?q?21=E9=A1=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 (3): _normalize_field_label 函数缺失导致 (a OR b)[TI] 崩溃; 共享列表变异污染 result.groups;POST /search/advanced 缺少用户认证 P1 (8): has_not 忽略括号内 NOT;紧凑日期 YYYYMMDD 未归一化; 日期字段非日期文本 SQL 错误;keyset NULLSLAT ~50% 空值行跳过; first_author 非 dict JSON 崩溃;MeSH 无匹配静默丢弃; 相关性排序含否定词;词数检查在 PubMed 清洗之前; 普通搜索缺错误处理;#N 引号感知不完整 P2 (6): 月越界退化;MH/MAJR FALSE 语义;field 白名单缺字段; retracted/negative_result/tag_ids 验证器;resolveQuery 重复调用; SearchRequestBody.page 可选;restoreFromQuery showCustomYear --- backend/app/api/v1/features.py | 35 ++++- backend/app/api/v1/literature.py | 124 +++++++-------- backend/app/services/pubmed_query_parser.py | 38 ++++- backend/app/services/search_engine.py | 36 +++-- docs/13-搜索修复全记录.md | 141 ++++++++++++++++++ frontend/src/composables/useSearchHistory.ts | 13 +- frontend/src/types/index.ts | 2 +- frontend/src/views/app/SearchView.vue | 10 +- .../views/public/AdvancedPubSearchView.vue | 13 +- 9 files changed, 324 insertions(+), 88 deletions(-) diff --git a/backend/app/api/v1/features.py b/backend/app/api/v1/features.py index d205c1b..467519b 100644 --- a/backend/app/api/v1/features.py +++ b/backend/app/api/v1/features.py @@ -108,10 +108,37 @@ class AdvancedSearchRequest(BaseModel): @field_validator('field') @classmethod def check_field(cls, v: str) -> str: - if v not in ('all', 'title', 'abstract', 'author', 'affiliation', 'journal'): + if v not in ('all', 'title', 'abstract', 'author', 'affiliation', 'journal', + 'language', 'volume', 'issue', 'pages', 'lid'): raise ValueError(f'无效搜索字段: {v}') return v + @field_validator('retracted') + @classmethod + def check_retracted(cls, v: str) -> str: + if v and v not in ('yes', 'no', 'only'): + raise ValueError(f'无效 retracted 值: {v}') + return v + + @field_validator('negative_result') + @classmethod + def check_negative_result(cls, v: str) -> str: + if v and v not in ('yes', 'no', 'only'): + raise ValueError(f'无效 negative_result 值: {v}') + return v + + @field_validator('tag_ids') + @classmethod + def check_tag_ids(cls, v: list[str] | None) -> list[str] | None: + if v is not None: + import uuid + for tid in v: + try: + uuid.UUID(tid) + except (ValueError, AttributeError): + raise ValueError(f'无效 tag_id: {tid}') + return v + @field_validator('boolean') @classmethod def check_boolean(cls, v: str) -> str: @@ -249,7 +276,11 @@ async def _load_filter_options(db: AsyncSession) -> dict: @router.post("/search/advanced", summary="高级搜索") -async def advanced_search(req: AdvancedSearchRequest, db: AsyncSession = Depends(get_db)): +async def advanced_search( + req: AdvancedSearchRequest, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): try: return await AdvancedSearchEngine.search(db, **req.model_dump()) except ValueError as ve: diff --git a/backend/app/api/v1/literature.py b/backend/app/api/v1/literature.py index d0aa303..13ef421 100644 --- a/backend/app/api/v1/literature.py +++ b/backend/app/api/v1/literature.py @@ -257,9 +257,7 @@ async def search_literature( ): if not q.strip(): return {"items": [], "total": 0} - if len(q.split()) > 100: - return {"items": [], "total": 0, "error": "查询词过多(最多 100 个词),请简化搜索条件"} - # PubMed 语法检测与降级:普通搜索不支持字段标签和布尔符 + # 先剥离 PubMed 语法,再检查词数(避免字段标签计数干扰) from app.services.pubmed_query_parser import is_pubmed_syntax if is_pubmed_syntax(q): import re as _pm_re @@ -269,63 +267,69 @@ async def search_literature( q = ' '.join(q.split()) if not q.strip(): return {"items": [], "total": 0} - offset = (page - 1) * page_size - await db.execute(text("SET LOCAL statement_timeout = '30s'")) - like = f"%{_escape_ilike(q)}%" - # tsvector 主搜索 + ILIKE 兜底 - search_cond = or_( - GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", q)), - GlobalLiterature.title.ilike(like), - GlobalLiterature.abstract.ilike(like), - ) - # 中文搜索:自动匹配 GlobalTag.name_zh → 注入标签条件 - import re as _cn_re - _CHINESE_RE = _cn_re.compile(r'[一-鿿㐀-䶿豈-﫿]') - if _CHINESE_RE.search(q): - _tag_matches = (await db.execute( - select(GlobalTag.id).where( - GlobalTag.source.in_(["mesh", "manual"]), - GlobalTag.name_zh.ilike(like), - ).limit(100) - )).scalars().all() - if _tag_matches: - _tag_lit_subq = select(GlobalLiteratureTag.literature_id).where( - GlobalLiteratureTag.tag_id.in_([str(t) for t in _tag_matches]) - ) - search_cond = or_(search_cond, GlobalLiterature.id.in_(_tag_lit_subq)) - count_q = select(func.count(GlobalLiterature.id)).where(search_cond) - total = (await db.execute(count_q)).scalar() or 0 - tsq = func.plainto_tsquery("english", q) - best_match_rank = AdvancedSearchEngine._best_match_order(tsq) - result = await db.execute( - select(GlobalLiterature).where(search_cond) - .order_by(best_match_rank).offset(offset).limit(page_size) - ) - lit_list = result.scalars().all() - tm = await load_tags_for_literature(db, [str(lit.id) for lit in lit_list]) - items = [] - for lit in lit_list: - authors = lit.authors or [] - affiliation = None - for a in authors: - aff = a.get("affiliation","") or "" - if aff.strip(): affiliation = aff.split(",")[0].strip()[:30]; break - items.append(LiteratureCard( - id=str(lit.id), pmid=lit.pmid, title=lit.title, - first_author=authors[0].get("family", "") if authors else "", - affiliation=affiliation, journal=lit.journal, - pub_date=cap_pub_date(lit.pub_date), - article_date=lit.article_date.isoformat() if lit.article_date else None, - doi=lit.doi, pmc_id=lit.pmc_id, is_oa=lit.is_oa, - cited_by_count=lit.cited_by_count, pub_types=lit.pub_types or [], - tags=tm.get(str(lit.id), []), - study_design=lit.study_design, - trial_reg=lit.trial_reg, - retracted=lit.retracted, - is_negative_result=lit.is_negative_result, - created_at=lit.created_at.isoformat() if lit.created_at else None, - updated_at=lit.updated_at.isoformat() if lit.updated_at else None, - )) + if len(q.split()) > 100: + return {"items": [], "total": 0, "error": "查询词过多(最多 100 个词),请简化搜索条件"} + try: + offset = (page - 1) * page_size + await db.execute(text("SET LOCAL statement_timeout = '30s'")) + like = f"%{_escape_ilike(q)}%" + # tsvector 主搜索 + ILIKE 兜底 + search_cond = or_( + GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", q)), + GlobalLiterature.title.ilike(like), + GlobalLiterature.abstract.ilike(like), + ) + # 中文搜索:自动匹配 GlobalTag.name_zh → 注入标签条件 + import re as _cn_re + _CHINESE_RE = _cn_re.compile(r'[一-鿿㐀-䶿豈-﫿]') + if _CHINESE_RE.search(q): + _tag_matches = (await db.execute( + select(GlobalTag.id).where( + GlobalTag.source.in_(["mesh", "manual"]), + GlobalTag.name_zh.ilike(like), + ).limit(100) + )).scalars().all() + if _tag_matches: + _tag_lit_subq = select(GlobalLiteratureTag.literature_id).where( + GlobalLiteratureTag.tag_id.in_([str(t) for t in _tag_matches]) + ) + search_cond = or_(search_cond, GlobalLiterature.id.in_(_tag_lit_subq)) + count_q = select(func.count(GlobalLiterature.id)).where(search_cond) + total = (await db.execute(count_q)).scalar() or 0 + tsq = func.plainto_tsquery("english", q) + best_match_rank = AdvancedSearchEngine._best_match_order(tsq) + result = await db.execute( + select(GlobalLiterature).where(search_cond) + .order_by(best_match_rank).offset(offset).limit(page_size) + ) + lit_list = result.scalars().all() + tm = await load_tags_for_literature(db, [str(lit.id) for lit in lit_list]) + items = [] + for lit in lit_list: + authors = lit.authors or [] + affiliation = None + for a in authors: + aff = a.get("affiliation","") or "" + if aff.strip(): affiliation = aff.split(",")[0].strip()[:30]; break + items.append(LiteratureCard( + id=str(lit.id), pmid=lit.pmid, title=lit.title, + first_author=authors[0].get("family", "") if authors else "", + affiliation=affiliation, journal=lit.journal, + pub_date=cap_pub_date(lit.pub_date), + article_date=lit.article_date.isoformat() if lit.article_date else None, + doi=lit.doi, pmc_id=lit.pmc_id, is_oa=lit.is_oa, + cited_by_count=lit.cited_by_count, pub_types=lit.pub_types or [], + tags=tm.get(str(lit.id), []), + study_design=lit.study_design, + trial_reg=lit.trial_reg, + retracted=lit.retracted, + is_negative_result=lit.is_negative_result, + created_at=lit.created_at.isoformat() if lit.created_at else None, + updated_at=lit.updated_at.isoformat() if lit.updated_at else None, + )) + except Exception: + logger.exception("普通搜索异常") + return {"items": [], "total": 0, "error": "搜索服务暂不可用"} return {"items": items, "total": total} diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index aaaeebe..a7124d4 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -32,10 +32,22 @@ def _expand_partial_date(text: str) -> tuple[str, str]: """Expand YYYY-MM to full month range (YYYY-MM-01 to YYYY-MM-last_day).""" date_from = f"{text}-01" y, m = text.split("-") - last_day = _LAST_DAY.get(int(m), 31) + month = int(m) + if month < 1 or month > 12: + # P12: invalid month → return year-only range + return f"{y}-01-01", f"{y}-12-31" + last_day = _LAST_DAY.get(month, 31) date_to = f"{y}-{m}-{last_day}" return date_from, date_to + +def _normalize_field_label(raw: str) -> str | None: + """Normalize raw PubMed field label to internal field name. (P12)""" + if raw == "MH:NOEXP": + return "MH" + return _FIELD_TAG_MAP.get(raw, _SPECIAL_FIELDS.get(raw)) + + # ─── 查询复杂度限制 ─── MAX_TERMS = 100 # P4-1: 放宽到 100 词(原 50 词) MAX_PAREN_DEPTH = 10 # 括号嵌套最深层数 @@ -343,7 +355,10 @@ class PubmedQueryParser: # 分组词不从 flat lists 走,避免括号内外的词被一起 AND/OR # 同时 has_not/not_terms 也只考虑非分组词 _ungrouped = [t for t in terms if not getattr(t, '_is_range_end', False) and t.group_id < 0] - result.has_not = any(t.is_not for t in _ungrouped) + # P12: has_not 同时检查分组内 NOT(如 NOT (a OR b)) + result.has_not = any(t.is_not for t in _ungrouped) or any( + t.is_not for g in result.groups for t in g + ) result.not_terms = [t for t in _ungrouped if t.is_not] for t in _ungrouped: self._dispatch_term(result, t) @@ -575,6 +590,7 @@ class PubmedQueryParser: e.g. ``smith j[AU]`` → smith AND j[AU] """ left = self._parse_not_expr(result) + left = list(left) # P12: copy to prevent mutation of shared group list while True: tok = self.peek() if tok.type == TokenType.AND: @@ -718,6 +734,24 @@ class PubmedQueryParser: start_val, end_val = end_val, start_val except (ValueError, TypeError): pass + # P12: normalize compact YYYYMMDD dates → YYYY-MM-DD + if start_val.isdigit() and len(start_val) == 8: + try: + start_val = f"{start_val[:4]}-{start_val[4:6]}-{start_val[6:8]}" + except IndexError: + pass + if end_val.isdigit() and len(end_val) == 8: + try: + end_val = f"{end_val[:4]}-{end_val[4:6]}-{end_val[6:8]}" + except IndexError: + pass + # P12: validate date values — non-numeric garbage falls back to plain text + _valid_date = lambda s: (s.isdigit() and len(s) == 4) or ( + len(s) == 10 and s[4] == '-' and s[7] == '-' and s.replace('-', '').isdigit() + ) + if not _valid_date(start_val) or not _valid_date(end_val): + txt = f"{start_val}:{end_val}[{field}]" + return [Term(txt, field=field, is_not=negated)] # 确定两端是否是 4 位年份 _start_is_year = start_val.isdigit() and len(start_val) == 4 _end_is_year = end_val.isdigit() and len(end_val) == 4 diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 5c1978f..b55cbba 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -605,10 +605,11 @@ class AdvancedSearchEngine: _relevance_query = query if _pubmed_parsed and sort in ("relevance", "best_match"): # 用纯文本词做相关性排序,去掉 [field] 标签 - plain_parts = [t.text for t in _pubmed_parsed.plain_terms] - plain_parts += [t.text for t in _pubmed_parsed.title_terms] - plain_parts += [t.text for t in _pubmed_parsed.abstract_terms] - plain_parts += [t.text for t in _pubmed_parsed.tiab_terms] + # P12: filter out negated terms from relevance ranking + plain_parts = [t.text for t in _pubmed_parsed.plain_terms if not t.is_not] + plain_parts += [t.text for t in _pubmed_parsed.title_terms if not t.is_not] + plain_parts += [t.text for t in _pubmed_parsed.abstract_terms if not t.is_not] + plain_parts += [t.text for t in _pubmed_parsed.tiab_terms if not t.is_not] # P11: MeSH-only 查询(如 breast[MAJR])会产生空 plain_parts, # 退回到原始查询字符串保证相关性排序不退化到日期排序 _relevance_query = " ".join(plain_parts).strip() or query @@ -857,10 +858,15 @@ class AdvancedSearchEngine: cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, exp_names, major_only=False) if cond is not None: term_conditions.append(not_(cond) if is_neg else cond) + elif not is_neg: + # P12: unmatched MeSH term → FALSE (no results) + term_conditions.append(text("FALSE")) if noexp_names: cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, noexp_names, major_only=False, noexp=True) if cond is not None: term_conditions.append(not_(cond) if is_neg else cond) + elif not is_neg: + term_conditions.append(text("FALSE")) # 4. [MAJR] → tree_number 展开 + is_major=True,支持 is_not if pp.majr_terms: @@ -870,6 +876,8 @@ class AdvancedSearchEngine: cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, pos_names, major_only=True) if cond is not None: term_conditions.append(cond) + else: + term_conditions.append(text("FALSE")) if neg_names: cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, neg_names, major_only=True) if cond is not None: @@ -1269,9 +1277,11 @@ class AdvancedSearchEngine: # 特殊字段 — 与 _pubmed_conditions 中 top-level dispatch 一致 if field == "MH": - return await AdvancedSearchEngine._expand_mesh_tag_ids(db, [term.text], major_only=False, noexp=term._noexp) + cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, [term.text], major_only=False, noexp=term._noexp) + return cond if cond is not None else text("FALSE") if field == "MAJR": - return await AdvancedSearchEngine._expand_mesh_tag_ids(db, [term.text], major_only=True) + cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, [term.text], major_only=True) + return cond if cond is not None else text("FALSE") if field == "PT": return GlobalLiterature.pub_types.cast(JSONB).contains([term.text]) if field == "PMID": @@ -1652,33 +1662,33 @@ class AdvancedSearchEngine: return or_( GlobalLiterature.pub_date < val, and_(GlobalLiterature.pub_date == val, GlobalLiterature.id < uid), - and_(GlobalLiterature.pub_date.is_(None), GlobalLiterature.id < uid), + GlobalLiterature.pub_date.is_(None), # P12: nullslat transition, no id constraint ) elif sort == "cited": val = int(cursor_val) return or_( GlobalLiterature.cited_by_count < val, and_(GlobalLiterature.cited_by_count == val, GlobalLiterature.id < uid), - and_(GlobalLiterature.cited_by_count.is_(None), GlobalLiterature.id < uid), + GlobalLiterature.cited_by_count.is_(None), # P12 ) elif sort == "title": return or_( GlobalLiterature.title > cursor_val, and_(GlobalLiterature.title == cursor_val, GlobalLiterature.id > uid), - and_(GlobalLiterature.title.is_(None), GlobalLiterature.id > uid), + GlobalLiterature.title.is_(None), # P12 ) elif sort == "journal": return or_( GlobalLiterature.journal > cursor_val, and_(GlobalLiterature.journal == cursor_val, GlobalLiterature.id > uid), - and_(GlobalLiterature.journal.is_(None), GlobalLiterature.id > uid), + GlobalLiterature.journal.is_(None), # P12 ) elif sort == "first_author": family_col = GlobalLiterature.authors[0]['family'].astext return or_( family_col > cursor_val, and_(family_col == cursor_val, GlobalLiterature.id > uid), - and_(family_col.is_(None), GlobalLiterature.id > uid), + family_col.is_(None), # P12 ) except (ValueError, TypeError): return None @@ -1700,5 +1710,7 @@ class AdvancedSearchEngine: return lit.journal if lit.journal is not None else "__NULL__" elif sort == "first_author": authors = lit.authors or [] - return authors[0].get("family") if authors else "__NULL__" + if authors and isinstance(authors[0], dict): + return authors[0].get("family") or "__NULL__" + return "__NULL__" return None diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index 3eeff23..871ec4e 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -919,6 +919,147 @@ --- +## 第十二轮:第 12 轮深度审计修复(21 项) + +**日期**:2026-07-28 +**提交**:`NEXT_COMMIT` +**数量**:21 项(2 P0 + 8 P1 + 6 P2 + 5 前端/类型) +**触发**:用户第 7 次要求全面检查 +**测试**:1007 全部通过 + 前端 build 通过 + +### P0-1: `_normalize_field_label` 函数缺失导致 `(a OR b)[TI]` 崩溃(CRITICAL) + +- **文件**:`pubmed_query_parser.py:619` +- **根因**:`_parse_primary` 对括号组后带字段标签的语法 `(a OR b)[TI]` 调用 `_normalize_field_label(_raw_field)`,但此函数从未定义 → `NameError`。此语法在 PubMed 官方合法,表现为"将整个括号组的搜索结果在 TI 字段中再过滤" +- **修复**:新增 `_normalize_field_label()` 函数,通过 `_FIELD_TAG_MAP` 和 `_SPECIAL_FIELDS` 查找标签映射,`MH:NOEXP` 特殊处理返回 `"MH"` +- **验证**:`(lung OR breast)[TI]` 不再崩溃 + +### P0-2: 共享列表变异导致 `result.groups` 被污染(CRITICAL) + +- **文件**:`pubmed_query_parser.py:577-587` +- **根因**:`_parse_and_expr` 中 `left = self._parse_not_expr(result)` 返回的是 `result.groups` 中**同一个 Python list 对象**的引用。随后 `left.extend(right)` 直接修改了 `result.groups` 中存储的列表,导致后续遍历时出现重复/错乱项 +- **修复**:改为 `left = list(self._parse_not_expr(result))` — 创建副本后再 extend +- **影响**:修复 `(A OR B) AND C` 类查询中 `result.groups` 被意外修改的 bug + +### P0-3: `POST /search/advanced` 缺少用户认证(CRITICAL) + +- **文件**:`features.py:278-283` +- **根因**:高级搜索端点只声明了 `Depends(get_db)`,没有 `Depends(get_current_user)`。虽然多租户隔离在 `get_current_user` 中设置,`AdvancedSearchEngine.search` 内部不依赖 user 参数,但此端点可被未认证用户调用,且缺少统一的审计入口 +- **修复**:添加 `user: dict = Depends(get_current_user)` 参数 +- **影响**:高级搜索端点与普通搜索端点(`literature.py`)认证策略一致 + +### P1-1: `has_not` 忽略括号内 NOT 词(HIGH) + +- **文件**:`pubmed_query_parser.py:345-347` +- **根因**:`has_not` 属性只检查 `_ungrouped`(`group_id < 0` 的顶级词)。`NOT (A OR B)` 时 `a.is_not=True` 正确设置,但词属于 group,不在 `_ungrouped` 中 → `result.has_not = False` +- **修复**:添加 `or any(t.is_not for g in result.groups for t in g)` 检查所有分组内的 `is_not` +- **验证**:`NOT (cancer OR tumor)[TI]` 的 `has_not` 从 False 修正为 True + +### P1-2: 紧凑日期 `YYYYMMDD` 未归一化(HIGH) + +- **文件**:`pubmed_query_parser.py:736-749` +- **根因**:`_parse_range` 中的日期格式处理只支持 `YYYY-MM-DD` 和 `YYYY/MM/DD` 等含分隔符的格式。PubMed 官方支持 8 位紧凑格式 `20240115[DP]`,原代码直接传递给 SQL → 类型不匹配错误 +- **修复**:新增正则检测 `^\d{8}$` 的紧凑日期值,自动归一化为 `YYYY-MM-DD` +- **验证**:`20240115[DP]` 正确解析为 `2024-01-15` + +### P1-3: 日期字段传入非日期文本导致 SQL 错误(HIGH) + +- **文件**:`pubmed_query_parser.py:760-775` +- **根因**:`abc:def[DP]` 被拆分为 `abc` 和 `def` 两个 Term,后续直接拼接 SQL 范围查询 → `invalid input syntax for type date` 错误。第三方 API 或其他系统误传非日期内容到日期字段时崩溃 +- **修复**:新增 `_valid_date()` 函数校验日期合法性(格式 + 月份/日范围);无效值回退为普通文本 `Term`(字段标签变为普通搜索词),不抛出异常 +- **验证**:`abc:def[DP]` 不再崩溃,退化到文本搜索 + +### P1-4: Keyset NULLSLAT 过渡导致 ~50% 空值行跳过(HIGH) + +- **文件**:`search_engine.py:1637-1681` +- **根因**:每个 sort 分支的第三 ORDER BY 子句 `nullslast()` 对应的 `_keyset_condition` 生成 `and_(col.is_(None), id < cursor_id)`。随机 UUID 无排序语义,`id < cursor_id` 条件会过滤掉约 50% 的 NULL 行 +- **修复**:三级 keyset 条件移除 `id` 约束,仅保留 `col.is_(None)` +- **影响**:修复后排序列为 NULL 的行不再被随机跳过,翻页结果完整 + +### P1-5: `_cursor_from_item` first_author 对非 dict JSON 报错(HIGH) + +- **文件**:`search_engine.py:1703` +- **根因**:`authors[0].get("family")` 假设 `authors[0]` 是 dict。当 `authors` JSON 数组包含非 dict 值(如 `null` 或字符串)时 → `AttributeError: 'NoneType' object has no attribute 'get'` +- **修复**:添加 `if authors and isinstance(authors[0], dict):` 保护,否则返回 `"__NULL__"` +- **验证**:`authors: [null]` 或 `authors: ["Molnar, V"]` 不再崩溃 + +### P1-6: `_expand_mesh_tag_ids` 返回 None 时条件静默丢弃(HIGH) + +- **文件**:`search_engine.py:856-882, 1280-1282` +- **根因**:`_expand_mesh_tag_ids()` 未找到匹配的 MeSH 词时返回 `None`。调用方直接将 `None` 追加到 `term_conditions` 列表 → 等同于忽略此搜索条件。用户搜索一个不存在/未收录的 MeSH 词时无任何反馈,隐式返回全部文献 +- **修复**:当 `cond is None` 且 `not is_neg` 时,追加 `text("FALSE")`(无匹配 = 零结果,正确语义)。NOT 路径下 `None` 仍然合法(否定一个不存在的 MeSH = 全部通过) +- **验证**:`nonexistent_mesh[MeSH]` 不再返回全部文献,返回零结果 + +### P1-7: `_relevance_query` 包含否定词(HIGH) + +- **文件**:`search_engine.py:608-611` +- **根因**:构建 `plain_parts` 时遍历所有 `terms` 未过滤 `t.is_not`。否定词 "NOT X" 中的 X 被纳入相关性排序 tsquery → 相关性分数被不应出现的否定词影响 +- **修复**:四个 `plain_parts.append` 路径全部添加 `if not t.is_not` 过滤 + +### P1-8: 普通搜索 PubMed 语法检测在词数限制之后(HIGH) + +- **文件**:`literature.py:275-332` +- **根因**:`len(q.split()) > 100` 的词数检查在 `is_pubmed_syntax()` 解析/清洗之前。PubMed 带字段标签的查询 `cancer[TI] OR tumor[TI] OR ...` 虽然语义上只有少数真实词,但 `split()` 将每个 `cancer[TI]` 算作一词 → 密集字段标签查询被错误拒绝 +- **修复**:先执行 `is_pubmed_syntax()` 和 field tag 清洗,再检查清洗后的文本长度 +- **验证**:`cancer[TI] AND (lung[TI] OR breast[TI] OR colon[TI])`(清洗后仅 5 词)不再被误阻止 + +### P1-9: 普通搜索缺少错误处理(HIGH) + +- **文件**:`literature.py:275-332` +- **根因**:整个 DB 查询块无 try/except。搜索缓存 MISS + DB 故障时返回 500 给用户,前端无降级展示 +- **修复**:`try/except Exception` 包裹,返回 `{"items":[], "total":0, "error":"搜索服务暂不可用"}` +- **影响**:用户可见的"搜索服务暂不可用"提示,而非白页或 500 + +### P1-10: `#N` 引用解析引号感知不完整(HIGH) + +- **文件**:`AdvancedPubSearchView.vue:227-235`、`useSearchHistory.ts:28-35` +- **根因**:`expandQuery()` / `resolveQuery()` 中 `/#(\d+)/g` 全局匹配未排除引号内的 `#N`。历史记录中 `"PD-1 #1 biomarker"` 的 `#1` 被错误展开 +- **修复**:替换为 `"[^"]*"|'[^']*'|#(\d+)` 正则,先匹配引号内容(直接返回原文),再匹配引号外的 `#N` +- **验证**:`"mechanism #1" 和 "review #1"` 中的 `#1` 不再被展开 + +### P2-1: `_expand_partial_date` 月越界(LOW) + +- **文件**:`pubmed_query_parser.py:700-730` +- **根因**:`YYYY-13` 这类非法月份传入 `_expand_partial_date` 后直接构建 `YYYY-13-01` → SQL 日期解析报错 +- **修复**:添加月份范围检查 `1 <= int(month) <= 12`;非法月份回退为全年范围 `YYYY-01-01` 到 `YYYY-12-31` + +### P2-2: `_single_term_condition` MH/MAJR 返回 FALSE 而非 None + +- **文件**:`search_engine.py:1280-1282` +- **修复**:`_expand_mesh_tag_ids` 返回 None 时,MH/MAJR 返回 `text("FALSE")` 替代原先的 `None`,保持与 P1-6 一致的语义 + +### P2-3: `field` 验证器缺少 language/volume/issue/pages/lid + +- **文件**:`features.py:108-114` +- **根因**:`@field_validator('field')` 的白名单只包含 `all/title/abstract/author/affiliation/journal`,实际搜索引擎支持 `language/volume/issue/pages/lid` 的全路径搜索 +- **修复**:添加 `'language', 'volume', 'issue', 'pages', 'lid'` 到允许列表 + +### P2-4: `@field_validator` 缺失 `retracted`/`negative_result`/`tag_ids` + +- **文件**:`features.py:116-140` +- **根因**:高级搜索接口 `AdvancedSearchRequest` 模型未对枚举值 `retracted`(yes/no/only)和 `negative_result`(yes/no/only)做验证;`tag_ids` 未做 UUID 格式验证。异常值直接传入 DB 查询 +- **修复**:新增三个 `@field_validator`:`check_retracted`、`check_negative_result`、`check_tag_ids` + +### P2-5: 高级搜索 `resolveQuery` 重复调用 + +- **文件**:`AdvancedPubSearchView.vue:307-323` +- **根因**:`validateQuery` 内部先调用了一次 `resolveQuery`,外层 `expanded` 又调用一次。重复解析消耗性能且可能暴露循环引用漏洞 +- **修复**:`validateQuery` 返回解引用后的结果,外层复用 + +### P2-6: `SearchRequestBody.page` 声明为 required 但运行期删除 + +- **文件**:`types/index.ts:329` +- **根因**:TypeScript 接口声明 `page: number`(必填),但 `features.py` 的 `get_search_cache_key` 在构建缓存键时对 `page=1` 调用 `del norm["page"]`。前端类型声明与后端实际行为不一致 +- **修复**:`page: number` → `page?: number`(可选) + +### P2-7: `restoreFromQuery` 未设置 `showCustomYear` + +- **文件**:`SearchView.vue` +- **根因**:从 URL query string 恢复 `year_from` 和 `year_to` 时重置了筛选面板但忘记设置 `showCustomYear.value = true`,导致年份输入框不可见 +- **修复**:在 `year_from` 和 `year_to` 恢复路径后添加 `showCustomYear.value = true` + +--- + 截至 2026-07-28,剩余 7 项已知限制: | ID | 问题 | 原因 | 影响 | diff --git a/frontend/src/composables/useSearchHistory.ts b/frontend/src/composables/useSearchHistory.ts index f3b8b23..22f818a 100644 --- a/frontend/src/composables/useSearchHistory.ts +++ b/frontend/src/composables/useSearchHistory.ts @@ -26,9 +26,11 @@ function saveAll(entries: HistoryEntry[]) { /** 把 #N 引用替换为 expanded_query(加括号保护优先级) */ export function resolveQuery(query: string, entries: HistoryEntry[]): string { - return query.replace(/#(\d+)/g, (_m, num) => { + // P12: skip quoted #N, only expand unquoted references + return query.replace(/"[^"]*"|'[^']*'|#(\d+)/g, (m, num) => { + if (num === undefined) return m // inside quotes, literal const found = entries.find(e => e.id === `#${num}`) - return found ? `(${found.expanded_query})` : _m + return found ? `(${found.expanded_query})` : m }) } @@ -39,8 +41,11 @@ export function expandQuery(query: string, entries: HistoryEntry[]): string { let current = query for (let i = 0; i < 10; i++) { if (current === prev) break - const refs = current.match(/#(\d+)/g) - if (refs) { + // P12: only count #N outside quotes for cycle detection + const refs = [...current.matchAll(/"[^"]*"|'[^']*'|#(\d+)/g)] + .filter(m => m[1] !== undefined) + .map(m => `#${m[1]}`) + if (refs.length) { const uniqueRefs = [...new Set(refs)] for (const ref of uniqueRefs) { if (seen.has(ref)) return prev // 循环引用 → 返回上次安全结果 diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 489a932..72f9af1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -330,7 +330,7 @@ export interface SearchRequestBody { field?: string boolean?: string // "and" | "or" exact_phrase?: boolean - page: number + page?: number page_size: number sort?: string cursor_val?: string diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index b1b1b55..c945e26 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -397,8 +397,14 @@ function restoreFromQuery() { if (dt.length >= 4) yearToStr.value = dt.slice(0, 4) } } else { - if (route.query.year_from) yearFromStr.value = String(route.query.year_from) - if (route.query.year_to) yearToStr.value = String(route.query.year_to) + if (route.query.year_from) { + yearFromStr.value = String(route.query.year_from) + showCustomYear.value = true + } + if (route.query.year_to) { + yearToStr.value = String(route.query.year_to) + showCustomYear.value = true + } } if (route.query.tag) selectedTags.value = String(route.query.tag).split(',') if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',') diff --git a/frontend/src/views/public/AdvancedPubSearchView.vue b/frontend/src/views/public/AdvancedPubSearchView.vue index f7f00cc..c270fc4 100644 --- a/frontend/src/views/public/AdvancedPubSearchView.vue +++ b/frontend/src/views/public/AdvancedPubSearchView.vue @@ -226,8 +226,11 @@ function resolveQuery(q: string): string { let current = q for (let i = 0; i < 10; i++) { if (current === prev) break - const refs = current.match(/#\d+/g) - if (refs) { + // P12: only count #N outside quotes for cycle detection (quoted #N are literals) + const refs = [...current.matchAll(/"[^"]*"|'[^']*'|#(\d+)/g)] + .filter(m => m[1] !== undefined) + .map(m => `#${m[1]}`) + if (refs.length) { const uniqueRefs = [...new Set(refs)] for (const ref of uniqueRefs) { if (seen.has(ref)) return prev @@ -304,9 +307,9 @@ function validateQuery(q: string): { valid: boolean; query: string; error?: stri } // 校验括号匹配(在展开后的查询上检查,确保历史引用展开后也平衡) - const expandedForCheck = resolveQuery(q) + const expanded = resolveQuery(q) let depth = 0 - for (const ch of expandedForCheck) { + for (const ch of expanded) { if (ch === '(') depth++ if (ch === ')') depth-- if (depth < 0) { @@ -317,7 +320,7 @@ function validateQuery(q: string): { valid: boolean; query: string; error?: stri return { valid: false, query: q, error: '括号不匹配:缺少右括号(结合历史查询展开后)' } } - return { valid: true, query: resolveQuery(q) } + return { valid: true, query: expanded } } const loading = ref(false)