feat: MBTI头像与用户规则链路升级,三端页面与接口同步
Made-with: Cursor
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
37
soul-api/internal/handler/miniprogram_mbti_avatars.go
Normal file
37
soul-api/internal/handler/miniprogram_mbti_avatars.go
Normal 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})
|
||||
}
|
||||
163
soul-api/internal/handler/order_webhook.go
Normal file
163
soul-api/internal/handler/order_webhook.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
// 按超级个体用户维度配置飞书群 webhook(VOX 地址)。
|
||||
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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user