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() // 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 { 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 } 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 } } // 以解析后的用户主键为准(effectiveUserId 可能与真实 user.id 不一致,曾导致可绑到自己) if referrer.ID == user.ID { 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": resolveAvatarURL(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": resolveAvatarURL(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": resolveAvatarURL(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": resolveAvatarURL(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 } var activeReferralCount int64 db.Model(&model.ReferralBinding{}). Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()). Count(&activeReferralCount) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "totalCommission": round(totalCommission, 2), "availableEarnings": round(availableEarnings, 2), "referralCount": activeReferralCount, }, }) } // EarningsLeaderboard GET /api/miniprogram/earnings/leaderboard // 首页分享排行榜(Top 5,后台可关闭) // 推广能力分算法:三维度(推广收入、推广数量、活跃热度)各归一化到 [1,10] 后相乘, // EarningsLeaderboard GET /api/miniprogram/earnings/leaderboard // 首页分享排行榜(Top 5,后台可关闭) // 推广能力分算法:三维度(推广收入、推广数量、活跃热度)各归一化到 [1,10] 后相乘, // 结果映射到 5000~20000 区间。 func EarningsLeaderboard(c *gin.Context) { db := database.DB() var cfg model.SystemConfig if err := db.Where("config_key = ?", "feature_config").First(&cfg).Error; err == nil { var m map[string]interface{} if json.Unmarshal(cfg.ConfigValue, &m) == nil { enabled := true if v, ok := m["shareLeaderboardEnabled"].(bool); ok { enabled = v } else if v, ok := m["earningsLeaderboardEnabled"].(bool); ok { enabled = v } if !enabled { c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "enabled": false}) return } } } result := computeShareLeaderboard(db) c.JSON(http.StatusOK, gin.H{"success": true, "data": result, "enabled": true}) } // AdminLeaderboard GET /api/admin/leaderboard 管理端分享排行榜(不检查开关) func AdminLeaderboard(c *gin.Context) { db := database.DB() result := computeShareLeaderboard(db) c.JSON(http.StatusOK, gin.H{"success": true, "data": result}) } // computeShareLeaderboard 三维度相乘推广能力分算法,返回 Top 5 func computeShareLeaderboard(db *gorm.DB) []gin.H { type dimRow struct { UserID string Val float64 Cnt int64 } commissionMap := map[string]float64{} orderCountMap := map[string]int64{} var paidRows []dimRow db.Raw(` SELECT referrer_id AS user_id, COALESCE(SUM(amount * 0.3), 0) AS val, COUNT(*) AS cnt FROM orders WHERE status = 'paid' AND referrer_id != '' GROUP BY referrer_id `).Scan(&paidRows) for _, r := range paidRows { commissionMap[r.UserID] = r.Val orderCountMap[r.UserID] = r.Cnt } bindingMap := map[string]int64{} var bindRows []dimRow db.Raw(` SELECT referrer_id AS user_id, 0 AS val, COUNT(*) AS cnt FROM referral_bindings WHERE status = 'active' AND expiry_date > NOW() GROUP BY referrer_id `).Scan(&bindRows) for _, r := range bindRows { bindingMap[r.UserID] = r.Cnt } visitMap := map[string]int64{} var visitRows []dimRow db.Raw(` SELECT referrer_id AS user_id, 0 AS val, COUNT(*) AS cnt FROM referral_visits WHERE referrer_id != '' GROUP BY referrer_id `).Scan(&visitRows) for _, r := range visitRows { visitMap[r.UserID] = r.Cnt } trackMap := map[string]int64{} var trackRows []dimRow db.Raw(` SELECT user_id, 0 AS val, COUNT(*) AS cnt FROM user_tracks WHERE user_id != '' GROUP BY user_id `).Scan(&trackRows) for _, r := range trackRows { trackMap[r.UserID] = r.Cnt } userSet := map[string]struct{}{} for uid := range commissionMap { userSet[uid] = struct{}{} } for uid := range bindingMap { userSet[uid] = struct{}{} } for uid := range visitMap { userSet[uid] = struct{}{} } if len(userSet) == 0 { return []gin.H{} } type userDims struct { UserID string D1 float64 D2 float64 D3 float64 Prod float64 } var maxD1, maxD2, maxD3 float64 dims := make([]userDims, 0, len(userSet)) for uid := range userSet { d1 := commissionMap[uid] d2 := float64(bindingMap[uid] + orderCountMap[uid]) d3 := float64(visitMap[uid] + trackMap[uid]) if d1 > maxD1 { maxD1 = d1 } if d2 > maxD2 { maxD2 = d2 } if d3 > maxD3 { maxD3 = d3 } dims = append(dims, userDims{UserID: uid, D1: d1, D2: d2, D3: d3}) } var maxProd float64 for i := range dims { nd1 := 1.0 if maxD1 > 0 { nd1 = 1 + (dims[i].D1/maxD1)*9 } nd2 := 1.0 if maxD2 > 0 { nd2 = 1 + (dims[i].D2/maxD2)*9 } nd3 := 1.0 if maxD3 > 0 { nd3 = 1 + (dims[i].D3/maxD3)*9 } dims[i].Prod = nd1 * nd2 * nd3 if dims[i].Prod > maxProd { maxProd = dims[i].Prod } } filtered := make([]userDims, 0, len(dims)) for _, d := range dims { if d.Prod > 1.01 { filtered = append(filtered, d) } } if len(filtered) == 0 { return []gin.H{} } for i := 0; i < len(filtered)-1; i++ { for j := i + 1; j < len(filtered); j++ { if filtered[j].Prod > filtered[i].Prod { filtered[i], filtered[j] = filtered[j], filtered[i] } } } if len(filtered) > 5 { filtered = filtered[:5] } type userLite struct { ID string Nickname string Avatar string } userIDs := make([]string, len(filtered)) for i, d := range filtered { userIDs[i] = d.UserID } var users []userLite db.Model(&model.User{}).Select("id", "nickname", "avatar").Where("id IN ?", userIDs).Find(&users) userMap := map[string]userLite{} for _, u := range users { userMap[u.ID] = u } result := make([]gin.H, 0, len(filtered)) for i, d := range filtered { ability := 5000 if maxProd > 1 { ability = 5000 + int(math.Round((d.Prod-1)/(maxProd-1)*15000)) } if ability > 20000 { ability = 20000 } if ability < 5000 { ability = 5000 } u := userMap[d.UserID] name := u.Nickname if name == "" || name == "微信用户" { name = "分享达人" } avatar := u.Avatar if avatar != "" { avatar = resolveAvatarURL(avatar) } tag := "活跃推手" if i == 0 { tag = "推广之星" } else if i <= 2 { tag = "推广达人" } result = append(result, gin.H{ "rank": i + 1, "nickname": name, "avatar": avatar, "abilityValue": ability, "heatValue": ability, "tag": tag, }) } return result } // 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 }