package handler import ( "encoding/json" "net/http" "strconv" "strings" "soul-api/internal/database" "soul-api/internal/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序) var excludeParts = []string{"序言", "尾声", "附录"} // 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:排除序言、尾声、附录(目录页固定模块,不参与中间篇章) func BookAllChapters(c *gin.Context) { db := database.DB() q := db.Model(&model.Chapter{}) if c.Query("excludeFixed") == "1" { 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 } 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}) } // 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) }) } // 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 } // findChapterAndRespond 按条件查章节并返回统一格式 // 免费判断优先级:system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price 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 { 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 } out := gin.H{ "success": true, "data": ch, "content": ch.Content, "chapterTitle": ch.ChapterTitle, "partTitle": ch.PartTitle, "id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, } isFreeFromConfig := getFreeChapterIDs(db)[ch.ID] if isFreeFromConfig { out["isFree"] = true out["price"] = float64(0) } else { if ch.IsFree != nil { out["isFree"] = *ch.IsFree } if ch.Price != nil { out["price"] = *ch.Price if *ch.Price == 0 { out["isFree"] = true } } } 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 } // 从 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 热门章节(按阅读量排序,排除序言/尾声/附录) func BookHot(c *gin.Context) { list := bookHotChaptersSorted(database.DB(), 10) 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) } c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } // BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签) func BookRecommended(c *gin.Context) { list := bookHotChaptersSorted(database.DB(), 3) if len(list) == 0 { // 兜底:按 updated_at 取前 3,同样排除序言/尾声/附录 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) } tags := []string{"热门", "推荐", "精选"} out := make([]gin.H, 0, len(list)) for i, ch := range list { 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, }) } c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) } // BookLatestChapters GET /api/book/latest-chapters func BookLatestChapters(c *gin.Context) { var list []model.Chapter database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list) 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 } 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 func BookStats(c *gin.Context) { var total int64 database.DB().Model(&model.Chapter{}).Count(&total) 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 维护"}) }