271 lines
6.7 KiB
Go
271 lines
6.7 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"
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
return strconv.Atoi(strings.TrimSpace(s))
|
||
}
|