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}) }