fix: 第8轮搜索深度审计修复 — 缓存失效、Redis重试、中文标签、翻页稳定性等12项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

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:
34047007@qq.com
2026-07-28 11:02:29 +08:00
parent c50831d1d8
commit 73f9468384
34 changed files with 696 additions and 218 deletions
@@ -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)
+25 -3
View File
@@ -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 {