fix: 第10轮搜索审计修复 — 缓存键PubMed标志、NOT OR语义、affiliation ILIKE、前端布尔OR切换和精确短语

- 后端: _search_cache_key和_facet_cache_key加入"pm": _is_pm(query)防止PubMed/纯文本路径缓存碰撞
- 后端: OR模式下neg_conds不再AND到所有条件,改用or_(*term_conditions)直接组合
- 后端: _field_condition("all")所有5条分支添加affiliation ILIKE通过jsonb_array_elements(authors)子查询
- 后端: AdvancedSearchRequest.query添加max_length=2000
- 前端: SearchView搜索栏添加AND/OR切换器和精确短语复选框
- 前端: exact_phrase参数从SearchView传递到API请求
- 前端: booleanOp和exactPhrase同步到URL并可从URL恢复
This commit is contained in:
34047007@qq.com
2026-07-28 11:41:08 +08:00
parent b26f5572e9
commit a985d07724
3 changed files with 38 additions and 6 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ async def test_rule(req: TestRuleRequest, pmid: int = Query(...), db: AsyncSessi
# ─── 高级搜索 ───
class AdvancedSearchRequest(BaseModel):
query: str = ""
query: str = Field("", max_length=2000)
field: str = "all"
boolean: str = "and"
exact_phrase: bool = False
+24 -3
View File
@@ -55,8 +55,10 @@ class AdvancedSearchEngine:
) -> str:
"""归一化查询参数 → 确定性缓存 key(所有 list 排序后参与哈希)"""
import hashlib, json
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
norm = {
"q": query.strip().lower(),
"pm": _is_pm(query),
"f": field, "b": boolean, "ep": exact_phrase,
"yf": year_from, "yt": year_to,
"df": date_from, "dt": date_to,
@@ -109,8 +111,10 @@ class AdvancedSearchEngine:
与 _search_cache_key 的区别:不含 p/ps/s/cd/ci。
"""
import hashlib, json
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
norm = {
"q": query.strip().lower(),
"pm": _is_pm(query),
"f": field, "b": boolean, "ep": exact_phrase,
"yf": year_from, "yt": year_to,
"df": date_from, "dt": date_to,
@@ -1144,10 +1148,13 @@ class AdvancedSearchEngine:
# 将 term_conditions 加入 conditions
if term_conditions:
if pp.boolean_operator in ("or", "mixed"):
if pp.boolean_operator == "or":
# OR 模式:所有条件(含 NOT)OR 在一起
conditions.append(or_(*term_conditions))
elif pp.boolean_operator == "mixed":
# mixed 模式下 NOT 项应独立 ANDPubMed: A OR B NOT C = (A OR B) AND NOT C
from sqlalchemy.sql.elements import UnaryExpression
from sqlalchemy.sql import operators as _sa_ops
# OR/mixed 模式下 NOT 项应独立 ANDPubMed: A OR B NOT C = (A OR B) AND NOT C
pos_conds = [c for c in term_conditions
if not (isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv)]
neg_conds = [c for c in term_conditions
@@ -1382,7 +1389,11 @@ class AdvancedSearchEngine:
pat = _pt()
if exact and not _wildcard:
# P4: 精确短语 → phraseto_tsquery(利用 GIN 索引,保留词序)
return GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term))
return or_(
GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term)),
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
)
if _wildcard:
# wildcard → ILIKE 右截断(tsvector 不支持 *),多字段覆盖
return or_(
@@ -1393,6 +1404,8 @@ class AdvancedSearchEngine:
GlobalLiterature.journal_iso.ilike(pat),
cast(GlobalLiterature.pmid, String).ilike(pat),
GlobalLiterature.doi.ilike(pat),
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
)
like_val = f"%{_escaped}%"
if "/" in term:
@@ -1400,6 +1413,8 @@ class AdvancedSearchEngine:
return or_(
GlobalLiterature.doi.ilike(_escape_ilike(term)),
GlobalLiterature.doi.ilike(like_val),
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
)
return or_(
GlobalLiterature.title.ilike(like_val),
@@ -1408,6 +1423,8 @@ class AdvancedSearchEngine:
GlobalLiterature.abstract.ilike(like_val),
GlobalLiterature.author_names_text.ilike(like_val),
GlobalLiterature.journal.ilike(like_val),
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
)
# P7-D2: Chinese → ILIKE fallback (tsvector is English-only)
if re.search(r'[一-鿿㐀-䶿豈-﫿]', term):
@@ -1416,6 +1433,8 @@ class AdvancedSearchEngine:
GlobalLiterature.abstract.ilike(like_val),
GlobalLiterature.author_names_text.ilike(like_val),
GlobalLiterature.journal.ilike(like_val),
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
)
# tsvector 索引主覆盖 title/abstract/author_names/chemicals/genes/mesh/keywords
# journal/journal_iso/affiliation 不在 tsvector 中,以 ILIKE 兜底
@@ -1423,6 +1442,8 @@ class AdvancedSearchEngine:
GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term)),
GlobalLiterature.journal.ilike(like_val),
GlobalLiterature.journal_iso.ilike(like_val),
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
)
@staticmethod
+13 -2
View File
@@ -44,6 +44,8 @@ const negativeResult = ref('') // '' = all, 'yes', 'no', 'only'
const selectedSpecies = ref<string[]>([])
const selectedSex = ref<string[]>([])
const selectedAge = ref<string[]>([])
const booleanOp = ref('and') // "and" | "or"
const exactPhrase = ref(false)
// ── PubMed 筛选器状态 ──
const hasAbstract = ref(false)
@@ -277,8 +279,9 @@ const { page, total, goToPage } = usePagination({
}
const body: SearchRequestBody = {
query: query.value, page: p, page_size: pageSize.value, sort: sort.value,
boolean: 'and',
boolean: booleanOp.value,
}
if (exactPhrase.value) body.exact_phrase = true
if (field.value !== 'all') body.field = field.value
// P3-6: precision_mode 不再发送(后端已忽略)
// ── Keyset 游标分页(所有排序模式通用,跳过 COUNT + OFFSET ──
@@ -401,6 +404,8 @@ function restoreFromQuery() {
if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',')
if (route.query.pub_type) pubTypes.value = String(route.query.pub_type).split(',')
if (route.query.lang) languages.value = String(route.query.lang).split(',')
if (route.query.boolean === 'or') booleanOp.value = 'or'
if (route.query.exact_phrase === 'true') exactPhrase.value = true
if (route.query.subset) nlmSubsets.value = String(route.query.subset).split(',')
if (route.query.retracted) retracted.value = String(route.query.retracted)
if (route.query.negative) negativeResult.value = String(route.query.negative)
@@ -514,6 +519,8 @@ function syncSearchToUrl() {
if (hasAssociatedData.value) q.associated_data = 'true'
if (medlineOnly.value) q.medline_only = 'true'
if (excludePreprints.value) q.exclude_preprints = 'true'
if (booleanOp.value !== 'and') q.boolean = booleanOp.value
if (exactPhrase.value) q.exact_phrase = 'true'
if (pageSize.value !== 20) q.page_size = String(pageSize.value)
// 页码持久化到 URLKeyset 排序使用 keyset 页码,offset 排序使用 offset 页码)
const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value
@@ -544,6 +551,8 @@ function resetAllFilters() {
query.value = ''
field.value = 'all'
sort.value = 'date'
booleanOp.value = 'and'
exactPhrase.value = false
goToPage(1)
}
@@ -829,7 +838,9 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
<div style="flex:1;min-width:0">
<div class="search-bar">
<div class="search-input-wrap"><NInput v-model:value="query" placeholder='搜索标题、摘要、作者、MeSH词... 支持PubMed语法如 "lung cancer"[TI]' size="small" clearable aria-label="搜索文献" @keyup.enter="goToPage(1)" /></div>
<NSelect v-model:value="field" :options="[{label:'全部',value:'all'},{label:'标题',value:'title'},{label:'摘要',value:'abstract'},{label:'作者',value:'author'},{label:'机构',value:'affiliation'},{label:'期刊',value:'journal'}]" size="small" />
<NSelect v-model:value="booleanOp" :options="[{label:'AND',value:'and'},{label:'OR',value:'or'}]" size="small" style="width:75px;flex-shrink:0" />
<NCheckbox v-model:checked="exactPhrase" size="small" style="flex-shrink:0;font-size:13px;white-space:nowrap">精确短语</NCheckbox>
<NSelect v-model:value="field" :options="[{label:'全部',value:'all'},{label:'标题',value:'title'},{label:'摘要',value:'abstract'},{label:'作者',value:'author'},{label:'机构',value:'affiliation'},{label:'期刊',value:'journal'}]" size="small" style="width:90px;flex-shrink:0" />
<NButton class="sky-btn" size="small" :loading="loading" :disabled="loading" @click="goToPage(1)"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton>
<NButton class="sky-btn" :ghost="showFilters" size="small" @click="showFilters=!showFilters"><template #icon><NIcon size="14"><component :is="showFilters ? EyeOffOutline : FilterOutline" /></NIcon></template>{{ showFilters?'隐藏筛选':'筛选' }}</NButton>
</div>