- Added a new entry for the 存客宝计划 Key in the documentation, ensuring consistency between the displayed planKey and the apiKey used for retries. - Implemented a caching mechanism for the JWT token in the ckbOpenGetToken function to reduce redundant API calls. - Refactored the lead management logic to ensure that the same planKey is used for both displaying leads and retrying failed pushes, enhancing data integrity and reducing discrepancies. This update aims to streamline lead management processes and improve the reliability of API interactions.
1403 lines
45 KiB
Go
1403 lines
45 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"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
|
||
}
|
||
|
||
// resolveLeadPlanAPIKeyForRetry 解析本条线索应采信的存客宝计划 apiKey(GET /api/db/ckb-leads mode=contact 的 planApiKey、
|
||
// 以及 retryOneLeadRecord 重推 pushLeadToCKB 共用本函数,禁止两处手写两套顺序):
|
||
// 1) ckb_lead_records.plan_api_key
|
||
// 2) action/source 默认(join/match → 内置 ckbAPIKey;lead+index_link_button → getCkbLeadApiKey)
|
||
// 3) 仍为空:按 target_person_id 或 params.targetUserId 查人物 persons.ckb_api_key
|
||
// 4) index_link 且无 target:与列表一致,用「全局 leadKey 对应 Person」兜底
|
||
// 5) 最后才 getCkbLeadApiKey()
|
||
//
|
||
// 旧逻辑在 plan_api_key 为空时直接 getCkbLeadApiKey(),且非 index_link 时用人物 key 无条件覆盖 —
|
||
// 会导致:列表因人物兜底显示 2y4v5…,重推却带全局 IPQ9s…;或在库内已有 plan_api_key 时仍被人物旧 key 覆盖。
|
||
func resolveLeadPlanAPIKeyForRetry(db *gorm.DB, r model.CkbLeadRecord, p map[string]interface{}) string {
|
||
planKey := strings.TrimSpace(r.PlanAPIKey)
|
||
action := strings.TrimSpace(r.Action)
|
||
source := strings.TrimSpace(r.Source)
|
||
|
||
if planKey == "" {
|
||
if action == "join" || action == "match" {
|
||
planKey = ckbAPIKey
|
||
} else if action == "lead" && source == "index_link_button" {
|
||
planKey = getCkbLeadApiKey()
|
||
}
|
||
}
|
||
if planKey == "" {
|
||
targetTok := strings.TrimSpace(r.TargetPersonID)
|
||
if targetTok == "" && p != nil {
|
||
if v, ok := p["targetUserId"].(string); ok {
|
||
targetTok = strings.TrimSpace(v)
|
||
}
|
||
}
|
||
if targetTok != "" {
|
||
if person, found := resolvePersonForLead(db, targetTok); found && strings.TrimSpace(person.CkbApiKey) != "" {
|
||
planKey = strings.TrimSpace(person.CkbApiKey)
|
||
}
|
||
}
|
||
}
|
||
if planKey == "" && source == "index_link_button" && strings.TrimSpace(r.TargetPersonID) == "" {
|
||
gk := getCkbLeadApiKey()
|
||
if gk != "" {
|
||
var fp model.Person
|
||
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", gk).First(&fp).Error == nil && strings.TrimSpace(fp.CkbApiKey) != "" {
|
||
planKey = strings.TrimSpace(fp.CkbApiKey)
|
||
}
|
||
}
|
||
}
|
||
if planKey == "" {
|
||
planKey = getCkbLeadApiKey()
|
||
}
|
||
return planKey
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
if wechatId == "" && p != nil {
|
||
if v, ok := p["wechat"].(string); ok {
|
||
wechatId = strings.TrimSpace(v)
|
||
}
|
||
}
|
||
|
||
leadKey := resolveLeadPlanAPIKeyForRetry(db, r, p)
|
||
|
||
targetName := ""
|
||
targetMemberID := ""
|
||
targetMemberName := ""
|
||
leadUserID := strings.TrimSpace(r.UserID)
|
||
if v, ok := p["userId"].(string); ok && leadUserID == "" {
|
||
leadUserID = strings.TrimSpace(v)
|
||
}
|
||
targetTok := strings.TrimSpace(r.TargetPersonID)
|
||
if targetTok == "" && p != nil {
|
||
if v, ok := p["targetUserId"].(string); ok {
|
||
targetTok = strings.TrimSpace(v)
|
||
}
|
||
}
|
||
if targetTok != "" {
|
||
if person, found := resolvePersonForLead(db, targetTok); found {
|
||
if strings.TrimSpace(person.Name) != "" {
|
||
targetName = strings.TrimSpace(person.Name)
|
||
}
|
||
if person.UserID != nil {
|
||
targetMemberID = strings.TrimSpace(*person.UserID)
|
||
}
|
||
}
|
||
}
|
||
if p != nil {
|
||
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
|
||
}
|
||
// 资源对接:真实用户须已购章节;管理端存客宝工作台「接口测试」固定 userId=admin_test,仅测连通性不做购课校验
|
||
if body.Type == "investor" && body.UserID != "" {
|
||
uid := strings.TrimSpace(body.UserID)
|
||
if uid != "admin_test" && !userHasContentPurchase(database.DB(), body.UserID) {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"error": "请先购买任意章节后再申请资源对接",
|
||
"errorCode": "CONTENT_PURCHASE_REQUIRED",
|
||
})
|
||
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(补全昵称)、targetUserId(Person.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 需要计划级 apiKey(persons.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)
|
||
}
|
||
}
|
||
|
||
// 去重:同一用户对同一 @人物 1 小时内仅允许一次;超过 1 小时允许再次触发(不同人物互不影响)
|
||
targetTok := strings.TrimSpace(body.TargetUserID)
|
||
if body.UserID != "" && targetTok != "" {
|
||
var lastRec model.CkbLeadRecord
|
||
err := db.Model(&model.CkbLeadRecord{}).
|
||
Where("user_id = ? AND target_person_id = ?", body.UserID, targetTok).
|
||
Order("created_at DESC").
|
||
First(&lastRec).Error
|
||
if err == nil && time.Since(lastRec.CreatedAt) < leadRepeatCooldown {
|
||
who := targetName
|
||
if who == "" {
|
||
who = "对方"
|
||
}
|
||
remain := leadRepeatCooldown - time.Since(lastRec.CreatedAt)
|
||
if remain < 0 {
|
||
remain = 0
|
||
}
|
||
minutes := int(math.Ceil(remain.Minutes()))
|
||
if minutes < 1 {
|
||
minutes = 1
|
||
}
|
||
msg := fmt.Sprintf("您已向「%s」提交过,请 %d 分钟后再试", who, minutes)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": msg,
|
||
"data": gin.H{
|
||
"repeatedSubmit": true,
|
||
"skipped": true,
|
||
"cooldownMinutes": minutes,
|
||
},
|
||
})
|
||
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]time.Time
|
||
}{m: make(map[string]time.Time)}
|
||
|
||
const leadRepeatCooldown = time.Hour
|
||
|
||
func webhookShouldSkip(userId string, targetMemberID string) bool {
|
||
if userId == "" && targetMemberID == "" {
|
||
return false
|
||
}
|
||
now := time.Now()
|
||
key := strings.TrimSpace(userId) + "|" + strings.TrimSpace(targetMemberID)
|
||
if key == "|" {
|
||
return false
|
||
}
|
||
_webhookDedupCache.Lock()
|
||
defer _webhookDedupCache.Unlock()
|
||
if lastAt, ok := _webhookDedupCache.m[key]; ok && now.Sub(lastAt) < leadRepeatCooldown {
|
||
return true
|
||
}
|
||
_webhookDedupCache.m[key] = now
|
||
if len(_webhookDedupCache.m) > 10000 {
|
||
_webhookDedupCache.m = map[string]time.Time{key: now}
|
||
}
|
||
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 within 1h", 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)
|
||
}
|