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

501 lines
16 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 (
"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 是否有权读取 chapterIDVIP / 全书购买 / 单章购买)
// isPremium=true 表示增值版fullbook 买断不含增值版
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
if userID == "" {
return false
}
// VIPis_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) {
// 优先复用管理端内容排行榜的算法,保证与后台「内容排行榜」顺序一致
sections, err := computeArticleRankingSections(database.DB())
if err == nil && len(sections) > 0 {
// 前 3 名作为精选推荐
limit := 3
if len(sections) < limit {
limit = len(sections)
}
tags := []string{"热门", "推荐", "精选"}
out := make([]gin.H, 0, limit)
for i := 0; i < limit; i++ {
s := sections[i]
tag := "精选"
if i < len(tags) {
tag = tags[i]
}
out = append(out, gin.H{
"id": s.ID,
"mid": s.MID,
"sectionTitle": s.Title,
"partTitle": s.PartTitle,
"chapterTitle": s.ChapterTitle,
"tag": tag,
"isFree": s.IsFree,
"price": s.Price,
"isNew": s.IsNew,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
return
}
// 兜底:沿用原有热门章节算法,至少保证有推荐
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 维护"})
}