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

585 lines
19 KiB
Go
Raw Normal View History

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