Files
soul-yongping/soul-api/internal/handler/referral.go

526 lines
16 KiB
Go
Raw Normal View History

package handler
import (
"encoding/json"
"fmt"
"math"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const defaultBindingDays = 30
// ReferralBind POST /api/referral/bind 推荐码绑定(新绑定/续期/切换)
func ReferralBind(c *gin.Context) {
var req struct {
UserID string `json:"userId"`
ReferralCode string `json:"referralCode" binding:"required"`
OpenID string `json:"openId"`
Source string `json:"source"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
return
}
effectiveUserID := req.UserID
if effectiveUserID == "" && req.OpenID != "" {
effectiveUserID = "user_" + req.OpenID[len(req.OpenID)-8:]
}
if effectiveUserID == "" || req.ReferralCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
return
}
db := database.DB()
bindingDays := defaultBindingDays
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["bindingDays"] != nil {
if v, ok := config["bindingDays"].(float64); ok {
bindingDays = int(v)
}
}
}
var referrer model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
return
}
if referrer.ID == effectiveUserID {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
return
}
var user model.User
if err := db.Where("id = ?", effectiveUserID).First(&user).Error; err != nil {
if req.OpenID != "" {
if err := db.Where("open_id = ?", req.OpenID).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
return
}
} else {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
return
}
}
expiryDate := time.Now().AddDate(0, 0, bindingDays)
var existing model.ReferralBinding
err := db.Where("referee_id = ? AND status = ?", user.ID, "active").Order("binding_date DESC").First(&existing).Error
action := "new"
var oldReferrerID interface{}
if err == nil {
if existing.ReferrerID == referrer.ID {
action = "renew"
db.Model(&existing).Updates(map[string]interface{}{
"expiry_date": expiryDate,
"binding_date": time.Now(),
})
} else {
action = "switch"
oldReferrerID = existing.ReferrerID
db.Model(&existing).Update("status", "cancelled")
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
db.Create(&model.ReferralBinding{
ID: bindID,
ReferrerID: referrer.ID,
RefereeID: user.ID,
ReferralCode: req.ReferralCode,
Status: refString("active"),
ExpiryDate: expiryDate,
BindingDate: time.Now(),
})
}
} else {
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
db.Create(&model.ReferralBinding{
ID: bindID,
ReferrerID: referrer.ID,
RefereeID: user.ID,
ReferralCode: req.ReferralCode,
Status: refString("active"),
ExpiryDate: expiryDate,
BindingDate: time.Now(),
})
db.Model(&model.User{}).Where("id = ?", referrer.ID).UpdateColumn("referral_count", gorm.Expr("COALESCE(referral_count, 0) + 1"))
}
msg := "绑定成功"
if action == "renew" {
msg = "绑定已续期"
} else if action == "switch" {
msg = "推荐人已切换"
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": msg,
"data": gin.H{
"action": action,
"referrer": gin.H{"id": referrer.ID, "nickname": getStringValue(referrer.Nickname)},
"expiryDate": expiryDate,
"bindingDays": bindingDays,
"oldReferrerId": oldReferrerID,
},
})
}
func refString(s string) *string { return &s }
func randomStr(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
}
return string(b)
}
// ReferralData GET /api/referral/data 获取分销数据统计
func ReferralData(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
db := database.DB()
// 获取分销配置(与 soul-admin 推广设置一致)
distributorShare := 0.9
minWithdrawAmount := 10.0
bindingDays := defaultBindingDays
userDiscount := 5
withdrawFee := 5.0
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
if minAmount, ok := config["minWithdrawAmount"].(float64); ok {
minWithdrawAmount = minAmount
}
if days, ok := config["bindingDays"].(float64); ok && days > 0 {
bindingDays = int(days)
}
if discount, ok := config["userDiscount"].(float64); ok {
userDiscount = int(discount)
}
if fee, ok := config["withdrawFee"].(float64); ok {
withdrawFee = fee
}
}
}
// 1. 查询用户基本信息
var user model.User
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
// 2. 绑定统计
var totalBindings int64
db.Model(&model.ReferralBinding{}).Where("referrer_id = ?", userId).Count(&totalBindings)
var activeBindings int64
db.Model(&model.ReferralBinding{}).Where(
"referrer_id = ? AND status = 'active' AND expiry_date > ?",
userId, time.Now(),
).Count(&activeBindings)
var convertedBindings int64
db.Model(&model.ReferralBinding{}).Where(
"referrer_id = ? AND status = 'active' AND purchase_count > 0",
userId,
).Count(&convertedBindings)
var expiredBindings int64
db.Model(&model.ReferralBinding{}).Where(
"referrer_id = ? AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= ?))",
userId, time.Now(),
).Count(&expiredBindings)
// 3. 付款统计
var paidOrders []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
totalAmount := 0.0
totalCommission := 0.0
uniqueUsers := make(map[string]bool)
for i := range paidOrders {
totalAmount += paidOrders[i].Amount
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
uniqueUsers[paidOrders[i].UserID] = true
}
uniquePaidCount := len(uniqueUsers)
// 4. 访问统计
totalVisits := int(totalBindings)
var visitCount int64
if err := db.Model(&model.ReferralVisit{}).
Select("COUNT(DISTINCT visitor_id) as count").
Where("referrer_id = ?", userId).
Count(&visitCount).Error; err == nil {
totalVisits = int(visitCount)
}
// 5. 提现统计(与小程序可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核)
// 待审核 = pending + processing + pending_confirm与 /api/withdraw/pending-confirm 口径一致
var pendingWithdraw struct{ Total float64 }
db.Model(&model.Withdrawal{}).
Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
Scan(&pendingWithdraw)
var successWithdraw struct{ Total float64 }
db.Model(&model.Withdrawal{}).
Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status = ?", userId, "success").
Scan(&successWithdraw)
pendingWithdrawAmount := pendingWithdraw.Total
withdrawnFromTable := successWithdraw.Total
// 6. 获取活跃绑定用户列表
var activeBindingsList []model.ReferralBinding
db.Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()).
Order("binding_date DESC").
Limit(20).
Find(&activeBindingsList)
activeUsers := []gin.H{}
for _, b := range activeBindingsList {
var referee model.User
db.Where("id = ?", b.RefereeID).First(&referee)
daysRemaining := int(time.Until(b.ExpiryDate).Hours() / 24)
if daysRemaining < 0 {
daysRemaining = 0
}
activeUsers = append(activeUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"daysRemaining": daysRemaining,
"hasFullBook": getBoolValue(referee.HasFullBook),
"bindingDate": b.BindingDate,
"status": "active",
})
}
// 7. 获取已转化用户列表
var convertedBindingsList []model.ReferralBinding
db.Where("referrer_id = ? AND status = 'active' AND purchase_count > 0", userId).
Order("last_purchase_date DESC").
Limit(20).
Find(&convertedBindingsList)
convertedUsers := []gin.H{}
for _, b := range convertedBindingsList {
var referee model.User
db.Where("id = ?", b.RefereeID).First(&referee)
commission := 0.0
if b.TotalCommission != nil {
commission = *b.TotalCommission
}
orderAmount := commission / distributorShare
convertedUsers = append(convertedUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"commission": commission,
"orderAmount": orderAmount,
"purchaseCount": getIntValue(b.PurchaseCount),
"conversionDate": b.LastPurchaseDate,
"status": "converted",
})
}
// 8. 获取已过期用户列表
var expiredBindingsList []model.ReferralBinding
db.Where(
"referrer_id = ? AND (status = 'expired' OR (status = 'active' AND expiry_date <= ?))",
userId, time.Now(),
).Order("expiry_date DESC").Limit(20).Find(&expiredBindingsList)
expiredUsers := []gin.H{}
for _, b := range expiredBindingsList {
var referee model.User
db.Where("id = ?", b.RefereeID).First(&referee)
expiredUsers = append(expiredUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"bindingDate": b.BindingDate,
"expiryDate": b.ExpiryDate,
"status": "expired",
})
}
// 9. 获取收益明细
var earningsDetailsList []model.Order
db.Where("referrer_id = ? AND status = 'paid'", userId).
Order("pay_time DESC").
Limit(20).
Find(&earningsDetailsList)
earningsDetails := []gin.H{}
for i := range earningsDetailsList {
e := &earningsDetailsList[i]
var buyer model.User
db.Where("id = ?", e.UserID).First(&buyer)
commission := computeOrderCommission(db, e, nil)
earningsDetails = append(earningsDetails, gin.H{
"id": e.ID,
"orderSn": e.OrderSN,
"amount": e.Amount,
"commission": commission,
"productType": e.ProductType,
"productId": getStringValue(e.ProductID),
"description": getStringValue(e.Description),
"buyerNickname": getStringValue(buyer.Nickname),
"buyerAvatar": getStringValue(buyer.Avatar),
"payTime": e.PayTime,
})
}
// 计算收益totalCommission 已按订单逐条计算)
estimatedEarnings := totalCommission
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
if availableEarnings < 0 {
availableEarnings = 0
}
// 计算即将过期用户数7天内
sevenDaysLater := time.Now().Add(7 * 24 * time.Hour)
expiringCount := 0
for _, b := range activeBindingsList {
if b.ExpiryDate.After(time.Now()) && b.ExpiryDate.Before(sevenDaysLater) {
expiringCount++
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
// 核心可见数据
"bindingCount": activeBindings,
"visitCount": totalVisits,
"paidCount": uniquePaidCount,
"expiredCount": expiredBindings,
// 收益数据
"totalCommission": round(totalCommission, 2),
"availableEarnings": round(availableEarnings, 2),
"pendingWithdrawAmount": round(pendingWithdrawAmount, 2),
"withdrawnEarnings": withdrawnFromTable,
"earnings": getFloatValue(user.Earnings),
"pendingEarnings": getFloatValue(user.PendingEarnings),
"estimatedEarnings": round(estimatedEarnings, 2),
"shareRate": int(distributorShare * 100),
"minWithdrawAmount": minWithdrawAmount,
"bindingDays": bindingDays,
"userDiscount": userDiscount,
"withdrawFee": withdrawFee,
// 推荐码
"referralCode": getStringValue(user.ReferralCode),
"referralCount": getIntValue(user.ReferralCount),
// 详细统计
"stats": gin.H{
"totalBindings": totalBindings,
"activeBindings": activeBindings,
"convertedBindings": convertedBindings,
"expiredBindings": expiredBindings,
"expiringCount": expiringCount,
"totalPaymentAmount": totalAmount,
},
// 用户列表
"activeUsers": activeUsers,
"convertedUsers": convertedUsers,
"expiredUsers": expiredUsers,
// 收益明细
"earningsDetails": earningsDetails,
},
})
}
// round 四舍五入保留小数
func round(val float64, precision int) float64 {
ratio := math.Pow(10, float64(precision))
return math.Round(val*ratio) / ratio
}
// MyEarnings GET /api/miniprogram/earnings 仅返回「我的收益」卡片所需数据(累计、可提现、推荐人数),用于我的页展示与刷新
func MyEarnings(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
db := database.DB()
var user model.User
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
var paidOrders []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
totalCommission := 0.0
for i := range paidOrders {
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
}
var pendingWithdraw struct{ Total float64 }
db.Model(&model.Withdrawal{}).
Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
Scan(&pendingWithdraw)
var successWithdraw struct{ Total float64 }
db.Model(&model.Withdrawal{}).
Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status = ?", userId, "success").
Scan(&successWithdraw)
pendingWithdrawAmount := pendingWithdraw.Total
withdrawnFromTable := successWithdraw.Total
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
if availableEarnings < 0 {
availableEarnings = 0
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"totalCommission": round(totalCommission, 2),
"availableEarnings": round(availableEarnings, 2),
"referralCount": getIntValue(user.ReferralCount),
},
})
}
// ReferralVisit POST /api/referral/visit 记录推荐访问(不需登录)
func ReferralVisit(c *gin.Context) {
var req struct {
ReferralCode string `json:"referralCode" binding:"required"`
VisitorOpenID string `json:"visitorOpenId"`
VisitorID string `json:"visitorId"`
Source string `json:"source"`
Page string `json:"page"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码不能为空"})
return
}
db := database.DB()
var referrer model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
return
}
source := req.Source
if source == "" {
source = "miniprogram"
}
visitorID := req.VisitorID
if visitorID == "" {
visitorID = ""
}
vOpenID := req.VisitorOpenID
vPage := req.Page
err := db.Create(&model.ReferralVisit{
ReferrerID: referrer.ID,
VisitorID: strPtrOrNil(visitorID),
VisitorOpenID: strPtrOrNil(vOpenID),
Source: strPtrOrNil(source),
Page: strPtrOrNil(vPage),
}).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已处理"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "访问已记录"})
}
func strPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}