Files
soul-yongping/soul-api/internal/handler/ckb_open.go
卡若 76965adb23 chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
2026-03-17 17:50:12 +08:00

461 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"
"strings"
"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")
}
// 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(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(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,
})
}