2026-03-17 14:02:09 +08:00
|
|
|
|
package cache
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"soul-api/internal/database"
|
|
|
|
|
|
"soul-api/internal/model"
|
|
|
|
|
|
"soul-api/internal/redis"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const defaultTimeout = 2 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// KeyBookParts 目录接口缓存 key,后台更新章节/内容时需 Del
|
|
|
|
|
|
const KeyBookParts = "soul:book:parts"
|
|
|
|
|
|
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// KeyAllChapters 全书章节列表,default 与 excludeFixed 两种
|
|
|
|
|
|
func KeyAllChapters(cacheKey string) string {
|
|
|
|
|
|
if cacheKey == "excludeFixed" {
|
|
|
|
|
|
return "soul:book:all-chapters:excludeFixed"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "soul:book:all-chapters"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// KeyChaptersByPart 篇章内章节,格式 soul:book:chapters-by-part:{partId}
|
|
|
|
|
|
func KeyChaptersByPart(partId string) string {
|
|
|
|
|
|
return "soul:book:chapters-by-part:" + partId
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// KeyChaptersByPartPattern 用于批量删除 chapters-by-part 缓存
|
|
|
|
|
|
const KeyChaptersByPartPattern = "soul:book:chapters-by-part:*"
|
|
|
|
|
|
|
|
|
|
|
|
// KeyBookLatestChapters 最新更新章节
|
|
|
|
|
|
const KeyBookLatestChapters = "soul:book:latest-chapters"
|
|
|
|
|
|
|
|
|
|
|
|
// KeyFreeChapterIDs 免费章节 ID 列表(JSON 数组)
|
|
|
|
|
|
const KeyFreeChapterIDs = "soul:config:free-chapters"
|
|
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// KeyBookHot 热门章节,格式 soul:book:hot:{limit}
|
|
|
|
|
|
func KeyBookHot(limit int) string { return "soul:book:hot:" + fmt.Sprint(limit) }
|
|
|
|
|
|
const KeyBookRecommended = "soul:book:recommended"
|
|
|
|
|
|
const KeyBookStats = "soul:book:stats"
|
|
|
|
|
|
const KeyConfigMiniprogram = "soul:config:miniprogram"
|
2026-03-18 16:00:57 +08:00
|
|
|
|
const KeyConfigAuditMode = "soul:config:audit-mode"
|
|
|
|
|
|
const KeyConfigCore = "soul:config:core"
|
|
|
|
|
|
const KeyConfigReadExtras = "soul:config:read-extras"
|
2026-03-17 14:02:09 +08:00
|
|
|
|
|
|
|
|
|
|
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB)
|
|
|
|
|
|
func Get(ctx context.Context, key string, dest interface{}) bool {
|
|
|
|
|
|
client := redis.Client()
|
|
|
|
|
|
if client == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
val, err := client.Get(ctx, key).Bytes()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if dest != nil && len(val) > 0 {
|
|
|
|
|
|
_ = json.Unmarshal(val, dest)
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set 写入 Redis,失败仅打日志不阻塞
|
|
|
|
|
|
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
|
|
|
|
|
|
client := redis.Client()
|
|
|
|
|
|
if client == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
data, err := json.Marshal(val)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("cache.Set marshal %s: %v", key, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
|
|
|
|
|
|
log.Printf("cache.Set %s: %v (非致命)", key, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Del 删除 key,失败仅打日志
|
|
|
|
|
|
func Del(ctx context.Context, key string) {
|
|
|
|
|
|
client := redis.Client()
|
|
|
|
|
|
if client == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
if err := client.Del(ctx, key).Err(); err != nil {
|
|
|
|
|
|
log.Printf("cache.Del %s: %v (非致命)", key, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),用于批量失效
|
|
|
|
|
|
func DelPattern(ctx context.Context, pattern string) {
|
|
|
|
|
|
client := redis.Client()
|
|
|
|
|
|
if client == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, defaultTimeout*2)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
keys, err := client.Keys(ctx, pattern).Result()
|
|
|
|
|
|
if err != nil || len(keys) == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.Del(ctx, keys...).Err(); err != nil {
|
|
|
|
|
|
log.Printf("cache.DelPattern %s: %v (非致命)", pattern, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// BookPartsTTL 目录接口缓存 TTL,后台更新时主动 Del,此为兜底时长
|
|
|
|
|
|
const BookPartsTTL = 10 * time.Minute
|
|
|
|
|
|
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// AllChaptersTTL 全书章节列表 TTL
|
|
|
|
|
|
const AllChaptersTTL = 10 * time.Minute
|
|
|
|
|
|
|
|
|
|
|
|
// ChaptersByPartTTL 篇章内章节 TTL
|
|
|
|
|
|
const ChaptersByPartTTL = 10 * time.Minute
|
|
|
|
|
|
|
|
|
|
|
|
// FreeChapterIDsTTL 免费章节配置 TTL
|
|
|
|
|
|
const FreeChapterIDsTTL = 5 * time.Minute
|
|
|
|
|
|
|
|
|
|
|
|
// InvalidateBookParts 后台更新章节/内容时调用,使目录、章节列表等缓存失效
|
2026-03-17 14:02:09 +08:00
|
|
|
|
func InvalidateBookParts() {
|
2026-03-18 12:56:34 +08:00
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
Del(ctx, KeyBookParts)
|
|
|
|
|
|
Del(ctx, KeyAllChapters("default"))
|
|
|
|
|
|
Del(ctx, KeyAllChapters("excludeFixed"))
|
|
|
|
|
|
Del(ctx, KeyBookLatestChapters)
|
|
|
|
|
|
Del(ctx, KeyFreeChapterIDs)
|
|
|
|
|
|
DelPattern(ctx, KeyChaptersByPartPattern)
|
2026-03-17 14:02:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// InvalidateBookCache 使热门、推荐、统计等书籍相关缓存失效(与 InvalidateBookParts 同时调用)
|
|
|
|
|
|
func InvalidateBookCache() {
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
Del(ctx, KeyBookRecommended)
|
|
|
|
|
|
Del(ctx, KeyBookStats)
|
|
|
|
|
|
for _, limit := range []int{3, 10, 20, 50} {
|
|
|
|
|
|
Del(ctx, KeyBookHot(limit))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
// InvalidateConfig 配置变更时调用,使小程序 config 及拆分接口缓存失效
|
2026-03-17 14:02:09 +08:00
|
|
|
|
func InvalidateConfig() {
|
2026-03-18 16:00:57 +08:00
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
Del(ctx, KeyConfigMiniprogram)
|
|
|
|
|
|
Del(ctx, KeyConfigAuditMode)
|
|
|
|
|
|
Del(ctx, KeyConfigCore)
|
|
|
|
|
|
Del(ctx, KeyConfigReadExtras)
|
2026-03-17 14:02:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BookRelatedTTL 书籍相关接口 TTL(hot/recommended/stats)
|
|
|
|
|
|
const BookRelatedTTL = 5 * time.Minute
|
|
|
|
|
|
|
|
|
|
|
|
// ConfigTTL 配置接口 TTL
|
|
|
|
|
|
const ConfigTTL = 10 * time.Minute
|
|
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
// AuditModeTTL 审核模式 TTL,管理端开关后需较快生效
|
|
|
|
|
|
const AuditModeTTL = 1 * time.Minute
|
|
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// KeyChapterContent 章节正文缓存,格式 soul:chapter:content:{mid},存原始 HTML 字符串
|
|
|
|
|
|
func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sprint(mid) }
|
|
|
|
|
|
|
|
|
|
|
|
// ChapterContentTTL 章节正文 TTL,后台更新时主动 Del
|
|
|
|
|
|
const ChapterContentTTL = 30 * time.Minute
|
|
|
|
|
|
|
|
|
|
|
|
// GetString 读取字符串(不经过 JSON,适合大文本 content)
|
|
|
|
|
|
func GetString(ctx context.Context, key string) (string, bool) {
|
|
|
|
|
|
client := redis.Client()
|
|
|
|
|
|
if client == nil {
|
|
|
|
|
|
return "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
val, err := client.Get(ctx, key).Result()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
return val, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetString 写入字符串(不经过 JSON,适合大文本 content)
|
|
|
|
|
|
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
|
|
|
|
|
|
client := redis.Client()
|
|
|
|
|
|
if client == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
|
|
|
|
|
|
log.Printf("cache.SetString %s: %v (非致命)", key, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// InvalidateChapterContent 章节内容更新时调用,mid<=0 时忽略
|
|
|
|
|
|
func InvalidateChapterContent(mid int) {
|
|
|
|
|
|
if mid <= 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
Del(context.Background(), KeyChapterContent(mid))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// InvalidateChapterContentByID 按业务 id 使章节内容缓存失效(内部查 mid 后调用 InvalidateChapterContent)
|
|
|
|
|
|
func InvalidateChapterContentByID(id string) {
|
|
|
|
|
|
if id == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
var mid int
|
|
|
|
|
|
if err := database.DB().Model(&model.Chapter{}).Where("id = ?", id).Pluck("mid", &mid).Error; err != nil || mid <= 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
InvalidateChapterContent(mid)
|
|
|
|
|
|
}
|