fix: PubMed搜索合规 — 34项修复 + query_expansion UnboundLocalError + HomeView precision_mode残留
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

Batch1 — 解析器 (pubmed_query_parser.py)
- P0-1: 未知字段标签降级为 WORD token 而非 ParseError
- P2-1: 增加未消费 token 检查
- P2-2: PRISMA 字段标签正则 [\w:+] → [\w:]+
- P2-3: Unicode NFKC 规格化输入
- P2-4: re.ASCII 防止 Unicode 数字匹配
- P2-9: 移除重复 dataclass 字段

Batch2 — 搜索引擎 (search_engine.py)
- P1-1: 批量 PMID 查询替代 N+1 循环
- P1-2: isdigit() → isdecimal()
- P1-5: 移除 precision_mode 参数
- P2-5: 移除死代码
- P2-6: 统一 _CHINESE_RE 正则

Batch3 — ATM 引擎 (query_expansion.py)
- P0-4: name_zh ILIKE 中文回退 + _find_mesh_tags 中文降级
- P1-3: name_en ILIKE 加 LIMIT 100

Batch4 — API 层 (features.py)
- P1-5: 移除 precision_mode 请求字段
- P1-11: 增加 logging
- P2-8: NLM_SUBSET_LABELS f-string 安全注释
- P2-12: split 校验器近似性注释

Batch5 — SearchView.vue
- P0-5a/b/c: 日期修复(UTC 方法、互斥逻辑、restoreFromQuery 合并)
- P2-10: 筛选模态关闭时重搜
- P3-1: 搜索框 aria-label

Batch6 — HomeView.vue
- P1-10: URL date → date_from/date_to
- P2-11: clearSearch 清空 feedItems 并重加载

Batch7 — LiteratureCard.vue
- P1-6: 字段标签正则 [\w-]+ → [\w:-]+
- P1-7: terms 切片限制 20 项防 ReDoS

