fix: 第12轮搜索审计修复 — _normalize_field_label 崩溃/keyset NULLSLAT/共享列表变异等21项修复
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | 问题 | 原因 | 影响 |
|
||||
|
||||
@@ -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 // 循环引用 → 返回上次安全结果
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user