Files
soul-yongping/soul-api/internal/handler/ckb_open.go
Alex-larget c936371165 Update mini program development documentation and enhance user interface elements
- Added a new entry for the latest mini program development rules and APIs in the evolution index.
- Updated the skill documentation to include guidelines on privacy authorization and capability detection.
- Modified the read and settings pages to improve user experience with new input styles and layout adjustments.
- Implemented user-select functionality for text elements in the read page to enhance interactivity.
- Refined CSS styles for better responsiveness and visual consistency across various components.
2026-03-14 16:23:01 +08:00

340 lines
9.3 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
}
// 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,
})
}