在多个页面中通过骨架屏优化加载状态。

在章节、礼物代付详情、阅读和搜索结果页面,用骨架屏替换传统加载指示器,以提升数据获取过程中的用户体验。
更新骨架屏样式,使加载状态更加美观。
实现章节和配置信息的缓存策略,以优化性能并减少冷启动问题。
This commit is contained in:
Alex-larget
2026-03-18 12:56:34 +08:00
parent 1fa20756a8
commit 46f94a9c81
23 changed files with 841 additions and 138 deletions

View File

@@ -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 秒生
// 缓存优先级Redis10min> 内存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 请求)
// 缓存优先级Redis10min> 内存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, "%", "\\%")