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:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

View File

@@ -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
}

View 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 头像映射已保存"})
}

View File

@@ -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

View File

@@ -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补全昵称、targetUserIdPerson.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)
}

View File

@@ -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":

View File

@@ -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,
},
})
}

View File

@@ -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 解析 completedAtRFC3339 字符串、毫秒/秒时间戳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 分钟

View File

@@ -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]
}

View File

@@ -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"`
}

View File

@@ -9,14 +9,14 @@ import (
// User 对应表 usersJSON 输出与现网接口 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:"-"`

View File

@@ -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"`

View 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" }

View File

@@ -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)