484 lines
16 KiB
Go
484 lines
16 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"sort"
|
||
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// listSelectCols 列表/导出不加载 content,大幅加速;含 updated_at 用于热度算法
|
||
var listSelectCols = []string{
|
||
"id", "section_title", "price", "is_free", "is_new",
|
||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order", "updated_at",
|
||
}
|
||
|
||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰),含点击、付款与热度排名
|
||
type sectionListItem struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Price float64 `json:"price"`
|
||
IsFree *bool `json:"isFree,omitempty"`
|
||
IsNew *bool `json:"isNew,omitempty"`
|
||
PartID string `json:"partId"`
|
||
PartTitle string `json:"partTitle"`
|
||
ChapterID string `json:"chapterId"`
|
||
ChapterTitle string `json:"chapterTitle"`
|
||
FilePath *string `json:"filePath,omitempty"`
|
||
ClickCount int `json:"clickCount,omitempty"` // 阅读/点击次数(reading_progress 条数)
|
||
PayCount int `json:"payCount,omitempty"` // 付款笔数(orders 已支付)
|
||
HotScore float64 `json:"hotScore,omitempty"` // 热度积分(文章排名算法算出)
|
||
HotRank int `json:"hotRank,omitempty"` // 热度排名(1=最高)
|
||
}
|
||
|
||
// 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":
|
||
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(), "sections": []sectionListItem{}})
|
||
return
|
||
}
|
||
ids := make([]string, 0, len(rows))
|
||
for _, r := range rows {
|
||
ids = append(ids, r.ID)
|
||
}
|
||
// 点击量:与 reading_progress 表直接捆绑,按 section_id 计数(小程序打开章节会立即上报一条)
|
||
type readCnt struct{ SectionID string `gorm:"column:section_id"`; Cnt int64 `gorm:"column:cnt"` }
|
||
var readCounts []readCnt
|
||
if len(ids) > 0 {
|
||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").Where("section_id IN ?", ids).Group("section_id").Scan(&readCounts)
|
||
}
|
||
readMap := make(map[string]int)
|
||
for _, x := range readCounts {
|
||
readMap[x.SectionID] = int(x.Cnt)
|
||
}
|
||
// 付款笔数:与 orders 表直接捆绑,兼容 paid/completed/success 等已支付状态
|
||
type payCnt struct{ ProductID string `gorm:"column:product_id"`; Cnt int64 `gorm:"column:cnt"` }
|
||
var payCounts []payCnt
|
||
if len(ids) > 0 {
|
||
db.Table("orders").Select("product_id, COUNT(*) as cnt").
|
||
Where("product_type = ? AND status IN ? AND product_id IN ?", "section", []string{"paid", "completed", "success"}, ids).
|
||
Group("product_id").Scan(&payCounts)
|
||
}
|
||
payMap := make(map[string]int)
|
||
for _, x := range payCounts {
|
||
payMap[x.ProductID] = int(x.Cnt)
|
||
}
|
||
// 文章排名算法:权重可从 config article_ranking_weights 读取,默认 阅读50% 新度30% 付款20%
|
||
readWeight, recencyWeight, payWeight := 0.5, 0.3, 0.2
|
||
var cfgRow model.SystemConfig
|
||
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfgRow).Error; err == nil && len(cfgRow.ConfigValue) > 0 {
|
||
var m map[string]interface{}
|
||
if json.Unmarshal(cfgRow.ConfigValue, &m) == nil {
|
||
if v, ok := m["readWeight"]; ok {
|
||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||
readWeight = f
|
||
}
|
||
}
|
||
if v, ok := m["recencyWeight"]; ok {
|
||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||
recencyWeight = f
|
||
}
|
||
}
|
||
if v, ok := m["payWeight"]; ok {
|
||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||
payWeight = f
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 热度 = readWeight*阅读排名分 + recencyWeight*新度排名分 + payWeight*付款排名分
|
||
readScore := make(map[string]float64)
|
||
idsByRead := make([]string, len(ids))
|
||
copy(idsByRead, ids)
|
||
sort.Slice(idsByRead, func(i, j int) bool { return readMap[idsByRead[i]] > readMap[idsByRead[j]] })
|
||
for i := 0; i < 20 && i < len(idsByRead); i++ {
|
||
readScore[idsByRead[i]] = float64(20 - i)
|
||
}
|
||
recencyScore := make(map[string]float64)
|
||
sort.Slice(rows, func(i, j int) bool { return rows[i].UpdatedAt.After(rows[j].UpdatedAt) })
|
||
for i := 0; i < 30 && i < len(rows); i++ {
|
||
recencyScore[rows[i].ID] = float64(30 - i)
|
||
}
|
||
payScore := make(map[string]float64)
|
||
idsByPay := make([]string, len(ids))
|
||
copy(idsByPay, ids)
|
||
sort.Slice(idsByPay, func(i, j int) bool { return payMap[idsByPay[i]] > payMap[idsByPay[j]] })
|
||
for i := 0; i < 20 && i < len(idsByPay); i++ {
|
||
payScore[idsByPay[i]] = float64(20 - i)
|
||
}
|
||
type idTotal struct {
|
||
id string
|
||
total float64
|
||
}
|
||
totals := make([]idTotal, 0, len(rows))
|
||
for _, r := range rows {
|
||
t := readWeight*readScore[r.ID] + recencyWeight*recencyScore[r.ID] + payWeight*payScore[r.ID]
|
||
totals = append(totals, idTotal{r.ID, t})
|
||
}
|
||
sort.Slice(totals, func(i, j int) bool { return totals[i].total > totals[j].total })
|
||
hotRankMap := make(map[string]int)
|
||
for i, t := range totals {
|
||
hotRankMap[t.id] = i + 1
|
||
}
|
||
hotScoreMap := make(map[string]float64)
|
||
for _, t := range totals {
|
||
hotScoreMap[t.id] = t.total
|
||
}
|
||
// 恢复 rows 的 sort_order 顺序(上面 recency 排序打乱了)
|
||
sort.Slice(rows, func(i, j int) bool {
|
||
soi, soj := 0, 0
|
||
if rows[i].SortOrder != nil {
|
||
soi = *rows[i].SortOrder
|
||
}
|
||
if rows[j].SortOrder != nil {
|
||
soj = *rows[j].SortOrder
|
||
}
|
||
if soi != soj {
|
||
return soi < soj
|
||
}
|
||
return rows[i].ID < rows[j].ID
|
||
})
|
||
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,
|
||
ClickCount: readMap[r.ID],
|
||
PayCount: payMap[r.ID],
|
||
HotScore: hotScoreMap[r.ID],
|
||
HotRank: hotRankMap[r.ID],
|
||
})
|
||
}
|
||
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,
|
||
},
|
||
})
|
||
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"
|
||
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,
|
||
}
|
||
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"` // 是否属于增值版
|
||
}
|
||
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
|
||
}
|
||
if body.EditionPremium != nil {
|
||
updates["edition_premium"] = *body.EditionPremium
|
||
}
|
||
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})
|
||
}
|