Files
soul-yongping/soul-api/internal/handler/book.go

455 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. 付款数前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 热门章节(按阅读量排序,排除序言/尾声/附录)
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 维护"})
}