package handler import ( "encoding/json" "net/http" "strconv" "strings" "time" "soul-api/internal/database" "soul-api/internal/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // miniprogramSuperIndividualPerson 有效 VIP 且已绑定人物(persons.user_id)→ 超级个体,可查看客资与轨迹 func miniprogramSuperIndividualPerson(db *gorm.DB, userID string) (*model.Person, bool) { userID = strings.TrimSpace(userID) if userID == "" { return nil, false } if ok, _ := isVipEffective(db, userID); !ok { return nil, false } var person model.Person if err := db.Where("user_id = ?", userID).First(&person).Error; err != nil { return nil, false } return &person, true } // dedupeLeadTracksDisplay 与获客 Webhook 类似:同一用户、同一行为、同一 target, // 若上一条已保留记录与当前条间隔小于 window,则跳过当前条(tracks 须按 created_at 倒序) func dedupeLeadTracksDisplay(tracks []model.UserTrack, window time.Duration) []model.UserTrack { if len(tracks) == 0 { return nil } out := make([]model.UserTrack, 0, len(tracks)) lastKept := map[string]time.Time{} for _, t := range tracks { tgt := "" if t.Target != nil { tgt = *t.Target } key := t.UserID + "\x00" + t.Action + "\x00" + tgt var ts time.Time if t.CreatedAt != nil { ts = *t.CreatedAt } if last, ok := lastKept[key]; ok && last.Sub(ts) < window { continue } lastKept[key] = ts out = append(out, t) } return out } func batchUserNicknames(db *gorm.DB, userIDs []string) map[string]string { out := map[string]string{} if len(userIDs) == 0 { return out } type row struct { ID string `gorm:"column:id"` Nickname string `gorm:"column:nickname"` } var rows []row _ = db.Table("users").Select("id", "nickname").Where("id IN ?", userIDs).Find(&rows).Error for _, r := range rows { if strings.TrimSpace(r.ID) != "" { out[r.ID] = strings.TrimSpace(r.Nickname) } } return out } // MiniprogramMySuperLeads GET /api/miniprogram/my/super-leads?userId=&page=&pageSize= // 超级个体查看绑定人物下的获客列表(按 user_id 去重,每人保留最新一条留资,与 /api/db/ckb-person-leads 一致) func MiniprogramMySuperLeads(c *gin.Context) { userID := strings.TrimSpace(c.Query("userId")) if userID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"}) return } db := database.DB() person, ok := miniprogramSuperIndividualPerson(db, userID) if !ok || person == nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可查看", "forbidden": true}) return } 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 } var total int64 db.Model(&model.CkbLeadRecord{}).Where("target_person_id = ?", person.PersonID). Select("COUNT(DISTINCT user_id)").Scan(&total) var records []model.CkbLeadRecord if err := db.Raw(` SELECT l.* FROM ckb_lead_records l INNER JOIN ( SELECT user_id, MAX(created_at) AS max_at FROM ckb_lead_records WHERE target_person_id = ? GROUP BY user_id ) latest ON l.user_id = latest.user_id AND l.created_at = latest.max_at WHERE l.target_person_id = ? ORDER BY l.created_at DESC LIMIT ? OFFSET ? `, person.PersonID, person.PersonID, pageSize, (page-1)*pageSize).Scan(&records).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } out := make([]gin.H, 0, len(records)) for _, r := range records { out = append(out, gin.H{ "id": r.ID, "userId": r.UserID, "nickname": r.Nickname, "phone": r.Phone, "wechatId": r.WechatID, "name": r.Name, "source": r.Source, "sourceLabel": leadSourceLabel(r.Source), "createdAt": r.CreatedAt, "pushStatus": r.PushStatus, "lastPushAt": r.LastPushAt, }) } c.JSON(http.StatusOK, gin.H{ "success": true, "records": out, "total": total, "page": page, "pageSize": pageSize, "personName": person.Name, }) } // MiniprogramMySuperLeadTracks GET /api/miniprogram/my/super-lead-tracks?userId=&limit=&before= // 曾向该人物留资的用户在小程序内的行为轨迹(user_tracks),时间倒序;近 1 小时同键去重。 // before:RFC3339,仅返回早于该时间的记录,用于翻页。 func MiniprogramMySuperLeadTracks(c *gin.Context) { userID := strings.TrimSpace(c.Query("userId")) if userID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"}) return } db := database.DB() person, ok := miniprogramSuperIndividualPerson(db, userID) if !ok || person == nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可查看", "forbidden": true}) return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40")) if limit < 1 || limit > 80 { limit = 40 } beforeStr := strings.TrimSpace(c.Query("before")) var beforeT *time.Time if beforeStr != "" { if bt, err := time.Parse(time.RFC3339Nano, beforeStr); err == nil { beforeT = &bt } else if bt, err := time.Parse(time.RFC3339, beforeStr); err == nil { beforeT = &bt } } sql := ` SELECT t.* FROM user_tracks t WHERE t.user_id IN ( SELECT DISTINCT user_id FROM ckb_lead_records WHERE target_person_id = ? AND user_id IS NOT NULL AND user_id != '' ) ` args := []interface{}{person.PersonID} if beforeT != nil { sql += ` AND t.created_at < ?` args = append(args, *beforeT) } sql += ` ORDER BY t.created_at DESC LIMIT 320` var tracks []model.UserTrack if err := db.Raw(sql, args...).Scan(&tracks).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } deduped := dedupeLeadTracksDisplay(tracks, time.Hour) if len(deduped) > limit { deduped = deduped[:limit] } var total int64 _ = db.Raw(` SELECT COUNT(*) FROM user_tracks t WHERE t.user_id IN ( SELECT DISTINCT user_id FROM ckb_lead_records WHERE target_person_id = ? AND user_id IS NOT NULL AND user_id != '' ) `, person.PersonID).Scan(&total) var nextBefore string if len(deduped) > 0 { last := deduped[len(deduped)-1] if last.CreatedAt != nil { nextBefore = last.CreatedAt.UTC().Format(time.RFC3339Nano) } } ids := make([]string, 0, len(deduped)) seen := map[string]bool{} for _, t := range deduped { uid := strings.TrimSpace(t.UserID) if uid != "" && !seen[uid] { seen[uid] = true ids = append(ids, uid) } } nickMap := batchUserNicknames(db, ids) titleMap := resolveChapterTitlesForTracks(db, deduped) out := make([]gin.H, 0, len(deduped)) for _, t := range deduped { target := "" if t.Target != nil { target = *t.Target } chTitle := "" if t.ChapterID != nil { chTitle = titleMap[strings.TrimSpace(*t.ChapterID)] } if chTitle == "" && target != "" { chTitle = titleMap[strings.TrimSpace(target)] } var extra map[string]interface{} if len(t.ExtraData) > 0 { _ = json.Unmarshal(t.ExtraData, &extra) } module := "" if extra != nil { if m, ok := extra["module"].(string); ok { module = m } } var createdAt time.Time if t.CreatedAt != nil { createdAt = *t.CreatedAt } uid := strings.TrimSpace(t.UserID) out = append(out, gin.H{ "id": t.ID, "userId": uid, "userNickname": nickMap[uid], "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action), "target": target, "chapterTitle": chTitle, "module": module, "moduleLabel": userTrackModuleLabelCN(module), "createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt), }) } c.JSON(http.StatusOK, gin.H{ "success": true, "tracks": out, "total": total, "limit": limit, "nextBefore": nextBefore, "personName": person.Name, }) }