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[:]) } // 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}) } // CKBLead POST /api/miniprogram/ckb/lead 小程序-链接卡若:上报线索到存客宝,便于卡若添加好友 // 请求体:phone(可选)、wechatId(可选)、name(可选)、userId(可选,用于补全昵称) // 至少传 phone 或 wechatId 之一;签名规则同 api_v1.md func CKBLead(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 == "" && wechatId == "" { c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"}) return } name := strings.TrimSpace(body.Name) if name == "" && body.UserID != "" { var u model.User if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" { name = *u.Nickname } } if name == "" { name = "小程序用户" } db := database.DB() var cond []string var args []interface{} if body.UserID != "" { cond = append(cond, "user_id = ?") args = append(args, body.UserID) } if phone != "" { cond = append(cond, "phone = ?") args = append(args, phone) } if wechatId != "" { cond = append(cond, "wechat_id = ?") args = append(args, wechatId) } // 2 分钟内同一用户/手机/微信只能提交一次(与前端限频一致) if len(cond) > 0 { cutoff := time.Now().Add(-2 * time.Minute) var recentCount int64 if db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Where("created_at > ?", cutoff).Count(&recentCount) == nil && recentCount > 0 { c.JSON(http.StatusOK, gin.H{"success": false, "message": "您操作太频繁,请2分钟后再试"}) return } } // 是否曾留资过(仅用于成功后的提示文案) repeatedSubmit := false if len(cond) > 0 { var existCount int64 repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0 } paramsJSON, _ := json.Marshal(map[string]interface{}{ "userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name, }) _ = db.Create(&model.CkbLeadRecord{ UserID: body.UserID, Nickname: name, Phone: phone, WechatID: wechatId, Name: strings.TrimSpace(body.Name), Params: string(paramsJSON), }).Error ts := time.Now().Unix() // 链接卡若:GET + query(便于浏览器测试),传参:name, phone, wechatId, apiKey, timestamp, sign leadKey := ckbAPIKey if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" { leadKey = cfg.CkbLeadAPIKey } 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() 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"` Data interface{} `json:"data"` } _ = json.Unmarshal(b, &result) if result.Code == 200 { msg := "提交成功,卡若会尽快联系您" if result.Message == "已存在" { 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 } errMsg := result.Message if errMsg == "" { errMsg = "提交失败,请稍后重试" } fmt.Printf("[CKBLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b)) c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg}) }