package vision import ( "container/list" "context" "crypto/sha256" "encoding/hex" "net/url" "sync" "time" ) // 取回缓存的边界。取值依据: // - 来源条数 64:一次会话里用户附上的不同图片远少于这个数, // 超出的老来源按 LRU 淘汰即可,不追求命中率; // - 内容条目 256:字节上限虽已按 4/3 的内联膨胀计过,但极小的图片能 // 堆出极多条目,256 条把 map/list 每项的开销也一并钉住; // - 内容字节 64 MiB:内联串约为解码字节的 4/3,单张图解码上限 // 5 MiB(MaxInlineImageBytes),64 MiB 约合 9 张满额图,够覆盖 // 一轮对话的附图,同时把常驻内存钉在一个明确上界内; // - TTL 10 分钟:一次会话内「同一张图被连续多轮重复解析」的窗口, // 也短到让上游换了同一 URL 的内容后最迟 10 分钟自愈(见文件末尾 // 「失效策略」)。 const ( defaultImageCacheMaxSources = 64 defaultImageCacheMaxContents = 256 defaultImageCacheMaxBytes = 64 << 20 defaultImageCacheTTL = 10 * time.Minute ) // imageCache 按「来源键 → 内容键」两级映射缓存「取回 + 内联」的结果, // 使同一张图的 URL 在后续回合不再重复走网络(缺陷 02/H1)。 // // 来源键是 URL 原文(ImageRef.Data),内容键是 sha256(MIME + 0x00 + 取回 // 字节)。两级分开是为了让同一张图从不同 URL 取回时只留一份 data: URI, // 而每条来源映射仍各自记着自己的 TTL;来源条数与内容总字节各有上限, // 缓存因此不会无界增长。 // // 并发安全:所有读写都在 mu 之下,取回本身在锁外进行 —— 同一 URL 的 // 并发未命中可能各取一次(内容相同则收敛到同一个内容键),但结构状态 // 不会因并发而损坏。 // // 失效策略(明确定义): // - 失败不缓存:取回失败(DNS/超时/非 200/非图像)不写任何条目, // 下一次解析照常重试,坏结果绝不会被「缓存住」而永久看不到图; // - 内容键决定复用:字节变了就是另一个内容键,旧内容不会被顶替它 // 的新字节命中; // - 来源映射有 TTL:同一 URL 的内容在上游变化后,最迟 TTL 到期即 // 重新取回并刷新映射(TTL 内仍返回旧图,这是缓存换取的确定性代价); // - 内容条目被容量淘汰时,指向它的来源映射一并作废,不留死映射。 type imageCache struct { mu sync.Mutex ttl time.Duration maxSrc int maxCtn int maxBytes int now func() time.Time sources map[string]*sourceNode contents map[string]*contentNode srcLRU *list.List ctnLRU *list.List bytes int } // sourceNode 是一条来源键映射:它指向内容键,并带自己的过期时刻。 type sourceNode struct { key string contentKey string expiresAt time.Time elem *list.Element } // contentNode 是一条内容键条目:存着可供模型直接消费的 data: URI。 type contentNode struct { key string dataURI string elem *list.Element } // newImageCache 构造一个按默认边界运行的缓存。 func newImageCache() *imageCache { return &imageCache{ ttl: defaultImageCacheTTL, maxSrc: defaultImageCacheMaxSources, maxCtn: defaultImageCacheMaxContents, maxBytes: defaultImageCacheMaxBytes, now: time.Now, sources: make(map[string]*sourceNode), contents: make(map[string]*contentNode), srcLRU: list.New(), ctnLRU: list.New(), } } // get 返回 sourceKey 命中的 data: URI。未命中、映射已过期、或内容条目已 // 被容量淘汰时返回 false,并把失效的那条映射清掉。 func (c *imageCache) get(sourceKey string) (string, bool) { c.mu.Lock() defer c.mu.Unlock() node, ok := c.sources[sourceKey] if !ok { return "", false } if !c.now().Before(node.expiresAt) { c.dropSource(node) return "", false } content, ok := c.contents[node.contentKey] if !ok { c.dropSource(node) return "", false } c.srcLRU.MoveToFront(node.elem) c.ctnLRU.MoveToFront(content.elem) return content.dataURI, true } // put 记下 sourceKey 这次取回的内容。取回失败的路径不调用它,因此失败的 // 结果永远不会进缓存(见 imageCache 的「失效策略」)。 func (c *imageCache) put(sourceKey, mime string, raw []byte, dataURI string) { key := contentKeyOf(mime, raw) c.mu.Lock() defer c.mu.Unlock() now := c.now() if node, ok := c.sources[sourceKey]; ok { node.contentKey = key node.expiresAt = now.Add(c.ttl) c.srcLRU.MoveToFront(node.elem) } else { node := &sourceNode{key: sourceKey, contentKey: key, expiresAt: now.Add(c.ttl)} node.elem = c.srcLRU.PushFront(node) c.sources[sourceKey] = node } // 同一个内容键必然对应同一份 MIME + 字节,DataURI 是纯函数, // 因此已存在的条目与原值逐字节相同,无需覆盖。 if _, ok := c.contents[key]; !ok { content := &contentNode{key: key, dataURI: dataURI} content.elem = c.ctnLRU.PushFront(content) c.contents[key] = content c.bytes += len(dataURI) } c.evict(now) } // evict 把缓存压回上界:先按 LRU 丢内容条目直到总字节与条数达标,再丢 // 多余的来源映射,最后清掉已过期的来源映射。 func (c *imageCache) evict(now time.Time) { for c.ctnLRU.Len() > 0 && c.bytes > c.maxBytes { c.dropContent(c.ctnLRU.Back().Value.(*contentNode)) } for c.ctnLRU.Len() > c.maxCtn { c.dropContent(c.ctnLRU.Back().Value.(*contentNode)) } for c.srcLRU.Len() > c.maxSrc { c.dropSource(c.srcLRU.Back().Value.(*sourceNode)) } for e := c.srcLRU.Back(); e != nil; { prev := e.Prev() node := e.Value.(*sourceNode) if !now.Before(node.expiresAt) { c.dropSource(node) } e = prev } } // dropSource 移除一条来源映射;重复调用是安全的。 func (c *imageCache) dropSource(node *sourceNode) { if _, ok := c.sources[node.key]; !ok { return } delete(c.sources, node.key) c.srcLRU.Remove(node.elem) } // dropContent 移除一条内容条目并同步扣减字节数;指向它的来源映射一并 // 作废,免得留下永远命中不了的映射占着条数。 func (c *imageCache) dropContent(node *contentNode) { content, ok := c.contents[node.key] if !ok { return } delete(c.contents, node.key) c.ctnLRU.Remove(content.elem) c.bytes -= len(content.dataURI) for _, src := range c.sources { if src.contentKey == node.key { c.dropSource(src) } } } // stats 返回(来源映射数, 内容条目数, 缓存内 data: URI 总字节), // 供测试断言上界真的生效。 func (c *imageCache) stats() (sources, contents, bytes int) { c.mu.Lock() defer c.mu.Unlock() return len(c.sources), len(c.contents), c.bytes } // contentKeyOf 计算内容键:sha256(MIME + 0x00 + 取回字节)。MIME 参与哈希, // 使「同字节但声明类型不同」的两次取回不会被当成同一条内容互相顶替。 func contentKeyOf(mime string, raw []byte) string { h := sha256.New() h.Write([]byte(mime)) h.Write([]byte{0}) h.Write(raw) return hex.EncodeToString(h.Sum(nil)) } // inlineImageCached 是解析器使用的「取回 + 内联」入口:src 是 http(s) URL // 时命中 c 就直接返回缓存的 data: URI(不再发起网络请求),未命中则取回 // 后回填。c 为 nil、或 src 不是 URL 时退化为 inlineImageSrc —— data:、 // base64 与本地路径没有网络开销,不进缓存(也避免把巨型内联串当 map 键)。 // // 失败不写缓存,并返回结构化错误 SourceFailure(只带主机与原因),供 // 调用方记一条可诊断的 WARN;下一次解析会照常重试。 func inlineImageCached(ctx context.Context, c *imageCache, src string) (string, error) { if c == nil { return inlineImageSrc(ctx, src) } ref, err := ParseImageRef(src) if err != nil { return "", err } if ref.Source != SourceURL { return inlineImageSrc(ctx, src) } if uri, ok := c.get(ref.Data); ok { return uri, nil } img, err := inlineImage(ctx, src) if err != nil { return "", sourceFailure(ref.Data, err) } c.put(ref.Data, img.MIME, img.Raw, img.URI) return img.URI, nil } // sourceFailure 把一次 URL 取回失败包成结构化错误,主机取自 URL。 // 主机用 u.Host(不含 userinfo、路径与查询串),URL 解析失败时留空。 func sourceFailure(rawURL string, err error) error { host := "" if u, perr := url.Parse(rawURL); perr == nil { host = u.Host } return &SourceFailure{Host: host, Reason: err} }