2026-03-07 22:58:43 +08:00
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" : "创业合伙,创业伙伴" }
2026-03-08 16:53:53 +08:00
func getCKBRuntimeConfig ( ) ( apiKey string , apiURL string , docNotes string ) {
apiKey = ckbAPIKey
apiURL = ckbAPIURL
var cfg model . SystemConfig
if err := database . DB ( ) . Where ( "config_key = ?" , "ckb_config" ) . First ( & cfg ) . Error ; err != nil {
return
}
var m map [ string ] interface { }
if err := json . Unmarshal ( cfg . ConfigValue , & m ) ; err != nil {
return
}
if v , ok := m [ "apiKey" ] . ( string ) ; ok && strings . TrimSpace ( v ) != "" {
apiKey = strings . TrimSpace ( v )
}
if v , ok := m [ "apiUrl" ] . ( string ) ; ok && strings . TrimSpace ( v ) != "" {
apiURL = strings . TrimSpace ( v )
}
if v , ok := m [ "docNotes" ] . ( string ) ; ok {
docNotes = v
}
return
}
2026-03-07 22:58:43 +08:00
// 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 ) {
2026-03-08 16:54:20 +08:00
apiKey , apiURL , _ := getCKBRuntimeConfig ( )
2026-03-07 22:58:43 +08:00
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
}
2026-03-08 16:20:12 +08:00
// 先写入 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 )
}
}
2026-03-07 22:58:43 +08:00
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
}
2026-03-08 16:54:20 +08:00
params [ "apiKey" ] = apiKey
params [ "sign" ] = ckbSign ( params , apiKey )
2026-03-07 22:58:43 +08:00
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 )
2026-03-08 16:54:20 +08:00
resp , err := http . Post ( apiURL , "application/json" , bytes . NewReader ( raw ) )
2026-03-07 22:58:43 +08:00
if err != nil {
2026-03-08 16:20:27 +08:00
fmt . Printf ( "[CKBJoin] CKB 请求失败: %v (match_records 已写入)\n" , err )
c . JSON ( http . StatusOK , gin . H { "success" : true , "message" : "已提交(存客宝暂不可达,稍后自动重试)" } )
2026-03-07 22:58:43 +08:00
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 ) {
2026-03-08 16:54:20 +08:00
apiKey , apiURL , _ := getCKBRuntimeConfig ( )
2026-03-07 22:58:43 +08:00
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 = "创业合伙"
}
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
}
2026-03-08 16:54:20 +08:00
params [ "apiKey" ] = apiKey
params [ "sign" ] = ckbSign ( params , apiKey )
2026-03-07 22:58:43 +08:00
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 )
2026-03-08 16:54:20 +08:00
resp , err := http . Post ( apiURL , "application/json" , bytes . NewReader ( raw ) )
2026-03-07 22:58:43 +08:00
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 ) {
2026-03-08 16:54:20 +08:00
apiKey , apiURL , _ := getCKBRuntimeConfig ( )
2026-03-07 22:58:43 +08:00
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" : "小程序-链接卡若" ,
2026-03-08 11:35:50 +08:00
"tags" : "链接卡若,创业实验" ,
"siteTags" : "创业实验APP,链接卡若" ,
2026-03-07 22:58:43 +08:00
"remark" : "首页点击「链接卡若」留资" ,
"name" : name ,
}
if phone != "" {
params [ "phone" ] = phone
}
if wechatId != "" {
params [ "wechatId" ] = wechatId
}
2026-03-08 16:54:20 +08:00
params [ "apiKey" ] = apiKey
params [ "sign" ] = ckbSign ( params , apiKey )
2026-03-07 22:58:43 +08:00
raw , _ := json . Marshal ( params )
2026-03-08 11:35:50 +08:00
fmt . Printf ( "[CKBLead] 请求: phone=%s wechatId=%s name=%s\n" , phone , wechatId , name )
2026-03-08 16:54:20 +08:00
resp , err := http . Post ( apiURL , "application/json" , bytes . NewReader ( raw ) )
2026-03-07 22:58:43 +08:00
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 )
2026-03-08 11:36:04 +08:00
fmt . Printf ( "[CKBLead] 响应: code=%d message=%s raw=%s\n" , result . Code , result . Message , string ( b ) )
2026-03-07 22:58:43 +08:00
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 = "提交失败,请稍后重试"
}
2026-03-08 11:36:04 +08:00
fmt . Printf ( "[CKBLead] 失败: phone=%s code=%d message=%s\n" , phone , result . Code , result . Message )
2026-03-07 22:58:43 +08:00
c . JSON ( http . StatusOK , gin . H { "success" : false , "message" : errMsg } )
}
2026-03-08 16:06:54 +08:00
// CKBPlanStats GET /api/db/ckb-plan-stats 代理存客宝获客计划统计
func CKBPlanStats ( c * gin . Context ) {
2026-03-08 16:54:20 +08:00
apiKey , apiURL , docNotes := getCKBRuntimeConfig ( )
2026-03-08 16:06:54 +08:00
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 ,
2026-03-08 16:54:20 +08:00
"ckbApiKey" : apiKey [ : minInt ( len ( apiKey ) , 8 ) ] + "..." ,
"ckbApiUrl" : apiURL ,
2026-03-08 16:06:54 +08:00
"lastSignTest" : ts ,
2026-03-08 16:54:20 +08:00
"docNotes" : docNotes ,
2026-03-08 16:06:54 +08:00
} ,
} )
}
2026-03-08 16:54:20 +08:00
func minInt ( a , b int ) int {
if a < b {
return a
}
return b
}