package handler import ( "context" "encoding/json" "net/http" "sort" "strconv" "strings" "sync" "time" "unicode/utf8" "soul-api/internal/cache" "soul-api/internal/database" "soul-api/internal/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序) var excludeParts = []string{"序言", "尾声", "附录"} // sortChaptersByNaturalID 同 sort_order 时按 id 自然排序(9.1 < 9.2 < 9.10),调用 db_book 的 naturalLessSectionID func sortChaptersByNaturalID(list []model.Chapter) { sort.Slice(list, func(i, j int) bool { soI, soJ := 999999, 999999 if list[i].SortOrder != nil { soI = *list[i].SortOrder } if list[j].SortOrder != nil { soJ = *list[j].SortOrder } if soI != soJ { return soI < soJ } return naturalLessSectionID(list[i].ID, list[j].ID) }) } // allChaptersSelectCols 列表不加载 content(longtext),避免 502 超时 var allChaptersSelectCols = []string{ "mid", "id", "part_id", "part_title", "chapter_id", "chapter_title", "section_title", "word_count", "is_free", "price", "sort_order", "status", "is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at", } // chapterMetaCols 章节详情元数据(不含 content),用于 content 缓存命中时的轻量查询 var chapterMetaCols = []string{ "mid", "id", "part_id", "part_title", "chapter_id", "chapter_title", "section_title", "word_count", "is_free", "price", "sort_order", "status", "is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at", } // allChaptersCache 内存缓存,减轻 DB 压力,30 秒 TTL var allChaptersCache struct { mu sync.RWMutex data []model.Chapter expires time.Time key string // excludeFixed 不同则 key 不同 } const allChaptersCacheTTL = 30 * time.Second // bookPartsCache 目录接口内存缓存,30 秒 TTL,减轻 DB 压力 type cachedPartRow struct { PartID string `json:"id"` PartTitle string `json:"title"` Subtitle string `json:"subtitle"` ChapterCount int `json:"chapterCount"` MinSortOrder int `json:"minSortOrder"` } type cachedFixedItem struct { ID string `json:"id"` MID int `json:"mid"` SectionTitle string `json:"title"` } // bookPartsRedisPayload Redis 缓存结构,与 BookParts 响应一致 type bookPartsRedisPayload struct { Parts []cachedPartRow `json:"parts"` TotalSections int64 `json:"totalSections"` FixedSections []cachedFixedItem `json:"fixedSections"` } var bookPartsCache struct { mu sync.RWMutex parts []cachedPartRow total int64 fixed []cachedFixedItem expires time.Time } const bookPartsCacheTTL = 30 * time.Second // WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502 func WarmAllChaptersCache() { db := database.DB() q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols) var list []model.Chapter if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil { return } sortChaptersByNaturalID(list) 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 } } allChaptersCache.mu.Lock() allChaptersCache.data = list allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL) allChaptersCache.key = "default" allChaptersCache.mu.Unlock() } // fetchAndCacheBookParts 执行 DB 查询并更新缓存,供 BookParts 与 WarmBookPartsCache 复用 func fetchAndCacheBookParts() (parts []cachedPartRow, total int64, fixed []cachedFixedItem) { db := database.DB() var wg sync.WaitGroup wg.Add(3) go func() { defer wg.Done() conds := make([]string, len(excludeParts)) args := make([]interface{}, len(excludeParts)) for i, p := range excludeParts { conds[i] = "part_title LIKE ?" args[i] = "%" + p + "%" } where := "(" + strings.Join(conds, " OR ") + ")" var rows []model.Chapter if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order"). Where(where, args...). Order("COALESCE(sort_order, 999999) ASC, id ASC"). Find(&rows).Error; err == nil { sortChaptersByNaturalID(rows) for _, r := range rows { fixed = append(fixed, cachedFixedItem{r.ID, r.MID, r.SectionTitle}) } } }() where := "1=1" args := []interface{}{} for _, p := range excludeParts { where += " AND part_title NOT LIKE ?" args = append(args, "%"+p+"%") } sql := `SELECT part_id, part_title, '' as subtitle, COUNT(DISTINCT chapter_id) as chapter_count, MIN(COALESCE(sort_order, 999999)) as min_sort FROM chapters WHERE ` + where + ` GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC` var raw []struct { PartID string `gorm:"column:part_id"` PartTitle string `gorm:"column:part_title"` Subtitle string `gorm:"column:subtitle"` ChapterCount int `gorm:"column:chapter_count"` MinSortOrder int `gorm:"column:min_sort"` } go func() { defer wg.Done() db.Raw(sql, args...).Scan(&raw) }() go func() { defer wg.Done() db.Model(&model.Chapter{}).Count(&total) }() wg.Wait() parts = make([]cachedPartRow, len(raw)) for i, r := range raw { parts[i] = cachedPartRow{ PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle, ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder, } } bookPartsCache.mu.Lock() bookPartsCache.parts = parts bookPartsCache.total = total bookPartsCache.fixed = fixed bookPartsCache.expires = time.Now().Add(bookPartsCacheTTL) bookPartsCache.mu.Unlock() return parts, total, fixed } // WarmBookPartsCache 启动时预热目录缓存(内存+Redis),避免首请求慢 func WarmBookPartsCache() { parts, total, fixed := fetchAndCacheBookParts() payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed} cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL) } // BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表) // 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id // 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free // 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章) // 带 30 秒内存缓存,管理端更新后最多 30 秒生效 func BookAllChapters(c *gin.Context) { cacheKey := "default" if c.Query("excludeFixed") == "1" { cacheKey = "excludeFixed" } allChaptersCache.mu.RLock() if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 { data := allChaptersCache.data allChaptersCache.mu.RUnlock() c.JSON(http.StatusOK, gin.H{"success": true, "data": data}) return } allChaptersCache.mu.RUnlock() db := database.DB() q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols) if cacheKey == "excludeFixed" { for _, p := range excludeParts { 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 } sortChaptersByNaturalID(list) 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 } } allChaptersCache.mu.Lock() allChaptersCache.data = list allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL) allChaptersCache.key = cacheKey allChaptersCache.mu.Unlock() c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } // BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接) func BookChapterByID(c *gin.Context) { id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"}) return } findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB { return db.Where("id = ?", id) }) } // BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情 // 返回 parts(排除序言/尾声/附录)、totalSections、fixedSections(id, mid, title 供序言/尾声/附录跳转用 mid) // 缓存优先级:Redis(10min,后台更新时失效)> 内存(30s)> DB;Redis 不可用时回退内存+DB func BookParts(c *gin.Context) { // 1. 优先 Redis(后台无更新时长期有效) var redisPayload bookPartsRedisPayload if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 { c.JSON(http.StatusOK, gin.H{ "success": true, "parts": redisPayload.Parts, "totalSections": redisPayload.TotalSections, "fixedSections": redisPayload.FixedSections, }) return } // 2. 内存缓存(30s,Redis 不可用时的容灾) bookPartsCache.mu.RLock() if time.Now().Before(bookPartsCache.expires) { parts := bookPartsCache.parts total := bookPartsCache.total fixed := bookPartsCache.fixed bookPartsCache.mu.RUnlock() c.JSON(http.StatusOK, gin.H{ "success": true, "parts": parts, "totalSections": total, "fixedSections": fixed, }) return } bookPartsCache.mu.RUnlock() // 3. DB 查询并更新 Redis + 内存 parts, total, fixed := fetchAndCacheBookParts() payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed} cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL) c.JSON(http.StatusOK, gin.H{ "success": true, "parts": parts, "totalSections": total, "fixedSections": fixed, }) } // BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求) 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() var list []model.Chapter if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols). Where("part_id = ?", partId). Order("COALESCE(sort_order, 999999) ASC, id ASC"). Find(&list).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) return } sortChaptersByNaturalID(list) 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 } } c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } // BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐) func BookChapterByMID(c *gin.Context) { midStr := c.Param("mid") if midStr == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 mid"}) return } mid, err := strconv.Atoi(midStr) if err != nil || mid < 1 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "mid 必须为正整数"}) return } findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB { return db.Where("mid = ?", mid) }) } // getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters) func getFreeChapterIDs(db *gorm.DB) map[string]bool { 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 { continue } var val interface{} if err := json.Unmarshal(row.ConfigValue, &val); err != nil { continue } if key == "free_chapters" { if arr, ok := val.([]interface{}); ok { for _, v := range arr { if s, ok := v.(string); ok { ids[s] = true } } } } else if key == "chapter_config" { if m, ok := val.(map[string]interface{}); ok { if arr, ok := m["freeChapters"].([]interface{}); ok { for _, v := range arr { if s, ok := v.(string); ok { ids[s] = true } } } } } } return ids } // checkUserChapterAccess 判断 userId 是否有权读取 chapterID(VIP / 全书购买 / 单章购买) // isPremium=true 表示增值版,fullbook 买断不含增值版 func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool { if userID == "" { return false } // VIP:is_vip=1 且未过期 var u model.User if err := db.Select("id", "is_vip", "vip_expire_date", "has_full_book").Where("id = ?", userID).First(&u).Error; err != nil { return false } if u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) { return true } // 全书买断(不含增值版) if !isPremium && u.HasFullBook != nil && *u.HasFullBook { return true } // 全书订单(兜底) if !isPremium { var cnt int64 db.Model(&model.Order{}).Where("user_id = ? AND product_type = 'fullbook' AND status = 'paid'", userID).Count(&cnt) if cnt > 0 { return true } } // 单章购买 var cnt int64 db.Model(&model.Order{}).Where( "user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid'", userID, chapterID, ).Count(&cnt) return cnt > 0 } // getUnpaidPreviewPercent 从 system_config 读取 unpaid_preview_percent,默认 20 func getUnpaidPreviewPercent(db *gorm.DB) int { var row model.SystemConfig if err := db.Where("config_key = ?", "unpaid_preview_percent").First(&row).Error; err != nil || len(row.ConfigValue) == 0 { return 20 } var val interface{} if err := json.Unmarshal(row.ConfigValue, &val); err != nil { return 20 } switch v := val.(type) { case float64: p := int(v) if p < 1 { p = 1 } if p > 100 { p = 100 } return p case int: if v < 1 { return 1 } if v > 100 { return 100 } return v } return 20 } // previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示 func previewContent(content string, percent int) string { total := utf8.RuneCountInString(content) if total == 0 { return "" } if percent < 1 { percent = 1 } if percent > 100 { percent = 100 } limit := total * percent / 100 if limit < 100 { limit = 100 } const maxPreview = 500 if limit > maxPreview { limit = maxPreview } if limit > total { limit = total } runes := []rune(content) return string(runes[:limit]) + "\n\n……(购买后阅读完整内容)" } // findChapterAndRespond 按条件查章节并返回统一格式 // 免费判断优先级:system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price // 付费章节:若请求携带 userId 且有购买权限则返回完整 content,否则返回 previewContent // content 缓存:优先 Redis,命中时仅查元数据(不含 LONGTEXT),未命中时查全量并回填缓存 func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) { var ch model.Chapter db := database.DB() // 1. 先查元数据(不含 content,轻量) if err := whereFn(db).Select(chapterMetaCols).First(&ch).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"}) return } c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } // 2. 取 content:优先 Redis,未命中再查 DB if content, ok := cache.GetString(context.Background(), cache.KeyChapterContent(ch.MID)); ok && content != "" { ch.Content = content } else { if err := db.Model(&model.Chapter{}).Where("mid = ?", ch.MID).Pluck("content", &ch.Content).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } cache.SetString(context.Background(), cache.KeyChapterContent(ch.MID), ch.Content, cache.ChapterContentTTL) } isFreeFromConfig := getFreeChapterIDs(db)[ch.ID] isFree := isFreeFromConfig if !isFree && ch.IsFree != nil && *ch.IsFree { isFree = true } if !isFree && ch.Price != nil && *ch.Price == 0 { isFree = true } // 确定返回的 content:免费直接返回,付费须校验购买权限 userID := c.Query("userId") isPremium := ch.EditionPremium != nil && *ch.EditionPremium var returnContent string if isFree { returnContent = ch.Content } else if checkUserChapterAccess(db, userID, ch.ID, isPremium) { returnContent = ch.Content } else { percent := getUnpaidPreviewPercent(db) returnContent = previewContent(ch.Content, percent) } // data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户 chForResponse := ch chForResponse.Content = returnContent out := gin.H{ "success": true, "data": chForResponse, "content": returnContent, "chapterTitle": ch.ChapterTitle, "partTitle": ch.PartTitle, "id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "isFree": isFree, } if isFreeFromConfig { out["price"] = float64(0) } else if ch.Price != nil { out["price"] = *ch.Price } c.JSON(http.StatusOK, out) } // BookChapters GET/POST/PUT/DELETE /api/book/chapters(与 app/api/book/chapters 一致,用 GORM) func BookChapters(c *gin.Context) { db := database.DB() switch c.Request.Method { case http.MethodGet: partId := c.Query("partId") status := c.Query("status") if status == "" { status = "published" } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 500 { pageSize = 100 } q := db.Model(&model.Chapter{}) if partId != "" { q = q.Where("part_id = ?", partId) } if status != "" && status != "all" { q = q.Where("status = ?", status) } var total int64 q.Count(&total) var list []model.Chapter q.Order("sort_order ASC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list) totalPages := int(total) / pageSize if int(total)%pageSize > 0 { totalPages++ } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "list": list, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, }, }) return case http.MethodPost: var body model.Chapter if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"}) return } if body.ID == "" || body.PartID == "" || body.ChapterID == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要字段 id/partId/chapterId"}) return } if err := db.Create(&body).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "data": body}) return case http.MethodPut: var body model.Chapter if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"}) return } updates := map[string]interface{}{ "part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle, "content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price, "sort_order": body.SortOrder, "status": body.Status, } if body.EditionStandard != nil { updates["edition_standard"] = body.EditionStandard } if body.EditionPremium != nil { updates["edition_premium"] = body.EditionPremium } if err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) return case http.MethodDelete: id := c.Query("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"}) return } if err := db.Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) return } c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"}) } // bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录 func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter { q := db.Model(&model.Chapter{}) for _, p := range excludeParts { q = q.Where("part_title NOT LIKE ?", "%"+p+"%") } var all []model.Chapter if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 { return nil } sortChaptersByNaturalID(all) // 从 reading_progress 统计阅读量 ids := make([]string, 0, len(all)) for _, c := range all { ids = append(ids, c.ID) } var counts []struct { SectionID string `gorm:"column:section_id"` Cnt int64 `gorm:"column:cnt"` } db.Table("reading_progress").Select("section_id, COUNT(*) as cnt"). Where("section_id IN ?", ids).Group("section_id").Scan(&counts) countMap := make(map[string]int64) for _, r := range counts { countMap[r.SectionID] = r.Cnt } // 按阅读量降序、同量按 updated_at 降序 type withSort struct { ch model.Chapter cnt int64 } withCnt := make([]withSort, 0, len(all)) for _, c := range all { withCnt = append(withCnt, withSort{ch: c, cnt: countMap[c.ID]}) } for i := 0; i < len(withCnt)-1; i++ { for j := i + 1; j < len(withCnt); j++ { if withCnt[j].cnt > withCnt[i].cnt || (withCnt[j].cnt == withCnt[i].cnt && withCnt[j].ch.UpdatedAt.After(withCnt[i].ch.UpdatedAt)) { withCnt[i], withCnt[j] = withCnt[j], withCnt[i] } } } out := make([]model.Chapter, 0, limit) for i := 0; i < limit && i < len(withCnt); i++ { out = append(out, withCnt[i].ch) } return out } // BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50) // Redis 缓存 5min,章节更新时失效 func BookHot(c *gin.Context) { limit := 10 if l := c.Query("limit"); l != "" { if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 50 { limit = n } } // 优先 Redis var cached []model.Chapter if cache.Get(context.Background(), cache.KeyBookHot(limit), &cached) && len(cached) > 0 { c.JSON(http.StatusOK, gin.H{"success": true, "data": cached}) return } list := bookHotChaptersSorted(database.DB(), limit) if len(list) == 0 { q := database.DB().Model(&model.Chapter{}) for _, p := range excludeParts { q = q.Where("part_title NOT LIKE ?", "%"+p+"%") } q.Order("sort_order ASC, id ASC").Limit(10).Find(&list) sortChaptersByNaturalID(list) } cache.Set(context.Background(), cache.KeyBookHot(limit), list, cache.BookRelatedTTL) c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } // BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章) // Redis 缓存 5min,章节更新时失效 func BookRecommended(c *gin.Context) { var cached []gin.H if cache.Get(context.Background(), cache.KeyBookRecommended, &cached) && len(cached) > 0 { c.JSON(http.StatusOK, gin.H{"success": true, "data": cached}) return } sections, err := computeArticleRankingSections(database.DB()) if err != nil || len(sections) == 0 { c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}}) return } limit := 3 if len(sections) < limit { limit = len(sections) } tags := []string{"热门", "推荐", "精选"} out := make([]gin.H, 0, limit) for i := 0; i < limit; i++ { s := sections[i] tag := "精选" if i < len(tags) { tag = tags[i] } out = append(out, gin.H{ "id": s.ID, "mid": s.MID, "sectionTitle": s.Title, "partTitle": s.PartTitle, "chapterTitle": s.ChapterTitle, "tag": tag, "isFree": s.IsFree, "price": s.Price, "isNew": s.IsNew, }) } cache.Set(context.Background(), cache.KeyBookRecommended, out, cache.BookRelatedTTL) c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) } // BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录) func BookLatestChapters(c *gin.Context) { 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 } 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 } } c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } func escapeLikeBook(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "%", "\\%") s = strings.ReplaceAll(s, "_", "\\_") return s } // BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致) func BookSearch(c *gin.Context) { q := strings.TrimSpace(c.Query("q")) if q == "" { c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": ""}) return } pattern := "%" + escapeLikeBook(q) + "%" var list []model.Chapter err := database.DB().Model(&model.Chapter{}). Where("section_title LIKE ? OR content LIKE ?", pattern, pattern). Order("sort_order ASC, id ASC"). Limit(20). Find(&list).Error if err != nil { c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q}) return } sortChaptersByNaturalID(list) lowerQ := strings.ToLower(q) results := make([]gin.H, 0, len(list)) for _, ch := range list { matchType := "content" if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) { matchType = "title" } results = append(results, gin.H{ "id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle, "isFree": ch.IsFree, "matchType": matchType, }) } c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q}) } // BookStats GET /api/book/stats // Redis 缓存 5min,章节更新时失效 func BookStats(c *gin.Context) { var cached struct { TotalChapters int64 `json:"totalChapters"` } if cache.Get(context.Background(), cache.KeyBookStats, &cached) { c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": cached.TotalChapters}}) return } var total int64 database.DB().Model(&model.Chapter{}).Count(&total) cache.Set(context.Background(), cache.KeyBookStats, gin.H{"totalChapters": total}, cache.BookRelatedTTL) c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}}) } // BookSync GET/POST /api/book/sync func BookSync(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"}) }