Files
soul-yongping/soul-api/internal/handler/admin_rfm.go

307 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&reg)
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})
}