package handler import ( "bytes" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "soul-api/internal/database" "soul-api/internal/model" ) 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": "创业合伙,创业伙伴"} type CKBRouteConfig struct { APIURL string `json:"apiUrl"` APIKey string `json:"apiKey"` Source string `json:"source"` Tags string `json:"tags"` SiteTags string `json:"siteTags"` Notes string `json:"notes"` } type CKBConfigPayload struct { Routes map[string]CKBRouteConfig `json:"routes"` DocNotes string `json:"docNotes"` DocContent string `json:"docContent"` APIURL string `json:"apiUrl,omitempty"` APIKey string `json:"apiKey,omitempty"` } func defaultCKBRouteConfig(routeKey string) CKBRouteConfig { cfg := CKBRouteConfig{ APIURL: ckbAPIURL, APIKey: ckbAPIKey, SiteTags: "创业实验APP", } switch routeKey { case "join_partner": cfg.Source = "创业实验-创业合伙" cfg.Tags = "创业合伙,创业伙伴" case "join_investor": cfg.Source = "创业实验-资源对接" cfg.Tags = "资源对接,资源群" case "join_mentor": cfg.Source = "创业实验-导师顾问" cfg.Tags = "导师顾问,咨询服务" case "join_team": cfg.Source = "创业实验-团队招募" cfg.Tags = "切片团队,团队招募" case "match": cfg.Source = "创业实验-找伙伴匹配" cfg.Tags = "找伙伴" cfg.SiteTags = "创业实验APP,匹配用户" case "lead": cfg.Source = "小程序-链接卡若" cfg.Tags = "链接卡若,创业实验" cfg.SiteTags = "创业实验APP,链接卡若" } return cfg } func getCKBConfigPayload() CKBConfigPayload { payload := CKBConfigPayload{ Routes: map[string]CKBRouteConfig{}, } var cfg model.SystemConfig if err := database.DB().Where("config_key = ?", "ckb_config").First(&cfg).Error; err != nil { return payload } var m map[string]interface{} if err := json.Unmarshal(cfg.ConfigValue, &m); err != nil { return payload } if v, ok := m["docNotes"].(string); ok { payload.DocNotes = v } if v, ok := m["docContent"].(string); ok { payload.DocContent = v } if v, ok := m["apiKey"].(string); ok { payload.APIKey = v } if v, ok := m["apiUrl"].(string); ok { payload.APIURL = v } if routes, ok := m["routes"].(map[string]interface{}); ok { for key, raw := range routes { itemMap, ok := raw.(map[string]interface{}) if !ok { continue } item := defaultCKBRouteConfig(key) if v, ok := itemMap["apiUrl"].(string); ok && strings.TrimSpace(v) != "" { item.APIURL = strings.TrimSpace(v) } if v, ok := itemMap["apiKey"].(string); ok && strings.TrimSpace(v) != "" { item.APIKey = strings.TrimSpace(v) } if v, ok := itemMap["source"].(string); ok && strings.TrimSpace(v) != "" { item.Source = strings.TrimSpace(v) } if v, ok := itemMap["tags"].(string); ok && strings.TrimSpace(v) != "" { item.Tags = strings.TrimSpace(v) } if v, ok := itemMap["siteTags"].(string); ok && strings.TrimSpace(v) != "" { item.SiteTags = strings.TrimSpace(v) } if v, ok := itemMap["notes"].(string); ok { item.Notes = v } payload.Routes[key] = item } } return payload } func getCKBRouteConfig(routeKey string) (cfg CKBRouteConfig, docNotes string, docContent string) { cfg = defaultCKBRouteConfig(routeKey) payload := getCKBConfigPayload() docNotes = payload.DocNotes docContent = payload.DocContent if item, ok := payload.Routes[routeKey]; ok { cfg = item } else { if strings.TrimSpace(payload.APIURL) != "" { cfg.APIURL = strings.TrimSpace(payload.APIURL) } if strings.TrimSpace(payload.APIKey) != "" { cfg.APIKey = strings.TrimSpace(payload.APIKey) } } return cfg, docNotes, docContent } // 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 } routeCfg, _, _ := getCKBRouteConfig("join_" + body.Type) // 先写入 match_records(无论 CKB 是否成功,用户确实提交了表单) if body.UserID != "" { rec := model.MatchRecord{ ID: fmt.Sprintf("mr_ckb_%d", time.Now().UnixNano()), UserID: body.UserID, MatchType: body.Type, } if body.Phone != "" { rec.Phone = &body.Phone } if body.Wechat != "" { rec.WechatID = &body.Wechat } if err := database.DB().Create(&rec).Error; err != nil { fmt.Printf("[CKBJoin] 写入 match_records 失败: %v\n", err) } } ts := time.Now().Unix() params := map[string]interface{}{ "timestamp": ts, "source": routeCfg.Source, "tags": routeCfg.Tags, "siteTags": routeCfg.SiteTags, "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"] = routeCfg.APIKey params["sign"] = ckbSign(params, routeCfg.APIKey) 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(routeCfg.APIURL, "application/json", bytes.NewReader(raw)) if err != nil { fmt.Printf("[CKBJoin] CKB 请求失败: %v (match_records 已写入)\n", err) 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 { // 资源对接:同步更新用户资料中的 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) { routeCfg, _, _ := getCKBRouteConfig("match") 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 } ts := time.Now().Unix() label := ckbSourceMap[body.MatchType] if label == "" { label = "创业合伙" } tags := routeCfg.Tags if label != "" && tags != "" && !strings.Contains(tags, label) { tags = tags + "," + label } params := map[string]interface{}{ "timestamp": ts, "source": routeCfg.Source, "tags": tags, "siteTags": routeCfg.SiteTags, "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"] = routeCfg.APIKey params["sign"] = ckbSign(params, routeCfg.APIKey) 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(routeCfg.APIURL, "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) { routeCfg, _, _ := getCKBRouteConfig("lead") 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 = "小程序用户" } ts := time.Now().Unix() params := map[string]interface{}{ "timestamp": ts, "source": routeCfg.Source, "tags": routeCfg.Tags, "siteTags": routeCfg.SiteTags, "remark": "首页点击「链接卡若」留资", "name": name, } if phone != "" { params["phone"] = phone } if wechatId != "" { params["wechatId"] = wechatId } params["apiKey"] = routeCfg.APIKey params["sign"] = ckbSign(params, routeCfg.APIKey) raw, _ := json.Marshal(params) fmt.Printf("[CKBLead] 请求: phone=%s wechatId=%s name=%s\n", phone, wechatId, name) resp, err := http.Post(routeCfg.APIURL, "application/json", bytes.NewReader(raw)) if err != nil { 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) fmt.Printf("[CKBLead] 响应: code=%d message=%s raw=%s\n", result.Code, result.Message, string(b)) if result.Code == 200 { msg := "提交成功,卡若会尽快联系您" if result.Message == "已存在" { msg = "您已留资,我们会尽快联系您" } c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data}) return } errMsg := result.Message if errMsg == "" { errMsg = "提交失败,请稍后重试" } fmt.Printf("[CKBLead] 失败: phone=%s code=%d message=%s\n", phone, result.Code, result.Message) c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg}) } // CKBPlanStats GET /api/db/ckb-plan-stats 代理存客宝获客计划统计 func CKBPlanStats(c *gin.Context) { routeCfg, docNotes, docContent := getCKBRouteConfig("lead") ts := time.Now().Unix() // 用 scenarios 接口查询方式不可行,存客宝 plan-stats 需要 JWT // 这里用本地 match_records + CKB 签名信息返回聚合统计 db := database.DB() // 各类型提交数量(通过 CKBJoin 写入的 mr_ckb_ 开头的记录) type TypeStat struct { MatchType string `gorm:"column:match_type" json:"matchType"` Total int64 `gorm:"column:total" json:"total"` } var ckbStats []TypeStat db.Raw("SELECT match_type, COUNT(*) as total FROM match_records WHERE id LIKE 'mr_ckb_%' GROUP BY match_type").Scan(&ckbStats) var ckbTotal int64 db.Raw("SELECT COUNT(*) FROM match_records WHERE id LIKE 'mr_ckb_%'").Scan(&ckbTotal) // 各类型有联系方式的数量 var withContact int64 db.Raw("SELECT COUNT(*) FROM match_records WHERE id LIKE 'mr_ckb_%' AND ((phone IS NOT NULL AND phone != '') OR (wechat_id IS NOT NULL AND wechat_id != ''))").Scan(&withContact) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "ckbTotal": ckbTotal, "withContact": withContact, "byType": ckbStats, "ckbApiKey": routeCfg.APIKey[:minInt(len(routeCfg.APIKey), 8)] + "...", "ckbApiUrl": routeCfg.APIURL, "lastSignTest": ts, "docNotes": docNotes, "docContent": docContent, "routes": getCKBConfigPayload().Routes, }, }) } // DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细 func DBCKBLeadList(c *gin.Context) { db := database.DB() mode := strings.TrimSpace(c.DefaultQuery("mode", "submitted")) // submitted|contact matchType := strings.TrimSpace(c.Query("matchType")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } q := db.Model(&model.MatchRecord{}).Where("id LIKE 'mr_ckb_%'") if matchType != "" { q = q.Where("match_type = ?", matchType) } if mode == "contact" { q = q.Where("((phone IS NOT NULL AND phone != '') OR (wechat_id IS NOT NULL AND wechat_id != ''))") } var total int64 q.Count(&total) var records []model.MatchRecord if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } userIDs := make(map[string]bool) for _, r := range records { if r.UserID != "" { userIDs[r.UserID] = true } } ids := make([]string, 0, len(userIDs)) for id := range userIDs { ids = append(ids, id) } var users []model.User if len(ids) > 0 { db.Where("id IN ?", ids).Find(&users) } userMap := make(map[string]*model.User) for i := range users { userMap[users[i].ID] = &users[i] } safeNickname := func(u *model.User) string { if u == nil || u.Nickname == nil { return "" } return *u.Nickname } out := make([]gin.H, 0, len(records)) for _, r := range records { out = append(out, gin.H{ "id": r.ID, "userId": r.UserID, "userNickname": safeNickname(userMap[r.UserID]), "matchType": r.MatchType, "phone": func() string { if r.Phone == nil { return "" }; return *r.Phone }(), "wechatId": func() string { if r.WechatID == nil { return "" }; return *r.WechatID }(), "createdAt": r.CreatedAt, }) } c.JSON(http.StatusOK, gin.H{ "success": true, "records": out, "total": total, "page": page, "pageSize": pageSize, }) } func minInt(a, b int) int { if a < b { return a } return b }