更新管理端迁移Mycontent-temp的菜单与布局规范,确保主导航收敛并优化隐藏页面入口。新增相关会议记录与文档,反映团队讨论的最新决策与实施建议。

This commit is contained in:
Alex-larget
2026-03-10 18:06:10 +08:00
parent e23eba5d3e
commit aebb533507
82 changed files with 2376 additions and 1126 deletions

View File

@@ -0,0 +1,194 @@
package handler
import (
"encoding/json"
"net/http"
"sync"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var paidStatuses = []string{"paid", "completed", "success"}
// AdminDashboardStats GET /api/admin/dashboard/stats
// 轻量聚合:总用户、付费订单数、付费用户数、总营收、转化率(无订单/用户明细)
func AdminDashboardStats(c *gin.Context) {
db := database.DB()
var (
totalUsers int64
paidOrderCount int64
totalRevenue float64
paidUserCount int64
)
var wg sync.WaitGroup
wg.Add(4)
go func() { defer wg.Done(); db.Model(&model.User{}).Count(&totalUsers) }()
go func() { defer wg.Done(); db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount) }()
go func() {
defer wg.Done()
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
}()
go func() {
defer wg.Done()
db.Table("orders").Where("status IN ?", paidStatuses).
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
}()
wg.Wait()
conversionRate := 0.0
if totalUsers > 0 && paidUserCount > 0 {
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"totalUsers": totalUsers,
"paidOrderCount": paidOrderCount,
"paidUserCount": paidUserCount,
"totalRevenue": totalRevenue,
"conversionRate": conversionRate,
})
}
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
func AdminDashboardRecentOrders(c *gin.Context) {
db := database.DB()
var recentOrders []model.Order
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(5).Find(&recentOrders)
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
}
// AdminDashboardNewUsers GET /api/admin/dashboard/new-users
func AdminDashboardNewUsers(c *gin.Context) {
db := database.DB()
var newUsers []model.User
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
c.JSON(http.StatusOK, gin.H{"success": true, "newUsers": buildNewUsersOut(newUsers)})
}
// AdminDashboardOverview GET /api/admin/dashboard/overview
// 数据概览:总用户、付费订单数、付费用户数、总营收、转化率、最近订单、新用户
// 优化6 组查询并行执行,减少总耗时
func AdminDashboardOverview(c *gin.Context) {
db := database.DB()
var (
totalUsers int64
paidOrderCount int64
totalRevenue float64
paidUserCount int64
recentOrders []model.Order
newUsers []model.User
)
var wg sync.WaitGroup
wg.Add(6)
go func() {
defer wg.Done()
db.Model(&model.User{}).Count(&totalUsers)
}()
go func() {
defer wg.Done()
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount)
}()
go func() {
defer wg.Done()
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
}()
go func() {
defer wg.Done()
db.Table("orders").Where("status IN ?", paidStatuses).
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
}()
go func() {
defer wg.Done()
db.Where("status IN ?", paidStatuses).
Order("created_at DESC").Limit(5).Find(&recentOrders)
}()
go func() {
defer wg.Done()
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
}()
wg.Wait()
conversionRate := 0.0
if totalUsers > 0 && paidUserCount > 0 {
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
}
recentOut := buildRecentOrdersOut(db, recentOrders)
newOut := buildNewUsersOut(newUsers)
c.JSON(http.StatusOK, gin.H{
"success": true,
"totalUsers": totalUsers,
"paidOrderCount": paidOrderCount,
"paidUserCount": paidUserCount,
"totalRevenue": totalRevenue,
"conversionRate": conversionRate,
"recentOrders": recentOut,
"newUsers": newOut,
})
}
func dashStr(s *string) string {
if s == nil || *s == "" {
return ""
}
return *s
}
func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
if len(recentOrders) == 0 {
return nil
}
userIDs := make(map[string]bool)
for _, o := range recentOrders {
if o.UserID != "" {
userIDs[o.UserID] = true
}
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
db.Where("id IN ?", ids).Find(&users)
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
out := make([]gin.H, 0, len(recentOrders))
for _, o := range recentOrders {
b, _ := json.Marshal(o)
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if u := userMap[o.UserID]; u != nil {
m["userNickname"] = dashStr(u.Nickname)
m["userAvatar"] = dashStr(u.Avatar)
} else {
m["userNickname"] = ""
m["userAvatar"] = ""
}
out = append(out, m)
}
return out
}
func buildNewUsersOut(newUsers []model.User) []gin.H {
out := make([]gin.H, 0, len(newUsers))
for _, u := range newUsers {
out = append(out, gin.H{
"id": u.ID,
"nickname": dashStr(u.Nickname),
"phone": dashStr(u.Phone),
"referralCode": dashStr(u.ReferralCode),
"createdAt": u.CreatedAt,
})
}
return out
}

