package handler import ( "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项有值) } // calcRFMScoreForUser 计算单个用户的 RFM+ 综合分(0-100) // 六维度:R(25%) + F(20%) + M(20%) + 推荐(15%) + 行为轨迹(10%) + 资料完善(10%) 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 { 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*0.25 + fScore*0.20 + mScore*0.20 + refScore*0.15 + trackScore*0.10 + profileScore*0.10 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 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) 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}) }