Files
soul-yongping/soul-api/internal/handler/db_person.go

438 lines
14 KiB
Go
Raw Normal View History

package handler
import (
2026-03-14 14:37:17 +08:00
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
2026-03-14 14:37:17 +08:00
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 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})
}
// DBPersonSave POST /api/db/persons 管理端-新增或更新人物
2026-03-14 14:37:17 +08:00
// 新增时自动生成 32 位唯一 token文章 @ 时存 token小程序点击时用 token 兑换真实密钥
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-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列表由管理端选择后传入
}
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
}
if !isValidNameOrLabel(strings.TrimSpace(body.Name)) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 只能包含汉字/字母/数字,不能为纯符号"})
return
}
db := database.DB()
var existing model.Person
// 按 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())
}
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
2026-03-18 21:06:16 +08:00
existing.Aliases = strings.TrimSpace(body.Aliases)
existing.Label = body.Label
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 = ""
}
db.Save(&existing)
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 {
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)
}
}
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{}{
"name": name,
"planType": 1,
"sceneId": 9,
"scenario": 9,
"status": 1,
"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,
"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
}
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{
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
2026-03-18 21:06:16 +08:00
Aliases: strings.TrimSpace(body.Aliases),
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,
}
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
}
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
}
c.JSON(http.StatusOK, resp)
}
// createPersonMinimal 仅按 name 创建 Person含存客宝计划供 autolink 复用
// userID 可为空;用于“绑定用户 → 幂等创建”的场景
func createPersonMinimal(db *gorm.DB, name string, userID string) (*model.Person, error) {
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{
UserID: strPtrIfNotEmpty(userID),
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
}
return &newPerson, nil
}
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
}
// 3) 创建
_, 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
}
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
// 若有 ckb_plan_id先调存客宝删除计划再删本地
func DBPersonDelete(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": "人物不存在"})
return
}
// 若有存客宝计划,先调 CKB 删除
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
}
}
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
}
c.JSON(http.StatusOK, gin.H{"success": true})
}