Files
soul-yongping/soul-api/internal/handler/db_person.go
2026-03-18 21:06:16 +08:00

438 lines
14 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 (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"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})
}
// 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 管理端-新增或更新人物
// 新增时自动生成 32 位唯一 token文章 @ 时存 token小程序点击时用 token 兑换真实密钥
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"`
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
existing.Aliases = strings.TrimSpace(body.Aliases)
existing.Label = body.Label
existing.CkbApiKey = body.CkbApiKey
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
}
// 新增:创建本地 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
}
// 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}
}
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,
"distributionEnabled": false,
"deviceGroups": deviceIDs,
}
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
}
}
newPerson := model.Person{
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
Aliases: strings.TrimSpace(body.Aliases),
Label: body.Label,
CkbApiKey: apiKey,
CkbPlanID: planID,
Greeting: body.Greeting,
Tips: body.Tips,
RemarkType: body.RemarkType,
RemarkFormat: body.RemarkFormat,
AddFriendInterval: addInterval,
StartTime: startTime,
EndTime: endTime,
}
idsStr := make([]string, 0, len(deviceIDs))
for _, id := range deviceIDs {
idsStr = append(idsStr, fmt.Sprintf("%d", id))
}
newPerson.DeviceGroups = strings.Join(idsStr, ",")
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
}
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})
}