chore: 清理敏感与开发文档,仅同步代码

- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
This commit is contained in:
卡若
2026-03-17 17:50:12 +08:00
parent 868b0a10d9
commit 76965adb23
443 changed files with 24175 additions and 64154 deletions

View File

@@ -1,10 +1,17 @@
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"
@@ -15,15 +22,207 @@ import (
// 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 列表不加载 contentlongtext避免 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 表)
// 小程序目录页以此接口为准与后台内容管理一致含「2026每日派对干货」等 part 须在 chapters 表中存在且 part_title 正确。
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
// 不过滤 status后台配置的篇章均返回由前端展示。
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
func BookAllChapters(c *gin.Context) {
q := database.DB().Model(&model.Chapter{})
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+"%")
}
@@ -33,7 +232,24 @@ func BookAllChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
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 查询(兼容旧链接)
@@ -48,6 +264,81 @@ func BookChapterByID(c *gin.Context) {
})
}
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
// 返回 parts排除序言/尾声/附录、totalSections、fixedSectionsid, mid, title 供序言/尾声/附录跳转用 mid
// 缓存优先级Redis10min后台更新时失效> 内存30s> DBRedis 不可用时回退内存+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. 内存缓存30sRedis 不可用时的容灾)
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")
@@ -65,11 +356,144 @@ func BookChapterByMID(c *gin.Context) {
})
}
// 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 是否有权读取 chapterIDVIP / 全书购买 / 单章购买)
// isPremium=true 表示增值版fullbook 买断不含增值版
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
if userID == "" {
return false
}
// VIPis_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()
if err := whereFn(db).First(&ch).Error; err != nil {
// 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
@@ -77,25 +501,59 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
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": ch,
"content": ch.Content,
"data": chForResponse,
"content": returnContent,
"chapterTitle": ch.ChapterTitle,
"partTitle": ch.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": ch.SectionTitle,
"isFree": isFree,
}
if ch.IsFree != nil {
out["isFree"] = *ch.IsFree
}
if ch.Price != nil {
if isFreeFromConfig {
out["price"] = float64(0)
} else if ch.Price != nil {
out["price"] = *ch.Price
// 价格为 0 元则自动视为免费
if *ch.Price == 0 {
out["isFree"] = true
}
}
c.JSON(http.StatusOK, out)
}
@@ -195,7 +653,7 @@ func BookChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}
// bookHotChaptersSorted 按阅读量优先排序(兼容旧逻辑);排除序言/尾声/附录
// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录
func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
q := db.Model(&model.Chapter{})
for _, p := range excludeParts {
@@ -205,6 +663,8 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []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)
@@ -219,9 +679,10 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
for _, r := range counts {
countMap[r.SectionID] = r.Cnt
}
// 按阅读量降序、同量按 updated_at 降序
type withSort struct {
ch model.Chapter
cnt int64
ch model.Chapter
cnt int64
}
withCnt := make([]withSort, 0, len(all))
for _, c := range all {
@@ -242,162 +703,102 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
return out
}
// bookRecommendedByScore 文章推荐算法阅读量前20(50%) + 最近30篇(30%) + 付款数前20(20%),排除序言/尾声/附录
func bookRecommendedByScore(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.Find(&all).Error; err != nil || len(all) == 0 {
return nil
}
ids := make([]string, 0, len(all))
for _, c := range all {
ids = append(ids, c.ID)
}
// 1. 阅读量reading_progress 按 section_id 计数前20名得 20,19,...,1 分
var readCounts []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(&readCounts)
readMap := make(map[string]int64)
for _, r := range readCounts {
readMap[r.SectionID] = r.Cnt
}
type idCnt struct {
id string
cnt int64
}
readSorted := make([]idCnt, 0, len(all))
for _, c := range all {
readSorted = append(readSorted, idCnt{c.ID, readMap[c.ID]})
}
for i := 0; i < len(readSorted)-1; i++ {
for j := i + 1; j < len(readSorted); j++ {
if readSorted[j].cnt > readSorted[i].cnt {
readSorted[i], readSorted[j] = readSorted[j], readSorted[i]
}
}
}
readScore := make(map[string]float64)
for i := 0; i < 20 && i < len(readSorted); i++ {
readScore[readSorted[i].id] = float64(20 - i)
}
// 2. 最近30篇按 updated_at 降序前30名得 30,29,...,1 分
recencySorted := make([]model.Chapter, len(all))
copy(recencySorted, all)
for i := 0; i < len(recencySorted)-1; i++ {
for j := i + 1; j < len(recencySorted); j++ {
if recencySorted[j].UpdatedAt.After(recencySorted[i].UpdatedAt) {
recencySorted[i], recencySorted[j] = recencySorted[j], recencySorted[i]
}
}
}
recencyScore := make(map[string]float64)
for i := 0; i < 30 && i < len(recencySorted); i++ {
recencyScore[recencySorted[i].ID] = float64(30 - i)
}
// 3. 付款数前20orders 中 product_type='section' 且 status='paid',按 product_id 计数
var payCounts []struct {
ProductID string `gorm:"column:product_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Table("orders").Select("product_id, COUNT(*) as cnt").
Where("product_type = ? AND status = ? AND product_id IN ?", "section", "paid", ids).
Group("product_id").Scan(&payCounts)
payMap := make(map[string]int64)
for _, r := range payCounts {
payMap[r.ProductID] = r.Cnt
}
paySorted := make([]idCnt, 0, len(payMap))
for id, cnt := range payMap {
paySorted = append(paySorted, idCnt{id, cnt})
}
for i := 0; i < len(paySorted)-1; i++ {
for j := i + 1; j < len(paySorted); j++ {
if paySorted[j].cnt > paySorted[i].cnt {
paySorted[i], paySorted[j] = paySorted[j], paySorted[i]
}
}
}
payScore := make(map[string]float64)
for i := 0; i < 20 && i < len(paySorted); i++ {
payScore[paySorted[i].id] = float64(20 - i)
}
// 4. 总分 = 0.5*阅读 + 0.3*新度 + 0.2*付款,按总分降序取 limit
type withTotal struct {
ch model.Chapter
total float64
}
withTotalList := make([]withTotal, 0, len(all))
for _, c := range all {
t := 0.5*readScore[c.ID] + 0.3*recencyScore[c.ID] + 0.2*payScore[c.ID]
withTotalList = append(withTotalList, withTotal{ch: c, total: t})
}
for i := 0; i < len(withTotalList)-1; i++ {
for j := i + 1; j < len(withTotalList); j++ {
if withTotalList[j].total > withTotalList[i].total {
withTotalList[i], withTotalList[j] = withTotalList[j], withTotalList[i]
}
}
}
out := make([]model.Chapter, 0, limit)
for i := 0; i < limit && i < len(withTotalList); i++ {
out = append(out, withTotalList[i].ch)
}
return out
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50
// Redis 缓存 5min章节更新时失效
func BookHot(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 10)
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 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
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 精选推荐(文章推荐算法阅读50%+最近30篇30%+付款20%,排除序言/尾声/附录
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章
// Redis 缓存 5min章节更新时失效
func BookRecommended(c *gin.Context) {
list := bookRecommendedByScore(database.DB(), 3)
if len(list) == 0 {
list = bookHotChaptersSorted(database.DB(), 3)
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
}
if len(list) == 0 {
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("updated_at DESC, id ASC").Limit(3).Find(&list)
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, len(list))
for i, ch := range list {
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": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "partTitle": ch.PartTitle,
"chapterTitle": ch.ChapterTitle, "tag": tag,
"isFree": ch.IsFree, "price": ch.Price, "isNew": ch.IsNew,
"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
// 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
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
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})
}
@@ -426,6 +827,7 @@ func BookSearch(c *gin.Context) {
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 {
@@ -442,9 +844,18 @@ func BookSearch(c *gin.Context) {
}
// 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}})
}