diff --git a/backend/app/api/v1/features.py b/backend/app/api/v1/features.py index e97449f..96f3ab9 100644 --- a/backend/app/api/v1/features.py +++ b/backend/app/api/v1/features.py @@ -87,7 +87,7 @@ class AdvancedSearchRequest(BaseModel): medline_only: bool = False exclude_preprints: bool = False - # ── 查询复杂度限制 ── + # ── 参数验证 ── @field_validator('query') @classmethod def check_query_complexity(cls, v: str) -> str: @@ -96,6 +96,27 @@ class AdvancedSearchRequest(BaseModel): raise ValueError('查询词过多(最多 100 个词),请简化搜索条件') return v + @field_validator('sort') + @classmethod + def check_sort(cls, v: str) -> str: + if v not in ('date', 'cited', 'relevance', 'first_author', 'journal', 'title'): + raise ValueError(f'无效排序方式: {v}') + return v + + @field_validator('field') + @classmethod + def check_field(cls, v: str) -> str: + if v not in ('all', 'title', 'abstract', 'author', 'affiliation', 'journal'): + raise ValueError(f'无效搜索字段: {v}') + return v + + @field_validator('boolean') + @classmethod + def check_boolean(cls, v: str) -> str: + if v not in ('and', 'or'): + raise ValueError(f'无效布尔操作符: {v}') + return v + # ── 筛选选项 ── diff --git a/backend/app/api/v1/literature.py b/backend/app/api/v1/literature.py index 1b89793..cac6c85 100644 --- a/backend/app/api/v1/literature.py +++ b/backend/app/api/v1/literature.py @@ -257,6 +257,8 @@ async def search_literature( ): if not q.strip(): return {"items": [], "total": 0} + if len(q.split()) > 100: + return {"items": [], "total": 0, "error": "查询词过多(最多 100 个词),请简化搜索条件"} offset = (page - 1) * page_size like = f"%{q}%" # tsvector 主搜索 + ILIKE 兜底 diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index bf85b3c..516822e 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -43,6 +43,7 @@ _FIELD_TAG_MAP: dict[str, str] = { "LAU": "author", "TW": "all", "OT": "all", + "ALL": "all", # P3-7: [ALL] = 全部字段 "MESH": "MH", # P1-2: [MESH] 是 [MH] 的别名 # 新增标量字段 "LA": "language", @@ -73,7 +74,7 @@ _DATE_RANGE_FIELDS = {"DP", "EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"} # 所有合法字段标签(PubMed 全量字段) # P1-1: 移除了 BOOK/FILTER/ISBN(未实现,降级为 plain text 不如报错透明) _ALL_FIELD_TAGS = { - "AB", "AD", "AU", "CN", "FAU", "AUID", "LAU", "COIS", + "AB", "AD", "ALL", "AU", "CN", "FAU", "AUID", "LAU", "COIS", "DCOM", "CRDT", "EDAT", "MHDA", "LR", "DP", "DOI", "DEP", # P1-2: Date of Electronic Publication "RN", "ED", "GR", "IR", "IP", @@ -362,6 +363,28 @@ class PubmedQueryParser: result.pharmaco_terms.append(term) elif term.field is None: result.plain_terms.append(term) + # ── 独立日期字段(非范围语法):"2024-01-01"[DP] → from=to=该日期 ── + elif term.field == "DP": + result.date_from = term.text + result.date_to = term.text + elif term.field == "EDAT": + result.edat_from = term.text + result.edat_to = term.text + elif term.field == "CRDT": + result.crdt_from = term.text + result.crdt_to = term.text + elif term.field == "MHDA": + result.mhda_from = term.text + result.mhda_to = term.text + elif term.field == "LR": + result.lr_from = term.text + result.lr_to = term.text + elif term.field == "DCOM": + result.dcom_from = term.text + result.dcom_to = term.text + elif term.field == "DEP": + result.dep_from = term.text + result.dep_to = term.text elif term.field == "__RANGE_DP__": pass elif term.field == "__RANGE_EDAT__": @@ -536,6 +559,8 @@ class PubmedQueryParser: # 反向范围自动交换(如 2026:2024[DP] → 2024:2026[DP]) if start_val.isdigit() and end_val.isdigit() and int(start_val) > int(end_val): start_val, end_val = end_val, start_val + elif not start_val.isdigit() and not end_val.isdigit() and start_val > end_val: + start_val, end_val = end_val, start_val # Year-only range (e.g., 2024:2026[EDAT]) if start_val.isdigit() and len(start_val) == 4: if yr_from_attr: diff --git a/backend/app/services/query_expansion.py b/backend/app/services/query_expansion.py index 063acc7..f5bff61 100644 --- a/backend/app/services/query_expansion.py +++ b/backend/app/services/query_expansion.py @@ -95,7 +95,7 @@ async def _find_mesh_tags(db: AsyncSession, query: str) -> list[UUID]: seen.add(tid) # 方法 C: name_zh ILIKE 匹配(P0-4: 中文查询降级) - if not tag_ids and re.search(r'[一-鿿]', q): + if not tag_ids and re.search(r'[一-鿿㐀-䶿豈-﫿]', q): stmt = select(GlobalTag.id).where( GlobalTag.source.in_(["mesh", "manual"]), GlobalTag.name_zh.ilike(like_pattern), @@ -117,7 +117,7 @@ async def _find_partial_mesh_tags(db: AsyncSession, query: str) -> list[UUID]: words = [w.strip().lower() for w in query.strip().split() if len(w.strip()) >= MIN_QUERY_LENGTH][:10] if len(words) < 2: # P0-4: 中文查询无法按空格分词,尝试整体 name_zh ILIKE 匹配 - if re.search(r'[一-鿿]', query): + if re.search(r'[一-鿿㐀-䶿豈-﫿]', query): tag_ids = [] stmt = select(GlobalTag.id).where( GlobalTag.source.in_(["mesh", "manual"]), diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 156d6b0..10583c9 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -172,8 +172,9 @@ class AdvancedSearchEngine: or pp.databank_terms or pp.pharmaco_terms or pp.ed_terms or pp.investigator_terms or pp.personal_name_terms or pp.pubnote_terms or pp.auid_terms or pp.cois_terms or pp.tt_terms + or pp.sb_terms or pp.stat_terms or pp.uid_terms or pp.edat_from or pp.crdt_from or pp.mhda_from - or pp.lr_from or pp.dcom_from + or pp.lr_from or pp.dcom_from or pp.dep_from or pp.plain_terms or pp.has_not or pp.year_from or pp.year_to ) @@ -199,8 +200,9 @@ class AdvancedSearchEngine: or _pubmed_parsed.databank_terms or _pubmed_parsed.pharmaco_terms or _pubmed_parsed.ed_terms or _pubmed_parsed.investigator_terms or _pubmed_parsed.personal_name_terms or _pubmed_parsed.pubnote_terms or _pubmed_parsed.auid_terms or _pubmed_parsed.cois_terms or _pubmed_parsed.tt_terms + or _pubmed_parsed.sb_terms or _pubmed_parsed.stat_terms or _pubmed_parsed.uid_terms or _pubmed_parsed.edat_from or _pubmed_parsed.crdt_from or _pubmed_parsed.mhda_from - or _pubmed_parsed.lr_from or _pubmed_parsed.dcom_from + or _pubmed_parsed.lr_from or _pubmed_parsed.dcom_from or _pubmed_parsed.dep_from or _pubmed_parsed.plain_terms or _pubmed_parsed.has_not or _pubmed_parsed.year_from or _pubmed_parsed.year_to) ]): @@ -269,7 +271,10 @@ class AdvancedSearchEngine: _atm_cond = None _atm_query = query.replace('"', '').replace("'", '').strip() if _atm_query and field == "all": - _atm_cond = await _expand_atm(db, _atm_query) + try: + _atm_cond = await _expand_atm(db, _atm_query) + except Exception: + _atm_cond = None _cond_before = len(conditions) if boolean == "and": @@ -627,7 +632,10 @@ class AdvancedSearchEngine: combined = " ".join(t.text for t in pp.plain_terms if not t.is_not).strip() if combined and not re.search(r'[一-鿿㐀-䶿豈-﫿]', combined): from app.services.query_expansion import expand_atm as _expand_atm_inline - atm_cond = await _expand_atm_inline(db, combined) + try: + atm_cond = await _expand_atm_inline(db, combined) + except Exception: + atm_cond = None if atm_cond is not None: text_cond = and_(*plain_conds) if len(plain_conds) > 1 else plain_conds[0] term_conditions.append(or_(atm_cond, text_cond)) @@ -826,16 +834,27 @@ class AdvancedSearchEngine: neg_conds = [GlobalLiterature.vernacular_title.ilike(f"%{_escape_ilike(t.text)}%") for t in neg] term_conditions.append(not_(or_(*neg_conds))) - # P1-2: [SB] Subset → nlm_subsets(需 journal JOIN 子查询) + # P1-2: [SB] Subset + # medline[SB] → citation_status='medline'(记录级) + # pubmed[SB] → no-op(全部记录都在 PubMed 中) + # 单字母代码(AIM/M/S/D/N/Q/T/X)→ nlm_subsets(期刊级) if pp.sb_terms: pos = [t for t in pp.sb_terms if not t.is_not] neg = [t for t in pp.sb_terms if t.is_not] for subl, is_neg in [(pos, False), (neg, True)]: for t in subl: - subq = select(GlobalJournal.issn).where( - GlobalJournal.nlm_subsets.overlap([t.text.upper()]) - ) - cond = GlobalLiterature.journal_issn.in_(subq) + val = t.text.upper() + if val == "PUBMED": + continue # no-op: 所有记录都是 PubMed + elif val == "MEDLINE": + cond = GlobalLiterature.citation_status == "medline" + elif len(val) == 1 and val.isalpha(): + subq = select(GlobalJournal.issn).where( + GlobalJournal.nlm_subsets.overlap([val]) + ) + cond = GlobalLiterature.journal_issn.in_(subq) + else: + cond = GlobalLiterature.citation_status == val.lower() term_conditions.append(not_(cond) if is_neg else cond) # P1-2: [STAT] Status → citation_status @@ -879,7 +898,7 @@ class AdvancedSearchEngine: else "and") combine_fn = or_ if gop == "or" else and_ combined = combine_fn(*group_conds) if len(group_conds) > 1 else group_conds[0] - if all_not and len(group_conds) > 1: + if all_not and len(group_conds) >= 1: combined = not_(combined) term_conditions.append(combined) @@ -1135,46 +1154,52 @@ class AdvancedSearchEngine: # 1b. name_en ILIKE 回退 — batch via OR name_conds.append(GlobalTag.name_en.ilike(m)) - if entry_conds: - rows = (await db.execute( - select(GlobalTag.id).where( - GlobalTag.source.in_(["mesh", "manual"]), - GlobalTag.mesh_ui.isnot(None), - GlobalTag.entry_terms.isnot(None), - or_(*entry_conds), - ) - )).all() - for (tid,) in rows: - mesh_tag_ids.add(tid) + try: + if entry_conds: + rows = (await db.execute( + select(GlobalTag.id).where( + GlobalTag.source.in_(["mesh", "manual"]), + GlobalTag.mesh_ui.isnot(None), + GlobalTag.entry_terms.isnot(None), + or_(*entry_conds), + ) + )).all() + for (tid,) in rows: + mesh_tag_ids.add(tid) - if name_conds: - rows = (await db.execute( - select(GlobalTag.id).where( - GlobalTag.source.in_(["mesh", "manual"]), - GlobalTag.mesh_ui.isnot(None), - or_(*name_conds), - ) - )).all() - for (tid,) in rows: - mesh_tag_ids.add(tid) + if name_conds: + rows = (await db.execute( + select(GlobalTag.id).where( + GlobalTag.source.in_(["mesh", "manual"]), + GlobalTag.mesh_ui.isnot(None), + or_(*name_conds), + ) + )).all() + for (tid,) in rows: + mesh_tag_ids.add(tid) + except Exception: + pass if not mesh_tag_ids: return None # tree_number 前缀展开:取匹配 tag 的所有 tree_number,查子节点([MH:noexp] 时跳过) if not noexp: - tns = (await db.execute( - select(GlobalTagTreeNumber.tree_number).where( - GlobalTagTreeNumber.tag_id.in_(list(mesh_tag_ids)) - ).distinct() - )).scalars().all() - - if tns: - child_conds = [GlobalTagTreeNumber.tree_number.like(f"{tn}%") for tn in tns] - children = (await db.execute( - select(GlobalTagTreeNumber.tag_id).where(or_(*child_conds)) + try: + tns = (await db.execute( + select(GlobalTagTreeNumber.tree_number).where( + GlobalTagTreeNumber.tag_id.in_(list(mesh_tag_ids)) + ).distinct() )).scalars().all() - mesh_tag_ids.update(children) + + if tns: + child_conds = [GlobalTagTreeNumber.tree_number.like(f"{tn}%") for tn in tns] + children = (await db.execute( + select(GlobalTagTreeNumber.tag_id).where(or_(*child_conds)) + )).scalars().all() + mesh_tag_ids.update(children) + except Exception: + pass uids = list(mesh_tag_ids) if major_only: diff --git a/backend/scripts/import_mesh_full.py b/backend/scripts/import_mesh_full.py index b1c58d4..33f9362 100644 --- a/backend/scripts/import_mesh_full.py +++ b/backend/scripts/import_mesh_full.py @@ -92,8 +92,8 @@ def collect_entry_terms(record) -> list[str]: terms: set[str] = set() for term_elem in record.findall(".//Term/String"): if term_elem.text: - term = term_elem.text.strip() - if term and term.lower() != descriptor_name: + term = term_elem.text.strip().lower() # P3-7: 统一小写以匹配 JSONB @> 精确比较 + if term and term != descriptor_name: terms.add(term) return sorted(terms) diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index 2d92de9..fc954e8 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -31,6 +31,7 @@ const savedPmids = ref>(new Set()) // ── 筛选参数 ── const yearFromStr = ref(''); const yearToStr = ref('') +const urlDateFrom = ref(''); const urlDateTo = ref('') // 来自 URL 的完整日期(保留精度) const datePreset = ref(null) const selectedTiers = ref([]) const selectedTags = ref([]) @@ -299,6 +300,10 @@ const { page, total, goToPage } = usePagination({ if (datePreset.value === 'custom') { if (yearFromStr.value && !isNaN(yf) && yf >= 1900 && yf <= 2100) body.year_from = yf if (yearToStr.value && !isNaN(yt) && yt >= 1900 && yt <= 2100) body.year_to = yt + } else if (!datePreset.value && (urlDateFrom.value || urlDateTo.value)) { + // 来自 URL 的完整日期(保留月日精度) + if (urlDateFrom.value) body.date_from = urlDateFrom.value + if (urlDateTo.value) body.date_to = urlDateTo.value } else { if (!datePreset.value && yearFromStr.value && !isNaN(yf) && yf >= 1900 && yf <= 2100) body.year_from = yf if (!datePreset.value && yearToStr.value && !isNaN(yt) && yt >= 1900 && yt <= 2100) body.year_to = yt @@ -378,10 +383,12 @@ function restoreFromQuery() { datePreset.value = null if (route.query.date_from) { const df = String(route.query.date_from) + urlDateFrom.value = df // 保留完整日期避免精度丢失 if (df.length >= 4) yearFromStr.value = df.slice(0, 4) } if (route.query.date_to) { const dt = String(route.query.date_to) + urlDateTo.value = dt if (dt.length >= 4) yearToStr.value = dt.slice(0, 4) } } @@ -402,6 +409,10 @@ function restoreFromQuery() { if (route.query.medline_only) medlineOnly.value = route.query.medline_only === 'true' if (route.query.exclude_preprints) excludePreprints.value = route.query.exclude_preprints === 'true' if (route.query.p) restoredPage.value = parseInt(String(route.query.p)) || 1 + if (route.query.page_size) { + const ps = parseInt(String(route.query.page_size)) + if (ps >= 10 && ps <= 100) pageSize.value = ps + } // keyset 游标不能从 URL 恢复 → page>1 回退到首页 if (sort.value === 'date' && restoredPage.value > 1) restoredPage.value = 1 } @@ -411,8 +422,12 @@ watch(datePreset, (val) => { if (val && val !== 'custom') { yearFromStr.value = '' yearToStr.value = '' + urlDateFrom.value = ''; urlDateTo.value = '' // 清除 URL 日期 if (searched.value) goToPage(1) } + if (val === 'custom') { + urlDateFrom.value = ''; urlDateTo.value = '' // 自定义年份覆盖 URL 日期 + } }) // P2-10: 弹窗关闭时(mask-closable 或确定按钮)自动搜索 @@ -491,6 +506,7 @@ function syncSearchToUrl() { if (hasAssociatedData.value) q.associated_data = 'true' if (medlineOnly.value) q.medline_only = 'true' if (excludePreprints.value) q.exclude_preprints = 'true' + if (pageSize.value !== 20) q.page_size = String(pageSize.value) // 页码持久化到 URL(不同排序下使用各自的分页) const currentPage = sort.value === 'date' ? keysetPage.value : page.value if (currentPage > 1) q.p = String(currentPage) diff --git a/frontend/src/views/public/HomeView.vue b/frontend/src/views/public/HomeView.vue index 9f96034..8061337 100644 --- a/frontend/src/views/public/HomeView.vue +++ b/frontend/src/views/public/HomeView.vue @@ -370,7 +370,7 @@ function restoreFromUrl() { // ── 同步状态到 URL ── const _mounted = ref(false) const searchKey = computed(() => - JSON.stringify([searchParams.value.query, searchParams.value.tag_ids, searchParams.value.date_from, searchParams.value.date_to, searchParams.value.sort]) + JSON.stringify([searchParams.value.query, searchParams.value.tag_ids, searchParams.value.date_from, searchParams.value.date_to, searchParams.value.sort, searchParams.value.field, searchParams.value.retracted, searchParams.value.negative_result]) ) watch(searchKey, () => { @@ -380,6 +380,10 @@ watch(searchKey, () => { if (searchParams.value.date_from) query.date_from = searchParams.value.date_from if (searchParams.value.date_to) query.date_to = searchParams.value.date_to if (searchParams.value.query) query.q = searchParams.value.query + if (searchParams.value.sort && searchParams.value.sort !== 'date') query.sort = searchParams.value.sort + if (searchParams.value.field && searchParams.value.field !== 'all') query.field = searchParams.value.field + if (searchParams.value.retracted) query.retracted = searchParams.value.retracted + if (searchParams.value.negative_result) query.negative = searchParams.value.negative_result router.replace({ query }) })