后修复:
- query_expansion.py _find_partial_mesh_tags UnboundLocalError(单非中文词未初始化 tag_ids)
- HomeView.vue handleAdvancedSearch precision_mode 残留引用
This commit is contained in:
34047007@qq.com
2026-07-27 09:45:17 +08:00
parent 43392438c8
commit 723c4fc5c9
8 changed files with 129 additions and 81 deletions
@@ -111,7 +111,7 @@ const nctId = computed(() => {
// 从查询中提取纯文本词(去掉 PubMed 字段标签如 [TI]、[AB] 等)
function extractPlainText(q: string): string {
return q
.replace(/\[[\w-]+\]/g, '') // [field tags]
.replace(/\[[\w:-]+\]/g, '') // [field tags] including [MH:noexp]
.replace(/"?\b(AND|OR|NOT)\b"?/gi, '')// boolean operators
.replace(/[()]/g, '') // P3-4: 去掉括号
.replace(/#\d+/g, '') // P3-4: 去掉 #N 引用标记
@@ -127,7 +127,8 @@ const highlightedTitle = computed(() => {
const plain = extractPlainText(raw)
if (!plain) return ''
// 拆分为独立词项,逐词高亮(多词查询不拼成一个连写短语)
const terms = plain.split(/\s+/).filter(t => t.length > 0)
// P1-7: 限制最多 20 个高亮词,防止正则 ReDoS
const terms = plain.split(/\s+/).filter(t => t.length > 0).slice(0, 20)
if (terms.length === 0) return ''
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
// P3-3: 单字符词添加 \b 词边界,但 CJK 字符跳过(\b 对 CJK 无效)
+33 -28
View File
@@ -50,7 +50,6 @@ const hasFullText = ref(false)
const hasAssociatedData = ref(false)
const medlineOnly = ref(false)
const excludePreprints = ref(false)
const precisionMode = ref('majr') // 'majr' | 'mesh'
// ── 筛选选项数据 ──
const filterOptions = ref<any>(null)
@@ -294,17 +293,18 @@ const { page, total, goToPage } = usePagination({
// cursor 不存在时保留 body.page,回退到 offset 分页
}
}
// 年份 / 日期
// 年份 / 日期datePreset 与 year_* 互斥)
const yf = Number(yearFromStr.value)
const yt = Number(yearToStr.value)
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
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
if (datePreset.value && datePreset.value !== 'custom') {
body.date_to = new Date().toISOString().slice(0, 10)
const d = new Date()
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
const now = new Date()
body.date_to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString().slice(0, 10)
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
if (datePreset.value === '1y') d.setUTCFullYear(d.getUTCFullYear() - 1)
else if (datePreset.value === '5y') d.setUTCFullYear(d.getUTCFullYear() - 5)
else if (datePreset.value === '10y') d.setUTCFullYear(d.getUTCFullYear() - 10)
body.date_from = d.toISOString().slice(0, 10)
}
// Text Availability
@@ -369,15 +369,16 @@ function restoreFromQuery() {
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
if (route.query.date_preset && ['1y','5y','10y'].includes(String(route.query.date_preset))) {
datePreset.value = String(route.query.date_preset)
} else if (route.query.date_from) {
} else if (route.query.date_from || route.query.date_to) {
datePreset.value = null
const df = String(route.query.date_from)
if (df.length >= 4) yearFromStr.value = df.slice(0, 4)
}
if (route.query.date_to) {
datePreset.value = null
const dt = String(route.query.date_to)
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
if (route.query.date_from) {
const df = String(route.query.date_from)
if (df.length >= 4) yearFromStr.value = df.slice(0, 4)
}
if (route.query.date_to) {
const dt = String(route.query.date_to)
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
}
}
if (route.query.tag) selectedTags.value = String(route.query.tag).split(',')
if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',')
@@ -395,7 +396,6 @@ function restoreFromQuery() {
if (route.query.associated_data) hasAssociatedData.value = route.query.associated_data === 'true'
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.precision) precisionMode.value = String(route.query.precision)
if (route.query.p) restoredPage.value = parseInt(String(route.query.p)) || 1
// keyset 游标不能从 URL 恢复 → page>1 回退到首页
if (sort.value === 'date' && restoredPage.value > 1) restoredPage.value = 1
@@ -410,6 +410,11 @@ watch(datePreset, (val) => {
}
})
// P2-10: 弹窗关闭时(mask-closable 或确定按钮)自动搜索
watch(showPubTypeModal, (val) => { if (!val && searched.value) goToPage(1) })
watch(showLangModal, (val) => { if (!val && searched.value) goToPage(1) })
watch(showAgeModal, (val) => { if (!val && searched.value) goToPage(1) })
// pageSize 变化时持久化并重新搜索
watch(pageSize, (v) => {
localStorage.setItem('search:pageSize', String(v))
@@ -451,17 +456,19 @@ function syncSearchToUrl() {
if (query.value) q.q = query.value
if (field.value !== 'all') q.field = field.value
if (sort.value !== 'date') q.sort = sort.value
if (yearFromStr.value) q.year_from = yearFromStr.value
if (yearToStr.value) q.year_to = yearToStr.value
if (datePreset.value && datePreset.value !== 'custom') {
q.date_preset = datePreset.value
// 同时保存计算出的 date_from/date_to,使分享的 URL 能直接恢复
q.date_to = new Date().toISOString().slice(0, 10)
const d = new Date()
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
const now = new Date()
q.date_to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString().slice(0, 10)
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
if (datePreset.value === '1y') d.setUTCFullYear(d.getUTCFullYear() - 1)
else if (datePreset.value === '5y') d.setUTCFullYear(d.getUTCFullYear() - 5)
else if (datePreset.value === '10y') d.setUTCFullYear(d.getUTCFullYear() - 10)
q.date_from = d.toISOString().slice(0, 10)
} else {
if (yearFromStr.value) q.year_from = yearFromStr.value
if (yearToStr.value) q.year_to = yearToStr.value
}
if (selectedTags.value.length) q.tag = selectedTags.value.join(',')
if (selectedTiers.value.length) q.tier = selectedTiers.value.join(',')
@@ -479,7 +486,6 @@ function syncSearchToUrl() {
if (hasAssociatedData.value) q.associated_data = 'true'
if (medlineOnly.value) q.medline_only = 'true'
if (excludePreprints.value) q.exclude_preprints = 'true'
if (precisionMode.value !== 'majr') q.precision = precisionMode.value
// 页码持久化到 URL(不同排序下使用各自的分页)
const currentPage = sort.value === 'date' ? keysetPage.value : page.value
if (currentPage > 1) q.p = String(currentPage)
@@ -505,7 +511,6 @@ function resetAllFilters() {
hasAssociatedData.value = false
medlineOnly.value = false
excludePreprints.value = false
precisionMode.value = 'majr'
query.value = ''
field.value = 'all'
sort.value = 'date'
@@ -793,7 +798,7 @@ 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 @keyup.enter="goToPage(1)" /></div>
<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" />
<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>
+9 -11
View File
@@ -25,7 +25,6 @@ const searchParams = ref({
date_to: null as string | null,
retracted: '',
negative_result: '',
precision_mode: 'majr',
page_size: 20,
sort: 'date',
})
@@ -148,7 +147,6 @@ async function fetchData(resetPage = true) {
if (searchParams.value.date_to) body.date_to = searchParams.value.date_to
if (searchParams.value.retracted) body.retracted = searchParams.value.retracted
if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result
if (searchParams.value.precision_mode) body.precision_mode = searchParams.value.precision_mode
// keyset 游标
if (cursorDate.value && cursorId.value) {
body.cursor_date = cursorDate.value
@@ -225,11 +223,13 @@ function clearSearch() {
localQuery.value = ''
searchParams.value = {
query: '', field: 'all', tag_ids: [], date_from: null, date_to: null,
retracted: '', negative_result: '', precision_mode: 'majr',
retracted: '', negative_result: '',
page_size: 20, sort: 'date',
}
selectedTagIds.value = []
showAdvanced.value = false
feedItems.value = []
loadHomepageFeed()
}
// ── 高级搜索 ──
@@ -242,7 +242,6 @@ function handleAdvancedSearch(params: {
tag_ids: string[]
retracted: string
negative_result: string
precision_mode: string
sort: string
}) {
showAdvanced.value = false
@@ -256,7 +255,6 @@ function handleAdvancedSearch(params: {
if (params.retracted) q.retracted = params.retracted
if (params.negative_result) q.negative = params.negative_result
if (params.sort !== 'date') q.sort = params.sort
if (params.precision_mode !== 'majr') q.precision = params.precision_mode
router.push({ name: 'public-search', query: q })
}
@@ -349,17 +347,16 @@ async function loadMore() {
// ── 从 URL 恢复状态 ──
function restoreFromUrl() {
const tag = route.query.tag as string
const date = route.query.date as string
const dateFrom = route.query.date_from as string
const dateTo = route.query.date_to as string
const q = route.query.q as string
if (tag) {
const ids = tag.split(',')
selectedTagIds.value = ids
searchParams.value.tag_ids = ids
}
if (date) {
searchParams.value.date_from = date
searchParams.value.date_to = date
}
if (dateFrom) searchParams.value.date_from = dateFrom
if (dateTo) searchParams.value.date_to = dateTo
if (q) {
searchParams.value.query = q
localQuery.value = q
@@ -376,7 +373,8 @@ watch(searchKey, () => {
if (!_mounted.value) return
const query: Record<string, string> = {}
if (searchParams.value.tag_ids.length) query.tag = searchParams.value.tag_ids.join(',')
if (searchParams.value.date_from) query.date = searchParams.value.date_from
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
router.replace({ query })
})