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

@@ -304,3 +304,70 @@ func DBUsersJourneyStats(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "stats": stats})
}
// journeyUserItem 用户旅程列表项
type journeyUserItem struct {
ID string `json:"id"`
Nickname string `json:"nickname"`
Phone string `json:"phone"`
CreatedAt string `json:"createdAt"`
}
// DBUsersJourneyUsers GET /api/db/users/journey-users?stage=xxx&limit=20 — 按阶段查用户
func DBUsersJourneyUsers(c *gin.Context) {
db := database.DB()
stage := strings.TrimSpace(c.Query("stage"))
limitStr := c.DefaultQuery("limit", "20")
limit := 20
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
}
if limit > 100 {
limit = 100
}
var users []model.User
switch stage {
case "register":
db.Order("created_at DESC").Limit(limit).Find(&users)
case "browse":
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "view_chapter").Distinct("user_id")
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
case "bind_phone":
db.Where("phone IS NOT NULL AND phone != ''").Order("created_at DESC").Limit(limit).Find(&users)
case "first_pay":
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
Where("status IN ?", []string{"paid", "success", "completed"})).
Order("created_at DESC").Limit(limit).Find(&users)
case "fill_profile":
db.Where("mbti IS NOT NULL OR industry IS NOT NULL").Order("created_at DESC").Limit(limit).Find(&users)
case "match":
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "match").Distinct("user_id")
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
case "vip":
db.Where("is_vip = ?", true).Order("created_at DESC").Limit(limit).Find(&users)
case "distribution":
db.Where("referral_code IS NOT NULL AND referral_code != ''").Where("COALESCE(earnings, 0) > ?", 0).
Order("created_at DESC").Limit(limit).Find(&users)
default:
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的 stage 参数"})
return
}
list := make([]journeyUserItem, 0, len(users))
for _, u := range users {
nick, phone := "", ""
if u.Nickname != nil {
nick = *u.Nickname
}
if u.Phone != nil {
phone = *u.Phone
}
list = append(list, journeyUserItem{
ID: u.ID,
Nickname: nick,
Phone: phone,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "users": list})
}

View File

@@ -0,0 +1,93 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"soul-api/internal/database"
"github.com/gin-gonic/gin"
)
// AdminTrackStats GET /api/admin/track/stats 管理端-按钮/标签点击统计(按模块+action聚合
// 查询参数period=today|week|month|all默认 today
func AdminTrackStats(c *gin.Context) {
period := c.DefaultQuery("period", "today")
db := database.DB()
now := time.Now()
var since time.Time
switch period {
case "today":
since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
case "week":
since = now.AddDate(0, 0, -7)
case "month":
since = now.AddDate(0, -1, 0)
default:
since = time.Time{}
}
type trackRow struct {
Action string `gorm:"column:action"`
Target string `gorm:"column:target"`
ExtraData []byte `gorm:"column:extra_data"`
Count int64 `gorm:"column:count"`
}
query := db.Table("user_tracks").
Select("action, COALESCE(target, '') as target, extra_data, COUNT(*) as count").
Group("action, COALESCE(target, ''), extra_data").
Order("count DESC")
if !since.IsZero() {
query = query.Where("created_at >= ?", since)
}
var rows []trackRow
query.Find(&rows)
type statItem struct {
Action string `json:"action"`
Target string `json:"target"`
Module string `json:"module"`
Page string `json:"page"`
Count int64 `json:"count"`
}
byModule := make(map[string][]statItem)
total := int64(0)
for _, r := range rows {
module := "other"
page := ""
if len(r.ExtraData) > 0 {
var extra map[string]interface{}
if json.Unmarshal(r.ExtraData, &extra) == nil {
if m, ok := extra["module"].(string); ok && m != "" {
module = m
}
if p, ok := extra["page"].(string); ok && p != "" {
page = p
}
}
}
item := statItem{
Action: r.Action,
Target: r.Target,
Module: module,
Page: page,
Count: r.Count,
}
byModule[module] = append(byModule[module], item)
total += r.Count
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"period": period,
"total": total,
"byModule": byModule,
})
}

View File

