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

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