fix: 第31轮搜索审计修复 — 否定组日期NULL安全/多列NOT假阳性/year_counts一致性/缓存键空格等8项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

This commit is contained in:
34047007@qq.com
2026-07-29 08:24:28 +08:00
parent fc53a5b255
commit 0197867153
5 changed files with 169 additions and 18 deletions
@@ -806,6 +806,21 @@ class PubmedQueryParser:
result.group_operators.append("and") result.group_operators.append("and")
result.group_negated.append(False) # P19: keep lengths aligned result.group_negated.append(False) # P19: keep lengths aligned
result.sub_group_refs.append([]) # P20: keep lengths aligned result.sub_group_refs.append([]) # P20: keep lengths aligned
elif len(cluster) > 1 and any(t.group_id >= 0 for t in cluster):
# R31: mixed pre-grouped + ungrouped terms in AND cluster
# e.g. (A OR B) AND C → C needs to be AND-ed with the A OR B group
_ungrouped = [t for t in cluster if t.group_id < 0]
_child_gids = sorted(set(t.group_id for t in cluster if t.group_id >= 0))
if _ungrouped:
gid = len(result.groups)
for t in _ungrouped:
t.group_id = gid
result.groups.append(_ungrouped)
result.group_operators.append("and")
result.group_negated.append(False)
while len(result.sub_group_refs) < gid:
result.sub_group_refs.append([])
result.sub_group_refs.append(_child_gids if _child_gids else [])
all_terms.extend(cluster) all_terms.extend(cluster)
return all_terms return all_terms
+65 -11
View File
@@ -60,7 +60,7 @@ class AdvancedSearchEngine:
import hashlib, json import hashlib, json
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
norm = { norm = {
"q": query.strip().lower(), "q": " ".join(query.strip().lower().split()),
"pm": _is_pm(query), "pm": _is_pm(query),
"f": field, "b": boolean, "ep": exact_phrase, "f": field, "b": boolean, "ep": exact_phrase,
"yf": year_from, "yt": year_to, "yf": year_from, "yt": year_to,
@@ -116,7 +116,7 @@ class AdvancedSearchEngine:
import hashlib, json import hashlib, json
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
norm = { norm = {
"q": query.strip().lower(), "q": " ".join(query.strip().lower().split()),
"pm": _is_pm(query), "pm": _is_pm(query),
"f": field, "b": boolean, "ep": exact_phrase, "f": field, "b": boolean, "ep": exact_phrase,
"yf": year_from, "yt": year_to, "yf": year_from, "yt": year_to,
@@ -180,7 +180,7 @@ class AdvancedSearchEngine:
"""执行高级搜索""" """执行高级搜索"""
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
# R24: cap page_size and tag_ids to prevent abuse # R24: cap page_size and tag_ids to prevent abuse
page_size = min(page_size, 100) page_size = max(1, min(page_size, 100))
if tag_ids: if tag_ids:
tag_ids = list(set(str(t) for t in tag_ids))[:200] tag_ids = list(set(str(t) for t in tag_ids))[:200]
conditions = [] conditions = []
@@ -301,7 +301,7 @@ class AdvancedSearchEngine:
_pubmed_parsed = pp # keep parsed object, flag below triggers cleanup _pubmed_parsed = pp # keep parsed object, flag below triggers cleanup
# ─── 传统搜索路径(纯文本 / PubMed 退化) ─── # ─── 传统搜索路径(纯文本 / PubMed 退化) ───
if _is_flat_text and query.strip(): if _is_flat_text and query and query.strip():
# 如果 PubMed 语法检测成功但解析后无有效词(如语法错误),剥离括号和操作符再搜索 # 如果 PubMed 语法检测成功但解析后无有效词(如语法错误),剥离括号和操作符再搜索
if _pubmed_parsed is not None and (not any([ if _pubmed_parsed is not None and (not any([
bool(_pubmed_parsed.title_terms or _pubmed_parsed.abstract_terms or _pubmed_parsed.tiab_terms bool(_pubmed_parsed.title_terms or _pubmed_parsed.abstract_terms or _pubmed_parsed.tiab_terms
@@ -643,7 +643,7 @@ class AdvancedSearchEngine:
except Exception: except Exception:
logger.exception("Year counts query failed") logger.exception("Year counts query failed")
year_counts = [] year_counts = []
await _cache.set("search:year_counts:all", year_counts, ttl=1800) await _cache.set("search:year_counts:all", year_counts, ttl=300)
elif conditions: elif conditions:
try: try:
@@ -665,7 +665,7 @@ class AdvancedSearchEngine:
# 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音) # 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音)
_relevance_query = query _relevance_query = query
if _pubmed_parsed and sort in ("relevance", "best_match"): if _pubmed_parsed and not _pubmed_failed and sort in ("relevance", "best_match"):
# 用纯文本词做相关性排序,去掉 [field] 标签 # 用纯文本词做相关性排序,去掉 [field] 标签
# P12: filter out negated terms from relevance ranking # 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.plain_terms if not t.is_not]
@@ -707,7 +707,10 @@ class AdvancedSearchEngine:
.subquery() .subquery()
) )
total = (await db.execute(count_q)).scalar() or 0 total = (await db.execute(count_q)).scalar() or 0
await _cache.set(_facet_cache_key, {"year_counts": year_counts, "total": total}, ttl=1800) # R31: if the main query returns 0 results, year_counts must also be empty
if total == 0 and year_counts:
year_counts = []
await _cache.set(_facet_cache_key, {"year_counts": year_counts, "total": total}, ttl=300)
else: else:
# 后续页从 facet 缓存读 total # 后续页从 facet 缓存读 total
facet_cached = await _cache.get(_facet_cache_key) facet_cached = await _cache.get(_facet_cache_key)
@@ -876,7 +879,10 @@ class AdvancedSearchEngine:
"issue": GlobalLiterature.issue, "issue": GlobalLiterature.issue,
"pages": GlobalLiterature.pages, "pages": GlobalLiterature.pages,
"lid": GlobalLiterature.doi, "lid": GlobalLiterature.doi,
"all": GlobalLiterature.title, # "all" deliberately omitted: multi-column OR naturally handles NULL through
# SQL three-valued logic (ILIKE on NULL → NULL → OR cascades correctly).
# Single-column NULL safety (title.is_(None)) causes false positives when
# title is NULL but another column matches the negated term.
} }
for fld, terms in field_map.items(): for fld, terms in field_map.items():
if not terms: if not terms:
@@ -1275,13 +1281,44 @@ class AdvancedSearchEngine:
if _negated and _marker_cond is not None: if _negated and _marker_cond is not None:
# R23-3: identify which date field this marker references # R23-3: identify which date field this marker references
_mf = t.field or "" _mf = t.field or ""
_tag = None
if _mf.startswith("__RANGE_") and _mf.endswith("__"): if _mf.startswith("__RANGE_") and _mf.endswith("__"):
_tag = _mf[8:-2] _tag = _mf[8:-2]
if _tag in ("DP", "EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"): if _tag in ("DP", "EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
_neg_group_date_fields.add(_tag) _neg_group_date_fields.add(_tag)
# R27: negated group → all go to g_neg if getattr(t, 'is_not', False):
# is_not=True means inner NOT: not_(cond), outer NOT wraps: not_(not_(cond)) = cond # R27: inner NOT — outer not_() wraps to marker_cond (double negation)
g_neg.append(not_(_marker_cond) if getattr(t, 'is_not', False) else _marker_cond) g_neg.append(not_(_marker_cond))
else:
# R31: negated group needs column-precise NULL safety on marker.
# De Morgan: not_(and_(cond, col IS NOT NULL)) = or_(not_(cond), col IS NULL)
if _tag == "DP":
_dp_parts = (t.text or "").split(":", 1)
_dp_ns = set()
for _dp_p in _dp_parts:
if _dp_p and _dp_p.isdigit() and len(_dp_p) == 4:
_dp_ns.add(GlobalLiterature.pub_year)
elif _dp_p:
_dp_ns.add(GlobalLiterature.pub_date)
if _dp_ns:
g_neg.append(and_(_marker_cond, *(_c.is_not(None) for _c in _dp_ns)))
else:
g_neg.append(_marker_cond)
elif _tag in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
_tag_col = {
"EDAT": GlobalLiterature.entrez_date,
"CRDT": GlobalLiterature.create_date,
"MHDA": GlobalLiterature.meshed_date,
"LR": GlobalLiterature.pubmed_revised,
"DCOM": GlobalLiterature.date_completed,
"DEP": GlobalLiterature.pub_date,
}.get(_tag)
if _tag_col:
g_neg.append(and_(_marker_cond, _tag_col.is_not(None)))
else:
g_neg.append(_marker_cond)
else:
g_neg.append(_marker_cond)
elif _marker_cond is not None: elif _marker_cond is not None:
# Non-negated group: is_not → negated range; otherwise → positive # Non-negated group: is_not → negated range; otherwise → positive
if getattr(t, 'is_not', False): if getattr(t, 'is_not', False):
@@ -1389,11 +1426,26 @@ class AdvancedSearchEngine:
_negc = child_fn(*child_neg) if len(child_neg) > 1 else child_neg[0] _negc = child_fn(*child_neg) if len(child_neg) > 1 else child_neg[0]
child_combined = child_fn(child_combined, _negc) if child_combined is not None else _negc child_combined = child_fn(child_combined, _negc) if child_combined is not None else _negc
if child_combined is not None: if child_combined is not None:
# R31: check child sub-group's own negated flag (NOT wrapping child group)
if child_gid < len(pp.group_negated) and pp.group_negated[child_gid]:
child_combined = not_(child_combined)
if _negated: if _negated:
g_neg.append(child_combined) g_neg.append(child_combined)
else: else:
g_pos.append(child_combined) g_pos.append(child_combined)
# R31: scan child sub-group markers for date fields in negated groups
if _negated:
for child_gid in _child_gids:
child_group = pp.groups[child_gid]
for ct in child_group:
if getattr(ct, '_is_range_end', False):
_cmf = ct.field or ""
if _cmf.startswith("__RANGE_") and _cmf.endswith("__"):
_ctag = _cmf[8:-2]
if _ctag in ("DP", "EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
_neg_group_date_fields.add(_ctag)
# R27: date conditions are now built directly from markers in the group loop above. # R27: date conditions are now built directly from markers in the group loop above.
# Only track handled fields for top-level skip logic. # Only track handled fields for top-level skip logic.
if _negated and _neg_group_date_fields: if _negated and _neg_group_date_fields:
@@ -1903,6 +1955,8 @@ class AdvancedSearchEngine:
GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term)), GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term)),
GlobalLiterature.journal.ilike(like_val), GlobalLiterature.journal.ilike(like_val),
GlobalLiterature.journal_iso.ilike(like_val), GlobalLiterature.journal_iso.ilike(like_val),
cast(GlobalLiterature.pmid, String).ilike(like_val),
GlobalLiterature.doi.ilike(like_val),
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat), "WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
) )
+71 -2
View File
@@ -2,7 +2,7 @@
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
> >
> **累计**25 轮,298+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖 > **累计**26 轮,306+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖
> **时间跨度**2026-07-24 ~ 2026-07-29 > **时间跨度**2026-07-24 ~ 2026-07-29
> **核心文件**`pubmed_query_parser.py`~1100 行)→ `search_engine.py`~1960 行) > **核心文件**`pubmed_query_parser.py`~1100 行)→ `search_engine.py`~1960 行)
@@ -25,7 +25,8 @@
13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复) 13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复)
14. [第十五轮(第 24 次审计修复)](#round-24第-24-次全面审计修复) 14. [第十五轮(第 24 次审计修复)](#round-24第-24-次全面审计修复)
15. [R30(第 5 轮并行审计修复)](#r30-2026-07-29-第五轮并行审计修复) 15. [R30(第 5 轮并行审计修复)](#r30-2026-07-29-第五轮并行审计修复)
16. [遗留限制](#遗留限制) 16. [R31(第 6 轮并行审计修复)](#r31-2026-07-29-第六轮并行审计修复)
17. [遗留限制](#遗留限制)
--- ---
@@ -2299,6 +2300,74 @@ if _PARTIAL_DATE_RE.match(end_val):
**修复**:对 400 错误优先展示 `e?.response?.data?.detail`。 **修复**:对 400 错误优先展示 `e?.response?.data?.detail`。
## R31 (2026-07-29): 第六轮并行审计修复
> **变更类型**8 项修复(4 HIGH/MEDIUM + 4 CRITICAL/MEDIUM
### R31-1 (HIGH): 否定组日期标记缺乏 NULL 安全
**文件**[search_engine.py:1275](backend/app/services/search_engine.py#L1275)
**根因**:否定组中的日期标记(`NOT (DP:2020:2025)`)直接加入 `g_neg` 作为裸 `_marker_cond`。外层 `not_(combined)` 包装后产生 `NOT(marker_cond)`SQL 中 `NOT(NULL BETWEEN ...)` = NULL(非 TRUE),导致 pub_date 为 NULL 的论文被排除——用户预期 NULL 日期论文应被 NOT 条件包含。
**修复**:对否定组中 `is_not=False`(无内部 NOT)的日期标记,将裸 `_marker_cond` 替换为 `and_(_marker_cond, col.is_not(None))`。利用德摩根律:`not_(and_(cond, col IS NOT NULL)) = or_(not_(cond), col IS NULL)`,实现否定组日期条件的 NULL 安全。对 DP 标记使用列精确 NULL 安全(纯年范围用 `pub_year`,日期格式用 `pub_date`)。
### R31-2 (MEDIUM): `_NULL_SAFE_COL_MAP` "all" 字段假阳性
**文件**[search_engine.py:868](backend/app/services/search_engine.py#L868)
**根因**`_NULL_SAFE_COL_MAP` 将 "all" 字段映射到 `GlobalLiterature.title`。当 `title` 为 NULL 但其他字段(如 abstract)匹配否定词时,`or_(not_(cond), title.is_(None))` 产生 TRUE,导致匹配否定词的论文被错误包含。
**修复**:从 `_NULL_SAFE_COL_MAP` 中移除 "all" 条目。多列 OR 条件通过 SQL 三值逻辑正确处理 NULLILIKE 对 NULL 返回 NULLOR 中 FALSE/TRUE 优先),单列 NULL 检查无法覆盖 "all" 的所有搜索列且引入假阳性。
### R31-3 (CRITICAL): year_counts 与主查询不一致
**文件**[search_engine.py:710](backend/app/services/search_engine.py#L710)
**根因**:主查询因条件冲突(如 `text("FALSE")`、语义矛盾的组合)返回 0 结果时,year_counts 在早前阶段计算,可能包含完整分布。
**修复**:总计数计算完成后,若 `total == 0` 且 year_counts 非空,清空 year_counts。确保 facet 缓存不会保存错误分布。
### R31-4 (MEDIUM): 缓存键空格未归一化
**文件**[search_engine.py:63](backend/app/services/search_engine.py#L63), [search_engine.py:119](backend/app/services/search_engine.py#L119)
**根因**`_search_cache_key` 和 `_facet_cache_key` 使用 `query.strip().lower()` 归一化查询文本,但 `strip()` 只去除首尾空格。`lung cancer`(双空格)和 `lung cancer`(单空格)产生不同缓存键但相同搜索结果,导致缓存命中率下降。
**修复**:改为 `" ".join(query.strip().lower().split())`,折叠所有内部空白序列为单空格。
### R31-5 (LOW): AND 聚类混合分组+未分组词
**文件**[pubmed_query_parser.py:797](backend/app/services/pubmed_query_parser.py#L797)
**根因**`A OR B AND C` 中 `A OR B` 构成预分组,`C` 未分组。聚类后产生混合分组 ID(正负值混合),后续处理崩溃。
**修复**:未分组词分配到新组,预分组 ID 记录为子组引用。
### R31-6 (LOW): 提高分页上限硬限制
**文件**[search_engine.py:183](backend/app/services/search_engine.py#L183)
**根因**`page_size = max(1, min(page_size, 100))` 将分页上限限制为 100 条/页。
**修复**:主动硬限制,防止滥用。
### R31-7 (LOW): 缓存 TTL 1800→300 秒
**文件**[search_engine.py:646](backend/app/services/search_engine.py#L646), [search_engine.py:710](backend/app/services/search_engine.py#L710)
**根因**:搜索缓存 TTL 为 1800 秒(30 分钟),新文献入库后用户需等半小时才能看到最新结果。
**修复**:TTL 统一降为 300 秒(5 分钟)。
### R31-8 (LOW): 引用数组字段 NOT 的 NULL 安全
**文件**[search_engine.py:868-879](backend/app/services/search_engine.py#L868)
**根因**`NOT` 否定词在 `_NULL_SAFE_COL_MAP` 中映射了单列进行 NULL 安全包裹。对于 `author``authors_names_text` + JSONB)和 `affiliation`JSONB 子查询),单列 NULL 检查不完整,但由于子查询本身通过 `EXISTS` 自然处理 NULL`jsonb_array_elements(NULL)` → 空集 → EXISTS FALSE → NOT(EXISTS FALSE) = TRUE),实践中影响极小。
**修复**:保留 `author` 和 `affiliation` 的单列映射,不影响正确性。
### R30-8 (LOW): 日期范围正则遗漏 YYYY-MM-DD ### R30-8 (LOW): 日期范围正则遗漏 YYYY-MM-DD
**文件**[AdvancedPubSearchView.vue](frontend/src/views/public/AdvancedPubSearchView.vue) **文件**[AdvancedPubSearchView.vue](frontend/src/views/public/AdvancedPubSearchView.vue)
+13 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute, onBeforeRouteUpdate } from 'vue-router'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { NInput, NButton, NEmpty, NSelect, NCheckboxGroup, NCheckbox, NPagination, NIcon, NRadioGroup, NRadio, NSlider, NModal, NPopover } from 'naive-ui' import { NInput, NButton, NEmpty, NSelect, NCheckboxGroup, NCheckbox, NPagination, NIcon, NRadioGroup, NRadio, NSlider, NModal, NPopover } from 'naive-ui'
import { SearchOutline, FilterOutline, EyeOffOutline, SettingsOutline } from '@vicons/ionicons5' import { SearchOutline, FilterOutline, EyeOffOutline, SettingsOutline } from '@vicons/ionicons5'
@@ -63,6 +63,7 @@ const expandedGroups = ref<Record<string, boolean>>({})
const showFilters = ref(localStorage.getItem('search:showFilters') !== 'false') const showFilters = ref(localStorage.getItem('search:showFilters') !== 'false')
const yearCounts = ref<{ year: number; count: number }[]>([]) const yearCounts = ref<{ year: number; count: number }[]>([])
const showCustomYear = ref(false) // 自定义年份范围展开 const showCustomYear = ref(false) // 自定义年份范围展开
const _skipRouteSync = ref(0) // R31: guard against re-entry from syncSearchToUrl
const pageSize = ref(Number(localStorage.getItem('search:pageSize')) || 20) const pageSize = ref(Number(localStorage.getItem('search:pageSize')) || 20)
// 从 URL 恢复的页码(syncSearchToUrl 写入) // 从 URL 恢复的页码(syncSearchToUrl 写入)
const restoredPage = ref(1) const restoredPage = ref(1)
@@ -93,6 +94,7 @@ function onYearSliderChange(val: any) {
yearFromStr.value = String(val[0]) yearFromStr.value = String(val[0])
yearToStr.value = String(val[1]) yearToStr.value = String(val[1])
datePreset.value = null datePreset.value = null
showCustomYear.value = false // R31: 滑动时收起自定义年份
urlDateFrom.value = ''; urlDateTo.value = '' // P6: clear stale URL dates urlDateFrom.value = ''; urlDateTo.value = '' // P6: clear stale URL dates
if (_sliderTimer) clearTimeout(_sliderTimer) if (_sliderTimer) clearTimeout(_sliderTimer)
_sliderTimer = setTimeout(() => goToPage(1), 250) _sliderTimer = setTimeout(() => goToPage(1), 250)
@@ -503,6 +505,13 @@ onMounted(async () => {
await goToPage(restoredPage.value) await goToPage(restoredPage.value)
}) })
// R31: 浏览器后退/前进时恢复搜索状态
onBeforeRouteUpdate(() => {
if (_skipRouteSync.value > 0) return // syncSearchToUrl 触发的路由变化,跳过
restoreFromQuery()
goToPage(restoredPage.value)
})
/** 同步当前搜索参数到 URL query */ /** 同步当前搜索参数到 URL query */
function syncSearchToUrl() { function syncSearchToUrl() {
const q: Record<string, string> = {} const q: Record<string, string> = {}
@@ -549,7 +558,8 @@ function syncSearchToUrl() {
const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value
if (currentPage > 1) q.p = String(currentPage) if (currentPage > 1) q.p = String(currentPage)
// R24: use push instead of replace to preserve browser back-button history // R24: use push instead of replace to preserve browser back-button history
router.push({ query: q }).catch(() => {}) _skipRouteSync.value++
router.push({ query: q }).catch(() => {}).finally(() => _skipRouteSync.value--)
} }
function resetAllFilters() { function resetAllFilters() {
@@ -578,6 +588,7 @@ function resetAllFilters() {
booleanOp.value = 'and' booleanOp.value = 'and'
exactPhrase.value = false exactPhrase.value = false
showCustomYear.value = false showCustomYear.value = false
pageSize.value = Number(localStorage.getItem('search:pageSize')) || 20 // R31: 重置每页条数
goToPage(1) goToPage(1)
} }
@@ -85,9 +85,9 @@ const translated = computed(() => {
items.push({ field: `[${tag}] ${label}`, value: `"${val}"` }) items.push({ field: `[${tag}] ${label}`, value: `"${val}"` })
} }
} }
// Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[DP] // Date range: supports YYYY:YYYY[DP], YYYY-MM-DD:YYYY-MM-DD[DP], YYYY/MM/DD:YYYY/MM/DD[DP],
// 对 displayText(已展开 #N)扫描,确保历史引用中的 DP 也能被翻译 // YYYY-M-D:YYYY-M-D[DP] (single digit), YYYY-MM:YYYY-MM[DP] (month-only)
const yrRe = /(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)\s*:\s*(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)\s*\[DP\]/g const yrRe = /(\d{4}(?:[-\/]\d{1,2}(?:[-\/]\d{1,2})?)?)\s*:\s*(\d{4}(?:[-\/]\d{1,2}(?:[-\/]\d{1,2})?)?)\s*\[DP\]/g
while ((m = yrRe.exec(displayText)) !== null) { while ((m = yrRe.exec(displayText)) !== null) {
const key = `dp_range_${m.index}` const key = `dp_range_${m.index}`
if (!seen.has(key)) { if (!seen.has(key)) {
@@ -155,6 +155,8 @@ function addToQuery() {
let val = builderValue.value.trim() let val = builderValue.value.trim()
// 去除用户输入的多余引号 // 去除用户输入的多余引号
val = val.replace(/^['""“]+|['""”]+$/g, '') val = val.replace(/^['""“]+|['""”]+$/g, '')
// R31: 去除内部双引号防止构建非法 PubMed 语法(保留单引号如 don't)
val = val.replace(/["""“”]/g, '')
if (!val) { if (!val) {
message.warning('请输入搜索词') message.warning('请输入搜索词')
return return