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:
@@ -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.1:is_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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user