Files
soul-yongping/soul-api/internal/handler/ckb.go

1325 lines
42 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"
"context"
"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": "创业合伙,创业伙伴"}
// ckbLeadSaveUnified 将 join/match 等也落到 ckb_lead_records与 lead 同表),用于后台统一查看推送状态与存客宝响应快照
func ckbLeadSaveUnified(action, userID, nickname, phone, wechatId, source, planAPIKey string, params interface{}) int64 {
paramsJSON, _ := json.Marshal(params)
rec := model.CkbLeadRecord{
Action: strings.TrimSpace(action),
UserID: strings.TrimSpace(userID),
Nickname: strings.TrimSpace(nickname),
Phone: strings.TrimSpace(phone),
WechatID: strings.TrimSpace(wechatId),
Source: strings.TrimSpace(source),
PlanAPIKey: strings.TrimSpace(planAPIKey),
Params: string(paramsJSON),
PushStatus: "pending",
}
if rec.Action == "" {
rec.Action = "lead"
}
_ = database.DB().Create(&rec).Error
return rec.ID
}
// 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[:])
}
// userHasContentPurchase 与小程序资源对接 requirePurchase 一致:已付章节或全书解锁
func userHasContentPurchase(db *gorm.DB, userID string) bool {
if strings.TrimSpace(userID) == "" {
return false
}
var u model.User
if db.Select("has_full_book").Where("id = ?", userID).First(&u).Error == nil {
if u.HasFullBook != nil && *u.HasFullBook {
return true
}
}
var n int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND product_type = ?", userID, "paid", "section").Count(&n)
return n > 0
}
// 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
}
// inferCkbLeadPushStatusFromText 根据存客宝返回文案推断细粒度状态(与后台「获客列表」展示对齐)
func inferCkbLeadPushStatusFromText(msg string) string {
m := strings.TrimSpace(msg)
if m == "" {
return ""
}
ml := strings.ToLower(m)
if strings.Contains(m, "过期") || strings.Contains(ml, "expired") {
return "expired"
}
if (strings.Contains(m, "待") && (strings.Contains(m, "通过") || strings.Contains(m, "验证") || strings.Contains(m, "审核"))) ||
strings.Contains(ml, "pending") || strings.Contains(ml, "queue") {
return "pending_verify"
}
return ""
}
func normalizeCkbDataPushStatus(v string) string {
s := strings.TrimSpace(strings.ToLower(v))
switch s {
case "success", "ok", "done", "sent":
return "success"
case "pending", "waiting", "processing", "queued":
return "pending_verify"
case "expired", "invalid":
return "expired"
case "failed", "fail":
return "failed"
default:
return ""
}
}
// applyCkbLeadPushOutcome 根据存客宝 HTTP 返回更新推送状态success / pending_verify / expired / failed 等)
func applyCkbLeadPushOutcome(db *gorm.DB, recordID int64, code int, message string, data interface{}) {
if db == nil || recordID <= 0 {
return
}
msg := strings.TrimSpace(message)
dataJSON := ""
if data != nil {
if b, err := json.Marshal(data); err == nil {
dataJSON = strings.TrimSpace(string(b))
}
}
if code == 200 {
status := "success"
if dm, ok := data.(map[string]interface{}); ok {
for _, key := range []string{"pushStatus", "push_status", "status", "leadStatus", "state"} {
if raw, ok := dm[key].(string); ok {
if n := normalizeCkbDataPushStatus(raw); n != "" {
status = n
break
}
}
}
}
if status == "success" {
if t := inferCkbLeadPushStatusFromText(msg); t != "" {
status = t
}
}
now := time.Now()
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
"push_status": status,
"ckb_code": code,
"ckb_message": msg,
"ckb_data": dataJSON,
"ckb_error": "",
"last_push_at": now,
"next_retry_at": nil,
}).Error
return
}
if code == 201 || code == 202 {
st := "pending_verify"
if t := inferCkbLeadPushStatusFromText(msg); t != "" {
st = t
}
now := time.Now()
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
"push_status": st,
"ckb_code": code,
"ckb_message": msg,
"ckb_data": dataJSON,
"ckb_error": msg,
"last_push_at": now,
"next_retry_at": nil,
}).Error
return
}
errMsg := msg
if errMsg == "" {
errMsg = fmt.Sprintf("存客宝返回 code=%d", code)
}
// code!=200落库响应快照 + 标记失败
now := time.Now()
updates := map[string]interface{}{
"push_status": "failed",
"ckb_code": code,
"ckb_message": msg,
"ckb_data": dataJSON,
"ckb_error": strings.TrimSpace(errMsg),
"last_push_at": now,
"next_retry_at": now.Add(5 * time.Minute),
"retry_count": gorm.Expr("retry_count + 1"),
}
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(updates).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_code": 0,
"ckb_message": "",
"ckb_data": "",
"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
}
// resolvePersonForLead 按 token 优先、其次 person_id 查人物(与列表 personMap 双键一致)。
// 若仅用 token 查询失败,会误用全局 apiKey存客宝常返回「无效的apiKey」。
func resolvePersonForLead(db *gorm.DB, targetUserID string) (model.Person, bool) {
tok := strings.TrimSpace(targetUserID)
if tok == "" {
return model.Person{}, false
}
var p model.Person
if err := db.Where("token = ?", tok).First(&p).Error; err == nil {
return p, true
}
if err := db.Where("person_id = ?", tok).First(&p).Error; err == nil {
return p, true
}
return model.Person{}, false
}
// existsUnifiedLeadRecent join/match 幂等去重:同用户+动作+来源+联系方式在窗口期内仅保留一条,避免重复点击刷数据
func existsUnifiedLeadRecent(db *gorm.DB, action, userID, source, phone, wechatID string, within time.Duration) bool {
if db == nil {
return false
}
action = strings.TrimSpace(action)
userID = strings.TrimSpace(userID)
source = strings.TrimSpace(source)
phone = strings.TrimSpace(phone)
wechatID = strings.TrimSpace(wechatID)
if action == "" || userID == "" {
return false
}
if phone == "" && wechatID == "" {
return false
}
q := db.Model(&model.CkbLeadRecord{}).
Where("action = ? AND user_id = ? AND source = ?", action, userID, source)
if phone != "" {
q = q.Where("phone = ?", phone)
}
if wechatID != "" {
q = q.Where("wechat_id = ?", wechatID)
}
if within > 0 {
q = q.Where("created_at >= ?", time.Now().Add(-within))
}
var n int64
q.Count(&n)
return n > 0
}
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 := strings.TrimSpace(r.PlanAPIKey)
if leadKey == "" {
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) != "" {
if person, found := resolvePersonForLead(db, v); found && 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 {
applyCkbLeadPushOutcome(db, r.ID, res.Code, res.Message, nil)
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 {
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
}
if body.Type == "investor" && body.UserID != "" {
if !userHasContentPurchase(database.DB(), body.UserID) {
// 交互原则:用户侧友好提示(不暴露存客宝/规则细节);后台可通过规则引导再完善
c.JSON(http.StatusOK, gin.H{
"success": true,
"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 = "-"
}
submitParams := 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,
}
joinSource := "join_" + body.Type
// 幂等:同用户在短时间内重复点击同类加入,不再重复落库
if existsUnifiedLeadRecent(database.DB(), "join", body.UserID, joinSource, body.Phone, body.Wechat, 10*time.Minute) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功,我们会尽快联系您", "data": gin.H{"repeatedSubmit": true}})
return
}
// 同步写入统一线索表(便于后台统一推送状态/响应快照)
leadID := ckbLeadSaveUnified("join", body.UserID, nickname, body.Phone, body.Wechat, joinSource, ckbAPIKey, submitParams)
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 {
// 用户侧保持友好:无论存客宝是否响应,都提示提交成功;后台用 push_status/ckb_error 追踪
markLeadPushFailed(database.DB(), leadID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "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 {
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, result.Data)
// 资源对接:同步更新用户资料中的 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 = "加入失败,请稍后重试"
}
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, result.Data)
// 打印 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": true, "message": "提交成功,我们会尽快联系您"})
}
// 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 = "-"
}
submitParams := map[string]interface{}{
"matchType": body.MatchType, "phone": body.Phone, "wechat": body.Wechat,
"userId": body.UserID, "nickname": body.Nickname, "matchedUser": body.MatchedUser,
}
matchSource := "match_" + body.MatchType
if existsUnifiedLeadRecent(database.DB(), "match", body.UserID, matchSource, body.Phone, body.Wechat, 5*time.Minute) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功", "data": gin.H{"repeatedSubmit": true}})
return
}
leadID := ckbLeadSaveUnified("match", body.UserID, nickname, body.Phone, body.Wechat, matchSource, ckbAPIKey, submitParams)
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)
markLeadPushFailed(database.DB(), leadID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "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 {
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, nil)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功", "data": nil})
return
}
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, nil)
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,
})
rec := model.CkbLeadRecord{
Action: "lead",
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
PlanAPIKey: leadKey,
Params: string(paramsJSON),
PushStatus: "pending",
}
// 与全局 leadKey 绑定的 Person首页「链接卡若」写入 target_person_id 便于后台「对应 @人」展示
var bindPerson model.Person
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&bindPerson).Error == nil && strings.TrimSpace(bindPerson.PersonID) != "" {
rec.TargetPersonID = bindPerson.PersonID
}
_ = db.Create(&rec).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)
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "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 {
applyCkbLeadPushOutcome(db, rec.ID, result.Code, result.Message, result.Data)
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)
markLeadPushFailed(db, rec.ID, errMsg, true)
c.JSON(http.StatusOK, gin.H{"success": true, "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 一般为 Person.token兼容 person_id与列表展示双键解析一致
// 首页链接卡若targetUserId 为空 → 用全局 getCkbLeadApiKey()
leadKey := getCkbLeadApiKey()
targetName := strings.TrimSpace(body.TargetNickname)
targetMemberID := strings.TrimSpace(body.TargetMemberID)
personTips := "" // Person 配置的获客成功提示,优先于默认文案
if body.TargetUserID != "" {
if p, ok := resolvePersonForLead(db, body.TargetUserID); ok {
if strings.TrimSpace(p.CkbApiKey) != "" {
leadKey = strings.TrimSpace(p.CkbApiKey)
}
personTips = strings.TrimSpace(p.Tips)
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
} else {
fmt.Printf("[CKBLead] 未找到人物 targetUserId=%s非 token/person_id回退全局 apiKey\n", body.TargetUserID)
}
}
// 去重:同一用户对同一 @人物targetUserId=Person.token仅允许一条有效留资已存在则不再写库、不调存客宝不同人物互不影响
targetTok := strings.TrimSpace(body.TargetUserID)
if body.UserID != "" && targetTok != "" {
var existCount int64
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND target_person_id = ?", body.UserID, targetTok).Count(&existCount)
if existCount > 0 {
who := targetName
if who == "" {
who = "对方"
}
msg := fmt.Sprintf("您已向「%s」留资过无需重复提交", who)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": msg,
"data": gin.H{
"repeatedSubmit": true,
"skipped": true,
},
})
return
}
}
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,
})
rec := model.CkbLeadRecord{
Action: "lead",
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
TargetPersonID: body.TargetUserID,
Source: source,
PlanAPIKey: leadKey,
Params: string(paramsJSON),
PushStatus: "pending",
}
_ = db.Create(&rec).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)
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "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 {
applyCkbLeadPushOutcome(db, rec.ID, result.Code, result.Message, result.Data)
who := targetName
if who == "" {
who = "对方"
}
var msg string
if personTips != "" {
msg = personTips
} 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"] = false
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: false,
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 {
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", rec.ID).Update("plan_api_key", globalKey).Error
applyCkbLeadPushOutcome(db, rec.ID, rr.Code, rr.Message, rr.Data)
who := targetName
if who == "" {
who = "对方"
}
var msg string
if personTips != "" {
msg = personTips
} 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: false,
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)
markLeadPushFailed(db, rec.ID, errMsg, true)
respObj := gin.H{
"success": true,
"message": "添加成功,我们正在为您安排对接",
"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 "首页·留资弹窗"
case "home_pinned_person":
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)
}