2026-03-14 14:37:17 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"crypto/md5"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strconv"
|
2026-03-16 11:24:42 +08:00
|
|
|
|
"strings"
|
2026-03-14 14:37:17 +08:00
|
|
|
|
"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 == "" {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
return "", fmt.Errorf("无效的apiKey:CKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,请在后端 .env 中配置后重试")
|
2026-03-14 14:37:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 = "存客宝鉴权失败"
|
|
|
|
|
|
}
|
2026-03-18 20:33:50 +08:00
|
|
|
|
return "", fmt.Errorf("%s", msg)
|
2026-03-14 14:37:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 = "创建计划失败"
|
|
|
|
|
|
}
|
2026-03-18 20:33:50 +08:00
|
|
|
|
return 0, nil, ckbResponse, fmt.Errorf("%s", result.Message)
|
2026-03-14 14:37:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 原始 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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 20:33:50 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 17:18:49 +08:00
|
|
|
|
// 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 ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 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 = "获取计划详情失败"
|
|
|
|
|
|
}
|
2026-03-18 20:33:50 +08:00
|
|
|
|
return "", fmt.Errorf("%s", result.Message)
|
2026-03-14 14:37:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
if result.Data.APIKey == "" {
|
|
|
|
|
|
return "", fmt.Errorf("计划详情中缺少 apiKey")
|
|
|
|
|
|
}
|
|
|
|
|
|
return result.Data.APIKey, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 11:24:42 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 16:23:01 +08:00
|
|
|
|
// 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 = "删除计划失败"
|
|
|
|
|
|
}
|
2026-03-18 20:33:50 +08:00
|
|
|
|
return fmt.Errorf("%s", result.Message)
|
2026-03-14 16:23:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 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{}{
|
2026-03-16 17:18:49 +08:00
|
|
|
|
"id": id,
|
|
|
|
|
|
"memo": memo,
|
|
|
|
|
|
"imei": m["imei"],
|
|
|
|
|
|
"wechatId": wechatID,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"avatar": m["avatar"],
|
|
|
|
|
|
"nickname": m["nickname"],
|
|
|
|
|
|
"usedInPlan": m["usedInPlans"],
|
2026-03-14 14:37:17 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-03-18 21:06:16 +08:00
|
|
|
|
|
|
|
|
|
|
// 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{}{
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"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"],
|
2026-03-18 21:06:16 +08:00
|
|
|
|
"remarkFormat": m["remarkFormat"],
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"addInterval": m["addInterval"],
|
|
|
|
|
|
"startTime": m["startTime"],
|
|
|
|
|
|
"endTime": m["endTime"],
|
2026-03-18 21:06:16 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|