From c28798a5e511a6a955be7a7d7d38a8d4370a915b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A1=E8=8B=A5?= Date: Sun, 8 Mar 2026 08:25:39 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20soul-api=20=E6=8E=A5=E5=8F=A3=E9=80=BB?= =?UTF-8?q?=E8=BE=91=20|=20=E5=8E=9F=E5=9B=A0:=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=80=BB=E8=BE=91=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- soul-api/internal/handler/admin_rfm.go | 271 +++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 soul-api/internal/handler/admin_rfm.go diff --git a/soul-api/internal/handler/admin_rfm.go b/soul-api/internal/handler/admin_rfm.go new file mode 100644 index 00000000..297e168e --- /dev/null +++ b/soul-api/internal/handler/admin_rfm.go @@ -0,0 +1,271 @@ +package handler + +import ( + "math" + "net/http" + "sort" + "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 := parseInt(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, + }, + }) +} + +// parseInt 辅助函数 +func parseInt(s string) (int, error) { + var n int + _, err := fmt.Sscanf(s, "%d", &n) + return n, err +}