- 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.
438 lines
13 KiB
Go
438 lines
13 KiB
Go
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_records(action=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), ¶msMap)
|
||
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{},
|
||
},
|
||
})
|
||
}
|