View File

@@ -5,6 +5,8 @@ import (
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -109,8 +111,61 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
return ids
}
// checkUserChapterAccess 判断 userId 是否有权读取 chapterIDVIP / 全书购买 / 单章购买)
// isPremium=true 表示增值版fullbook 买断不含增值版
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
if userID == "" {
return false
}
// VIPis_vip=1 且未过期
var u model.User
if err := db.Select("id", "is_vip", "vip_expire_date", "has_full_book").Where("id = ?", userID).First(&u).Error; err != nil {
return false
}
if u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
return true
}
// 全书买断(不含增值版)
if !isPremium && u.HasFullBook != nil && *u.HasFullBook {
return true
}
// 全书订单(兜底)
if !isPremium {
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = 'fullbook' AND status = 'paid'", userID).Count(&cnt)
if cnt > 0 {
return true
}
}
// 单章购买
var cnt int64
db.Model(&model.Order{}).Where(
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid'",
userID, chapterID,
).Count(&cnt)
return cnt > 0
}
// previewContent 取内容的前 20%(最多前 200 个字符),并追加省略提示
func previewContent(content string) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
limit := total / 5
if limit < 100 {
limit = 100
}
if limit > total {
limit = total
}
runes := []rune(content)
return string(runes[:limit]) + "\n\n……购买后阅读完整内容"
}
// findChapterAndRespond 按条件查章节并返回统一格式
// 免费判断优先级system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content否则返回 previewContent
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
var ch model.Chapter
db := database.DB()
@@ -122,30 +177,43 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
isFree := isFreeFromConfig
if !isFree && ch.IsFree != nil && *ch.IsFree {
isFree = true
}
if !isFree && ch.Price != nil && *ch.Price == 0 {
isFree = true
}
// 确定返回的 content免费直接返回付费须校验购买权限
userID := c.Query("userId")
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
var returnContent string
if isFree {
returnContent = ch.Content
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
returnContent = ch.Content
} else {
returnContent = previewContent(ch.Content)
}
out := gin.H{
"success": true,
"data": ch,
"content": ch.Content,
"content": returnContent,
"chapterTitle": ch.ChapterTitle,
"partTitle": ch.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": ch.SectionTitle,
"isFree": isFree,
}
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
if isFreeFromConfig {
out["isFree"] = true
out["price"] = float64(0)
} else {
if ch.IsFree != nil {
out["isFree"] = *ch.IsFree
}
if ch.Price != nil {
out["price"] = *ch.Price
if *ch.Price == 0 {
out["isFree"] = true
}
}
} else if ch.Price != nil {
out["price"] = *ch.Price
}
c.JSON(http.StatusOK, out)
}

View File

