290 lines
7.8 KiB
Go
290 lines
7.8 KiB
Go
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,
|
||
})
|
||
}
|