fix: 第8轮搜索深度审计修复 — 缓存失效、Redis重试、中文标签、翻页稳定性等12项
CRITICAL:
- invalidate_search_cache 清理 atm:* 缓存(MeSH ATM扩展不再用过期结果)
- Pro 方案 api_quota_per_day 1000→10000(修复低于 Free 的数据错误)
- CacheService/RateLimitMiddleware Redis 连接失败60秒自动重试(原永久降级)
- 普通搜索中文输入自动匹配 GlobalTag.name_zh(如"肺癌"通过MeSH标签关联文献)
- AdvancedPubSearchView resolveQuery 添加 seen Set 检测交替 #N 循环引用
HIGH:
- 限速器 _burst_windows 每500请求清理过期条目(防止内存泄漏)
- cron daily_ftp_update 末尾调用 invalidate_search_cache()(自动管道不再用过期缓存)
MEDIUM:
- _apply_order_by ASC 排序加 id tiebreaker(title/journal/first_author翻页跳行/重复)
- _keyset_condition 所有 is_(None) 加 id tiebreaker + __NULL__ 哨兵值
- _field_condition("all") 默认tsvector路径加 journal/journal_iso ILIKE 兜底
- SearchView restoreFromQuery date_preset/year_from/year_to 优先顺序修复
docs: 更新 12/13 搜索文档,移除 CLAUDE.md 陈旧 SQLite 提及
This commit is contained in:
@@ -5,6 +5,12 @@ interface UsePaginationOptions {
|
||||
fetchFn: (page: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset-based pagination composable.
|
||||
*
|
||||
* 注意:keyset 分页模式(date/cited/title/journal/first_author 排序)由 SearchView
|
||||
* 独立管理 keysetCursors/keysetHasMore/keysetPage。此 composable 的 totalPages/hasMore
|
||||
* 在 keyset 模式下语义不准确(keyset 用 has_more 标志而非总页数判断),由调用方覆盖使用。 */
|
||||
export function usePagination(opts: UsePaginationOptions) {
|
||||
const { fetchFn, pageSize: initialPageSize = 20 } = opts
|
||||
const page = ref(1)
|
||||
|
||||
@@ -32,18 +32,35 @@ export function resolveQuery(query: string, entries: HistoryEntry[]): string {
|
||||
})
|
||||
}
|
||||
|
||||
/** 递归展开所有 #N 引用为纯查询 */
|
||||
/** 递归展开所有 #N 引用为纯查询,带循环引用检测 */
|
||||
export function expandQuery(query: string, entries: HistoryEntry[]): string {
|
||||
const seen = new Set<string>()
|
||||
let prev = ''
|
||||
let current = query
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (current === prev) break
|
||||
const refs = current.match(/#(\d+)/g)
|
||||
if (refs) {
|
||||
for (const ref of refs) {
|
||||
if (seen.has(ref)) return prev // 循环引用 → 返回上次安全结果
|
||||
seen.add(ref)
|
||||
}
|
||||
}
|
||||
prev = current
|
||||
current = resolveQuery(current, entries)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** 重新计算所有条目的 expanded_query,在删除/淘汰后保证一致性 */
|
||||
function recomputeExpanded(entries: HistoryEntry[]) {
|
||||
const ids = new Set(entries.map(e => e.id))
|
||||
for (const entry of entries) {
|
||||
entry.expanded_query = expandQuery(entry.query, entries)
|
||||
.replace(/#(\d+)/g, (m) => ids.has(m) ? m : '(deleted)')
|
||||
}
|
||||
}
|
||||
|
||||
export function useSearchHistory() {
|
||||
const entries = ref<HistoryEntry[]>(loadAll())
|
||||
|
||||
@@ -69,6 +86,7 @@ export function useSearchHistory() {
|
||||
if (all.length >= MAX_ENTRIES) {
|
||||
all.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||
all.shift()
|
||||
recomputeExpanded(all) // 清除悬空 #N 引用
|
||||
}
|
||||
|
||||
all.push(entry)
|
||||
@@ -79,6 +97,7 @@ export function useSearchHistory() {
|
||||
|
||||
function remove(id: string) {
|
||||
const all = loadAll().filter(e => e.id !== id)
|
||||
recomputeExpanded(all) // 重新展开,清除悬空 #N 引用
|
||||
saveAll(all)
|
||||
entries.value = all
|
||||
}
|
||||
@@ -90,8 +109,10 @@ export function useSearchHistory() {
|
||||
|
||||
function download() {
|
||||
const all = loadAll()
|
||||
/** 转义 TSV 特殊字符:制表符/换行/回车 → 空格 */
|
||||
const escapeTsv = (s: string) => s.replace(/[\t\n\r]/g, ' ')
|
||||
const lines = all.map(e =>
|
||||
`${e.id}\t${e.result_count ?? ''}\t${e.timestamp}\t${e.query}`
|
||||
`${e.id}\t${e.result_count ?? ''}\t${e.timestamp}\t${escapeTsv(e.query)}`
|
||||
)
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -99,7 +120,8 @@ export function useSearchHistory() {
|
||||
a.href = url
|
||||
a.download = `pubmed-search-history-${new Date().toISOString().slice(0, 10)}.tsv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
// 延迟回收 blob URL,确保浏览器已开始下载
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -166,6 +166,7 @@ export interface SupplMeshEntry {
|
||||
|
||||
/** 文献对象(列表用) */
|
||||
export interface LiteratureItem {
|
||||
id?: string
|
||||
pmid: number
|
||||
title?: string
|
||||
doi?: string
|
||||
@@ -327,6 +328,8 @@ export interface SavedFilter {
|
||||
export interface SearchRequestBody {
|
||||
query: string
|
||||
field?: string
|
||||
boolean?: string // "and" | "or"
|
||||
exact_phrase?: boolean
|
||||
page: number
|
||||
page_size: number
|
||||
sort?: string
|
||||
|
||||
@@ -19,6 +19,7 @@ const router = useRouter(); const route = useRoute()
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const displaySettings = useDisplaySettings()
|
||||
const KEYSET_SORTS = new Set(['date', 'cited', 'title', 'journal', 'first_author'])
|
||||
|
||||
// ── 搜索参数 ──
|
||||
const query = ref('')
|
||||
@@ -276,6 +277,7 @@ const { page, total, goToPage } = usePagination({
|
||||
}
|
||||
const body: SearchRequestBody = {
|
||||
query: query.value, page: p, page_size: pageSize.value, sort: sort.value,
|
||||
boolean: 'and',
|
||||
}
|
||||
if (field.value !== 'all') body.field = field.value
|
||||
// P3-6: precision_mode 不再发送(后端已忽略)
|
||||
@@ -339,6 +341,7 @@ const { page, total, goToPage } = usePagination({
|
||||
if (retracted.value) body.retracted = retracted.value
|
||||
if (negativeResult.value) body.negative_result = negativeResult.value
|
||||
const { data } = await api.post('/features/search/advanced', body, { signal })
|
||||
if (gen !== searchGeneration.value) return // 丢弃老旧请求
|
||||
results.value = data.items || []
|
||||
// 游标分页:首页走 COUNT 存 total,后续页沿用;cursor 和数据一起返回
|
||||
if (p === 1) total.value = data.total || 0
|
||||
@@ -370,12 +373,14 @@ const { page, total, goToPage } = usePagination({
|
||||
/** 从路由 query 恢复搜索参数 */
|
||||
function restoreFromQuery() {
|
||||
if (route.query.q) query.value = String(route.query.q)
|
||||
if (route.query.field) field.value = String(route.query.field)
|
||||
if (route.query.sort) sort.value = String(route.query.sort)
|
||||
if (route.query.year_from) yearFromStr.value = String(route.query.year_from)
|
||||
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
|
||||
const VALID_FIELDS = new Set(['all', 'title', 'abstract', 'author', 'affiliation', 'journal'])
|
||||
const VALID_SORTS = new Set(['date', 'cited', 'best_match', 'relevance', 'first_author', 'journal', 'title'])
|
||||
if (route.query.field && VALID_FIELDS.has(String(route.query.field))) field.value = String(route.query.field)
|
||||
if (route.query.sort && VALID_SORTS.has(String(route.query.sort))) sort.value = String(route.query.sort)
|
||||
if (route.query.date_preset && ['1y','5y','10y','custom'].includes(String(route.query.date_preset))) {
|
||||
datePreset.value = String(route.query.date_preset)
|
||||
yearFromStr.value = ''
|
||||
yearToStr.value = ''
|
||||
} else if (route.query.date_from || route.query.date_to) {
|
||||
datePreset.value = null
|
||||
if (route.query.date_from) {
|
||||
@@ -388,6 +393,9 @@ function restoreFromQuery() {
|
||||
urlDateTo.value = dt
|
||||
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
|
||||
}
|
||||
} else {
|
||||
if (route.query.year_from) yearFromStr.value = String(route.query.year_from)
|
||||
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
|
||||
}
|
||||
if (route.query.tag) selectedTags.value = String(route.query.tag).split(',')
|
||||
if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',')
|
||||
@@ -410,8 +418,8 @@ function restoreFromQuery() {
|
||||
const ps = parseInt(String(route.query.page_size))
|
||||
if (ps >= 10 && ps <= 100) pageSize.value = ps
|
||||
}
|
||||
// keyset 游标不能从 URL 恢复 → page>1 回退到首页(所有排序模式)
|
||||
if (restoredPage.value > 1) restoredPage.value = 1
|
||||
// keyset 游标不能从 URL 恢复 → page>1 回退首页(仅键集排序模式)
|
||||
if (KEYSET_SORTS.has(sort.value) && restoredPage.value > 1) restoredPage.value = 1
|
||||
}
|
||||
|
||||
// 选择预设(1y/5y/10y)时清除自定义年份并自动搜索
|
||||
@@ -507,8 +515,8 @@ function syncSearchToUrl() {
|
||||
if (medlineOnly.value) q.medline_only = 'true'
|
||||
if (excludePreprints.value) q.exclude_preprints = 'true'
|
||||
if (pageSize.value !== 20) q.page_size = String(pageSize.value)
|
||||
// 页码持久化到 URL(不同排序下使用各自的分页)
|
||||
const currentPage = sort.value === 'date' ? keysetPage.value : page.value
|
||||
// 页码持久化到 URL(Keyset 排序使用 keyset 页码,offset 排序使用 offset 页码)
|
||||
const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value
|
||||
if (currentPage > 1) q.p = String(currentPage)
|
||||
router.replace({ query: q }).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NInput, NSelect, NIcon, useMessage } from 'naive-ui'
|
||||
import { SearchOutline, CodeSlashOutline, AddOutline } from '@vicons/ionicons5'
|
||||
@@ -86,8 +86,9 @@ const translated = computed(() => {
|
||||
}
|
||||
}
|
||||
// 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
|
||||
while ((m = yrRe.exec(queryText.value)) !== null) {
|
||||
while ((m = yrRe.exec(displayText)) !== null) {
|
||||
const key = `dp_range_${m.index}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
@@ -216,20 +217,34 @@ function removeEntry(id: string) {
|
||||
remove(id)
|
||||
}
|
||||
|
||||
// ── resolve #N ──
|
||||
// ── resolve #N(迭代展开,支持嵌套引用) ──
|
||||
function resolveQuery(q: string): string {
|
||||
if (!q.includes('#')) return q
|
||||
const all = historyEntries.value
|
||||
// 只替换不在引号字符串内的 #N 引用
|
||||
return q.replace(/"[^"]*"|'[^']*'|#(\d+)/g, (m, num) => {
|
||||
if (num === undefined) return m // 在引号内,不做替换
|
||||
const found = all.find(e => e.id === `#${num}`)
|
||||
if (!found) {
|
||||
message.warning(`查询编号 ${m} 在历史中不存在,已保留原样`)
|
||||
return m
|
||||
const seen = new Set<string>()
|
||||
let prev = ''
|
||||
let current = q
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (current === prev) break
|
||||
const refs = current.match(/#\d+/g)
|
||||
if (refs) {
|
||||
for (const ref of refs) {
|
||||
if (seen.has(ref)) return prev
|
||||
seen.add(ref)
|
||||
}
|
||||
}
|
||||
return `(${found.expanded_query})`
|
||||
})
|
||||
prev = current
|
||||
current = current.replace(/"[^"]*"|'[^']*'|#(\d+)/g, (m, num) => {
|
||||
if (num === undefined) return m // 在引号内,不做替换
|
||||
const found = all.find(e => e.id === `#${num}`)
|
||||
if (!found) {
|
||||
message.warning(`查询编号 ${m} 在历史中不存在,已保留原样`)
|
||||
return m
|
||||
}
|
||||
return `(${found.expanded_query})`
|
||||
})
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function validateQuery(q: string): { valid: boolean; query: string; error?: string } {
|
||||
@@ -287,17 +302,18 @@ function validateQuery(q: string): { valid: boolean; query: string; error?: stri
|
||||
}
|
||||
}
|
||||
|
||||
// 校验括号匹配
|
||||
// 校验括号匹配(在展开后的查询上检查,确保历史引用展开后也平衡)
|
||||
const expandedForCheck = resolveQuery(q)
|
||||
let depth = 0
|
||||
for (const ch of q) {
|
||||
for (const ch of expandedForCheck) {
|
||||
if (ch === '(') depth++
|
||||
if (ch === ')') depth--
|
||||
if (depth < 0) {
|
||||
return { valid: false, query: q, error: '括号不匹配:多余的右括号' }
|
||||
return { valid: false, query: q, error: '括号不匹配:多余的右括号(结合历史查询展开后)' }
|
||||
}
|
||||
}
|
||||
if (depth !== 0) {
|
||||
return { valid: false, query: q, error: '括号不匹配:缺少右括号' }
|
||||
return { valid: false, query: q, error: '括号不匹配:缺少右括号(结合历史查询展开后)' }
|
||||
}
|
||||
|
||||
return { valid: true, query: resolveQuery(q) }
|
||||
@@ -305,12 +321,17 @@ function validateQuery(q: string): { valid: boolean; query: string; error?: stri
|
||||
|
||||
const loading = ref(false)
|
||||
const reversedHistory = computed(() => historyEntries.value.slice().reverse())
|
||||
const fetchCountController = ref<AbortController | null>(null)
|
||||
|
||||
async function fetchCount(q: string): Promise<number | null> {
|
||||
fetchCountController.value?.abort()
|
||||
fetchCountController.value = new AbortController()
|
||||
try {
|
||||
const { data } = await api.post('/features/search/advanced', { query: q, page_size: 1 })
|
||||
const { data } = await api.post('/features/search/advanced', { query: q, page_size: 1 }, { signal: fetchCountController.value.signal })
|
||||
return (data && typeof data.total === 'number') ? data.total : null
|
||||
} catch {
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return null
|
||||
console.warn('fetchCount failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -361,6 +382,10 @@ function formatDate(ts: string): string {
|
||||
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
} catch { return ts }
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
fetchCountController.value?.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -423,7 +448,7 @@ function formatDate(ts: string): string {
|
||||
Search
|
||||
</NButton>
|
||||
<NButton @click="clearQuery" class="sky-btn" size="small">Clear</NButton>
|
||||
<NButton @click="addToHistory" class="sky-btn" size="small">
|
||||
<NButton @click="addToHistory" class="sky-btn" size="small" :disabled="loading">
|
||||
<template #icon><NIcon size="14"><AddOutline /></NIcon></template>
|
||||
Add to History
|
||||
</NButton>
|
||||
|
||||
@@ -38,9 +38,13 @@ const searchTotal = ref(0)
|
||||
const cursorVal = ref<string | null>(null)
|
||||
const cursorId = ref<string | null>(null)
|
||||
const hasMoreItems = ref(false)
|
||||
// offset 分页计数器(用于 best_match/relevance 排序)
|
||||
const loadMorePage = ref(1)
|
||||
const KEYSET_SORTS = new Set(['date', 'cited', 'title', 'journal', 'first_author'])
|
||||
|
||||
// 搜索请求 AbortController,防竞态
|
||||
const searchController = ref<AbortController | null>(null)
|
||||
const loadMoreController = ref<AbortController | null>(null)
|
||||
|
||||
// ── 公开统计 ──
|
||||
const platformStats = ref({ literature_total: 0, journal_total: 0, daily_avg_30d: 0 })
|
||||
@@ -144,6 +148,7 @@ async function fetchData(resetPage = true) {
|
||||
if (resetPage) {
|
||||
cursorVal.value = null
|
||||
cursorId.value = null
|
||||
loadMorePage.value = 1
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -319,32 +324,46 @@ function onResize() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const body: Record<string, any> = { page_size: searchParams.value.page_size, sort: searchParams.value.sort || 'date' }
|
||||
if (searchParams.value.query) body.query = searchParams.value.query
|
||||
if (searchParams.value.field !== 'all') body.field = searchParams.value.field
|
||||
if (searchParams.value.tag_ids.length) body.tag_ids = searchParams.value.tag_ids
|
||||
if (searchParams.value.date_from) body.date_from = searchParams.value.date_from
|
||||
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
|
||||
// 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
|
||||
// P1-F9: 统一使用服务端返回的游标(替代手动从末条提取)
|
||||
cursorVal.value = data.cursor_val ?? null
|
||||
cursorId.value = data.cursor_id ?? null
|
||||
} catch (e) { toast.apiError(e, '加载更多失败,请重试') }
|
||||
finally { loadingMore.value = false }
|
||||
}
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
// 取消上次未完成的 loadMore 请求,防竞态
|
||||
loadMoreController.value?.abort()
|
||||
const controller = new AbortController()
|
||||
loadMoreController.value = controller
|
||||
try {
|
||||
const body: Record<string, any> = { page_size: searchParams.value.page_size, sort: searchParams.value.sort || 'date' }
|
||||
if (searchParams.value.query) body.query = searchParams.value.query
|
||||
if (searchParams.value.field !== 'all') body.field = searchParams.value.field
|
||||
if (searchParams.value.tag_ids.length) body.tag_ids = searchParams.value.tag_ids
|
||||
if (searchParams.value.date_from) body.date_from = searchParams.value.date_from
|
||||
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
|
||||
const isKeyset = KEYSET_SORTS.has(searchParams.value.sort || 'date')
|
||||
if (isKeyset) {
|
||||
if (cursorVal.value && cursorId.value) {
|
||||
body.cursor_val = cursorVal.value
|
||||
body.cursor_id = cursorId.value
|
||||
}
|
||||
} else {
|
||||
body.page = loadMorePage.value + 1
|
||||
}
|
||||
const { data } = await api.post('/features/search/advanced', body, { signal: controller.signal })
|
||||
feedItems.value.push(...(data.items || []))
|
||||
hasMoreItems.value = data.has_more ?? false
|
||||
if (isKeyset) {
|
||||
cursorVal.value = data.cursor_val ?? null
|
||||
cursorId.value = data.cursor_id ?? null
|
||||
} else {
|
||||
loadMorePage.value++
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||
toast.apiError(e, '加载更多失败,请重试')
|
||||
}
|
||||
finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
// ── 从 URL 恢复状态 ──
|
||||
function restoreFromUrl() {
|
||||
@@ -418,6 +437,8 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('resize', onResize)
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||
searchController.value?.abort()
|
||||
loadMoreController.value?.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user