- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go) - 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken) - 阅读页分享朋友圈复制与 toast 去重 - soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整 - 脚本:content_upload、miniprogram 上传辅助等 Made-with: Cursor
447 lines
12 KiB
Go
447 lines
12 KiB
Go
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(®)
|
||
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})
|
||
}
|