@@ -308,7 +308,7 @@ func BalanceRefund(c *gin.Context) {
}})
}
// GET /api/miniprogram/balance/transactions 交易记录
// GET /api/miniprogram/balance/transactions 交易记录(含余额变动 + 阅读消费订单)
func BalanceTransactions(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
@@ -317,10 +317,81 @@ func BalanceTransactions(c *gin.Context) {
}
db := database.DB()
var txns []model.BalanceTransaction
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&txns)
c.JSON(http.StatusOK, gin.H{"success": true, "data": txns})
var orders []model.Order
db.Where("user_id = ? AND product_type = ? AND status IN ?", userID, "section", []string{"paid", "completed", "success"}).
Order("created_at DESC").Limit(50).Find(&orders)
type txRow struct {
ID string `json:"id"`
Type string `json:"type"`
Amount float64 `json:"amount"`
Description string `json:"description"`
CreatedAt interface{} `json:"createdAt"`
}
merged := make([]txRow, 0, len(txns)+len(orders))
for _, t := range txns {
merged = append(merged, txRow{
ID: fmt.Sprintf("bal_%d", t.ID), Type: t.Type, Amount: t.Amount,
Description: t.Description, CreatedAt: t.CreatedAt,
})
}
for _, o := range orders {
desc := "阅读消费"
if o.Description != nil && *o.Description != "" {
desc = *o.Description
}
merged = append(merged, txRow{
ID: o.ID, Type: "consume", Amount: -o.Amount,
Description: desc, CreatedAt: o.CreatedAt,
})
}
// 按时间倒序排列
for i := 0; i < len(merged); i++ {
for j := i + 1; j < len(merged); j++ {
ti := fmt.Sprintf("%v", merged[i].CreatedAt)
tj := fmt.Sprintf("%v", merged[j].CreatedAt)
if ti < tj {
merged[i], merged[j] = merged[j], merged[i]
}
}
}
if len(merged) > 50 {
merged = merged[:50]
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": merged})
}
// GET /api/admin/balance/summary 管理端-余额统计
func BalanceSummary(c *gin.Context) {
db := database.DB()
type Summary struct {
TotalUsers int64 `json:"totalUsers"`
TotalBalance float64 `json:"totalBalance"`
TotalRecharged float64 `json:"totalRecharged"`
TotalGifted float64 `json:"totalGifted"`
TotalRefunded float64 `json:"totalRefunded"`
GiftCount int64 `json:"giftCount"`
PendingGifts int64 `json:"pendingGifts"`
}
var s Summary
db.Model(&model.UserBalance{}).Count(&s.TotalUsers)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(balance),0)").Scan(&s.TotalBalance)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_recharged),0)").Scan(&s.TotalRecharged)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_gifted),0)").Scan(&s.TotalGifted)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_refunded),0)").Scan(&s.TotalRefunded)
db.Model(&model.GiftUnlock{}).Count(&s.GiftCount)
db.Model(&model.GiftUnlock{}).Where("status = ?", "pending").Count(&s.PendingGifts)
c.JSON(200, gin.H{"success": true, "data": s})
}
// GET /api/miniprogram/balance/gift/info 查询礼物码信息

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

View File

