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:
Alex-larget
2026-03-24 15:44:56 +08:00
127 changed files with 9196 additions and 3504 deletions

View File

@@ -92,6 +92,8 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
// persons 历史库可能因旧索引冲突导致 AutoMigrate 中断,补一层列级自愈,避免 /api/db/persons 报 Unknown column。
ensurePersonSchema(db)
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
log.Printf("database: link_tags migrate warning: %v", err)
}
@@ -111,6 +113,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
}
@@ -119,3 +127,22 @@ func Init(dsn string) error {
func DB() *gorm.DB {
return db
}
func ensurePersonSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.Person{}, "is_pinned") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '置顶到小程序首页'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add is_pinned", err)
}
}
if !m.HasColumn(&model.Person{}, "person_source") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN person_source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源:空=后台手工vip_sync=超级个体同步'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add person_source", err)
}
}
if !m.HasIndex(&model.Person{}, "idx_persons_is_pinned") {
if err := db.Exec("CREATE INDEX idx_persons_is_pinned ON persons(is_pinned)").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=create idx_persons_is_pinned", err)
}
}
}

View File

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

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

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

View File

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

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

View File

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

View File

@@ -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 且在此列直接填真实 AppIDC 端会把该值当作 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 需要 typeminiprogram 类型 mpKey,用 key 查 linkedMiniprograms 得 appId
// 链接标签列表(小程序 onLinkTagTapminiprogram 类型下发 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(&sectionRows)
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(&currentUser).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),

View File

