✨ feat(cache): 幂等只读接口接入缓存

- 新增 withCache 包装:命中直接返回,miss 回源后写入,仅缓存成功响应
- 详情/目录/批量详情/章节摘要缓存 24 小时;搜索/分类列表/分类详情短缓存 10 分钟
- 7 个 fqapi 处理函数原函数体不动、改名 fetchXxx,外层套缓存壳
- redisGetCache 补 3 秒读超时,与写路径一致,防远程 Redis 网络抖动拖垮请求
- 新增 TestWithCache:验证命中不回源、失败响应不缓存
This commit is contained in:
chenxin committed 2026-09-29 23:43:56 +08:00
1 parent f0d89b462b
commit 586b714f3c
10 files changed
+109 -8

No files matched your search

+13
View File
@@ -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() {
+43
View File
@@ -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)
}
}
+7 -1
View File
@@ -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
}
+7 -1
View File
@@ -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(
+7 -1
View File
@@ -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)
+7 -1
View File
@@ -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
}
+7 -1
View File
@@ -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
}
+7 -1
View File
@@ -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
}
+7 -1
View File
@@ -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
}
+4 -1
View File
@@ -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 或错误都视为未命中
}