feat: 数据概览简化 + 用户管理增加余额/提现列
- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额 - 数据概览:移除余额统计区块(余额改在用户管理中展示) - 数据概览:恢复转化率卡片(唯一付费用户/总用户) - 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额 - 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段 - 后端:User model 添加 WalletBalance 非数据库字段 - 包含之前的小程序埋点和管理后台点击统计面板 Made-with: Cursor
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
93
soul-api/internal/handler/admin_track.go
Normal file
93
soul-api/internal/handler/admin_track.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 查询礼物码信息
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
137
soul-api/internal/handler/oss.go
Normal file
137
soul-api/internal/handler/oss.go
Normal 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
|
||||
}
|
||||
@@ -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{}).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user