- Added a new entry for the 存客宝计划 Key in the documentation, ensuring consistency between the displayed planKey and the apiKey used for retries. - Implemented a caching mechanism for the JWT token in the ckbOpenGetToken function to reduce redundant API calls. - Refactored the lead management logic to ensure that the same planKey is used for both displaying leads and retrying failed pushes, enhancing data integrity and reducing discrepancies. This update aims to streamline lead management processes and improve the reliability of API interactions.
986 lines
32 KiB
Go
986 lines
32 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"soul-api/internal/cache"
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
|
||
// syncMissingSuperIndividualsAsPersons 将当前有效超级个体(VIP 口径与 vip-members 一致)中尚未绑定 persons.user_id 的用户,
|
||
// 通过 ensurePersonForUser 追加为可 @ 人物(昵称不合法或存客宝失败时跳过并打日志)。
|
||
func syncMissingSuperIndividualsAsPersons(db *gorm.DB) {
|
||
ids := activeSuperIndividualUserIDs(db)
|
||
if len(ids) == 0 {
|
||
return
|
||
}
|
||
var bound []string
|
||
if err := db.Model(&model.Person{}).
|
||
Where("user_id IS NOT NULL AND user_id != ?", "").
|
||
Distinct("user_id").
|
||
Pluck("user_id", &bound).Error; err != nil {
|
||
log.Printf("[persons] sync: list bound user_id failed: %v", err)
|
||
return
|
||
}
|
||
boundSet := make(map[string]struct{}, len(bound))
|
||
for _, b := range bound {
|
||
boundSet[strings.TrimSpace(b)] = struct{}{}
|
||
}
|
||
// 未配置「超级个体统一获客计划」时,创建 Person 走 createPersonMinimal,必须先过存客宝开放鉴权。
|
||
// 鉴权失败若仍逐用户 ensurePersonForUser,会对每个超级个体各打一条相同错误,刷屏且拖慢 /api/db/persons。
|
||
if _, sharedOk := loadSuperIndividualSharedPlanConfig(db); !sharedOk {
|
||
if _, err := ckbOpenGetToken(); err != nil {
|
||
log.Printf("[persons] sync super-individual skipped (need CKB open API or shared plan): %v — configure CKB_OPEN_API_KEY + CKB_OPEN_ACCOUNT, or system_config key %s",
|
||
err, SuperIndividualSharedPlanConfigKey)
|
||
return
|
||
}
|
||
}
|
||
any := false
|
||
for _, uid := range ids {
|
||
uid = strings.TrimSpace(uid)
|
||
if uid == "" {
|
||
continue
|
||
}
|
||
if _, ok := boundSet[uid]; ok {
|
||
continue
|
||
}
|
||
if err := ensurePersonForUser(db, uid); err != nil {
|
||
log.Printf("[persons] sync super-individual userId=%s: %v", uid, err)
|
||
continue
|
||
}
|
||
boundSet[uid] = struct{}{}
|
||
any = true
|
||
}
|
||
if any {
|
||
invalidateReadExtrasCache()
|
||
}
|
||
}
|
||
|
||
// DBPersonList GET /api/db/persons 管理端-@提及人物列表
|
||
func DBPersonList(c *gin.Context) {
|
||
db := database.DB()
|
||
syncMissingSuperIndividualsAsPersons(db)
|
||
var rows []model.Person
|
||
if err := 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})
|
||
}
|
||
|
||
// collectPersonUniquenessConflicts 检测名称/别名是否与其他 @人物 冲突(与 autolink 一致:sanitize + 逗号分隔别名)
|
||
// excludePersonID 非空时排除该 person_id(编辑当前条)
|
||
func collectPersonUniquenessConflicts(db *gorm.DB, name, aliasesStr, excludePersonID string) []string {
|
||
name = strings.TrimSpace(name)
|
||
aliasesStr = strings.TrimSpace(aliasesStr)
|
||
excludePersonID = strings.TrimSpace(excludePersonID)
|
||
|
||
var messages []string
|
||
nameKey := ""
|
||
if name != "" {
|
||
nameKey = sanitizeNameOrLabel(name)
|
||
if nameKey == "" || !isValidNameOrLabel(nameKey) {
|
||
nameKey = ""
|
||
}
|
||
}
|
||
|
||
seenLocal := make(map[string]bool)
|
||
dupAlias := make(map[string]bool)
|
||
var inputAliasKeys []string
|
||
for _, seg := range splitPersonAliases(aliasesStr) {
|
||
k := sanitizeNameOrLabel(seg)
|
||
if k == "" || !isValidNameOrLabel(k) {
|
||
continue
|
||
}
|
||
if seenLocal[k] {
|
||
dupAlias[k] = true
|
||
continue
|
||
}
|
||
seenLocal[k] = true
|
||
inputAliasKeys = append(inputAliasKeys, k)
|
||
}
|
||
for k := range dupAlias {
|
||
messages = append(messages, fmt.Sprintf("别名「%s」重复填写", k))
|
||
}
|
||
|
||
if nameKey != "" {
|
||
for _, ak := range inputAliasKeys {
|
||
if ak == nameKey {
|
||
messages = append(messages, fmt.Sprintf("名称与别名「%s」重复", nameKey))
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
var rows []model.Person
|
||
q := db.Select("person_id", "name", "aliases")
|
||
if excludePersonID != "" {
|
||
q = q.Where("person_id != ?", excludePersonID)
|
||
}
|
||
if err := q.Find(&rows).Error; err != nil {
|
||
return []string{"查询人物列表失败: " + err.Error()}
|
||
}
|
||
|
||
for _, p := range rows {
|
||
display := strings.TrimSpace(p.Name)
|
||
oNameKey := sanitizeNameOrLabel(display)
|
||
if oNameKey == "" {
|
||
continue
|
||
}
|
||
oAliasSet := make(map[string]bool)
|
||
for _, a := range splitPersonAliases(p.Aliases) {
|
||
k := sanitizeNameOrLabel(a)
|
||
if k != "" && isValidNameOrLabel(k) {
|
||
oAliasSet[k] = true
|
||
}
|
||
}
|
||
|
||
if nameKey != "" {
|
||
if oNameKey == nameKey {
|
||
messages = append(messages, fmt.Sprintf("名称「%s」与已有 @人物「%s」重复", nameKey, display))
|
||
}
|
||
if oAliasSet[nameKey] {
|
||
messages = append(messages, fmt.Sprintf("名称「%s」已被 @人物「%s」用作别名", nameKey, display))
|
||
}
|
||
}
|
||
for _, ak := range inputAliasKeys {
|
||
if oNameKey == ak {
|
||
messages = append(messages, fmt.Sprintf("别名「%s」与 @人物「%s」的名称重复", ak, display))
|
||
}
|
||
if oAliasSet[ak] {
|
||
messages = append(messages, fmt.Sprintf("别名「%s」已被 @人物「%s」使用", ak, display))
|
||
}
|
||
}
|
||
}
|
||
|
||
seen := make(map[string]bool)
|
||
uniq := make([]string, 0, len(messages))
|
||
for _, m := range messages {
|
||
if m != "" && !seen[m] {
|
||
seen[m] = true
|
||
uniq = append(uniq, m)
|
||
}
|
||
}
|
||
return uniq
|
||
}
|
||
|
||
// DBPersonCheckUnique GET /api/db/persons/check-unique?name=&aliases=&excludePersonId= 管理端-实时检测名称/别名唯一性
|
||
func DBPersonCheckUnique(c *gin.Context) {
|
||
name := strings.TrimSpace(c.Query("name"))
|
||
aliases := strings.TrimSpace(c.Query("aliases"))
|
||
exclude := strings.TrimSpace(c.Query("excludePersonId"))
|
||
if name == "" && aliases == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "ok": true, "messages": []string{}})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
msgs := collectPersonUniquenessConflicts(db, name, aliases, exclude)
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "ok": len(msgs) == 0, "messages": msgs})
|
||
}
|
||
|
||
// 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"`
|
||
UserID string `json:"userId"` // 可选:绑定会员用户 id,与「指定人=会员」一致;传空字符串则解除绑定
|
||
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
|
||
}
|
||
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
|
||
}
|
||
}
|
||
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())
|
||
}
|
||
isUpdate := db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil
|
||
excludeUniq := ""
|
||
if isUpdate {
|
||
excludeUniq = existing.PersonID
|
||
}
|
||
if msgs := collectPersonUniquenessConflicts(db, body.Name, body.Aliases, excludeUniq); len(msgs) > 0 {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": msgs[0], "messages": msgs})
|
||
return
|
||
}
|
||
|
||
if isUpdate {
|
||
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
|
||
}
|
||
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)
|
||
invalidateReadExtrasCache()
|
||
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,
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
invalidateReadExtrasCache()
|
||
c.JSON(http.StatusOK, resp)
|
||
}
|
||
|
||
// UserSourceArticleMentionAuto users.source:文章保存时 @ 无匹配人物自动创建的占位账号(无 open_id,与 persons.user_id 绑定)
|
||
const UserSourceArticleMentionAuto = "article_mention_auto"
|
||
|
||
func genMentionAutoReferralCode() string {
|
||
b := make([]byte, 9)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return "M" + fmt.Sprintf("%016X", time.Now().UnixNano())[:19]
|
||
}
|
||
s := "M" + hex.EncodeToString(b)
|
||
if len(s) > 20 {
|
||
return s[:20]
|
||
}
|
||
return s
|
||
}
|
||
|
||
// createUserPlaceholderForArticleMention 创建无微信 open_id 的占位用户,供 ParseAutoLink 新建 @ 人物时与 Person 绑定
|
||
func createUserPlaceholderForArticleMention(db *gorm.DB, displayName string) (*model.User, error) {
|
||
displayName = strings.TrimSpace(displayName)
|
||
if displayName == "" {
|
||
return nil, fmt.Errorf("displayName 必填")
|
||
}
|
||
runes := []rune(displayName)
|
||
if len(runes) > 80 {
|
||
displayName = string(runes[:80])
|
||
}
|
||
nick := displayName
|
||
hasFullBook := false
|
||
earnings := 0.0
|
||
pendingEarnings := 0.0
|
||
referralCount := 0
|
||
purchasedSections := "[]"
|
||
avatar := ""
|
||
src := UserSourceArticleMentionAuto
|
||
|
||
for attempt := 0; attempt < 8; attempt++ {
|
||
userID := "user_mention_" + randomSuffix()
|
||
referralCode := genMentionAutoReferralCode()
|
||
u := model.User{
|
||
ID: userID,
|
||
OpenID: nil,
|
||
Nickname: &nick,
|
||
Avatar: &avatar,
|
||
ReferralCode: &referralCode,
|
||
HasFullBook: &hasFullBook,
|
||
PurchasedSections: &purchasedSections,
|
||
Earnings: &earnings,
|
||
PendingEarnings: &pendingEarnings,
|
||
ReferralCount: &referralCount,
|
||
Source: &src,
|
||
}
|
||
if err := db.Create(&u).Error; err != nil {
|
||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||
continue
|
||
}
|
||
return nil, err
|
||
}
|
||
return &u, nil
|
||
}
|
||
return nil, fmt.Errorf("创建占位用户失败,请重试")
|
||
}
|
||
|
||
// 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
|
||
}
|
||
invalidateReadExtrasCache()
|
||
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) 创建:若已配置「超级个体统一获客计划」,则共用该计划,否则每人新建 SOUL链接人与事-*
|
||
if cfg, ok := loadSuperIndividualSharedPlanConfig(db); ok {
|
||
_, err := createPersonWithSharedSuperIndividualPlan(db, nickname, userID, cfg)
|
||
return err
|
||
}
|
||
_, 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.Transaction(func(tx *gorm.DB) error {
|
||
if pinned {
|
||
if err := tx.Model(&model.Person{}).Where("is_pinned = ?", true).Update("is_pinned", false).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return tx.Model(&model.Person{}).Where("person_id = ?", row.PersonID).Update("is_pinned", pinned).Error
|
||
}); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
invalidateReadExtrasCache()
|
||
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})
|
||
}
|
||
|
||
// DBPersonPinnedToken GET /api/db/persons/pinned-token 当前置顶人物 token(管理端预览/配置用,与置顶列表首条一致)
|
||
func DBPersonPinnedToken(c *gin.Context) {
|
||
db := database.DB()
|
||
var p model.Person
|
||
err := db.Where("is_pinned = ?", true).Order("updated_at DESC").First(&p).Error
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "token": ""})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "token": strings.TrimSpace(p.Token)})
|
||
}
|
||
|
||
// CKBPinnedPerson GET /api/miniprogram/ckb/pinned-person 小程序首页:当前置顶人物(无置顶时 data 为 null)
|
||
// name 为 @人物展示名(Person.Name);avatar 为绑定会员用户头像(与运营文案「点击链接@名」一致)
|
||
func CKBPinnedPerson(c *gin.Context) {
|
||
db := database.DB()
|
||
var p model.Person
|
||
err := db.Where("is_pinned = ?", true).Order("updated_at DESC").First(&p).Error
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||
return
|
||
}
|
||
personName := strings.TrimSpace(p.Name)
|
||
if personName == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||
return
|
||
}
|
||
avatar := ""
|
||
if p.UserID != nil && *p.UserID != "" {
|
||
var u model.User
|
||
if db.Select("avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {
|
||
avatar = resolveAvatarURL(getStringValue(u.Avatar))
|
||
}
|
||
}
|
||
// 返回 per-person 首页入口配置(置顶超级个体独立配置),小程序优先读此字段
|
||
var homeEntryConfig interface{}
|
||
if p.HomeEntryConfig != nil && strings.TrimSpace(*p.HomeEntryConfig) != "" {
|
||
var hec interface{}
|
||
if json.Unmarshal([]byte(*p.HomeEntryConfig), &hec) == nil {
|
||
homeEntryConfig = hec
|
||
}
|
||
}
|
||
data := gin.H{
|
||
"name": personName,
|
||
"avatar": avatar,
|
||
"token": strings.TrimSpace(p.Token),
|
||
}
|
||
if homeEntryConfig != nil {
|
||
data["homeEntryConfig"] = homeEntryConfig
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": data,
|
||
})
|
||
}
|
||
|
||
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
|
||
// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝计划详情做只读连通性探测(不调 update,避免误报「更新计划失败」)
|
||
// 可选 ?personId=xxx 仅检查单个人物(超级个体行内刷新)
|
||
func AdminCKBPlanCheck(c *gin.Context) {
|
||
db := database.DB()
|
||
var persons []model.Person
|
||
q := db.Where("ckb_plan_id > 0")
|
||
if pid := strings.TrimSpace(c.Query("personId")); pid != "" {
|
||
q = q.Where("person_id = ?", pid)
|
||
}
|
||
q.Find(&persons)
|
||
if len(persons) == 0 {
|
||
msg := "暂无配置了存客宝计划的人物"
|
||
if strings.TrimSpace(c.Query("personId")) != "" {
|
||
msg = "该人物未配置存客宝计划或不存在"
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": msg})
|
||
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 probeErr := ckbOpenProbePlanReachable(token, p.CkbPlanID); probeErr != nil {
|
||
item["status"] = "error"
|
||
item["error"] = probeErr.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
|
||
}
|
||
// 超级个体共用统一计划:只删本地人物,禁止删除存客宝计划
|
||
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 删除(手工添加的一人一号计划)
|
||
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
|
||
}
|
||
invalidateReadExtrasCache()
|
||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||
}
|