fix: 第30轮搜索审计修复 — 否定组NULL安全/嵌套括号_has_or/传递子组/DP标记列精度等8项
This commit is contained in:
@@ -894,7 +894,15 @@ class PubmedQueryParser:
|
|||||||
_parent_gid = len(result.groups)
|
_parent_gid = len(result.groups)
|
||||||
_ungrouped = [t for t in terms if t.group_id < 0]
|
_ungrouped = [t for t in terms if t.group_id < 0]
|
||||||
# P20: track sub-groups created by _parse_or_expr inside this paren
|
# P20: track sub-groups created by _parse_or_expr inside this paren
|
||||||
_child_gids = sorted(set(t.group_id for t in terms if t.group_id >= 0))
|
# R30: exclude gids already in existing sub_group_refs (transitive children)
|
||||||
|
# and gids that are themselves parent groups (have sub_group_refs)
|
||||||
|
_existing_children = set(g for refs in result.sub_group_refs for g in refs)
|
||||||
|
_child_candidates = sorted(set(t.group_id for t in terms if t.group_id >= 0))
|
||||||
|
_child_gids = [
|
||||||
|
gid for gid in _child_candidates
|
||||||
|
if gid not in _existing_children
|
||||||
|
and (gid >= len(result.sub_group_refs) or not result.sub_group_refs[gid])
|
||||||
|
]
|
||||||
for t in _ungrouped:
|
for t in _ungrouped:
|
||||||
t.group_id = _parent_gid
|
t.group_id = _parent_gid
|
||||||
if _ungrouped:
|
if _ungrouped:
|
||||||
@@ -908,7 +916,17 @@ class PubmedQueryParser:
|
|||||||
# R21: only write sub_group_refs when creating a parent group,
|
# R21: only write sub_group_refs when creating a parent group,
|
||||||
# preventing phantom entries when _ungrouped is empty (all terms already in child groups)
|
# preventing phantom entries when _ungrouped is empty (all terms already in child groups)
|
||||||
result.groups.append(_ungrouped)
|
result.groups.append(_ungrouped)
|
||||||
_has_or = any(t.type == TokenType.OR for t in self.tokens[start_pos:end_pos])
|
# R30: track paren depth to avoid counting OR inside nested parens
|
||||||
|
_or_depth = 0
|
||||||
|
_has_or = False
|
||||||
|
for _t in self.tokens[start_pos:end_pos]:
|
||||||
|
if _t.type == TokenType.LPAREN:
|
||||||
|
_or_depth += 1
|
||||||
|
elif _t.type == TokenType.RPAREN:
|
||||||
|
_or_depth -= 1
|
||||||
|
elif _t.type == TokenType.OR and _or_depth == 0:
|
||||||
|
_has_or = True
|
||||||
|
break
|
||||||
result.group_operators.append("or" if _has_or else "and")
|
result.group_operators.append("or" if _has_or else "and")
|
||||||
result.group_negated.append(False) # P16: P19 revert logic handles NOT group tracking
|
result.group_negated.append(False) # P16: P19 revert logic handles NOT group tracking
|
||||||
# 已分组的 Term(嵌套括号 OR 子组)不再重复加组
|
# 已分组的 Term(嵌套括号 OR 子组)不再重复加组
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ class AdvancedSearchEngine:
|
|||||||
_pubmed_parsed = None
|
_pubmed_parsed = None
|
||||||
_pubmed_failed = False
|
_pubmed_failed = False
|
||||||
_is_flat_text = True #是否走传统 tsvector + ILIKE 路径
|
_is_flat_text = True #是否走传统 tsvector + ILIKE 路径
|
||||||
if query.strip():
|
if query and query.strip():
|
||||||
|
|
||||||
if is_pubmed_syntax(query):
|
if is_pubmed_syntax(query):
|
||||||
pp = parse_pubmed_query(query)
|
pp = parse_pubmed_query(query)
|
||||||
@@ -1289,7 +1289,18 @@ class AdvancedSearchEngine:
|
|||||||
_mf2 = t.field or ""
|
_mf2 = t.field or ""
|
||||||
_tag2 = _mf2[8:-2] if _mf2.startswith("__RANGE_") and _mf2.endswith("__") else None
|
_tag2 = _mf2[8:-2] if _mf2.startswith("__RANGE_") and _mf2.endswith("__") else None
|
||||||
if _tag2 == "DP":
|
if _tag2 == "DP":
|
||||||
g_neg.append(or_(not_(_marker_cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None)))
|
# R30: column-precise NULL safety based on marker range values
|
||||||
|
_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(or_(not_(_marker_cond), *(_c.is_(None) for _c in _dp_ns)))
|
||||||
|
else:
|
||||||
|
g_neg.append(not_(_marker_cond))
|
||||||
elif _tag2 in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
|
elif _tag2 in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
|
||||||
_tag2_col = {
|
_tag2_col = {
|
||||||
"EDAT": GlobalLiterature.entrez_date,
|
"EDAT": GlobalLiterature.entrez_date,
|
||||||
@@ -1335,7 +1346,18 @@ class AdvancedSearchEngine:
|
|||||||
_cmf = t.field or ""
|
_cmf = t.field or ""
|
||||||
_ctag = _cmf[8:-2] if _cmf.startswith("__RANGE_") and _cmf.endswith("__") else None
|
_ctag = _cmf[8:-2] if _cmf.startswith("__RANGE_") and _cmf.endswith("__") else None
|
||||||
if _ctag == "DP":
|
if _ctag == "DP":
|
||||||
child_neg.append(or_(not_(_c_marker_cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None)))
|
# R30: column-precise NULL safety for child sub-group DP markers
|
||||||
|
_cdp_parts = (t.text or "").split(":", 1)
|
||||||
|
_cdp_ns = set()
|
||||||
|
for _cdp_p in _cdp_parts:
|
||||||
|
if _cdp_p and _cdp_p.isdigit() and len(_cdp_p) == 4:
|
||||||
|
_cdp_ns.add(GlobalLiterature.pub_year)
|
||||||
|
elif _cdp_p:
|
||||||
|
_cdp_ns.add(GlobalLiterature.pub_date)
|
||||||
|
if _cdp_ns:
|
||||||
|
child_neg.append(or_(not_(_c_marker_cond), *(_c.is_(None) for _c in _cdp_ns)))
|
||||||
|
else:
|
||||||
|
child_neg.append(not_(_c_marker_cond))
|
||||||
elif _ctag in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
|
elif _ctag in ("EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"):
|
||||||
_ctag_col = {
|
_ctag_col = {
|
||||||
"EDAT": GlobalLiterature.entrez_date,
|
"EDAT": GlobalLiterature.entrez_date,
|
||||||
@@ -1386,27 +1408,7 @@ class AdvancedSearchEngine:
|
|||||||
# NOT(A OR B): single UnaryExpression → 顶层 OR/NOT 分离时被检测为 neg → 独立 AND
|
# NOT(A OR B): single UnaryExpression → 顶层 OR/NOT 分离时被检测为 neg → 独立 AND
|
||||||
if g_neg:
|
if g_neg:
|
||||||
combined = combine_fn(*g_neg) if len(g_neg) > 1 else g_neg[0]
|
combined = combine_fn(*g_neg) if len(g_neg) > 1 else g_neg[0]
|
||||||
_notted = not_(combined)
|
term_conditions.append(not_(combined))
|
||||||
# R28: NULL-safe NOT for date fields in negated groups
|
|
||||||
if _neg_group_date_fields:
|
|
||||||
_ns_cols = []
|
|
||||||
if "DP" in _neg_group_date_fields:
|
|
||||||
_ns_cols.extend([GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None)])
|
|
||||||
_NS_COL_MAP = {
|
|
||||||
"EDAT": GlobalLiterature.entrez_date,
|
|
||||||
"CRDT": GlobalLiterature.create_date,
|
|
||||||
"MHDA": GlobalLiterature.meshed_date,
|
|
||||||
"LR": GlobalLiterature.pubmed_revised,
|
|
||||||
"DCOM": GlobalLiterature.date_completed,
|
|
||||||
"DEP": GlobalLiterature.pub_date,
|
|
||||||
}
|
|
||||||
for _ns_tag in _neg_group_date_fields:
|
|
||||||
_ns_col = _NS_COL_MAP.get(_ns_tag)
|
|
||||||
if _ns_col is not None:
|
|
||||||
_ns_cols.append(_ns_col.is_(None))
|
|
||||||
if _ns_cols:
|
|
||||||
_notted = or_(_notted, *_ns_cols)
|
|
||||||
term_conditions.append(_notted)
|
|
||||||
else:
|
else:
|
||||||
# 混合/正组:将 pos 和 neg 按组操作符组合,保留组内结构
|
# 混合/正组:将 pos 和 neg 按组操作符组合,保留组内结构
|
||||||
# 避免 neg 被 OR/NOT 分离拉出来破坏语义
|
# 避免 neg 被 OR/NOT 分离拉出来破坏语义
|
||||||
@@ -1525,8 +1527,16 @@ class AdvancedSearchEngine:
|
|||||||
elif dp_conds:
|
elif dp_conds:
|
||||||
cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0]
|
cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0]
|
||||||
if dp_negated:
|
if dp_negated:
|
||||||
# R28: NULL-safe NOT — rows with NULL pub_year/pub_date should not be excluded
|
# R30: NULL-safe only for columns actually in the condition
|
||||||
conditions.append(or_(not_(cond), GlobalLiterature.pub_year.is_(None), GlobalLiterature.pub_date.is_(None)))
|
_dp_ns = []
|
||||||
|
if pp.year_from is not None or pp.year_to is not None:
|
||||||
|
_dp_ns.append(GlobalLiterature.pub_year.is_(None))
|
||||||
|
if pp.date_from is not None or pp.date_to is not None:
|
||||||
|
_dp_ns.append(GlobalLiterature.pub_date.is_(None))
|
||||||
|
if _dp_ns:
|
||||||
|
conditions.append(or_(not_(cond), *_dp_ns))
|
||||||
|
else:
|
||||||
|
conditions.append(not_(cond))
|
||||||
else:
|
else:
|
||||||
conditions.append(cond)
|
conditions.append(cond)
|
||||||
elif dp_negated:
|
elif dp_negated:
|
||||||
|
|||||||
+71
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
||||||
>
|
>
|
||||||
> **累计**:24 轮,290+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖
|
> **累计**:25 轮,298+ 项修复,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 行)
|
||||||
|
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
12. [第十二轮:第 12 轮深度审计修复(21 项)](#第十二轮第-12-轮深度审计修复)
|
12. [第十二轮:第 12 轮深度审计修复(21 项)](#第十二轮第-12-轮深度审计修复)
|
||||||
13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复)
|
13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复)
|
||||||
14. [第十五轮(第 24 次审计修复)](#round-24第-24-次全面审计修复)
|
14. [第十五轮(第 24 次审计修复)](#round-24第-24-次全面审计修复)
|
||||||
15. [遗留限制](#遗留限制)
|
15. [R30(第 5 轮并行审计修复)](#r30-2026-07-29-第五轮并行审计修复)
|
||||||
|
16. [遗留限制](#遗留限制)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2237,3 +2238,71 @@ if _PARTIAL_DATE_RE.match(end_val):
|
|||||||
- ✅ **1007 tests passed, 0 failed**
|
- ✅ **1007 tests passed, 0 failed**
|
||||||
- 全部搜索测试通过
|
- 全部搜索测试通过
|
||||||
- 无回归
|
- 无回归
|
||||||
|
|
||||||
|
## R30 (2026-07-29): 第五轮并行审计修复
|
||||||
|
|
||||||
|
> **变更类型**:6 项修复(1 HIGH 已在前序会话修复 + 5 项新增)
|
||||||
|
|
||||||
|
### R30-1 (HIGH): 否定组 NULL 安全包裹整个组合条件
|
||||||
|
|
||||||
|
**文件**:[search_engine.py:1385-1409](backend/app/services/search_engine.py#L1385)
|
||||||
|
|
||||||
|
**根因**:R28 为否定组添加的 NULL 安全 NOT 包裹过于宽泛:`or_(not_(date_cond + keyword_combined), col1.is_(None), col2.is_(None))`。当日期列为 NULL 时,关键词匹配也通过 NULL 安全逃逸,导致否定组中的关键词条件失效。
|
||||||
|
|
||||||
|
**修复**:完全移除此 NULL 安全包裹层。否定组现在使用纯 `not_(combined)`,不附加列 NULL 检查。日期列的 NULL 安全由列精确的 `dp_negated` 路径单独处理。
|
||||||
|
|
||||||
|
### R30-2 (MEDIUM): `dp_negated` 双列 NULL 安全不精确
|
||||||
|
|
||||||
|
**文件**:[search_engine.py:1525-1531](backend/app/services/search_engine.py#L1525)
|
||||||
|
|
||||||
|
**根因**:顶层 `dp_negated` 路径始终同时添加 `pub_year.is_(None)` 和 `pub_date.is_(None)`,即使条件仅使用其中一列(如纯年范围 `2024:2025[DP]` 只使用 `pub_year`)。
|
||||||
|
|
||||||
|
**修复**:检查条件使用了哪些列(`year_from/to` → `pub_year`,`date_from/to` → `pub_date`),仅对实际使用的列添加 NULL 安全。
|
||||||
|
|
||||||
|
### R30-3 (MEDIUM): 嵌套括号 `_has_or` 跨越作用域边界
|
||||||
|
|
||||||
|
**文件**:[pubmed_query_parser.py:911](backend/app/services/pubmed_query_parser.py#L911)
|
||||||
|
|
||||||
|
**根因**:`_has_or` 通过扫描 `start_pos:end_pos` 范围内所有 token 检测 OR 运算符。嵌套括号如 `((A OR B AND C) AND D)` 中,外层括号的 `_has_or` 扫描到内层 OR,导致外层分组操作符被设为 "or" 而非 "and"。
|
||||||
|
|
||||||
|
**修复**:在扫描过程中跟踪括号深度,仅计数当前深度(`_or_depth == 0`)的 OR token。
|
||||||
|
|
||||||
|
### R30-4 (MEDIUM): `_child_gids` 包含传递性子组
|
||||||
|
|
||||||
|
**文件**:[pubmed_query_parser.py:897](backend/app/services/pubmed_query_parser.py#L897)
|
||||||
|
|
||||||
|
**根因**:`_child_gids` 从所有返回的 Term 中收集 group_id,包括嵌套括号创建的传递性父组。这些传递性组不应作为当前组的子组引用。
|
||||||
|
|
||||||
|
**修复**:过滤掉 (a) 已在现有 `sub_group_refs` 中的 gid(传递性子组)和 (b) 自身就是父组的 gid(`sub_group_refs` 非空)。
|
||||||
|
|
||||||
|
### R30-5 (MEDIUM): 非否定组 DP 标记 NULL 安全列不精确
|
||||||
|
|
||||||
|
**文件**:[search_engine.py:1289-1292](backend/app/services/search_engine.py#L1289), [search_engine.py:1337-1338](backend/app/services/search_engine.py#L1337)
|
||||||
|
|
||||||
|
**根因**:非否定组中的 DP 标记 `is_not=True`(如 `NOT 2024:2025[DP]` 在非否定组内)和子组路径中的 DP 标记始终同时添加 `pub_year.is_(None)` 和 `pub_date.is_(None)`。
|
||||||
|
|
||||||
|
**修复**:根据标记文本中的范围值类型(4 位年份 → `pub_year`,日期格式 → `pub_date`)选择性地添加 NULL 安全列。
|
||||||
|
|
||||||
|
### R30-6 (LOW): `query=None` 崩溃
|
||||||
|
|
||||||
|
**文件**:[search_engine.py:256](backend/app/services/search_engine.py#L256)
|
||||||
|
|
||||||
|
**根因**:`query.strip()` 在 `query` 为 `None` 时抛出 `AttributeError`。
|
||||||
|
|
||||||
|
**修复**:改为 `if query and query.strip():`。
|
||||||
|
|
||||||
|
### R30-7 (LOW): 搜索错误详情丢失
|
||||||
|
|
||||||
|
**文件**:[SearchView.vue](frontend/src/views/app/SearchView.vue)
|
||||||
|
|
||||||
|
**根因**:400 错误的 catch 块使用硬编码的 '搜索参数有误' 提示,未展示服务器的错误详情。
|
||||||
|
|
||||||
|
**修复**:对 400 错误优先展示 `e?.response?.data?.detail`。
|
||||||
|
|
||||||
|
### R30-8 (LOW): 日期范围正则遗漏 YYYY-MM-DD
|
||||||
|
|
||||||
|
**文件**:[AdvancedPubSearchView.vue](frontend/src/views/public/AdvancedPubSearchView.vue)
|
||||||
|
|
||||||
|
**根因**:日期范围正则只匹配 `YYYY:YYYY` 和 `YYYY/MM/DD:YYYY/MM/DD`,遗漏 `YYYY-MM-DD:YYYY-MM-DD`(ISO 格式)。
|
||||||
|
|
||||||
|
**修复**:添加 `-` 作为可选日期分隔符。`(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)`。
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ const { page, total, goToPage } = usePagination({
|
|||||||
searchError.value = e?.response?.status === 401
|
searchError.value = e?.response?.status === 401
|
||||||
? '请登录后使用搜索功能'
|
? '请登录后使用搜索功能'
|
||||||
: e?.response?.status === 400
|
: e?.response?.status === 400
|
||||||
? '搜索参数有误,请调整后重试'
|
? e?.response?.data?.detail || '搜索参数有误,请调整后重试'
|
||||||
: e?.response?.status === 429
|
: e?.response?.status === 429
|
||||||
? '请求过于频繁,请稍后重试'
|
? '请求过于频繁,请稍后重试'
|
||||||
: '搜索失败,请检查网络或稍后重试'
|
: '搜索失败,请检查网络或稍后重试'
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const translated = computed(() => {
|
|||||||
}
|
}
|
||||||
// Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[DP]
|
// Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[DP]
|
||||||
// 对 displayText(已展开 #N)扫描,确保历史引用中的 DP 也能被翻译
|
// 对 displayText(已展开 #N)扫描,确保历史引用中的 DP 也能被翻译
|
||||||
const yrRe = /(\d{4}(?:\/\d{2}\/\d{2})?)\s*:\s*(\d{4}(?:\/\d{2}\/\d{2})?)\s*\[DP\]/g
|
const yrRe = /(\d{4}(?:[-\/]\d{2}[-\/]\d{2})?)\s*:\s*(\d{4}(?:[-\/]\d{2}[-\/]\d{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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user