Files
soul-yongping/soul-api/internal/handler/db.go
2026-03-24 01:22:50 +08:00

1741 lines
57 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"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
}
}
// 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}
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
apiDomain := "https://soulapi.quwanzhi.com"
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
apiDomain = cfg.BaseURL
}
defaultMp := gin.H{
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": apiDomain,
"buyerDiscount": 5,
"referralBindDays": 30,
"minWithdraw": 10,
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
"mchId": "1318592501",
"auditMode": false,
"supportWechat": true,
"shareIcon": "", // 分享图标URL由管理端配置
"mpUi": defaultMpUi(),
}
out := gin.H{
"success": true,
"prices": defaultPrices,
"features": defaultFeatures,
"mpConfig": defaultMp,
"configs": gin.H{},
}
db := database.DB()
keys := []string{"chapter_config", "feature_config", "mp_config"}
for _, k := range keys {
var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
continue
}
var val interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
continue
}
switch k {
case "chapter_config":
if m, ok := val.(map[string]interface{}); ok {
if v, ok := m["prices"].(map[string]interface{}); ok {
out["prices"] = v
}
if v, ok := m["features"].(map[string]interface{}); ok {
out["features"] = v
}
out["configs"].(gin.H)["chapter_config"] = m
}
case "feature_config":
if m, ok := val.(map[string]interface{}); ok {
// 合并到 features不整体覆盖以保留 chapter_config 里的
cur := out["features"].(gin.H)
for kk, vv := range m {
cur[kk] = vv
}
out["configs"].(gin.H)["feature_config"] = m
}
case "mp_config":
if m, ok := val.(map[string]interface{}); ok {
// 合并默认值DB 有则覆盖
merged := make(gin.H)
for k, v := range defaultMp {
merged[k] = v
}
for k, v := range m {
merged[k] = v
}
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
out["mpConfig"] = merged
out["configs"].(gin.H)["mp_config"] = merged
}
}
}
// 价格以管理端「站点与作者」site_settings 为准(运营唯一配置入口),无则用 chapter_config 或默认值
var siteRow model.SystemConfig
if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 {
var siteVal map[string]interface{}
if err := json.Unmarshal(siteRow.ConfigValue, &siteVal); err == nil {
cur := out["prices"].(gin.H)
if v, ok := siteVal["sectionPrice"].(float64); ok && v > 0 {
cur["section"] = v
}
if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 {
cur["fullbook"] = v
}
}
}
// 好友优惠(用于 read 页展示优惠价)
var refRow model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
var refVal map[string]interface{}
if err := json.Unmarshal(refRow.ConfigValue, &refVal); err == nil {
if v, ok := refVal["userDiscount"].(float64); ok {
out["userDiscount"] = v
}
}
}
if _, has := out["userDiscount"]; !has {
out["userDiscount"] = float64(5)
}
// 链接标签列表(小程序 onLinkTagTap 需要 typeminiprogram 类型存 mpKey用 key 查 linkedMiniprograms 得 appId
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
}
tags = append(tags, h)
}
out["linkTags"] = tags
}
// 关联小程序列表key 为 32 位密钥,小程序用 key 查 appId 后 wx.navigateToMiniProgram
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{}
}
} else {
// 未找到配置或查询失败,使用空数组作为默认值
out["linkedMiniprograms"] = []gin.H{}
}
// 归一化 auditMode兼容历史 bool / 字符串 / 数字)
if mp, ok := out["mpConfig"].(gin.H); ok {
mp["auditMode"] = parseConfigBool(mp["auditMode"])
}
return out
}
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// Redis 缓存 10min配置变更时失效
//
// Deprecated: 计划迁移至 /config/core + /config/audit-mode + /config/read-extras保留以兼容线上小程序
func GetPublicDBConfig(c *gin.Context) {
var cached map[string]interface{}
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
out := buildMiniprogramConfig()
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
// 缓存未命中时仅查 mp_config 一条记录,避免 buildMiniprogramConfig 全量查询导致超时
// Redis 不可用时 cache 包自动降级到内存备用
func GetAuditMode(c *gin.Context) {
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
auditMode := getAuditModeFromDB()
out := gin.H{"auditMode": auditMode}
cache.Set(context.Background(), cache.KeyConfigAuditMode, out, cache.AuditModeTTL)
c.JSON(http.StatusOK, out)
}
// getAuditModeFromDB 仅查询 mp_config 的 auditMode轻量级避免超时
func getAuditModeFromDB() bool {
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "mp_config").First(&row).Error; err != nil {
return false
}
var mp map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &mp); err != nil {
return false
}
return parseConfigBool(mp["auditMode"])
}
// GetCoreConfig GET /api/miniprogram/config/core 核心配置prices、features、userDiscount、mpConfig首屏/Tab 用
func GetCoreConfig(c *gin.Context) {
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigCore, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
full := buildMiniprogramConfig()
out := gin.H{
"success": true,
"prices": full["prices"],
"features": full["features"],
"userDiscount": full["userDiscount"],
"mpConfig": full["mpConfig"],
}
if out["prices"] == nil {
out["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
}
if out["features"] == nil {
out["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
}
if out["userDiscount"] == nil {
out["userDiscount"] = float64(5)
}
if out["mpConfig"] == nil {
out["mpConfig"] = gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigCore, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
// buildMentionPersonsForRead 阅读页 @ 自动解析用:与后台 persons 表一致(含 name/label/aliases/token
// inPool=true 表示已绑定会员 user_id超级个体/可进匹配流量池),仍参与 @ 匹配以便正文与后台人物库对齐。
func buildMentionPersonsForRead() []gin.H {
db := database.DB()
var rows []model.Person
if err := db.Select("person_id", "token", "name", "aliases", "label", "user_id").
Where("token IS NOT NULL AND token != ?", "").
Order("name ASC").
Find(&rows).Error; err != nil {
return []gin.H{}
}
out := make([]gin.H, 0, len(rows))
for _, p := range rows {
inPool := p.UserID != nil && strings.TrimSpace(*p.UserID) != ""
out = append(out, gin.H{
"personId": p.PersonID,
"token": p.Token,
"name": strings.TrimSpace(p.Name),
"aliases": strings.TrimSpace(p.Aliases),
"label": strings.TrimSpace(p.Label),
"inPool": inPool,
})
}
return out
}
// GetReadExtras GET /api/miniprogram/config/read-extras 阅读页扩展linkTags、linkedMiniprograms、mentionPersons懒加载
func GetReadExtras(c *gin.Context) {
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigReadExtras, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
full := buildMiniprogramConfig()
out := gin.H{
"linkTags": full["linkTags"],
"linkedMiniprograms": full["linkedMiniprograms"],
"mentionPersons": buildMentionPersonsForRead(),
}
if out["linkTags"] == nil {
out["linkTags"] = []gin.H{}
}
if out["linkedMiniprograms"] == nil {
out["linkedMiniprograms"] = []gin.H{}
}
if out["mentionPersons"] == nil {
out["mentionPersons"] = []gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigReadExtras, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
// WarmConfigCache 启动时预热 config 及拆分接口缓存,避免首请求冷启动
func WarmConfigCache() {
out := buildMiniprogramConfig()
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
// 拆分接口预热
auditMode := false
if mp, ok := out["mpConfig"].(gin.H); ok {
auditMode = parseConfigBool(mp["auditMode"])
}
cache.Set(context.Background(), cache.KeyConfigAuditMode, gin.H{"auditMode": auditMode}, cache.AuditModeTTL)
core := gin.H{
"success": true,
"prices": out["prices"],
"features": out["features"],
"userDiscount": out["userDiscount"],
"mpConfig": out["mpConfig"],
}
if core["prices"] == nil {
core["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
}
if core["features"] == nil {
core["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
}
if core["userDiscount"] == nil {
core["userDiscount"] = float64(5)
}
if core["mpConfig"] == nil {
core["mpConfig"] = gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigCore, core, cache.ConfigTTL)
readExtras := gin.H{
"linkTags": out["linkTags"],
"linkedMiniprograms": out["linkedMiniprograms"],
"mentionPersons": buildMentionPersonsForRead(),
}
if readExtras["linkTags"] == nil {
readExtras["linkTags"] = []gin.H{}
}
if readExtras["linkedMiniprograms"] == nil {
readExtras["linkedMiniprograms"] = []gin.H{}
}
if readExtras["mentionPersons"] == nil {
readExtras["mentionPersons"] = []gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigReadExtras, readExtras, cache.ConfigTTL)
}
// DBConfigGet GET /api/db/config管理端鉴权后同路径由 db 组处理时用)
func DBConfigGet(c *gin.Context) {
key := c.Query("key")
db := database.DB()
var list []model.SystemConfig
q := db.Table("system_config")
if key != "" {
q = q.Where("config_key = ?", key)
}
if err := q.Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if key != "" && len(list) == 1 {
var val interface{}
_ = json.Unmarshal(list[0].ConfigValue, &val)
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
return
}
data := make([]gin.H, 0, len(list))
for _, row := range list {
var val interface{}
_ = json.Unmarshal(row.ConfigValue, &val)
data = append(data, gin.H{"configKey": row.ConfigKey, "configValue": val})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回功能开关、站点/作者与价格、小程序配置
func AdminSettingsGet(c *gin.Context) {
db := database.DB()
apiDomain := "https://soulapi.quwanzhi.com"
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
apiDomain = cfg.BaseURL
}
defaultMp := gin.H{
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": apiDomain,
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
"mchId": "1318592501",
"minWithdraw": float64(10),
"auditMode": false,
"supportWechat": true,
"mpUi": defaultMpUi(),
}
out := gin.H{
"success": true,
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp,
"ossConfig": gin.H{},
}
keys := []string{"feature_config", "site_settings", "mp_config", "oss_config"}
for _, k := range keys {
var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
continue
}
var val interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
continue
}
switch k {
case "feature_config":
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
out["featureConfig"] = m
}
case "site_settings":
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
out["siteSettings"] = m
}
case "mp_config":
if m, ok := val.(map[string]interface{}); ok {
merged := make(gin.H)
for k, v := range defaultMp {
merged[k] = v
}
for k, v := range m {
merged[k] = v
}
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
out["mpConfig"] = merged
}
case "oss_config":
if m, ok := val.(map[string]interface{}); ok {
safe := make(map[string]interface{})
for k, v := range m {
if k == "accessKeySecret" {
safe[k] = "****"
} else {
safe[k] = v
}
}
out["ossConfig"] = safe
}
}
}
c.JSON(http.StatusOK, out)
}
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存功能开关、站点/作者与价格、小程序配置
func AdminSettingsPost(c *gin.Context) {
var body struct {
FeatureConfig map[string]interface{} `json:"featureConfig"`
SiteSettings map[string]interface{} `json:"siteSettings"`
MpConfig map[string]interface{} `json:"mpConfig"`
OssConfig map[string]interface{} `json:"ossConfig"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
db := database.DB()
saveKey := func(key, desc string, value interface{}) error {
valBytes, err := json.Marshal(value)
if err != nil {
return err
}
var row model.SystemConfig
err = db.Where("config_key = ?", key).First(&row).Error
if err != nil {
row = model.SystemConfig{ConfigKey: key, ConfigValue: valBytes, Description: &desc}
return db.Create(&row).Error
}
row.ConfigValue = valBytes
if desc != "" {
row.Description = &desc
}
return db.Save(&row).Error
}
if body.FeatureConfig != nil {
if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()})
return
}
}
if body.SiteSettings != nil {
if err := saveKey("site_settings", "站点与作者配置", body.SiteSettings); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存站点设置失败: " + err.Error()})
return
}
}
if body.MpConfig != nil {
if err := saveKey("mp_config", "小程序专用配置", body.MpConfig); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存小程序配置失败: " + err.Error()})
return
}
}
if body.OssConfig != nil {
if err := saveKey("oss_config", "阿里云 OSS 配置", body.OssConfig); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存 OSS 配置失败: " + err.Error()})
return
}
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
}
// AdminReferralSettingsGet GET /api/admin/referral-settings 推广设置页专用:仅返回 referral_config
func AdminReferralSettingsGet(c *gin.Context) {
db := database.DB()
defaultConfig := gin.H{
"distributorShare": float64(90),
"minWithdrawAmount": float64(10),
"bindingDays": float64(30),
"userDiscount": float64(5),
"withdrawFee": float64(5),
"enableAutoWithdraw": false,
"vipOrderShareVip": float64(20),
"vipOrderShareNonVip": float64(10),
}
var row model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig})
return
}
var val map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil || len(val) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
}
// AdminReferralSettingsPost POST /api/admin/referral-settings 推广设置页专用:仅保存 referral_config请求体为完整配置对象
func AdminReferralSettingsPost(c *gin.Context) {
var body struct {
DistributorShare float64 `json:"distributorShare"`
MinWithdrawAmount float64 `json:"minWithdrawAmount"`
BindingDays float64 `json:"bindingDays"`
UserDiscount float64 `json:"userDiscount"`
WithdrawFee float64 `json:"withdrawFee"`
EnableAutoWithdraw bool `json:"enableAutoWithdraw"`
VipOrderShareVip float64 `json:"vipOrderShareVip"`
VipOrderShareNonVip float64 `json:"vipOrderShareNonVip"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
vipOrderShareVip := body.VipOrderShareVip
if vipOrderShareVip == 0 {
vipOrderShareVip = 20
}
vipOrderShareNonVip := body.VipOrderShareNonVip
if vipOrderShareNonVip == 0 {
vipOrderShareNonVip = 10
}
val := gin.H{
"distributorShare": body.DistributorShare,
"minWithdrawAmount": body.MinWithdrawAmount,
"bindingDays": body.BindingDays,
"userDiscount": body.UserDiscount,
"withdrawFee": body.WithdrawFee,
"enableAutoWithdraw": body.EnableAutoWithdraw,
"vipOrderShareVip": vipOrderShareVip,
"vipOrderShareNonVip": vipOrderShareNonVip,
}
valBytes, err := json.Marshal(val)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
db := database.DB()
desc := "分销 / 推广规则配置"
var row model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil {
row = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc}
err = db.Create(&row).Error
} else {
row.ConfigValue = valBytes
row.Description = &desc
err = db.Save(&row).Error
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
}
func authorConfigToResponse(row *model.AuthorConfig) gin.H {
defaultStats := []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
defaultHighlights := []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"}
var stats []gin.H
if row.Stats != "" {
_ = json.Unmarshal([]byte(row.Stats), &stats)
}
if len(stats) == 0 {
stats = defaultStats
}
var highlights []string
if row.Highlights != "" {
_ = json.Unmarshal([]byte(row.Highlights), &highlights)
}
if len(highlights) == 0 {
highlights = defaultHighlights
}
return gin.H{
"name": row.Name,
"avatar": row.Avatar,
"avatarImg": row.AvatarImg,
"title": row.Title,
"bio": row.Bio,
"stats": stats,
"highlights": highlights,
}
}
// AdminAuthorSettingsGet GET /api/admin/author-settings 作者详情配置(管理端专用)
func AdminAuthorSettingsGet(c *gin.Context) {
defaultAuthor := gin.H{
"name": "卡若",
"avatar": "K",
"avatarImg": "",
"title": "Soul派对房主理人 · 私域运营专家",
"bio": "每天早上6点到9点在Soul派对房分享真实的创业故事。专注私域运营与项目变现用云阿米巴模式帮助创业者构建可持续的商业体系。",
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
}
db := database.DB()
var row model.AuthorConfig
if err := db.First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
}
// AdminAuthorSettingsPost POST /api/admin/author-settings 保存作者详情配置
func AdminAuthorSettingsPost(c *gin.Context) {
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
str := func(k string) string {
if v, ok := body[k]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
return ""
}
name := str("name")
if name == "" {
name = "卡若"
}
avatar := str("avatar")
if avatar == "" {
avatar = "K"
}
statsVal := body["stats"]
if statsVal == nil {
statsVal = []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
}
highlightsVal := body["highlights"]
if highlightsVal == nil {
highlightsVal = []string{}
}
statsBytes, _ := json.Marshal(statsVal)
highlightsBytes, _ := json.Marshal(highlightsVal)
db := database.DB()
var row model.AuthorConfig
err := db.First(&row).Error
if err != nil {
row = model.AuthorConfig{
Name: name,
Avatar: avatar,
AvatarImg: str("avatarImg"),
Title: str("title"),
Bio: str("bio"),
Stats: string(statsBytes),
Highlights: string(highlightsBytes),
}
err = db.Create(&row).Error
} else {
row.Name = name
row.Avatar = avatar
row.AvatarImg = str("avatarImg")
row.Title = str("title")
row.Bio = str("bio")
row.Stats = string(statsBytes)
row.Highlights = string(highlightsBytes)
err = db.Save(&row).Error
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "作者设置已保存"})
}
// MiniprogramAboutAuthor GET /api/miniprogram/about/author 小程序-关于作者页拉取作者配置(公开,无需鉴权)
func MiniprogramAboutAuthor(c *gin.Context) {
defaultAuthor := gin.H{
"name": "卡若",
"avatar": "K",
"avatarImg": "",
"title": "Soul派对房主理人 · 私域运营专家",
"bio": "每天早上6点到9点在Soul派对房分享真实的创业故事。",
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
}
db := database.DB()
var row model.AuthorConfig
if err := db.First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
}
// DBConfigPost POST /api/db/config
func DBConfigPost(c *gin.Context) {
var body struct {
Key string `json:"key"`
Value interface{} `json:"value"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Key == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
return
}
valBytes, err := json.Marshal(body.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
db := database.DB()
desc := body.Description
var row model.SystemConfig
err = db.Where("config_key = ?", body.Key).First(&row).Error
if err != nil {
row = model.SystemConfig{ConfigKey: body.Key, ConfigValue: valBytes, Description: &desc}
err = db.Create(&row).Error
} else {
row.ConfigValue = valBytes
if body.Description != "" {
row.Description = &desc
}
err = db.Save(&row).Error
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}
// DBUsersList GET /api/db/users支持分页 page、pageSize可选搜索 search有 id 时返回单个 user购买状态、分销收益、绑定人数从订单/绑定表实时计算)
func DBUsersList(c *gin.Context) {
db := database.DB()
id := strings.TrimSpace(c.Query("id"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
search := strings.TrimSpace(c.DefaultQuery("search", ""))
vipFilter := c.Query("vip") // "true" 时仅返回 VIPhasFullBook
poolFilter := c.Query("pool") // "complete" 时仅返回已完善资料的用户
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
// 有 id 时返回单个用户(供 UserDetailModal 等使用)
if id != "" {
var user model.User
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "user": nil})
return
}
// 填充 hasFullBook含 orders、is_vip、手动设置的 has_full_book
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
id, "paid", "completed", "fullbook", "vip").Count(&cnt)
hasFull := cnt > 0 || (user.IsVip != nil && *user.IsVip) || (user.HasFullBook != nil && *user.HasFullBook)
user.HasFullBook = ptrBool(hasFull)
c.JSON(http.StatusOK, gin.H{"success": true, "user": user})
return
}
q := db.Model(&model.User{})
if search != "" {
pattern := "%" + search + "%"
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
}
if poolFilter == "complete" {
q = q.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" {
q = q.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())
}
var total int64
q.Count(&total)
var users []model.User
query := db.Model(&model.User{})
if search != "" {
pattern := "%" + search + "%"
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
}
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())
}
if err := query.Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&users).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "users": []interface{}{}})
return
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
if len(users) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true, "users": users,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
return
}
userIDs := make([]string, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
// 1. 购买状态:全书已购、已付费章节数(从 orders 计算)
hasFullBookMap := make(map[string]bool)
sectionCountMap := make(map[string]int)
var fullbookRows []struct {
UserID string
}
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
}
var sectionRows []struct {
UserID string
Count int64
}
db.Model(&model.Order{}).Select("user_id, COUNT(*) as count").
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)
}
// 2. 分销收益:从 referrer 订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%
referrerEarningsMap := make(map[string]float64)
var referrerOrders []model.Order
db.Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").Find(&referrerOrders)
for i := range referrerOrders {
rid := referrerOrders[i].ReferrerID
if rid != nil && *rid != "" {
referrerEarningsMap[*rid] += computeOrderCommission(db, &referrerOrders[i], nil)
}
}
withdrawnMap := make(map[string]float64)
var withdrawnRows []struct {
UserID string
Total float64
}
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
Where("status = ?", "success").
Group("user_id").Find(&withdrawnRows)
for _, r := range withdrawnRows {
withdrawnMap[r.UserID] = r.Total
}
pendingWithdrawMap := make(map[string]float64)
var pendingRows []struct {
UserID string
Total float64
}
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
Where("status IN ?", []string{"pending", "processing", "pending_confirm"}).
Group("user_id").Find(&pendingRows)
for _, r := range pendingRows {
pendingWithdrawMap[r.UserID] = r.Total
}
// 3. 绑定人数:综合 referral_bindings + orders.referrer_id + users.referral_count 三源取最大值
referralCountMap := make(map[string]int)
var refCountRows []struct {
ReferrerID string `gorm:"column:referrer_id"`
Count int64 `gorm:"column:count"`
}
db.Model(&model.ReferralBinding{}).Select("referrer_id, COUNT(*) as count").
Group("referrer_id").Scan(&refCountRows)
for _, r := range refCountRows {
referralCountMap[r.ReferrerID] = int(r.Count)
}
var orderRefRows []struct {
ReferrerID string `gorm:"column:referrer_id"`
Count int64 `gorm:"column:count"`
}
db.Model(&model.Order{}).Select("referrer_id, COUNT(DISTINCT user_id) as count").
Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").
Group("referrer_id").Scan(&orderRefRows)
for _, r := range orderRefRows {
if int(r.Count) > referralCountMap[r.ReferrerID] {
referralCountMap[r.ReferrerID] = int(r.Count)
}
}
// 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
// 购买状态含订单、is_vip、手动设置的 has_full_book
hasFull := hasFullBookMap[uid]
if users[i].IsVip != nil && *users[i].IsVip && users[i].VipExpireDate != nil && users[i].VipExpireDate.After(time.Now()) {
hasFull = true
}
if users[i].HasFullBook != nil && *users[i].HasFullBook {
hasFull = true
}
users[i].HasFullBook = ptrBool(hasFull)
users[i].PurchasedSectionCount = sectionCountMap[uid]
// 分销收益
totalE := referrerEarningsMap[uid]
withdrawn := withdrawnMap[uid]
pendingWd := pendingWithdrawMap[uid]
available := totalE - withdrawn - pendingWd
if available < 0 {
available = 0
}
users[i].Earnings = ptrFloat64(totalE)
users[i].PendingEarnings = ptrFloat64(available)
bindCount := referralCountMap[uid]
dbCount := 0
if users[i].ReferralCount != nil {
dbCount = *users[i].ReferralCount
}
if dbCount > bindCount {
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{
"success": true, "users": users,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
}
func ptrBool(b bool) *bool { return &b }
func ptrFloat64(f float64) *float64 { v := f; return &v }
func ptrInt(n int) *int { return &n }
// DBUsersAction POST /api/db/users创建、PUT /api/db/users更新
func DBUsersAction(c *gin.Context) {
db := database.DB()
if c.Request.Method == http.MethodPost {
var body struct {
OpenID *string `json:"openId"`
Phone *string `json:"phone"`
Nickname *string `json:"nickname"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
IsAdmin *bool `json:"isAdmin"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
userID := "user_" + randomSuffix()
code := "SOUL" + randomSuffix()[:4]
nick := "用户"
if body.Nickname != nil && *body.Nickname != "" {
nick = *body.Nickname
} else {
nick = nick + userID[len(userID)-4:]
}
u := model.User{
ID: userID, Nickname: &nick, ReferralCode: &code,
OpenID: body.OpenID, Phone: body.Phone, WechatID: body.WechatID, Avatar: body.Avatar,
}
if body.IsAdmin != nil {
u.IsAdmin = body.IsAdmin
}
if err := db.Create(&u).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
return
}
// PUT 更新(含 VIP 手动设置is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_biotags 存 ckb_tags
var body struct {
ID string `json:"id"`
Nickname *string `json:"nickname"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
PendingEarnings *float64 `json:"pendingEarnings"`
IsVip *bool `json:"isVip"`
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
VipSort *int `json:"vipSort"` // 手动排序,越小越前
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
VipName *string `json:"vipName"`
VipAvatar *string `json:"vipAvatar"`
VipProject *string `json:"vipProject"`
VipContact *string `json:"vipContact"`
VipBio *string `json:"vipBio"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
// 手动设置 VIP 时,必须提供有效到期日
if body.IsVip != nil && *body.IsVip {
if body.VipExpireDate == nil || strings.TrimSpace(*body.VipExpireDate) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "开启 VIP 时请填写有效到期日"})
return
}
if _, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(*body.VipExpireDate), time.Local); err != nil {
if _, err2 := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*body.VipExpireDate), time.Local); err2 != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "到期日格式无效,请使用 YYYY-MM-DD"})
return
}
}
}
updates := map[string]interface{}{}
if body.Nickname != nil {
updates["nickname"] = *body.Nickname
}
if body.Phone != nil {
updates["phone"] = *body.Phone
}
if body.WechatID != nil {
updates["wechat_id"] = *body.WechatID
}
if body.Avatar != nil {
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Tags != nil {
updates["ckb_tags"] = *body.Tags
}
if body.HasFullBook != nil {
updates["has_full_book"] = *body.HasFullBook
}
if body.IsAdmin != nil {
updates["is_admin"] = *body.IsAdmin
}
if body.Earnings != nil {
updates["earnings"] = *body.Earnings
}
if body.PendingEarnings != nil {
updates["pending_earnings"] = *body.PendingEarnings
}
if body.IsVip != nil {
updates["is_vip"] = *body.IsVip
if *body.IsVip {
now := time.Now()
updates["vip_activated_at"] = now // 手动设置时与付款一致:按时间排序,最新在前
} else {
updates["vip_activated_at"] = nil
}
}
if body.VipExpireDate != nil {
if *body.VipExpireDate == "" {
updates["vip_expire_date"] = nil
} else {
if t, err := time.ParseInLocation("2006-01-02", *body.VipExpireDate, time.Local); err == nil {
updates["vip_expire_date"] = t
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", *body.VipExpireDate, time.Local); err == nil {
updates["vip_expire_date"] = t
}
}
}
if body.VipSort != nil {
updates["vip_sort"] = *body.VipSort
}
if body.VipRole != nil {
s := strings.TrimSpace(*body.VipRole)
if s == "" {
updates["vip_role"] = nil
} else {
updates["vip_role"] = s
}
}
if body.VipName != nil {
updates["vip_name"] = *body.VipName
}
if body.VipAvatar != nil {
updates["vip_avatar"] = avatarToPath(*body.VipAvatar)
}
if body.VipProject != nil {
updates["vip_project"] = *body.VipProject
}
if body.VipContact != nil {
updates["vip_contact"] = *body.VipContact
}
if body.VipBio != nil {
updates["vip_bio"] = *body.VipBio
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return
}
// VIP 相关更新时记录日志(手动设置)
if body.IsVip != nil || body.VipExpireDate != nil || body.VipName != nil || body.VipAvatar != nil || body.VipProject != nil || body.VipContact != nil || body.VipBio != nil {
isVipStr := "-"
if body.IsVip != nil {
isVipStr = fmt.Sprintf("%v", *body.IsVip)
}
vipExpire := "-"
if body.VipExpireDate != nil {
vipExpire = *body.VipExpireDate
}
fmt.Printf("[VIP] 设置方式=手动设置, userId=%s, isVip=%s, vipExpireDate=%s\n", body.ID, isVipStr, vipExpire)
}
if err := db.Model(&model.User{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户更新成功"})
}
func randomSuffix() string {
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
}
// DBUsersDelete DELETE /api/db/users软删除仅设置 deleted_at用户再次登录会新建账号
func DBUsersDelete(c *gin.Context) {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
db := database.DB()
result := db.Where("id = ?", id).Delete(&model.User{})
if result.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在或已被删除"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户已删除(假删除),该用户再次登录将创建新账号"})
}
// DBUsersReferrals GET /api/db/users/referrals绑定关系详情弹窗收益与「已付费」与小程序口径一致订单+提现表实时计算)
func DBUsersReferrals(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
return
}
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{}
}
refereeIdSet := make(map[string]bool, len(bindings))
for _, b := range bindings {
refereeIdSet[b.RefereeID] = true
}
// 补充:从 orders.referrer_id 找到通过推荐码购买但未在 referral_bindings 中的用户
var orderBuyerIDs []string
db.Model(&model.Order{}).Select("DISTINCT user_id").
Where("referrer_id = ? AND status = ? AND user_id != ''", userId, "paid").
Pluck("user_id", &orderBuyerIDs)
for _, buyerID := range orderBuyerIDs {
if !refereeIdSet[buyerID] {
refereeIdSet[buyerID] = true
}
}
allRefereeIds := make([]string, 0, len(refereeIdSet))
for id := range refereeIdSet {
allRefereeIds = append(allRefereeIds, id)
}
var users []model.User
if len(allRefereeIds) > 0 {
db.Where("id IN ?", allRefereeIds).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
referrals := make([]gin.H, 0, len(bindings)+len(orderBuyerIDs))
bindingRefereeSet := make(map[string]bool, len(bindings))
for _, b := range bindings {
bindingRefereeSet[b.RefereeID] = true
u := userMap[b.RefereeID]
nick := "微信用户"
var avatar *string
var phone *string
hasFullBook := false
if u != nil {
if u.Nickname != nil {
nick = *u.Nickname
}
avatar, phone = u.Avatar, u.Phone
if u.HasFullBook != nil {
hasFullBook = *u.HasFullBook
}
}
status := "active"
if b.Status != nil {
status = *b.Status
}
daysRemaining := 0
if b.ExpiryDate.After(time.Now()) {
daysRemaining = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
}
hasPaid := b.PurchaseCount != nil && *b.PurchaseCount > 0
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook)
avStr := ""
if avatar != nil {
avStr = resolveAvatarURL(*avatar)
}
referrals = append(referrals, gin.H{
"id": b.RefereeID, "nickname": nick, "avatar": avStr, "phone": phone,
"hasFullBook": hasFullBook || status == "converted",
"purchasedSections": getBindingPurchaseCount(b),
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
"status": displayStatus,
})
}
for _, buyerID := range orderBuyerIDs {
if bindingRefereeSet[buyerID] {
continue
}
u := userMap[buyerID]
nick := "微信用户"
avStr := ""
var phone *string
hasFullBook := false
if u != nil {
if u.Nickname != nil {
nick = *u.Nickname
}
if u.Avatar != nil {
avStr = resolveAvatarURL(*u.Avatar)
}
phone = u.Phone
if u.HasFullBook != nil {
hasFullBook = *u.HasFullBook
}
}
referrals = append(referrals, gin.H{
"id": buyerID, "nickname": nick, "avatar": avStr, "phone": phone,
"hasFullBook": hasFullBook, "purchasedSections": 0,
"createdAt": nil, "bindingStatus": "order_only", "daysRemaining": 0, "commission": nil,
"status": "paid",
})
}
// 累计收益、待提现:与小程序 MyEarnings 一致,从订单逐条 computeOrderCommission 求和
var refOrders []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&refOrders)
earningsE := 0.0
for i := range refOrders {
earningsE += computeOrderCommission(db, &refOrders[i], nil)
}
var withdrawnSum struct{ Total float64 }
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status = ?", userId, "success").
Scan(&withdrawnSum)
withdrawnE := withdrawnSum.Total
var pendingWdSum struct{ Total float64 }
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
Scan(&pendingWdSum)
availableE := earningsE - withdrawnE - pendingWdSum.Total
if availableE < 0 {
availableE = 0
}
// 已付费人数:绑定中 purchase_count > 0 的 + 仅订单用户order_only 都已付费)
purchased := 0
for _, b := range bindings {
if b.PurchaseCount != nil && *b.PurchaseCount > 0 {
purchased++
}
}
orderOnlyCount := 0
for _, buyerID := range orderBuyerIDs {
if !bindingRefereeSet[buyerID] {
orderOnlyCount++
purchased++
}
}
totalReferrals := len(bindings) + orderOnlyCount
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),
},
})
}
func getBindingPurchaseCount(b model.ReferralBinding) int {
if b.PurchaseCount == nil {
return 0
}
return *b.PurchaseCount
}
func bindingStatusDisplay(hasPaid bool, hasFullBook bool) string {
if hasFullBook {
return "vip"
}
if hasPaid {
return "paid"
}
return "free"
}
func roundFloat(v float64, prec int) float64 {
ratio := 1.0
for i := 0; i < prec; i++ {
ratio *= 10
}
return float64(int(v*ratio+0.5)) / ratio
}
// DBInit POST /api/db/init
func DBInit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "初始化接口已就绪(表结构由迁移维护)"}})
}
// DBDistribution GET /api/db/distribution支持分页 page、pageSize筛选 status、search
func DBDistribution(c *gin.Context) {
userId := c.Query("userId")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
statusFilter := c.Query("status")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
db := database.DB()
q := db.Model(&model.ReferralBinding{})
if userId != "" {
q = q.Where("referrer_id = ?", userId)
}
if statusFilter != "" && statusFilter != "all" {
q = q.Where("status = ?", statusFilter)
}
var total int64
q.Count(&total)
var bindings []model.ReferralBinding
query := db.Model(&model.ReferralBinding{}).Order("binding_date DESC")
if userId != "" {
query = query.Where("referrer_id = ?", userId)
}
if statusFilter != "" && statusFilter != "all" {
query = query.Where("status = ?", statusFilter)
}
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&bindings).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
return
}
referrerIds := make(map[string]bool)
refereeIds := make(map[string]bool)
for _, b := range bindings {
referrerIds[b.ReferrerID] = true
refereeIds[b.RefereeID] = true
}
allIds := make([]string, 0, len(referrerIds)+len(refereeIds))
for id := range referrerIds {
allIds = append(allIds, id)
}
for id := range refereeIds {
if !referrerIds[id] {
allIds = append(allIds, id)
}
}
var users []model.User
if len(allIds) > 0 {
db.Where("id IN ?", allIds).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
getStr := func(s *string) string {
if s == nil || *s == "" {
return ""
}
return *s
}
out := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
refNick := "微信用户"
var refereePhone, refereeAvatar *string
if u := userMap[b.RefereeID]; u != nil {
if u.Nickname != nil && *u.Nickname != "" {
refNick = *u.Nickname
} else {
refNick = "微信用户"
}
refereePhone = u.Phone
refereeAvatar = u.Avatar
}
var referrerName, referrerAvatar *string
if u := userMap[b.ReferrerID]; u != nil {
referrerName = u.Nickname
referrerAvatar = u.Avatar
}
days := 0
if b.ExpiryDate.After(time.Now()) {
days = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
}
// 佣金展示用累计佣金 total_commission支付回调累加无则用 commission_amount
commissionVal := b.TotalCommission
if commissionVal == nil {
commissionVal = b.CommissionAmount
}
statusVal := ""
if b.Status != nil {
statusVal = *b.Status
}
out = append(out, gin.H{
"id": b.ID, "referrerId": b.ReferrerID, "referrerName": getStr(referrerName), "referrerCode": b.ReferralCode, "referrerAvatar": resolveAvatarURL(getStr(referrerAvatar)),
"refereeId": b.RefereeID, "refereeNickname": refNick, "refereePhone": getStr(refereePhone), "refereeAvatar": resolveAvatarURL(getStr(refereeAvatar)),
"boundAt": b.BindingDate, "expiresAt": b.ExpiryDate, "status": statusVal,
"daysRemaining": days, "commission": commissionVal, "totalCommission": commissionVal, "source": "miniprogram",
})
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
c.JSON(http.StatusOK, gin.H{
"success": true, "bindings": out,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
}
// DBChapters GET/POST /api/db/chapters
func DBChapters(c *gin.Context) {
var list []model.Chapter
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// DBConfigDelete DELETE /api/db/config
func DBConfigDelete(c *gin.Context) {
key := c.Query("key")
if key == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
return
}
if err := database.DB().Where("config_key = ?", key).Delete(&model.SystemConfig{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DBInitGet GET /api/db/init
func DBInitGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}})
}
// DBMigrateGet GET /api/db/migrate
func DBMigrateGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移状态查询(由 Prisma/外部维护)"})
}
// DBMigratePost POST /api/db/migrate
func DBMigratePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移由 Prisma/外部执行"})
}