@@ -177,8 +177,9 @@ func AdminSettingsGet(c *gin.Context) {
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp,
"ossConfig": gin.H{},
}
keys := []string{"feature_config", "site_settings", "mp_config"}
keys := []string{"feature_config", "site_settings", "mp_config", "oss_config"}
for _, k := range keys {
var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
@@ -208,6 +209,10 @@ func AdminSettingsGet(c *gin.Context) {
}
out["mpConfig"] = merged
}
case "oss_config":
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
out["ossConfig"] = m
}
}
}
c.JSON(http.StatusOK, out)
@@ -219,6 +224,7 @@ func AdminSettingsPost(c *gin.Context) {
FeatureConfig map[string]interface{} `json:"featureConfig"`
SiteSettings map[string]interface{} `json:"siteSettings"`
MpConfig map[string]interface{} `json:"mpConfig"`
OssConfig map[string]interface{} `json:"ossConfig"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -260,6 +266,12 @@ func AdminSettingsPost(c *gin.Context) {
return
}
}
if body.OssConfig != nil {
if err := saveKey("oss_config", "阿里云 OSS 配置", body.OssConfig); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存 OSS 配置失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
}
@@ -667,6 +679,17 @@ func DBUsersList(c *gin.Context) {
referralCountMap[r.ReferrerID] = int(r.Count)
}
// 4. 用户余额:从 user_balances 查询
balanceMap := make(map[string]float64)
var balRows []struct {
UserID string
Balance float64
}
db.Table("user_balances").Select("user_id, COALESCE(balance, 0) as balance").Find(&balRows)
for _, r := range balRows {
balanceMap[r.UserID] = r.Balance
}
// 填充每个用户的实时计算字段
for i := range users {
uid := users[i].ID
@@ -687,7 +710,9 @@ func DBUsersList(c *gin.Context) {
}
users[i].Earnings = ptrFloat64(totalE)
users[i].PendingEarnings = ptrFloat64(available)
users[i].WithdrawnEarnings = ptrFloat64(withdrawn)
users[i].ReferralCount = ptrInt(referralCountMap[uid])
users[i].WalletBalance = ptrFloat64(balanceMap[uid])
}
c.JSON(http.StatusOK, gin.H{
@@ -745,6 +770,7 @@ func DBUsersAction(c *gin.Context) {
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
Tags *string `json:"tags"`
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
@@ -789,6 +815,9 @@ func DBUsersAction(c *gin.Context) {
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
}
if body.Tags != nil {
updates["tags"] = *body.Tags
}
if body.HasFullBook != nil {
updates["has_full_book"] = *body.HasFullBook
}
@@ -847,6 +876,9 @@ func DBUsersAction(c *gin.Context) {
if body.VipBio != nil {
updates["vip_bio"] = *body.VipBio
}
if body.Tags != nil {
updates["tags"] = *body.Tags
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return

View File

@@ -38,6 +38,7 @@ type sectionListItem struct {
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 返回)
}
@@ -156,47 +157,132 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
pinnedSet[id] = true
}
}
now := time.Now()
sections := make([]sectionListItem, 0, len(rows))
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
}
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)
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()
@@ -255,6 +341,7 @@ func DBBookAction(c *gin.Context) {
"chapterTitle": ch.ChapterTitle,
"editionStandard": ch.EditionStandard,
"editionPremium": ch.EditionPremium,
"previewPercent": ch.PreviewPercent,
},
})
return
@@ -386,6 +473,8 @@ func DBBookAction(c *gin.Context) {
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": "请求体无效"})
@@ -494,6 +583,14 @@ func DBBookAction(c *gin.Context) {
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
}

View File

@@ -108,6 +108,74 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// CKBPersonLeadStats GET /api/db/ckb-person-leads 每个人物的获客线索统计及明细
func CKBPersonLeadStats(c *gin.Context) {
db := database.DB()
personToken := c.Query("token")
if personToken != "" {
// 返回某人物的线索明细(通过 token → Person.PersonID → CkbLeadRecord.TargetPersonID
var person model.Person
if err := db.Where("token = ?", personToken).First(&person).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id = ?", person.Token)
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records)
out := make([]gin.H, 0, len(records))
for _, r := range records {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"nickname": r.Nickname,
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"personName": person.Name,
"records": out,
"total": total,
"page": page,
"pageSize": pageSize,
})
return
}
// 无 token 参数:返回所有人物的获客数量汇总
type PersonLeadStat struct {
Token string `gorm:"column:target_person_id" json:"token"`
Total int64 `gorm:"column:total" json:"total"`
}
var stats []PersonLeadStat
db.Raw("SELECT target_person_id, COUNT(*) as total FROM ckb_lead_records WHERE target_person_id != '' GROUP BY target_person_id").Scan(&stats)
// 同时统计全局(无特定人物的)线索
var globalTotal int64
db.Model(&model.CkbLeadRecord{}).Where("target_person_id = '' OR target_person_id IS NULL").Count(&globalTotal)
c.JSON(http.StatusOK, gin.H{
"success": true,
"byPerson": stats,
"globalLeads": globalTotal,
})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()

View File

@@ -24,6 +24,7 @@ func DBLinkTagSave(c *gin.Context) {
var body struct {
TagID string `json:"tagId"`
Label string `json:"label"`
Aliases string `json:"aliases"`
URL string `json:"url"`
Type string `json:"type"`
AppID string `json:"appId"`
@@ -48,6 +49,7 @@ func DBLinkTagSave(c *gin.Context) {
var existing model.LinkTag
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
existing.Label = body.Label
existing.Aliases = body.Aliases
existing.URL = body.URL
existing.Type = body.Type
existing.AppID = body.AppID
@@ -57,7 +59,7 @@ func DBLinkTagSave(c *gin.Context) {
return
}
// body.URL 已在 miniprogram 类型时置空
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -45,6 +45,7 @@ func DBPersonSave(c *gin.Context) {
var body struct {
PersonID string `json:"personId"`
Name string `json:"name"`
Aliases string `json:"aliases"`
Label string `json:"label"`
CkbApiKey string `json:"ckbApiKey"` // 存客宝真实密钥,留空则 fallback 全局 Key
Greeting string `json:"greeting"`
@@ -71,6 +72,7 @@ func DBPersonSave(c *gin.Context) {
var existing model.Person
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
existing.Aliases = body.Aliases
existing.Label = body.Label
existing.CkbApiKey = body.CkbApiKey
existing.Greeting = body.Greeting
@@ -175,6 +177,7 @@ func DBPersonSave(c *gin.Context) {
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
Aliases: body.Aliases,
Label: body.Label,
CkbApiKey: apiKey,
CkbPlanID: planID,

View File

@@ -98,6 +98,9 @@ func MiniprogramLogin(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
// 记录注册行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"})
// 新用户异步调用神射手自动打标手机号尚未绑定phone 为空时暂不调用)
AdminShensheShouAutoTag(userID, "")
} else {
@@ -723,6 +726,9 @@ func MiniprogramPhone(c *gin.Context) {
if req.UserID != "" {
db := database.DB()
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
// 记录绑定手机号行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
// 绑定手机号后,异步调用神射手自动完善标签
AdminShensheShouAutoTag(req.UserID, phoneNumber)

View File

@@ -0,0 +1,137 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"strings"
"time"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"soul-api/internal/database"
"soul-api/internal/model"
)
type ossConfigCache struct {
Endpoint string `json:"endpoint"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
Bucket string `json:"bucket"`
Region string `json:"region"`
}
func getOssConfig() *ossConfigCache {
db := database.DB()
var row model.SystemConfig
if err := db.Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
return nil
}
var cfg ossConfigCache
if err := json.Unmarshal(row.ConfigValue, &cfg); err != nil {
return nil
}
if cfg.Endpoint == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
return nil
}
return &cfg
}
func ossUploadFile(file multipart.File, folder, filename string) (string, error) {
cfg := getOssConfig()
if cfg == nil {
return "", fmt.Errorf("OSS 未配置")
}
endpoint := cfg.Endpoint
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
}
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
data, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("读取文件失败: %w", err)
}
err = bucket.PutObject(objectKey, strings.NewReader(string(data)))
if err != nil {
return "", fmt.Errorf("上传 OSS 失败: %w", err)
}
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
if err != nil {
host := cfg.Bucket + "." + cfg.Endpoint
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
host = cfg.Bucket + "." + cfg.Endpoint
} else {
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
}
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
return host + "/" + objectKey, nil
}
return signedURL, nil
}
func ossUploadBytes(data []byte, folder, filename, contentType string) (string, error) {
cfg := getOssConfig()
if cfg == nil {
return "", fmt.Errorf("OSS 未配置")
}
endpoint := cfg.Endpoint
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
}
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
var opts []oss.Option
if contentType != "" {
opts = append(opts, oss.ContentType(contentType))
}
err = bucket.PutObject(objectKey, strings.NewReader(string(data)), opts...)
if err != nil {
return "", fmt.Errorf("上传 OSS 失败: %w", err)
}
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
if err != nil {
host := cfg.Bucket + "." + cfg.Endpoint
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
host = cfg.Bucket + "." + cfg.Endpoint
} else {
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
}
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
return host + "/" + objectKey, nil
}
return signedURL, nil
}

