feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整 - soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新 - soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL - .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle Made-with: Cursor
This commit is contained in:
@@ -111,6 +111,12 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
|
||||
log.Printf("database: user_rules migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserTrack{}); err != nil {
|
||||
log.Printf("database: user_tracks migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserRuleCompletion{}); err != nil {
|
||||
log.Printf("database: user_rule_completions migrate warning: %v", err)
|
||||
}
|
||||
log.Println("database: connected")
|
||||
return 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 头像映射已保存"})
|
||||
}
|
||||
@@ -37,7 +37,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 +46,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
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -417,6 +419,21 @@ 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,
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
@@ -446,13 +463,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)
|
||||
@@ -510,7 +529,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,
|
||||
@@ -585,7 +605,16 @@ 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),
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
@@ -649,7 +678,16 @@ 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),
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
|
||||
return
|
||||
}
|
||||
@@ -676,7 +714,64 @@ 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)
|
||||
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) bool {
|
||||
if userId == "" {
|
||||
return false
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
_webhookDedupCache.Lock()
|
||||
defer _webhookDedupCache.Unlock()
|
||||
if _webhookDedupCache.m[userId] == today {
|
||||
return true
|
||||
}
|
||||
_webhookDedupCache.m[userId] = today
|
||||
if len(_webhookDedupCache.m) > 10000 {
|
||||
_webhookDedupCache.m = map[string]string{userId: today}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
|
||||
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID) {
|
||||
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
|
||||
return
|
||||
}
|
||||
var cfg model.SystemConfig
|
||||
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
|
||||
return
|
||||
@@ -690,19 +785,41 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
|
||||
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{}{
|
||||
@@ -721,5 +838,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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,80 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 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": "点击链接卡若", "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 +110,7 @@ func buildMiniprogramConfig() gin.H {
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"shareIcon": "", // 分享图标URL,由管理端配置
|
||||
"mpUi": defaultMpUi(),
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
@@ -87,6 +162,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
|
||||
}
|
||||
@@ -380,6 +456,7 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
"minWithdraw": float64(10),
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"mpUi": defaultMpUi(),
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
@@ -416,6 +493,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":
|
||||
|
||||
@@ -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,
|
||||
@@ -786,6 +820,106 @@ 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,
|
||||
"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
|
||||
if target != "" {
|
||||
line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target))
|
||||
} else if module != "" {
|
||||
line = fmt.Sprintf("%s (%s)", label, module)
|
||||
} 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 +1074,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"
|
||||
@@ -345,6 +346,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 +424,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]
|
||||
}
|
||||
|
||||
@@ -35,13 +35,12 @@ type Person struct {
|
||||
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
|
||||
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
|
||||
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
|
||||
// 置顶到小程序首页
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
|
||||
|
||||
// PersonSource 来源:空=后台手工添加;vip_sync=超级个体自动同步(共用统一计划)
|
||||
PersonSource string `gorm:"column:person_source;size:32;default:''" json:"personSource"`
|
||||
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"` // 置顶到小程序首页
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
// User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰)
|
||||
// 软删除:管理端删除仅设置 deleted_at,用户再次登录会创建新账号
|
||||
type User struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
|
||||
SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON
|
||||
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
|
||||
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
|
||||
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
|
||||
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
|
||||
SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON
|
||||
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
|
||||
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
|
||||
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
|
||||
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
|
||||
// P3 资料扩展(stitch_soul)
|
||||
Mbti *string `gorm:"column:mbti;size:16" json:"mbti,omitempty"`
|
||||
Region *string `gorm:"column:region;size:100" json:"region,omitempty"`
|
||||
@@ -43,18 +43,18 @@ type User struct {
|
||||
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
|
||||
|
||||
// 用户标签(管理端编辑、神射手回填共用 ckb_tags 列,JSON 数组字符串)
|
||||
CkbTags *string `gorm:"column:ckb_tags;type:text" json:"ckbTags,omitempty"`
|
||||
CkbTags *string `gorm:"column:ckb_tags;type:text" json:"ckbTags,omitempty"`
|
||||
// VIP 相关(与 next-project 线上 users 表一致,支持手动设置;管理端需读写)
|
||||
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
|
||||
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
|
||||
VipActivatedAt *time.Time `gorm:"column:vip_activated_at" json:"vipActivatedAt,omitempty"` // 成为 VIP 时间,排序用:付款=pay_time,手动=now
|
||||
VipSort *int `gorm:"column:vip_sort" json:"vipSort,omitempty"` // 手动排序,越小越前,NULL 按 vip_activated_at
|
||||
VipRole *string `gorm:"column:vip_role;size:50" json:"vipRole,omitempty"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
|
||||
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"vipAvatar,omitempty"`
|
||||
VipProject *string `gorm:"column:vip_project;size:200" json:"vipProject,omitempty"`
|
||||
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
|
||||
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
|
||||
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
|
||||
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
|
||||
VipActivatedAt *time.Time `gorm:"column:vip_activated_at" json:"vipActivatedAt,omitempty"` // 成为 VIP 时间,排序用:付款=pay_time,手动=now
|
||||
VipSort *int `gorm:"column:vip_sort" json:"vipSort,omitempty"` // 手动排序,越小越前,NULL 按 vip_activated_at
|
||||
VipRole *string `gorm:"column:vip_role;size:50" json:"vipRole,omitempty"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
|
||||
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"vipAvatar,omitempty"`
|
||||
VipProject *string `gorm:"column:vip_project;size:200" json:"vipProject,omitempty"`
|
||||
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
|
||||
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
|
||||
|
||||
// 软删除:管理端假删除,用户再次登录会新建账号
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
|
||||
|
||||
@@ -2,7 +2,7 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserRule 用户旅程引导规则(如:匹配后填写头像、付款1980需填写信息等)
|
||||
// UserRule 用户旅程触达规则(各节点弹窗标题/说明,由管理端配置)
|
||||
type UserRule struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
|
||||
12
soul-api/internal/model/user_rule_completion.go
Normal file
12
soul-api/internal/model/user_rule_completion.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type UserRuleCompletion struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserID string `gorm:"column:user_id;size:100;not null;uniqueIndex:idx_user_rule" json:"userId"`
|
||||
RuleID uint `gorm:"column:rule_id;not null;uniqueIndex:idx_user_rule" json:"ruleId"`
|
||||
CompletedAt time.Time `gorm:"column:completed_at;autoCreateTime" json:"completedAt"`
|
||||
}
|
||||
|
||||
func (UserRuleCompletion) TableName() string { return "user_rule_completions" }
|
||||
@@ -117,8 +117,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.GET("/super-individual/stats", handler.AdminSuperIndividualStats)
|
||||
admin.GET("/user/track", handler.UserTrackGet)
|
||||
admin.GET("/track/stats", handler.AdminTrackStats)
|
||||
admin.GET("/dashboard/leads", handler.AdminDashboardLeads)
|
||||
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
|
||||
admin.GET("/mbti-avatars", handler.AdminMbtiAvatarsGet)
|
||||
admin.POST("/mbti-avatars", handler.AdminMbtiAvatarsPost)
|
||||
}
|
||||
|
||||
// ----- 鉴权 -----
|
||||
@@ -193,6 +194,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.GET("/users/referrals", handler.DBUsersReferrals)
|
||||
db.GET("/users/rfm", handler.DBUsersRFM)
|
||||
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
|
||||
db.GET("/users/journey-users", handler.DBUsersJourneyUsers)
|
||||
db.GET("/users/tracks", handler.DBUserTracksList)
|
||||
db.GET("/vip-roles", handler.DBVipRolesList)
|
||||
db.POST("/vip-roles", handler.DBVipRolesAction)
|
||||
db.PUT("/vip-roles", handler.DBVipRolesAction)
|
||||
@@ -371,8 +374,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.GET("/persons/pinned", handler.DBPersonPinnedList)
|
||||
// 埋点
|
||||
miniprogram.POST("/track", handler.MiniprogramTrackPost)
|
||||
// 规则引擎(用户旅程引导)
|
||||
// 规则引擎(用户旅程触达)
|
||||
miniprogram.GET("/user-rules", handler.MiniprogramUserRulesGet)
|
||||
miniprogram.POST("/user-rules/complete", handler.MiniprogramUserRuleComplete)
|
||||
// 余额
|
||||
miniprogram.GET("/balance", handler.BalanceGet)
|
||||
miniprogram.GET("/balance/transactions", handler.BalanceTransactionsGet)
|
||||
|
||||
Reference in New Issue
Block a user