package handler import ( "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 表) // 小程序目录页以此接口为准,与后台内容管理一致;含「2026每日派对干货」等 part 须在 chapters 表中存在且 part_title 正确。 // 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id // COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位 // 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章) // 不过滤 status,后台配置的篇章均返回,由前端展示。 func BookAllChapters(c *gin.Context) { q := database.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 } c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(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) }) } // findChapterAndRespond 按条件查章节并返回统一格式 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, } if ch.IsFree != nil { out["isFree"] = *ch.IsFree } if ch.Price != nil { out["price"] = *ch.Price // 价格为 0 元则自动视为免费 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 } 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 } 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 } // 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. 付款数前20:orders 中 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 热门章节(按阅读量排序,排除序言/尾声/附录) 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 精选推荐(文章推荐算法:阅读50%+最近30篇30%+付款20%,排除序言/尾声/附录) func BookRecommended(c *gin.Context) { list := bookRecommendedByScore(database.DB(), 3) if len(list) == 0 { list = bookHotChaptersSorted(database.DB(), 3) } 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) } 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 维护"}) }