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

616 lines
19 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 (
"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})
}