Files
Mycontent/soul-api/internal/handler/miniprogram_my_super_crm.go
2026-04-13 14:32:32 +08:00

290 lines
7.8 KiB
Go
Raw Permalink 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 (
"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 小时同键去重。
// beforeRFC3339仅返回早于该时间的记录用于翻页。
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,
})
}