Files
soul-yongping/soul-api/internal/handler/ckb.go
2026-03-24 01:22:50 +08:00

879 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
)
// 存客宝 API Key 约定(详见 开发文档/8、部署/存客宝API-Key约定.md
// - 链接卡若(添加卡若好友):使用 CKB_LEAD_API_KEY.env 配置),未配则用下方 ckbAPIKey
// - 其他场景join/match 等):使用 ckbAPIKey
const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd"
const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
// ckbSubmitSave 加好友/留资类接口统一落库:记录 action、userId、昵称、用户提交的传参写入 ckb_submit_records
func ckbSubmitSave(action, userID, nickname string, params interface{}) {
paramsJSON, _ := json.Marshal(params)
_ = database.DB().Create(&model.CkbSubmitRecord{
Action: action,
UserID: userID,
Nickname: nickname,
Params: string(paramsJSON),
}).Error
}
// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait空值跳过按键升序拼接值MD5(拼接串) 再 MD5(结果+apiKey)
func ckbSign(params map[string]interface{}, apiKey string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "sign" || k == "apiKey" || k == "portrait" {
continue
}
v := params[k]
if v == nil || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var concat string
for _, k := range keys {
v := params[k]
switch val := v.(type) {
case string:
concat += val
case float64:
concat += strconv.FormatFloat(val, 'f', -1, 64)
case int:
concat += strconv.Itoa(val)
case int64:
concat += strconv.FormatInt(val, 10)
default:
concat += ""
}
}
h := md5.Sum([]byte(concat))
first := hex.EncodeToString(h[:])
h2 := md5.Sum([]byte(first + apiKey))
return hex.EncodeToString(h2[:])
}
// getCkbLeadApiKey 链接卡若密钥优先级system_config.site_settings.ckbLeadApiKey > .env CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
func getCkbLeadApiKey() string {
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "site_settings").First(&row).Error; err == nil && len(row.ConfigValue) > 0 {
var m map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &m); err == nil {
if v, ok := m["ckbLeadApiKey"].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
}
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
return cfg.CkbLeadAPIKey
}
return ckbAPIKey
}
// CKBJoin POST /api/ckb/join
func CKBJoin(c *gin.Context) {
var body struct {
Type string `json:"type" binding:"required"`
Phone string `json:"phone"`
Wechat string `json:"wechat"`
Name string `json:"name"`
UserID string `json:"userId"`
Remark string `json:"remark"`
CanHelp string `json:"canHelp"` // 资源对接:我能帮到你什么
NeedHelp string `json:"needHelp"` // 资源对接:我需要什么帮助
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
if body.Phone == "" && body.Wechat == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
if body.Type != "team" && body.Type != "investor" && body.Type != "mentor" && body.Type != "partner" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
return
}
nickname := strings.TrimSpace(body.Name)
if nickname == "" && body.UserID != "" {
var u model.User
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
nickname = *u.Nickname
}
}
if nickname == "" {
nickname = "-"
}
ckbSubmitSave("join", body.UserID, nickname, map[string]interface{}{
"type": body.Type, "phone": body.Phone, "wechat": body.Wechat, "name": body.Name,
"userId": body.UserID, "remark": body.Remark, "canHelp": body.CanHelp, "needHelp": body.NeedHelp,
})
ts := time.Now().Unix()
params := map[string]interface{}{
"timestamp": ts,
"source": "创业实验-" + ckbSourceMap[body.Type],
"tags": ckbTagsMap[body.Type],
"siteTags": "创业实验APP",
"remark": body.Remark,
}
if body.Remark == "" {
remark := "用户通过创业实验APP申请" + ckbSourceMap[body.Type]
if body.Type == "investor" && (body.CanHelp != "" || body.NeedHelp != "") {
remark = fmt.Sprintf("能帮:%s 需要:%s", body.CanHelp, body.NeedHelp)
}
params["remark"] = remark
}
if body.Phone != "" {
params["phone"] = body.Phone
}
if body.Wechat != "" {
params["wechatId"] = body.Wechat
}
if body.Name != "" {
params["name"] = body.Name
}
params["apiKey"] = ckbAPIKey
params["sign"] = ckbSign(params, ckbAPIKey)
sourceData := map[string]interface{}{
"joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID,
"device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
}
if body.Type == "investor" {
if body.CanHelp != "" {
sourceData["canHelp"] = body.CanHelp
}
if body.NeedHelp != "" {
sourceData["needHelp"] = body.NeedHelp
}
}
params["portrait"] = map[string]interface{}{
"type": 4, "source": 0,
"sourceData": sourceData,
"remark": ckbSourceMap[body.Type] + "申请",
"uniqueId": "soul_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
}
raw, _ := json.Marshal(params)
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
// 资源对接:同步更新用户资料中的 help_offer、help_need、phone、wechat_id
if body.Type == "investor" && body.UserID != "" {
updates := map[string]interface{}{}
if body.CanHelp != "" {
updates["help_offer"] = body.CanHelp
}
if body.NeedHelp != "" {
updates["help_need"] = body.NeedHelp
}
if body.Phone != "" {
updates["phone"] = body.Phone
}
if body.Wechat != "" {
updates["wechat_id"] = body.Wechat
}
if len(updates) > 0 {
database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates)
}
}
msg := "成功加入" + ckbSourceMap[body.Type]
if result.Message == "已存在" {
msg = "您已加入,我们会尽快联系您"
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
return
}
errMsg := result.Message
if errMsg == "" {
errMsg = "加入失败,请稍后重试"
}
// 打印 CKB 原始响应便于排查
fmt.Printf("[CKBJoin] 失败 type=%s wechat=%s code=%d message=%s raw=%s\n",
body.Type, body.Wechat, result.Code, result.Message, string(b))
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
}
// CKBMatch POST /api/ckb/match
func CKBMatch(c *gin.Context) {
var body struct {
MatchType string `json:"matchType"`
Phone string `json:"phone"`
Wechat string `json:"wechat"`
UserID string `json:"userId"`
Nickname string `json:"nickname"`
MatchedUser interface{} `json:"matchedUser"`
}
_ = c.ShouldBindJSON(&body)
if body.Phone == "" && body.Wechat == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
nickname := strings.TrimSpace(body.Nickname)
if nickname == "" {
nickname = "-"
}
ckbSubmitSave("match", body.UserID, nickname, map[string]interface{}{
"matchType": body.MatchType, "phone": body.Phone, "wechat": body.Wechat,
"userId": body.UserID, "nickname": body.Nickname, "matchedUser": body.MatchedUser,
})
ts := time.Now().Unix()
label := ckbSourceMap[body.MatchType]
if label == "" {
label = "创业合伙"
}
params := map[string]interface{}{
"timestamp": ts,
"source": "创业实验-找伙伴匹配",
"tags": "找伙伴," + label,
"siteTags": "创业实验APP,匹配用户",
"remark": "用户发起" + label + "匹配",
}
if body.Phone != "" {
params["phone"] = body.Phone
}
if body.Wechat != "" {
params["wechatId"] = body.Wechat
}
if body.Nickname != "" {
params["name"] = body.Nickname
}
params["apiKey"] = ckbAPIKey
params["sign"] = ckbSign(params, ckbAPIKey)
params["portrait"] = map[string]interface{}{
"type": 4, "source": 0,
"sourceData": map[string]interface{}{
"action": "match", "matchType": body.MatchType, "matchLabel": label,
"userId": body.UserID, "device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
},
"remark": "找伙伴匹配-" + label,
"uniqueId": "soul_match_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
}
raw, _ := json.Marshal(params)
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
if err != nil {
fmt.Printf("[CKBMatch] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配记录已上报", "data": nil})
return
}
fmt.Printf("[CKBMatch] 存客宝返回异常 code=%d message=%s\n", result.Code, result.Message)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
}
// CKBSync GET/POST /api/ckb/sync
func CKBSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CKBIndexLead POST /api/miniprogram/ckb/index-lead 小程序首页「点击链接卡若」专用留资接口
// - 固定使用全局 CKB_LEAD_API_KEY不受文章 @ 人物的 ckb_api_key 影响
// - 请求体userId可选用于补全昵称、phone/wechatId至少一个、name可选
func CKBIndexLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
wechatId := strings.TrimSpace(body.WechatID)
// 存客宝侧仅接收手机号,不接收微信号;首页入口必须提供手机号
if phone == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请先填写手机号"})
return
}
name := strings.TrimSpace(body.Name)
db := database.DB()
if name == "" && body.UserID != "" {
var u model.User
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
}
if name == "" {
name = "小程序用户"
}
// 首页固定使用全局密钥system_config > .env > 代码内置
leadKey := getCkbLeadApiKey()
if leadKey == "" {
fmt.Printf("[CKBIndexLead] 警告CKB_LEAD_API_KEY 未配置,使用默认密钥\n")
leadKey = ckbAPIKey
}
// 去重:同一用户只记录一次(首页链接卡若)
repeatedSubmit := false
if body.UserID != "" {
var existCount int64
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND source = ?", body.UserID, "index_link_button").Count(&existCount)
repeatedSubmit = existCount > 0
}
source := "index_link_button"
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
}).Error
ts := time.Now().Unix()
params := map[string]interface{}{
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
params["phone"] = phone
params["sign"] = ckbSign(params, leadKey)
q := url.Values{}
q.Set("name", name)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("apiKey", leadKey)
q.Set("phone", phone)
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
fmt.Printf("[CKBIndexLead] 请求存客宝完整链接: %s\n", reqURL)
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
var msg string
var defaultPerson model.Person
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&defaultPerson).Error == nil && strings.TrimSpace(defaultPerson.Tips) != "" {
msg = strings.TrimSpace(defaultPerson.Tips)
} else if repeatedSubmit {
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
} else {
msg = "提交成功,卡若会尽快联系您"
}
data := gin.H{}
if result.Data != nil {
if m, ok := result.Data.(map[string]interface{}); ok {
data = m
}
}
data["repeatedSubmit"] = repeatedSubmit
personName := "卡若"
if defaultPerson.Name != "" {
personName = defaultPerson.Name
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
TargetMemberID: "",
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
// 存客宝返回失败,透传其错误信息与 code便于前端/运营判断原因
errMsg := strings.TrimSpace(result.Message)
if errMsg == "" {
errMsg = "提交失败,请稍后重试"
}
// 特殊处理无效的apiKey错误提示检查配置
if strings.Contains(strings.ToLower(errMsg), "无效") || strings.Contains(strings.ToLower(errMsg), "invalid") || strings.Contains(strings.ToLower(errMsg), "apikey") {
if leadKey == "" || leadKey == ckbAPIKey {
errMsg = "系统配置异常请联系管理员检查CKB_LEAD_API_KEY配置"
} else {
errMsg = "提交失败,请稍后重试(配置错误)"
}
}
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s leadKey=%s\n", result.Code, result.Message, string(b), leadKey)
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errMsg,
"ckbCode": result.Code,
"ckbMessage": result.Message,
})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)、文章@某人、超级个体详情点头像
// 请求体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 / 超级个体人物 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)
wechatId := strings.TrimSpace(body.WechatID)
if phone == "" && wechatId == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
name := strings.TrimSpace(body.Name)
db := database.DB()
if name == "" && body.UserID != "" {
var u model.User
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
}
if name == "" {
name = "小程序用户"
}
// 存客宝 scenarios 内部 API 需要计划级 apiKeypersons.ckb_api_key不是 token
// 文章 @ 场景targetUserId=token → 查 Person 取 CkbApiKey 作为 leadKey
// 首页链接卡若targetUserId 为空 → 用全局 getCkbLeadApiKey()
leadKey := getCkbLeadApiKey()
targetName := strings.TrimSpace(body.TargetNickname)
targetMemberID := strings.TrimSpace(body.TargetMemberID)
personTips := "" // Person 配置的获客成功提示,优先于默认文案
if body.TargetUserID != "" {
var p model.Person
if db.Where("token = ?", body.TargetUserID).First(&p).Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "未找到该人物配置,请稍后重试"})
return
}
if strings.TrimSpace(p.CkbApiKey) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "该人物尚未配置存客宝密钥,请联系管理员"})
return
}
leadKey = p.CkbApiKey
personTips = strings.TrimSpace(p.Tips)
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
}
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
repeatedSubmit := false
if body.UserID != "" && body.TargetUserID != "" {
var existCount int64
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND target_person_id = ?", body.UserID, body.TargetUserID).Count(&existCount)
repeatedSubmit = existCount > 0
}
source := strings.TrimSpace(body.Source)
if source == "" {
source = "index_lead"
}
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
TargetPersonID: body.TargetUserID,
Source: source,
Params: string(paramsJSON),
}).Error
ts := time.Now().Unix()
params := map[string]interface{}{
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
if phone != "" {
params["phone"] = phone
}
if wechatId != "" {
params["wechatId"] = wechatId
}
params["sign"] = ckbSign(params, leadKey)
q := url.Values{}
q.Set("name", name)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("apiKey", leadKey)
if phone != "" {
q.Set("phone", phone)
}
if wechatId != "" {
q.Set("wechatId", wechatId)
}
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
fmt.Printf("[CKBLead] 请求存客宝完整链接: %s\n", reqURL)
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"` // 存客保部分接口用 msg 返回错误
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
who := targetName
if who == "" {
who = "对方"
}
var msg string
if personTips != "" {
msg = personTips
} else if repeatedSubmit {
msg = fmt.Sprintf("您已留资过,我们已再次通知 %s请耐心等待添加", who)
} else {
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
}
data := gin.H{}
if result.Data != nil {
if m, ok := result.Data.(map[string]interface{}); ok {
data = m
}
}
data["repeatedSubmit"] = repeatedSubmit
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
ckbMsg := strings.TrimSpace(result.Message)
if ckbMsg == "" {
ckbMsg = strings.TrimSpace(result.Msg)
}
errMsg := ckbMsg
if errMsg == "" {
errMsg = "提交失败,请稍后重试"
}
// apiKey 无效时,若使用的是 person-specific key自动回退到全局 key 重试一次
isKeyError := strings.Contains(strings.ToLower(errMsg), "无效") || strings.Contains(strings.ToLower(errMsg), "invalid") || strings.Contains(strings.ToLower(errMsg), "apikey")
globalKey := getCkbLeadApiKey()
if isKeyError && leadKey != globalKey && globalKey != "" {
fmt.Printf("[CKBLead] person key 无效,回退全局 key 重试 personKey=%s globalKey=%s\n", leadKey, globalKey)
retryParams := map[string]interface{}{
"name": name,
"timestamp": time.Now().Unix(),
"apiKey": globalKey,
}
if phone != "" {
retryParams["phone"] = phone
}
if wechatId != "" {
retryParams["wechatId"] = wechatId
}
retryParams["sign"] = ckbSign(retryParams, globalKey)
rq := url.Values{}
rq.Set("name", name)
rq.Set("timestamp", strconv.FormatInt(retryParams["timestamp"].(int64), 10))
rq.Set("apiKey", globalKey)
if phone != "" {
rq.Set("phone", phone)
}
if wechatId != "" {
rq.Set("wechatId", wechatId)
}
rq.Set("sign", retryParams["sign"].(string))
retryURL := ckbAPIURL + "?" + rq.Encode()
if retryResp, retryErr := http.Get(retryURL); retryErr == nil {
defer retryResp.Body.Close()
rb, _ := io.ReadAll(retryResp.Body)
var rr struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(rb, &rr)
if rr.Code == 200 {
who := targetName
if who == "" {
who = "对方"
}
var msg string
if personTips != "" {
msg = personTips
} else if repeatedSubmit {
msg = fmt.Sprintf("您已留资过,我们已再次通知 %s请耐心等待添加", who)
} else {
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
}
fmt.Printf("[CKBLead] 全局 key 重试仍失败 code=%d msg=%s\n", rr.Code, rr.Message)
}
}
if isKeyError {
if leadKey == "" {
errMsg = "系统配置异常,请联系管理员检查存客宝密钥配置"
} else {
errMsg = "提交失败,请稍后重试(配置错误)"
}
}
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d msg=%s raw=%s leadKey=%s\n", result.Code, ckbMsg, string(b), leadKey)
respObj := gin.H{
"success": false,
"message": errMsg,
"ckbCode": result.Code,
"ckbMessage": ckbMsg,
}
if ckbMsg == "" && len(b) > 0 {
respObj["ckbRaw"] = string(b)
}
c.JSON(http.StatusOK, respObj)
}
type leadWebhookPayload struct {
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
TargetMemberID string // 超级个体 userId用于按人路由 webhook
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, 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[key] == today {
return true
}
_webhookDedupCache.m[key] = today
if len(_webhookDedupCache.m) > 10000 {
_webhookDedupCache.m = map[string]string{key: today}
}
return false
}
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 ""
}
var webhookURL string
if len(cfg.ConfigValue) > 0 {
_ = json.Unmarshal(cfg.ConfigValue, &webhookURL)
}
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
}
tag := "📋 新获客"
if p.Repeated {
tag = "🔄 重复获客"
}
sourceLabel := leadSourceLabel(p.Source)
contactPerson := p.PersonName
if contactPerson == "" {
contactPerson = p.MemberName
}
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{}{
"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 {
fmt.Printf("[CKBWebhook] 发送失败: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", contactPerson, resp.StatusCode)
}