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 } } // isLikelyWxMiniProgramAppID 判断是否为微信小程序 AppID 常见形态:wx + 16 位十六进制(共 18 字符)。 // 后台「链接标签」若 type=miniprogram 且在此列直接填真实 AppID,C 端会把该值当作 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} 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:miniprogram 类型下发 mpKey=C 端用其匹配 linkedMiniprograms[].key;历史设计为「密钥→appId」,现支持 app_id 列直接填微信 AppID 并由下方 merge 自动补 linkedMiniprograms) var linkTagRows []model.LinkTag _ = db.Order("label ASC").Find(&linkTagRows).Error 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 // 可为「关联表 key」或「直接 wx AppID」;后者由 mergeDirectMiniProgramLinksFromLinkTags 补全 linkedMiniprograms } else { h["appId"] = t.AppID } tags = append(tags, h) } 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 { if err := json.Unmarshal(linkedMpRow.ConfigValue, &linkedList); err != nil { linkedList = nil } } if linkedList == nil { linkedList = []gin.H{} } mergeDirectMiniProgramLinksFromLinkTags(&linkedList, linkTagRows) out["linkedMiniprograms"] = linkedList // 归一化 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" 时仅返回 VIP(hasFullBook) 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(§ionRows) 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_bio;tags 存 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(¤tUser).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/外部执行"}) }