This commit is contained in:
Alex-larget
2026-03-24 18:45:32 +08:00
parent dcb7961945
commit f3d74ce94a
68 changed files with 2461 additions and 2535 deletions

View File

@@ -2,6 +2,7 @@ package handler
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
@@ -113,6 +114,216 @@ func getCkbLeadApiKey() string {
return ckbAPIKey
}
func markLeadPushSuccess(db *gorm.DB, recordID int64) {
if db == nil || recordID <= 0 {
return
}
now := time.Now()
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
"push_status": "success",
"ckb_error": "",
"last_push_at": now,
"next_retry_at": nil,
}).Error
}
func markLeadPushFailed(db *gorm.DB, recordID int64, errMsg string, incRetry bool) {
if db == nil || recordID <= 0 {
return
}
now := time.Now()
updates := map[string]interface{}{
"push_status": "failed",
"ckb_error": strings.TrimSpace(errMsg),
"last_push_at": now,
"next_retry_at": now.Add(5 * time.Minute),
}
if incRetry {
updates["retry_count"] = gorm.Expr("retry_count + 1")
}
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(updates).Error
}
type ckbLeadPushResult struct {
Code int
Message string
Raw string
}
func pushLeadToCKB(name, phone, wechatId, leadKey string) (ckbLeadPushResult, error) {
ts := time.Now().Unix()
params := map[string]interface{}{
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
if strings.TrimSpace(phone) != "" {
params["phone"] = strings.TrimSpace(phone)
}
if strings.TrimSpace(wechatId) != "" {
params["wechatId"] = strings.TrimSpace(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 v, ok := params["phone"].(string); ok && v != "" {
q.Set("phone", v)
}
if v, ok := params["wechatId"].(string); ok && v != "" {
q.Set("wechatId", v)
}
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
resp, err := http.Get(reqURL)
if err != nil {
return ckbLeadPushResult{}, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(b, &result)
msg := strings.TrimSpace(result.Message)
if msg == "" {
msg = strings.TrimSpace(result.Msg)
}
return ckbLeadPushResult{
Code: result.Code,
Message: msg,
Raw: string(b),
}, nil
}
func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord) bool {
select {
case <-ctx.Done():
return false
default:
}
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", r.ID).Update("push_status", "pending").Error
var p map[string]interface{}
_ = json.Unmarshal([]byte(r.Params), &p)
source := strings.TrimSpace(r.Source)
name := strings.TrimSpace(r.Nickname)
if name == "" {
if v, ok := p["name"].(string); ok {
name = strings.TrimSpace(v)
}
}
if name == "" {
name = "小程序用户"
}
phone := strings.TrimSpace(r.Phone)
wechatId := strings.TrimSpace(r.WechatID)
if phone == "" {
if v, ok := p["phone"].(string); ok {
phone = strings.TrimSpace(v)
}
}
if wechatId == "" {
if v, ok := p["wechatId"].(string); ok {
wechatId = strings.TrimSpace(v)
}
}
leadKey := getCkbLeadApiKey()
targetName := ""
targetMemberID := ""
targetMemberName := ""
leadUserID := strings.TrimSpace(r.UserID)
if v, ok := p["userId"].(string); ok && leadUserID == "" {
leadUserID = strings.TrimSpace(v)
}
if source != "index_link_button" {
if v, ok := p["targetUserId"].(string); ok && strings.TrimSpace(v) != "" {
var person model.Person
if db.Where("token = ?", strings.TrimSpace(v)).First(&person).Error == nil && strings.TrimSpace(person.CkbApiKey) != "" {
leadKey = strings.TrimSpace(person.CkbApiKey)
targetName = strings.TrimSpace(person.Name)
if person.UserID != nil {
targetMemberID = strings.TrimSpace(*person.UserID)
}
}
}
if v, ok := p["targetNickname"].(string); ok && strings.TrimSpace(v) != "" {
targetName = strings.TrimSpace(v)
}
if v, ok := p["targetMemberId"].(string); ok && strings.TrimSpace(v) != "" {
targetMemberID = strings.TrimSpace(v)
}
if v, ok := p["targetMemberName"].(string); ok && strings.TrimSpace(v) != "" {
targetMemberName = strings.TrimSpace(v)
}
}
res, perr := pushLeadToCKB(name, phone, wechatId, leadKey)
if perr != nil {
markLeadPushFailed(db, r.ID, perr.Error(), true)
return false
}
if res.Code == 200 {
markLeadPushSuccess(db, r.ID)
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: targetName,
MemberName: targetMemberName,
TargetMemberID: targetMemberID,
Source: source,
Repeated: true,
LeadUserID: leadUserID,
})
return true
}
msg := res.Message
if msg == "" {
msg = "重推失败"
}
markLeadPushFailed(db, r.ID, msg, true)
return false
}
func RetryCkbLeadByID(ctx context.Context, recordID int64) (bool, error) {
if recordID <= 0 {
return false, fmt.Errorf("recordID 无效")
}
db := database.DB()
var r model.CkbLeadRecord
if err := db.Where("id = ?", recordID).First(&r).Error; err != nil {
return false, err
}
return retryOneLeadRecord(ctx, db, r), nil
}
// RetryFailedCkbLeads 重推存客宝失败留资记录(供定时任务调用)
func RetryFailedCkbLeads(ctx context.Context, limit int) (retried, success int, err error) {
if limit < 1 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
db := database.DB()
now := time.Now()
var rows []model.CkbLeadRecord
if err := db.Where("push_status = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", "failed", now).
Order("id ASC").Limit(limit).Find(&rows).Error; err != nil {
return 0, 0, err
}
retried = len(rows)
for i := range rows {
if retryOneLeadRecord(ctx, db, rows[i]) {
success++
}
}
return retried, success, nil
}
// CKBJoin POST /api/ckb/join
func CKBJoin(c *gin.Context) {
var body struct {
@@ -388,15 +599,17 @@ func CKBIndexLead(c *gin.Context) {
"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
rec := model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
PushStatus: "pending",
}
_ = db.Create(&rec).Error
ts := time.Now().Unix()
params := map[string]interface{}{
@@ -417,7 +630,8 @@ func CKBIndexLead(c *gin.Context) {
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
return
}
defer resp.Body.Close()
@@ -429,6 +643,7 @@ func CKBIndexLead(c *gin.Context) {
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
markLeadPushSuccess(db, rec.ID)
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) != "" {
@@ -451,14 +666,14 @@ func CKBIndexLead(c *gin.Context) {
personName = defaultPerson.Name
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
TargetMemberID: "",
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
@@ -478,12 +693,8 @@ func CKBIndexLead(c *gin.Context) {
}
}
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,
})
markLeadPushFailed(db, rec.ID, errMsg, true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)、文章@某人、超级个体详情点头像
@@ -496,9 +707,9 @@ func CKBLead(c *gin.Context) {
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
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)
@@ -529,21 +740,19 @@ func CKBLead(c *gin.Context) {
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)
fmt.Printf("[CKBLead] 未找到人物 token=%s回退全局获客池\n", body.TargetUserID)
} else {
if strings.TrimSpace(p.CkbApiKey) != "" {
leadKey = p.CkbApiKey
}
personTips = strings.TrimSpace(p.Tips)
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
}
}
@@ -565,7 +774,7 @@ func CKBLead(c *gin.Context) {
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
rec := model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
@@ -574,7 +783,9 @@ func CKBLead(c *gin.Context) {
TargetPersonID: body.TargetUserID,
Source: source,
Params: string(paramsJSON),
}).Error
PushStatus: "pending",
}
_ = db.Create(&rec).Error
ts := time.Now().Unix()
params := map[string]interface{}{
@@ -605,7 +816,8 @@ func CKBLead(c *gin.Context) {
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
return
}
defer resp.Body.Close()
@@ -618,6 +830,7 @@ func CKBLead(c *gin.Context) {
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
markLeadPushSuccess(db, rec.ID)
who := targetName
if who == "" {
who = "对方"
@@ -639,15 +852,15 @@ func CKBLead(c *gin.Context) {
data["repeatedSubmit"] = repeatedSubmit
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
@@ -713,16 +926,17 @@ func CKBLead(c *gin.Context) {
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
markLeadPushSuccess(db, rec.ID)
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
}
@@ -737,9 +951,10 @@ func CKBLead(c *gin.Context) {
}
}
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d msg=%s raw=%s leadKey=%s\n", result.Code, ckbMsg, string(b), leadKey)
markLeadPushFailed(db, rec.ID, errMsg, true)
respObj := gin.H{
"success": false,
"message": errMsg,
"success": true,
"message": "添加成功,我们正在为您安排对接",
"ckbCode": result.Code,
"ckbMessage": ckbMsg,
}
@@ -750,15 +965,15 @@ func CKBLead(c *gin.Context) {
}
type leadWebhookPayload struct {
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
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用于查询行为轨迹
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
}
func leadSourceLabel(source string) string {
@@ -783,7 +998,7 @@ func leadSourceLabel(source string) string {
var _webhookDedupCache = struct {
sync.Mutex
m map[string]string
m map[string]string
}{m: make(map[string]string)}
func webhookShouldSkip(userId string, targetMemberID string) bool {