稳定版本构建完成

This commit is contained in:
Alex-larget
2026-03-18 21:06:16 +08:00
parent d6cdd6fdba
commit c4f737c829
411 changed files with 90567 additions and 1216 deletions

View File

@@ -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,
})
}

View 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,
})
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -15,6 +15,7 @@ type Person struct {
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token文章/小程序传此值
Name string `gorm:"column:name;size:100" json:"name"`
Aliases string `gorm:"column:aliases;size:255;default:''" json:"aliases"` // 逗号分隔别名:用于 @ 自动匹配
Label string `gorm:"column:label;size:200" json:"label"`
CkbApiKey string `gorm:"column:ckb_api_key;size:100;default:''" json:"ckbApiKey"` // 存客宝真实密钥,不对外暴露

View File

@@ -87,6 +87,8 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
// 存客宝开放 API 辅助接口:设备列表(供链接人与事选择设备)
admin.GET("/ckb/devices", handler.AdminCKBDevices)
// 存客宝开放 API 辅助接口:获客计划列表(供链接人与事一键选择计划覆盖参数)
admin.GET("/ckb/plans", handler.AdminCKBPlans)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
@@ -198,6 +200,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/user-rules", handler.DBUserRulesList)
db.POST("/user-rules", handler.DBUserRulesAction)