View File

@@ -46,7 +46,7 @@ func SearchGet(c *gin.Context) {
Select("id, mid, section_title, part_title, chapter_title, price, is_free, '' as snippet").
Where("section_title LIKE ?", pattern).
Order("sort_order ASC, id ASC").
Limit(30).
Limit(3).
Find(&titleMatches)
titleIDs := make(map[string]bool, len(titleMatches))
@@ -55,7 +55,7 @@ func SearchGet(c *gin.Context) {
}
// 第二步:内容匹配(排除已命中标题的,用 SQL 提取摘要避免加载完整 content
remaining := 50 - len(titleMatches)
remaining := 20 - len(titleMatches)
var contentMatches []searchRow
if remaining > 0 {
contentQ := db.Model(&model.Chapter{}).

View File

@@ -16,7 +16,7 @@ const uploadDir = "uploads"
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
// UploadPost POST /api/upload 上传图片(表单 file
// UploadPost POST /api/upload 上传图片(表单 file,优先 OSS
func UploadPost(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
@@ -40,16 +40,30 @@ func UploadPost(c *gin.Context) {
if folder == "" {
folder = "avatars"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
// 尝试 OSS 上传
if ossCfg := getOssConfig(); ossCfg != nil {
src, err := file.Open()
if err == nil {
defer src.Close()
if ossURL, err := ossUploadFile(src, folder, name); err == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "url": ossURL, "data": gin.H{"url": ossURL, "fileName": name, "size": file.Size, "type": ct, "storage": "oss"}})
return
}
}
}
// 回退本地存储
dir := filepath.Join(uploadDir, folder)
_ = os.MkdirAll(dir, 0755)
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "storage": "local"}})
}
func randomStrUpload(n int) string {

View File

@@ -34,11 +34,11 @@ var (
"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
}
allowedVideoTypes = map[string]bool{
"video/mp4": true, "video/quicktime": true, "video/x-msvideo": true,
"video/mp4": true, "video/quicktime": true, "video/webm": true, "video/x-msvideo": true,
}
)
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩)
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩),优先 OSS
// 表单file必填, folder可选默认 images, quality可选 1-100默认 85
func UploadImagePost(c *gin.Context) {
file, err := c.FormFile("file")
@@ -65,14 +65,11 @@ func UploadImagePost(c *gin.Context) {
if folder == "" {
folder = "images"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".jpg"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext)
dst := filepath.Join(dir, name)
src, err := file.Open()
if err != nil {
@@ -85,61 +82,58 @@ func UploadImagePost(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"})
return
}
// JPEG支持质量压缩
// JPEG 压缩
var finalData []byte
finalCt := ct
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
img, err := jpeg.Decode(bytes.NewReader(data))
if err == nil {
if img, err := jpeg.Decode(bytes.NewReader(data)); err == nil {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{
"success": true, "url": url,
"data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct, "quality": quality},
})
return
}
finalData = buf.Bytes()
}
}
}
// PNG/GIF解码后原样保存
if strings.Contains(ct, "png") {
img, err := png.Decode(bytes.NewReader(data))
if err == nil {
} else if strings.Contains(ct, "png") {
if img, err := png.Decode(bytes.NewReader(data)); err == nil {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
finalData = buf.Bytes()
}
}
}
if strings.Contains(ct, "gif") {
img, err := gif.Decode(bytes.NewReader(data))
if err == nil {
} else if strings.Contains(ct, "gif") {
if img, err := gif.Decode(bytes.NewReader(data)); err == nil {
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
finalData = buf.Bytes()
}
}
}
if finalData == nil {
finalData = data
}
// 其他格式或解析失败时直接写入
if err := os.WriteFile(dst, data, 0644); err != nil {
// 优先 OSS 上传
if ossURL, err := ossUploadBytes(finalData, folder, name, finalCt); err == nil {
c.JSON(http.StatusOK, gin.H{
"success": true, "url": ossURL,
"data": gin.H{"url": ossURL, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "oss"},
})
return
}
// 回退本地存储
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
dst := filepath.Join(dir, name)
if err := os.WriteFile(dst, finalData, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(data)), "type": ct}})
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "local"}})
}
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传(指定目录)
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传,优先 OSS
// 表单file必填, folder可选默认 videos
func UploadVideoPost(c *gin.Context) {
file, err := c.FormFile("file")
@@ -160,13 +154,30 @@ func UploadVideoPost(c *gin.Context) {
if folder == "" {
folder = "videos"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".mp4"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
// 优先 OSS 上传
if ossCfg := getOssConfig(); ossCfg != nil {
src, err := file.Open()
if err == nil {
defer src.Close()
if ossURL, err := ossUploadFile(src, folder, name); err == nil {
c.JSON(http.StatusOK, gin.H{
"success": true, "url": ossURL,
"data": gin.H{"url": ossURL, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "oss"},
})
return
}
}
}
// 回退本地存储
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
@@ -175,7 +186,7 @@ func UploadVideoPost(c *gin.Context) {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{
"success": true, "url": url,
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder},
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "local"},
})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -595,6 +596,11 @@ func UserTrackPost(c *gin.Context) {
if body.Target != "" {
t.ChapterID = &chID
}
if body.ExtraData != nil {
if raw, err := json.Marshal(body.ExtraData); err == nil {
t.ExtraData = raw
}
}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return