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