Files
卡若 76965adb23 chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
2026-03-17 17:50:12 +08:00

585 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"bytes"
"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[:])
}
// 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 = "小程序用户"
}
// 首页固定使用全局密钥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()
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)
}