feat: 数据概览简化 + 用户管理增加余额/提现列

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

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 15:57:09 +08:00
parent 991e17698c
commit 708547d0dd
52 changed files with 3161 additions and 1103 deletions

View File

@@ -147,17 +147,58 @@ func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium boo
return cnt > 0
}
// previewContent 取内容的前 50%(不少于 100 个字符),并追加省略提示
func previewContent(content string) string {
// getUnpaidPreviewPercent 从 system_config 读取 unpaid_preview_percent默认 20
func getUnpaidPreviewPercent(db *gorm.DB) int {
var row model.SystemConfig
if err := db.Where("config_key = ?", "unpaid_preview_percent").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
return 20
}
var val interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
return 20
}
switch v := val.(type) {
case float64:
p := int(v)
if p < 1 {
p = 1
}
if p > 100 {
p = 100
}
return p
case int:
if v < 1 {
return 1
}
if v > 100 {
return 100
}
return v
}
return 20
}
// previewContent 取内容的前 percent%,上限 500 字(手动设置 percent 也受此限制),不少于 100 字
func previewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
// 截取前 50% 的内容,保证有足够的预览长度
limit := total / 2
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
if limit > total {
limit = total
}
@@ -198,7 +239,11 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
returnContent = ch.Content
} else {
returnContent = previewContent(ch.Content)
percent := getUnpaidPreviewPercent(db)
if ch.PreviewPercent != nil && *ch.PreviewPercent >= 1 && *ch.PreviewPercent <= 100 {
percent = *ch.PreviewPercent
}
returnContent = previewContent(ch.Content, percent)
}
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
@@ -289,7 +334,7 @@ func BookChapters(c *gin.Context) {
updates := map[string]interface{}{
"part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
"content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
"sort_order": body.SortOrder, "status": body.Status,
"sort_order": body.SortOrder, "status": body.Status, "hot_score": body.HotScore,
}
if body.EditionStandard != nil {
updates["edition_standard"] = body.EditionStandard
@@ -368,18 +413,59 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
return out
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录
// BookHot GET /api/book/hot 热门章节(按 hot_score 降序,使用与管理端相同的排名算法
// 支持 ?limit=N 参数,默认 20最大 100
func BookHot(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 10)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
db := database.DB()
sections, err := computeArticleRankingSections(db)
if err != nil || len(sections) == 0 {
var list []model.Chapter
q := db.Model(&model.Chapter{}).
Select("mid, id, part_id, part_title, chapter_id, chapter_title, section_title, is_free, price, sort_order, hot_score, updated_at")
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
q.Order("hot_score DESC, sort_order ASC, id ASC").Limit(20).Find(&list)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
limit := 20
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
limit = l
if limit > 100 {
limit = 100
}
}
if len(sections) < limit {
limit = len(sections)
}
tags := []string{"热门", "推荐", "精选"}
result := make([]gin.H, 0, limit)
for i := 0; i < limit; i++ {
s := sections[i]
tag := ""
if i < len(tags) {
tag = tags[i]
}
result = append(result, gin.H{
"id": s.ID,
"mid": s.MID,
"sectionTitle": s.Title,
"partTitle": s.PartTitle,
"chapterTitle": s.ChapterTitle,
"price": s.Price,
"isFree": s.IsFree,
"clickCount": s.ClickCount,
"payCount": s.PayCount,
"hotScore": s.HotScore,
"hotRank": i + 1,
"isPinned": s.IsPinned,
"tag": tag,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
@@ -498,9 +584,21 @@ func BookSearch(c *gin.Context) {
// BookStats GET /api/book/stats
func BookStats(c *gin.Context) {
db := database.DB()
var total int64
database.DB().Model(&model.Chapter{}).Count(&total)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
db.Model(&model.Chapter{}).Count(&total)
var freeCount int64
db.Model(&model.Chapter{}).Where("is_free = ?", true).Count(&freeCount)
var totalWords struct{ S int64 }
db.Model(&model.Chapter{}).Select("COALESCE(SUM(word_count),0) as s").Scan(&totalWords)
var userCount int64
db.Model(&model.User{}).Count(&userCount)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"totalChapters": total,
"freeChapters": freeCount,
"totalWordCount": totalWords.S,
"totalUsers": userCount,
}})
}
// BookSync GET/POST /api/book/sync