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}) }