2026-03-06 17:52:52 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
2026-03-24 18:45:32 +08:00
|
|
|
|
"context"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"crypto/md5"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
2026-03-23 18:38:23 +08:00
|
|
|
|
"log"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"net/http"
|
2026-03-07 21:30:40 +08:00
|
|
|
|
"net/url"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"sort"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
2026-03-23 18:38:23 +08:00
|
|
|
|
"sync"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"gorm.io/gorm"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
|
2026-03-07 21:30:40 +08:00
|
|
|
|
"soul-api/internal/config"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"soul-api/internal/database"
|
|
|
|
|
|
"soul-api/internal/model"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-07 21:30:40 +08:00
|
|
|
|
// 存客宝 API Key 约定(详见 开发文档/8、部署/存客宝API-Key约定.md):
|
|
|
|
|
|
// - 链接卡若(添加卡若好友):使用 CKB_LEAD_API_KEY(.env 配置),未配则用下方 ckbAPIKey
|
|
|
|
|
|
// - 其他场景(join/match 等):使用 ckbAPIKey
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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": "创业合伙,创业伙伴"}
|
|
|
|
|
|
|
2026-03-26 20:26:35 +08:00
|
|
|
|
// ckbLeadSaveUnified 将 join/match 等也落到 ckb_lead_records(与 lead 同表),用于后台统一查看推送状态与存客宝响应快照
|
|
|
|
|
|
func ckbLeadSaveUnified(action, userID, nickname, phone, wechatId, source, planAPIKey string, params interface{}) int64 {
|
2026-03-07 21:30:40 +08:00
|
|
|
|
paramsJSON, _ := json.Marshal(params)
|
2026-03-26 20:26:35 +08:00
|
|
|
|
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
|
2026-03-07 21:30:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// 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[:])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 15:44:08 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 20:26:35 +08:00
|
|
|
|
// 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{}) {
|
2026-03-24 18:45:32 +08:00
|
|
|
|
if db == nil || recordID <= 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-26 20:26:35 +08:00
|
|
|
|
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:落库响应快照 + 标记失败
|
2026-03-24 18:45:32 +08:00
|
|
|
|
now := time.Now()
|
2026-03-26 20:26:35 +08:00
|
|
|
|
updates := map[string]interface{}{
|
|
|
|
|
|
"push_status": "failed",
|
|
|
|
|
|
"ckb_code": code,
|
|
|
|
|
|
"ckb_message": msg,
|
|
|
|
|
|
"ckb_data": dataJSON,
|
|
|
|
|
|
"ckb_error": strings.TrimSpace(errMsg),
|
2026-03-24 18:45:32 +08:00
|
|
|
|
"last_push_at": now,
|
2026-03-26 20:26:35 +08:00
|
|
|
|
"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
|
2026-03-24 18:45:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-03-26 20:26:35 +08:00
|
|
|
|
"ckb_code": 0,
|
|
|
|
|
|
"ckb_message": "",
|
|
|
|
|
|
"ckb_data": "",
|
2026-03-24 18:45:32 +08:00
|
|
|
|
"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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 15:47:31 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 20:26:35 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 18:45:32 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-26 20:26:35 +08:00
|
|
|
|
leadKey := strings.TrimSpace(r.PlanAPIKey)
|
|
|
|
|
|
if leadKey == "" {
|
|
|
|
|
|
leadKey = getCkbLeadApiKey()
|
|
|
|
|
|
}
|
2026-03-24 18:45:32 +08:00
|
|
|
|
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) != "" {
|
2026-03-25 15:47:31 +08:00
|
|
|
|
if person, found := resolvePersonForLead(db, v); found && strings.TrimSpace(person.CkbApiKey) != "" {
|
2026-03-24 18:45:32 +08:00
|
|
|
|
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 {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
applyCkbLeadPushOutcome(db, r.ID, res.Code, res.Message, nil)
|
2026-03-24 18:45:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// CKBJoin POST /api/ckb/join
|
|
|
|
|
|
func CKBJoin(c *gin.Context) {
|
|
|
|
|
|
var body struct {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
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"` // 资源对接:我能帮到你什么
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-24 15:44:08 +08:00
|
|
|
|
if body.Type == "investor" && body.UserID != "" {
|
|
|
|
|
|
if !userHasContentPurchase(database.DB(), body.UserID) {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
// 交互原则:用户侧友好提示(不暴露存客宝/规则细节);后台可通过规则引导再完善
|
2026-03-24 15:44:08 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
2026-03-26 20:26:35 +08:00
|
|
|
|
"success": true,
|
|
|
|
|
|
"message": "提交成功,我们会尽快联系您",
|
2026-03-24 15:44:08 +08:00
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-07 21:30:40 +08:00
|
|
|
|
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 = "-"
|
|
|
|
|
|
}
|
2026-03-26 20:26:35 +08:00
|
|
|
|
submitParams := map[string]interface{}{
|
2026-03-07 21:30:40 +08:00
|
|
|
|
"type": body.Type, "phone": body.Phone, "wechat": body.Wechat, "name": body.Name,
|
|
|
|
|
|
"userId": body.UserID, "remark": body.Remark, "canHelp": body.CanHelp, "needHelp": body.NeedHelp,
|
2026-03-26 20:26:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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 {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
// 用户侧保持友好:无论存客宝是否响应,都提示提交成功;后台用 push_status/ckb_error 追踪
|
|
|
|
|
|
markLeadPushFailed(database.DB(), leadID, err.Error(), true)
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功,我们会尽快联系您"})
|
2026-03-06 17:52:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
b, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
var result struct {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
|
Message string `json:"message"`
|
2026-03-06 17:52:52 +08:00
|
|
|
|
Data interface{} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = json.Unmarshal(b, &result)
|
|
|
|
|
|
if result.Code == 200 {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, result.Data)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// 资源对接:同步更新用户资料中的 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 = "加入失败,请稍后重试"
|
|
|
|
|
|
}
|
2026-03-26 20:26:35 +08:00
|
|
|
|
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, result.Data)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
// 打印 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))
|
2026-03-26 20:26:35 +08:00
|
|
|
|
// 用户侧仍提示提交成功
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功,我们会尽快联系您"})
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CKBMatch POST /api/ckb/match
|
|
|
|
|
|
func CKBMatch(c *gin.Context) {
|
|
|
|
|
|
var body struct {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
MatchType string `json:"matchType"`
|
|
|
|
|
|
Phone string `json:"phone"`
|
|
|
|
|
|
Wechat string `json:"wechat"`
|
|
|
|
|
|
UserID string `json:"userId"`
|
|
|
|
|
|
Nickname string `json:"nickname"`
|
2026-03-06 17:52:52 +08:00
|
|
|
|
MatchedUser interface{} `json:"matchedUser"`
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = c.ShouldBindJSON(&body)
|
|
|
|
|
|
if body.Phone == "" && body.Wechat == "" {
|
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-07 21:30:40 +08:00
|
|
|
|
nickname := strings.TrimSpace(body.Nickname)
|
|
|
|
|
|
if nickname == "" {
|
|
|
|
|
|
nickname = "-"
|
|
|
|
|
|
}
|
2026-03-26 20:26:35 +08:00
|
|
|
|
submitParams := map[string]interface{}{
|
2026-03-07 21:30:40 +08:00
|
|
|
|
"matchType": body.MatchType, "phone": body.Phone, "wechat": body.Wechat,
|
|
|
|
|
|
"userId": body.UserID, "nickname": body.Nickname, "matchedUser": body.MatchedUser,
|
2026-03-26 20:26:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
ts := time.Now().Unix()
|
|
|
|
|
|
label := ckbSourceMap[body.MatchType]
|
|
|
|
|
|
if label == "" {
|
|
|
|
|
|
label = "创业合伙"
|
|
|
|
|
|
}
|
|
|
|
|
|
params := map[string]interface{}{
|
|
|
|
|
|
"timestamp": ts,
|
|
|
|
|
|
"source": "创业实验-找伙伴匹配",
|
|
|
|
|
|
"tags": "找伙伴," + label,
|
|
|
|
|
|
"siteTags": "创业实验APP,匹配用户",
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"remark": "用户发起" + label + "匹配",
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
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),
|
|
|
|
|
|
},
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"remark": "找伙伴匹配-" + label,
|
2026-03-06 17:52:52 +08:00
|
|
|
|
"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 {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
fmt.Printf("[CKBMatch] 请求存客宝失败: %v\n", err)
|
2026-03-26 20:26:35 +08:00
|
|
|
|
markLeadPushFailed(database.DB(), leadID, err.Error(), true)
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功"})
|
2026-03-06 17:52:52 +08:00
|
|
|
|
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 {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, nil)
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功", "data": nil})
|
2026-03-06 17:52:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-26 20:26:35 +08:00
|
|
|
|
applyCkbLeadPushOutcome(database.DB(), leadID, result.Code, result.Message, nil)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
fmt.Printf("[CKBMatch] 存客宝返回异常 code=%d message=%s\n", result.Code, result.Message)
|
2026-03-26 20:26:35 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "提交成功"})
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CKBSync GET/POST /api/ckb/sync
|
|
|
|
|
|
func CKBSync(c *gin.Context) {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 11:36:50 +08:00
|
|
|
|
// 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 = "小程序用户"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 首页固定使用全局密钥:system_config > .env > 代码内置
|
|
|
|
|
|
leadKey := getCkbLeadApiKey()
|
2026-03-22 08:34:28 +08:00
|
|
|
|
if leadKey == "" {
|
|
|
|
|
|
fmt.Printf("[CKBIndexLead] 警告:CKB_LEAD_API_KEY 未配置,使用默认密钥\n")
|
|
|
|
|
|
leadKey = ckbAPIKey
|
|
|
|
|
|
}
|
2026-03-12 11:36:50 +08:00
|
|
|
|
|
2026-03-16 09:21:39 +08:00
|
|
|
|
// 去重:同一用户只记录一次(首页链接卡若)
|
|
|
|
|
|
repeatedSubmit := false
|
2026-03-12 11:36:50 +08:00
|
|
|
|
if body.UserID != "" {
|
2026-03-16 09:21:39 +08:00
|
|
|
|
var existCount int64
|
|
|
|
|
|
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND source = ?", body.UserID, "index_link_button").Count(&existCount)
|
|
|
|
|
|
repeatedSubmit = existCount > 0
|
2026-03-12 11:36:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
source := "index_link_button"
|
|
|
|
|
|
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
|
|
|
|
|
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
|
|
|
|
|
"source": source,
|
|
|
|
|
|
})
|
2026-03-24 18:45:32 +08:00
|
|
|
|
rec := model.CkbLeadRecord{
|
2026-03-26 20:26:35 +08:00
|
|
|
|
Action: "lead",
|
2026-03-24 18:45:32 +08:00
|
|
|
|
UserID: body.UserID,
|
|
|
|
|
|
Nickname: name,
|
|
|
|
|
|
Phone: phone,
|
|
|
|
|
|
WechatID: wechatId,
|
|
|
|
|
|
Name: strings.TrimSpace(body.Name),
|
|
|
|
|
|
Source: source,
|
2026-03-26 20:26:35 +08:00
|
|
|
|
PlanAPIKey: leadKey,
|
2026-03-24 18:45:32 +08:00
|
|
|
|
Params: string(paramsJSON),
|
|
|
|
|
|
PushStatus: "pending",
|
|
|
|
|
|
}
|
2026-03-26 20:26:35 +08:00
|
|
|
|
// 与全局 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
|
|
|
|
|
|
}
|
2026-03-24 18:45:32 +08:00
|
|
|
|
_ = db.Create(&rec).Error
|
2026-03-12 11:36:50 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-03-16 17:18:49 +08:00
|
|
|
|
fmt.Printf("[CKBIndexLead] 请求存客宝完整链接: %s\n", reqURL)
|
2026-03-12 11:36:50 +08:00
|
|
|
|
resp, err := http.Get(reqURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
|
2026-03-24 18:45:32 +08:00
|
|
|
|
markLeadPushFailed(db, rec.ID, err.Error(), true)
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
|
2026-03-12 11:36:50 +08:00
|
|
|
|
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 {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
applyCkbLeadPushOutcome(db, rec.ID, result.Code, result.Message, result.Data)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
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 {
|
2026-03-12 11:36:50 +08:00
|
|
|
|
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
|
2026-03-22 08:34:28 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
msg = "提交成功,卡若会尽快联系您"
|
2026-03-12 11:36:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
data := gin.H{}
|
|
|
|
|
|
if result.Data != nil {
|
|
|
|
|
|
if m, ok := result.Data.(map[string]interface{}); ok {
|
|
|
|
|
|
data = m
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
data["repeatedSubmit"] = repeatedSubmit
|
2026-03-23 18:38:23 +08:00
|
|
|
|
|
|
|
|
|
|
personName := "卡若"
|
|
|
|
|
|
if defaultPerson.Name != "" {
|
|
|
|
|
|
personName = defaultPerson.Name
|
|
|
|
|
|
}
|
|
|
|
|
|
go sendLeadWebhook(db, leadWebhookPayload{
|
2026-03-24 18:45:32 +08:00
|
|
|
|
LeadName: name,
|
|
|
|
|
|
Phone: phone,
|
|
|
|
|
|
Wechat: wechatId,
|
|
|
|
|
|
PersonName: personName,
|
2026-03-24 01:22:50 +08:00
|
|
|
|
TargetMemberID: "",
|
2026-03-24 18:45:32 +08:00
|
|
|
|
Source: source,
|
|
|
|
|
|
Repeated: repeatedSubmit,
|
|
|
|
|
|
LeadUserID: body.UserID,
|
2026-03-23 18:38:23 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-12 11:36:50 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// 存客宝返回失败,透传其错误信息与 code,便于前端/运营判断原因
|
|
|
|
|
|
errMsg := strings.TrimSpace(result.Message)
|
|
|
|
|
|
if errMsg == "" {
|
|
|
|
|
|
errMsg = "提交失败,请稍后重试"
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 特殊处理:无效的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)
|
2026-03-24 18:45:32 +08:00
|
|
|
|
markLeadPushFailed(db, rec.ID, errMsg, true)
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
|
2026-03-12 11:36:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)、文章@某人、超级个体详情点头像
|
|
|
|
|
|
// 请求体:phone/wechatId(至少一个)、userId(补全昵称)、targetUserId(Person.token)、targetNickname、source(如 article_mention、member_detail_avatar)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
func CKBLead(c *gin.Context) {
|
|
|
|
|
|
var body struct {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
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"` // 被@的人显示名(用于文案)
|
2026-03-24 18:45:32 +08:00
|
|
|
|
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id(无 person token 时全局留资,写入 params 便于运营)
|
|
|
|
|
|
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params,不误导读为「对方会联系您」)
|
|
|
|
|
|
Source string `json:"source"` // index_lead / article_mention / member_detail_global
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
_ = 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)
|
2026-03-10 18:06:10 +08:00
|
|
|
|
db := database.DB()
|
2026-03-06 17:52:52 +08:00
|
|
|
|
if name == "" && body.UserID != "" {
|
|
|
|
|
|
var u model.User
|
2026-03-10 18:06:10 +08:00
|
|
|
|
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
2026-03-06 17:52:52 +08:00
|
|
|
|
name = *u.Nickname
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
name = "小程序用户"
|
|
|
|
|
|
}
|
2026-03-10 18:06:10 +08:00
|
|
|
|
|
2026-03-16 17:18:49 +08:00
|
|
|
|
// 存客宝 scenarios 内部 API 需要计划级 apiKey(persons.ckb_api_key),不是 token
|
2026-03-25 15:47:31 +08:00
|
|
|
|
// 文章 @ 场景:targetUserId 一般为 Person.token;兼容 person_id,与列表展示双键解析一致
|
2026-03-16 17:18:49 +08:00
|
|
|
|
// 首页链接卡若:targetUserId 为空 → 用全局 getCkbLeadApiKey()
|
2026-03-14 14:37:17 +08:00
|
|
|
|
leadKey := getCkbLeadApiKey()
|
2026-03-16 17:18:49 +08:00
|
|
|
|
targetName := strings.TrimSpace(body.TargetNickname)
|
2026-03-24 01:22:50 +08:00
|
|
|
|
targetMemberID := strings.TrimSpace(body.TargetMemberID)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
personTips := "" // Person 配置的获客成功提示,优先于默认文案
|
2026-03-10 18:06:10 +08:00
|
|
|
|
if body.TargetUserID != "" {
|
2026-03-25 15:47:31 +08:00
|
|
|
|
if p, ok := resolvePersonForLead(db, body.TargetUserID); ok {
|
2026-03-24 18:45:32 +08:00
|
|
|
|
if strings.TrimSpace(p.CkbApiKey) != "" {
|
2026-03-25 15:47:31 +08:00
|
|
|
|
leadKey = strings.TrimSpace(p.CkbApiKey)
|
2026-03-24 18:45:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
personTips = strings.TrimSpace(p.Tips)
|
|
|
|
|
|
if targetName == "" {
|
|
|
|
|
|
targetName = p.Name
|
|
|
|
|
|
}
|
|
|
|
|
|
if targetMemberID == "" {
|
|
|
|
|
|
if p.UserID != nil {
|
|
|
|
|
|
targetMemberID = strings.TrimSpace(*p.UserID)
|
|
|
|
|
|
}
|
2026-03-24 01:22:50 +08:00
|
|
|
|
}
|
2026-03-25 15:47:31 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
fmt.Printf("[CKBLead] 未找到人物 targetUserId=%s(非 token/person_id),回退全局 apiKey\n", body.TargetUserID)
|
2026-03-24 01:22:50 +08:00
|
|
|
|
}
|
2026-03-10 18:06:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 15:47:31 +08:00
|
|
|
|
// 去重:同一用户对同一 @人物(targetUserId=Person.token)仅允许一条有效留资;已存在则不再写库、不调存客宝(不同人物互不影响)
|
|
|
|
|
|
targetTok := strings.TrimSpace(body.TargetUserID)
|
|
|
|
|
|
if body.UserID != "" && targetTok != "" {
|
2026-03-07 21:30:40 +08:00
|
|
|
|
var existCount int64
|
2026-03-25 15:47:31 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-07 21:30:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:06:10 +08:00
|
|
|
|
source := strings.TrimSpace(body.Source)
|
|
|
|
|
|
if source == "" {
|
|
|
|
|
|
source = "index_lead"
|
|
|
|
|
|
}
|
2026-03-07 21:30:40 +08:00
|
|
|
|
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
|
|
|
|
|
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
2026-03-23 18:38:23 +08:00
|
|
|
|
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
|
|
|
|
|
|
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
|
2026-03-07 21:30:40 +08:00
|
|
|
|
})
|
2026-03-24 18:45:32 +08:00
|
|
|
|
rec := model.CkbLeadRecord{
|
2026-03-26 20:26:35 +08:00
|
|
|
|
Action: "lead",
|
2026-03-10 18:06:10 +08:00
|
|
|
|
UserID: body.UserID,
|
|
|
|
|
|
Nickname: name,
|
|
|
|
|
|
Phone: phone,
|
|
|
|
|
|
WechatID: wechatId,
|
|
|
|
|
|
Name: strings.TrimSpace(body.Name),
|
|
|
|
|
|
TargetPersonID: body.TargetUserID,
|
|
|
|
|
|
Source: source,
|
2026-03-26 20:26:35 +08:00
|
|
|
|
PlanAPIKey: leadKey,
|
2026-03-10 18:06:10 +08:00
|
|
|
|
Params: string(paramsJSON),
|
2026-03-24 18:45:32 +08:00
|
|
|
|
PushStatus: "pending",
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = db.Create(&rec).Error
|
2026-03-10 18:06:10 +08:00
|
|
|
|
|
2026-03-06 17:52:52 +08:00
|
|
|
|
ts := time.Now().Unix()
|
|
|
|
|
|
params := map[string]interface{}{
|
|
|
|
|
|
"name": name,
|
2026-03-07 21:30:40 +08:00
|
|
|
|
"timestamp": ts,
|
|
|
|
|
|
"apiKey": leadKey,
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
if phone != "" {
|
|
|
|
|
|
params["phone"] = phone
|
|
|
|
|
|
}
|
|
|
|
|
|
if wechatId != "" {
|
|
|
|
|
|
params["wechatId"] = wechatId
|
|
|
|
|
|
}
|
2026-03-07 21:30:40 +08:00
|
|
|
|
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()
|
2026-03-16 17:18:49 +08:00
|
|
|
|
fmt.Printf("[CKBLead] 请求存客宝完整链接: %s\n", reqURL)
|
2026-03-07 21:30:40 +08:00
|
|
|
|
resp, err := http.Get(reqURL)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
if err != nil {
|
2026-03-07 21:30:40 +08:00
|
|
|
|
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
|
2026-03-24 18:45:32 +08:00
|
|
|
|
markLeadPushFailed(db, rec.ID, err.Error(), true)
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
|
2026-03-06 17:52:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
b, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
var result struct {
|
2026-03-07 21:30:40 +08:00
|
|
|
|
Code int `json:"code"`
|
2026-03-10 18:06:10 +08:00
|
|
|
|
Message string `json:"message"`
|
2026-03-16 17:18:49 +08:00
|
|
|
|
Msg string `json:"msg"` // 存客保部分接口用 msg 返回错误
|
2026-03-06 17:52:52 +08:00
|
|
|
|
Data interface{} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = json.Unmarshal(b, &result)
|
|
|
|
|
|
if result.Code == 200 {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
applyCkbLeadPushOutcome(db, rec.ID, result.Code, result.Message, result.Data)
|
2026-03-10 18:06:10 +08:00
|
|
|
|
who := targetName
|
|
|
|
|
|
if who == "" {
|
|
|
|
|
|
who = "对方"
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
var msg string
|
|
|
|
|
|
if personTips != "" {
|
|
|
|
|
|
msg = personTips
|
|
|
|
|
|
} else {
|
|
|
|
|
|
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
|
2026-03-07 21:30:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
data := gin.H{}
|
|
|
|
|
|
if result.Data != nil {
|
|
|
|
|
|
if m, ok := result.Data.(map[string]interface{}); ok {
|
|
|
|
|
|
data = m
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-25 15:47:31 +08:00
|
|
|
|
data["repeatedSubmit"] = false
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
go sendLeadWebhook(db, leadWebhookPayload{
|
2026-03-24 18:45:32 +08:00
|
|
|
|
LeadName: name,
|
|
|
|
|
|
Phone: phone,
|
|
|
|
|
|
Wechat: wechatId,
|
|
|
|
|
|
PersonName: who,
|
|
|
|
|
|
MemberName: strings.TrimSpace(body.TargetMemberName),
|
2026-03-24 01:22:50 +08:00
|
|
|
|
TargetMemberID: targetMemberID,
|
2026-03-24 18:45:32 +08:00
|
|
|
|
Source: source,
|
2026-03-25 15:47:31 +08:00
|
|
|
|
Repeated: false,
|
2026-03-24 18:45:32 +08:00
|
|
|
|
LeadUserID: body.UserID,
|
2026-03-23 18:38:23 +08:00
|
|
|
|
})
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
2026-03-07 21:30:40 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
2026-03-06 17:52:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-16 17:18:49 +08:00
|
|
|
|
ckbMsg := strings.TrimSpace(result.Message)
|
|
|
|
|
|
if ckbMsg == "" {
|
|
|
|
|
|
ckbMsg = strings.TrimSpace(result.Msg)
|
|
|
|
|
|
}
|
|
|
|
|
|
errMsg := ckbMsg
|
2026-03-06 17:52:52 +08:00
|
|
|
|
if errMsg == "" {
|
|
|
|
|
|
errMsg = "提交失败,请稍后重试"
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 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 {
|
2026-03-26 20:26:35 +08:00
|
|
|
|
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", rec.ID).Update("plan_api_key", globalKey).Error
|
|
|
|
|
|
applyCkbLeadPushOutcome(db, rec.ID, rr.Code, rr.Message, rr.Data)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
who := targetName
|
|
|
|
|
|
if who == "" {
|
|
|
|
|
|
who = "对方"
|
|
|
|
|
|
}
|
|
|
|
|
|
var msg string
|
|
|
|
|
|
if personTips != "" {
|
|
|
|
|
|
msg = personTips
|
|
|
|
|
|
} else {
|
|
|
|
|
|
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
|
|
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
go sendLeadWebhook(db, leadWebhookPayload{
|
2026-03-24 18:45:32 +08:00
|
|
|
|
LeadName: name,
|
|
|
|
|
|
Phone: phone,
|
|
|
|
|
|
Wechat: wechatId,
|
|
|
|
|
|
PersonName: who,
|
|
|
|
|
|
MemberName: strings.TrimSpace(body.TargetMemberName),
|
2026-03-24 01:22:50 +08:00
|
|
|
|
TargetMemberID: targetMemberID,
|
2026-03-24 18:45:32 +08:00
|
|
|
|
Source: source,
|
2026-03-25 15:47:31 +08:00
|
|
|
|
Repeated: false,
|
2026-03-24 18:45:32 +08:00
|
|
|
|
LeadUserID: body.UserID,
|
2026-03-23 18:38:23 +08:00
|
|
|
|
})
|
2026-03-22 08:34:28 +08:00
|
|
|
|
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)
|
2026-03-24 18:45:32 +08:00
|
|
|
|
markLeadPushFailed(db, rec.ID, errMsg, true)
|
2026-03-16 17:18:49 +08:00
|
|
|
|
respObj := gin.H{
|
2026-03-24 18:45:32 +08:00
|
|
|
|
"success": true,
|
|
|
|
|
|
"message": "添加成功,我们正在为您安排对接",
|
2026-03-16 17:18:49 +08:00
|
|
|
|
"ckbCode": result.Code,
|
|
|
|
|
|
"ckbMessage": ckbMsg,
|
|
|
|
|
|
}
|
|
|
|
|
|
if ckbMsg == "" && len(b) > 0 {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
respObj["ckbRaw"] = string(b)
|
2026-03-16 17:18:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, respObj)
|
2026-03-06 17:52:52 +08:00
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
type leadWebhookPayload struct {
|
2026-03-24 18:45:32 +08:00
|
|
|
|
LeadName string // 留资客户姓名
|
|
|
|
|
|
Phone string
|
|
|
|
|
|
Wechat string
|
|
|
|
|
|
PersonName string // 对接人(Person 表 name / targetNickname)
|
|
|
|
|
|
MemberName string // 超级个体名称(targetMemberName)
|
2026-03-24 01:22:50 +08:00
|
|
|
|
TargetMemberID string // 超级个体 userId,用于按人路由 webhook
|
2026-03-24 18:45:32 +08:00
|
|
|
|
Source string // 技术来源标识
|
|
|
|
|
|
Repeated bool
|
|
|
|
|
|
LeadUserID string // 留资用户ID,用于查询行为轨迹
|
2026-03-23 18:38:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 "首页·留资弹窗"
|
2026-03-25 15:47:31 +08:00
|
|
|
|
case "home_pinned_person":
|
|
|
|
|
|
return "首页·置顶@人物"
|
2026-03-23 18:38:23 +08:00
|
|
|
|
default:
|
|
|
|
|
|
if source == "" {
|
|
|
|
|
|
return "未知来源"
|
|
|
|
|
|
}
|
|
|
|
|
|
return source
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var _webhookDedupCache = struct {
|
|
|
|
|
|
sync.Mutex
|
2026-03-24 18:45:32 +08:00
|
|
|
|
m map[string]string
|
2026-03-23 18:38:23 +08:00
|
|
|
|
}{m: make(map[string]string)}
|
|
|
|
|
|
|
2026-03-24 01:22:50 +08:00
|
|
|
|
func webhookShouldSkip(userId string, targetMemberID string) bool {
|
|
|
|
|
|
if userId == "" && targetMemberID == "" {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
today := time.Now().Format("2006-01-02")
|
2026-03-24 01:22:50 +08:00
|
|
|
|
key := strings.TrimSpace(userId) + "|" + strings.TrimSpace(targetMemberID)
|
|
|
|
|
|
if key == "|" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
_webhookDedupCache.Lock()
|
|
|
|
|
|
defer _webhookDedupCache.Unlock()
|
2026-03-24 01:22:50 +08:00
|
|
|
|
if _webhookDedupCache.m[key] == today {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-03-24 01:22:50 +08:00
|
|
|
|
_webhookDedupCache.m[key] = today
|
2026-03-23 18:38:23 +08:00
|
|
|
|
if len(_webhookDedupCache.m) > 10000 {
|
2026-03-24 01:22:50 +08:00
|
|
|
|
_webhookDedupCache.m = map[string]string{key: today}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 01:22:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
}
|
2026-03-24 01:22:50 +08:00
|
|
|
|
// 回退全局获客 webhook
|
2026-03-22 08:34:28 +08:00
|
|
|
|
var cfg model.SystemConfig
|
|
|
|
|
|
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
|
2026-03-24 01:22:50 +08:00
|
|
|
|
return ""
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
var webhookURL string
|
|
|
|
|
|
if len(cfg.ConfigValue) > 0 {
|
|
|
|
|
|
_ = json.Unmarshal(cfg.ConfigValue, &webhookURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
webhookURL = strings.TrimSpace(webhookURL)
|
|
|
|
|
|
if webhookURL == "" || !strings.HasPrefix(webhookURL, "http") {
|
2026-03-24 01:22:50 +08:00
|
|
|
|
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 == "" {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
tag := "📋 新获客"
|
|
|
|
|
|
if p.Repeated {
|
|
|
|
|
|
tag = "🔄 重复获客"
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
sourceLabel := leadSourceLabel(p.Source)
|
|
|
|
|
|
|
|
|
|
|
|
contactPerson := p.PersonName
|
|
|
|
|
|
if contactPerson == "" {
|
|
|
|
|
|
contactPerson = p.MemberName
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
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)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
if p.Wechat != "" {
|
|
|
|
|
|
text += fmt.Sprintf("\n微信: %s", p.Wechat)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
text += fmt.Sprintf("\n时间: %s", time.Now().Format("2006-01-02 15:04"))
|
|
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
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()
|
2026-03-23 18:38:23 +08:00
|
|
|
|
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", contactPerson, resp.StatusCode)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|