Files
soul-yongping/soul-api/internal/handler/ckb_open.go
卡若 fa9903d235 feat: 人物编辑弹窗改为CKB计划选择下拉框
- 新增 GET /api/admin/ckb/plans 获取存客宝获客计划列表
- 新增 GET /api/admin/ckb/plan-detail 获取计划详情
- PersonAddEditModal: 密钥字段改为可搜索的计划选择器
  选择计划后自动覆盖 greeting/tips/设备/时间等参数
- 删除"修复 CKB 密钥"按钮

Made-with: Cursor
2026-03-15 23:12:43 +08:00

423 lines
12 KiB
Go
Raw 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"
"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
}
// ckbOpenGetToken 获取开放 API JWT
func ckbOpenGetToken() (string, error) {
apiKey, account := getCkbOpenConfig()
if apiKey == "" || account == "" {
return "", fmt.Errorf("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(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(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")
}
// 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(result.Message)
}
if result.Data.APIKey == "" {
return "", fmt.Errorf("计划详情中缺少 apiKey")
}
return result.Data.APIKey, 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 管理端-存客宝获客计划列表
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.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "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/plans?" + 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
}
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 {
if m, ok := item.(map[string]interface{}); ok {
plans = append(plans, map[string]interface{}{
"id": m["id"],
"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})
}
// AdminCKBPlanDetail GET /api/admin/ckb/plan-detail?planId=xxx 管理端-存客宝获客计划详情
func AdminCKBPlanDetail(c *gin.Context) {
planIDStr := c.Query("planId")
if planIDStr == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 planId"})
return
}
planID, _ := strconv.ParseInt(planIDStr, 10, 64)
if planID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "planId 无效"})
return
}
token, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
apiKey, err := ckbOpenGetPlanDetail(token, planID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "apiKey": apiKey})
}