在多个页面中通过骨架屏优化加载状态。
在章节、礼物代付详情、阅读和搜索结果页面,用骨架屏替换传统加载指示器,以提升数据获取过程中的用户体验。 更新骨架屏样式,使加载状态更加美观。 实现章节和配置信息的缓存策略,以优化性能并减少冷启动问题。
This commit is contained in:
@@ -94,7 +94,27 @@ var bookPartsCache struct {
|
||||
|
||||
const bookPartsCacheTTL = 30 * time.Second
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
|
||||
// chaptersByPartCache 篇章内章节列表内存缓存,30 秒 TTL
|
||||
type chaptersByPartEntry struct {
|
||||
data []model.Chapter
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
var chaptersByPartCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*chaptersByPartEntry
|
||||
}
|
||||
|
||||
const chaptersByPartCacheTTL = 30 * time.Second
|
||||
|
||||
// InvalidateChaptersByPartCache 后台更新章节时调用,使 chapters-by-part 内存缓存失效
|
||||
func InvalidateChaptersByPartCache() {
|
||||
chaptersByPartCache.mu.Lock()
|
||||
chaptersByPartCache.entries = nil
|
||||
chaptersByPartCache.mu.Unlock()
|
||||
}
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存(Redis+内存),避免首请求冷启动 502
|
||||
func WarmAllChaptersCache() {
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
@@ -112,6 +132,7 @@ func WarmAllChaptersCache() {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyAllChapters("default"), list, cache.AllChaptersTTL)
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
@@ -205,12 +226,19 @@ func WarmBookPartsCache() {
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
|
||||
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
cacheKey := "default"
|
||||
if c.Query("excludeFixed") == "1" {
|
||||
cacheKey = "excludeFixed"
|
||||
}
|
||||
// 1. 优先 Redis
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyAllChapters(cacheKey), &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
// 2. 内存缓存
|
||||
allChaptersCache.mu.RLock()
|
||||
if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
|
||||
data := allChaptersCache.data
|
||||
@@ -220,6 +248,7 @@ func BookAllChapters(c *gin.Context) {
|
||||
}
|
||||
allChaptersCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
if cacheKey == "excludeFixed" {
|
||||
@@ -227,7 +256,6 @@ func BookAllChapters(c *gin.Context) {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
}
|
||||
var list []model.Chapter
|
||||
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
@@ -243,6 +271,8 @@ func BookAllChapters(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回填 Redis + 内存
|
||||
cache.Set(context.Background(), cache.KeyAllChapters(cacheKey), list, cache.AllChaptersTTL)
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
@@ -311,14 +341,33 @@ func BookParts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求)
|
||||
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
||||
func BookChaptersByPart(c *gin.Context) {
|
||||
partId := c.Query("partId")
|
||||
if partId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
// 1. 优先 Redis
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyChaptersByPart(partId), &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
// 2. 内存缓存
|
||||
chaptersByPartCache.mu.RLock()
|
||||
if chaptersByPartCache.entries != nil {
|
||||
if e, ok := chaptersByPartCache.entries[partId]; ok && time.Now().Before(e.expires) {
|
||||
list := e.data
|
||||
chaptersByPartCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
}
|
||||
chaptersByPartCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询
|
||||
db := database.DB()
|
||||
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
|
||||
Where("part_id = ?", partId).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
@@ -336,6 +385,16 @@ func BookChaptersByPart(c *gin.Context) {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
|
||||
// 回填 Redis + 内存
|
||||
cache.Set(context.Background(), cache.KeyChaptersByPart(partId), list, cache.ChaptersByPartTTL)
|
||||
chaptersByPartCache.mu.Lock()
|
||||
if chaptersByPartCache.entries == nil {
|
||||
chaptersByPartCache.entries = make(map[string]*chaptersByPartEntry)
|
||||
}
|
||||
chaptersByPartCache.entries[partId] = &chaptersByPartEntry{data: list, expires: time.Now().Add(chaptersByPartCacheTTL)}
|
||||
chaptersByPartCache.mu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
@@ -357,8 +416,16 @@ func BookChapterByMID(c *gin.Context) {
|
||||
}
|
||||
|
||||
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters)
|
||||
// Redis 缓存 5min,后台更新时失效
|
||||
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
var ids map[string]bool
|
||||
if cache.Get(context.Background(), cache.KeyFreeChapterIDs, &ids) {
|
||||
if ids == nil {
|
||||
return make(map[string]bool)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
ids = make(map[string]bool)
|
||||
for _, key := range []string{"free_chapters", "chapter_config"} {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", key).First(&row).Error; err != nil {
|
||||
@@ -388,6 +455,7 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyFreeChapterIDs, ids, cache.FreeChapterIDsTTL)
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -773,13 +841,18 @@ func BookRecommended(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
|
||||
// Redis 缓存 5min,首页「最新更新」主接口
|
||||
func BookLatestChapters(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var list []model.Chapter
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
@@ -799,9 +872,42 @@ func BookLatestChapters(c *gin.Context) {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// WarmLatestChaptersCache 启动时预热最新章节 Redis 缓存(首页主接口)
|
||||
func WarmLatestChaptersCache() {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if !list[i].UpdatedAt.Equal(list[j].UpdatedAt) {
|
||||
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
||||
}
|
||||
return naturalLessSectionID(list[i].ID, list[j].ID)
|
||||
})
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
|
||||
}
|
||||
|
||||
func escapeLikeBook(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "%", "\\%")
|
||||
|
||||
Reference in New Issue
Block a user