package handler import ( "encoding/json" "math" "net/http" "sort" "strconv" "strings" "time" "soul-api/internal/database" "soul-api/internal/model" "github.com/gin-gonic/gin" ) // rfmUser 计算后的 RFM 用户数据 type rfmUser struct { ID string `json:"id"` Nickname string `json:"nickname"` Phone string `json:"phone,omitempty"` Avatar string `json:"avatar,omitempty"` RFMScore float64 `json:"rfmScore"` RFMLevel string `json:"rfmLevel"` Recency int `json:"recency"` // 距最近订单天数 Frequency int `json:"frequency"` // 订单数 Monetary float64 `json:"monetary"` // 总消费 LastOrderAt *string `json:"lastOrderAt,omitempty"` } // rfmOrder 从 orders 表读取的简化结构 type rfmOrder struct { UserID string Amount float64 CreatedAt time.Time } func calcRFMLevel(score float64) string { switch { case score >= 85: return "S" case score >= 70: return "A" case score >= 50: return "B" case score >= 30: return "C" default: return "D" } } // rfmExtras 额外维度,用于增强 RFM 综合打分 type rfmExtras struct { ReferralCount int // 推荐人数 TrackCount int // 行为轨迹条数 ProfileComplete bool // 资料是否完善(mbti/industry/phone 至少2项有值) } // rfmWeights 六维度权重,总和应为 1.0;从 system_config.rfm_weights 动态读取 type rfmWeights struct { Recency float64 `json:"recency"` Freq float64 `json:"frequency"` Monetary float64 `json:"monetary"` Referral float64 `json:"referral"` Track float64 `json:"track"` Profile float64 `json:"profile"` } var defaultRFMWeights = rfmWeights{ Recency: 0.25, Freq: 0.20, Monetary: 0.20, Referral: 0.15, Track: 0.10, Profile: 0.10, } func loadRFMWeights() rfmWeights { db := database.DB() var row model.SystemConfig if err := db.Where("config_key = ?", "rfm_weights").First(&row).Error; err != nil || len(row.ConfigValue) == 0 { return defaultRFMWeights } var w rfmWeights if err := json.Unmarshal(row.ConfigValue, &w); err != nil { return defaultRFMWeights } sum := w.Recency + w.Freq + w.Monetary + w.Referral + w.Track + w.Profile if sum <= 0 { return defaultRFMWeights } return w } // AdminRFMWeightsGet GET /api/admin/rfm-weights 返回当前 RFM 权重 func AdminRFMWeightsGet(c *gin.Context) { w := loadRFMWeights() c.JSON(http.StatusOK, gin.H{"success": true, "weights": w}) } // AdminRFMWeightsPut PUT /api/admin/rfm-weights 保存 RFM 权重 func AdminRFMWeightsPut(c *gin.Context) { var w rfmWeights if err := c.ShouldBindJSON(&w); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"}) return } sum := w.Recency + w.Freq + w.Monetary + w.Referral + w.Track + w.Profile if sum <= 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "权重总和须大于 0"}) return } valBytes, _ := json.Marshal(w) db := database.DB() desc := "RFM 六维度权重配置" var row model.SystemConfig if err := db.Where("config_key = ?", "rfm_weights").First(&row).Error; err != nil { row = model.SystemConfig{ConfigKey: "rfm_weights", ConfigValue: valBytes, Description: &desc} db.Create(&row) } else { row.ConfigValue = valBytes db.Save(&row) } c.JSON(http.StatusOK, gin.H{"success": true, "weights": w}) } // calcRFMScoreForUser 计算单个用户的 RFM+ 综合分(0-100) func calcRFMScoreForUser(recencyDays int, frequency int, monetary float64, maxRecency, maxFreq int, maxMonetary float64) float64 { return calcRFMScoreForUserExt(recencyDays, frequency, monetary, maxRecency, maxFreq, maxMonetary, rfmExtras{}, 0, 0) } func calcRFMScoreForUserExt(recencyDays int, frequency int, monetary float64, maxRecency, maxFreq int, maxMonetary float64, ext rfmExtras, maxReferral, maxTrack int) float64 { w := loadRFMWeights() var rScore, fScore, mScore float64 if maxRecency > 0 { rScore = (1 - float64(recencyDays)/float64(maxRecency)) * 100 } if maxFreq > 0 { fScore = float64(frequency) / float64(maxFreq) * 100 } if maxMonetary > 0 { mScore = monetary / maxMonetary * 100 } var refScore, trackScore, profileScore float64 if maxReferral > 0 { refScore = math.Min(float64(ext.ReferralCount)/float64(maxReferral)*100, 100) } if maxTrack > 0 { trackScore = math.Min(float64(ext.TrackCount)/float64(maxTrack)*100, 100) } if ext.ProfileComplete { profileScore = 100 } total := rScore*w.Recency + fScore*w.Freq + mScore*w.Monetary + refScore*w.Referral + trackScore*w.Track + profileScore*w.Profile return math.Round(total) } // DBUsersRFM GET /api/db/users/rfm — 全量 RFM 排行 func DBUsersRFM(c *gin.Context) { db := database.DB() search := strings.TrimSpace(c.Query("search")) limitStr := c.DefaultQuery("limit", "100") // 1. 聚合每个用户的订单数据 type orderAgg struct { UserID string OrderCount int TotalAmount float64 LastOrderAt time.Time } var aggs []orderAgg if err := db.Raw(` SELECT user_id, COUNT(*) as order_count, SUM(amount) as total_amount, MAX(created_at) as last_order_at FROM orders WHERE status IN ('paid','success','completed') GROUP BY user_id `).Scan(&aggs).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } if len(aggs) == 0 { c.JSON(http.StatusOK, gin.H{"success": true, "users": []rfmUser{}}) return } // 2. 计算各维度最大值(用于归一化) now := time.Now() var maxRecency int var maxFreq int var maxMonetary float64 for _, a := range aggs { days := int(now.Sub(a.LastOrderAt).Hours() / 24) if days > maxRecency { maxRecency = days } if a.OrderCount > maxFreq { maxFreq = a.OrderCount } if a.TotalAmount > maxMonetary { maxMonetary = a.TotalAmount } } // 3. 计算每用户得分 aggMap := make(map[string]orderAgg, len(aggs)) for _, a := range aggs { aggMap[a.UserID] = a } userIDs := make([]string, 0, len(aggs)) for _, a := range aggs { userIDs = append(userIDs, a.UserID) } // 3.1 聚合行为轨迹数量(每用户) type trackAgg struct { UserID string TrackCount int } var trackAggs []trackAgg _ = db.Raw(`SELECT user_id, COUNT(*) as track_count FROM user_tracks WHERE user_id IN ? GROUP BY user_id`, userIDs).Scan(&trackAggs) trackMap := make(map[string]int, len(trackAggs)) var maxTrack int for _, t := range trackAggs { trackMap[t.UserID] = t.TrackCount if t.TrackCount > maxTrack { maxTrack = t.TrackCount } } // 4. 拉取用户基础信息 var users []model.User q := db.Where("id IN ?", userIDs) if search != "" { q = q.Where("nickname LIKE ? OR phone LIKE ?", "%"+search+"%", "%"+search+"%") } if err := q.Find(&users).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } // 4.1 计算推荐人数最大值 var maxReferral int for _, u := range users { rc := 0 if u.ReferralCount != nil { rc = *u.ReferralCount } if rc > maxReferral { maxReferral = rc } } // 5. 组装结果(使用增强版 RFM+ 六维度打分) result := make([]rfmUser, 0, len(users)) for _, u := range users { agg, ok := aggMap[u.ID] if !ok { continue } recencyDays := int(now.Sub(agg.LastOrderAt).Hours() / 24) refCount := 0 if u.ReferralCount != nil { refCount = *u.ReferralCount } profileOK := false filledCount := 0 if u.Phone != nil && *u.Phone != "" { filledCount++ } if u.Mbti != nil && *u.Mbti != "" { filledCount++ } if u.Industry != nil && *u.Industry != "" { filledCount++ } profileOK = filledCount >= 2 ext := rfmExtras{ ReferralCount: refCount, TrackCount: trackMap[u.ID], ProfileComplete: profileOK, } score := calcRFMScoreForUserExt(recencyDays, agg.OrderCount, agg.TotalAmount, maxRecency, maxFreq, maxMonetary, ext, maxReferral, maxTrack) lastAt := agg.LastOrderAt.Format(time.RFC3339) ru := rfmUser{ ID: u.ID, RFMScore: score, RFMLevel: calcRFMLevel(score), Recency: recencyDays, Frequency: agg.OrderCount, Monetary: agg.TotalAmount, LastOrderAt: &lastAt, } if u.Nickname != nil { ru.Nickname = *u.Nickname } if u.Phone != nil { ru.Phone = *u.Phone } if u.Avatar != nil { ru.Avatar = resolveAvatarURL(*u.Avatar) } result = append(result, ru) } // 6. 按 RFM 分降序排序 sort.Slice(result, func(i, j int) bool { return result[i].RFMScore > result[j].RFMScore }) // 7. 截取数量 limit := 100 if limitStr != "" { if n, err := strconv.Atoi(strings.TrimSpace(limitStr)); err == nil && n > 0 { limit = n } } if limit > len(result) { limit = len(result) } result = result[:limit] c.JSON(http.StatusOK, gin.H{"success": true, "users": result, "total": len(result)}) } // DBUserRFMSingle GET /api/db/users/rfm-single?userId=xxx — 单用户 RFM func DBUserRFMSingle(c *gin.Context) { db := database.DB() userID := strings.TrimSpace(c.Query("userId")) if userID == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"}) return } type orderAgg struct { OrderCount int TotalAmount float64 LastOrderAt *time.Time } var agg orderAgg if err := db.Raw(` SELECT COUNT(*) as order_count, SUM(amount) as total_amount, MAX(created_at) as last_order_at FROM orders WHERE user_id = ? AND status IN ('paid','success','completed') `, userID).Scan(&agg).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } if agg.OrderCount == 0 { c.JSON(http.StatusOK, gin.H{"success": true, "rfm": nil}) return } // 获取全量最大值用于打分(简化:用固定上限或查全表最大值) type maxVals struct { MaxFreq int MaxMonetary float64 MaxRecency int } var mv maxVals _ = db.Raw(` SELECT MAX(cnt) as max_freq, MAX(total) as max_monetary, MAX(DATEDIFF(NOW(), last_at)) as max_recency FROM ( SELECT COUNT(*) as cnt, SUM(amount) as total, MAX(created_at) as last_at FROM orders WHERE status IN ('paid','success','completed') GROUP BY user_id ) t `).Scan(&mv) now := time.Now() var recencyDays int var lastAtStr *string if agg.LastOrderAt != nil { recencyDays = int(now.Sub(*agg.LastOrderAt).Hours() / 24) s := agg.LastOrderAt.Format(time.RFC3339) lastAtStr = &s } score := calcRFMScoreForUser(recencyDays, agg.OrderCount, agg.TotalAmount, mv.MaxRecency, mv.MaxFreq, mv.MaxMonetary) c.JSON(http.StatusOK, gin.H{ "success": true, "rfm": gin.H{ "rfmScore": score, "rfmLevel": calcRFMLevel(score), "recency": recencyDays, "frequency": agg.OrderCount, "monetary": agg.TotalAmount, "lastOrderAt": lastAtStr, }, }) } // DBUsersJourneyStats GET /api/db/users/journey-stats — 各旅程阶段人数 func DBUsersJourneyStats(c *gin.Context) { db := database.DB() stats := make(map[string]int64) var reg int64 db.Table("users").Count(®) stats["register"] = reg var browse int64 db.Table("user_tracks").Where("action = ?", "view_chapter").Distinct("user_id").Count(&browse) stats["browse"] = browse var bindPhone int64 db.Table("users").Where("phone IS NOT NULL AND phone != ''").Count(&bindPhone) stats["bind_phone"] = bindPhone var firstPay int64 db.Table("orders").Where("status IN ?", []string{"paid", "success", "completed"}).Distinct("user_id").Count(&firstPay) stats["first_pay"] = firstPay var fillProfile int64 db.Table("users").Where("mbti IS NOT NULL OR industry IS NOT NULL").Count(&fillProfile) stats["fill_profile"] = fillProfile var match int64 db.Table("user_tracks").Where("action = ?", "match").Distinct("user_id").Count(&match) stats["match"] = match var vip int64 db.Table("users").Where("is_vip = 1").Count(&vip) stats["vip"] = vip var dist int64 db.Table("users").Where("referral_code IS NOT NULL AND referral_code != '' AND earnings > 0").Count(&dist) stats["distribution"] = dist // 打赏/上麦礼遇购买人数 var tipPay int64 db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "link_karuo_tip").Distinct("user_id").Count(&tipPay) stats["tip_pay"] = tipPay // 余额充值购买人数 var recharge int64 db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "balance_recharge").Distinct("user_id").Count(&recharge) stats["balance_recharge"] = recharge // 匹配次数购买人数 var matchPay int64 db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "match").Distinct("user_id").Count(&matchPay) stats["match_pay"] = matchPay // 流量池:时间窗内活跃(有任意轨迹的去重用户) now := time.Now() since7 := now.AddDate(0, 0, -7) since30 := now.AddDate(0, 0, -30) var active7, active30 int64 _ = db.Raw(`SELECT COUNT(DISTINCT user_id) FROM user_tracks WHERE created_at >= ?`, since7).Scan(&active7).Error _ = db.Raw(`SELECT COUNT(DISTINCT user_id) FROM user_tracks WHERE created_at >= ?`, since30).Scan(&active30).Error stats["active_7d"] = active7 stats["active_30d"] = active30 var reg7 int64 db.Table("users").Where("created_at >= ?", since7).Count(®7) stats["register_7d"] = reg7 c.JSON(http.StatusOK, gin.H{"success": true, "stats": stats}) } // journeyUserItem 用户旅程列表项 type journeyUserItem struct { ID string `json:"id"` Nickname string `json:"nickname"` Phone string `json:"phone"` CreatedAt string `json:"createdAt"` } // DBUsersJourneyUsers GET /api/db/users/journey-users?stage=xxx&limit=20 — 按阶段查用户 func DBUsersJourneyUsers(c *gin.Context) { db := database.DB() stage := strings.TrimSpace(c.Query("stage")) limitStr := c.DefaultQuery("limit", "20") limit := 20 if n, err := strconv.Atoi(limitStr); err == nil && n > 0 { limit = n } if limit > 100 { limit = 100 } var users []model.User switch stage { case "register": db.Order("created_at DESC").Limit(limit).Find(&users) case "browse": subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "view_chapter").Distinct("user_id") db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users) case "bind_phone": db.Where("phone IS NOT NULL AND phone != ''").Order("created_at DESC").Limit(limit).Find(&users) case "first_pay": db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id"). Where("status IN ?", []string{"paid", "success", "completed"})). Order("created_at DESC").Limit(limit).Find(&users) case "fill_profile": db.Where("mbti IS NOT NULL OR industry IS NOT NULL").Order("created_at DESC").Limit(limit).Find(&users) case "match": subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "match").Distinct("user_id") db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users) case "vip": db.Where("is_vip = ?", true).Order("created_at DESC").Limit(limit).Find(&users) case "distribution": db.Where("referral_code IS NOT NULL AND referral_code != ''").Where("COALESCE(earnings, 0) > ?", 0). Order("created_at DESC").Limit(limit).Find(&users) case "tip_pay": db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id"). Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "link_karuo_tip")). Order("created_at DESC").Limit(limit).Find(&users) case "balance_recharge": db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id"). Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "balance_recharge")). Order("created_at DESC").Limit(limit).Find(&users) case "match_pay": db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id"). Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "match")). Order("created_at DESC").Limit(limit).Find(&users) default: c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的 stage 参数"}) return } list := make([]journeyUserItem, 0, len(users)) for _, u := range users { nick, phone := "", "" if u.Nickname != nil { nick = *u.Nickname } if u.Phone != nil { phone = *u.Phone } list = append(list, journeyUserItem{ ID: u.ID, Nickname: nick, Phone: phone, CreatedAt: u.CreatedAt.Format(time.RFC3339), }) } c.JSON(http.StatusOK, gin.H{"success": true, "users": list}) }