Merge branch 'yongxu' into devlop

# Conflicts:
#	.cursor/meeting/README.md   resolved by yongxu version
#	.gitignore   resolved by yongxu version
#	miniprogram/pages/index/index.js   resolved by yongxu version
#	miniprogram/pages/read/read.js   resolved by yongxu version
#	miniprogram/pages/read/read.wxml   resolved by yongxu version
#	soul-admin/dist/index.html   resolved by yongxu version
#	soul-admin/src/App.tsx   resolved by yongxu version
#	soul-admin/src/components/RichEditor.css   resolved by yongxu version
#	soul-admin/src/components/RichEditor.tsx   resolved by yongxu version
#	soul-admin/src/components/modules/user/UserDetailModal.tsx   resolved by yongxu version
#	soul-admin/src/layouts/AdminLayout.tsx   resolved by yongxu version
#	soul-admin/src/pages/chapters/ChaptersPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/content/ContentPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/dashboard/DashboardPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/FindPartnerPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/FindPartnerTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MentorTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/mentors/MentorsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/settings/SettingsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/users/UsersPage.tsx   resolved by yongxu version
#	soul-admin/tsconfig.tsbuildinfo   resolved by yongxu version
#	soul-api/internal/database/database.go   resolved by yongxu version
#	soul-api/internal/handler/admin_dashboard.go   resolved by yongxu version
#	soul-api/internal/handler/book.go   resolved by yongxu version
#	soul-api/internal/handler/ckb.go   resolved by yongxu version
#	soul-api/internal/handler/db_book.go   resolved by yongxu version
#	soul-api/internal/handler/db_person.go   resolved by yongxu version
#	soul-api/internal/handler/match_records.go   resolved by yongxu version
#	soul-api/internal/handler/user.go   resolved by yongxu version
#	soul-api/internal/model/chapter.go   resolved by yongxu version
#	soul-api/internal/model/person.go   resolved by yongxu version
#	soul-api/internal/router/router.go   resolved by yongxu version
#	开发文档/10、项目管理/运营与变更.md   resolved by yongxu version
#	开发文档/1、需求/需求汇总.md   resolved by yongxu version
#	开发文档/README.md   resolved by yongxu version
This commit is contained in:
Alex-larget
2026-03-10 20:20:59 +08:00
128 changed files with 6057 additions and 2046 deletions

View File

@@ -222,6 +222,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 != "" {
@@ -246,8 +253,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 {
@@ -255,7 +262,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
}
@@ -263,6 +270,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)
@@ -783,9 +796,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,
@@ -810,7 +826,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
@@ -821,15 +837,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)
}
}