2026-03-10 11:04:34 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"context"
|
2026-03-14 14:37:17 +08:00
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/base64"
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"encoding/json"
|
2026-03-10 11:04:34 +08:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"net/http"
|
2026-03-14 14:37:17 +08:00
|
|
|
|
"strings"
|
2026-03-10 11:04:34 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"soul-api/internal/cache"
|
2026-03-10 11:04:34 +08:00
|
|
|
|
"soul-api/internal/database"
|
|
|
|
|
|
"soul-api/internal/model"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-03-16 17:18:49 +08:00
|
|
|
|
"gorm.io/gorm"
|
2026-03-10 11:04:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// SuperIndividualSharedPlanConfigKey system_config 键:超级个体自动 @ 人物共用的存客宝获客计划
|
|
|
|
|
|
const SuperIndividualSharedPlanConfigKey = "super_individual_shared_plan"
|
|
|
|
|
|
|
|
|
|
|
|
type superIndividualSharedPlanJSON struct {
|
|
|
|
|
|
PlanID int64 `json:"planId"`
|
|
|
|
|
|
CkbApiKey string `json:"ckbApiKey"`
|
|
|
|
|
|
PlanName string `json:"planName"`
|
|
|
|
|
|
Greeting string `json:"greeting"`
|
|
|
|
|
|
Tips string `json:"tips"`
|
|
|
|
|
|
RemarkType string `json:"remarkType"`
|
|
|
|
|
|
RemarkFormat string `json:"remarkFormat"`
|
|
|
|
|
|
DeviceGroups string `json:"deviceGroups"` // 逗号分隔设备 ID,可空则取存客宝默认设备
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func loadSuperIndividualSharedPlanConfig(db *gorm.DB) (superIndividualSharedPlanJSON, bool) {
|
|
|
|
|
|
var row model.SystemConfig
|
|
|
|
|
|
if err := db.Where("config_key = ?", SuperIndividualSharedPlanConfigKey).First(&row).Error; err != nil {
|
|
|
|
|
|
return superIndividualSharedPlanJSON{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
var cfg superIndividualSharedPlanJSON
|
|
|
|
|
|
if err := json.Unmarshal(row.ConfigValue, &cfg); err != nil {
|
|
|
|
|
|
return superIndividualSharedPlanJSON{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.PlanID <= 0 || strings.TrimSpace(cfg.CkbApiKey) == "" {
|
|
|
|
|
|
return cfg, false
|
|
|
|
|
|
}
|
|
|
|
|
|
return cfg, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// createPersonWithSharedSuperIndividualPlan 超级个体:不新建存客宝计划,写入统一 planId + apiKey
|
|
|
|
|
|
func createPersonWithSharedSuperIndividualPlan(db *gorm.DB, name, userID string, cfg superIndividualSharedPlanJSON) (*model.Person, error) {
|
|
|
|
|
|
name = strings.TrimSpace(name)
|
|
|
|
|
|
userID = strings.TrimSpace(userID)
|
|
|
|
|
|
if name == "" || userID == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("name 与 userID 必填")
|
|
|
|
|
|
}
|
|
|
|
|
|
tok, err := genPersonToken()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("生成 token 失败")
|
|
|
|
|
|
}
|
|
|
|
|
|
safeUID := strings.Map(func(r rune) rune {
|
|
|
|
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
|
|
|
|
|
return r
|
|
|
|
|
|
}
|
|
|
|
|
|
return '_'
|
|
|
|
|
|
}, userID)
|
|
|
|
|
|
if len(safeUID) > 36 {
|
|
|
|
|
|
safeUID = safeUID[:36]
|
|
|
|
|
|
}
|
|
|
|
|
|
personID := fmt.Sprintf("si_%s_%d", safeUID, time.Now().UnixMilli())
|
|
|
|
|
|
|
|
|
|
|
|
deviceGroups := strings.TrimSpace(cfg.DeviceGroups)
|
|
|
|
|
|
if deviceGroups == "" {
|
|
|
|
|
|
openToken, errTok := ckbOpenGetToken()
|
|
|
|
|
|
if errTok == nil {
|
|
|
|
|
|
if defaultID, e2 := ckbOpenGetDefaultDeviceID(openToken); e2 == nil {
|
|
|
|
|
|
deviceGroups = fmt.Sprintf("%d", defaultID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
newPerson := model.Person{
|
|
|
|
|
|
UserID: strPtrIfNotEmpty(userID),
|
|
|
|
|
|
PersonID: personID,
|
|
|
|
|
|
Token: tok,
|
|
|
|
|
|
Name: name,
|
|
|
|
|
|
CkbApiKey: strings.TrimSpace(cfg.CkbApiKey),
|
|
|
|
|
|
CkbPlanID: cfg.PlanID,
|
|
|
|
|
|
Greeting: cfg.Greeting,
|
|
|
|
|
|
Tips: cfg.Tips,
|
|
|
|
|
|
RemarkType: cfg.RemarkType,
|
|
|
|
|
|
RemarkFormat: cfg.RemarkFormat,
|
|
|
|
|
|
AddFriendInterval: 1,
|
|
|
|
|
|
StartTime: "09:00",
|
|
|
|
|
|
EndTime: "18:00",
|
|
|
|
|
|
DeviceGroups: deviceGroups,
|
|
|
|
|
|
PersonSource: model.PersonSourceVipSync,
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Create(&newPerson).Error; err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
invalidateReadExtrasCache()
|
|
|
|
|
|
return &newPerson, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 人物增删改后使 read-extras(含 mentionPersons)失效
|
|
|
|
|
|
func invalidateReadExtrasCache() {
|
|
|
|
|
|
cache.Del(context.Background(), cache.KeyConfigReadExtras)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
// DBPersonList GET /api/db/persons 管理端-@提及人物列表
|
|
|
|
|
|
func DBPersonList(c *gin.Context) {
|
|
|
|
|
|
var rows []model.Person
|
|
|
|
|
|
if err := database.DB().Order("name 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, "persons": rows})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// DBPersonDetail GET /api/db/person 管理端-单个人物详情(编辑回显用)
|
|
|
|
|
|
func DBPersonDetail(c *gin.Context) {
|
|
|
|
|
|
pid := c.Query("personId")
|
|
|
|
|
|
if pid == "" {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
var row model.Person
|
|
|
|
|
|
if err := database.DB().Where("person_id = ?", pid).First(&row).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "person": row})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
// DBPersonSave POST /api/db/persons 管理端-新增或更新人物
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 新增时自动生成 32 位唯一 token,文章 @ 时存 token,小程序点击时用 token 兑换真实密钥
|
2026-03-10 11:04:34 +08:00
|
|
|
|
func DBPersonSave(c *gin.Context) {
|
|
|
|
|
|
var body struct {
|
2026-03-14 14:37:17 +08:00
|
|
|
|
PersonID string `json:"personId"`
|
|
|
|
|
|
Name string `json:"name"`
|
2026-03-22 08:34:28 +08:00
|
|
|
|
UserID string `json:"userId"` // 可选:绑定会员用户 id,与「指定人=会员」一致;传空字符串则解除绑定
|
2026-03-18 21:06:16 +08:00
|
|
|
|
Aliases string `json:"aliases"`
|
2026-03-14 14:37:17 +08:00
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
|
CkbApiKey string `json:"ckbApiKey"` // 存客宝真实密钥,留空则 fallback 全局 Key
|
|
|
|
|
|
Greeting string `json:"greeting"`
|
|
|
|
|
|
Tips string `json:"tips"`
|
|
|
|
|
|
RemarkType string `json:"remarkType"`
|
|
|
|
|
|
RemarkFormat string `json:"remarkFormat"`
|
|
|
|
|
|
AddFriendInterval *int `json:"addFriendInterval"`
|
|
|
|
|
|
StartTime string `json:"startTime"`
|
|
|
|
|
|
EndTime string `json:"endTime"`
|
|
|
|
|
|
DeviceGroups []int64 `json:"deviceGroups"` // 设备ID列表,由管理端选择后传入
|
2026-03-10 11:04:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if body.Name == "" {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 必填"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-16 17:18:49 +08:00
|
|
|
|
if !isValidNameOrLabel(strings.TrimSpace(body.Name)) {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 只能包含汉字/字母/数字,不能为纯符号"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
uidBind := strings.TrimSpace(body.UserID)
|
|
|
|
|
|
if uidBind != "" {
|
|
|
|
|
|
var u model.User
|
|
|
|
|
|
if err := database.DB().Where("id = ?", uidBind).First(&u).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "绑定的会员用户不存在"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 11:04:34 +08:00
|
|
|
|
db := database.DB()
|
|
|
|
|
|
var existing model.Person
|
2026-03-16 11:09:26 +08:00
|
|
|
|
// 按 name 查找:文章编辑自动创建场景,PersonID 为空时先查是否已存在
|
|
|
|
|
|
if body.PersonID == "" {
|
|
|
|
|
|
if db.Where("name = ?", strings.TrimSpace(body.Name)).First(&existing).Error == nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if body.PersonID == "" {
|
|
|
|
|
|
body.PersonID = fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(strings.TrimSpace(body.Name), " ", "_")), time.Now().UnixMilli())
|
|
|
|
|
|
}
|
2026-03-10 11:04:34 +08:00
|
|
|
|
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
if uidBind != "" {
|
|
|
|
|
|
var conflict model.Person
|
|
|
|
|
|
if db.Where("user_id = ? AND person_id != ?", uidBind, existing.PersonID).First(&conflict).Error == nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该会员已绑定其他 @人物"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
existing.UserID = &uidBind
|
|
|
|
|
|
} else {
|
|
|
|
|
|
existing.UserID = nil
|
|
|
|
|
|
}
|
2026-03-10 11:04:34 +08:00
|
|
|
|
existing.Name = body.Name
|
2026-03-18 21:06:16 +08:00
|
|
|
|
existing.Aliases = strings.TrimSpace(body.Aliases)
|
2026-03-10 11:04:34 +08:00
|
|
|
|
existing.Label = body.Label
|
2026-03-10 18:06:10 +08:00
|
|
|
|
existing.CkbApiKey = body.CkbApiKey
|
2026-03-14 14:37:17 +08:00
|
|
|
|
existing.Greeting = body.Greeting
|
|
|
|
|
|
existing.Tips = body.Tips
|
|
|
|
|
|
existing.RemarkType = body.RemarkType
|
|
|
|
|
|
existing.RemarkFormat = body.RemarkFormat
|
|
|
|
|
|
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
|
|
|
|
|
|
existing.AddFriendInterval = *body.AddFriendInterval
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(body.StartTime) != "" {
|
|
|
|
|
|
existing.StartTime = strings.TrimSpace(body.StartTime)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(body.EndTime) != "" {
|
|
|
|
|
|
existing.EndTime = strings.TrimSpace(body.EndTime)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(body.DeviceGroups) > 0 {
|
|
|
|
|
|
ids := make([]string, 0, len(body.DeviceGroups))
|
|
|
|
|
|
for _, id := range body.DeviceGroups {
|
|
|
|
|
|
if id > 0 {
|
|
|
|
|
|
ids = append(ids, fmt.Sprintf("%d", id))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
existing.DeviceGroups = strings.Join(ids, ",")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
existing.DeviceGroups = ""
|
|
|
|
|
|
}
|
2026-03-10 11:04:34 +08:00
|
|
|
|
db.Save(&existing)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
invalidateReadExtrasCache()
|
2026-03-10 11:04:34 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-14 14:37:17 +08:00
|
|
|
|
|
|
|
|
|
|
// 新增:创建本地 Person 记录前,先在存客宝创建获客计划并获取 planId + apiKey
|
|
|
|
|
|
tok, err := genPersonToken()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成 token 失败"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 获取开放 API token
|
|
|
|
|
|
openToken, err := ckbOpenGetToken()
|
|
|
|
|
|
if err != nil {
|
2026-03-10 11:04:34 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-14 14:37:17 +08:00
|
|
|
|
|
|
|
|
|
|
// 2. 构造创建计划请求体
|
|
|
|
|
|
name := fmt.Sprintf("SOUL链接人与事-%s", body.Name)
|
|
|
|
|
|
addInterval := 1
|
|
|
|
|
|
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
|
|
|
|
|
|
addInterval = *body.AddFriendInterval
|
|
|
|
|
|
}
|
|
|
|
|
|
startTime := "09:00"
|
|
|
|
|
|
if strings.TrimSpace(body.StartTime) != "" {
|
|
|
|
|
|
startTime = strings.TrimSpace(body.StartTime)
|
|
|
|
|
|
}
|
|
|
|
|
|
endTime := "18:00"
|
|
|
|
|
|
if strings.TrimSpace(body.EndTime) != "" {
|
|
|
|
|
|
endTime = strings.TrimSpace(body.EndTime)
|
|
|
|
|
|
}
|
|
|
|
|
|
deviceIDs := make([]int64, 0, len(body.DeviceGroups))
|
|
|
|
|
|
for _, id := range body.DeviceGroups {
|
|
|
|
|
|
if id > 0 {
|
|
|
|
|
|
deviceIDs = append(deviceIDs, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 11:24:42 +08:00
|
|
|
|
if len(deviceIDs) == 0 {
|
|
|
|
|
|
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取默认设备失败: " + err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
deviceIDs = []int64{defaultID}
|
|
|
|
|
|
}
|
2026-03-14 14:37:17 +08:00
|
|
|
|
planPayload := map[string]interface{}{
|
2026-03-14 16:23:01 +08:00
|
|
|
|
"name": name,
|
2026-03-16 17:18:49 +08:00
|
|
|
|
"planType": 1,
|
|
|
|
|
|
"sceneId": 9,
|
|
|
|
|
|
"scenario": 9,
|
|
|
|
|
|
"status": 1,
|
2026-03-14 16:23:01 +08:00
|
|
|
|
"remarkType": body.RemarkType,
|
|
|
|
|
|
"greeting": body.Greeting,
|
|
|
|
|
|
"addInterval": addInterval,
|
|
|
|
|
|
"startTime": startTime,
|
|
|
|
|
|
"endTime": endTime,
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"tips": body.Tips,
|
2026-03-14 14:37:17 +08:00
|
|
|
|
"distributionEnabled": false,
|
2026-03-16 11:24:42 +08:00
|
|
|
|
"deviceGroups": deviceIDs,
|
2026-03-14 14:37:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
planID, ckbCreateData, ckbResponse, err := ckbOpenCreatePlan(openToken, planPayload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
out := gin.H{"success": false, "error": "创建存客宝计划失败: " + err.Error()}
|
|
|
|
|
|
if ckbResponse != nil {
|
|
|
|
|
|
out["ckbResponse"] = ckbResponse
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, out)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 17:18:49 +08:00
|
|
|
|
apiKey := parseApiKeyFromCreateData(ckbCreateData)
|
|
|
|
|
|
if apiKey == "" {
|
|
|
|
|
|
var getErr error
|
|
|
|
|
|
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
|
|
|
|
|
|
if getErr != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建成功但获取计划密钥失败: " + getErr.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-14 14:37:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
newPerson := model.Person{
|
2026-03-14 16:23:01 +08:00
|
|
|
|
PersonID: body.PersonID,
|
|
|
|
|
|
Token: tok,
|
|
|
|
|
|
Name: body.Name,
|
2026-03-18 21:06:16 +08:00
|
|
|
|
Aliases: strings.TrimSpace(body.Aliases),
|
2026-03-14 16:23:01 +08:00
|
|
|
|
Label: body.Label,
|
|
|
|
|
|
CkbApiKey: apiKey,
|
|
|
|
|
|
CkbPlanID: planID,
|
|
|
|
|
|
Greeting: body.Greeting,
|
|
|
|
|
|
Tips: body.Tips,
|
|
|
|
|
|
RemarkType: body.RemarkType,
|
|
|
|
|
|
RemarkFormat: body.RemarkFormat,
|
2026-03-14 14:37:17 +08:00
|
|
|
|
AddFriendInterval: addInterval,
|
|
|
|
|
|
StartTime: startTime,
|
|
|
|
|
|
EndTime: endTime,
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
if uidBind != "" {
|
|
|
|
|
|
var conflict model.Person
|
|
|
|
|
|
if db.Where("user_id = ?", uidBind).First(&conflict).Error == nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该会员已绑定其他 @人物"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
u := uidBind
|
|
|
|
|
|
newPerson.UserID = &u
|
|
|
|
|
|
}
|
2026-03-16 11:24:42 +08:00
|
|
|
|
idsStr := make([]string, 0, len(deviceIDs))
|
|
|
|
|
|
for _, id := range deviceIDs {
|
|
|
|
|
|
idsStr = append(idsStr, fmt.Sprintf("%d", id))
|
2026-03-14 14:37:17 +08:00
|
|
|
|
}
|
2026-03-16 11:24:42 +08:00
|
|
|
|
newPerson.DeviceGroups = strings.Join(idsStr, ",")
|
2026-03-14 14:37:17 +08:00
|
|
|
|
|
|
|
|
|
|
if err := db.Create(&newPerson).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
resp := gin.H{"success": true, "person": newPerson}
|
|
|
|
|
|
if len(ckbCreateData) > 0 {
|
|
|
|
|
|
resp["ckbCreateResult"] = ckbCreateData
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
invalidateReadExtrasCache()
|
2026-03-14 14:37:17 +08:00
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 17:18:49 +08:00
|
|
|
|
// createPersonMinimal 仅按 name 创建 Person(含存客宝计划),供 autolink 复用
|
2026-03-18 20:33:50 +08:00
|
|
|
|
// userID 可为空;用于“绑定用户 → 幂等创建”的场景
|
|
|
|
|
|
func createPersonMinimal(db *gorm.DB, name string, userID string) (*model.Person, error) {
|
2026-03-16 17:18:49 +08:00
|
|
|
|
name = strings.TrimSpace(name)
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("name 必填")
|
|
|
|
|
|
}
|
|
|
|
|
|
personID := fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(name, " ", "_")), time.Now().UnixMilli())
|
|
|
|
|
|
tok, err := genPersonToken()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("生成 token 失败")
|
|
|
|
|
|
}
|
|
|
|
|
|
openToken, err := ckbOpenGetToken()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
planName := fmt.Sprintf("SOUL链接人与事-%s", name)
|
|
|
|
|
|
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("获取默认设备失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
planPayload := map[string]interface{}{
|
|
|
|
|
|
"name": planName,
|
|
|
|
|
|
"planType": 1,
|
|
|
|
|
|
"sceneId": 9,
|
|
|
|
|
|
"scenario": 9,
|
|
|
|
|
|
"status": 1,
|
|
|
|
|
|
"addInterval": 1,
|
|
|
|
|
|
"startTime": "09:00",
|
|
|
|
|
|
"endTime": "18:00",
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"distributionEnabled": false,
|
|
|
|
|
|
"deviceGroups": []int64{defaultID},
|
|
|
|
|
|
}
|
|
|
|
|
|
planID, createData, _, err := ckbOpenCreatePlan(openToken, planPayload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("创建存客宝计划失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
apiKey := parseApiKeyFromCreateData(createData)
|
|
|
|
|
|
if apiKey == "" {
|
|
|
|
|
|
var getErr error
|
|
|
|
|
|
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
|
|
|
|
|
|
if getErr != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("获取计划密钥失败: %w", getErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
newPerson := model.Person{
|
2026-03-18 20:33:50 +08:00
|
|
|
|
UserID: strPtrIfNotEmpty(userID),
|
2026-03-16 17:18:49 +08:00
|
|
|
|
PersonID: personID,
|
|
|
|
|
|
Token: tok,
|
|
|
|
|
|
Name: name,
|
|
|
|
|
|
CkbApiKey: apiKey,
|
|
|
|
|
|
CkbPlanID: planID,
|
|
|
|
|
|
AddFriendInterval: 1,
|
|
|
|
|
|
StartTime: "09:00",
|
|
|
|
|
|
EndTime: "18:00",
|
|
|
|
|
|
DeviceGroups: fmt.Sprintf("%d", defaultID),
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Create(&newPerson).Error; err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
invalidateReadExtrasCache()
|
2026-03-16 17:18:49 +08:00
|
|
|
|
return &newPerson, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 20:33:50 +08:00
|
|
|
|
func strPtrIfNotEmpty(s string) *string {
|
|
|
|
|
|
s = strings.TrimSpace(s)
|
|
|
|
|
|
if s == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// setCkbPlanEnabled 将存客宝计划置为启用/停用(最佳努力)
|
|
|
|
|
|
func setCkbPlanEnabled(openToken string, planID int64, enabled bool) error {
|
|
|
|
|
|
if planID <= 0 {
|
|
|
|
|
|
return fmt.Errorf("planID 无效")
|
|
|
|
|
|
}
|
|
|
|
|
|
status := 0
|
|
|
|
|
|
if enabled {
|
|
|
|
|
|
status = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
|
"planId": planID,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"enabled": enabled,
|
|
|
|
|
|
"scenario": 9, // 兜底:部分接口可能要求带 scenario,与 create 保持一致
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err := ckbOpenUpdatePlan(openToken, payload)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ensurePersonForUser 确保用户对应的 Person 存在(用于超级个体开通成功后的自动创建)
|
|
|
|
|
|
// 幂等规则:
|
|
|
|
|
|
// 1) 优先按 persons.user_id 查;存在则必要时同步 name=nickname
|
|
|
|
|
|
// 2) 若无 user_id 记录,则按 name=nickname 兜底复用;若复用成功且 user_id 为空则补绑
|
|
|
|
|
|
// 3) 都不存在则创建(含 CKB 计划)
|
|
|
|
|
|
//
|
|
|
|
|
|
// 该逻辑为“最佳努力”,调用方不应因失败而阻断支付/权益激活。
|
|
|
|
|
|
func ensurePersonForUser(db *gorm.DB, userID string) error {
|
|
|
|
|
|
userID = strings.TrimSpace(userID)
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
return fmt.Errorf("userID 不能为空")
|
|
|
|
|
|
}
|
|
|
|
|
|
var user model.User
|
|
|
|
|
|
if err := db.Select("id", "nickname").Where("id = ?", userID).First(&user).Error; err != nil {
|
|
|
|
|
|
return fmt.Errorf("用户不存在: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
nickname := ""
|
|
|
|
|
|
if user.Nickname != nil {
|
|
|
|
|
|
nickname = strings.TrimSpace(*user.Nickname)
|
|
|
|
|
|
}
|
|
|
|
|
|
if nickname == "" {
|
|
|
|
|
|
return fmt.Errorf("用户昵称为空,跳过创建 Person")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !isValidNameOrLabel(nickname) {
|
|
|
|
|
|
return fmt.Errorf("用户昵称不符合 Person.name 规则,跳过创建 Person")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取 CKB open token(仅在需要启用计划时使用;失败不阻断)
|
|
|
|
|
|
openToken, _ := ckbOpenGetToken()
|
|
|
|
|
|
|
|
|
|
|
|
// 1) 按 user_id 查
|
|
|
|
|
|
var p model.Person
|
|
|
|
|
|
if err := db.Where("user_id = ?", userID).First(&p).Error; err == nil {
|
|
|
|
|
|
// 同步展示名(跟随昵称)
|
|
|
|
|
|
if strings.TrimSpace(p.Name) != nickname {
|
|
|
|
|
|
db.Model(&p).Updates(map[string]interface{}{"name": nickname, "updated_at": time.Now()})
|
|
|
|
|
|
}
|
|
|
|
|
|
// 续费/恢复:若已有计划则尝试重新启用(最佳努力)
|
|
|
|
|
|
if openToken != "" && p.CkbPlanID > 0 {
|
|
|
|
|
|
_ = setCkbPlanEnabled(openToken, p.CkbPlanID, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) 按 name 兜底复用
|
|
|
|
|
|
var byName model.Person
|
|
|
|
|
|
if err := db.Where("name = ?", nickname).First(&byName).Error; err == nil {
|
|
|
|
|
|
// 若未绑定 user_id,补绑;并确保 name 为昵称
|
|
|
|
|
|
updates := map[string]interface{}{}
|
|
|
|
|
|
if byName.UserID == nil || strings.TrimSpace(*byName.UserID) == "" {
|
|
|
|
|
|
updates["user_id"] = userID
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(byName.Name) != nickname {
|
|
|
|
|
|
updates["name"] = nickname
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(updates) > 0 {
|
|
|
|
|
|
updates["updated_at"] = time.Now()
|
|
|
|
|
|
db.Model(&byName).Updates(updates)
|
|
|
|
|
|
}
|
|
|
|
|
|
if openToken != "" && byName.CkbPlanID > 0 {
|
|
|
|
|
|
_ = setCkbPlanEnabled(openToken, byName.CkbPlanID, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 3) 创建:若已配置「超级个体统一获客计划」,则共用该计划,否则每人新建 SOUL链接人与事-*
|
|
|
|
|
|
if cfg, ok := loadSuperIndividualSharedPlanConfig(db); ok {
|
|
|
|
|
|
_, err := createPersonWithSharedSuperIndividualPlan(db, nickname, userID, cfg)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-03-18 20:33:50 +08:00
|
|
|
|
_, err := createPersonMinimal(db, nickname, userID)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 14:37:17 +08:00
|
|
|
|
func genPersonToken() (string, error) {
|
|
|
|
|
|
b := make([]byte, 24)
|
|
|
|
|
|
if _, err := rand.Read(b); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
s := base64.URLEncoding.EncodeToString(b)
|
|
|
|
|
|
s = strings.ReplaceAll(s, "+", "")
|
|
|
|
|
|
s = strings.ReplaceAll(s, "/", "")
|
|
|
|
|
|
s = strings.ReplaceAll(s, "=", "")
|
|
|
|
|
|
if len(s) >= 32 {
|
|
|
|
|
|
return s[:32], nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
|
2026-03-10 11:04:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// DBPersonPin PUT /api/db/persons/pin 管理端-置顶/取消置顶人物到小程序首页
|
|
|
|
|
|
func DBPersonPin(c *gin.Context) {
|
|
|
|
|
|
var body struct {
|
|
|
|
|
|
PersonID string `json:"personId" binding:"required"`
|
|
|
|
|
|
IsPinned *bool `json:"isPinned"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
db := database.DB()
|
|
|
|
|
|
var row model.Person
|
|
|
|
|
|
if err := db.Where("person_id = ? OR token = ?", body.PersonID, body.PersonID).First(&row).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
pinned := true
|
|
|
|
|
|
if body.IsPinned != nil {
|
|
|
|
|
|
pinned = *body.IsPinned
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pinned = !row.IsPinned
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Model(&row).Update("is_pinned", pinned).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "isPinned": pinned})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DBPersonPinnedList GET /api/db/persons/pinned 管理端/小程序-获取置顶人物列表
|
|
|
|
|
|
func DBPersonPinnedList(c *gin.Context) {
|
|
|
|
|
|
var rows []model.Person
|
|
|
|
|
|
if err := database.DB().Where("is_pinned = ?", true).Order("updated_at DESC").Find(&rows).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]gin.H, 0, len(rows))
|
|
|
|
|
|
db := database.DB()
|
|
|
|
|
|
for _, p := range rows {
|
|
|
|
|
|
item := gin.H{
|
|
|
|
|
|
"personId": p.PersonID,
|
|
|
|
|
|
"token": p.Token,
|
|
|
|
|
|
"name": p.Name,
|
|
|
|
|
|
"label": p.Label,
|
|
|
|
|
|
"isPinned": p.IsPinned,
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.UserID != nil && *p.UserID != "" {
|
|
|
|
|
|
var u model.User
|
|
|
|
|
|
if db.Select("id", "nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {
|
|
|
|
|
|
item["userId"] = u.ID
|
|
|
|
|
|
item["avatar"] = getUrlValue(u.Avatar)
|
|
|
|
|
|
item["nickname"] = getStringValue(u.Nickname)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "persons": out})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
|
|
|
|
|
|
// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝获取状态
|
|
|
|
|
|
func AdminCKBPlanCheck(c *gin.Context) {
|
|
|
|
|
|
db := database.DB()
|
|
|
|
|
|
var persons []model.Person
|
|
|
|
|
|
db.Where("ckb_plan_id > 0").Find(&persons)
|
|
|
|
|
|
if len(persons) == 0 {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": "暂无配置了存客宝计划的人物"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
token, err := ckbOpenGetToken()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]gin.H, 0, len(persons))
|
|
|
|
|
|
for _, p := range persons {
|
|
|
|
|
|
item := gin.H{
|
|
|
|
|
|
"personId": p.PersonID,
|
|
|
|
|
|
"name": p.Name,
|
|
|
|
|
|
"ckbPlanId": p.CkbPlanID,
|
|
|
|
|
|
"status": "unknown",
|
|
|
|
|
|
}
|
|
|
|
|
|
// 尝试启用计划
|
|
|
|
|
|
if enableErr := setCkbPlanEnabled(token, p.CkbPlanID, true); enableErr != nil {
|
|
|
|
|
|
item["status"] = "error"
|
|
|
|
|
|
item["error"] = enableErr.Error()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
item["status"] = "online"
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "plans": out})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
|
2026-03-14 16:23:01 +08:00
|
|
|
|
// 若有 ckb_plan_id,先调存客宝删除计划,再删本地
|
2026-03-10 11:04:34 +08:00
|
|
|
|
func DBPersonDelete(c *gin.Context) {
|
|
|
|
|
|
pid := c.Query("personId")
|
|
|
|
|
|
if pid == "" {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-14 16:23:01 +08:00
|
|
|
|
var row model.Person
|
|
|
|
|
|
if err := database.DB().Where("person_id = ?", pid).First(&row).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 超级个体共用统一计划:只删本地人物,禁止删除存客宝计划
|
|
|
|
|
|
if strings.TrimSpace(row.PersonSource) == model.PersonSourceVipSync {
|
|
|
|
|
|
if err := database.DB().Where("person_id = ?", pid).Delete(&model.Person{}).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
invalidateReadExtrasCache()
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已删除本地 @人物(共用获客计划未删除)"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 若有存客宝计划,先调 CKB 删除(手工添加的一人一号计划)
|
2026-03-14 16:23:01 +08:00
|
|
|
|
if row.CkbPlanID > 0 {
|
|
|
|
|
|
token, err := ckbOpenGetToken()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取存客宝鉴权失败: " + err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := ckbOpenDeletePlan(token, row.CkbPlanID); err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除存客宝计划失败: " + err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 11:04:34 +08:00
|
|
|
|
if err := database.DB().Where("person_id = ?", pid).Delete(&model.Person{}).Error; err != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
invalidateReadExtrasCache()
|
2026-03-10 11:04:34 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
|
|
|
|
}
|