Files
Mycontent/soul-api/internal/handler/vip_members_admin.go
卡若 6d11fb295d feat: 同步本地三端改动并清理上传凭证风险
整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。

Made-with: Cursor
2026-04-06 15:59:34 +08:00

319 lines
9.8 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 统计超级个体被点击次数:
// 1. member-detail 页 avatar_click / btn_clicktarget 格式「链接头像_userId」
// 2. 首页超级个体横滑 card_clicktarget 格式「超级个体_userId」
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 user_id, SUM(clicks) AS clicks FROM (
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
UNION ALL
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
FROM user_tracks
WHERE action = 'card_click'
AND target LIKE '超级个体\_%'
AND SUBSTRING(target, 6) IN ?
GROUP BY user_id
) t GROUP BY user_id
`, userIDs, 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)
// 批量查 persons 关联token, isPinned, homeEntryConfig
personByUserID := map[string]*model.Person{}
if len(ids) > 0 {
var persons []model.Person
db.Where("user_id IN ?", ids).Find(&persons)
for i := range persons {
if persons[i].UserID != nil && *persons[i].UserID != "" {
personByUserID[*persons[i].UserID] = &persons[i]
}
}
}
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])
if p, ok := personByUserID[uid]; ok {
item["personId"] = p.PersonID
item["personToken"] = p.Token
item["isPinned"] = p.IsPinned
item["personName"] = p.Name
item["personSource"] = p.PersonSource
item["ckbPlanId"] = p.CkbPlanID
item["ckbApiKey"] = p.CkbApiKey
if p.HomeEntryConfig != nil && strings.TrimSpace(*p.HomeEntryConfig) != "" {
var hec interface{}
if json.Unmarshal([]byte(*p.HomeEntryConfig), &hec) == nil {
item["homeEntryConfig"] = hec
}
}
}
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})
}
// DBPersonHomeEntryConfigSave PUT /api/db/persons/home-entry-config
// 保存超级个体的首页入口配置(打赏弹窗 + 上麦时段),存入 persons.home_entry_config。
func DBPersonHomeEntryConfigSave(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
HomeEntryConfig interface{} `json:"homeEntryConfig"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
userID := strings.TrimSpace(body.UserID)
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "userId 不能为空"})
return
}
db := database.DB()
var person model.Person
if err := db.Where("user_id = ?", userID).First(&person).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该用户尚未关联超级个体人物"})
return
}
configJSON, err := json.Marshal(body.HomeEntryConfig)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置序列化失败"})
return
}
s := string(configJSON)
if err := db.Model(&model.Person{}).Where("id = ?", person.ID).Update("home_entry_config", s).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}