Files
soul-yongping/soul-api/internal/handler/db_ckb_leads.go

332 lines
9.9 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_submit_recordsjoin/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")
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 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]
}
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
personName := ""
ckbPlanId := int64(0)
if p := personMap[r.TargetPersonID]; p != nil {
personName = p.Name
ckbPlanId = p.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,
"userId": r.UserID,
"userNickname": displayNick,
"userAvatar": userAvatar,
"matchType": "lead",
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"targetPersonId": r.TargetPersonID,
"personName": personName,
"ckbPlanId": ckbPlanId,
"pushStatus": r.PushStatus,
"retryCount": r.RetryCount,
"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_submit_records
q := db.Model(&model.CkbSubmitRecord{})
if matchType != "" {
// matchType 对应 action: join 时 type 在 params 中match 时 matchType 在 params 中
// 简化:仅按 action 过滤join 时 params 含 type
if matchType == "join" || matchType == "match" {
q = q.Where("action = ?", matchType)
}
}
var total int64
q.Count(&total)
var records []model.CkbSubmitRecord
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,
"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_submit_records + 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_submit_records GROUP BY action").Scan(&submitStats)
var submitTotal int64
db.Model(&model.CkbSubmitRecord{}).Count(&submitTotal)
var leadTotal int64
db.Model(&model.CkbLeadRecord{}).Count(&leadTotal)
withContact := leadTotal // ckb_lead_records 均有 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{},
},
})
}