- Store 增 MaxBytes(默认 256MB):写入后节流扫描(10 分钟一次、同时只跑一个), 超限按 mtime 从旧到新删到 3/4 水位(滞回,避免卡在边界反复删),顺带清掉 进程被杀留下的 .tmp - 命中缓存时 Touch 更新 mtime,淘汰因此是 LRU 而非「最早生成的先死」, 热图不会被误删后反复回源拉存储 - 新增 Purge(sha):后台删除上传文件时连带清掉该图所有宽度的产物与 .failed 标记,否则已删图片会永久占着缓存
173 lines
4.4 KiB
Go
173 lines
4.4 KiB
Go
package thumbs
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func makePNG(t *testing.T, w, h int, transparent bool) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
// 左红右绿两半,方便验证盒均值没把通道混错
|
|
c := color.RGBA{255, 0, 0, 255}
|
|
if x >= w/2 {
|
|
c = color.RGBA{0, 255, 0, 255}
|
|
}
|
|
if transparent {
|
|
c = color.RGBA{0, 0, 0, 0}
|
|
}
|
|
img.Set(x, y, c)
|
|
}
|
|
}
|
|
var b bytes.Buffer
|
|
if err := png.Encode(&b, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
func TestGenerateDownscale(t *testing.T) {
|
|
src := makePNG(t, 800, 400, false)
|
|
out, mime, pass, err := Generate(src, "image/png", 400)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pass {
|
|
t.Fatal("800px 图在 w=400 不应直通")
|
|
}
|
|
if mime != "image/jpeg" {
|
|
t.Fatalf("mime=%s", mime)
|
|
}
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Width != 400 || cfg.Height != 200 {
|
|
t.Fatalf("got %dx%d, want 400x200", cfg.Width, cfg.Height)
|
|
}
|
|
img, err := jpeg.Decode(bytes.NewReader(out))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 盒均值后左右两半的中心仍是红/绿(容差给 JPEG 质量 82 留余量)
|
|
r, g, _, _ := img.At(100, 100).RGBA()
|
|
if r>>8 < 200 || g>>8 > 60 {
|
|
t.Fatalf("左半应偏红: r=%d g=%d", r>>8, g>>8)
|
|
}
|
|
r, g, _, _ = img.At(300, 100).RGBA()
|
|
if g>>8 < 200 || r>>8 > 60 {
|
|
t.Fatalf("右半应偏绿: r=%d g=%d", r>>8, g>>8)
|
|
}
|
|
}
|
|
|
|
func TestGeneratePassThrough(t *testing.T) {
|
|
src := makePNG(t, 300, 150, false)
|
|
out, mime, pass, err := Generate(src, "image/png", 960)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !pass || mime != "image/png" || !bytes.Equal(out, src) {
|
|
t.Fatalf("小图应原样直通 pass=%v mime=%s", pass, mime)
|
|
}
|
|
}
|
|
|
|
func TestGenerateRejectsGarbage(t *testing.T) {
|
|
if _, _, _, err := Generate([]byte("not an image"), "image/jpeg", 400); err == nil {
|
|
t.Fatal("垃圾字节应报错")
|
|
}
|
|
}
|
|
|
|
func TestFlattenAlphaUnpremultiply(t *testing.T) {
|
|
// 50% 透明的纯红:alpha=128 预乘 R=128,还原应为 R≈255
|
|
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
|
img.Set(0, 0, color.NRGBA{255, 0, 0, 128})
|
|
flat := flatten(img, img.Bounds())
|
|
if flat[0] < 240 || flat[3] != 128 {
|
|
t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3])
|
|
}
|
|
}
|
|
|
|
func TestSweepEvictsOldest(t *testing.T) {
|
|
s := NewStore(t.TempDir())
|
|
s.MaxBytes = 2500 // 每个文件 1000B,留得下 2 个
|
|
sha := func(i int) string { return strings.Repeat(string(rune('a'+i)), 64) }
|
|
|
|
// 三个宽度:写入顺序即 mtime 顺序(下面再显式拨时间)
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := s.Put(sha(i), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// 跳过节流,让这次 Put 真的触发扫描
|
|
s.lastSweep = time.Time{}
|
|
oldest := time.Now().Add(-3 * time.Hour)
|
|
for i := 0; i < 2; i++ { // 前两个假装很久没人访问
|
|
os.Chtimes(s.jpegPath(sha(i), 480), oldest, oldest)
|
|
}
|
|
if _, err := s.Put(sha(3), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var total int64
|
|
var kept []string
|
|
filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
fi, _ := d.Info()
|
|
total += fi.Size()
|
|
kept = append(kept, d.Name())
|
|
return nil
|
|
})
|
|
if total > s.MaxBytes {
|
|
t.Fatalf("sweep 后仍超限: total=%d max=%d files=%v", total, s.MaxBytes, kept)
|
|
}
|
|
if len(kept) >= 4 {
|
|
t.Fatalf("一个都没淘汰,说明扫描没跑: files=%v", kept)
|
|
}
|
|
// 最新写入的那个必须还在
|
|
if s.FindCached(sha(3), 480) == "" {
|
|
t.Fatalf("刚写入的缓存被误删: %v", kept)
|
|
}
|
|
}
|
|
|
|
func TestPurgeRemovesAllEntriesForFile(t *testing.T) {
|
|
s := NewStore(t.TempDir())
|
|
sha := strings.Repeat("f", 64)
|
|
for _, w := range []int{480, 960} {
|
|
if _, err := s.Put(sha, w, []byte("data"), "image/jpeg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
s.MarkFailed(sha)
|
|
other := strings.Repeat("a", 64)
|
|
if _, err := s.Put(other, 480, []byte("keep"), "image/jpeg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s.Purge(sha)
|
|
|
|
for _, w := range []int{480, 960} {
|
|
if p := s.FindCached(sha, w); p != "" {
|
|
t.Fatalf("purge 后仍残留 %s", p)
|
|
}
|
|
}
|
|
if s.FailedRecently(sha) {
|
|
t.Fatal("失败标记没清掉")
|
|
}
|
|
if s.FindCached(other, 480) == "" {
|
|
t.Fatal("误删了别的文件的缓存")
|
|
}
|
|
}
|