sync: soul-api 接口逻辑 | 原因: 后端接口逻辑修改
This commit is contained in:
271
soul-api/internal/handler/admin_rfm.go
Normal file
271
soul-api/internal/handler/admin_rfm.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"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) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
Reference in New Issue
Block a user