Files
soul-yongping/soul-api/internal/handler/db_person.go
卡若 80e397f7ac feat: 运营-用户功能四大需求完整实现
1. 客资中心:Dashboard 聚合 CKB 线索+提交记录,联表用户信息
2. @置顶:Person 三端(后端+管理端+小程序)置顶功能,首页优先展示
3. 存客宝场景:一键检查并自动启用所有场景获客计划
4. 去重增强:后端聚合 dupCount,管理端展示重复标记和统计
5. 首页文案:"最新更新"→"推荐","开始阅读"→"点击阅读"

Made-with: Cursor
2026-03-19 16:20:46 +08:00

532 lines
17 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
}
// 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})
}
// 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})
}