2026-03-06 17:52:52 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-17 14:02:09 +08:00
|
|
|
|
"context"
|
2026-03-08 11:56:27 +08:00
|
|
|
|
"encoding/json"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"net/http"
|
2026-03-14 18:04:05 +08:00
|
|
|
|
"sort"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
2026-03-14 16:23:01 +08:00
|
|
|
|
"sync"
|
2026-03-10 18:06:10 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
"unicode/utf8"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
"soul-api/internal/cache"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"soul-api/internal/database"
|
|
|
|
|
|
"soul-api/internal/model"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
|
|
|
|
|
|
var excludeParts = []string{"序言", "尾声", "附录"}
|
|
|
|
|
|
|
2026-03-14 18:04:05 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 16:23:01 +08:00
|
|
|
|
// 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",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// 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",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 16:23:01 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-03-16 16:10:30 +08:00
|
|
|
|
// 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"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// bookPartsRedisPayload Redis 缓存结构,与 BookParts 响应一致
|
|
|
|
|
|
type bookPartsRedisPayload struct {
|
|
|
|
|
|
Parts []cachedPartRow `json:"parts"`
|
|
|
|
|
|
TotalSections int64 `json:"totalSections"`
|
|
|
|
|
|
FixedSections []cachedFixedItem `json:"fixedSections"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 16:10:30 +08:00
|
|
|
|
var bookPartsCache struct {
|
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
|
parts []cachedPartRow
|
|
|
|
|
|
total int64
|
|
|
|
|
|
fixed []cachedFixedItem
|
|
|
|
|
|
expires time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bookPartsCacheTTL = 30 * time.Second
|
|
|
|
|
|
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 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
|
2026-03-14 16:23:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-14 18:04:05 +08:00
|
|
|
|
sortChaptersByNaturalID(list)
|
2026-03-14 16:23:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 12:56:34 +08:00
|
|
|
|
cache.Set(context.Background(), cache.KeyAllChapters("default"), list, cache.AllChaptersTTL)
|
2026-03-14 16:23:01 +08:00
|
|
|
|
allChaptersCache.mu.Lock()
|
|
|
|
|
|
allChaptersCache.data = list
|
|
|
|
|
|
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
|
|
|
|
|
allChaptersCache.key = "default"
|
|
|
|
|
|
allChaptersCache.mu.Unlock()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 16:10:30 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// WarmBookPartsCache 启动时预热目录缓存(内存+Redis),避免首请求慢
|
2026-03-16 16:10:30 +08:00
|
|
|
|
func WarmBookPartsCache() {
|
2026-03-17 14:02:09 +08:00
|
|
|
|
parts, total, fixed := fetchAndCacheBookParts()
|
|
|
|
|
|
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
|
|
|
|
|
|
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
|
2026-03-16 16:10:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
|
|
|
|
|
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
2026-03-08 11:56:27 +08:00
|
|
|
|
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
2026-03-06 17:52:52 +08:00
|
|
|
|
func BookAllChapters(c *gin.Context) {
|
2026-03-14 16:23:01 +08:00
|
|
|
|
cacheKey := "default"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
if c.Query("excludeFixed") == "1" {
|
2026-03-14 16:23:01 +08:00
|
|
|
|
cacheKey = "excludeFixed"
|
|
|
|
|
|
}
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 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. 内存缓存
|
2026-03-14 16:23:01 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 3. DB 查询
|
2026-03-14 16:23:01 +08:00
|
|
|
|
db := database.DB()
|
|
|
|
|
|
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
|
|
|
|
|
if cacheKey == "excludeFixed" {
|
2026-03-06 17:52:52 +08:00
|
|
|
|
for _, p := range excludeParts {
|
|
|
|
|
|
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-14 18:04:05 +08:00
|
|
|
|
sortChaptersByNaturalID(list)
|
2026-03-08 11:56:27 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-14 16:23:01 +08:00
|
|
|
|
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 回填 Redis + 内存
|
|
|
|
|
|
cache.Set(context.Background(), cache.KeyAllChapters(cacheKey), list, cache.AllChaptersTTL)
|
2026-03-14 16:23:01 +08:00
|
|
|
|
allChaptersCache.mu.Lock()
|
|
|
|
|
|
allChaptersCache.data = list
|
|
|
|
|
|
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
|
|
|
|
|
allChaptersCache.key = cacheKey
|
|
|
|
|
|
allChaptersCache.mu.Unlock()
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 18:04:05 +08:00
|
|
|
|
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
|
|
|
|
|
|
// 返回 parts(排除序言/尾声/附录)、totalSections、fixedSections(id, mid, title 供序言/尾声/附录跳转用 mid)
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// 缓存优先级:Redis(10min,后台更新时失效)> 内存(30s)> DB;Redis 不可用时回退内存+DB
|
2026-03-14 18:04:05 +08:00
|
|
|
|
func BookParts(c *gin.Context) {
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// 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 不可用时的容灾)
|
2026-03-16 16:10:30 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-03-14 18:04:05 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-16 16:10:30 +08:00
|
|
|
|
bookPartsCache.mu.RUnlock()
|
2026-03-14 18:04:05 +08:00
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// 3. DB 查询并更新 Redis + 内存
|
2026-03-16 16:10:30 +08:00
|
|
|
|
parts, total, fixed := fetchAndCacheBookParts()
|
2026-03-17 14:02:09 +08:00
|
|
|
|
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
|
|
|
|
|
|
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
|
|
|
|
|
|
|
2026-03-14 18:04:05 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"parts": parts,
|
|
|
|
|
|
"totalSections": total,
|
2026-03-16 16:10:30 +08:00
|
|
|
|
"fixedSections": fixed,
|
2026-03-14 18:04:05 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求)
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
2026-03-14 18:04:05 +08:00
|
|
|
|
func BookChaptersByPart(c *gin.Context) {
|
|
|
|
|
|
partId := c.Query("partId")
|
|
|
|
|
|
if partId == "" {
|
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 1. 优先 Redis
|
2026-03-14 18:04:05 +08:00
|
|
|
|
var list []model.Chapter
|
2026-03-18 12:56:34 +08:00
|
|
|
|
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()
|
2026-03-14 18:04:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 12:56:34 +08:00
|
|
|
|
|
|
|
|
|
|
// 回填 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()
|
|
|
|
|
|
|
2026-03-14 18:04:05 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 11:56:27 +08:00
|
|
|
|
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters)
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// Redis 缓存 5min,后台更新时失效
|
2026-03-08 11:56:27 +08:00
|
|
|
|
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
2026-03-18 12:56:34 +08:00
|
|
|
|
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)
|
2026-03-08 11:56:27 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 12:56:34 +08:00
|
|
|
|
cache.Set(context.Background(), cache.KeyFreeChapterIDs, ids, cache.FreeChapterIDsTTL)
|
2026-03-08 11:56:27 +08:00
|
|
|
|
return ids
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:06:10 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 13:17:49 +08:00
|
|
|
|
// 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 {
|
2026-03-10 18:06:10 +08:00
|
|
|
|
total := utf8.RuneCountInString(content)
|
|
|
|
|
|
if total == 0 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
2026-03-17 13:17:49 +08:00
|
|
|
|
if percent < 1 {
|
|
|
|
|
|
percent = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if percent > 100 {
|
|
|
|
|
|
percent = 100
|
|
|
|
|
|
}
|
|
|
|
|
|
limit := total * percent / 100
|
2026-03-10 18:06:10 +08:00
|
|
|
|
if limit < 100 {
|
|
|
|
|
|
limit = 100
|
|
|
|
|
|
}
|
2026-03-17 13:17:49 +08:00
|
|
|
|
const maxPreview = 500
|
|
|
|
|
|
if limit > maxPreview {
|
|
|
|
|
|
limit = maxPreview
|
|
|
|
|
|
}
|
2026-03-10 18:06:10 +08:00
|
|
|
|
if limit > total {
|
|
|
|
|
|
limit = total
|
|
|
|
|
|
}
|
|
|
|
|
|
runes := []rune(content)
|
|
|
|
|
|
return string(runes[:limit]) + "\n\n……(购买后阅读完整内容)"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// findChapterAndRespond 按条件查章节并返回统一格式
|
2026-03-08 11:56:27 +08:00
|
|
|
|
// 免费判断优先级:system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
|
2026-03-10 18:06:10 +08:00
|
|
|
|
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content,否则返回 previewContent
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// content 缓存:优先 Redis,命中时仅查元数据(不含 LONGTEXT),未命中时查全量并回填缓存
|
2026-03-06 17:52:52 +08:00
|
|
|
|
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
|
|
|
|
|
var ch model.Chapter
|
|
|
|
|
|
db := database.DB()
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// 1. 先查元数据(不含 content,轻量)
|
|
|
|
|
|
if err := whereFn(db).Select(chapterMetaCols).First(&ch).Error; err != nil {
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-10 18:06:10 +08:00
|
|
|
|
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:06:10 +08:00
|
|
|
|
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 {
|
2026-03-17 13:17:49 +08:00
|
|
|
|
percent := getUnpaidPreviewPercent(db)
|
|
|
|
|
|
returnContent = previewContent(ch.Content, percent)
|
2026-03-10 18:06:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:20:03 +08:00
|
|
|
|
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
|
|
|
|
|
|
chForResponse := ch
|
|
|
|
|
|
chForResponse.Content = returnContent
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
out := gin.H{
|
|
|
|
|
|
"success": true,
|
2026-03-10 20:20:03 +08:00
|
|
|
|
"data": chForResponse,
|
2026-03-10 18:06:10 +08:00
|
|
|
|
"content": returnContent,
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"chapterTitle": ch.ChapterTitle,
|
|
|
|
|
|
"partTitle": ch.PartTitle,
|
|
|
|
|
|
"id": ch.ID,
|
|
|
|
|
|
"mid": ch.MID,
|
|
|
|
|
|
"sectionTitle": ch.SectionTitle,
|
2026-03-10 18:06:10 +08:00
|
|
|
|
"isFree": isFree,
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
2026-03-08 11:56:27 +08:00
|
|
|
|
if isFreeFromConfig {
|
|
|
|
|
|
out["price"] = float64(0)
|
2026-03-10 18:06:10 +08:00
|
|
|
|
} else if ch.Price != nil {
|
|
|
|
|
|
out["price"] = *ch.Price
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-14 18:04:05 +08:00
|
|
|
|
sortChaptersByNaturalID(all)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// 从 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:44:36 +08:00
|
|
|
|
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50)
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// Redis 缓存 5min,章节更新时失效
|
2026-03-06 17:52:52 +08:00
|
|
|
|
func BookHot(c *gin.Context) {
|
2026-03-17 11:44:36 +08:00
|
|
|
|
limit := 10
|
|
|
|
|
|
if l := c.Query("limit"); l != "" {
|
|
|
|
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 50 {
|
|
|
|
|
|
limit = n
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// 优先 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
|
|
|
|
|
|
}
|
2026-03-17 11:44:36 +08:00
|
|
|
|
list := bookHotChaptersSorted(database.DB(), limit)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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)
|
2026-03-14 18:04:05 +08:00
|
|
|
|
sortChaptersByNaturalID(list)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
2026-03-17 14:02:09 +08:00
|
|
|
|
cache.Set(context.Background(), cache.KeyBookHot(limit), list, cache.BookRelatedTTL)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 11:42:13 +08:00
|
|
|
|
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// Redis 缓存 5min,章节更新时失效
|
2026-03-06 17:52:52 +08:00
|
|
|
|
func BookRecommended(c *gin.Context) {
|
2026-03-17 14:02:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-12 11:36:50 +08:00
|
|
|
|
sections, err := computeArticleRankingSections(database.DB())
|
2026-03-12 11:42:13 +08:00
|
|
|
|
if err != nil || len(sections) == 0 {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}})
|
2026-03-12 11:36:50 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-12 11:42:13 +08:00
|
|
|
|
limit := 3
|
|
|
|
|
|
if len(sections) < limit {
|
|
|
|
|
|
limit = len(sections)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
tags := []string{"热门", "推荐", "精选"}
|
2026-03-12 11:42:13 +08:00
|
|
|
|
out := make([]gin.H, 0, limit)
|
|
|
|
|
|
for i := 0; i < limit; i++ {
|
|
|
|
|
|
s := sections[i]
|
2026-03-06 17:52:52 +08:00
|
|
|
|
tag := "精选"
|
|
|
|
|
|
if i < len(tags) {
|
|
|
|
|
|
tag = tags[i]
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, gin.H{
|
2026-03-12 11:42:13 +08:00
|
|
|
|
"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,
|
2026-03-06 17:52:52 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-03-17 14:02:09 +08:00
|
|
|
|
cache.Set(context.Background(), cache.KeyBookRecommended, out, cache.BookRelatedTTL)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 16:23:01 +08:00
|
|
|
|
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// Redis 缓存 5min,首页「最新更新」主接口
|
2026-03-06 17:52:52 +08:00
|
|
|
|
func BookLatestChapters(c *gin.Context) {
|
2026-03-18 12:56:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-14 16:23:01 +08:00
|
|
|
|
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 {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-14 18:04:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
2026-03-14 16:23:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 12:56:34 +08:00
|
|
|
|
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 12:56:34 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-14 18:04:05 +08:00
|
|
|
|
sortChaptersByNaturalID(list)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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
|
2026-03-17 14:02:09 +08:00
|
|
|
|
// Redis 缓存 5min,章节更新时失效
|
2026-03-06 17:52:52 +08:00
|
|
|
|
func BookStats(c *gin.Context) {
|
2026-03-17 14:02:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-06 17:52:52 +08:00
|
|
|
|
var total int64
|
|
|
|
|
|
database.DB().Model(&model.Chapter{}).Count(&total)
|
2026-03-17 14:02:09 +08:00
|
|
|
|
cache.Set(context.Background(), cache.KeyBookStats, gin.H{"totalChapters": total}, cache.BookRelatedTTL)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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 维护"})
|
|
|
|
|
|
}
|