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

616 lines
19 KiB
Go
Raw Normal View History

package handler
import (
"context"
"encoding/json"
"math"
"net/http"
"sort"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// listSelectCols 列表/导出不加载 content大幅加速
var listSelectCols = []string{
"id", "mid", "section_title", "price", "is_free", "is_new",
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
"hot_score", "updated_at",
}
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
type sectionListItem struct {
ID string `json:"id"`
MID int `json:"mid,omitempty"` // 自增主键,小程序跳转用
Title string `json:"title"`
Price float64 `json:"price"`
IsFree *bool `json:"isFree,omitempty"`
IsNew *bool `json:"isNew,omitempty"` // stitch_soul标记最新新增
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
FilePath *string `json:"filePath,omitempty"`
ClickCount int64 `json:"clickCount"` // 阅读次数reading_progress
PayCount int64 `json:"payCount"` // 付款笔数orders.product_type=section
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
}
// computeSectionListWithHotScore 计算章节列表(含 hotScore保持 sort_order 顺序,供 章节管理 树使用
func computeSectionListWithHotScore(db *gorm.DB) ([]sectionListItem, error) {
sections, err := computeSectionsWithHotScore(db, false)
if err != nil {
return nil, err
}
return sections, nil
}
// computeArticleRankingSections 统一计算内容排行榜:置顶优先 + 按 hotScore 降序
// 供管理端内容排行榜页与小程序首页精选推荐共用,排序与置顶均在后端计算
func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
sections, err := computeSectionsWithHotScore(db, true)
if err != nil {
return nil, err
}
// 读取置顶配置 pinned_section_ids
pinnedIDs := []string{}
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
_ = json.Unmarshal(cfg.ConfigValue, &pinnedIDs)
}
pinnedSet := make(map[string]int) // id -> 置顶顺序
for i, id := range pinnedIDs {
if id != "" {
pinnedSet[id] = i
}
}
// 排序:置顶优先(按置顶顺序),其次按 hotScore 降序
sort.Slice(sections, func(i, j int) bool {
pi, pj := pinnedSet[sections[i].ID], pinnedSet[sections[j].ID]
piOk, pjOk := sections[i].IsPinned, sections[j].IsPinned
if piOk && !pjOk {
return true
}
if !piOk && pjOk {
return false
}
if piOk && pjOk {
return pi < pj
}
return sections[i].HotScore > sections[j].HotScore
})
return sections, nil
}
// computeSectionsWithHotScore 内部:计算 hotScore可选设置 isPinned
func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem, error) {
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
return nil, err
}
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
readCountMap := make(map[string]int64)
if len(ids) > 0 {
var rp []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(&rp)
for _, r := range rp {
readCountMap[r.SectionID] = r.Cnt
}
}
payCountMap := make(map[string]int64)
if len(ids) > 0 {
var op []struct {
ProductID string `gorm:"column:product_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Model(&model.Order{}).
Select("product_id, COUNT(*) as cnt").
Where("product_type = ? AND product_id IN ? AND status IN ?", "section", ids, []string{"paid", "completed", "success"}).
Group("product_id").Scan(&op)
for _, r := range op {
payCountMap[r.ProductID] = r.Cnt
}
}
readWeight, payWeight, recencyWeight := 0.5, 0.3, 0.2
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
var v struct {
ReadWeight float64 `json:"readWeight"`
RecencyWeight float64 `json:"recencyWeight"`
PayWeight float64 `json:"payWeight"`
}
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
if v.ReadWeight > 0 {
readWeight = v.ReadWeight
}
if v.PayWeight > 0 {
payWeight = v.PayWeight
}
if v.RecencyWeight > 0 {
recencyWeight = v.RecencyWeight
}
}
}
pinnedIDs := []string{}
if setPinned {
var cfg2 model.SystemConfig
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg2).Error; err == nil && len(cfg2.ConfigValue) > 0 {
_ = json.Unmarshal(cfg2.ConfigValue, &pinnedIDs)
}
}
pinnedSet := make(map[string]bool)
for _, id := range pinnedIDs {
if id != "" {
pinnedSet[id] = true
}
}
now := time.Now()
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
readCnt := readCountMap[r.ID]
payCnt := payCountMap[r.ID]
recencyScore := 0.0
if !r.UpdatedAt.IsZero() {
days := now.Sub(r.UpdatedAt).Hours() / 24
recencyScore = math.Max(0, (30-days)/30)
if recencyScore > 1 {
recencyScore = 1
}
}
hot := float64(readCnt)*readWeight + float64(payCnt)*payWeight + recencyScore*recencyWeight
item := sectionListItem{
ID: r.ID,
MID: r.MID,
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
IsNew: r.IsNew,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
ChapterTitle: r.ChapterTitle,
ClickCount: readCnt,
PayCount: payCnt,
HotScore: hot,
}
if setPinned {
item.IsPinned = pinnedSet[r.ID]
}
sections = append(sections, item)
}
return sections, nil
}
// DBBookAction GET/POST/PUT /api/db/book
func DBBookAction(c *gin.Context) {
db := database.DB()
switch c.Request.Method {
case http.MethodGet:
action := c.Query("action")
id := c.Query("id")
switch action {
case "list":
// 章节管理树:按 sort_order 顺序,含 hotScore
sections, err := computeSectionListWithHotScore(db)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return
case "ranking":
// 内容排行榜:置顶优先 + hotScore 降序,排序由后端统一计算,前端只展示
sections, err := computeArticleRankingSections(db)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return
case "read":
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
var ch model.Chapter
if err := db.Where("id = ?", id).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"section": gin.H{
"id": ch.ID,
"title": ch.SectionTitle,
"price": price,
"content": ch.Content,
"isNew": ch.IsNew,
"partId": ch.PartID,
"partTitle": ch.PartTitle,
"chapterId": ch.ChapterID,
"chapterTitle": ch.ChapterTitle,
"editionStandard": ch.EditionStandard,
"editionPremium": ch.EditionPremium,
},
})
return
case "section-orders":
// 某章节的付款记录(管理端展示)
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
var orders []model.Order
if err := db.Where("product_type = ? AND product_id = ? AND status IN ?", "section", id, []string{"paid", "completed", "success"}).
Order("pay_time DESC, created_at DESC").Limit(200).Find(&orders).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "orders": []model.Order{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "orders": orders})
return
case "export":
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
sections = append(sections, sectionListItem{
ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree, IsNew: r.IsNew,
PartID: r.PartID, PartTitle: r.PartTitle, ChapterID: r.ChapterID, ChapterTitle: r.ChapterTitle,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections})
return
default:
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
return
}
case http.MethodPost:
var body struct {
Action string `json:"action"`
Data []importItem `json:"data"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
switch body.Action {
case "sync":
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成Gin 无文件源时可从 DB 已存在数据视为已同步)"})
return
case "import":
imported, failed := 0, 0
for _, item := range body.Data {
price := 1.0
if item.Price != nil {
price = *item.Price
}
isFree := false
if item.IsFree != nil {
isFree = *item.IsFree
}
wordCount := len(item.Content)
status := "published"
editionStandard, editionPremium := true, false
ch := model.Chapter{
ID: item.ID,
PartID: strPtr(item.PartID, "part-1"),
PartTitle: strPtr(item.PartTitle, "未分类"),
ChapterID: strPtr(item.ChapterID, "chapter-1"),
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
SectionTitle: item.Title,
Content: item.Content,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
Status: &status,
EditionStandard: &editionStandard,
EditionPremium: &editionPremium,
}
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
if err == gorm.ErrRecordNotFound {
err = db.Create(&ch).Error
} else if err == nil {
err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"section_title": ch.SectionTitle,
"content": ch.Content,
"word_count": ch.WordCount,
"is_free": ch.IsFree,
"price": ch.Price,
}).Error
}
if err != nil {
failed++
continue
}
imported++
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
return
default:
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
return
}
case http.MethodPut:
var body struct {
Action string `json:"action"`
// reorder新顺序支持跨篇跨章时附带 partId/chapterId
IDs []string `json:"ids"`
Items []reorderItem `json:"items"`
// move-sections批量移动节到目标篇/章
SectionIds []string `json:"sectionIds"`
TargetPartID string `json:"targetPartId"`
TargetChapterID string `json:"targetChapterId"`
TargetPartTitle string `json:"targetPartTitle"`
TargetChapterTitle string `json:"targetChapterTitle"`
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
IsNew *bool `json:"isNew"` // stitch_soul标记最新新增
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
HotScore *float64 `json:"hotScore"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.Action == "reorder" {
// 立即返回成功,后台异步执行排序更新
if len(body.Items) > 0 {
items := make([]reorderItem, len(body.Items))
copy(items, body.Items)
c.JSON(http.StatusOK, gin.H{"success": true})
go func() {
db := database.DB()
for i, it := range items {
if it.ID == "" {
continue
}
up := map[string]interface{}{"sort_order": i}
if it.PartID != "" {
up["part_id"] = it.PartID
}
if it.PartTitle != "" {
up["part_title"] = it.PartTitle
}
if it.ChapterID != "" {
up["chapter_id"] = it.ChapterID
}
if it.ChapterTitle != "" {
up["chapter_title"] = it.ChapterTitle
}
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
}
}()
return
}
if len(body.IDs) > 0 {
ids := make([]string, len(body.IDs))
copy(ids, body.IDs)
c.JSON(http.StatusOK, gin.H{"success": true})
go func() {
db := database.DB()
for i, id := range ids {
if id != "" {
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
}
}
}()
return
}
}
if body.Action == "move-sections" {
if len(body.SectionIds) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少 sectionIds请先勾选要移动的节"})
return
}
if body.TargetPartID == "" || body.TargetChapterID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少目标篇或目标章targetPartId、targetChapterId"})
return
}
up := map[string]interface{}{
"part_id": body.TargetPartID,
"chapter_id": body.TargetChapterID,
"part_title": body.TargetPartTitle,
"chapter_title": body.TargetChapterTitle,
}
if err := db.Model(&model.Chapter{}).Where("id IN ?", body.SectionIds).Updates(up).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
if body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
price := 1.0
if body.Price != nil {
price = *body.Price
}
isFree := false
if body.IsFree != nil {
isFree = *body.IsFree
}
wordCount := len(body.Content)
updates := map[string]interface{}{
"section_title": body.Title,
"content": body.Content,
"word_count": wordCount,
"price": price,
"is_free": isFree,
}
if body.IsNew != nil {
updates["is_new"] = *body.IsNew
}
// 默认普通版:未传时按普通版处理
if body.EditionStandard != nil {
updates["edition_standard"] = *body.EditionStandard
} else if body.EditionPremium == nil {
updates["edition_standard"] = true
updates["edition_premium"] = false
}
if body.EditionPremium != nil {
updates["edition_premium"] = *body.EditionPremium
}
if body.HotScore != nil {
updates["hot_score"] = *body.HotScore
}
if body.PartID != "" {
updates["part_id"] = body.PartID
}
if body.PartTitle != "" {
updates["part_title"] = body.PartTitle
}
if body.ChapterID != "" {
updates["chapter_id"] = body.ChapterID
}
if body.ChapterTitle != "" {
updates["chapter_title"] = body.ChapterTitle
}
var existing model.Chapter
err := db.Where("id = ?", body.ID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// 新建Create
partID := body.PartID
if partID == "" {
partID = "part-1"
}
partTitle := body.PartTitle
if partTitle == "" {
partTitle = "未分类"
}
chapterID := body.ChapterID
if chapterID == "" {
chapterID = "chapter-1"
}
chapterTitle := body.ChapterTitle
if chapterTitle == "" {
chapterTitle = "未分类"
}
editionStandard, editionPremium := true, false
if body.EditionPremium != nil && *body.EditionPremium {
editionStandard, editionPremium = false, true
} else if body.EditionStandard != nil {
editionStandard = *body.EditionStandard
}
status := "published"
ch := model.Chapter{
ID: body.ID,
PartID: partID,
PartTitle: partTitle,
ChapterID: chapterID,
ChapterTitle: chapterTitle,
SectionTitle: body.Title,
Content: body.Content,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
Status: &status,
EditionStandard: &editionStandard,
EditionPremium: &editionPremium,
}
if body.IsNew != nil {
ch.IsNew = body.IsNew
}
if err := db.Create(&ch).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
err = db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
if 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": "不支持的请求方法"})
}
type reorderItem struct {
ID string `json:"id"`
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
}
type importItem struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
PartID *string `json:"partId"`
PartTitle *string `json:"partTitle"`
ChapterID *string `json:"chapterId"`
ChapterTitle *string `json:"chapterTitle"`
}
func strPtr(s *string, def string) string {
if s != nil && *s != "" {
return *s
}
return def
}
// DBBookDelete DELETE /api/db/book
func DBBookDelete(c *gin.Context) {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
if err := database.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})
}