fix: 第31轮搜索审计修复 — 否定组日期NULL安全/多列NOT假阳性/year_counts一致性/缓存键空格等8项
This commit is contained in:
@@ -806,6 +806,21 @@ class PubmedQueryParser:
|
||||
result.group_operators.append("and")
|
||||
result.group_negated.append(False) # P19: 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)
|
||||
return all_terms
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ class AdvancedSearchEngine:
|
||||
import hashlib, json
|
||||
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
|
||||
norm = {
|
||||
"q": query.strip().lower(),
|
||||
"q": " ".join(query.strip().lower().split()),
|
||||
"pm": _is_pm(query),
|
||||
"f": field, "b": boolean, "ep": exact_phrase,
|
||||
"yf": year_from, "yt": year_to,
|
||||
@@ -116,7 +116,7 @@ class AdvancedSearchEngine:
|
||||
import hashlib, json
|
||||
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
|
||||
norm = {
|
||||
"q": query.strip().lower(),
|
||||
"q": " ".join(query.strip().lower().split()),
|
||||
"pm": _is_pm(query),
|
||||
"f": field, "b": boolean, "ep": exact_phrase,
|
||||
"yf": year_from, "yt": year_to,
|
||||
@@ -180,7 +180,7 @@ class AdvancedSearchEngine:
|
||||
"""执行高级搜索"""
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
# 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:
|
||||
tag_ids = list(set(str(t) for t in tag_ids))[:200]
|
||||
conditions = []
|
||||
@@ -301,7 +301,7 @@ class AdvancedSearchEngine:
|
||||
_pubmed_parsed = pp # keep parsed object, flag below triggers cleanup
|
||||
|
||||
# ─── 传统搜索路径(纯文本 / PubMed 退化) ───
|
||||
if _is_flat_text and query.strip():
|
||||
if _is_flat_text and query and query.strip():
|
||||
# 如果 PubMed 语法检测成功但解析后无有效词(如语法错误),剥离括号和操作符再搜索
|
||||
if _pubmed_parsed is not None and (not any([
|
||||
bool(_pubmed_parsed.title_terms or _pubmed_parsed.abstract_terms or _pubmed_parsed.tiab_terms
|
||||
@@ -643,7 +643,7 @@ class AdvancedSearchEngine:
|
||||
except Exception:
|
||||
logger.exception("Year counts query failed")
|
||||
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:
|
||||
try:
|
||||
@@ -665,7 +665,7 @@ class AdvancedSearchEngine:
|
||||
|
||||
# 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音)
|
||||
_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] 标签
|
||||
# P12: filter out negated terms from relevance ranking
|
||||
plain_parts = [t.text for t in _pubmed_parsed.plain_terms if not t.is_not]
|
||||
@@ -707,7 +707,10 @@ class AdvancedSearchEngine:
|
||||
.subquery()
|
||||
)
|
||||
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:
|
||||
# 后续页从 facet 缓存读 total
|
||||
facet_cached = await _cache.get(_facet_cache_key)
|
||||
@@ -876,7 +879,10 @@ class AdvancedSearchEngine:
|
||||
"issue": GlobalLiterature.issue,
|
||||
"pages": GlobalLiterature.pages,
|
||||
"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():
|
||||
if not terms:
|
||||
@@ -1275,13 +1281,44 @@ class AdvancedSearchEngine:
|
||||
if _negated and _marker_cond is not None:
|
||||
# R23-3: identify which date field this marker references
|
||||
_mf = t.field or ""
|
||||
_tag = None
|
||||
if _mf.startswith("__RANGE_") and _mf.endswith("__"):
|
||||
_tag = _mf[8:-2]
|
||||
if _tag in ("DP", "EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
|
||||
_neg_group_date_fields.add(_tag)
|
||||
# R27: negated group → all go to g_neg
|
||||
# is_not=True means inner NOT: not_(cond), outer NOT wraps: not_(not_(cond)) = cond
|
||||
g_neg.append(not_(_marker_cond) if getattr(t, 'is_not', False) else _marker_cond)
|
||||
if getattr(t, 'is_not', False):
|
||||
# R27: inner NOT — outer not_() wraps to marker_cond (double negation)
|
||||
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:
|
||||
# Non-negated group: is_not → negated range; otherwise → positive
|
||||
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]
|
||||
child_combined = child_fn(child_combined, _negc) if child_combined is not None else _negc
|
||||
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:
|
||||
g_neg.append(child_combined)
|
||||
else:
|
||||
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.
|
||||
# Only track handled fields for top-level skip logic.
|
||||
if _negated and _neg_group_date_fields:
|
||||
@@ -1903,6 +1955,8 @@ class AdvancedSearchEngine:
|
||||
GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term)),
|
||||
GlobalLiterature.journal.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 "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
|
||||
)
|
||||
|
||||
+71
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
||||
>
|
||||
> **累计**:25 轮,298+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖
|
||||
> **累计**:26 轮,306+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖
|
||||
> **时间跨度**:2026-07-24 ~ 2026-07-29
|
||||
> **核心文件**:`pubmed_query_parser.py`(~1100 行)→ `search_engine.py`(~1960 行)
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复)
|
||||
14. [第十五轮(第 24 次审计修复)](#round-24第-24-次全面审计修复)
|
||||
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`。
|
||||
|
||||
## 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 三值逻辑正确处理 NULL(ILIKE 对 NULL 返回 NULL,OR 中 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
|
||||
|
||||
**文件**:[AdvancedPubSearchView.vue](frontend/src/views/public/AdvancedPubSearchView.vue)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 { NInput, NButton, NEmpty, NSelect, NCheckboxGroup, NCheckbox, NPagination, NIcon, NRadioGroup, NRadio, NSlider, NModal, NPopover } from 'naive-ui'
|
||||
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 yearCounts = ref<{ year: number; count: number }[]>([])
|
||||
const showCustomYear = ref(false) // 自定义年份范围展开
|
||||
const _skipRouteSync = ref(0) // R31: guard against re-entry from syncSearchToUrl
|
||||
const pageSize = ref(Number(localStorage.getItem('search:pageSize')) || 20)
|
||||
// 从 URL 恢复的页码(syncSearchToUrl 写入)
|
||||
const restoredPage = ref(1)
|
||||
@@ -93,6 +94,7 @@ function onYearSliderChange(val: any) {
|
||||
yearFromStr.value = String(val[0])
|
||||
yearToStr.value = String(val[1])
|
||||
datePreset.value = null
|
||||
showCustomYear.value = false // R31: 滑动时收起自定义年份
|
||||
urlDateFrom.value = ''; urlDateTo.value = '' // P6: clear stale URL dates
|
||||
if (_sliderTimer) clearTimeout(_sliderTimer)
|
||||
_sliderTimer = setTimeout(() => goToPage(1), 250)
|
||||
@@ -503,6 +505,13 @@ onMounted(async () => {
|
||||
await goToPage(restoredPage.value)
|
||||
})
|
||||
|
||||
// R31: 浏览器后退/前进时恢复搜索状态
|
||||
onBeforeRouteUpdate(() => {
|
||||
if (_skipRouteSync.value > 0) return // syncSearchToUrl 触发的路由变化,跳过
|
||||
restoreFromQuery()
|
||||
goToPage(restoredPage.value)
|
||||
})
|
||||
|
||||
/** 同步当前搜索参数到 URL query */
|
||||
function syncSearchToUrl() {
|
||||
const q: Record<string, string> = {}
|
||||
@@ -549,7 +558,8 @@ function syncSearchToUrl() {
|
||||
const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value
|
||||
if (currentPage > 1) q.p = String(currentPage)
|
||||
// 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() {
|
||||
@@ -578,6 +588,7 @@ function resetAllFilters() {
|
||||
booleanOp.value = 'and'
|
||||
exactPhrase.value = false
|
||||
showCustomYear.value = false
|
||||
pageSize.value = Number(localStorage.getItem('search:pageSize')) || 20 // R31: 重置每页条数
|
||||
goToPage(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ const translated = computed(() => {
|
||||
items.push({ field: `[${tag}] ${label}`, value: `"${val}"` })
|
||||
}
|
||||
}
|
||||
// Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[DP]
|
||||
// 对 displayText(已展开 #N)扫描,确保历史引用中的 DP 也能被翻译
|
||||
const yrRe = /(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)\s*:\s*(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)\s*\[DP\]/g
|
||||
// Date range: supports YYYY:YYYY[DP], YYYY-MM-DD:YYYY-MM-DD[DP], YYYY/MM/DD:YYYY/MM/DD[DP],
|
||||
// YYYY-M-D:YYYY-M-D[DP] (single digit), YYYY-MM:YYYY-MM[DP] (month-only)
|
||||
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) {
|
||||
const key = `dp_range_${m.index}`
|
||||
if (!seen.has(key)) {
|
||||
@@ -155,6 +155,8 @@ function addToQuery() {
|
||||
let val = builderValue.value.trim()
|
||||
// 去除用户输入的多余引号
|
||||
val = val.replace(/^['""“]+|['""”]+$/g, '')
|
||||
// R31: 去除内部双引号防止构建非法 PubMed 语法(保留单引号如 don't)
|
||||
val = val.replace(/["""“”]/g, '')
|
||||
if (!val) {
|
||||
message.warning('请输入搜索词')
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user