onesvm-browser-server/server/internal/gateway/cache.go
chii 347c94c121 fix: 搜索缓存往返保留 results,并加检索源规则过滤
缓存命中改写信封时补 UnmarshalJSON,避免 results 被抹空;零结果不进 300s 缓存。模版层对 SearXNG hits 做广告标记丢弃与同域限额 1 条,减轻 1688 霸屏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 11:33:13 +08:00

138 lines
3.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// cache.go:搜索短缓存(plan-final §2.4:消化上游 CAPTCHA 的设计结论)。
// 进程内 LRU,容量 50,TTL 300s;键 = region+query+参数 hash。
// 仅缓存 search 成功信封;read 不缓存(页面可能变化 + 体积大)。
package gateway
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"sync"
"time"
"onesvm.com/onesvm/browser-server/internal/contract"
)
// CacheTTL 缓存有效期(design 值 300s)。
const CacheTTL = 300 * time.Second
// CacheCap LRU 容量(design 值 50)。
const CacheCap = 50
// cachedEntry 缓存条目:缓存完整信封字节 + 命中时改写 provenance.cached=true。
type cachedEntry struct {
body []byte // 原始信封 JSON(cached=false 时的形状)
expiresAt time.Time
// LRU 双向链表需要的前后指针(用 map+slice 简化:访问时间排序)
lastUsed time.Time
key string
}
// SearchCache 搜索短缓存(并发安全)。
type SearchCache struct {
mu sync.Mutex
m map[string]*cachedEntry
now func() time.Time
hits int64
miss int64
}
// NewSearchCache 构造。
func NewSearchCache() *SearchCache {
return &SearchCache{m: map[string]*cachedEntry{}, now: time.Now}
}
// cacheKey 缓存键:region + query + 参数 hash(max_results/time_range/lang)。
func cacheKey(in *contract.SearchInput) string {
h := sha256.New()
h.Write([]byte(in.Region))
h.Write([]byte{0})
h.Write([]byte(in.Query))
h.Write([]byte{0})
if in.TimeRange != nil {
h.Write([]byte(*in.TimeRange))
}
h.Write([]byte{0})
if in.Lang != nil {
h.Write([]byte(*in.Lang))
}
h.Write([]byte{0})
h.Write([]byte{byte(in.MaxResults)})
return hex.EncodeToString(h.Sum(nil))
}
// Get 命中返回改写后的信封字节(provenance.cached=true、usage.credits=0、
// provenance.retrieved_at 保留原值——数据确实是那时取的)。
func (c *SearchCache) Get(in *contract.SearchInput) ([]byte, bool) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
k := cacheKey(in)
e, ok := c.m[k]
if !ok {
c.miss++
return nil, false
}
if now.After(e.expiresAt) {
delete(c.m, k)
c.miss++
return nil, false
}
e.lastUsed = now
c.hits++
return rewriteCached(e.body, now), true
}
// rewriteCached 改写缓存命中信封:cached=true、credits=0。
// 通过结构化反序列化改字段再序列化,避免字符串替换脆弱性。
func rewriteCached(body []byte, now time.Time) []byte {
var env contract.Envelope
if err := json.Unmarshal(body, &env); err != nil {
return body // 不应发生(写缓存的必是合法信封)
}
env.Provenance.Cached = true
env.Usage.Credits = 0
env.TookMs = 0 // 缓存命中无执行耗时
_ = now
b, err := json.Marshal(env)
if err != nil {
return body
}
return b
}
// Put 写缓存(仅 search 成功且非空结果)。超容量按 lastUsed 淘汰最旧。
func (c *SearchCache) Put(in *contract.SearchInput, body []byte) {
if isEmptySearchEnvelope(body) {
return
}
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
k := cacheKey(in)
c.m[k] = &cachedEntry{body: body, expiresAt: now.Add(CacheTTL), lastUsed: now, key: k}
if len(c.m) > CacheCap {
c.evictOldest()
}
}
// evictOldest 淘汰最久未用条目(LRU)。
func (c *SearchCache) evictOldest() {
keys := make([]string, 0, len(c.m))
for k := range c.m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return c.m[keys[i]].lastUsed.Before(c.m[keys[j]].lastUsed)
})
victim := keys[0]
delete(c.m, victim)
}
// Stats 观测(/metrics 扩展)。
func (c *SearchCache) Stats() (hits, miss int64) {
c.mu.Lock()
defer c.mu.Unlock()
return c.hits, c.miss
}