Files
soul-yongping/soul-api/internal/handler/db_book.go
卡若 708547d0dd feat: 数据概览简化 + 用户管理增加余额/提现列
- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额
- 数据概览:移除余额统计区块(余额改在用户管理中展示)
- 数据概览:恢复转化率卡片(唯一付费用户/总用户)
- 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额
- 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段
- 后端:User model 添加 WalletBalance 非数据库字段
- 包含之前的小程序埋点和管理后台点击统计面板

Made-with: Cursor
2026-03-15 15:57:09 +08:00

713 lines
22 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"` // 热度积分(加权计算)
HotRank int `json:"hotRank"` // 热度排名(按 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
}
}
const rankTop = 20
// 构建基础 section 数据
type rawSection struct {
item sectionListItem
readCnt int64
payCnt int64
updatedAt time.Time
}
raws := make([]rawSection, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
raws = append(raws, rawSection{
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: readCountMap[r.ID],
PayCount: payCountMap[r.ID],
},
readCnt: readCountMap[r.ID],
payCnt: payCountMap[r.ID],
updatedAt: r.UpdatedAt,
})
}
// 排名积分:前 rankTop 名分别得 rankTop ~ 1 分,其余 0 分
readRankScore := make(map[string]float64, len(raws))
payRankScore := make(map[string]float64, len(raws))
recencyRankScore := make(map[string]float64, len(raws))
// 阅读量排名
sorted := make([]int, len(raws))
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].readCnt > raws[sorted[b]].readCnt
})
for rank, idx := range sorted {
if rank >= rankTop || raws[idx].readCnt == 0 {
break
}
readRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 付款量排名
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].payCnt > raws[sorted[b]].payCnt
})
for rank, idx := range sorted {
if rank >= rankTop || raws[idx].payCnt == 0 {
break
}
payRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 新度排名(按 updated_at 最近排序)
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].updatedAt.After(raws[sorted[b]].updatedAt)
})
for rank, idx := range sorted {
if rank >= rankTop {
break
}
recencyRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 计算最终热度分
sections := make([]sectionListItem, 0, len(raws))
hotUpdates := make(map[string]float64, len(raws))
for i := range raws {
id := raws[i].item.ID
hot := readRankScore[id]*readWeight + recencyRankScore[id]*recencyWeight + payRankScore[id]*payWeight
hot = math.Round(hot*100) / 100
hotUpdates[id] = hot
raws[i].item.HotScore = hot
if setPinned {
raws[i].item.IsPinned = pinnedSet[id]
}
sections = append(sections, raws[i].item)
}
// 计算排名序号
ranked := make([]sectionListItem, len(sections))
copy(ranked, sections)
sort.Slice(ranked, func(i, j int) bool {
return ranked[i].HotScore > ranked[j].HotScore
})
rankMap := make(map[string]int, len(ranked))
for i, s := range ranked {
rankMap[s.ID] = i + 1
}
for i := range sections {
sections[i].HotRank = rankMap[sections[i].ID]
}
go persistHotScores(db, hotUpdates)
return sections, nil
}
// persistHotScores writes computed hot_score values back to the chapters table
func persistHotScores(db *gorm.DB, scores map[string]float64) {
for id, score := range scores {
_ = db.WithContext(context.Background()).
Model(&model.Chapter{}).
Where("id = ?", id).
UpdateColumn("hot_score", score).Error
}
}
// 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,
"previewPercent": ch.PreviewPercent,
},
})
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"`
PreviewPercent *int `json:"previewPercent"` // 章节级预览比例(%)1-100
ClearPreviewPercent *bool `json:"clearPreviewPercent"` // true 表示清除覆盖、使用全局
}
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.ClearPreviewPercent != nil && *body.ClearPreviewPercent {
updates["preview_percent"] = nil
} else if body.PreviewPercent != nil {
p := *body.PreviewPercent
if p >= 1 && p <= 100 {
updates["preview_percent"] = p
}
}
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})
}