From 12f6e82308bac8f0977ed8f755d9d2409e6a7a83 Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 12:44:47 +0800 Subject: [PATCH] fix(api): lazy cover self-heal + tmp-file skip --- backend/internal/api/content.go | 38 ++++++++++++++++++++++++++-- backend/internal/api/content_test.go | 24 ++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/backend/internal/api/content.go b/backend/internal/api/content.go index 057397e..ff26de0 100644 --- a/backend/internal/api/content.go +++ b/backend/internal/api/content.go @@ -36,8 +36,42 @@ func (a *api) serveCover(c *gin.Context) { } a.immutable(c) dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) - if entries, e := os.ReadDir(dir); e == nil && len(entries) > 0 { - http.ServeFile(c.Writer, c.Request, filepath.Join(dir, entries[0].Name())) + if entries, e := os.ReadDir(dir); e == nil { + for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态 + if !strings.Contains(en.Name(), ".tmp") { + http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name())) + return + } + } + } + if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到) + if root, ok := a.bookRoot(c, b); ok { + if f, size, ok := a.openBook(c, b, root); ok { + defer f.Close() + var img []byte + var ext string + var e error + if b.Format == "cbz" { + img, ext, e = bookfile.CBZCover(f, size) + } else { + img, ext, e = bookfile.EPUBCover(f, size) + } + if e == nil { + dst := filepath.Join(dir, "cover"+ext) + if e := os.MkdirAll(dir, 0o755); e == nil { + tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano()) // 并发幂等:唯一 tmp + rename 原子 + if e := os.WriteFile(tmp, img, 0o644); e == nil { + if e := os.Rename(tmp, dst); e == nil { + http.ServeFile(c.Writer, c.Request, dst) + } + } + os.Remove(tmp) + } + } + } + } + } + if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图 return } c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover)) diff --git a/backend/internal/api/content_test.go b/backend/internal/api/content_test.go index ea4b323..f7c814e 100644 --- a/backend/internal/api/content_test.go +++ b/backend/internal/api/content_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -47,6 +48,29 @@ func TestCoverCBZAndPlaceholder(t *testing.T) { } } +// 不经 scanner 直接入库(模拟缓存丢失/卷漂移)→ GET cover 就地抽图自愈并落盘(tmp+rename) +func TestCoverSelfHeal(t *testing.T) { + st, _, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + lib, root := newLibrary(t, st, h, atok, booksDir, "comics") + p := filepath.Join(root, "heal.cbz") + writeCBZ(t, p, 2) + fi, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + id, err := st.InsertBook(context.Background(), lib.ID, "heal.cbz", "heal", "cbz", fi.Size(), fi.ModTime().Unix(), 2) + if err != nil { + t.Fatal(err) + } + for i := 1; i <= 2; i++ { // 第二次走已缓存目录,均应 200 image/* + w := do(h, "GET", "/api/books/"+itoa(id)+"/cover", atok, nil) + if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Type"), "image/") { + t.Fatalf("self-heal cover pass %d: %d %q", i, w.Code, w.Header().Get("Content-Type")) + } + } +} + func TestPages(t *testing.T) { h, cbzID, txtID := serveFixture(t) tok := adminToken(t, h)