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" } } // calcRFMScore 计算单个用户的 RFM 综合分(0-100) // R: 距今天数 → 越少越高;F: 频次;M: 总金额 // 简化打分:R(40%) + F(30%) + M(30%),各维度用全量用户相对排位归一化 func calcRFMScoreForUser(recencyDays int, frequency int, monetary float64, maxRecency, maxFreq int, maxMonetary float64) float64 { var rScore, fScore, mScore float64 // R 分:天数越少越高,取反归一化 if maxRecency > 0 { rScore = (1 - float64(recencyDays)/float64(maxRecency)) * 100 } // F 分:频次越高越好 if maxFreq > 0 { fScore = float64(frequency) / float64(maxFreq) * 100 } // M 分:金额越高越好 if maxMonetary > 0 { mScore = monetary / maxMonetary * 100 } total := rScore*0.4 + fScore*0.3 + mScore*0.3 // 四舍五入到整数 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) } // 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 } // 5. 组装结果 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) score := calcRFMScoreForUser(recencyDays, agg.OrderCount, agg.TotalAmount, maxRecency, maxFreq, maxMonetary) 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 = *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}) }