package handler import ( "encoding/json" "net/http" "strings" "time" "soul-api/internal/database" "soul-api/internal/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) 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 前缀「链接头像_」。 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(` SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks FROM user_tracks WHERE action IN ('avatar_click', 'btn_click') AND target LIKE '链接头像\_%' AND SUBSTRING(target, 6) IN ? 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 } // DBVipMembersList GET /api/db/vip-members 管理端 - VIP 成员列表(用于超级个体排序) // 与小程序端 VipMembers 的列表逻辑保持一致:仅列出仍在有效期内的 VIP 用户。 // 额外聚合:clickCount(首页超级个体卡片点击)、leadCount(绑定人物后的去重获客),供管理端表格展示。 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) } ids := make([]string, 0, len(users)) for i := range users { ids = append(ids, users[i].ID) } clickByUser := batchSuperIndividualClicks(db, ids) leadByUser := batchSuperIndividualLeads(db, ids) webhookMap := loadSuperIndividualWebhookMap(db) list := make([]gin.H, 0, len(users)) for i := range users { item := formatVipMember(db, &users[i], true) uid := users[i].ID item["clickCount"] = clickByUser[uid] item["leadCount"] = leadByUser[uid] item["webhookUrl"] = strings.TrimSpace(webhookMap[uid]) list = append(list, item) } c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)}) } // 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}) }