Merge branch 'devlop' into yongxu-dev
# Conflicts: # miniprogram/app.js resolved by devlop version # miniprogram/pages/chapters/chapters.js resolved by devlop version # miniprogram/pages/match/match.js resolved by devlop version # miniprogram/pages/member-detail/member-detail.js resolved by devlop version # miniprogram/pages/my/my.js resolved by devlop version # miniprogram/pages/read/read.js resolved by devlop version # miniprogram/pages/referral/referral.js resolved by devlop version # soul-api/internal/model/person.go resolved by devlop version
This commit is contained in:
@@ -307,7 +307,7 @@ func AdminDashboardMerchantBalance(c *gin.Context) {
|
||||
}
|
||||
|
||||
// AdminSuperIndividualStats GET /api/admin/super-individual/stats
|
||||
// 超级个体点击/获客统计:从 user_tracks 中筛选 target LIKE '超级个体_%' 的记录,
|
||||
// 超级个体点击/获客统计:从 user_tracks 中筛选「点击头像」记录(target LIKE '链接头像_%'),
|
||||
// 按被点击的超级个体 ID 分组,统计点击次数、独立点击用户数
|
||||
func AdminSuperIndividualStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
@@ -324,7 +324,8 @@ func AdminSuperIndividualStats(c *gin.Context) {
|
||||
COUNT(*) AS clicks,
|
||||
COUNT(DISTINCT user_id) AS unique_clicks
|
||||
FROM user_tracks
|
||||
WHERE action = 'card_click' AND target LIKE '超级个体\_%'
|
||||
WHERE action IN ('avatar_click', 'btn_click')
|
||||
AND target LIKE '链接头像\_%'
|
||||
GROUP BY target_id
|
||||
ORDER BY clicks DESC
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
|
||||
88
soul-api/internal/handler/admin_mbti_avatars.go
Normal file
88
soul-api/internal/handler/admin_mbti_avatars.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const mbtiAvatarsConfigKey = "mbti_avatars"
|
||||
const mbtiAvatarsDescription = "MBTI 16型人格头像映射"
|
||||
|
||||
// AdminMbtiAvatarsGet GET /api/admin/mbti-avatars 读取 MBTI 头像映射(system_config.mbti_avatars)
|
||||
func AdminMbtiAvatarsGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": map[string]string{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "读取配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
out := make(map[string]string)
|
||||
if len(row.ConfigValue) > 0 {
|
||||
if uerr := json.Unmarshal(row.ConfigValue, &out); uerr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置 JSON 无效: " + uerr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": out})
|
||||
}
|
||||
|
||||
// AdminMbtiAvatarsPost POST /api/admin/mbti-avatars 保存 MBTI 头像映射(upsert)
|
||||
func AdminMbtiAvatarsPost(c *gin.Context) {
|
||||
var body struct {
|
||||
Avatars map[string]string `json:"avatars"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
avatars := body.Avatars
|
||||
if avatars == nil {
|
||||
avatars = map[string]string{}
|
||||
}
|
||||
valBytes, err := json.Marshal(avatars)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "序列化失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
desc := mbtiAvatarsDescription
|
||||
var row model.SystemConfig
|
||||
err = db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
row = model.SystemConfig{
|
||||
ConfigKey: mbtiAvatarsConfigKey,
|
||||
ConfigValue: valBytes,
|
||||
Description: &desc,
|
||||
}
|
||||
if cerr := db.Create(&row).Error; cerr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + cerr.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "查询配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
row.ConfigValue = valBytes
|
||||
row.Description = &desc
|
||||
if serr := db.Save(&row).Error; serr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + serr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
_mbtiAvatarCacheTs = 0
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "MBTI 头像映射已保存"})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -37,7 +38,8 @@ func DBUserRulesList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules 小程序规则引擎:返回启用的规则,无需鉴权
|
||||
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules?userId=xxx
|
||||
// 返回启用的规则,并标记当前用户已完成的规则
|
||||
func MiniprogramUserRulesGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var rules []model.UserRule
|
||||
@@ -45,7 +47,43 @@ func MiniprogramUserRulesGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
userId := c.Query("userId")
|
||||
completedSet := make(map[uint]bool)
|
||||
if userId != "" {
|
||||
var completions []model.UserRuleCompletion
|
||||
db.Where("user_id = ?", userId).Find(&completions)
|
||||
for _, comp := range completions {
|
||||
completedSet[comp.RuleID] = true
|
||||
}
|
||||
}
|
||||
out := make([]gin.H, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID, "title": r.Title, "description": r.Description,
|
||||
"trigger": r.Trigger, "sort": r.Sort, "completed": completedSet[r.ID],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": out})
|
||||
}
|
||||
|
||||
// MiniprogramUserRuleComplete POST /api/miniprogram/user-rules/complete
|
||||
func MiniprogramUserRuleComplete(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
RuleID uint `json:"ruleId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
comp := model.UserRuleCompletion{UserID: body.UserID, RuleID: body.RuleID}
|
||||
result := db.Where("user_id = ? AND rule_id = ?", body.UserID, body.RuleID).FirstOrCreate(&comp)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "alreadyCompleted": result.RowsAffected == 0})
|
||||
}
|
||||
|
||||
// DBUserRulesAction POST/PUT/DELETE /api/db/user-rules
|
||||
@@ -55,11 +93,14 @@ func DBUserRulesAction(c *gin.Context) {
|
||||
switch c.Request.Method {
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Trigger string `json:"trigger"`
|
||||
Sort int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Trigger string `json:"trigger"`
|
||||
TriggerConditions interface{} `json:"triggerConditions"`
|
||||
ActionType string `json:"actionType"`
|
||||
ActionConfig interface{} `json:"actionConfig"`
|
||||
Sort int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
@@ -73,9 +114,20 @@ func DBUserRulesAction(c *gin.Context) {
|
||||
Title: trimSpace(body.Title),
|
||||
Description: body.Description,
|
||||
Trigger: trimSpace(body.Trigger),
|
||||
ActionType: trimSpace(body.ActionType),
|
||||
Sort: body.Sort,
|
||||
Enabled: enabled,
|
||||
}
|
||||
if body.TriggerConditions != nil {
|
||||
if b, err := json.Marshal(body.TriggerConditions); err == nil {
|
||||
rule.TriggerConditions = b
|
||||
}
|
||||
}
|
||||
if body.ActionConfig != nil {
|
||||
if b, err := json.Marshal(body.ActionConfig); err == nil {
|
||||
rule.ActionConfig = b
|
||||
}
|
||||
}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
@@ -84,12 +136,15 @@ func DBUserRulesAction(c *gin.Context) {
|
||||
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
ID uint `json:"id" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Trigger string `json:"trigger"`
|
||||
Sort *int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ID uint `json:"id" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Trigger string `json:"trigger"`
|
||||
TriggerConditions interface{} `json:"triggerConditions"`
|
||||
ActionType string `json:"actionType"`
|
||||
ActionConfig interface{} `json:"actionConfig"`
|
||||
Sort *int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
@@ -110,6 +165,17 @@ func DBUserRulesAction(c *gin.Context) {
|
||||
}
|
||||
updates["description"] = body.Description
|
||||
updates["trigger"] = trimSpace(body.Trigger)
|
||||
updates["action_type"] = trimSpace(body.ActionType)
|
||||
if body.TriggerConditions != nil {
|
||||
if b, err := json.Marshal(body.TriggerConditions); err == nil {
|
||||
updates["trigger_conditions"] = string(b)
|
||||
}
|
||||
}
|
||||
if body.ActionConfig != nil {
|
||||
if b, err := json.Marshal(body.ActionConfig); err == nil {
|
||||
updates["action_config"] = string(b)
|
||||
}
|
||||
}
|
||||
if body.Sort != nil {
|
||||
updates["sort"] = *body.Sort
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ type cachedPartRow struct {
|
||||
Subtitle string `json:"subtitle"`
|
||||
ChapterCount int `json:"chapterCount"`
|
||||
MinSortOrder int `json:"minSortOrder"`
|
||||
// Icon 可选:system_config.book_part_icons JSON 中按 part_id 配置的封面图 URL
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
type cachedFixedItem struct {
|
||||
ID string `json:"id"`
|
||||
@@ -109,6 +111,48 @@ var bookPartsCache struct {
|
||||
|
||||
const bookPartsCacheTTL = 30 * time.Second
|
||||
|
||||
// loadBookPartIconURLs 读取 system_config.book_part_icons:{"part-1":"https://..."},key 与 chapters.part_id 一致
|
||||
func loadBookPartIconURLs() map[string]string {
|
||||
out := map[string]string{}
|
||||
var row model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", "book_part_icons").First(&row).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
|
||||
return out
|
||||
}
|
||||
for k, v := range raw {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeBookPartIcons 将配置中的篇封面 URL 写入 parts(每次接口响应前调用,避免 Redis 旧缓存缺 icon)
|
||||
func mergeBookPartIcons(parts []cachedPartRow) {
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
m := loadBookPartIconURLs()
|
||||
if len(m) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range parts {
|
||||
if u := strings.TrimSpace(m[parts[i].PartID]); u != "" {
|
||||
parts[i].Icon = u
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chaptersByPartCache 篇章内章节列表内存缓存,30 秒 TTL
|
||||
type chaptersByPartEntry struct {
|
||||
data []model.Chapter
|
||||
@@ -320,6 +364,7 @@ func BookParts(c *gin.Context) {
|
||||
// 1. 优先 Redis(后台无更新时长期有效)
|
||||
var redisPayload bookPartsRedisPayload
|
||||
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
|
||||
mergeBookPartIcons(redisPayload.Parts)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": redisPayload.Parts,
|
||||
@@ -336,6 +381,7 @@ func BookParts(c *gin.Context) {
|
||||
total := bookPartsCache.total
|
||||
fixed := bookPartsCache.fixed
|
||||
bookPartsCache.mu.RUnlock()
|
||||
mergeBookPartIcons(parts)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": parts,
|
||||
@@ -348,6 +394,7 @@ func BookParts(c *gin.Context) {
|
||||
|
||||
// 3. DB 查询并更新 Redis + 内存
|
||||
parts, total, fixed := fetchAndCacheBookParts()
|
||||
mergeBookPartIcons(parts)
|
||||
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
|
||||
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -443,6 +445,22 @@ func CKBIndexLead(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
|
||||
personName := "卡若"
|
||||
if defaultPerson.Name != "" {
|
||||
personName = defaultPerson.Name
|
||||
}
|
||||
go sendLeadWebhook(db, leadWebhookPayload{
|
||||
LeadName: name,
|
||||
Phone: phone,
|
||||
Wechat: wechatId,
|
||||
PersonName: personName,
|
||||
TargetMemberID: "",
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
@@ -472,13 +490,15 @@ func CKBIndexLead(c *gin.Context) {
|
||||
// 请求体:phone/wechatId(至少一个)、userId(补全昵称)、targetUserId(Person.token)、targetNickname、source(如 article_mention、member_detail_avatar)
|
||||
func CKBLead(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
TargetUserID string `json:"targetUserId"` // 被@的 personId(文章 mention 场景)
|
||||
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
|
||||
Source string `json:"source"` // index_lead / article_mention
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
TargetUserID string `json:"targetUserId"` // 被@的 personId(文章 mention / 超级个体人物 token)
|
||||
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
|
||||
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id(无 person token 时全局留资,写入 params 便于运营)
|
||||
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params,不误导读为「对方会联系您」)
|
||||
Source string `json:"source"` // index_lead / article_mention / member_detail_global
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
phone := strings.TrimSpace(body.Phone)
|
||||
@@ -504,6 +524,7 @@ func CKBLead(c *gin.Context) {
|
||||
// 首页链接卡若:targetUserId 为空 → 用全局 getCkbLeadApiKey()
|
||||
leadKey := getCkbLeadApiKey()
|
||||
targetName := strings.TrimSpace(body.TargetNickname)
|
||||
targetMemberID := strings.TrimSpace(body.TargetMemberID)
|
||||
personTips := "" // Person 配置的获客成功提示,优先于默认文案
|
||||
if body.TargetUserID != "" {
|
||||
var p model.Person
|
||||
@@ -520,6 +541,11 @@ func CKBLead(c *gin.Context) {
|
||||
if targetName == "" {
|
||||
targetName = p.Name
|
||||
}
|
||||
if targetMemberID == "" {
|
||||
if p.UserID != nil {
|
||||
targetMemberID = strings.TrimSpace(*p.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
|
||||
@@ -536,7 +562,8 @@ func CKBLead(c *gin.Context) {
|
||||
}
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"targetUserId": body.TargetUserID, "source": source,
|
||||
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
|
||||
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
@@ -611,7 +638,17 @@ func CKBLead(c *gin.Context) {
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
|
||||
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
|
||||
go sendLeadWebhook(db, leadWebhookPayload{
|
||||
LeadName: name,
|
||||
Phone: phone,
|
||||
Wechat: wechatId,
|
||||
PersonName: who,
|
||||
MemberName: strings.TrimSpace(body.TargetMemberName),
|
||||
TargetMemberID: targetMemberID,
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
@@ -675,7 +712,17 @@ func CKBLead(c *gin.Context) {
|
||||
} else {
|
||||
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
|
||||
}
|
||||
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
|
||||
go sendLeadWebhook(db, leadWebhookPayload{
|
||||
LeadName: name,
|
||||
Phone: phone,
|
||||
Wechat: wechatId,
|
||||
PersonName: who,
|
||||
MemberName: strings.TrimSpace(body.TargetMemberName),
|
||||
TargetMemberID: targetMemberID,
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
|
||||
return
|
||||
}
|
||||
@@ -702,10 +749,82 @@ func CKBLead(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, respObj)
|
||||
}
|
||||
|
||||
func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, repeated bool) {
|
||||
type leadWebhookPayload struct {
|
||||
LeadName string // 留资客户姓名
|
||||
Phone string
|
||||
Wechat string
|
||||
PersonName string // 对接人(Person 表 name / targetNickname)
|
||||
MemberName string // 超级个体名称(targetMemberName)
|
||||
TargetMemberID string // 超级个体 userId,用于按人路由 webhook
|
||||
Source string // 技术来源标识
|
||||
Repeated bool
|
||||
LeadUserID string // 留资用户ID,用于查询行为轨迹
|
||||
}
|
||||
|
||||
func leadSourceLabel(source string) string {
|
||||
switch source {
|
||||
case "member_detail_global":
|
||||
return "超级个体详情页·全局链接"
|
||||
case "member_detail_avatar":
|
||||
return "超级个体详情页·点击头像"
|
||||
case "article_mention":
|
||||
return "文章正文·@提及人物"
|
||||
case "index_link_button":
|
||||
return "首页·链接卡若按钮"
|
||||
case "index_lead":
|
||||
return "首页·留资弹窗"
|
||||
default:
|
||||
if source == "" {
|
||||
return "未知来源"
|
||||
}
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
var _webhookDedupCache = struct {
|
||||
sync.Mutex
|
||||
m map[string]string
|
||||
}{m: make(map[string]string)}
|
||||
|
||||
func webhookShouldSkip(userId string, targetMemberID string) bool {
|
||||
if userId == "" && targetMemberID == "" {
|
||||
return false
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
key := strings.TrimSpace(userId) + "|" + strings.TrimSpace(targetMemberID)
|
||||
if key == "|" {
|
||||
return false
|
||||
}
|
||||
_webhookDedupCache.Lock()
|
||||
defer _webhookDedupCache.Unlock()
|
||||
if _webhookDedupCache.m[key] == today {
|
||||
return true
|
||||
}
|
||||
_webhookDedupCache.m[key] = today
|
||||
if len(_webhookDedupCache.m) > 10000 {
|
||||
_webhookDedupCache.m = map[string]string{key: today}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadLeadWebhookURL(db *gorm.DB, targetMemberID string) string {
|
||||
// 优先按超级个体 userId 映射(单人单群)
|
||||
targetMemberID = strings.TrimSpace(targetMemberID)
|
||||
if targetMemberID != "" {
|
||||
var mapCfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&mapCfg).Error; err == nil && len(mapCfg.ConfigValue) > 0 {
|
||||
var m map[string]string
|
||||
if json.Unmarshal(mapCfg.ConfigValue, &m) == nil {
|
||||
if u := strings.TrimSpace(m[targetMemberID]); u != "" && strings.HasPrefix(u, "http") {
|
||||
return u
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 回退全局获客 webhook
|
||||
var cfg model.SystemConfig
|
||||
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
var webhookURL string
|
||||
if len(cfg.ConfigValue) > 0 {
|
||||
@@ -713,22 +832,56 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
|
||||
}
|
||||
webhookURL = strings.TrimSpace(webhookURL)
|
||||
if webhookURL == "" || !strings.HasPrefix(webhookURL, "http") {
|
||||
return ""
|
||||
}
|
||||
return webhookURL
|
||||
}
|
||||
|
||||
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
|
||||
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID, p.TargetMemberID) {
|
||||
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
|
||||
return
|
||||
}
|
||||
webhookURL := loadLeadWebhookURL(db, p.TargetMemberID)
|
||||
if webhookURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
tag := "新获客"
|
||||
if repeated {
|
||||
tag = "重复获客"
|
||||
tag := "📋 新获客"
|
||||
if p.Repeated {
|
||||
tag = "🔄 重复获客"
|
||||
}
|
||||
text := fmt.Sprintf("[%s] %s → %s\n姓名: %s", tag, source, target, name)
|
||||
if phone != "" {
|
||||
text += fmt.Sprintf("\n手机: %s", phone)
|
||||
sourceLabel := leadSourceLabel(p.Source)
|
||||
|
||||
contactPerson := p.PersonName
|
||||
if contactPerson == "" {
|
||||
contactPerson = p.MemberName
|
||||
}
|
||||
if wechat != "" {
|
||||
text += fmt.Sprintf("\n微信: %s", wechat)
|
||||
if contactPerson == "" || contactPerson == "对方" {
|
||||
contactPerson = "(公共获客池)"
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s\n来源: %s\n对接人: %s", tag, sourceLabel, contactPerson)
|
||||
text += "\n━━━━━━━━━━"
|
||||
text += fmt.Sprintf("\n姓名: %s", p.LeadName)
|
||||
if p.Phone != "" {
|
||||
text += fmt.Sprintf("\n手机: %s", p.Phone)
|
||||
}
|
||||
if p.Wechat != "" {
|
||||
text += fmt.Sprintf("\n微信: %s", p.Wechat)
|
||||
}
|
||||
text += fmt.Sprintf("\n时间: %s", time.Now().Format("2006-01-02 15:04"))
|
||||
|
||||
if p.LeadUserID != "" {
|
||||
recentTracks := GetUserRecentTracks(db, p.LeadUserID, 5)
|
||||
if len(recentTracks) > 0 {
|
||||
text += "\n━━━━━━━━━━\n最近行为:"
|
||||
for i, line := range recentTracks {
|
||||
text += fmt.Sprintf("\n %d. %s", i+1, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if strings.Contains(webhookURL, "qyapi.weixin.qq.com") {
|
||||
payload, _ = json.Marshal(map[string]interface{}{
|
||||
@@ -747,5 +900,5 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", target, resp.StatusCode)
|
||||
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", contactPerson, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -172,6 +172,18 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error)
|
||||
).Delete(&model.Order{})
|
||||
|
||||
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o)
|
||||
if pushErr := pushPaidOrderWebhook(db, &o); pushErr != nil {
|
||||
syncOrdersLogf("订单 %s webhook 推送失败: %v", o.OrderSN, pushErr)
|
||||
markOrderWebhookResult(db, o.OrderSN, false, pushErr)
|
||||
} else {
|
||||
markOrderWebhookResult(db, o.OrderSN, true, nil)
|
||||
}
|
||||
}
|
||||
// 兜底补偿:服务器卡顿/回调异常导致的未推送订单,统一补推
|
||||
if retried, sentCount, rerr := RetryPendingPaidOrderWebhooks(ctx, 500); rerr != nil {
|
||||
syncOrdersLogf("补推未发送订单失败: %v", rerr)
|
||||
} else if retried > 0 {
|
||||
syncOrdersLogf("补推未发送订单: 扫描 %d 笔,成功 %d 笔", retried, sentCount)
|
||||
}
|
||||
return synced, total, nil
|
||||
}
|
||||
@@ -199,6 +211,28 @@ func CronSyncOrders(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CronRetryOrderWebhooks GET/POST /api/cron/retry-order-webhooks
|
||||
// 手动补偿重推:仅推送未成功推送过的已支付订单。
|
||||
func CronRetryOrderWebhooks(c *gin.Context) {
|
||||
limit := 500
|
||||
if s := strings.TrimSpace(c.Query("limit")); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
retried, sent, err := RetryPendingPaidOrderWebhooks(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"retried": retried,
|
||||
"sent": sent,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// CronUnbindExpired GET/POST /api/cron/unbind-expired
|
||||
func CronUnbindExpired(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
|
||||
@@ -17,6 +17,197 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// parseConfigBool 将 JSON/map 中可能出现的 bool、字符串、数字归一为开关态(auditMode 等)
|
||||
func parseConfigBool(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(t))
|
||||
return s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
case float64:
|
||||
return t != 0
|
||||
case int:
|
||||
return t != 0
|
||||
case int64:
|
||||
return t != 0
|
||||
case json.Number:
|
||||
if i, err := t.Int64(); err == nil {
|
||||
return i != 0
|
||||
}
|
||||
if f, err := t.Float64(); err == nil {
|
||||
return f != 0
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isLikelyWxMiniProgramAppID 判断是否为微信小程序 AppID 常见形态:wx + 16 位十六进制(共 18 字符)。
|
||||
// 后台「链接标签」若 type=miniprogram 且在此列直接填真实 AppID,C 端会把该值当作 mpKey 去 linkedMiniprograms 里匹配 key;
|
||||
// 若未单独配置 linked_miniprograms,会提示「未找到关联小程序配置」。mergeDirectMiniProgramLinksFromLinkTags 会据此自动补全映射。
|
||||
func isLikelyWxMiniProgramAppID(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) != 18 || !strings.HasPrefix(s, "wx") {
|
||||
return false
|
||||
}
|
||||
for i := 2; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func linkedMiniprogramItemKey(item gin.H) string {
|
||||
if v, ok := item["key"]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func linkedMiniprogramItemAppIDEmpty(item gin.H) bool {
|
||||
v, ok := item["appId"]
|
||||
if !ok || v == nil {
|
||||
return true
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return !ok || strings.TrimSpace(s) == ""
|
||||
}
|
||||
|
||||
func linkedMiniprogramItemPathEmpty(item gin.H) bool {
|
||||
v, ok := item["path"]
|
||||
if !ok || v == nil {
|
||||
return true
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return !ok || strings.TrimSpace(s) == ""
|
||||
}
|
||||
|
||||
// mergeDirectMiniProgramLinksFromLinkTags 将「直接填写微信 AppID」的链接标签并入 linkedMiniprograms,兼容现有小程序 navigateToMiniProgram 查表逻辑(不改 C 端)。
|
||||
func mergeDirectMiniProgramLinksFromLinkTags(linked *[]gin.H, tags []model.LinkTag) {
|
||||
if linked == nil {
|
||||
return
|
||||
}
|
||||
byKey := make(map[string]int)
|
||||
for i := range *linked {
|
||||
k := linkedMiniprogramItemKey((*linked)[i])
|
||||
if k != "" {
|
||||
byKey[k] = i
|
||||
}
|
||||
}
|
||||
for _, t := range tags {
|
||||
if strings.TrimSpace(strings.ToLower(t.Type)) != "miniprogram" {
|
||||
continue
|
||||
}
|
||||
app := strings.TrimSpace(t.AppID)
|
||||
if app == "" || !isLikelyWxMiniProgramAppID(app) {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(t.PagePath)
|
||||
if idx, ok := byKey[app]; ok {
|
||||
item := (*linked)[idx]
|
||||
if linkedMiniprogramItemAppIDEmpty(item) {
|
||||
item["appId"] = app
|
||||
}
|
||||
if path != "" && linkedMiniprogramItemPathEmpty(item) {
|
||||
item["path"] = path
|
||||
}
|
||||
(*linked)[idx] = item
|
||||
continue
|
||||
}
|
||||
entry := gin.H{"key": app, "appId": app}
|
||||
if path != "" {
|
||||
entry["path"] = path
|
||||
}
|
||||
*linked = append(*linked, entry)
|
||||
byKey[app] = len(*linked) - 1
|
||||
}
|
||||
}
|
||||
|
||||
// defaultMpUi 小程序文案与导航默认值,存于 mp_config.mpUi;管理端系统设置可部分覆盖(深合并)
|
||||
func defaultMpUi() gin.H {
|
||||
return gin.H{
|
||||
"tabBar": gin.H{
|
||||
"home": "首页", "chapters": "目录", "match": "找伙伴", "my": "我的",
|
||||
},
|
||||
"chaptersPage": gin.H{
|
||||
"bookTitle": "一场SOUL的创业实验场",
|
||||
"bookSubtitle": "来自Soul派对房的真实商业故事",
|
||||
},
|
||||
"homePage": gin.H{
|
||||
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
|
||||
"linkKaruoText": "点击链接卡若", "linkKaruoAvatar": "",
|
||||
"searchPlaceholder": "搜索章节标题或内容...",
|
||||
"bannerTag": "推荐", "bannerReadMoreText": "点击阅读",
|
||||
"superSectionTitle": "超级个体", "superSectionLinkText": "获客入口",
|
||||
"superSectionLinkPath": "/pages/match/match",
|
||||
"pickSectionTitle": "精选推荐",
|
||||
"latestSectionTitle": "最新新增",
|
||||
},
|
||||
"myPage": gin.H{
|
||||
"cardLabel": "名片", "vipLabelVip": "会员中心", "vipLabelGuest": "成为会员",
|
||||
"cardPath": "", "vipPath": "/pages/vip/vip",
|
||||
"readStatLabel": "已读章节", "recentReadTitle": "最近阅读",
|
||||
"readStatPath": "/pages/reading-records/reading-records?focus=all",
|
||||
"recentReadPath": "/pages/reading-records/reading-records?focus=recent",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func asStringMap(v interface{}) map[string]interface{} {
|
||||
if v == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// deepMergeMpUi 将 DB 中的 mpUi 与默认值深合并(嵌套 map)
|
||||
func deepMergeMpUi(base gin.H, overRaw interface{}) gin.H {
|
||||
over := asStringMap(overRaw)
|
||||
out := gin.H{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range over {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
bv := out[k]
|
||||
vm := asStringMap(v)
|
||||
if len(vm) == 0 && v != nil {
|
||||
// 非 map 覆盖
|
||||
out[k] = v
|
||||
continue
|
||||
}
|
||||
if len(vm) > 0 {
|
||||
bm := asStringMap(bv)
|
||||
if len(bm) == 0 {
|
||||
out[k] = deepMergeMpUi(gin.H{}, vm)
|
||||
} else {
|
||||
sub := gin.H{}
|
||||
for sk, sv := range bm {
|
||||
sub[sk] = sv
|
||||
}
|
||||
out[k] = deepMergeMpUi(sub, vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用
|
||||
func buildMiniprogramConfig() gin.H {
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
@@ -36,6 +227,7 @@ func buildMiniprogramConfig() gin.H {
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"shareIcon": "", // 分享图标URL,由管理端配置
|
||||
"mpUi": defaultMpUi(),
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
@@ -87,6 +279,7 @@ func buildMiniprogramConfig() gin.H {
|
||||
for k, v := range m {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
|
||||
out["mpConfig"] = merged
|
||||
out["configs"].(gin.H)["mp_config"] = merged
|
||||
}
|
||||
@@ -123,42 +316,46 @@ func buildMiniprogramConfig() gin.H {
|
||||
if _, has := out["userDiscount"]; !has {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
// 链接标签列表(小程序 onLinkTagTap 需要 type;miniprogram 类型存 mpKey,用 key 查 linkedMiniprograms 得 appId)
|
||||
// 链接标签列表(小程序 onLinkTagTap:miniprogram 类型下发 mpKey=C 端用其匹配 linkedMiniprograms[].key;历史设计为「密钥→appId」,现支持 app_id 列直接填微信 AppID 并由下方 merge 自动补 linkedMiniprograms)
|
||||
var linkTagRows []model.LinkTag
|
||||
if err := db.Order("label ASC").Find(&linkTagRows).Error; err == nil {
|
||||
tags := make([]gin.H, 0, len(linkTagRows))
|
||||
for _, t := range linkTagRows {
|
||||
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": t.URL, "type": t.Type, "pagePath": t.PagePath}
|
||||
if t.Type == "miniprogram" {
|
||||
h["mpKey"] = t.AppID // miniprogram 类型时 AppID 列存的是密钥
|
||||
} else {
|
||||
h["appId"] = t.AppID
|
||||
_ = db.Order("label ASC").Find(&linkTagRows).Error
|
||||
tags := make([]gin.H, 0, len(linkTagRows))
|
||||
for _, t := range linkTagRows {
|
||||
cType := t.Type
|
||||
cURL := t.URL
|
||||
// wxlink(小程序短链):下发给 C 端时转为 url 类型,现有 read.js web-view 可直接跳转,无需升级小程序
|
||||
if strings.EqualFold(cType, "wxlink") {
|
||||
cType = "url"
|
||||
if cURL == "" {
|
||||
cURL = t.AppID
|
||||
}
|
||||
tags = append(tags, h)
|
||||
}
|
||||
out["linkTags"] = tags
|
||||
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": cURL, "type": cType, "pagePath": t.PagePath}
|
||||
if t.Type == "miniprogram" {
|
||||
h["mpKey"] = t.AppID
|
||||
} else {
|
||||
h["appId"] = t.AppID
|
||||
}
|
||||
tags = append(tags, h)
|
||||
}
|
||||
// 关联小程序列表(key 为 32 位密钥,小程序用 key 查 appId 后 wx.navigateToMiniProgram)
|
||||
out["linkTags"] = tags
|
||||
|
||||
// 关联小程序列表(小程序:find(m => m.key === mpKey) → navigateToMiniProgram)
|
||||
var linkedList []gin.H
|
||||
var linkedMpRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "linked_miniprograms").First(&linkedMpRow).Error; err == nil && len(linkedMpRow.ConfigValue) > 0 {
|
||||
var linkedList []gin.H
|
||||
if err := json.Unmarshal(linkedMpRow.ConfigValue, &linkedList); err == nil && len(linkedList) > 0 {
|
||||
out["linkedMiniprograms"] = linkedList
|
||||
} else {
|
||||
// JSON解析失败,使用空数组
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
if err := json.Unmarshal(linkedMpRow.ConfigValue, &linkedList); err != nil {
|
||||
linkedList = nil
|
||||
}
|
||||
} else {
|
||||
// 未找到配置或查询失败,使用空数组作为默认值
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
// 明确归一化 auditMode:仅当 DB 显式为 true 时返回 true,否则一律 false(避免历史脏数据/类型异常导致误判)
|
||||
if linkedList == nil {
|
||||
linkedList = []gin.H{}
|
||||
}
|
||||
mergeDirectMiniProgramLinksFromLinkTags(&linkedList, linkTagRows)
|
||||
out["linkedMiniprograms"] = linkedList
|
||||
// 归一化 auditMode(兼容历史 bool / 字符串 / 数字)
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
mp["auditMode"] = true
|
||||
} else {
|
||||
mp["auditMode"] = false
|
||||
}
|
||||
mp["auditMode"] = parseConfigBool(mp["auditMode"])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -203,10 +400,7 @@ func getAuditModeFromDB() bool {
|
||||
if err := json.Unmarshal(row.ConfigValue, &mp); err != nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return parseConfigBool(mp["auditMode"])
|
||||
}
|
||||
|
||||
// GetCoreConfig GET /api/miniprogram/config/core 核心配置(prices、features、userDiscount、mpConfig),首屏/Tab 用
|
||||
@@ -303,9 +497,7 @@ func WarmConfigCache() {
|
||||
// 拆分接口预热
|
||||
auditMode := false
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
auditMode = true
|
||||
}
|
||||
auditMode = parseConfigBool(mp["auditMode"])
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigAuditMode, gin.H{"auditMode": auditMode}, cache.AuditModeTTL)
|
||||
core := gin.H{
|
||||
@@ -392,6 +584,7 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
"minWithdraw": float64(10),
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"mpUi": defaultMpUi(),
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
@@ -428,6 +621,7 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
for k, v := range m {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
|
||||
out["mpConfig"] = merged
|
||||
}
|
||||
case "oss_config":
|
||||
@@ -818,7 +1012,9 @@ func DBUsersList(c *gin.Context) {
|
||||
pattern := "%" + search + "%"
|
||||
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
if vipFilter == "true" || vipFilter == "1" {
|
||||
if poolFilter == "complete" {
|
||||
query = query.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
|
||||
} else if vipFilter == "true" || vipFilter == "1" {
|
||||
query = query.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)",
|
||||
[]string{"fullbook", "vip"}, "paid", "completed", time.Now())
|
||||
}
|
||||
@@ -853,7 +1049,7 @@ func DBUsersList(c *gin.Context) {
|
||||
var fullbookRows []struct {
|
||||
UserID string
|
||||
}
|
||||
db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status = ?", []string{"fullbook", "vip"}, "paid").Find(&fullbookRows)
|
||||
db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status IN ?", []string{"fullbook", "vip"}, []string{"paid", "completed", "success"}).Find(&fullbookRows)
|
||||
for _, r := range fullbookRows {
|
||||
hasFullBookMap[r.UserID] = true
|
||||
}
|
||||
@@ -862,7 +1058,7 @@ func DBUsersList(c *gin.Context) {
|
||||
Count int64
|
||||
}
|
||||
db.Model(&model.Order{}).Select("user_id, COUNT(*) as count").
|
||||
Where("product_type = ? AND status = ?", "section", "paid").
|
||||
Where("product_type = ? AND status IN ?", "section", []string{"paid", "completed", "success"}).
|
||||
Group("user_id").Find(§ionRows)
|
||||
for _, r := range sectionRows {
|
||||
sectionCountMap[r.UserID] = int(r.Count)
|
||||
@@ -925,6 +1121,35 @@ func DBUsersList(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. RFM 实时打分:对当前页用户批量计算(只查当前页 userIDs 的聚合)
|
||||
type rfmAgg struct {
|
||||
UserID string
|
||||
OrderCount int
|
||||
TotalAmount float64
|
||||
LastOrderAt time.Time
|
||||
}
|
||||
var rfmAggs []rfmAgg
|
||||
db.Raw(`SELECT user_id, COUNT(*) as order_count, SUM(amount) as total_amount, MAX(created_at) as last_order_at
|
||||
FROM orders WHERE user_id IN ? AND status IN ('paid','success','completed')
|
||||
GROUP BY user_id`, userIDs).Scan(&rfmAggs)
|
||||
rfmAggMap := make(map[string]rfmAgg, len(rfmAggs))
|
||||
var rfmMaxRecency, rfmMaxFreq int
|
||||
var rfmMaxMonetary float64
|
||||
now := time.Now()
|
||||
for _, a := range rfmAggs {
|
||||
rfmAggMap[a.UserID] = a
|
||||
days := int(now.Sub(a.LastOrderAt).Hours() / 24)
|
||||
if days > rfmMaxRecency {
|
||||
rfmMaxRecency = days
|
||||
}
|
||||
if a.OrderCount > rfmMaxFreq {
|
||||
rfmMaxFreq = a.OrderCount
|
||||
}
|
||||
if a.TotalAmount > rfmMaxMonetary {
|
||||
rfmMaxMonetary = a.TotalAmount
|
||||
}
|
||||
}
|
||||
|
||||
// 填充每个用户的实时计算字段
|
||||
for i := range users {
|
||||
uid := users[i].ID
|
||||
@@ -957,6 +1182,16 @@ func DBUsersList(c *gin.Context) {
|
||||
bindCount = dbCount
|
||||
}
|
||||
users[i].ReferralCount = ptrInt(bindCount)
|
||||
|
||||
// RFM 打分(有订单的用户才有分数)
|
||||
if agg, ok := rfmAggMap[uid]; ok {
|
||||
recencyDays := int(now.Sub(agg.LastOrderAt).Hours() / 24)
|
||||
score := calcRFMScoreForUser(recencyDays, agg.OrderCount, agg.TotalAmount,
|
||||
rfmMaxRecency, rfmMaxFreq, rfmMaxMonetary)
|
||||
level := calcRFMLevel(score)
|
||||
users[i].RFMScore = ptrFloat64(score)
|
||||
users[i].RFMLevel = &level
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -1176,6 +1411,106 @@ func DBUsersReferrals(c *gin.Context) {
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 入站来源链路:即使未完成绑定,也保留“通过谁的分享链接点击进入”的历史
|
||||
var currentUser model.User
|
||||
_ = db.Select("id,open_id").Where("id = ?", userId).First(¤tUser).Error
|
||||
var inboundVisits []model.ReferralVisit
|
||||
visitQ := db.Model(&model.ReferralVisit{}).Where("visitor_id = ?", userId)
|
||||
if currentUser.OpenID != nil && strings.TrimSpace(*currentUser.OpenID) != "" {
|
||||
visitQ = visitQ.Or("visitor_openid = ?", strings.TrimSpace(*currentUser.OpenID))
|
||||
}
|
||||
_ = visitQ.Order("created_at ASC").Limit(300).Find(&inboundVisits).Error
|
||||
|
||||
referrerVisitIDs := make(map[string]bool)
|
||||
for _, v := range inboundVisits {
|
||||
if strings.TrimSpace(v.ReferrerID) != "" {
|
||||
referrerVisitIDs[strings.TrimSpace(v.ReferrerID)] = true
|
||||
}
|
||||
}
|
||||
referrerVisitList := make([]string, 0, len(referrerVisitIDs))
|
||||
for id := range referrerVisitIDs {
|
||||
referrerVisitList = append(referrerVisitList, id)
|
||||
}
|
||||
referrerVisitUserMap := make(map[string]*model.User)
|
||||
if len(referrerVisitList) > 0 {
|
||||
var rs []model.User
|
||||
_ = db.Where("id IN ?", referrerVisitList).Find(&rs).Error
|
||||
for i := range rs {
|
||||
referrerVisitUserMap[rs[i].ID] = &rs[i]
|
||||
}
|
||||
}
|
||||
|
||||
inboundVisitItems := make([]gin.H, 0, len(inboundVisits))
|
||||
firstInbound := gin.H{}
|
||||
latestInbound := gin.H{}
|
||||
for i, v := range inboundVisits {
|
||||
nickname := "微信用户"
|
||||
avatar := ""
|
||||
if u := referrerVisitUserMap[v.ReferrerID]; u != nil {
|
||||
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
|
||||
nickname = strings.TrimSpace(*u.Nickname)
|
||||
}
|
||||
if u.Avatar != nil {
|
||||
avatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
|
||||
}
|
||||
}
|
||||
source := ""
|
||||
page := ""
|
||||
if v.Source != nil {
|
||||
source = strings.TrimSpace(*v.Source)
|
||||
}
|
||||
if v.Page != nil {
|
||||
page = strings.TrimSpace(*v.Page)
|
||||
}
|
||||
item := gin.H{
|
||||
"seq": i + 1,
|
||||
"visitedAt": v.CreatedAt,
|
||||
"referrerId": v.ReferrerID,
|
||||
"referrerNickname": nickname,
|
||||
"referrerAvatar": avatar,
|
||||
"source": source,
|
||||
"page": page,
|
||||
}
|
||||
if i == 0 {
|
||||
firstInbound = item
|
||||
}
|
||||
latestInbound = item
|
||||
inboundVisitItems = append(inboundVisitItems, item)
|
||||
}
|
||||
|
||||
activeBinding := gin.H{}
|
||||
var activeRef model.ReferralBinding
|
||||
if err := db.Where("referee_id = ? AND status = ?", userId, "active").Order("binding_date DESC").First(&activeRef).Error; err == nil {
|
||||
bindNick := "微信用户"
|
||||
bindAvatar := ""
|
||||
if u := referrerVisitUserMap[activeRef.ReferrerID]; u != nil {
|
||||
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
|
||||
bindNick = strings.TrimSpace(*u.Nickname)
|
||||
}
|
||||
if u.Avatar != nil {
|
||||
bindAvatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
|
||||
}
|
||||
} else {
|
||||
var ru model.User
|
||||
if err := db.Select("id,nickname,avatar").Where("id = ?", activeRef.ReferrerID).First(&ru).Error; err == nil {
|
||||
if ru.Nickname != nil && strings.TrimSpace(*ru.Nickname) != "" {
|
||||
bindNick = strings.TrimSpace(*ru.Nickname)
|
||||
}
|
||||
if ru.Avatar != nil {
|
||||
bindAvatar = resolveAvatarURL(strings.TrimSpace(*ru.Avatar))
|
||||
}
|
||||
}
|
||||
}
|
||||
activeBinding = gin.H{
|
||||
"referrerId": activeRef.ReferrerID,
|
||||
"referrerNickname": bindNick,
|
||||
"referrerAvatar": bindAvatar,
|
||||
"referralCode": activeRef.ReferralCode,
|
||||
"bindingDate": activeRef.BindingDate,
|
||||
"expiryDate": activeRef.ExpiryDate,
|
||||
}
|
||||
}
|
||||
|
||||
var bindings []model.ReferralBinding
|
||||
if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
|
||||
bindings = []model.ReferralBinding{}
|
||||
@@ -1318,6 +1653,13 @@ func DBUsersReferrals(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "referrals": referrals,
|
||||
"inboundSource": gin.H{
|
||||
"totalVisits": len(inboundVisitItems),
|
||||
"firstVisit": firstInbound,
|
||||
"latestVisit": latestInbound,
|
||||
"activeBinding": activeBinding,
|
||||
"visits": inboundVisitItems,
|
||||
},
|
||||
"stats": gin.H{
|
||||
"total": totalReferrals, "purchased": purchased, "free": totalReferrals - purchased,
|
||||
"earnings": roundFloat(earningsE, 2), "pendingEarnings": roundFloat(availableE, 2), "withdrawnEarnings": roundFloat(withdrawnE, 2),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
@@ -14,30 +15,64 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func isDigits12(s string) bool {
|
||||
if len(s) != 12 {
|
||||
// isValidLinkTagID 自定义 tagId:与库字段 tag_id(50) 对齐,禁止破坏 #标签 解析的字符
|
||||
func isValidLinkTagID(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
if utf8.RuneCountInString(s) > 50 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r == '#' || r == ',' || r == '\n' || r == '\r' || r == '\t' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isZId12(s string) bool {
|
||||
if len(s) != 12 || s[0] != 'z' {
|
||||
return false
|
||||
// linkTagAdminOut 管理端 API 出参:不含 appSecret 明文,仅 hasAppSecret
|
||||
type linkTagAdminOut struct {
|
||||
ID uint `json:"id"`
|
||||
TagID string `json:"tagId"`
|
||||
Label string `json:"label"`
|
||||
Aliases string `json:"aliases"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
PagePath string `json:"pagePath,omitempty"`
|
||||
HasAppSecret bool `json:"hasAppSecret"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
func linkTagToAdminOut(t model.LinkTag) linkTagAdminOut {
|
||||
o := linkTagAdminOut{
|
||||
ID: t.ID,
|
||||
TagID: t.TagID,
|
||||
Label: t.Label,
|
||||
Aliases: t.Aliases,
|
||||
URL: t.URL,
|
||||
Type: t.Type,
|
||||
AppID: t.AppID,
|
||||
PagePath: t.PagePath,
|
||||
HasAppSecret: strings.TrimSpace(t.AppSecret) != "",
|
||||
}
|
||||
for i := 1; i < 12; i++ {
|
||||
ch := s[i]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
if !t.CreatedAt.IsZero() {
|
||||
o.CreatedAt = t.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
return true
|
||||
if !t.UpdatedAt.IsZero() {
|
||||
o.UpdatedAt = t.UpdatedAt.Format(time.RFC3339)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func linkTagsToAdminOut(rows []model.LinkTag) []linkTagAdminOut {
|
||||
out := make([]linkTagAdminOut, 0, len(rows))
|
||||
for _, t := range rows {
|
||||
out = append(out, linkTagToAdminOut(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func genZId12() string {
|
||||
@@ -63,7 +98,7 @@ func DBLinkTagList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": linkTagsToAdminOut(rows)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,7 +134,7 @@ func DBLinkTagList(c *gin.Context) {
|
||||
totalPages := (int(total) + pageSize - 1) / pageSize
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"linkTags": rows,
|
||||
"linkTags": linkTagsToAdminOut(rows),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
@@ -110,13 +145,14 @@ func DBLinkTagList(c *gin.Context) {
|
||||
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
|
||||
func DBLinkTagSave(c *gin.Context) {
|
||||
var body struct {
|
||||
TagID string `json:"tagId"`
|
||||
Label string `json:"label"`
|
||||
Aliases string `json:"aliases"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
AppID string `json:"appId"`
|
||||
PagePath string `json:"pagePath"`
|
||||
TagID string `json:"tagId"`
|
||||
Label string `json:"label"`
|
||||
Aliases string `json:"aliases"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
AppID string `json:"appId"`
|
||||
AppSecret string `json:"appSecret"`
|
||||
PagePath string `json:"pagePath"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -128,6 +164,7 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
body.URL = strings.TrimSpace(body.URL)
|
||||
body.Type = strings.TrimSpace(body.Type)
|
||||
body.AppID = strings.TrimSpace(body.AppID)
|
||||
body.AppSecret = strings.TrimSpace(body.AppSecret)
|
||||
body.PagePath = strings.TrimSpace(body.PagePath)
|
||||
|
||||
if body.Label == "" {
|
||||
@@ -141,24 +178,18 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
if body.Type == "" {
|
||||
body.Type = "url"
|
||||
}
|
||||
// tagId 规则:12位数字,或 12位且以 z 开头(z + 11位[a-z0-9])
|
||||
// 管理端新增:可不传 tagId,由后端生成;编辑:通常会携带现有 tagId
|
||||
// 管理端新增:可不传 tagId,由后端生成 z 开头 12 位;也可自定义任意 1~50 字符(如 kr)
|
||||
if body.TagID == "" {
|
||||
body.TagID = genZId12()
|
||||
autoCreate = true
|
||||
}
|
||||
if !isValidLinkTagID(body.TagID) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 长度 1~50 字符,且不能含 #、逗号或换行"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var existing model.LinkTag
|
||||
// 若 tagId 不符合新规则:仅允许更新已有记录(兼容历史中文 tagId),禁止新建
|
||||
if !(isDigits12(body.TagID) || isZId12(body.TagID)) {
|
||||
if err := db.Where("tag_id = ?", body.TagID).First(&existing).Error; err == nil {
|
||||
// allow update existing legacy tagId
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 必须为12位数字,或12位且以 z 开头(z+11位小写字母数字)"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 小程序类型:只存 appId + pagePath,不存 weixin:// 到 url
|
||||
if body.Type == "miniprogram" {
|
||||
body.URL = ""
|
||||
@@ -166,7 +197,7 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
// 按 label 查找:仅用于「自动创建」场景(tagId 为空时回落 label),若已存在则直接返回
|
||||
if autoCreate {
|
||||
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(existing)})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -176,21 +207,24 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
existing.URL = body.URL
|
||||
existing.Type = body.Type
|
||||
existing.AppID = body.AppID
|
||||
if body.AppSecret != "" {
|
||||
existing.AppSecret = body.AppSecret
|
||||
}
|
||||
existing.PagePath = body.PagePath
|
||||
db.Save(&existing)
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(existing)})
|
||||
return
|
||||
}
|
||||
// body.URL 已在 miniprogram 类型时置空
|
||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
|
||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, AppSecret: body.AppSecret, PagePath: body.PagePath}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
// 极低概率:生成的 tagId 冲突,重试一次
|
||||
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
|
||||
t.TagID = genZId12()
|
||||
if e2 := db.Create(&t).Error; e2 == nil {
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(t)})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -198,7 +232,7 @@ func DBLinkTagSave(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(t)})
|
||||
}
|
||||
|
||||
// DBLinkTagDelete DELETE /api/db/link-tags?tagId=xxx 管理端-删除链接标签
|
||||
|
||||
@@ -306,6 +306,16 @@ func MatchUsers(c *gin.Context) {
|
||||
if r.Avatar != nil {
|
||||
avatar = resolveAvatarURL(*r.Avatar)
|
||||
}
|
||||
if avatar == "" && r.Mbti != nil {
|
||||
mbti := strings.ToUpper(strings.TrimSpace(*r.Mbti))
|
||||
if mbti != "" {
|
||||
avatar = resolveAvatarURL(getMbtiAvatar(db, mbti))
|
||||
}
|
||||
}
|
||||
mbtiOut := ""
|
||||
if r.Mbti != nil {
|
||||
mbtiOut = strings.TrimSpace(*r.Mbti)
|
||||
}
|
||||
wechat := ""
|
||||
if r.WechatID != nil {
|
||||
wechat = *r.WechatID
|
||||
@@ -342,7 +352,7 @@ func MatchUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"id": r.ID, "nickname": nickname, "avatar": avatar, "wechat": wechat, "phone": phone,
|
||||
"id": r.ID, "nickname": nickname, "avatar": avatar, "mbti": mbtiOut, "wechat": wechat, "phone": phone,
|
||||
"introduction": intro, "tags": []string{"创业者", tag},
|
||||
"matchScore": 80 + (r.CreatedAt.Unix() % 20),
|
||||
"commonInterests": []gin.H{
|
||||
|
||||
@@ -798,6 +798,13 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
).Delete(&model.Order{})
|
||||
processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order)
|
||||
}
|
||||
// 支付成功后实时推送到 webhook;失败记录,交给定时补偿任务统一重推
|
||||
if pushErr := pushPaidOrderWebhook(db, &order); pushErr != nil {
|
||||
fmt.Printf("[PayNotify] webhook 推送失败: orderSn=%s, err=%v\n", orderSn, pushErr)
|
||||
markOrderWebhookResult(db, orderSn, false, pushErr)
|
||||
} else {
|
||||
markOrderWebhookResult(db, orderSn, true, nil)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
37
soul-api/internal/handler/miniprogram_mbti_avatars.go
Normal file
37
soul-api/internal/handler/miniprogram_mbti_avatars.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniprogramMbtiAvatarsGet GET /api/miniprogram/config/mbti-avatars
|
||||
// 公开只读:返回 16 型 MBTI → 头像 URL,供小程序在无用户头像时按性格展示(推广海报等)。
|
||||
func MiniprogramMbtiAvatarsGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": map[string]string{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "读取失败"})
|
||||
return
|
||||
}
|
||||
out := make(map[string]string)
|
||||
if len(row.ConfigValue) > 0 {
|
||||
if uerr := json.Unmarshal(row.ConfigValue, &out); uerr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置无效"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": out})
|
||||
}
|
||||
163
soul-api/internal/handler/order_webhook.go
Normal file
163
soul-api/internal/handler/order_webhook.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func loadOrderWebhookURL(db *gorm.DB) string {
|
||||
keys := []string{"order_paid_webhook_url", "ckb_lead_webhook_url"}
|
||||
for _, key := range keys {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", key).First(&cfg).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
var webhookURL string
|
||||
if len(cfg.ConfigValue) > 0 {
|
||||
_ = json.Unmarshal(cfg.ConfigValue, &webhookURL)
|
||||
}
|
||||
webhookURL = strings.TrimSpace(webhookURL)
|
||||
if webhookURL != "" && strings.HasPrefix(webhookURL, "http") {
|
||||
return webhookURL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pushPaidOrderWebhook(db *gorm.DB, order *model.Order) error {
|
||||
if order == nil || order.OrderSN == "" {
|
||||
return fmt.Errorf("empty order")
|
||||
}
|
||||
if order.WebhookPushStatus == "sent" {
|
||||
return nil
|
||||
}
|
||||
webhookURL := loadOrderWebhookURL(db)
|
||||
if webhookURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var user model.User
|
||||
_ = db.Select("id,nickname,phone,open_id").Where("id = ?", order.UserID).First(&user).Error
|
||||
|
||||
productName := order.ProductType
|
||||
if order.Description != nil && strings.TrimSpace(*order.Description) != "" {
|
||||
productName = strings.TrimSpace(*order.Description)
|
||||
}
|
||||
status := ""
|
||||
if order.Status != nil {
|
||||
status = *order.Status
|
||||
}
|
||||
if status == "" {
|
||||
status = "paid"
|
||||
}
|
||||
|
||||
text := "💰 用户购买成功(实时推送)"
|
||||
text += fmt.Sprintf("\n订单号: %s", order.OrderSN)
|
||||
if user.Nickname != nil && strings.TrimSpace(*user.Nickname) != "" {
|
||||
text += fmt.Sprintf("\n用户: %s", strings.TrimSpace(*user.Nickname))
|
||||
}
|
||||
if user.Phone != nil && strings.TrimSpace(*user.Phone) != "" {
|
||||
text += fmt.Sprintf("\n手机: %s", strings.TrimSpace(*user.Phone))
|
||||
}
|
||||
text += fmt.Sprintf("\n商品: %s", productName)
|
||||
text += fmt.Sprintf("\n金额: %.2f", order.Amount)
|
||||
text += fmt.Sprintf("\n状态: %s", status)
|
||||
if order.PayTime != nil {
|
||||
text += fmt.Sprintf("\n支付时间: %s", order.PayTime.Format("2006-01-02 15:04:05"))
|
||||
} else {
|
||||
text += fmt.Sprintf("\n支付时间: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if strings.Contains(webhookURL, "qyapi.weixin.qq.com") {
|
||||
payload, _ = json.Marshal(map[string]interface{}{
|
||||
"msgtype": "text",
|
||||
"text": map[string]string{"content": text},
|
||||
})
|
||||
} else {
|
||||
payload, _ = json.Marshal(map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"content": map[string]string{"text": text},
|
||||
})
|
||||
}
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("webhook status=%d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markOrderWebhookResult(db *gorm.DB, orderSn string, sent bool, pushErr error) {
|
||||
if orderSn == "" {
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"webhook_push_attempts": gorm.Expr("COALESCE(webhook_push_attempts, 0) + 1"),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if sent {
|
||||
now := time.Now()
|
||||
updates["webhook_push_status"] = "sent"
|
||||
updates["webhook_pushed_at"] = now
|
||||
updates["webhook_push_error"] = ""
|
||||
} else {
|
||||
errText := ""
|
||||
if pushErr != nil {
|
||||
errText = strings.TrimSpace(pushErr.Error())
|
||||
}
|
||||
if len(errText) > 500 {
|
||||
errText = errText[:500]
|
||||
}
|
||||
updates["webhook_push_status"] = "failed"
|
||||
updates["webhook_push_error"] = errText
|
||||
}
|
||||
_ = db.Model(&model.Order{}).Where("order_sn = ?", orderSn).Updates(updates).Error
|
||||
}
|
||||
|
||||
// RetryPendingPaidOrderWebhooks 扫描未推送成功的已支付订单并补推。
|
||||
func RetryPendingPaidOrderWebhooks(ctx context.Context, limit int) (retried, sent int, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 2000 {
|
||||
limit = 2000
|
||||
}
|
||||
db := database.DB()
|
||||
var rows []model.Order
|
||||
if err := db.Where(
|
||||
"status IN ? AND COALESCE(webhook_push_status,'') <> ?",
|
||||
[]string{"paid", "completed"}, "sent",
|
||||
).Order("pay_time ASC, created_at ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
for i := range rows {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return retried, sent, ctx.Err()
|
||||
default:
|
||||
}
|
||||
retried++
|
||||
pushErr := pushPaidOrderWebhook(db, &rows[i])
|
||||
if pushErr == nil {
|
||||
sent++
|
||||
markOrderWebhookResult(db, rows[i].OrderSN, true, nil)
|
||||
} else {
|
||||
markOrderWebhookResult(db, rows[i].OrderSN, false, pushErr)
|
||||
}
|
||||
}
|
||||
return retried, sent, nil
|
||||
}
|
||||
@@ -476,12 +476,16 @@ func MyEarnings(c *gin.Context) {
|
||||
if availableEarnings < 0 {
|
||||
availableEarnings = 0
|
||||
}
|
||||
var activeReferralCount int64
|
||||
db.Model(&model.ReferralBinding{}).
|
||||
Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()).
|
||||
Count(&activeReferralCount)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"totalCommission": round(totalCommission, 2),
|
||||
"availableEarnings": round(availableEarnings, 2),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
"referralCount": activeReferralCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -550,6 +551,46 @@ func UserReadingProgressGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// parseCompletedAtPtr 解析 completedAt:RFC3339 字符串、毫秒/秒时间戳(float64)
|
||||
func parseCompletedAtPtr(v interface{}) *time.Time {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
case float64:
|
||||
if x <= 0 {
|
||||
return nil
|
||||
}
|
||||
var t time.Time
|
||||
if x >= 1e12 {
|
||||
t = time.UnixMilli(int64(x))
|
||||
} else {
|
||||
t = time.Unix(int64(x), 0)
|
||||
}
|
||||
return &t
|
||||
case int64:
|
||||
if x <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.UnixMilli(x)
|
||||
return &t
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseDuration 从 JSON 解析 duration,兼容数字与字符串(防止客户端传字符串导致累加异常)
|
||||
func parseDuration(v interface{}) int {
|
||||
if v == nil {
|
||||
@@ -578,7 +619,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
Progress int `json:"progress"`
|
||||
Duration interface{} `json:"duration"` // 兼容 int/float64/string,防止字符串导致累加异常
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
CompletedAt interface{} `json:"completedAt"` // 兼容 ISO 字符串或历史客户端误传的时间戳数字
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
@@ -602,11 +643,8 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
} else if existing.CompletedAt != nil {
|
||||
completedAt := parseCompletedAtPtr(body.CompletedAt)
|
||||
if completedAt == nil && existing.CompletedAt != nil {
|
||||
completedAt = existing.CompletedAt
|
||||
}
|
||||
db.Model(&existing).Updates(map[string]interface{}{
|
||||
@@ -618,11 +656,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if status == "" {
|
||||
status = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
}
|
||||
completedAt := parseCompletedAtPtr(body.CompletedAt)
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: duration,
|
||||
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
|
||||
@@ -651,8 +685,38 @@ func userTrackActionLabelCN(action string) string {
|
||||
return "绑定微信"
|
||||
case "fill_profile":
|
||||
return "完善资料"
|
||||
case "fill_avatar":
|
||||
return "设置头像"
|
||||
case "update_avatar":
|
||||
return "完善头像"
|
||||
case "update_nickname":
|
||||
return "修改昵称"
|
||||
case "visit_page":
|
||||
return "访问页面"
|
||||
case "first_pay":
|
||||
return "首次付款"
|
||||
case "vip_activate":
|
||||
return "开通会员"
|
||||
case "click_super":
|
||||
return "点击超级个体"
|
||||
case "lead_submit":
|
||||
return "提交留资"
|
||||
case "withdraw":
|
||||
return "申请提现"
|
||||
case "referral_bind":
|
||||
return "绑定推荐人"
|
||||
case "card_click":
|
||||
return "点击名片"
|
||||
case "btn_click":
|
||||
return "按钮点击"
|
||||
case "tab_click":
|
||||
return "切换标签"
|
||||
case "nav_click":
|
||||
return "导航点击"
|
||||
case "page_view":
|
||||
return "页面浏览"
|
||||
case "search":
|
||||
return "搜索"
|
||||
default:
|
||||
if action == "" {
|
||||
return "行为"
|
||||
@@ -661,6 +725,41 @@ func userTrackActionLabelCN(action string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// userTrackModuleLabelCN 埋点 module 英文字段 → 中文位置(与用户旅程、群播报一致)
|
||||
func userTrackModuleLabelCN(module string) string {
|
||||
m := strings.TrimSpace(strings.ToLower(module))
|
||||
switch m {
|
||||
case "":
|
||||
return ""
|
||||
case "home", "index":
|
||||
return "首页"
|
||||
case "chapters":
|
||||
return "目录"
|
||||
case "match":
|
||||
return "找伙伴"
|
||||
case "my":
|
||||
return "我的"
|
||||
case "read", "reading":
|
||||
return "阅读"
|
||||
case "vip":
|
||||
return "会员中心"
|
||||
case "referral":
|
||||
return "推广中心"
|
||||
case "member_detail", "member-detail", "memberdetail":
|
||||
return "超级个体详情"
|
||||
case "profile", "profile_show", "profile-show":
|
||||
return "个人资料"
|
||||
case "search":
|
||||
return "搜索"
|
||||
case "wallet":
|
||||
return "钱包"
|
||||
case "settings":
|
||||
return "设置"
|
||||
default:
|
||||
return module
|
||||
}
|
||||
}
|
||||
|
||||
func humanTimeAgoCN(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
@@ -769,6 +868,16 @@ func UserTrackGet(c *gin.Context) {
|
||||
chapterTitle = v
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -779,6 +888,8 @@ func UserTrackGet(c *gin.Context) {
|
||||
"actionLabel": userTrackActionLabelCN(t.Action),
|
||||
"target": target,
|
||||
"chapterTitle": chapterTitle,
|
||||
"module": module,
|
||||
"moduleLabel": userTrackModuleLabelCN(module),
|
||||
"createdAt": t.CreatedAt,
|
||||
"timeAgo": humanTimeAgoCN(createdAt),
|
||||
})
|
||||
@@ -786,6 +897,108 @@ func UserTrackGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)})
|
||||
}
|
||||
|
||||
// DBUserTracksList GET /api/db/users/tracks?userId=xxx&limit=20 管理端查看某用户行为轨迹
|
||||
func DBUserTracksList(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要 userId"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
db := database.DB()
|
||||
var tracks []model.UserTrack
|
||||
db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks)
|
||||
titleMap := resolveChapterTitlesForTracks(db, tracks)
|
||||
out := make([]gin.H, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
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
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": t.ID, "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": len(out)})
|
||||
}
|
||||
|
||||
// GetUserRecentTracks 内部复用:获取用户最近 N 条有效行为的可读文字(用于 webhook 等)
|
||||
func GetUserRecentTracks(db *gorm.DB, userId string, limit int) []string {
|
||||
if userId == "" || limit < 1 {
|
||||
return nil
|
||||
}
|
||||
var tracks []model.UserTrack
|
||||
db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks)
|
||||
titleMap := resolveChapterTitlesForTracks(db, tracks)
|
||||
lines := make([]string, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
label := userTrackActionLabelCN(t.Action)
|
||||
target := ""
|
||||
if t.ChapterID != nil {
|
||||
if v := titleMap[strings.TrimSpace(*t.ChapterID)]; v != "" {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
if target == "" && t.Target != nil {
|
||||
target = *t.Target
|
||||
if v := titleMap[strings.TrimSpace(target)]; v != "" {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
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 line string
|
||||
modCN := userTrackModuleLabelCN(module)
|
||||
if target != "" {
|
||||
line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target))
|
||||
} else if modCN != "" {
|
||||
line = fmt.Sprintf("%s · %s", label, modCN)
|
||||
} else {
|
||||
line = label
|
||||
}
|
||||
if t.CreatedAt != nil {
|
||||
line += " · " + humanTimeAgoCN(*t.CreatedAt)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// UserTrackPost POST /api/user/track 记录行为(GORM)
|
||||
func UserTrackPost(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -940,22 +1153,75 @@ func UserDashboardStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 遍历:统计 readSectionIds / totalReadSeconds,同时去重取最近 5 个不重复章节
|
||||
readCount := len(progressList)
|
||||
// 2. 按章节去重:已读数 = 不重复 section_id 数量;列表按「最近一次打开」倒序
|
||||
type secAgg struct {
|
||||
lastOpen time.Time
|
||||
}
|
||||
secMap := make(map[string]*secAgg)
|
||||
totalReadSeconds := 0
|
||||
recentIDs := make([]string, 0, 5)
|
||||
seenRecent := make(map[string]bool)
|
||||
readSectionIDs := make([]string, 0, len(progressList))
|
||||
for _, item := range progressList {
|
||||
totalReadSeconds += item.Duration
|
||||
if item.SectionID != "" {
|
||||
readSectionIDs = append(readSectionIDs, item.SectionID)
|
||||
// 去重:同一章节只保留最近一次
|
||||
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
|
||||
seenRecent[item.SectionID] = true
|
||||
recentIDs = append(recentIDs, item.SectionID)
|
||||
}
|
||||
sid := strings.TrimSpace(item.SectionID)
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
var t time.Time
|
||||
if item.LastOpenAt != nil {
|
||||
t = *item.LastOpenAt
|
||||
} else if !item.UpdatedAt.IsZero() {
|
||||
t = item.UpdatedAt
|
||||
} else {
|
||||
t = item.CreatedAt
|
||||
}
|
||||
if agg, ok := secMap[sid]; ok {
|
||||
if t.After(agg.lastOpen) {
|
||||
agg.lastOpen = t
|
||||
}
|
||||
} else {
|
||||
secMap[sid] = &secAgg{lastOpen: t}
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. 已购买的章节(orders 表)也计入已读;用 pay_time 作为 lastOpen
|
||||
var purchasedRows []struct {
|
||||
ProductID string
|
||||
PayTime *time.Time
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("product_id, pay_time").
|
||||
Where("user_id = ? AND product_type = 'section' AND status IN ? AND product_id IS NOT NULL AND product_id != ''",
|
||||
userID, []string{"paid", "completed", "success"}).
|
||||
Scan(&purchasedRows)
|
||||
for _, row := range purchasedRows {
|
||||
sid := strings.TrimSpace(row.ProductID)
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
var pt time.Time
|
||||
if row.PayTime != nil {
|
||||
pt = *row.PayTime
|
||||
}
|
||||
if agg, ok := secMap[sid]; ok {
|
||||
if !pt.IsZero() && pt.After(agg.lastOpen) {
|
||||
agg.lastOpen = pt
|
||||
}
|
||||
} else {
|
||||
secMap[sid] = &secAgg{lastOpen: pt}
|
||||
}
|
||||
}
|
||||
|
||||
readCount := len(secMap)
|
||||
sortedSectionIDs := make([]string, 0, len(secMap))
|
||||
for sid := range secMap {
|
||||
sortedSectionIDs = append(sortedSectionIDs, sid)
|
||||
}
|
||||
sort.Slice(sortedSectionIDs, func(i, j int) bool {
|
||||
return secMap[sortedSectionIDs[i]].lastOpen.After(secMap[sortedSectionIDs[j]].lastOpen)
|
||||
})
|
||||
readSectionIDs := sortedSectionIDs
|
||||
recentIDs := sortedSectionIDs
|
||||
if len(recentIDs) > 5 {
|
||||
recentIDs = recentIDs[:5]
|
||||
}
|
||||
|
||||
// 不足 60 秒但有阅读记录时,至少显示 1 分钟
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -295,12 +296,8 @@ func isWechatDefaultNickname(s string) bool {
|
||||
return s != "" && strings.HasPrefix(s, "微信用户")
|
||||
}
|
||||
|
||||
// vipMemberShowcaseOK 首页「超级个体」横滑:必须有可展示头像 URL,且展示名非微信默认占位
|
||||
// vipMemberShowcaseOK 首页「超级个体」横滑:展示名非微信默认占位即可;无头像时小程序用首字/MBTI 映射图(后台可配 mbti_avatars)
|
||||
func vipMemberShowcaseOK(item gin.H) bool {
|
||||
av, _ := item["avatar"].(string)
|
||||
if strings.TrimSpace(av) == "" {
|
||||
return false
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || isWechatDefaultNickname(name) {
|
||||
@@ -345,6 +342,9 @@ func formatVipMember(db *gorm.DB, u *model.User, isVip bool) gin.H {
|
||||
if avatar == "" {
|
||||
avatar = getUrlValue(u.VipAvatar)
|
||||
}
|
||||
if avatar == "" && u.Mbti != nil && *u.Mbti != "" {
|
||||
avatar = getMbtiAvatar(db, strings.ToUpper(strings.TrimSpace(*u.Mbti)))
|
||||
}
|
||||
avatar = resolveAvatarURL(avatar)
|
||||
project := getStringValue(u.VipProject)
|
||||
if project == "" {
|
||||
@@ -420,3 +420,24 @@ func formatVipMember(db *gorm.DB, u *model.User, isVip bool) gin.H {
|
||||
func parseInt(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
|
||||
var _mbtiAvatarCache map[string]string
|
||||
var _mbtiAvatarCacheTs int64
|
||||
|
||||
func getMbtiAvatar(db *gorm.DB, mbti string) string {
|
||||
now := time.Now().Unix()
|
||||
if _mbtiAvatarCache != nil && now-_mbtiAvatarCacheTs < 300 {
|
||||
return _mbtiAvatarCache[mbti]
|
||||
}
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "mbti_avatars").First(&cfg).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
m := make(map[string]string)
|
||||
if err := json.Unmarshal([]byte(cfg.ConfigValue), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
_mbtiAvatarCache = m
|
||||
_mbtiAvatarCacheTs = now
|
||||
return m[mbti]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
@@ -11,7 +13,34 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// batchSuperIndividualClicks 与 AdminSuperIndividualStats 一致:user_tracks 中 action=card_click 且 target 前缀「超级个体_」
|
||||
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 {
|
||||
@@ -23,9 +52,13 @@ func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64
|
||||
}
|
||||
var rows []row
|
||||
_ = db.Raw(`
|
||||
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
|
||||
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 ?
|
||||
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 {
|
||||
@@ -103,6 +136,7 @@ func DBVipMembersList(c *gin.Context) {
|
||||
}
|
||||
clickByUser := batchSuperIndividualClicks(db, ids)
|
||||
leadByUser := batchSuperIndividualLeads(db, ids)
|
||||
webhookMap := loadSuperIndividualWebhookMap(db)
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
@@ -110,8 +144,74 @@ func DBVipMembersList(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user