Files
Mycontent/soul-api/internal/handler/admin_rfm.go
2026-04-13 14:32:32 +08:00

554 lines
16 KiB
Go
Raw Permalink 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 (
"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(&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
// 打赏/上麦礼遇购买人数
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(&reg7)
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})
}