Files
soul-yongping/soul-api/internal/handler/db_ckb_leads.go
Alex-larget 159ce035f2 1
2026-03-26 21:29:18 +08:00

371 lines
12 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 (
"fmt"
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// 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 {
// 兜底:历史数据 plan_api_key 可能为空(已迁移但仍有漏网),按 action 回填展示,不改库
planKey := strings.TrimSpace(r.PlanAPIKey)
if planKey == "" {
if strings.TrimSpace(r.Action) == "join" || strings.TrimSpace(r.Action) == "match" {
planKey = ckbAPIKey
} else if strings.TrimSpace(r.Action) == "lead" && r.Source == "index_link_button" {
planKey = getCkbLeadApiKey()
}
}
personName := ""
ckbPlanId := int64(0)
if p := personMap[r.TargetPersonID]; p != nil {
personName = p.Name
ckbPlanId = p.CkbPlanID
// 兜底:迁移后计划 key 多写在 persons.ckb_api_key但 lead_records.plan_api_key 可能仍为空
// 此处仅用于管理端回显(前端会做掩码展示),不改库
if planKey == "" && strings.TrimSpace(p.CkbApiKey) != "" {
planKey = strings.TrimSpace(p.CkbApiKey)
}
} else if strings.TrimSpace(r.TargetPersonID) == "" && r.Source == "index_link_button" && indexLinkFallback != nil {
personName = indexLinkFallback.Name
ckbPlanId = indexLinkFallback.CkbPlanID
if planKey == "" && strings.TrimSpace(indexLinkFallback.CkbApiKey) != "" {
planKey = strings.TrimSpace(indexLinkFallback.CkbApiKey)
}
}
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)
}
}
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 {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"userNickname": safeNickname(userMap[r.UserID]),
"matchType": r.Action,
"nickname": r.Nickname,
"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{},
},
})
}