Files
soul-yongping/soul-api/internal/handler/admin_rfm.go
卡若 5724fba877 feat: 小程序超级个体/个人资料/CKB获客;VIP列表展示过滤;管理端与API联调
- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go)
- 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken)
- 阅读页分享朋友圈复制与 toast 去重
- soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整
- 脚本:content_upload、miniprogram 上传辅助等

Made-with: Cursor
2026-03-22 08:34:28 +08:00

447 lines
12 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"
}
}
// 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(&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})
}
// 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})
}