Files
Mycontent/soul-api/internal/handler/ckb_open.go
乘风 b2b753d566 feat: unify planKey handling for lead management
- Added a new entry for the 存客宝计划 Key in the documentation, ensuring consistency between the displayed planKey and the apiKey used for retries.
- Implemented a caching mechanism for the JWT token in the ckbOpenGetToken function to reduce redundant API calls.
- Refactored the lead management logic to ensure that the same planKey is used for both displaying leads and retrying failed pushes, enhancing data integrity and reducing discrepancies.

This update aims to streamline lead management processes and improve the reliability of API interactions.
2026-05-08 15:39:47 +08:00

670 lines
18 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"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"soul-api/internal/config"
)
const ckbOpenBaseURL = "https://ckbapi.quwanzhi.com"
// ckbOpenSign 按 open-api-sign.mdsign = 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
}
var (
ckbOpenTokenMu sync.Mutex
ckbOpenCachedToken string
ckbOpenCachedExpiry time.Time
)
// ckbOpenTokenCacheTTL 开放 API JWT 内存缓存时长(避免 GET /api/db/persons 等高频路径对存客宝重复 /v1/open/auth/token
const ckbOpenTokenCacheTTL = 45 * time.Minute
// ckbOpenGetToken 获取开放 API JWT带进程内短期缓存
func ckbOpenGetToken() (string, error) {
apiKey, account := getCkbOpenConfig()
if apiKey == "" || account == "" {
return "", fmt.Errorf("无效的apiKeyCKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,请在后端 .env 中配置后重试")
}
ckbOpenTokenMu.Lock()
if ckbOpenCachedToken != "" && time.Now().Before(ckbOpenCachedExpiry) {
tok := ckbOpenCachedToken
ckbOpenTokenMu.Unlock()
return tok, nil
}
ckbOpenTokenMu.Unlock()
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)
}
ckbOpenTokenMu.Lock()
ckbOpenCachedToken = authResult.Data.Token
ckbOpenCachedExpiry = time.Now().Add(ckbOpenTokenCacheTTL)
out := ckbOpenCachedToken
ckbOpenTokenMu.Unlock()
return out, 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
}
// ckbOpenProbePlanReachable 仅用于连通性探测GET /v1/plan/detail 返回 code==200 即认为计划可访问。
// 与 ckbOpenGetPlanDetail 不同:不少计划详情接口不返回 apiKey或字段名不同不应因此判失败更不应降级去调 update 触发「更新计划失败」误报。
func ckbOpenProbePlanReachable(token string, planID int64) 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 json.RawMessage `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code != 200 {
msg := result.Message
if msg == "" {
msg = "获取计划详情失败"
}
return fmt.Errorf("%s", msg)
}
return 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,
})
}