package handler import ( "encoding/json" "net/http" "strconv" "strings" "time" "unicode/utf8" "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 } // 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 } // previewContent 取内容的前 20%(最多前 200 个字符),并追加省略提示 func previewContent(content string) string { total := utf8.RuneCountInString(content) if total == 0 { return "" } limit := total / 5 if limit < 100 { limit = 100 } 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 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 } 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 { returnContent = previewContent(ch.Content) } // 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 } // 从 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 维护"}) }