feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本

- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
This commit is contained in:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

View File

@@ -7,11 +7,13 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -417,6 +419,21 @@ func CKBIndexLead(c *gin.Context) {
}
}
data["repeatedSubmit"] = repeatedSubmit
personName := "卡若"
if defaultPerson.Name != "" {
personName = defaultPerson.Name
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
@@ -446,13 +463,15 @@ func CKBIndexLead(c *gin.Context) {
// 请求体phone/wechatId至少一个、userId补全昵称、targetUserIdPerson.token、targetNickname、source如 article_mention、member_detail_avatar
func CKBLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention 场景
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
Source string `json:"source"` // index_lead / article_mention
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention / 超级个体人物 token
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id无 person token 时全局留资,写入 params 便于运营)
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params不误导读为「对方会联系您」
Source string `json:"source"` // index_lead / article_mention / member_detail_global
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
@@ -510,7 +529,8 @@ func CKBLead(c *gin.Context) {
}
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"targetUserId": body.TargetUserID, "source": source,
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
@@ -585,7 +605,16 @@ func CKBLead(c *gin.Context) {
}
data["repeatedSubmit"] = repeatedSubmit
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
@@ -649,7 +678,16 @@ func CKBLead(c *gin.Context) {
} else {
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
}
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
}
@@ -676,7 +714,64 @@ func CKBLead(c *gin.Context) {
c.JSON(http.StatusOK, respObj)
}
func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, repeated bool) {
type leadWebhookPayload struct {
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
}
func leadSourceLabel(source string) string {
switch source {
case "member_detail_global":
return "超级个体详情页·全局链接"
case "member_detail_avatar":
return "超级个体详情页·点击头像"
case "article_mention":
return "文章正文·@提及人物"
case "index_link_button":
return "首页·链接卡若按钮"
case "index_lead":
return "首页·留资弹窗"
default:
if source == "" {
return "未知来源"
}
return source
}
}
var _webhookDedupCache = struct {
sync.Mutex
m map[string]string
}{m: make(map[string]string)}
func webhookShouldSkip(userId string) bool {
if userId == "" {
return false
}
today := time.Now().Format("2006-01-02")
_webhookDedupCache.Lock()
defer _webhookDedupCache.Unlock()
if _webhookDedupCache.m[userId] == today {
return true
}
_webhookDedupCache.m[userId] = today
if len(_webhookDedupCache.m) > 10000 {
_webhookDedupCache.m = map[string]string{userId: 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
}
var cfg model.SystemConfig
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
return
@@ -690,19 +785,41 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
return
}
tag := "新获客"
if repeated {
tag = "重复获客"
tag := "📋 新获客"
if p.Repeated {
tag = "🔄 重复获客"
}
text := fmt.Sprintf("[%s] %s → %s\n姓名: %s", tag, source, target, name)
if phone != "" {
text += fmt.Sprintf("\n手机: %s", phone)
sourceLabel := leadSourceLabel(p.Source)
contactPerson := p.PersonName
if contactPerson == "" {
contactPerson = p.MemberName
}
if wechat != "" {
text += fmt.Sprintf("\n微信: %s", wechat)
if contactPerson == "" || contactPerson == "对方" {
contactPerson = "(公共获客池)"
}
text := fmt.Sprintf("%s\n来源: %s\n对接人: %s", tag, sourceLabel, contactPerson)
text += "\n━━━━━━━━━━"
text += fmt.Sprintf("\n姓名: %s", p.LeadName)
if p.Phone != "" {
text += fmt.Sprintf("\n手机: %s", p.Phone)
}
if p.Wechat != "" {
text += fmt.Sprintf("\n微信: %s", p.Wechat)
}
text += fmt.Sprintf("\n时间: %s", time.Now().Format("2006-01-02 15:04"))
if p.LeadUserID != "" {
recentTracks := GetUserRecentTracks(db, p.LeadUserID, 5)
if len(recentTracks) > 0 {
text += "\n━━━━━━━━━━\n最近行为:"
for i, line := range recentTracks {
text += fmt.Sprintf("\n %d. %s", i+1, line)
}
}
}
var payload []byte
if strings.Contains(webhookURL, "qyapi.weixin.qq.com") {
payload, _ = json.Marshal(map[string]interface{}{
@@ -721,5 +838,5 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
return
}
defer resp.Body.Close()
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", target, resp.StatusCode)
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", contactPerson, resp.StatusCode)
}