稳定版本构建完成
This commit is contained in:
@@ -501,3 +501,113 @@ func AdminCKBDevices(c *gin.Context) {
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
91
soul-api/internal/handler/db_ckb_person_leads.go
Normal file
91
soul-api/internal/handler/db_ckb_person_leads.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBCKBPersonLeads GET /api/db/ckb-person-leads
|
||||
// - 不带 token:返回每个 Person 的获客数(基于 ckb_lead_records.target_person_id)
|
||||
// - 带 token:返回该 Person 的获客明细(分页)
|
||||
func DBCKBPersonLeads(c *gin.Context) {
|
||||
db := database.DB()
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
|
||||
// 1) 汇总:每个人物的获客数
|
||||
if token == "" {
|
||||
type Row struct {
|
||||
Token string `json:"token"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
var rows []Row
|
||||
// persons.token 唯一;左连接保证没获客也能返回 total=0
|
||||
if err := db.Raw(`
|
||||
SELECT p.token AS token, COUNT(l.id) AS total
|
||||
FROM persons p
|
||||
LEFT JOIN ckb_lead_records l ON l.target_person_id = p.person_id
|
||||
GROUP BY p.token
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "byPerson": rows})
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 明细:某个人物的获客列表
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
var person model.Person
|
||||
// token 是管理端/小程序统一引用的主键;兜底允许传 personId(便于排查/手工调用)
|
||||
if err := db.Where("token = ? OR person_id = ?", token, token).First(&person).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到人物"})
|
||||
return
|
||||
}
|
||||
|
||||
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id = ?", person.PersonID)
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var records []model.CkbLeadRecord
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"nickname": r.Nickname,
|
||||
"phone": r.Phone,
|
||||
"wechatId": r.WechatID,
|
||||
"name": r.Name,
|
||||
"source": r.Source,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"records": out,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"personName": person.Name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
@@ -11,14 +14,97 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func isDigits12(s string) bool {
|
||||
if len(s) != 12 {
|
||||
return false
|
||||
}
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isZId12(s string) bool {
|
||||
if len(s) != 12 || s[0] != 'z' {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < 12; i++ {
|
||||
ch := s[i]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func genZId12() string {
|
||||
const letters = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
b := make([]byte, 12)
|
||||
b[0] = 'z'
|
||||
for i := 1; i < 12; i++ {
|
||||
b[i] = letters[rnd.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DBLinkTagList GET /api/db/link-tags 管理端-链接标签列表
|
||||
func DBLinkTagList(c *gin.Context) {
|
||||
var rows []model.LinkTag
|
||||
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
|
||||
// 兼容旧前端:无分页/搜索参数时返回全量
|
||||
pageStr := strings.TrimSpace(c.Query("page"))
|
||||
pageSizeStr := strings.TrimSpace(c.Query("pageSize"))
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if pageStr == "" && pageSizeStr == "" && search == "" {
|
||||
var rows []model.LinkTag
|
||||
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
q := db.Model(&model.LinkTag{})
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
q = q.Where("label LIKE ? OR tag_id LIKE ?", like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
|
||||
var rows []model.LinkTag
|
||||
offset := (page - 1) * pageSize
|
||||
if err := q.Order("label ASC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
totalPages := (int(total) + pageSize - 1) / pageSize
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"linkTags": rows,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
|
||||
@@ -35,6 +121,14 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
autoCreate := strings.TrimSpace(body.TagID) == ""
|
||||
body.TagID = strings.TrimSpace(body.TagID)
|
||||
body.Label = strings.TrimSpace(body.Label)
|
||||
body.URL = strings.TrimSpace(body.URL)
|
||||
body.Type = strings.TrimSpace(body.Type)
|
||||
body.AppID = strings.TrimSpace(body.AppID)
|
||||
body.PagePath = strings.TrimSpace(body.PagePath)
|
||||
|
||||
if body.Label == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 必填"})
|
||||
return
|
||||
@@ -43,22 +137,37 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 只能包含汉字/字母/数字,不能为纯符号"})
|
||||
return
|
||||
}
|
||||
if body.TagID == "" {
|
||||
body.TagID = body.Label
|
||||
}
|
||||
if body.Type == "" {
|
||||
body.Type = "url"
|
||||
}
|
||||
// tagId 规则:12位数字,或 12位且以 z 开头(z + 11位[a-z0-9])
|
||||
// 管理端新增:可不传 tagId,由后端生成;编辑:通常会携带现有 tagId
|
||||
if body.TagID == "" {
|
||||
body.TagID = genZId12()
|
||||
autoCreate = true
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var existing model.LinkTag
|
||||
// 若 tagId 不符合新规则:仅允许更新已有记录(兼容历史中文 tagId),禁止新建
|
||||
if !(isDigits12(body.TagID) || isZId12(body.TagID)) {
|
||||
if err := db.Where("tag_id = ?", body.TagID).First(&existing).Error; err == nil {
|
||||
// allow update existing legacy tagId
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 必须为12位数字,或12位且以 z 开头(z+11位小写字母数字)"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 小程序类型:只存 appId + pagePath,不存 weixin:// 到 url
|
||||
if body.Type == "miniprogram" {
|
||||
body.URL = ""
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.LinkTag
|
||||
// 按 label 查找:文章编辑自动创建场景,若已存在则直接返回
|
||||
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
// 按 label 查找:仅用于「自动创建」场景(tagId 为空时回落 label),若已存在则直接返回
|
||||
if autoCreate {
|
||||
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
}
|
||||
}
|
||||
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
|
||||
existing.Label = body.Label
|
||||
@@ -74,6 +183,15 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
// body.URL 已在 miniprogram 类型时置空
|
||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
// 极低概率:生成的 tagId 冲突,重试一次
|
||||
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
|
||||
t.TagID = genZId12()
|
||||
if e2 := db.Create(&t).Error; e2 == nil {
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func DBPersonSave(c *gin.Context) {
|
||||
var body struct {
|
||||
PersonID string `json:"personId"`
|
||||
Name string `json:"name"`
|
||||
Aliases string `json:"aliases"`
|
||||
Label string `json:"label"`
|
||||
CkbApiKey string `json:"ckbApiKey"` // 存客宝真实密钥,留空则 fallback 全局 Key
|
||||
Greeting string `json:"greeting"`
|
||||
@@ -83,6 +84,7 @@ func DBPersonSave(c *gin.Context) {
|
||||
}
|
||||
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
|
||||
existing.Name = body.Name
|
||||
existing.Aliases = strings.TrimSpace(body.Aliases)
|
||||
existing.Label = body.Label
|
||||
existing.CkbApiKey = body.CkbApiKey
|
||||
existing.Greeting = body.Greeting
|
||||
@@ -197,6 +199,7 @@ func DBPersonSave(c *gin.Context) {
|
||||
PersonID: body.PersonID,
|
||||
Token: tok,
|
||||
Name: body.Name,
|
||||
Aliases: strings.TrimSpace(body.Aliases),
|
||||
Label: body.Label,
|
||||
CkbApiKey: apiKey,
|
||||
CkbPlanID: planID,
|
||||
|
||||
Reference in New Issue
Block a user