Files
Mycontent/soul-api/internal/handler/db_ckb_leads.go
乘风 b2b753d566 feat: unify planKey handling for lead management
- 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.
2026-05-08 15:39:47 +08:00

438 lines
13 KiB
Go
Raw Permalink 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 (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// jsonValAsTrimmedString 从 JSON map 取值(兼容 number / string用于 params 里手机号等字段
func jsonValAsTrimmedString(v interface{}) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
if t == 0 {
return ""
}
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strings.TrimSpace(strconv.FormatFloat(t, 'f', -1, 64))
case json.Number:
return strings.TrimSpace(string(t))
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
// extractPhoneWechatFromParamsJSON 从线索 params JSON 抽取联系方式(加入/匹配上报结构可能只用顶层 phone/wechat
func extractPhoneWechatFromParamsJSON(paramsJSON string) (phone, wechat string) {
s := strings.TrimSpace(paramsJSON)
if s == "" {
return "", ""
}
var p map[string]interface{}
if json.Unmarshal([]byte(s), &p) != nil {
return "", ""
}
for _, k := range []string{"phone", "mobile", "tel"} {
if v := jsonValAsTrimmedString(p[k]); v != "" {
phone = v
break
}
}
for _, k := range []string{"wechat", "wechatId", "wechat_id", "wx", "wxId"} {
if v := jsonValAsTrimmedString(p[k]); v != "" {
wechat = v
break
}
}
return phone, wechat
}
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
// mode=submitted: ckb_lead_recordsaction=join/match兼容旧面板命名
// mode=contact: ckb_lead_records链接卡若留资有 phone/wechat
func DBCKBLeadList(c *gin.Context) {
db := database.DB()
mode := c.DefaultQuery("mode", "submitted")
matchType := c.Query("matchType")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
if mode == "contact" {
search := c.Query("search")
source := c.Query("source")
action := strings.TrimSpace(c.Query("action"))
pushStatus := strings.TrimSpace(c.Query("pushStatus"))
q := db.Model(&model.CkbLeadRecord{})
if search != "" {
q = q.Where("nickname LIKE ? OR phone LIKE ? OR wechat_id LIKE ? OR name LIKE ?",
"%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%")
}
if source != "" {
q = q.Where("source = ?", source)
}
if action != "" {
q = q.Where("action = ?", action)
}
if pushStatus != "" {
q = q.Where("push_status = ?", pushStatus)
}
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// 批量查 persons 获取 personName、ckbPlanId
personIDs := make([]string, 0)
for _, r := range records {
if r.TargetPersonID != "" {
personIDs = append(personIDs, r.TargetPersonID)
}
}
personMap := make(map[string]*model.Person)
if len(personIDs) > 0 {
var persons []model.Person
db.Where("person_id IN ? OR token IN ?", personIDs, personIDs).Find(&persons)
for i := range persons {
personMap[persons[i].PersonID] = &persons[i]
personMap[persons[i].Token] = &persons[i]
}
}
// 批量查 users头像、昵称与会员资料一致
uidSet := make(map[string]struct{})
for _, r := range records {
if strings.TrimSpace(r.UserID) != "" {
uidSet[r.UserID] = struct{}{}
}
}
uids := make([]string, 0, len(uidSet))
for id := range uidSet {
uids = append(uids, id)
}
userMap := make(map[string]*model.User)
if len(uids) > 0 {
var urows []model.User
db.Select("id", "nickname", "avatar").Where("id IN ?", uids).Find(&urows)
for i := range urows {
userMap[urows[i].ID] = &urows[i]
}
}
// 首页 index_link_button 历史数据可能未写 target_person_id用全局 leadKey 对应 Person 回退展示
var indexLinkFallback *model.Person
leadKey := getCkbLeadApiKey()
if leadKey != "" {
var fp model.Person
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&fp).Error == nil {
indexLinkFallback = &fp
}
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
// 与重推 retryOneLeadRecord 共用 resolveLeadPlanAPIKeyForRetry避免列表「计划Key」与实发 apiKey 再分叉
var paramsMap map[string]interface{}
_ = json.Unmarshal([]byte(r.Params), &paramsMap)
planKey := resolveLeadPlanAPIKeyForRetry(db, r, paramsMap)
personName := ""
ckbPlanId := int64(0)
if p := personMap[r.TargetPersonID]; p != nil {
personName = p.Name
ckbPlanId = p.CkbPlanID
} else if strings.TrimSpace(r.TargetPersonID) == "" && r.Source == "index_link_button" && indexLinkFallback != nil {
personName = indexLinkFallback.Name
ckbPlanId = indexLinkFallback.CkbPlanID
}
displayNick := r.Nickname
userAvatar := ""
if u := userMap[r.UserID]; u != nil {
userAvatar = resolveAvatarURL(getStringValue(u.Avatar))
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
displayNick = strings.TrimSpace(*u.Nickname)
}
}
out = append(out, gin.H{
"id": r.ID,
"action": r.Action,
"userId": r.UserID,
"userNickname": displayNick,
"userAvatar": userAvatar,
"matchType": "lead",
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"planApiKey": planKey,
"targetPersonId": r.TargetPersonID,
"personName": personName,
"ckbPlanId": ckbPlanId,
"pushStatus": r.PushStatus,
"retryCount": r.RetryCount,
"ckbCode": r.CkbCode,
"ckbMessage": r.CkbMessage,
"ckbData": r.CkbData,
"ckbError": r.CkbError,
"lastPushAt": r.LastPushAt,
"nextRetryAt": r.NextRetryAt,
"createdAt": r.CreatedAt,
})
}
// 统计摘要:来源分布、去重获客人数
type sourceStat struct {
Source string `gorm:"column:source" json:"source"`
Cnt int64 `gorm:"column:cnt" json:"cnt"`
}
var sourceStats []sourceStat
db.Raw("SELECT COALESCE(source,'未知') as source, COUNT(*) as cnt FROM ckb_lead_records GROUP BY source ORDER BY cnt DESC").Scan(&sourceStats)
var uniqueUsers int64
db.Raw("SELECT COUNT(DISTINCT user_id) FROM ckb_lead_records WHERE user_id IS NOT NULL AND user_id != ''").Scan(&uniqueUsers)
c.JSON(http.StatusOK, gin.H{
"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize,
"stats": gin.H{
"uniqueUsers": uniqueUsers,
"sourceStats": sourceStats,
},
})
return
}
// mode=submitted: 兼容旧面板,统一从 ckb_lead_records 中读取 join/match
q := db.Model(&model.CkbLeadRecord{}).Where("action IN ?", []string{"join", "match"})
if matchType != "" {
if matchType == "join" || matchType == "match" {
q = q.Where("action = ?", matchType)
}
}
search := strings.TrimSpace(c.Query("search"))
if search != "" {
like := "%" + search + "%"
q = q.Where(
"(user_id LIKE ? OR nickname LIKE ? OR phone LIKE ? OR wechat_id LIKE ? OR "+
"CAST(id AS CHAR) LIKE ? OR params LIKE ? OR source LIKE ?)",
like, like, like, like, like, like, like,
)
}
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
userIDs := make(map[string]bool)
for _, r := range records {
if r.UserID != "" {
userIDs[r.UserID] = true
}
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
if len(ids) > 0 {
db.Where("id IN ?", ids).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
safeNickname := func(u *model.User) string {
if u == nil || u.Nickname == nil {
return ""
}
return *u.Nickname
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
phone := strings.TrimSpace(r.Phone)
wechatID := strings.TrimSpace(r.WechatID)
if phone == "" || wechatID == "" {
pp, ww := extractPhoneWechatFromParamsJSON(r.Params)
if phone == "" {
phone = pp
}
if wechatID == "" {
wechatID = ww
}
}
// 仍为空:按 user_id 回填会员资料autoCKBReport 历史误写、或库内列为空但用户已补全资料)
if u := userMap[r.UserID]; u != nil {
if phone == "" && u.Phone != nil {
phone = strings.TrimSpace(*u.Phone)
}
if wechatID == "" && u.WechatID != nil {
wechatID = strings.TrimSpace(*u.WechatID)
}
}
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"userNickname": safeNickname(userMap[r.UserID]),
"matchType": r.Action,
"nickname": r.Nickname,
"phone": phone,
"wechatId": wechatID,
"params": r.Params,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// DBCKBLeadRetry POST /api/db/ckb-leads/retry 管理端-手动重推单条失败线索
func DBCKBLeadRetry(c *gin.Context) {
var body struct {
ID int64 `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少有效 id"})
return
}
ok, err := RetryCkbLeadByID(c.Request.Context(), body.ID)
if err != nil {
msg := strings.TrimSpace(err.Error())
if msg == "" {
msg = "重推失败"
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": msg})
return
}
db := database.DB()
var r model.CkbLeadRecord
if err := db.Where("id = ?", body.ID).First(&r).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "pushed": ok})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"pushed": ok,
"record": gin.H{
"id": r.ID,
"pushStatus": r.PushStatus,
"retryCount": r.RetryCount,
"ckbCode": r.CkbCode,
"ckbMessage": r.CkbMessage,
"ckbData": r.CkbData,
"ckbError": r.CkbError,
"lastPushAt": r.LastPushAt,
"nextRetryAt": r.NextRetryAt,
},
})
}
// DBCKBLeadDelete POST /api/db/ckb-leads/delete 管理端-删除一条留资记录(运营清理误报/测试数据)
func DBCKBLeadDelete(c *gin.Context) {
var body struct {
ID int64 `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少有效 id"})
return
}
db := database.DB()
res := db.Delete(&model.CkbLeadRecord{}, body.ID)
if res.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": res.Error.Error()})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "记录不存在或已删除"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ckbLeadDeleteBatchMax 单次批量删除上限,防止误操作与请求过大
const ckbLeadDeleteBatchMax = 500
// DBCKBLeadDeleteBatch POST /api/db/ckb-leads/delete-batch 管理端-批量删除留资记录
func DBCKBLeadDeleteBatch(c *gin.Context) {
var body struct {
IDs []int64 `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请传入 ids 数组"})
return
}
seen := make(map[int64]struct{})
clean := make([]int64, 0, len(body.IDs))
for _, id := range body.IDs {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
clean = append(clean, id)
}
if len(clean) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "没有有效的 id"})
return
}
if len(clean) > ckbLeadDeleteBatchMax {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("单次最多删除 %d 条,请减少勾选或分批提交", ckbLeadDeleteBatchMax),
})
return
}
db := database.DB()
res := db.Where("id IN ?", clean).Delete(&model.CkbLeadRecord{})
if res.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": res.Error.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "deleted": res.RowsAffected})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(统一基于 ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()
type TypeStat struct {
Action string `gorm:"column:action" json:"matchType"`
Total int64 `gorm:"column:total" json:"total"`
}
var submitStats []TypeStat
db.Raw("SELECT action, COUNT(*) as total FROM ckb_lead_records WHERE action IN ('join','match') GROUP BY action").Scan(&submitStats)
var submitTotal int64
db.Model(&model.CkbLeadRecord{}).Where("action IN ?", []string{"join", "match"}).Count(&submitTotal)
var leadTotal int64
db.Model(&model.CkbLeadRecord{}).Where("action = ?", "lead").Count(&leadTotal)
withContact := leadTotal // lead 记录均有 phone 或 wechat
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"ckbTotal": submitTotal + leadTotal,
"withContact": withContact,
"byType": submitStats,
"ckbApiKey": "***",
"ckbApiUrl": "https://ckbapi.quwanzhi.com/v1/api/scenarios",
"docNotes": "",
"docContent": "",
"routes": gin.H{},
},
})
}