package handler import ( "bytes" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/gin-gonic/gin" "soul-api/internal/config" ) const ckbOpenBaseURL = "https://ckbapi.quwanzhi.com" // ckbOpenSign 按 open-api-sign.md:sign = MD5(MD5(account+timestamp) + apiKey) func ckbOpenSign(account string, ts int64, apiKey string) string { plain := account + strconv.FormatInt(ts, 10) h := md5.Sum([]byte(plain)) first := hex.EncodeToString(h[:]) h2 := md5.Sum([]byte(first + apiKey)) return hex.EncodeToString(h2[:]) } func getCkbOpenConfig() (apiKey, account string) { cfg := config.Get() if cfg != nil { apiKey = cfg.CkbOpenAPIKey account = cfg.CkbOpenAccount } return } // ckbOpenGetToken 获取开放 API JWT func ckbOpenGetToken() (string, error) { apiKey, account := getCkbOpenConfig() if apiKey == "" || account == "" { return "", fmt.Errorf("无效的apiKey:CKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,请在后端 .env 中配置后重试") } ts := time.Now().Unix() sign := ckbOpenSign(account, ts, apiKey) authBody := map[string]interface{}{ "apiKey": apiKey, "account": account, "timestamp": ts, "sign": sign, } raw, _ := json.Marshal(authBody) authResp, err := http.Post(ckbOpenBaseURL+"/v1/open/auth/token", "application/json", bytes.NewReader(raw)) if err != nil { return "", fmt.Errorf("请求存客宝鉴权失败: %w", err) } defer authResp.Body.Close() authBytes, _ := io.ReadAll(authResp.Body) var authResult struct { Code int `json:"code"` Message string `json:"message"` Data struct { Token string `json:"token"` } `json:"data"` } _ = json.Unmarshal(authBytes, &authResult) if authResult.Code != 200 || authResult.Data.Token == "" { msg := authResult.Message if msg == "" { msg = "存客宝鉴权失败" } return "", fmt.Errorf("%s", msg) } return authResult.Data.Token, nil } // ckbOpenCreatePlan 调用 /v1/plan/create 创建获客计划,返回 planId、存客宝原始 data、以及完整响应(失败时便于排查) func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int64, createData map[string]interface{}, ckbResponse map[string]interface{}, err error) { raw, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, ckbOpenBaseURL+"/v1/plan/create", bytes.NewReader(raw)) if err != nil { return 0, nil, nil, fmt.Errorf("构造创建计划请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return 0, nil, nil, fmt.Errorf("请求存客宝创建计划失败: %w", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) var result struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } _ = json.Unmarshal(b, &result) // 始终组装完整响应,便于失败时返回给调用方查看存客宝实际返回 ckbResponse = map[string]interface{}{ "code": result.Code, "message": result.Message, "data": nil, } if len(result.Data) > 0 { var dataObj interface{} _ = json.Unmarshal(result.Data, &dataObj) ckbResponse["data"] = dataObj } if result.Code != 200 { if result.Message == "" { result.Message = "创建计划失败" } return 0, nil, ckbResponse, fmt.Errorf("%s", result.Message) } // 原始 data 转为 map 供响应展示 createData = make(map[string]interface{}) _ = json.Unmarshal(result.Data, &createData) // 存客宝可能返回 planId 为数字或字符串(如 "629"),兼容解析 planID = parsePlanIDFromData(createData) if planID != 0 { return planID, createData, ckbResponse, nil } return 0, createData, ckbResponse, fmt.Errorf("创建计划返回结果中缺少 planId") } // ckbOpenUpdatePlan 调用 PUT /v1/plan/update 更新获客计划(用于停用/启用) // payload 至少包含 planId func ckbOpenUpdatePlan(token string, payload map[string]interface{}) (ckbResponse map[string]interface{}, err error) { raw, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPut, ckbOpenBaseURL+"/v1/plan/update", bytes.NewReader(raw)) if err != nil { return nil, fmt.Errorf("构造更新计划请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("请求存客宝更新计划失败: %w", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) var result struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } _ = json.Unmarshal(b, &result) ckbResponse = map[string]interface{}{ "code": result.Code, "message": result.Message, "data": nil, } if len(result.Data) > 0 { var dataObj interface{} _ = json.Unmarshal(result.Data, &dataObj) ckbResponse["data"] = dataObj } if result.Code != 200 { msg := result.Message if msg == "" { msg = "更新计划失败" } return ckbResponse, fmt.Errorf("%s", msg) } return ckbResponse, nil } // parseApiKeyFromCreateData 从 create 返回的 data 中解析 apiKey(若存客宝直接返回则复用,避免二次请求) func parseApiKeyFromCreateData(data map[string]interface{}) string { for _, key := range []string{"apiKey", "api_key"} { v, ok := data[key] if !ok || v == nil { continue } if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { return strings.TrimSpace(s) } } return "" } // parsePlanIDFromData 从 data 中解析 planId,支持 number 或 string;若无则尝试 id func parsePlanIDFromData(data map[string]interface{}) int64 { for _, key := range []string{"planId", "id"} { v, ok := data[key] if !ok || v == nil { continue } switch val := v.(type) { case float64: if val > 0 { return int64(val) } case int: if val > 0 { return int64(val) } case int64: if val > 0 { return val } case string: if val == "" { continue } n, err := strconv.ParseInt(val, 10, 64) if err == nil && n > 0 { return n } } } return 0 } // ckbOpenGetPlanDetail 调用 /v1/plan/detail?planId=,返回计划级 apiKey func ckbOpenGetPlanDetail(token string, planID int64) (string, error) { u := fmt.Sprintf("%s/v1/plan/detail?planId=%d", ckbOpenBaseURL, planID) req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return "", fmt.Errorf("构造计划详情请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("请求存客宝计划详情失败: %w", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) var result struct { Code int `json:"code"` Message string `json:"message"` Data struct { APIKey string `json:"apiKey"` } `json:"data"` } _ = json.Unmarshal(b, &result) if result.Code != 200 { if result.Message == "" { result.Message = "获取计划详情失败" } return "", fmt.Errorf("%s", result.Message) } if result.Data.APIKey == "" { return "", fmt.Errorf("计划详情中缺少 apiKey") } return result.Data.APIKey, nil } // ckbOpenGetDefaultDeviceID 获取默认设备 ID:拉设备列表,取第一个 memo 或 nickname 包含 "soul" 的设备;用于 deviceGroups 必填时的默认值 func ckbOpenGetDefaultDeviceID(token string) (int64, error) { u := ckbOpenBaseURL + "/v1/devices?keyword=soul&page=1&limit=50" req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return 0, fmt.Errorf("构造设备列表请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { return 0, fmt.Errorf("请求存客宝设备列表失败: %w", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) var parsed map[string]interface{} if err := json.Unmarshal(b, &parsed); err != nil { return 0, fmt.Errorf("解析设备列表失败: %w", err) } var listAny interface{} if dataVal, ok := parsed["data"].(map[string]interface{}); ok { listAny = dataVal["list"] } else if la, ok := parsed["list"]; ok { listAny = la } arr, ok := listAny.([]interface{}) if !ok { return 0, fmt.Errorf("设备列表格式异常") } // 优先匹配 memo/nickname 包含 soul 的设备;若无则取第一个(keyword 可能已过滤) for _, item := range arr { m, ok := item.(map[string]interface{}) if !ok { continue } memo := toString(m["memo"]) if memo == "" { memo = toString(m["imei"]) } nickname := toString(m["nickname"]) lowerMemo := strings.ToLower(memo) lowerNick := strings.ToLower(nickname) if strings.Contains(lowerMemo, "soul") || strings.Contains(lowerNick, "soul") { id := parseDeviceID(m["id"]) if id > 0 { return id, nil } } } // 未找到含 soul 的,取第一个 if len(arr) > 0 { if m, ok := arr[0].(map[string]interface{}); ok { id := parseDeviceID(m["id"]) if id > 0 { return id, nil } } } return 0, fmt.Errorf("未找到名为 soul 的设备,请先在存客宝添加设备并设置 memo 或 nickname 包含 soul") } func toString(v interface{}) string { if v == nil { return "" } switch val := v.(type) { case string: return val case float64: return strconv.FormatFloat(val, 'f', -1, 64) case int: return strconv.Itoa(val) case int64: return strconv.FormatInt(val, 10) default: return fmt.Sprint(v) } } func parseDeviceID(v interface{}) int64 { if v == nil { return 0 } switch val := v.(type) { case float64: if val > 0 { return int64(val) } case int: if val > 0 { return int64(val) } case int64: if val > 0 { return val } case string: if val == "" { return 0 } n, err := strconv.ParseInt(val, 10, 64) if err == nil && n > 0 { return n } } return 0 } // ckbOpenDeletePlan 调用 DELETE /v1/plan/delete 删除存客宝获客计划 func ckbOpenDeletePlan(token string, planID int64) error { payload := map[string]interface{}{"planId": planID} raw, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodDelete, ckbOpenBaseURL+"/v1/plan/delete", bytes.NewReader(raw)) if err != nil { return fmt.Errorf("构造删除计划请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("请求存客宝删除计划失败: %w", err) } 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 { if result.Message == "" { result.Message = "删除计划失败" } return fmt.Errorf("%s", result.Message) } return nil } // AdminCKBDevices GET /api/admin/ckb/devices 管理端-存客宝设备列表(供链接人与事选择设备) // 通过开放 API 获取 JWT,再调用 /v1/devices,返回精简后的设备列表。 func AdminCKBDevices(c *gin.Context) { token, err := ckbOpenGetToken() if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } // 2. 调用 /v1/devices 获取设备列表 pageStr := c.Query("page") if pageStr == "" { pageStr = "1" } limitStr := c.Query("limit") if limitStr == "" { limitStr = "20" } keyword := c.Query("keyword") values := url.Values{} values.Set("page", pageStr) values.Set("limit", limitStr) if keyword != "" { values.Set("keyword", keyword) } deviceURL := ckbOpenBaseURL + "/v1/devices" if len(values) > 0 { deviceURL += "?" + values.Encode() } req, err := http.NewRequest(http.MethodGet, deviceURL, nil) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "构造设备列表请求失败"}) return } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求存客宝设备列表失败"}) return } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) // 设备返回结构:参考 Cunkebao getDeviceList 使用方式,形如 { code, msg, data: { list, total } } 或 { code, msg, list, total } var parsed map[string]interface{} if err := json.Unmarshal(b, &parsed); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析存客宝设备列表失败"}) return } // 尝试从 data.list 或 list 提取设备列表与 total var listAny interface{} if dataVal, ok := parsed["data"].(map[string]interface{}); ok { listAny = dataVal["list"] if _, ok := parsed["total"]; !ok { if tv, ok := dataVal["total"]; ok { parsed["total"] = tv } } } else if la, ok := parsed["list"]; ok { listAny = la } devices := make([]map[string]interface{}, 0) if arr, ok := listAny.([]interface{}); ok { for _, item := range arr { if m, ok := item.(map[string]interface{}); ok { id := m["id"] memo := m["memo"] if memo == nil || memo == "" { memo = m["imei"] } wechatID := m["wechatId"] status := "offline" if alive, ok := m["alive"].(float64); ok && int(alive) == 1 { status = "online" } devices = append(devices, map[string]interface{}{ "id": id, "memo": memo, "imei": m["imei"], "wechatId": wechatID, "status": status, "avatar": m["avatar"], "nickname": m["nickname"], "usedInPlan": m["usedInPlans"], "totalFriend": m["totalFriend"], }) } } } total := 0 switch tv := parsed["total"].(type) { case float64: total = int(tv) case int: total = tv case string: if n, err := strconv.Atoi(tv); err == nil { total = n } } c.JSON(http.StatusOK, gin.H{ "success": true, "devices": devices, "total": total, }) } // AdminCKBPlans GET /api/admin/ckb/plans 管理端-存客宝获客计划列表(供链接人与事选择计划一键覆盖参数) // 通过开放 API 获取 JWT,再调用 /v1/plan/list,返回精简后的计划列表。 func AdminCKBPlans(c *gin.Context) { token, err := ckbOpenGetToken() if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } pageStr := c.Query("page") if pageStr == "" { pageStr = "1" } limitStr := c.Query("limit") if limitStr == "" { limitStr = "50" } keyword := c.Query("keyword") values := url.Values{} values.Set("page", pageStr) values.Set("limit", limitStr) if keyword != "" { values.Set("keyword", keyword) } planURL := ckbOpenBaseURL + "/v1/plan/list" if len(values) > 0 { planURL += "?" + values.Encode() } req, err := http.NewRequest(http.MethodGet, planURL, nil) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "构造计划列表请求失败"}) return } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求存客宝计划列表失败"}) return } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) var parsed map[string]interface{} if err := json.Unmarshal(b, &parsed); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析存客宝计划列表失败"}) return } // 计划返回结构:参考设备列表解析方式,尽可能兼容 {data:{list,total}} 或 {list,total} var listAny interface{} if dataVal, ok := parsed["data"].(map[string]interface{}); ok { listAny = dataVal["list"] if _, ok := parsed["total"]; !ok { if tv, ok := dataVal["total"]; ok { parsed["total"] = tv } } } else if la, ok := parsed["list"]; ok { listAny = la } plans := make([]map[string]interface{}, 0) if arr, ok := listAny.([]interface{}); ok { for _, item := range arr { m, ok := item.(map[string]interface{}) if !ok { continue } plans = append(plans, map[string]interface{}{ "id": m["planId"], "name": m["name"], "apiKey": m["apiKey"], "sceneId": m["sceneId"], "scenario": m["scenario"], "enabled": m["enabled"], "greeting": m["greeting"], "tips": m["tips"], "remarkType": m["remarkType"], "remarkFormat": m["remarkFormat"], "addInterval": m["addInterval"], "startTime": m["startTime"], "endTime": m["endTime"], "deviceGroups": m["deviceGroups"], }) } } total := 0 switch tv := parsed["total"].(type) { case float64: total = int(tv) case int: total = tv case string: if n, err := strconv.Atoi(tv); err == nil { total = n } } c.JSON(http.StatusOK, gin.H{ "success": true, "plans": plans, "total": total, }) }