feat: MBTI头像与用户规则链路升级,三端页面与接口同步
Made-with: Cursor
This commit is contained in:
@@ -17,6 +17,36 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// parseConfigBool 将 JSON/map 中可能出现的 bool、字符串、数字归一为开关态(auditMode 等)
|
||||
func parseConfigBool(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(t))
|
||||
return s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
case float64:
|
||||
return t != 0
|
||||
case int:
|
||||
return t != 0
|
||||
case int64:
|
||||
return t != 0
|
||||
case json.Number:
|
||||
if i, err := t.Int64(); err == nil {
|
||||
return i != 0
|
||||
}
|
||||
if f, err := t.Float64(); err == nil {
|
||||
return f != 0
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// defaultMpUi 小程序文案与导航默认值,存于 mp_config.mpUi;管理端系统设置可部分覆盖(深合并)
|
||||
func defaultMpUi() gin.H {
|
||||
return gin.H{
|
||||
@@ -29,7 +59,8 @@ func defaultMpUi() gin.H {
|
||||
},
|
||||
"homePage": gin.H{
|
||||
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
|
||||
"linkKaruoText": "点击链接卡若", "searchPlaceholder": "搜索章节标题或内容...",
|
||||
"linkKaruoText": "点击链接卡若", "linkKaruoAvatar": "",
|
||||
"searchPlaceholder": "搜索章节标题或内容...",
|
||||
"bannerTag": "推荐", "bannerReadMoreText": "点击阅读",
|
||||
"superSectionTitle": "超级个体", "superSectionLinkText": "获客入口",
|
||||
"superSectionLinkPath": "/pages/match/match",
|
||||
@@ -224,13 +255,9 @@ func buildMiniprogramConfig() gin.H {
|
||||
// 未找到配置或查询失败,使用空数组作为默认值
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
// 明确归一化 auditMode:仅当 DB 显式为 true 时返回 true,否则一律 false(避免历史脏数据/类型异常导致误判)
|
||||
// 归一化 auditMode(兼容历史 bool / 字符串 / 数字)
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
mp["auditMode"] = true
|
||||
} else {
|
||||
mp["auditMode"] = false
|
||||
}
|
||||
mp["auditMode"] = parseConfigBool(mp["auditMode"])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -275,10 +302,7 @@ func getAuditModeFromDB() bool {
|
||||
if err := json.Unmarshal(row.ConfigValue, &mp); err != nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return parseConfigBool(mp["auditMode"])
|
||||
}
|
||||
|
||||
// GetCoreConfig GET /api/miniprogram/config/core 核心配置(prices、features、userDiscount、mpConfig),首屏/Tab 用
|
||||
@@ -371,9 +395,7 @@ func WarmConfigCache() {
|
||||
// 拆分接口预热
|
||||
auditMode := false
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
auditMode = true
|
||||
}
|
||||
auditMode = parseConfigBool(mp["auditMode"])
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigAuditMode, gin.H{"auditMode": auditMode}, cache.AuditModeTTL)
|
||||
core := gin.H{
|
||||
@@ -880,7 +902,9 @@ func DBUsersList(c *gin.Context) {
|
||||
pattern := "%" + search + "%"
|
||||
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
if vipFilter == "true" || vipFilter == "1" {
|
||||
if poolFilter == "complete" {
|
||||
query = query.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
|
||||
} else if vipFilter == "true" || vipFilter == "1" {
|
||||
query = query.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)",
|
||||
[]string{"fullbook", "vip"}, "paid", "completed", time.Now())
|
||||
}
|
||||
@@ -915,7 +939,7 @@ func DBUsersList(c *gin.Context) {
|
||||
var fullbookRows []struct {
|
||||
UserID string
|
||||
}
|
||||
db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status = ?", []string{"fullbook", "vip"}, "paid").Find(&fullbookRows)
|
||||
db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status IN ?", []string{"fullbook", "vip"}, []string{"paid", "completed", "success"}).Find(&fullbookRows)
|
||||
for _, r := range fullbookRows {
|
||||
hasFullBookMap[r.UserID] = true
|
||||
}
|
||||
@@ -924,7 +948,7 @@ func DBUsersList(c *gin.Context) {
|
||||
Count int64
|
||||
}
|
||||
db.Model(&model.Order{}).Select("user_id, COUNT(*) as count").
|
||||
Where("product_type = ? AND status = ?", "section", "paid").
|
||||
Where("product_type = ? AND status IN ?", "section", []string{"paid", "completed", "success"}).
|
||||
Group("user_id").Find(§ionRows)
|
||||
for _, r := range sectionRows {
|
||||
sectionCountMap[r.UserID] = int(r.Count)
|
||||
@@ -987,6 +1011,35 @@ func DBUsersList(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. RFM 实时打分:对当前页用户批量计算(只查当前页 userIDs 的聚合)
|
||||
type rfmAgg struct {
|
||||
UserID string
|
||||
OrderCount int
|
||||
TotalAmount float64
|
||||
LastOrderAt time.Time
|
||||
}
|
||||
var rfmAggs []rfmAgg
|
||||
db.Raw(`SELECT user_id, COUNT(*) as order_count, SUM(amount) as total_amount, MAX(created_at) as last_order_at
|
||||
FROM orders WHERE user_id IN ? AND status IN ('paid','success','completed')
|
||||
GROUP BY user_id`, userIDs).Scan(&rfmAggs)
|
||||
rfmAggMap := make(map[string]rfmAgg, len(rfmAggs))
|
||||
var rfmMaxRecency, rfmMaxFreq int
|
||||
var rfmMaxMonetary float64
|
||||
now := time.Now()
|
||||
for _, a := range rfmAggs {
|
||||
rfmAggMap[a.UserID] = a
|
||||
days := int(now.Sub(a.LastOrderAt).Hours() / 24)
|
||||
if days > rfmMaxRecency {
|
||||
rfmMaxRecency = days
|
||||
}
|
||||
if a.OrderCount > rfmMaxFreq {
|
||||
rfmMaxFreq = a.OrderCount
|
||||
}
|
||||
if a.TotalAmount > rfmMaxMonetary {
|
||||
rfmMaxMonetary = a.TotalAmount
|
||||
}
|
||||
}
|
||||
|
||||
// 填充每个用户的实时计算字段
|
||||
for i := range users {
|
||||
uid := users[i].ID
|
||||
@@ -1019,6 +1072,16 @@ func DBUsersList(c *gin.Context) {
|
||||
bindCount = dbCount
|
||||
}
|
||||
users[i].ReferralCount = ptrInt(bindCount)
|
||||
|
||||
// RFM 打分(有订单的用户才有分数)
|
||||
if agg, ok := rfmAggMap[uid]; ok {
|
||||
recencyDays := int(now.Sub(agg.LastOrderAt).Hours() / 24)
|
||||
score := calcRFMScoreForUser(recencyDays, agg.OrderCount, agg.TotalAmount,
|
||||
rfmMaxRecency, rfmMaxFreq, rfmMaxMonetary)
|
||||
level := calcRFMLevel(score)
|
||||
users[i].RFMScore = ptrFloat64(score)
|
||||
users[i].RFMLevel = &level
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -1238,6 +1301,106 @@ func DBUsersReferrals(c *gin.Context) {
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 入站来源链路:即使未完成绑定,也保留“通过谁的分享链接点击进入”的历史
|
||||
var currentUser model.User
|
||||
_ = db.Select("id,open_id").Where("id = ?", userId).First(¤tUser).Error
|
||||
var inboundVisits []model.ReferralVisit
|
||||
visitQ := db.Model(&model.ReferralVisit{}).Where("visitor_id = ?", userId)
|
||||
if currentUser.OpenID != nil && strings.TrimSpace(*currentUser.OpenID) != "" {
|
||||
visitQ = visitQ.Or("visitor_openid = ?", strings.TrimSpace(*currentUser.OpenID))
|
||||
}
|
||||
_ = visitQ.Order("created_at ASC").Limit(300).Find(&inboundVisits).Error
|
||||
|
||||
referrerVisitIDs := make(map[string]bool)
|
||||
for _, v := range inboundVisits {
|
||||
if strings.TrimSpace(v.ReferrerID) != "" {
|
||||
referrerVisitIDs[strings.TrimSpace(v.ReferrerID)] = true
|
||||
}
|
||||
}
|
||||
referrerVisitList := make([]string, 0, len(referrerVisitIDs))
|
||||
for id := range referrerVisitIDs {
|
||||
referrerVisitList = append(referrerVisitList, id)
|
||||
}
|
||||
referrerVisitUserMap := make(map[string]*model.User)
|
||||
if len(referrerVisitList) > 0 {
|
||||
var rs []model.User
|
||||
_ = db.Where("id IN ?", referrerVisitList).Find(&rs).Error
|
||||
for i := range rs {
|
||||
referrerVisitUserMap[rs[i].ID] = &rs[i]
|
||||
}
|
||||
}
|
||||
|
||||
inboundVisitItems := make([]gin.H, 0, len(inboundVisits))
|
||||
firstInbound := gin.H{}
|
||||
latestInbound := gin.H{}
|
||||
for i, v := range inboundVisits {
|
||||
nickname := "微信用户"
|
||||
avatar := ""
|
||||
if u := referrerVisitUserMap[v.ReferrerID]; u != nil {
|
||||
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
|
||||
nickname = strings.TrimSpace(*u.Nickname)
|
||||
}
|
||||
if u.Avatar != nil {
|
||||
avatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
|
||||
}
|
||||
}
|
||||
source := ""
|
||||
page := ""
|
||||
if v.Source != nil {
|
||||
source = strings.TrimSpace(*v.Source)
|
||||
}
|
||||
if v.Page != nil {
|
||||
page = strings.TrimSpace(*v.Page)
|
||||
}
|
||||
item := gin.H{
|
||||
"seq": i + 1,
|
||||
"visitedAt": v.CreatedAt,
|
||||
"referrerId": v.ReferrerID,
|
||||
"referrerNickname": nickname,
|
||||
"referrerAvatar": avatar,
|
||||
"source": source,
|
||||
"page": page,
|
||||
}
|
||||
if i == 0 {
|
||||
firstInbound = item
|
||||
}
|
||||
latestInbound = item
|
||||
inboundVisitItems = append(inboundVisitItems, item)
|
||||
}
|
||||
|
||||
activeBinding := gin.H{}
|
||||
var activeRef model.ReferralBinding
|
||||
if err := db.Where("referee_id = ? AND status = ?", userId, "active").Order("binding_date DESC").First(&activeRef).Error; err == nil {
|
||||
bindNick := "微信用户"
|
||||
bindAvatar := ""
|
||||
if u := referrerVisitUserMap[activeRef.ReferrerID]; u != nil {
|
||||
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
|
||||
bindNick = strings.TrimSpace(*u.Nickname)
|
||||
}
|
||||
if u.Avatar != nil {
|
||||
bindAvatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
|
||||
}
|
||||
} else {
|
||||
var ru model.User
|
||||
if err := db.Select("id,nickname,avatar").Where("id = ?", activeRef.ReferrerID).First(&ru).Error; err == nil {
|
||||
if ru.Nickname != nil && strings.TrimSpace(*ru.Nickname) != "" {
|
||||
bindNick = strings.TrimSpace(*ru.Nickname)
|
||||
}
|
||||
if ru.Avatar != nil {
|
||||
bindAvatar = resolveAvatarURL(strings.TrimSpace(*ru.Avatar))
|
||||
}
|
||||
}
|
||||
}
|
||||
activeBinding = gin.H{
|
||||
"referrerId": activeRef.ReferrerID,
|
||||
"referrerNickname": bindNick,
|
||||
"referrerAvatar": bindAvatar,
|
||||
"referralCode": activeRef.ReferralCode,
|
||||
"bindingDate": activeRef.BindingDate,
|
||||
"expiryDate": activeRef.ExpiryDate,
|
||||
}
|
||||
}
|
||||
|
||||
var bindings []model.ReferralBinding
|
||||
if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
|
||||
bindings = []model.ReferralBinding{}
|
||||
@@ -1380,6 +1543,13 @@ func DBUsersReferrals(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "referrals": referrals,
|
||||
"inboundSource": gin.H{
|
||||
"totalVisits": len(inboundVisitItems),
|
||||
"firstVisit": firstInbound,
|
||||
"latestVisit": latestInbound,
|
||||
"activeBinding": activeBinding,
|
||||
"visits": inboundVisitItems,
|
||||
},
|
||||
"stats": gin.H{
|
||||
"total": totalReferrals, "purchased": purchased, "free": totalReferrals - purchased,
|
||||
"earnings": roundFloat(earningsE, 2), "pendingEarnings": roundFloat(availableE, 2), "withdrawnEarnings": roundFloat(withdrawnE, 2),
|
||||
|
||||
Reference in New Issue
Block a user