Files
Mycontent/soul-api/internal/handler/vip_members_admin.go
Alex-larget 54955f5512 重构:更新API基础URL并增强内容解析逻辑
- 更改了API基础URL,以便在开发和生产环境之间切换。
- 添加了assetBase参数,以提高内容解析中图像源的分辨率。
- 增强了阅读页面的配置,将assetBase纳入图像URL的配置中。
- 更新了OpenPlatformPage,以便正确导航至设置和API文档。
- 对ContentPage进行了重构,以包含额外的webhook配置检索功能。
- 改进了DashboardPage,使其能更有效地处理用户交互。
- 清理了未使用的导入,并优化了AdminLayout,以提高可读性。
2026-04-02 17:15:03 +08:00

249 lines
7.3 KiB
Go
Raw 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"
"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)})
}
// activeSuperIndividualUserIDs 与 DBVipMembersList / 小程序超级个体口径一致的有效用户 id无条数上限供 @ 列表自动补人)
func activeSuperIndividualUserIDs(db *gorm.DB) []string {
var users []model.User
err := db.Table("users").
Select("id").
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
Order("id ASC").
Find(&users).Error
if err != nil || len(users) == 0 {
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 {
return nil
}
if err := db.Select("id").Where("id IN ?", userIDs).Find(&users).Error; err != nil {
return nil
}
}
out := make([]string, 0, len(users))
for _, u := range users {
id := strings.TrimSpace(u.ID)
if id != "" {
out = append(out, id)
}
}
return out
}
// DBVipMemberWebhookSet PUT /api/db/vip-members/webhook
// 按超级个体用户维度配置飞书群 webhookVOX 地址)。
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})
}