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