fix: 搜索模块审计修复 — ATM+NOT、affiliation、分页、SB 等 12 项漏洞
This commit is contained in:
@@ -71,6 +71,7 @@ class AdvancedSearchRequest(BaseModel):
|
||||
# keyset 游标分页(所有排序模式通用,设了 cursor 后 page 参数被忽略,不做 COUNT)
|
||||
cursor_val: str | None = None # 上一页最后一条的排序列值(字符串,服务端按 sort 模式解析)
|
||||
cursor_id: str | None = None # 上一页最后一条的 id(UUID 字符串)
|
||||
cursor_date: str | None = None # 向后兼容(映射到 cursor_val)
|
||||
|
||||
# ── PubMed 筛选器参数 ──
|
||||
# Text Availability
|
||||
|
||||
@@ -553,6 +553,13 @@ class PubmedQueryParser:
|
||||
end_pos = self.pos # P2-2: 记录组结束标记位置
|
||||
self.expect(TokenType.RPAREN)
|
||||
self._depth -= 1
|
||||
# P2-F12: (a OR b)[TI] — 组后字段标签应用到组内所有词
|
||||
if self.peek().type == TokenType.FIELD:
|
||||
ft = self.advance()
|
||||
_raw_field = ft.value[1:-1].upper()
|
||||
_field = _normalize_field_label(_raw_field)
|
||||
for t in terms:
|
||||
t.field = _field
|
||||
# 标记为子组,不放入 flat lists,保留括号分组结构
|
||||
group_id = len(result.groups)
|
||||
for t in terms:
|
||||
@@ -640,10 +647,18 @@ class PubmedQueryParser:
|
||||
start_val, end_val = end_val, start_val
|
||||
elif not _start_is_digit and not _end_is_digit and start_val > end_val:
|
||||
start_val, end_val = end_val, start_val
|
||||
elif _start_is_digit and not _end_is_digit and int(start_val) > int(end_val[:4]):
|
||||
elif _start_is_digit and not _end_is_digit:
|
||||
try:
|
||||
if int(start_val) > int(end_val[:4]):
|
||||
start_val, end_val = end_val, start_val
|
||||
elif not _start_is_digit and _end_is_digit and int(start_val[:4]) > int(end_val):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif not _start_is_digit and _end_is_digit:
|
||||
try:
|
||||
if int(start_val[:4]) > int(end_val):
|
||||
start_val, end_val = end_val, start_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# 确定两端是否是 4 位年份
|
||||
_start_is_year = start_val.isdigit() and len(start_val) == 4
|
||||
_end_is_year = end_val.isdigit() and len(end_val) == 4
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, and_, case, cast, func, literal_column, not_, or_, select, text
|
||||
from sqlalchemy import String, and_, case, cast, exists, func, literal_column, not_, or_, select, text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -597,6 +597,9 @@ class AdvancedSearchEngine:
|
||||
_keyset_cond = AdvancedSearchEngine._keyset_condition(sort, cursor_val, cursor_id)
|
||||
if _keyset_cond is not None:
|
||||
conditions.append(_keyset_cond)
|
||||
elif sort not in AdvancedSearchEngine.KEYSET_COLUMN_SORTS and page > 1:
|
||||
# P1-F5: best_match/relevance 不支持 keyset,用 OFFSET 翻页
|
||||
q = q.offset((page - 1) * page_size)
|
||||
|
||||
# 重新构建查询
|
||||
q = select(GlobalLiterature)
|
||||
@@ -625,7 +628,9 @@ class AdvancedSearchEngine:
|
||||
# 后续页从 facet 缓存读 total
|
||||
facet_cached = await _cache.get(_facet_cache_key)
|
||||
if facet_cached:
|
||||
if isinstance(facet_cached, dict):
|
||||
total = facet_cached.get("total", 0)
|
||||
# P2-F11: 旧格式 list(只有 year_counts),保持 total=0
|
||||
|
||||
# 构建游标供翻页
|
||||
next_cursor_val = None
|
||||
@@ -783,29 +788,35 @@ class AdvancedSearchEngine:
|
||||
term_conditions.append(field_combine(*field_conds) if len(field_conds) > 1 else field_conds[0])
|
||||
|
||||
# 2. 纯文本词(无字段标签)— P0-2: 对无标签词补充 ATM MeSH 展开
|
||||
# P0-F1: ATM 只展开肯定词,否定词独立 AND,避免被 ATM OR 短路
|
||||
if pp.plain_terms:
|
||||
plain_conds = []
|
||||
for term in pp.plain_terms:
|
||||
cond = AdvancedSearchEngine._field_condition("all", term.text, term.exact)
|
||||
if term.is_not:
|
||||
cond = not_(cond)
|
||||
plain_conds.append(cond)
|
||||
if plain_conds:
|
||||
combined = " ".join(t.text for t in pp.plain_terms if not t.is_not).strip()
|
||||
if combined and not re.search(r'[一-鿿㐀-䶿豈-]', combined):
|
||||
pos_conds = [AdvancedSearchEngine._field_condition("all", t.text, t.exact)
|
||||
for t in pp.plain_terms if not t.is_not]
|
||||
neg_conds = [not_(AdvancedSearchEngine._field_condition("all", t.text, t.exact))
|
||||
for t in pp.plain_terms if t.is_not]
|
||||
combined_pos_text = " ".join(t.text for t in pp.plain_terms if not t.is_not).strip()
|
||||
if combined_pos_text and pos_conds and not re.search(r'[一-鿿㐀-䶿豈-]', combined_pos_text):
|
||||
from app.services.query_expansion import expand_atm as _expand_atm_inline
|
||||
try:
|
||||
atm_cond = await _expand_atm_inline(db, combined)
|
||||
atm_cond = await _expand_atm_inline(db, combined_pos_text)
|
||||
except Exception:
|
||||
logger.exception("ATM expansion failed (pubmed plain_terms): %s", combined[:100])
|
||||
logger.exception("ATM expansion failed (pubmed plain_terms): %s", combined_pos_text[:100])
|
||||
atm_cond = None
|
||||
else:
|
||||
atm_cond = None
|
||||
term_cond = None
|
||||
if pos_conds:
|
||||
pos_combined = field_combine(*pos_conds) if len(pos_conds) > 1 else pos_conds[0]
|
||||
if atm_cond is not None:
|
||||
text_cond = field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0]
|
||||
term_conditions.append(or_(atm_cond, text_cond))
|
||||
term_cond = or_(atm_cond, pos_combined)
|
||||
else:
|
||||
term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
|
||||
else:
|
||||
term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
|
||||
term_cond = pos_combined
|
||||
elif atm_cond is not None:
|
||||
term_cond = atm_cond
|
||||
if term_cond is not None:
|
||||
term_conditions.append(term_cond)
|
||||
# 否定词独立 AND(不参与 ATM 展开)
|
||||
term_conditions.extend(neg_conds)
|
||||
|
||||
# 3. [MH] → tree_number 展开,支持 is_not 和 _noexp
|
||||
if pp.mesh_terms:
|
||||
@@ -1037,7 +1048,8 @@ class AdvancedSearchEngine:
|
||||
continue # no-op: 所有记录都是 PubMed
|
||||
elif val == "MEDLINE":
|
||||
cond = GlobalLiterature.citation_status == "medline"
|
||||
elif len(val) == 1 and val.isalpha():
|
||||
elif val.isalpha():
|
||||
# P1-F7: 所有字母子集码(含 AIM 等多字母)统一走 nlm_subsets
|
||||
subq = select(GlobalJournal.issn).where(
|
||||
GlobalJournal.nlm_subsets.overlap([val])
|
||||
)
|
||||
@@ -1300,7 +1312,12 @@ class AdvancedSearchEngine:
|
||||
GlobalLiterature.journal_iso.ilike(pat),
|
||||
)
|
||||
elif field == "affiliation":
|
||||
return cast(GlobalLiterature.authors, String).ilike(_pt())
|
||||
# P0-F2: 用 jsonb_array_elements 提取 affiliation 值,避免 JSON 键名假阳性
|
||||
_pat = _pt()
|
||||
return text(
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)"
|
||||
).bindparams(aff_pat=_pat)
|
||||
elif field == "language":
|
||||
return GlobalLiterature.language.ilike(_pt())
|
||||
elif field == "volume":
|
||||
@@ -1493,11 +1510,11 @@ class AdvancedSearchEngine:
|
||||
return [GlobalLiterature.cited_by_count.desc().nullslast(), GlobalLiterature.id.desc()]
|
||||
elif sort == "best_match" and relevance_query.strip():
|
||||
tsq = func.plainto_tsquery("english", relevance_query)
|
||||
return [AdvancedSearchEngine._best_match_order(tsq)]
|
||||
return [AdvancedSearchEngine._best_match_order(tsq), GlobalLiterature.id.desc()]
|
||||
elif sort == "relevance" and relevance_query.strip():
|
||||
tsq = func.plainto_tsquery("english", relevance_query)
|
||||
rank = func.ts_rank(GlobalLiterature.search_tsv, tsq)
|
||||
return [rank.desc()]
|
||||
return [rank.desc(), GlobalLiterature.id.desc()]
|
||||
elif sort == "first_author":
|
||||
return [GlobalLiterature.authors[0]['family'].astext.asc().nullslast()]
|
||||
elif sort == "journal":
|
||||
|
||||
@@ -372,9 +372,12 @@ export interface FolderItem {
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
total_pages?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
has_more?: boolean
|
||||
year_counts?: Array<{year: number, count: number}>
|
||||
cursor_val?: string | null
|
||||
cursor_id?: string | null
|
||||
}
|
||||
|
||||
/** 搜索结果卡片显示设置 */
|
||||
|
||||
@@ -410,8 +410,8 @@ function restoreFromQuery() {
|
||||
const ps = parseInt(String(route.query.page_size))
|
||||
if (ps >= 10 && ps <= 100) pageSize.value = ps
|
||||
}
|
||||
// keyset 游标不能从 URL 恢复 → page>1 回退到首页
|
||||
if (sort.value === 'date' && restoredPage.value > 1) restoredPage.value = 1
|
||||
// keyset 游标不能从 URL 恢复 → page>1 回退到首页(所有排序模式)
|
||||
if (restoredPage.value > 1) restoredPage.value = 1
|
||||
}
|
||||
|
||||
// 选择预设(1y/5y/10y)时清除自定义年份并自动搜索
|
||||
|
||||
@@ -35,10 +35,13 @@ const searched = ref(false)
|
||||
const searchTotal = ref(0)
|
||||
|
||||
// keyset 游标(用于"加载更多",page 被忽略)
|
||||
const cursorDate = ref<string | null>(null)
|
||||
const cursorVal = ref<string | null>(null)
|
||||
const cursorId = ref<string | null>(null)
|
||||
const hasMoreItems = ref(false)
|
||||
|
||||
// 搜索请求 AbortController,防竞态
|
||||
const searchController = ref<AbortController | null>(null)
|
||||
|
||||
// ── 公开统计 ──
|
||||
const platformStats = ref({ literature_total: 0, journal_total: 0, daily_avg_30d: 0 })
|
||||
const statsLoading = ref(true)
|
||||
@@ -124,7 +127,7 @@ async function loadHomepageFeed() {
|
||||
const items = data.items || []
|
||||
if (items.length > 0) {
|
||||
const last = items[items.length - 1]
|
||||
cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null
|
||||
cursorVal.value = (last.article_date || last.pub_date)?.slice(0, 10) || null
|
||||
cursorId.value = last.id || null
|
||||
}
|
||||
searched.value = true
|
||||
@@ -133,8 +136,13 @@ async function loadHomepageFeed() {
|
||||
|
||||
// ── 数据加载核心 ──
|
||||
async function fetchData(resetPage = true) {
|
||||
// P2-F14: 取消上次未完成的请求,防竞态
|
||||
searchController.value?.abort()
|
||||
searchController.value = new AbortController()
|
||||
const signal = searchController.value.signal
|
||||
|
||||
if (resetPage) {
|
||||
cursorDate.value = null
|
||||
cursorVal.value = null
|
||||
cursorId.value = null
|
||||
}
|
||||
loading.value = true
|
||||
@@ -148,11 +156,11 @@ async function fetchData(resetPage = true) {
|
||||
if (searchParams.value.retracted) body.retracted = searchParams.value.retracted
|
||||
if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result
|
||||
// keyset 游标
|
||||
if (cursorDate.value && cursorId.value) {
|
||||
body.cursor_val = cursorDate.value
|
||||
if (cursorVal.value && cursorId.value) {
|
||||
body.cursor_val = cursorVal.value
|
||||
body.cursor_id = cursorId.value
|
||||
}
|
||||
const { data } = await api.post('/features/search/advanced', body)
|
||||
const { data } = await api.post('/features/search/advanced', body, { signal })
|
||||
if (resetPage) {
|
||||
feedItems.value = data.items || []
|
||||
searchTotal.value = data.total ?? 0
|
||||
@@ -161,17 +169,9 @@ async function fetchData(resetPage = true) {
|
||||
}
|
||||
hasMoreItems.value = data.has_more ?? false
|
||||
// keyset 游标(全部使用服务端返回的游标,通用所有排序模式)
|
||||
if (searchParams.value.sort !== 'date') {
|
||||
// 非 date 排序由服务端返回 cursor_val,用 response 字段
|
||||
if (data.cursor_val) {
|
||||
cursorDate.value = data.cursor_val
|
||||
cursorId.value = data.cursor_id
|
||||
}
|
||||
} else {
|
||||
if (data.cursor_val && data.cursor_id) {
|
||||
cursorDate.value = data.cursor_val
|
||||
cursorId.value = data.cursor_id
|
||||
}
|
||||
cursorVal.value = data.cursor_val
|
||||
cursorId.value = data.cursor_id ?? null
|
||||
}
|
||||
searched.value = true
|
||||
} catch (e) { toast.apiError(e, '搜索文献失败,请重试') }
|
||||
@@ -331,21 +331,17 @@ async function loadMore() {
|
||||
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
|
||||
// P3-6: precision_mode 不再发送
|
||||
if (cursorDate.value && cursorId.value) {
|
||||
body.cursor_date = cursorDate.value
|
||||
// P1-F4: 发 cursor_val(非 cursor_date),服务端兼容两者
|
||||
if (cursorVal.value && cursorId.value) {
|
||||
body.cursor_val = cursorVal.value
|
||||
body.cursor_id = cursorId.value
|
||||
}
|
||||
const { data } = await api.post('/features/search/advanced', body)
|
||||
feedItems.value.push(...(data.items || []))
|
||||
hasMoreItems.value = data.has_more ?? false
|
||||
// 更新游标
|
||||
const items = data.items || []
|
||||
if (items.length > 0) {
|
||||
const last = items[items.length - 1]
|
||||
cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null
|
||||
cursorId.value = last.id || null
|
||||
}
|
||||
// P1-F9: 统一使用服务端返回的游标(替代手动从末条提取)
|
||||
cursorVal.value = data.cursor_val ?? null
|
||||
cursorId.value = data.cursor_id ?? null
|
||||
} catch (e) { toast.apiError(e, '加载更多失败,请重试') }
|
||||
finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user