2026-03-07 22:58:43 +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{"序言", "尾声", "附录"}
|
|
|
|
|
|
|
|
|
|
|
|
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
2026-03-08 08:00:39 +08:00
|
|
|
|
// 小程序目录页以此接口为准,与后台内容管理一致;含「2026每日派对干货」等 part 须在 chapters 表中存在且 part_title 正确。
|
2026-03-07 22:58:43 +08:00
|
|
|
|
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
|
|
|
|
|
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
|
|
|
|
|
|
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
2026-03-08 08:00:39 +08:00
|
|
|
|
// 不过滤 status,后台配置的篇章均返回,由前端展示。
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-08 08:00:39 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 维护"})
|
|
|
|
|
|
}
|