2026-03-12 11:36:50 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-24 01:22:50 +08:00
|
|
|
|
"encoding/json"
|
2026-03-12 11:36:50 +08:00
|
|
|
|
"net/http"
|
2026-03-24 01:22:50 +08:00
|
|
|
|
"strings"
|
2026-03-12 11:36:50 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"soul-api/internal/database"
|
|
|
|
|
|
"soul-api/internal/model"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-03-22 08:34:28 +08:00
|
|
|
|
"gorm.io/gorm"
|
2026-03-12 11:36:50 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-24 01:22:50 +08:00
|
|
|
|
const superIndividualWebhookConfigKey = "super_individual_webhook_map"
|
|
|
|
|
|
|
|
|
|
|
|
func loadSuperIndividualWebhookMap(db *gorm.DB) map[string]string {
|
|
|
|
|
|
out := map[string]string{}
|
|
|
|
|
|
var cfg model.SystemConfig
|
|
|
|
|
|
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&cfg).Error; err != nil {
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(cfg.ConfigValue) == 0 {
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
raw := map[string]string{}
|
|
|
|
|
|
if err := json.Unmarshal(cfg.ConfigValue, &raw); err != nil {
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
for k, v := range raw {
|
|
|
|
|
|
k = strings.TrimSpace(k)
|
|
|
|
|
|
v = strings.TrimSpace(v)
|
|
|
|
|
|
if k == "" || v == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// batchSuperIndividualClicks 统计「点击头像」行为:
|
|
|
|
|
|
// user_tracks 中 action=avatar_click(兼容历史 btn_click)且 target 前缀「链接头像_」。
|
2026-03-22 08:34:28 +08:00
|
|
|
|
func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64 {
|
|
|
|
|
|
out := make(map[string]int64)
|
|
|
|
|
|
if len(userIDs) == 0 {
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
type row struct {
|
|
|
|
|
|
UserID string `gorm:"column:user_id"`
|
|
|
|
|
|
Clicks int64 `gorm:"column:clicks"`
|
|
|
|
|
|
}
|
|
|
|
|
|
var rows []row
|
|
|
|
|
|
_ = db.Raw(`
|
2026-03-24 01:22:50 +08:00
|
|
|
|
SELECT
|
|
|
|
|
|
SUBSTRING(target, 6) AS user_id,
|
|
|
|
|
|
COUNT(*) AS clicks
|
2026-03-22 08:34:28 +08:00
|
|
|
|
FROM user_tracks
|
2026-03-24 01:22:50 +08:00
|
|
|
|
WHERE action IN ('avatar_click', 'btn_click')
|
|
|
|
|
|
AND target LIKE '链接头像\_%'
|
|
|
|
|
|
AND SUBSTRING(target, 6) IN ?
|
2026-03-22 08:34:28 +08:00
|
|
|
|
GROUP BY user_id
|
|
|
|
|
|
`, userIDs).Scan(&rows)
|
|
|
|
|
|
for _, r := range rows {
|
|
|
|
|
|
if r.UserID != "" {
|
|
|
|
|
|
out[r.UserID] = r.Clicks
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// batchSuperIndividualLeads 与 AdminSuperIndividualStats 一致:persons.user_id 绑定 + ckb_lead_records 去重获客人数
|
|
|
|
|
|
func batchSuperIndividualLeads(db *gorm.DB, userIDs []string) map[string]int64 {
|
|
|
|
|
|
out := make(map[string]int64)
|
|
|
|
|
|
if len(userIDs) == 0 {
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
type row struct {
|
|
|
|
|
|
UserID string `gorm:"column:user_id"`
|
|
|
|
|
|
Leads int64 `gorm:"column:leads"`
|
|
|
|
|
|
}
|
|
|
|
|
|
var rows []row
|
|
|
|
|
|
_ = db.Raw(`
|
|
|
|
|
|
SELECT p.user_id AS user_id, COUNT(DISTINCT l.user_id) AS leads
|
|
|
|
|
|
FROM persons p
|
|
|
|
|
|
INNER JOIN ckb_lead_records l ON l.target_person_id = p.person_id
|
|
|
|
|
|
WHERE p.user_id IN ?
|
|
|
|
|
|
GROUP BY p.user_id
|
|
|
|
|
|
`, userIDs).Scan(&rows)
|
|
|
|
|
|
for _, r := range rows {
|
|
|
|
|
|
if r.UserID != "" {
|
|
|
|
|
|
out[r.UserID] = r.Leads
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 11:36:50 +08:00
|
|
|
|
// DBVipMembersList GET /api/db/vip-members 管理端 - VIP 成员列表(用于超级个体排序)
|
|
|
|
|
|
// 与小程序端 VipMembers 的列表逻辑保持一致:仅列出仍在有效期内的 VIP 用户。
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 额外聚合:clickCount(首页超级个体卡片点击)、leadCount(绑定人物后的去重获客),供管理端表格展示。
|
2026-03-12 11:36:50 +08:00
|
|
|
|
func DBVipMembersList(c *gin.Context) {
|
|
|
|
|
|
limit := 200
|
|
|
|
|
|
if l := c.Query("limit"); l != "" {
|
|
|
|
|
|
if n, err := parseInt(l); err == nil && n > 0 && n <= 500 {
|
|
|
|
|
|
limit = n
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
db := database.DB()
|
|
|
|
|
|
|
|
|
|
|
|
// 与 VipMembers 一致:优先 users 表(is_vip=1 且 vip_expire_date>NOW),排序使用 vip_sort
|
|
|
|
|
|
var users []model.User
|
|
|
|
|
|
err := db.Table("users").
|
|
|
|
|
|
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort", "vip_expire_date", "is_vip", "phone", "wechat_id").
|
|
|
|
|
|
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
|
|
|
|
|
|
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
|
|
|
|
|
|
Limit(limit).
|
|
|
|
|
|
Find(&users).Error
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil || len(users) == 0 {
|
|
|
|
|
|
// 兜底:从 orders 查,逻辑与 VipMembers 保持一致
|
|
|
|
|
|
var userIDs []string
|
|
|
|
|
|
db.Model(&model.Order{}).Select("DISTINCT user_id").
|
|
|
|
|
|
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
|
|
|
|
|
|
Pluck("user_id", &userIDs)
|
|
|
|
|
|
if len(userIDs) == 0 {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
db.Where("id IN ?", userIDs).Find(&users)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
ids := make([]string, 0, len(users))
|
|
|
|
|
|
for i := range users {
|
|
|
|
|
|
ids = append(ids, users[i].ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
clickByUser := batchSuperIndividualClicks(db, ids)
|
|
|
|
|
|
leadByUser := batchSuperIndividualLeads(db, ids)
|
2026-03-24 01:22:50 +08:00
|
|
|
|
webhookMap := loadSuperIndividualWebhookMap(db)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
2026-03-12 11:36:50 +08:00
|
|
|
|
list := make([]gin.H, 0, len(users))
|
|
|
|
|
|
for i := range users {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
item := formatVipMember(db, &users[i], true)
|
|
|
|
|
|
uid := users[i].ID
|
|
|
|
|
|
item["clickCount"] = clickByUser[uid]
|
|
|
|
|
|
item["leadCount"] = leadByUser[uid]
|
2026-03-24 01:22:50 +08:00
|
|
|
|
item["webhookUrl"] = strings.TrimSpace(webhookMap[uid])
|
2026-03-22 08:34:28 +08:00
|
|
|
|
list = append(list, item)
|
2026-03-12 11:36:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
|
|
|
|
|
}
|
2026-03-24 01:22:50 +08:00
|
|
|
|
|
|
|
|
|
|
// DBVipMemberWebhookSet PUT /api/db/vip-members/webhook
|
|
|
|
|
|
// 按超级个体用户维度配置飞书群 webhook(VOX 地址)。
|
|
|
|
|
|
func DBVipMemberWebhookSet(c *gin.Context) {
|
|
|
|
|
|
var body struct {
|
|
|
|
|
|
UserID string `json:"userId"`
|
|
|
|
|
|
WebhookURL string `json:"webhookUrl"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
userID := strings.TrimSpace(body.UserID)
|
|
|
|
|
|
webhookURL := strings.TrimSpace(body.WebhookURL)
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "userId 不能为空"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if webhookURL != "" && !strings.HasPrefix(webhookURL, "http") {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "Webhook 地址必须是 http/https"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
db := database.DB()
|
|
|
|
|
|
var count int64
|
|
|
|
|
|
db.Model(&model.User{}).Where("id = ?", userID).Count(&count)
|
|
|
|
|
|
if count == 0 {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
webhookMap := loadSuperIndividualWebhookMap(db)
|
|
|
|
|
|
if webhookURL == "" {
|
|
|
|
|
|
delete(webhookMap, userID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
webhookMap[userID] = webhookURL
|
|
|
|
|
|
}
|
|
|
|
|
|
val, _ := json.Marshal(webhookMap)
|
|
|
|
|
|
desc := "超级个体飞书群Webhook映射(按userId)"
|
|
|
|
|
|
var row model.SystemConfig
|
|
|
|
|
|
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&row).Error; err != nil {
|
|
|
|
|
|
if err == gorm.ErrRecordNotFound {
|
|
|
|
|
|
row = model.SystemConfig{
|
|
|
|
|
|
ConfigKey: superIndividualWebhookConfigKey,
|
|
|
|
|
|
ConfigValue: val,
|
|
|
|
|
|
Description: &desc,
|
|
|
|
|
|
}
|
|
|
|
|
|
if e := db.Create(&row).Error; e != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row.ConfigValue = val
|
|
|
|
|
|
row.Description = &desc
|
|
|
|
|
|
if e := db.Save(&row).Error; e != nil {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
|
|
|
|
}
|