@@ -290,15 +290,17 @@ func CKBSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序-链接卡若:上报线索到存客宝,便于卡若添加好友
// 请求体phone(可选)、wechatId可选、name可选、userId可选用于补全昵称
// 至少传 phone 或 wechatId 之一;签名规则同 api_v1.md
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)或文章@某人(点击 mention
// 请求体phone/wechatId至少一个、userId补全昵称、targetUserId被@的 personId、targetNickname、source
func CKBLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention 场景)
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
Source string `json:"source"` // index_lead / article_mention
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
@@ -308,16 +310,37 @@ func CKBLead(c *gin.Context) {
return
}
name := strings.TrimSpace(body.Name)
db := database.DB()
if name == "" && body.UserID != "" {
var u model.User
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
}
if name == "" {
name = "小程序用户"
}
db := database.DB()
// 确定使用哪个存客宝密钥
// 优先级:被@人物的 ckb_api_key > 全局 CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
leadKey := ckbAPIKey
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
leadKey = cfg.CkbLeadAPIKey
}
targetName := strings.TrimSpace(body.TargetNickname) // 被@人的显示名,用于成功文案
if body.TargetUserID != "" {
var p model.Person
if db.Where("person_id = ?", body.TargetUserID).First(&p).Error == nil {
if p.CkbApiKey != "" {
leadKey = p.CkbApiKey
}
if targetName == "" {
targetName = p.Name
}
}
}
// 去重限频2 分钟内同一用户/手机/微信只能提交一次
var cond []string
var args []interface{}
if body.UserID != "" {
@@ -332,7 +355,6 @@ func CKBLead(c *gin.Context) {
cond = append(cond, "wechat_id = ?")
args = append(args, wechatId)
}
// 2 分钟内同一用户/手机/微信只能提交一次(与前端限频一致)
if len(cond) > 0 {
cutoff := time.Now().Add(-2 * time.Minute)
var recentCount int64
@@ -341,30 +363,32 @@ func CKBLead(c *gin.Context) {
return
}
}
// 是否曾留资过(仅用于成功后的提示文案)
repeatedSubmit := false
if len(cond) > 0 {
var existCount int64
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
}
source := strings.TrimSpace(body.Source)
if source == "" {
source = "index_lead"
}
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"targetUserId": body.TargetUserID, "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Params: string(paramsJSON),
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
TargetPersonID: body.TargetUserID,
Source: source,
Params: string(paramsJSON),
}).Error
ts := time.Now().Unix()
// 链接卡若GET + query便于浏览器测试传参name, phone, wechatId, apiKey, timestamp, sign
leadKey := ckbAPIKey
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
leadKey = cfg.CkbLeadAPIKey
}
params := map[string]interface{}{
"name": name,
"timestamp": ts,
@@ -399,17 +423,19 @@ func CKBLead(c *gin.Context) {
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
msg := "提交成功,卡若会尽快联系您"
if result.Message == "已存在" {
msg = "您已留资,我们会尽快联系您"
// 成功文案:有被@的人则用 TA 的名字,否则用"对方"
who := targetName
if who == "" {
who = "对方"
}
msg := fmt.Sprintf("提交成功,%s 会尽快联系您", who)
if repeatedSubmit {
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
msg = fmt.Sprintf("您已留资过,我们已再次通知 %s,请耐心等待添加", who)
}
data := gin.H{}
if result.Data != nil {

View File

@@ -101,6 +101,22 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["userDiscount"]; !has {
out["userDiscount"] = float64(5)
}
// 链接标签列表(小程序 onLinkTagTap 需要知道 type用于 ckb/miniprogram 的特殊处理)
var linkTagRows []model.LinkTag
if err := db.Order("label ASC").Find(&linkTagRows).Error; err == nil {
tags := make([]gin.H, 0, len(linkTagRows))
for _, t := range linkTagRows {
tags = append(tags, gin.H{
"tagId": t.TagID,
"label": t.Label,
"url": t.URL,
"type": t.Type,
"pagePath": t.PagePath,
"appId": t.AppID,
})
}
out["linkTags"] = tags
}
c.JSON(http.StatusOK, out)
}

View File

@@ -163,18 +163,21 @@ func DBBookAction(c *gin.Context) {
}
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,
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 {
@@ -212,14 +215,19 @@ func DBBookAction(c *gin.Context) {
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"` // 是否属于增值版
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"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -315,13 +323,88 @@ func DBBookAction(c *gin.Context) {
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
}
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
if body.HotScore != nil {
updates["hot_score"] = *body.HotScore
}
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

View File

@@ -24,9 +24,10 @@ func DBPersonList(c *gin.Context) {
// DBPersonSave POST /api/db/persons 管理端-新增或更新人物
func DBPersonSave(c *gin.Context) {
var body struct {
PersonID string `json:"personId"`
Name string `json:"name"`
Label string `json:"label"`
PersonID string `json:"personId"`
Name string `json:"name"`
Label string `json:"label"`
CkbApiKey string `json:"ckbApiKey"` // 存客宝密钥,留空则 fallback 全局 Key
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -44,11 +45,12 @@ func DBPersonSave(c *gin.Context) {
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
existing.Label = body.Label
existing.CkbApiKey = body.CkbApiKey
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
return
}
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label}
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label, CkbApiKey: body.CkbApiKey}
if err := db.Create(&p).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -220,6 +220,13 @@ func miniprogramPayPost(c *gin.Context) {
db := database.DB()
// -------- V1.1 后端价格:从 DB 读取标准价,客户端传值仅用于日志对比,实际以后端计算为准 --------
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
}
// 查询用户的有效推荐人(先查 binding再查 referralCode
var referrerID *string
if req.UserID != "" {
@@ -244,8 +251,8 @@ func miniprogramPayPost(c *gin.Context) {
}
}
// 有推荐人时应用好友优惠(无论是 binding 还是 referralCode
finalAmount := req.Amount
// 有推荐人时应用好友优惠,以后端标准价为基准计算最终金额,忽略客户端传值
finalAmount := standardPrice
if referrerID != nil {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
@@ -253,7 +260,7 @@ func miniprogramPayPost(c *gin.Context) {
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
discountRate := userDiscount / 100
finalAmount = req.Amount * (1 - discountRate)
finalAmount = standardPrice * (1 - discountRate)
if finalAmount < 0.01 {
finalAmount = 0.01
}
@@ -261,6 +268,11 @@ func miniprogramPayPost(c *gin.Context) {
}
}
}
// 记录客户端与后端金额差异(仅日志,不拦截)
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
}
// 生成订单号
orderSn := wechat.GenerateOrderSn()
@@ -372,7 +384,7 @@ func miniprogramPayGet(c *gin.Context) {
switch tradeState {
case "SUCCESS":
status = "paid"
// 若微信已支付,主动同步到本地 orders(不等 PayNotify便于购买次数即时生效
// V1.3 修复:主动同步到本地 orders并激活对应权益VIP/全书),避免等待 PayNotify 延迟
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" {
@@ -382,7 +394,13 @@ func miniprogramPayGet(c *gin.Context) {
"transaction_id": transactionID,
"pay_time": now,
})
order.Status = strToPtr("paid")
order.PayTime = &now
orderPollLogf("主动同步订单已支付: %s", orderSn)
// 激活权益
if order.UserID != "" {
activateOrderBenefits(db, order.UserID, order.ProductType, now)
}
}
case "CLOSED", "REVOKED", "PAYERROR":
status = "failed"
@@ -484,17 +502,12 @@ func MiniprogramPayNotify(c *gin.Context) {
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
} else if attach.ProductType == "vip" {
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at排序后付款在前
expireDate := time.Now().AddDate(0, 0, 365)
// V4.2 修复:续费时累加剩余天数(从 max(now, vip_expire_date) 加 365 天
vipActivatedAt := time.Now()
if order.PayTime != nil {
vipActivatedAt = *order.PayTime
}
db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": vipActivatedAt,
})
expireDate := activateVIP(db, buyerUserID, 365, vipActivatedAt)
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
@@ -781,9 +794,12 @@ func MiniprogramUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
id, "paid", "fullbook", "vip").Count(&cnt)
// V4.1 修复is_vip 同时校验过期时间is_vip=1 且 vip_expire_date>NOW而非仅凭订单数量
isVipActive, _ := isVipFromUsers(db, id)
if !isVipActive {
// 兜底orders 表有有效 VIP 订单
isVipActive, _ = isVipFromOrders(db, id)
}
// 用户信息与会员资料vip*、P3 资料扩展,供会员详情页完整展示
item := gin.H{
"id": user.ID,
@@ -808,7 +824,7 @@ func MiniprogramUsers(c *gin.Context) {
"helpOffer": getStringValue(user.HelpOffer),
"helpNeed": getStringValue(user.HelpNeed),
"projectIntro": getStringValue(user.ProjectIntro),
"is_vip": cnt > 0,
"is_vip": isVipActive,
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
return
@@ -819,15 +835,101 @@ func MiniprogramUsers(c *gin.Context) {
list := make([]gin.H, 0, len(users))
for i := range users {
u := &users[i]
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
u.ID, "paid", "fullbook", "vip").Count(&cnt)
// V4.1is_vip 同时校验过期时间
uvip, _ := isVipFromUsers(db, u.ID)
if !uvip {
uvip, _ = isVipFromOrders(db, u.ID)
}
list = append(list, gin.H{
"id": u.ID,
"nickname": getStringValue(u.Nickname),
"avatar": getStringValue(u.Avatar),
"is_vip": cnt > 0,
"is_vip": uvip,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// strToPtr 返回字符串指针(辅助函数)
func strToPtr(s string) *string { return &s }
// activateVIP 为用户激活 VIP续费时从 max(now, vip_expire_date) 累加 days 天
// 返回最终过期时间
func activateVIP(db *gorm.DB, userID string, days int, activatedAt time.Time) time.Time {
var u model.User
db.Select("id", "is_vip", "vip_expire_date").Where("id = ?", userID).First(&u)
base := activatedAt
if u.VipExpireDate != nil && u.VipExpireDate.After(base) {
base = *u.VipExpireDate // 续费累加
}
expireDate := base.AddDate(0, 0, days)
db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": activatedAt,
})
return expireDate
}
// activateOrderBenefits 订单支付成功后激活对应权益VIP / 全书)
func activateOrderBenefits(db *gorm.DB, userID, productType string, payTime time.Time) {
switch productType {
case "fullbook":
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
case "vip":
activateVIP(db, userID, 365, payTime)
}
}
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
// productType: fullbook / vip / section / match
// productId: 章节购买时为章节 ID
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
switch productType {
case "fullbook", "vip", "match":
// 从 system_config 读取
configKey := "chapter_config"
if productType == "vip" {
configKey = "vip_config"
}
var row model.SystemConfig
if err := db.Where("config_key = ?", configKey).First(&row).Error; err == nil {
var cfg map[string]interface{}
if json.Unmarshal(row.ConfigValue, &cfg) == nil {
fieldMap := map[string]string{
"fullbook": "fullbookPrice",
"vip": "price",
"match": "matchPrice",
}
if v, ok := cfg[fieldMap[productType]].(float64); ok && v > 0 {
return v, nil
}
}
}
// 兜底默认值
defaults := map[string]float64{"fullbook": 9.9, "vip": 1980, "match": 68}
if p, ok := defaults[productType]; ok {
return p, nil
}
return 0, fmt.Errorf("未知商品类型: %s", productType)
case "section":
if productID == "" {
return 0, fmt.Errorf("单章购买缺少 productId")
}
var ch model.Chapter
if err := db.Select("id", "price", "is_free").Where("id = ?", productID).First(&ch).Error; err != nil {
return 0, fmt.Errorf("章节不存在: %s", productID)
}
if ch.IsFree != nil && *ch.IsFree {
return 0, fmt.Errorf("该章节为免费章节,无需支付")
}
if ch.Price == nil || *ch.Price <= 0 {
return 0, fmt.Errorf("章节价格未配置: %s", productID)
}
return *ch.Price, nil
default:
return 0, fmt.Errorf("未知商品类型: %s", productType)
}
}

View File

@@ -38,6 +38,17 @@ func ReferralBind(c *gin.Context) {
}
db := database.DB()
// V3.1 修复:若同时提供了 openId 和 userId校验 userId 对应的用户确实拥有该 openId
// 防止攻击者伪造他人 userId 来绑定推荐关系
if req.UserID != "" && req.OpenID != "" {
var cnt int64
db.Model(&model.User{}).Where("id = ? AND open_id = ?", req.UserID, req.OpenID).Count(&cnt)
if cnt == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户身份校验失败"})
return
}
}
bindingDays := defaultBindingDays
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {

View File

@@ -69,6 +69,7 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
return base * vipOrderShareNonVip
}
// 内容订单:若有推荐人且 userDiscount>0反推原价否则按实付
// 设计意图:推广者拿的是折前原价的佣金,好友折扣由平台承担,不影响推广者收益
commissionBase := order.Amount
if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) {
if (1 - userDiscount) > 0 {

View File

@@ -639,3 +639,91 @@ func UserUpdate(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
}
// UserDashboardStats GET /api/miniprogram/user/dashboard-stats?userId=
// 小程序「我的」页聚合统计:已读章节列表、最近阅读、总阅读时长、匹配历史数
func UserDashboardStats(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
return
}
db := database.DB()
// 1. 拉取该用户所有阅读进度记录,按最近打开时间倒序
var progressList []model.ReadingProgress
if err := db.Where("user_id = ?", userID).Order("last_open_at DESC").Find(&progressList).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取阅读统计失败"})
return
}
// 2. 遍历:统计 readSectionIds / totalReadSeconds同时去重取最近 5 个不重复章节
readCount := len(progressList)
totalReadSeconds := 0
recentIDs := make([]string, 0, 5)
seenRecent := make(map[string]bool)
readSectionIDs := make([]string, 0, len(progressList))
for _, item := range progressList {
totalReadSeconds += item.Duration
if item.SectionID != "" {
readSectionIDs = append(readSectionIDs, item.SectionID)
// 去重:同一章节只保留最近一次
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
seenRecent[item.SectionID] = true
recentIDs = append(recentIDs, item.SectionID)
}
}
}
// 不足 60 秒但有阅读记录时,至少显示 1 分钟
totalReadMinutes := totalReadSeconds / 60
if totalReadSeconds > 0 && totalReadMinutes == 0 {
totalReadMinutes = 1
}
// 3. 批量查 chapters 获取真实标题与 mid
chapterMap := make(map[string]model.Chapter)
if len(recentIDs) > 0 {
var chapters []model.Chapter
if err := db.Select("id", "mid", "section_title").Where("id IN ?", recentIDs).Find(&chapters).Error; err == nil {
for _, ch := range chapters {
chapterMap[ch.ID] = ch
}
}
}
// 按最近阅读顺序组装,标题 fallback 为 section_id
recentChapters := make([]gin.H, 0, len(recentIDs))
for _, id := range recentIDs {
ch, ok := chapterMap[id]
title := id
mid := 0
if ok {
if ch.SectionTitle != "" {
title = ch.SectionTitle
}
mid = ch.MID
}
recentChapters = append(recentChapters, gin.H{
"id": id,
"mid": mid,
"title": title,
})
}
// 4. 匹配历史数(该用户发起的匹配次数)
var matchHistory int64
db.Model(&model.MatchRecord{}).Where("user_id = ?", userID).Count(&matchHistory)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"readCount": readCount,
"totalReadMinutes": totalReadMinutes,
"recentChapters": recentChapters,
"matchHistory": matchHistory,
"readSectionIds": readSectionIDs,
},
})
}