@@ -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 位;也可自定义任意 150 字符(如 kr
if body.TagID == "" {
body.TagID = genZId12()
autoCreate = true
}
if !isValidLinkTagID(body.TagID) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 长度 150 字符,且不能含 #、逗号或换行"})
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 管理端-删除链接标签

View File

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

View File

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

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

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

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,
@@ -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 分钟

View File

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

View File

@@ -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
// 按超级个体用户维度配置飞书群 webhookVOX 地址)。
func DBVipMemberWebhookSet(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
WebhookURL string `json:"webhookUrl"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
userID := strings.TrimSpace(body.UserID)
webhookURL := strings.TrimSpace(body.WebhookURL)
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "userId 不能为空"})
return
}
if webhookURL != "" && !strings.HasPrefix(webhookURL, "http") {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "Webhook 地址必须是 http/https"})
return
}
db := database.DB()
var count int64
db.Model(&model.User{}).Where("id = ?", userID).Count(&count)
if count == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
return
}
webhookMap := loadSuperIndividualWebhookMap(db)
if webhookURL == "" {
delete(webhookMap, userID)
} else {
webhookMap[userID] = webhookURL
}
val, _ := json.Marshal(webhookMap)
desc := "超级个体飞书群Webhook映射按userId"
var row model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row = model.SystemConfig{
ConfigKey: superIndividualWebhookConfigKey,
ConfigValue: val,
Description: &desc,
}
if e := db.Create(&row).Error; e != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
return
}
} else {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
} else {
row.ConfigValue = val
row.Description = &desc
if e := db.Save(&row).Error; e != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -11,6 +11,8 @@ type LinkTag struct {
URL string `gorm:"column:url;size:500" json:"url"`
Type string `gorm:"column:type;size:20" json:"type"`
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
// AppSecret 目标小程序 AppSecret仅存库列表/保存响应用 hasAppSecret永不 json 明文下发
AppSecret string `gorm:"column:app_secret;size:256;default:''" json:"-"`
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`

View File

@@ -4,26 +4,31 @@ import "time"
// Order 对应表 ordersJSON 输出与现网接口 1:1小写驼峰
type Order struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
OpenID string `gorm:"column:open_id;size:100" json:"openId"`
ProductType string `gorm:"column:product_type;size:50" json:"productType"`
ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"`
PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,omitempty"`
PaymentMethod *string `gorm:"column:payment_method;size:20" json:"paymentMethod,omitempty"`
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
OpenID string `gorm:"column:open_id;size:100" json:"openId"`
ProductType string `gorm:"column:product_type;size:50" json:"productType"`
ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"`
PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,omitempty"`
PaymentMethod *string `gorm:"column:payment_method;size:20" json:"paymentMethod,omitempty"`
// 代付:关联代付请求、实际付款人
GiftPayRequestID *string `gorm:"column:gift_pay_request_id;size:50" json:"giftPayRequestId,omitempty"`
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
// 飞书 webhook 推送状态paid 后实时推送;失败可补偿重推)
WebhookPushStatus string `gorm:"column:webhook_push_status;size:20;default:''" json:"webhookPushStatus,omitempty"`
WebhookPushedAt *time.Time `gorm:"column:webhook_pushed_at" json:"webhookPushedAt,omitempty"`
WebhookPushAttempts int `gorm:"column:webhook_push_attempts;default:0" json:"webhookPushAttempts,omitempty"`
WebhookPushError *string `gorm:"column:webhook_push_error;size:500" json:"webhookPushError,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Order) TableName() string { return "orders" }

View File

@@ -19,7 +19,6 @@ type Person struct {
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token文章/小程序传此值
Name string `gorm:"column:name;size:100" json:"name"`
Avatar string `gorm:"column:avatar;size:512;default:''" json:"avatar"` // 头像 URL无 user_id 时使用;有 user_id 时优先用 users.avatar
Aliases string `gorm:"column:aliases;size:255;default:''" json:"aliases"` // 逗号分隔别名:用于 @ 自动匹配
Label string `gorm:"column:label;size:200" json:"label"`
CkbApiKey string `gorm:"column:ckb_api_key;size:100;default:''" json:"ckbApiKey"` // 存客宝真实密钥,不对外暴露
@@ -35,8 +34,9 @@ type Person struct {
AddFriendInterval int `gorm:"column:add_friend_interval;default:1" json:"addFriendInterval"`
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"` // 置顶到小程序首页
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
// 置顶到小程序首页
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
// PersonSource 来源:空=后台手工添加vip_sync=超级个体自动同步(共用统一计划)
PersonSource string `gorm:"column:person_source;size:32;default:''" json:"personSource"`

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:"-"`
@@ -62,6 +62,8 @@ type User struct {
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
WalletBalance *float64 `gorm:"-" json:"walletBalance,omitempty"`
RFMScore *float64 `gorm:"-" json:"rfmScore,omitempty"`
RFMLevel *string `gorm:"-" json:"rfmLevel,omitempty"`
}
func (User) TableName() string { return "users" }

View File

@@ -1,17 +1,63 @@
package model
import "time"
import (
"database/sql/driver"
"time"
)
// UserRule 用户旅程引导规则匹配后填写头像、付款1980需填写信息等)
// RuleJSON 存储 JSON 数组/对象的列user_rules 的 trigger_conditions 等)
type RuleJSON []byte
// MarshalJSON 原样输出 JSON避免 encoding/json 将 []byte 编成 base64 导致前端把 triggerConditions 当字符串而 .map 崩溃
func (r RuleJSON) MarshalJSON() ([]byte, error) {
if len(r) == 0 {
return []byte("null"), nil
}
out := make([]byte, len(r))
copy(out, r)
return out, nil
}
// UnmarshalJSON 接收请求体中的原始 JSON对象/数组)
func (r *RuleJSON) UnmarshalJSON(data []byte) error {
if r == nil {
return nil
}
if len(data) == 0 || string(data) == "null" {
*r = nil
return nil
}
*r = append((*r)[0:0], data...)
return nil
}
func (r RuleJSON) Value() (driver.Value, error) { return []byte(r), nil }
func (r *RuleJSON) Scan(value interface{}) error {
if value == nil {
*r = nil
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
*r = append((*r)[0:0], b...)
return nil
}
// UserRule 用户旅程触达规则(结构化触发条件 + 推送动作,由管理端配置)
type UserRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Title string `gorm:"column:title;size:200;not null" json:"title"`
Description string `gorm:"column:description;type:text" json:"description"`
Trigger string `gorm:"column:trigger;size:100" json:"trigger"`
Sort int `gorm:"column:sort;default:0" json:"sort"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Title string `gorm:"column:title;size:200;not null" json:"title"`
Description string `gorm:"column:description;type:text" json:"description"`
Trigger string `gorm:"column:trigger;size:100" json:"trigger"`
TriggerConditions RuleJSON `gorm:"column:trigger_conditions;type:json" json:"triggerConditions,omitempty"`
ActionType string `gorm:"column:action_type;size:50;default:'popup'" json:"actionType,omitempty"`
ActionConfig RuleJSON `gorm:"column:action_config;type:json" json:"actionConfig,omitempty"`
Sort int `gorm:"column:sort;default:0" json:"sort"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (UserRule) TableName() string { return "user_rules" }

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

@@ -118,8 +118,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)
}
// ----- 鉴权 -----
@@ -163,6 +164,8 @@ func Setup(cfg *config.Config) *gin.Engine {
{
cron.GET("/sync-orders", handler.CronSyncOrders)
cron.POST("/sync-orders", handler.CronSyncOrders)
cron.GET("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.POST("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.GET("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.POST("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.GET("/unbind-expired", handler.CronUnbindExpired)
@@ -194,11 +197,14 @@ 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)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/vip-members", handler.DBVipMembersList)
db.PUT("/vip-members/webhook", handler.DBVipMemberWebhookSet)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/mentors", handler.DBMentorsList)
@@ -304,6 +310,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/config/audit-mode", handler.GetAuditMode)
miniprogram.GET("/config/core", handler.GetCoreConfig)
miniprogram.GET("/config/read-extras", handler.GetReadExtras)
miniprogram.GET("/config/mbti-avatars", handler.MiniprogramMbtiAvatarsGet)
// Deprecated: 保留以兼容线上,计划迁移至上述拆分接口
miniprogram.GET("/config", handler.GetPublicDBConfig)
miniprogram.POST("/login", handler.MiniprogramLogin)
@@ -375,8 +382,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)

View File

@@ -341,32 +341,32 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
if not ok and restart_method in ("auto", "ssh"):
# SSH正式环境固定监听 DEPLOY_PORT默认 8080。用 fuser 释放端口,避免宝塔守护
# 启动的进程 cwd 与项目目录不一致导致 pgrep+cwd 校验永远失败。
# 用 timeout + bash -c 单引号包裹,避免远端偶发挂死导致 Paramiko stdout.read 永久阻塞
restart_inner = (
# 拆成两次 exec先短命令起进程本机 sleep 后再 curl避免单条远程命令+管道偶发拖死 Paramiko
start_cmd = (
"cd %s && (fuser -k %d/tcp 2>/dev/null || true) && sleep 2 && "
"setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
"sleep 12 && curl -sf --connect-timeout 5 --max-time 15 "
"( setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & ) && "
"sleep 1 && echo START_OK"
) % (project_path, DEPLOY_PORT)
stdin, stdout, stderr = client.exec_command(
"timeout 45 bash -c " + shlex.quote(start_cmd),
timeout=55,
get_pty=True,
)
start_out = stdout.read().decode("utf-8", errors="replace").strip()
if "START_OK" not in start_out:
print(" [stderr] 起进程输出: %s" % start_out[:300])
time.sleep(12)
health_cmd = (
"curl -sf --connect-timeout 5 --max-time 15 "
"http://127.0.0.1:%d/health 2>/dev/null | grep -q '\"status\"' "
"&& echo RESTART_OK || echo RESTART_FAIL"
) % (project_path, DEPLOY_PORT, DEPLOY_PORT)
restart_cmd = "timeout 95 bash -c " + shlex.quote(restart_inner)
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=110)
err_holder = []
def _drain_stderr():
try:
err_holder.append(stderr.read().decode("utf-8", errors="replace"))
except Exception:
err_holder.append("")
t = threading.Thread(target=_drain_stderr)
t.daemon = True
t.start()
) % DEPLOY_PORT
stdin, stdout, stderr = client.exec_command(
"timeout 25 bash -c " + shlex.quote(health_cmd),
timeout=35,
get_pty=True,
)
out = stdout.read().decode("utf-8", errors="replace").strip()
t.join(timeout=5)
err = (err_holder[0] if err_holder else "").strip()
if err:
print(" [stderr] %s" % err[:200])
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulApp 已通过 SSH 重启")

View File

@@ -0,0 +1,38 @@
-- persons 表:补齐首页置顶与来源字段(兼容低版本 MySQL 的幂等写法)
SET @db = DATABASE();
SET @sql = (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'is_pinned'
),
'SELECT 1',
"ALTER TABLE persons ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '置顶到小程序首页'"
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'person_source'
),
'SELECT 1',
"ALTER TABLE persons ADD COLUMN person_source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源:空=后台手工vip_sync=超级个体同步'"
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'persons' AND INDEX_NAME = 'idx_persons_is_pinned'
),
'SELECT 1',
'CREATE INDEX idx_persons_is_pinned ON persons(is_pinned)'
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,40 @@
-- 林鹏程:金蝶生态与企业数字化主责;含「超级个体课题」表述(写入 project_intro 专段)
-- 若已存在同 id 请先删或改 id
INSERT INTO users (
id, nickname, avatar, referral_code, source,
is_vip, vip_expire_date, vip_activated_at, vip_sort,
vip_name, vip_role, vip_avatar, vip_bio, vip_project,
mbti, region, industry, position, business_scale, skills,
story_best_month, story_achievement, story_turning,
help_offer, help_need, project_intro,
created_at, updated_at
) VALUES (
'soulvip_linpengcheng_04',
'林鹏程',
'https://api.dicebear.com/7.x/notionists/png?seed=linpengcheng&size=256',
'SOULLPC01',
'vip_showcase',
1,
'2035-12-31 23:59:59',
NOW(),
14,
'林鹏程',
'金蝶生态 · 企业数字化',
'https://api.dicebear.com/7.x/notionists/png?seed=linpengcheng&size=256',
'金蝶体系一线负责人:销售、实施、运维与续费,服务制造与政企等传统客户的 ERP 与数字化。',
'金蝶项目获客与交付;招投标与大客户运维。超级个体课题:可复制「获客—交付—续费」打法。',
NULL,
'福建厦门',
'企业服务 / 金蝶与 ERP',
'金蝶业务负责人 · 数字化交付',
'深耕金蝶系 ToB线索、招投标、实施交付到运维续费与团队资产侧郑清土分工各管一摊。',
'金蝶销售与大客户拓展、项目交付与团队管理、制造业数字化方案、招投标与回款节奏',
'大单回款与多个项目同时验收的月份——交付线吃紧但现金流最厚,是金蝶 ToB 的常态高峰。',
'从单点销售到能带实施团队做交付与续费,在厦门制造与政企圈里把金蝶这条线跑成稳定基本盘。',
'认清自己适合扛「重交付、重文档、重回款」的企业服务,与只做资产底盘的合伙人并排协作。',
'金蝶/ERP 选型与报价路径、招投标与实施排期、大客户运维与续费、制造现场数字化落地方案梳理。',
'有预算的制造与政企数字化项目;靠谱售前与交付搭档;资金与验收节奏清晰的甲方。',
'【我是谁】\n林鹏程金蝶生态与企业数字化这条线的主责制造业、政企等客户的 ERP、财务供应链与现场数字化从线索、招投标到实施、运维与续费。\n\n【我的业务】\n不是郑清土那边的房产叙事——金蝶相关获客、方案、交付、回款都以我这条业务线为准与清土在团队里分工他更重资产与配置我更重 ToB 交付与续费。\n\n【超级个体课题】\n把「金蝶生态获客 → 项目交付 → 客户续费/增购」跑成可复制的项目制打法:同一套方法论套不同制造与政企客户,减少对人手感的依赖,把超级个体的个人产能放大成团队产能。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULLPC01',
NOW(),
NOW()
);

View File

@@ -0,0 +1,104 @@
-- 三位超级个体展示用户:西兰花(叶总)、流光(刘光)、郑清土
-- 林鹏程(金蝶线)见 insert_vip_linpengcheng_kingdee.sql
-- 资料口径来自 Soul 派对转写与卡若侧归档摘要;无 open_id仅用于首页横滑与会员详情展示
-- 头像为 DiceBear 占位图;若小程序不显示,请在后台配置 downloadFile 合法域名或换成本地上传图
INSERT INTO users (
id, nickname, avatar, referral_code, source,
is_vip, vip_expire_date, vip_activated_at, vip_sort,
vip_name, vip_role, vip_avatar, vip_bio, vip_project,
mbti, region, industry, position, business_scale, skills,
story_best_month, story_achievement, story_turning,
help_offer, help_need, project_intro,
created_at, updated_at
) VALUES
(
'soulvip_xilanhua_yezong01',
'西兰花',
'https://api.dicebear.com/7.x/notionists/png?seed=xilanhuaYe&size=256',
'SOULXLH01',
'vip_showcase',
1,
'2035-12-31 23:59:59',
NOW(),
11,
'西兰花',
'金融业务负责人',
'https://api.dicebear.com/7.x/notionists/png?seed=xilanhuaYe&size=256',
'群里的叶总:助贷与金融落地极强,团队里常对接渠道与放款合作。',
'厦门助贷与金融业务;派对里阿满类合作可找叶总聊城市复制与分成机制。',
NULL,
'福建厦门',
'助贷 / 金融服务',
'叶总(业务对接)',
'前端约百人跑金融业务;偏好线下见过、可控性高的产品与合作方。',
'渠道搭建、团队动员、银行与资方对接、分润与裂变机制设计',
'团队跑通规模化获客与投产,数据曾聊到比同城老牌助贷更高倍数。',
'厦门国际银行等渠道放款成绩突出,被视作团队里金融板块核心人物之一。',
'从单点成交到百人前端:更重渠道可持续与及时分润,避免断链。',
'可聊助贷与城市复制、渠道怎么分钱才不断;具体额度与合规边界当面捋。',
'要实际跑通过的产品与靠谱后端;不接受纯概念或无法落地的合作。',
'【我是谁】\n昵称西兰花群里大家称叶总。长期做助贷与金融公司落地厦门资源深。\n\n【聊天里的画像】\n派对转写里提到前端大量人力跑金融业务对合作方与产品「要摸得着、控得住」也参与供应链裂变场景重视给渠道大头与及时返现。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULXLH01',
NOW(),
NOW()
),
(
'soulvip_liuguang_liuguang02',
'流光',
'https://api.dicebear.com/7.x/notionists/png?seed=liuguangSoul&size=256',
'SOULLG02',
'vip_showcase',
1,
'2035-12-31 23:59:59',
NOW(),
12,
'流光',
'战略与人脉操盘手',
'https://api.dicebear.com/7.x/notionists/png?seed=liuguangSoul&size=256',
'刘光(流光):识人用人、战略规划、人脉链接,偏幕后撮合与顶层设计。',
'知己项目、MBTI 与人才体系、流量与私域项目中的链接与活动侧。',
'ENFP',
'福建厦门',
'咨询 / 人才与组织发展',
'联合创始人 / 战略顾问向',
'多年流量与私域项目撮合经验;擅长把人与场攒在一起。',
'识人用人体系、战略规划、线上线下活动与资源整合',
'在流量合作与知己方向,把 MBTI 与人才视角带进团队决策。',
'最看重「把对的人放在对的位置」,用活动与链接放大信任。',
'意识到强链接之后要配强执行,主动找 ENTJ 型搭档补落地。',
'可帮你做人才与合伙匹配、活动与资源引荐、项目叙事与节奏梳理。',
'需要执行力强的合伙人或 PM 把方案落到日更、周更。',
'【我是谁】\n流光本名刘光。聊天纪要里在迷茫期把 MBTI 与人才视角带进团队,强项是链接人与攒局。\n\n【聊天里的画像】\n相关人物「流光」目录多聊私域与流量合作近期记录里和知己项目、神仙团队、企业数字中台等方向绑定。自我定位偏战略与人脉执行需与强落地型搭档互补。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULLG02',
NOW(),
NOW()
),
(
'soulvip_zhengqingtu_00003',
'郑清土',
'https://api.dicebear.com/7.x/notionists/png?seed=zhengqingtu&size=256',
'SOULZQT03',
'vip_showcase',
1,
'2035-12-31 23:59:59',
NOW(),
13,
'郑清土',
'厦门房产 · 资产配置',
'https://api.dicebear.com/7.x/notionists/png?seed=zhengqingtu&size=256',
'五缘湾一带有家族房产底盘,以持有、长租与家族配置为主。金蝶与企业数字化由合伙人林鹏程主责,需要可引荐。',
'厦门房产长租与持有;本地实业资源协作。金蝶/ERP 交付请对接林鹏程。',
NULL,
'福建厦门',
'房产投资 / 资产管理',
'创业者 · 房产与资源配置',
'厦门核心板块(含五缘湾)多套自持与家族房产;团队内企业服务(金蝶)由林鹏程团队一线交付,本人侧重资产侧与协作。',
'房产选址与持有、租金与空置管理、资产配置与家族协作、本地商务资源引荐',
'租金季稳定入账,再叠一笔实业协作或分红回款——现金流不绑单一风口时最踏实。',
'在厦门把房产层做成可复用的底盘;把重招投标、重交付的金蝶线交给更擅长的合伙人林鹏程去扛,分工清晰。',
'从「什么都自己扛」到「资产守底盘、金蝶与服务找鹏程」——各展所长,减少角色错位。',
'厦门房产持有与出租实操、长租租客筛选、本地资源撮合(金蝶类需求转介林鹏程)。',
'优质长租租客与物业资源;实业合伙里偏资产与资本侧的靠谱窗口。',
'【我是谁】\n郑清土厦门本地人向长期以房产与资产配置为底盘。\n\n【重要说明】\n「金蝶 / ERP / 企业数字化」这条业务线是合伙人林鹏程的主业与一线交付,不是本人亲自扛的赛道;有金蝶获客、实施、招投标、运维续费,请直接找小程序超级个体「林鹏程」或群内对接他。\n\n【房产】\n五缘湾等核心板块自持与家族持有更看重地段与现金流长租、持有、空置与维护可深聊。\n\n【协作】\n本地实业与资源对接可聊涉及金蝶体系一律引荐林鹏程团队。\n\n【延伸】\n电竞、RWA、算力等仅作个人关注不在此展开。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULZQT03',
NOW(),
NOW()
);

View File

@@ -0,0 +1,42 @@
-- 用户规则表 v2新增结构化触发条件和推送动作列GORM AutoMigrate 会自动加列,此脚本用于填充现有规则的结构化数据)
ALTER TABLE user_rules ADD COLUMN IF NOT EXISTS trigger_conditions JSON DEFAULT NULL;
ALTER TABLE user_rules ADD COLUMN IF NOT EXISTS action_type VARCHAR(50) DEFAULT 'popup';
ALTER TABLE user_rules ADD COLUMN IF NOT EXISTS action_config JSON DEFAULT NULL;
-- 根据现有 trigger 字段回填结构化条件(最佳实践预设)
-- #10: 注册完成 → 填写头像
UPDATE user_rules SET trigger_conditions = '["after_login","fill_profile"]', action_type = 'popup'
WHERE sort = 10 AND trigger_conditions IS NULL;
-- #20: 完成匹配 → 补充个人资料
UPDATE user_rules SET trigger_conditions = '["after_match","fill_profile"]', action_type = 'popup'
WHERE sort = 20 AND trigger_conditions IS NULL;
-- #30: 首次浏览章节 → 绑定手机号
UPDATE user_rules SET trigger_conditions = '["view_chapter","bind_phone"]', action_type = 'popup'
WHERE sort = 30 AND trigger_conditions IS NULL;
-- #40: 付款 ¥1980 → 填写完整信息
UPDATE user_rules SET trigger_conditions = '["purchase_fullbook","fill_profile","add_wechat"]', action_type = 'popup'
WHERE sort = 40 AND trigger_conditions IS NULL;
-- #50: 加入派对房 → 填写项目介绍
UPDATE user_rules SET trigger_conditions = '["after_match"]', action_type = 'navigate'
WHERE sort = 50 AND trigger_conditions IS NULL;
-- #60: 浏览 5 个章节 → 分享推广
UPDATE user_rules SET trigger_conditions = '["browse_5_chapters","share_action"]', action_type = 'popup'
WHERE sort = 60 AND trigger_conditions IS NULL;
-- #70: 绑定微信 → 开启分销
UPDATE user_rules SET trigger_conditions = '["add_wechat","referral_bind"]', action_type = 'navigate'
WHERE sort = 70 AND trigger_conditions IS NULL;
-- #80: 收益达到
UPDATE user_rules SET trigger_conditions = '["withdraw_request"]', action_type = 'popup'
WHERE sort = 80 AND trigger_conditions IS NULL;
-- #90: 完善信息 → 进入流量池
UPDATE user_rules SET trigger_conditions = '["fill_profile","add_wechat","bind_phone"]', action_type = 'popup'
WHERE sort = 90 AND trigger_conditions IS NULL;

View File

@@ -0,0 +1,17 @@
-- 爱赛车的阿猫 · 资料完善(与小程序 profile-show / member-detail 字段对齐)
UPDATE users SET
region = '福建厦门 · 鼓浪屿(常住)',
industry = '房产运营 · 投资管理',
position = '包租婆 / 个人投资人',
business_scale = '鼓浪屿多套房源自持与精细化运营,现金流稳定为先;同时少量参与早期项目与副业赛道。',
skills = '长租与旅居式运营、租金定价与空置管理、轻量资产配置、早期项目看人看事、资源整合',
story_best_month = '把车爱好和生活节奏理顺:一边打理岛上的房子,一边用投资视角挑案子;租金与分红叠在一起时最有体感——钱在干活,人还能留时间去赛道边上看机会。',
story_achievement = '在鼓浪屿把工作与生活揉在一起:白天处理租客与维护,傍晚骑车吹风。最有成就感的是让房子持续产生现金,同时还能留时间给真正想跟的创业项目。',
story_turning = '从「只收租」到「租+投」双轨:现金流打底,遇到靠谱的创始人与赛道再出手。转折点是承认自己更适合做场上补给,而不是天天追风口。',
help_offer = '鼓浪屿租房/旅居踩坑指南;小户型民宿运营心得;早期商业计划书吐槽与撮合(不一定投,但愿意聊清楚)。',
help_need = '优质内容团队与品牌操盘手;可信的私域/电商合作窗口;同频的投资与创业圈子引荐。',
project_intro = '【我是谁】\n常住鼓浪屿的包租婆也是闲不住的个人投资人。喜欢赛车与机械硬核感做事习惯先算现金流、再谈情怀。\n\n【我在做什么】\n· 岛上自持房源长租与精细化运营,追求稳定出租率与口碑。\n· 少量天使/跟投,偏好有护城河、创始人靠谱的早期项目。\n\n【链接与入口】\n· 创业派对与同名内容https://soul.quwanzhi.com/?ref=SOULMO8AHO\n· 合作或看房况:欢迎通过本小程序资料页已绑定的手机与我联系(可加微信后深聊)。\n\n【合作期待】\n希望遇到能把故事讲清楚、把账算明白的合伙人闲聊勿扰尊重彼此时间。',
vip_bio = '鼓浪屿常住 · 包租婆 · 个人投资人 · 赛车爱好者。房产现金流打底,少量跟投早期项目;欢迎通过小程序联系。',
vip_project = '鼓浪屿自持房产运营 · 早期项目投资跟投 · 小程序内可直达内容与推荐链接',
updated_at = NOW()
WHERE id = 'ogpTW5cVMxd5afBBtXdvmeMO8aho' AND deleted_at IS NULL;

View File

@@ -0,0 +1,25 @@
-- 陈周(人字拖):桂花糕 + 3D 打印文创 / 特产店 / 京东旗舰店 + 本地生活引流
-- 用户 iduser_268099bf原昵称 微信用户89VE2026-03-18 注册,已是 VIP
-- 口径Soul 妙记 2026032020260321「用所选项目新建的文件夹」
UPDATE users SET
nickname = '陈周(人字拖)',
avatar = 'https://api.dicebear.com/7.x/notionists/png?seed=chenzhouRenzituo&size=256',
vip_name = '陈周(人字拖)',
vip_role = '桂花糕 · 3D打印文创',
vip_avatar = 'https://api.dicebear.com/7.x/notionists/png?seed=chenzhouRenzituo&size=256',
vip_bio = '外号人字拖,好记。一条线是家里桂花糕与特产(京东旗舰店背书 + 本地生活引流);一条线是 3D 打印文创与定制,从一张图到可交付。',
vip_project = '桂花糕/桂花酒等综合品类电商3D 打印设备与文创定制、文旅伴手礼打样。',
vip_sort = 20,
region = '福建(特产与文旅向)',
industry = '食品电商 / 3D打印文创',
position = '创始人 · 桂花糕与3D定制',
business_scale = '已跑京东旗舰店强背书;本地生活引流;店内综合品类(桂花糕引流 + 桂花酒等。3D 线:文旅特产店场景 + 车模/旅游附件等个性定制,设备可做塑料壳类打样。',
skills = '全平台内容引流、京东店运营、本地生活获客、3D打印文创与设计交付、客户需求一轮迭代打磨',
story_best_month = '派对与视频号带来曝光时,桂花糕咨询和 3D 定制线索叠在一起——同一套「先讲清楚卖什么」的话术,两条线都能接。',
story_achievement = '把「陈州/陈周 + 人字拖」做成好记的个人 IP桂花糕生意垒到京东旗舰店 + 本地引流闭环3D 打印链接工厂与文旅资源。',
story_turning = '从早年卖手机的微信昵称记忆点,转到「人字拖」这种更好亲近的称呼;业务上从单一品类走到「特产 + 3D 定制」双引擎。',
help_offer = '桂花糕与特产电商起盘、京东旗舰店与本地生活怎么配3D 打印文创从需求描述到一版可改的设计交付流程。',
help_need = '稳定供应链与包材;有预算的文旅/工厂 3D 打样与批量意向;同城内容与派对联动曝光。',
project_intro = '【我是谁】\n陈周大家叫我「人字拖」名字好记、好亲近。派对妙记里自我介绍陈州/陈周和人字拖是一组记忆点。\n\n【桂花糕与特产】\n这段时间也在帮家人把桂花糕这条线做大已上京东旗舰店用平台背书本地生活做引流。店里做综合品类以桂花糕带流量延伸桂花酒等。妙记里也提到全平台、简单重复也能出结果关键是方法要多、平台要铺开。\n\n【3D 打印】\n另一条线是 3D 打印:设备侧能做塑料壳等;文创侧帮客户把想法落成实物——文旅场景我们有特产店与定价锚点,定制线可接车模、旅游附件等。一张图 + 描述需求,通常一轮修改就能接近预期。也关注德化等地工厂把瓷器开模转向 3D 的趋势。\n\n【内容】\n本人也是运动向博主账号曾因帮家人做桂花糕不够「垂直」但仍在积累关注与推荐主业不靠单条爆款靠业务闭环。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULTG89VE',
updated_at = NOW()
WHERE id = 'user_268099bf' AND deleted_at IS NULL;

View File

@@ -0,0 +1,18 @@
-- 已 superseded金蝶线划归林鹏程后请用 update_zhengqingtu_split_kingdee_to_linpengcheng.sql
-- (历史)郑清土:强化「厦门房产底盘」与「金蝶系等传统 ToB 业务」表述
UPDATE users SET
vip_role = '厦门房产 · 传统ToB业务',
vip_bio = '五缘湾一带有家族房产底盘;多年金蝶销售与实施服务,传统企业服务与大客户交付是主业现金流。',
vip_project = '厦门房产配置与长租/持有策略;金蝶生态销售、实施与运维类传统 ToB 项目;新经济方向仅作延伸关注。',
industry = '房产持有 / 企业服务ToB',
position = '创业者 · 房产与传统业务负责人',
business_scale = '厦门核心板块(含五缘湾)多套自持与家族房产;传统业务侧长期服务制造业与政企类金蝶项目,现金流稳、决策偏谨慎。',
skills = '房产选址与持有、租金与资产配置、金蝶系销售与大客户拓展、项目交付与团队管理、传统 IT 服务商务谈判',
story_best_month = '传统业务大单回款与租金季叠在一起的那几个月——一边是 ToB 交付收尾,一边是房租按时入账,现金流最踏实。',
story_achievement = '把金蝶这条线从销售做到能带团队做实施与续费,同时在厦门把房产这一层慢慢垒成「睡后收入」底盘,不靠风口讲故事。',
story_turning = '早年写代码后来转销售与管理,再往后明白:传统业务要深、房产要早布局;新业务可以试,但不能动底盘。',
help_offer = '厦门房产持有与出租实操、金蝶类传统 ToB 获客与交付经验、大客户招投标与回款节奏。',
help_need = '优质长租租客与物业资源;传统制造业/政企数字化预算内的靠谱项目线索。',
project_intro = '【我是谁】\n郑清土厦门本地人向的创业者。底盘两块一是家里在五缘湾等核心板块有房产配置与持有二是自己多年扎在金蝶体系的销售、实施与服务做的是制造业、政企等传统客户的数字化与 ERP 类项目。\n\n【房产】\n更看重「地段 + 现金流」而不是短期炒作:长租、持有、家族资产配置一起讨论都可以。厦门岛内外的租金逻辑、空置与维护,更愿意用经验换时间。\n\n【传统业务】\n金蝶系 ToB 是老本行:从线索、招投标到交付、运维与续费,习惯按项目制把团队搭起来。偏保守,不接看不懂预算来源的单。\n\n【延伸】\n电竞、RWA、算力等新赛道有关注但公开资料里不作为主业描述合作优先聊房产与传统 ToB。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULZQT03',
updated_at = NOW()
WHERE id = 'soulvip_zhengqingtu_00003' AND deleted_at IS NULL;

View File

@@ -0,0 +1,17 @@
-- 郑清土:金蝶/ERP/企业数字化为林鹏程主业,郑清土侧重房产与资产配置;二者分工写清楚
UPDATE users SET
vip_role = '厦门房产 · 资产配置',
vip_bio = '五缘湾一带有家族房产底盘,以持有、长租与家族配置为主。金蝶与企业数字化由合伙人林鹏程主责,需要可引荐。',
vip_project = '厦门房产长租与持有;本地实业资源协作。金蝶/ERP 交付请对接林鹏程。',
industry = '房产投资 / 资产管理',
position = '创业者 · 房产与资源配置',
business_scale = '厦门核心板块(含五缘湾)多套自持与家族房产;团队内企业服务(金蝶)由林鹏程团队一线交付,本人侧重资产侧与协作。',
skills = '房产选址与持有、租金与空置管理、资产配置与家族协作、本地商务资源引荐',
story_best_month = '租金季稳定入账,再叠一笔实业协作或分红回款——现金流不绑单一风口时最踏实。',
story_achievement = '在厦门把房产层做成可复用的底盘;把重招投标、重交付的金蝶线交给更擅长的合伙人林鹏程去扛,分工清晰。',
story_turning = '从「什么都自己扛」到「资产守底盘、金蝶与服务找鹏程」——各展所长,减少角色错位。',
help_offer = '厦门房产持有与出租实操、长租租客筛选、本地资源撮合(金蝶类需求转介林鹏程)。',
help_need = '优质长租租客与物业资源;实业合伙里偏资产与资本侧的靠谱窗口。',
project_intro = '【我是谁】\n郑清土厦门本地人向长期以房产与资产配置为底盘。\n\n【重要说明】\n「金蝶 / ERP / 企业数字化」这条业务线是合伙人林鹏程的主业与一线交付,不是本人亲自扛的赛道;有金蝶获客、实施、招投标、运维续费,请直接找小程序超级个体「林鹏程」或群内对接他。\n\n【房产】\n五缘湾等核心板块自持与家族持有更看重地段与现金流长租、持有、空置与维护可深聊。\n\n【协作】\n本地实业与资源对接可聊涉及金蝶体系一律引荐林鹏程团队。\n\n【延伸】\n电竞、RWA、算力等仅作个人关注不在此展开。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULZQT03',
updated_at = NOW()
WHERE id = 'soulvip_zhengqingtu_00003' AND deleted_at IS NULL;