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

335 lines
10 KiB
Go
Raw Normal View History

2026-02-25 11:52:11 +08:00
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{"序言", "尾声", "附录"}
2026-02-25 11:52:11 +08:00
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
func BookAllChapters(c *gin.Context) {
var list []model.Chapter
if err := database.DB().Order("sort_order 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})
}
// 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{}{
2026-02-25 11:52:11 +08:00
"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 {
2026-02-25 11:52:11 +08:00
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 热门章节(按阅读量排序,排除序言/尾声/附录)
2026-02-25 11:52:11 +08:00
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)
}
2026-02-25 11:52:11 +08:00
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})
}
2026-02-25 11:52:11 +08:00
// 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 维护"})
}