feat: MBTI头像与用户规则链路升级,三端页面与接口同步

Made-with: Cursor
This commit is contained in:
卡若
2026-03-24 01:22:50 +08:00
parent fa3da12b16
commit 1d56d0336c
71 changed files with 3848 additions and 1621 deletions

View File

@@ -307,7 +307,7 @@ func AdminDashboardMerchantBalance(c *gin.Context) {
}
// AdminSuperIndividualStats GET /api/admin/super-individual/stats
// 超级个体点击/获客统计:从 user_tracks 中筛选 target LIKE '超级个体_%' 的记录
// 超级个体点击/获客统计:从 user_tracks 中筛选「点击头像」记录(target LIKE '链接头像_%'
// 按被点击的超级个体 ID 分组,统计点击次数、独立点击用户数
func AdminSuperIndividualStats(c *gin.Context) {
db := database.DB()
@@ -324,7 +324,8 @@ func AdminSuperIndividualStats(c *gin.Context) {
COUNT(*) AS clicks,
COUNT(DISTINCT user_id) AS unique_clicks
FROM user_tracks
WHERE action = 'card_click' AND target LIKE '超级个体\_%'
WHERE action IN ('avatar_click', 'btn_click')
AND target LIKE '链接头像\_%'
GROUP BY target_id
ORDER BY clicks DESC
`).Scan(&rows).Error; err != nil {

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
@@ -92,11 +93,14 @@ func DBUserRulesAction(c *gin.Context) {
switch c.Request.Method {
case http.MethodPost:
var body struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Trigger string `json:"trigger"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Trigger string `json:"trigger"`
TriggerConditions interface{} `json:"triggerConditions"`
ActionType string `json:"actionType"`
ActionConfig interface{} `json:"actionConfig"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
@@ -110,9 +114,20 @@ func DBUserRulesAction(c *gin.Context) {
Title: trimSpace(body.Title),
Description: body.Description,
Trigger: trimSpace(body.Trigger),
ActionType: trimSpace(body.ActionType),
Sort: body.Sort,
Enabled: enabled,
}
if body.TriggerConditions != nil {
if b, err := json.Marshal(body.TriggerConditions); err == nil {
rule.TriggerConditions = b
}
}
if body.ActionConfig != nil {
if b, err := json.Marshal(body.ActionConfig); err == nil {
rule.ActionConfig = b
}
}
if err := db.Create(&rule).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
@@ -121,12 +136,15 @@ func DBUserRulesAction(c *gin.Context) {
case http.MethodPut:
var body struct {
ID uint `json:"id" binding:"required"`
Title string `json:"title"`
Description string `json:"description"`
Trigger string `json:"trigger"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
ID uint `json:"id" binding:"required"`
Title string `json:"title"`
Description string `json:"description"`
Trigger string `json:"trigger"`
TriggerConditions interface{} `json:"triggerConditions"`
ActionType string `json:"actionType"`
ActionConfig interface{} `json:"actionConfig"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
@@ -147,6 +165,17 @@ func DBUserRulesAction(c *gin.Context) {
}
updates["description"] = body.Description
updates["trigger"] = trimSpace(body.Trigger)
updates["action_type"] = trimSpace(body.ActionType)
if body.TriggerConditions != nil {
if b, err := json.Marshal(body.TriggerConditions); err == nil {
updates["trigger_conditions"] = string(b)
}
}
if body.ActionConfig != nil {
if b, err := json.Marshal(body.ActionConfig); err == nil {
updates["action_config"] = string(b)
}
}
if body.Sort != nil {
updates["sort"] = *body.Sort
}

View File

@@ -85,6 +85,8 @@ type cachedPartRow struct {
Subtitle string `json:"subtitle"`
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
// Icon 可选system_config.book_part_icons JSON 中按 part_id 配置的封面图 URL
Icon string `json:"icon,omitempty"`
}
type cachedFixedItem struct {
ID string `json:"id"`
@@ -109,6 +111,48 @@ var bookPartsCache struct {
const bookPartsCacheTTL = 30 * time.Second
// loadBookPartIconURLs 读取 system_config.book_part_icons{"part-1":"https://..."}key 与 chapters.part_id 一致
func loadBookPartIconURLs() map[string]string {
out := map[string]string{}
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "book_part_icons").First(&row).Error; err != nil {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
out[k] = s
}
}
}
return out
}
// mergeBookPartIcons 将配置中的篇封面 URL 写入 parts每次接口响应前调用避免 Redis 旧缓存缺 icon
func mergeBookPartIcons(parts []cachedPartRow) {
if len(parts) == 0 {
return
}
m := loadBookPartIconURLs()
if len(m) == 0 {
return
}
for i := range parts {
if u := strings.TrimSpace(m[parts[i].PartID]); u != "" {
parts[i].Icon = u
}
}
}
// chaptersByPartCache 篇章内章节列表内存缓存30 秒 TTL
type chaptersByPartEntry struct {
data []model.Chapter
@@ -320,6 +364,7 @@ func BookParts(c *gin.Context) {
// 1. 优先 Redis后台无更新时长期有效
var redisPayload bookPartsRedisPayload
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
mergeBookPartIcons(redisPayload.Parts)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": redisPayload.Parts,
@@ -336,6 +381,7 @@ func BookParts(c *gin.Context) {
total := bookPartsCache.total
fixed := bookPartsCache.fixed
bookPartsCache.mu.RUnlock()
mergeBookPartIcons(parts)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
@@ -348,6 +394,7 @@ func BookParts(c *gin.Context) {
// 3. DB 查询并更新 Redis + 内存
parts, total, fixed := fetchAndCacheBookParts()
mergeBookPartIcons(parts)
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)

View File

@@ -429,6 +429,7 @@ func CKBIndexLead(c *gin.Context) {
Phone: phone,
Wechat: wechatId,
PersonName: personName,
TargetMemberID: "",
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
@@ -497,6 +498,7 @@ func CKBLead(c *gin.Context) {
// 首页链接卡若targetUserId 为空 → 用全局 getCkbLeadApiKey()
leadKey := getCkbLeadApiKey()
targetName := strings.TrimSpace(body.TargetNickname)
targetMemberID := strings.TrimSpace(body.TargetMemberID)
personTips := "" // Person 配置的获客成功提示,优先于默认文案
if body.TargetUserID != "" {
var p model.Person
@@ -513,6 +515,11 @@ func CKBLead(c *gin.Context) {
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
}
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
@@ -611,6 +618,7 @@ func CKBLead(c *gin.Context) {
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
@@ -684,6 +692,7 @@ func CKBLead(c *gin.Context) {
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
@@ -720,6 +729,7 @@ type leadWebhookPayload struct {
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
TargetMemberID string // 超级个体 userId用于按人路由 webhook
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
@@ -750,31 +760,45 @@ var _webhookDedupCache = struct {
m map[string]string
}{m: make(map[string]string)}
func webhookShouldSkip(userId string) bool {
if userId == "" {
func webhookShouldSkip(userId string, targetMemberID string) bool {
if userId == "" && targetMemberID == "" {
return false
}
today := time.Now().Format("2006-01-02")
key := strings.TrimSpace(userId) + "|" + strings.TrimSpace(targetMemberID)
if key == "|" {
return false
}
_webhookDedupCache.Lock()
defer _webhookDedupCache.Unlock()
if _webhookDedupCache.m[userId] == today {
if _webhookDedupCache.m[key] == today {
return true
}
_webhookDedupCache.m[userId] = today
_webhookDedupCache.m[key] = today
if len(_webhookDedupCache.m) > 10000 {
_webhookDedupCache.m = map[string]string{userId: today}
_webhookDedupCache.m = map[string]string{key: today}
}
return false
}
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID) {
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
return
func loadLeadWebhookURL(db *gorm.DB, targetMemberID string) string {
// 优先按超级个体 userId 映射(单人单群)
targetMemberID = strings.TrimSpace(targetMemberID)
if targetMemberID != "" {
var mapCfg model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&mapCfg).Error; err == nil && len(mapCfg.ConfigValue) > 0 {
var m map[string]string
if json.Unmarshal(mapCfg.ConfigValue, &m) == nil {
if u := strings.TrimSpace(m[targetMemberID]); u != "" && strings.HasPrefix(u, "http") {
return u
}
}
}
}
// 回退全局获客 webhook
var cfg model.SystemConfig
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
return
return ""
}
var webhookURL string
if len(cfg.ConfigValue) > 0 {
@@ -782,6 +806,18 @@ func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
}
webhookURL = strings.TrimSpace(webhookURL)
if webhookURL == "" || !strings.HasPrefix(webhookURL, "http") {
return ""
}
return webhookURL
}
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID, p.TargetMemberID) {
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
return
}
webhookURL := loadLeadWebhookURL(db, p.TargetMemberID)
if webhookURL == "" {
return
}

View File

@@ -172,6 +172,18 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error)
).Delete(&model.Order{})
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o)
if pushErr := pushPaidOrderWebhook(db, &o); pushErr != nil {
syncOrdersLogf("订单 %s webhook 推送失败: %v", o.OrderSN, pushErr)
markOrderWebhookResult(db, o.OrderSN, false, pushErr)
} else {
markOrderWebhookResult(db, o.OrderSN, true, nil)
}
}
// 兜底补偿:服务器卡顿/回调异常导致的未推送订单,统一补推
if retried, sentCount, rerr := RetryPendingPaidOrderWebhooks(ctx, 500); rerr != nil {
syncOrdersLogf("补推未发送订单失败: %v", rerr)
} else if retried > 0 {
syncOrdersLogf("补推未发送订单: 扫描 %d 笔,成功 %d 笔", retried, sentCount)
}
return synced, total, nil
}
@@ -199,6 +211,28 @@ func CronSyncOrders(c *gin.Context) {
})
}
// CronRetryOrderWebhooks GET/POST /api/cron/retry-order-webhooks
// 手动补偿重推:仅推送未成功推送过的已支付订单。
func CronRetryOrderWebhooks(c *gin.Context) {
limit := 500
if s := strings.TrimSpace(c.Query("limit")); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
limit = n
}
}
retried, sent, err := RetryPendingPaidOrderWebhooks(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"retried": retried,
"sent": sent,
"limit": limit,
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})

View File

@@ -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(&sectionRows)
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(&currentUser).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),

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
@@ -295,6 +296,16 @@ func MatchUsers(c *gin.Context) {
if r.Avatar != nil {
avatar = resolveAvatarURL(*r.Avatar)
}
if avatar == "" && r.Mbti != nil {
mbti := strings.ToUpper(strings.TrimSpace(*r.Mbti))
if mbti != "" {
avatar = resolveAvatarURL(getMbtiAvatar(db, mbti))
}
}
mbtiOut := ""
if r.Mbti != nil {
mbtiOut = strings.TrimSpace(*r.Mbti)
}
wechat := ""
if r.WechatID != nil {
wechat = *r.WechatID
@@ -331,7 +342,7 @@ func MatchUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": r.ID, "nickname": nickname, "avatar": avatar, "wechat": wechat, "phone": phone,
"id": r.ID, "nickname": nickname, "avatar": avatar, "mbti": mbtiOut, "wechat": wechat, "phone": phone,
"introduction": intro, "tags": []string{"创业者", tag},
"matchScore": 80 + (r.CreatedAt.Unix() % 20),
"commonInterests": []gin.H{

View File

@@ -798,6 +798,13 @@ func MiniprogramPayNotify(c *gin.Context) {
).Delete(&model.Order{})
processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order)
}
// 支付成功后实时推送到 webhook失败记录交给定时补偿任务统一重推
if pushErr := pushPaidOrderWebhook(db, &order); pushErr != nil {
fmt.Printf("[PayNotify] webhook 推送失败: orderSn=%s, err=%v\n", orderSn, pushErr)
markOrderWebhookResult(db, orderSn, false, pushErr)
} else {
markOrderWebhookResult(db, orderSn, true, nil)
}
return nil
})
if err != nil {

View File

@@ -0,0 +1,37 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// MiniprogramMbtiAvatarsGet GET /api/miniprogram/config/mbti-avatars
// 公开只读:返回 16 型 MBTI → 头像 URL供小程序在无用户头像时按性格展示推广海报等
func MiniprogramMbtiAvatarsGet(c *gin.Context) {
db := database.DB()
var row model.SystemConfig
err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": map[string]string{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "读取失败"})
return
}
out := make(map[string]string)
if len(row.ConfigValue) > 0 {
if uerr := json.Unmarshal(row.ConfigValue, &out); uerr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置无效"})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": out})
}

View File

@@ -0,0 +1,163 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"gorm.io/gorm"
)
func loadOrderWebhookURL(db *gorm.DB) string {
keys := []string{"order_paid_webhook_url", "ckb_lead_webhook_url"}
for _, key := range keys {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", key).First(&cfg).Error; err != nil {
continue
}
var webhookURL string
if len(cfg.ConfigValue) > 0 {
_ = json.Unmarshal(cfg.ConfigValue, &webhookURL)
}
webhookURL = strings.TrimSpace(webhookURL)
if webhookURL != "" && strings.HasPrefix(webhookURL, "http") {
return webhookURL
}
}
return ""
}
func pushPaidOrderWebhook(db *gorm.DB, order *model.Order) error {
if order == nil || order.OrderSN == "" {
return fmt.Errorf("empty order")
}
if order.WebhookPushStatus == "sent" {
return nil
}
webhookURL := loadOrderWebhookURL(db)
if webhookURL == "" {
return nil
}
var user model.User
_ = db.Select("id,nickname,phone,open_id").Where("id = ?", order.UserID).First(&user).Error
productName := order.ProductType
if order.Description != nil && strings.TrimSpace(*order.Description) != "" {
productName = strings.TrimSpace(*order.Description)
}
status := ""
if order.Status != nil {
status = *order.Status
}
if status == "" {
status = "paid"
}
text := "💰 用户购买成功(实时推送)"
text += fmt.Sprintf("\n订单号: %s", order.OrderSN)
if user.Nickname != nil && strings.TrimSpace(*user.Nickname) != "" {
text += fmt.Sprintf("\n用户: %s", strings.TrimSpace(*user.Nickname))
}
if user.Phone != nil && strings.TrimSpace(*user.Phone) != "" {
text += fmt.Sprintf("\n手机: %s", strings.TrimSpace(*user.Phone))
}
text += fmt.Sprintf("\n商品: %s", productName)
text += fmt.Sprintf("\n金额: %.2f", order.Amount)
text += fmt.Sprintf("\n状态: %s", status)
if order.PayTime != nil {
text += fmt.Sprintf("\n支付时间: %s", order.PayTime.Format("2006-01-02 15:04:05"))
} else {
text += fmt.Sprintf("\n支付时间: %s", time.Now().Format("2006-01-02 15:04:05"))
}
var payload []byte
if strings.Contains(webhookURL, "qyapi.weixin.qq.com") {
payload, _ = json.Marshal(map[string]interface{}{
"msgtype": "text",
"text": map[string]string{"content": text},
})
} else {
payload, _ = json.Marshal(map[string]interface{}{
"msg_type": "text",
"content": map[string]string{"text": text},
})
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(payload))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook status=%d", resp.StatusCode)
}
return nil
}
func markOrderWebhookResult(db *gorm.DB, orderSn string, sent bool, pushErr error) {
if orderSn == "" {
return
}
updates := map[string]interface{}{
"webhook_push_attempts": gorm.Expr("COALESCE(webhook_push_attempts, 0) + 1"),
"updated_at": time.Now(),
}
if sent {
now := time.Now()
updates["webhook_push_status"] = "sent"
updates["webhook_pushed_at"] = now
updates["webhook_push_error"] = ""
} else {
errText := ""
if pushErr != nil {
errText = strings.TrimSpace(pushErr.Error())
}
if len(errText) > 500 {
errText = errText[:500]
}
updates["webhook_push_status"] = "failed"
updates["webhook_push_error"] = errText
}
_ = db.Model(&model.Order{}).Where("order_sn = ?", orderSn).Updates(updates).Error
}
// RetryPendingPaidOrderWebhooks 扫描未推送成功的已支付订单并补推。
func RetryPendingPaidOrderWebhooks(ctx context.Context, limit int) (retried, sent int, err error) {
if limit <= 0 {
limit = 200
}
if limit > 2000 {
limit = 2000
}
db := database.DB()
var rows []model.Order
if err := db.Where(
"status IN ? AND COALESCE(webhook_push_status,'') <> ?",
[]string{"paid", "completed"}, "sent",
).Order("pay_time ASC, created_at ASC").Limit(limit).Find(&rows).Error; err != nil {
return 0, 0, err
}
for i := range rows {
select {
case <-ctx.Done():
return retried, sent, ctx.Err()
default:
}
retried++
pushErr := pushPaidOrderWebhook(db, &rows[i])
if pushErr == nil {
sent++
markOrderWebhookResult(db, rows[i].OrderSN, true, nil)
} else {
markOrderWebhookResult(db, rows[i].OrderSN, false, pushErr)
}
}
return retried, sent, nil
}

View File

@@ -685,8 +685,34 @@ func userTrackActionLabelCN(action string) string {
return "绑定微信"
case "fill_profile":
return "完善资料"
case "fill_avatar":
return "设置头像"
case "visit_page":
return "访问页面"
case "first_pay":
return "首次付款"
case "vip_activate":
return "开通会员"
case "click_super":
return "点击超级个体"
case "lead_submit":
return "提交留资"
case "withdraw":
return "申请提现"
case "referral_bind":
return "绑定推荐人"
case "card_click":
return "点击名片"
case "btn_click":
return "按钮点击"
case "tab_click":
return "切换标签"
case "nav_click":
return "导航点击"
case "page_view":
return "页面浏览"
case "search":
return "搜索"
default:
if action == "" {
return "行为"
@@ -695,6 +721,41 @@ func userTrackActionLabelCN(action string) string {
}
}
// userTrackModuleLabelCN 埋点 module 英文字段 → 中文位置(与用户旅程、群播报一致)
func userTrackModuleLabelCN(module string) string {
m := strings.TrimSpace(strings.ToLower(module))
switch m {
case "":
return ""
case "home", "index":
return "首页"
case "chapters":
return "目录"
case "match":
return "找伙伴"
case "my":
return "我的"
case "read", "reading":
return "阅读"
case "vip":
return "会员中心"
case "referral":
return "推广中心"
case "member_detail", "member-detail", "memberdetail":
return "超级个体详情"
case "profile", "profile_show", "profile-show":
return "个人资料"
case "search":
return "搜索"
case "wallet":
return "钱包"
case "settings":
return "设置"
default:
return module
}
}
func humanTimeAgoCN(t time.Time) string {
if t.IsZero() {
return ""
@@ -803,6 +864,16 @@ func UserTrackGet(c *gin.Context) {
chapterTitle = v
}
}
var extra map[string]interface{}
if len(t.ExtraData) > 0 {
_ = json.Unmarshal(t.ExtraData, &extra)
}
module := ""
if extra != nil {
if m, ok := extra["module"].(string); ok {
module = m
}
}
var createdAt time.Time
if t.CreatedAt != nil {
createdAt = *t.CreatedAt
@@ -813,6 +884,8 @@ func UserTrackGet(c *gin.Context) {
"actionLabel": userTrackActionLabelCN(t.Action),
"target": target,
"chapterTitle": chapterTitle,
"module": module,
"moduleLabel": userTrackModuleLabelCN(module),
"createdAt": t.CreatedAt,
"timeAgo": humanTimeAgoCN(createdAt),
})
@@ -865,6 +938,7 @@ func DBUserTracksList(c *gin.Context) {
out = append(out, gin.H{
"id": t.ID, "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action),
"target": target, "chapterTitle": chTitle, "module": module,
"moduleLabel": userTrackModuleLabelCN(module),
"createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt),
})
}
@@ -905,10 +979,11 @@ func GetUserRecentTracks(db *gorm.DB, userId string, limit int) []string {
}
}
var line string
modCN := userTrackModuleLabelCN(module)
if target != "" {
line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target))
} else if module != "" {
line = fmt.Sprintf("%s (%s)", label, module)
} else if modCN != "" {
line = fmt.Sprintf("%s · %s", label, modCN)
} else {
line = label
}

View File

@@ -296,12 +296,8 @@ func isWechatDefaultNickname(s string) bool {
return s != "" && strings.HasPrefix(s, "微信用户")
}
// vipMemberShowcaseOK 首页「超级个体」横滑:必须有可展示头像 URL展示名非微信默认占位
// vipMemberShowcaseOK 首页「超级个体」横滑:展示名非微信默认占位即可;无头像时小程序用首字/MBTI 映射图(后台可配 mbti_avatars
func vipMemberShowcaseOK(item gin.H) bool {
av, _ := item["avatar"].(string)
if strings.TrimSpace(av) == "" {
return false
}
name, _ := item["name"].(string)
name = strings.TrimSpace(name)
if name == "" || isWechatDefaultNickname(name) {

View File

@@ -1,7 +1,9 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"soul-api/internal/database"
@@ -11,7 +13,34 @@ import (
"gorm.io/gorm"
)
// batchSuperIndividualClicks 与 AdminSuperIndividualStats 一致user_tracks 中 action=card_click 且 target 前缀「超级个体_」
const superIndividualWebhookConfigKey = "super_individual_webhook_map"
func loadSuperIndividualWebhookMap(db *gorm.DB) map[string]string {
out := map[string]string{}
var cfg model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&cfg).Error; err != nil {
return out
}
if len(cfg.ConfigValue) == 0 {
return out
}
raw := map[string]string{}
if err := json.Unmarshal(cfg.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if k == "" || v == "" {
continue
}
out[k] = v
}
return out
}
// batchSuperIndividualClicks 统计「点击头像」行为:
// user_tracks 中 action=avatar_click兼容历史 btn_click且 target 前缀「链接头像_」。
func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64 {
out := make(map[string]int64)
if len(userIDs) == 0 {
@@ -23,9 +52,13 @@ func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64
}
var rows []row
_ = db.Raw(`
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
SELECT
SUBSTRING(target, 6) AS user_id,
COUNT(*) AS clicks
FROM user_tracks
WHERE action = 'card_click' AND target LIKE '超级个体\_%' AND SUBSTRING(target, 6) IN ?
WHERE action IN ('avatar_click', 'btn_click')
AND target LIKE '链接头像\_%'
AND SUBSTRING(target, 6) IN ?
GROUP BY user_id
`, userIDs).Scan(&rows)
for _, r := range rows {
@@ -103,6 +136,7 @@ func DBVipMembersList(c *gin.Context) {
}
clickByUser := batchSuperIndividualClicks(db, ids)
leadByUser := batchSuperIndividualLeads(db, ids)
webhookMap := loadSuperIndividualWebhookMap(db)
list := make([]gin.H, 0, len(users))
for i := range users {
@@ -110,8 +144,74 @@ func DBVipMembersList(c *gin.Context) {
uid := users[i].ID
item["clickCount"] = clickByUser[uid]
item["leadCount"] = leadByUser[uid]
item["webhookUrl"] = strings.TrimSpace(webhookMap[uid])
list = append(list, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
}
// DBVipMemberWebhookSet PUT /api/db/vip-members/webhook
// 按超级个体用户维度配置飞书群 webhookVOX 地址)。
func DBVipMemberWebhookSet(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
WebhookURL string `json:"webhookUrl"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
userID := strings.TrimSpace(body.UserID)
webhookURL := strings.TrimSpace(body.WebhookURL)
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "userId 不能为空"})
return
}
if webhookURL != "" && !strings.HasPrefix(webhookURL, "http") {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "Webhook 地址必须是 http/https"})
return
}
db := database.DB()
var count int64
db.Model(&model.User{}).Where("id = ?", userID).Count(&count)
if count == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
return
}
webhookMap := loadSuperIndividualWebhookMap(db)
if webhookURL == "" {
delete(webhookMap, userID)
} else {
webhookMap[userID] = webhookURL
}
val, _ := json.Marshal(webhookMap)
desc := "超级个体飞书群Webhook映射按userId"
var row model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row = model.SystemConfig{
ConfigKey: superIndividualWebhookConfigKey,
ConfigValue: val,
Description: &desc,
}
if e := db.Create(&row).Error; e != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
return
}
} else {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
} else {
row.ConfigValue = val
row.Description = &desc
if e := db.Save(&row).Error; e != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}