缓存命中改写信封时补 UnmarshalJSON,避免 results 被抹空;零结果不进 300s 缓存。模版层对 SearXNG hits 做广告标记丢弃与同域限额 1 条,减轻 1688 霸屏。 Co-authored-by: Cursor <cursoragent@cursor.com>
138 lines
3.6 KiB
Go
138 lines
3.6 KiB
Go
// 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
|
||
}
|