From 586b714f3ca7f9903c740d1bb3921973e8710cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Tue, 29 Sep 2026 23:43:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(cache):=20=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=8E=A5=E5=8F=A3=E6=8E=A5=E5=85=A5=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 withCache 包装:命中直接返回,miss 回源后写入,仅缓存成功响应 - 详情/目录/批量详情/章节摘要缓存 24 小时;搜索/分类列表/分类详情短缓存 10 分钟 - 7 个 fqapi 处理函数原函数体不动、改名 fetchXxx,外层套缓存壳 - redisGetCache 补 3 秒读超时,与写路径一致,防远程 Redis 网络抖动拖垮请求 - 新增 TestWithCache:验证命中不回源、失败响应不缓存 --- internal/fqapi/cache_manager.go | 13 +++++++++ internal/fqapi/cache_manager_test.go | 43 ++++++++++++++++++++++++++++ internal/fqapi/catalog.go | 8 +++++- internal/fqapi/category_front.go | 8 +++++- internal/fqapi/category_landing.go | 8 +++++- internal/fqapi/detail.go | 8 +++++- internal/fqapi/item_summary.go | 8 +++++- internal/fqapi/multi_detail.go | 8 +++++- internal/fqapi/search.go | 8 +++++- internal/fqapi/store_redis.go | 5 +++- 10 files changed, 109 insertions(+), 8 deletions(-) diff --git a/internal/fqapi/cache_manager.go b/internal/fqapi/cache_manager.go index 98afe75..c4e9180 100644 --- a/internal/fqapi/cache_manager.go +++ b/internal/fqapi/cache_manager.go @@ -255,6 +255,19 @@ func isCacheEnabled() bool { return cfg.Cache.Enabled } +// withCache 只读接口的缓存包装:命中直接返回,miss 回源后写入(仅缓存成功响应) +// ponytail: 空参等防御分支若漏到此处,其错误 JSON 也会被缓存到 TTL 到期——handler 层 validateParams 已拦截 +func withCache(key string, ttl int, fetch func() (string, error)) (string, error) { + if c := GetCache(key); c != "" { + return c, nil + } + result, err := fetch() + if err == nil { + UpdateCache(key, result, ttl) + } + return result, err +} + // GetCache 获取正文内容缓存 func GetCache(key string) string { if !isCacheEnabled() { diff --git a/internal/fqapi/cache_manager_test.go b/internal/fqapi/cache_manager_test.go index e7846a0..cbc13b2 100644 --- a/internal/fqapi/cache_manager_test.go +++ b/internal/fqapi/cache_manager_test.go @@ -55,3 +55,46 @@ func TestConcurrentCacheAccess(t *testing.T) { } wg.Wait() } + +// TestWithCache 验证缓存命中不回源、失败响应不缓存 +func TestWithCache(t *testing.T) { + if err := config.LoadConfig("../../config/config.yaml"); err != nil { + t.Skipf("无法加载配置: %v", err) + } + db, err := sql.Open("sqlite3", t.TempDir()+"/test.db?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + t.Fatal(err) + } + oldDB, oldDriver := cacheDB, dbDriver + cacheDB, dbDriver = db, "sqlite3" + defer func() { + cacheDB, dbDriver = oldDB, oldDriver + db.Close() + }() + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, value TEXT, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + )`); err != nil { + t.Fatal(err) + } + + calls := 0 + fetch := func() (string, error) { calls++; return "result", nil } + if got, _ := withCache("wc_hit", 60, fetch); got != "result" { + t.Fatalf("首次应返回回源结果, got %q", got) + } + if got, _ := withCache("wc_hit", 60, fetch); got != "result" { + t.Fatalf("命中应返回缓存结果, got %q", got) + } + if calls != 1 { + t.Fatalf("命中缓存不应回源, fetch 调用 %d 次", calls) + } + + errCalls := 0 + errFetch := func() (string, error) { errCalls++; return "", fmt.Errorf("boom") } + withCache("wc_miss", 60, errFetch) + withCache("wc_miss", 60, errFetch) + if errCalls != 2 { + t.Fatalf("失败响应不应被缓存, fetch 调用 %d 次", errCalls) + } +} diff --git a/internal/fqapi/catalog.go b/internal/fqapi/catalog.go index 09bb93e..68ef370 100644 --- a/internal/fqapi/catalog.go +++ b/internal/fqapi/catalog.go @@ -10,8 +10,14 @@ import ( var catalogLogger = common.NewLogger("catalog", "INFO", "") -// HandleGetCatalog 处理获取书籍目录 +// HandleGetCatalog 处理获取书籍目录,目录数据基本不变,缓存 24 小时 func HandleGetCatalog(bookID string) (string, error) { + return withCache("catalog_" + bookID, 86400, func() (string, error) { + return fetchCatalog(bookID) + }) +} + +func fetchCatalog(bookID string) (string, error) { if bookID == "" { return BuildErrorResponse("需要参数 book_id"), nil } diff --git a/internal/fqapi/category_front.go b/internal/fqapi/category_front.go index 0138795..0bdd1a7 100644 --- a/internal/fqapi/category_front.go +++ b/internal/fqapi/category_front.go @@ -7,8 +7,14 @@ import ( var categoryFrontLogger = common.NewLogger("category_front", "INFO", "") -// HandleCategoryFront 处理获取分类列表 +// HandleCategoryFront 处理获取分类列表,分类列表短缓存 10 分钟 func HandleCategoryFront(tab interface{}) (string, error) { + return withCache(fmt.Sprintf("category_front_%v", tab), 600, func() (string, error) { + return fetchCategoryFront(tab) + }) +} + +func fetchCategoryFront(tab interface{}) (string, error) { tabInt := ParseIntParam(tab, 0) reqURL := BuildURL( diff --git a/internal/fqapi/category_landing.go b/internal/fqapi/category_landing.go index dee2fb0..f99eeae 100644 --- a/internal/fqapi/category_landing.go +++ b/internal/fqapi/category_landing.go @@ -7,8 +7,14 @@ import ( var categoryLandingLogger = common.NewLogger("category_landing", "INFO", "") -// HandleCategoryLanding 处理获取分类详情 +// HandleCategoryLanding 处理获取分类详情,分类详情短缓存 10 分钟 func HandleCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy interface{}) (string, error) { + return withCache(fmt.Sprintf("category_landing_%v_%v_%v_%v_%v", categoryID, offset, wordNumber, bookStatus, sortBy), 600, func() (string, error) { + return fetchCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy) + }) +} + +func fetchCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy interface{}) (string, error) { // 参数转换 categoryIDInt := ParseIntParam(categoryID, 0) offsetInt := ParseIntParam(offset, 0) diff --git a/internal/fqapi/detail.go b/internal/fqapi/detail.go index 0732e70..c973800 100644 --- a/internal/fqapi/detail.go +++ b/internal/fqapi/detail.go @@ -8,8 +8,14 @@ import ( var detailLogger = common.NewLogger("detail", "INFO", "") -// HandleGetDetail 处理获取书籍详情 +// HandleGetDetail 处理获取书籍详情,详情数据基本不变,缓存 24 小时 func HandleGetDetail(bookID string) (string, error) { + return withCache("detail_" + bookID, 86400, func() (string, error) { + return fetchDetail(bookID) + }) +} + +func fetchDetail(bookID string) (string, error) { if bookID == "" { return BuildErrorResponse("需要参数 book_id"), nil } diff --git a/internal/fqapi/item_summary.go b/internal/fqapi/item_summary.go index 083877a..d2f54cd 100644 --- a/internal/fqapi/item_summary.go +++ b/internal/fqapi/item_summary.go @@ -7,8 +7,14 @@ import ( var itemSummaryLogger = common.NewLogger("item_summary", "INFO", "") -// HandleItemSummary 处理获取章节摘要 +// HandleItemSummary 处理获取章节摘要,章节摘要数据基本不变,缓存 24 小时 func HandleItemSummary(itemIDs, bookID string) (string, error) { + return withCache("item_summary_" + bookID + "_" + itemIDs, 86400, func() (string, error) { + return fetchItemSummary(itemIDs, bookID) + }) +} + +func fetchItemSummary(itemIDs, bookID string) (string, error) { if itemIDs == "" || bookID == "" { return BuildErrorResponseWithCode(100144, "参数错误"), nil } diff --git a/internal/fqapi/multi_detail.go b/internal/fqapi/multi_detail.go index d9ea9cf..c404247 100644 --- a/internal/fqapi/multi_detail.go +++ b/internal/fqapi/multi_detail.go @@ -107,8 +107,14 @@ func chunkArray(arr []string, size int) [][]string { return chunks } -// HandleGetMultiDetail 处理批量获取书籍详情 +// HandleGetMultiDetail 处理批量获取书籍详情,详情数据基本不变,缓存 24 小时 func HandleGetMultiDetail(bookID interface{}) (string, error) { + return withCache(fmt.Sprintf("multi_detail_%v", bookID), 86400, func() (string, error) { + return fetchMultiDetail(bookID) + }) +} + +func fetchMultiDetail(bookID interface{}) (string, error) { if bookID == nil { return BuildErrorResponse("需要参数 book_id"), nil } diff --git a/internal/fqapi/search.go b/internal/fqapi/search.go index 678d490..3ff37a6 100644 --- a/internal/fqapi/search.go +++ b/internal/fqapi/search.go @@ -11,8 +11,14 @@ var searchLogger = common.NewLogger("search", "INFO", "") // GetDeviceHeader 获取设备header(需要从外部注入) var GetDeviceHeader func() string -// HandleBookSearch 处理书籍搜索 +// HandleBookSearch 处理书籍搜索,搜索结果短缓存 10 分钟,减轻上游压力 func HandleBookSearch(key string, offset int, tabType int) (string, error) { + return withCache(fmt.Sprintf("search_%s_%d_%d", key, offset, tabType), 600, func() (string, error) { + return fetchBookSearch(key, offset, tabType) + }) +} + +func fetchBookSearch(key string, offset int, tabType int) (string, error) { if key == "" { return BuildErrorResponseWithCode(1, "需要参数 key"), nil } diff --git a/internal/fqapi/store_redis.go b/internal/fqapi/store_redis.go index b450762..c87d107 100644 --- a/internal/fqapi/store_redis.go +++ b/internal/fqapi/store_redis.go @@ -23,7 +23,10 @@ const ( // redisGetCache 从 Redis 获取正文缓存 func redisGetCache(key string) string { - val, err := redisCache.Get(context.Background(), redisCacheKeyPrefix+key).Result() + // 读操作同样加 3s 超时,防止远程 Redis 网络抖动拖垮请求(与写路径一致) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + val, err := redisCache.Get(ctx, redisCacheKeyPrefix+key).Result() if err != nil { return "" // miss 或错误都视为未命中 }